Explicit Locks: synchronized vs ReentrantLock

Part 5 of 8 in Java Concurrency: Deep Dive

synchronized and ReentrantLock both guard a critical section so only one thread runs it at a time, but ReentrantLock has methods synchronized doesn’t. This post runs two small Java programs, each compiled and run for real, to show both mechanisms fixing the same counter race, and ReentrantLock.tryLock() bailing out instead of blocking.

Same race, fixed two ways

LockedCounterDemo runs 10 threads, each incrementing a shared counter 10,000 times (100,000 increments total, expected), three times: once with no protection at all, once with the increment inside a synchronized block, and once with the increment guarded by a ReentrantLock.

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

public class LockedCounterDemo {
    static final int THREAD_COUNT = 10;
    static final int INCREMENTS_PER_THREAD = 10_000;
    static final int EXPECTED = THREAD_COUNT * INCREMENTS_PER_THREAD;

    static int unsafeCounter = 0;
    static int synchronizedCounter = 0;
    static int lockedCounter = 0;
    static final Object monitor = new Object();
    static final Lock lock = new ReentrantLock();

    public static void main(String[] args) throws InterruptedException {
        runThreads(() -> unsafeCounter++);
        System.out.println("unsynchronized:  expected " + EXPECTED + ", got " + unsafeCounter);

        runThreads(() -> {
            synchronized (monitor) {
                synchronizedCounter++;
            }
        });
        System.out.println("synchronized:    expected " + EXPECTED + ", got " + synchronizedCounter);

        runThreads(() -> {
            lock.lock();
            try {
                lockedCounter++;
            } finally {
                lock.unlock();
            }
        });
        System.out.println("ReentrantLock:   expected " + EXPECTED + ", got " + lockedCounter);
    }

    static void runThreads(Runnable incrementOnce) throws InterruptedException {
        Thread[] threads = new Thread[THREAD_COUNT];
        for (int t = 0; t < THREAD_COUNT; t++) {
            threads[t] = new Thread(() -> {
                for (int i = 0; i < INCREMENTS_PER_THREAD; i++) {
                    incrementOnce.run();
                }
            });
        }
        for (Thread th : threads) th.start();
        for (Thread th : threads) th.join();
    }
}

Real javac+java output, JDK 25 (Zulu):

unsynchronized:  expected 100000, got 25220
synchronized:    expected 100000, got 100000
ReentrantLock:   expected 100000, got 100000

The unsynchronized run landed on 25220 instead of 100000 — 74780 increments were lost to the race between threads. Both the synchronized run and the ReentrantLock run landed on exactly 100000.

tryLock(): bail out instead of block

TryLockDemo starts a thread that acquires a ReentrantLock and holds it for 1000ms. While it’s held, a second thread calls lock.tryLock() with no arguments — a call that returns immediately instead of waiting. A third thread calls lock.tryLock(2000, TimeUnit.MILLISECONDS) — willing to wait, but only up to 2000ms.

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

public class TryLockDemo {
    public static void main(String[] args) throws InterruptedException {
        ReentrantLock lock = new ReentrantLock();
        long start = System.nanoTime();

        Thread holder = new Thread(() -> {
            lock.lock();
            try {
                long at = (System.nanoTime() - start) / 1_000_000;
                System.out.println("holder: acquired lock at " + at + "ms, holding for 1000ms");
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            } finally {
                lock.unlock();
                long at = (System.nanoTime() - start) / 1_000_000;
                System.out.println("holder: released lock at " + at + "ms");
            }
        });
        holder.start();
        Thread.sleep(100); // make sure holder has the lock first

        Thread immediateTry = new Thread(() -> {
            long at = (System.nanoTime() - start) / 1_000_000;
            boolean acquired = lock.tryLock();
            long after = (System.nanoTime() - start) / 1_000_000;
            System.out.println("immediateTry: tryLock() at " + at + "ms returned " + acquired + " (checked at " + after + "ms, did not block)");
            if (acquired) lock.unlock();
        });
        immediateTry.start();
        immediateTry.join();

        Thread timedTry = new Thread(() -> {
            long at = (System.nanoTime() - start) / 1_000_000;
            try {
                boolean acquired = lock.tryLock(2000, TimeUnit.MILLISECONDS);
                long after = (System.nanoTime() - start) / 1_000_000;
                System.out.println("timedTry: tryLock(2000ms) requested at " + at + "ms, returned " + acquired + " at " + after + "ms");
                if (acquired) lock.unlock();
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        });
        timedTry.start();
        timedTry.join();

        holder.join();
    }
}

Real javac+java output, JDK 25 (Zulu):

holder: acquired lock at 5ms, holding for 1000ms
immediateTry: tryLock() at 108ms returned false (checked at 108ms, did not block)
timedTry: tryLock(2000ms) requested at 113ms, returned true at 1009ms
holder: released lock at 1009ms

immediateTry’s tryLock() was checked at 108ms and returned at 108ms — no wait, it just returned false because the holder still had the lock. timedTry’s tryLock(2000, TimeUnit.MILLISECONDS) was requested at 113ms but didn’t return until 1009ms, right after the holder released at 1009ms, returning true — it waited for the lock to actually become available, well inside its 2000ms budget.

Takeaway

The first run showed synchronized and ReentrantLock both closing the same gap: unprotected, 10 threads landed on 25220 of 100000 expected increments; both locking mechanisms landed on exactly 100000. The second run showed something synchronized has no equivalent for in this code: tryLock() returned false immediately instead of blocking, and tryLock(2000ms) waited only up to its given budget before returning true once the lock actually freed up.