Skip to content
academia.sh

Lesson 05 / 13

Static Members and Initialization Blocks

A static method is not overridden, it is hidden: the same call spelling is picked from the declared type, and a static call made through a null reference even works. The class-level state's single copy, which access triggers class initialization, and the case where the same line goes to two separate sides are measured.

Contents

The previous lesson set two abstraction tools side by side: the abstract class worked with single inheritance, the interface with multiple, and when two independent interfaces gave the same default, the compiler refused to decide. What the two tools shared was that both worked at the instance level. Every method called belonged to an object, so the decision mostly came from the runtime type.

This lesson’s subject is members belonging to the class itself: the static method, the class variable, and the initialization block. Behind a method called without an object, there is no runtime type; what remains is only the name the compiler sees. There are three questions: does the same line go to different sides for an instance method versus a static method, how many copies does class-level state carry, and which access triggers a class’s initialization block?

The Measurement Core

The course’s core is the same pair: declared type Base, runtime type Sub. This lesson adds two members to the pair — a class variable (produced) and an instance field (turn) — but the existing members’ behavior does not change.

  • CI21 — The two types are deliberately separated; the oracle is the rig itself. When Base d = new Sub() is written, which type an answer comes from is known before looking at the answer.
  • CI22 — Only members are added to the Base/Sub pair; the values the shared definition’s members produce do not change.
  • CI23 — In the initialization measurement, every scenario runs in a separate loader; one scenario’s initializing a class does not affect another.
  • CI24 — Initialization steps print their name when they run; order is a dump, not an inference.
  • CI25 — The only thing counted is which side the decision comes from. Instruction count and added steps are not counted in this course; the class file is read only to show where the decision froze, and the reading calls the compiler from within the program and parses the file it produces with the standard library.
// Base.java — the course's core: instance method next to static method
class Base {
    static int produced = 0;
    final int turn;
    Base() { turn = ++produced; }
    String name() { return "base method"; }
    static String status() { return "base static"; }
}
// Sub.java — subclass redeclaring the same two names
class Sub extends Base {
    @Override String name() { return "sub method"; }
    static String status() { return "sub static"; }
}

name carries an @Override mark, and the compiler accepts it: this is an overriding. status carries no such mark — had it, compilation would stop. A static method cannot be overridden; what happens when it is redeclared under the same name is called hiding, and the behavior we saw hiding produce in fields repeats here for methods.

Same Spelling, Two Sides

The first measurement puts five calls side by side. Three are made through a reference declared Base, one through a reference declared Sub, and the last through a reference bound to no object at all.

// Side.java — which type does the same call spelling get picked from
public class Side {
    @SuppressWarnings("static-access")
    public static void main(String[] args) {
        Base d = new Sub();
        Sub a = new Sub();
        Base empty = null;
        System.out.printf("%-30s %-12s %s%n", "call written", "result", "decided by");
        System.out.printf("%-30s %-12s %s%n", "d.name()     [declared Base]", d.name(), "runtime type");
        System.out.printf("%-30s %-12s %s%n", "d.status()   [declared Base]", d.status(), "declared type");
        System.out.printf("%-30s %-12s %s%n", "a.name()     [declared Sub]", a.name(), "runtime type");
        System.out.printf("%-30s %-12s %s%n", "a.status()   [declared Sub]", a.status(), "declared type");
        System.out.printf("%-30s %-12s %s%n", "empty.status() [null ref]", empty.status(), "declared type");
        System.out.println();
        System.out.println("instance field -> d.turn " + d.turn + " | a.turn " + a.turn);
        System.out.println("class field -> Base.produced " + Base.produced + " | Sub.produced " + Sub.produced);
        Sub.produced = 99;
        System.out.println("after Sub.produced = 99, Base.produced: " + Base.produced);
    }
}
call written                   result       decided by
d.name()     [declared Base]   sub method   runtime type
d.status()   [declared Base]   base static  declared type
a.name()     [declared Sub]    sub method   runtime type
a.status()   [declared Sub]    sub static   declared type
empty.status() [null ref]      base static  declared type

instance field -> d.turn 1 | a.turn 2
class field -> Base.produced 2 | Sub.produced 2
after Sub.produced = 99, Base.produced: 99

The first two lines are written through the same d reference and give opposite answers. d.name() returns sub method: the object is a Sub, and the method was picked from there. d.status() returns base static: the object is still the same object, but the pick was made from d’s declared type. The only difference between the two is that one of the two declarations inside Sub starts with the word static.

The fifth line is proof of this reading. empty is bound to no object at all; even so, the call empty.status() gives a result and raises no error. The reference is not even read. A reference appearing in a static call’s spelling is only a syntactic convenience; the compiler takes that reference’s type, and discards its value.

