Skip to content
CalliCoder

Java Locks and Atomic Variables Tutorial

Java 13 min read

ReentrantLock over synchronized when you need a timeout or a second condition, the unlock that must live in a finally, why ReadWriteLock often loses to a plain lock, and where LongAdder beats AtomicLong.

synchronized covers most cases and cannot do four things: give up after a timeout, be interrupted, be acquired in one method and released in another, or offer more than one waiting condition. java.util.concurrent.locks exists for those four, and it costs you the one guarantee the keyword gives for free. That the lock is always released.

Written against Java 17.

ReentrantLock

private final ReentrantLock lock = new ReentrantLock();

void process() {
    lock.lock();
    try {
        // critical section
    } finally {
        lock.unlock();
    }
}

The try must start immediately after lock(), and unlock() must be in the finally. Any other arrangement leaks the lock on an exception, and a leaked lock is a hang rather than a crash — every other thread blocks forever with no stack trace pointing at the cause.

Putting lock() inside the try is the specific mistake to avoid, if the acquisition itself throws, the finally calls unlock() on a lock this thread does not hold, and IllegalMonitorStateException replaces the original exception.

Reentrant means the holding thread can acquire it again; the lock keeps a count and releases when it reaches zero. That matches synchronized and is what lets one guarded method call another.

What it can do that synchronized cannot

Give up:

if (lock.tryLock()) {
    try { ... } finally { lock.unlock(); }
} else {
    // do something else instead of blocking
}

Give up after waiting:

if (lock.tryLock(2, TimeUnit.SECONDS)) {
    try { ... } finally { lock.unlock(); }
}

Be interrupted while waiting:

lock.lockInterruptibly();   // throws InterruptedException instead of blocking forever

That last one matters for shutdown, a thread blocked on synchronized cannot be interrupted, so a service that will not stop is often a thread waiting on a monitor nobody will release.

tryLock with a timeout is also the standard escape from deadlock: acquire what you can, back off and retry when you cannot, rather than holding one lock while blocking forever on a second.

Fairness, and why the default is unfair

ReentrantLock fair = new ReentrantLock(true);

A fair lock hands ownership to the longest-waiting thread. The default is unfair, which allows barging, a thread arriving as the lock is released can take it ahead of the queue.

Unfair is the default because it is much faster: handing the lock to a thread already running beats waking a parked one. The cost is that a thread can be starved in principle, which in practice almost never happens because critical sections are short.

Choose fairness only when starvation is observed, and expect a large throughput drop. Note that tryLock() without a timeout barges even on a fair lock.

ReadWriteLock

private final ReadWriteLock rwLock = new ReentrantReadWriteLock();

Object read(String key) {
    rwLock.readLock().lock();
    try { return map.get(key); }
    finally { rwLock.readLock().unlock(); }
}

void write(String key, Object value) {
    rwLock.writeLock().lock();
    try { map.put(key, value); }
    finally { rwLock.writeLock().unlock(); }
}

Many readers concurrently, one writer exclusively. The idea is obviously right and the measurement often disagrees, because the read lock is not free: acquiring it writes to a shared counter, so concurrent readers contend on that instead of on the data.

It pays off when reads massively outnumber writes and the critical sections are long enough for the concurrency to matter. For a map.get, a plain lock is usually faster, and ConcurrentHashMap is faster than either.

A read lock cannot be upgraded to a write lock, the attempt deadlocks. Release and re-acquire, then re-check the state, because it may have changed in the gap.

StampedLock adds an optimistic read that takes no lock at all:

long stamp = stampedLock.tryOptimisticRead();
int value = this.value;
if (!stampedLock.validate(stamp)) {
    stamp = stampedLock.readLock();
    try { value = this.value; } finally { stampedLock.unlockRead(stamp); }
}

It is faster and it is not reentrant, so it is easy to deadlock against yourself. Use it for short, well-understood reads or not at all.

Conditions

synchronized gives one wait set per object. A lock gives as many as you declare, which is what a bounded buffer needs:

private final ReentrantLock lock = new ReentrantLock();
private final Condition notFull = lock.newCondition();
private final Condition notEmpty = lock.newCondition();

void put(T item) throws InterruptedException {
    lock.lock();
    try {
        while (queue.size() == capacity) {
            notFull.await();
        }
        queue.add(item);
        notEmpty.signal();
    } finally {
        lock.unlock();
    }
}

