Skip to content
academia.sh

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.

Start typing to search.

↑↓ Esc navigate · open · close