Lesson 02 / 10
Program Life Cycle
The virtual machine counts as the entry point only the single signature satisfying four conditions at once; a class's static initializer runs not at the program's start but the moment the class is first used, and only once; an enum with two names written in the source carries three fields and five methods in the class file.
Contents
The previous lesson measured what a class file is: eight of the twelve methods written in the source carried a step there absent from the source, and the table also held a three-instruction constructor never written in the source at all. When that constructor gets called was never said; where a class’s own setup work sits was never asked either.
This lesson measures that gap. A class file sitting on disk does nothing on its own; the virtual machine loads it, prepares it, and runs the instructions inside it in an order. There are three questions: which signature does the virtual machine count as the entry point, in what order does a class’s setup work run against an instance’s setup work, and which members not written in the source join that order?
The Measurement Core
The previous lesson’s Gauge core counted instructions. What is counted in this lesson is not
instructions, it is members: the fields and methods in the class file, every method’s
signature, whether it is public and static, its return type. The core does the same job — it
compiles and reads the class file — but does not carry the instruction analysis this lesson
does not use.
- LR6 — The entry-point measurement is read from the class file itself: the method’s name, modifiers, return type, and parameter list. The measurement does not invoke a launcher; it tests the language’s stated four conditions against real class files.
- LR7 — In the order measurement, the oracle is the object’s own record: every initialization step writes its name to a shared list when it runs, so the order is not an inference, it is a dump.
- LR8 — The record list is printed once, at the program’s end; the printing itself does not mix into the measured order.
- LR9 — The member dump counts all members in the class file; those written in the source are on the list too. An added member is read by comparing the list against the source.
- LR10 — The measurement reads no environment-dependent data: duration, memory address, identity, and path are not written; the only things counted are member count and run order.
// Gauge.java — class file member dump: fields, method signatures and modifiers
import java.io.PrintWriter;
import java.io.Writer;
import java.lang.classfile.*;
import java.lang.constant.MethodTypeDesc;
import java.lang.reflect.AccessFlag;
import java.nio.file.*;
import java.util.*;
import java.util.spi.ToolProvider;
class Gauge {
record Member(String name, String signature, boolean isPublic, boolean isStatic, String returns) {}
record ClassInfo(List<String> fields, List<Member> methods) {}
static ClassInfo read(String source, String className) throws Exception {
Path d = Files.createTempDirectory("gauge");
Path f = d.resolve(className + ".java");
Files.writeString(f, source);
PrintWriter sink = new PrintWriter(Writer.nullWriter());
if (ToolProvider.findFirst("javac").orElseThrow()
.run(sink, sink, "-d", d.toString(), f.toString()) != 0)
throw new IllegalStateException("did not compile: " + className);
ClassModel cm = ClassFile.of().parse(d.resolve(className + ".class"));
List<String> fields = new ArrayList<>();
for (FieldModel fm : cm.fields()) fields.add(fm.fieldName().stringValue());
List<Member> methods = new ArrayList<>();
for (MethodModel m : cm.methods()) {
MethodTypeDesc t = m.methodTypeSymbol();
List<String> p = new ArrayList<>();
for (var a : t.parameterList()) p.add(a.displayName());
methods.add(new Member(m.methodName().stringValue(), "(" + String.join(", ", p) + ")",
m.flags().has(AccessFlag.PUBLIC), m.flags().has(AccessFlag.STATIC),
t.returnType().displayName()));
}
return new ClassInfo(fields, methods);
}
static List<String> names(ClassInfo s) {
List<String> a = new ArrayList<>();
for (Member u : s.methods()) a.add(u.name() + u.signature());
return a;
}
}
The Entry Point: The Signature Satisfying Four Conditions at Once
Where a program starts, in Java, is not a file’s first line. The launcher is given a class
name; that class gets loaded and a particular method is searched for inside it. What is
searched for is not a name, it is a signature: the method’s name has to be main, it has
to be public, it has to be static, it has to return no value, and its single parameter has to
be a string array. If all four conditions are not met at once, that method is not the entry
point.
The measurement below compiles five separate sources and tests the main method in each
one’s class file against these conditions. All five sources are flawless, and all five compile
without a hitch — the split does not surface at compile time, it surfaces after loading.
// Main.java — the signature the virtual machine counts as the entry point
import java.util.*;
public class Main {
public static void main(String[] args) throws Exception {
Map<String, String> candidate = new LinkedHashMap<>();
candidate.put("Correct", "class Correct { public static void main(String[] a) { } }");
candidate.put("NotStatic", "class NotStatic { public void main(String[] a) { } }");
candidate.put("NotPublic", "class NotPublic { static void main(String[] a) { } }");
candidate.put("NoArray", "class NoArray { public static void main(String a) { } }");
candidate.put("Returning", "class Returning { public static int main(String[] a) { return 0; } }");
System.out.printf("%-10s %-11s %-6s %-6s %-6s %s%n",
"class", "sig", "public", "static", "return", "entry point");
for (var g : candidate.entrySet())
for (Gauge.Member u : Gauge.read(g.getValue(), g.getKey()).methods())
if (u.name().equals("main")) {
boolean entry = u.isPublic() && u.isStatic()
&& u.returns().equals("void") && u.signature().equals("(String[])");
System.out.printf("%-10s %-11s %-6s %-6s %-6s %s%n", g.getKey(), u.signature(),
u.isPublic(), u.isStatic(), u.returns(), entry);
}
}
}
class sig public static return entry point Correct (String[]) true true void true NotStatic (String[]) true false void false NotPublic (String[]) false true void false NoArray (String) true true void false Returning (String[]) true true int false
One of the five classes carries an entry point. The remaining four each fail on a single
condition, and every failure says something separate. NotStatic is not static: calling a
non-static method first requires an instance, but at the program’s start there is no instance
yet — the virtual machine cannot call it without creating one. NotPublic is not accessible
from outside the class; since the caller is not the class itself, access is closed. NoArray
takes a string, not a string array: command-line arguments are an array, and their count is
not known beforehand. Returning returns a value; a program’s termination information is
given not through a return value, but through a separate call.
That the parameter is a string array is not a formatting detail: arguments given to the program from outside arrive there, in order, with their count unknown beforehand. The entry point meets the single piece of data coming from the outside world in this one parameter.
The real observation here is where the defect gets caught. All five sources compiled; their class files were produced. Whether a method is an entry point is not the compiler’s question, it is the virtual machine’s. This is the other face of the split the previous lesson established: the compiler adds steps to the source, while the virtual machine carries its own conditions for what it will find in the class file.
The Class Once, the Instance Every Time
Once the entry point is found, the run comes next. There are two separate levels in preparing a class: class-level work is done once, instance-level work is redone with every new object. Each level also has two steps of its own — field initializers and static initializer blocks. On top of these comes a constructor.
For the measurement, the course’s concrete axis is used: Entry, holding an inventory record.
It has three fields — name, count, and weight in grams. Every initialization step writes
its name to a shared list when it runs; the list is printed at the end.
// Order.java — in what order do the static initializer, field initializer, and constructor run
import java.util.*;
public class Order {
static final List<String> LOG = new ArrayList<>();
static void log(String s) { LOG.add(s); }
static class Entry {
static { log("class: static initializer"); }
static final long UNIT_WEIGHT = unitWeight();
String name;
int count = startCount();
long weight;
{ log("instance: initializer block"); }
Entry(String name, long weight) {
log("constructor: " + name);
this.name = name;
this.weight = weight;
}
static long unitWeight() { log("class: field initializer"); return 1L; }
static int startCount() { log("instance: field initializer"); return 0; }
}
public static void main(String[] args) {
log("-- main started");
new Entry("bolt", 12L);
log("-- first instance done");
new Entry("nut", 7L);
LOG.forEach(System.out::println);
}
}
-- main started class: static initializer class: field initializer instance: field initializer instance: initializer block constructor: bolt -- first instance done instance: field initializer instance: initializer block constructor: nut
The dump’s first line is the unexpected one: main starts before the class’s static
initializer. A class’s preparation is not done at program launch, it is done the moment that
class is really needed for the first time — here, on the first new call. If a class is never
used, its static initializer never runs at all.
The second observation is the absence of repetition. On the second new call, the two lines
starting with class: do not show up again; only the instance-level steps repeat.
Class-level work is done once, no matter how many instances are produced. As a number: for two
instances, 2 class-level steps ran, 6 instance-level steps ran.
The class: field initializer line in the dump makes another distinction visible too. The
UNIT_WEIGHT field is declared static final, meaning it will never change again — even so,
its value is produced by running something during class initialization, because it comes
from a method call. Had its value been a constant computable at compile time, it would have
taken the previous lesson’s constant-folding path, and no step would show up here at all. A
field being unchangeable does not mean its value was obtained without running anything.
Third is the order itself. At both levels, the field initializer runs first, then the static initializer block; both follow the order written in the source. The constructor’s body is the last of these — meaning a constructor, once its body starts running, can assume the fields have already been initialized. Assigning a field in the constructor and assigning it at its declaration do not do the same job: the second runs first, the first overwrites it.
Members Not Written in the Source
The previous lesson’s table held an <init> never written in the source at all. Now it can be
looked at directly — and next to it, a form adding much more can be placed. How many members
does an enum with two names written in the source carry in the class file?
// Members.java — members not written in the source, and what happens once a constructor is written
public class Members {
public static void main(String[] args) throws Exception {
Gauge.ClassInfo empty = Gauge.read("class Store { }", "Store");
System.out.println("class Store { } -> fields " + empty.fields()
+ ", methods " + Gauge.names(empty));
Gauge.ClassInfo own = Gauge.read("class Store { Store(int count) { } }", "Store");
System.out.println("class Store { Store(int) } -> fields " + own.fields()
+ ", methods " + Gauge.names(own));
Gauge.ClassInfo y = Gauge.read("enum Direction { NORTH, SOUTH }", "Direction");
System.out.println();
System.out.println("names written in source: 2");
System.out.println("fields in class file (" + y.fields().size() + "): " + y.fields());
System.out.println("methods in class file (" + y.methods().size() + "): "
+ Gauge.names(y));
}
}
class Store { } -> fields [], methods [<init>()]
class Store { Store(int) } -> fields [], methods [<init>(int)]
names written in source: 2
fields in class file (3): [NORTH, SOUTH, $VALUES]
methods in class file (5): [values(), valueOf(String), <init>(String, int), $values(), <clinit>()]
A class with an entirely empty body carries one method in its class file: a no-argument
constructor. Without a single character written in the source, a member has been added; its
name is the default constructor, and its only job is to call the base class’s constructor.
This is exactly the three-instruction <init> row in the previous lesson’s table.
The enum goes much further. Against two names written in the source, the class file carries
three fields and five methods. NORTH and SOUTH are written; $VALUES is not. None
of the methods are written: values and valueOf are the surface the specification defines
for an enum, $values serves them, <init> takes two parameters — even though no constructor
call is written in the source at all — and <clinit> is the class file’s name for the static
initializer block measured in the previous section. This is exactly where the enum’s constants
get set up.
This is where the course’s third claim is born: what gets added is not only a step, it is a member. A class’s surface is not made up only of the members written in its source.
A Bounding Measurement: The Member That Disappears on Writing
The output’s second line draws the claim’s boundary. Once a single-parameter constructor is
written for the Store class, the class file again has exactly one method, but this time
that method is <init>(int) — the very one we wrote. The no-argument constructor has
disappeared.
This has a visible consequence, and it is not silent. The same call is put next to two
Store forms below and compilation is attempted.
// Effect.java — what happens on the calling side once the added constructor disappears
public class Effect {
static String attempt(String source) {
try { Gauge.read(source, "Store"); return "compiled"; }
catch (Exception e) { return "not compiled"; }
}
public static void main(String[] args) {
String client = " class Client { static Store make() { return new Store(); } }";
System.out.println("constructor not written + new Store() -> " + attempt("class Store { }" + client));
System.out.println("constructor written + new Store() -> "
+ attempt("class Store { Store(int count) { } }" + client));
}
}
constructor not written + new Store() -> compiled constructor written + new Store() -> not compiled
The calling side writes the same line in both cases, and even though the line itself did
not change, one compiles and the other does not. No capability was removed from the Store
class; the added constructor was displaced because a constructor was added to the source.
The compiler places a member only when none at all is written; a single written
constructor evicts the added one.
The added step’s condition is read from this too. The compiler fills a gap; once the gap is filled, no addition is made. Knowing what a class’s file will hold requires looking not at what we wrote in the source, but at what we did not write.
Summary
- A class file does work only once loaded; the entry point is a single signature satisfying
four conditions at once — public, static, returning nothing, with a single string-array
parameter, named
main. - All five candidate classes compile without a hitch, and only one carries an entry point: these conditions are not the compiler’s question, they are the virtual machine’s.
- Class-level initialization runs not at program launch but the first time a class is needed, and only once; for two instances, 2 class-level and 6 instance-level steps were counted.
- The order is the same at both levels: the field initializer first, then the static initializer, and the constructor’s body last of all.
- A class with an empty body carries a default constructor in its class file; an enum with two names written in the source carries 3 fields and 5 methods — what is added is not only a step, it is a member.
- Once a single-parameter constructor is written, the added no-argument constructor disappears: the compiler adds a member only when none at all is written.
Next Step
This lesson measured when and in what order a class runs, but never asked how the
virtual machine finds that class. The names Store and Entry used in the measurements were
each written standing alone; yet every type name inside a class file, as seen in the previous
lesson, was carried in its full form. Where does the gap between a name’s short form and
its full form close — at compile time, or during loading? The next lesson measures this: when
the same program is written with an import versus a fully qualified name, are the class files
produced distinguishable from one another, and what does the package name change in that file?
To keep your progress and take notes, Log in
My notes
Log in to take notes.