Back to course

Inner Classes and Nests

22 minintermediateModule 3: The Attribute MechanismContinues from jvm-attributes
22 min · Module 3: The Attribute Mechanism · Foundations

Why This Matters

Java's nested classes are not a language feature with a file format representation. They are a naming convention, two attributes, and a great deal of compiler bookkeeping — and the gap between what the source says and what the file contains is where all the difficulty is.

The source says static class Nested and gets a file called Inner$Nested.class. Nothing in the source names that file. Nothing in the format requires it to be called that. And for the classes the compiler invents — anonymous ones, local ones declared inside methods — there is no name in the source at all, yet a file appears.

So the compiler has to invent names, record what it invented, and record the relationships the language needs at run time. That is three attributes' worth of work, and each one answers a different question. InnerClasses records what the compiler decided. NestHost and NestMembers record what the language permits. Getting that distinction straight is the difference between reading these attributes and merely decoding them.

A Simple Model

Three attributes, and the shape of each is different — which the previous concept would have predicted:

InnerClasses, on a class: u2 number_of_classes then that many 8-byte records: u2 inner_class_info_index the nested class, a Class entry u2 outer_class_info_index the enclosing class, or 0 u2 inner_name_index the simple name, or 0 u2 inner_class_access_flags see below, and they are NOT the class flag table NestHost, on a nested class: u2 host_class_index ONE index. No count. NestMembers, on the outermost class: u2 number_of_classes then that many u2 Class indices

And the three answer three different questions, which is the whole design:

That last distinction has a concrete consequence worth holding onto. InnerClasses is about names; the nest is about access. They are populated independently — verified below — and a class can be in a nest without appearing in any InnerClasses record, and vice versa. A tool that assumes the two lists are the same will be wrong on real input.

The Actual Detail

One source declaration, six files, and every InnerClasses record decoded from the raw bytes of Inner.class:

  0053: 00 06
        |  +-- SIX records
        +----- SIX nested classes, 8 bytes each

  [0] inner=#7   outer=#0   name=#0   flags=0x0000
      class = Inner$1              <- the ANONYMOUS Runnable
      outer = (none)                name   = (none)
  [1] inner=#18  outer=#0   name=#36  flags=0x0000
      class = Inner$1Local         <- declared INSIDE A METHOD
      outer = (none)                name   = "Local"
  [2] inner=#27  outer=#13  name=#37  flags=0x4018
      class = Inner$E              enum E
      flags = ACC_FINAL ACC_ENUM 0x0008
  [3] inner=#29  outer=#13  name=#38  flags=0x0608
      class = Inner$Iface         interface Iface
      flags = ACC_INTERFACE ACC_ABSTRACT 0x0008
  [4] inner=#31  outer=#13  name=#39  flags=0x0000
      class = Inner$Member         class Member, a non-static inner class
      flags = (none)
  [5] inner=#33  outer=#13  name=#40  flags=0x0008
      class = Inner$Nested         static class Nested
      flags = 0x0008

Five things in that table are each a finding, and two of them contradict what a reader would guess.

The 0x0008 bit, and the third flag table

Records [2], [3] and [5] all carry 0x0008, and 0x0008 is ACC_STATIC. A class cannot be static — top-level classes have no static, and ACC_STATIC in the class flag table means nothing at all.

But these are not classes in the top-level sense. inner_class_access_flags describes a member of a class, so it uses the field/method flag table, not the class one. And a nested enum and a nested interface are both implicitly static, which is a language rule: a nested interface is static because an interface cannot hold instance state, and a nested enum is static because an enum's instances are supposed to be singletons. Record [5] is the explicit case — the source said static class Nested and the bit records exactly that.

And this is a live trap in this course's own decoder. It reported record [2] as ACC_FINAL ACC_ENUM UNKNOWN(0x0008) — which is exactly right and also exactly the bug the Module 2 concept warned about. The decoder used the class flag table because it was asked to render class flags, and 0x0008 is not in that table, so it honestly reported an unknown bit. The bits are identical to the member table; only the table that knows their names is different. A reader that renders inner_class_access_flags with the class table will show UNKNOWN for every nested interface and every nested enum in the file, and a reader that renders them with a merged table will show ACC_STATIC for a top-level class that is not static. Neither is catastrophic and both are wrong, which is the worst shape for a bug.

