Unlocking Synchronization In Java – Thread Harmony

In Java, Synchronization of multiple things means ensuring smooth coordination and teamwork among different elements. It’s about making sure that everyone is on the same page, acting together seamlessly, and avoiding conflicts or confusion. Let’s see a scenario with the absence of Synchronization.

Imagine you and your two friends have a joint bank account with a total amount of ₹1000, and you all want to withdraw all the money at the same time. All three of you might rush to the ATM or bank counter to withdraw ₹1000 simultaneously, creating a mess. If transactions are not synchronized, then there can be a worst-case scenario. Everyone tries to withdraw ₹1000 which is not possible but without transactions being synchronized, this may be possible. This can lead to an overdrawn account, causing negative balances and financial complications.

So what’s the solution?

To avoid this chaos, synchronization is necessary. It’s like taking turns. Only one person should withdraw money at a time to maintain order and ensure the correct balance in your joint account.

Without synchronization

What Is Synchronization?

Now synchronization in Java is useful to handle the problems and issues of multithreading. Synchronization in Java refers to the mechanism that ensures the orderly and coordinated execution of threads when accessing shared resources (joint account). It allows only one thread at a time to access critical sections of code, preventing conflicts and maintaining data integrity in multithreaded applications. 

Why Synchronization?  

Synchronization in Java serves two key purposes:

  1. Preventing Thread Interference: It ensures that multiple threads don’t interfere with each other when accessing shared resources simultaneously.
  1. Preserving Consistency: Synchronization prevents data inconsistencies that may occur when multiple threads modify shared data concurrently.

Types Of Synchronization In Java 

Multitasking in Java can be performed in two ways.
1. Process-based multitasking

2. Thread-based multitasking

and synchronization is needed where multiple tasks are performed concurrently. Hence in Java, there are two types of synchronization that serve different purposes:

  • Process Synchronization
  • Thread Synchronization

In this blog, we will majorly discuss Thread Synchronization in Java.

Thread Synchronization 

Thread synchronization focuses on coordinating the execution of multiple threads within a single process. It ensures orderly execution, prevents conflicts, and maintains data consistency. In Java, thread synchronization is subdivided into two types:

  • Mutual Exclusive: This type of thread synchronization ensures that only one thread can access a shared resource or critical section of code at a time. It prevents concurrent execution and potential conflicts that may arise when multiple threads attempt to modify or access the same resource simultaneously.
  • Inter-thread Communication: Inter-thread communication involves threads coordinating their activities and exchanging information. It allows threads to wait for certain conditions to be met or signal other threads when specific events occur. This type of synchronization facilitates collaboration and synchronization between threads, enabling them to work together to accomplish tasks efficiently.
Thread Synchronization 

Mutual Exclusive  

A crucial idea in thread synchronization is mutual exclusive, which makes sure that only one thread at a time can access a shared resource or a crucial section of code. It prevents conflicts and data corruption that can occur when multiple threads try to modify the same resource simultaneously.

Understanding the problem without Synchronization

class Counter {
    private int count = 0;


    public void increment() {
        count++;
    }

    public int getCount() {
        return count;
    }
}

class Example {
    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) {
            Thread.currentThread().interrupt();
        }

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

Output (varies):

Final Count: 1672

Explanation:

In this code, we have a Counter class with an increment() method that increments a shared count variable by one. The main() method creates two threads, t1 and t2, both of which concurrently call the increment() method multiple times to increment the count variable. Without proper thread synchronization, the output of the program may not be the expected result of 2000. This is due to a race condition, where multiple threads access and modify the shared count variable simultaneously, leading to inconsistent and unpredictable results.

In Java, mutual exclusive is commonly achieved through three main approaches:

  1. Synchronized Blocks

In this approach, you can use synchronized blocks to ensure mutual exclusion. A synchronized block is a section of code enclosed within the synchronized keyword. Only one thread can execute the synchronized block at a time.

Here’s an example code snippet demonstrating the use of synchronized blocks:

class Counter {
    private int count = 0;

    public void increment() {
        synchronized (this) {
            count++;
            System.out.println("Incremented count: " + count);
        }
    }
}

class Example {
    public static void main(String[] args) {
        Counter counter = new Counter();

        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 5; i++) {
                counter.increment();
            }
        });

        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 5; i++) {
                counter.increment();
            }
        });

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

