Popular Searches
Popular Course Categories
Popular Courses

Understanding Synchronization and Race Conditions

Understanding Synchronization and Race Conditions

Multithreading and Concurrency Fundamentals

Understanding Synchronization and Race Conditions in Java

Course: Core Java Training

Topic: Understanding Synchronization and Race Conditions

Course Link: Core Java Training Course

Demo Link: Register for a Free Course Demo

JustAcademy Website: Visit JustAcademy


1. Introduction to Synchronization and Race Conditions

Java supports multithreading, which allows multiple threads to execute concurrently within the same application. Multithreading can improve responsiveness, resource utilization, and application performance. However, when multiple threads access and modify shared data simultaneously, unexpected results may occur.

Synchronization is a mechanism used to control access to shared resources so that critical operations are performed safely. A race condition occurs when the correctness of a program depends on the timing or execution order of multiple threads accessing shared data.

Understanding both concepts is essential when developing multithreaded Java applications such as banking systems, inventory applications, ticket-booking platforms, and concurrent data-processing services.

Learning Objectives

  • Understand synchronization and race conditions in Java.
  • Learn why shared mutable data creates concurrency problems.
  • Understand critical sections and thread interference.
  • Implement synchronized methods and synchronized blocks.
  • Differentiate synchronized instance methods from static synchronized methods.
  • Explore common concurrency problems and debugging techniques.
  • Apply best practices for writing thread-safe Java code.

2. What Is a Thread?

A thread is a lightweight unit of execution within a program. A Java application can create multiple threads to perform tasks concurrently.

Threads within the same process generally share heap memory, including objects and their instance data. Each thread has its own execution stack, which contains method calls and local variables.

Example: Creating Two Threads

class MyTask extends Thread {
    @Override
    public void run() {
        System.out.println(
            Thread.currentThread().getName() + " is running"
        );
    }
}

public class ThreadExample {
    public static void main(String[] args) {
        MyTask t1 = new MyTask();
        MyTask t2 = new MyTask();

        t1.start();
        t2.start();
    }
}

Explanation:

  • The MyTask class extends the Thread class.
  • The run() method contains the task executed by each thread.
  • The start() method starts a new thread and causes its run() method to execute.
  • The output order is not guaranteed because the scheduler controls thread execution.

When threads work with independent data, concurrent execution is usually straightforward. Problems arise when multiple threads modify the same mutable object without suitable coordination.


3. Understanding Shared Resources

A shared resource is a variable, object, file, collection, or other resource that can be accessed by multiple threads.

Examples of Shared Resources

  • A bank account balance accessed by multiple transactions.
  • A ticket counter used by several booking threads.
  • A shared counter updated by background tasks.
  • A common inventory quantity modified by purchase requests.
  • A shared collection modified by multiple threads.

Example: Shared Counter

class Counter {
    int count = 0;

    void increment() {
        count++;
    }
}

If several threads call increment() on the same Counter object, they access the same count variable. The operation count++ is not atomic; it involves reading the value, calculating the incremented value, and writing the result.

When these steps overlap between threads, an update may be lost.


4. What Is a Race Condition?

A race condition is a concurrency problem in which a program produces an incorrect or unpredictable result because multiple threads access shared state and the outcome depends on their execution timing.

How a Race Condition Occurs

Suppose a shared counter starts at zero. Two threads attempt to increment it simultaneously.

Step Thread 1 Thread 2 Counter Value
1 Reads 0 - 0
2 - Reads 0 0
3 Calculates 1 - 0
4 - Calculates 1 0
5 Writes 1 - 1
6 - Writes 1 1

Although two increments were requested, the final value is one instead of two. This is called a lost update.

The table illustrates one possible interleaving. Actual thread scheduling is nondeterministic, so the same program may produce different results on different runs.

Common Causes of Race Conditions

  • Multiple threads modifying shared mutable variables.
  • Performing compound operations without synchronization.
  • Checking a condition and modifying a resource in separate, unprotected steps.
  • Updating shared collections without appropriate thread-safety mechanisms.
  • Using shared application state without a clear concurrency strategy.

5. Race Condition Example in Java

The following program demonstrates a shared counter that is not protected against concurrent access.

class UnsafeCounter {
    private int count = 0;

    public void increment() {
        count++;
    }

    public int getCount() {
        return count;
    }
}

public class RaceConditionDemo {
    public static void main(String[] args)
            throws InterruptedException {

        UnsafeCounter counter = new UnsafeCounter();

        Runnable task = () -> {
            for (int i = 0; i < 100000; i++) {
                counter.increment();
            }
        };

        Thread t1 = new Thread(task);
        Thread t2 = new Thread(task);

        t1.start();
        t2.start();

        t1.join();
        t2.join();

        System.out.println(
            "Final Count: " + counter.getCount()
        );
    }
}

