SaveMyCert
Log in
5 of 5 free questions left today·for 30 a day
Applying Object-Oriented Principles in Java Programs

Java Variable Scope, Encapsulation, Immutability, var and _ (1Z0-831)

21 min read1Z0-831 · Applying Object-Oriented Principles in Java ProgramsUpdated

Variable scope in Java is the region of code where a name refers to a particular variable, and Oracle 1Z0-831 objective 3.4 pairs it with encapsulation, immutable objects, local variable type inference (var) and unnamed variables (_). This lesson covers block, switch, pattern and lambda scope, shadowing and definite assignment, private access and defensive copies, how to build immutable classes and records, and exactly where var and _ compile — every rule checked against JDK 25.

What you’ll learn
  • Determine where a local, parameter, field or pattern variable is in scope, including colon-style switch blocks and lambda parameters
  • Trace code where parameters, locals and pattern variables shadow fields, and reach the field with this or the class name
  • Predict when the compiler rejects a read of a local that is not definitely assigned
  • Apply encapsulation with private fields and explain why access is per class, not per object
  • Build immutable classes and records with final fields, defensive copies and List.copyOf
  • Decide where var compiles and what type it infers
  • Use unnamed variables (_) where they are allowed and explain what still runs

Variable scope: where a local variable can be used

A local variable is in scope from the point of its declaration to the end of the block ({ … }) that contains it, and nowhere else. Fields are different: an instance or static field is in scope throughout the body of its class, whatever order members are declared in (one caveat: a field initializer that reads a later field by its simple name fails with illegal forward reference). Four kinds of scope come up in code-reading questions:

VariableScopeDefault value?
Static fieldThe whole class; reachable from other classes as ClassName.name if access allowsYes (0, false, null…)
Instance fieldThe whole class, through an object (this.name or ref.name)Yes
Method or constructor parameterThe entire method or constructor bodyAlways holds the argument
Local variableFrom its declaration to the end of the enclosing blockNo — must be definitely assigned before it is read

Two rules decide most scope questions:

  • A local cannot be redeclared while it is still in scope. An inner block may not declare a variable with the name of an outer local or parameter (variable x is already defined in method …). Unlike C, Java has no local-shadows-local. Once a block has ended, though, its locals are gone and the name can be reused — by a sibling block, or by a later declaration in the outer block.
  • A colon-style switch block is one scope. A local declared after case 1: stays in scope until the closing brace of the whole switch, so a later case 2: can use the name. Its initializer does not run when control jumps straight to case 2, so that group must assign the variable before reading it.
public class Main {
    static int cost(String size) {
        int result;
        switch (size) {
            case "S":
                int base = 5;
                result = base;
                break;
            case "L":
                base = 9;            // same scope as case "S"
                result = base * 2;
                break;
            default:
                result = 0;
        }
        return result;
    }

    public static void main(String[] args) {
        System.out.println(cost("L") + " " + cost("S")
            + " " + cost("M"));
    }
}

This prints 18 5 0. The call cost("L") jumps straight to case "L", skipping the declaration's initializer, yet base is in scope there and is assigned before it is read. Reading base in that group before assigning it fails with variable base might not have been initialized. With arrow labels (case "S" -> { … }) each case body is its own block, and the same reference in the second case fails with cannot find symbol. Loop variables (the scope of a for header variable) are covered with flow control in the loops lesson.

Shadowing: when a parameter or local hides a field

A parameter or local variable with the same name as a field shadows the field: inside that scope the simple name means the local, and the field is reachable only through this.name (instance fields) or ClassName.name (static fields). This is legal and common in constructors — and it produces one of the most frequent bugs on code-reading items, the assignment that silently writes to itself.

public class Main {
    static String mode = "auto";
    private String name = "none";

    void rename(String name) {
        name = name.trim();          // the parameter only
        System.out.println(name + "/" + this.name);
        this.name = name;            // now the field
    }

    public static void main(String[] args) {
        String mode = "manual";      // local shadows the field
        System.out.println(mode + " " + Main.mode);
        Main m = new Main();
        m.rename("  kit ");
        System.out.println(m.name);
    }
}

