equals/hashCode, and SOLIDTerse, high-density. Keyword → rule. Everything here is derivable from Section II.
| instanceof | getClass() | |
|---|---|---|
| Subclass == superclass? | can be true | never |
| Symmetry risk | can break (fix: make equals final) | safe |
| LSP | respects | can violate (trivial subclass) |
| null | auto-false (no null check needed) | need explicit null check |
| hashCode impl | effect |
|---|---|
return id; |
good spread → few collisions → fast |
return id % 5; |
only 5 distinct codes → many collisions → slower load |
| L | Principle | One-liner |
|---|---|---|
| S | Single Responsibility | 1 class → 1 reason to change |
| O | Open–Closed | open to extend, closed to modify |
| L | Liskov Substitution | subtype usable wherever base is |
| I | Interface Segregation | many small interfaces > 1 fat one |
| D | Dependency Inversion | depend on abstractions, not concretes |
Built from scratch, beginner-friendly. [+] marks additions/corrections beyond the core material.
Every Java class silently extends java.lang.Object, so a handful of methods are available on every object you ever touch.
Java has a single rooted inheritance hierarchy. When you write class Student {}, the compiler treats it as class Student extends Object {}. If your class already extends something else, that parent (or its parent, eventually) extends Object. So Object is the ancestor of all classes — directly or indirectly. It lives in the java.lang package, which is auto-imported.
Because of this, every object inherits these methods for free:
| Method | What it does |
|---|---|
toString() | String representation of the object |
equals(Object) | Equality test (default = same reference) |
hashCode() | Integer hash used by hash-based collections |
getClass() | Runtime class (a Class object) [+] |
clone() [+] | Field-by-field copy (protected; needs Cloneable) |
wait()/notify()/notifyAll() [+] | Thread coordination on the object's monitor |
finalize() [+] | Legacy cleanup hook — deprecated, don't use |
.toString() or drop any object into a HashSet without writing those methods. The three you'll override constantly are toString, equals, and hashCode — the rest of these notes is mostly about doing that correctly.Object. Follow-up: "name methods it provides" → the table above.→ Code: Object.java
Printing an object reference secretly calls its toString(); the default output is cryptic, so you override it.
When you run System.out.println(student), Java doesn't print the object's memory; it implicitly calls student.toString() and prints that string. The default implementation in Object is:
public String toString() {
return getClass().getName() + "@" + Integer.toHexString(hashCode());
}
So an un-overridden object prints like com.demo.Student@1b6d3586. Reading left to right:
getClass().getName() is the fully-qualified class name, "@" is a literal
separator, and Integer.toHexString(hashCode()) is the object's hash code rendered in
hexadecimal. [+] That hex string is not a memory address — a common
misconception. It's just the hashCode in base-16; on the default implementation it's derived from the
object's identity, but the JVM does not expose raw pointers.
Override toString() to return something meaningful:
@Override
public String toString() {
return "Student{firstName=" + firstName + ", lastName=" + lastName + ", email=" + email + "}";
}
Why override it: meaningful one-line printing via println; invaluable for logging and
debugging; represents all fields (including numeric ones) as a single string. Many built-in classes
already override it — the wrapper classes (Integer, Long,
Double, Boolean, Character, etc.), the collection classes, and
String/StringBuilder/StringBuffer — which is why printing a
list shows [1, 2, 3] rather than gibberish.
toString() for you
(in VS Code: Right-click → Source Action → Generate toString, then pick
fields). [+] Don't put secrets (passwords, tokens) in toString()
— they leak straight into logs.→ Code: ToStringDemo.java · Lab: Exercise 1
By default equals() checks whether two references point to the same
object; override it to compare by the fields you care about.
Create two Student objects with identical first name, last name, and email, and
a.equals(b) still returns false. Reason: the inherited
Object.equals() is literally this == obj — a reference (memory-location)
comparison. Two separately-constructed objects live at different locations, so they're "not equal" even when
their data matches.
To make equality mean "same student id", override it:
@Override
public boolean equals(Object obj) {
if (obj == null) return false; // nothing equals null
if (!(obj instanceof Student)) return false; // wrong type -> not equal
if (obj == this) return true; // same reference -> fast yes
return this.getId() == ((Student) obj).getId(); // compare by id
}
Read it as a funnel: reject null, reject wrong types, accept an identical reference instantly,
then fall back to the real field comparison. The instanceof check also doubles as a type guard
so the cast on the last line is safe. [+] Note instanceof already returns
false for null, so the explicit null check above is technically
redundant when you use instanceof — it's kept here for clarity.
equals() is the backbone of
collections. List.contains, Set dedup, Map key lookup — all
defer to equals(). Get it wrong and your data structures quietly misbehave.equals compares references, not fields. The five contract rules (next) are also
classic.→ Code: EqualsDemo.java · Lab: Exercise 2
The type check at the top of equals() can use instanceof or
getClass(), and the choice quietly changes how subclasses behave — a favourite interview
trap.
This wasn't spelled out in my core material, but it's the natural follow-up question, so it's worth nailing down with verified reasoning:
| Aspect | instanceof Student |
getClass() == obj.getClass() |
|---|---|---|
| Can a subclass instance equal a superclass instance? | Yes | No (exact type required) |
Symmetry (a.equals(b) == b.equals(a)) |
Can break if a subclass adds state to equals |
Always preserved |
| Liskov Substitution Principle | Respected | Can violate it for trivial subclasses |
| null handling | Auto-false; no null check needed | Needs an explicit null check |
Joshua Bloch (Effective Java) favours instanceof, because getClass() means
an object can only ever equal another of the exact same runtime class — so a harmless
subclass that merely adds a method is suddenly "not equal" to its parent, which is surprising and brushes
against the Liskov principle. The catch with instanceof: if a subclass overrides
equals and folds in extra fields, you can break symmetry (parent says "equal", child
says "not equal"). The standard fix is to make equals (and hashCode)
final, or to only use instanceof when subclasses don't change the notion of
equality.
→ Code: InstanceofVsGetClass.java
Hash-based collections find objects by their hashCode() first; if you override
equals() but not hashCode(), a HashSet will store visible duplicates.
Store two "equal" students (you've already overridden equals) in a HashSet and call
set.contains(other) — it can still return false. Why, if a
HashSet stores unique objects? Because a HashSet doesn't scan every element. It
keeps elements in buckets, and each bucket is chosen from the object's hash code. The
lookup goes: compute hashCode() → jump to that bucket → only there compare with
equals().
If two equal students return different hash codes (the default behaviour, since default
hashCode is per-instance), they land in different buckets and never get compared — so the
set never notices they're duplicates. Fix it by making equal objects produce equal hash codes:
@Override
public int hashCode() {
return id; // equal ids -> equal hashCode -> same bucket
}
hashCode whenever
you override equals" exists precisely because of bucketing. Two objects that are
equals but have different hash codes are invisible to each other inside hash collections.→ Code: HashCodeDemo.java · Lab: Exercise 3
Equality and hashing follow a small set of rules; breaking them is how collections silently corrupt.
equals() contract — for non-null references:
x.equals(x) is true.x.equals(y) iff y.equals(x).x=y and y=z then x=z.x.equals(null) is always false.The hashCode bridge — the one rule everyone forgets:
| Statement | True? |
|---|---|
| Two equal objects have the same hashCode | Must be true |
| Two objects with the same hashCode are equal | Not required (collisions are legal) |
So can two unequal objects share a hash code? Yes — that's a collision, and it's allowed. But two equal objects must never disagree on their hash code. [+] Mnemonic: "equals implies same hash, but same hash does not imply equals."
A bad hashCode() doesn't break correctness — it wrecks performance by piling
everything into a few buckets.
Replace return id; with return id % 5; and loading the same data into a
HashSet takes noticeably longer. The reason: id % 5 yields only five distinct hash
codes, so thousands of students cram into five buckets. Within a bucket, finding/inserting means comparing
against everything already there — so you've traded near-instant lookups for slow scans. The lesson:
override hashCode() wisely to spread values and avoid collisions.
[+] How a HashSet works under the hood (verified): a
HashSet is backed by a HashMap. Insertion:
hashCode() of the element.hash % capacity, but actually
hash & (capacity-1) since capacity is a power of two. [+]equals(); if
no match, chain it in.| Java version | Bucket structure | Worst-case lookup in bucket |
|---|---|---|
| 7 and earlier | Linked list | O(n) |
| 8 and later [+] | List → balanced (red-black) tree once a bucket exceeds 8 entries | O(log n) |
[+] There's also resizing: when the set fills past its load factor (default
0.75), capacity doubles and everything is rehashed — which is partly why repeatedly
adding to a poorly-distributed set is slow.
hashCode → O(1) average
operations. Degenerate hashCode (e.g. return 1;) → everything in one bucket
→ O(n) (or O(log n) on Java 8+ after treeify) — a hash set that behaves like a list.→ Code: CollisionDemo.java · Lab: Exercise 4
String.hashCode() is a polynomial in 31; the constant is an odd prime chosen
mostly because it's cheap to compute, not because it's mathematically optimal.
The formula (from the Java spec):
s[0]*31^(n-1) + s[1]*31^(n-2) + ... + s[n-1]
// computed iteratively as: h = 31*h + s[i]
Why 31 specifically (verified against Bloch's Effective Java and the original OpenJDK reasoning):
31 * i as (i << 5) - i — a shift
plus a subtract, cheaper than a multiply on older/RISC hardware.31 from K&R and picked it because it's the
cheapest to compute. State it as "a reasonable, cheap, traditional choice," not "the optimal one."→ Code: StringHash31.java · Lab: Exercise 5
SOLID is five design principles from Robert C. Martin ("Uncle Bob") that keep object-oriented code clean, loosely coupled, and easy to change.
| Letter | Principle | Plain meaning |
|---|---|---|
| S | Single Responsibility | A class should have one, and only one, reason to change. |
| O | Open–Closed | Open for extension, closed for modification. |
| L | Liskov Substitution | Subtypes must be usable anywhere their base type is expected. |
| I | Interface Segregation | Prefer several small interfaces over one fat one. |
| D | Dependency Inversion | Depend on abstractions, not concrete classes. |
Why SOLID: to write clean and maintainable code, to preserve good design over time, to keep modules loosely coupled, and to improve reusability and testability.
A class should do one job; if you can describe it with "and," it probably has too many responsibilities.
My core material named SRP but didn't develop it, so here's the full picture (verified, standard definition). "One reason to change" means each class answers to a single actor/concern. A class that parses input, applies business rules, and writes to a database has three reasons to change — a format change, a rule change, or a storage change — and any of them can break the others.
Analogy: a Swiss-army knife is convenient until one tool jams and you can't trust the rest. Separate
tools fail independently. Real-world example: split a Report class into
ReportData (holds data), ReportFormatter (renders it), and
ReportSaver (persists it). Now changing the export format never risks the persistence logic.
→ Code: SRP_Before / SRP_After · Lab: Exercise 6
You should be able to add new behaviour by adding new code, not by editing code that already works.
Also named-but-not-developed in my core material, so here's the verified standard treatment. "Open for extension, closed for modification" means: when a new requirement arrives, you extend the system (new class, new implementation of an interface) rather than reopening and rewriting tested classes. The usual vehicle is polymorphism — program to an interface, then add new implementations.
Classic smell: a growing if/else or switch on a type code. Each new type
forces an edit to the same method. Fix: define an interface (e.g. Shape.area()),
implement it per type (Circle, Square), and let callers depend on
Shape. Adding a Triangle means writing one new class — the area-calculating
code never changes.
→ Code: OCP_Before / OCP_After
My core material focused on S and O; for completeness, the remaining three (verified standard definitions):
Square extending Rectangle where setting width also changes
height — code that trusts Rectangle's behaviour breaks.Machine interface (print, scan, fax) so a simple
printer isn't forced to stub out fax().MessageSender interface instead of hard-wiring
EmailSender, so you can swap in SMS or a test double without touching the high-level logic.
Standard library only, Java 17+ (any modern JDK works). Each: filename, run command, comments, expected output.
// Reusable model. Other demos build on this file.
public class Student {
private int id;
private String firstName, lastName, email;
public Student(int id, String firstName, String lastName, String email) {
this.id = id; this.firstName = firstName;
this.lastName = lastName; this.email = email;
}
public int getId() { return id; }
public String getEmail() { return email; }
}
public class ToStringDemo {
static class Plain { int id = 7; } // no toString override
static class Nice {
int id = 7;
@Override public String toString() { // meaningful override
return "Nice{id=" + id + "}";
}
}
public static void main(String[] a) {
System.out.println(new Plain()); // default: ClassName@hexHashCode
System.out.println(new Nice()); // calls our override implicitly
}
}
hashCode is
identity-based and JVM-specific, so the part after @ changes between runs. Your overridden
output is deterministic.public class EqualsDemo {
static class Student {
int id;
Student(int id) { this.id = id; }
@Override public boolean equals(Object obj) {
if (obj == null) return false;
if (!(obj instanceof Student)) return false;
if (obj == this) return true;
return this.id == ((Student) obj).id; // value equality by id
}
}
public static void main(String[] a) {
Student s1 = new Student(1), s2 = new Student(1), s3 = new Student(2);
System.out.println(s1 == s2); // false: different references
System.out.println(s1.equals(s2)); // true : same id
System.out.println(s1.equals(s3)); // false: different id
}
}
== is false but equals is
true: == compares references (addresses); the overridden equals compares
the id field.public class InstanceofVsGetClass {
static class A {}
static class B extends A {}
public static void main(String[] x) {
Object oA = new A(), oB = new B();
// instanceof: a subclass IS-A superclass
System.out.println(oB instanceof A); // true
// getClass: exact runtime type only
System.out.println(oB.getClass() == A.class); // false
System.out.println(oB.getClass() == B.class); // true
}
}
instanceof treats a B as an A (enables cross-type equality, risks
symmetry); getClass() demands the exact class (safe symmetry, stricter).import java.util.HashSet;
public class HashCodeDemo {
static class Student {
int id;
Student(int id) { this.id = id; }
@Override public boolean equals(Object o) {
return o instanceof Student && ((Student)o).id == id;
}
// Comment OUT the next method to see the bug:
@Override public int hashCode() { return id; }
}
public static void main(String[] a) {
HashSet<Student> set = new HashSet<>();
set.add(new Student(1));
set.add(new Student(1)); // "equal" duplicate
System.out.println(set.size()); // 1 with hashCode, 2 without
System.out.println(set.contains(new Student(1))); // true / false
}
}
import java.util.HashSet;
public class CollisionDemo {
static class Key {
int id; boolean bad;
Key(int id, boolean bad) { this.id = id; this.bad = bad; }
@Override public boolean equals(Object o) {
return o instanceof Key && ((Key)o).id == id;
}
@Override public int hashCode() {
return bad ? id % 5 : id; // bad: only 5 buckets
}
}
static long load(boolean bad, int n) {
long t = System.nanoTime();
HashSet<Key> s = new HashSet<>();
for (int i = 0; i < n; i++) s.add(new Key(i, bad));
return (System.nanoTime() - t) / 1_000_000; // ms
}
public static void main(String[] a) {
int n = 200_000;
System.out.println("good hashCode: " + load(false, n) + " ms");
System.out.println("bad hashCode: " + load(true, n) + " ms");
}
}
id % 5 gives 5 distinct
codes, so 200k items chain into 5 buckets and each insert scans a long chain. You might expect Java 8+
treeification to rescue this — it does not, and the reason is worth knowing:
treeify only restores O(log n) lookups when the keys are Comparable. A red-black bin
orders entries by hash first; when every hash in the bin is identical, it needs
compareTo to break the tie. Key here implements only
equals/hashCode, so the bin falls back to scanning, and the load stays
quadratic. Measured on this machine: adding implements Comparable<Key> takes
n=100k from 3412 ms to 30 ms.public class StringHash31 {
// Reproduce String.hashCode() by hand
static int myHash(String s) {
int h = 0;
for (int i = 0; i < s.length(); i++) h = 31 * h + s.charAt(i); // h = (h<<5) - h + c
return h;
}
public static void main(String[] a) {
System.out.println(myHash("Java")); // matches built-in
System.out.println("Java".hashCode());
// 31*x optimization identity:
int x = 12345;
System.out.println((31 * x) == ((x << 5) - x)); // true
}
}
true proves 31*x == (x<<5)-x — the
shift/subtract trick the JIT uses.public class SRP_Demo {
// BEFORE: one class with 3 reasons to change (data + format + save)
static class ReportBad {
String data = "sales=100";
String toHtml() { return "<p>" + data + "</p>"; }
void save() { System.out.println("saving: " + data); }
}
// AFTER: one responsibility each
static class ReportData { String data = "sales=100"; }
static class HtmlFormatter { String format(ReportData r){ return "<p>"+r.data+"</p>"; } }
static class FileSaver { void save(ReportData r){ System.out.println("saving: "+r.data);} }
public static void main(String[] a) {
ReportData d = new ReportData();
System.out.println(new HtmlFormatter().format(d)); // change format here only
new FileSaver().save(d); // change persistence here only
}
}
public class OCP_Demo {
// BEFORE: adding a shape forces editing this method (closed? no).
// double area(Object s){ if (s instanceof Circle)... else if ... }
// AFTER: open for extension via an interface, closed for modification.
interface Shape { double area(); }
static class Circle implements Shape {
double r; Circle(double r){this.r=r;}
public double area(){ return Math.PI*r*r; }
}
static class Square implements Shape {
double s; Square(double s){this.s=s;}
public double area(){ return s*s; }
}
// Adding Triangle = new class, ZERO edits to this method:
static double total(Shape[] shapes){
double t=0; for (Shape s: shapes) t += s.area(); return t;
}
public static void main(String[] a){
System.out.printf("%.2f%n", total(new Shape[]{ new Circle(1), new Square(2) }));
}
}
Warm-up → Core → Challenge. Predict before you run; reveal the solution only after.
# 1. Check the JDK (need 11+, 17+ recommended)
java -version
javac -version
# 2. Make a working folder
mkdir solid-lab && cd solid-lab
# 3. Compile & run any file (single-file source mode, JDK 11+):
java Exercise1.java # runs directly, no separate javac needed
# or the classic two-step:
javac Exercise1.java && java Exercise1
Goal: see the default toString(), then override it.
Exercise1.java with a class Book holding title and
pages.main, print a Book instance with no override.@Override toString() and print again.@ represent?public class Exercise1 {
static class Book {
String title; int pages;
Book(String t, int p){ title=t; pages=p; }
@Override public String toString(){ // comment out to see default
return "Book{title=" + title + ", pages=" + pages + "}";
}
}
public static void main(String[] a){
System.out.println(new Book("SOLID", 320));
}
}
Goal: make two distinct objects compare equal by a chosen field.
User with an email field.== and .equals() before
overriding.equals() to compare emails (case-insensitive). Re-test.== ever become true after your
override? Why not?public class Exercise2 {
static class User {
String email; User(String e){ email=e; }
@Override public boolean equals(Object o){
if (!(o instanceof User)) return false; // also handles null
return email.equalsIgnoreCase(((User)o).email);
}
}
public static void main(String[] a){
User u1 = new User("A@x.com"), u2 = new User("a@x.com");
System.out.println(u1 == u2); // false (always: 2 objects)
System.out.println(u1.equals(u2)); // true (case-insensitive match)
}
}
== stays false because they remain two separate objects in memory; only
equals looks at the field.
Goal: reproduce the "equal objects still duplicate" bug, then fix it.
User from Exercise 2 (with equals overridden, no
hashCode).HashSet; print size().@Override hashCode() returning email.toLowerCase().hashCode(). Re-test.
equals not fix dedup?import java.util.HashSet;
public class Exercise3 {
static class User {
String email; User(String e){ email=e; }
@Override public boolean equals(Object o){
return o instanceof User && email.equalsIgnoreCase(((User)o).email);
}
@Override public int hashCode(){ // remove to see the bug
return email.toLowerCase().hashCode(); // equal -> same bucket
}
}
public static void main(String[] a){
HashSet<User> s = new HashSet<>();
s.add(new User("a@x.com")); s.add(new User("A@x.com"));
System.out.println(s.size()); // 1 with hashCode, 2 without
}
}
Without matching hash codes the two users land in different buckets, so the set never
compares them with equals — both get stored.
Goal: feel the performance cost of collisions.
Key with an int id and a switchable hashCode
(id vs id % 4).HashSet for each version (System.nanoTime()).
id % 4
effectively use? Predict the slowdown ratio, then check.import java.util.HashSet;
public class Exercise4 {
static class Key {
int id; boolean bad; Key(int id, boolean bad){ this.id=id; this.bad=bad; }
@Override public boolean equals(Object o){ return o instanceof Key && ((Key)o).id==id; }
@Override public int hashCode(){ return bad ? id % 4 : id; }
}
static long run(boolean bad){
long t=System.nanoTime(); HashSet<Key> s=new HashSet<>();
for (int i=0;i<200_000;i++) s.add(new Key(i,bad));
return (System.nanoTime()-t)/1_000_000;
}
public static void main(String[] a){
System.out.println("good: "+run(false)+" ms");
System.out.println("bad : "+run(true) +" ms");
}
}
id % 4 uses only 4 distinct codes; all 200k items chain into 4 buckets.
[+] Treeification does not save you here: a tree bin orders by
hash, and with every hash equal it needs the keys to be Comparable to break the tie.
This key is not, so the bin degenerates to a scan and the cost really is quadratic — note
the run takes ~23 seconds, not milliseconds. Make the key Comparable and
the same workload drops back to tens of ms.
Goal: match the JDK's String hash and prove the 31 shortcut.
myHash(String) using h = 31*h + ch."Hello".hashCode() for several strings.31*x == (x<<5)-x for some x.public class Exercise5 {
static int myHash(String s){
int h=0; for (int i=0;i<s.length();i++) h = 31*h + s.charAt(i); return h;
}
public static void main(String[] a){
for (String s : new String[]{"Hello","SOLID","a"}) System.out.println(s+": mine="+myHash(s)+" jdk="+s.hashCode());
int x=987; System.out.println("shift trick: " + (31*x == (x<<5)-x));
}
}
It must match: this is the documented algorithm. (A single char like
'a' hashes to its code point, 97.)
Goal: take a fat class with a type-switch and refactor it to satisfy both principles.
PaymentProcessor with process(String type, double amt) that does
if "card"... else if "cash"... and also logs.PaymentMethod interface + one class per method.
WalletPayment without editing the dispatch code — prove OCP.public class Exercise6 {
// SRP: logging is its own responsibility
interface Logger { void log(String m); }
static class ConsoleLogger implements Logger {
public void log(String m){ System.out.println("[log] " + m); }
}
// OCP: each payment type is a separate implementation
interface PaymentMethod { void pay(double amt); }
static class CardPayment implements PaymentMethod {
public void pay(double a){ System.out.println("card -> " + a); }
}
static class CashPayment implements PaymentMethod {
public void pay(double a){ System.out.println("cash -> " + a); }
}
static class WalletPayment implements PaymentMethod { // ADDED, no edits elsewhere
public void pay(double a){ System.out.println("wallet -> " + a); }
}
// Dispatch depends on the abstraction; never changes when types are added
static class Processor {
Logger logger;
Processor(Logger l){ logger = l; }
void process(PaymentMethod m, double amt){
m.pay(amt); logger.log("processed " + amt);
}
}
public static void main(String[] a){
Processor p = new Processor(new ConsoleLogger());
p.process(new CardPayment(), 100);
p.process(new CashPayment(), 50);
p.process(new WalletPayment(), 25); // works with zero dispatch edits
}
}
Adding WalletPayment touched zero existing classes → OCP satisfied.
Logging lives apart from payment logic → SRP satisfied.
println(obj) shows ClassName@hex and that the hex is a
hashCode, not an address.==-unequal but can be
equals-equal.equals without hashCode breaks
HashSet dedup (buckets).instanceof vs getClass() (symmetry vs LSP).(i<<5)-i) and note it isn't provably
optimal.toString, equals,
hashCode, getClass, and more.toString() is auto-called by println; override it for readable
logging/debugging. Default is ClassName@hexHashCode (hex = hashCode, [+] not a memory address).equals() defaults to reference equality; override for value equality and respect the
contract (reflexive, symmetric, transitive, consistent, false-on-null).instanceof vs getClass() [+] is a real
trade-off: instanceof respects LSP but can break symmetry; getClass() is
symmetric but rejects subclasses.hashCode() & buckets: override it whenever you override equals.
Equal objects must share a hashCode or hash collections fail.Comparable: a tree bin orders by hash, and if
every hash collides it needs compareTo to break the tie. Exercise 4
measures exactly this — a non-Comparable key stays quadratic.String.hashCode is odd + prime + cheap
((i<<5)-i); [+] traditional, not proven optimal.
[+] = my own additions/corrections, all fact-checked. Core spine: Object methods
(toString/equals/hashCode) + intro to SOLID with SRP and
Open–Closed.