JAVA / VARIABLES, PRIMITIVES AND TYPES
Null, references and what a variable really holds
Explain what a Java variable slot actually stores, predict when two names share one object, and trace a NullPointerException to the reference that held null.
What you will learn
- Tell from a type whether a variable's slot holds a value or a reference to an object
- Predict when two variables name one object so a change through either is visible
- Read a NullPointerException as: code followed a reference that held null
- Know that null fits only in reference slots, never in an int, double or boolean
Understanding Null, references and what a variable really holds
A variable in Java is a named slot of fixed size, and what fits in that slot is decided entirely by the declared type. If the type is one of the eight primitives, the slot holds the value itself: int count = 7 puts the number seven in the slot. If the type is anything else, such as String, an array, ArrayList or a class you wrote, the slot holds a reference: a handle the JVM uses to reach an object that lives elsewhere in memory. new Box() is what creates the object; the variable only remembers how to get to it.
Because a slot holds either a value or a handle, assignment copies the contents of the slot and nothing else. Copying an int produces two independent numbers, so changing one leaves the other untouched. Copying a reference produces two names for one object, so a change made through either name is visible through both. That is also why == on reference types asks whether this is the very same object rather than whether the contents match, and why equals exists as a separate question.
null is a value a reference slot can hold, and it means the slot names no object at all. It is not zero, not an empty String, and not an object with blank fields, so there is nothing on the far side to reach: a field access, method call, array index or .length on a null reference throws NullPointerException the moment that line runs. Primitive slots can never hold null because they always contain a real value, which is why int x = null; is rejected by the compiler instead of failing later at runtime.
public class WhatAVariableHolds {
static class Box {
String label = "empty";
}
public static void main(String[] args) {
int count = 7;
int copy = count;
copy = 99;
System.out.println("count=" + count + ", copy=" + copy);
Box first = new Box();
Box second = first;
second.label = "filled";
System.out.println("first.label=" + first.label + ", second.label=" + second.label);
System.out.println("first == second: " + (first == second));
Box missing = null;
System.out.println("missing=" + missing);
try {
System.out.println(missing.label);
} catch (NullPointerException e) {
System.out.println("NPE: there is no object at the end of a null reference");
}
}
}Every variable stores a value: for a primitive that value is the data itself, for every other type it is a reference to an object, and null is the reference that leads to no object.
Worked examples
A method gets the handle, not the variable
Shows that a method can change the object a reference points at but cannot repoint the caller's variable.
import java.util.ArrayList;
import java.util.List;
public class PassingReferences {
static void addItem(List<String> target) {
target.add("added");
}
static void replace(List<String> target) {
target = new ArrayList<>();
target.add("ignored");
}
public static void main(String[] args) {
List<String> items = new ArrayList<>();
addItem(items);
replace(items);
System.out.println(items);
System.out.println("size: " + items.size());
}
}Example explained
Line 1addItem(items) copies the handle out of items into the parameter target, so both name the same ArrayList and target.add mutates the object the caller can see.
Line 2Inside replace, target = new ArrayList<>() overwrites only that parameter's own slot, so the caller's items slot still holds the original handle.
Line 3The word ignored is added to an object nobody else references, which is why the printed list is [added] and its size is 1.
Default contents of a slot
Shows that uninitialised fields get zeroed slots, which means 0 for a primitive and null for a reference.
public class Defaults {
static int number;
static String text;
static boolean flag;
public static void main(String[] args) {
System.out.println("number: " + number);
System.out.println("text: " + text);
System.out.println("flag: " + flag);
System.out.println("text is null: " + (text == null));
// int broken = null; // compile error: null is not a value an int slot can hold
}
}Example explained
Line 1number prints 0 because a primitive slot must always contain a real value and the default bit pattern for int is zero.
Line 2text prints null because its slot is a reference slot, and a zeroed reference slot means it points at no object.
Line 3The four characters null appear because string concatenation converts a null reference to the text null instead of calling toString on it.
Line 4The commented line cannot compile: no conversion exists from null into an int slot, so the compiler stops it rather than letting it fail at runtime.
Important notes
Integer boxed = null; int n = boxed; compiles but throws NullPointerException, because the conversion is really boxed.intValue() and a null wrapper is not 0.
From Java 14 onwards the exception message names the exact expression that was null, such as Cannot read field "label" because "missing" is null, so read it instead of guessing which part of a chain failed.
Common mistakes
Thinking new String[3] fills the array with empty Strings: the array object exists but all three slots hold null, so names[0].length() throws NullPointerException on the first element.
Comparing text with ==: it prints true for two literals the compiler shares, then prints false for the same characters built at runtime, so the bug only shows up once real input arrives.
Assuming a freshly constructed object's reference fields are usable: an unassigned String name field is null, so config.name.trim() throws instead of returning an empty String.
Try it yourself
Change, predict, then run
In a browser editor, declare int[] counts = new int[2] and String[] labels = new String[2], print counts[0] and labels[0], and explain to yourself why one prints 0 and the other prints null. Then call labels[0].length() inside a try/catch and read the exception message to see which expression was null.
Open the Java workspaceCheck your understanding
After StringBuilder a = new StringBuilder("hi"); StringBuilder b = a; a = null; what is true?
- Both a and b are null, because they were pointing at the same object.
- b now holds an empty StringBuilder, since the object it pointed at was cleared.
- b still refers to the StringBuilder containing hi; only a's own slot was overwritten with null.
- The line a = null does not compile, because a already refers to an object.
Show answer
Assigning null writes into a's slot and nowhere else. On the second line b received its own copy of the handle, and copies are not linked to each other, so b.toString() still returns hi. The first option is tempting because both names really did refer to one object, but a variable points at an object, not at another variable, so nulling one name cannot reach through and change the other.