Why $1 exists and $1Local is a different thing

Record [0] is the anonymous Runnable, and it is the reason the file is called Inner$1.class. The compiler had to invent a name and recorded nothing:outer_class_info_index = 0 and inner_name_index = 0. A class with no name and no recorded outer class is the file's entire justification for the $1 convention — the number is the only identity an anonymous class has, and it is a counter of anonymous classes within the outer class, starting at 1.

Record [1] is the class declared inside a method, and it is the interesting one: it has a name ("Local") but no outer class recorded. The source was void use() that declares class Local, then news one, so Local is scoped to one method and the compiler emitted Inner$1Local.class — a $1and a name, which is not what either convention predicts on its own.

The numbering scheme behind that name is worth stating because it is entirely implementation-defined and the format does not care:

  Inner$1         the first ANONYMOUS class in Inner
  Inner$1Local    a LOCAL class: $1 because the anonymous Runnable was
                  compiled first, then the simple name
  Inner$Nested    a named nested class: just Outer$SimpleName
  Inner$E         a nested enum: Outer$SimpleName
  Inner$Iface     a nested interface: Outer$SimpleName
  Inner$Member    a non-static inner class: Outer$SimpleName

  the rule javac actually uses: an ANONYMOUS class gets Outer$N where N
  counts anonymous classes; a LOCAL class gets Outer$NLocal where N is
  the counter at the time it was compiled; a NAMED nested class gets
  Outer$SimpleName. All three are conventions, not rules -- nothing in
  the format requires any of them, and every JVM accepts any name.

Which brings up the question the descriptor grammar only hinted at: the $ in LDesc$Nested; is not a nesting marker. It is a legal character in a Utf8 entry, the compiler happens to use it to join an outer and inner name, and a class genuinely named A$B in Java is indistinguishable in the file from a nested class B of A. The method reference in Module 1 makes it concrete — this::id compiles to a synthetic method named lambda$new$0, a $ in the middle of a method name that has nothing to do with nesting. The attribute is the only place the relationship is recorded, which is exactly why it exists.

The nest, and that it is a different set

Now the other two attributes, from the same file, and this is where the sets diverge:

  NestMembers on Inner.class -- 6 entries:
      #27 Inner$E        #29 Inner$Iface     #31 Inner$Member
      #33 Inner$Nested   #18 Inner$1Local    #7  Inner$1

  NestHost on Inner$Nested.class -- ONE u2, no count:
      0010    ->  #16 = Inner

NestMembers lists all six, which is exactly the InnerClasses set. So for this class the two agree — and that is a coincidence of a small example, not a rule. The distinction is what each one is for: InnerClasses says how the compiler named things, NestMembers says who may read whose private fields. A class can be in a nest without being a nested class in the source sense, and the format has no requirement that the lists match.

And note the shape difference once more, because it is the previous concept's point made concrete: NestHost is a bare index with no count, while NestMembers is a counted list. A reader that assumed a common body shape would read NestHost's index as a count and walk into the following attribute. The raw bytes 00 10 are two bytes of body; there is no count anywhere in them.

A Real Example

What a nest actually buys, and why it replaced synthetic accessors. The source-level problem is old and the mechanism is not obvious:

BEFORE Java 11: a nested class reading its outer class's private field class Outer has a private int secret = 42, and a nested class Inner whose method read() returns that field. secret is private to Outer. Inner is a DIFFERENT class. So javac generated, in Outer.class: static synthetic int access$000(Outer) <- a bridge static synthetic int access$100(Outer) <- and Inner gets its own and Inner.read() compiles to: aload_0 invokestatic Outer.access$000:(LOuter;)I the METHOD CALL costs real bytes, shows up in profiles, and the bridge is visible in the class file even though nobody wrote it. FROM Java 11: the nest Outer$Nested.class gets: NestHost -> Outer Outer.class gets: NestMembers -> Outer$Nested and Inner.read() compiles to: aload_0 getfield Outer.secret a DIRECT field access. No bridge, no call, and the verifier checks the nest membership instead.

