In a multithreaded environment, multiple threads may try to access shared resources concurrently, leading to race conditions, data inconsistency, or deadlocks. To prevent such issues, Java provides several thread synchronization techniques to control access to shared resources.
In this blog, we'll explore the most commonly used synchronization techniques in Java with practical code examples. π
1. Synchronized Keyword (Locks / Mutexes)
The synchronized
keyword ensures that only one thread can access a synchronized block or method at a time.
Example: Synchronized Method
class SharedResource {
private int count = 0;
public synchronized void increment() { // Method-level synchronization
count++;
}
public synchronized int getCount() {
return count;
}
}
public class SynchronizedExample {
public static void main(String[] args) throws InterruptedException {
SharedResource resource = new SharedResource();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 1000; i++) resource.increment();
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 1000; i++) resource.increment();
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("Final count: " + resource.getCount());
}
}
β
Output: Final count: 2000
(ensures correct shared access)
2. Synchronized Block
Instead of synchronizing an entire method, we can synchronize only a critical section inside a method to improve performance.
Example: Synchronized Block
class SharedCounter {
private int count = 0;
public void increment() {
synchronized (this) { // Synchronizing only critical section
count++;
}
}
public int getCount() {
return count;
}
}
π Use case: Useful when only part of the method needs synchronization.
3. Using ReentrantLock
ReentrantLock
is a more flexible alternative to the synchronized
keyword. It allows tryLock() for non-blocking attempts and lockInterruptibly() to respond to interrupts.
Example: ReentrantLock
import java.util.concurrent.locks.ReentrantLock;
class SharedData {
private int count = 0;
private final ReentrantLock lock = new ReentrantLock();
public void increment() {
lock.lock(); // Lock acquired
try {
count++;
} finally {
lock.unlock(); // Lock released
}
}
public int getCount() {
return count;
}
}
public class ReentrantLockExample {
public static void main(String[] args) throws InterruptedException {
SharedData data = new SharedData();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 1000; i++) data.increment();
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 1000; i++) data.increment();
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("Final count: " + data.getCount());
}
}
πΉ Why use ReentrantLock?
- Provides fair locking (first-come, first-served basis).
- Allows lock timeout with
tryLock()
. - Can be manually unlocked, unlike
synchronized
.
4. Using Semaphore
Semaphore
controls access to a resource with a fixed number of permits.
Example: Semaphore for Limited Access
import java.util.concurrent.Semaphore;
class SharedPrinter {
private final Semaphore semaphore = new Semaphore(1); // 1 permit
public void print(String message) {
try {
semaphore.acquire(); // Acquire lock
System.out.println(Thread.currentThread().getName() + " printing: " + message);
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
semaphore.release(); // Release lock
}
}
}
public class SemaphoreExample {
public static void main(String[] args) {
SharedPrinter printer = new SharedPrinter();
Runnable task = () -> printer.print("Hello from " + Thread.currentThread().getName());
Thread t1 = new Thread(task);
Thread t2 = new Thread(task);
t1.start();
t2.start();
}
}
β Use case: Useful in rate-limiting and resource pooling.
5. Read-Write Locks (ReentrantReadWriteLock)
When multiple threads only read data, they donβt need exclusive access. ReadWriteLock allows multiple readers but only one writer.
Example: Read-Write Lock
import java.util.concurrent.locks.ReentrantReadWriteLock;
class SharedDataRW {
private int data = 0;
private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
public void write(int value) {
lock.writeLock().lock();
try {
data = value;
System.out.println("Written: " + value);
} finally {
lock.writeLock().unlock();
}
}
public int read() {
lock.readLock().lock();
try {
return data;
} finally {
lock.readLock().unlock();
}
}
}
πΉ Use case: Improves performance in read-heavy applications.
6. Using Atomic Variables
Instead of locks, AtomicInteger
ensures lock-free thread-safe operations.
Example: Atomic Integer
import java.util.concurrent.atomic.AtomicInteger;
class SharedAtomic {
private final AtomicInteger count = new AtomicInteger(0);
public void increment() {
count.incrementAndGet();
}
public int getCount() {
return count.get();
}
}
β Faster than locks for simple counters
7. Using Countdown Latch
A CountDownLatch
waits for a specified number of threads to complete before proceeding.
Example: Countdown Latch
import java.util.concurrent.CountDownLatch;
public class CountDownLatchExample {
public static void main(String[] args) throws InterruptedException {
CountDownLatch latch = new CountDownLatch(3);
Runnable task = () -> {
System.out.println(Thread.currentThread().getName() + " completed");
latch.countDown();
};
new Thread(task).start();
new Thread(task).start();
new Thread(task).start();
latch.await(); // Wait for all threads to finish
System.out.println("All threads finished!");
}
}
β Use case: Ensuring all prerequisites complete before proceeding.
Conclusion
Java offers multiple thread synchronization techniques, each with its advantages:
-
synchronized
: Simple but may cause contention. -
ReentrantLock
: More flexible locking mechanism. -
Semaphore
: Controls access with limited permits. -
ReadWriteLock
: Efficient for read-heavy operations. -
Atomic variables
: Best for lock-free counters. -
CountDownLatch
: Useful for waiting for multiple threads.
By choosing the right technique, you can avoid race conditions, improve performance, and build reliable concurrent applications. π
Whatβs your favorite way to synchronize threads in Java? Let me know in the comments! π
Top comments (0)