Java Lock Interface and ReentrantLock
Table of Contents + β
In the last lesson you learned about Java CompletableFuture. Now we go back to locks. You already know synchronized lets only one thread touch shared data at a time, but it is rigid.
With plain synchronized you cannot:
- Ask βis the lock free right now?β and walk away if it is busy.
- Give up after waiting a few seconds.
- Cancel a thread that is stuck waiting.
Java fixes all of this with the Lock interface and its main class, ReentrantLock.
π€ Why we need more than synchronized
Think of synchronized as a door you can only stand in front of and wait. The Lock interface gives you a door with a handle you control yourself.
- With
synchronizeda thread waits there until it gets in. No βtry and give upβ. - No βwait, but let me be cancelledβ.
- The lock is tied to a code block, so you cannot lock in one method and unlock in another.
ReentrantLockfixes all of this: you calllock()to get in andunlock()to let the next thread in.
π lock() and unlock()
The basic shape: create a ReentrantLock, call lock() before the shared data, call unlock() after. The one rule that matters most: always put unlock() in a finally block.
The example below protects a shared counter with a lock and unlocks safely in finally:
import java.util.concurrent.locks.Lock;import java.util.concurrent.locks.ReentrantLock;
class Counter { private int count = 0; private final Lock lock = new ReentrantLock(); // the lock object
public void increment() { lock.lock(); // β
get the lock try { count++; // critical section } finally { lock.unlock(); // β
always release, even if work throws } }
public int getCount() { return count; }}
public class Main { public static void main(String[] args) throws InterruptedException { Counter counter = new Counter();
Runnable task = () -> { for (int i = 0; i < 1000; i++) counter.increment(); };
Thread t1 = new Thread(task); Thread t2 = new Thread(task); t1.start(); t2.start(); t1.join(); t2.join();
System.out.println("Final count: " + counter.getCount()); }}How it works:
- The
Counterholds the shared number and oneLockobject. lock.lock()gives the thread the lock. No other thread can pass until it callsunlock().- The
count++runs safely, thenfinallycallsunlock()no matter what. - Two threads each increment a thousand times. The result is exactly 2000, every run.
Output
Final count: 2000The finally part is not optional. If count++ threw and you unlocked outside finally, the lock would stay held forever and every other thread would wait forever. So the rule is firm. Lock in the try, unlock in the finally.
πͺ tryLock() to avoid waiting
The first power synchronized cannot give you: tryLock() asks for the lock but does not wait.
- If the lock is free, it grabs it and returns
true. - If the lock is busy, it returns
falseright away and moves on. - The thread never blocks.
The example below tries to get the lock and does other work if it is busy:
import java.util.concurrent.locks.Lock;import java.util.concurrent.locks.ReentrantLock;
class Printer { private final Lock lock = new ReentrantLock();
public void print(String name) { if (lock.tryLock()) { // grab it only if free try { System.out.println(name + " got the lock and is printing"); } finally { lock.unlock(); } } else { System.out.println(name + " could not get the lock, will skip"); } }}
public class Main { public static void main(String[] args) throws InterruptedException { Printer printer = new Printer();
Thread t1 = new Thread(() -> printer.print("Worker-1")); Thread t2 = new Thread(() -> printer.print("Worker-2")); t1.start(); t2.start(); t1.join(); t2.join(); }}One thread gets the lock and prints. The other may find it busy and take the else path instead of waiting. Note we only call unlock() inside the if, because we only locked when tryLock() returned true. The output depends on timing.
Output (timing may vary)
Worker-1 got the lock and is printingWorker-2 could not get the lock, will skipThis is very useful for avoiding deadlock. A thread that needs two locks can grab the first, then tryLock() the second. If the second is busy, it releases the first and retries later, instead of holding one lock while stuck waiting for another.
β±οΈ tryLock(timeout) to wait a limited time
tryLock(timeout, unit) waits up to a set time, for when you do not want to give up instantly but also do not want to wait forever.
- If the lock comes free within that time, it returns
true. - If the time runs out first, it returns
false. - A thread never hangs forever waiting for a lock that may never come free.
The example below waits up to two seconds for the lock, then gives up:
import java.util.concurrent.TimeUnit;import java.util.concurrent.locks.Lock;import java.util.concurrent.locks.ReentrantLock;
class Resource { private final Lock lock = new ReentrantLock();
public void use(String name) { try { if (lock.tryLock(2, TimeUnit.SECONDS)) { // wait at most 2 seconds try { System.out.println(name + " is using the resource"); Thread.sleep(3000); // hold it for 3 seconds } finally { lock.unlock(); } } else { System.out.println(name + " gave up after waiting 2 seconds"); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }}
public class Main { public static void main(String[] args) { Resource resource = new Resource(); new Thread(() -> resource.use("Worker-1")).start(); new Thread(() -> resource.use("Worker-2")).start(); }}Worker-1 gets the lock and holds it for three seconds. Worker-2 waits, but its limit is only two seconds. So Worker-2 gives up before Worker-1 is done. This keeps the program responsive.
Output (timing may vary)
Worker-1 is using the resourceWorker-2 gave up after waiting 2 secondsβ lockInterruptibly()
A plain lock() cannot be interrupted, so a thread waiting in it cannot be cancelled. lockInterruptibly() fixes this.
- A thread waiting in
lockInterruptibly()throwsInterruptedExceptionif another thread interrupts it. - This lets you cancel a thread that is stuck waiting for a lock.
The example below shows a worker that can be cancelled while it waits:
import java.util.concurrent.locks.Lock;import java.util.concurrent.locks.ReentrantLock;
class Job { private final Lock lock = new ReentrantLock();
public void run(String name) { try { lock.lockInterruptibly(); // can be cancelled while waiting try { System.out.println(name + " is working"); Thread.sleep(5000); } finally { lock.unlock(); } } catch (InterruptedException e) { System.out.println(name + " was interrupted while waiting"); } }}This matters for long-running programs. If a user clicks cancel, you interrupt the waiting thread and it stops cleanly. A plain lock() would ignore the interrupt and keep waiting.
π Reentrancy: a thread can re-enter its own lock
The name comes from reentrancy: a thread that already holds the lock can take it again without blocking itself.
- Each
lock()adds one to a hold count. - Each
unlock()removes one. - The lock is only fully released when the count reaches zero.
The example below shows one method locking, then calling another method that locks the same lock again:
import java.util.concurrent.locks.ReentrantLock;
class Account { private final ReentrantLock lock = new ReentrantLock(); private int balance = 0;
public void deposit(int amount) { lock.lock(); try { balance += amount; logBalance(); // calls another locking method } finally { lock.unlock(); } }
public void logBalance() { lock.lock(); // β
same thread, re-acquires safely try { System.out.println("Balance is now " + balance); } finally { lock.unlock(); } }}
public class Main { public static void main(String[] args) { Account account = new Account(); account.deposit(100); account.deposit(50); }}When deposit holds the lock and calls logBalance, the same thread asks for the lock again. Because the lock is reentrant, this works fine. Without reentrancy, the thread would deadlock with itself. Each lock() is matched by one unlock(), so the counts balance out.
Output
Balance is now 100Balance is now 150βοΈ The fairness option
When many threads wait for a lock, who gets it next?
- By default,
ReentrantLockdoes not promise an order. A new arrival might cut ahead of one that waited longer. This is fast. - Ask for a fair lock by passing
trueto the constructor. It gives the lock to the longest-waiting thread. - A fair lock is a bit slower, because it has to track the waiting order.
The example below builds a fair lock:
import java.util.concurrent.locks.Lock;import java.util.concurrent.locks.ReentrantLock;
class Service { private final Lock lock = new ReentrantLock(true); // true means fair
public void handle(String name) { lock.lock(); try { System.out.println(name + " is being served"); } finally { lock.unlock(); } }}Use a fair lock only when waiting order really matters. For most code the default unfair lock is the right choice because it is faster.
π£ Condition objects: a flexible wait and notify
The Lock version of wait() and notify() is the Condition object, with more control.
- Make a condition from the lock with
newCondition(). - A thread calls
await()to wait and release the lock. - Another thread calls
signal()to wake one waiting thread. - One lock can have many conditions, so you can wake the right group instead of all threads.
The example below shows a simple producer and consumer using a condition:
import java.util.concurrent.locks.Condition;import java.util.concurrent.locks.Lock;import java.util.concurrent.locks.ReentrantLock;
class Box { private final Lock lock = new ReentrantLock(); private final Condition ready = lock.newCondition(); private boolean hasItem = false;
public void produce() throws InterruptedException { lock.lock(); try { hasItem = true; System.out.println("Produced an item"); ready.signal(); // wake a waiting consumer } finally { lock.unlock(); } }
public void consume() throws InterruptedException { lock.lock(); try { while (!hasItem) { ready.await(); // wait and release the lock } System.out.println("Consumed an item"); hasItem = false; } finally { lock.unlock(); } }}The consumer calls await() inside a while loop. The loop check matters: after waking up, the thread checks the condition again before moving on. The producer calls signal() to wake one waiter.
π Lock vs synchronized comparison
Both tools protect shared data. The difference is control.
| Feature | synchronized | ReentrantLock |
|---|---|---|
| Release | Automatic at block end | Manual, you call unlock() |
| Try without waiting | Not possible | Yes, with tryLock() |
| Wait with a timeout | Not possible | Yes, tryLock(timeout) |
| Cancel while waiting | Not possible | Yes, lockInterruptibly() |
| Fairness option | No | Yes, pass true |
| Wait and notify | One per object | Many Condition objects |
| Ease of use | Simple, hard to misuse | More code, easy to forget unlock |
The short version: use synchronized for a plain lock, because it is shorter and you can never forget to release it. Reach for ReentrantLock when you need trying, timeouts, cancelling, or fairness.
π Converting a synchronized counter to ReentrantLock
Letβs convert a counter from synchronized to ReentrantLock. First, the synchronized version you already know:
class Counter { private int count = 0;
public synchronized void increment() { // β no extra control count++; }
public synchronized int getCount() { return count; }}Now the same counter with a ReentrantLock. The behaviour is identical, but now you could add tryLock or a timeout later. This version replaces the keyword with explicit lock and unlock calls:
import java.util.concurrent.locks.Lock;import java.util.concurrent.locks.ReentrantLock;
class Counter { private int count = 0; private final Lock lock = new ReentrantLock();
public void increment() { lock.lock(); // β
explicit lock try { count++; } finally { lock.unlock(); // β
explicit release in finally } }
public int getCount() { lock.lock(); try { return count; // reads are protected too } finally { lock.unlock(); } }}Both versions give the right answer of 2000 with two threads. For a plain counter, synchronized or AtomicInteger is the cleaner pick. Choose the lock only when you actually use its extra features.
β οΈ Common Mistakes
A few ReentrantLock slip-ups to watch for.
- Forgetting to call unlock(). If you never unlock, the lock stays held and every other thread waits forever. This is the most common and most painful mistake.
// β Wrong: if work throws, unlock never runslock.lock();count++;lock.unlock();
// β
Right: finally guarantees the releaselock.lock();try { count++;} finally { lock.unlock();}-
Putting unlock() outside finally. Even if you remember to unlock, doing it outside
finallymeans an exception skips it. Always unlock infinally. -
Calling unlock() when you did not get the lock. With
tryLock(), only unlock inside theifwhere the lock was actually taken. Unlocking a lock you do not hold throwsIllegalMonitorStateException.
// β Wrong: unlocks even when tryLock returned falselock.tryLock();try { count++;} finally { lock.unlock(); // throws if the lock was never acquired}
// β
Right: only unlock when you got the lockif (lock.tryLock()) { try { count++; } finally { lock.unlock(); }}- Using a lock when synchronized is simpler. If you do not need tryLock, timeouts, or fairness, the extra code just adds risk. Plain
synchronizedis safer for simple cases.
β Best Practices
Habits for safe locking with ReentrantLock.
- Always unlock in a finally block. This is the golden rule. It guarantees the lock is released even when the protected code throws.
- Match every lock() with exactly one unlock(). Because the lock is reentrant, the counts must balance, or the lock never fully frees.
- Use tryLock() to avoid deadlock. Grabbing locks with try-and-give-up is a clean way to break out of a possible lock cycle.
- Use tryLock(timeout) to stay responsive. Never let a thread wait forever for a lock that may not come.
- Keep the critical section small. Lock only the code that touches shared data, so other threads wait as little as possible.
- Prefer synchronized for simple cases. Reach for
ReentrantLockonly when you need its extra powers. - Use a fair lock only when order matters. The default unfair lock is faster, so keep it unless waiting order is important.
π§© What Youβve Learned
Nicely done. Letβs recap the Lock interface and ReentrantLock.
- β
The Lock interface in
java.util.concurrent.locksis a flexible alternative tosynchronized, and ReentrantLock is its main class. - β Use lock() to get in and unlock() to release, and always put unlock() in a finally block.
- β
tryLock() grabs the lock only if free and returns
falseotherwise, so the thread never blocks. - β tryLock(timeout) waits a limited time, and lockInterruptibly() lets a waiting thread be cancelled.
- β Reentrancy means a thread can re-acquire a lock it already holds, tracked by a hold count.
- β A fair lock serves the longest-waiting thread first, at a small speed cost.
- β Condition objects give a flexible wait and notify with await() and signal().
- β Use synchronized for simple locks and ReentrantLock when you need its extra control.
Check Your Knowledge
Test what you learned. Pick an answer for each question, then click Check.
- 1
Where should you always call unlock() on a ReentrantLock?
Why: Unlocking in finally guarantees the lock is released even if the protected code throws an exception.
- 2
What does tryLock() do when the lock is busy?
Why: tryLock() returns false immediately if the lock is taken, so the thread can do something else instead of waiting.
- 3
What does reentrancy mean for ReentrantLock?
Why: A thread holding the lock can call lock() again without blocking; each lock() is matched by one unlock().
- 4
When is plain synchronized a better choice than ReentrantLock?
Why: For simple cases synchronized is shorter and you can never forget to release the lock, so it is safer.
π Whatβs Next?
ReentrantLock lets only one thread in at a time, even for reads. But many threads can safely read shared data at once, as long as nobody is writing. Next you will learn ReadWriteLock, which allows many readers together but only one writer. This boosts speed for read-heavy data. Letβs learn it.