Skip to content
academia.sh

Lesson 05 / 10

Variables and Scope

The same int count declaration compiles to three separate things depending on where it sits: a field in the class body, a slot in a method body, statically the class's single-copy field; a method using the same name in two sibling blocks produces the same 20 instructions with one fewer slot.

Contents

The previous lesson measured which form a value is held in: the number itself, or an object wrapping the number. The difference was one letter in the source, a call in the class file. This lesson asks the same question of the name itself. The line int count = 5 compiles to three separate things depending on where it is written; the writing of all three in the source is identical.

The Programming Fundamentals course established variables, binding, scope, and lifetime as concepts — those are not repeated here. There, where a name is visible was explained. Here, what is measured is which place the compiler gives that name in the class file: a field sitting in the class body, or a slot in the local space set aside per call for the method.

Three Name Families

Java writes a name into one of three places, and the choice is decided by where the declaration sits.

  • A local variable is declared in a method’s body. It does not appear as a name in the class file; it holds a numbered slot in the method’s local space. A new copy forms every time the method is called and disappears when the call ends. Parameters are local variables too.
  • An instance variable is declared in the class body, without static. It stands as a field in the class file and has a separate copy in every object.
  • A class variable is declared in the class body with static. It is a field too, but its copy is single: it belongs to the class independent of object count.

The distinction depends on a keyword and an indentation in the source; in the class file it depends on two separate structures. Fields stand by name in the class file’s field table. Local variables are not there at all — the method’s code uses not names but numbers. This lesson measures the three families’ distinction on the count field of the record carried through the course.

The Measurement Core

The core is the previous lesson’s core, with two measurements added: the slot count each method uses, and the field accesses in that method. The class’s field table is read too. If compilation fails, no class file is produced, and the core reports this separately.

  • BS6 — Slot count is the size of the local space the class file sets aside for that method — not the number of names written in the source. In instance methods, slot number zero is reserved for this; in static methods, it starts from the first parameter.
  • BS7 — The field-access column carries the instruction name: getfield and putfield access an instance’s field, getstatic and putstatic access the class’s field.
// Gauge.java — reads where a name lands in the class file: a field, or a slot
import java.io.PrintWriter;
import java.io.Writer;
import java.lang.classfile.*;
import java.lang.classfile.attribute.CodeAttribute;
import java.lang.classfile.instruction.*;
import java.nio.file.*;
import java.util.*;
import java.util.spi.ToolProvider;

class Gauge {
    record Method(String name, int instructions, int slots, List<String> fieldAccess) {}
    record ClassInfo(List<String> fields, List<Method> methods) {}

    static Path compile(String source, String className) throws Exception {
        Path d = Files.createTempDirectory("gauge");
        Files.writeString(d.resolve(className + ".java"), source);
        PrintWriter sink = new PrintWriter(Writer.nullWriter());
        int result = ToolProvider.findFirst("javac").orElseThrow()
                .run(sink, sink, "-d", d.toString(), d.resolve(className + ".java").toString());
        return result == 0 ? d.resolve(className + ".class") : null;
    }

    static ClassInfo read(String source, String className) throws Exception {
        Path c = compile(source, className);
        if (c == null) throw new IllegalStateException("did not compile: " + className);
        ClassModel cm = ClassFile.of().parse(c);
        List<String> fields = new ArrayList<>();
        for (FieldModel f : cm.fields()) fields.add(f.fieldName().stringValue());
        List<Method> methods = new ArrayList<>();
        for (MethodModel m : cm.methods()) {
            if (m.code().isEmpty()) continue;
            CodeAttribute code = (CodeAttribute) m.code().get();
            List<String> access = new ArrayList<>();
            int instructions = 0;
            for (CodeElement e : code) {
                if (e instanceof Instruction) instructions++;
                if (e instanceof FieldInstruction fi)
                    access.add(fi.opcode().name().toLowerCase(Locale.ROOT)
                            + " " + fi.name().stringValue());
            }
            methods.add(new Method(m.methodName().stringValue(), instructions,
                    code.maxLocals(), access));
        }
        return new ClassInfo(fields, methods);
    }
}

Four Names, Three Separate Places

The measured class declares totalCount and count in the class body, and incoming, updated, n, t, temp, and second in method bodies. blockScope and noBlock do the same work in the same order; the only difference between them is whether the two temporary names are opened inside curly braces.

