Synchronized
In the realm of concurrent programming, managing multiple threads that access shared resources simultaneously is a fundamental challenge. Without proper coordination, developers often encounter devastating issues such as race conditions and data inconsistency. Java, a language designed with robust multi-threading support, provides a built-in mechanism to handle these complexities: the synchronized keyword.
Often referred to as an Intrinsic Lock or a Monitor Lock, synchronized is a cornerstone of Java's concurrency model. Its elegance lies in its simplicity—every object in Java can serve as a lock, allowing developers to protect critical sections of code with ease.
To use synchronized effectively, one must understand its two defining properties:
- Mutual Exclusion: This ensures that only one thread can hold a particular lock at any given time. If a thread attempts to enter a synchronized section while another thread already holds the lock, the requesting thread will be blocked until the lock is released.
- Reentrancy: Java’s implementation is reentrant, meaning that if a thread already holds a lock, it can acquire the same lock again without getting stuck. This prevents a thread from deadlocking itself when calling nested synchronized methods.
Practical Usage Patterns
Depending on the scope of the protection required, synchronized can be applied in three primary ways.
1. Synchronized Instance Methods
When you apply the keyword to an instance method, the lock is tied to the specific instance of the class (this). This means that two different threads can execute the same method simultaneously if they are operating on two different objects, but they will be serialized if they attempt to access the same object.
public class Counter {
private int count = 0;
// Only one thread can access this method on a specific instance at a time
public synchronized void increment() {
count++;
}
public synchronized int getCount() {
return count;
}
}
2. Synchronized Static Methods
When a method is declared as static synchronized, the lock is not tied to an instance, but to the Class object itself (e.g., Counter.class). This provides a global lock for all instances of that class, ensuring that even if multiple threads are using different objects, they cannot execute the static synchronized method concurrently.
public class StaticCounter {
private static int count = 0;
// Locks the StaticCounter.class object
public static synchronized void increment() {
count++;
}
}
3. Synchronized Code Blocks
For high-performance applications, locking an entire method is often overkill and can lead to unnecessary contention. Synchronized blocks allow for finer control by defining a specific "critical section." By specifying a dedicated lock object, you can reduce the lock granularity, ensuring that only the most sensitive lines of code are protected.
public class RefinedCounter {
private int count = 0;
private final Object lock = new Object();
public void increment() {
// Only the actual increment operation is synchronized
synchronized (lock) {
count++;
}
}
}
The Mechanics Under the Hood
To truly master synchronized, one must look beneath the surface at how the Java Virtual Machine (JVM) manages these locks. Historically, synchronized was considered a "heavyweight" operation because it relied heavily on operating system-level mutexes, requiring expensive transitions between user mode and kernel mode.
However, since JDK 6, the JVM has implemented sophisticated optimizations to minimize this overhead through a process known as Lock Upgrading.
The Object Header and the Mark Word
In the JVM, every object has a memory layout consisting of the Object Header, Instance Data, and Padding. The Object Header is critical for synchronization because it contains the Mark Word. The Mark Word stores essential metadata, including the object's hash code, GC age, and—most importantly—the lock status. As a thread competes for a lock, the JVM modifies the Mark Word to reflect the current state of the lock.
The Evolution of Lock States
The JVM optimizes performance by transitioning through different lock levels based on the intensity of thread contention. This progression is strictly one-way: locks can upgrade, but they do not typically downgrade in the same manner.
- Unlocked State: No thread currently holds the lock.
- Biased Locking: This is an optimization for scenarios where a lock is predominantly acquired by a single thread. The JVM "biases" the lock toward that thread, allowing it to re-acquire the lock without performing expensive atomic operations (like CAS).
- Lightweight Locking: When multiple threads begin to compete for the lock, the JVM upgrades to lightweight locking. Instead of involving the OS, threads attempt to acquire the lock using CAS (Compare-And-Swap) operations and "spinning" (repeatedly checking the lock status in a loop). This avoids the massive cost of suspending and resuming threads.
- Heavyweight Locking: If contention becomes severe or if threads spin for too long without success, the lock is upgraded to a heavyweight lock. At this stage, the threads are truly blocked, and the JVM relies on the operating system to manage the thread waiting queue.
Best Practices for Developers
While synchronized is a powerful tool, it must be used judiciously to maintain system throughput.
- Prefer
synchronizedfor Simplicity: Unless you require the advanced features ofjava.util.concurrent.locks.ReentrantLock(such as fairness policies or interruptible lock acquisitions), thesynchronizedkeyword is often the better choice. It is cleaner, easier to read, and the JVM automatically handles lock release, even if an exception is thrown. - Minimize Lock Granularity: Always aim to lock the smallest possible section of code. The less time a thread holds a lock, the more concurrency your application can support.
- Prevent Deadlocks: When your logic requires acquiring multiple locks, always ensure that all threads acquire those locks in the same consistent order. Inconsistent acquisition orders are the primary cause of deadlocks.