Checkout holds a lock, then calls the payment client. On virtual threads that used to be a scalability bug: the virtual thread stayed pinned to its carrier for the whole I/O wait. The internet’s answer was “never use synchronized again.”

That advice was a Java 21–23 workaround, not a language funeral. synchronized is still the lock you want when you need an object’s monitor — wait / notify, documented JDK contracts, and short critical sections. Reach for ReentrantLock when you need timed or interruptible acquisition, several condition queues, fairness, or a blocking critical section on an older JDK.

This post is the lock-choice companion to virtual threads. It does not retell what a virtual thread is.

Pinning was the real tax

A virtual thread runs on a carrier. When it blocks on ordinary JDK I/O, the JVM can unmount it and reuse that carrier. Historically, that unmount was illegal inside a synchronized method or block: the JVM recorded the carrier as the monitor owner, so unmounting would have handed the lock to whatever mounted next.

The classic checkout smell looks like this — a monitor held across a blocking call:

synchronized Order charge(String orderId) {
    Order order = orders.get(orderId);
    PaymentResult paid = paymentClient.charge(order); // blocks
    order.markPaid(paid);
    return order;
}

On Java 21–23, that charge call pins the virtual thread to its carrier. Enough of those and the scheduler starves: every carrier is stuck inside someone else’s monitor. Libraries rewrote synchronized to ReentrantLock for that reason — java.util.concurrent locks never pinned.

Note: Holding any lock across I/O is still a contention problem. JEP 491 removes the pinning tax; it does not make a wide critical section a good design.

When it shipped

ReleaseStatusSpec
Java 21 LTSVirtual threads standard; synchronized still pinsJEP 444
Java 24Synchronize without pinning (delivered JVM change, not a preview)JEP 491
Java 25 LTSSame pinning model as 24, on the LTS most teams will runJEP 491

JEP 491 is final in Java 24+ — no --enable-preview. Prefer Java 25 LTS when virtual threads are central to the service. On Java 21–23, keep treating a synchronized region that might block as a pinning risk.

What JEP 491 actually changed

JEP 491 makes the virtual thread — not its carrier — the monitor owner. Blocking to enter synchronized, and blocking in Object.wait(), can unmount. Existing libraries that use intrinsic locks scale with virtual threads without a rewrite.

You do not have to revert code that already moved to ReentrantLock. Choose the lock that matches the problem. The JEP’s own guidance matches Java Concurrency in Practice §13.4: use synchronized where it is practical, and use java.util.concurrent.locks when you need the extra knobs.

The jdk.tracePinnedThreads system property goes away with this change (setting it on 24+ is a no-op). Keep JDK Flight Recorder’s jdk.VirtualThreadPinned event for the pins that remain — mostly native frames, covered below.

When synchronized still wins

1. The object is the lock

Intrinsic locks are identity monitors. Any object can be the lock, with no extra Lock field and no try/finally unlock ritual. That matches APIs that already document “synchronize on this instance.”

A per-order gate is one line of state:

final class Order {
    private Status status = Status.OPEN;

    synchronized void cancel() {
        if (status != Status.OPEN) {
            throw new IllegalStateException("cannot cancel " + status);
        }
        status = Status.CANCELLED;
    }

    synchronized Status status() {
        return status;
    }
}

Callers lock the order they already hold. A ReentrantLock here would be another object to allocate, expose, or leak.

2. wait / notify / notifyAll

The wait set lives on the same monitor. A bounded checkout queue is the textbook case — producers wait for space, workers wait for work:

final class CheckoutQueue {
    private final Deque<Order> orders = new ArrayDeque<>();
    private final int capacity;

    CheckoutQueue(int capacity) {
        this.capacity = capacity;
    }

    synchronized void submit(Order order) throws InterruptedException {
        while (orders.size() >= capacity) {
            wait();
        }
        orders.addLast(order);
        notifyAll();
    }

    synchronized Order take() throws InterruptedException {
        while (orders.isEmpty()) {
            wait();
        }
        Order order = orders.removeFirst();
        notifyAll();
        return order;
    }
}

notifyAll is the safe default with one wait set: submit and take wait for different conditions on the same monitor. If you need to wake only producers or only consumers, that is a Condition story — next section.

3. Short critical sections

For a few field writes, synchronized is harder to get wrong than lock(); try { … } finally { unlock(); }. Miss the finally and you never release; miss the synchronized brace and the compiler tells you.

Keep the region tiny. Inventory decrement belongs in the lock. The HTTP call that follows does not:

final class Warehouse {
    private final Map<String, Integer> stock = new HashMap<>();

    synchronized boolean reserve(String sku, int qty) {
        int available = stock.getOrDefault(sku, 0);
        if (available < qty) {
            return false;
        }
        stock.put(sku, available - qty);
        return true;
    }
}

On Java 24+ this also no longer pins if someone later slips a blocking call inside. Do not take that as permission to put I/O back in.

4. JDK and library monitor contracts

Some APIs document the monitor you must use. Collections.synchronizedList is the usual one: iteration is not atomic unless you lock the list itself.

List<Order> open = Collections.synchronizedList(new ArrayList<>());

synchronized (open) {          // the documented monitor
    for (Order order : open) {
        tally(order);
    }
}

The same pattern shows up around Vector, Hashtable, and any type whose Javadoc says “the caller must synchronize on this object.” Replacing the library lock with a private ReentrantLock does not satisfy that contract.

When ReentrantLock (and friends) win

Use java.util.concurrent.locks when synchronized cannot express the policy. Four cases show up constantly in checkout code.