Expected Result

Final Count: 200000

The expected count is 200,000 because each thread performs 100,000 increments. However, the actual result may be lower due to lost updates.

Important: The race condition does not guarantee an incorrect result on every execution. A particular run might produce the expected value by chance.

Code Explanation

  • UnsafeCounter contains a shared mutable integer.
  • Both threads execute the same Runnable task.
  • Each thread increments the same counter 100,000 times.
  • start() begins execution of each thread.
  • join() makes the main thread wait for both threads to finish.
  • The final count is printed after both threads complete.

The use of join() ensures that the result is read after both tasks finish, but it does not make the increments thread-safe.


6. What Is Synchronization in Java?

Synchronization is a mechanism that coordinates access to shared resources among multiple threads. Java provides the synchronized keyword to establish mutual exclusion and memory-visibility guarantees.

When a thread enters a synchronized method or block, it must acquire the associated monitor lock. Another thread attempting to acquire the same lock must wait until the lock becomes available.

Main Purposes of Synchronization

  • Mutual exclusion: Prevents multiple threads from executing protected sections under the same monitor simultaneously.
  • Data consistency: Helps prevent conflicting updates to shared state.
  • Memory visibility: Ensures that changes made before releasing a monitor become visible to a subsequent thread that acquires the same monitor.
  • Coordination: Provides a foundation for safely coordinating operations on shared objects.

Synchronization does not automatically make every operation in an application thread-safe. The relevant shared state must be protected consistently, and all cooperating threads must follow the same locking strategy.


7. Using the synchronized Method

A synchronized instance method acquires the monitor lock associated with the current object, represented by this. Only one thread at a time can execute synchronized instance methods guarded by that same object monitor.

Example: Thread-Safe Counter

class SafeCounter {
    private int count = 0;

    public synchronized void increment() {
        count++;
    }

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

public class SynchronizationDemo {
    public static void main(String[] args)
            throws InterruptedException {

        SafeCounter counter = new SafeCounter();

        Runnable task = () -> {
            for (int i = 0; i < 100000; i++) {
                counter.increment();
            }
        };

        Thread t1 = new Thread(task);
        Thread t2 = new Thread(task);

        t1.start();
        t2.start();

        t1.join();
        t2.join();

        System.out.println(
            "Final Count: " + counter.getCount()
        );
    }
}

Output

Final Count: 200000

How It Works

  1. The SafeCounter object is shared between both threads.
  2. Each thread calls the synchronized increment() method.
  3. A thread must acquire the counter object's monitor before entering the method.
  4. Other threads attempting to enter synchronized methods protected by that monitor must wait.
  5. Each increment completes while holding the lock.
  6. After both threads finish, the final count is 200,000.

The synchronized getter also ensures that reading the counter uses the same monitor. Because the main thread reads after both joins, the read is already ordered after the worker threads' completed actions; synchronizing the getter is nevertheless useful when other threads may read the counter concurrently during updates.


8. Using a synchronized Block

A synchronized block allows a developer to protect only a specific section of code rather than an entire method. This can reduce the time a thread holds a lock.

Syntax

synchronized (lockObject) {
    // Critical section
}

Example: Synchronized Block

class BlockCounter {
    private int count = 0;
    private final Object lock = new Object();

    public void increment() {
        synchronized (lock) {
            count++;
        }
    }

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

Explanation

