Create your own
Lesson illustration

Protecting Compound State Transitions with Locks

Hello. In the previous lesson, you reproduced races caused by read-modify-write and check-then-act logic. The important conclusion was that the whole business transition must be protected—not merely the final assignment or a single collection call.

This lesson applies that conclusion. You will protect a shared in-memory state transition with Java’s intrinsic monitor mechanism (synchronized) and with ReentrantLock, understand what object is actually being locked, and choose a narrow, correct critical section. These are useful tools for JVM-local coordination inside one service instance; later modules will address database transactions and distributed consistency, which require different mechanisms.


Mutual exclusion: make one transition indivisible

Take the inventory example from the previous lesson. The business rule is:

If one item is available, only one request may reserve it successfully.

The unsafe implementation lets two request threads separately observe availability and then both decrement:

final class UnsafeInventory {

    private int available;

    UnsafeInventory(int available) {
        this.available = available;
    }

    boolean reserveOne() {
        if (available <= 0) {
            return false;
        }

        available--;
        return true;
    }
}

The critical section is not only available--. It includes both:

  1. Checking whether reservation is allowed.
  2. Updating the state to record that reservation.

A mutex gives one thread exclusive access to that critical section. If another thread reaches the same protected region first, it waits until the current owner leaves it. The waiting thread then re-checks the current state, rather than acting on a stale observation.

Using a Mutex Object in Java | Baeldung

Read Baeldung’s explanation of mutexes and its sequence-generator example. It reinforces why a critical section must have one owner at a time and introduces the two basic synchronized forms.

In Sections 2 through 4, begin at the mutex definition. Then continue through the SequenceGenerator example and the synchronized method and block variants. Focus on the distinction between the shared state, the critical section, and the object used as the mutex.

The Java Memory Model adds an essential guarantee here:

An unlock of a particular monitor happens-before a later lock of that same monitor.

In practical terms, when Thread A exits a synchronized block, its changes made inside the block become visible to Thread B when B subsequently enters a block synchronized on the same object. Thus, correct synchronization provides both:

  • Atomicity through exclusive entry;
  • Visibility through the lock-release and later lock-acquisition relationship.

It also provides ordering constraints needed for the protected code. This is why synchronized fixes more than the lost-update symptom that volatile cannot fix.


synchronized: the monitor object matters

Every Java object has an associated intrinsic monitor, sometimes casually called its intrinsic lock. A synchronized block uses one chosen object as that monitor:

synchronized (monitor) {
    // one thread at a time for this monitor object
}

Two pieces of code mutually exclude each other only if they synchronize on the same monitor object. This is the rule to hold onto.

Java Synchronized - The synchronized keyword in Java and Java synchronized blocks and methods

Watch Jakob Jenkov’s “Java Synchronized” introduction for a visual explanation of synchronized methods, blocks, and monitor objects.

Watch methods and monitors. Focus on the equivalence between an instance synchronized method and a block that synchronizes on this, and on why two synchronized regions must use the same monitor to coordinate.

Synchronized instance method

The shortest safe version of the inventory code is:

final class SynchronizedInventory {

    private int available;

    SynchronizedInventory(int available) {
        if (available < 0) {
            throw new IllegalArgumentException("available must not be negative");
        }
        this.available = available;
    }

    synchronized boolean reserveOne() {
        if (available <= 0) {
            return false;
        }

        available--;
        return true;
    }

    synchronized int available() {
        return available;
    }
}

For an instance method, this:

synchronized boolean reserveOne() {
    // body
}

has the same locking behavior as this:

boolean reserveOne() {
    synchronized (this) {
        // body
    }
}

Both use the monitor belonging to the current SynchronizedInventory instance. One thread can be inside reserveOne() or available() at a time for the same inventory object.

It is important that the read method also synchronizes on the same monitor. Otherwise, a concurrent caller may not receive the visibility guarantee associated with the write. More fundamentally, if a caller needs a consistent snapshot across multiple mutable fields, it may need to acquire the same lock around the complete read.

Prefer a private lock object for most mutable classes

Synchronizing on this is valid, but it exposes your locking policy to every caller that holds the object reference:

synchronized (inventory) {
    // external code can acquire this lock too
}

That can create accidental lock contention, or make future refactoring difficult. In service code, use a private, final lock object when the lock is purely an implementation detail:

final class Inventory {

    private final Object lock = new Object();
    private int available;

    Inventory(int available) {
        if (available < 0) {
            throw new IllegalArgumentException("available must not be negative");
        }
        this.available = available;
    }

    boolean reserveOne() {
        synchronized (lock) {
            if (available <= 0) {
                return false;
            }

            available--;
            return true;
        }
    }

    int available() {
        synchronized (lock) {
            return available;
        }
    }
}

private final is deliberate:

  • private prevents unrelated code from acquiring the monitor.
  • final ensures the object used for locking never changes.
  • A dedicated Object communicates that the lock protects this class’s mutable state.