This prints manual auto, then kit/none, then kit. In main the local mode shadows the static field, so only Main.mode reaches the field. In rename, name = name.trim() changes only the parameter; this.name still reads the field's old value until the last line writes it. The classic trap is the constructor line x = x; where x is both a parameter and a field: it compiles, assigns the parameter to itself, and the field keeps its default (0, false or null). The fix is this.x = x;. Likewise, incrementing a parameter that shadows a static field changes only the parameter. Write ClassName.field to update the field.

  • Shadowing a field is allowed; redeclaring a local or parameter in an inner scope is not.
  • Assigning to a parameter never changes the caller's variable — Java passes the value.

Definite assignment: locals have no default value

Fields and array elements start at their default value, but a local variable has none: the compiler rejects any read of a local that is not definitely assigned on every path that reaches it, with variable x might not have been initialized. This is a compile-time check, so Java never prints a garbage value and never throws for it at runtime.

The analysis is deliberately simple. It understands the structure of statements and the value of constant expressions (literals, final variables initialized with constants, and operators applied to them), but it does not reason about the values of ordinary variables:

Code before reading nCompiles?Why
if (1 < 2) n = 1;Yes1 < 2 is a constant expression, so the skipping path does not exist
final boolean D = true; if (D) n = 1;YesA final local initialized with a constant is a constant variable
boolean on = true; if (on) n = 1;NoNot final, so on is an ordinary variable even though its value never changes
while (true) { n = 1; break; }YesThe condition is constant, so the only exit is the break after the assignment (a header-less for (;;) works the same way)
do { n = 1; } while (more);YesA do body always runs at least once
if (x > 0) n = 1; else if (x <= 0) n = 2;NoThe two conditions cover every value, but the compiler does not reason about values: the final missing else leaves a path without an assignment
for (int i = 0; i < 3; i++) n = i;NoA loop with a non-constant condition may run zero times (the same goes for while (cond))
public class Main {
    public static void main(String[] args) {
        final boolean DEBUG = true;
        int a;
        if (DEBUG) a = 1;            // constant variable
        int b;
        do { b = 2; } while (args.length > 5); // body runs once
        int c;
        while (true) { c = 3; break; }   // constant true
        System.out.println(a + b + c);
    }
}

All three locals are definitely assigned, and the program prints 6. Logical operators do not help either: a condition such as flag || !flag is always true, but because it is built from a variable it is not a constant expression. The same rule explains blank final fields (see the immutability section): a final field with no initializer gets no usable default either, and it must be assigned in an initializer or in every constructor.

Pattern variables: flow scoping and shadowing

A pattern variable such as s in o instanceof String s is in scope only where the compiler can prove the match succeeded — this is called flow scoping. It is not tied to a block: the binding can be in scope in the rest of a condition, in a then-branch, in an else-branch or even after the whole statement.

  • a && b: the binding from a is in scope in b (b runs only when a matched). With || it is not — the right side runs only when the match failed.
  • if (o instanceof String s) { … }: s is in scope in the then-branch only. After the if it is gone.
  • When the match is negated (if (!(o instanceof Integer n)) …), the binding is in scope in the else branch, and in the statements after the if when the then-branch cannot complete normally (it always ends in return, throw, break or continue): the only way past the if is a successful match. If the then-branch can fall through (it only prints, say), the binding is not in scope afterwards.
  • The same reasoning applies to a loop whose condition is a negated match and whose body has no break: the loop can only end when the match succeeds, so the binding is in scope after the loop.

A pattern variable is a local variable, so it follows the local rules: it cannot reuse the name of a local or parameter that is in scope (variable s is already defined), but it may shadow a field. Where the binding is out of scope, the same simple name refers to the field again:

public class Main {
    static int n = 100;

    static int twice(Object o) {
        if (!(o instanceof Integer n)) {
            throw new IllegalArgumentException("no");
        }
        return n * 2;                // the pattern variable
    }

    static String kind(Object o) {
        if (!(o instanceof Integer n)) {
            return "text " + n;      // the static field
        } else {
            return "number " + n;    // the pattern variable
        }
    }