The bottom three lines answer the second question. Two objects were produced, and each carries its own turn value: 1 and 2. produced, in turn, gives the same number — 2 — no matter which of the two names it is read through. The name written as Sub.produced is not a separate counter; once Sub.produced = 99 is written, Base.produced also becomes 99. An instance field carries one copy per object; a class variable is one single copy across the whole inheritance chain.

The Proof in the Class File

Behavior has been measured; now where the decision froze can be looked at. The measurement core compiles a source text, reads the class file it produces, and returns two things for every call inside a method’s body: the call’s owner as written in the class file, and the call’s kind.

// Gauge.java — a call's owner and kind as written in the class file
import java.io.PrintWriter;
import java.io.Writer;
import java.lang.classfile.*;
import java.lang.classfile.instruction.*;
import java.nio.file.*;
import java.util.*;
import java.util.spi.ToolProvider;

class Gauge {
    record Call(String owner, String name, String kind) {}

    static Path compile(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);
        return d;
    }

    static List<Call> calls(String source, String className, String method) throws Exception {
        ClassModel cm = ClassFile.of().parse(compile(source, className).resolve(className + ".class"));
        List<Call> calls = new ArrayList<>();
        for (MethodModel m : cm.methods()) {
            if (!m.methodName().stringValue().equals(method) || m.code().isEmpty()) continue;
            for (CodeElement e : m.code().get())
                if (e instanceof InvokeInstruction iv)
                    calls.add(new Call(shortName(iv.owner().asInternalName()),
                            iv.name().stringValue(), kind(iv.opcode())));
        }
        return calls;
    }

    static String kind(Opcode o) {
        return switch (o) {
            case INVOKESTATIC -> "static call";
            case INVOKEVIRTUAL -> "virtual call";
            default -> "direct call";
        };
    }

    static String shortName(String internal) { return internal.substring(internal.lastIndexOf('/') + 1); }
}
// Owner.java — what is the call's owner written as in the class file
public class Owner {
    static final String SOURCE = """
        class Base {
            String name() { return "base method"; }
            static String status() { return "base static"; }
        }
        class Sub extends Base {
            @Override String name() { return "sub method"; }
            static String status() { return "sub static"; }
        }
        class Caller {
            static String baseInstance(Base d) { return d.name(); }
            static String subInstance(Sub a) { return a.name(); }
            @SuppressWarnings("static-access")
            static String baseStatic(Base d) { return d.status(); }
            @SuppressWarnings("static-access")
            static String subStatic(Sub a) { return a.status(); }
        }
        """;

    public static void main(String[] args) throws Exception {
        System.out.printf("%-13s %-24s %s%n", "method", "call in class file", "call kind");
        for (String y : new String[] {"baseInstance", "subInstance", "baseStatic", "subStatic"})
            for (Gauge.Call c : Gauge.calls(SOURCE, "Caller", y))
                System.out.printf("%-13s %-24s %s%n", y, c.owner() + "." + c.name(), c.kind());
    }
}
method        call in class file       call kind
baseInstance  Base.name                virtual call
subInstance   Sub.name                 virtual call
baseStatic    Base.status              static call
subStatic     Sub.status               static call

In all four lines, the owner written is the declared type; this is the table’s most misread spot. The baseInstance method also has Base as its owner, and yet the method that actually runs is Sub‘s. The distinction is not in the owner, it is in the kind. In a virtual call, the owner is only a starting point: the virtual machine re-resolves the call starting from the object’s real class, so the Base.name text written in the class file does not decide the result. In a static call, there is no resolving; the owner is the result itself, and Sub.status and Base.status are two separate targets.

This reduces the lesson’s main measurement to one sentence: a static call’s target is written at compile time and has no place left to be changed at runtime.

Which Access Triggers Initialization

Class-level state has to be set up somewhere, and that place is the initialization block. When this block runs — not at program launch, but the first time the class is really needed, and only once — was measured in the Java Fundamentals course and is not repeated here. The question here is different: which spelling counts as being needed?

The measurement below runs four separate accesses in four separate loaders, so every scenario starts from a clean slate. Initialization blocks print their name when they run.

// Trigger.java — which access triggers class initialization
import java.net.URL;
import java.net.URLClassLoader;
import java.nio.file.Path;

public class Trigger {
    static final String SOURCE = """
        class Base {
            static { System.out.println("  Base initialized"); }
            static int counter = 0;
            static final int CEILING = 100;
        }
        class Sub extends Base {
            static { System.out.println("  Sub initialized"); }
            static int subCounter = 0;
        }
        class S1 { static void run() { System.out.println("  value read: " + Sub.counter); } }
        class S2 { static void run() { System.out.println("  value read: " + Base.CEILING); } }
        class S3 { static void run() { System.out.println("  value read: " + Sub.subCounter); } }
        class S4 { static void run() { new Sub(); System.out.println("  a Sub was produced"); } }
        """;