Avoid synchronizing on publicly reachable objects, string literals, boxed values, or mutable references that might be reassigned. Those choices can unintentionally coordinate with unrelated code—or fail to coordinate at all.

Keep the lock scope aligned with the invariant

Here, available is the state guarded by lock. The invariant and the lock boundary are easy to state:

While holding lock, reserveOne() checks that stock remains positive and decrements it before another thread can make the same decision.

The lock should cover the whole transition, but no more. Do not place slow, unpredictable work inside a JVM lock:

boolean reserveOneAndNotify() {
    synchronized (lock) {
        if (available <= 0) {
            return false;
        }

        available--;

        externalClient.sendReservationEvent(); // Avoid this inside the lock
        return true;
    }
}

A remote HTTP call, a database query, Kafka publication, file I/O, or lengthy calculation makes every competing thread wait. Worse, failures and callback behavior become harder to reason about.

For an in-memory example, take a snapshot of the minimal result while protected, then perform external work after releasing the lock:

boolean reserveOneAndNotify() {
    boolean reserved;

    synchronized (lock) {
        if (available <= 0) {
            return false;
        }

        available--;
        reserved = true;
    }

    if (reserved) {
        notificationClient.notifyReservation();
    }

    return true;
}

This improves lock duration, but introduces an important real-world concern: the in-memory state update and external notification are not one atomic transaction. If notification fails after the decrement, the state has changed without an event. Do not try to solve that distributed consistency problem with a larger synchronized block. Later, you will use database transactions and the transactional outbox pattern for that class of requirement.


A synchronized method does not combine separate calls automatically

A common misunderstanding is: “All my methods are synchronized, so callers see a consistent multi-step result.” The lock is released when each method returns. Another thread may enter between two calls.

Oracle’s SynchronizedRGB example demonstrates this precisely: separate synchronized getters can still return values from different moments unless the caller binds the reads under the same monitor.

A Synchronized Class Example (The Java™ Tutorials > Essential Java Classes > Concurrency)

Read Oracle’s SynchronizedRGB example to see why individually synchronized methods do not automatically make a sequence of method calls atomic.

In the “A Synchronized Class Example” page, review the class implementation first, then read the discussion beginning the inconsistent snapshot scenario. Notice that the fix locks on color around both getter calls, so no writer using that same monitor can intervene between them.

For example, assume a mutable order summary exposes two individually synchronized methods:

final class OrderSummary {

    private final Object lock = new Object();

    private int itemCount;
    private long totalMinorUnits;

    void replace(int itemCount, long totalMinorUnits) {
        synchronized (lock) {
            this.itemCount = itemCount;
            this.totalMinorUnits = totalMinorUnits;
        }
    }

    int itemCount() {
        synchronized (lock) {
            return itemCount;
        }
    }

    long totalMinorUnits() {
        synchronized (lock) {
            return totalMinorUnits;
        }
    }
}

This caller can receive an inconsistent combination:

int count = summary.itemCount();
long total = summary.totalMinorUnits();

A writer can execute replace() between those calls. If the caller requires a consistent pair, expose an atomic snapshot operation instead:

record OrderSnapshot(int itemCount, long totalMinorUnits) {
}

OrderSnapshot snapshot() {
    synchronized (lock) {
        return new OrderSnapshot(itemCount, totalMinorUnits);
    }
}

This is usually cleaner than making callers know about your monitor object. It protects the invariant at the class boundary and returns an immutable value.


ReentrantLock: explicit locking with additional control

ReentrantLock is an explicit lock from java.util.concurrent.locks. It can protect exactly the same kind of compound transition:

import java.util.concurrent.locks.ReentrantLock;

final class LockingInventory {

    private final ReentrantLock lock = new ReentrantLock();
    private int available;

    LockingInventory(int available) {
        if (available < 0) {
            throw new IllegalArgumentException("available must not be negative");
        }
        this.available = available;
    }

    boolean reserveOne() {
        lock.lock();

        try {
            if (available <= 0) {
                return false;
            }

            available--;
            return true;
        } finally {
            lock.unlock();
        }
    }

    int available() {
        lock.lock();

        try {
            return available;
        } finally {
            lock.unlock();
        }
    }
}

The try/finally structure is mandatory practice:

lock.lock();
try {
    // protected code
} finally {
    lock.unlock();
}

If code inside the critical section throws—for example, an invariant check fails—finally still releases the lock. Omitting it can leave the lock permanently held, causing later threads to wait indefinitely.

The following comparison captures the main trade-off:

A comparison of Java’s intrinsic `synchronized` monitor mechanism and `ReentrantLock`: `synchronized` releases automatically at block exit, whereas explicit locks require `unlock()` in a `finally` block and offer capabilities such as timed and interruptible acquisition.

Why is it called reentrant?

Both Java intrinsic locks and ReentrantLock are reentrant. A thread that already owns a lock may acquire it again without blocking itself.

final class ReentrantExample {

    synchronized void reserveAndAudit() {
        reserveOne();
        // Still safe: this thread already owns this object's monitor.
    }

    synchronized void reserveOne() {
        // Protected by the same monitor.
    }
}

