Sitemap
Dev Genius

Coding, Tutorials, News, UX, UI and much more related to development

Learning Java from Scratch with Arbiter: Threads, Race Conditions, synchronized, and Deadlocks

Four threads, one counter, and a silently wrong total — fixed with synchronized, plus a deadlock caused and cured on purpose

12 min read9 hours ago

--

Press enter or click to view image in full size
Four threads race toward one shared counter — and sometimes the total quietly comes out wrong.
class Print1To10 implements Runnable {
public int sum = 0;

public void run() {
for (int i = 1; i <= 10; i++) {
System.out.println("Thread Name: " + Thread.currentThread().getName() + ", task value: " + i);
sum += i;
}
}
}

class PrintEven implements Runnable {
public void run() {
for (int i = 2; i <= 20; i += 2) {
System.out.println("Thread Name: " + Thread.currentThread().getName() + ", task value: " + i);
}
}
}

class PrintOdd implements Runnable {
public void run() {
for (int i = 1; i < 20; i += 2) {
System.out.println("Thread Name: " + Thread.currentThread().getName() + ", task value: " + i);
}
}
}

class IncrementCounter {
protected int counter = 0;
public int incrementCount = 100000;

public int getCounterValue() {
return counter;
}
}

class IncrementCounterUnsynchronized extends IncrementCounter implements Runnable{
public void run() {
for (int i = 0; i < incrementCount; i++) {
counter++;
}
}
}

class IncrementCounterSynchronized extends IncrementCounter implements Runnable {
private final Object lockCounter = new Object();

public void run() {
for (int i = 0; i < incrementCount; i++) {
synchronized (lockCounter) {
counter++;
}
}
}
}

