Java Lock Interface and ReentrantLock

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 synchronized a 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.
  • ReentrantLock fixes all of this: you call lock() to get in and unlock() 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 Counter holds the shared number and one Lock object.
  • lock.lock() gives the thread the lock. No other thread can pass until it calls unlock().
  • The count++ runs safely, then finally calls unlock() no matter what.
  • Two threads each increment a thousand times. The result is exactly 2000, every run.

Output

Final count: 2000

The 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 false right 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 printing
Worker-2 could not get the lock, will skip

This 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 resource
Worker-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() throws InterruptedException if 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 100
Balance is now 150

βš–οΈ The fairness option

When many threads wait for a lock, who gets it next?

  • By default, ReentrantLock does 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 true to 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 runs
lock.lock();
count++;
lock.unlock();
// βœ… Right: finally guarantees the release
lock.lock();
try {
count++;
} finally {
lock.unlock();
}
  • Putting unlock() outside finally. Even if you remember to unlock, doing it outside finally means an exception skips it. Always unlock in finally.

  • Calling unlock() when you did not get the lock. With tryLock(), only unlock inside the if where the lock was actually taken. Unlocking a lock you do not hold throws IllegalMonitorStateException.

// ❌ Wrong: unlocks even when tryLock returned false
lock.tryLock();
try {
count++;
} finally {
lock.unlock(); // throws if the lock was never acquired
}
// βœ… Right: only unlock when you got the lock
if (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 synchronized is 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 ReentrantLock only 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.locks is a flexible alternative to synchronized, 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 false otherwise, 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. 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. 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. 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. 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.

Java ReadWriteLock

Share & Connect