Java already had synchronized, so the Lock interface was not added to replace locking in general.

It was added because some coordination problems need more control than intrinsic monitors expose cleanly.

If your code needs only one critical section and straightforward mutual exclusion, synchronized is still a strong default. If your code needs timing, cancellation, multiple wait queues, or richer acquisition behavior, Lock becomes useful.


Problem Statement

Consider a backend service that refreshes tenant routing tables.

Only one refresh should run at a time, but the caller also wants these rules:

  • request threads should not block forever waiting for a refresh lock
  • shutdown should be able to cancel blocked maintenance work
  • different waiters may need different wake-up conditions

That combination is awkward with plain synchronized.


Why synchronized Was Not Enough

Intrinsic locking gives you:

  • mutual exclusion
  • visibility on monitor entry and exit
  • monitor-based wait/notify coordination

What it does not give directly:

  • non-blocking acquisition
  • timed acquisition
  • interruptible acquisition while waiting for the lock
  • multiple explicit condition queues per lock

Those gaps are exactly why java.util.concurrent.locks exists.


Basic Example

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

class InventoryService {
    private final Lock lock = new ReentrantLock();
    private int available = 10;

    boolean reserveOne() {
        lock.lock();
        try {
            if (available > 0) {
                available--;
                return true;
            }
            return false;
        } finally {
            lock.unlock();
        }
    }
}

This looks similar to synchronized, which is fine. The value is not the syntax. The value is that the same lock object can later support tryLock, lockInterruptibly, and Condition.


Runnable Example

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

public class LockInterfaceDemo {

    public static void main(String[] args) throws Exception {
        TenantRefreshCoordinator coordinator = new TenantRefreshCoordinator();

        Thread refreshThread = new Thread(() -> {
            coordinator.refreshRoutingTable();
        }, "refresh-worker");

        refreshThread.start();
        TimeUnit.MILLISECONDS.sleep(50);

        boolean fastPath = coordinator.tryReadConfigForRequest();
        System.out.println("Request got immediate access = " + fastPath);

        refreshThread.join();
    }

    static final class TenantRefreshCoordinator {
        private final Lock lock = new ReentrantLock();
        private String routingVersion = "v1";

        void refreshRoutingTable() {
            lock.lock();
            try {
                sleep(300);
                routingVersion = "v2";
                System.out.println("Refreshed routing to " + routingVersion);
            } finally {
                lock.unlock();
            }
        }

        boolean tryReadConfigForRequest() {
            if (!lock.tryLock()) {
                return false;
            }
            try {
                System.out.println("Serving request using " + routingVersion);
                return true;
            } finally {
                lock.unlock();
            }
        }
    }

    static void sleep(long millis) {
        try {
            TimeUnit.MILLISECONDS.sleep(millis);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new RuntimeException(e);
        }
    }
}

The example is simple, but it shows the point. One code path takes the lock normally. Another chooses a non-blocking acquisition policy.

That second behavior is the real reason Lock exists.


Production-Style Scenarios

Use Lock when the problem needs one or more of these:

  • fail-fast lock acquisition on hot paths
  • bounded waiting for administrative tasks
  • shutdown-aware blocking that reacts to interruption
  • more than one logical wait queue for the same protected state

Common examples:

  • bounded resource pools
  • refresh or leadership coordination
  • schedulers with draining and refill conditions
  • shared caches with one refresh path and many waiters

Trade-Offs

Lock is more flexible than synchronized, but it is easier to misuse.

Typical mistakes:

  • forgetting unlock() in a finally block
  • mixing several lock types with no clear discipline
  • using explicit locks where plain synchronized would be simpler and safer

The right comparison is not “which one looks modern.” It is “which one matches the coordination problem with the least risk.”


Decision Guide

Prefer synchronized when:

  • one monitor boundary is enough
  • you do not need timed or interruptible acquisition
  • the code should stay as simple as possible

Prefer Lock when:

  • waiting policy matters
  • interruption during acquisition matters
  • multiple conditions matter
  • you want an explicit lock object with explicit lifecycle around acquisition

Key Takeaways

  • Lock exists because some concurrency problems need richer acquisition and coordination behavior than intrinsic monitors provide.
  • The most important added capabilities are tryLock, timed acquisition, interruption support, and Condition.
  • synchronized is still a good default for simple mutual exclusion.
  • Choose explicit locks when the problem actually needs the extra control.

Next post: ReentrantLock in Java Deep Dive

Comments