// Location.java — four names, three separate places
public class Location {
    static final String SOURCE = """
        class Warehouse {
            static int totalCount = 0;
            int count;
            void add(int incoming) {
                int updated = count + incoming;
                count = updated;
                totalCount += incoming;
            }
            static int blockScope(int n) {
                int t = 0;
                { int temp = n * 2; t += temp; }
                { int second = n * 3; t += second; }
                return t;
            }
            static int noBlock(int n) {
                int t = 0;
                int temp = n * 2; t += temp;
                int second = n * 3; t += second;
                return t;
            }
            static long wide(long weight, int extra) {
                long updated = weight + extra;
                return updated;
            }
            int visible(int count) { return count; }
            int accessesField(int count) { return this.count; }
        }
        """;

    public static void main(String[] args) throws Exception {
        Gauge.ClassInfo s = Gauge.read(SOURCE, "Warehouse");
        System.out.println("fields in class file: " + s.fields());
        System.out.printf("%-14s %6s %5s  %s%n", "method", "instr", "slots", "field access");
        for (Gauge.Method y : s.methods())
            System.out.printf("%-14s %6d %5d  %s%n", y.name(), y.instructions(), y.slots(),
                    y.fieldAccess().isEmpty() ? "-" : String.join(", ", y.fieldAccess()));
    }
}
fields in class file: [totalCount, count]
method          instr slots  field access
<init>              3     1  -
add                13     3  getfield count, putfield count, getstatic totalCount, putstatic totalCount
blockScope         20     3  -
noBlock            20     4  -
wide                7     5  -
visible             2     2  -
accessesField       3     2  getfield count
<clinit>            3     0  putstatic totalCount

The field table has two names: totalCount and count. Of the eight names declared in the source, only two stand as names in the class file. The remaining six are not names anywhere; they live only as slot numbers. A local variable’s name does not enter the class file — by the time the compiler’s work is done, the name has been spent. If the compiler is separately told to, names can be added to the class file as debug information, but this is not part of the code: the method’s instructions use the same slots without that information either. Field names, by contrast, are not optional, because they are accessed from outside the class and the accessing code has to know the name.

The add row shows all three families at once. In the same method, count appears twice with an instance-field instruction (getfield, putfield), totalCount twice with a class-field instruction (getstatic, putstatic); incoming and updated enter no field instruction at all, because they are in slots. Three slots are counted: zero this, one incoming, two updated.

The measurement’s real row is the pair in the middle. blockScope and noBlock produce the same 20 instructions — the work done at run time is identical, bit for bit. Slot count is separate: 3 and 4. Curly braces do nothing at run time; the work they do finishes at compile time. Because the compiler knows the name temp dies at the end of the first block, it gives the name second the same slot. Block scope is a concept that does not exist at run time: all that is left of it in the class file is one fewer slot.

The wide row shows from another angle that a slot is not a name count: three names are declared, five slots are counted. A slot is a fixed width, and 64-bit values do not fit in it; long weight holds two slots, int extra one slot, long updated two slots again. The local space’s size depends not on how many names are written but on which type those names are — this is the class-file counterpart of the width column in the previous lesson’s table.

The last two rows separate shadowing. visible and accessesField carry the same signature and the name count appears in both; one makes no field access at all, the other does a getfield. The count declared as a parameter shadows the count that is a field; the only way to reach the field is to write this.count. Which one gets read is decided at compile time and stands as two separate instructions in the class file.

Both corners of the table carry meaning too. <init> and <clinit> are methods not written in the source; the first is the constructor preparing the count field, the second is the class-initialization block carrying the totalCount = 0 assignment. Both were measured in the previous topic. <clinit>’s slot count being zero is meaningful here: there is no this in a class-initialization block, because there is not yet any object in the picture.

Scope’s Visible Result

The distinction of place has a consequence visible not as a number but as a value. When two objects are produced from the same class, the instance variable splits in two, the class variable does not.

  • BS8 — Two objects are produced and the third call is made to the second object; because the class variable carries the total of all calls, the expected value is 3 plus 4 plus 10.
  • BS9 — The shadowing test is done at a point where previous calls have already changed the field, so it can be told apart from the value whether the parameter or the field is being read.
// Scope.java — the visible result of a field's and a slot's lifetime
public class Scope {
    static class Warehouse {
        static int totalCount = 0;                 // class variable: single copy
        int count;                                  // instance variable: one copy per instance

        void add(int incoming) {
            int updated = count + incoming;         // local variable: one copy per call
            count = updated;
            totalCount += incoming;
        }

        int visible(int count) { return count; }     // parameter shadows the field
        int accessesField(int count) { return this.count; }
    }