That is a real optimisation with a real mechanism behind it, and the interesting part is what moved. The compiler no longer has to invent a method; the verifier now does the work, by consulting NestHost and NestMembers when it type-checks the getfield. The cost has not disappeared — it has moved from the class file's method table to its attribute list, and from run time to load time. That is the trade the format prefers throughout: pay at load, once, so the hot path stays direct.

And the two attributes are what make that possible from both sides. A getfield inside Inner needs to know that Inner belongs to Outer's nest — so Inner$Nested.class needs NestHost. And the verifier needs to know that Outer claims Inner as a member, rather than trusting a self-declaration — so Outer.class needs NestMembers. Both directions are required because either one alone would let a class claim access it has not been given.

That is the security argument, and it is worth making explicit because it is the same argument as control flow guard and weak externals in this collection: a claim about a relationship has to be checkable by someone who is not the claimant.NestMembers is the outer's side of that, and without it NestHost would be a class simply asserting that it may read private state. Compare a ELF symbol's binding and visibility, which is the same two-sided arrangement between a symbol and the section that defines it.

One consequence worth knowing before you read a real file: the nest is not the same as the enclosing class, and it is not transitive. A class in Outer's nest can read Inner's privates and vice versa, but a third class in the same nest that is a nested class of a different member is not automatically related. And a static nested class is in the nest like any other, because a static nested class still has access to the outer's privates in the source language. The Inner$E enum in this course's sample is nested, static, and a member of the nest — three facts from three different mechanisms, and only the third one is about access.

Try It Yourself

$ javap -v -p out/Inner.class
$ python3 courses/jvm/assets/samples/crosscheck.py classes/Inner.class

Check Your Understanding

Without looking back: a InnerClasses record has inner = #7, outer = 0, name = 0, flags = 0. What class is this, why does its file have a numeric component in its name, and what does the 0x0008 bit mean on a record whose other flags are 0x4018?

Apply It

You are writing a tool that finds every class in a nest, given one class file. It reads InnerClasses from that file and follows each record's outer_class_info_index, which works for a static nested class and fails for both an anonymous class and a class declared inside a method: in both cases the outer index is zero, so the tool finds nothing and reports the class as having no nest. javap on the same file lists the nest correctly. What is the defect, what is the correct source for the relationship, and what is the general rule about trusting a field that is allowed to be zero?

The general habit: a field that is permitted to hold a sentinel value carries no information when it does, and a consumer that reads a sentinel as a value will invent a relationship that is not there. Check for the sentinel before you use the field.

Connect

A nest is a two-sided declaration, and this collection has three other mechanisms built the same way, for the same reason. An ELF symbol's binding and an object's visibility are recorded by the producer and checked by whoever consumes the reference. A PE's load config records guard metadata that the loader verifies. A COFF weak external is a claim about a symbol that the linker is expected to check rather than trust. The pattern is always the same: a claim about a relationship is only useful if something other than the claimant checks it, and a one-sided declaration is just an assertion.

The $ finding is a naming lesson the whole collection keeps teaching. A PE import is by ordinal, a COFF static-library symbol by ordinal in a string table, and both are positions that a producer fills in and a consumer resolves. The Java $ convention is the weakest version of the family — a name, not a position, and one the format does not require — which is why it is the one that needs an attribute to record the real relationship. Compare the ELF symbol table, where the name is in the file and the index is assigned by the linker: a position is resolved, a name is trusted, and only the second kind needs a second attribute to say what it means.

The synthetic-accessor story connects to COMDAT and to weak externals in a specific way: all three are compiler-generated members that exist only to satisfy a language rule the machine does not have. A nested class reading a private field is not a privilege the CPU understands; private is enforced by the compiler and the verifier, so the mechanism has to be a generated method or a declared relationship. The trend across all three is the same — move enforcement from generated code to checked metadata, and the direction of travel is set by the cost of generated code, which is bytes in the file and a call in the profile.

And the flag-table surprise is the third time this course has found it, which is the argument for teaching it as a general habit rather than a fact. A section header's flags and the file header's are different; a PE section's characteristics are a third vocabulary. Any format with more than one kind of record ends up with more than one flag table, because the flags describe the record and the records differ — and the failure is always a bit reported with a name that is meaningless in that position, which is a class of wrong that no reader can detect from the value alone.

Next: the Signature attribute, which is the second type grammar in the format — and the one place where the file says something the language deliberately erased.