Output:

Incremented count: 1
Incremented count: 2
Incremented count: 3
Incremented count: 4
Incremented count: 5
Incremented count: 6
Incremented count: 7
Incremented count: 8
Incremented count: 9
Incremented count: 10

In this approach, we use synchronized blocks to achieve mutual exclusion. The `increment()` method in the `Counter` class is enclosed within a synchronized block using the `synchronized (this)` syntax. This ensures that only one thread can execute the block at a time by acquiring the lock on the instance of the `Counter` object. Thus, when multiple threads call the `increment()` method, they take turns executing it, preventing concurrent modification of the shared `count` variable.

  1. Synchronized Methods

Another way to achieve mutual exclusion is by using synchronized methods. When a method is declared as synchronized, only one thread can execute the method at a time, ensuring mutual exclusion.

Here’s an example code snippet demonstrating the use of synchronized methods:

class Counter {
    private int count = 0;

    public synchronized void increment() {
        count++;
        System.out.println("Incremented count: " + count);
    }
}

class Example {
    public static void main(String[] args) {
        Counter counter = new Counter();

        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 5; i++) {
                counter.increment();
            }
        });

        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 5; i++) {
                counter.increment();
            }
        });

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

Output:

Incremented count: 1
Incremented count: 2
Incremented count: 3
Incremented count: 4
Incremented count: 5
Incremented count: 6
Incremented count: 7
Incremented count: 8
Incremented count: 9
Incremented count: 10

This approach utilizes synchronized methods to achieve mutual exclusion. The `increment()` method in the `Counter` class is declared as synchronized. This indicates that only one thread at a time can use the method. A thread obtains the lock linked to the object instance when it enters the synchronised method. As a result, other threads have to wait until the lock is released before they can execute the method. This ensures that the shared `count` variable is updated atomically, preventing data corruption.

Java threads execution with synchronized method
  1. Static Synchronization

Static synchronization is used when you want to synchronize access to static methods or shared static variables. It ensures that only one thread can execute the synchronized static method or access the synchronized static block at a time.

Here’s an example code snippet demonstrating static synchronization:

class Counter {
    private static int count = 0;

    public static synchronized void increment() {
        count++;
        System.out.println("Incremented count: " + count);
    }
}

class Example {
    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 5; i++) {
                Counter.increment();
            }
        });

        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 5; i++) {
                Counter.increment();
            }
        });

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

Output:

Incremented count: 1
Incremented count: 2
Incremented count: 3
Incremented count: 4
Incremented count: 5
Incremented count: 6
Incremented count: 7
Incremented count: 8
Incremented count: 9
Incremented count: 10

In this approach, we employ static synchronization to achieve mutual exclusive for static methods or shared static variables. The `increment()` method in the `Counter` class is declared as synchronized and static. This means that only one thread can execute the method at a time, regardless of the instance of the `Counter` object. When a thread enters the synchronized static method, it acquires the lock associated with the `Counter` class itself. This ensures that only one thread can execute the method and update the shared `count` variable, preventing concurrency issues.

Inter-Thread Communication

Inter-thread synchronization is a crucial aspect of concurrent programming in Java that allows threads to communicate and coordinate their activities. It enables threads to wait for specific conditions to be met or signal other threads when certain events occur. This synchronization mechanism ensures orderly and synchronized behavior among multiple threads.

In Java, inter-thread synchronization can be achieved using various constructs, such as `wait()`, `notify()`, and `notifyAll()` methods provided by the `Object` class. These methods enable threads to wait for a condition to become true and notify other waiting threads when the condition changes.

For example, 

class Message {
    private String content;
    private boolean isMessageReady;


