Java, from nothing · chapter 3 of 33
References, null, and what equals really compares
Chapter 1.3 · Part 1, Java from nothing · about 30 minutes
What you need before this chapter: chapter 1.2, Fields, methods, and constructors. You should be able to write a class with a validating constructor, private fields, and methods that read and return those fields.
When you finish this chapter you will be able to:
- Explain what a variable of a class type actually holds, and draw the difference between that and the object itself
- Predict correctly whether
==printstrueorfalsefor any two variables, includingStringvariables - Explain why
equalsand==can disagree, and say which one to reach for - Read a
NullPointerExceptionstack trace and say exactly which variable wasnull
1. The one idea in this chapter
When you write Ticket first = new Ticket("KA-01-4432", 555);, it is easy to think of first as the ticket. It is not. new Ticket(...) builds an object somewhere in the part of memory Java uses to store objects, and what first actually holds is a reference: a way of finding that object, not the object itself. Chapter 1.1 already touched this, in the error you get from Ticket first; with no new after it. This chapter is the full explanation. Getting it wrong is the single most common source of confusion in a beginner's first few months of Java, and it stays a real interview topic long after that.
Here is the concrete difference it makes. Assignment between two variables of a class type does not copy the object. It copies the reference.
Ticket a = new Ticket("KA-01-4432", 555);
Ticket b = a;
After these two lines, there is exactly one Ticket object in memory. a and b are two separate variables, but both hold a reference to that same one object. There is no second ticket anywhere. If Ticket had a method that changed one of its fields, calling that method through b would change what a sees too, because a and b are two names for the same thing. Compare that with:
Ticket c = new Ticket("KA-01-4432", 555);
Now there are two separate Ticket objects, a's and c's, that happen to hold identical values. a and c are not two names for the same object. They are two different objects that look alike. Java gives you two different tools for these two different questions, and mixing them up is where the bugs come from:
==asks "are these two variables pointing at the exact same object?" It compares references..equals(...)asks "do these two objects count as equal?" What that means is up to the class.Tickethas not been told what "equal" means for a ticket. It falls back to a default every Java object gets: equal only if it is the exact same object..equals(...)and==therefore give the same answer on aTicketfor now. That changes the moment a class writes its ownequalsmethod, a subject for Part 2 rather than this chapter.
2. Type this
Two small classes and one file that exercises them. First, save this as Ticket.java, trimmed from chapter 1.2 down to what this chapter needs:
public class Ticket {
private final String plate;
private final int arrivedAtMinute;
public Ticket(String plate, int arrivedAtMinute) {
if (plate == null || plate.isBlank()) {
throw new IllegalArgumentException("plate cannot be blank");
}
if (arrivedAtMinute < 0) {
throw new IllegalArgumentException("arrivedAtMinute cannot be negative");
}
this.plate = plate;
this.arrivedAtMinute = arrivedAtMinute;
}
public String getPlate() {
return plate;
}
public int getArrivedAtMinute() {
return arrivedAtMinute;
}
}
Now save this as Combined.java, in the same folder:
public class Combined {
public static void main(String[] args) {
Ticket a = new Ticket("KA-01-4432", 555);
Ticket b = a;
Ticket c = new Ticket("KA-01-4432", 555);
System.out.println("a == b: " + (a == b));
System.out.println("a == c: " + (a == c));
System.out.println("a.equals(c): " + a.equals(c));
ParkingSpot spotA = new ParkingSpot();
ParkingSpot spotB = spotA;
spotB.occupy();
System.out.println("spotA occupied: " + spotA.isOccupied());
String s1 = "hi";
String s2 = "hi";
String s3 = new String("hi");
System.out.println("s1 == s2: " + (s1 == s2));
System.out.println("s1 == s3: " + (s1 == s3));
System.out.println("s1.equals(s3): " + s1.equals(s3));
}
}
class ParkingSpot {
private boolean occupied;
public boolean isOccupied() {
return occupied;
}
public void occupy() {
occupied = true;
}
}
ParkingSpot is a second, tiny class, placed in the same file after Combined. Java allows several classes in one .java file as long as at most one of them is public, and the file name matches that one. Combined is public, so the file is Combined.java; ParkingSpot has no access keyword at all, which is legal for a top-level class and means only code in the same package can use it.
3. Run it
javac Ticket.java Combined.java
java Combined
You should see exactly this:
a == b: true
a == c: false
a.equals(c): false
spotA occupied: true
s1 == s2: true
s1 == s3: false
s1.equals(s3): true
Read that output slowly before section 4 explains it. Three of these seven lines are the ones that trip people up for years. a == c is false even though the two tickets hold identical data. s1 == s3 is false even though both are the text "hi". spotA occupied is true even though nothing in the code ever wrote to spotA directly.
4. What just happened, line by line
Ticket a = new Ticket("KA-01-4432", 555); builds one Ticket object and stores a reference to it in a.
Ticket b = a; does not build anything. It copies the reference that a holds into b. After this line, a and b are two different variables holding the identical reference, pointing at the one object built on the line above.
a == b prints true because == on reference types compares the references themselves, and a and b hold the same one.
Ticket c = new Ticket("KA-01-4432", 555); builds a second, brand new object with its own space in memory, even though the values passed in are identical to a's. c holds a reference to this new object, different from the reference a and b hold.
a == c prints false. This is not a bug, and it is not Java being unhelpful. a and c point at two different objects that happen to contain the same data. Two different printed parking tickets can show the same plate and the same arrival time without being the same piece of paper. == answers "same object?" and the honest answer is no.
a.equals(c) also prints false, and the reason is specific: nobody has taught Ticket what "equal" should mean. Every class in Java, including Ticket, inherits a method called equals from a common ancestor class, Object. Every class in Java extends Object whether it says so or not; chapter 1.4 covers what extends fully means. Object's own version of equals is "same object, and nothing else," so calling .equals(...) on a class that has not replaced it just repeats what == already told you. Two tickets with identical plates and identical arrival times count as unequal until Ticket says otherwise, which is why a later chapter on overriding equals exists at all.
ParkingSpot spotA = new ParkingSpot(); ParkingSpot spotB = spotA; is the same reference copy as a and b, on a class with a method that actually changes something. spotB.occupy() runs the occupy() method on the object spotB refers to, setting its occupied field to true. Since spotA and spotB refer to that same one object, spotA.isOccupied() also reports true. Nothing wrote to spotA by name. The object underneath both variables changed, and both variables see it, because there was only ever one ParkingSpot.
String s1 = "hi"; String s2 = "hi"; looks like it should behave the way a and c did: two separate new calls, two separate objects, == should be false. It is not, and the reason is a detail specific to String literals. Java keeps a single shared table of every literal text value a program contains, called the string pool. When the compiler sees "hi" written directly in source code, it does not build a new String object each time. It looks the text up in the pool and reuses the one already there. Both s1 and s2 end up holding a reference to that one pooled object, so s1 == s2 is true, for the same reason a == b was true: they are the same object.
String s3 = new String("hi"); turns pooling off deliberately. Writing new String(...) always builds a fresh String object on the spot, bypassing the pool even though its text is identical to what is already pooled. s1 == s3 is false, because s1 points at the pooled object and s3 points at a separate one that happens to hold the same characters. s1.equals(s3) is true, because String, unlike Ticket so far, has its own equals method that compares the actual characters rather than asking whether it is the same object.
This is the trap the chapter's introduction warned about. String interning makes == look like it compares content, because in ordinary code almost every String you write is a literal, and literals get pooled. The habit of writing if (name == "admin") passes every test you run by hand. It then fails the day a String arrives from a file, a network response, or a database, none of which go through the compiler's literal pool. The rule that holds in every case, with no exceptions: compare object references with ==, compare content with .equals(...), and for String specifically, that means always using .equals(...), never ==.
5. Errors you are likely to hit
null and the NullPointerException. Every reference variable, before it is given an object, can hold a special value called null, meaning "refers to nothing." Chapter 1.1 showed the compiler refusing to let you use a variable that was never assigned at all. null is different: it compiles fine, because null is a real, legal value for a reference to hold. The trouble starts when you try to use it as if it pointed at an object:
public class NullTest {
public static void main(String[] args) {
Ticket t = null;
System.out.println(t.getPlate());
}
}
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "Ticket.getPlate()" because "<local1>" is null
at NullTest.main(NullTest.java:4)
Read this like the earlier stack traces: the last line names the file and line where it happened, line 4, which is t.getPlate(). The message above it is Java telling you exactly what it was trying to do, call getPlate(), and exactly why it could not: the variable it needed for that call held null. Modern Java, since version 14, names the specific method and the specific variable rather than leaving you to guess, which is why the message is this precise. The <local1> in place of the name t is a smaller, separate detail. javac only keeps a variable's original name in the compiled file when asked with an extra flag, so by default the message refers to it positionally instead. The fact reported does not change either way: some variable holding null had a method called on it.
NullPointerException is not a sign that something is broken beyond repair. It is Java refusing to follow a reference that leads nowhere, the moment it is asked to, instead of doing something undefined. The fix is always the same: find the named variable, work out why it is null at that point, and either set it before that line runs or check for null explicitly first.
6. Your turn
Predict the output of this program before running it, then compile and run it to check yourself.
public class Predict {
public static void main(String[] args) {
Ticket x = new Ticket("KA-01-4432", 555);
Ticket y = new Ticket("KA-01-4432", 555);
Ticket z = x;
System.out.println(x == y);
System.out.println(x == z);
System.out.println(x.equals(y));
}
}
The answer.
false
true
false
x and y are two separate objects built by two separate new calls, so x == y is false, even though the values passed in are identical, for the same reason a == c was false earlier. z = x copies the reference, so x == z is true; they are two names for one object. x.equals(y) is false because Ticket has not overridden equals, so it still means the same thing as ==. If you predicted all three correctly before running it, you have the core distinction this chapter teaches.
7. Going deeper: when overriding equals sets a trap of its own
This is a real failure that catches engineers who already understand everything above. Suppose a class does override equals, the way a real Ticket eventually will, and bases it on a field that can change after the object is built:
import java.util.HashSet;
import java.util.Objects;
import java.util.Set;
class MutableTicket {
private String plate;
MutableTicket(String plate) {
this.plate = plate;
}
void setPlate(String plate) {
this.plate = plate;
}
@Override
public boolean equals(Object o) {
if (!(o instanceof MutableTicket other)) return false;
return Objects.equals(plate, other.plate);
}
@Override
public int hashCode() {
return Objects.hash(plate);
}
}
public class HashBug {
public static void main(String[] args) {
Set<MutableTicket> parked = new HashSet<>();
MutableTicket t = new MutableTicket("KA-01-4432");
parked.add(t);
System.out.println("found before edit: " + parked.contains(t));
t.setPlate("KA-01-9999");
System.out.println("found after edit: " + parked.contains(t));
}
}
Two new pieces of syntax appear here, worth naming before the output. Set<MutableTicket> and HashSet come from chapter 1.7. For now, all you need is that a HashSet finds things using a number called a hash code, produced by an object's hashCode() method, rather than by scanning every entry it holds. if (!(o instanceof MutableTicket other)) is a type check with a built-in cast. It asks whether o is a MutableTicket, and if so, hands you that same object back through a new variable named other, already typed correctly.
Compile and run this, and the output is:
found before edit: true
found after edit: false
The HashSet found t right after adding it, and then genuinely lost it, while t itself was never removed and still sits inside the set. Here is why. A HashSet decides which internal bucket to store an object in in based on the value hashCode() returned at the moment it was added. hashCode() here is built from plate, so it returned one value when plate was "KA-01-4432". Calling t.setPlate("KA-01-9999") changes plate, which changes what hashCode() would return now, but the object is still sitting in the bucket that matched its old hash code. Asking contains(t) makes the HashSet compute t's current hash code and look in the bucket that matches, which is the wrong bucket now. The rule this proves: never mutate a field that equals or hashCode depends on while the object is stored in a hash-based collection. The safe version either makes plate final, the way Ticket already does, or never puts a mutable object into a HashSet at all.
One more honest detail, since Objects.hash(plate) is common code you will see everywhere: it is convenient, but it is not free. Objects.hash(...) takes a variable number of arguments, and Java implements that by building an actual array to hold them every single time the method is called. Disassembling the compiled class confirms it: the bytecode for a call to Objects.hash(plate) contains an anewarray instruction, which allocates a new array object, before it ever calls hash. For a hashCode() called once per HashSet lookup, that allocation is nothing to worry about. Code that computes a hash code millions of times a second often writes the arithmetic out by hand instead. That is why you will sometimes see a hashCode() full of multiplications and no Objects.hash in sight.
8. Why this matters in an interview
Two failures in this chapter turn up constantly in real systems, not just in interview rooms. The first is comparing objects with == when .equals(...) was meant, most often on String. It survives testing because literals are pooled and look equal by == in every quick check a developer runs by hand. It fails once real data, read from a file or typed by a user, breaks the illusion. The second is storing a mutable object in a HashSet or as a HashMap key, then changing the field its hash depends on, exactly what the last section demonstrated. Both pass a code review, because the code reads correctly and only fails at runtime, on specific data. That is precisely what a design interviewer is listening for when they ask "what could go wrong here?"
The deeper habit worth carrying forward is this: whenever you write a class, decide on purpose whether its objects are meant to be compared by identity or by content. If by content, decide whether any field that decision depends on can ever change after construction. Ticket's fields are already final, from chapter 1.2, and that is not a coincidence. It is the same design decision that avoids the trap in this chapter, arrived at before the trap was even shown to you.
Next: chapter 1.4, Inheritance and polymorphism: extends, super, overriding, and dynamic dispatch. You have already seen extends twice in this chapter's asides, borrowed before its time; the next chapter gives it a proper, full explanation.
← 1.2 Fields, methods, and constructors · All chapters · 1.4 Inheritance and polymorphism: extends, super, overriding, and dynamic dispatch →