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.