A Java Lock lets only one thread at a time run a piece of code. We create a ReentrantLock, call lock() before the code that changes shared data, and call unlock() in a finally block.
We use a lock when several threads change the same data and the result must stay correct, such as a page-view counter, a seat booking, a price list or an order queue.
The following example uses a ReentrantLock, so two threads that each add 1,000,000 page views to the same counter always get the right total.
private final Lock lock = new ReentrantLock();
private int views = 0;
void addView() {
lock.lock(); // 1. wait until no other thread holds the lock
try {
views++; // 2. only one thread at a time runs this line
} finally {
lock.unlock(); // 3. always release, even after an exception
}
}
// threads t1 and t2 call addView() 1,000,000 times each
// views = 2000000
With lock, round 1: 2000000
With lock, round 2: 2000000
With lock, round 3: 2000000
With lock, round 4: 2000000
With lock, round 5: 2000000
Notice that the unlock() call goes in finally. Without finally, an exception inside the try block leaves the lock held, and every other thread waits forever.
Next, we look at the wrong count we get without a lock and compare Lock with synchronized. Then we cover tryLock() with a timeout, lockInterruptibly(), fair locks, ReadWriteLock, StampedLock, Condition, and how locks behave with virtual threads on Java 21 and Java 24+.
1. Why Do We Need a Lock?
Let’s remove the lock from the counter and run the same two threads. We also mark the field volatile, so each thread sees the latest value.
private volatile int views = 0;
void addViewUnsafe() {
views++; // read, add 1, write back: three steps
}
The total should be 2,000,000 every time, but without the lock, some rounds lose updates.
Without lock, round 1: 1996518
Without lock, round 2: 1175828
Without lock, round 3: 2000000
Without lock, round 4: 2000000
Without lock, round 5: 2000000
The wrong count comes from views++, which looks like one step but runs as three steps. The thread reads the value and adds 1, and then it writes the new value back.
When two threads read the same value, for example 41, both add 1 and both write 42, so one update is lost.

