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 tosynchronized.lockInterruptibly()— block until acquired, but bail withInterruptedExceptionif interrupted. Lets you cancel a thread that's waiting for a lock.tryLock()— try once, returntrue/falseimmediately. 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 + Condition | Intrinsic 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
synchronizedfor simple mutual exclusion. It's automatic, can't leak, and the JVM optimises it heavily. - Reach for
Lockwhen you need any of: a timeout on acquisition, the ability to cancel a waiter viainterrupt, multipleConditions on the same lock, or read-write distinction (ReentrantReadWriteLock). - Reach for
Lockwhen 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
finallyand the lock leaks. JVM cannot save you. - No structured nesting verification. With
synchronizedthe compiler enforces lock/unlock pairing; withLock, you canunlock()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,synchronizedmay be a hair faster. - More surface for misuse.
tryLockandlockInterruptiblyboth 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.
What to take from the run:
- Section 1's
try/finallypattern is what everyLockcall site needs. There's no syntactic protection — if you delete thefinally, 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)returnedfalseafter roughly 100 ms because the holder thread was still in its 500 ms sleep. That's the deadline contract — the call returnsfalseafter the timeout no matter what. Withsynchronizedthis thread would have blocked until the holder released, with no escape hatch. - Section 3's waiter was interrupted while waiting for the lock, and
lockInterruptiblythrewInterruptedException. Compare tolock.lock()orsynchronized— neither responds tointerrupt()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 —notFullfor producers,notEmptyfor consumers. When the producer added an item, itsignalednotEmptyspecifically; only a consumer was woken. Withwait/notifyAllon an intrinsic monitor, every waiting thread is woken and re-checks; theConditionpair sends the wake-up to the right side of the queue and saves the wakeup/recheck round-trip. - The
signal()(singular) rather thansignalAll()is safe here because allawaiters 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),signalAllwould 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
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?