Java Concurrency — Synchronization & Internal Locks
"Every concurrency bug boils down to one thing: unprotected shared mutable state. Locks are the walls you build around it."
This article covers the complete locking toolkit — from the built-in synchronized keyword to ReentrantLock, ReadWriteLock, and the JVM optimizations that make them fast.
The synchronized Keyword
synchronized is Java's built-in mutual exclusion mechanism. It can be applied to methods or blocks:
Synchronized Methods
public class SafeCounter {
private int count = 0;
// Locks on 'this' (the instance)
public synchronized void increment() {
count++;
}
// Same lock ('this') — mutual exclusion with increment()
public synchronized int getCount() {
return count;
}
}
Synchronized Blocks
public class SafeCounter {
private int count = 0;
private final Object lock = new Object(); // Dedicated lock object
public void increment() {
synchronized (lock) {
count++;
}
}
public int getCount() {
synchronized (lock) {
return count;
}
}
}
Static Synchronized
public class Registry {
private static int count = 0;
// Locks on the Class object: Registry.class
public static synchronized void increment() {
count++;
}
}
Best practice: Use a dedicated
private final Object lock = new Object()for locking instead ofthis. This prevents external code from accidentally (or maliciously) synchronizing on your object.
Object Monitors — How Intrinsic Locks Work
Every Java object has an associated monitor. When a thread enters a synchronized block, it acquires the monitor. Other threads attempting to enter any synchronized block on the same monitor are blocked.
Entry Set vs Wait Set
| Set | How Thread Gets Here | How Thread Leaves |
|---|---|---|
| Entry Set | Tries to enter synchronized when lock is held |
Lock becomes available, thread acquires it |
| Wait Set | Calls wait() inside synchronized — releases lock |
Another thread calls notify() or notifyAll() |
Reentrancy
A reentrant lock allows the same thread to acquire it multiple times without deadlocking. Both synchronized and ReentrantLock are reentrant.
public class ReentrantDemo {
public synchronized void methodA() {
System.out.println("In methodA");
methodB(); // Same thread re-acquires the lock — no deadlock!
}
public synchronized void methodB() {
System.out.println("In methodB");
}
}
If locks were NOT reentrant, calling methodB() from methodA() would deadlock — the thread would wait for a lock it already holds.
volatile — Visibility Without Locking
volatile guarantees visibility and ordering but NOT atomicity:
// Without volatile — Thread B may never see the update
private boolean running = true;
// With volatile — guarantees visibility
private volatile boolean running = true;
When to Use volatile
| Use Case | volatile |
synchronized/Lock |
|---|---|---|
| Simple flags (stop/resume) | ✅ | Overkill |
Compound operations (count++) |
❌ Not atomic | ✅ |
| Publishing immutable objects | ✅ | ✅ |
| Check-then-act | ❌ | ✅ |
The Lock Interface — Beyond synchronized
public interface Lock {
void lock(); // Blocking acquire
void lockInterruptibly() throws IE; // Interruptible acquire
boolean tryLock(); // Non-blocking attempt
boolean tryLock(long time, TimeUnit u)
throws InterruptedException; // Timed attempt
void unlock(); // Release
Condition newCondition(); // Condition variable
}
Why Lock Over synchronized?
| Feature | synchronized |
Lock |
|---|---|---|
| Non-blocking attempt | ❌ | ✅ tryLock() |
| Timed attempt | ❌ | ✅ tryLock(time, unit) |
| Interruptible | ❌ | ✅ lockInterruptibly() |
| Multiple conditions | ❌ | ✅ newCondition() |
| Fair ordering | ❌ | ✅ Constructor flag |
| Auto-release on exception | ✅ | ❌ Must finally |
| Can span multiple methods | ❌ | ✅ |
ReentrantLock — The Workhorse
Basic Pattern
private final ReentrantLock lock = new ReentrantLock();
public void criticalSection() {
lock.lock();
try {
// Protected code
} finally {
lock.unlock(); // ALWAYS in finally!
}
}
tryLock() — Deadlock Avoidance
public boolean transferMoney(Account from, Account to, double amount)
throws InterruptedException {
while (true) {
if (from.getLock().tryLock(100, TimeUnit.MILLISECONDS)) {
try {
if (to.getLock().tryLock(100, TimeUnit.MILLISECONDS)) {
try {
from.debit(amount);
to.credit(amount);
return true;
} finally {
to.getLock().unlock();
}
}
} finally {
from.getLock().unlock();
}
}
// Both locks not acquired — back off and retry
Thread.sleep(ThreadLocalRandom.current().nextLong(10, 50));
}
}
Fair vs Unfair
ReentrantLock unfair = new ReentrantLock(); // Default: unfair (faster)
ReentrantLock fair = new ReentrantLock(true); // Fair: FIFO ordering, slower
| Aspect | Unfair (default) | Fair |
|---|---|---|
| Throughput | Higher | Lower (~2x slower) |
| Starvation | Possible | Prevented (FIFO) |
| Use when | Performance matters most | Every thread must eventually proceed |
ReadWriteLock — Concurrent Reads, Exclusive Writes
When reads vastly outnumber writes, using an exclusive lock for reads is wasteful. ReadWriteLock provides:
Read lock (shared) — multiple readers simultaneously
Write lock (exclusive) — one writer, no readers
Thread-Safe Cache Example
public class Cache<K, V> {
private final Map<K, V> map = new HashMap<>();
private final ReadWriteLock rwLock = new ReentrantReadWriteLock();
public V get(K key) {
rwLock.readLock().lock();
try {
return map.get(key); // Multiple readers can call simultaneously ✅
} finally {
rwLock.readLock().unlock();
}
}
public void put(K key, V value) {
rwLock.writeLock().lock();
try {
map.put(key, value); // Exclusive access ✅
} finally {
rwLock.writeLock().unlock();
}
}
}
JVM Lock Optimizations
Modern HotSpot JVM makes synchronized surprisingly fast through four key optimizations:
| Optimization | Effect | When |
|---|---|---|
| Biased Locking | Zero-cost lock for single-threaded access | Uncontended locks (deprecated Java 15+) |
| Thin Locks | CAS on object header — no OS involvement | Low contention |
| Adaptive Spinning | CPU spin-wait before parking | Recent history suggests lock will be released soon |
| Lock Elision | Removes lock entirely via escape analysis | Object never escapes the thread |
| Lock Coarsening | Merges adjacent synchronized blocks | Multiple syncs on same lock nearby |
// Lock Coarsening — JVM optimizes:
synchronized(lock) { doA(); }
synchronized(lock) { doB(); }
synchronized(lock) { doC(); }
// Into:
synchronized(lock) { doA(); doB(); doC(); }
// Lock Elision — JVM removes lock entirely:
void concat(String a, String b) {
StringBuffer sb = new StringBuffer(); // sb never escapes method
sb.append(a); // synchronized is useless here
sb.append(b);
return sb.toString();
}
// JVM: "sb is thread-local, removing all synchronization"
Deadlocks — Detection & Prevention
Deadlock occurs when two or more threads are each waiting for a lock held by the other:
Four Conditions for Deadlock (ALL Required)
Mutual Exclusion — resources are exclusively held
Hold and Wait — thread holds one resource while waiting for another
No Preemption — resources can't be forcibly taken away
Circular Wait — circular chain of threads, each waiting for the next
Prevention Strategies
// Strategy 1: Lock Ordering — always acquire locks in consistent order
// If transferring between account A and B, always lock the lower-ID first
public void transfer(Account a1, Account a2, double amount) {
Account first = a1.getId() < a2.getId() ? a1 : a2;
Account second = a1.getId() < a2.getId() ? a2 : a1;
synchronized (first) {
synchronized (second) {
// Safe — always same order
}
}
}
// Strategy 2: tryLock with timeout
if (lock1.tryLock(100, TimeUnit.MILLISECONDS)) {
try {
if (lock2.tryLock(100, TimeUnit.MILLISECONDS)) {
try { /* critical section */ }
finally { lock2.unlock(); }
}
} finally { lock1.unlock(); }
}
// Strategy 3: Single lock (coarser granularity)
synchronized (globalLock) {
// All operations under one lock — no deadlock possible
// But reduced concurrency
}
Detecting Deadlocks
// Programmatic detection
ThreadMXBean bean = ManagementFactory.getThreadMXBean();
long[] deadlockedThreads = bean.findDeadlockedThreads();
if (deadlockedThreads != null) {
ThreadInfo[] infos = bean.getThreadInfo(deadlockedThreads, true, true);
for (ThreadInfo info : infos) {
System.out.println(info);
}
}
Choosing the Right Lock
FAQs
Q1: What is the difference between synchronized and ReentrantLock?
A: Both provide mutual exclusion and are reentrant. ReentrantLock adds: tryLock() for non-blocking attempts, lockInterruptibly() for interruptible waits, fairness control, and multiple Condition variables. synchronized has automatic release on exception and is simpler.
Q2: What JVM optimizations make synchronized fast?
A: Biased locking (zero-cost for uncontended), thin locks (CAS on header), adaptive spinning (CPU spin before OS park), lock coarsening (merge adjacent syncs), and lock elision (remove via escape analysis).
Q3: How do you prevent deadlocks?
A: (1) Lock ordering — always acquire locks in a consistent, predefined order. (2) tryLock() with timeout — back off and retry. (3) Use a single coarser lock. (4) Avoid nested locking when possible.
Q4: When would you use ReadWriteLock over synchronized?
A: When reads vastly outnumber writes (e.g., caches, configuration). ReadWriteLock allows multiple concurrent readers while still ensuring exclusive write access. For write-heavy workloads, the overhead of two lock types makes it worse than synchronized.
Q5: What is the difference between volatile and synchronized?
A: volatile guarantees visibility (writes are flushed, reads are fresh) and ordering (prevents reordering) but NOT atomicity. synchronized provides all three: visibility, ordering, AND atomicity. Use volatile for simple flags; use synchronized for compound operations.
Summary
The one-liner: synchronized is simple and JVM-optimized for most cases; ReentrantLock adds tryLock, fairness, and conditions; ReadWriteLock optimizes read-heavy workloads; volatile handles visibility without locking — choose based on your contention profile.