    public static void main(String[] args) {
        System.out.println(twice(21));
        System.out.println(kind("hi") + ", " + kind(7));
    }
}

This prints 42, then text 100, number 7. In twice the then-branch always throws, so n after the if is the pattern variable. In kind, the then-branch is the path where the match failed, so there n means the static field; in the else branch it means the binding.

Lambda parameters versus anonymous class parameters

A lambda does not introduce a new scope for names: its parameters and the locals in its body are declared in the enclosing scope, so neither a lambda parameter nor a local declared in the lambda body can reuse the name of a local or parameter that is in scope at that point (variable x is already defined in method main(String[])). An anonymous class method is an ordinary method in a new class, so its parameters and locals may simply shadow the enclosing local.

LambdaAnonymous class method
Parameter or body local named like an enclosing localCompile errorAllowed — shadows the local
Meaning of thisThe enclosing instanceThe anonymous object
Using an enclosing localOnly if final or effectively finalOnly if final or effectively final
import java.util.function.Supplier;

public class Main {
    String id = "outer";

    void run() {
        String tag = "local";
        Supplier<String> anon = new Supplier<>() {
            String id = "anon";

            public String get() {
                String tag = "inner";   // new method: may shadow
                return tag + "/" + this.id;
            }
        };
        Supplier<String> lambda = () -> tag + "/" + this.id;
        System.out.println(anon.get() + " " + lambda.get());
    }

    public static void main(String[] args) {
        new Main().run();
    }
}

This prints inner/anon local/outer. Inside the anonymous class, the local tag shadows the enclosing one and this.id is the anonymous object's field. In the lambda, tag is the enclosing local and this is the Main instance. Declaring String tag inside the lambda body, or writing the lambda as tag -> … for a one-parameter interface, fails with variable tag is already defined in method run(). Lambdas themselves (functional interfaces, method references) are taught in the functional-programming lessons; here only the scope rule matters.

Encapsulation: private fields behind methods

Encapsulation means keeping an object's state in private fields and letting other code change it only through methods that can enforce rules (validation, invariants). The access modifier on the field is what makes the rule enforceable; a validating setter is useless if the field itself is reachable.

Modifier on a memberVisible to
privateCode inside the top-level class that contains it (including its nested classes)
(none) — package accessEvery class in the same package
protectedThe same package plus subclasses elsewhere — wider than package access
publicEveryone
  • Two classes in one source file are still two classes. A private field of Meter cannot be read from another top-level class just because they share a source file: reading has private access in Meter.
  • Access is checked per class, not per object. Code inside Ticket may read and write the private fields of any Ticket, not just this — which is how equals and compareTo are usually written.
  • A field and a method may share a name (seats and seats()); they are looked up separately.
  • Making the field final is not encapsulation: it blocks every reassignment, the setter's included. Making the setter private blocks the legitimate path and leaves the field open.
class Ticket {
    private int seats;

    Ticket(int seats) {
        this.seats = seats;
    }

    Ticket merge(Ticket other) {
        return new Ticket(seats + other.seats); // same class
    }

    int seats() {
        return seats;
    }
}

public class Main {
    public static void main(String[] args) {
        Ticket t = new Ticket(3).merge(new Ticket(4));
        System.out.println(t.seats());
    }
}

This prints 7: merge is declared inside Ticket, so other.seats is accessible even though it belongs to a different object.

Defensive copies: arrays, lists and unmodifiable views

A private field still leaks if it holds a mutable object that other code also holds a reference to. A class protects such state with defensive copies: copy a mutable argument when storing it (the input side), and copy — or hand out an unmodifiable list — when returning it (the output side). Either leak alone is enough to break encapsulation, so an immutable class needs both closed.

  • Input leak: this.days = days; keeps the caller's array, so a later week[0] = … in the caller changes the object. Fix: this.days = days.clone(); (or Arrays.copyOf).
  • Output leak: return days; hands out the internal array, so plan.days()[2] = … writes straight into it. Fix: return days.clone();.
  • final on the field, parameter, method or caller's local changes none of this: final stops reassigning a variable, never changing the contents of the object it refers to.

