← Back to the section

synchronized is Java's built-in mechanism for mutual exclusion: simple and reliable, but sometimes its capabilities fall short. For those cases the java.util.concurrent.locks package offers a more flexible tool — Lock.

three threads, one lock, time runs left to right thread A thread B thread C critical sectionlock() section is doneunlock() lock() — waiting in queue critical section tryLock() → false does other work, no waiting

Thread A took the lock and is working. Thread B called lock() and joined the queue: it wakes up only after unlock() and gets nothing else done meanwhile. Thread C called tryLock(), got false and immediately went off to do other work — that is the difference between an explicit lock and synchronized, where the "don't wait" option simply doesn't exist.

What's wrong with synchronized

synchronized works on a "grab it or wait" principle: once one thread has captured the monitor, the others block and wait patiently. You can't leave early, interrupt the wait, or try to acquire the lock just "to check."

Imagine a thread has already been waiting 10 seconds for the lock and the user clicks "Cancel." There is no way to tell that through synchronized — the thread simply keeps waiting.

Or another example: many readers and one rare writer. synchronized doesn't distinguish reading from writing — readers will block each other, even though concurrent reading is perfectly safe. Explicit locks exist precisely for situations like these.

ReentrantLock: the basics

ReentrantLock is the most commonly used implementation of the Lock interface. "Reentrant" means that the same thread can acquire the lock several times in a row (without getting stuck on itself), and must release it exactly the same number of times.

The minimal pattern — and a check that it works: two threads, a hundred thousand increments each.

live example

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class Counter {
    private final Lock lock = new ReentrantLock();
    private int count = 0;

    public void increment() {
        lock.lock();
        try {
            count++;               // critical section
        } finally {
            lock.unlock();         // ALWAYS in finally
        }
    }

    public static void main(String[] args) throws InterruptedException {
        Counter counter = new Counter();
        Runnable job = () -> { for (int i = 0; i < 100_000; i++) counter.increment(); };
        Thread a = new Thread(job);
        Thread b = new Thread(job);
        a.start();
        b.start();
        a.join();
        b.join();
        System.out.println("after two threads: " + counter.count);
    }
}
Run

Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →

It prints exactly 200000. Remove lock() and unlock() — every run will give its own number, and a smaller one.

The rule is ironclad: unlock() always goes in a finally block. If an exception is thrown before unlock(), the lock stays acquired forever and every other thread hangs.

tryLock: try without hanging

tryLock() returns true if the lock could be acquired right now and false if it's busy: the thread doesn't block and decides for itself what to do next. There's also a variant with a timeout — wait for a given time and leave empty-handed. In the example below the main thread holds the lock while a neighbour tries both variants.

live example