  • The lock object is used as a monitor.
  • Only one thread at a time can execute a block guarded by this same lock.
  • The increment operation is protected from concurrent updates.
  • The getter acquires the same lock to read the value consistently while updates are taking place.
  • Other code outside these synchronized blocks can execute without acquiring this lock.

The lock should be private and final when practical. This prevents external code from replacing the lock reference or unexpectedly acquiring the same monitor.


9. Synchronized Method vs. Synchronized Block

Feature Synchronized Method Synchronized Block
Protected area The entire method body Only the specified block
Lock selection Instance monitor or class monitor for static methods Explicitly specified monitor expression
Lock duration Generally held throughout the method execution Held while the block executes
Control Simple and concise Allows more precise control
Use case When the whole method must be protected When only a critical section needs protection
Performance May hold the lock longer than necessary Can reduce lock contention if the protected region is smaller

Neither form is automatically faster in every situation. Choose the simplest locking strategy that correctly protects the shared state.


10. Instance-Level and Class-Level Synchronization

Instance-Level Synchronization

A synchronized instance method locks the current object. Different instances have different monitors, so threads using different objects can execute their synchronized instance methods simultaneously.

class Example {
    public synchronized void display() {
        System.out.println("Instance method");
    }
}

Class-Level Synchronization

A static synchronized method locks the monitor associated with the class's Class object.

class Example {
    public static synchronized void show() {
        System.out.println("Static synchronized method");
    }
}

For the class Example, the static method uses the monitor represented by Example.class.

Comparison

Aspect Instance Synchronization Static Synchronization
Lock object Current instance, this Class object, such as Example.class
Scope Coordinates threads using the same instance monitor Coordinates threads using the same class monitor
Typical purpose Protect instance fields Protect shared static state
Different locks Different instances use different monitors All calls for the same class use the same class monitor

An instance synchronized method and a static synchronized method on the same class use different monitors. They do not automatically block one another.


11. Understanding Critical Sections

A critical section is a portion of code that accesses shared mutable state and must be executed under appropriate coordination to preserve correctness.

Example

public synchronized void withdraw(double amount) {
    if (balance >= amount) {
        balance -= amount;
    }
}

In this example, checking the balance and deducting the amount are performed under one monitor. If the check and update were not protected together, two concurrent withdrawals could both pass the balance check and cause an invalid result.

Characteristics of a Good Critical Section

  • Protects the complete operation that must remain consistent.
  • Holds the lock for the shortest practical duration.
  • Avoids unnecessary file operations, network calls, or long-running computations while holding a lock.
  • Uses a clearly defined lock object.
  • Does not expose partially updated shared state.

12. Race Conditions vs. Synchronization

Aspect Race Condition Synchronization
Meaning A concurrency bug caused by unsafe interactions between threads A mechanism for coordinating access to shared resources
Effect May produce inconsistent or incorrect results Can prevent conflicting access to protected state
Nature A problem to identify and fix A technique for controlling concurrent execution
Example Two threads increment the same integer without protection Using a synchronized method for the increment
Performance May appear fast but is unreliable Can introduce lock contention and waiting

Synchronization is one way to prevent race conditions. Other approaches include atomic classes, locks, immutable objects, thread confinement, and suitable concurrent collections.


13. Synchronization and Memory Visibility

Synchronization provides more than mutual exclusion. It also establishes a memory-visibility relationship between threads that use the same monitor.

When one thread releases a monitor, its actions before releasing that monitor happen-before a subsequent successful acquisition of the same monitor by another thread.

Example

class SharedData {
    private int value = 0;

    public synchronized void setValue(int value) {
        this.value = value;
    }

    public synchronized int getValue() {
        return value;
    }
}

Both methods use the same object's monitor. If one thread calls setValue() and another subsequently acquires that monitor through getValue(), the synchronization establishes visibility of the preceding update.

Note: A plain shared variable accessed without synchronization, appropriate atomic operations, or another valid coordination mechanism may suffer from visibility problems as well as race conditions.


14. Using AtomicInteger as an Alternative

For simple numeric counters, Java provides AtomicInteger in the java.util.concurrent.atomic package. Its atomic operations can safely update a shared integer without requiring a synchronized method for each increment.

Example

import java.util.concurrent.atomic.AtomicInteger;

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

    public void increment() {
        count.incrementAndGet();
    }

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

public class AtomicCounterDemo {
    public static void main(String[] args)
            throws InterruptedException {

        AtomicCounter counter = new AtomicCounter();

        Runnable task = () -> {
            for (int i = 0; i < 100000; i++) {
                counter.increment();
            }
        };

        Thread t1 = new Thread(task);
        Thread t2 = new Thread(task);

        t1.start();
        t2.start();

        t1.join();
        t2.join();

        System.out.println(counter.getCount());
    }
}

Output

200000

AtomicInteger is useful for independent atomic counter operations. It does not automatically make a multi-step business operation atomic. If correctness requires several reads and updates to occur as one indivisible transaction, use a suitable locking strategy or another appropriate design.


15. Common Problems Related to Synchronization

15.1 Deadlock

A deadlock occurs when two or more threads wait indefinitely for locks held by one another.

Thread 1: Holds Lock A and waits for Lock B
Thread 2: Holds Lock B and waits for Lock A

Prevention: Acquire multiple locks in a consistent global order, avoid unnecessary nested locking, and consider explicit locks with timeouts where appropriate.

15.2 Starvation

Starvation occurs when a thread repeatedly fails to obtain the CPU time or resources it needs because other threads continue to receive priority or access.

Prevention: Avoid unnecessarily long critical sections and unfair resource-acquisition patterns. Review scheduling and lock fairness requirements.

15.3 Livelock

A livelock occurs when threads remain active and repeatedly respond to each other but fail to make meaningful progress.

Prevention: Use retry limits, randomized backoff, and better coordination policies.

15.4 Excessive Lock Contention

Lock contention occurs when many threads compete for the same lock, causing waiting and reducing throughput.

Prevention: Keep critical sections short, reduce shared mutable state, and consider atomic variables or suitable concurrent data structures.


16. Common Mistakes to Avoid

