Getting Started With Concurrency In Java


In the last post, we were introduced to the concept of concurrency. In this post, we will look at how to get started with concurrency using Java, an object-oriented programming language. This assumes that you have some experience with Java.
There are two levels at which concurrency can be considered: one is the operating system level, and the other is the language level. At the operating system level, the central entity is the process. A process is a running instance of a program that is allocated a dedicated portion of the main memory. This way, single-core computers can run multiple processes simultaneously by alternating CPU time between them. At the language level, a popular option is the thread. But what is a thread?
A thread can be viewed as the smallest unit of executable code. Yeah, yeah, textbook definition, but it's a good starting point. It's saying that a thread can't be split into even smaller pieces of execution. Threads belong to a parent process and share resources - like memory, files and code - with other threads in the same process. In addition to this, each thread has its own exclusive state called the stack. There are two basic ways to create a thread in Java. One way is to subclass the Thread
class. Another way is to implement the Runnable
interface. We'll examine these two methods of creating threads.
Thread Creation (Using Thread
)
As noted earlier, a thread can be created by extending the Thread
class. Here's how it's done.
// App.java
class Activity extends Thread {
public Activity(String title) {
this.title = title;
}
@Override
public void run() {
System.out.println("Activity: " + title);
}
}
public class App {
public static void main(String[] args) throws InterruptedException {
var activity = new Activity("Brush Teeth");
activity.start();
activity.join();
}
}
Several things are happening in this piece of code, but for now, we're interested in the Activity
class definition. It extends the Thread
class and overrides the run
method. The run
method is a special one; the code in this method is what runs when the thread starts. A thread is created when an instance of the Activity
class is created. But a thread doesn't start immediately after it's created; that would be magical and mostly undesirable. To start a thread, the start
method has to be called on the Thread
instance. And that's how to create and start a thread using the Thread
class.
Thread Creation (Using Runnable
)
The second way to create a thread is by implementing the Runnable
interface. It's not so different from the first approach. Let's see how to create activity threads using this approach.
class Activity implements Runnable {
private final String title;
public Activity(String title) {
this.title = title;
}
@Override
public void run() {
System.out.println("Activity: " + title);
}
}
public class App {
public static void main(String[] args) throws InterruptedException {
var activity = new Thread(new Activity("Brush Teeth"));
activity.start();
activity.join();
}
}
The first thing we notice is that Activity
now implements Runnable
instead of extending Thread
; apart from that, there isn't any difference in the definition of the Activity
class. The other difference is in the way the instance was created. There's a familiar face there; the Thread
class. In the end, the Thread
class is always used; one may wonder why the Runnable
approach exists. The Runnable
approach is preferred in most cases. Here are several reasons for this.
Using
Runnable
allows us to extend another class; we can't do the same with theThread
approach since Java doesn't support multiple inheritance.The
Runnable
approach enables the usage of advanced concurrency patterns.
You may have heard about Callable.
Callable
is another interface like Runnable
that can be used to create workloads to run on a thread. The difference lies in the fact that Callable
allows you to return a value and throw a checked exception, two things you cannot do with a Runnable
. We won’t go into details about Callable
in this post.Lifecycle Of A Thread
To understand concurrency better, it's important to know what states a thread goes through during its lifetime. In Java, a thread can be in one of several states:
New – The thread has been created but hasn’t started yet (i.e.,
start()
hasn’t been called).Runnable – The thread is executing or scheduled to execute in the Java Virtual Machine (JVM).
Blocked – The thread is inactive, waiting to acquire a monitor lock (in case of synchronisation).
Waiting – The thread is waiting indefinitely until another thread signals it (through
notify()
ornotifyAll()
).Timed Waiting – The thread is waiting for a specific amount of time (e.g., via
sleep()
orjoin(timeout)
).Terminated – The thread has finished execution, either by completing its task or being stopped abruptly due to an exception.
The current state of a thread can be checked using thread.getState()
. Understanding the lifecycle helps in writing code that behaves predictably, especially when multiple threads are involved.
The Need For Synchronisation
Threads in the same process share memory. This makes it easy for them to communicate, but also introduces a potential problem: race conditions. This occurs when multiple threads access shared data simultaneously, and at least one thread modifies the data. The result can be inconsistent or unpredictable behaviour.
Example of a Race Condition
class Counter {
int count = 0;
public void increment() {
count++;
}
}
public class App {
public static void main(String[] args) throws InterruptedException {
var counter = new Counter();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 100_000_000; i++) {
counter.increment();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 100_000_000; i++) {
counter.increment();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("Count: " + counter.count); // You'd expect 200,000,000, but...
}
}
You might expect the final count to be 200,000,000, but it often isn't. Why? Because count++
is not atomic, it’s a combination of read, increment, and write operations. If you are thinking, “This is more complex than I want 😢", worry not, for that is why synchronized
and other options exist.
Enter Synchronisation
To make critical sections (shared code that must not be concurrently accessed) safe, we can use the synchronized
keyword:
class Counter {
int count = 0;
public synchronized void increment() { // Synchronized on an instance method
//Different threads can call the same method on different instances
count++;
}
public static synchronized void incrementStatic() { // Synchronized on a static method
// Only one thread can execute a static synchronised block at a time
// Do something
}
public void incrementBlock() { // Synchronized block
synchronized(this) { // Only this block of code is synchronised, not the entire method
// Do something
}
}
}
Now, only one thread can access increment()
at a time, preventing race conditions.
But be careful: overusing synchronisation can lead to thread contention (threads waiting for each other) and even deadlocks (threads blocked, each holding a resource and waiting for another resource held by a different blocked thread) if not managed properly.
Locks are also another way to achieve synchronisation.
Communication Across Threads
Sometimes threads need to coordinate—perhaps one thread must wait for another to finish some work before proceeding. Java provides several ways to achieve this.
Using join()
As seen earlier, join()
allows one thread to wait for another to finish:
thread1.start();
thread1.join(); // Waits until thread1 completes
Using wait()
and notify()
Java also allows threads to communicate via shared objects using wait()
, notify()
, and notifyAll()
. These are lower-level tools that require synchronised context:
class Mailbox {
private String message;
private boolean empty = true;
public synchronized void put(String msg) throws InterruptedException {
while (!empty) wait();
message = msg;
empty = false;
notifyAll();
}
public synchronized String take() throws InterruptedException {
while (empty) wait();
empty = true;
notifyAll();
return message;
}
}
This is a classic producer-consumer example. One thread can put a message into the mailbox, and another can take it, using wait()
to pause when necessary and notifyAll()
to resume waiting threads.
Java also provides higher-level abstractions like
BlockingQueue
,CountDownLatch
, andExecutorService
that make thread communication easier and more robust—but we’ll explore those in a future article.
Wrapping Up
In this post, we’ve taken a practical look at how concurrency is handled in Java. We’ve seen how to create threads using both Thread
and Runnable
, discussed the lifecycle of a thread, looked at why synchronisation is necessary, and how threads can communicate with each other.
Concurrency can be tricky, but it's also incredibly powerful. Java gives us several tools to manage it effectively, from low-level thread manipulation to higher-level abstractions. In the next post, we’ll build on this foundation by exploring Executor Services and how they simplify thread management.
Subscribe to my newsletter
Read articles from Joshua Farayola directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by

Joshua Farayola
Joshua Farayola
Hi, I’m Joshua, a passionate developer and lifelong learner who enjoys building with code and sharing insights along the way. I write about Java and web development. Always interested in discussions around clean code and developer workflows. Feel free to reach out or leave a comment — I’m always happy to connect.