    public synchronized void produce(String message) {
        while (isMessageReady) {
            try {
                wait();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }

        content = message;
        isMessageReady = true;
        System.out.println("Message produced: " + message);
        notifyAll();
    }

    public synchronized String consume() {
        while (!isMessageReady) {
            try {
                wait();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }

        String message = content;
        isMessageReady = false;
        System.out.println("Message consumed: " + message);
        notifyAll();
        return message;
    }
}

class Example {
    public static void main(String[] args) {
        Message message = new Message();

        Thread producerThread = new Thread(() -> {
            for (int i = 1; i <= 5; i++) {
                String content = "Message " + i;
                message.produce(content);
            }
        });

        Thread consumerThread = new Thread(() -> {
            for (int i = 1; i <= 5; i++) {
                String consumedMessage = message.consume();
            }
        });

        producerThread.start();
        consumerThread.start();
    }
}

Output:

Message produced: Message 1
Message consumed: Message 1
Message produced: Message 2
Message consumed: Message 2
Message produced: Message 3
Message consumed: Message 3
Message produced: Message 4
Message consumed: Message 4
Message produced: Message 5
Message consumed: Message 5

The Message class represents a shared message content and a flag indicating whether the message is ready. The `produce()` method is used by the producer thread to produce a message. It waits until the message is consumed by checking the `isMessageReady` flag using a `while` loop and `wait()`. Once the message is consumed, it sets the message content, updates the flag, prints the produced message, and notifies all waiting threads using `notifyAll()`.

The `consume()` method is used by the consumer thread to consume a message. It waits until a message is produced by checking the `isMessageReady` flag using a `while` loop and `wait()`. Once a message is produced, it retrieves the message content, updates the flag, prints the consumed message, and notifies all waiting threads using `notifyAll()`.

Deadlock In Java

Deadlock in Java refers to a situation where two or more threads are blocked indefinitely, waiting for each other to release resources, resulting in a system deadlock. It occurs when multiple threads acquire locks on resources and hold them, leading to a circular dependency where none of the threads can proceed.

Code Example:

public class DeadlockExample {
    private static final Object resource1 = new Object();
    private static final Object resource2 = new Object();

    public static void main(String[] args) {
        Thread thread1 = new Thread(() -> {
            synchronized (resource1) {
                System.out.println("Thread 1: Acquired resource 1 lock");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (resource2) {
                    System.out.println("Thread 1: Acquired resource 2 lock");
                }
            }
        });

        Thread thread2 = new Thread(() -> {
            synchronized (resource2) {
                System.out.println("Thread 2: Acquired resource 2 lock");
                synchronized (resource1) {
                    System.out.println("Thread 2: Acquired resource 1 lock");
                }
            }
        });

        thread1.start();
        thread2.start();
    }
}

In the given code example, two threads `thread1` and `thread2` are acquiring locks on `resource1` and `resource2`, respectively. However, the order of lock acquisition is different for each thread. This creates a potential deadlock situation where `thread1` holds `resource1` and waits for `resource2`, while `thread2` holds `resource2` and waits for `resource1`.

Avoiding Deadlock

To avoid deadlock, it is essential to follow certain best practices:

  1. Avoid circular wait: Ensure that threads always acquire resources in a consistent order to break the circular dependency. This prevents the possibility of deadlock.
  2. Lock timeout: Implement a lock acquisition timeout mechanism to avoid waiting indefinitely. If a lock cannot be acquired within a specified time, the thread can release the acquired resources and retry later.
  3. Resource allocation: Use resource allocation strategies that minimize the chances of deadlock, such as resource ordering or using a centralized resource manager.
  4. Avoid unnecessary resource holding: Release resources as soon as they are no longer needed. Holding resources for an extended period increases the likelihood of deadlock.

For example,

By modifying the code example above to acquire locks in a consistent order, we can avoid the deadlock scenario:

Thread thread1 = new Thread(() -> {
    synchronized (resource1) {
        System.out.println("Thread 1: Acquired resource 1 lock");
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        synchronized (resource2) {
            System.out.println("Thread 1: Acquired resource 2 lock");
        }
    }
});

Thread thread2 = new Thread(() -> {
    synchronized (resource1) {
        System.out.println("Thread 2: Acquired resource 1 lock");
        synchronized (resource2) {
            System.out.println("Thread 2: Acquired resource 2 lock");
        }
    }
});

In this modified code, both threads acquire locks on `resource1` before attempting to acquire `resource2`. This ensures a consistent lock acquisition order, breaking the circular dependency and preventing deadlock.

Avoiding Deadlock

Thread Joining VS Thread Synchronization

Thread Joining: Thread joining is a mechanism where one thread waits for another thread to complete its execution before proceeding further. Its purpose is to ensure that a specific thread completes its execution before the current thread proceeds. `join()` method is used to implement thread joining.

Thread Synchronization: Thread synchronization involves coordinating the execution of multiple threads to ensure orderly and synchronized access to shared resources. Its purpose is to prevent thread interference and maintain data consistency when multiple threads access shared resources concurrently. Synchronized blocks, synchronized methods, and static synchronization are used for thread synchronization.

Difference between Thread Joining And Thread Synchronization

AspectThread JoiningThread Synchronization
PurposeEnsures completion of a specific thread.Coordinates access to shared resources.
Execution FlowBlocks the current thread until joined thread completes.Controls access to shared resources.
Syntax`join()` method.Synchronized blocks, synchronized methods, static synchronization.
DependencyDepends on the completion of a specific thread.Concerned with coordinated access among threads.

Importance Of Thread Synchronization

  1. Preventing Thread Interference: Thread synchronization ensures that only one thread can access a shared resource or critical section of code at a time. This prevents thread interference and maintains data integrity and consistency.
  1. Avoiding Race Conditions: Race conditions occur when multiple threads access and modify shared data simultaneously, leading to unpredictable and erroneous results. Thread synchronization helps to avoid race conditions by enforcing mutual exclusion and orderly access to shared resources.
  1. Maintaining Data Consistency: In multithreaded environments, when multiple threads read and write to shared variables, there is a risk of inconsistent data states. Thread synchronization ensures that shared data is accessed and modified in a synchronized manner, preventing data corruption and maintaining consistency.
  1. Achieving Thread Safety: Thread synchronization is essential for achieving thread safety, which means that a program behaves correctly and produces the expected results even in concurrent execution scenarios. Synchronizing critical sections of code helps protect shared resources and prevents concurrent access issues.
  1. Enabling Correct Program Execution: By synchronizing access to shared resources, thread synchronization enables correct program execution by ensuring that the desired order of operations and dependencies are maintained among multiple threads.
  1. Preventing Deadlocks: Thread synchronization techniques, when used correctly, help in avoiding deadlocks. Deadlocks occur when multiple threads are blocked indefinitely, waiting for each other to release resources. By carefully designing synchronization mechanisms, deadlocks can be prevented, ensuring smooth program execution.
  1. Facilitating Parallel Processing: Thread synchronization allows multiple threads to execute concurrently while ensuring synchronized access to shared resources. This enables efficient parallel processing and utilization of system resources.

Common Challenges And Pitfalls In Thread Synchronization

  1. Deadlocks: Improper ordering of locks or improper use of synchronization constructs can lead to deadlocks. Avoid nested locking and ensure consistent lock acquisition and release order.
  2. Performance Bottlenecks: Excessive synchronization can cause performance issues. Use synchronization only when necessary and consider alternative synchronization mechanisms like ReadWriteLock or ConcurrentHashMap.
  3. Inefficient Lock Granularity: Inappropriate lock granularity can impact performance and scalability. Choose the right level of lock granularity to balance thread safety and concurrency.
  4. Livelocks: Livelocks occur when threads are unable to make progress. Mitigate livelocks by introducing randomness or backoff mechanisms in synchronization logic.
  5. Missed Signals: Improper signaling and synchronization logic can result in missed signals during inter-thread communication. Ensure correct signaling to prevent missed signals.
  6. Deadlocks due to Shared Resource Dependencies: Thread dependencies on multiple shared resources can lead to deadlocks. Follow a consistent order while acquiring locks on shared resources to prevent deadlocks.
  7. Incorrect Data Sharing: Improperly shared data can lead to data inconsistencies. Identify shared data correctly and apply proper synchronization mechanisms to avoid inconsistencies.

Conclusion

  1. Thread synchronization is essential for maintaining orderly execution, preventing thread interference, and ensuring data consistency in Java.
  2. Synchronization is used to prevent conflicts and maintain data integrity in multithreaded environments.
  3. Synchronization in Java is of two types: Process Synchronization and Thread Synchronization.
  4. Furthermore, Thread Synchronization is divided into two types i.e. Mutual Exclusive and Inter-Thread Synchronization.
  5. Mutual exclusive is achieved through synchronized blocks, synchronized methods, and static synchronization.
  6. Inter-thread synchronization involves communication between threads using wait(), notify(), and notifyAll() methods.
  7. Deadlock should be avoided through careful design and ordering of locks.
  8. Understand the differences between thread joining and thread synchronization.
  9. Take care of common challenges and pitfalls in Thread Synchronization.

Happy synchronizing!

Leave a Reply

Your email address will not be published. Required fields are marked *