For lists, three APIs look alike but behave differently:

ExpressionCopies the elements?Can callers modify it?Sees later changes to the source?
new ArrayList<>(src)YesYesNo
Collections.unmodifiableList(src)No — a read-only viewNo (UnsupportedOperationException)Yes
List.copyOf(src)Yes (returns src itself if it is already an unmodifiable copy)No (UnsupportedOperationException)No
import java.util.*;

public class Main {
    public static void main(String[] args) {
        List<Integer> marks = new ArrayList<>(List.of(70, 80));
        List<Integer> view = Collections.unmodifiableList(marks);
        List<Integer> frozen = List.copyOf(marks);
        List<Integer> own = new ArrayList<>(marks);
        marks.set(0, 99);
        own.set(1, 10);
        System.out.println(view.get(0) + " " + frozen.get(0)
            + " " + own);
        try {
            frozen.set(1, 0);
        } catch (UnsupportedOperationException e) {
            System.out.println("frozen is read-only");
        }
    }
}

This prints 99 70 [70, 10] and then frozen is read-only: the view shows the change made to marks, the List.copyOf result does not, and the ArrayList copy is independent and modifiable. Only List.copyOf closes both leaks with one call: the stored list neither follows the caller's list nor accepts changes from callers. It also rejects null elements with a NullPointerException.

Creating immutable objects: classes, blank finals and records

An immutable object's observable state can never change after construction. The standard recipe is:

  1. Make every field private and final.
  2. Provide no setters or other methods that change state.
  3. Prevent subclasses from adding mutable behaviour: declare the class final (or use a record, which is implicitly final).
  4. Defensively copy mutable components in and out — final is shallow: a final List field still points at a list whose elements can change.

Blank finals. A final field without an initializer is a blank final. It must be assigned exactly once: either in an instance initializer block ({ name = …; }) or on every path through every constructor (a constructor that starts with this(…) is covered by the one it calls). A static blank final is assigned in a static { … } initializer. A constructor that leaves it unassigned fails with variable name might not have been initialized; assigning it twice in a constructor fails with variable name might already have been assigned, and assigning it in an ordinary method fails with cannot assign a value to final variable name. Unlike ordinary fields, a blank final never silently keeps null or 0.

Records. A record's fields are private final and it has no setters, but a List component would still be the caller's mutable list. The fix goes in the compact constructor: reassign the parameter (names = List.copyOf(names);), and the implicit field assignment that runs at the end of the compact constructor stores the copy. Reassigning the parameter there is legal; assigning this.names in a compact constructor is not.

import java.util.ArrayList;
import java.util.List;

final class Basket {
    private final List<String> items;

    Basket(List<String> items) {
        this.items = List.copyOf(items);
    }

    List<String> items() {
        return items;
    }
}

record Crew(List<String> names) {
    Crew {
        names = List.copyOf(names);
    }
}

public class Main {
    public static void main(String[] args) {
        List<String> src = new ArrayList<>(List.of("x"));
        Basket b = new Basket(src);
        Crew c = new Crew(src);
        src.add("y");
        System.out.println(b.items() + " " + c.names());
    }
}

This prints [x] [x]: both the class and the record copied the list at construction, so adding "y" to src changes neither, and both returned lists throw UnsupportedOperationException if a caller tries to add to them. Constructor and initializer order in general is taught in the classes-and-records lesson.

Local variable type inference with var

var declares a local variable whose type the compiler infers from its initializer at compile time; the variable is still statically typed and can never hold a value of another type later. var is a reserved type name, not a keyword: var var = 2; compiles, but no class or interface may be named var.

AllowedRejected
Local with an initializer: var n = 10;Fields, method parameters, return types ('var' is not allowed here)
Enhanced for and basic for variablesNo initializer, or initializer referring to itself (cannot infer type)
try-with-resources variablesvar x = null; and a bare array initializer var a = {1, 2};
Lambda parameters, if all of them use var: (var a, var b) -> a + bMixing var and plain names in one lambda (invalid lambda parameter declaration); a lambda or method reference as the initializer; a catch parameter
An array creation with a type, var ids = new int[] {1, 2};, or a cast null, var none = (Integer) null;Compound declarations var a = 1, b = 2; and var[] a

