Java spent fifteen years without any way to say "this class belongs to that library and must not see anything else". The class path was one flat namespace, every class was public or private, and encapsulation stopped at the package boundary whether or not the package was meant to be an API. Then Java 9 added modules, and the mechanism they use is the strangest in this course: two constant pool tags that the JDK's own disassembler does not recognise.
Here is the finding that makes this concept worth its own lesson. javap — the reference disassembler, shipped with the JDK, used in the first concept as one of three independent readers — prints CONSTANT_Module and CONSTANT_Package as Unknown:
$ javap -v -p module-info.class
#6 = Unknown #7 // "com.example.demo"
#8 = Unknown #9 // "java.base"
#13 = Unknown #14 // com/example/api
#15 = Unknown #16 // com/example/internal
#17 = Unknown #12 // "com.example.friend"
#19 = Unknown #20 // com/example/model
It reads them correctly and names them "Unknown". The Utf8 it resolves is right, so the tool understands the structure; it just has no name for the tag. And that is a genuinely useful thing to know about a thirty-year-old disassembler: tags 19 and 20 are so rarely used that the tool that has shipped with every JDK for three decades still has no label for them. It is a direct measurement of how unusual a feature is.
Two new pool tags, and they are the simplest in the format — one name index each:
Three bytes each, and that is the whole thing. Neither is a CONSTANT_Class, which is the design point worth noticing: a class entry's name_index points at an internal name (com/example/api/Service) because that is what the JVM needs to resolve, whereas a module's or a package's name is a human-facing dotted or slashed identifier that the resolution logic never uses. So the two new tags are names for the benefit of readers, and the format gave them their own tags rather than overloading Class to mean three things.
That is the opposite choice from the annotation's enum and class values, which were flattened into CONSTANT_Utf8 strings because they needed no identity. Modules and packages do need identity — the same module name appears in many files and a reader compares them — so each got a pool tag that can be shared. One of those decisions is right and the other is right for a different reason, and the difference is whether the name has to be found by reference or only read.
The attribute is the one place the module's whole declaration lives, and it is five variable-length sections with no overall count:
No section ids, no tags, no end markers — the five sections follow each other in a fixed order and the counts tell you where each ends. That is the opposite design from the escape hatch, where each thing carries its own name and length. Here everything is positional, which means a reader must know the order to read it at all, and adding a sixth kind of directive would require a new major version rather than an attribute.
A real module-info.class, compiled from a nine-line source, 370 bytes, 24 constant pool entries and one 56-byte attribute:
flags: (0x8000) ACC_MODULE
Module:
#6,0 // "com.example.demo"
#0
2 // requires
#8,0 // "java.base"
#10 // 26.0.1
#11,20 // "java.logging" ACC_TRANSITIVE
#10 // 26.0.1
2 // exports
#13,0 // com/example/api
#15,0 // com/example/internal to ... 1
#17 // ... to "com.example.friend"
1 // opens
#19,0 // com/example/model
1 // uses
#21 // com/example/api/Service
1 // provides
#21 // com/example/api/Service with ... 1
#23 // ... with com/example/internal/Impl
Read that against the nine lines of source that produced it, and every count has a visible cause:
module com.example.demo -- the nine directives below:
requires java.base; -> 1 requires, no flags
requires transitive java.logging; -> 2nd requires, ACC_TRANSITIVE
exports com.example.api; -> 1st export, no target list
exports com.example.internal -> 2nd export, "to" count 1
to com.example.friend;
opens com.example.model; -> 1 opens, no target list
uses com.example.api.Service; -> 1 uses
provides com.example.api.Service -> 1 provides, "with" count 1
with com.example.internal.Impl;
-- end of module --
Several things fall out of the decoded values, and they are the interesting content of the concept.
Both requires entries carry the same version index #10 = "26.0.1" — one Utf8 for two references, the constant pool's dictionary doing its job on a field most formats would have inlined. And the second entry's #11,20 is a CONSTANT_Module with flags 0x0020 = ACC_TRANSITIVE.
So a requires entry is three fields, not two: a module reference, a u2 of flags, and a version string. The version is not part of the module name — it is a separate optional Utf8, and a module that specifies no version has 0 there. A version number being a plain string rather than a comparable value is a deliberate and slightly surprising choice, and the reason is that Java versions are strings in the specification too: 26.0.1 and 26 are both legal and are not compared numerically.
The two exports entries differ in a field that is easy to miss: the first has 0 target count and the second has 1, naming com.example.friend. An export is public to everyone by default and restricted only by an explicit target list — the to clause narrows it. There is no "exported to nobody" form; that is what opens is for.
And the distinction between exports and opens is compile-time versus run-time access. exports makes a package's types nameable in source — other modules can import them and call their public members. opens makes a package's types reflectable — Class.forName works and setAccessible can reach non-public members, but you still cannot name the type in source. Two keywords, one attribute, and a distinction that exists because Java has compile-time access and reflective access as separate mechanisms. A framework doing dependency injection opens a package; a library that wants to be used exports one.
The last two sections are the service loader, and they are the only parts of the attribute that name classes rather than modules or packages — both use CONSTANT_Class indices, not the new tags:
uses: #21 -> com/example/api/Service (a CONSTANT_Class)
provides: #21 -> com/example/api/Service with ... 1
#23 -> com/example/internal/Impl
and note: the SAME index #21 appears in both sections, because
both are pointing at the same Class pool entry. The service type
is stored once.
That is the point of the pairing. A module uses a service to declare that it looks one up, and provides one to declare that it supplies an implementation. The runtime's service loader pairs them by type name, not by identity, so the two lists in two different files meet at a string. A cross-file correspondence resolved by name — and the same shape as a dynamic ELF symbol being resolved by name across objects, with a different failure mode if the names do not match.
Which gives the honest limit of the whole feature. Module boundaries are enforced by the launcher, not by the class file. A class file carries its module's declarations, but those declarations mean nothing on a plain class path, where everything sees everything. That is the same two-mode architecture as the rest of Underlayer, and it is worth naming: the attribute is a declaration of intent that a specific runtime configuration chooses to honour. Run the same class on the class path and the module system is inert — which is exactly how the migration was staged, and why every module-aware library also works without it.
Why this feature needed new pool tags when sealing was happy with a list of class indices — and the answer is about what the names are for.
And the payoff of that choice is visible in this course's own tooling. The `java.lang.classfile` API added in Java 22 models these as distinct types — a ModuleEntry and a PackageEntry, distinct from ClassEntry — so a reader using the standard API cannot confuse them even by accident. A well-chosen pool tag becomes a type in every API built on top of it, and a conflated one becomes a permanent ambiguity that every consumer has to work around.
Compare ELF's e_type and PE's characteristics: both formats have a "what kind of thing is this" field, and both added values to it over time — ET_DYN and ET_CORE in ELF, the relocation-stripped flag in PE. The class file's answer was to add tags rather than flag values, which is the more expensive choice up front and the cheaper one forever, because a new tag cannot be confused with an old one and a new flag value can be silently mistaken for a combination of existing bits by a reader that does not know it. A reader from 1997 reading a tag it does not know skips it; a reader from 1997 reading an unknown flag value still renders it, and the InnerClasses0x0008 problem from Module 3 is exactly that failure.
The final point is the one that makes this concept more than a structure walk. A class file that declares modules is 370 bytes and describes an entire library's public surface — what it requires, what it exposes, what it reflects on, what it consumes and provides — in a form a machine can check. Before this, none of that was in the file; it lived in a build tool's configuration and in a convention about directory layout. The module feature did not add runtime capability so much as move a declaration from prose into bytes, and every other feature in this course has been a version of the same move: record components, permitted subclasses, enum constants, lambda targets. The format's thirty-year pattern is that each language feature eventually arrives as a structure the machine can read rather than a convention it can only hope for.
$ javap -v -p out/module-info.class
$ python3 courses/jvm/assets/samples/crosscheck.py classes/module-info.classmodule-info.class with javap -v and grep for Unknown. Then decode the same pool with the JDK's own API — Cf.java in this course's samples does it — and see Module and Package where javap gave up on naming them. Two readers, one structure, one label.Utf8, 2 Class, 1 Module, 7 Package, 1 more Module. Over a third of the pool exists only to name modules and packages — and that is what the feature costs in a 370-byte file.requires a version and another none. The one without has 0 there, and it is not part of the name string — so a reader that appended the version to the name would be wrong. Print both name indices and both version indices.exports to opens and see what changes. Same package, one keyword different. One attribute byte, and the difference between "other modules can name this type" and "reflection can reach this type" — a compile-time/run-time distinction in a single flag.provides names a type the other does not uses. Nothing at load time complains, because the pairing is by name at resolution time. Compare a dynamic ELF symbol that never resolves — a link-time failure in a different phase.opensed. It throws InaccessibleObjectException — which is the enforcement point, and it is in the runtime, not the class file.Module attribute is positional with no section tags. Sketch what adding one would cost and compare with the attribute mechanism's answer to the identical question. The contrast is the format's one inconsistency, and it is a deliberate one.Without looking back: you run javap -v on a module-info.class and see #6 = Unknown #7 // "com.example.demo". The same file's uses section shows #21 as a CONSTANT_Class. What is the difference between the two entries, why does javap label one and not the other, and what would go wrong if CONSTANT_Module were encoded as a CONSTANT_Class?
You are building a dependency-analysis tool. It reads every class file, collects the module each one belongs to from its Module attribute, and builds a graph of module dependencies to detect cycles. On a codebase built with modules it works. On the class path — where most dependencies actually live — it reports every class as belonging to the unnamed module and then produces a single-node graph, and the team concludes the project has no cycles when it has three. What is the tool assuming, and how should it handle the two cases without making the module-aware path worse?
The general habit: a format leaves three states open — present with a value, present with a sentinel, and absent. Never collapse the last two. A default is usually the tool's invention, not the format's.
Modules are an import mechanism, and the closest comparison is one this course has already established: DWARF's unit-level model. A DWARF unit declares its own dependencies — which unit it was compiled from, its producer, its language — and a consumer that has not got the unit it needs cannot proceed. Both put a dependency declaration in the artefact so the consumer can check it, rather than leaving it to a build system's knowledge. The differences are instructive too: DWARF units are not enforced at load, and a DWARF consumer that skips a missing unit often still works.
The exports/opens distinction has a closer relative in the PE's exports directory and in an ELF symbol table: all three are a list of names with an address or a target, and all three are declarations of what a consumer may reach. Where they part company is the enforcement point — a PE export table is honoured by the loader because there is no other way to find the code, while a module's exports is honoured by a launcher that could be configured to ignore it. The Java case is the unusual one: a declaration of encapsulation that is optional to enforce, which is what made the migration possible and is also why it has been called advisory.
The positional five-section Module attribute is the format's one departure from its own philosophy, and comparing it to a WebAssembly custom section shows the cost. A custom section is name, length, contents — fully extensible, and adding a new kind costs nothing. The Module attribute is five positional sections with fixed order, so a new kind of module directive would require a new major version, because there is nowhere to put a sixth count. That is a real limitation, and it is the direct consequence of choosing positional layout for a feature that was still finding its shape. The format's own answer to that problem is the attribute mechanism, and this attribute chose not to use it.
And the uses/provides pairing is a cross-file correspondence resolved by name, which makes it the third instance in this course of that pattern: an ELF dynamic symbol matched by name across objects, a COFF weak external resolved by the linker, and now a Java service type matched across module files. Resolving by name is what lets independently-compiled things find each other, and the price is that a rename on one side silently breaks the match unless something checks both directions. Services add the uses half precisely so the consumer's intent is on the record too — which is the same two-sided declaration as the nest and the sealed hierarchy, and the same reason it exists.
One last connection, and it is the one that ties the module to the course's own architecture. The escape hatch exists so old readers survive new writers. invokedynamic exists so new writers need no new opcodes. Modules are the case where the format added something with no escape hatch at all — a positional attribute, a new class flag, and two new pool tags — which is what happens when a feature is big enough to be worth a version number. It is the right answer and it is also the only place in this course where the format's two strategies — additive and versioned — are both visible in one design, because CONSTANT_Module is additive while the attribute's layout is versioned.
Last: the version history, which is where all of this gets put in order — what each major version added, and the pattern behind thirty years of additions.