Back to course

Two Offset Conventions

24 minadvancedModule 2: Members and CodeContinues from jvm-code
24 min · Module 2: Members and Code · Algorithms

Why This Matters

This concept is about a trap so specific and so well-hidden that it is worth a whole lesson on its own: the class file format uses two different conventions for what a branch offset means, both of them inside the same attribute, and nothing in the bytes says which is which.

Offsets in instructions are relative to the address of the branch instruction itself. Offsets in the exception table are absolute positions in the code array. Both are two-byte or four-byte unsigned or signed numbers. Both appear in the same Code attribute. A reader that applies the wrong convention does not error — it computes a destination that is a valid offset, just not the intended one.

And the error is not a constant. It is the position of the branch, so every branch in a method is wrong by a different amount. A disassembler built on the wrong convention produces output that looks structurally perfect: real offsets, real instruction boundaries, plausible destinations. It is wrong everywhere and visibly so only if you know where to look.

A Simple Model

Both conventions, stated side by side, because the whole point is that they are easy to confuse:

CONVENTION A -- INSTRUCTION BRANCH OFFSETS the stored value is RELATIVE to the address of the opcode that contains it. destination = address_of_this_instruction + stored_offset applies to: if<cond>, goto, jsr, ifnull, ifnonnull, tableswitch, lookupswitch the offset is SIGNED, two bytes for the conditional forms CONVENTION B -- EXCEPTION TABLE OFFSETS the stored value is an ABSOLUTE index into the code array. destination = stored_offset, counted from byte 0 of code applies to: start_pc, end_pc, handler_pc `to` (end_pc) is EXCLUSIVE: the protected range is [from, to) CONVENTION C -- LineNumberTable AND STACKMapTable also absolute, counted from byte 0 of the code array. three conventions, two of them absolute, and the odd one out is the one that looks like the others.

Convention A is the anomaly and it is worth asking why. The answer is position independence, and it is a good reason. An absolute offset would mean that moving a method's code within the array, or the method within the class, required rewriting every branch. A relative offset survives both, because the branch and its target move together. The same reasoning is why an x86 or ARM jmp is relative and why a PE import thunk is a relative jmp.

But note the cost, because it is not free: the relative offset cannot be read without knowing where the instruction is, which means a reader that scans for opcodes must track a position even for instructions it does not care about, and a disassembler cannot print a target without also printing the instruction's own address. The absolute tables have no such coupling. Convention A buys relocatability with a dependency on position; conventions B and C buy simplicity with a dependency on stability. A format that never moves code could use absolute everywhere, and this one cannot.

The Actual Detail

The switch alignment, which is a trap inside a trap

Before the two conventions can be demonstrated they have to be read past a padding rule, because the two switch opcodes are the only instructions in the format with variable-length operands that are not length-prefixed:

  tableswitch, 48 bytes of code, opcode at offset 1:

    00: 1b                    iload_1
    01: aa                    tableswitch
    02: 00 00                 2 bytes of PADDING
    04: 00 00 00 2d           default   = 45
    08: 00 00 00 00           low       = 0
    0c: 00 00 00 04           high      = 4
    10: 00 00 00 23           target[0] = 35   <- RELATIVE, not absolute
    14: 00 00 00 25           target[1] = 37
    18: 00 00 00 27           target[2] = 39
    1c: 00 00 00 29           target[3] = 41
    20: 00 00 00 2b           target[4] = 43
    24: 04 ac                 iconst_1 ; ireturn
    ...

The padding exists to 4-byte-align the operands, and the alignment is measured from the start of the code array, not from the instruction. The opcode is at offset 1, the operands would start at 2, and the next 4-byte boundary is 4 — so exactly two bytes of padding. Every one of those numbers is checkable against the file, and the arithmetic above is what this course's decoder reads.

Now apply convention A. The stored targets are 35, 37, 39, 41, 43 and the default is 45. The instruction is at offset 1, so the real destinations are 36, 38, 40, 42, 44 and 46. And here is the confirmation, from javap -c on the same file:

  $ javap -c Shapes.class
             1: tableswitch      // 0 to 4
                       0: 36
                       1: 38
                       2: 40
                       3: 42
                       4: 44
                 default: 46
                 -- end of switch
            36: iconst_1
            38: iconst_2
            40: iconst_3
            42: iconst_4
            44: iconst_5
            46: iconst_0