The lock keeps track of how many times that thread has acquired it, and releases fully only after matching exits or unlocks. Reentrancy makes it safe for a synchronized public method to invoke another synchronized helper using the same monitor.

However, reentrancy does not solve deadlocks between different locks. Keep locking designs simple: ideally one clearly owned lock per coherent state aggregate.

When ReentrantLock is justified

For most small in-memory critical sections, start with synchronized. It is concise, exception-safe by construction, and sufficient.

Choose ReentrantLock when you specifically need an explicit-lock capability, such as:

RequirementReentrantLock capability
Do not wait forever for a locktryLock() or timed tryLock(timeout, unit)
Allow cancellation while waitinglockInterruptibly()
Coordinate several distinct wait conditionsMultiple Condition objects
Require a fairness policy in a defined scenarionew ReentrantLock(true)
Need lock-state inspection or advanced diagnosticsMethods such as isLocked() and hasQueuedThreads()

For example, a short timeout can allow a service to fail fast under local contention:

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

final class TimedInventory {

    private final ReentrantLock lock = new ReentrantLock();
    private int available;

    boolean tryReserveOne(Duration timeout) throws InterruptedException {
        boolean acquired = lock.tryLock(timeout.toMillis(), TimeUnit.MILLISECONDS);

        if (!acquired) {
            return false;
        }

        try {
            if (available <= 0) {
                return false;
            }

            available--;
            return true;
        } finally {
            lock.unlock();
        }
    }
}

Do not add tryLock() merely because it exists. You must define what “could not acquire the lock” means to the caller. Is it a retryable outcome? A 503 Service Unavailable response? A rejected background job? Without a meaningful policy, the simpler blocking synchronized form is clearer.

The Baeldung resource’s ReentrantLock section shows the canonical locking pattern:

Using a Mutex Object in Java | Baeldung

Return to Baeldung for its compact side-by-side treatment of a synchronized block and ReentrantLock.

In Sections 4 and 5, start at the custom mutex example, then read the following ReentrantLock example. Compare automatic monitor release for synchronized with the explicit try/finally release obligation for ReentrantLock.


Verify the fix using the failing invariant

The safest test is the deterministic barrier test from the previous lesson. Its expected assertion does not change when you replace UnsafeInventory with Inventory:

assertEquals(1, successfulReservations);

With the safe synchronized implementation:

  1. Request A enters the block and evaluates available.
  2. Request B tries to enter the same block and waits.
  3. Request A decrements stock and exits, releasing the monitor.
  4. Request B enters, now observes zero stock, and returns false.

The test verifies the business invariant, not an implementation detail. You can change from synchronized to ReentrantLock later and retain the same test.

A useful production-oriented rule follows:

Lock around an in-memory invariant, but do not mistake the lock for a cross-request, cross-instance, or database-level consistency mechanism.

In a Spring Boot application, an ordinary @Service is normally a singleton per application instance. A private lock correctly coordinates request threads inside that one JVM. But if Kubernetes runs five replicas, each replica has its own singleton and its own lock. Likewise, two independent service instances cannot protect a shared database row using their separate JVM monitors.

For database-backed inventory, the real correctness boundary will eventually be enforced with a transaction and an appropriate concurrency strategy such as optimistic locking, pessimistic locking, or a conditional SQL update. The Java lock remains useful for genuinely local mutable state: bounded in-memory caches, batch coordination, a local sequence generator, or an object whose state is entirely in one JVM.


Practical decision guide

When you encounter shared mutable state, use this sequence:

  1. State the invariant.
    For example: “A reservation succeeds only if it consumes one remaining unit.”

  2. Find the scope of sharing.
    Is the state local to one method, one bean instance, the JVM, the database, or multiple services?

  3. Make the complete local transition atomic.
    With a private monitor and synchronized, or with one ReentrantLock.

  4. Use exactly the same lock for all accesses that must participate in the invariant.
    Different lock objects provide no mutual exclusion.

  5. Keep the critical section short.
    Do not hold an in-process lock while calling remote systems, publishing events, or waiting on slow I/O.

  6. Use ReentrantLock only when an explicit capability is necessary.
    If not, prefer synchronized for its smaller surface area and automatic release.


Key takeaways

  • A compound business action such as “check stock and reserve it” must be protected as one critical section.
  • synchronized provides mutual exclusion and the required visibility guarantee when every participant uses the same monitor object.
  • A private, final lock object usually avoids exposing a class’s synchronization policy through this.
  • Synchronizing individual methods does not automatically make a sequence of separate calls atomic; expose an atomic snapshot or operation when callers need a consistent view.
  • ReentrantLock protects state similarly but must always be released in finally; use it for concrete needs such as timed, interruptible, or condition-based locking.
  • JVM locks coordinate threads only within one application process. They are not a substitute for database transactions or distributed consistency controls.

Next, you will move from manually protected state to Java’s concurrent collections, learning to select collections and atomic operations that match common read-write workloads.

Can't find a good explanation? Sign up and we'll make it for you

Sign up