1. Blog
  2. Software Development
  3. Java Concurrency: Master the Art of Multithreading
Software Development

Java Concurrency: Master the Art of Multithreading

Unlock the power of Java Concurrency for efficient and scalable applications. Maximize performance and responsiveness with concurrent programming.

BairesDev Editorial Team

By BairesDev Editorial Team

BairesDev is an award-winning nearshore software outsourcing company. Our 4,000+ engineers and specialists are well-versed in 100s of technologies.

16 min read

Featured image

Unpack the power of multi-threading and parallel computing to supercharge your applications with Java concurrency. Beginner or expert, this guide will catapult your skills into the fast-paced arena of concurrent programming. Buckle up and let’s unravel Java’s robust APIs for a riveting ride in the realm of high-performance coding!

What Is Concurrency?

Concurrency is used by all the top Java development companies, and refers to the ability of a program to execute multiple tasks simultaneously. It enables the efficient utilization of system resources and can improve the overall performance and responsiveness of the application.

Java concurrency’s concepts, classes, and the interfaces used for multithreading, such as `Thread`, `Runnable`, `Callable`, `Future`, `ExecutorService`, and the classes in `java.util.concurrent`, are part of the standard Java libraries so there shouldn’t be too much difference across 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 where multiple threads of execution exist within a single application.

Multithreading is just one way to achieve concurrency in Java. Concurrency can also be achieved through 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 a variety of reasons.

Improved Performance

Concurrency allows for the division of complex and time-consuming tasks into smaller parts that can be executed simultaneously, resulting in improved performance. This makes the most 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 improved resource efficiency. By implementing asynchronous I/O operations, the system can avoid blocking a single thread and allow other tasks to run concurrently, thus maximizing resource utilization and system efficiency.

Improved Responsiveness

Improved Responsiveness: Concurrency can enhance the user experience in interactive applications by guaranteeing that the application remains responsive. During the execution of a computationally intensive task by one thread, another thread can concurrently handle user inputs or UI updates.

Simplified Modeling

In certain scenarios such as simulations or game engines, concurrent entities are inherent to the problem domain, and thus a concurrent programming approach is both more intuitive and more performant. This is commonly referred to as 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 streamline the development of concurrent code and mitigate prevalent concurrency problems.

Concurrency Drawbacks

It is important to understand that concurrent programming isn’t for the beginner. It brings an increased level of intricacy to your applications and entails a distinct set of difficulties, such as managing synchronization, preventing deadlocks, and guaranteeing thread safety and that’s not all. Here are a few things to consider before diving in.

Complexity: Writing concurrent programs can be more difficult and time-consuming than writing single-threaded programs. It is imperative for developers to have a grasp of synchronization, memory visibility, atomic operations, and thread communication.

Debugging Difficulties: The non-deterministic nature of concurrent programs can pose a challenge when debugging. The occurrence of race conditions or deadlocks can be inconsistent, which poses a challenge in reproducing and resolving them.

Error Potential: Improper handling of concurrency can lead to errors like race conditions, deadlocks, and thread interference. This issue may 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 performance loss.

Overhead: Creating and maintaining threads increases the CPU and memory use on your machine. Insufficient management may 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 whilst concurrency is a great option, it’s not all plain sailing.

Java Concurrency Tutorial: Thread Creation

Threads can be created in three ways. Here, we will create the same thread using different methods.

By Inheriting From Thread Class

One way to create a thread is by inheriting it from the thread class. Then, all you have to do is override the thread object’s run() method. The run() method will be invoked when the thread is started.

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();
    }
}

One common mistake is to call the run() method to start the thread. It might seem correct as everything works just fine, but calling the run() method does not start a new thread. Instead, it executes the code of the thread inside the parent thread. We use the start() method to execute a new thread.

You can test this by calling “thread.run()” instead of “thread.start()” in the above code. You’ll see that “main” is printed in the console, which means we are not creating any threads. Instead, the task is executed in the main thread. For more on thread class, be sure to check the docs.

By Implementing Runnable Interface

Another way of creating 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 you want the runnable 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 the 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 since you can inherit only one class in Java. It’s also easier to create a thread pool using runnables.

By Using Anonymous Declarations

This method is very similar to the above method. But instead of creating a new class that implements the runnable method, you create an anonymous function that contains the task you want to execute.

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 state of waiting until the threadOne has finished execution.

Calling Thread.sleep(long timeInMilliSeconds) static method will put the current thread into a state of timed waiting.

Thread Lifecycle

A thread can be in one of the following states. Use Thread.getState() to get the current state of the thread.

  1. NEW: created but has not started execution
  2. RUNNABLE: started execution
  3. BLOCKED: waiting to acquire a lock
  4. WAITING: waiting for some other thread to perform a task
  5. TIMED_WAITING: waiting for a specified time period
  6. TERMINATED: completed execution or aborted

Executors and Thread Pools

Threads require some resources to start, and they are stopped after the task is done. For applications with many tasks, you would want to queue up 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 where 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 which stores a variable count and a function that increments the count. In the RaceConditionsExample, we’re starting a thousand threads, each of which will invoke the increment() method. Finally, we’re waiting for all the threads to finish executing and then print out the value of the count variable.