Six numbers, and every one is the stored value plus one. The stored values are not destinations; they are displacements, and the only thing that turns them into addresses is knowing where the instruction was.

The lookupswitch in the same class behaves identically, with a different layout — a match/target pair list instead of a dense range, and a count rather than a high value:

    00: 1b                 iload_1
    01: ab                 lookupswitch
    02: 00 00              2 bytes of padding, same rule
    04: 00 00 00 2c        default  = 44
    08: 00 00 00 03        npairs   = 3
    0c: 00 00 00 01 00 00 00 23    match 1     -> 35  -> real 36
    14: 00 00 00 64 00 00 00 26    match 100   -> 38  -> real 39
    1c: 00 00 27 10 00 00 00 29    match 10000 -> 41  -> real 42

Now the exception table, in the same method

Here is the contrast, and it needs no arithmetic at all — the numbers javap prints are the numbers in the file:

  $ javap -c Shapes.class        (trycatch, same class)
         Exception table:
            from    to  target type
                0     5    10   Class java/lang/ArithmeticException
                0     5    18   Class java/lang/RuntimeException
                0     5    27   any
               10    13    27   any
               18    22    27   any
               27    29    27   any

Offset 10 is where the ArithmeticException handler begins, and the file says 10. No adjustment, and that is the point. The same two-byte field, in the same attribute, twenty bytes further on, means something different. A reader that applied convention A here would add each handler's own position — and handler 1's position is 10, so it would report 20 and be wrong by ten, while handler 3 at position 27 would be reported as 54 and be wrong by twenty-seven. Three handlers, three different errors, none of them the same as the other.

Two further details in that table, both easy to get wrong. to is exclusive: the first row protects [0, 5), which is bipush 100; iload_1; idiv; istore_2 — the division and its result, and not the ifle that follows. And rows are tried in order, so the three rows covering [0, 5) form a chain: arithmetic first, then runtime, then any. That is the compiled form of a catch list followed by a finally, and the any rows at offsets 10, 18 and 27 are the finally block being entered from each exit path.

And the reason the two conventions differ is worth stating, because it explains which one you will meet where. A branch offset is relative because the branch and its target must be able to move together — the code array can be relocated, and a relative offset survives. An exception table offset is absolute because the exception table is not code: nothing relocates it, it is a table that happens to live inside the Code attribute, and making it relative would buy nothing while making every handler lookup an addition. The tables are index-like and the branches are code-like, and each gets the convention its own kind of thing deserves. The trap exists only because the format put both inside one attribute — a different design would have put the exception table in its own top-level block, where its offsets would be obviously a different kind of number, as a COFF relocation table and a DWARF line table both do.

The wide prefix

One more instruction-encoding detail, and the reason it exists is arithmetic rather than design. A local variable index is one byte, which caps it at 255 — and 256 does not fit. So there is a prefix opcode that widens the index that follows:

    c4 36 01 00          wide istore 256      4 bytes
    36 01                 istore 1              2 bytes
    36 ff                 istore 255            2 bytes, the maximum
                              without the prefix

  0xc4 widens exactly ten opcodes: iload, lload, fload, dload,
  aload, istore, lstore, fstore, dstore, astore -- plus ret and
  iinc. Nothing else may carry it.

  verified on a generated class with 300 live locals:
    max_locals = 303
    96 real wide prefixes
    the first is c4 36 01 00 = wide istore 256, at code offset 1139
    the preceding instruction is an unprefixed istore 255

The prefix appears at exactly 256 and not before, because that is the first index a single byte cannot hold. This is the same class of decision as the ELF header's choice of field widths, and the same trap it creates: a reader that assumes a single instruction width will desynchronise at exactly one point, and the bytes there are a plausible opcode followed by a plausible index. The mitigation is the same too — decode the instruction stream and check the position against code_length, which is the assertion from the previous concept, because here it is the only thing that will notice.

A Real Example

One number that confirms both conventions at once, which is the most efficient verification this course has found. The StackMapTable of the tableswitch method is eight bytes:

  00 06 24 01 01 01 01 01
      |  |  +-- five more frames, each one byte, frame_type 0x01
      |  +----- frame_type 0x24 = 36, a same_frame at offset 36
      +-------- 6 frames

  a StackMapTable frame's offset is ABSOLUTE (convention C), and
  frame N's offset is:

      first frame:  offset = frame_type
      later frames:  offset = previous_offset + frame_type + 1

  which gives:
      frame 0:  36
      frame 1:  36 + 1 + 1 = 38
      frame 2:  38 + 1 + 1 = 40
      frame 3:  42
      frame 4:  44
      frame 5:  46