    static void scenario(Path d, String driver, String spelling) throws Exception {
        System.out.println("access written: " + spelling);
        try (URLClassLoader cl = new URLClassLoader(new URL[] {d.toUri().toURL()}, null)) {
            var m = cl.loadClass(driver).getDeclaredMethod("run");
            m.setAccessible(true);
            m.invoke(null);
        }
        System.out.println();
    }

    public static void main(String[] args) throws Exception {
        Path d = Gauge.compile(SOURCE, "Base");
        scenario(d, "S1", "Sub.counter    [field declared in Base]");
        scenario(d, "S2", "Base.CEILING   [compile-time constant]");
        scenario(d, "S3", "Sub.subCounter [field declared in Sub]");
        scenario(d, "S4", "new Sub()");
    }
}
access written: Sub.counter    [field declared in Base]
  Base initialized
  value read: 0

access written: Base.CEILING   [compile-time constant]
  value read: 100

access written: Sub.subCounter [field declared in Sub]
  Base initialized
  Sub initialized
  value read: 0

access written: new Sub()
  Base initialized
  Sub initialized
  a Sub was produced

In the first scenario, the name written in the source is Sub, but the class initialized is Base, and Sub is never initialized at all. The written name is not the trigger; the class the field is declared in is the trigger, and that is found at compile time. The third scenario uses the same spelling, but with a field declared inside Sub, and this time both classes initialize, with the base class first. Same spelling, two separate outcomes; what makes the difference is not the name itself, it is which class the name resolves to.

The second scenario goes further still: no class initializes, yet the value is read and comes out correct. CEILING is a static final field whose value is a constant computable at compile time; the compiler has written that value directly into the reading side, so Base is never touched at runtime at all. Reading a class variable initializes the class; reading a constant does not — and the two lines are indistinguishable in the source.

A Bounding Measurement: The Same Line Goes to Two Sides

The measurements up to now called an instance method and a static method on separate lines. The real defect is born once the two sit side by side: a single line, a single reference, two separate decisions.

// SideBySide.java — the same line goes to two separate sides
public class SideBySide {
    @SuppressWarnings("static-access")
    static String line(Base d) { return d.name() + " / " + d.status(); }

    @SuppressWarnings("static-access")
    public static void main(String[] args) {
        for (Base d : new Base[] {new Base(), new Sub()})
            System.out.printf("Base d = new %-6s -> %s%n", d.getClass().getSimpleName() + "()", line(d));
        Sub a = new Sub();
        System.out.printf("Sub a = new %-6s -> %s%n", "Sub()", a.name() + " / " + a.status());
    }
}
Base d = new Base() -> base method / base static
Base d = new Sub()  -> sub method / base static
Sub a = new Sub()  -> sub method / sub static

The line method never changed; the same body ran on all three lines. Yet the three results give three separate combinations. In the first line the two answers agree, because the declared type and the runtime type are the same. They agree in the third line too, for the same reason. The split is visible only on the second line — and exactly there, a programmer’s expected reading breaks: the object is a Sub, yet the status information comes from Base.

Two boundaries follow from this. First, there is no situation where the rule reverses; a static member is always picked from the declared type. Second, and more important, the split cannot be observed in most programs: as long as the declared type and the runtime type are not separated, the two sides give the same answer and the difference never surfaces at all. The day a subclass is written for a class, a line that had worked correctly up to that day can silently change direction. The defect’s cost is not paid where it is written, it is paid where the subclass is used — and what shows up there is not an error, it is a wrong value.

Summary

  • A static method is not overridden, it is hidden: d.name() gives sub method from the runtime type, d.status() gives base static from the declared type; the only difference between the two lines is the word static in the declaration.
  • A static call never reads the reference’s value at all; empty.status() made through a null reference gives a result and raises no error.
  • An instance field carries one copy per object (turn gives 1 and 2), a class variable is single across the inheritance chain: once Sub.produced = 99 is written, Base.produced becomes 99 too.
  • In the class file, all four calls’ owner is the declared type; the distinction is in the kind. In a virtual call the owner is a starting point, in a static call it is the result itself.
  • Class initialization is triggered not by the written name, but by the class the field is declared in: Sub.counter initializes only Base, while Base.CEILING initializes no class at all.
  • The split is visible only when the declared type and the runtime type are separated; in two of the three-line measurement, the two sides give the same answer and the difference cannot be observed.

Next Step

In this lesson’s last measurement, a value was read without ever triggering class initialization: CEILING was a static final field, and its value had passed to the reading side. That single word, final, showed up here only as a side effect; in fact it does three separate jobs at once, and in all three it carries the decision to compile time. The same word blocks reassigning a local variable, overriding a method, and extending a class. The next lesson measures these three constraints separately, shows the consequence of a constant value being embedded in the reading class, and bounds, with the same measurement, that a final reference does not freeze the object — meaning it is not immutability.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close