Home Resource Centre Thread Synchronization In Java | Syntax, Uses, & More(+Examples)

Thread Synchronization In Java | Syntax, Uses, & More(+Examples)

Thread synchronization in Java is a way to control the access of multiple threads to shared resources, ensuring that only one thread can access a resource at a time. This prevents issues like data corruption that can occur when two or more threads try to modify the same data simultaneously.

In this article, we will define thread synchronization and explain why it’s important. We will then look at the different synchronization methods in Java, including the synchronized keyword and locks, and discuss how to use them effectively to write safe and efficient multithreaded code.

What Is Thread Synchronization In Java?

Thread synchronization in Java programming is a mechanism to control access to shared resources when multiple threads are executing concurrently. It ensures that only one thread can access a critical section (a block of code or shared resource) at a time, preventing data inconsistency and race conditions.

In Java, synchronization is primarily achieved using the synchronized keyword, which can be applied to methods or blocks of code. 

Syntax Of Thread Synchronization In Java

synchronized(object) {
    // Critical section: code that needs synchronization
}

Here: 

  • synchronized: This Java keyword marks the block or method as synchronized, allowing only one thread at a time to execute it.
  • object: Acts as a lock. Threads must acquire the lock on this object before entering the synchronized block.
  • Critical section: The part of the code that is executed under synchronization, ensuring thread-safe access to shared resources.

The Need For Thread Synchronization In Java

Thread synchronization is essential in Java for the following reasons:

  1. Prevent Data Inconsistency: When multiple threads access shared resources (e.g., variables, objects) simultaneously, their actions may lead to inconsistent or corrupted data. Synchronization ensures data integrity.
  2. Avoid Race Conditions: Race conditions occur when two or more threads try to update shared data at the same time, leading to unpredictable outcomes. Synchronization prevents this by allowing only one thread to access the critical section at a time.
  3. Ensure Thread Safety: In a multi-threaded environment, synchronized code ensures that shared resources are used safely, avoiding unexpected behavior or crashes.
  4. Maintain Resource Integrity: Shared resources, such as files, databases, or memory, must be accessed in a controlled way to prevent data corruption or incomplete transactions.
  5. Facilitate Proper Communication Between Threads: Synchronization helps ensure proper communication and coordination between threads, avoiding deadlocks or livelocks in concurrent applications.
  6. Enable Consistent Output: It ensures predictable and correct behavior by controlling the sequence of thread execution, especially when shared resources are involved.
  7. Support for Critical Operations: Operations like banking transactions, booking systems, or inventory management systems rely on synchronization to ensure accurate and reliable outcomes.

Real-World Analogy

Think of multiple people trying to withdraw money from a single bank account simultaneously. Without synchronization, they could withdraw more money than available, leading to inconsistencies. Synchronization acts like a queue system, allowing one person at a time to access the account safely.

Types Of Thread Synchronization In Java

There are primarily two types of thread synchronization in Java: 

  1. Mutual Exclusion: This type ensures that only one thread accesses a critical section (shared resource) at any given time. It prevents race conditions and ensures data consistency.
  2. Coordination Synchronization (Thread Communication): This type ensures that threads coordinate their execution through signaling mechanisms to achieve the desired execution order.

Explore this amazing course and master all the key concepts of Java programming effortlessly!

Mutual Exclusion In Thread Synchronization In Java

Mutual Exclusion is a core concept in thread synchronization that ensures only one thread can access a critical section (shared resource or code block) at a time. It prevents race conditions and ensures data consistency when multiple threads attempt to modify or read shared data simultaneously.

In Java, mutual exclusion is primarily achieved using the synchronized keyword. When a thread enters a synchronized method or block, it acquires a lock on the object, ensuring no other thread can access synchronized methods or blocks of the same object until the lock is released.

  • Synchronized Methods: Locks the entire method so only one thread can execute it at a time. For Example-

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

  • Synchronized Blocks: Locks only a specific block of code, offering better performance by limiting the scope of synchronization. For Example-

public void increment() {
    synchronized (this) {
        counter++;
    }
}

Some of the key characteristics of mutual exclusion are:

  1. Single Access: Only one thread can execute a critical section at any given time.
  2. Thread Blocking: Other threads attempting to access the same synchronized code must wait until the lock is released.
  3. Data Safety: Ensures consistency of shared resources by serializing access.