import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class TryLockDemo {
    private static final Lock lock = new ReentrantLock();

    public static void main(String[] args) throws InterruptedException {
        lock.lock();                       // the main thread took the lock
        Thread worker = new Thread(() -> {
            System.out.println("tryLock() right away: " + lock.tryLock());
            try {
                boolean got = lock.tryLock(300, TimeUnit.MILLISECONDS);
                System.out.println("tryLock() with a timeout: " + got);
                if (got) {
                    lock.unlock();
                }
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        });
        worker.start();
        Thread.sleep(100);
        lock.unlock();                     // release it while the neighbour is still waiting
        worker.join();
    }
}
Run

Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →

The output: false on the first line, true on the second. The signatures differ: plain tryLock() doesn't throw InterruptedException, while the timeout variant does — it waits, after all.

That's how several locks are taken: acquire the first one, try the second, and if that fails, release the first and retry later. It saves you from a deadlock.

lockInterruptibly: interruptible waiting

lockInterruptibly() waits for the lock exactly like lock(), but with one important difference: if the thread receives an interrupt (Thread.interrupt()), it immediately leaves the wait with an InterruptedException.

live example

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class InterruptibleDemo {
    private static final Lock lock = new ReentrantLock();

    public static void main(String[] args) throws InterruptedException {
        lock.lock();                        // the lock is taken and won't be released
        Thread waiting = new Thread(() -> {
            try {
                lock.lockInterruptibly();   // wait, but respond to an interrupt
                try {
                    System.out.println("entered the section");
                } finally {
                    lock.unlock();
                }
            } catch (InterruptedException e) {
                System.out.println("wait interrupted, the lock was never taken");
            }
        });
        waiting.start();
        Thread.sleep(100);
        waiting.interrupt();                // a plain lock() would not notice this interrupt
        waiting.join();
        lock.unlock();
    }
}
Run

Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →

Replace lockInterruptibly() with lock() and the program never finishes: the thread keeps waiting even after interrupt(). Interruptible waiting is needed where an operation must be cancellable from outside: application shutdown, a user giving up.

Fairness: a fair queue

By default ReentrantLock makes no promise about which thread gets the lock next: usually the winner is whoever arrived "at the right moment." That's an unfair lock, and it's faster — no queue to maintain.

If a thread can wait forever while others keep snatching the lock away, that's starvation. For that case ReentrantLock has a fairness mode:

// true — enable a fair queue (FIFO by waiting time)
Lock fairLock = new ReentrantLock(true);

In fair mode the lock goes to whoever has waited the longest: no starvation, but the queue costs speed — so fair mode is switched on only where starvation genuinely hurts.

ReadWriteLock: readers and writers

When data is read often and modified rarely, ReadWriteLock helps — it has two locks instead of one. The read lock can be held by any number of threads at once as long as nobody is writing; the write lock is exclusive and waits until every reader has left.

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;

public class Cache {
    private final ReadWriteLock rwLock = new ReentrantReadWriteLock();
    private final Lock readLock  = rwLock.readLock();
    private final Lock writeLock = rwLock.writeLock();
    private String value;

    public String get() {
        readLock.lock();         // many readers may be inside
        try {
            return value;
        } finally {
            readLock.unlock();
        }
    }

    public void set(String newValue) {
        writeLock.lock();        // one writer, no readers
        try {
            value = newValue;
        } finally {
            writeLock.unlock();
        }
    }
}

When reads outnumber writes several times over, contention between threads drops noticeably. There is one trap: you can't "upgrade" a read lock to a write lock inside one thread — the writer waits for readers to leave, that reader is the thread itself, and it hangs for good. The other direction is allowed.

StampedLock: optimistic reading

StampedLock (Java 8+) is a more advanced alternative to ReadWriteLock. Its main feature is optimistic reading: a thread reads the data without acquiring the lock and only afterward checks whether it changed in the meantime.

import java.util.concurrent.locks.StampedLock;

public class Point {
    private final StampedLock sl = new StampedLock();
    private double x, y;

    public double distanceFromOrigin() {
        long stamp = sl.tryOptimisticRead(); // read without locking
        double cx = x, cy = y;
        if (!sl.validate(stamp)) {           // did the data change?
            stamp = sl.readLock();           // take a regular read lock
            try {
                cx = x; cy = y;
            } finally {
                sl.unlockRead(stamp);
            }
        }
        return Math.sqrt(cx * cx + cy * cy);
    }
}

StampedLock is the fastest choice under frequent reads, but harder to use: it isn't reentrant and works with "stamps" (long) instead of the familiar Lock interface.

When synchronized is enough

Explicit locks aren't always needed. synchronized is enough when the section is short, you have to wait for it anyway, and nobody separates readers from writers — and the code is simpler: there's no unlock() to forget.

A short selection formula:

Capability neededChoice
Just protect a sectionsynchronized
tryLock or a timeoutReentrantLock
Interrupting the waitReentrantLock
Many readers, a rare writerReadWriteLock
Maximum read speedStampedLock

In short

  • Lock is an explicit lock from java.util.concurrent.locks, ReentrantLock is the main implementation; unlock() always goes in a finally block.
  • tryLock() never blocks the thread: if the lock is busy it returns false; the timeout variant waits for the given time and throws InterruptedException.
  • lockInterruptibly() lets you interrupt the wait via Thread.interrupt(), while a plain lock() ignores the interrupt.
  • Fair mode (new ReentrantLock(true)) eliminates starvation at the cost of speed.
  • ReadWriteLock speeds up "many readers, a rare writer"; upgrading a read lock to a write lock is impossible.
  • StampedLock reads without acquiring the lock: faster, but harder; short sections do fine with synchronized.