Back to Blog
Java

Java synchronized vs Lock: Key Differences

java synchronized vs lock: Compare Java synchronized and Lock APIs: syntax, features, fairness, interruption, and performance tradeoffs to choose the right concurrency...

concurrencymultithreadinglockssynchronizationjava-concurrencythread-safety
Illustration comparing Java synchronized keyword and Lock API with a scale balancing two concurrency mechanisms

When you need to protect shared state in a multithreaded Java program, synchronized and the Lock interface are the two primary mechanisms. The java synchronized vs lock decision is not about which is universally better; it's about which fits the specific concurrency requirements of your code. synchronized is a built-in language keyword that provides implicit locking, while Lock is an explicit API offering more control. This article compares their syntax, capabilities, and runtime behavior so you can make an informed choice.

The Core Difference: Language Keyword vs API

synchronized is a keyword in the Java language. When you mark a method or a block with synchronized, the JVM automatically acquires the monitor lock before entering the block and releases it after exiting, even if an exception occurs. This is the simplest form of mutual exclusion and has been part of Java since version 1.0.

The Lock interface, introduced in Java 5, is a higher-level abstraction. It provides methods like lock(), unlock(), tryLock(), and lockInterruptibly(). Unlike synchronized, Lock does not automatically release the lock; you must explicitly call unlock() in a finally block to avoid leaving the lock held. This extra responsibility gives you more flexibility but also increases the risk of bugs if not handled carefully.

Syntax and Basic Usage

Here is the classic synchronized block:

public class Counter { private int count; public void increment() { synchronized (this) { count++; } } }

The lock is acquired on the this object, and the JVM ensures it is released when the block exits, whether normally or via an exception. You can also use synchronized on a method signature:

public synchronized void increment() { count++; }

With Lock, you typically use a ReentrantLock instance:

import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; public class Counter { private final Lock lock = new ReentrantLock(); private int count; public void increment() { lock.lock(); try { count++; } finally { lock.unlock(); } } }

The try-finally structure is mandatory: if count++ throws an exception, unlock() still runs. Forgetting to unlock in a finally block can cause deadlocks or thread starvation in production.

What Lock Offers Beyond synchronized

The Lock interface adds capabilities that synchronized cannot provide directly:

  • tryLock(): Attempts to acquire the lock without blocking indefinitely. It returns true if the lock was acquired, false otherwise. You can also pass a timeout and time unit to wait a limited amount of time.
  • lockInterruptibly(): Acquires the lock unless the current thread is interrupted. This allows a thread waiting for a lock to respond to interruption, which is useful for implementing responsive cancellation.
  • Fairness: A ReentrantLock can be constructed with a fairness parameter. A fair lock grants access to the longest-waiting thread, reducing starvation. synchronized does not guarantee fairness.
  • Multiple Condition Variables: Lock can create multiple Condition objects, allowing threads to wait for different conditions on the same lock. synchronized supports only one implicit condition per monitor via wait() and notify().

These features make Lock more expressive, but they also add complexity. For many simple cases, synchronized is sufficient and less error-prone.

Fairness and Thread Scheduling

synchronized uses an unfair scheduling policy. When multiple threads contend for the same monitor, the JVM does not guarantee that the longest-waiting thread gets the lock. This can lead to starvation under heavy contention, though in practice it rarely causes problems for short critical sections.

A ReentrantLock can be created as fair:

Lock fairLock = new ReentrantLock(true);

A fair lock reduces the chance of starvation by handing the lock to the thread that has been waiting the longest. However, fairness comes at a performance cost because the lock must track ordering. If your application does not require strict ordering, an unfair lock is usually faster. Use a fair lock only when you have a specific requirement to avoid thread starvation, such as in a resource pool where each thread must eventually get access.

Handling Interruptions and Timeouts

One of the most practical advantages of Lock is the ability to handle thread interruption and timeouts. With synchronized, if a thread is blocked waiting to enter a synchronized block, it cannot be interrupted. The thread will continue waiting until the lock becomes available, which can make shutdown procedures difficult.

With lockInterruptibly(), you can respond to an interrupt while waiting:

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

If the thread is interrupted while waiting, lockInterruptibly() throws InterruptedException, allowing the thread to clean up and exit. Similarly, tryLock(long time, TimeUnit unit) lets you wait for a limited period:

if (lock.tryLock(2, TimeUnit.SECONDS)) { try { // critical section } finally { lock.unlock(); } } else { // handle failure to acquire lock }

This pattern is valuable in distributed systems or UI applications where you cannot afford to block indefinitely.

Performance and Contention Behavior

Performance is often cited as a reason to choose one mechanism over the other, but the reality is nuanced. For uncontended locks, synchronized has been optimized heavily in modern JVMs (e.g., biased locking, lightweight locking) and can be faster than ReentrantLock because it uses native monitor primitives. Under low contention, the difference is negligible.

Under high contention, ReentrantLock can perform better in some scenarios because it uses a more sophisticated queuing mechanism and allows more control over fairness. However, this is not a universal rule. The actual behavior depends on the JVM version, the number of threads, the duration of the critical section, and the hardware. Without benchmarking your specific workload, you should not assume that one is inherently faster.

What matters more is the cost of the operation inside the critical section. If the critical section is short and simple, the lock acquisition overhead dominates. If it is long, the lock type matters less. In practice, synchronized is often sufficient, and the extra features of Lock are only needed when you require interruption, timeouts, or multiple conditions.

Choosing Between synchronized and Lock

Use synchronized when:

  • You need simple mutual exclusion and do not require advanced features.
  • You want the JVM to handle lock release automatically, reducing the chance of errors.
  • Your critical sections are short and contention is low.
  • You are working with code that already uses wait() and notify() and does not need multiple conditions.

Use Lock when:

  • You need tryLock() to avoid blocking indefinitely.
  • You must respond to thread interruption while waiting for the lock.
  • You require a fair lock to prevent starvation.
  • You need multiple condition queues for complex coordination.

If you are unsure, start with synchronized. It is simpler, less error-prone, and covers the majority of use cases. Only introduce Lock when a specific requirement cannot be met by synchronized.

Common Pitfalls When Using Lock

The most common mistake with Lock is forgetting to call unlock() in a finally block. This can cause deadlocks that are difficult to diagnose. Always structure your code as:

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

Another pitfall is using lock() without checking the return value of tryLock(). If you call tryLock() and it returns false, you must not proceed into the critical section. Always check the boolean result.

Also be aware that Lock is not automatically reentrant across different lock instances. A ReentrantLock is reentrant, meaning the same thread can acquire it multiple times, but if you use a custom Lock implementation, it may not be. Always verify the implementation's behavior.

Finally, avoid holding a lock while performing I/O or other long-running operations. This reduces contention and improves responsiveness. Even with Lock, the same principle applies as with synchronized: keep critical sections as short as possible.

java synchronized vs lock: Practical Usage and Code Examples | RYUSLOG DEV