The inferred type is the initializer's exact static type, which has three consequences that code items exploit:

  • No target type for the diamond. new ArrayList<>() normally takes its type argument from the declared type; with var there is none, so new HashMap<>() gives a HashMap<Object, Object> and new ArrayList<>() an ArrayList<Object>. Putting mixed keys and values compiles, but reading a value into a String variable without a cast fails (Object cannot be converted to String).
  • Literals keep their type. var a = 1; is an int, so a later a = 3000000000L; fails with a lossy-conversion error.
  • Non-denotable types are kept. For an anonymous class, var infers the anonymous class itself, so members it adds are reachable — an explicit Object declaration would hide them.
import java.util.HashMap;

public class Main {
    public static void main(String[] args) {
        var map = new HashMap<>();      // HashMap<Object, Object>
        map.put("k", 1);
        map.put(2, "v");
        Object hit = map.get("k");

        var helper = new Object() {
            String shout(String s) {
                return s.toUpperCase() + "!";
            }
        };

        var var = "ok";                 // var is not a keyword
        System.out.println(hit + " " + map.size() + " "
            + helper.shout("hi") + " " + var);
    }
}

This prints 1 2 HI! ok. helper has the anonymous class type, so its extra method shout can be called; declared as Object helper it could not.

Unnamed variables: declaring with _

An unnamed variable, written _ (standard since Java 22), is a declaration whose value you are required to bind but never use. It has no name: _ never enters the scope, so it can be declared any number of times in one scope, and it can never be read — _ is not an expression (underscore not allowed here).

Allowed as _Not allowed as _
Local variable with an initializer (String _ = step("A");)Fields (static or instance)
Enhanced for variable and basic for header variablesMethod and constructor parameters
try-with-resources variableA local without an initializer (int _;)
catch parameter, lambda parameterAny read: System.out.println(_)
Patterns: case String _, P(int x, _)

Fields and method parameters are excluded because they are part of a class's API (other code can see them); every allowed place is local. An unnamed declaration still does all its work: its initializer is evaluated, a loop still iterates, a resource is still closed. Before Java 22, _ had been reserved since Java 9 and using it as an identifier was a compile error — which is why older material says it fails.

import java.io.StringReader;
import java.util.List;
import java.util.function.BinaryOperator;

public class Main {
    static StringBuilder log = new StringBuilder();

    static String step(String s) {
        log.append(s);
        return s;
    }

    public static void main(String[] args) throws Exception {
        String _ = step("A");
        String _ = step("B");                  // no clash
        for (var _ : List.of(1, 2, 3)) {
            step("c");
        }
        try (var _ = new StringReader(step("R"))) {
            step("t");
        }
        BinaryOperator<Integer> pick = (_, b) -> b;
        System.out.println(log + " " + pick.apply(1, 4));
    }
}

This prints ABcccRt 4: both unnamed locals ran their initializers, the loop ran three times, and the resource's initializer and the block both ran. The same holds everywhere else: an unnamed catch parameter still catches, and in a basic for header such as for (int i = 0, _ = setUp(); …) the unnamed variable's initializer runs once, before the first iteration. Unnamed patterns inside switch and record patterns are covered with pattern matching in the flow-control lesson.

Tip. Objective 3.4 is examined through short, complete programs. Scope items ask whether code compiles when a name is reused in a nested block, a switch group, a pattern-matching if or a lambda, or what is printed when a parameter, local or pattern variable shadows a field. Definite-assignment items offer several fragments and ask which compile. Encapsulation items ask which access change protects a validated field, or whether a class may read another instance's private field. Immutability items show a class or record holding an array or list and ask what a caller's later change does to it, or which change (or pair of changes) makes it immutable — with final, unmodifiable views and copies as the near-twins. var and _ items list declarations and ask which compile, or rely on the type var infers and on the side effects of unnamed declarations.