Code Example: 

Output: 

Thread-1 has acquired the lock.
Thread-1: 1
Thread-1: 2
Thread-1: 3
Thread-1: 4
Thread-1: 5
Thread-1 has released the lock.
Thread-2 has acquired the lock.
Thread-2: 1
Thread-2: 2
Thread-2: 3
Thread-2: 4
Thread-2: 5
Thread-2 has released the lock.

Explanation: 

In the above code example-

  1. We have a SharedResource class with a synchronized method printNumbers(), ensuring that only one thread can execute it at a time. The lock is tied to the SharedResource object.
  2. In printNumbers(), we print a message when a thread acquires the lock. This helps us see when a thread starts using the resource.
  3. Inside a loop, we print numbers from 1 to 5, tagging each with the thread's name. This shows the thread's progress as it works with the resource.
  4. We simulate some work by making the thread sleep for 500 milliseconds between iterations. If the sleep gets interrupted, we handle it by printing the exception.
  5. When the loop ends, we print a message indicating that the thread has released the lock.
  6. Next, we define a MyThread class that extends Thread. Each thread has its own name and a reference to the shared resource it will use.
  7. The MyThread constructor accepts a SharedResource object and the thread's name, initializing these properties for later use.
  8. In the run() method of MyThread, we call the printNumbers() method of the shared resource, passing the thread's name to identify it.
  9. In the Main class, we create a single SharedResource instance, which will be shared between multiple threads.
  10. We create two threads, t1 and t2, both referencing the same shared resource and having distinct names, "Thread-1" and "Thread-2."
  11. We start both threads using start() method. The synchronized nature of printNumbers ensures that even though both threads attempt to run concurrently, only one will execute the method at a time, maintaining thread safety.

Coordination Synchronization (Thread Communication) In Java

Coordination Synchronization, also known as Thread Communication, is a mechanism in Java where threads cooperate and communicate with each other to achieve a common goal. It allows one thread to notify another thread about a specific condition or event, ensuring proper execution flow between threads.

Java provides methods like wait(), notify(), and notifyAll() in the Object class to enable thread communication. These methods allow a thread to pause execution and release the lock until another thread signals it to resume, facilitating smooth coordination.

Methods used:

  • wait(): Causes the current thread to wait until another thread invokes notify() or notifyAll() on the same object. Releases the lock held by the thread.
  • notify(): Wakes up a single thread that is waiting on the object's monitor. The thread resumes once it reacquires the lock.
  • notifyAll(): Wakes up all threads waiting on the object's monitor. Only one thread will acquire the lock at a time. 
  • Shared Monitor: Coordination happens on an object's monitor, requiring synchronization for thread communication.

Code Example: 

Output: 

Producer: Produced an item.
Consumer: Consumed an item.
Producer: Produced an item.
Consumer: Consumed an item.
Producer: Produced an item.
Consumer: Consumed an item.
Producer: Produced an item.
Consumer: Consumed an item.
Producer: Produced an item.
Consumer: Consumed an item.

Explanation:

In the above code example-

  1. We have a SharedResource class representing a shared object that facilitates coordination between a producer and a consumer using the produce() and consume() methods.
  2. A flag variable acts as a signal: false means no item has been produced yet, and true means an item is ready to be consumed.
  3. In the produce() method, we use a synchronized block to ensure thread safety. If flag is true, the producer waits because the consumer hasn't consumed the previous item yet. We achieve this using the wait() method.
  4. Once the producer can proceed, it prints a message indicating that it has produced an item, sets flag to true, and calls notify() to wake up the consumer.
  5. The consume() method is also synchronized. If flag is false, the consumer waits because there is no item to consume. The wait() method is used here as well.
  6. When the consumer can proceed, it prints a message indicating that it has consumed an item, sets flag to false, and calls notify() to wake up the producer.
  7. Next, we define a Producer class extending Thread. Its constructor accepts the shared resource object, allowing it to produce items by calling the produce() method in its run() method.
  8. Similarly, we define a Consumer class extending Thread. Its constructor also takes the shared resource object, allowing it to consume items by calling the consume() method in its run() method.
  9. Both the producer and consumer repeat their respective tasks 5 times using a loop.
  10. In the Main class, we create a single instance of SharedResource to be shared by the producer and consumer threads.
  11. We create and start a Producer thread and a Consumer thread. The produce and consume methods work together using wait() and notify() to ensure proper synchronization, preventing race conditions and maintaining a consistent flow of production and consumption.

