Missing hashCode() Corrupted Payment Batch — Java Object
A HashSet returned size() > 1 for same IDs: hashCode() was missing.
20+ years shipping production Java in banking & fintech. Lessons pulled from things that broke in production.
- Object class (java.lang.Object) is the root of every Java class hierarchy — always inherited
- 11 methods baked in: toString, equals, hashCode, getClass, clone, finalize, wait, notify, notifyAll
- Default equals checks reference equality (==) — useless for value comparison in HashMap lookups
- Contracts matter: override equals() and hashCode() together or break HashMap, HashSet, and caching
- Bad toString() makes logs unreadable — override it on every class you debug
- wait()/notify() live on Object because locks belong to the object, not the thread
The Object class is the root of Java's single-inheritance hierarchy — every class you write, every library class you import, ultimately extends java.lang.Object. It exists to provide a baseline set of behaviors that every Java object shares: identity comparison via , hash-based collection support via equals()hashCode(), string representation via toString(), thread synchronization primitives (wait, notify, notifyAll), and lifecycle hooks (finalize, now deprecated).
When you don't override these methods, you inherit Object's defaults — which use memory address-based identity. That's fine for simple cases, but it's the root cause of bugs like the corrupted payment batch in this article: a HashSet<PaymentTransaction> silently allowed duplicates because PaymentTransaction overrode without equals()hashCode(), violating the contract that equal objects must have equal hash codes.
The result? Payments were dropped from batch processing without any exception — just silent data loss.
The /equals()hashCode() contract is non-negotiable: if you override one, you must override both. Object's default hashCode() returns the object's memory address (typically via a JVM-internal pointer or a random number in modern JVMs), which means two logically equal objects (same transaction ID, same amount) will have different hash codes. A HashMap or HashSet will place them in different buckets, treat them as distinct entries, and your business logic silently fails.
This is the single most common Java bug in production systems — I've seen it corrupt financial data, duplicate customer records, and break caching layers at Fortune 500 companies. The fix is always the same: use Objects.hash() or Objects.equals() in your overrides, and never hand-roll hash code logic unless you've benchmarked it.
Beyond the contract, Object provides toString() (useful for logging — override it with meaningful state), getClass() (for runtime type inspection, though prefer instanceof), and (which does shallow copying and is notoriously broken — use copy constructors or a builder instead). The deprecated clone() should never be used; use finalize()Cleaner or AutoCloseable for resource cleanup.
The wait/notify methods are low-level concurrency primitives that you should almost never call directly — use java.util.concurrent locks, CountDownLatch, or CompletableFuture instead. Understanding when to treat objects as entities (identity-based, mutable, with based on a primary key), value objects (state-based, immutable, with equals() on all fields), or DTOs (data carriers with no behavior, often using equals()record in modern Java) is the real-world pattern that separates senior engineers from those who ship corrupted batches.
Think of the Object class like the universal ID card every person on Earth shares. No matter who you are — a chef, a pilot, a student — you all have a name, a date of birth, and a signature. In Java, every class you create automatically gets that same 'ID card' from the Object class. It gives every object a set of built-in abilities: the power to describe itself, compare itself to others, and prove its identity. You didn't ask for it, you don't have to declare it, but it's always there.
Every Java program you've ever written has been quietly standing on the shoulders of a single class: java.lang.Object. It's the root of every class hierarchy in Java — whether you write 'extends Object' or not, every class you create inherits from it automatically. That means String, Integer, your custom BankAccount class, and even arrays are all Objects under the hood. This isn't a trivial detail; it's the reason Java can have methods like Collections.sort(), or why you can store anything in a List<Object>. The Object class is the shared contract that makes polymorphism possible at the most fundamental level.
The problem it solves is simple: how do you write generic code that works with any type? Before generics, and still today in many infrastructure-level APIs, the answer is Object. More importantly, the Object class defines a set of behaviours that every well-designed class should honour — equality, hashing, and string representation. If your class breaks those contracts, bugs creep in that are notoriously hard to track down. HashMap lookups silently fail. Sets store duplicates. Logs show useless memory addresses instead of real data.
By the end of this article you'll understand exactly what the Object class gives you, why its key methods form a contract you must respect, how to override them correctly in your own classes, and what to watch out for when you don't. You'll also walk away with the answers to the Object class questions that trip up even experienced developers in interviews.
What the Object Class Actually Gives Every Java Class
When the JVM loads your class, it quietly wires in java.lang.Object as the parent if you haven't declared one. That means every instance of your class ships with eleven methods baked in — no imports, no setup required.
The ones you'll interact with most are: toString() (what does this object look like as text?), equals(Object o) (are these two objects the same in meaning?), hashCode() (what's this object's numeric fingerprint?), getClass() (what type is this at runtime?), and clone() (can I make a copy?). There are also three threading-related methods — wait(), notify(), and notifyAll() — which are foundational to Java's built-in monitor-based concurrency.
The key insight is this: Object defines the protocol, but the default implementations are almost always wrong for your specific class. The default toString() returns something like 'com.example.BankAccount@6d06d69c' — a class name plus a hex memory address. That's useless in logs. The default equals() checks reference equality (same object in memory), not value equality. The default hashCode() derives from memory address. For most real classes, all three defaults need to be replaced.
Understanding this distinction — Object gives you the slot, you provide the meaning — is the mental model that makes everything else click.
public class ObjectClassInspector { public static void main(String[] args) { // A plain String — which is also an Object String greeting = "Hello, TheCodeForge"; // getClass() — runtime type info baked in from Object System.out.println(greeting.getClass().getName()); // java.lang.String System.out.println(greeting.getClass().getSimpleName()); // String // Every array is also an Object int[] scores = {95, 87, 72}; System.out.println(scores.getClass().getSimpleName()); // int[] System.out.println(scores instanceof Object); // true // Default toString() on a custom object — notice the ugly output RawProduct rawProduct = new RawProduct("Laptop", 999.99); System.out.println(rawProduct); // Something like: RawProduct@1b6d3586 // Default equals() compares REFERENCES, not values RawProduct anotherLaptop = new RawProduct("Laptop", 999.99); System.out.println(rawProduct.equals(anotherLaptop)); // false — different objects in memory System.out.println(rawProduct == anotherLaptop); // false — same reason } } // Intentionally bare class — no overrides — so we can see Object's defaults class RawProduct { String name; double price; RawProduct(String name, double price) { this.name = name; this.price = price; } }
equals(), hashCode()equals() and hashCode() together — never one without the otherequals() AND hashCode() — HashMap silently fails without bothThe equals() and hashCode() Contract — Why You Must Override Both or Neither
This is the most important section in this entire article, because breaking this contract causes bugs that are silent, invisible, and maddening.
Java's collections framework — HashMap, HashSet, LinkedHashMap — relies on a two-step lookup. First it calls hashCode() to find the right 'bucket', then it calls equals() to confirm the match. The contract Java enforces is this: if two objects are equal according to equals(), they MUST return the same hashCode(). The reverse isn't required — two objects can share a hashCode() without being equal (that's a collision, and it's acceptable) — but the forward direction is absolute.
If you override equals() but forget hashCode(), you've broken the contract. Your HashMap will happily store what it thinks are two different objects when they're logically the same, because they land in different buckets. Your HashSet will contain duplicates. You'll pull your hair out wondering why get() returns null on a key you just put() in.
The rule is simple: always override both together. Modern Java makes this easy — your IDE can generate both, or you can use Objects.equals() and Objects.hash() from java.util.Objects to write clean, null-safe implementations in a few lines.
import java.util.HashMap; import java.util.HashSet; import java.util.Objects; public class ProductWithContract { public static void main(String[] args) { // --- PART 1: The broken class (equals only, no hashCode) --- BrokenProduct broken1 = new BrokenProduct("Keyboard", "KB-101"); BrokenProduct broken2 = new BrokenProduct("Keyboard", "KB-101"); System.out.println("=== Broken Contract ==="); System.out.println("broken1.equals(broken2): " + broken1.equals(broken2)); // true (we defined it) HashSet<BrokenProduct> brokenSet = new HashSet<>(); brokenSet.add(broken1); brokenSet.add(broken2); // Should be 1 — they're 'equal' — but hashCode is inconsistent so both get stored! System.out.println("Set size (should be 1, is): " + brokenSet.size()); // 2 — BUG! // --- PART 2: The correct class (both equals AND hashCode) --- FixedProduct fixed1 = new FixedProduct("Keyboard", "KB-101"); FixedProduct fixed2 = new FixedProduct("Keyboard", "KB-101"); System.out.println("\n=== Correct Contract ==="); System.out.println("fixed1.equals(fixed2): " + fixed1.equals(fixed2)); // true System.out.println("fixed1.hashCode() == fixed2.hashCode(): " + (fixed1.hashCode() == fixed2.hashCode())); // true — contract honoured HashSet<FixedProduct> fixedSet = new HashSet<>(); fixedSet.add(fixed1); fixedSet.add(fixed2); System.out.println("Set size (should be 1, is): " + fixedSet.size()); // 1 — CORRECT! // HashMap lookup also works correctly now HashMap<FixedProduct, String> inventory = new HashMap<>(); inventory.put(fixed1, "Warehouse A, Shelf 3"); // fixed2 is logically the same product — we should be able to look it up System.out.println("Lookup with equal key: " + inventory.get(fixed2)); // Warehouse A, Shelf 3 } } // --- BROKEN: equals without hashCode --- class BrokenProduct { private String name; private String sku; BrokenProduct(String name, String sku) { this.name = name; this.sku = sku; } @Override public boolean equals(Object other) { if (this == other) return true; // same reference — definitely equal if (!(other instanceof BrokenProduct)) return false; // wrong type — not equal BrokenProduct that = (BrokenProduct) other; return Objects.equals(this.sku, that.sku); // SKU is the business identity } // hashCode NOT overridden — contract is broken! } // --- FIXED: both equals AND hashCode --- class FixedProduct { private String name; private String sku; FixedProduct(String name, String sku) { this.name = name; this.sku = sku; } @Override public boolean equals(Object other) { if (this == other) return true; if (!(other instanceof FixedProduct)) return false; FixedProduct that = (FixedProduct) other; return Objects.equals(this.sku, that.sku); // identity based on SKU } @Override public int hashCode() { // Objects.hash() is null-safe and combines fields consistently return Objects.hash(this.sku); } @Override public String toString() { return "Product{name='" + name + "', sku='" + sku + "'}"; } }
equals(), not all fields. A Product is the same product if it has the same SKU — even if its price was updated. Including mutable fields like price in hashCode() is dangerous because if the price changes after the object is put in a HashSet, the object becomes unretrievable. Its hashCode changes, but the set's bucket structure doesn't update.HashMap.get() returns null on a key you just put() — hours of debugging for one missing methodequals() and hashCode() together, using the same immutable fieldsequals() says two objects are equal, hashCode() MUST return the same valueObjects.equals() and Objects.hash() for clean, null-safe implementationstoString(), getClass() and clone() — The Methods You'll Use Every Day
Once you've nailed equals() and hashCode(), toString() is the next most impactful override. Every time you log an object, print it, concatenate it with a string, or pass it to a debugger, toString() is called. A good toString() makes debugging fast. A missing one wastes hours.
A well-crafted toString() should include the class name and every field that helps a developer understand the object's state. It doesn't need to be pretty — it needs to be informative. Use the format 'ClassName{field1=value1, field2=value2}' as a convention; it's readable and follows what many libraries (like Lombok's @ToString) generate automatically.
getClass() is your runtime type inspector. It's different from instanceof — instanceof checks the hierarchy ('is this a Vehicle or anything that extends it?'), while getClass() returns the exact runtime class. This distinction matters in equals() implementations: if you use getClass() instead of instanceof for the type check, subclass instances will never be equal to parent instances, even if they hold the same data. That's sometimes what you want, but it's a conscious choice.
clone() deserves a special mention: it's marked protected in Object, it requires you to implement the Cloneable marker interface, and it performs a shallow copy by default. For most modern code, skip clone() entirely — use a copy constructor or a static factory method instead. They're clearer, safer, and don't carry clone()'s awkward checked exception.
import java.util.ArrayList; import java.util.List; public class OrderDemonstration { public static void main(String[] args) { // Build an order with some items List<String> items = new ArrayList<>(); items.add("Mechanical Keyboard"); items.add("USB-C Hub"); Order originalOrder = new Order("ORD-2024-001", "Alice", items); // toString() makes logging immediately useful System.out.println("Original: " + originalOrder); // getClass() vs instanceof — see the difference System.out.println("\ngetClass(): " + originalOrder.getClass().getSimpleName()); System.out.println("instanceof Order: " + (originalOrder instanceof Order)); System.out.println("instanceof Object: " + (originalOrder instanceof Object)); // always true! // SHALLOW copy via copy constructor — preferred over clone() Order shallowCopy = new Order(originalOrder); System.out.println("\nShallow copy: " + shallowCopy); // Demonstrate shallow copy danger: modifying the shared list affects both! originalOrder.getItems().add("Monitor Stand"); // mutating the shared list System.out.println("\nAfter adding item to original:"); System.out.println("Original items: " + originalOrder.getItems()); System.out.println("Copy items: " + shallowCopy.getItems()); // also changed — shallow! // DEEP copy — creates a new list so they're truly independent Order deepCopy = Order.deepCopyOf(originalOrder); originalOrder.getItems().add("Laptop Stand"); System.out.println("\nAfter adding another item to original:"); System.out.println("Original items: " + originalOrder.getItems()); System.out.println("Deep copy items: " + deepCopy.getItems()); // NOT changed — deep! } } class Order { private String orderId; private String customerName; private List<String> items; // Standard constructor Order(String orderId, String customerName, List<String> items) { this.orderId = orderId; this.customerName = customerName; this.items = items; // stores the reference — intentional for shallow copy demo } // SHALLOW copy constructor — shares the same list reference Order(Order source) { this.orderId = source.orderId; this.customerName = source.customerName; this.items = source.items; // same list object — changes in one affect the other } // DEEP copy factory method — truly independent copy static Order deepCopyOf(Order source) { // new ArrayList<>(source.items) creates a brand new list with the same contents return new Order(source.orderId, source.customerName, new ArrayList<>(source.items)); } public List<String> getItems() { return items; } @Override public String toString() { // Informative format: ClassName{field=value, ...} return "Order{id='" + orderId + "', customer='" + customerName + "', items=" + items + "}"; } }
equals() — one excludes subclasses, the other includes themclone() — they're explicit, safe, and checked at compile timefinalize(), wait(), notify() — The Object Methods You Should Know But Rarely Touch
The Object class has a few methods that feel intimidating but follow simple rules once you know their purpose.
finalize() was Java's original attempt at a destructor. It gets called by the garbage collector before an object is removed from memory. Sounds useful — but it's so unpredictable (you have no control over when the GC runs) that it's been deprecated since Java 9. Never use it for releasing resources. Use try-with-resources and the AutoCloseable interface instead. finalize() is in Object because Java needed a hook for cleanup at design time; in practice, it turned into a performance and correctness nightmare.
wait(), notify(), and notifyAll() are the foundation of Java's built-in thread synchronisation. They live on Object — not on Thread — because the lock in Java belongs to the object, not the thread. Any object can act as a lock via the 'synchronized' keyword. wait() tells the current thread to release the lock and park itself until notified. notify() wakes one waiting thread. notifyAll() wakes all of them. These three methods must always be called from inside a synchronized block, otherwise you get an IllegalMonitorStateException.
For modern concurrent code, java.util.concurrent offers better tools — ReentrantLock, Semaphore, CountDownLatch. But understanding wait/notify helps you understand what those abstractions are built on top of.
// A simple producer-consumer using wait() and notify() from Object // This demonstrates WHY these methods live on Object — the lock belongs to the channel object public class MessageChannel { private String message; // the shared data private boolean messageReady = false; // guard flag — prevents spurious wake-ups // Called by the PRODUCER thread public synchronized void sendMessage(String newMessage) throws InterruptedException { // If there's already an unread message, wait until the consumer reads it while (messageReady) { wait(); // releases the lock on 'this' and parks this thread } this.message = newMessage; this.messageReady = true; System.out.println("[Producer] Sent: " + newMessage); notify(); // wake up the consumer thread } // Called by the CONSUMER thread public synchronized String receiveMessage() throws InterruptedException { // If no message is ready, wait until the producer sends one while (!messageReady) { wait(); // releases the lock and parks this thread } messageReady = false; System.out.println("[Consumer] Received: " + message); notify(); // wake up the producer thread return message; } public static void main(String[] args) { MessageChannel channel = new MessageChannel(); // Producer thread — sends three messages Thread producer = new Thread(() -> { try { channel.sendMessage("Order #1001 confirmed"); channel.sendMessage("Order #1002 confirmed"); channel.sendMessage("DONE"); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }, "ProducerThread"); // Consumer thread — reads until it gets DONE Thread consumer = new Thread(() -> { try { String received; do { received = channel.receiveMessage(); } while (!received.equals("DONE")); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }, "ConsumerThread"); consumer.start(); // start consumer first — it will wait for messages producer.start(); } }
wait() and notify() belong to Object instead of Thread?' The answer: because the lock in Java is owned by the object, not the thread. Any object can be a lock. wait() and notify() operate on that lock, so they naturally belong on Object. If they were on Thread, you couldn't coordinate two threads through a shared data object — which is exactly what you need for a producer-consumer.Real-World Pattern: Entity Objects vs Value Objects vs DTOs
Knowing which Object methods to override isn't a theoretical exercise — it depends entirely on what role your class plays in the system. Three common patterns emerge in production code: Entity objects, Value objects, and DTOs. Each has a different relationship with equals(), hashCode(), and toString().
Entity objects have a persistent identity (like a database ID). Two entity instances are equal if they share the same ID, even if their other fields differ. Always override equals() and hashCode() using only the identity field (the primary key). Including mutable fields corrupts your collections if those fields change after the entity is loaded into a session.
Value objects have no identity — two value objects are equal if all their fields match. Think Money(amount=100, currency="USD") equals another Money with the same values. Override equals() and hashCode() using ALL fields. These are ideal candidates for Java records (introduced in Java 16) which generate equals(), hashCode(), and toString() automatically from the component fields.
DTOs (Data Transfer Objects) carry data across boundaries — REST requests, service layers, message queues. They rarely need equals() or hashCode() because you never store them in a HashMap or HashSet. Leave the defaults. But always override toString() for logging — when your DTO appears in a failed validation log, you need to see the data, not ClassName@hex.
Getting this distinction wrong is how production bugs happen. Treating an entity as a value object (comparing all fields) breaks detached entity equality. Treating a DTO as an entity (comparing by a null ID) throws NullPointerException. Know your pattern, override accordingly.
import java.util.Objects; import java.util.HashSet; import java.util.UUID; public class EntityValueDTOPatterns { public static void main(String[] args) { // --- ENTITY: identity-based equality --- UserEntity alice1 = new UserEntity(UUID.fromString("a1b2c3d4-..."), "Alice", "alice@example.com"); UserEntity alice2 = new UserEntity(UUID.fromString("a1b2c3d4-..."), "Alice", "alice@work.com"); // same ID but different email — still the same entity System.out.println("Entity equality (same ID): " + alice1.equals(alice2)); // true HashSet<UserEntity> entitySet = new HashSet<>(); entitySet.add(alice1); entitySet.add(alice2); System.out.println("Entity set size (should be 1): " + entitySet.size()); // 1 // --- VALUE OBJECT: all-fields equality --- Money price1 = new Money(new BigDecimal("29.99"), "USD"); Money price2 = new Money(new BigDecimal("29.99"), "USD"); System.out.println("\nValue equality (all fields match): " + price1.equals(price2)); // true HashSet<Money> priceSet = new HashSet<>(); priceSet.add(price1); priceSet.add(price2); System.out.println("Value set size (should be 1): " + priceSet.size()); // 1 // --- DTO: no equality needed, just logging --- CreateUserRequest request = new CreateUserRequest("Alice", "alice@example.com"); System.out.println("\nDTO for logging: " + request); // DTOs are never stored in HashSets — default equals is fine } } // --- ENTITY: identity-based (ID only) --- class UserEntity { private UUID id; private String name; private String email; UserEntity(UUID id, String name, String email) { this.id = id; this.name = name; this.email = email; } @Override public boolean equals(Object other) { if (this == other) return true; if (!(other instanceof UserEntity)) return false; UserEntity that = (UserEntity) other; return Objects.equals(this.id, that.id); // ID only — name/email can change } @Override public int hashCode() { return Objects.hash(id); // hash on ID only — matches equals() } @Override public String toString() { return "UserEntity{id=" + id + ", name='" + name + "', email='" + email + "'}"; } } // --- VALUE OBJECT: all fields --- class Money { private BigDecimal amount; private String currency; Money(BigDecimal amount, String currency) { this.amount = amount; this.currency = currency; } @Override public boolean equals(Object other) { if (this == other) return true; if (!(other instanceof Money)) return false; Money that = (Money) other; return Objects.equals(this.amount, that.amount) && Objects.equals(this.currency, that.currency); } @Override public int hashCode() { return Objects.hash(amount, currency); } @Override public String toString() { return currency + " " + amount; } } // --- DTO: toString only --- class CreateUserRequest { private String name; private String email; CreateUserRequest(String name, String email) { this.name = name; this.email = email; } // No equals() or hashCode() — default reference equality is fine // But toString() is essential for request logging @Override public String toString() { return "CreateUserRequest{name='" + name + "', email='" + email + "'}"; } }
- Entity = persistent identity — use ID-only in
equals()/hashCode() - Value object = structural equality — use all fields in
equals()/hashCode() - DTO = data carrier — skip
equals()/hashCode(), always override toString() - Java records generate value-object-style methods automatically — use them for value objects
equals() in a HashSet cause unpredictable memory bloat — they're compared by reference anywayequals() in a caching layer produce duplicate cache entries — identical data has multiple cache keysWhy Default equals() Will Burn You in Production
The default equals() from Object uses reference equality — it checks if two variables point to the exact same memory address. That's rarely what you want. Two HTTP requests create two distinct objects with identical field values. With default equals(), they're not equal. This breaks HashSet lookups, HashMap keys, and collection contains() calls silently. No compiler warning. No runtime error. Just subtle bugs at 3 AM. You must override equals() whenever your objects carry identity through their fields. Remember: if you override equals(), you MUST override hashCode(). Period. The contract is non-negotiable. Two equal objects MUST produce the same hash code. Break this, and your objects vanish from HashSets — because they're stored in the wrong bucket.
// io.thecodeforge @Entity public class Order { @Id private Long id; private String customerId; private BigDecimal total; @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; Order order = (Order) o; return id != null && id.equals(order.id); } @Override public int hashCode() { return getClass().hashCode(); / Use class-based hash for entities } }
id.equals() in equals() if id can be null. Check id != null first, or use Objects.equals(). A null id means the entity wasn't persisted yet — two transient entities should NOT be equal.The One JVM Method That Breaks in Mocked Tests
getClass() returns the runtime class of an object. It's final — you cannot override it. This matters when you're writing equals() that uses instanceof vs getClass(). Using instanceof allows subclasses to be equal to their parent — dangerous for entities but useful for value objects. getClass() enforces strict type equality. Here's the production trap: mocking frameworks like Mockito return proxied objects. A mocked OrderService.getClass() returns a CGLIB proxy class, not OrderService. If your equals() uses getClass(), a mock will never equal the real object. Always define equals() on interfaces or abstract classes when working with mocked dependencies. For entities, use getClass() and accept that mocks won't match. For services, don't override equals() at all — they should be singletons.
// io.thecodeforge @Service public class PaymentService { public boolean processPayment(Order order) { // Business logic here return true; } } @ExtendWith(MockitoExtension.class) class PaymentServiceTest { @Mock private PaymentService mockService; @Test void testMockClass() { // Prints: class com.example.PaymentService$MockitoMock$123 System.out.println(mockService.getClass()); // NEVER do this in production equals logic: // if (getClass() != obj.getClass()) return false; // It will fail for mocked objects! } }
equals() as the type discriminator. But never call getClass() inside a toString() that logs sensitive fields — stack traces become data leaks.equals() for strict type safety. Mock proxies break this — design accordingly.The Silent Duplicate: How a Missing hashCode() Corrupted a Payment Batch
size() > 1 for transactions with identical transaction IDs. The equals() method compared by transactionId, but hashCode() was not overridden — so two logically equal objects landed in different hash buckets and both passed the deduplication check.equals() was overridden, the HashSet would correctly treat duplicates as one entry. This is wrong — HashSet uses hashCode() first to select the bucket, then equals() to check within the bucket. If hashCode() is inconsistent with equals(), duplicates land in separate buckets and equals() is never called between them.equals() using Objects.equals(this.transactionId, that.transactionId) but did not override hashCode(). The inherited Object.hashCode() returns the memory address, which differs for every new instance. Two transactions with the same ID each produced a different hash code, placing them in different buckets inside the HashSet.equals() is correctly invoked.- Override
equals()and hashCode() together or not at all — this is not optional, it is a contract the JVM enforces at the data structure level. - Use the exact same fields in both methods. If
equals()checks transactionId, hashCode() must also use transactionId. - Add a unit test that puts two equal objects into a HashSet and asserts
size()== 1 — this catches the bug before it reaches production.
size() larger than expected — duplicates presentequals() AND hashCode() are both overridden. Run: HashSet.class.getDeclaredMethod("add", Object.class) trace — if objects go to different buckets, hashCode() is missing or inconsistent.HashMap.get() returns null for a key that was just put() into the mapput() and before get(). If they differ, hashCode() uses mutable fields or is missing.wait() or notify()wait() or notify().System.out.println("hashCode before put: " + key.hashCode());Objects.hash(yourField1, yourField2) // verify consistent field usageequals() fieldsSystem.out.println(keyPut.hashCode() + " vs " + keyGet.hashCode());System.out.println(keyPut.equals(keyGet)); // verify equals logicequals() and hashCode() — never write them by handjavap -p YourClass.class | grep toStringjava -cp . -Djava.io.tmpdir=/tmp YourClass # trace object outputThread.holdsLock(this) // returns boolean — call right before wait()javap -c YourClass.class | grep monitorenterwait()/notify() in synchronized(yourLockObject) block| Method | Default Behaviour (from Object) | When You Should Override It | Best Production Pattern |
|---|---|---|---|
| toString() | Returns ClassName@hexHashCode — useless in logs | Always — for any class you'll log, print, or debug | Entity, Value, DTO: override all |
| equals(Object o) | Reference equality — same memory address only | When two objects with identical field values should be considered equal | Entity: ID only. Value: all fields. DTO: skip. |
| hashCode() | Derived from memory address (implementation-specific) | Whenever you override equals() — always both together | Entity: ID only. Value: all fields. DTO: skip. |
| getClass() | Returns the exact runtime Class object — cannot be overridden | Never — it's final. Use instanceof for type checks in equals() | Use instanceof in equals() for Liskov substitution; use getClass() for strict type safety |
| clone() | Shallow field-by-field copy, requires Cloneable | Rarely — prefer copy constructors or static factory methods instead | Use copy constructors or static factory methods — clone() is a legacy trap |
| finalize() | Empty — called by GC before object removal | Never — deprecated since Java 9. Use AutoCloseable instead | Use try-with-resources and AutoCloseable — finalize() is unpredictable and slow |
wait() / notify() | Thread coordination via the object's monitor lock | Never overridden — used as-is inside synchronized blocks | Prefer java.util.concurrent (ReentrantLock, Semaphore) for new code |
Key takeaways
equals(), hashCode(), getClass(), and the threading methods available, whether you asked for them or not.equals() returns true for two objects, their hashCode() MUST return the same value — break this and HashMap/HashSet silently corrupt your data.notify() live on Object, not Thread, because locks in Java belong to objectsfinalize() for resource cleanupclone() for copying complex objects — use copy constructors or static factory methods instead. Both are Object methods that looked good on paper and failed in practice.Objects.equals() and Objects.hash() from java.util.ObjectsCommon mistakes to avoid
5 patternsOverriding equals() but forgetting hashCode()
HashMap.get() returns null for a key you just put() — the data structure silently corrupts your data without throwing any error or warningequals() and hashCode() at the same time. Use Objects.hash(field1, field2) for a clean, null-safe hashCode() that matches your equals() fields. Never write one without the other.Using mutable fields in hashCode()
Calling wait() or notify() outside a synchronized block
wait() and notify() refuse to operatewait(), notify(), and notifyAll() from within a synchronized method or a synchronized(object) block on the same object you're waiting/notifying on. Verify this with Thread.holdsLock(this) before calling wait().Overriding equals() using getClass() when instanceof would be correct
equals() unless you explicitly want to prevent subclass equality. If you must use getClass(), document the decision — it violates the principle that a subclass should extend the parent's contract.Using clone() for deep copying complex objects
clone() entirely. Use a copy constructor (public MyClass(MyClass source)) or a static factory method (MyClass.copyOf(source)). These are explicit, checked at compile time, and support deep copying naturally.Interview Questions on This Topic
Why do wait() and notify() live on the Object class instead of the Thread class?
wait() releases the current thread's hold on that object's lock, and notify() wakes a thread waiting on that same object's lock. If these methods lived on Thread, you couldn't coordinate two threads through a shared data object — you'd need to pass the Thread reference around. The design communicates a key insight: it's the object being synchronized on that matters, not the thread doing the synchronizing.What happens if you override equals() in a class but don't override hashCode()? Give a concrete example of the bug this causes.
equals(), they MUST produce the same hashCode(). Without the override, hashCode() uses Object's default (typically derived from memory address). Two logically equal objects land in different hash buckets inside HashMap or HashSet. For example, a HashSet<Product> with two products sharing the same SKU stores both entries because they go into different buckets — equals() is never called between them. The set reports size() = 2 instead of 1. HashMap.get() returns null for a key you just put() because get() looks in the bucket corresponding to the current hashCode(), which differs from the hashCode() used during put().What's the difference between using instanceof and getClass() in an equals() implementation, and which should you use?
equals(), a DiscountedProduct can be equal to a Product if they share the same fields. This follows the Liskov substitution principle. If you use getClass(), they can never be equal, which violates Liskov substitution and breaks polymorphism. The general recommendation is to use instanceof unless you have an explicit design reason to prevent subclass equality. If you use getClass(), your equals() implementation is not symmetric when subclasses are involved — a Product can be equal to a DiscountedProduct (via instanceof) but a DiscountedProduct cannot be equal to a Product (via getClass()).Can you explain why the default hashCode() in Object is typically derived from the memory address, and why this is a problem for HashMap keys?
equals(). If you override equals() to compare by value but keep the default hashCode(), two objects with the same field values (and therefore equal according to equals()) will have different hash codes. HashMap relies on hashCode() to select the bucket — so these equal objects go into different buckets, and the map treats them as different keys. The hash code should derive from the same fields that equals() uses, not from the object's memory location.Why was finalize() deprecated, and what should you use instead for resource cleanup?
finalize(), and you have no control over GC timing — it might never run before the JVM exits. Finalizers also cause performance issues: objects with finalize() take longer to collect because the GC must track them separately. They can resurrect objects, and exceptions in finalize() are silently swallowed. Instead, use the AutoCloseable interface with try-with-resources. This guarantees cleanup happens deterministically when the try block exits, whether normally or via an exception. For cleanup that must run even without an explicit try-with-resources, use Cleaner (Java 9+) which is lighter than finalize() but still not as predictable as try-with-resources.Frequently Asked Questions
Yes — every class in Java implicitly extends java.lang.Object if it doesn't explicitly extend another class. Even that other class ultimately extends Object somewhere up the chain. Arrays and interfaces are also objects at runtime. It's truly universal.
The default equals() inherited from Object checks reference equality — it returns true only if both references point to the exact same object in memory (equivalent to ==). It has no knowledge of your fields. If you want two separate objects with the same data to be considered equal, you must override it.
Technically it compiles, but it violates the spirit of the contract and creates confusing behaviour. The equals/hashCode contract runs in one direction: equal objects must have equal hash codes. If you override hashCode() without equals(), two objects that share a hash code still won't be considered equal by collections — they'll both be stored in a HashSet. Always override both or neither.
Use instanceof unless you have an explicit design reason to prevent subclass equality. instanceof follows the Liskov substitution principle — a subclass instance can be equal to a parent instance. getClass() enforces stricter type identity — subclass instances are never equal to parent instances. The instanceof approach is more flexible and is the convention used by most Java libraries. If you use getClass(), document it clearly because it breaks the expectation that equals() is symmetric across the class hierarchy.
Yes, but understand what they generate. @Data generates equals() and hashCode() from ALL non-static, non-transient fields. This is perfect for value objects but wrong for entities — entities should only use the ID field. For entities, manually specify @EqualsAndHashCode(onlyExplicitlyIncluded = true) and annotate only the ID field with @EqualsAndHashCode.Include. Never blindly trust Lombok defaults — they're designed for the general case, not your specific domain model.
The object becomes permanently unretrievable from that HashSet. When you inserted it, the hash code was computed from the original field values, and the object was placed in the corresponding bucket. After mutation, hashCode() returns a different value. Calling contains() or remove() computes the new hash code, looks in the wrong bucket, and never finds the object. The object leaks until the collection is garbage collected. This is why hashCode() must only use immutable fields.
20+ years shipping production Java in banking & fintech. Lessons pulled from things that broke in production.
That's OOP Concepts. Mark it forged?
7 min read · try the examples if you haven't