So the six frames are at offsets 36, 38, 40, 42, 44, 46. And the switch's six destinations, after adding the opcode's address of 1, are 36, 38, 40, 42, 44, 46.

The same six numbers, from two different mechanisms. One is a table of absolute frame positions, read directly. The other is a list of relative displacements that only become positions once the instruction's own address is added. They agree to the byte, and that agreement is the proof: a relative branch that was misread as absolute would put the frames at 35, 37, 39, 41, 43, 45, which are all odd numbers pointing into the middle of instructions, and the frames would not line up with anything.

That is what a cross-check like this is for. Neither number is self-evidently right on its own — one is a table of integers and the other is a table of displacements, and a reader that has the arithmetic subtly wrong will produce a self-consistent wrong answer in both. Two independent derivations landing on the same six values is the only evidence that both are right, and it is much stronger than either table looking plausible.

What makes this particular cross-check available is a coincidence of the sample: the tableswitch method's only branches are its six destinations, so every frame has a branch and every branch has a frame. When you design a sample, designing it so two independent structures must agree is worth more than adding a tenth feature — and it is why the next concept can be written at all, since the frame offsets are the subject.

Try It Yourself

$ javap -c -p out/Shapes.class
$ python3 courses/jvm/assets/samples/class_decode.py out/Shapes.class

Check Your Understanding

Without looking back: in a 48-byte method, a tableswitch's opcode is at offset 1 and its five stored targets are 35, 37, 39, 41, 43 with a stored default of 45. What are the real destinations, how many bytes of padding precede the operands, and what would a reader report if it treated the stored values as absolute?

Apply It

You are writing a bytecode-to-source mapper. It reports correct line numbers for most of a large codebase. For methods containing a tableswitch or a try/catch, it reports line numbers that are off, and the offsets it prints are always smaller than the ones javap prints. Everything else about those methods is correct: the instruction stream decodes cleanly, the method boundaries are right, the line table is read correctly. The error is always negative. What is the defect, what does the consistent sign of the error tell you, and what is the general habit for handling a field whose meaning depends on where it appears?

The general habit: an offset's meaning is a property of the table it came from, not of the field. Keep stored values stored, and resolve to addresses in one place — then the convention becomes a parameter you can pass rather than a habit you can forget.

Connect

Relative branch offsets are not a JVM peculiarity but the universal choice for anything that relocates, and the collection is full of the comparison. An ELF R_X86_64_PC32 relocation is the same idea at link time: the addend is a displacement, not an address, precisely so the linker can move code. A PE import thunk is a five-byte jmp through a relative pointer for the same reason. A COFF IMAGE_REL_I386_REL32 again. Every format that patches addresses into code uses a displacement rather than an absolute value, and every one of them is choosing relocatability over simplicity in exactly the same trade.

What the JVM adds is the table convention alongside the code one, and that is rarer. An ELF relocation's offset field is absolute — it says where in the section to patch — and so is a DWARF line-table entry's address, and so is a PE base relocation's target rva. So the JVM is not unusual in having two conventions; what is unusual is putting both inside one attribute, where nothing marks the difference. Every other format keeps them in separate structures — code in a section, tables in a section — so a reader knows which convention applies from where it is reading.

The switch padding is the same alignment discipline as WebAssembly's block-type lookahead in one sense and the exact opposite in another. Both are variable-width operands that have to be located before they can be read. WebAssembly's problem is a one-byte peek, which is cheap; the JVM's is a 4-byte alignment rule, which is why every disassembler for the JVM has had this bug at least once. Compare a PE section's alignment, where the same arithmetic appears at file-layout scale — the rule that a loader rounds an offset up to a power of two is the rule the switch is applying to its own operands.

The wide prefix connects to something this course has already established twice. It is the same shape as the ELF header choosing a 2-byte e_machine and a 4-byte e_entry to match the ranges each field needs, and the same shape as a format running out of address bits and growing a second header. The prefix exists because the format chose one byte for compactness and then had a value that did not fit, and the fix was to add an escape rather than widen the field — paying a byte on the rare instruction rather than a byte on every one. That is the correct trade and it is the same reasoning behind a variable-width integer in a different format.

Next: the verifier's data — where the six frame offsets computed above turn out to be the subject, and where a type checker writes its conclusions into the file the verifier is about to check.