Synchronization Mechanisms

Thread Safety and Preventing Race Conditions

← Back to Index

What is Synchronization?

Imagine a shared bathroom in a house with multiple people. If two people try to use it at the same time, chaos! The solution? A lock on the door. Only one person can use it at a time. Synchronization works the same way in Java - it controls access to shared resources so only one thread can use them at a time.

Real-World Analogy: ATM Machine

You're at an ATM checking your balance ($1000). While you're deciding how much to withdraw, your spouse at another ATM also sees $1000 and starts withdrawing. You both withdraw $600...

Without synchronization: Both see $1000, both withdraw $600 → account becomes -$200 (overdraft!)

With synchronization: One person completes their transaction first (balance → $400), then the other sees $400 and gets "Insufficient funds"

The Problem: Race Condition

A race condition occurs when multiple threads try to modify shared data simultaneously, leading to unpredictable results.

❌ Problem - Race Condition Example
class Counter {
    private int count = 0;

    public void increment() {
        count++;  // Looks simple, but NOT atomic!
    }
}

// What count++ actually does (3 steps):
1. Read current value of count
2. Add 1 to it
3. Write new value back to count

// Race condition scenario:
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();
t1.join();
t2.join();

System.out.println(counter.count);
// Expected: 2000
// Actual: 1847, 1923, 1956... (varies every run!)

// WHY? Threads interfere with each other:
// T1 reads: 100 | T2 reads: 100
// T1 adds 1: 101 | T2 adds 1: 101
// T1 writes: 101 | T2 writes: 101
// Result: 101 instead of 102! One increment lost!

Solution 1: Synchronized Methods

The synchronized keyword ensures only ONE thread can execute the method at a time.

✅ Solution - Thread-Safe with Synchronized
class SafeCounter {
    private int count = 0;

    public synchronized void increment() {
        count++;  // Now thread-safe!
    }

    public synchronized int getCount() {
        return count;
    }
}

// Now it always returns 2000!

How Synchronized Works

Every object in Java has an invisible LOCK (monitor)

┌─────────────────┐
│  Counter Object │  ← Has a lock
│  count = 0      │
│  🔒 LOCK        │
└─────────────────┘

Step-by-step execution:
1. Thread A calls increment() → Tries to acquire lock
2. Lock is available → Thread A gets it 🔒✅
3. Thread A executes count++ safely
4. Thread B calls increment() → Tries to acquire lock
5. Lock is HELD by A → Thread B waits ⏳
6. Thread A finishes → Releases lock 🔓
7. Thread B acquires lock 🔒✅ → Executes count++

Complete Bank Account Example

class BankAccount {
    private int balance = 1000;

    public synchronized void withdraw(int amount) {
        String thread = Thread.currentThread().getName();
        System.out.println(thread + ": Checking balance...");

        if (balance >= amount) {
            System.out.println(thread + ": Sufficient funds");
            try { Thread.sleep(100); } catch (InterruptedException e) {}
            balance -= amount;
            System.out.println(thread + ": Withdrew $" + amount);
            System.out.println(thread + ": New balance: $" + balance);
        } else {
            System.out.println(thread + ": Insufficient funds!");
        }
    }

    public synchronized void deposit(int amount) {
        balance += amount;
        System.out.println("Deposited $" + amount + ", Balance: $" + balance);
    }
}

public class BankTest {
    public static void main(String[] args) throws InterruptedException {
        BankAccount account = new BankAccount();

        Thread person1 = new Thread(() -> account.withdraw(600), "Person1");
        Thread person2 = new Thread(() -> account.withdraw(600), "Person2");

        person1.start();
        person2.start();
        person1.join();
        person2.join();
    }
}

Output (synchronized - safe):

Person1: Checking balance...
Person1: Sufficient funds
Person1: Withdrew $600
Person1: New balance: $400
Person2: Checking balance...
Person2: Insufficient funds!

Solution 2: Synchronized Blocks

Sometimes you only need to synchronize part of a method, not the whole thing. Use synchronized blocks for better performance.

Syntax

public void myMethod() {
    // This code can run concurrently
    doSomeWork();

    synchronized(this) {
        // Only THIS part is synchronized
        criticalSection();
    }

    // This code can run concurrently
    doMoreWork();
}

Example: Synchronized Block

class Logger {
    private List<String> logs = new ArrayList<>();

    public void log(String message) {
        // Formatting doesn't need synchronization
        String formatted = LocalDateTime.now() + ": " + message;

        // Only synchronize the critical part
        synchronized(this) {
            logs.add(formatted);  // Shared resource - needs lock
        }
    }
}

Synchronizing on Different Objects

class BankAccount {
    private int balance = 1000;
    private final Object balanceLock = new Object();

    private List<String> transactions = new ArrayList<>();
    private final Object transactionLock = new Object();