  • Synchronizing different objects: Two synchronized methods do not coordinate with one another if they use different monitors.
  • Protecting only part of an operation: A check and its corresponding update may need to be protected as a single critical section.
  • Assuming increment is atomic: The expression count++ is not an atomic operation on an ordinary integer.
  • Assuming join fixes race conditions: join() waits for thread completion but does not make unsafe updates safe.
  • Using synchronized inconsistently: A lock protects shared state only when relevant access paths follow the same coordination strategy.
  • Holding locks for too long: Long-running work inside a critical section can reduce concurrency.
  • Assuming volatile makes compound operations atomic: volatile provides visibility and ordering guarantees, but does not make operations such as count++ atomic.
  • Ignoring deadlock risk: Acquiring locks in inconsistent orders can cause threads to wait indefinitely.

17. Best Practices for Thread-Safe Java Programming

  1. Identify all shared mutable data in the application.
  2. Prefer immutable objects when data does not need to change.
  3. Use local variables and thread confinement when shared state is unnecessary.
  4. Choose one consistent synchronization strategy for each shared resource.
  5. Protect complete logical operations, not just individual statements.
  6. Keep synchronized sections short and avoid blocking external calls while holding locks.
  7. Use AtomicInteger, AtomicLong, or other atomic classes for suitable individual atomic operations.
  8. Use concurrent collections, such as ConcurrentHashMap, when their behavior matches the application's requirements.
  9. Follow a consistent lock-acquisition order to reduce deadlock risk.
  10. Use thread-safe testing strategies and repeat tests under varied execution conditions.
  11. Use tools such as thread dumps, profilers, and concurrency-focused tests to investigate problems.
  12. Document the locking rules for complex shared objects.

18. Practical Example: Bank Account Synchronization

Consider a bank account that is shared by multiple transaction threads. The account balance must not be updated in a way that loses changes or allows invalid withdrawals.

class BankAccount {
    private double balance;

    public BankAccount(double balance) {
        this.balance = balance;
    }

    public synchronized void deposit(double amount) {
        if (amount > 0) {
            balance += amount;
        }
    }

    public synchronized boolean withdraw(double amount) {
        if (amount > 0 && balance >= amount) {
            balance -= amount;
            return true;
        }
        return false;
    }

    public synchronized double getBalance() {
        return balance;
    }
}

public class BankDemo {
    public static void main(String[] args)
            throws InterruptedException {

        BankAccount account = new BankAccount(1000);

        Thread t1 = new Thread(() -> {
            account.deposit(500);
        });

        Thread t2 = new Thread(() -> {
            account.withdraw(300);
        });

        t1.start();
        t2.start();

        t1.join();
        t2.join();

        System.out.println(
            "Final Balance: " + account.getBalance()
        );
    }
}

Possible Output

Final Balance: 1200.0

Explanation

  • The initial balance is 1,000.
  • One thread deposits 500.
  • Another thread withdraws 300 if sufficient funds are available.
  • All three methods use the same account object's monitor.
  • The deposit and withdrawal execute under mutual exclusion, so their updates are not lost.
  • The final balance is 1,200 regardless of which operation acquires the lock first, assuming both operations complete as shown.

Real-world note: Financial systems need additional protections, such as precise monetary representations, transaction boundaries, persistence, validation, and failure handling. Synchronization on a Java object alone does not provide database-level transaction guarantees across multiple application instances.


19. How to Debug Race Conditions

Race conditions can be difficult to reproduce because they depend on thread scheduling and timing. A program may work correctly during one test and fail under heavier load.

Debugging Techniques

  • Review all locations where shared mutable variables are read and written.
  • Check whether compound operations are protected by a common lock or atomic operation.
  • Run repeated tests with a high number of threads and iterations.
  • Use logging carefully because logging can alter thread timing and hide a concurrency bug.
  • Inspect thread dumps when the application appears stuck.
  • Use profiling and concurrency-testing tools to identify contention and unsafe access.
  • Verify that all reads and writes follow the intended synchronization policy.
  • Test boundary conditions, such as simultaneous withdrawals or updates to the same record.

Testing Reminder

A single successful run is not proof that a multithreaded program is thread-safe. Correctness should follow from the synchronization design and the guarantees of the APIs used.


20. Hands-On Practice Exercises

Exercise 1: Shared Counter

Create a counter class and run two threads that increment it 50,000 times each.

