Lecture #4: References, Aliasing, and Object Identity
Why this chapter exists
Coming from C, you already have solid intuitions about pointers, and those intuitions mostly transfer to Java — but not entirely, and the gap is exactly where new Java programmers get burned. This short chapter closes that gap before you meet collections, where it matters most: a HashSet that silently accepts duplicates, or a List.contains() that always returns false, is almost always this problem in disguise.
Variables of object type hold a reference, not the object
A Java variable of a primitive type (int, double, boolean, …) holds its value directly, exactly like in C. A variable of any object type — a class, an array, a String — holds a reference to an object living elsewhere (conceptually, on the heap). This is close to “a pointer that Java manages for you”: you never see an address, you never dereference it explicitly with * or ->, and you cannot do pointer arithmetic on it, but the underlying idea — a variable that points at an object rather than being one — is the same.
Point p1 = new Point();
p1.x = 3;
p1.y = 4;
Point p2 = p1; // p2 now refers to the SAME object as p1
p2.x = 99;
System.out.println(p1.x); // prints 99 — p1 and p2 are aliases
p2 = p1 does not copy the Point. It copies the reference — both variables now name the same object. This is called aliasing, and it is the normal, expected way objects behave in Java. There is no struct-style value semantics for objects; if you want an independent copy, you must create one explicitly (a copy constructor, a clone() method, or simply new Point(p1.x, p1.y)).
(This first example uses a plain, publicly-mutable Point — like the ones from earlier chapters — just to keep the aliasing demonstration itself as simple as possible. Later in this chapter you’ll see a more disciplined Point, with private final fields and a proper constructor, once the topic shifts to equals/hashCode/toString.)
Method calls: pass-by-value of the reference
Java is strictly pass-by-value — always. What that means differs by type:
- Passing a primitive copies the value. The method gets its own
int; changes inside the method never escape. - Passing an object reference copies the reference, not the object. The method receives its own variable pointing at the same object as the caller’s. It can mutate the object through that reference (the caller will see the mutation), but it cannot make the caller’s variable point somewhere else.
static void reset(Point p) {
p.x = 0; // mutates the object the caller can also see
p = new Point(); // only rebinds the LOCAL variable p — no effect outside
p.x = -1;
p.y = -1;
}
Point origin = new Point();
origin.x = 3;
origin.y = 4;
reset(origin);
System.out.println(origin.x); // prints 0, NOT -1
This single example resolves most of the “Java passes objects by reference” confusion you’ll see online: Java passes the reference itself by value. Mutating through it is visible to the caller; reassigning it is not.
== compares references, not content
On primitives, == compares values, same as C. On object types, == compares whether two variables refer to the same object in memory — it says nothing about whether the objects “look the same.”
String a = new String("hello");
String b = new String("hello");
System.out.println(a == b); // false — two distinct String objects
System.out.println(a.equals(b)); // true — same character content
Arrays are objects too, so the same rules apply to them — and this is where the previous chapter’s “arrays are objects, not raw memory” point comes back to bite you. Two arrays with identical contents are still two different objects:
int[] arr1 = {1, 2, 3};
int[] arr2 = {1, 2, 3};
System.out.println(arr1 == arr2); // false — different array objects
System.out.println(arr1.equals(arr2)); // ALSO false! arrays inherit Object's
// default equals(), which is identity
System.out.println(arr1); // something like [I@1b6d3586, not "[1, 2, 3]"
System.out.println(Arrays.equals(arr1, arr2)); // true — the actual fix
System.out.println(Arrays.toString(arr1)); // "[1, 2, 3]" — the actual fix
Arrays never got their own overridden equals/toString in the standard library — use the java.util.Arrays utility class’s static methods (Arrays.equals, Arrays.toString, and their multi-dimensional cousins Arrays.deepEquals/Arrays.deepToString) instead of expecting the array itself to behave sensibly.
This is the single most common source of subtle bugs for programmers arriving from C or from languages where == means value equality. Rule of thumb: never use == to compare object content. Use == only when you deliberately want identity (the same object), and use .equals(...) for content equality.
(One wrinkle you’ll encounter: two String literals, like "hello" == "hello", often do return true, because the compiler pools identical literals into a shared object. Don’t rely on this — it’s an implementation detail, not a guarantee, and it breaks the moment either string is built at runtime rather than written literally.)
Every object inherits equals, hashCode, and toString — and the defaults are almost never what you want
Every class in Java implicitly extends java.lang.Object, which supplies three methods every object has whether you write them or not:
equals(Object other)— default implementation is identical to==(reference identity).hashCode()— default implementation derives from the object’s identity/memory location.toString()— default implementation printsClassName@hexHashCode, e.g.Point@1b6d3586.
For a data class like Point, all three defaults are wrong for what you almost always want: two points with the same coordinates should be “equal,” should print as something readable, and should behave correctly as keys or elements in a hash-based collection. You get none of that for free — you must override them.
public class Point {
private final int x, y;
public Point(int x, int y) {
this.x = x;
this.y = y;
}
public int getX() { return x; }
public int getY() { return y; }
@Override
public boolean equals(Object other) {
if (this == other) return true; // fast path
if (!(other instanceof Point)) return false; // type check — also covers null,
// since `null instanceof AnyType` is always false
Point p = (Point) other;
return this.x == p.x && this.y == p.y; // field-by-field comparison
}
@Override
public int hashCode() {
return java.util.Objects.hash(x, y);
}
@Override
public String toString() {
return "Point(" + x + ", " + y + ")";
}
}
With this in place: System.out.println(new Point(3, 4)) prints Point(3, 4) instead of a memory hash. new Point(3,4).equals(new Point(3,4)) returns true. And, critically, Point now works correctly inside collections.
The equals/hashCode contract — why you can’t override just one
Hash-based collections (HashSet, HashMap) work by computing hashCode() to find the right bucket, then using equals() to check the objects in that bucket. If you override equals() without also overriding hashCode() consistently, you break this mechanism. The rule, mandated by the Object contract, is:
If
a.equals(b)istrue, thena.hashCode() == b.hashCode()must also be true.
Concretely, two “equal” points must produce the same hash code, or a HashSet<Point> will happily store the “same” point twice — it computes different hash codes for them, looks in two different buckets, and never notices they’re duplicates:
Set<Point> visited = new HashSet<>();
visited.add(new Point(1, 1));
visited.add(new Point(1, 1));
System.out.println(visited.size()); // 1, with the Point shown above (equals + hashCode both overridden)
If Point had only overridden equals() and left hashCode() at Object’s default, that same code would print 2 instead: the two points would be “equal” by your equals() logic, but — with different, identity-based hash codes — HashSet would look for them in two different buckets and never even call equals() to compare them.
The reverse (equal hash codes but not equal objects) is allowed — that’s just a hash collision, which every hash-based structure already handles. Only the forward direction of the contract is mandatory. In practice: override equals() and hashCode() together, always, based on the same set of fields — most IDEs can generate both for you correctly in one action, and java.util.Objects.hash(...) (shown above) is a convenient, correct way to write hashCode() by hand.
Recap
- Object-typed variables hold references, not objects; assignment copies the reference, creating an alias, not a copy.
- Method arguments are always passed by value — for object types, that value is the reference, so mutation is visible to the caller but reassignment is not.
==on objects compares identity (same memory), never content — use.equals(...)for content comparison.Objectsupplies defaultequals/hashCode/toString, all based on identity — override them for any class you intend to compare by value, print meaningfully, or store in aHashSet/HashMap.equals()andhashCode()must be overridden together and stay consistent, or hash-based collections will silently misbehave.