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/Subpair; 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 wordstaticin 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 (
turngives 1 and 2), a class variable is single across the inheritance chain: onceSub.produced = 99is written,Base.producedbecomes 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.counterinitializes onlyBase, whileBase.CEILINGinitializes 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.