  • First implement the counter without synchronization.
  • Observe whether the result differs from 100,000.
  • Then use a synchronized method and compare the results.

Exercise 2: Synchronized Block

Modify the counter to use a private lock object and a synchronized block. Ensure that both incrementing and reading use the same lock when concurrent access is possible.

Exercise 3: Bank Account

Build a bank account with deposit and withdrawal methods. Run multiple threads and verify that the final balance follows the expected transaction rules.

Exercise 4: AtomicInteger

Replace the integer counter with an AtomicInteger. Compare the implementation with the synchronized counter and explain when each approach is appropriate.

Exercise 5: Deadlock Investigation

Create two objects and two threads that attempt to acquire their locks in opposite orders. Observe the deadlock risk, then modify the code to acquire locks consistently.


21. Interview Questions and Answers

Q1. What is synchronization in Java?

Answer: Synchronization coordinates access to shared resources. Java's synchronized keyword uses monitor locks to provide mutual exclusion and memory-visibility guarantees.

Q2. What is a race condition?

Answer: A race condition occurs when the result of concurrent operations depends on their timing or execution order, potentially causing incorrect behavior.

Q3. Why is count++ not thread-safe?

Answer: For an ordinary integer, count++ consists of a read, an increment calculation, and a write. Concurrent executions can overlap and lose updates.

Q4. What is the difference between synchronized methods and synchronized blocks?

Answer: A synchronized method protects its whole method body using the relevant object or class monitor. A synchronized block protects a specific section using the monitor specified in the block.

Q5. Can two threads execute a synchronized method simultaneously?

Answer: They cannot simultaneously execute synchronized code guarded by the same monitor. They may execute synchronized methods on different objects because those objects have different monitors.

Q6. What is the difference between instance and static synchronization?

Answer: Instance synchronization uses the current object's monitor, while static synchronization uses the corresponding class object's monitor.

Q7. Does synchronization prevent deadlocks?

Answer: No. Synchronization can protect shared state, but acquiring multiple locks in an inconsistent order can cause deadlocks.

Q8. What is the purpose of join()?

Answer: The join() method makes the calling thread wait until another thread terminates. It does not independently make shared updates thread-safe.

Q9. What is the difference between synchronized and volatile?

Answer: Synchronization provides mutual exclusion and memory-visibility guarantees. volatile provides visibility and ordering guarantees for a variable but does not provide mutual exclusion or make compound operations atomic.

Q10. What is an AtomicInteger?

Answer: AtomicInteger is a class that provides atomic operations on an integer value. It is useful for counters and other suitable numeric updates.

Q11. Can synchronized static and instance methods block one another?

Answer: Not merely because they belong to the same class. Static synchronized methods use the class monitor, while instance synchronized methods use an instance monitor.

Q12. How can race conditions be prevented?

Answer: Use appropriate synchronization, atomic classes, immutable data, thread confinement, concurrent collections, or other concurrency mechanisms suited to the operation.


22. Quick Revision Checklist

  • A thread is an independent execution path within a program.
  • Shared mutable state can create concurrency problems.
  • A race condition occurs when correctness depends on thread timing.
  • Compound operations such as count++ are not atomic on ordinary integers.
  • The synchronized keyword uses a monitor lock.
  • Instance synchronized methods use the current object's monitor.
  • Static synchronized methods use the class object's monitor.
  • Synchronized blocks allow specific critical sections to be protected.
  • Synchronization provides mutual exclusion and memory visibility.
  • join() waits for thread completion but does not fix unsafe updates.
  • AtomicInteger supports atomic counter operations.
  • Deadlock, starvation, and lock contention are important concurrency concerns.
  • Thread-safe design must protect every relevant access path consistently.

23. Conclusion

Synchronization and race conditions are fundamental concepts in Java multithreading. When several threads access and modify shared state, uncoordinated operations can produce lost updates, inconsistent values, and unpredictable application behavior.

Java provides synchronized methods, synchronized blocks, monitor locks, and concurrency utilities such as AtomicInteger to help developers create thread-safe applications. Selecting the correct approach requires understanding the shared resource, the critical section, and the guarantees needed by the application.

By practicing with counters, bank accounts, shared collections, and concurrent tasks, developers can learn to identify race conditions, prevent unsafe updates, and build more reliable multithreaded Java applications.


Learn Core Java with JustAcademy

Strengthen your Java programming and multithreading knowledge with the following resources:

Previous Next
whatsapp