Key takeaways
  • A local is in scope from its declaration to the end of its block; it cannot be redeclared in an inner block while still in scope, but a field can be shadowed.
  • A colon-style switch block is one scope, so a local declared under one case is visible (but not initialized) under later cases; arrow cases each have their own block.
  • Locals have no default value: the compiler rejects reads that are not definitely assigned, and it only trusts constant expressions and statement structure.
  • Pattern variables are flow-scoped: in scope wherever the match is known to have succeeded, including after an if whose failure branch cannot complete normally.
  • Lambda parameters live in the enclosing scope and cannot reuse a local's name; an anonymous class method's parameter can shadow it.
  • private is checked per class: code in a class can read the private fields of any instance of that class, but not those of another class in the same file.
  • final is shallow — copy mutable inputs and outputs; Collections.unmodifiableList is a view of the source, List.copyOf is an unmodifiable copy.
  • Blank final fields must be assigned exactly once, in an initializer or in every constructor; in a record, reassign the compact constructor’s parameter to store a copy.
  • var infers the initializer's exact static type — ArrayList<Object> from an empty diamond, the anonymous class itself — and is rejected for fields, parameters, null, bare array initializers and compound declarations.
  • _ declares an unnamed local, for, resource, catch, lambda or pattern variable; it can repeat in one scope, can never be read, and its initializer still runs.

Frequently asked questions

Can an inner block in Java redeclare a local variable from the outer block?

No. While a local variable or parameter is still in scope, an inner block cannot declare another variable with the same name; javac reports that the variable is already defined in the method. A name can be reused only after the earlier variable's block has ended, and local variables may shadow fields but never other locals.

Why does Java say a variable might not have been initialized?

Local variables have no default value, so the compiler requires every path to a read of a local to assign it first. The analysis follows the structure of the code and constant expressions such as if (true), but not the run-time values of ordinary variables, so if (count > 0) n = 1; followed by a read of n is rejected even when count is always positive, while if (1 < 2) n = 1; is accepted.

Is a final field enough to make a Java object immutable?

No. final only stops the field from being reassigned; if the field refers to a mutable array or list, its contents can still change. An immutable class also needs private fields, no mutating methods, a final class (or a record) and defensive copies of mutable inputs and outputs, for example List.copyOf in the constructor.

What is the difference between Collections.unmodifiableList and List.copyOf?

Collections.unmodifiableList returns a read-only view of the original list: callers cannot modify it through the view, but changes made to the original list show through. List.copyOf returns a new unmodifiable list holding the elements present at the time of the call, so later changes to the source do not affect it. Both throw UnsupportedOperationException on modification attempts.

Where can var be used in Java?

var can be used only for local variables that have an initializer, enhanced and basic for loop variables, try-with-resources variables, and lambda parameters when every parameter of the lambda uses var. It cannot be used for fields, method parameters or return types, with a null or bare array initializer, or in a compound declaration such as var a = 1, b = 2.

What type does var infer for new ArrayList<>()?

It infers ArrayList<Object>. The diamond normally takes its type argument from the declared target type, and with var there is no target, so Object is used. The list accepts any element, but get returns Object, so assigning an element to a String variable without a cast does not compile.

What are unnamed variables in Java?

An unnamed variable is a declaration written with an underscore, standard since Java 22, used when a variable must be declared but its value is never needed — for example a catch parameter, a lambda parameter, a loop variable or a local with an initializer. It is never in scope, so it can be declared many times and cannot be read, and fields and method parameters cannot be unnamed.

Can a pattern variable have the same name as a field?

Yes. A pattern variable such as s in o instanceof String s is a local variable, so it may shadow a field of the same name; where the pattern variable is in scope the name means the binding, and elsewhere it means the field. It cannot reuse the name of a local variable or parameter that is already in scope.

Source

This lesson covers the "Applying Object-Oriented Principles in Java Programs" domain of the official 1Z0-831 exam guide. Vendors revise their guides — check the source for the current version.

Sign up free to mark lessons complete, bookmark topics and track your exam readiness.

Spotted a mistake in this lesson?