Introduction
Collections and concurrency are two pillars of senior Java development, and they're almost guaranteed to come up in technical interviews. When an interviewer asks Java interview questions: collections and concurrency, they're not looking for textbook definitions—they're testing whether you've debugged production race conditions, sized thread pools correctly, and understood the trade-offs between simple and thread-safe data structures. Getting these right separates engineers who've shipped systems from those who've only read the JavaDoc.
In this article, we'll walk through the questions that actually matter: how HashMap really works under the hood, why ConcurrentHashMap beats a synchronized map, when volatile is enough and when you need synchronized, and how to size thread pools so your application doesn't starve or waste resources.
HashMap internals—Buckets, treeification, and resizing
This is where most interviews start. "Tell me how HashMap works internally." The depth of your answer reveals whether you understand data structures or just know how to call put() and get().
A HashMap uses an array of buckets (initially 16 entries). When you insert a key-value pair, the hash code is used to compute which bucket it belongs to. If two keys hash to the same bucket, you get a collision—the entries chain together as linked list nodes (Java 8+) or tree nodes (red-black trees after 8 collisions).
Here's the critical part: HashMap resizes when its load factor exceeds 0.75. This means if you have a 16-bucket map and insert 13 items, the next insertion triggers a resize to 32 buckets. All entries are rehashed and redistributed. This is expensive—O(n) operation—but it keeps average lookup time close to O(1).
// This is the scenario interviewers ask about
HashMap<String, Integer> map = new HashMap<>();
// Default initial capacity: 16
// Load factor: 0.75
// Threshold for resize: 16 * 0.75 = 12
// Insert 13 items → resize triggered → now 32 buckets
for (int i = 0; i < 13; i++) {
map.put("key" + i, i);
}
// Bucket structure after 8+ collisions in one bucket:
// Linked list → Red-Black Tree (TreeNode)
// Threshold: TREEIFY_THRESHOLD = 8
The treeification threshold (8 collisions before converting to a tree) is tuned for typical hash distributions. If you're seeing trees in production, it signals poor hash code quality—investigate the object's hashCode() implementation.
When you size a HashMap for known capacity, use new HashMap<>(initialCapacity) with a value computed as (expectedSize / 0.75) + 1 to avoid resize overhead on startup. This matters when you're bulk-loading data.
ConcurrentHashMap vs. synchronized map—Fine-grained locking wins
The question usually comes next: "How would you make HashMap thread-safe?" Most junior engineers say "use Collections.synchronizedMap()" or "wrap every operation in synchronized." Both are wrong for high-concurrency workloads.
Collections.synchronizedMap() wraps HashMap with a single global lock. Every operation—read, write, or iteration—acquires that lock. Under contention, threads queue up waiting for the lock. Throughput drops.
ConcurrentHashMap uses segment-level locking (Java 7) or bucket-level locking (Java 8+). Only the bucket being written to is locked. Multiple threads can write to different buckets simultaneously. Reads don't acquire locks at all—they're lock-free. This is why ConcurrentHashMap dominates in production.
// Bad: Global lock on every operation
Map<String, Integer> syncMap = Collections.synchronizedMap(new HashMap<>());
// Good: Fine-grained locking, lock-free reads
ConcurrentHashMap<String, Integer> concMap = new ConcurrentHashMap<>();
// Example: 100 threads writing to different keys
IntStream.range(0, 100).parallel().forEach(i -> {
concMap.put("key" + i, i); // Lock only the bucket for "key" + i
});
// Example: Read-heavy workload
IntStream.range(0, 1000000).parallel().forEach(i -> {
Integer value = concMap.get("key" + (i % 100)); // No lock acquired
});
A strong interview answer includes this trade-off: ConcurrentHashMap doesn't allow null keys or values (synchronized map does), and iteration might not reflect concurrent updates. If you need iteration guarantees, snapshot the map or use a lock explicitly.
Also mention: ConcurrentHashMap has putIfAbsent(), compute(), and computeIfAbsent() for atomic read-modify-write operations. These are crucial for cache patterns and avoid race conditions that synchronized maps don't prevent (even on synchronized access, the operations aren't atomic).
volatile vs. synchronized—Visibility vs. mutual exclusion
This trips up many mid-level engineers. "When do I use volatile?" The answer: when you need visibility without mutual exclusion.
volatile ensures that reads and writes to a field are visible across threads. When thread A writes to a volatile field, thread B is guaranteed to see that write on subsequent reads. Volatile does not provide mutual exclusion—two threads can still race.
synchronized provides mutual exclusion (only one thread at a time in the critical section) and visibility as a side effect. The memory barrier on entry and exit ensures all threads see consistent memory.
// Correct: Visibility only
class Flag {
volatile boolean shutdown = false;
void worker() {
while (!shutdown) {
// Do work
}
}
void requestShutdown() {
shutdown = true; // All threads see this immediately
}
}
// Wrong: Needs mutual exclusion, not just visibility
class Counter {
volatile int count = 0;
void increment() {
count++; // Race condition! Read, increment, write are not atomic
}
}
// Correct: Mutual exclusion
class SafeCounter {
private int count = 0;
synchronized void increment() {
count++; // Atomic operation
}
}
A concrete production scenario: you have a flag that triggers a graceful shutdown. Multiple threads read it in a loop. Use volatile—it's zero-overhead compared to synchronized. But if you're implementing a counter or cache, synchronized or AtomicInteger is necessary to prevent lost updates.
Mention AtomicInteger and friends as a middle ground: they use compare-and-swap (CAS) instructions, providing mutual exclusion without explicit locking. For simple atomic operations, AtomicInteger outperforms synchronized.
Thread pool sizing—CPU-bound, I/O-bound, and virtual threads
Interviewers love this because the answer reveals production thinking. "You're seeing poor throughput in your thread pool. What do you check?"
Thread pool size should match workload profile:
- CPU-bound tasks (heavy computation): size ≈ number of CPU cores. More threads waste context-switch overhead. Size to `Runtime.getRuntime().availableProcessors()`.
- I/O-bound tasks (network, database calls): size higher than cores. While one thread waits for I/O, others execute. Common heuristic: `cores * (1 + wait_time / compute_time)`. In practice, 5–10× cores if I/O is slow.
// CPU-bound: Web server processing numerical data
ExecutorService cpuBound = Executors.newFixedThreadPool(
Runtime.getRuntime().availableProcessors()
);
// I/O-bound: API server making database queries
// Each request waits ~100ms for DB, computes for ~1ms
// Rule of thumb: cores * (1 + 100/1) ≈ cores * 101
// In practice, capped to reasonable limit like cores * 8
ExecutorService ioBound = Executors.newFixedThreadPool(
Math.min(
Runtime.getRuntime().availableProcessors() * 8,
200 // Cap to avoid thread explosion
)
);
// Traditional approach: Submit tasks
ioBound.submit(() -> {
String result = callSlowDatabase();
processResult(result);
});
But here's the modern shift: virtual threads (Java 19+) change the game. A virtual thread is lightweight—you can spawn millions of them. The runtime maps them to a small pool of platform threads. This means you can use a simpler model: create one virtual thread per task, no pool sizing needed.
// Virtual threads: spawn freely, no sizing needed
try (ExecutorService vthreads = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 1_000_000; i++) {
vthreads.submit(() -> {
String result = callSlowDatabase();
processResult(result);
});
}
}
// No out-of-memory, no context switch overhead, cleaner code
A strong answer acknowledges this transition. For new systems, virtual threads eliminate the guessing game. For legacy systems, measure actual workload and adjust empirically—thread pool size isn't set-and-forget.
Common mistakes
- Assuming volatile is atomic: Visibility ≠ atomicity. A volatile long increment is not atomic; use AtomicLong or synchronized.
- Forgetting load factor in HashMap capacity planning: Allocating a HashMap with capacity 100 doesn't mean 100 entries before resize—it resizes at 75. Account for 0.75.
- Using synchronized map for high-concurrency reads: Every read acquires the global lock. ConcurrentHashMap readers don't lock; use it instead.
- Over-sizing thread pools: Too many threads = excessive context switches, memory overhead, and worse performance. Size to workload, measure, then tune.
- Ignoring iteration during concurrent modification: ConcurrentHashMap iteration might not reflect all concurrent updates. Document this assumption in your code.
Key takeaways
- HashMap uses bucket-based storage with chaining/treeification; resize happens at 0.75 load factor. Plan initial capacity to avoid startup resizes.
- ConcurrentHashMap provides fine-grained (bucket-level) locking and lock-free reads; it outperforms synchronized maps under contention.
- volatile guarantees visibility across threads but not atomicity; synchronized provides both. Use volatile for flags, synchronized or AtomicXxx for compound operations.
- Thread pool size depends on workload: CPU-bound ≈ cores, I/O-bound higher. Virtual threads simplify I/O-heavy systems by allowing one thread per task.
- Always measure production workload before final pool sizing. Empirical tuning beats guesses.






Comments
Loading comments…