Timed tryLock

A flash-sale reserve should fail fast under contention, not queue until the lock becomes free. Intrinsic locks have no timed acquire:

final class FlashReserve {
    private final ReentrantLock lock = new ReentrantLock();
    private final Map<String, Integer> stock = new HashMap<>();

    boolean reserve(String sku, int qty) {
        try {
            if (!lock.tryLock(50, TimeUnit.MILLISECONDS)) {
                return false;
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            return false;
        }
        try {
            int available = stock.getOrDefault(sku, 0);
            if (available < qty) {
                return false;
            }
            stock.put(sku, available - qty);
            return true;
        } finally {
            lock.unlock();
        }
    }
}

tryLock() without a timeout is the non-blocking variant. Neither exists on synchronized.

Several condition queues

ReentrantLock.newCondition() gives you more than one wait set. Wake only the side that can make progress — workers on notEmpty, producers on notFull — instead of notifyAll on a mixed crowd:

final class CheckoutQueue {
    private final Deque<Order> orders = new ArrayDeque<>();
    private final int capacity;
    private final ReentrantLock lock = new ReentrantLock();
    private final Condition notEmpty = lock.newCondition();
    private final Condition notFull = lock.newCondition();

    CheckoutQueue(int capacity) {
        this.capacity = capacity;
    }

    void submit(Order order) throws InterruptedException {
        lock.lock();
        try {
            while (orders.size() >= capacity) {
                notFull.await();
            }
            orders.addLast(order);
            notEmpty.signal();
        } finally {
            lock.unlock();
        }
    }

    Order take() throws InterruptedException {
        lock.lock();
        try {
            while (orders.isEmpty()) {
                notEmpty.await();
            }
            Order order = orders.removeFirst();
            notFull.signal();
            return order;
        } finally {
            lock.unlock();
        }
    }
}

That is the same queue as before, with cheaper wakeups when producers and consumers wait for different things.

Interruptible acquire, fairness, and read/write

Three more knobs that monitors do not have:

  • lock.lockInterruptibly() — a cancelled checkout thread can abandon the wait instead of sitting on the monitor until it is granted.
  • new ReentrantLock(true) — FIFO grant order under contention. Unfair is still the throughput default; fairness is a policy you opt into.
  • ReadWriteLock / StampedLock — many readers, few writers (catalog reads vs stock updates). No intrinsic equivalent.

Always unlock in finally. That ceremony is the cost of the extra API.

Virtual threads on Java 21–23

If the critical section might block — JDBC, HTTP, queue.take(), a condition wait you do not control — prefer ReentrantLock on Java 21–23. Those locks never pinned. Shrink the synchronized region, or stay off virtual threads for that code path, if you cannot change the lock.

On Java 24/25 that pinning reason is gone. Keep ReentrantLock for timed / interruptible / multi-condition / fair needs, not as a blanket replacement.

Remaining pins: native frames

JEP 491 eliminated nearly all Java-side pinning from synchronized. A virtual thread still cannot unmount with a native frame on the stack that is in the way.

That still happens when:

  • A JNI native method (or Foreign Function & Memory downcall) blocks in C, or calls back into Java that then blocks or waits on a monitor.
  • The thread is inside class loading or a class initializer that blocks (the JEP calls these out as rare leftover cases).

You will not rewrite those away with ReentrantLock. Isolate native blockers on platform threads if they hold carriers, and keep jdk.VirtualThreadPinned in the JFR profile when you adopt virtual threads next to JNI.

Cheat sheet

synchronized (obj) { … }     // identity monitor; wait/notify; auto release
obj.wait() / notify() / notifyAll()

ReentrantLock lock = new ReentrantLock();          // optional fair=true
lock.lock() / lock.unlock()                        // always unlock in finally
lock.tryLock(timeout, unit)
lock.lockInterruptibly()
lock.newCondition() → await / signal / signalAll

Java 21–23: synchronized + blocking I/O pins the carrier
Java 24+ (JEP 491): synchronized no longer pins; 25 LTS is the production target
Still pins: JNI / FFM native frames that block
Good synchronized: object monitors, wait/notify, short CS, documented JDK locks
Good ReentrantLock: timed, interruptible, fair, several conditions, 21–23 + block

Do:

  • Use synchronized for short critical sections, per-object identity, and wait / notify.
  • Honor library Javadoc that names the monitor (Collections.synchronizedList, and similar).
  • Prefer Java 25 LTS when virtual threads plus existing synchronized libraries are the runtime model.

Don’t:

  • Treat synchronized as obsolete after Loom — that was a pinning workaround, not a design law.
  • Hold a lock across HTTP or JDBC just because Java 24 unpins it.
  • Use synchronized + blocking I/O on Java 21–23 on a virtual-thread executor.
  • Forget unlock() in finally when you do choose ReentrantLock.

Wrap-up

Virtual threads made lock choice look like a migration project. After JEP 491, it is a design choice again: synchronized for monitors, ReentrantLock for policy the language keyword cannot express.

Keep checkout state behind a small intrinsic lock. Reach for timed tryLock, extra Condition queues, or interruptible acquire when the queueing rules need them. On Java 21–23, still dodge blocking inside synchronized. On Java 24/25, spend your caution on native frames and on lock scope, not on deleting every monitor.

When fan-out needs cancellation as one unit of work, step up to structured concurrency — the next chapter in the same Loom story.

Next optional step in the series Fan-out with cancellation: treat related tasks as one unit of work. Structured Concurrency: Treat Related Tasks as One Unit of Work