Locks and Objects

In the realm of multi-threaded programming, ensuring thread safety is a fundamental challenge. In Java, synchronization is not an abstract concept floating in the ether; rather, it is deeply intertwined with the object model. To master concurrency in Java, one must move beyond simply knowing how to use keywords and instead understand the intrinsic relationship between locks and objects.
In Java, every single object is implicitly associated with a Monitor (also known as an intrinsic lock). This relationship is baked into the JVM's memory management. When an object is created, its memory layout includes an Object Header, which contains a specific field known as the Mark Word. This Mark Word is responsible for storing the lock's state, including whether the lock is currently held, whether it is biased, or whether it has been inflated to a heavy-weight monitor.

When a thread attempts to enter a synchronized block or method, the JVM attempts to acquire the monitor associated with that specific object. The rules governing this process are straightforward:

  • Mutual Exclusion: Only one thread can hold a particular object's lock at any given time.
  • Reentrancy: Java locks are reentrant, meaning a thread that already holds a lock can acquire it again without deadlocking itself.
  • Blocking: Any thread that fails to acquire the lock will enter a BLOCKED state, waiting until the current owner releases the monitor.

One of the most common sources of concurrency bugs is a misunderstanding of which object is actually being locked. The synchronized keyword behaves differently depending on its context:

  1. Instance Methods: When you mark a non-static method as synchronized, the lock is acquired on the current instance, referred to as this.
  2. Static Methods: When a static method is synchronized, the lock is acquired on the Class object associated with that type (e.g., LockDemo.class). This lock is global to all instances of that class.
  3. Synchronized Blocks: This provides the most granular control, allowing you to specify a particular object to act as the lock.

Consider the following implementation:

public class LockDemo {
    private final Object lock = new Object();
    private int count = 0;

    // Locks on the current instance (this)
    public synchronized void incByMethod() {
        count++;
    }

    // Locks on the class object (LockDemo.class)
    public static synchronized void classLevel() { 
        // Static logic here
    }

    // Locks on the specific 'lock' instance
    public void incByBlock() {
        synchronized (lock) {
            count++;
        }
    }
}

Crucial Insight: In the example above, incByMethod and incByBlock use different lock objects. Consequently, they do not provide mutual exclusion against each other. A thread could be executing incByMethod while another thread simultaneously executes incByBlock. Choosing the wrong lock object is a primary cause of race conditions.

Avoiding Common Locking Traps

To write robust concurrent code, you must be intentional about your choice of lock objects. Follow these industry best practices to avoid subtle bugs:

  • Use private final Objects: Always declare your lock objects as private final. The private modifier prevents external code from interfering with your locking logic, and final ensures that the reference cannot be changed. If the reference changes, different threads will end up locking on different objects, rendering your synchronization useless.
  • Never Lock on String Literals: Strings in Java are stored in a global String Constant Pool. If you use a string like synchronized("MY_LOCK"), you are locking on a globally shared object. Any other unrelated library or piece of code in your application that happens to use the same string literal will be blocked by your code, leading to unpredictable performance issues or deadlocks.
  • Avoid Wrapper Classes: Do not use Integer, Boolean, or other boxed types as locks. These classes implement caching mechanisms (e.g., IntegerCache). Locking on a Boolean or a small Integer might inadvertently lock on a shared instance used elsewhere in the JVM.
  • Separate Concerns: A lock object should serve only one purpose: synchronization. Do not use a business object (like a User or Account object) as a lock, as this exposes your internal synchronization mechanism to the rest of the application.

Inter-Thread Communication: wait and notify

The binding between locks and objects is most evident when using the wait(), notify(), and notifyAll() methods. These methods are not members of a "Lock" class; they are members of java.lang.Object.

Because these methods rely on the object's monitor, they must be called from within a synchronized context. If you attempt to call wait() without holding the object's lock, the JVM will throw an IllegalMonitorStateException.

synchronized (queue) {
    while (queue.isEmpty()) {
        // Releases the 'queue' lock and puts the thread to sleep
        queue.wait(); 
    }
    String item = queue.poll();
    // Wakes up threads waiting on the 'queue' monitor
    queue.notifyAll(); 
}

When a thread calls wait(), it atomically releases the lock and enters a waiting state. When it is woken up via notify(), it must re-acquire the lock before it can continue execution. Always use a while loop to check the condition after waking up to protect against spurious wakeups.

Explicit Locks: Decoupling Lock from Object

While synchronized is convenient, the java.util.concurrent.locks package introduces ReentrantLock, which offers a different paradigm. Unlike intrinsic locks, an explicit lock is a standalone object that is decoupled from the data it protects.

private final ReentrantLock lock = new ReentrantLock();

public void add() {
    lock.lock();
    try {
        count++;
    } finally {
        // Manual release is mandatory
        lock.unlock(); 
    }
}

ReentrantLock provides advanced capabilities that synchronized lacks, such as:

  • tryLock(): Attempt to acquire the lock without blocking indefinitely.
  • Timed Lock Acquisition: Wait for a lock for a specific duration.
  • Fairness Policies: Ensure that the longest-waiting thread gets the lock next.
  • Interruptible Locking: Allow a thread to be interrupted while it is waiting for a lock.

However, this flexibility comes with responsibility: you must use a try-finally block to ensure the lock is released, or you risk a "lock leak" that can freeze your entire application.

Summary

To master Java concurrency, remember this mantra: You lock the object, not the code.

  • Intrinsic locks are tied to objects via the Monitor and the Mark Word.
  • Scope matters: Be mindful of whether you are locking this, a Class object, or a specific instance.
  • Safety first: Use private final dedicated objects and avoid the pitfalls of the String pool and wrapper caches.
  • Coordination: wait and notify are intrinsically linked to the object's monitor.
  • Flexibility: Use ReentrantLock when you need advanced features, but always manage the lifecycle manually.