A bug like the lost update is called a race condition, because the result depends on which thread runs first, so the count changes from run to run. Some runs are even correct, which is why tests often miss the bug.
The volatile keyword does not fix the bug, because it only makes a new value visible to other threads, and views++ is still three separate steps.
A lock fixes the lost update. The code between lock() and unlock() is called the critical section, and only one thread at a time can run it.
A second thread that calls lock() waits until the first thread calls unlock(). Code that gives correct results with many threads is called thread-safe.
2. The Lock Interface and ReentrantLock
Lock is an interface in the java.util.concurrent.locks package, which has been part of Java since Java 5, and ReentrantLock is the implementation we use in most code. The Lock interface has six methods.
| Method | What it does |
|---|---|
| lock() | Waits until the lock is free, then takes it |
| unlock() | Releases the lock |
| tryLock() | Takes the lock if the lock is free and returns true. Otherwise returns false at once |
| tryLock(time, unit) | Waits up to the given time for the lock. Returns true or false |
| lockInterruptibly() | Like lock(), but stops waiting when the thread is interrupted |
| newCondition() | Creates a Condition, used to wait for a state such as “queue not empty” (section 9) |
2.1. What “Reentrant” Means
Reentrant means that a thread that already holds the lock can take it again without waiting. Reentrancy matters when one locked method calls another method that uses the same lock. The method getHoldCount() tells us how many times the current thread holds the lock.
void saveCart() {
lock.lock();
try {
int holds = lock.getHoldCount(); // 1
saveItem(); // saveItem() locks the same lock again
} finally {
lock.unlock();
}
}
void saveItem() {
lock.lock();
try {
int holds = lock.getHoldCount(); // 2
} finally {
lock.unlock();
}
}
// after saveCart(): lock.getHoldCount() = 0, lock.isLocked() = false
Every lock() needs its own unlock(), because the lock becomes free only when the hold count is back to 0. A non-reentrant lock would block forever in saveItem(), because the thread would wait for itself.
2.2. Unlocking a Lock We Do Not Hold
Only the thread that holds a ReentrantLock can release the lock. When another thread calls unlock(), or a thread calls unlock() twice, Java throws an IllegalMonitorStateException.
ReentrantLock lock = new ReentrantLock();
lock.unlock(); // java.lang.IllegalMonitorStateException
3. Lock vs synchronized
The synchronized keyword solves the same counter problem with less code. Java takes the lock when the thread enters the block and releases the lock when the thread leaves, even after an exception.
synchronized void addView() {
views++;
}
The synchronized keyword is also reentrant, and a synchronized method takes either an object-level or a class-level lock.
A Lock needs more code, but it has features that synchronized does not have, such as a timeout and a fair order.
| Feature | synchronized | ReentrantLock |
|---|---|---|
| Release | automatic, at the end of the block | manual, unlock() in finally |
| Return at once when the lock is busy | no | tryLock() |
| Wait with a timeout | no | tryLock(time, unit) |
| Stop waiting on interrupt | no | lockInterruptibly() |
| Fair order for waiting threads | no | new ReentrantLock(true) |
| Wait for a state | one wait set per object (wait(), notify()) | many Condition objects per lock |
| Lock in one method, unlock in another | no | yes |
| Reentrant | yes | yes |
| Separate read and write locks | no | ReentrantReadWriteLock |
We use synchronized by default and switch to a Lock when we need one of the extra features, such as a timeout or a fair order. The reason is that synchronized is shorter and we cannot forget the unlock(), which is also the advice in JEP 491 for new code.
For a simple counter, an AtomicInteger is even simpler than both, because incrementAndGet() adds 1 in one atomic step without a lock. We use a lock when code changes several values together, or must check and change a value in one step.
4. Using tryLock() With a Timeout
The method lock() waits as long as needed, but sometimes a thread should not wait. For example, when a user wants to book a seat that another request is booking right now, we tell the user “try again” instead of making the request wait.
The method tryLock() takes the lock only if it is free, whereas tryLock(time, unit) waits up to the given time. Both methods return true when the thread got the lock.
We call unlock() only when tryLock() returned true, so the call goes inside the if block.
// another thread holds seatLock
boolean got = seatLock.tryLock(); // false, no waiting
boolean gotAfterWait = seatLock.tryLock(500, TimeUnit.MILLISECONDS); // false after about 500 ms
// the other thread released seatLock
if (seatLock.tryLock(1, TimeUnit.SECONDS)) {
try {
System.out.println("seat booked by main"); // seat booked by main
} finally {
seatLock.unlock();
}
} else {
System.out.println("Seat is busy, try again later");
}
tryLock(): false
tryLock(500 ms): false after about 500 ms
tryLock(1 s) after release: true, seat booked by main
The method tryLock() also helps against a deadlock. In a deadlock, thread A holds lock X and waits for lock Y while thread B holds lock Y and waits for lock X, so both threads wait forever.
With tryLock(timeout), a thread stops waiting after the timeout and releases the lock it holds, so it can try again later. The safest fix is still to take locks in the same order in every thread.
5. Using lockInterruptibly()
In Java, one thread asks another thread to stop by calling interrupt(), which sets a flag on the thread that waiting methods can check.
A thread that waits in lock() ignores the interrupt and keeps waiting. A thread that waits in lockInterruptibly() stops waiting and gets an InterruptedException.
In the example, the main thread holds the report lock while a worker thread waits for it in lockInterruptibly(). Then main interrupts the worker.
// worker thread
try {
reportLock.lockInterruptibly();
try {
System.out.println("worker: building the report");
} finally {
reportLock.unlock();
}
} catch (InterruptedException e) {
System.out.println("worker: interrupted while waiting for the lock, giving up");
}
// main thread holds reportLock
boolean waiting = reportLock.hasQueuedThread(worker); // true, worker is waiting
worker.interrupt(); // worker leaves the wait
main: worker is waiting = true
worker: interrupted while waiting for the lock, giving up
main: worker state = TERMINATED
We use lockInterruptibly() in tasks that should stop when the application shuts down. For example, ExecutorService.shutdownNow() interrupts the running tasks, but a task stuck in lock() would not notice.
6. Fair and Unfair Locks
When a lock is released, several threads may be waiting for the lock, and the mode of a ReentrantLock decides which thread goes next. There are two modes.
- A fair lock, new ReentrantLock(true), gives the lock to the thread that has waited the longest.
- An unfair lock, new ReentrantLock(), is the default. A thread that asks for the lock at the right moment can take it before the waiting threads, which is called barging.
In the example, threads A, B and C wait for a lock that main holds. Then main releases the lock and asks for the lock again at once.
ReentrantLock lock = new ReentrantLock(fair);
lock.lock();
// start A, B and C; each one calls lock() and waits
lock.unlock(); // release ...
lock.lock(); // ... and ask again at once
// fair=true : A B C main
// fair=false: main A B C
fair=true : A B C main
fair=false: main A B C
With the fair lock, main got the lock after A, B and C. With the unfair lock, main took the lock again before thread A started running.
Over 20 runs, the fair lock printed A B C main every time, whereas the unfair lock printed main A B C in 19 of 20 runs.
Unfair locks are faster, so we use a fair lock only when a thread must not wait too long. Starting a waiting thread again takes time, and an unfair lock skips that delay.
Fair locks with many threads can have lower throughput than unfair locks. Also, tryLock() without a timeout ignores fairness, even on a fair lock.
7. ReadWriteLock for Data That Is Read Often
Many threads can read the same data at the same time without any problem, and a problem occurs only when a thread changes the data. A ReentrantLock still lets only one reader in at a time, so readers wait for each other for no reason.
A ReadWriteLock has two locks.
- The read lock can be held by many threads at the same time, as long as no thread holds the write lock.
- The write lock is exclusive. A writer waits until all readers and other writers are gone, and while it holds the lock, nobody else can read or write.