public class java_15ThreadsRaces {
public static void main(String[] args) {
Print1To10 print1To10 = new Print1To10();

Thread print1To10Task = new Thread(print1To10);
Thread printEvenTask = new Thread(new PrintEven());
Thread printOddTask = new Thread(new PrintOdd());

Thread print5Multiple = new Thread(() -> {
for (int i = 5; i <= 50; i += 5) {
System.out.println("Thread Name: " + Thread.currentThread().getName() + ", task value: " + i);
}
});

print1To10Task.start();
printEvenTask.start();
printOddTask.start();
print5Multiple.start();

try {
print1To10Task.join();
printEvenTask.join();
printOddTask.join();
print5Multiple.join();
} catch (InterruptedException e) {
System.out.println("Exception Occurred: " + e);
}

boolean print1To10TaskCompleted = print1To10Task.isAlive();
boolean printEvenTaskCompleted = printEvenTask.isAlive();
boolean printOddTaskCompleted = printOddTask.isAlive();
boolean print5MultipleCompleted = print5Multiple.isAlive();

System.out.println(
!print1To10TaskCompleted &&
!printEvenTaskCompleted &&
!printOddTaskCompleted &&
!print5MultipleCompleted ? "All tasks complete. Result of Print1To10Numbers: " + print1To10.sum : "Tasks still pending"
);

System.out.println();
System.out.println("unsynchronized [5+ runs, showing some incorrect] Unsynchronized Run + ==================== +");
for (int i = 0; i < 5; i++) {
IncrementCounterUnsynchronized unsynchronizedIncrement = new IncrementCounterUnsynchronized();
int incrementCountUnsynchronized = unsynchronizedIncrement.incrementCount;

Thread unsyncThread1 = new Thread(unsynchronizedIncrement);
Thread unsyncThread2 = new Thread(unsynchronizedIncrement);
Thread unsyncThread3 = new Thread(unsynchronizedIncrement);
Thread unsyncThread4 = new Thread(unsynchronizedIncrement);

unsyncThread1.start();
unsyncThread2.start();
unsyncThread3.start();
unsyncThread4.start();

try {
unsyncThread1.join();
unsyncThread2.join();
unsyncThread3.join();
unsyncThread4.join();
} catch (InterruptedException e) {
System.out.println("Exception Occurred: " + e);
}

System.out.println("Counter Value Expected (4 threads times " + incrementCountUnsynchronized + "): " + 4 * incrementCountUnsynchronized);
System.out.println("Counter Value Actual (Run " + (i+1) + "): " + unsynchronizedIncrement.getCounterValue());
}
System.out.println(" + ==================== + Unsynchronized Run ");

// This works because synchronized locks another thread to interrupt 3 step process of increment
// ReentrantLock was not used due to not requiring much of the methods available by it and synchronized can handle this task efficiently
System.out.println("\nfixed [5+ runs, all exactly correct] Synchronized Run + ==================== +");
for (int i = 0; i < 5; i++) {
IncrementCounterSynchronized synchronizedIncrement = new IncrementCounterSynchronized();
int incrementCountSynchronized = synchronizedIncrement.incrementCount;

Thread syncThread1 = new Thread(synchronizedIncrement);
Thread syncThread2 = new Thread(synchronizedIncrement);
Thread syncThread3 = new Thread(synchronizedIncrement);
Thread syncThread4 = new Thread(synchronizedIncrement);

syncThread1.start();
syncThread2.start();
syncThread3.start();
syncThread4.start();

try {
syncThread1.join();
syncThread2.join();
syncThread3.join();
syncThread4.join();
} catch (InterruptedException e) {
System.out.println("Exception Occurred: " + e);
}

System.out.println("Counter Value Expected (4 threads times " + incrementCountSynchronized + "): " + 4 * incrementCountSynchronized);
System.out.println("Counter Value Actual (Run " + (i+1) + "): " + synchronizedIncrement.getCounterValue());
}
System.out.println(" + ==================== + Synchronized Run ");

final Object lockA = new Object();
final Object lockB = new Object();

System.out.println();
System.out.println("=== 1. DEMONSTRATING DEADLOCK ===");
Thread deadlockIncorrectA = new Thread(() -> {
try {
synchronized (lockA) {
System.out.println(Thread.currentThread().getName() + " acquired Lock A");
Thread.sleep(50);

synchronized (lockB) {
System.out.println(Thread.currentThread().getName() + " acquired Lock B");
}
}
} catch (InterruptedException e) {
System.out.println("Exception Occured: " + e);
}
});

Thread deadlockIncorrectB = new Thread(() -> {
try {
synchronized (lockB) {
System.out.println(Thread.currentThread().getName() + " acquired Lock B");
Thread.sleep(50);

synchronized (lockA) {
System.out.println(Thread.currentThread().getName() + " acquired Lock A");
}
}
} catch (InterruptedException e) {
System.out.println("Exception Occured: " + e);
}
});

deadlockIncorrectA.setDaemon(true);
deadlockIncorrectB.setDaemon(true);

deadlockIncorrectA.start();
deadlockIncorrectB.start();

try {
deadlockIncorrectA.join(2000);
deadlockIncorrectB.join(2000);
} catch (InterruptedException e) {
System.out.println("Exception Occured: " + e);
}

boolean thread1Alive = deadlockIncorrectA.isAlive();
boolean thread2Alive = deadlockIncorrectB.isAlive();

System.out.println("Timeout expired. Thread 1 alive? " + thread1Alive);
System.out.println("Timeout expired. Thread 2 alive? " + thread2Alive);
System.out.println(thread1Alive && thread2Alive ? "-> Result: Deadlock successfully occurred!\n" : "-> Result: Threads finished.\n");

final Object lockC = new Object();
final Object lockD = new Object();
// NOTE: not using locks A and B as they are deadlocked until main is completed,
// Therefore using the same locks will show the same output and not provide a solution to deadlock

System.out.println("=== 2. FIXING THE DEADLOCK (Consistent Order) ===");
Thread deadlockCorrectA = new Thread(() -> {
try {
synchronized (lockC) {
System.out.println(Thread.currentThread().getName() + " acquired Lock C");
Thread.sleep(50);

synchronized (lockD) {
System.out.println(Thread.currentThread().getName() + " acquired Lock D");
}
}
} catch (InterruptedException e) {
System.out.println("Exception Occured: " + e);
}
});

Thread deadlockCorrectB = new Thread(() -> {
try {
synchronized (lockC) {
System.out.println(Thread.currentThread().getName() + " acquired Lock C");
Thread.sleep(50);

synchronized (lockD) {
System.out.println(Thread.currentThread().getName() + " acquired Lock D");
}
}
} catch (InterruptedException e) {
System.out.println("Exception Occured: " + e);
}
});

long startTime = System.currentTimeMillis();

deadlockCorrectA.start();
deadlockCorrectB.start();

try {
deadlockCorrectA.join(2000);
deadlockCorrectB.join(2000);
} catch (InterruptedException e) {
System.out.println("Exception Occured: " + e);
}

long elapsedTime = System.currentTimeMillis() - startTime;
boolean fixedThread1Alive = deadlockCorrectA.isAlive();
boolean fixedThread2Alive = deadlockCorrectB.isAlive();

System.out.println("Elapsed time: " + elapsedTime + " ms");
System.out.println("Timeout expired. Thread 1 alive? " + fixedThread1Alive);
System.out.println("Timeout expired. Thread 2 alive? " + fixedThread2Alive);
System.out.println(fixedThread1Alive && fixedThread2Alive ? "-> Result: Deadlock successfully occurred!\n" : "-> Result: Threads finished.\n");
}
}

Until today, every program in this series has done one thing at a time. A line ran, finished, and the next line ran. That is a comfortable way to write code, and it is about to stop being true.

