Java’s volatile keyword, AtomicInteger, and synchronized blocks all deal with concurrent access to shared variables, but they operate at fundamentally different levels and solve distinct problems.
Let’s see volatile in action. Imagine a simple flag that signals a background thread to stop.
class ShutdownFlag {
private volatile boolean shutdownRequested = false;
public void requestShutdown() {
shutdownRequested = true; // This write is visible to other threads
}
public boolean isShutdownRequested() {
return shutdownRequested; // This read is guaranteed to see the latest write
}
}
class WorkerThread extends Thread {
private ShutdownFlag flag;
public WorkerFlag(ShutdownFlag flag) {
this.flag = flag;
}
@Override
public void run() {
while (!flag.isShutdownRequested()) {
// Do some work...
try {
Thread.sleep(100);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
System.out.println("Worker thread shutting down.");
}
}
public class VolatileDemo {
public static void main(String[] args) throws InterruptedException {
ShutdownFlag flag = new ShutdownFlag();
WorkerThread worker = new WorkerThread(flag);
worker.start();
Thread.sleep(1000); // Let the worker run for a bit
System.out.println("Requesting shutdown...");
flag.requestShutdown(); // Signal the worker to stop
worker.join(); // Wait for the worker to finish
System.out.println("Main thread exiting.");
}
}
In this example, volatile ensures that when requestShutdown() sets shutdownRequested to true, that change is immediately flushed from the writing thread’s cache to main memory, and any subsequent reads of shutdownRequested by other threads (like in isShutdownRequested()) will fetch the latest value from main memory, bypassing their own caches. This guarantees that the while loop condition in WorkerThread will eventually become false and the thread will terminate. Without volatile, the WorkerThread might cache the old false value indefinitely, leading to a deadlock.
The core problem volatile solves is visibility. It guarantees that writes to a volatile variable by one thread are immediately visible to other threads. It does not guarantee atomicity for operations involving multiple steps. For instance, i++ on a volatile int i is still a read-modify-write operation and not atomic.
AtomicInteger is a more powerful tool for dealing with integer counters. It provides atomic operations on an int value, meaning operations like incrementing, decrementing, or setting a value happen as a single, indivisible unit. This is crucial when multiple threads might be trying to update a counter simultaneously.
Consider a scenario where you need to assign unique IDs to objects being created concurrently.
import java.util.concurrent.atomic.AtomicInteger;
class IdGenerator {
private final AtomicInteger nextId = new AtomicInteger(0); // Starts at 0
public int generateNextId() {
return nextId.incrementAndGet(); // Atomically increments and returns the new value
}
}
class Producer implements Runnable {
private IdGenerator idGenerator;
private int id;
public Producer(IdGenerator idGenerator) {
this.idGenerator = idGenerator;
}
@Override
public void run() {
this.id = idGenerator.generateNextId();
System.out.println(Thread.currentThread().getName() + " generated ID: " + this.id);
}
}
public class AtomicIntegerDemo {
public static void main(String[] args) throws InterruptedException {
IdGenerator generator = new IdGenerator();
Thread[] producers = new Thread[10];
for (int i = 0; i < producers.length; i++) {
producers[i] = new Thread(new Producer(generator), "Producer-" + (i + 1));
producers[i].start();
}
for (Thread producer : producers) {
producer.join();
}
System.out.println("All IDs generated.");
}
}
Here, nextId.incrementAndGet() is an atomic operation. If two threads call generateNextId() at nearly the same time, one thread will successfully increment the counter to, say, 5, and the other will increment it to 6. The AtomicInteger uses low-level hardware instructions (like Compare-And-Swap, or CAS) to ensure that this increment-and-get process is indivisible. Without AtomicInteger, if two threads read the value 4, both might increment it to 5 and write 5 back, resulting in a lost increment.
synchronized blocks (or methods) provide the strongest form of thread safety. They guarantee both visibility and atomicity for the code within the synchronized block. When a thread enters a synchronized block, it acquires a lock on the object associated with that block. No other thread can enter a synchronized block that uses the same lock until the first thread exits the block and releases the lock.
Consider a bank account where multiple threads might deposit money.
class BankAccount {
private double balance = 1000.0;
public synchronized void deposit(double amount) {
// This entire block is protected by the 'this' object's lock
System.out.println(Thread.currentThread().getName() + " attempting to deposit " + amount);
double currentBalance = balance; // Read balance
try {
// Simulate some processing time
Thread.sleep(50);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
balance = currentBalance + amount; // Update balance
System.out.println(Thread.currentThread().getName() + " deposited " + amount + ", new balance: " + balance);
}
public double getBalance() {
return balance;
}
}
class Depositor implements Runnable {
private BankAccount account;
private double amount;
public Depositor(BankAccount account, double amount) {
this.account = account;
this.amount = amount;
}
@Override
public void run() {
account.deposit(amount);
}
}
public class SynchronizedDemo {
public static void main(String[] args) throws InterruptedException {
BankAccount account = new BankAccount();
Thread[] depositors = new Thread[5];
for (int i = 0; i < depositors.length; i++) {
depositors[i] = new Thread(new Depositor(account, 100.0), "Depositor-" + (i + 1));
depositors[i].start();
}
for (Thread depositor : depositors) {
depositor.join();
}
System.out.println("Final balance: " + account.getBalance());
}
}
In deposit(), the synchronized keyword ensures that only one thread can execute the code within the deposit method at any given time. This prevents race conditions where one thread reads the balance, another thread deposits money and updates the balance, and then the first thread writes its outdated balance back, effectively losing the second deposit. The synchronized block implicitly handles visibility, ensuring all writes within the synchronized block are visible to threads that later acquire the same lock.
The subtle point that often trips people up with synchronized is that it locks on an object. If you have multiple synchronized methods in a class, and they all lock on this, then only one can execute at a time. However, if you have synchronized methods and synchronized blocks that lock on different objects, they can execute concurrently. For example, if you had a synchronized (otherObject) block, it wouldn’t contend with synchronized void myMethod().
volatile is for simple visibility of a single variable’s state. AtomicInteger is for atomic operations on primitive integer types. synchronized is for protecting blocks of code involving multiple operations or multiple variables, ensuring both atomicity and visibility.
The next challenge you’ll face is understanding how to manage locks more granularly, perhaps by using ReentrantLock for more flexible locking strategies than synchronized.