Sharpen your coding skills with Unstop's 100-Day Coding Sprint and compete now for a top spot on the leaderboard!

Advantages Of Thread Synchronization In Java

Some of the common advantages of thread synchronization in Java are as follows:

  1. Prevents Data Inconsistency: Ensures that shared resources are accessed by only one thread at a time, preventing race conditions and maintaining data consistency.
  2. Maintains Thread Safety: Synchronization guarantees safe execution of critical sections, making it essential for multi-threaded programs.
  3. Facilitates Coordination: Helps threads coordinate with each other, ensuring proper sequence and execution logic in complex applications.
  4. Avoids Unexpected Behavior: Prevents issues like corrupted data or partial updates, ensuring predictable outcomes.
  5. Enables Scalability: Properly synchronized programs can efficiently handle multiple threads, making them more scalable for concurrent environments.
  6. Critical for Shared Resources: Ensures integrity of files, databases, or memory when accessed by multiple threads, particularly in transactional systems.

Disadvantages Of Thread Synchronization In Java

Some of the potential disadvantages of thread synchronization in Java are as follows:

  1. Reduced Performance: Synchronization introduces overhead because threads must acquire locks and wait for access, slowing down execution.
  2. Thread Contention: Multiple threads competing for the same lock can lead to bottlenecks, particularly in high-concurrency scenarios.
  3. Increased Complexity: Writing synchronized code is complex and error-prone, requiring careful design to avoid bugs like deadlocks or livelocks.
  4. Deadlocks: Poorly designed synchronization mechanisms can cause deadlocks, where two or more threads are waiting indefinitely for each other’s locks.
  5. Potential Resource Wastage: If threads spend too much time waiting for locks, it can lead to inefficient utilization of CPU and other system resources.
  6. Debugging Challenges: Issues like race conditions, deadlocks, or contention are often difficult to identify and debug in synchronized applications.

Are you looking for someone to answer all your programming-related queries? Let's find the perfect mentor here.

Alternatives To Synchronization In Java

While synchronization is a popular method to handle thread safety and coordination, there are alternative techniques in Java that can provide more efficient or flexible solutions, depending on the use case.

1. Locks (ReentrantLock)

  • The ReentrantLock class, part of java.util.concurrent.locks, provides more control over thread synchronization compared to the synchronized keyword.
  • Advantages:
    1. Explicit lock acquisition and release.
    2. Ability to try locking (tryLock()), interrupt waiting threads, or set lock timeouts.
    3. Multiple condition variables can be used. For Example-

ReentrantLock lock = new ReentrantLock();
lock.lock();
try {
    // Critical section
} finally {
    lock.unlock();
}

2. Atomic Variables

  • Atomic classes such as AtomicInteger, AtomicLong, AtomicReference, etc., from the java.util.concurrent.atomic package provide a way to perform atomic operations without synchronization.
  • Advantages:
    1. Provides lock-free thread-safe operations for simple variables.
    2. Better performance in scenarios with low contention. For Example-

AtomicInteger count = new AtomicInteger(0);
count.incrementAndGet(); // Atomic operation

3. Executor Service

  • The ExecutorService interface provides a high-level replacement for managing and controlling thread execution, abstracting away much of the synchronization complexity.
  • Advantages:
    1. Simplifies the handling of threads and tasks.
    2. Provides built-in methods for managing thread pools, such as submit(), invokeAll(), and invokeAny().  For Example-

ExecutorService executor = Executors.newFixedThreadPool(2);
executor.submit(() -> {
    // Task to be executed
});
executor.shutdown();

4. Semaphores

  • A Semaphore is a counting semaphore used for managing a fixed number of resources and controlling access to them.
  • Advantages:
    1. Useful for limiting access to a resource pool, preventing over-subscription.
    2. Can allow multiple threads to acquire resources concurrently, unlike traditional locks. For Example-

Semaphore semaphore = new Semaphore(2); // Allow 2 threads
semaphore.acquire();
try {
    // Critical section
} finally {
    semaphore.release();
}

