Field note / java

publishedupdated 2026-05-01#java#jvm#memory-model#string#equals-hashcode

Core Java & JVM Internals

Java memory model, class loading, object lifecycle, pass-by-value semantics, equals/hashCode contract, and String internals.

Overview

  • The JVM divides memory into distinct regions: Heap (objects), Metaspace (class metadata), Stack (per-thread frames), and PC Register (current instruction pointer). Understanding these is essential for GC tuning and diagnosing memory leaks.
  • Java is strictly pass-by-value. When you pass an object, you pass a copy of the reference — the callee can mutate the object's fields, but cannot make the caller's variable point to a different object.
  • String literals are deduplicated in the String Pool (part of Metaspace). new String("hello") bypasses the pool and allocates on the heap. intern() moves a heap string into the pool and returns the canonical reference.
  • The equals()/hashCode() contract is the foundation of all hash-based collections. Violating it causes silent, catastrophic bugs in HashMap, HashSet, and anything that indexes by object identity.
  • Immutability (final fields, no setters, defensive copies) is the single most effective technique for writing thread-safe value objects. String, Integer, LocalDate are all immutable by design.

Architecture

JVM Memory Layout
═══════════════════════════════════════════════════════════════════

  ┌─────────────────────────────────────────────────────────┐
  │                        HEAP                             │
  │  ┌─────────────────────────────┐  ┌─────────────────┐  │
  │  │       Young Generation      │  │  Old Generation  │  │
  │  │  ┌───────┐ ┌────┐ ┌─────┐  │  │                  │  │
  │  │  │ Eden  │ │ S0 │ │ S1  │  │  │  Long-lived       │  │
  │  │  │ (new  │ │    │ │     │  │  │  objects           │  │
  │  │  │  obj) │ │(to)│ │(from│  │  │  (survived 15+    │  │
  │  │  └───────┘ └────┘ └─────┘  │  │   minor GCs)      │  │
  │  │   ← Minor GC (fast) →      │  │  ← Major GC →     │  │
  │  └─────────────────────────────┘  └─────────────────┘  │
  └─────────────────────────────────────────────────────────┘

  ┌─────────────────────────────────────────────────────────┐
  │                      METASPACE                          │
  │   Class metadata │ Method bytecode │ String Pool        │
  │   Static fields  │ Interned Strings                     │
  └─────────────────────────────────────────────────────────┘

  ┌────────────────────┐   ┌────────────────────┐
  │   Thread Stack #1  │   │   Thread Stack #2  │
  │  ┌──────────────┐  │   │  ┌──────────────┐  │
  │  │  Frame: main │  │   │  │  Frame: run  │  │
  │  │  local vars  │  │   │  │  local vars  │  │
  │  │  operand stk │  │   │  │  operand stk │  │
  │  └──────────────┘  │   │  └──────────────┘  │
  │  PC Register       │   │  PC Register       │
  └────────────────────┘   └────────────────────┘

  GC Roots: Static fields, local vars on stacks, JNI refs
  Objects reachable from GC roots are ALIVE. All others collected.

  String pool behaviour:
    String a = "hello";          ──► pool["hello"] (Metaspace)
    String b = "hello";          ──► same pool["hello"]  → a == b: TRUE
    String c = new String("hello"); ──► heap object       → a == c: FALSE
    String d = c.intern();       ──► pool["hello"]       → a == d: TRUE

Technical Implementation

Correct equals() and hashCode() for a Value Class

import java.time.LocalDate;
import java.util.Objects;

public final class Person {
    private final String name;
    private final LocalDate dateOfBirth;

    public Person(String name, LocalDate dateOfBirth) {
        this.name = Objects.requireNonNull(name, "name");
        this.dateOfBirth = Objects.requireNonNull(dateOfBirth, "dateOfBirth");
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;                      // reflexive fast-path
        if (!(o instanceof Person)) return false;        // null-safe + type check
        Person other = (Person) o;
        return Objects.equals(name, other.name)
            && Objects.equals(dateOfBirth, other.dateOfBirth);
    }

    // Contract: equal objects MUST return same hashCode.
    // Objects.hash() uses 31-prime polynomial — low collision rate.
    @Override
    public int hashCode() {
        return Objects.hash(name, dateOfBirth);
    }

    // Optionally implement Comparable for natural ordering
    // Always consistent with equals() — same fields, same order.
    public String getName() { return name; }
    public LocalDate getDateOfBirth() { return dateOfBirth; }

    @Override
    public String toString() {
        return "Person{name='" + name + "', dob=" + dateOfBirth + '}';
    }
}

// Breaking the contract: this causes HashSet to contain "duplicates"
// public int hashCode() { return 42; }  // All objects in same bucket — O(n) lookup
// public int hashCode() { return name.hashCode(); }  // Ignores dob — wrong equals match

String Pool and intern() Semantics

public class StringPoolDemo {
    public static void main(String[] args) {
        // Literals — interned automatically at compile time
        String a = "hello";
        String b = "hello";
        System.out.println(a == b);          // true  — same pool reference
        System.out.println(a.equals(b));     // true

        // new String() bypasses the pool — always a fresh heap object
        String c = new String("hello");
        System.out.println(a == c);          // false — different references
        System.out.println(a.equals(c));     // true  — same content

        // intern() deduplicates: returns pool reference, allows GC of heap copy
        String d = c.intern();
        System.out.println(a == d);          // true  — d now points to pool entry

        // Runtime strings are NOT pooled automatically
        String s1 = "hel" + "lo";           // compile-time constant → pooled
        String s2 = "hel";
        String s3 = s2 + "lo";              // runtime concat → heap object
        System.out.println(s1 == s3);        // false
        System.out.println(s1 == s3.intern()); // true

        // String.format and StringBuilder results are always on heap — never pooled
        // Use intern() with care in hot paths: pool lookup has its own cost.
    }
}

Immutable Class with Builder Pattern

import java.time.LocalDate;
import java.util.Collections;
import java.util.List;
import java.util.ArrayList;
import java.util.Objects;

// All rules for immutability:
// 1. Class is final (no subclass can break invariants)
// 2. All fields are private final
// 3. No setters
// 4. Defensive copies of mutable inputs and outputs
// 5. Don't leak 'this' from constructor
public final class Employee {
    private final String id;
    private final String name;
    private final LocalDate hireDate;
    private final List<String> roles; // mutable — must be defensively copied

    private Employee(Builder builder) {
        this.id = Objects.requireNonNull(builder.id, "id");
        this.name = Objects.requireNonNull(builder.name, "name");
        this.hireDate = Objects.requireNonNull(builder.hireDate, "hireDate");
        // Defensive copy: caller mutating their list after build won't affect us
        this.roles = Collections.unmodifiableList(new ArrayList<>(builder.roles));
    }

    public String getId()           { return id; }
    public String getName()         { return name; }
    public LocalDate getHireDate()  { return hireDate; }
    // Defensive copy on output: caller can't mutate the internal list
    public List<String> getRoles()  { return roles; } // already unmodifiable

    public static class Builder {
        private String id;
        private String name;
        private LocalDate hireDate;
        private final List<String> roles = new ArrayList<>();

        public Builder id(String id)             { this.id = id; return this; }
        public Builder name(String name)         { this.name = name; return this; }
        public Builder hireDate(LocalDate d)     { this.hireDate = d; return this; }
        public Builder role(String role)         { roles.add(role); return this; }

        public Employee build() { return new Employee(this); }
    }

    // Usage:
    // Employee e = new Employee.Builder()
    //     .id("E001").name("Avery").hireDate(LocalDate.of(2022, 10, 1))
    //     .role("Engineer").role("Architect")
    //     .build();
}

Interview Preparation

Q: Why must you override hashCode() when you override equals()?

The HashMap (and all hash-based collections) use a two-step lookup: first compute hashCode() to find the bucket, then call equals() within that bucket to find the exact key. If two objects are equals() but have different hashCode() values, they will land in different buckets and the map will never find the second one — get() returns null even though the key is logically present. The JDK contract states: if a.equals(b) then a.hashCode() == b.hashCode(). The converse is not required — hash collisions are allowed. IDEs generate both methods together precisely to prevent this mistake.

Q: What is the Java Memory Model and why does volatile matter?

The Java Memory Model (JMM, JSR-133) defines the rules for when writes by one thread become visible to reads by another. Without synchronization, the JVM and CPU are free to reorder instructions and cache values in CPU registers or L1 cache — a write by Thread A may not be seen by Thread B for an indefinite period. volatile establishes a happens-before relationship: a write to a volatile field is immediately flushed to main memory, and any subsequent read of that field sees the latest value. It also prevents reordering of instructions around the volatile access. Critically, volatile only guarantees visibility, not atomicityvolatile int count; count++ is still a read-modify-write race condition. Use AtomicInteger for atomic compound operations.

Q: Is String thread-safe and why?

Yes. String is immutable — its char[] (or byte[] in Java 9+ compact strings) is set once in the constructor and never modified. Because no mutation is possible, there is no shared mutable state and therefore no race conditions. Multiple threads can safely read the same String reference concurrently without any synchronization. This is why String is the preferred type for map keys, configuration values, and anything shared across threads. StringBuilder is mutable and NOT thread-safe; StringBuffer is thread-safe via synchronized methods but rarely needed.

Q: Explain the difference between Comparable and Comparator.

Comparable<T> defines the natural ordering of a class — the class itself implements compareTo(T o). This is appropriate when there is one obvious, canonical ordering (e.g., LocalDate by chronological order, String by lexicographic order). Comparator<T> is an external strategy object that compares two objects of any type — it decouples the ordering logic from the class. Use Comparator when: you need multiple orderings of the same type (by name, by salary, by hire date), you don't own the class, or the ordering is context-specific. In Java 8+, Comparator.comparing(Person::getName).thenComparing(Person::getDateOfBirth) composes comparators cleanly without boilerplate.


Learning Resources