Thread synchronization in Java is used to control how multiple threads access shared data. It plays a key role in preventing errors like race conditions and data inconsistency in multi-threaded programs. Imagine two threads trying to update your bank balance at the same time without synchronization, this could lead to wrong or unexpected results. This article will help you understand what synchronization means in Java, why it is important, and how to use tools like the synchronized keyword, locks, and atomic variables to ensure thread-safe code.
In this article, we will further study what thread Synchronization in Java is in detail, with real-world examples of it.
Table of Contents:
What is Synchronization in Java?
Synchronization in Java is a method to control how multiple threads access shared resources. It prevents data inconsistency when 2 or more threads access the same data at the same time. The synchronized keyword in Java is used to control access to a critical section by multiple threads. It allows only one thread to access a critical section at a time.
Note: A critical section is a piece of code that uses shared data and should only run in one thread at a time.
In the image above, we see four threads trying to use a synchronized method. At first, thread-1 tries to enter the synchronized method, and thread-4 has to wait until thread-1 finishes. Once thread-1 finishes its work, then thread-4 gets a chance to enter the method and do its work.
Understanding Concurrency and Race Conditions
Now, let us understand the concept of Concurrency and race conditions in Java.
What is Concurrency?
Concurrency is the ability of a program to run multiple tasks at the same time. It helps improve the performance of the systems, especially in systems with multiple cores or threads. For example, you can think of concurrency like a chef cooking multiple dishes at once.
What is a Race Condition?
A race condition occurs when two or more threads or processes access shared data at the same time, and the output depends on the order in which they access the data. If the access is not properly controlled (or synchronized), it can lead to unexpected and incorrect behavior.
When multiple threads read and write shared variables without proper synchronization, the data may get corrupted or inconsistent. Even though each thread may behave correctly in isolation, they can interfere with each other when run concurrently. Due to this, it can lead to consequences like incorrect results, crashes, or unexpected behavior.
For example, you have a balance of ₹1000 in your bank account. Two threads, t1 and t2, try to withdraw ₹700 from the account at the same time. Then, without synchronization:
- Both will see that the balance is ₹1000.
- Both will try to withdraw ₹700.
- And the result is that ₹1400 might be withdrawn from the account, due to which the balance will become negative or incorrect.
In the above example, the actual problem is that,
- No waiting: Threads t1 and t2 do not wait for each other.
- No order: You do not know which thread will run first.
- Shared data gets mixed: When both threads access the critical section at the same time.
Java Memory Model (JMM)
The Java Memory Model (JMM) defines how threads will interact through memory and what behaviors are allowed in a multithreaded environment. It also specifies how and when changes made by one thread become visible to others. In Java, every thread can keep its own copy of variables in a small memory area called a cache. This means one thread might not see the latest value of a variable changed by another thread. Also, to make programs faster, the Java Virtual Machine (JVM) and the CPU may change the order of the instructions, which can cause unexpected results if the threads are not properly coordinated with each other. The Java Memory Model (JMM) is used to solve this problem by applying rules to ensure that threads communicate correctly and safely, even with these optimizations.
Types of Synchronization
There are two main types of synchronization in Java. These are:
1. Process Synchronization
Process Synchronization deals with independent processes that run in parallel to each other which ensures that any two processes do not interfere with each other when accessing shared system resources.
It is handled by the Operating System, not directly by Java. Java programs do not deal with process synchronization directly, unless you are using tools like inter-process communication.
2. Thread Synchronization
Thread Synchronization is used to manage access to shared resources among multiple threads of the same process. Java provides built-in support for thread synchronization.
There are two types of thread synchronization:
1. Mutual exclusion: Mutual Exclusion ensures that only one thread can access the shared resource at any given time.
2. Cooperation or Inter-thread Communication: Cooperation allows threads to communicate and coordinate with each other.
Note: Java can use java.lang.Process, sockets, RMI, or external libraries for inter-process communication.
Ways to Achieve Synchronization in Java
In Java, you can perform synchronization using the synchronized keyword in the following ways.
1. Synchronized Method
In this, you can declare an entire method as a synchronized method, which ensures that one thread at a time can execute this method on the same object.
Example:
Output:
Explanation: In the above Java code, two threads increase the counter 1000 times. Because we used the synchronized keyword, they don’t interfere with each other, and the final count is 2000. The synchronized void increase() method ensures that only one thread can access the increase() method at a time. The join() method makes the main thread wait until t1 and t2 finish their execution.
2. Synchronized Block
In this, instead of locking the whole method, only a certain part of the code is synchronized that accesses shared data.
Example:
Output:
Explanation: In the above Java code, two threads increase the counter 1000 times. Because we used the synchronized keyword, they don’t interfere with each other, and the final count is 2000. The synchronized(this) locks the current object so that only one thread at a time can run the count++ part, which ensures thread safety and avoids the race conditions.
3. Static Synchronized Method
When a method is declared both static and synchronized, it means only one thread can execute that method at a time, across all instances of the class. The lock is not on a particular object, but on the class itself.
Example:
Output:
Explanation: In the above Java code, threads t1, t2, and t3 are created. The static synchronized locks are made on the class, not on an object. So, even if the threads come from different objects, only one thread at a time can enter the printMessage() method.
4. Thread Synchronization
This is the most common use of the synchronized keyword in Java, it makes sure that only one thread accesses the shared data at a time by acquiring a lock on the object or class.
Example:
Output:
Explanation: In the above Java code, the add() method is made synchronized, so only one thread can run it at a time. Without using synchronization, both threads will update num at the same time, which will cause wrong results.
5. Process synchronization
Process synchronization means keeping two or more independent processes in sync when they are using shared resources like files, databases, or memory. In Java, this is usually done using Inter-Process Communication (IPC) or external mechanisms like files, sockets, or database locks.
Note: Java does not have built-in inter-process synchronization mechanisms
Example:
Before the execution of the code, the file “Intellipaat.txt” will be empty. After the execution of the code, the file will look as below.
Output:
Get 100% Hike!
Master Most in Demand Skills Now!
Advanced Synchronization Techniques in Java
Java has many other tools that do not use the synchronized keyword to manage thread safety in Java. These tools are more efficient. Some of them are:
1. Locks (ReentrantLock)
A Lock in Java is a tool that is used to control access to shared resources like variables, files, memory, etc., in a multithreaded program. It is part of the java.util.concurrent.locks package.
It is like using a door lock; only one person can go inside a room, and others have to wait.
ReentrantLock is a part of the java.util.concurrent.locks package, which can acquire a lock on more than once without getting blocked. Unlike the synchronized keyword, it gives explicit control over the process of locking and unlocking. It is mainly used when you need better control over locking logic timeouts.
Example:
Output:
After the execution of the code, the output.txt will look as below.
Explanation: In the above Java code, we are using ReentrantLock to control the access of a thread to the critical section. The lock.lock() method is used to call a thread to enter the section, and lock.unlock() is used to allow other threads to enter once the first entered is done.
2. Atomic Variables
Atomic Variable is present in the java.util.concurrent.atomic package, which has classes like AtomicInteger, AtomicLong, AtomicBoolean, AtomicReference, etc., which use the CPU-level atomic operations, like CAS (Compare And Swap) for lock-free synchronization.
It is very fast and efficient for small blocks of code, like counters. They are mainly used when you want to avoid locking completely.
Example:
Output:
Explanation: In the above Java code, AtomicInteger is used to increment a number safely in a multithreaded environment without using any other lock.
3. Executor Service
Executor Service is a part of the java.util.concurrent package, which has a thread pool that can manage and schedule threads efficiently. It is easy, as it removes the need to create and manage each thread manually. But it does not remove the need for synchronization when accessing the shared resources.
It is used by the application where a large number of threads are used, as it can handle a large number of threads easily. It also promotes the parallel task execution.
Example:
Output:
Explanation: In the above Java code, we are using ExecutorService to run a task using a thread pool, which will help the execution of a task in an organized way.
4. Semaphores
A semaphore is a value that is used to restrict the number of threads that can access a resource at a time. It is a part of the java.util.concurrent package.
A semaphore with a value of 1 is called a binary semaphore.
For example, 3 threads can access a pool of 3 printers, but if the 4th comes, it must wait.
Example:
Output:
Explanation: In the above Java code, a Semaphore is used to allow only one thread to access the critical section at a time.
5. Read-Write Locks (ReadWriteLock)
Read-Write Lock is present in the java.util.concurrent.locks.ReadWriteLock class, and it uses the readLock() and writeLock() methods internally to allow multiple threads to read at the same time. But allow only one thread to write, and while the process of writing, other processes, like reading or writing, cannot be performed.
Example: It is used in memory caches, real-time analytics data.
Example:
Output:
Explanation: In the above Java code, ReadWriteLock is used to allow multiple threads to read shared data at the same time.
6. Volatile Keyword
Volatile Keyword in Java is used to make changes to a variable that is visible to all the threads. Using this keyword, the latest value will be used from the main memory. It is used for simple flag variables.
Example:
Output:
Explanation: In the above Java code, volatile boolean running = true; ensures that changes made by one thread are visible to other threads immediately.
Inter-Thread Communication
Inter-thread communication means two or more threads are communicating with each other while sharing the data. It is also called Cooperative Synchronization, because the threads cooperate with each other instead of competing.
There are mainly 4 methods that are used inside the synchronized blocks or methods of the object class. The first three methods are from the Object class and must be called within synchronized blocks, and the sleep()
method belongs to the Thread
class and does not release the lock.
1. wait(): It tells the current thread to pause and release the lock. It can only be called from a synchronized context.
2. notify(): It wakes up one thread that is waiting.
3. notifyAll(): It wakes up all the waiting threads.
4. sleep(): It pauses the current thread for a specified time (in milliseconds), but does not release the lock.
Imagine there are two threads:
- Thread A is a Producer that creates data.
- Thread B is a Consumer that uses the data produced by A.
We don’t want that Thread B to consume the data before Thread A has produced it. So, Thread B should wait until Thread A finishes its job. After Thread A produces the data, it will notify Thread B to consume it.
This kind of communication between the threads is known as inter-thread communication.
For example, consider the following code.
Output:
The above Java code shows how one thread (producer) puts numbers into a buffer and another thread (consumer) takes them out. If the buffer is full, the producer waits. If the box is empty, the consumer waits. They do this turn by turn using wait() and notify(). Then, the synchronized keyword makes sure that only one thread uses the buffer at a time.
Advantages of Synchronization
Below are the advantages of synchronization in Java.
1. It is safe from errors, as only one thread can work on shared data at a time.
2. It keeps your data right, even if more than one thread is working together.
3. It is simple to use, as Java provides the synchronized keyword, which makes synchronization easy.
4. No extra tool is needed, as you can manage synchronization directly in Java code, without using any external libraries.
Disadvantages of Synchronization
Below are the disadvantages of synchronization in Java.
1. It makes the program slow, as only one thread can access the block at a time.
2. Sometimes it creates a problem of deadlock, if two threads are waiting for each other to release locks.
3. It makes it hard to find the bugs in the program, because errors related to synchronization are difficult to debug.
4. You need to write the code carefully because a small mistake in using synchronization, like missing a lock, can cause serious problems like a race condition.
Best Practices of Using Synchronization
Below are some of the best practices of using synchronization in Java.
1. Use Synchronization When Necessary: Use synchronization only for shared data accessed by multiple threads to avoid unnecessary performance costs.
2. Limit Synchronization to Critical Sections: Synchronize only the critical section, not the whole method, because it avoids deadlocks and improves performance.
3. Prefer Synchronized Blocks Over Synchronized Methods: Use a synchronized Block instead of a synchronized method because it gives more flexibility and better performance
4. Document the Purpose of Synchronization: Add clear comments to explain why a synchronized block is used to improve code readability and maintainability.
5. Minimize Shared Data Between Threads: Try to reduce the use of shared variables between the threads, which will reduce the chances of a race condition.
Common Pitfalls and How to Avoid Them
Some of the common mistakes while using synchronization in Java are discussed below.
1. Locking on Mutable or Public Objects: A common mistake is locking on an object that is either mutable or publicly accessible, because if the object reference changes or is accessed elsewhere, it can break synchronization or create bugs. To avoid this, always lock on a private and final object so the lock remains consistent and is not exposed outside the class.
2. Using this as a Lock: Synchronizing on this can be risky because any external code holding a reference to the object can also lock on it, which can lead to deadlocks or conflicts. Instead of this, you can create a dedicated private lock object and synchronize on that.
3. Forgetting to Use volatile for Shared Flags: When multiple threads read and write a shared flag (like a boolean), forgetting to declare it as volatile can cause one thread to not see the updated value made by another. To ensure visibility, always use the volatile keyword or consider using atomic variables like AtomicBoolean.
4. Calling wait(), notify(), or notifyAll() Without Synchronization: If you call wait() or notify() outside a synchronized block, Java will throw an IllegalMonitorStateException. To prevent this, always call these methods inside a synchronized block that locks on the same object.
5. Synchronizing More Code Than Necessary: Overusing synchronization, such as locking entire methods or large blocks of code, can affect the performance and increase the risk of deadlocks. To avoid this, only synchronize a specific part of the code (the critical section) that actually uses the shared resources.
Real-World Examples of Synchronization
1. In online banking apps, two people might try to withdraw money at the same time. Without any control, both could withdraw more than the actual balance. This causes wrong account updates.
2. In offices, many people may send files to the same printer simultaneously. If all the pages are printed at once, pages can get mixed up, which can waste paper and confuse the users. Synchronization helps handle print jobs one by one.
3. On YouTube or Instagram, many people can like or view something at the same time. If it happens together, some likes or views might not be added. This can show the wrong total number.
4. In food delivery apps, one thread may add items while another places the order. If both of them act at once, the cart might show the wrong items. This can lead to wrong or missing orders. Synchronization keeps the cart updated in order.
Unlock Your Future in Java
Start Your Java Journey for Free Today
While synchronization ensures thread safety, it can also impact performance if it is not used wisely. When multiple threads compete for synchronized resources, it can lead to increased CPU context switching and thread scheduling overhead, which can degrade the throughput of the application. Synchronization can also hurt performance if it is overused or if large blocks of code are unnecessarily locked, causing threads to wait longer than needed. To avoid such issues, developers should minimize the synchronized region and avoid holding locks during long-running operations. Tools like JVisualVM and JConsole can help monitor thread activity, CPU usage, and detect bottlenecks in multi-threaded applications, making them useful for optimizing synchronization strategies
Conclusion
With this, we have learned that thread synchronization in Java is essential for building reliable and error-free multi-threaded applications. By using techniques like the synchronized keyword, locks, atomic variables, and inter-thread communication, we can prevent race conditions, data inconsistency, and unexpected behavior. Whether you are building banking apps, online carts, or any system where multiple threads work together, understanding synchronization will help you write safe and correct Java code. Use synchronization only where needed, and always follow best practices to avoid performance issues or deadlocks in your programs.
Useful Resources:
What is Synchronization in Java – FAQs
Q1. What is synchronization in Java?
Synchronization in Java means only one thread can do a task at a time to avoid conflicts.
Q2. What is a synchronized class?
synchronized(X.class) is used to make sure only one thread can run that block of code at a time.
Q3. What is the difference between synchronized and lock() in Java?
The synchronized keyword is simpler and automatically manages locking and unlocking, while the Lock interface gives you more control, such as trying to acquire the lock without blocking or setting a timeout.
Q4. Is synchronization thread-safe?
Yes, synchronization is thread-safe because it ensures that only one thread can access the synchronized block or method at a time, preventing data inconsistency.
Q5. Can we synchronize static methods?
Yes, we can synchronize static methods in Java, and in that case, the lock is applied on the class object rather than on any particular instance.
Q6. What is the difference between volatile and atomic?
The volatile keyword only ensures visibility of changes to variables across threads, while atomic classes like AtomicInteger provide both visibility and atomicity for operations like increment and compare-and-set.