    public static void main(String[] args) {
        Warehouse bolt = new Warehouse(), screw = new Warehouse();
        bolt.add(3);
        bolt.add(4);
        screw.add(10);
        System.out.println("bolt.count: " + bolt.count + " | screw.count: " + screw.count
                + " | Warehouse.totalCount: " + Warehouse.totalCount);
        System.out.println("visible(9): " + bolt.visible(9)
                + " | accessesField(9): " + bolt.accessesField(9));
    }
}
bolt.count: 7 | screw.count: 10 | Warehouse.totalCount: 17
visible(9): 9 | accessesField(9): 7

count is separate in every object: 7 and 10. totalCount is single: 17, that is, the sum of three calls. updated shows up nowhere, because it is destroyed at the end of every call. The three families’ lifetimes are separated this way too — a slot lives for the call, an instance field for the object, a class field for as long as the class stays loaded.

The second line shows shadowing’s cost. Two calls to the same object with the same argument return 9 and 7. There is no difference between the two methods’ bodies in the source except this.. A parameter carrying the same name as a field makes that field unreachable throughout the method; what gets read is no longer the object’s state, it is the argument coming from the caller.

The Bounding Measurement: Which Redeclaration Passes

Shadowing does not happen everywhere. Redeclaring the same name in nested blocks in Java is a compile error; shadowing only happens between two names from separate families, that is, between a field and a local. The five forms below are placed into the same class skeleton, and only whether they compile is checked.

  • BS10 — All five cases are written into the same body, with the same class name; the measurement looks not at the text the compiler produces but at whether the class file forms. The compiler’s message is not printed.
// Declaration.java — which redeclaration passes the compile stage
public class Declaration {
    static final String[][] CASES = {
        {"same name in two sibling blocks",
         "int t = 0; { int g = n; t += g; } { int g = n; t += g; } return t;"},
        {"redeclaring the outer name in an inner block",
         "int t = 0; { int t = n; } return t;"},
        {"accessing an inner-block name from outside",
         "int t = 0; { int g = n; } return t + g;"},
        {"local name same as a field name",
         "int count = n; return count + this.count;"},
        {"local name same as a parameter name",
         "int n = 1; return n;"},
    };

    public static void main(String[] args) throws Exception {
        System.out.printf("%-34s %s%n", "declaration form", "compile");
        for (String[] d : CASES) {
            String source = "class D { int count = 5; int measure(int n) { " + d[1] + " } }";
            System.out.printf("%-34s %s%n", d[0],
                    Gauge.compile(source, "D") != null ? "passes" : "error");
        }
    }
}
declaration form                   compile
same name in two sibling blocks    passes
redeclaring the outer name in an inner block error
accessing an inner-block name from outside error
local name same as a field name    passes
local name same as a parameter name error

The two passing rows share something: in neither is there a point where two names are visible at the same time. The g names in sibling blocks never see each other, because the first has closed before the second opens — this is the source-side counterpart of slot sharing. A field and a local, by contrast, are from separate families, and the compiler can tell which one is meant by this..

The three errors share something too: in every one, a single local name has been declared twice or used outside its scope. In Java, a local variable’s name cannot be redeclared within its scope; an inner block cannot shadow an outer one. The fifth row is the narrowest form of this — a parameter is a local variable too, so redeclaring the same name in the body is an error. The boundary is drawn cleanly this way: shadowing exists only between a field and a local, not between two locals, and the compiler rejects the second case before a class file is ever produced.

It should also be written that this prohibition has a cost: because a short, meaningful name in an inner block cannot be used a second time, names pile up in long methods. What is gained in return is that a name has a single declaration at any point in a method body — the reader who sees a name never has to search for which declaration it refers to. The measurement shows this decision has no run-time counterpart at all: both passing forms produce the same instructions in the class file.

Summary

  • Where a declaration sits decides where the name lands in the class file: names in the class body enter the field table, names in a method body only hold a numbered slot and never appear as a name in the class file.
  • The add method shows all three families at once: getfield/putfield for count, getstatic/putstatic for totalCount, no field instruction at all for incoming and updated.
  • Block scope ends at compile time: blockScope and noBlock produce the same 20 instructions, the block form uses only one fewer slot.
  • An instance variable lives per object, a class variable per class, a local variable per call; the measurement’s values 7, 10, and 17 separate these three lifetimes.
  • Shadowing happens only between a field and a local and is resolved with this.; redeclaring a local name in an inner block is a compile error.

Next Step

This lesson measured where a name is written. Once the values names carry settle into their places, next come the operators that combine them. The next lesson counts what one-character forms like + and += leave behind in the class file: the conversion the compiler inserts when an int and a long are added, the compound-assignment form’s silent addition of a narrowing never written in the source, and its visible consequence; short-circuit evaluation showing up in the class file as a branch instruction — and where two constants are added, no step being added at all.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close