If you run the code multiple times, you’ll 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. The threads can execute the read write operation in any order. So, there will be a case when the order of execution is 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 the threads are reading the count variable before any 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 examined what race conditions are. In order to avoid race conditions, we need to synchronize tasks. In this section, we’ll look at different ways to synchronize different processes across multiple threads.

Lock

There will be cases when you’d want a task to be executed by a single thread at a time. But how would you make sure a task is being executed by only one thread?

One way to do so 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 performing a task, the thread tries to acquire the lock. If it’s successful in doing so, it proceeds with the task. Once it’s done performing the task, it releases the lock. If the thread fails to acquire the lock, it means the task is being executed by another thread.

Here’s 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 in a thread, it tries to acquire the lock. If it’s successful, it executes the task. However, if it’s unsuccessful, the thread is blocked until the lock is released.

The isLocked() returns a boolean value depending on whether lock can be acquired or not.

The tryLock() method tries to acquire the lock in a nonblocking way. It returns true if it’s successful and false otherwise.

The unlock() method releases the lock.

ReadWriteLock

When working with shared data and resources, usually, you’d want two things:

  1. Multiple threads should be able to read the resource at a time if it’s not being written.
  2. Only one thread can write the shared resource at a time if no other thread is reading or writing it.

ReadWriteLock Interface achieves this by using two locks instead of one. The read lock can be acquired by multiple threads at a time if no thread has acquired the write lock. The write lock can be acquired only if both read and write lock have not been acquired.

Here’s 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’s not being written). But only one thread can write our cache at a time. To achieve this, we will use the 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 across threads.

// SYNTAX
synchronized (Object reference_object) {
 // code you want to be synchronized
}

When you create a synchronized block, you need to pass a reference object. In the above example ”this” or the current object is the reference object, which means if multiple instances of the are created, they won’t be synchronized.

You can also make a method synchronized by using the synchronized keyword.

public synchronized int increment();

Deadlocks

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 take a specific action. As a result, they remain stuck indefinitely, unable to make progress.

Consider this, you have two threads and two locks (let’s call them threadA, threadB, lockA and lockB). ThreadA will try to acquire lockA first and if it’s successful, it will try 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 is waiting to lockB. ThreadB has acquired lockB and is waiting to acquire lockA. Here, threadA will never acquire lockB as it is held by threadB. Similarly, threadB can never acquire lockA as it is held by threadA. This kind of situation is called a deadlock.

Here are some points to keep in mind to avoid deadlocks.

  1. Define a strict order in which resources should be acquired. All threads must follow the same order when requesting resources.
  2. Avoid nesting of locks or synchronized blocks. The cause for the deadlock in the previous example was that the threads were not able to release one lock without acquiring the other lock.
  3. Ensure that threads do not acquire multiple resources simultaneously. If a thread holds one resource and needs another, it should release the first resource before attempting to acquire the second. This prevents circular dependencies and reduces the likelihood of deadlocks.
  4. 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 prevents 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

The ConcurrentHashMap is a thread-safe alternative to HashMap. It provides methods optimized for atomic updates and retrieval operations. For example, putIfAbsent(), remove(), and replace() methods perform the 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 might create inconsistencies with read operations or even throw ConcurrentModificationException.

CopyOnWriteArrayList solves this problem by copying the content of the entire array every time it is modified. That way, we can iterate over the previous copy while a new copy is being modified.

The thread-safety mechanism of CopyOnWriteArrayList comes with a cost. Modifying 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 are looking to build a high-performance application that is operating system independent, then opting for Java concurrency is a great option. But it’s not the only show in town. Here are some alternatives you might consider.

The Go Programming Language, often known as Golang, is a statically-typed programming language developed by Google and used widely in Go development services. This software solution is lauded for its efficient and streamlined performance, especially in handling concurrent operations. Central to its prowess in concurrency is Go’s use of goroutines, lightweight threads managed by the Go runtime, which make concurrent programming both straightforward and highly efficient.

Scala, a JVM-compatible language that beautifully marries functional and object-oriented paradigms, is a favored choice of many a Scala development company. 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’s important to be aware of Python’s Global Interpreter Lock (GIL), which can limit the efficiency of threading, particularly for CPU-bound tasks.

Erlang/OTP is a functional programming language that has been specifically designed 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 improved application performance through parallel computing and multi-threading, a valuable asset for developers in the age of multi-core processors. It leverages robust APIs, making concurrent programming efficient and reliable. However, it does come with its own set of challenges. Java developers must manage shared resources carefully to avoid issues like deadlocks, race conditions, or thread interference. The complexity of concurrent programming in Java may require additional time to master, but the potential benefits for application performance make it a worthwhile endeavor.

If you liked this Java concurrency tutorial, be sure to check out our other resources on Java

Tags:
BairesDev Editorial Team

By BairesDev Editorial Team

Founded in 2009, BairesDev is the leading nearshore technology solutions company, with 4,000+ professionals in more than 50 countries, representing the top 1% of tech talent. The company's goal is to create lasting value throughout the entire digital transformation journey.

Stay up to dateBusiness, technology, and innovation insights.Written by experts. Delivered weekly.

Related articles

Contact BairesDev
By continuing to use this site, you agree to our cookie policy and privacy policy.