Classes And Objects
References, Equality & the Object Model
Every variable is either a primitive that holds its value or a reference that holds the address of a heap object. That split governs assignment, aliasing, equality and null. == compares what the variable holds (a value or an address), while .equals compares meaning. The Integer cache and String pool make == seem to work, and dereferencing null throws. The flagship lesson, with a stack/heap picture and every trap shown live.
Suggest an editReferences, Equality & the Object Model
The last several lessons have been building toward this one. Every variable in Java is one of exactly two things:
- a primitive, which holds its value directly;
- a reference, which points at an object that lives in a memory area called the heap.
That single distinction explains nearly every Java surprise you've met:
- assigning an array shares it, but assigning an
intcopies it; ==sometimes lies;- a method can change your object but not your variable;
nullis the most common crash in the language.
Get this picture right, and the rules stop feeling arbitrary. They become consequences.
💡 The core idea.
- Every variable is either a primitive that holds its value or a reference that holds a heap object's address.
- That split governs assignment, aliasing, equality, and
null. - Get the picture right and the rules stop being arbitrary — they become consequences.
This is the deep pass of primitives, == vs .equals on strings, pass-by-value, and aliasing. Every output below was produced by compiling and running the code.
You'll be able to: predict what = copies for a primitive and for a reference, and draw which variables share one object; predict == and .equals for arrays, Integers and Strings, and pick the comparison that means "same contents"; predict which line throws NullPointerException, including a null Integer stored into an int; name the moment an object becomes unreachable, and what the garbage collector may then do.
📘 How to read the Intuition boxes. Each one is built in three moves:
- The mechanism — what the compiler and the JVM do.
- A concrete bite — a specific, runnable failure (often a real compiler error), shown so the trap is visible.
- The earned rule — the decision heuristic, now justified rather than asserted, plus its cost.
Table of contents
- Two kinds of variable: values and references
==compares what the variable holds.equalsand the Integer cache trap- The String pool
nullandNullPointerException- When an object becomes unreachable
- Mental-model summary
- Gotcha checklist
- Check yourself
- Sources
1. Two kinds of variable: values and references
A primitive variable is its value: the bits sit right in the variable. A reference variable holds a pointer to an object stored elsewhere [1], and the object itself isn't in the variable.
So where do they live? Each running method has a frame on its thread's stack, which holds its local variables [2]. Every object and array is allocated on the heap, one area shared by the whole program [3]. This lesson calls the pointer an address. The JVM doesn't pin down how it's stored [4], but the picture holds.
Assignment copies whatever the variable holds — and that's the whole story:
Output:
5 99
99 99Analysis. int y = x copied the value 5 into y, so changing y to 99 left x at 5. These are two independent values.
int[] b = a copied the reference. a and b now hold the same address and point at the one array, so b[0] = 99 changed the array they both see. Hence 99 99.
The diagram is the model. These local variables sit on the stack. The primitives hold values, and the references hold addresses that point into the heap. Two references can point at the same object. (A primitive field is different: it sits inside its object, on the heap.)
Intuition.
Mechanism. A variable stores a fixed-size slot of bits. For a primitive, those bits are the value. For a reference type (arrays, String, every class), they are the address of a heap object. = copies the slot (the value, or the address), never the heap object itself.
Concrete bite. The two output lines show the contrast. Copy a primitive, and the copies are independent (5 99). Copy a reference, and both names share one object (99 99). The same fact explains pass-by-value of references: a method gets a copy of the reference, so it can change the shared object.
💡 Earned rule. Always ask "is this a value or a reference?". The answer tells you whether = (or a method call) makes an independent copy or shares an object.
References cost you the sharing surprise. The payoff is efficiency: passing a million-element array copies one address, not a million ints. Every trap in the rest of this lesson follows from this one distinction.
2. == compares what the variable holds
== looks at the bits in the two variables. For primitives, those bits are values, so == compares values. For references, those bits are addresses, so == asks "same object?". That's identity, not contents [5].
Output:
false
true
trueAnalysis.
a == bisfalse, even though both arrays hold{1, 2, 3}. They're different objects at different addresses, and==compares addresses.a == cistruebecausecwas assigneda, so they share one address.a[0] == b[0]istruebecause those areints, and==on primitives compares values.
Same operator, two meanings, and the operands decide which: references or primitives.
Intuition.
Mechanism. == always asks "are the stored bits equal?". A primitive's bits are its value, and a reference's bits are an address. So == is value equality for primitives and identity (same object) for references. There's no third behavior.
== between types that can never be the same object doesn't compile: Integer == String gives incomparable types: Integer and String.
Concrete bite. The trap is a == b coming out false for two identical-looking arrays. On references, == can't see contents, only identity.
Non-example: .equals on arrays. The usual advice is "compare contents with .equals". For arrays, that advice fails:
Output:
false
trueAn array's .equals is the one every object starts with, inherited from the class Object. It returns true only when both sides are the same object [6]. Your own classes start out the same way: two new Box(7) objects aren't .equals until the class defines what "equal" means. equals & hashCode shows how. For arrays, compare contents with Arrays.equals.
💡 Earned rule. Use == for primitives and for deliberate "same object?" checks. Never use it to compare the contents of two objects. For contents, use .equals on classes that define it (String, Integer), and Arrays.equals on arrays.
Forget, and you get a comparison that's false for objects that mean the same thing, or, as the next sections show, true by accident.
3. .equals and the Integer cache trap
Every primitive type has a matching wrapper class: an object that holds one value. Integer wraps an int, Double a double, Boolean a boolean. Java converts between them automatically:
- boxing turns an
intinto anInteger(Integer a = 127;) [7]; - unboxing turns an
Integerback into anint[8].
.equals is a method that an object defines to compare by meaning. For Integer, it compares the wrapped numbers. The danger is that boxing small numbers reuses cached objects, so == accidentally agrees with .equals — until it doesn't.
Output:
true
false
true
trueAnalysis.
Integer a = 127boxes through a cache ofIntegerobjects for −128..127, soaandbare the same cached object, anda == bistrue.128is outside that cache. Herecanddboxed to two distinct objects, soc == disfalse, whilec.equals(d), which compares the wrapped values, istrue.- The primitives
eandfhold128directly, soe == fcompares values and givestrue.
The Integer == flipped from true to false between 127 and 128.
Intuition.
Mechanism. Integer is a reference type, so == compares object identity. Boxing a value in −128..127 always gives the same object [7] [9]. Beyond that range, the JVM may reuse objects, but by default it doesn't [9]. So == is coincidentally true for small values, and false for larger ones on this JVM.
Mix an Integer with an int, and == unboxes the Integer and compares numbers [10]: Integer c = 128; int e = 128; c == e is true.
Concrete bite. A test that passes with 127 and fails with 128 is the telltale sign of code that used == for object equality. The cache hides the bug in small-number tests and lets it loose in production.
💡 Earned rule. Compare wrapper objects with .equals, and save == for primitives and identity.
The cost is vigilance. Boxing hides the boundary, so a passing small-value == test proves nothing. For objects, .equals is the comparison that means "same value". The String pool, coming next, works the same way.
4. The String pool
String is a reference type too, so == on strings means identity. But Java interns string literals, so identical literals share one pooled object [11]. That makes == look right for literals and wrong for everything else.
Output:
true
false
trueAnalysis. a and b are the same literal "hello", which the pool stores once. So a == b is true, by interning, not by content comparison. new String("hello") forces a separate object, so a == c is false, even though a.equals(c) (comparing characters) is true. That's the mechanism behind the Strings teaser: == on strings is identity, and the pool makes literals share it.
Intuition.
Mechanism. Literals, and more generally the values of compile-time constant expressions, are interned into a shared pool, so equal ones are the same object [11]. A string built at run time is a fresh object: new String(…), user input, or + with a variable.
Concrete bite. A string computed from a variable misses the pool, even when its characters match a literal:
Output:
false
truecomputed spells abc, but it was built at run time from the variable part, so it's a new object. A program that compares typed-in or computed strings with == rejects correct matches.
💡 Earned rule. Compare string contents with .equals (or .equalsIgnoreCase), never with ==.
The pool's cost is that == passes in quick literal tests and fails on real strings. It's the Integer cache's trap in a different form, with the same lesson: == is identity, and .equals is meaning.
5. null and NullPointerException
A reference can point at no object: the value null [1]. You can legally hold it and print it. But the moment you try to use the object it points to (call a method, read a field), there's no object, and the JVM throws NullPointerException.
Output (prints null, then throws):
null
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "String.length()" because "<local1>" is nullAnalysis. System.out.println(s) printed null, because println handles a null reference by writing the text "null". But s.length() tried to follow the reference to call a method, and there was no object to call it on. So the JVM threw NullPointerException.
Since Java 14, the message names what was null and what you tried to do [12]. The variable shows as <local1> because the Run button compiles without local-variable names. Compile with javac -g, and the message says because "s" is null.
Intuition.
Mechanism. null is the absence of an object: a reference holding no address. Reading or printing the reference is fine. Dereferencing it (.method(), .field) needs an object that isn't there, and that's the NullPointerException.
Concrete bite. The null line works, and the s.length() line throws. The boundary is use. Fields of a reference type start as null, and so do the slots of a new object array and many "not found" results. So any object you didn't definitely set could be null, and the first method call on it crashes.
Non-example: an NPE with no dot in sight. Unboxing is a hidden method call. Store a null Integer into an int, and the JVM must call intValue() on nothing [8]:
Output (prints before, then a thrown exception):
before
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "java.lang.Integer.intValue()" because "<local1>" is nullLine 5, int n = count;, has no . in it, yet the message names Integer.intValue(). When an NPE points at a line with no method call, look for an Integer, Double or Boolean that's being used as a number or a boolean.
Comparing with .equals has the same sharp edge. "hi".equals(s) returns false when s is null, but s.equals("hi") throws. java.util.Objects.equals(a, b) is safe on either side [13].
💡 Earned rule. Guard a reference before dereferencing when it might be null: if (s != null && s.length() > 0). The short-circuit && skips the right side when s is null.
null costs you constant vigilance, and it's the most common crash in Java. The typed alternative, which makes "might be absent" visible in the type, is Optional, covered in Modern Java Idioms.
6. When an object becomes unreachable
Java has no statement that deletes an object. Objects are "never explicitly deallocated" [3]. The garbage collector reclaims the heap memory of objects the program can no longer reach.
An object is reachable as long as some running code can still get to it through a chain of references [14]. It becomes unreachable once the last such reference is gone:
- the variable is pointed at another object (
a = new Counter();); - the variable is set to
null; - the method holding the only local reference returns.
Output:
0 1Analysis. a = new Counter() pointed a at a second object, with count 0. The first object survived, because keep still refers to it (count 1). Delete the line Counter keep = a;, and after the reassignment nothing refers to the first counter. It becomes unreachable, and the program can never read it again.
Intuition. Mechanism. Reachability is about references, not about variables going quiet. As long as one live reference leads to an object, the object stays. When none does, the garbage collector may reclaim it. The specification sets no moment for that, and a small program may end before any collection runs [3].
Concrete bite. The reverse is the real-world bug. An object stays alive as long as anything refers to it, such as a forgotten entry in a long-lived static list. That object is never collected, however useless it has become. It's a memory leak, in a language with a garbage collector.
💡 Earned rule. Let objects go by letting references go. Keep variables in the smallest scope, and remove entries from long-lived collections when you're done with them. Never rely on when an object is collected.
Automatic memory management costs you control over the timing. The payoff is that there's no "use after delete" bug: an object you can still reach is never reclaimed.
7. Mental-model summary
| Principle | Consequence |
|---|---|
| A variable holds a value (primitive) or an address (reference) | = copies the value or the address, never the heap object |
| Locals live in stack frames; objects and arrays live on the heap | Two references can point at one heap object |
== compares the stored bits |
Value equality for primitives; identity (same object) for references |
.equals compares meaning, where the class defines it |
Use it for String and wrappers; arrays and new classes start with identity — use Arrays.equals |
| Boxing wraps a primitive in an object; unboxing unwraps it | Integer == int compares numbers; a null Integer unboxed throws |
| The Integer cache (−128..127) and the String pool share objects | == is accidentally true for small Integers and pooled literals, false otherwise |
null is a reference to no object; dereferencing it throws |
Printing null is fine; null.method() is a NullPointerException |
| An object with no live reference to it is unreachable | The garbage collector may reclaim it, at no fixed time; a lingering reference keeps it alive |
8. Gotcha checklist
| Symptom | Likely cause | Fix |
|---|---|---|
Two objects with equal contents compare false with == |
== is identity on references |
use .equals for contents |
a.equals(b) is false for two arrays with the same elements |
arrays keep Object's identity .equals |
Arrays.equals(a, b) |
.equals is false for two objects of your own class |
the class does not define .equals |
define it (equals & hashCode) |
An Integer or String == test passes for small values and fails for others |
the Integer cache or the String pool | switch to .equals |
incomparable types: Integer and String |
== between types that can never be the same object |
compare values of one type |
NullPointerException: Cannot invoke "…" because "…" is null |
a method called on a null reference |
guard with x != null &&, or make sure it is set |
Cannot invoke "java.lang.Integer.intValue()" on a line with no method call |
a null Integer unboxed to int |
check for null first, or use int when a value is always present |
because "<local1>" is null |
the class was compiled without local-variable names | count locals, or compile with javac -g to see the name |
s.equals("hi") throws |
s is null |
"hi".equals(s) or Objects.equals(s, "hi") |
| A method changed your object (or array) through a parameter | the reference was copied, and the object shared | pass a copy to protect the original |
| Memory grows though the objects are no longer needed | something still refers to them, such as a static list |
remove the reference |
✅ Check yourself
One check per objective. Answer before you open anything.
The 🧪 box below: p == q for 100, r == s and r.equals(s) for 200, "ab" + "c" == "abc", z + "!", and z.toUpperCase().
p == qwith100:true. Both boxed values come from the cache.r == swith200:false, andr.equals(s):true.200is outside the cache.x == yfor"ab" + "c"and"abc":true."ab" + "c"is a constant expression, so it's interned like a literal [11]. Built from a variable, as in §4, it would befalse.z + "!"withzset tonull: printsnull!. String concatenation writes anullreference as the textnull.z.toUpperCase(): throwsNullPointerException: Cannot invoke "String.toUpperCase()" because "<local5>" is null. The5counts the locals of amainthat declaresp,q,r,sandz, in that order.
📚 Sources
- The Java Language Specification, Java SE 25, §4.3.1 "Objects" ("The reference values … are pointers to these objects, and a special null reference") — https://docs.oracle.com/javase/specs/jls/se25/html/jls-4.html#jls-4.3.1
- The Java Virtual Machine Specification, Java SE 25, §2.5.2 "Java Virtual Machine Stacks" — https://docs.oracle.com/javase/specs/jvms/se25/html/jvms-2.html#jvms-2.5.2
- The Java Virtual Machine Specification, Java SE 25, §2.5.3 "Heap" (reclaimed by a garbage collector; "objects are never explicitly deallocated") — https://docs.oracle.com/javase/specs/jvms/se25/html/jvms-2.html#jvms-2.5.3
- The Java Virtual Machine Specification, Java SE 25, §2.7 "Representation of Objects" — https://docs.oracle.com/javase/specs/jvms/se25/html/jvms-2.html#jvms-2.7
- The Java Language Specification, Java SE 25, §15.21.3 "Reference Equality Operators
==and!=" — https://docs.oracle.com/javase/specs/jls/se25/html/jls-15.html#jls-15.21.3 java.lang.Object.equals(Object), Java SE 25 API — https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/lang/Object.html#equals(java.lang.Object)- The Java Language Specification, Java SE 25, §5.1.7 "Boxing Conversion" (−128 to 127: "It is always the case that a == b") — https://docs.oracle.com/javase/specs/jls/se25/html/jls-5.html#jls-5.1.7
- The Java Language Specification, Java SE 25, §5.1.8 "Unboxing Conversion" ("If r is null, unboxing conversion throws a NullPointerException") — https://docs.oracle.com/javase/specs/jls/se25/html/jls-5.html#jls-5.1.8
java.lang.Integer.valueOf(int), Java SE 25 API ("will always cache values in the range -128 to 127 … and may cache other values") — https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/lang/Integer.html#valueOf(int)- The Java Language Specification, Java SE 25, §15.21.1 "Numerical Equality Operators
==and!=" — https://docs.oracle.com/javase/specs/jls/se25/html/jls-15.html#jls-15.21.1 - The Java Language Specification, Java SE 25, §3.10.5 "String Literals" (constant-expression strings are "interned") — https://docs.oracle.com/javase/specs/jls/se25/html/jls-3.html#jls-3.10.5
- JEP 358: Helpful NullPointerExceptions (JDK 14; local names need
javac -g) — https://openjdk.org/jeps/358 java.util.Objects.equals(Object, Object), Java SE 25 API — https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/util/Objects.html#equals(java.lang.Object,java.lang.Object)- The Java Language Specification, Java SE 25, §12.6.1 "Implementing Finalization" ("A reachable object is any object that can be accessed in any potential continuing computation from any live thread") — https://docs.oracle.com/javase/specs/jls/se25/html/jls-12.html#jls-12.6.1
🧪 Predict, then check.
- Predict each line:
Integer p = 100, q = 100; System.out.println(p == q);, thenInteger r = 200, s = 200; System.out.println(r == s);, thenSystem.out.println(r.equals(s));. - For
String x = "ab" + "c";andString y = "abc";, predictx == y. (Hint: is"ab" + "c"a compile-time constant?) - Predict what
String z = null; System.out.println(z + "!");prints, and whatz.toUpperCase()would do instead.
Your Turn
Before you move on, check your understanding with the coach — explain the idea, apply it, weigh the trade-offs, then defend your reasoning.