Home
Java from First Principles / Chapter 3 — Variables, Primitives, and the Memory Model

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:

Java
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:

Java
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).

Java
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**.

Stack versus heap memory in Java Stack One per thread · frames pushed/popped per method · fast · small main() frame int age = 30; 30 String name User u greet() frame int local = 5; 5 primitives stored directly object variables hold a reference (•) Heap Shared across threads · all objects live here · GC reclaims · larger, slower String "Alice" 0xA1B2 User name → "Alice" age = 30 email = "a@x.io" 0xC3D4
Primitives sit in the stack frame as raw values. Object variables only hold a reference — a memory address pointing into the heap where the actual object lives.

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.

Java
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):

Java
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:

Java
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.**

Java
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:

Java
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.**

Java
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.

Java
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.

Java
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.

Java
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:

Java
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:

Java
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