    public void withdraw(int amount) {
        synchronized(balanceLock) {
            balance -= amount;
        }
    }

    public void addTransaction(String transaction) {
        synchronized(transactionLock) {
            transactions.add(transaction);
        }
    }
}

// Benefits: withdraw() and addTransaction() can run concurrently!
// They use different locks

Solution 3: Volatile Keyword

Use volatile for simple flags that are read/written by multiple threads.

The Problem: Caching

class TaskRunner {
    private boolean running = true;  // Problem: might be cached!

    public void run() {
        while (running) {  // Thread might cache this value
            doWork();
        }
    }

    public void stop() {
        running = false;  // Other thread might not see this change!
    }
}

The Solution: Volatile

class TaskRunner {
    private volatile boolean running = true;  // Always read from main memory

    public void run() {
        while (running) {  // Always sees latest value
            doWork();
        }
    }

    public void stop() {
        running = false;  // Immediately visible to all threads
    }
}

// volatile ensures:
✅ Changes are immediately visible to all threads
✅ No caching in thread-local memory
❌ Does NOT provide atomicity (use synchronized for that)

When to Use Volatile

Solution 4: Atomic Classes

Java provides atomic classes for lock-free thread-safe operations.

AtomicInteger Example

import java.util.concurrent.atomic.*;

class AtomicCounter {
    private AtomicInteger count = new AtomicInteger(0);

    public void increment() {
        count.incrementAndGet();  // Atomic operation - thread-safe!
    }

    public int getCount() {
        return count.get();
    }
}

// No need for synchronized - AtomicInteger handles it!

Common Atomic Classes

AtomicInteger atomicInt = new AtomicInteger(0);
atomicInt.incrementAndGet();  // ++count
atomicInt.decrementAndGet();  // --count
atomicInt.addAndGet(5);     // count += 5
atomicInt.compareAndSet(10, 20);  // if(count==10) count=20

AtomicLong atomicLong = new AtomicLong(0L);
AtomicBoolean atomicBool = new AtomicBoolean(false);
AtomicReference<String> atomicRef = new AtomicReference<>("initial");

Solution 5: ReentrantLock - Advanced Locking

More flexible than synchronized, with tryLock(), timed locks, and interruptible locks.

import java.util.concurrent.locks.*;

class BankAccountWithLock {
    private int balance = 1000;
    private final Lock lock = new ReentrantLock();

    public void withdraw(int amount) {
        lock.lock();  // Acquire lock
        try {
            if (balance >= amount) {
                balance -= amount;
                System.out.println("Withdrew $" + amount);
            }
        } finally {
            lock.unlock();  // ALWAYS unlock in finally!
        }
    }

    public boolean tryWithdraw(int amount) {
        if (lock.tryLock()) {  // Try to get lock, don't wait
            try {
                if (balance >= amount) {
                    balance -= amount;
                    return true;
                }
            } finally {
                lock.unlock();
            }
        }
        return false;  // Couldn't get lock
    }
}

ReentrantLock Features

Comparison: Synchronized vs Lock vs Atomic

Feature synchronized ReentrantLock Atomic Classes
Ease of Use Very easy More complex Very easy
Performance Good Good Best (lock-free)
Try Lock ❌ No ✅ Yes N/A
Timeout ❌ No ✅ Yes N/A
Fairness No guarantee ✅ Optional N/A
Use Case General purpose Advanced control Single variables

Common Pitfalls & Best Practices

❌ Deadlock Example

Object lock1 = new Object();
Object lock2 = new Object();

// Thread 1
synchronized(lock1) {
    Thread.sleep(100);
    synchronized(lock2) {  // Waits for lock2
        doWork();
    }
}

// Thread 2
synchronized(lock2) {
    Thread.sleep(100);
    synchronized(lock1) {  // Waits for lock1
        doWork();
    }
}

// DEADLOCK! Both threads wait forever

✅ Avoiding Deadlock

// Always acquire locks in same order!
synchronized(lock1) {
    synchronized(lock2) {
        doWork();
    }
}
Best Practices
  • ✅ Keep synchronized blocks small - minimize lock time
  • ✅ Use specific locks for specific resources
  • ✅ Always acquire locks in same order (prevent deadlock)
  • ✅ Use concurrent collections (ConcurrentHashMap, CopyOnWriteArrayList)
  • ✅ Prefer atomic classes for simple counters/flags
  • ✅ Use volatile for simple shared flags
  • ✅ Document synchronization strategy in comments
  • ❌ Don't synchronize on public objects (use private locks)
  • ❌ Don't synchronize on String literals (they're interned!)
  • ❌ Don't call unknown methods inside synchronized blocks

Why It Matters

Remember

Synchronization is like traffic lights - it prevents collisions but adds overhead. Use it when needed, but don't over-synchronize!