Variables, Primitives, and the Memory Model
Stack vs heap. Primitives vs references. Autoboxing traps. The memory model that shapes every line of Java you'll write.
The variable you think you're holding
Here's a Java exercise. Predict the output:
int a = 5;
int b = a;
b = 10;
System.out.println(a); // ?
Easy: 5. The integer 5 was copied into b. Changing b doesn't affect a.
Now try this:
List<Integer> a = new ArrayList<>(List.of(1, 2, 3));
List<Integer> b = a;
b.add(99);
System.out.println(a); // ?
This prints [1, 2, 3, 99]. Adding to b changed a. Both variables point to the *same* list.
Same statement, different behaviour. The difference is that int is a **primitive** and List is an **object**. Java treats them in fundamentally different ways at the memory level. Understanding this distinction — and the stack-vs-heap model that backs it — is the foundation for almost everything else: parameter passing, equality, equality of collections, threading, cloning, memory leaks. Get it wrong and you'll fight Java forever.
Primitives vs references
Java has exactly **eight primitive types**, baked into the language:
| Type | Size | Range / values |
|---|---|---|
| boolean | 1 bit (JVM-dependent) | true / false |
| byte | 8 bits | -128 to 127 |
| short | 16 bits | -32,768 to 32,767 |
| int | 32 bits | ~±2.1 billion |
| long | 64 bits | ~±9.2 quintillion |
| float | 32 bits | IEEE 754 single-precision |
| double | 64 bits | IEEE 754 double-precision |
| char | 16 bits | Unicode character (UTF-16 code unit) |
Primitives store their *value* directly. int x = 5 puts the literal bits 0000...0101 into a memory slot named x. When you assign one primitive to another, the value is copied.
Everything else in Java — every class, including the Java standard library types like String, Integer, ArrayList, HashMap — is an **object**. Objects live on the heap. Object variables don't hold the object itself; they hold a **reference** to it (a memory address pointing into the heap).
int x = 5; // x holds the value 5
String s = "hello"; // s holds a reference to a String object
List<Integer> list = ...; // list holds a reference to a List object
When you assign one reference variable to another, you're copying the *address*, not the object. Both variables now point to the same object. Mutate the object through either variable and the other sees the change. That's what the second example at the top of the chapter showed.
This split — primitives are values, objects are references — explains a huge fraction of Java's behaviour.
Stack vs heap, properly understood
Java's memory has two main areas you care about as a developer: the **stack** and the **heap**.
The **stack** is per-thread. When a method is called, a new "stack frame" is pushed onto that thread's stack. The frame contains the method's local variables, parameters, and bookkeeping. When the method returns, its frame is popped (discarded entirely).
Stack frames hold:
- **Primitive values** of local variables (the actual bits).
- **References** of object-typed local variables (just the address pointing into the heap).
The stack is fast — push/pop is essentially a single CPU instruction. It's also limited in size (typically ~512KB-1MB per thread). That's why infinite recursion gets a StackOverflowError.
The **heap** is shared across all threads in the JVM. Every object you create with new lives here, regardless of where it was created. Objects on the heap are managed by the garbage collector — they live as long as something references them, and get reclaimed when nothing does.
Look at the diagram. The local variable int age = 30 lives on the stack as raw bits. The local variable User u lives on the stack as just a pointer — the actual User object with all its fields lives on the heap. When greet() is called, a new frame is pushed; when it returns, the frame disappears and any primitives local to greet() are gone. Object references in the frame are gone too — but the objects themselves stick around if anything else still references them.
Pass by value — even for objects
A confusing point that even seasoned Java developers sometimes get wrong: **Java is always pass-by-value**. Always. Even when you pass an object.
What's being passed by value is the *reference*. The reference is copied — both the caller and the callee end up with references pointing to the same object, but they're two separate references. Reassigning the parameter inside the method doesn't affect the caller's variable.
public static void reassign(User u) {
u = new User("Bob"); // local copy of reference, doesn't affect caller
}
public static void mutate(User u) {
u.setName("Bob"); // mutates the object both refs point to
}
User alice = new User("Alice");
reassign(alice);
System.out.println(alice.getName()); // "Alice" — caller's reference unchanged
mutate(alice);
System.out.println(alice.getName()); // "Bob" — object was mutated
So:
- **Reassigning a parameter** inside a method has no effect outside. (The caller's reference is unaffected.)
- **Mutating the object through a parameter** is visible outside. (Both references point to the same object.)
This is sometimes called "pass-by-reference-value" or "pass references by value" to be precise. But the simple answer is: Java passes by value, always. Some other languages (like C++) have true pass-by-reference where the parameter is an alias to the caller's variable. Java doesn't.
Wrapper classes: when primitives become objects
For each primitive type, Java has a corresponding **wrapper class** in java.lang:
| Primitive | Wrapper |
|---|---|
| boolean | Boolean |
| byte | Byte |
| short | Short |
| int | Integer |
| long | Long |
| float | Float |
| double | Double |
| char | Character |
Wrappers are full objects. They live on the heap. They have methods. They can be null. They can be stored in collections (which can't hold raw primitives because of how Java generics work — List<int> doesn't compile, only List<Integer>).
You'd think you have to manually convert between primitives and wrappers all the time. You don't. Java does it for you via **autoboxing** (primitive → wrapper) and **unboxing** (wrapper → primitive):
Integer wrapped = 42; // autoboxing: int → Integer
int unwrapped = wrapped; // unboxing: Integer → int
List<Integer> nums = new ArrayList<>();
nums.add(7); // autoboxing: int 7 → Integer 7
int first = nums.get(0); // unboxing: Integer → int
This is convenient. It's also one of Java's richest sources of subtle bugs.
Autoboxing traps that bite
**Trap 1: NullPointerException on unboxing.** A wrapper variable can be null. Unboxing a null wrapper throws NPE:
Integer x = null;
int y = x; // NullPointerException at runtime
The int y = x looks innocuous. The actual generated bytecode calls x.intValue() on null, which crashes. This is one of the most common production NPEs in Java.
**Trap 2: == on wrappers compares references, not values.**
Integer a = 127;
Integer b = 127;
System.out.println(a == b); // true
Integer c = 128;
Integer d = 128;
System.out.println(c == d); // false (!!)
For values in -128 to 127, Java keeps a cache of pre-allocated Integer objects (the **Integer cache**). Autoboxing the same int twice returns the same cached object. Outside that range, autoboxing creates a fresh Integer each time. So == happens to work for small numbers and silently breaks for larger ones.
**The fix: always use .equals() on wrappers, never ==.** Or unbox to primitive before comparing.
**Trap 3: performance in tight loops.** Every autoboxing creates a wrapper object on the heap. In a loop that runs millions of times:
Long sum = 0L; // wrapper
for (long i = 0; i < 1_000_000; i++) {
sum += i; // unbox, add, autobox → creates a new Long each iteration
}
This is dramatically slower than using a primitive long sum. The fix: use primitives in hot loops; only use wrappers when you need null or collections.
**Trap 4: silent unboxing in conditional expressions.**
Map<String, Boolean> flags = new HashMap<>();
if (flags.get("enabled")) { // NPE if key is missing — get() returns null
...
}
flags.get("enabled") returns Boolean. The if statement unboxes it to boolean. If the key isn't present, the wrapper is null and unboxing throws NPE. Use Boolean.TRUE.equals(flags.get(...)) to be null-safe.
Variable scope and lifetime
A variable's **scope** is the region of code where the name is visible. Its **lifetime** is how long the underlying storage lives. They overlap but aren't the same.
**Local variables** — declared inside a method or block. Scope: from declaration to the end of the enclosing block. Lifetime: the duration of the method invocation. Live on the stack.
**Method parameters** — like local variables, scoped to the method body.
**Instance fields** (non-static fields of a class) — declared at class level. Scope: anywhere within the class (subject to access modifiers). Lifetime: as long as the enclosing object lives. Live on the heap as part of the object.
**Static fields** — declared with static. Scope: anywhere within the class. Lifetime: as long as the class is loaded (typically the entire JVM lifetime). Live in the method area / Metaspace, NOT the heap.
public class User {
static int totalUsers = 0; // class-level, lives for JVM lifetime
private String name; // instance-level, lives with each User
public void greet() {
String prefix = "Hello, "; // local, lives only during greet() call
System.out.println(prefix + name);
}
}
A key rule: **a variable must be definitely assigned before use**. Local variables don't get default values. Instance and static fields do — int defaults to 0, references default to null, boolean to false.
public class Counter {
int count; // automatically initialised to 0
void demo() {
int local;
// System.out.println(local); // won't compile — not assigned
local = 5;
System.out.println(local); // fine
}
}
The compiler catches use-before-assignment of local variables. This is a safety win — many languages let you read garbage values from uninitialised stack slots; Java forces you to initialise.
final, effectively final, and immutability
The final keyword can be applied to variables to prevent reassignment after the first assignment.
final int x = 5;
x = 10; // compile error: cannot assign to final variable
final List<Integer> nums = new ArrayList<>();
nums.add(1); // FINE — modifying the object is not reassigning the variable
nums = new ArrayList<>(); // compile error: reassigning the variable
Note carefully: final on a reference variable means the *reference* can't change. It does NOT mean the object is immutable. You can still mutate what the final reference points to.
To make an object truly immutable, design the class itself to disallow mutation (no setters, no mutable fields exposed). String is famously immutable — once created, the characters can't change. List.copyOf(...) and Map.copyOf(...) produce immutable collections (since Java 10).
A related concept: **effectively final**. A variable that isn't declared final but is never reassigned after its first assignment is treated as effectively final. This matters for lambdas and inner classes, which can capture local variables only if they're final or effectively final:
int counter = 0;
Runnable r = () -> System.out.println(counter); // counter is effectively final — OK
counter = 5;
Runnable r2 = () -> System.out.println(counter); // compile error — counter is no longer effectively final
Modern Java code uses final liberally on local variables. It costs nothing, documents intent, and prevents accidental reassignment bugs. Some teams require it via linting; others find it noisy. Either choice is defensible.
var: type inference for locals
Since Java 10, you can use var for local variables and Java infers the type from the right-hand side:
var name = "Alice"; // inferred as String
var nums = new ArrayList<Integer>(); // inferred as ArrayList<Integer>
var count = 5; // inferred as int
for (var entry : map.entrySet()) { // inferred as Map.Entry<K, V>
...
}
var doesn't make Java dynamically typed. The type is still checked at compile time — it's just inferred rather than written explicitly. The compiled bytecode is identical to writing the type explicitly.
**When var helps:**
- The type is obvious from the right-hand side and writing it adds no information.
- The type is long or generic-heavy (Map<String, List<UserSession>> → just var).
- You want to focus on the variable name and what it does, not its declaration.
**When var hurts:**
- The right-hand side isn't a constructor call — the reader has to figure out what var x = someMethod() returns.
- The variable is widely used through the method and not seeing its type makes the code harder to follow.
Limitations: var works only for local variables and for loops. Not for fields, parameters, return types, or null initialisers. You can't do var x = null — there's no type to infer.
A pragmatic rule: use var for obviously-typed locals; spell out the type when it adds clarity.
⁂ Back to all modules