while, never if, a thread can wake without being signalled, a spurious wakeup, and even a real signal does not guarantee the condition still holds by the time this thread runs. Re-checking in a loop is the only correct form.

Two conditions mean signal wakes a thread waiting on the right one, rather than notifyAll waking everybody to have most of them go back to sleep.

Atomic variables

For a single variable, no lock is needed at all:

private final AtomicLong counter = new AtomicLong();

counter.incrementAndGet();
counter.addAndGet(5);
counter.compareAndSet(expected, updated);
counter.updateAndGet(v -> Math.max(v, candidate));

Underneath is a compare-and-swap instruction: read the value, compute the new one, write it only if the old one has not changed, retry otherwise. There is no blocking, so there is no deadlock and no context switch.

The retry is the cost. Under heavy contention many threads spin, and LongAdder wins by spreading the count across per-thread cells:

private final LongAdder requests = new LongAdder();
requests.increment();
long total = requests.sum();

sum() is not an atomic snapshot. It adds the cells while they are still being written, so use it for metrics, not for a value another decision depends on.

AtomicReference extends the same idea to objects, and updateAndGet takes a function that must be side-effect free, because it can run several times when the CAS retries.

The limit is the same as volatile’s, one level up: an atomic protects one variable. Two atomics that must agree with each other are two independent atomics, and there is no window in which both are locked.

Diagnosing a stuck lock

A hang caused by a lock looks like nothing at all: no exception, no log line, CPU at zero. The tool is a thread dump.

jcmd <pid> Thread.print

synchronized monitors appear directly: a blocked thread shows waiting to lock <0x00000007...> and another shows locked <0x00000007...>, which names the owner.

ReentrantLock does not appear the same way, because it is built on AbstractQueuedSynchronizer rather than on a monitor. A waiting thread shows parking to wait for <0x...> (a java.util.concurrent.locks.ReentrantLock$NonfairSync) and the owner is not named in the dump. That is a real diagnostic disadvantage of locks over the keyword, and it is worth weighing when choosing between them.

Two things help. ReentrantLock exposes isLocked(), getHoldCount() and a protected getOwner(), so a subclass can surface the owner in a log line. And jcmd <pid> Thread.print -l includes the lock objects reachable from AQS, which sometimes identifies the holder where the plain dump does not.

If neither works, the fallback is the timeout: replacing lock() with tryLock(10, SECONDS) and logging the failure converts a silent hang into a message naming the code that could not get in.

Related: concurrency issues and synchronization and the basics. More in the Java guides.

Frequently asked questions

When should I use ReentrantLock instead of synchronized?

When you need tryLock, a timeout, an interruptible acquisition, a fairness policy, more than one condition, or a release in a different scope. Otherwise synchronized is simpler and cannot leak.

Why must unlock() be in a finally block?

An exception in the critical section would otherwise leave the lock held forever. That is a hang with no stack trace, which is much harder to diagnose than a crash.

Should lock() go inside or outside the try?

Outside, immediately before it. Inside, a failed acquisition still runs the finally and unlock() throws IllegalMonitorStateException, hiding the real error.

Is a fair lock better?

Rarely. Fairness costs significant throughput because it prevents barging. Use it only when starvation is actually observed.

Is ReadWriteLock always faster for read-heavy code?

No. Acquiring the read lock is itself a shared write, so readers contend on the lock. It wins when reads vastly outnumber writes and critical sections are long; for a map lookup, ConcurrentHashMap beats it.

Can I upgrade a read lock to a write lock?

Not with ReentrantReadWriteLock, it deadlocks. Release the read lock, acquire the write lock, and re-check the state, which may have changed.

Why use while instead of if around await()?

Spurious wakeups exist, and a signalled condition can be falsified by another thread before this one runs. Re-checking in a loop is the only correct form.

AtomicLong or LongAdder?

AtomicLong when you need an exact value at any moment. LongAdder under heavy contention, where it wins by spreading the count: at the cost of sum() not being an atomic snapshot.

Is the function passed to updateAndGet run once?

No. It re-runs whenever the compare-and-swap fails, so it must be pure. A side effect inside it happens an unpredictable number of times.

Do atomics remove the need for locks?

Only for a single variable. Two values that must stay consistent with each other need a lock or a single atomic reference to an immutable object holding both.