Let's give meaning to it all.
If you are unfamiliar with Natural Deduction and Gentzen-style proofs, please refer to the Wikipedia page on the topic. The short story is that we refer to logical rules like this:
Which means that implies .
If we want multiple ways of reaching the conclusion, we can make more rules. For example, conjunction only requires one rule, both and have to be true, but the disjunction has two rules: either has to be true or has to be true.
In natural deduction, we build up syntactic objects which represent the truth of some event. We call them (judgements). Many times you will see them written like , which is read: the context of $ implies $ is true.
Today we are going to talk about semantics. Program semantics involves assigning meaning to programs. When we can talk about what a piece of syntax means, it is easier to explain what a program does.
We are going to discuss some different approaches to writing down the semantics of a program. They all essentially turn a program's syntax into mathematical logic.
One of the first approaches invented to assign meaning to programs was Axiomatic Semantics. Here, the meaning of the program is described by assigning preconditions and postconditions to all statements in a program.

The flowchart from the original paper on semantics Assigning Meaning to Programs
by Robert W. Floyd. The program computes the sum of an array.
We can describe the semantics of a program by denoting three parts, the precondition , the program , and the postcondition , this is also called Hoare triplets.
A Hoare triplet means that if the world satisfies before executing , then the world will satisfy after. We can compose the proofs of correctness of program parts into a proof of total correctness. Assume a program , where is executed after . Then we can describe the correctness of , like so:
If you are unfamiliar with the syntax above, it is natural deduction; see the section on natural deduction. Given , , and the implication that the postcondition of $ implies the precondition of are true, we can also prove that .
This approach is effective at describing the meaning of specific programs. This approach excels at program verification but is not typically used for program analysis in general.
Another approach to defining program semantics is Denotational Semantics. Denotational semantics maps the semantics of a program to something well-understood. This can be another programming language or mathematics.
Consider a very simple expressional language called , which has addition, variables and natural numbers :
If you want to give meaning to the program x + 5, we can define a map from expressions to a function from a store to integers:
Essentially, gives meaning to numbers, variables, and addition. Numbers should be read as natural numbers. Variables correspond to looking up a value in a store. The semantic + is equivalent to the mathematical operator .
Now we can see that x + 5 can be calculated in the store , using normal math:
If you think this looks exactly like functional programming, you would be right. It is also particularly well-suited for describing Expression-oriented programming languages.
Next, we cover Operational Semantics, the semantic style we will focus on this semester. Operational semantics describes the semantics of a program as changes to a state. This makes it ideal for describing imperative languages like JVM bytecode. Furthermore, the Structural Operational Semantics is defined as you would write an interpreter, which is handy because you are going to write one.
The Structural Operational Semantics or Small Step Semantics is written as a judgment of the type , indicating that given the environment , the program state transitions to .
We call this approach small-step semantics because we only execute a single operation at a time.
The Natural Operational Semantics or Big Step Semantics describes running the program until it halts. where is the final value of the program. Big step semantics often appears simpler than small step semantics because it ignores the intermediate execution order.
Consider the application rule of lambda calculus (see here and Lambda calculus - Wikipedia).
Here contains a mapping from variables to values, which we can update using the operation.
Essentially, this rule states that function application computes a in if computes a closure , $ computes a value , and computes in the environment where $ is set to . Compare this with the single-step semantics. We start out simple, compute on the left side until no more progress can be made:
Because we can no longer do the full computation in one go, we have to carry around the partial state of the experession. To this end we have to have to redefine what a expression is.
Now we say that if we have evaluated left hand side until a value has been reached we calculate on the right side.
Finally, we include the mapping in the left hand side.
Here we have three rules instead of one. First, in , we step the function , then in we step the argument , and finally we insert the value into the closure . Notice that we also have to keep track of the variables captured by the closure using the notation. We also need to add a closure evaluation rule:
Big Step semantics have the benefit of being easier to read; however, they have disadvantages, namely: we cannot reason about programs that run forever, and we cannot turn big step semantics into a working implementation. In contrast, small step semantics are easy to convert into an interpreter, and we can always recover the big step semantics from operational semantics by simply applying the single step semantics until the program terminates with a value:
With our newfound definition of single-step semantics, we can define the meaning of a program as a Transition System:
A Transition system is a triplet where is the set of program states, is the transition relation (defined by the single step semantics) and are possible initial states.
A is the possible infinite sequence of states and operations of the program.
The meaning of a program is now the set of traces that it exhibit:
This is also called the Maximal Trace Semantics. We can now define properties, like does a program halt, using relatively well defined math:
But more about this next time.
Before we can start assigning meaning to the JVM bytecode, we need to understand what JVM bytecode is. There are many different instruction architectures, such as X86, ARM, and RISC-V. If you want to support them all, you have to produce machine code for each. Java, however, wanted to provide a write once, run everywhere
solution, so they needed a virtual machine. A virtual machine is a piece of code written for each platform that all run the same virtual machine code. Actually, JVM stands for Java Virtual Machine.
When Java is compiled it gets compiled into a format known as JVM bytecode and placed in files with the .class extension. These files are called class files.
In the jpamb folder you should be able to run the following command to see the content of the target/classes/jpamb/cases/Simple.class file.
$ javap -cp target/classes -c jpamb.cases.SimpleEach classfile contains the class name, its fields, and its methods. Each method contains a list of executable instructions. JVM bytecode is designed to be easy to interpret, which can make it hard to read. In this course, we will use an intermediate language that reduces the two hundred or so instructions to 39 and performs several useful optimizations.
You should be able to find decompiled versions of each class file in the target/decompiled/ folder, in an easy to read JSON format (see codec here).
Examine the decompiled cases, starting with Simple.json. Locate the assertFalse method listed in the methods. Find the code.bytecode section, which should look like:
jq '.methods[] | select(.name=="assertFalse") | .code.bytecode' \
target/decompiled/jpamb/cases/Simple.json[
{ "opr": "get", "field": { "class": "jpamb/cases/Simple", "name": "$assertionsDisabled", "type": "boolean" }, "static": true },
{ "opr": "ifz", "condition": "ne", "target": 6 },
{ "opr": "new", "class": "java/lang/AssertionError" },
{ "opr": "dup", "words": 1 },
{ "opr": "invoke", "access": "special", "method": { "args": [], "is_interface": false, "name": "<init>", "ref": { "kind": "class", "name": "java/lang/AssertionError" }, "returns": null }, },
{ "opr": "throw" },
{ "opr": "return", "type": null }
]Especially, notice the opr, it explains what operations to run:
| in# | opr | stack | description |
|---|---|---|---|
00 | get | [] | Get the assertionsDisabled boolean and put it on the stack |
01 | ifz | [bool] | if it is not equal to zero (false) jump to the 6th instruction (i.e. return) |
02 | new | [] | otherwise create a new AssertionError object. |
03 | dup | [ref] | dublicate the reference |
04 | invoke | [ref, ref] | call the init method on the AssertionErrror (consuming the top ref erence). |
05 | throw | [ref] | throw the assertion error. |
06 | return | [] | otherwise return. |
Finally, have we built Python classes for all the instructions used in the benchmark suite? You can see them here. To parse each bytecode, you can either use the jpamb.jvm.Opcode.from_json function for each instruction or use the jpamb.Suite.method_opcodes(methodid, eff=eff) to get the opcodes from an absolute method ID.
Try to run the inspect command:
$ uv run jpamb inspect "jpamb.cases.Simple.assertBoolean:(Z)V"You can also try the other formats --format=repr (shows the python code), --format=real (shows the JVM memonic), and --format=json (shows the decompiled json).
Now you should be able to build a syntactic analysis over the bytecode:
Start by taking a look at the solutions/syntactic/src/syntactic_bytecode.py.
Then try to expand it to be more precise.
In this section, we introduce the semantics of a limited JVM. As it is incomplete, you will need to complete it yourself.
The goal of this section is to define the small step semantics of the JVM, and encode it in both math and Python. You can follow along with code in the dynamic Solution and the defintion of the state in jvm.state.
We want to define judgments of , , or , where is the bytecode and is the state. In Python, we want to define a function step, which, given a state , computes either a new state or a done
string.
def step(bc: Bytecode, s: State) -> State | str:
...As with all single-step semantic rules, the JVM runs in a context. This context is the bytecode . A simple operation looks up the bytecode instruction at . is the program counter, i.e., the name of the method and the offset in that method.
We use the following shorthands:
To do this in Python, you can use PC class from jvm.state
class PC:
method: jvm.AbsMethodID
offset: int
...We can change offset using the following command:
>>> pc = PC(method, offset)
>>> pc += n # add n to the offset
>>> pc %= n # replace the offest with nTo access the opcode, use the program counter as the index to the bytecode. Before that, we must access the bytecode using specific procedures.
>>> suite, eff = jpamb.setup()
>>> bc = jpamb.Bytecode(suite, eff, {})
>>> bc[pc]The JVM is a stack-based virtual machine; this means that instead of using named variables (registers) to store intermediate values, it uses an operator stack. Some values are stored in local method storage called locals, which can be accessed using indices. Finally, the machine can also store information in global memory, referred to as the heap.
The values in (our interpretation of) the JVM are dynamically typed; this means that every value carries information about its type. There are two kinds of values, stack values and heap values .
The stack contains signed 32-bit integers, 32-bit floating-point values (IEEE 754 Standard (JLS ยง1.7)), and 32-bit references to the heap. The heap can contain values from the stack, 8-bit bytes, 16-bit unsigned chars, signed 16-bit shorts, arrays (consisting of a type and a list of values), and objects (comprising a name and a mapping of field names to heap values).
In this course, we won't cover long and double to avoid unnecessary complexity. Furthermore, we'll try to avoid inner classes and bootstrap methods.
Implementations of the types are stored in the jvm.type.
The stack is a list of values: . denotes the empty stack, and we add and remove elements from the end of the stack. A stack with the integers 1, 2, and 3 looks like this: . A simple implementation of this in Python is shown below:
class OperandStack:
operands: deque[StackValue]The JVM saves local variables of type to a local array . This array contains the method inputs and any data stored on the stack instead of the heap. The local array is indexed .
class Locals:
locals: list[StackValue | None]In its simplest form, the JVM state is a triplet called a frame, where represents the local variables, is the operand stack, and is the program counter.
In Python, we would write:
class Frame:
locals: Locals
stack: OperandStack
pc: PCMost of our operations only operate on the frame, so we can already define our first simple SOS judgment like this:
Anticipating the definition of State in the next section, we define the stepping function, like so:
def step(bc: jpamb.Bytecode, state: State) -> tuple[PC, State | str]:
frame = state.frames.peek()
opr = bc[frame.pc]
pc, output = frame.pc, state # Default to outputting the state
print(f"Stepping {pc}:\n > {opr}", file=sys.stderr)
match opr:
# The opcodes to handle ...
return pc, outputWe are now ready for some examples. First we have the push operation :
Which we can encode in Python like this:
case jvm.Push(type=t, value=v):
if t is not jvm.Int():
raise NotImplementedError(f"Don't know how to handle {t}")
frame.stack.push(StackInt(v))
frame.pc += 1We can also load values from the locals using the operation:
Which we can encode in Python like this:
case jvm.Load(type=jvm.Int(), index=n):
v = frame.locals[n]
frame.stack.push(v)
frame.pc += 1In the JVM methods are capable of calling other methods. To support this we need a stack of frames, or a call stack :
And now since we have multiple frames, we also need a way for the frames to share data. We call that the heap . And it is just mapping from memory locations to .
The state is now just a tuple of the heap and the call stack: . Which we can represent in Python like this:
class State:
heap: Heap
frames: CallStackSo now we have reached the correct definition of the stepping function, over the State.
We can use the operations we defined over frames, by lifting them into the state, by doing the frame operation on the top frame:
Our simplification of the JVM terminates when we exit from the last method, or we encounter an error. We do that with either an or an .
In Python, we can represent terminating with no error by simply returning "ok" if the stack is empty:
case jvm.Return(type=jvm.Int()):
v1 = frame.stack.pop()
state.frames.pop()
if state.frames:
frame = state.frames.peek()
frame.stack.push(v1)
frame.pc += 1
else:
output = "ok"One example of throwing an error is the divide operation:
Which can be translated into a single case match in Python. In the case we terminate with an error, we just return the corresponding string:
case jvm.Binary(type=jvm.Int(), operant=op):
v2, v1 = frame.stack.pop(), frame.stack.pop()
assert isinstance(v1, jvmc.StackInt), f"expected int, but got {v1}"
assert isinstance(v2, jvmc.StackInt), f"expected int, but got {v2}"
value: int | str = binary(op, v1.value, v2.value)
if isinstance(value, str):
output = value
else:
frame.stack.push(jvmc.StackInt(value))
frame.pc += 1def binary(op, v1: int, v2: int) -> int | str:
match op:
case jvm.BinaryOpr.Div:
try:
return v1 // v2
except ZeroDivisionError:
return "divide by zero"
case a:
raise NotImplementedError(f"Unhandled binary {op!r}")So this is a very limited definition of semantics, the JVM; we have not covered threads or exceptions, and we probably won't in this course. But even with these restrictions, there are still many undefined rules left. It is your job to implement the rest of the instructions and build a working interpreter. To get started on this, we recommend you start small.
dup as single step semanticsWrite dup as single step semantic, given the definitions above. You can use the following resources:
jvm2json/CODEC.txt at kalhauge/jvm2json: the decompiled codec (search for <ByteCodeInst>).
Chapter 4. The class File Format: the class file format. And, finally
Chapter 6. The Java Virtual Machine Instruction Set: the official specification of each instruction.
dup to the case statementEncode the dup instruction above in the solutions/dynamic/src/dynamic.py file.
Finally, you should write an interpreter, which can interpret all the cases.
Write an interpreter for the JVM, that given a method id, an input, and a maximal number of iterations, prints the steps to get to a final state.
The defintion of the task can be found here. Make sure JPAMB is up-to-date and that you have added dynamic to the environment: (current is 0.6.0)
$ uv pip list
Package Version Editable project location
---------------- ------- -----------------------------------------------------
dynamic 0.1.0 .../jpamb/solutions/dynamic
jpamb 0.6.0 .../jpamb
...To get started, try running the interpreter on the simple cases, and in step-wise fasion
$ jpamb -vv interpret --filter Simple --step-wise dynamic-interpreterYou should expect to see an output like this:
$ jpamb -vv interpret --filter Simple --step-wise dynamic-interpreter
โ Reading cases from /home/chrg/Projects/Courses/jpamb/target/stats/cases.txt
โ Reading cases from /home/chrg/Projects/Courses/jpamb/target/stats/cases.txt
โ Getting info about interpreter
โ SUCCESS Ran dynamic-interpreter info
โ Getting info about interpreter
โ Trying to read state from cache
โ Trying to read state from cache
โ Experiment 1/28 jpamb.cases.Simple.assertBoolean:(false) -> assertion error
โโ Run experiment dynamic-interpreter 'jpamb.cases.Simple.assertBoolean:(Z)V' '(false)' 100
โโ Run experiment dynamic-interpreter 'jpamb.cases.Simple.assertBoolean:(Z)V' '(false)' 100
โ WARNING Ran dynamic-interpreter 'jpamb.cases.Simple.assertBoolean:(Z)V' '(false)' 100, and got error:
โ (state
โ :heap ()
โ :frames (
โ :00 (frame
โ :locals (
โ :00 (int 0)
โ )
โ :stack ()
โ :pc "jpamb.cases.Simple.assertBoolean:(Z)V:0"
โ )
โ )
โ )
โ Stepping jpamb.cases.Simple.assertBoolean:(Z)V:0:
โ > get static jpamb.cases.Simple.$assertionsDisabled:Z
โ (state
โ :heap ()
โ :frames (
โ :00 (frame
โ :locals (
โ :00 (int 0)
โ )
โ :stack (
โ :00 (int 0)
โ )
โ :pc "jpamb.cases.Simple.assertBoolean:(Z)V:1"
โ )
โ )
โ )
โ Stepping jpamb.cases.Simple.assertBoolean:(Z)V:1:
โ > ifz ne 8
โ Traceback (most recent call last):
โ File "/home/chrg/Projects/Courses/jpamb/.jpamb-eval/bin/dynamic-interpreter", line 10, in <module>
โ sys.exit(interpret())
โ ~~~~~~~~~^^
โ File "/home/chrg/Projects/Courses/jpamb/solutions/dynamic/src/dynamic.py", line 136, in interpret
โ pc, state = step(bc, state)
โ ~~~~^^^^^^^^^^^
โ File "/home/chrg/Projects/Courses/jpamb/solutions/dynamic/src/dynamic.py", line 80, in step
โ raise NotImplementedError(a.help())
โ NotImplementedError: It seems Ifz(offset=3, condition=<CmpOpr.Ne: 1>, target=8) is not implemented! Instructions can be found at: https://docs.oracle.com/javase/specs/jvms/se23/html/jvms-6.html#jvms-6.5.if_cond
โ WARNING Invalid output: No reponse created
โ Experiment 1/28 jpamb.cases.Simple.assertBoolean:(false) -> assertion error
ERROR Stopping earlyNow it states that the Ifz case has not been implement, go ahead and do that. In the large match statement, add the following code:
case jvm.Ifz(condition=op, target=target):
value = frame.stack.pop()
assert isinstance(value, jvmc.StackInt), f"expected int, but got {value}"
if compare(op, value.value, 0):
frame.pc %= target
else:
frame.pc += 1Here are some advice getting started:
Start small, one method at a time. You don't have to cover the entire language.
It's okay to hack some things, like getting
the $assertionsDisabled static field. You can assume that is always be false.
Print out the state to stderr at every step, this will help you debug (already done in the example)
Use the --step-wise flag to run the last failed case next time.
Until next time, write the best analysis you can and upload the results to Autolab, and ponder the following:
What is the semantics of a program?
Name some ways can you describe the semantics of a program?
What does it mean to interpret a piece of code?
Floyd, Robert W . (1993). Assigning Meanings to Programs.
doi:10.1007/978-94-011-1793-7_4 link
Nielson, Hanne Riis; Nielson, Flemming (2007). Semantics with Applications.
Plotkin, Gordon D (2004). The origins of structural operational semantics.
doi:10.1016/j.jlap.2004.03.009 link