Last Updated on July 22, 2024

When multiple threads access and modify shared data, it’s crucial to ensure thread safety. One of the mechanisms to achieve threads safety is Java locks located in package java.util.concurrent.lock.

Java concurrency allows a program to perform multiple operations at the same time, potentially improving responsiveness and overall performance, especially when dealing with long-running tasks or tasks that involve waiting for external resources (like network requests or disk I/O).

Java concurrency is achieved primarily through the concept of threads.

A thread is a single unit of execution within a Java program. It represents a lightweight process that can be managed by the JVM (Java Virtual Machine).Each thread has its own call stack for storing method invocations and local variables.Multiple threads can coexist and execute concurrently within a single application.

When multiple threads access and modify shared data, it’s crucial to ensure thread safety. This means preventing race conditions (where the outcome depends on unpredictable thread scheduling) and data inconsistencies.Java provides mechanisms like synchronization and locks to achieve thread safety. These mechanisms ensure that only one thread can access and modify a shared resource at a time.

Java Locks

In Java concurrency, locks are a fundamental tool for ensuring thread safety. They act as a synchronization mechanism that controls access to shared resources by multiple threads.

Locks are objects in Java that provide exclusive access to a shared resource.A thread can acquire a lock on a resource before accessing it. This prevents other threads from acquiring the same lock and modifying the resource until the first thread releases the lock.

Locks prevent race conditions and data inconsistencies, leading to predictable and reliable program behavior.

However, Acquiring and releasing locks can introduce some overhead compared to non-synchronized access. Additionally, If not used carefully, locks can lead to deadlocks, where two or more threads are waiting for each other’s locks indefinitely.

Locks can be used whenever multiple threads need to access and modify the same shared resource concurrently.

Lock Interface

The java.util.concurrent.locks.Lock interface in Java is a core component for building more complex and flexible synchronization mechanisms compared to the basic synchronized keyword.

lock(): This method attempts to acquire the lock. If the lock is already acquired by another thread, the current thread will typically block until the lock is released.

lockInterruptibly(): Similar to lock(), but throws an InterruptedException if the current thread is interrupted while waiting for the lock.

tryLock(): This method attempts to acquire the lock without blocking. If the lock is not available, it immediately returns false.

tryLock(long time, TimeUnit unit): This method attempts to acquire the lock within a specified waiting time. If the lock cannot be acquired within the time limit, it returns false.

unlock(): This method releases the lock, allowing other threads to acquire it.

There are several types of Java locks. we will give brief description and sample usage in following paragraphs.

ReentrantLock

The ReentrantLock class is a common implementation of the Lock interface.

It ensures that only one thread can access a shared resource at a time. By using ReentrantLock, you gain the benefits of explicit locking with the Lock interface and the convenience of a pre-built implementation.

A thread can acquire the same lock multiple times (reentrancy), making it useful for scenarios where a thread might need to access the same resource multiple times within a critical section.

Example:

package org.codeline;
import java.util.concurrent.locks.ReentrantLock;

public class Counter {
    private int count = 0;
    private final ReentrantLock lock = new ReentrantLock();
    public void increment() {
        lock.lock(); // Acquire the lock before modifying count
        try {
            count++;
        } finally {
            lock.unlock(); // Release the lock after modification
        }
    }

    public int getCount() {
        lock.lock(); // Acquire the lock before reading count
        try {
            return count;
        } finally {
            lock.unlock(); // Release the lock after reading
        }
    }
}
}

In this example, the increment() method acquires the lock before modifying the count and releases it afterward. This ensures that only one thread can modify the count at a time, preventing race conditions and maintaining data consistency.

Main program:

package org.codeline;
public class Main {
    public static void main(String[] args) throws InterruptedException {
        Counter counter = new Counter();

        // Create multiple threads to increment the counter concurrently
        Thread thread1 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                counter.increment();
            }
        });

        Thread thread2 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                counter.increment();
            }
        });

        thread1.start();
        thread2.start();

        thread1.join();
        thread2.join();

        System.out.println("Final count: " + counter.getCount()); // Output may vary due to concurrency
    }

ReadWriteLock

It allows concurrent read operations while providing exclusive access for write operations. This is efficient for scenarios with frequent read access and occasional writes.

Ensures thread safety for both read and write operations. Read operations are safe when multiple threads hold the read lock, but write operations require the exclusive write lock for data integrity.

package org.codeline;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
import java.util.HashMap;

public class ReadWriteMap {

