ReentrantLock vs synchronized: Source-Code Comparison & Selection Guide

This article provides a comprehensive source-code level comparison of Java's ReentrantLock and synchronized, covering their underlying implementations (JVM monitor vs AQS/CLH), lock upgrade mechanisms, fairness, interruptibility, timeout support, condition variables, and performance characteristics across JDK versions to guide practical selection.

Java Tech Workshop
Java Tech Workshop
Java Tech Workshop
ReentrantLock vs synchronized: Source-Code Comparison & Selection Guide

Introduction

Java concurrency developers face a daily choice between synchronized and ReentrantLock. Common opinions claim synchronized is simpler and JVM-optimized, while ReentrantLock offers advanced features like fairness, interruption, timeouts, and multiple conditions. Some argue ReentrantLock outperforms synchronized under high contention. This article examines the source-level implementations, core differences, and performance realities to provide a principled selection guide.

1. Essential Differences

synchronized : JVM built-in monitor lock (C++ implementation), based on object header Mark Word and ObjectMonitor. Implicit locking — entering a synchronized block acquires the lock automatically; exiting releases it automatically.

ReentrantLock : JDK implemented reentrant lock (Java implementation), based on AQS (AbstractQueuedSynchronizer). Explicit locking — manual lock() / unlock() required, typically in a finally block.

This fundamental distinction — VM-level vs JDK-level — drives all subsequent differences.

2. synchronized Internals: Object Header & Lock Upgrade

2.1 Object Header: Where Lock State Lives

Every Java object has an Object Header containing a Mark Word storing hash code, generational age, and lock information. On 64-bit JVM (unlocked state):

| 25 bits | 31 bits | 1 bit | 4 bits | 1 bit | 2 bits |
| unused  | hashCode| unused| age    | biased| lock tag |

The last two bits (lock tag) indicate lock state:

01 : Unlocked / Biased (biased bit = 1 for biased, 0 for unlocked)

00 : Lightweight — Mark Word points to stack lock record

10 : Heavyweight — Mark Word points to ObjectMonitor

11 : GC mark

Lock metadata resides in the object header Mark Word; no extra object needed.

2.2 Lock Upgrade: From Unlocked to Heavyweight

Since JDK 6, synchronized uses a one-way upgrade path based on contention:

Unlocked → Biased → Lightweight → Heavyweight
         (contention increases, one-way, no downgrade)

Phase 1: Unlocked

Newly created object, no thread contention.

Phase 2: Biased Locking

First thread entering synchronized block sets Mark Word to biased state recording its thread ID. Subsequent entries by same thread only compare thread ID — no CAS needed. Ideal for single-thread repeated access; near-zero overhead. Note : Biased locking disabled by default since JDK 15 ( -XX:+UseBiasedLocking no longer default), deprecated in JDK 18 because revocation overhead under high contention outweighs benefits.

Phase 3: Lightweight Locking

When a second thread attempts to acquire a biased lock, bias is revoked and lock upgrades to lightweight. Steps:

Thread creates a Lock Record in its stack frame.

CAS copies Mark Word to Lock Record and updates Mark Word to point to Lock Record.

CAS success → lightweight lock acquired.

CAS failure → contention detected; spin retry a few times, then upgrade to heavyweight.

Suitable for alternating low-contention access; avoids kernel transitions via CAS + spinning.

Phase 4: Heavyweight Locking

After spinning fails, lock becomes heavyweight based on ObjectMonitor (C++ object) containing: _owner: current lock-holding thread _EntryList: blocked threads waiting for lock _WaitSet: threads waiting via

wait()
_count

: reentry count

Lock/unlock invoke OS mutex, causing user/kernel mode switches — expensive but avoids CPU spinning under heavy contention.

2.3 Bytecode Level: monitorenter/monitorexit

synchronized

compiles to two bytecode instructions:

0: aload_0
1: dup
2: astore_1
3: monitorenter   ← enter monitor (acquire)
4: aload_1
5: monitorexit    ← exit monitor (release, normal path)
6: goto 14
9: astore_2
10: aload_1
11: monitorexit    ← exit monitor (release, exception path)
12: aload_2
13: athrow
14: return

Two monitorexit instructions guarantee release on both normal and exceptional exits — JVM ensures no deadlock from forgotten unlock.

3. ReentrantLock Internals: AQS & CLH Queue

3.1 AQS Foundation

ReentrantLock

delegates to inner Sync class extending AbstractQueuedSynchronizer (AQS). AQS core components:

state : volatile int representing reentry count (0 = unlocked, n = reentered n times)

CLH Queue : failed acquirers wrapped as Nodes in a doubly-linked list, parked via LockSupport.park() ConditionObject : supports multiple condition queues

Two concrete sync implementations:

NonfairSync (default): lock() immediately CAS state from 0 to 1; on failure enters AQS queue.

FairSync : lock() directly enqueues; tryAcquire checks hasQueuedPredecessors() before CAS.

3.2 Nonfair Lock Acquisition Flow

CAS compareAndSetState(0, 1) — barge attempt; success sets owner thread and returns.

On failure, call AQS acquire(1). acquire invokes tryAcquire (via nonfairTryAcquire) for another barge attempt; success returns.

Failure → addWaiter wraps thread as Node appended to CLH queue tail. acquireQueued spins: if predecessor is head, retry CAS; else park() to suspend.

On unpark, thread re-spins and becomes new head upon success.

3.3 Unlock Flow

release(1)

calls tryRelease(1): decrement state; if reaches 0, clear owner and return true.

If release succeeded (state=0), unparkSuccessor(head) wakes head's successor.

Woken thread returns from park, spins to acquire lock.

