W3docs

Java Lock Interface

The java.util.concurrent.locks.Lock interface — what it does that `synchronized` can't, and the rules for using it safely.

Java Lock Interface

synchronized is the small, sharp tool. It's fast, it's automatic, and it covers most mutual-exclusion needs. But once you outgrow it — once you need a timeout, a way to abort, or more than one condition variable — Java has a second, richer locking API: the java.util.concurrent.locks.Lock interface and its implementations. This chapter introduces the interface; the next two chapters cover the two implementations (ReentrantLock, ReentrantReadWriteLock) you'll actually use.

What the interface gives you

public interface Lock {
  void lock();
  void lockInterruptibly() throws InterruptedException;
  boolean tryLock();
  boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
  void unlock();
  Condition newCondition();
}

Six methods. Five of them are about acquiring or releasing the lock; one returns a Condition (the Lock's answer to wait/notify).

The four ways to acquire are what synchronized doesn't give you:

  • lock() — block until acquired. Closest to synchronized.
  • lockInterruptibly() — block until acquired, but bail with InterruptedException if interrupted. Lets you cancel a thread that's waiting for a lock.
  • tryLock() — try once, return true/false immediately. Doesn't block.
  • tryLock(time, unit) — try up to a timeout, then give up. The deadlock-prevention tool from the previous-but-one chapter.

synchronized only has one acquire mode — block forever until you get it. That's appropriate for most code; it's not appropriate when you need a deadline or a cancellation point.

The mandatory try/finally pattern

synchronized releases the monitor automatically when the block exits — for normal completion or for an exception. Lock does not. If you forget to call unlock, the lock is held forever and everything after is stuck.

The right pattern, every time:

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

The unlock must be in a finally so it runs even if the body throws. There's no try-with-resources for Lock directly (it's not AutoCloseable), but you'll see wrapper patterns that fake it. The standard pattern above is what almost all production code uses.

tryLock and timeout

The two tryLock overloads are how Lock lets you handle "what if we can't get it?":

if (lock.tryLock()) {
  try {
    doWork();
  } finally {
    lock.unlock();
  }
} else {
  // didn't get the lock — do something else, maybe retry later
}
if (lock.tryLock(500, TimeUnit.MILLISECONDS)) {       // wait up to 500ms
  try {
    doWork();
  } finally {
    lock.unlock();
  }
} else {
  throw new TimeoutException("couldn't acquire " + name);
}

That second form is what makes deadlock recovery possible. With synchronized, a thread waiting for a monitor is stuck until the holder releases — there's no way out except for the JVM to die. With tryLock(timeout), you give up after a deadline and either retry, fail the operation, or take an alternative path.

lockInterruptibly — cancellable lock acquisition

synchronized does not respond to Thread.interrupt() while waiting. A thread BLOCKED on a monitor stays blocked even if you interrupt it — the JVM just sets the flag and forgets it.

lock.lockInterruptibly() does respond. If another thread calls interrupt() on you while you're waiting for the lock, the call throws InterruptedException immediately:

try {
  lock.lockInterruptibly();
} catch (InterruptedException e) {
  Thread.currentThread().interrupt();
  return;                                              // gave up on the work
}
try {
  doWork();
} finally {
  lock.unlock();
}

This is essential in server code: a request comes in, a thread tries to acquire a lock, the request gets cancelled (client disconnect, timeout from a load balancer), the supervisor calls interrupt() on the worker. With synchronized the worker keeps waiting; with lockInterruptibly, it gives up.

Condition — the Lock-aware wait/notify

A single Lock can have several Condition objects:

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

You hold the lock, you await() on a condition (which releases the lock and parks you), and another thread signal()s the condition (which moves you to the BLOCKED state waiting for the lock). The mapping to wait/notify:

Lock + ConditionIntrinsic monitor
lock.lock()enter synchronized (obj)
condition.await()obj.wait()
condition.signal()obj.notify()
condition.signalAll()obj.notifyAll()
lock.unlock()exit synchronized

The win over wait/notify: multiple conditions per lock. A bounded buffer can have one condition for "not full" and one for "not empty" — producers signal(notEmpty) after putting an item; consumers signal(notFull) after taking. Only the right side is woken. The single-monitor notifyAll approach has to wake everyone and hope.

We'll see the bounded-buffer rewrite in the ReentrantLock chapter.

When to use Lock, when to stay with synchronized

A pragmatic decision rule:

  • Default to synchronized for simple mutual exclusion. It's automatic, can't leak, and the JVM optimises it heavily.
  • Reach for Lock when you need any of: a timeout on acquisition, the ability to cancel a waiter via interrupt, multiple Conditions on the same lock, or read-write distinction (ReentrantReadWriteLock).
  • Reach for Lock when contention is heavy and you need a fair-ordering option (new ReentrantLock(true) is the fair version; intrinsic monitors are unfair). Fair ordering trades throughput for predictability.

You should not "upgrade" synchronized to Lock for no reason. The two are equivalent for the basic case; the rest of the chapter is about when the extra capabilities pay off.

What you give up

Lock has costs synchronized doesn't:

  • No automatic release. Forget finally and the lock leaks. JVM cannot save you.
  • No structured nesting verification. With synchronized the compiler enforces lock/unlock pairing; with Lock, you can unlock() from a different method or path and the compiler doesn't notice.
  • No native runtime optimisations. The JVM has special optimisations for intrinsic monitors (biased locking, lock coarsening, lock elision in some cases) that don't apply to Lock. For very low-contention code, synchronized may be a hair faster.
  • More surface for misuse. tryLock and lockInterruptibly both have to be paired with a check; missing the check yields a silent "lock not acquired" bug.

Use Lock for the capabilities, not for the syntax.

A worked example: Lock doing what synchronized can't

The program below uses ReentrantLock (the standard Lock implementation) to demonstrate the three things synchronized doesn't offer: tryLock with timeout, lockInterruptibly, and a custom Condition.

java— editable, runs on the server

What to take from the run:

  • Section 1's try/finally pattern is what every Lock call site needs. There's no syntactic protection — if you delete the finally, the code compiles, and the lock leaks the first time the body throws. Memorise the shape: lock(), try { ... } finally { unlock(); }.
  • Section 2's tryLock(100, MS) returned false after roughly 100 ms because the holder thread was still in its 500 ms sleep. That's the deadline contract — the call returns false after the timeout no matter what. With synchronized this thread would have blocked until the holder released, with no escape hatch.
  • Section 3's waiter was interrupted while waiting for the lock, and lockInterruptibly threw InterruptedException. Compare to lock.lock() or synchronized — neither responds to interrupt() while waiting. This is the difference between a server that can cancel timed-out requests and one that just accumulates stuck threads.
  • Section 4 used two Conditions on one lock — notFull for producers, notEmpty for consumers. When the producer added an item, it signaled notEmpty specifically; only a consumer was woken. With wait/notifyAll on an intrinsic monitor, every waiting thread is woken and re-checks; the Condition pair sends the wake-up to the right side of the queue and saves the wakeup/recheck round-trip.
  • The signal() (singular) rather than signalAll() is safe here because all awaiters on each condition are interchangeable — any producer can fill the slot we just opened. If the waiters were not interchangeable (e.g., they were waiting for different specific keys), signalAll would still be the safer default.

What's next

The next chapter, Java ReentrantLock, goes into detail on the standard Lock implementation — its reentrancy, its fairness policy, and the getHoldCount/isHeldByCurrentThread diagnostic API.

Practice

Practice

You're writing code that needs to acquire a lock with a deadline — give up after 200 ms if the lock isn't available, and do an alternative action instead. Which approach is the right one?