← Back to the section

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

What's wrong with synchronized

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

Imagine a thread has already been waiting 10 seconds to acquire the lock. The user clicks "Cancel." There's no way to notify the waiting thread through synchronized — it will simply keep waiting.

Or another example: a system with 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 these situations.

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 usage pattern:

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();           // acquire the lock
        try {
            count++;           // critical section
        } finally {
            lock.unlock();     // ALWAYS in finally
        }
    }
}

The rule is ironclad: unlock() always goes in a finally block. If an exception is thrown before unlock(), the lock stays acquired forever — every other thread will hang. The finally block guarantees release under any outcome.

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 — it decides for itself what to do next.

public boolean tryIncrement() {
    if (lock.tryLock()) {        // did we get the lock?
        try {
            count++;
            return true;
        } finally {
            lock.unlock();
        }
    }
    return false;                // lock is busy, no big deal
}

There's also a variant with a timeout — wait for a given amount of time and leave if it doesn't come free:

if (lock.tryLock(200, TimeUnit.MILLISECONDS)) {
    try {
        // do the work
    } finally {
        lock.unlock();
    }
} else {
    // lock has been busy for more than 200 ms — do something else
}

This is especially useful when working with several locks: acquire the first one, try to acquire the second — and if that fails, release the first and retry later. Such a tactic helps avoid a deadlock.

lockInterruptibly: interruptible waiting

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

public void interruptibleWork() throws InterruptedException {
    lock.lockInterruptibly();    // may throw InterruptedException
    try {
        // do the work
    } finally {
        lock.unlock();
    }
}

A plain lock.lock() ignores interruption — the thread keeps waiting even after interrupt(). lockInterruptibly() is needed when it's important to be able to cancel the operation from outside — for example, during application shutdown or on a user request.

Fairness: a fair queue

By default, ReentrantLock gives no guarantees about which thread will get the lock next. In practice, the winner is often whoever happened to arrive "at the right moment" — this is called an unfair lock. It's faster because there's no need to maintain a queue.

If a thread can wait indefinitely while others constantly snatch the lock away — that's starvation. For such cases, ReentrantLock supports a fairness mode:

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

In fair mode, the lock is given to the thread that has been waiting the longest. This eliminates starvation but reduces overall speed because of the overhead of managing the queue.

Short formula: unfair is faster; fair is fairer. Use fair only when starvation is genuinely a problem.

ReadWriteLock: readers and writers

When data is read often and modified rarely, ReadWriteLock gives a significant performance boost. It has two locks:

  • read lock — can be acquired by several threads simultaneously (as long as no one is writing);
  • write lock — exclusive, waits until all readers have left.
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();         // several threads can read simultaneously
        try {
            return value;
        } finally {
            readLock.unlock();
        }
    }

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

If read operations vastly outnumber writes, ReadWriteLock substantially reduces contention between threads.

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 during the read?
            stamp = sl.readLock();           // then take a regular read lock
            try {
                cx = x; cy = y;
            } finally {
                sl.unlockRead(stamp);
            }
        }
        return Math.sqrt(cx * cx + cy * cy);
    }
}

StampedLock delivers maximum speed under a high read frequency, but it's 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 perfectly sufficient when:

  • the critical section is simple and short;
  • you don't need to try acquiring the lock without waiting (tryLock);
  • you don't need to interrupt the wait;
  • there's no split between readers and writers;
  • the code is simpler — fewer chances to forget unlock().

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 primary implementation.
  • unlock() always goes in a finally block — no exceptions.
  • tryLock() returns control immediately if the lock is busy; with a timeout, it waits for the given time.
  • lockInterruptibly() lets you interrupt the wait via Thread.interrupt().
  • Fair mode (new ReentrantLock(true)) eliminates starvation but runs slower.
  • ReadWriteLock speeds up "many readers, a rare writer" scenarios.
  • StampedLock provides optimistic reading without acquiring the lock — faster, but more complex.
  • synchronized is still good for simple cases — don't overcomplicate without a reason.
  • Synchronized and the object monitor — how Java's built-in lock works and what happens inside the monitor.
  • Atomic operations and CAS — when locks aren't needed at all: AtomicInteger, AtomicReference and optimistic updates.
  • Common concurrency bugs — deadlock, livelock, starvation and data races: how they arise and how to avoid them.