In the last lesson, we built lazy Streams pipelines with reduce and groupingBy. The theory behind them is in the companion article on lazy evaluation, folds, and Optional. Today we make Java do several things at once, and we meet the first bug in this series that never throws an exception, never prints a warning, and sometimes doesn't even happen.

The program above does four things:

  • Runs four independent tasks on four threads at the same time
  • Lets four threads increment one shared counter with no protection, and watches the total go wrong
  • Fixes the counter with synchronized
  • Builds a deadlock on purpose, proves it happened, then removes it with one rule

Thread and Runnable: Describing Work vs. Running It

In Java, a Runnable is a piece of work and a Thread is the worker that performs it. Describing the work is the easy part:

class PrintEven implements Runnable {
public void run() {
for (int i = 2; i <= 20; i += 2) {
System.out.println("Thread Name: " + Thread.currentThread().getName() + ", task value: " + i);
}
}
}

Runnable has exactly one method, run(). Because it has only one abstract method, it can also be written as a lambda, the same arrow syntax we used with Streams:

Thread print5Multiple = new Thread(() -> {
for (int i = 5; i <= 50; i += 5) {
System.out.println("Thread Name: " + Thread.currentThread().getName() + ", task value: " + i);
}
});

Our program creates four threads this way: Print1To10 (a class that also stores a sum), PrintEven, PrintOdd, and the lambda above. Thread.currentThread().getName() is how each line reveals which thread printed it.

The most important rule is the next one. Calling .start() creates a new thread of execution. Calling .run() directly just runs the method on the thread you are already on, like any ordinary method call. Nothing concurrent happens.

print1To10Task.start();
printEvenTask.start();
printOddTask.start();
print5Multiple.start();

The Output Order Is Not Yours to Control

Run this program a few times and the first block of output changes. In some runs Thread-0 prints all ten of its numbers before any other thread says a word. In other runs the lines from Thread-1, Thread-2, and Thread-3 are mixed together. The JVM and the operating system decide who runs when, and each task here is so short that one thread can finish before the next has been scheduled.

Both orderings are correct. The only guarantee we have is within a single thread: Thread-1 will always print 2 before 4 before 6. Across threads, there is no promised order.

Start Everything First, Join Afterwards

print1To10Task.start();
printEvenTask.start();
printOddTask.start();
print5Multiple.start();

print1To10Task.join();
printEvenTask.join();
printOddTask.join();
print5Multiple.join();

join() makes the calling thread, here main, wait until that thread's run() has returned. The order matters. All four start() calls come first, so all four threads are running before main waits for anything. If we wrote start(); join(); start(); join(); for each thread, every thread would have to finish before the next one began, and we would have rebuilt a sequential program with extra steps.

After the joins, main checks isAlive() on each thread and prints the sum that Print1To10 accumulated:

All tasks complete. Result of Print1To10Numbers: 55

That 55 is reliable because join() guarantees that everything the thread wrote is visible to main once join() returns.

The Counter That Lies

Now the interesting part. Four threads, each running the same loop 100,000 times, all incrementing one shared field:

class IncrementCounterUnsynchronized extends IncrementCounter implements Runnable {
public void run() {
for (int i = 0; i < incrementCount; i++) {
counter++;
}
}
}

All four threads are handed the same IncrementCounterUnsynchronized object, so they share one counter. The expected result is 4 x 100,000 = 400,000. Here is what one recorded run produced:

Counter Value Expected (4 threads times 100000): 400000
Counter Value Actual (Run 1): 135072

Nothing crashed and no exception was thrown. The program simply reported a wrong number, confidently.

The reason is that counter++ looks like one operation but is three: read the current value, add one, write the result back. Two threads can read the same value before either writes. Both compute the same new value and both write it. Two increments happened, but the counter only moved by one. This is called a lost update.

Press enter or click to view image in full size
Two timelines comparing an unprotected counter, where two threads read 5 and both write 6, with a locked counter where the second thread waits and the result is 7.
Without a lock, two threads can read the same value and overwrite each other. With a lock, each read-add-write finishes before the next begins.

There is a second, quieter lesson here. In the loop of five runs, some iterations come out exactly right by luck. A run that passes proves nothing. A race condition is a probability, not a certainty, which is also why it only shows up reliably at 400,000 increments and would be easy to miss at 10.

synchronized: One Thread at a Time

The fix gives every thread the same lock object and makes the increment happen inside it:

class IncrementCounterSynchronized extends IncrementCounter implements Runnable {
private final Object lockCounter = new Object();

public void run() {
for (int i = 0; i < incrementCount; i++) {
synchronized (lockCounter) {
counter++;
}
}
}
}

Every Java object has an invisible lock, called a monitor. Entering a synchronized (lockCounter) block means taking that monitor, and only one thread can hold it at a time. While one thread is inside, the others wait at the door. That makes read, add, and write behave as one indivisible step.