We use the class ReentrantReadWriteLock. A price list is a good fit, because prices are read often and change rarely.
private final Map<String, Integer> prices = new HashMap<>();
private final ReadWriteLock rwLock = new ReentrantReadWriteLock();
private final Lock readLock = rwLock.readLock();
private final Lock writeLock = rwLock.writeLock();
Integer get(String item) {
readLock.lock();
try {
return prices.get(item); // get("apple") = 5
} finally {
readLock.unlock();
}
}
void put(String item, int price) {
writeLock.lock();
try {
prices.put(item, price); // put("apple", 5)
} finally {
writeLock.unlock();
}
}
To prove that readers share the lock, we started three reader threads. Each reader holds the read lock and waits at a CyclicBarrier for the other two. The barrier lets the readers continue only when all three hold the read lock at the same time.
apple = 5
Readers holding the read lock: 3
Write lock while reading, tryLock(): false
Write lock after readers left, tryLock(): true
A thread that holds the read lock cannot upgrade to the write lock, so a call to writeLock.lock() inside the read lock waits forever.
The other direction works, so a writer can take the read lock and then release the write lock, which is called downgrading.
8. StampedLock and Optimistic Reads
StampedLock was added in Java 8. It has a read lock and a write lock, like ReadWriteLock, and it also offers an optimistic read.
In an optimistic read, the thread reads the data without any lock and then checks whether a writer changed the data in the meantime. If a writer did, the thread reads again with a real read lock.
The locking methods of StampedLock return a long number called a stamp. We pass the stamp back to validate() and to the unlock methods.
long stamp = lock.tryOptimisticRead(); // no lock, only a stamp
int h = home;
int a = away;
if (!lock.validate(stamp)) { // a write happened in between
stamp = lock.readLock(); // read again, this time with a lock
try {
h = home;
a = away;
} finally {
lock.unlockRead(stamp);
}
}
// score = h + ":" + a
The method validate() returns true when nothing changed, and false after a goal was written between tryOptimisticRead() and validate().
score = 1:0
validate, no write: true
validate, after a write: false
score = 1:1
A StampedLock is not reentrant. A thread that holds the write lock and asks for it again waits for itself forever. StampedLock also does not support Condition. We use StampedLock only for small classes with many reads, where the speed gain is measured.
9. Waiting for a State With Condition
Sometimes a thread holds the lock but cannot continue yet. For example, a consumer wants to take an order from a queue, but the queue is empty, so the consumer must wait until a producer adds an order. A Condition lets the thread wait for a state such as “queue not empty”.
A Condition comes from lock.newCondition() and has two main methods.
- The method await() releases the lock and waits. When the thread wakes up, await() takes the lock again before returning.
- The method signal() wakes one waiting thread, and signalAll() wakes all of them.
Condition does the same job for a Lock as wait() and notify() do for synchronized. One lock can have several conditions.
Our order queue holds at most 2 orders. The queue uses notFull for the producer and notEmpty for the consumer.
private final Deque<String> orders = new ArrayDeque<>();
private final Lock lock = new ReentrantLock();
private final Condition notFull = lock.newCondition();
private final Condition notEmpty = lock.newCondition();
void put(String order) throws InterruptedException {
lock.lock();
try {
while (orders.size() == capacity) {
notFull.await(); // releases the lock while waiting
}
orders.addLast(order);
notEmpty.signal(); // wake one waiting consumer
} finally {
lock.unlock();
}
}
String take() throws InterruptedException {
lock.lock();
try {
while (orders.isEmpty()) {
notEmpty.await();
}
String order = orders.removeFirst();
notFull.signal(); // wake the waiting producer
return order;
} finally {
lock.unlock();
}
}
The producer adds 4 pizzas to a queue of size 2, so it waits each time the queue is full.
producer: added pizza-1
producer: added pizza-2
producer: queue full, waiting
producer: added pizza-3
consumer: took pizza-1
producer: queue full, waiting
consumer: took pizza-2
producer: added pizza-4
consumer: took pizza-3
consumer: took pizza-4
We always call await() inside a while loop, never inside an if. A thread can sometimes wake up without any signal(), which is called a spurious wakeup, and another thread may have taken the order first.
The while loop checks the state again after every wakeup.
In real code, we rarely write this queue ourselves. The JDK class ArrayBlockingQueue uses the same design, with one ReentrantLock and two conditions. With ArrayBlockingQueue, we solve the producer-consumer problem with a BlockingQueue in a few lines.
10. Locks and Virtual Threads
Virtual threads are lightweight threads managed by the JVM. They became final in Java 21, and the JVM runs many of them on a few platform threads, called carrier threads.
When a virtual thread blocks, for example on I/O, the JVM unmounts the virtual thread from its carrier, so the carrier can run another virtual thread.
On Java 21, 22 and 23, a virtual thread that blocks inside a synchronized block cannot leave its carrier, which is called pinning. A ReentrantLock never pinned the carrier.
JEP 491, delivered in Java 24, removed pinning for synchronized. Since Java 24, a virtual thread can also leave its carrier while waiting for a monitor or waiting in Object.wait().
We measured the difference with 200 virtual threads that each block for 100 ms while holding a lock. Each task has its own lock, so the tasks do not wait for each other. We fixed the number of carriers to 2 with -Djdk.virtualThreadScheduler.parallelism=2.
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 200; i++) {
Object monitor = new Object();
executor.submit(() -> {
synchronized (monitor) {
TimeUnit.MILLISECONDS.sleep (100); // blocks while holding the monitor
}
return null;
});
}
}
// the ReentrantLock version replaces synchronized with lock() / try / finally unlock()
Java 21
synchronized : 10036 ms
ReentrantLock: 105 ms
Java 25
synchronized : 106 ms
ReentrantLock: 107 ms
On Java 21, each blocked task held one of the 2 carriers, so only 2 tasks ran at a time and 200 tasks took 100 x 100 ms = 10 seconds. On Java 25, both versions took about 100 ms.
So on Java 24 and later, we do not need to replace synchronized with ReentrantLock for virtual threads. JEP 491 also says that code already moved to ReentrantLock can stay as it is. A few cases still pin a virtual thread.
- Native code (a native method or the Foreign Function and Memory API) calls back into Java code, and that Java code blocks.
- A thread blocks while a class is loaded or initialized, for example inside a static initializer.
11. Java Lock FAQs
11.1. What Happens if I Forget to Call unlock()?
The thread keeps holding the lock, so every other thread that calls lock() waits forever and the program stops making progress.
A thread dump then shows the waiting threads in state WAITING (parking). The fix is the try-finally pattern from the first snippet, with lock() placed right before try.
11.2. Is ReentrantLock Faster Than synchronized?
Not in general. Both are fast when only one thread uses the lock, and with many threads the speed depends on the JVM version and on the code.
JEP 491 says only that the two have “different performance characteristics”. We choose by features, not by speed, and measure when speed matters.
11.3. Can One Thread Lock and Another Thread Unlock a ReentrantLock?
No. The thread that called lock() must call unlock(), and any other thread gets an IllegalMonitorStateException (section 2.2). For a “permit” that one thread takes and another thread returns, we use a Semaphore instead.
11.4. Should I Use a Lock or ConcurrentHashMap?
For a shared map, ConcurrentHashMap is the better choice, because it is thread-safe without our own lock, and methods such as merge() and computeIfAbsent() change a value in one step. We use our own lock when several fields or collections must change together.
12. Conclusion
A Lock lets one thread at a time run the code that changes shared data, and we always call lock() before try and unlock() in finally.
ReentrantLock adds tryLock() with a timeout, lockInterruptibly(), a fair mode and several Condition objects per lock. ReadWriteLock lets many readers in at once, and StampedLock adds optimistic reads.
When we only need one thread at a time, synchronized is shorter, and since Java 24 it no longer pins virtual threads.
13. References
- Lock JavaDoc (Java 25)
- ReentrantLock JavaDoc (Java 25)
- ReentrantReadWriteLock JavaDoc (Java 25)
- StampedLock JavaDoc (Java 25)
- Condition JavaDoc (Java 25)
- JEP 491: Synchronize Virtual Threads without Pinning
- JEP 444: Virtual Threads
Happy Learning !!
Few Changes Suggested:
1-) In PrinterQueue change to : ReentrantLock queueLock = new ReentrantLock();
2-) Reduce time : Long duration = (long) (Math.random() * 100);
System.out.println(Thread.currentThread().getName() + “: PrintQueue: Printing a Job during ” + (duration / 10) + ” seconds :: Time – ” + new Date());
First of all thanks for the nice post . I am a beginner and I am very confused with lock().
What exactly is lock() locking. I mean a thread or an object or what??
So just to be clear about my understanding of locks.
queueLock.lock(); will lock queueLock
and this means only one Thread can run this block of code(Between try and catch) at a time.
is it correct?
This code is locking the block as there is only one instance of PrinterQueue. Right??
Please help me understand it.
Thanks
Hi Lokesh,
If possible can you cover a comparitive study of ReentrantLock against ReadWriteLock() and ReentrantReadWriteLock()
Regards,
Himansu
Important Difference in Lock and synchronized blocks :
Lock implementations provide additional functionality over the use of synchronized methods and statements by providing a non-blocking attempt to acquire a lock (tryLock()), an attempt to acquire the lock that can be interrupted (lockInterruptibly, and an attempt to acquire the lock that can timeout (tryLock(long, TimeUnit)).
They allow more flexible structuring, may have quite different properties, and may support multiple associated Condition objects.
– from JavaDoc
Hi Lokesh,
There is one more key difference is unlike synchronized mechanism which uses implict JVM CAS instruction, Lock uses explicit “object” locking (not sure implicitly it uses CAS). Other then lock and unlock api this “object” it self can also be locked using synchronized. Quoting the javadoc
“Note that Lock instances are just normal objects and can themselves be used as the target in a synchronized statement. Acquiring the monitor lock of a Lock instance has no specified relationship with invoking any of the lock() methods of that instance. It is recommended that to avoid confusion you never use Lock instances in this way, except within their own implementation.”
Yes, locks implicitly use CAS using sun.misc.Unsafe. Thanks for your valuable comment.