Back to Blog
Java

Java Lock Interface: Flexible Concurrency Control

Understand the java lock interface, its methods, and how to use ReentrantLock for advanced concurrency control beyond synchronized.

Java ConcurrencyLock InterfaceReentrantLockSynchronizationMultithreading
Java Lock interface concept with a padlock and multiple threads waiting, representing concurrency control

The java lock interface is part of java.util.concurrent.locks and provides a more flexible locking mechanism than the built-in synchronized keyword. It allows timed waits, interruptible lock acquisition, and multiple condition queues per lock. This article explains the interface's core methods, demonstrates practical usage patterns, and highlights when to prefer it over synchronized.

Core Methods of the Lock Interface

The Lock interface defines five primary methods. The most commonly used are lock(), unlock(), and tryLock(). The other two, lockInterruptibly() and newCondition(), address specific concurrency needs.

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

lock() acquires the lock, blocking if it is not available. unlock() releases it. tryLock() attempts to acquire the lock without blocking, returning true if successful and false otherwise. The timed variant tryLock(long, TimeUnit) waits up to the specified duration. lockInterruptibly() acquires the lock unless the calling thread is interrupted. newCondition() creates a Condition bound to this lock, used for advanced waiting and signaling.

Using Lock with try-finally

Unlike synchronized, a Lock is not automatically released when an exception occurs. You must release it explicitly, typically in a finally block to guarantee cleanup.

Lock lock = new ReentrantLock(); try { lock.lock(); // critical section } finally { lock.unlock(); }

This pattern is mandatory. If the critical section throws an exception and unlock() is not called, the lock remains held, causing deadlocks or starvation for other threads. Always pair lock() with unlock() in a finally block, even when the code inside appears safe.

Non-Blocking Acquisition with tryLock

tryLock() is useful when you want to avoid indefinite blocking. It returns immediately, allowing the thread to perform alternative work if the lock is held.

if (lock.tryLock()) { try { // update shared state } finally { lock.unlock(); } } else { // fallback path, e.g., log and retry later }

The timed version tryLock(2, TimeUnit.SECONDS) waits up to two seconds before giving up. This is valuable in scenarios where waiting indefinitely is unacceptable, such as in interactive applications or when handling multiple resources.

Lock vs synchronized: Choosing the Right Tool

synchronized is simpler and sufficient for many cases. It provides automatic release and reentrant behavior. However, Lock offers capabilities that synchronized lacks:

  • Timed lock acquisition (tryLock with timeout)
  • Interruptible lock acquisition (lockInterruptibly)
  • Multiple condition queues (newCondition)
  • Fairness control via ReentrantLock(boolean fair)
  • Non-blocking attempt (tryLock)

Use synchronized when you need basic mutual exclusion and don't require these features. Use Lock when you need timed waits, interruptible operations, or multiple conditions. The extra flexibility comes with the responsibility of manual unlock, which is a common source of bugs if not handled correctly.

Conditions: Waiting and Signaling

A Condition is associated with a Lock and provides await(), signal(), and signalAll() methods, similar to Object.wait() and notify() but more precise. You can create multiple conditions per lock, allowing threads to wait for different state changes.

Lock lock = new ReentrantLock(); Condition notFull = lock.newCondition(); Condition notEmpty = lock.newCondition(); // Producer lock.lock(); try { while (buffer.isFull()) { notFull.await(); } buffer.add(item); notEmpty.signal(); } finally { lock.unlock(); } // Consumer lock.lock(); try { while (buffer.isEmpty()) { notEmpty.await(); } item = buffer.remove(); notFull.signal(); } finally { lock.unlock(); }

await() releases the lock and waits until signaled. When the thread is signaled, it reacquires the lock before returning. Always use await() inside a loop to guard against spurious wakeups. Conditions are the preferred way to implement producer-consumer patterns when you need finer control than wait() and notify().

Fairness and Performance Considerations

ReentrantLock has a fairness parameter. A fair lock grants access to the longest-waiting thread, reducing starvation but lowering throughput. An unfair lock (the default) can barge, which often performs better under contention.

Lock fairLock = new ReentrantLock(true); Lock unfairLock = new ReentrantLock(false); // default

Fair locks are useful when thread starvation is a concern, but they incur overhead because the lock must track waiting threads. In most applications, an unfair lock is sufficient. Measure your specific workload before choosing. Also, Lock operations are generally slightly more expensive than synchronized due to the additional flexibility, but the difference is often negligible unless the lock is acquired very frequently.

Common Pitfalls with Lock

Forgetting to call unlock() is the most frequent mistake. Even if you use try-finally, a lock acquired outside the try block can still leak if the acquisition itself throws. The pattern lock.lock(); try { ... } finally { lock.unlock(); } is safe because lock() rarely throws; if it does, the lock is not held. However, lockInterruptibly() can throw InterruptedException, so you must handle it carefully.

Another pitfall is using tryLock() without checking the return value. If you call tryLock() and ignore the result, you may proceed without holding the lock, causing race conditions. Always check the boolean result.

Reentrancy is preserved: a thread can acquire the same ReentrantLock multiple times, and each lock() must be matched by an unlock(). This is similar to synchronized but requires explicit counting. Ensure the number of unlocks matches the number of locks, especially in recursive methods.

When to Use lockInterruptibly

lockInterruptibly() allows a thread to respond to interruption while waiting for the lock. This is important in thread pools or cancellation scenarios where you want to abort a task that is blocked on a lock.

public void performTask() throws InterruptedException { lock.lockInterruptibly(); try { // long-running critical section } finally { lock.unlock(); } }

If another thread calls interrupt() on the waiting thread, lockInterruptibly() throws InterruptedException, allowing the task to clean up. This is not possible with synchronized, where an interrupt only affects the thread after it acquires the lock. Use this method when you need responsive cancellation in concurrent code.

java lock interface: Practical Usage and Code Examples | RYUSLOG DEV