3.4 Reentrancy Implementation

Both support reentrancy via a counter: synchronized: ObjectMonitor _count increments on reentry, decrements on exit; lock released only when count reaches 0. ReentrantLock: AQS state increments in tryAcquire when current thread is owner; decrements in tryRelease; release only when state becomes 0.

Same principle: a counter tracks recursion depth.

4. Six-Dimension Comparison

4.1 Lock Acquisition

synchronized (heavyweight) : monitorenter → check ObjectMonitor._owner → if self, _count++; else enter _EntryList, block via OS mutex.

ReentrantLock (nonfair) : lock() → CAS state (barge) → on failure acquiretryAcquire → enqueue Node in CLH queue → park.

Key difference : synchronized queue is JVM C++ _EntryList (invisible to Java); ReentrantLock queue is Java CLH list, monitorable via getQueueLength(), getWaitingThreads().

4.2 Lock Release

synchronized : monitorexit_count-- → if 0, wake _EntryList head. Automatic — JVM guarantees release even on exception.

ReentrantLock : unlock()release(1)state-- → if 0, unpark CLH head successor. Manual — must call in finally or risk deadlock.

4.3 Fairness

synchronized : Only nonfair. After heavyweight upgrade, woken threads re-compete, allowing barging.

ReentrantLock : Supports both. Fair mode uses hasQueuedPredecessors() to enforce FIFO; nonfair (default) barges via CAS.

Fairness cost : Lower throughput (10-30% less), more context switches. Use only when starvation prevention is required.

4.4 Interrupt Responsiveness

synchronized : Cannot be interrupted while waiting; thread blocks indefinitely.

ReentrantLock : lockInterruptibly() throws InterruptedException, enabling cancellation (e.g., user cancel, timeout fallback).

4.5 Timeout Acquisition

synchronized : No timeout or try-lock support.

ReentrantLock : tryLock() immediate non-blocking attempt; tryLock(timeout, unit) waits up to specified duration. Enables fallback strategies (return cache, default value).

4.6 Condition Variables

synchronized : Single condition per object via wait() / notify() / notifyAll() on ObjectMonitor._WaitSet. notifyAll wakes all waiters — thundering herd problem.

ReentrantLock : Multiple Condition objects via newCondition(), each with own AQS ConditionObject queue. Example producer-consumer with separate notFull and notEmpty conditions avoids waking same-type threads.

5. Performance Comparison

5.1 JDK Version Impact

JDK 5 and earlier : synchronized heavyweight only; ReentrantLock clearly faster.

JDK 6 : synchronized introduces biased/lightweight/heavyweight upgrade; gap narrows.

JDK 8+ : Performance difference minimal; each wins in different scenarios.

JDK 15+ : Biased locking disabled by default; synchronized goes straight to lightweight under low contention, still very fast.

5.2 Contention Level Comparison

No contention (single thread) : synchronized slightly faster (biased/lightweight near-zero overhead); ReentrantLock has AQS framework overhead.

Low contention (alternating) : Roughly equal — both use CAS + spinning.

High contention (many threads) : ReentrantLock slightly faster — CLH queue enables precise wakeups; synchronized heavyweight also well-optimized.

5.3 Conclusion

Do not choose ReentrantLock for performance — modern JDK gap is negligible; premature optimization is harmful.

Functional requirements drive selection: need fairness, interruptibility, timeout, multiple conditions, monitoring → ReentrantLock; otherwise → synchronized. synchronized advantages: simplicity, safety (auto-release), continuous JVM optimization, cleaner code.

Summary & Selection Guide

ReentrantLock

and synchronized are the two most common Java mutexes. Their essence differs: synchronized is a JVM built-in monitor lock (C++, object header + lock upgrade), while ReentrantLock is a JDK implemented reentrant lock (Java, AQS + CLH queue) .

Key Takeaways

synchronized internals : Mark Word stores lock state; upgrade path Unlocked→Biased→Lightweight→Heavyweight (one-way); heavyweight uses ObjectMonitor ( _owner, _EntryList, _WaitSet, _count); bytecode monitorenter / monitorexit ensures auto-release on exception.

ReentrantLock internals : AQS state tracks reentry count; nonfair lock barges via CAS then CLH queue; fair lock checks hasQueuedPredecessors(); unlock decrements state and unparks successor; ConditionObject enables multiple condition queues.

Six-dimension comparison : auto vs manual release; nonfair-only vs fair/nonfair; no interrupt vs lockInterruptibly(); no timeout vs tryLock(timeout); single condition vs multiple conditions; invisible queue vs monitorable queue.

Performance : JDK 8+ difference small; low contention favors synchronized, high contention slightly favors ReentrantLock; never select based on performance alone.

Selection : Simple sync, no advanced features → synchronized; need fairness/interrupt/timeout/multi-condition/monitoring → ReentrantLock; read-heavy → ReentrantReadWriteLock or StampedLock. synchronized and ReentrantLock are not replacements but complementary tools. Understanding their internals enables precise, scenario-driven choices for safe and efficient concurrent code. There is no best lock, only the most suitable lock.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

AQSReentrantLockPerformance ComparisonsynchronizedJava ConcurrencyJVM InternalsLock UpgradeCLH Queue
Java Tech Workshop
Written by

Java Tech Workshop

Focused on Java backend technologies, sharing fundamentals, multithreading, JVM, the Spring ecosystem, microservices, distributed systems, high concurrency, source‑code analysis, and practical experience. Continuously delivers high‑quality original content, interview guides, and learning roadmaps to help Java developers progress from beginner to advanced, enhancing technical skills and core competitiveness.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.