Fading Coder

One Final Commit for the Last Sprint

Home > Tech > Content

Understanding Java's synchronized Keyword: Lock Types, Mechanics, and JVM Optimizations

Tech 1

Synchronized Lock Semantics

The synchronized keyword in Java enforces mutual exclusion by associating a monitor (intrinsic lock) with an object or class. Its behavior depends on what is being locked:

  • Instance-level locks: Applied to non-static methods or synchronized(this) blocks — each object instance has its own independent lock.
  • Class-level locks: Applied to static synchronized methods or synchronized(ClassName.class) blocks — all instances share the same lock tied to the Class object.

Crucially, acquiring a synchronized block or method automatically releases the lock upon normal completion or uncaught exception — no manual cleanup is required.

Instance Locks: Explicit and Implicit Forms

Synchronized Code Blocks with Custom Lock Objects

Locking on arbitrary objects enables fine-grained control over concurrency boundaries:

public class FineGrainedLocking implements Runnable {
    private final Object lockA = new Object();
    private final Object lockB = new Object();

    @Override
    public void run() {
        // First critical section — uses lockA
        synchronized (lockA) {
            System.out.println("Thread " + Thread.currentThread().getName() + " entered section A");
            try { Thread.sleep(2000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
            System.out.println("Thread " + Thread.currentThread().getName() + " exited section A");
        }

        // Second critical section — uses lockB; runs immediately after lockA is released
        synchronized (lockB) {
            System.out.println("Thread " + Thread.currentThread().getName() + " entered section B");
            try { Thread.sleep(2000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
            System.out.println("Thread " + Thread.currentThread().getName() + " exited section B");
        }
    }

    public static void main(String[] args) {
        FineGrainedLocking task = new FineGrainedLocking();
        new Thread(task, "Worker-1").start();
        new Thread(task, "Worker-2").start();
    }
}

Because lockA and lockB are distinct objects, threads may interleave execution across sections — one thread can release lockA and immediately acquire lockB, while another concurrently acquires lockA.

Synchronized Instance Methods

When applied to non-static methods, synchronized implicitly locks on this:

public class InstanceMethodLock implements Runnable {
    private static final InstanceMethodLock shared = new InstanceMethodLock();

    @Override
    public void run() {
        performCriticalTask();
    }

    private synchronized void performCriticalTask() {
        System.out.println("Thread " + Thread.currentThread().getName() + " acquired instance lock");
        try { Thread.sleep(2500); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
        System.out.println("Thread " + Thread.currentThread().getName() + " released instance lock");
    }

    public static void main(String[] args) {
        new Thread(shared, "T-1").start();
        new Thread(shared, "T-2").start();
    }
}

Here, both threads compete for the same shared instance’s monitor — execution becomes strictly sequential.

Class-Level Locks

These ensure exclusive access across all instances of a class:

Static Synchronized Methods

public class ClassLevelLock implements Runnable {
    private static final ClassLevelLock instance1 = new ClassLevelLock();
    private static final ClassLevelLock instance2 = new ClassLevelLock();

    @Override
    public void run() {
        executeClassScopedOperation();
    }

    private static synchronized void executeClassScopedOperation() {
        System.out.println("Thread " + Thread.currentThread().getName() + " entered static synchronized method");
        try { Thread.sleep(2000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
        System.out.println("Thread " + Thread.currentThread().getName() + " exited static synchronized method");
    }

    public static void main(String[] args) {
        new Thread(instance1, "C-1").start();
        new Thread(instance2, "C-2").start();
    }
}

Despite using separate instances (instance1, instance2), both threads contend for the ClassLevelLock.class monitor — serializing access.

Explicit Class Object Locking

Equivalent behavior can be achieved via explicit synchronization on the Class literal:

synchronized (ClassLevelLock.class) {
    // Critical section — same lock as static synchronized method
}

Underlying JVM Mechanism: Monitor Instructions

At the bytecode level, synchronized blocks compile into monitorenter and monitorexit instructions. Each object maintains a monitor counter (recursion depth):

  • On entry: monitorenter increments the counter. If zero, the thread acquires the monitor; if already owned by the same thread, it increments (enabling reentrancy).
  • On exit: monitorexit decrements the counter. When it reaches zero, the moniter is fully released.

This mechanism inherently supports reentrant locking: a thread holding a lock can reacquire it without blocking.

Example demonstrating reentrancy:

public class ReentrantExample {
    private synchronized void outer() {
        System.out.println("outer called");
        inner();
    }

    private synchronized void inner() {
        System.out.println("inner called");
        // No deadlock — same thread reenters its own lock
    }

    public static void main(String[] args) {
        new ReentrantExample().outer();
    }
}

Memory Visibility Guarantees

synchronized establishes a happens-before relationship between the release of a monitor and its subsequent acquisition by another thread. This ensures visibility of writes performed inside the synchronized block:

public class VisibilityDemo {
    private int value = 0;

    public synchronized void write() {
        value = 42; // Write visible to next reader
    }

    public synchronized int read() {
        return value; // Reads latest value written by previous writer
    }
}

The unlock in write() happens-before the lock in read(), guaranteeing that read() observes value == 42 if invoked after write() completes.

JVM Lock Optimization Strategies

Modern JVMs (JDK 6+) apply multiple optimizations to reduce synchronization overhead:

Optimization Description
Biased Locking Assumes single-threaded access; stores thread ID directly in object header. Eliminates CAS on first acquisition. Disabled by default since JDK 15.
Lightweight Locking Uses CAS-based spinning instead of OS mutexes when contention is low. Avoids kernel transitions.
Adaptive Spinning Adjusts spin count dynamically based on recent success/failure history of the same lock.
Lock Coarsening Merges adjacent synchronized blocks on the same object into a single broader region.
Lock Elimination Removes synchronization entirely when escape analysis proves the locked object never escapes the current thread’s scope.

Lock State Transitions

Java monitors evolve through states based on contention:

Unlocked → Biased → Lightweight → Heavyweight
  • Biased: Optimized for uncontended single-thread use.
  • Lightweight: CAS-based spinning under light contention.
  • Heavyweight: Falls back to OS-level mutexes (pthread_mutex) under sustained contention.

State upgrades are one-way; downgrades do not occur.

Comparing synchronized and java.util.concurrent.locks.Lock

Aspect synchronized Lock Interface
Acquisition Control Blocking only; no timeout or interruption support tryLock(), tryLock(long, TimeUnit), lockInterruptibly()
Flexibility Single implicit condition per lock Multiple Condition instances per Lock
Explicitness Automatic acquisition/release Manual lock()/unlock() required (often in finally)
Fairness Non-fair by default (may cause starvation) Optional fairness policy via constructor
Performance Highly optimized by JVM; minimal overhead in uncontended case Slightly higher baseline cost but more predictable under contention

Best Practices and Pitfalls

  • Avoid null lock objects: synchronized(null) throws NullPointerException.
  • Prefer narrow scopes: Minimize synchronized regions to reduce contention.
  • Never hold locks during I/O or long computations: Increases latency for other threads.
  • Prevent deadlocks: Acquire multiple locks in consistent global order.
  • Prefer high-level concurrency utilities: ConcurrentHashMap, CopyOnWriteArrayList, CompletableFuture, etc., often eliminate the need for explicit locking.

synchronized remains the idiomatic choice for simple, well-scoped mutual exclusion due to its simplicity, safety, and deep JVM integration.

Related Articles

Understanding Strong and Weak References in Java

Strong References Strong reference are the most prevalent type of object referencing in Java. When an object has a strong reference pointing to it, the garbage collector will not reclaim its memory. F...

Comprehensive Guide to SSTI Explained with Payload Bypass Techniques

Introduction Server-Side Template Injection (SSTI) is a vulnerability in web applications where user input is improper handled within the template engine and executed on the server. This exploit can r...

Implement Image Upload Functionality for Django Integrated TinyMCE Editor

Django’s Admin panel is highly user-friendly, and pairing it with TinyMCE, an effective rich text editor, simplifies content management significantly. Combining the two is particular useful for bloggi...

Leave a Comment

Anonymous

◎Feel free to join the discussion and share your thoughts.