This article delves into a common synchronization pitfall when using Integer objects as monitors in Java's synchronized blocks, particularly in concurrent scenarios.
Consider the following code simulating a ticket purchasing process with two threads:
import java.util.concurrent.TimeUnit;
public class SynchronizedTest {
public static void main(String[] args) {
Thread threadA = new Thread(new TicketConsumer(10), "Thread-A");
Thread threadB = new Thread(new TicketConsumer(10), "Thread-B");
threadA.start();
threadB.start();
}
}
class TicketConsumer implements Runnable {
private volatile static Integer availableTickets;
public TicketConsumer(int tickets) {
availableTickets = tickets;
}
@Override
public void run() {
while (true) {
System.out.println(Thread.currentThread().getName() + " attempting to buy ticket " + availableTickets + ", hash before lock: " + System.identityHashCode(availableTickets));
synchronized (availableTickets) {
System.out.println(Thread.currentThread().getName() + " acquired lock for ticket " + availableTickets + ", locked object hash: " + System.identityHashCode(availableTickets));
if (availableTickets > 0) {
try {
// Simulate purchase delay
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
}
System.out.println(Thread.currentThread().getName() + " bought ticket " + availableTickets-- + ", tickets remaining.");
} else {
return;
}
}
}
}
}
The objective is for two threads to consume a shared resource (tickets), with synchronized intended to ensure each ticket is bought only once. However, running this code can lead to unexpected behavior, such as both threads seemingly acquiring the same ticket and reporting the same lock object hash.
Why Synchronization Fails
The core issue arises because the synchronized block locks on the availableTickets Integer object. Java's Integer objects within the range of -128 to 127 are interned, meaning multiple references to these values point to the same object. However, when the value changes (e.g., availableTickets--), and especially if it falls outside the cache range or due to auto-unboxing and re-boxing during operations, new Integer objects can be created. If different threads end up locking on different Integer objects, evenif those objects represent the same numerical value at that moment, they are distinct monitors, and synchronization is lost.
To diagnose this, thread dumps can be taken using tools like jstack or IDE debuggers. Analyzing these dumps reveals that at certain points, threads are attempting to acquire different lock objects, leading to concurrent execution within the synchronized blocks.
For instance, in the first ticket purchase, one thread might hold the lock on an Integer object representing 10, while another thread is blocked waiting for that same object. After the first thread decrements the ticket count to 9 and releases the lock, the second thread acquires it. Meanwhile, the first thread might then attempt to re-acquire a lock, potentially on a new Integer object representing 9, leading to seperate execution paths.
The Integer Caching Mechanism
The Integer.valueOf(int) method employs caching for values between -128 and 127. When availableTickets-- is performed, the availableTickets variable might be updated with a new Integer object, especially if the value changes. If the initial value is 10 (within cache), subsequent decrements to 9 (also within cache) will still involve different object instances unless explicitly managed. This behavior can be amplified by initializing with a value above the cache range (e.g., 200), which forces the creation of new objects more frequently.
Avoiding Integer as a Lock Object
Using Integer objects as monitors is generally discouraged due to this unpredictable behavior. A common and safer alternative is to use a dedicated lock object, such as new Object(), or the class object (TicketConsumer.class) if the resource is entirely static.
// Using class object as lock
synchronized (TicketConsumer.class) {
// ... critical section ...
}
Advanced Scenarios and Solutions
In more complex scenarios, where locking on specific integer IDs is necessary (e.g., caching mechanisms), managing the lock objects becomes crucial. A robust approach involves using a ConcurrentHashMap to map IDs to specific lock objects. This ensures that regardless of how many times a new Integer object for a given ID is created, it will always map to the same, single lock instance managed by the map.
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class LockManager {
private final ConcurrentHashMap<Integer, Lock> locks = new ConcurrentHashMap<>();
public Lock getLock(Integer id) {
return locks.computeIfAbsent(id, k -> new ReentrantLock());
}
}
// Usage:
// LockManager lockManager = new LockManager();
// Lock specificLock = lockManager.getLock(someIntegerId);
// specificLock.lock();
// try {
// // critical section
// } finally {
// specificLock.unlock();
// }
This pattern guarantees that only one lock object exists for each unique ID, providing effective synchronization even for values outside the default Integer cache range.
Furthermore, for scenarios involving expensive data fetching and caching, as discussed in Brian Goetz's work on "Building High-Performance Caching," more advanced patterns involving ConcurrentHashMap, FutureTask, and avoiding synchronization altogether by managing task execution can offer superior scalability and performance.