Back to course

Fields, Methods and Descriptors

22 minintermediateModule 2: Members and CodeContinues from jvm-strings
22 min · Module 2: Members and Code · Foundations

Why This Matters

Everything after the constant pool describes what a class has and what it can do. Both are the same shape — a flag word, a name index, a descriptor index, and a list of attributes — and the descriptor is where the interesting grammar lives.

A descriptor is how the class file spells a type. Not a name, not a reference to the constant pool's idea of a class, but a complete, self-contained type written out in characters. The whole JVM type system fits in about a dozen symbols, and reading them is a skill in its own right — there is nothing else in the format where so much meaning is packed into so few bytes.

It is also where the format stops being a name table and starts describing behaviour, because a method's descriptor says what goes in and what comes out. That is enough for the verifier to type-check a call, to decide whether an override is legal, and to reject a class that claims to implement an interface it does not. All of that from one line of printable ASCII.

A Simple Model

The complete type grammar, as verified against three independent readers on a class declaring one field of every kind:

  PRIMITIVES          one character each

    Z   boolean          B   byte             C   char
    S   short            I   int              J   long
    F   float            D   double
                        V   void  -- ONLY as a method's return

  REFERENCES          L, an internal name, and a semicolon

    Ljava/lang/String;            a class or interface
    LDesc$Nested;                 a nested class, $ separating the parts

  ARRAYS              one [ per dimension, then the element type

    [I                  int[]
    [[I                 int[][]
    [Ljava/lang/String;    String[]
    [[D                 double[][]

  METHODS             an open paren, the parameters, a close paren, the return

    ()V                no parameters, returns void
    (I)Z               one int, returns boolean
    (IJLjava/lang/String;[ILjava/lang/Object;)V
                        int, long, String, int[], Object; returns void

Five things in that grammar are worth stating outright, because each one is a decision rather than a notation.

The Actual Detail

What a class with one field of every type and a handful of methods actually stores. The descriptor column is the entire per-member cost after the name, and it is worth seeing how small it is:

  field     descriptor
  z         Z
  b         B
  c         C
  s         S
  i         I
  j         J
  f         F
  d         D
  ref       Ljava/lang/String;
  arr       [I
  jag       [[I
  sarr      [Ljava/lang/String;

  method     descriptor
  m          ()V
  ret        ()I
  args       (IJLjava/lang/String;[ILjava/lang/Object;)V
  manyRet    ()J
  usesNested (LDesc$Nested;)LDesc;

All of those strings came out of the file and all three readers agree on every one of them, name by name and descriptor by descriptor. The longest is 41 characters for five parameters, and the shortest is 3. A field of type boolean costs one byte of type information. There is no per-type overhead anywhere.

Three access-flag tables, not one

The access_flags field is a u2 in all three places it appears — on the class, on each field, on each method — and the three tables are different. That is not a design convenience; it is a genuine trap, and this course's own decoder shipped with a bug in exactly this area.

  CLASS                         bit 0x0001 ACC_PUBLIC
                                  bit 0x0010 ACC_FINAL
                                  bit 0x0020 ACC_SUPER
                                  bit 0x0200 ACC_INTERFACE
                                  bit 0x0400 ACC_ABSTRACT
                                  bit 0x1000 ACC_SYNTHETIC
                                  bit 0x2000 ACC_ANNOTATION
                                  bit 0x4000 ACC_ENUM
                                  bit 0x8000 ACC_MODULE

  FIELD                         bit 0x0001 ACC_PUBLIC
                                  bit 0x0002 ACC_PRIVATE
                                  bit 0x0004 ACC_PROTECTED
                                  bit 0x0008 ACC_STATIC
                                  bit 0x0010 ACC_FINAL
                                  bit 0x0040 ACC_VOLATILE
                                  bit 0x0080 ACC_TRANSIENT
                                  bit 0x1000 ACC_SYNTHETIC
                                  bit 0x4000 ACC_ENUM

  METHOD                        bit 0x0001 ACC_PUBLIC
                                  bit 0x0002 ACC_PRIVATE
                                  bit 0x0004 ACC_PROTECTED
                                  bit 0x0008 ACC_STATIC
                                  bit 0x0010 ACC_FINAL
                                  bit 0x0020 ACC_SYNCHRONIZED
                                  bit 0x0040 ACC_BRIDGE
                                  bit 0x0080 ACC_VARARGS
                                  bit 0x0100 ACC_NATIVE
                                  bit 0x0400 ACC_ABSTRACT
                                  bit 0x0800 ACC_STRICT
                                  bit 0x1000 ACC_SYNTHETIC

Look at what overlaps and what does not. 0x0010 is ACC_FINAL everywhere — consistent. 0x1000 is ACC_SYNTHETIC everywhere. But 0x0008 is ACC_STATIC on a field and a method and means nothing at all on a class, while 0x0002 is ACC_PRIVATE on a field and a method and is not defined for a class. A reader that uses one table for all three will report names that are not wrong so much as meaningless, and the error is invisible because the bits it misinterprets are usually clear.

A real one, from this course's own decoder. The flag tables shipped here had 0x0009 listed as a single entry called ACC_FINAL. That was wrong twice over: 0x0009 is not a bit at all but the combinationACC_PUBLIC | ACC_STATIC, and ACC_FINAL is 0x0010. Every public static method was therefore reported as ACC_PUBLIC ACC_FINAL — two wrong names and one missing. It was caught by running the decoder and javap side by side on the same file and comparing, which is the only kind of check that works: all three readers could have shared the mistake, because a table copied from a wrong source is wrong identically everywhere. The general rule is that a table of names is the one part of a decoder that cannot be validated by agreement, and has to be checked against something outside the set of readers.

ConstantValue: a field's initial value, stored in the pool

One attribute deserves separate mention because it is the only place the file stores a value rather than code. A static final field of a primitive or String type gets a ConstantValue attribute whose entire body is a two-byte pool index:

  ConstantValue:
    u2 constantvalue_index      a pool index, and that is the whole attribute

  and the pool entry it points at is the value itself:
    #20 = Integer            2135567385
    #23 = Long               81985529216486895l
    #27 = Float              3.5f
    #30 = Double             -2.718281828459045d
    #34 = String             #35
    #35 = Utf8               hello

No bytes of the value appear in the attribute at all — it is a pointer into the pool like everything else. And note what that buys: the value is shared with every other occurrence of it in the file. Two classes with the same constant do not each store it; they each store an index, and the compiler emits one pool entry. That is the constant pool doing its job one more time, and it is the reason the format needs no separate data section.

The restriction to primitives and String is a language rule rather than a format one, and it is worth knowing why: a compile-time constant must have a value the compiler can write down and the verifier can check. An arbitrary object does not have one, which is why static final List<String> EMPTY = ... is legal Java and gets a <clinit> method that builds it at class-initialisation time instead.

A Real Example

The other two-slot rule. The constant pool gives Long and Double two indices each; the local variable frame does the same thing to the same two types, and for a related reason. Here is every method of a ten-method class, with the slot arithmetic checked against the file:

  method  descriptor      this  slots   predicted  max_locals
  a       ()V              yes      0          1           1
  b       (I)V             yes      1          2           2
  c       (II)V            yes      2          3           3
  d       (J)V             yes      2          3           3   <-- long is TWO
  e       (JJ)V            yes      4          5           5
  f       (D)V             yes      2          3           3   <-- double is TWO
  g       (IJD)V           yes      5          6           6
  s       (IJD)V           no       5          5           5   <-- static: no this
  h       (Ljava/lang/String;[ILjava/lang/Object;)V
                          yes      3          4           4   <-- arrays are ONE

Ten methods, ten predictions, ten matches. Three rules produce all of it:

max_locals = (1 if the method is NOT static, else 0) + sum over each declared parameter of: 2 if the type is long or double 1 otherwise, INCLUDING every array and every reference so: d(J)V 1 + 2 = 3 g(IJD)V 1 + 1 + 2 + 2 = 6 s(IJD)V 0 + 1 + 2 + 2 = 5 (static) h(String[], ...) 1 + 1 + 1 + 1 = 4 (three references)

Why 64-bit types take two slots is a register-allocation decision, and it explains the descriptor rule too. The JVM's calling convention passes 32-bit values in one slot and 64-bit values in two, so that a long and two ints both occupy eight bytes and a max_locals can be reasoned about as a frame size. J and D are the only two descriptor characters with this property, and a parser needs to know that before it can walk a parameter list — because (IJ) has three slots, not two, and a tool that assumes one-slot-per-parameter computes a frame size a quarter too small.

And the array case is the one that catches people, including a verification script written for this course: [I is one slot, not two. The [ is part of the type, not a separate thing, and an array of any dimension is a single reference. Getting it wrong inflates the count and makes max_locals look too large, which is a confusing symptom because the real rule is right and only the arithmetic is wrong.

And note what max_locals is not. It is not the number of local variables, and it is not the number the method might use. It is the number of slots the frame needs for the parameters and this, and it is set from the signature before the body is looked at. A method that declares a hundred locals and uses none of them still has max_locals equal to its parameter count plus one. The body's own locals live above that mark and are the verifier's business, not the signature's — which is why adding an unused local to a method does not change the descriptor, and why a tool that rebuilds a frame from the descriptor alone gets the right answer for the parameters and no information about the rest.

One more consequence worth flagging for the next concept, because it is where a reader can lose its place: descriptor parsing is recursive and self-delimiting, and the only field that is not one byte per character is the name inside an L...;.LDesc$Nested; is a reference whose name is Desc$Nested, terminated by the first ; — and the name may contain almost anything except that one character. So a descriptor parser cannot be a fixed-length reader; it must scan to the terminator, which is the same walk-to-a-terminator discipline the WebAssembly and PE courses arrived at independently.

Try It Yourself

$ javap -v -p out/Desc.class
$ python3 courses/jvm/assets/samples/class_decode.py out/Desc.class

Check Your Understanding

Without looking back: a non-static method has the descriptor (IJ[D)Ljava/lang/String;. How many local slots does its frame need for parameters and this, and what is the descriptor of the parameter that is a double array?

Apply It

You are writing a bytecode tool that computes a method's stack frame size for a JNI-style native call. It works on every method except those taking a long or a double, where it hands the native code a frame two slots too small and the process corrupts memory. Everything else in your pipeline is correct, and the affected methods all pass javap -v cleanly. What is the defect, what is the one-line check against the file, and why is it likely that this bug survived testing on a codebase with hundreds of methods?

The general habit: when a bug correlates perfectly with one input type, that type is where the rule you are missing lives. And when a tool is accidentally correct on your test data, enumerate the input space and ask which cases your data happens not to contain.

Connect

A descriptor is a type written as a string, and this collection has three other places that does the same thing for the same reasons. A COFF or PE symbol has no type at all — only a storage class — so a linker can check that a symbol exists but not that it is callable. A DWARF type is a graph of DIEs joined by DW_AT_type, so a type is a pointer chase rather than a string. A WebAssembly value type is one byte, with function types in a separate section entirely. The class file descriptor is the only one of the four that puts a complete type in a single self-delimiting string, and that is why it is the only one the verifier can read without a second lookup.

The three-flag-tables decision is the sharpest contrast, because it is a case of a format doing the conventional thing and paying for it. An ELF header has one flags word and a section header has another, and the two are documented separately for the same reason. A PE section's characteristics word is a third table again. Every format with more than one kind of record ends up with more than one flag vocabulary, and the reason is structural: the flags describe the thing, and the things are different. Consolidating them would mean either meaningless bits or an indirection, and both are worse than a second table.

The two-slot rule for 64-bit values is the concept's real finding and it connects to something in the previous one on purpose. A long takes two pool indices and two local slots, for the same underlying reason in both places: 64-bit values are handled as two 32-bit units so that a size can be computed by addition rather than by a special case. The constant pool does it so an entry fetch is uniform; the frame does it so a frame size is uniform. Same decision, applied twice, in a format that has to keep sizes computable everywhere. It is also a good example of a rule that is invisible in the encoding and load-bearing in every consumer — nothing marks a long in the file as occupying two of anything; the rule lives entirely in how a reader counts.

Next: the Code attribute — where max_locals stops being about parameters, and the instructions themselves begin.