The contract has one core rule: if two objects are equal via equals(), they must return the same hashCode(). The reverse doesn't have to be true — two objects can share a hash code without being equal, that's just a collision. Breaking this contract silently corrupts any hash-based structure — HashMap, HashSet, anything that uses hashing to find elements.
When you call map.get(key), HashMap first calls hashCode() to find the right bucket, then calls equals() to find the exact entry within that bucket. If you override equals() but forget hashCode(), two logically equal objects land in different buckets. get() returns null even though the key "exists" — and you'll spend a long time debugging it because the code looks correct.
The worst case I've seen is mutable keys. You put an object into a map, then mutate one of the fields that hashCode depends on — now the object is in the wrong bucket and the map can never find it again:
Account acc = new Account("ACC001");
map.put(acc, balance);
acc.id = "ACC999"; // mutation after insert — hash changed
map.get(acc); // returns null — it's in the wrong bucket now
Rule: anything used as a map key should be effectively immutable.
The other one I hit regularly is Lombok's @Data on JPA entities. It generates equals/hashCode from all fields including lazy collections, which either triggers N+1 queries or throws LazyInitializationException when you put the entity in a Set. The fix is always explicit:
@EqualsAndHashCode(of = "id") // use only the business key, nothing else
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Transaction t)) return false;
return id.equals(t.id);
}
@Override
public int hashCode() { return Objects.hash(id); }
HashMap is not thread-safe — concurrent writes can corrupt it, and in older JVMs could even cause an infinite loop during resize. ConcurrentHashMap is designed for concurrent use. In Java 8+, it uses CAS for inserting into an empty bucket and only synchronizes on the bucket head when there's a collision. Reads are completely lock-free — they use volatile reads on node values. So you get very high read throughput with minimal write contention.
CHM makes individual operations atomic, but not compound operations. This is the classic check-then-act race:
// BROKEN — another thread can insert between the check and the put:
if (!map.containsKey(key)) {
map.put(key, expensiveCompute());
}
// CORRECT — single atomic operation:
map.computeIfAbsent(key, k -> expensiveCompute());
But computeIfAbsent has its own trap — it holds the bucket lock for the entire duration of the lambda. If your compute function does I/O or calls an external service, you've just serialised every thread that touches that bucket. I always move the slow work outside:
// Pre-compute outside the map operation if computation is expensive:
Value v = expensiveCompute(); // no lock held here
map.putIfAbsent(key, v); // atomic, fast
CHM has no cross-key atomicity. If you need to atomically move a value from one key to another — like transferring a balance — CHM can't help. You need a ReadWriteLock around the whole operation, or rethink the data structure entirely.
Also worth knowing: size() on CHM is approximate. It uses a LongAdder internally with per-cell counters to avoid contention, and sums them at read time. Under heavy concurrent modification, the count can be slightly stale.
// Atomic word count — cleaner than compute():
map.merge(word, 1, Integer::sum);
// if absent: inserts 1. If present: applies Integer::sum atomically.
Collections.synchronizedMap()?" → It wraps every method in a synchronized block on the whole map — one global lock. CHM is almost always better: finer-grained locking, lock-free reads, no need to manually synchronize iteration.My default is always synchronized. It's JVM-managed — the lock is automatically released when the block exits, even if an exception is thrown. Since Java 6, the JVM also optimises it adaptively: it starts as a biased lock with near-zero cost, escalates to a thin spin lock under mild contention, and only inflates to a full OS mutex when there's real contention. So for most cases it's both simpler and fast enough.
I reach for ReentrantLock only when I need capabilities that synchronized doesn't have.
Three things come up in practice:
1. Timed lock attempt — tryLock()
Useful when you need to acquire two locks at once and can't guarantee ordering — the classic deadlock setup. Instead of blocking forever, you back off and retry:
// Transfer between two accounts — must lock both without deadlocking
boolean transfer(Account from, Account to, long amount) throws InterruptedException {
while (true) {
if (from.lock.tryLock(50, MILLISECONDS)) {
try {
if (to.lock.tryLock(50, MILLISECONDS)) { // try second lock
try {
from.debit(amount);
to.credit(amount);
return true;
} finally { to.lock.unlock(); }
}
} finally { from.lock.unlock(); }
}
// both locks not acquired — back off and retry
Thread.sleep(1);
}
}
With synchronized you can't do this — once you're blocked waiting for the second lock, you're stuck. tryLock lets you release what you have and try again.
2. Interruptible waiting — lockInterruptibly()
Useful when threads need to respond to shutdown signals. With synchronized, a thread blocked on a lock ignores interrupts — you can't cancel it. With lockInterruptibly(), the thread wakes up and throws InterruptedException when interrupted:
void processTask() throws InterruptedException {
lock.lockInterruptibly(); // throws if thread is interrupted while waiting
try {
doWork();
} finally { lock.unlock(); }
}
// On graceful shutdown:
workerThread.interrupt(); // thread unblocks from lockInterruptibly() and exits cleanly
// With synchronized: interrupt() is ignored — thread stays blocked until lock is released
3. Multiple condition queues
With synchronized you have one condition queue, so notifyAll() wakes every waiting thread — producers and consumers alike. With ReentrantLock you create separate conditions and signal only the right threads:
ReentrantLock lock = new ReentrantLock();
Condition notFull = lock.newCondition();
Condition notEmpty = lock.newCondition();
// Producer wakes only consumers — not other producers:
lock.lock();
try {
while (queue.size() == MAX) notFull.await();
queue.add(item);
notEmpty.signal();
} finally { lock.unlock(); }
ReentrantLock lock = new ReentrantLock();
Condition notFull = lock.newCondition();
Condition notEmpty = lock.newCondition();
// Producer wakes only consumers — not other producers:
lock.lock();
try {
while (queue.size() == MAX) notFull.await();
queue.add(item);
notEmpty.signal();
} finally { lock.unlock(); }
You must call unlock() yourself, always in a finally block. If you forget and an exception is thrown, the lock is held forever and every waiting thread hangs. That's a production incident. With synchronized the JVM handles this for you — one less thing to get wrong.
| Need | Use |
|---|---|
| Simple mutual exclusion | synchronized |
| Timed / interruptible lock | ReentrantLock |
| Multiple condition queues | ReentrantLock |
| Read-heavy, write-rare | ReadWriteLock / StampedLock |
The Java Memory Model defines what guarantees you have about visibility and ordering across threads. The key concept is happens-before: if action A happens-before action B, then everything A wrote is visible to B. Without a happens-before relationship, threads can see stale or reordered values — even if the code looks sequential.
People often describe volatile as "flushing to main memory" but that's an oversimplification. The real guarantee is: a volatile write happens-before any subsequent volatile read of the same variable. Everything written before the volatile write is also visible after the volatile read.
volatile boolean ready = false;
int data = 0;
// Thread 1:
data = 42; // (1)
ready = true; // (2) volatile write — establishes HB
// Thread 2:
while (!ready); // (3) volatile read — sees HB from (2)
System.out.println(data); // guaranteed to print 42, not 0
Without volatile on ready, the JIT or CPU is free to reorder (1) and (2), or cache ready in a register — Thread 2 might never see the update.
1. It doesn't give you atomicity. counter++ on a volatile field is still three operations — read, increment, write. Two threads can both read the same value and both write back incremented values, losing one update. Use AtomicLong or LongAdder for counters.
2. It doesn't protect compound actions. if (!flag) { init(); flag = true; } — two threads can both pass the null check. You still need synchronized or a CAS operation for that.
This one trips people up in real code:
// BROKEN — JIT can reorder: write reference BEFORE constructor finishes
if (instance == null) {
synchronized (MyClass.class) {
if (instance == null)
instance = new Singleton(); // another thread sees non-null but uninitialised!
}
}
// CORRECT — volatile prevents the reorder:
private volatile static Singleton instance;
Without volatile, another thread can see a non-null reference to a partially constructed object. The JIT is allowed to publish the reference before the constructor body completes. Adding volatile inserts a memory barrier that prevents this reordering.
Any interface with exactly one abstract method (SAM — Single Abstract Method). The @FunctionalInterface annotation enforces this at compile time but is not required.
@FunctionalInterface
interface Transformer<T, R> {
R transform(T input); // only one abstract method
default R andLog(T input) { ... } // default methods allowed
static Transformer<?,?> identity() { ... } // static methods allowed
}
| Interface | Signature | Use |
|---|---|---|
Function<T,R> | R apply(T t) | transform |
Predicate<T> | boolean test(T t) | filter |
Consumer<T> | void accept(T t) | side effect |
Supplier<T> | T get() | lazy produce |
BiFunction<T,U,R> | R apply(T t, U u) | two-arg transform |
UnaryOperator<T> | T apply(T t) | T → T (Function specialisation) |
Lambdas are not anonymous inner classes. The compiler emits an invokedynamic instruction (Java 7+). At first call, the JVM calls a bootstrap method (LambdaMetafactory) which generates a class at runtime and caches it. Subsequent calls go direct — effectively as fast as a method call.
// What you write:
Function<String, Integer> len = s -> s.length();
// What happens at runtime (conceptually):
// 1. invokedynamic → LambdaMetafactory.metafactory()
// 2. JVM generates a class implementing Function<String,Integer>
// 3. apply() delegates to the lambda body compiled as a private static method
// 4. Result cached — class generated only once
.class file at compile time. Lambdas generate a class at runtime via invokedynamic — more flexible (JVM can optimize the strategy) and avoids class explosion on disk.int threshold = 100; // effectively final — OK
list.stream().filter(x -> x > threshold);
int counter = 0;
list.forEach(x -> counter++); // compile error — counter is mutated
// Why: lambda captures a copy of the local variable.
// If it could mutate it, you'd have a stale copy in the lambda.
// Workaround for counters:
AtomicInteger counter = new AtomicInteger();
list.forEach(x -> counter.incrementAndGet()); // reference is final, object is mutable
String::toUpperCase // instance method — instance provided by stream
System.out::println // instance method on specific object
Integer::parseInt // static method
ArrayList::new // constructor reference
// They are syntactic sugar — identical bytecode to lambda equivalents
// Prefer method references when the lambda just calls one method — more readable
Function<String, String> trim = String::trim;
Function<String, String> upper = String::toUpperCase;
// andThen: trim → upper
Function<String, String> clean = trim.andThen(upper);
// Predicate composition:
Predicate<Integer> isPositive = n -> n > 0;
Predicate<Integer> isEven = n -> n % 2 == 0;
Predicate<Integer> both = isPositive.and(isEven);
list.stream().filter(both).collect(...);
@FunctionalInterface that declares throws Exception.A stream pipeline has three parts: source → intermediate operations → terminal operation. Intermediate ops (filter, map, flatMap) are lazy — nothing executes until a terminal op (collect, forEach, findFirst) is called.
list.stream()
.filter(u -> u.isActive()) // lazy — no work yet
.map(User::getEmail) // lazy — no work yet
.findFirst(); // terminal — pipeline executes, stops at first match
// Without laziness, filter+map would scan all 1M items even if findFirst matches element #2
filter, map, flatMap): process one element at a time — streamable, parallelisable.sorted, distinct, limit): must see multiple elements before producing output — require buffering, break parallelism efficiency.// map: one-to-one. Returns Stream<Stream<String>> — nested:
orders.stream().map(o -> o.getItems().stream())
// flatMap: one-to-many, flattens. Returns Stream<String>:
orders.stream().flatMap(o -> o.getItems().stream())
.collect(Collectors.toList());
// groupingBy — Map<Department, List<Employee>>
employees.stream().collect(Collectors.groupingBy(Employee::getDepartment));
// downstream collector — Map<Department, Long>
employees.stream().collect(Collectors.groupingBy(Employee::getDepartment, Collectors.counting()));
// partitioningBy — Map<Boolean, List<T>>
employees.stream().collect(Collectors.partitioningBy(e -> e.getSalary() > 100_000));
// joining
names.stream().collect(Collectors.joining(", ", "[", "]"));
// Uses ForkJoinPool.commonPool() — shared across the entire JVM!
list.parallelStream().map(this::heavyCpuWork).collect(...);
// Custom pool to isolate parallelism:
ForkJoinPool pool = new ForkJoinPool(4);
pool.submit(() -> list.parallelStream().map(this::work).collect(...)).get();
commonPool — heavy tasks starve other users (e.g. Spring's async methods).findFirst, forEachOrdered) kill parallel speedup with coordination overhead.reduce and collect?" → reduce produces a single immutable value by folding; collect mutates a mutable container (List, Map). Use collect for building collections — reduce into a List would create O(n²) intermediate copies.Future: blocking .get(), no chaining, no combining.CompletableFuture: non-blocking, chainable (thenApply, thenCompose), combinable (allOf, anyOf), error-handling (exceptionally, handle).CompletableFuture.supplyAsync(() -> fetch()) // ForkJoinPool.commonPool()
.thenApply(data -> transform(data)) // same thread OR calling thread — unpredictable!
.thenApplyAsync(data -> enrich(data), exec) // explicit executor — predictable
Async suffix, the continuation runs on whichever thread completed the previous stage. This causes subtle bugs under load. Always use explicit executors in production — ForkJoinPool.commonPool() is shared JVM-wide.ExecutorService io = Executors.newFixedThreadPool(50);
ExecutorService cpu = Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors());
CompletableFuture<Profile> pf = CompletableFuture.supplyAsync(() -> fetchProfile(id), io);
CompletableFuture<Recs> rf = CompletableFuture.supplyAsync(() -> fetchRecs(id), io);
CompletableFuture<History> hf = CompletableFuture.supplyAsync(() -> fetchHistory(id), io);
CompletableFuture.allOf(pf, rf, hf)
.thenApplyAsync(__ -> build(pf.join(), rf.join(), hf.join()), cpu)
.orTimeout(500, TimeUnit.MILLISECONDS)
.exceptionally(ex -> buildFallback());
// thenApply: maps T → R. If R is another CF, you get CF<CF<R>> — nested!
CF<CF<User>> bad = cf.thenApply(id -> fetchUser(id)); // fetchUser returns CF<User>
// thenCompose: flatMap — unwraps nested CF
CF<User> good = cf.thenCompose(id -> fetchUser(id));
exceptionally(fn): only on exception, recovers to a value.handle((r, ex) -> ...): always runs, handles both success and failure.whenComplete((r, ex) -> ...): side-effect only, doesn't change the result.join() vs get(): join throws unchecked CompletionException; use it inside lambdas.supplyAsync blocks trying to submit; or throws RejectedExecutionException depending on the pool's rejection policy.MaxGCPauseMillis target.| Scenario | GC |
|---|---|
| General REST service | G1 (default) |
| Low-latency API (<10ms p99) | ZGC |
| Batch / throughput-heavy | ParallelGC |
| Large heap (>32GB) with latency needs | ZGC |
-XX:MaxGCPauseMillis=100 # pause target (not a hard limit)
-XX:G1HeapRegionSize=16m # larger = fewer humongous objects
-XX:InitiatingHeapOccupancyPercent=35 # start concurrent marking earlier
-Xms8g -Xmx8g # fix heap size to avoid expansion STW