Mastering Multithreading in Java: Part 4 – Synchronization and the Synchronized Keyword
Recap of Sleep and Interrupt in Threads
In the previous article, we explored two critical concepts in multithreading: sleep() and interrupt(). We learned that threads can be paused using the sleep() method, and we discussed how interruptions allow us to wake up or stop a thread mid-execution. These techniques are essential when managing the flow and timing of your program.
Today, we’ll dive into another crucial topic in multithreading—synchronization—and understand how Java handles multiple threads accessing shared resources. We’ll also introduce the concept of a mutex (mutual exclusion) and explore different ways to synchronize threads.
What is Synchronization?
In multithreading, synchronization ensures that two or more threads do not simultaneously access a shared resource, such as a variable or object. Without synchronization, unpredictable behavior can occur when multiple threads try to read or modify shared data at the same time. This is known as a race condition, and it can lead to incorrect results or even crashes.
Java provides built-in synchronization mechanisms through the synchronized keyword, which can be applied to methods or code blocks. Synchronization forces a thread to acquire a lock on an object before executing any critical section of code, ensuring that only one thread can execute that section at a time.
Synchronized Methods
When you synchronize a method, Java allows only one thread to execute that method on the same object at any given time. This is the simplest form of synchronization. Here’s an example:
class Counter {
private int count = 0;
public synchronized void increment() {
count++;
}
public int getCount() {
return count;
}
}
public class Main {
public static void main(String[] args) {
Counter counter = new Counter();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
counter.increment();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
counter.increment();
}
});
t1.start();
t2.start();
try {
t1.join();
t2.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(counter.getCount());
}
}
In this example, two threads are incrementing the same counter. The synchronized keyword ensures that only one thread can increment the counter at a time, preventing incorrect results from a race condition.
Synchronized Blocks
In addition to synchronizing entire methods, you can also synchronize specific blocks of code. This gives you finer control over which part of your method is synchronized, rather than locking the whole method. Synchronizing a block is particularly useful if only a small portion of the code accesses a shared resource.
public class SynchronizedBlockExample {
private final Object lock = new Object();
private int count = 0;
public void increment() {
synchronized (lock) {
count++;
}
}
public int getCount() {
return count;
}
}
In this example, we use an explicit lock object to synchronize only the part of the method that modifies the shared variable count.
What is a Mutex?
A mutex (short for mutual exclusion) is a synchronization primitive that ensures that only one thread can access a resource at a time. While Java’s synchronized keyword provides implicit locking, mutexes offer more explicit control over locks.
In Java, you can implement a mutex using the ReentrantLock class from the java.util.concurrent.locks package. This class provides more flexibility than the synchronized keyword, such as the ability to try locking and unlocking manually.
Here’s a simple example of using a mutex (ReentrantLock):
领英推荐
public class MutexExample {
private int count = 0;
private final Lock lock = new ReentrantLock();
public void increment() {
lock.lock();
try {
count++;
} finally {
lock.unlock();
}
}
public int getCount() {
return count;
}
}
In this code, we use the lock.lock() method to acquire the lock before incrementing count, and lock.unlock() in the finally block to release the lock, ensuring the lock is always released even if an exception occurs.
Mutexes are especially useful when you need advanced control, such as timeouts or the ability to try locking without blocking.
Synchronization Using Reflection Objects
Java also allows synchronization using any object, not just methods or blocks. This is known as reflection-based synchronization, where any object can serve as a lock.
For example:
class ReflectionSyncExample {
private final Object lock = new Object();
private int count = 0;
public void increment() {
synchronized (lock) {
count++;
}
}
public int getCount() {
return count;
}
}
In this case, we use the lock object (which can be any object) as the synchronization object. When a thread enters the synchronized block, it locks the lock object and prevents other threads from entering any block synchronized on the same object.
This approach is useful when you need to synchronize on an object other than this, providing greater flexibility in how you control access to resources.
Conclusion: Mastering Synchronization
In this article, we’ve covered the essentials of synchronization in Java: how the synchronized keyword works with methods and blocks, the role of a mutex for manual locking, and the use of reflection objects for more customized synchronization.
Understanding synchronization is key to writing safe and efficient multithreaded programs. It prevents race conditions by ensuring that only one thread accesses critical sections of code at a time. Whether you use the simpler synchronized keyword or the more advanced ReentrantLock, mastering synchronization is essential to avoiding common pitfalls in concurrent programming, such as deadlocks and race conditions.
In the next article, we’ll explore even more advanced synchronization techniques, including how to handle common pitfalls like deadlocks and how to implement thread-safe collections.
Stay tuned!
Previously Covered Topics in This Series: