Leverage the power of Java concurrency for efficient, scalable applications. Maximize performance and responsiveness with concurrent programming.
Harness the power of parallel computing and multithreading to supercharge your applications with Java concurrency. Beginner or expert, this guide will catapult your skills into the fast-paced arena of concurrent programming. Fasten your seatbelts and let's unravel Java's robust APIs for a fascinating journey into the realm of high-performance coding!
What is concurrency?
Concurrency is used by all major Java development companies and refers to the ability of a program to perform multiple tasks simultaneously. It enables efficient utilization of system resources and can improve the overall performance and responsiveness of the application.
The Java concurrency concepts, classes and interfaces used for multithreading, such as `Thread`, `Runnable`, `Callable`, `Future`, `ExecutorService` and the classes in `java.util.concurrent`, are part of the libraries Standard Java, so there shouldn't be much difference between the various Java frameworks. But before we get into the nitty-gritty, first a very basic question.
Multiple threads in Java?
Multithreading refers to a programming technique in which there are multiple threads of execution within a single application.
Multithreading is just one way to achieve concurrency in Java. Concurrency can also be achieved by other means, such as multiprocessing, asynchronous programming, or event-driven programming.
But just for the uninitiated, a 'thread' is a single stream of processes that can be executed independently by a computer's processor.
Why should you use Java concurrency?
Concurrency is a great solution for building high-performance modern applications for several reasons.
Improved performance
Concurrency allows the division of complex and time-consuming tasks into smaller parts that can be executed simultaneously, resulting in better performance. This takes full advantage of today's multi-core CPUs and can make applications run much faster.
Better resource utilization
Concurrency enables optimal utilization of system resources, resulting in greater resource efficiency. By implementing asynchronous I/O operations, the system can avoid blocking a single thread and allow other tasks to run simultaneously, thereby maximizing resource utilization and system efficiency.
Improved responsiveness
Improved responsiveness: Concurrency can enhance the user experience in interactive applications by ensuring that the application remains responsive. While executing a computationally intensive task by one thread, another thread may simultaneously handle user inputs or user interface updates.
Simplified Modeling
In certain scenarios, such as simulations or game engines, concurrent entities are inherent to the problem domain, and therefore a concurrent programming approach is more intuitive and performant. This is commonly called simplified modeling.
Robust Concurrency API
Java offers a comprehensive and adaptable concurrency API that includes thread pools, concurrent collections, and atomic variables to ensure robustness. These concurrency tools simplify concurrent code development and mitigate prevalent concurrency issues.
Disadvantages of concurrency
It's important to understand that concurrent programming is not for beginners. It brings a higher level of complexity to your applications and entails a distinct set of difficulties, such as managing synchronization, avoiding conflicts, and ensuring thread safety, and that's not all. Here are some things to consider before taking the plunge.
Complexity: Writing concurrent programs can be more difficult and time-consuming than writing single-threaded programs. It is essential that developers have an understanding of synchronization, memory visibility, atomic operations and thread communication.
Debugging Difficulties: The non-deterministic nature of concurrent programs can pose a challenge during debugging. The occurrence of race conditions or deadlocks can be inconsistent, which poses a challenge in reproducing and resolving them.
Potential for error: Improper handling of concurrency can lead to errors such as race conditions, deadlocks, and thread interference. This problem can pose difficulties in identification and resolution.
Resource contention: Poorly designed concurrent applications can cause resource contention, in which many threads fight for the same resource, resulting in lost performance.
The overhead: Creating and maintaining threads increases CPU and memory usage on your machine. Insufficient management can result in suboptimal performance or resource depletion.
Complicated to test: Because thread execution is unpredictable and non-deterministic, testing concurrent programs can be challenging.
So while concurrency is a great option, it's not all smooth sailing.
Java Concurrency Tutorial: Thread Creation
Threads can be created in three ways. Here we will create the same thread using different methods.
Inheriting from the Thread class
One way to create a thread is to inherit it from the thread class. Then all you need to do is override the run method of the thread object. The run method will be invoked when the thread starts.
public class ExampleThread extends Thread { @Override public void run { // contains all the code you want to execute // when the thread starts // prints out the name of the thread // which is running the process System.out.println(Thread.currentThread .getName ); } }
To start a new thread, we create an instance of the above class and call the start method on it.
public class ThreadExamples { public static void main(String args) { ExampleThread thread = new ExampleThread ; thread.start; } }
A common mistake is calling the run method to start the thread. It may seem correct as everything works fine, but calling the run method does not start a new thread. Instead, it executes the thread's code within the parent thread. We use the start method to run a new thread.
You can test this by calling “thread.run” instead of “thread.start” in the code above. You will see that “main” is printed in the console, which means we are not creating any threads. Instead, the task runs on the main thread. For more information on thread class, be sure to check out the docs .
Implementing executable interface
Another way to create a thread is by implementing the Runnable interface. Similar to the previous method, you need to override the run method, which will contain all the tasks that you want the executable thread to execute.
public class ExampleRunnable implements Runnable { @Override public void run { System.out.println(Thread.currentThread .getName ); } } public class ThreadExamples { public static void main(String args) { ExampleRunnable runnable = new ExampleRunnable ; Thread thread = new Thread(runnable); thread.start; } }
Both methods work exactly the same with no difference in performance. However, the Runnable interface leaves the option of extending the class with some other class, as you can only inherit one class in Java. It's also easier to create a thread pool using executables.
Using anonymous statements
This method is very similar to the above method. But instead of creating a new class that implements the executable method, you create an anonymous function that contains the task you want to perform.
public class Main { public static void main(String args) { Thread thread = new Thread( -> { // task you want to execute System.out.println(Thread.currentThread .getName ); }); thread.start; } }
Thread Methods
If we call the threadOne.join method inside threadTwo, it will put threadTwo into a waiting state until threadOne finishes executing.
Calling the static method Thread.sleep(long timeInMilliSeconds) will put the current thread into a timed wait state.
Thread lifecycle
A thread can be in one of the following states. Use Thread.getState to get the current state of the thread.
- NEW: created but not started running
- RUNNABLE: execution started
- BLOCKED: waiting to acquire a lock
- WAITING: waiting for some other thread to perform a task
- TIMED_WAITING: waiting for a specified period of time
- TERMINATED: execution completed or aborted
Executors and thread pools
Threads require some resources to start and are stopped after the task is completed. For applications with many tasks, you would want to queue tasks instead of creating more threads. Wouldn't it be great if we could somehow reuse existing threads while also limiting the number of threads you can create?
The ExecutorService class allows us to create a certain number of threads and distribute tasks among the threads. Since you are creating a fixed number of threads, you have a lot of control over the performance of your application.
import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; public class Main { public static void main(String args) { ExecutorService executor = Executors.newFixedThreadPool(2); for (int i = 0; i < 20; i++) { int finalI = i; executor.submit( -> System.out.println(Thread.currentThread .getName + " is executing task " + finalI)); } executor.shutdown; } }
Race conditions
A race condition is a condition of a program in which its behavior depends on the relative timing or interleaving of multiple threads or processes. To better understand this, let's look at the example below.
public class Increment { private int count = 0; public void increment { count += 1; } public int getCount { return this.count; } } public class RaceConditionsExample { public static void main(String args) { Increment eg = new Increment ; for (int i = 0; i < 1000; i++) { Thread thread = new Thread(eg::increment); thread.start; } System.out.println(eg.getCount ); } }
Here, we have an Increment class that stores a variable count and a function that increments the count. In the RaceConditionsExample, we are starting a thousand threads, each of which will invoke the increment method. Finally, we are waiting for all threads to finish executing and then print the value of the count variable.
If you run the code several times, you will notice that sometimes the final value of count is less than 1,000. To understand why this happens, let's take two threads, Thread-x and Thread-y, as examples. Threads can perform read and write operation in any order. Therefore, there will be a case where the order of execution will be as follows.
Thread-x: Reads this.count (which is 0) Thread-y: Reads this.count (which is 0) Thread-x: Increments this.count by 1 Thread-y: Increments this.count by 1 Thread-x: Updates this.count (which becomes 1) Thread-y: Updates this.count (which becomes 1)
In this case, the final value of the count variable is 1 and not 2. This is because both threads are reading the count variable before either of them can update the value. This is known as a race condition. More specifically, a “read-modify-write” race condition.
Synchronization Strategies
In the previous section, we looked at what race conditions are. To avoid race conditions, we need to synchronize the tasks. In this section, we will look at different ways to synchronize different processes across multiple threads.
To lock
There will be cases where you want a task to be executed by a single thread at a time. But how would you ensure that a task is being executed by only one thread?
One way to do this is by using locks. The idea is that you create a lock object that can be “acquired” by a single thread at a time. Before executing a task, the thread tries to acquire the lock. If he manages to do this, he continues with the task. After completing the task, it releases the lock. If the thread is unable to acquire the lock, it means that the task is being performed by another thread.
Here is an example using the ReentrantLock class, which is an implementation of the lock interface.
import java.util.concurrent.locks.ReentrantLock; public class LockExample { private final ReentrantLock lock = new ReentrantLock ; private int count = 0; public int increment { lock.lock; try { return this.count++; } finally { lock.unlock; } } }
When we call the lock method on a thread, it tries to acquire the lock. If successful, it executes the task. However, if unsuccessful, the thread blocks until the lock is released.
isLocked returns a boolean value depending on whether the lock can be acquired or not.
The tryLock method attempts to acquire the lock in a non-blocking way. It returns true if successful and false otherwise.
The unlock method releases the lock.
ReadWriteLock
When working with shared data and resources, you typically want two things:
- Multiple threads must be able to read the resource at a time if it is not being written.
- Only one thread can write to the shared resource at a time if no other thread is reading or writing.
The ReadWriteLock interface achieves this by using two locks instead of one. The read lock can be acquired by multiple threads at the same time if no thread has acquired the write lock. The write lock can only be acquired if the read and write lock has not been acquired.
Here is an example to demonstrate. Suppose we have a SharedCache class that simply stores key-value pairs as shown below.
public class SharedCache { private Map<String, String> cache = new HashMap<> ; public String readData(String key) { return cache.get(key); } public void writeData(String key, String value) { cache.put(key, value); } }
We want multiple threads to read our cache at the same time (while it is not being written to). But only one thread can write our cache at a time. To achieve this, we will use ReentrantReadWriteLock which is an implementation of the ReadWriteLock interface.
public class SharedCache { private Map<String, String> cache = new HashMap<> ; private ReentrantReadWriteLock lock = new ReentrantReadWriteLock ; public String readData(String key) { lock.readLock.lock; try { return cache.get(key); } finally { lock.readLock.unlock; } } public void writeData(String key, String value) { lock.writeLock.lock; try { cache.put(key, value); } finally { lock.writeLock.unlock; } } }
Synchronized blocks and methods
Synchronized blocks are pieces of Java code that can be executed by only one thread at a time. They are a simple way to implement synchronization between threads.
//SYNTAX synchronized (Object reference_object) { // code you want to be synchronized }
When creating a synchronized block, you need to pass a reference object. In the example above, “this” or the current object is the reference object, which means that if multiple instances of are created, they will not be synchronized.
You can also synchronize a method using the synchronized keyword.
public synchronized int increment;
Impasses
Deadlock occurs when two or more threads are unable to proceed because each of them is waiting for the other to release a resource or perform a specific action. As a result, they remain stuck indefinitely, unable to progress.
Consider this: you have two threads and two locks (let's call them threadA, threadB, lockA and lockB). ThreadA will attempt to acquire lockA first, and if successful, it will attempt to acquire lockB. ThreadB, on the other hand, tries to acquire lockB first and then lockA.
import java.util.concurrent.locks.ReentrantLock; public class Main { public static void main(String args) { ReentrantLock lockA = new ReentrantLock ; ReentrantLock lockB = new ReentrantLock ; Thread threadA = new Thread( -> { lockA.lock; try { System.out.println("Thread-A has acquired Lock-A"); lockB.lock; try { System.out.println("Thread-A has acquired Lock-B"); } finally { lockB.unlock; } } finally { lockA.unlock; } }); Thread threadB = new Thread( -> { lockB.lock; try { System.out.println("Thread-B has acquired Lock-B"); lockA.lock; try { System.out.println("Thread-B has acquired Lock-A"); } finally { lockA.unlock; } } finally { lockB.unlock; } }); threadA.start; threadB.start; } }
Here, ThreadA acquires lockA and waits for lockB. ThreadB has acquired lockB and is waiting to acquire lockA. Here threadA will never acquire lockB as it is held by threadB. Likewise, threadB can never acquire lockA as it is held by threadA. This type of situation is called an impasse.
Here are some points you should keep in mind to avoid deadlocks.
- Set a strict order in which resources must be acquired. All threads must follow the same order when requesting resources.
- Avoid nesting locks or synchronized blocks. The cause of the deadlock in the previous example was that the threads were unable to release one lock without acquiring the other lock.
- Make sure threads don't acquire multiple resources simultaneously. If a thread holds one resource and needs another, it must release the first resource before attempting to acquire the second. This avoids circular dependencies and reduces the likelihood of conflicts.
- Set timeouts when acquiring locks or resources. If a thread fails to acquire a lock within a specified time, it releases all acquired locks and tries again later. This avoids a situation where a thread holds a lock indefinitely, potentially causing a deadlock.
Java Concurrent Collections
The Java platform provides several concurrent collections in the “java.util.concurrent” package that are designed to be thread safe and support concurrent access.
ConcurrentHashMap
ConcurrentHashMap is a thread-safe alternative to HashMap. It provides optimized methods for atomic updates and recovery operations. For example, the putIfAbsent , remove , and replace methods perform operations atomically and avoid race conditions.
CopyOnWriteArrayList
Consider a scenario where one thread is trying to read or iterate over an array list while another thread is trying to modify it. This can create inconsistencies in read operations or even throw ConcurrentModificationException.
CopyOnWriteArrayList solves this problem by copying the contents of the entire array whenever it is modified. This way, we can iterate over the previous copy while a new copy is being modified.
CopyOnWriteArrayList's thread safety mechanism comes at a cost. Modification operations, such as adding or removing elements, are expensive because they require creating a new copy of the underlying array. This makes CopyOnWriteArrayList suitable for scenarios where reads are more frequent than writes.
Alternatives to Concurrency in Java
If you want to build a high-performance application independent of the operating system, opting for Java concurrency is a great option. But it's not the only show in town. Here are some alternatives you can consider.
The Go programming language, also known as Golang, is a statically typed programming language developed by Google and widely used in Go development services. This software solution is praised for its efficient and streamlined performance, especially in handling concurrent operations . Central to its concurrency prowess is its use of goroutines, lightweight threads managed by the Go runtime, that make concurrent programming simple and highly efficient.
Scala, a JVM-compatible language that seamlessly combines functional and object-oriented paradigms, is the preferred choice of many Scala development companies. One of its strongest features is the robust library known as Akka. Designed specifically to handle concurrent operations, Akka employs the Actor model for concurrency, providing an intuitive and less error-prone alternative to traditional thread-based concurrency.
Python, a widely used language in python development services, comes equipped with concurrent programming libraries such as asyncio, multiprocessing, and threading. These libraries empower developers to manage parallel and concurrent execution effectively. However, it is important to be aware of Python's Global Interpreter Lock (GIL), which can limit threading efficiency, especially for CPU-bound tasks.
Erlang/OTP is a functional programming language that was designed specifically for building highly concurrent systems. OTP is a middleware that offers a collection of design principles and libraries for developing such systems.
Conclusion
Java concurrency opens the door to better application performance through parallel computing and multithreading, a valuable feature for developers in the era of multi-core processors. It leverages robust APIs, making concurrent programming efficient and reliable. However, it brings its own set of challenges. Developers need to manage shared resources carefully to avoid problems such as deadlocks, race conditions, or thread interference. The complexity of concurrent programming in Java may require more time to master, but the potential benefits to application performance make the effort worthwhile.
If you enjoyed this Java concurrency tutorial, be sure to check out our other Java resources
- Best Java GUI Frameworks
- Best Java IDEs and Text Editors
- 10 Best Java NLP Libraries and Tools
- Java Performance Tuning: 10 Proven Techniques to Maximize Java Speed
- 7 Best Java Profiler Tools for 2021