    private final HashMap<String, String> map = new HashMap<>();
    private final ReadWriteLock lock = new ReentrantReadWriteLock();

    public void put(String key, String value) {
        lock.writeLock().lock(); // Acquire write lock for modification
        try {
            map.put(key, value);
        } finally {
            lock.writeLock().unlock(); // Release write lock
        }
    }

    public String get(String key) {
        lock.readLock().lock(); // Acquire read lock for access
        try {
            return map.get(key);
        } finally {
            lock.readLock().unlock(); // Release read lock
        }
    }


}

Read Lock – If no thread acquired the write lock or requested for it, multiple threads can acquire it.

Write Lock – If no threads are reading or writing, only one thread can acquire it.

Main program:

package org.codeline;

public class Main {
public static void main(String[] args) throws InterruptedException {
        ReadWriteMap map = new ReadWriteMap();

        // Thread to write to the map
        Thread writer = new Thread(() -> {
        for (int i = 0; i < 10; i++) {
        map.put("key" + i, "value" + i);
        }
        });

        // Threads to read from the map concurrently
        Thread reader = new Thread(() -> {
        for (int i = 0; i < 10; i++) {
        System.out.println(map.get("key" + i));
        }
        });

        writer.start();
        reader.start();

        writer.join();
        reader.join();
        }
}

This example demonstrates how ReadWriteLock can be used to optimize concurrent access to a shared resource. Read operations can occur concurrently without blocking write operations, improving overall performance. However, write operations require exclusive access to the map, ensuring data consistency.

StampedLock

A StampedLock in Java is a type of lock introduced in Java 8 that provides an optimistic locking approach for concurrent programming.

It allows a thread to access a shared resource without acquiring a lock initially. The thread then validates a “stamp” associated with the resource to ensure it hasn’t been modified since the initial access.

By using optimistic locking, threads avoid unnecessary blocking when the data is not being modified by another thread. This can improve performance in scenarios with frequent read access and occasional writes.

package org.codeline;

import java.util.concurrent.locks.StampedLock;

public class Counter {

    private int count = 0;
    private final StampedLock lock = new StampedLock();

    public int getValue() {
        long stamp = lock.tryOptimisticRead(); // Try optimistic read
        int value = count;
        if (!lock.validate(stamp)) { // Validate if data hasn't changed
            stamp = lock.readLock(); // Acquire read lock if data changed
            try {
                value = count;
            } finally {
                lock.unlockRead(stamp); // Release read lock
            }
        }
        return value;
    }

    public void increment() {
        long stamp = lock.tryConvertToWriteLock(lock.tryOptimisticRead()); // Try optimistic read and convert to write lock
        if (stamp == 0) { // Lock not acquired, acquire write lock explicitly
            stamp = lock.writeLock();
        }
        try {
            count++;
        } finally {
            lock.unlockWrite(stamp); // Release write lock
        }
    }
}

In the above class getValue() method uses lock.tryOptimisticRead() to attempt an optimistic read and get a stamp, stores the current count value in a local variable value,alls lock.validate(stamp) to check if the data has been modified since the optimistic read.

If validation fails (data might have changed), it acquires a read lock using lock.readLock() and re-reads the count value within a try-finally block to ensure proper lock release.

The increment() method, tries to convert an optimistic read (acquired in getValue) to a write lock using lock.tryConvertToWriteLock(lock.tryOptimisticRead()).

If conversion fails (meaning another thread might be modifying the data), it acquires a write lock explicitly using lock.writeLock(), increments the count value within a try-finally block to ensure proper lock release.

Main program:

package org.codeline;

public class Main {
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();

// Thread to increment the counter
Thread writer = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
counter.increment();
}
});

// Threads to read the counter concurrently
Thread reader = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
System.out.println(counter.getValue());
}
});
writer.start();
reader.start();

writer.join();
reader.join();
}
}

This example demonstrates optimistic reads for getValue and potential conversion to a read lock if validation fails.

Conclusion

Java locks are a fundamental tool for achieving thread safety in concurrent programs.

Java offers various lock types, each with its strengths and weaknesses. Consider the synchronized keyword for simple scenarios, ReentrantLock for fine-grained control, ReadWriteLock for read-heavy workloads with occasional writes, and StampedLock for optimistic locking with potential performance benefits (but with added complexity).

By understanding these concepts and using locks judiciously, we can develop robust and efficient concurrent applications in Java.

The appropriate lock type and usage strategy depend on the specific requirements of our program’s concurrency needs.

Scroll to Top