The Object class
What every class inherits: toString and what belongs in it, getClass versus the declared type, clone and why a copy constructor wins, and finalize, replaced by try-with-resources and Cleaner.
Every class you write has a superclass, whether you name one or not. class Order {} means class Order extends Object, so every object in a Java program, arrays included, carries the same small set of methods. You override two or three of them, you read one, you should avoid one, and one survives mostly as a warning. This lesson takes them one at a time: what the default does, what an override owes its callers, and which of them a modern codebase should leave alone.
The root of every hierarchy
If a class declares no extends, javac makes its superclass java.lang.Object. Every chain of extends ends there, which is why a variable of type Object can hold any object at all. Arrays are objects too: int[].class.getSuperclass() is Object. Interfaces do not extend Object, but an interface type still offers Object's public methods, because whatever implements it is an object, so runnable.toString() compiles.
Object declares eleven methods:
| Method | Access | What the default does | What you do with it |
|---|---|---|---|
toString() | public | class name, @, hash code in hex | override it for anything a person will read |
equals(Object) | public | identity: this == other | override it for value types, together with hashCode |
hashCode() | public | an identity hash, stable for the object's life | override it whenever equals is overridden |
getClass() | public, final | returns the object's runtime class | read it; it cannot be overridden |
clone() | protected | copies every field, if the class implements Cloneable | avoid it; write a copy constructor |
finalize() | protected | nothing | never override it; it is deprecated for removal |
wait(), wait(long), wait(long, int) | public, final | blocks until notified | low-level threading; prefer java.util.concurrent |
notify(), notifyAll() | public, final | wakes waiting threads | the same |
equals and hashCode form one contract and have their own lesson in Java Fundamentals, and wait and notify belong to concurrency. This lesson is the rest.
toString: for people, not for programs
The default is getClass().getName() + "@" + Integer.toHexString(hashCode()), which is how Order@1b6d3586 ends up in a log. You call it far more often than you write it: string concatenation, String.valueOf, println, a logging framework's {} placeholder, and the debugger's variables view all call toString for you.
Arrays do not override it. An int[] prints as [I@4e25154f and a String[] as [Ljava.lang.String;@…, which is a classic useless log line; Arrays.toString and Arrays.deepToString are what was meant.
A good override is short and says which object this is:
@Override
public String toString() {
return "Order[id=" + id + ", status=" + status + ", lines=" + lines.size() + "]";
}Three rules keep it out of trouble:
- Never parse it. The format is for reading. If another program needs the data, give it a method that returns the data.
- Leave out secrets and personal data. A
toStringends up in logs, exception messages and error trackers, which more people can read, for longer, than the database. A record prints every component, sorecord Login(String user, String password)printsLogin[user=ann, password=hunter2]. - Do not reach far. Print the ids and counts of related objects, not the objects. A
toStringthat prints children whosetoStringprints the parent recurses untilStackOverflowError, and on a JPA entity, touching a lazily loaded collection fromtoStringcan run a query for every log line, or fail outside a transaction.
Records generate a toString of the form Point[x=1, y=2], which is usually right for plain data and wrong only for the secrets above.
getClass: the runtime class, not the declared type
getClass() returns the Class object for what the object actually is, which can be very different from the type the variable was declared with:
List<Integer> list = List.of(1, 2);
list.getClass().getName(); // java.util.ImmutableCollections$List12The declared type is List; the object is a private implementation class the JDK is free to change in any release. That is the first reason not to branch on getClass() in application code. It ties you to an implementation, where instanceof asks the question you usually mean: can this be used as a List?
getClass() is final, so no class can override it, but generated classes still surprise people. A lambda's class is a hidden generated class whose name contains $$Lambda. A Hibernate lazy-loading proxy or a Spring CGLIB proxy is a generated subclass, so entity.getClass() == Order.class is false for an Order that arrived as a proxy. That is one more reason for equals to use instanceof.
Where getClass() is the right call: putting the concrete type in a log message with getSimpleName(), and reflection.
clone: the method designed not to be called
clone is the strangest method on Object, and its design is the argument against using it:
- It is
protected, so callingclone()on an arbitrary object from outside its class does not compile:clone() has protected access in Object. A class has to override it aspublicto offer copying at all. - It works only if the class implements
Cloneable, an interface with no methods whose only effect is to change the behaviour of a method in a different class. Without it,super.clone()throwsCloneNotSupportedException, a checked exception every caller must handle even when it cannot happen. - It copies field by field without running a constructor, so an object whose constructor validates its arguments or counts its instances gets a copy that skipped both.
- The copy is shallow. Reference fields are copied as references, so the original and the clone share every array, list and mutable object they point to.
If you must support it, this is the idiom, with a covariant return type so callers need no cast:
class Box implements Cloneable {
int[] items = {1, 2, 3};
@Override
public Box clone() {
try {
return (Box) super.clone();
} catch (CloneNotSupportedException e) {
throw new AssertionError(e); // cannot happen: Box implements Cloneable
}
}
}Arrays are the case where clone works well: array.clone() is public, needs no try, and is the usual way to copy one level of an array.
For your own classes, write a copy constructor or a static factory instead: new Box(other) or Box.copyOf(other). It runs a constructor, so the invariants are checked. It needs no marker interface and no checked exception. It can copy deeply where deep matters, and it can return a different implementation. The collections already work this way, with new ArrayList<>(other) and List.copyOf(other). An immutable object needs no copy at all.
finalize: deprecated, and never reliable
finalize() was meant to let an object release its resources before the garbage collector reclaimed it. It never worked as that:
- It has no timing guarantee, and no guarantee at all. It runs some time after an object becomes unreachable, if a collection happens to notice, on a JVM thread you do not control, and possibly never before the process exits. A file handle released in
finalizeis released "eventually". - It slows collection down. An object with a finaliser cannot be reclaimed by the collection that finds it unreachable. It is queued, finalised by a single
Finalizerthread, and reclaimed in a later cycle. When finalising is slower than allocating, that queue grows until the heap runs out. - It is unsafe to write. It can resurrect the object by storing
thissomewhere reachable, and an exception thrown inside it is silently ignored.
It was deprecated in Java 9 and marked for removal in Java 18. Overriding it now produces warning: [removal] finalize() in Object has been deprecated and marked for removal, and java --finalization=disabled switches finalisation off, so you can test whether anything you run still depends on it.
Two things replaced it:
try-with-resources, for any resource whose lifetime you can see in the code. ImplementAutoCloseable, andclose()runs at the end of the block on every path, exceptions included. This covers almost every real case, and exceptions are covered in their own course.java.lang.ref.Cleaner(Java 9), as a safety net for a native resource that a caller forgot to close. You register the object with a cleaning action, and the action runs exactly once: whenclose()callsclean(), or after the object becomes unreachable, whichever comes first.
class NativeBuffer implements AutoCloseable {
private static final Cleaner CLEANER = Cleaner.create();
private final Cleaner.Cleanable cleanable;
NativeBuffer(long address) {
State state = new State(address); // holds no reference to this
this.cleanable = CLEANER.register(this, state);
}
@Override
public void close() {
cleanable.clean(); // runs the action at most once
}
private record State(long address) implements Runnable {
public void run() { /* free the native memory at address */ }
}
}Cleaner has one rule that matters: the action must not refer to the object it cleans. If it did, the registration would keep the object reachable and the action could only ever run from close(). That is why the state is a separate record, not a lambda that captures this.
Under the hood: what the defaults actually do
getClass()reads the class pointer stored in every object's header. That is why it is cheap, and why no code can make an object report a class other than the one it was allocated as.hashCode(), the default, generates an identity hash the first time it is asked and stores it in the object's header, so it stays the same even though the garbage collector moves the object.toString(), the default, callshashCode()as a virtual call. A class that overrideshashCodebut nottoStringtherefore prints its own hash: aMoneywhosehashCodereturns 42 prints asMoney@2a.clone()is native. It allocates a new object of the same class and copies the old object's field memory into it, which is precisely why no constructor runs and why every reference field ends up shared.finalize(), when a class overrides it, costs something before the object ever dies: HotSpot registers every instance of that class with the finaliser machinery as it is allocated.
Walkthrough: the password in the error tracker
A login endpoint deserialised its body into record LoginRequest(String email, String password). When validation failed, the handler threw:
throw new IllegalArgumentException("invalid login request: " + request);- String concatenation called the record's generated
toString, which includes every component:LoginRequest[email=ann@example.com, password=hunter2]. - The exception handler logged the message at
WARN, as it did for every bad request. - The log shipper forwarded warnings to the error tracker, where everyone on the engineering team could search them, with ninety days of retention.
- Nothing failed. The request was rejected correctly, every test passed, and the passwords of users who mistyped their email sat in a third-party system for months until someone searched for an unrelated error.
The fix was three changes, not one. The record got a toString that prints the email and password=***. The exception message stopped embedding request objects and named the failing rule instead. And a log filter began redacting anything shaped like a credential, for the next object nobody thought about.
Try it yourself
What is it, really?
record Point(int x, int y) {}
Object p = new Point(1, 2);
Object list = List.of(1, 2);
System.out.println(p);
System.out.println(p.getClass().getSimpleName());
System.out.println(list.getClass().getSimpleName());Answer
Point[x=1, y=2], Point, then List12. The record's generated toString names each component. getClass() ignores the declared type Object and reports the class each object was created as, and for List.of(1, 2) that is ImmutableCollections.List12, a private class the JDK uses for lists of one or two elements. Nothing in your code named it, which is exactly why no code should test for it.
How deep is the copy?
class Box implements Cloneable {
int[] items = {1, 2, 3};
@Override public Box clone() {
try {
return (Box) super.clone();
} catch (CloneNotSupportedException e) {
throw new AssertionError(e);
}
}
}
Box a = new Box();
Box b = a.clone();
b.items[0] = 99;
System.out.println(a == b);
System.out.println(a.items == b.items);
System.out.println(a.items[0]);Answer
false, true, 99. The clone is a different object, so a == b is false. But clone copied the items field as a reference, so both boxes point at the same array, and writing through b changes what a sees. A deep copy would need copy.items = items.clone() inside the override, and a copy constructor would make that decision visible where it is made.
Why this hash?
A class overrides hashCode to return Long.hashCode(cents) and does not override toString. System.out.println(new Money(42)) prints Money@2a. Where did 2a come from, and what does that tell you about the default toString?
Answer
2a is 42 in hex. Object.toString does not read an address or a hidden identity value; it calls hashCode(), and that call dispatches to the override. So two equal Money objects print identically, and the "@ and a number" part of a default toString is not a way to tell objects apart.
Misconceptions
- "Every class should override
toString,equals,hashCodeandclone."toStringfor anything a person reads,equalsandhashCodefor value types, andclonealmost never. - "
clonemakes a deep copy." It copies fields, so every reference field is shared between the original and the copy. - "Interfaces extend
Object." They do not, but every interface type still hasObject's public methods, because anything that implements it is an object. - "
finalizeis Java's destructor." It has no ordering, no timing and no guarantee, and it is deprecated for removal. The construct that behaves like a destructor istry-with-resources. - "
getClass() == Order.classis the precise, safe type check." It fails for every subclass and every generated proxy. Useinstanceofunless excluding subclasses is really the point. - "The number after
@is the memory address." It is the hash code in hex: the identity hash by default, or whatever an overriddenhashCodereturns.
Going deeper
- JLS §4.3.2, the class
Object, and JLS §12.6, finalisation of class instances. - JEP 421, Deprecate Finalization for Removal, for the history and the migration advice.
- The Javadoc of
java.lang.ref.Cleaner, including its warning about actions that capture the object. - Effective Java, item 8 (avoid finalizers and cleaners), item 12 (always override
toString) and item 13 (overrideclonejudiciously). javap -p java.lang.Object, which lists the constructor, the eleven methods and one private helper, withfinalandnativemarked on each.