5. Read-Write Locks (ReadWriteLock)

  • The ReadWriteLock interface provides a more sophisticated mechanism for managing thread synchronization when you have multiple threads performing read and write operations.
  • Advantages:
    1. Allows multiple threads to read concurrently while ensuring exclusive access for writes.
    2. More efficient in cases where reads are far more frequent than writes. For Example-

ReadWriteLock lock = new ReentrantReadWriteLock();
Lock readLock = lock.readLock();
Lock writeLock = lock.writeLock();

6. Using volatile for Shared Variables

  • The volatile keyword is a simple and lightweight alternative for synchronizing access to a shared variable. It ensures that changes made to a variable by one thread are immediately visible to other threads, preventing issues like caching or thread-local copies. For Example-

private volatile boolean flag = false;
public void stopThread() {
    flag = true;
}
public void checkFlag() {
    if (flag) {
        // Take appropriate action
    }
}

Deadlock And Thread Synchronization In Java

A deadlock is a situation in multithreaded programming where two or more threads are blocked forever, waiting for each other to release resources or locks. This occurs when threads hold locks on resources and are waiting for other locks that are held by the other threads, creating a cycle of dependencies. As a result, none of the threads involved can make progress, leading to the system becoming stuck.

How Do Deadlocks Happen?

A deadlock in Java occurs when:

  1. Thread 1 holds Lock A and waits for Lock B.
  2. Thread 2 holds Lock B and waits for Lock A.

Neither thread can proceed because each is waiting for the other to release a lock. This situation creates a circular dependency, resulting in a deadlock.

Thread Synchronization And Deadlocks

Thread synchronization is vital to prevent race conditions when multiple threads access shared resources. However, improper synchronization can lead to deadlocks. While synchronization mechanisms like the synchronized keyword or ReentrantLock ensure thread safety, they must be used carefully to avoid creating deadlock scenarios.

  • Synchronization with Locks: When using synchronized blocks or ReentrantLock to control access to critical sections, threads acquire locks. If multiple locks are needed, ensuring a proper lock order is critical to prevent deadlocks.
  • Fine-Grained Locking: Using finer-grained locks (locking only the required resources) can reduce the chances of deadlocks. The more resources you lock simultaneously, the greater the risk of circular dependencies.
  • Avoiding Resource Starvation: A system that involves waiting for locks for a long time may also suffer from starvation, where certain threads are never allowed to acquire resources because others keep holding the locks. This can sometimes be confused with a deadlock, though it’s a different issue.

Real-World Use Cases Of Thread Synchronization In Java

Thread synchronization plays a critical role in ensuring consistency, safety, and proper coordination in multi-threaded environments. Below are some common real-world scenarios in Java applications where synchronization is essential:

1. Bank Account Management (Preventing Overdrafts)

In a banking application, multiple threads may be handling different transactions like deposits, withdrawals, and transfers for the same account. Without synchronization, it’s possible for two threads to access and modify the balance at the same time, leading to inconsistencies such as overdrafts. For Example- 

public class BankAccount {
    private double balance;

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

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

2. Online Ticket Booking System

In a ticket booking system where multiple users are trying to purchase tickets for an event simultaneously, synchronization ensures that the available ticket count is updated correctly and only one user can book a ticket for a specific seat or row at a time. For Example- 

public class TicketBooking {
    private int availableTickets = 10;