Two details are easy to miss. First, the lock only works because all four threads share the same instance, and so the same lockCounter. If each thread had its own object, each would have its own lock and nothing would be protected. Second, the block releases the lock when it exits, even if an exception is thrown inside it, so the lock cannot be forgotten.

Get Someone’s stories in your inbox

Join Medium for free to get updates from this writer.

We could have used ReentrantLock, but we only need basic mutual exclusion, and synchronized handles that with less code. The result across all five runs:

Counter Value Expected (4 threads times 100000): 400000
Counter Value Actual (Run 1): 400000
...
Counter Value Actual (Run 5): 400000

Deadlock: Two Locks, Opposite Order

A lock fixes the race, but locks create a new way to fail. Here two threads each need two locks, and they ask for them in opposite orders:

// Thread A                          // Thread B
synchronized (lockA) { synchronized (lockB) {
Thread.sleep(50); Thread.sleep(50);
synchronized (lockB) { ... } synchronized (lockA) { ... }
} }

Thread A takes lockA, Thread B takes lockB, and the sleep(50) makes sure both have done so before either asks for its second lock. Now A waits for lockB, which B holds, and B waits for lockA, which A holds. Neither can leave its block to release what it holds until it has the second lock. They will wait forever.

A program that hangs forever is a bad way to prove a point, so the code uses two safety nets:

deadlockIncorrectA.setDaemon(true);
deadlockIncorrectB.setDaemon(true);

deadlockIncorrectA.start();
deadlockIncorrectB.start();

deadlockIncorrectA.join(2000);
deadlockIncorrectB.join(2000);

setDaemon(true) marks the threads as background threads, so the JVM can still exit when main finishes even though they are stuck. join(2000) waits at most two seconds instead of forever. After that, isAlive() is the evidence:

=== 1. DEMONSTRATING DEADLOCK ===
Thread-44 acquired Lock A
Thread-45 acquired Lock B
Timeout expired. Thread 1 alive? true
Timeout expired. Thread 2 alive? true
-> Result: Deadlock successfully occurred!

Notice that each thread printed that it got its first lock, and neither ever printed the second. Both still alive after the timeout means they are blocked, not just slow. (The thread numbers 44 and 45 are only a global counter, and they show how many threads this program had already created by that point.)

The Fix: One Order for Everyone

The cure is not fewer locks. It is the same order every time:

// Thread A                          // Thread B
synchronized (lockC) { synchronized (lockC) {
Thread.sleep(50); Thread.sleep(50);
synchronized (lockD) { ... } synchronized (lockD) { ... }
} }

Both threads now take lockC first, then lockD. Whoever wins lockC makes the other wait at the first lock, and nobody can ever hold lockD while waiting for lockC, so a circular wait cannot form. We use fresh locks C and D on purpose: A and B are still held by the stuck daemon threads, so reusing them would just reproduce the deadlock.

=== 2. FIXING THE DEADLOCK (Consistent Order) ===
Thread-46 acquired Lock C
Thread-46 acquired Lock D
Thread-47 acquired Lock C
Thread-47 acquired Lock D
Elapsed time: 102 ms
Timeout expired. Thread 1 alive? false
Timeout expired. Thread 2 alive? false
-> Result: Threads finished.

The two threads now run one after the other instead of side by side, and the elapsed time of about 102 ms shows it: two sleep(50) calls back to back. Slightly sequential and correct is a far better outcome than concurrent and frozen. The thread names also show they never overlapped, with Thread-46 completing both locks before Thread-47 began.

Key Takeaways

  • .start() creates a thread; .run() is just a method call
  • Start all threads before joining any of them, or the work becomes sequential again
  • Output order across threads is not guaranteed and changes between runs
  • counter++ is three steps, and unprotected shared state can lose updates with no error at all
  • A passing run does not prove a race condition is absent
  • synchronized on one shared lock object makes the read-add-write step indivisible
  • Two threads taking two locks in opposite orders can deadlock; taking them in the same order cannot
  • join(timeout) plus isAlive() lets you prove a deadlock without hanging the program

The Arbiter Journey So Far

This is lesson 15 of the Arbiter Learning Journey, a Java-from-scratch series building toward Arbiter, a microservices-based test management platform. Every lesson comes with a runnable source file, and every concept is written and tested by hand before moving on. A test platform runs many things at once, such as test executions, notifications, and API requests, so today’s lesson is groundwork for what comes later.

Watch the series on YouTube

Code on GitHub

Arbiter Lessons

Other series on this account

What’s Next

This program created 48 Thread objects by hand, and that is already awkward for what is a very small amount of work. In the next lesson we hand that job to the JVM with ExecutorService and thread pools, and then look at CompletableFuture for results that arrive later.

Read the companion theory article here.

--

--

Dev Genius
Dev Genius

Published in Dev Genius

Coding, Tutorials, News, UX, UI and much more related to development