    public synchronized boolean bookTicket() {
        if (availableTickets > 0) {
            availableTickets--;
            return true;
        }
        return false;
    }
}

3. Producer-Consumer Problem

In systems where multiple threads produce and consume items from a shared resource (like a queue or buffer), synchronization is crucial to avoid race conditions. For example, a producer thread adds items to a queue, and a consumer thread removes them.

4. Logging In Multi-Threaded Applications

In multi-threaded applications, threads might write to a log file simultaneously, which can cause log entries to become garbled or corrupt. Synchronizing the logging process ensures that only one thread can write to the log file at any given time. For Example- 

import java.io.FileWriter;
import java.io.IOException;

public class Logger {
    public synchronized void log(String message) {
        try (FileWriter writer = new FileWriter("app.log", true)) {
            writer.write(message + "\n");
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

5. Shared Resource Access In A Distributed System

In distributed systems, when multiple clients (threads) access a shared resource, such as a database, file system, or configuration object, synchronization is necessary to prevent data corruption or inconsistent reads/writes. For Example- 

public class DatabaseConnection {
    private static int activeConnections = 0;
    public synchronized void connect() {
        activeConnections++;
    }
    public synchronized void disconnect() {
        activeConnections--;
    }
}

Conclusion

Thread synchronization in Java is essential for ensuring thread safety and preventing issues like race conditions when multiple threads access shared resources. While traditional synchronization mechanisms like the synchronized keyword and explicit locks are widely used, alternatives such as atomic classes, the volatile keyword, and the ForkJoinPool framework provide more efficient and flexible solutions for managing concurrency. By understanding the various synchronization techniques and choosing the right one for your use case, you can create more efficient, thread-safe Java applications that scale well in a multithreaded environment.

Frequently Asked Questions

Q. What is thread synchronization in Java, and why is it important?

Thread synchronization in Java is a mechanism that ensures that multiple threads do not concurrently access shared resources or data, which could lead to inconsistent or incorrect behavior. Synchronization is crucial in multithreaded applications to prevent issues like race conditions, data corruption, and ensure thread safety. Without proper synchronization, multiple threads might interfere with each other, resulting in unpredictable outcomes.

Q. How does the synchronized keyword work in Java?

The synchronized keyword in Java is used to ensure that only one thread can access a block of code or method at a time. When a method or block is marked as synchronized, a thread must acquire the intrinsic lock (monitor) of the object before it can execute the synchronized code. If another thread is already executing a synchronized method or block, any other thread attempting to enter will have to wait until the lock is released. This helps prevent race conditions and ensures that shared resources are accessed in a controlled manner. For Example-

public synchronized void incrementCounter() {
    Counter++;
}

Q. What is the difference between intrinsic locks and explicit locks in Java?

The key differences are as follows:

  • Intrinsic Locks (Monitor Locks): Every object in Java has an intrinsic lock, which can be acquired using the synchronized keyword. Intrinsic locks are simpler to use but can offer less flexibility and control over the synchronization process.
  • Explicit Locks: These locks, such as ReentrantLock from java.util.concurrent.locks offer more fine-grained control than intrinsic locks. For example, they allow for try-lock mechanisms, timeouts, and can be locked/unlocked manually. Explicit locks are ideal when you need more advanced synchronization features, like lock fairness or handling more complex thread interaction scenarios.

Q. When should I use the volatile keyword in Java?

The volatile keyword is used to indicate that a variable may be accessed by multiple threads. It ensures that changes made to the variable by one thread are immediately visible to all other threads, preventing issues where a thread might read a stale value from its local cache. However, volatile only ensures visibility and does not guarantee atomicity. It's suitable for simple variables like flags or state indicators. For compound operations (like i++), volatile is not sufficient, and synchronization mechanisms like synchronized or atomic classes should be used. For Example-

private volatile boolean flag = false;

Q. Can deadlocks occur with thread synchronization in Java, and how can they be avoided?

Yes, deadlocks can occur in Java when two or more threads are waiting for each other to release locks, leading to a situation where neither thread can proceed. This usually happens when multiple locks are involved and the threads acquire them in different orders.

To avoid deadlocks:

  • Always acquire locks in a consistent order.
  • Use timeouts with explicit locks (e.g., ReentrantLock.tryLock()), so threads don't wait indefinitely.
  • Apply the lock hierarchy principle, where you define a strict order in which locks should be acquired.
  • Consider using higher-level concurrency utilities like ForkJoinPool, which help manage thread execution and avoid locking issues.

With this, we conclude our discussion on thread synchronization in Java. Here are a few other topics that you might be interested in reading: 

  1. Convert String To Date In Java | 3 Different Ways With Examples
  2. Final, Finally & Finalize In Java | 15+ Differences With Examples
  3. Super Keyword In Java | Definition, Applications & More (+Examples)
  4. How To Find LCM Of Two Numbers In Java? Simplified With Examples
  5. How To Find GCD Of Two Numbers In Java? All Methods With Examples
Muskaan Mishra
Technical Content Editor

I’m a Computer Science graduate with a knack for creative ventures. Through content at Unstop, I am trying to simplify complex tech concepts and make them fun. When I’m not decoding tech jargon, you’ll find me indulging in great food and then burning it out at the gym.

TAGS
Java Programming Language
Updated On: 16 Dec'24, 12:20 PM IST