Class 45: Debugging and Testing Multithreaded Applications
Part 8 — Reliability, Debugging and Real-World Applications
Welcome back!
Throughout this course, we have learned how to create threads, share data safely, synchronize operations, coordinate threads, manage thread pools, and work with modern Java concurrency features.
We have also explored problems such as deadlock, starvation, and livelock.
Now let's discuss something very practical:
How do we debug and test a multithreaded application?
Debugging multithreaded applications can be more challenging than debugging ordinary sequential code. The reason is simple: multiple threads execute independently, and their execution order can change from one run to another.
Let's understand some useful techniques.
1. Why Is Debugging Multithreaded Applications Difficult?
Imagine that you have a method that increments a shared counter.
Two threads execute this method at the same time.
Depending on how their operations are interleaved, the final result might be different.
This means that a problem may not occur every time we run the application.
Some common challenges include:
- Threads execute in different orders across different runs.
- Race conditions may appear only under certain timing conditions.
- Deadlocks can cause threads to stop making progress.
- Shared data may contain unexpected values.
- Adding debug statements or breakpoints can change thread timing.
Therefore, we need to understand how to inspect the state of threads and reproduce concurrency problems.
2. Give Meaningful Names to Threads
Let's start with a very simple but useful practice.
Give meaningful names to your threads.
When debugging an application, names such as Thread-0, Thread-1, and Thread-2 do not tell us much about what those threads are doing.
Instead, use names that describe their responsibilities.
public class ThreadNamingExample {
public static void main(String[] args) {
Thread orderThread = new Thread(() -> {
System.out.println(
"Processing customer orders"
);
}, "Order-Processor");
Thread paymentThread = new Thread(() -> {
System.out.println(
"Processing payments"
);
}, "Payment-Processor");
orderThread.start();
paymentThread.start();
}
}
Now, when we inspect the application, we can understand which thread is responsible for processing orders and which one handles payments.
We can also retrieve the current thread's name using:
Thread.currentThread().getName()
Meaningful thread names make logs and debugging output much easier to understand.
3. Understand Thread States
We learned about the thread lifecycle in Class 6.
Java provides the Thread.State enum to represent the state of a thread.
The important states are:
- NEW: The thread has been created but has not started.
- RUNNABLE: The thread is eligible to run or is running.
- BLOCKED: The thread is waiting to acquire an intrinsic monitor lock.
- WAITING: The thread is waiting indefinitely for another thread to perform an action.
- TIMED_WAITING: The thread is waiting for a specified amount of time.
- TERMINATED: The thread has completed execution.
We can inspect a thread's state using getState().
public class ThreadStateExample {
public static void main(String[] args)
throws InterruptedException {
Thread worker = new Thread(() -> {
System.out.println(
"Worker is running"
);
}, "My-Worker");
System.out.println(
worker.getState()
);
worker.start();
worker.join();
System.out.println(
worker.getState()
);
}
}
The output will be:
NEW
Worker is running
TERMINATED
The thread is NEW before start() is called.
After it completes, its state becomes TERMINATED.
Remember that getState() gives us a snapshot. A thread can change its state immediately after we inspect it.
4. Inspecting Thread States in a Real Application
Let's imagine that our application has several worker threads.
We can inspect their names and states using a simple loop.
public class ThreadInspectionExample {
public static void main(String[] args)
throws InterruptedException {
Thread worker1 = new Thread(() -> {
try {
Thread.sleep(5000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}, "Order-Worker");
Thread worker2 = new Thread(() -> {
try {
Thread.sleep(5000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}, "Payment-Worker");
worker1.start();
worker2.start();
Thread.sleep(500);
System.out.println(
worker1.getName() + " : "
+ worker1.getState()
);
System.out.println(
worker2.getName() + " : "
+ worker2.getState()
);
worker1.join();
worker2.join();
}
}
While the worker threads are sleeping, their state will normally be TIMED_WAITING.
This gives us a simple way to inspect what our threads are doing.
However, inspecting one thread at a time is not always enough when an application has hundreds or thousands of threads.
For that, we need another useful debugging technique.
5. What Is a Thread Dump?
A thread dump is a snapshot of the threads running inside a Java application.
It provides information such as:
- Thread names.
- Thread states.
- What each thread is currently executing.
- Which locks a thread holds or is waiting to acquire.
- Stack traces showing the methods involved in execution.
A thread dump is especially useful when an application becomes unresponsive or when we suspect a deadlock.
Imagine that a production application is not processing customer orders.
Instead of guessing what is happening, we can capture a thread dump and inspect the threads.
6. How Can We Capture a Thread Dump?
Java provides several ways to capture thread dumps.
Option 1: Using jstack
The JDK provides a command-line utility called jstack.
We can use it to capture a thread dump of a running Java process.
jstack <process-id>
For example:
jstack 12345
Here, 12345 represents the process ID of the Java application.
We can also save the output into a file:
jstack 12345 > thread-dump.txt
We can then open the file and inspect the thread information.
Option 2: Using jcmd
Another useful JDK utility is jcmd.
We can capture a thread dump using:
jcmd 12345 Thread.print
This prints thread information for the selected Java process.
Option 3: Using the JVM Process
On many HotSpot-based JVMs, sending a SIGQUIT signal to a Java process on Unix-like systems produces a thread dump.
kill -3 12345
This normally writes the thread dump to the application's standard error output.
It is not the same as terminating the application. The exact output destination depends on how the application is configured.
7. How to Read a Thread Dump
Let's look at a simplified example of a thread dump entry.
"Order-Processor" #25
java.lang.Thread.State: BLOCKED
at com.example.OrderService.process(OrderService.java:45)
- waiting to lock <0x123456>
- locked <0x987654>
Let's understand the important parts.
- Order-Processor: The name of the thread.
- BLOCKED: The thread is waiting to acquire an intrinsic monitor lock.
- Stack trace: Shows the method where the thread is executing or waiting.
- waiting to lock: Shows a lock the thread is trying to acquire.
- locked: Shows a lock currently held by the thread.
This information helps us understand why a thread is waiting.
If several threads are waiting for locks held by one another, we may have a deadlock.
A thread dump can also show information about waiting threads, sleeping threads, and threads actively executing code.
8. Identifying Deadlocks Using Thread Dumps
Remember our previous class about deadlocks.
Imagine:
- Thread 1 holds Lock A and waits for Lock B.
- Thread 2 holds Lock B and waits for Lock A.
A thread dump can help us identify this situation.
HotSpot JVM thread dumps can also include an explicit deadlock report when a monitor or ownable synchronizer deadlock is detected.
For example, the dump may contain a message similar to:
Found one Java-level deadlock:
"Thread-1":
waiting to lock monitor held by "Thread-2"
"Thread-2":
waiting to lock monitor held by "Thread-1"
This tells us that the threads are waiting for locks held by each other.
We can then inspect the stack traces and identify the methods responsible for acquiring those locks.
9. Debugging Using an IDE
We can also debug multithreaded applications using an IDE such as IntelliJ IDEA or Eclipse.
The general debugging process is similar to debugging ordinary Java applications.
- Place a breakpoint inside the method you want to inspect.
- Run the application in debug mode.
- Inspect the current thread.
- Check local variables and shared object values.
- Use the IDE's thread view to inspect other threads.
Most Java IDEs provide a way to view active threads and their call stacks.
This is particularly useful when we want to understand which thread is executing a particular method.
Important: Breakpoints can pause threads and change the timing of a concurrent application. A problem that occurs during normal execution may disappear when we debug it.
10. Why Should We Test Multithreaded Applications?
Debugging helps us investigate problems.
Testing helps us check whether our application behaves correctly under different conditions.
Consider a shared counter.
We expect two threads to increment it 100,000 times each.
The expected result is 200,000.
Let's write a simple test program.
public class CounterTest {
private static int count = 0;
public static void main(String[] args)
throws InterruptedException {
Thread thread1 = new Thread(() -> {
for (int i = 0; i < 100000; i++) {
count++;
}
});
Thread thread2 = new Thread(() -> {
for (int i = 0; i < 100000; i++) {
count++;
}
});
thread1.start();
thread2.start();
thread1.join();
thread2.join();
System.out.println(
"Final count: " + count
);
}
}
What should the final count be?
We expect 200,000.
But count++ is not an atomic operation. Both threads can read and update the value at the same time.
As a result, some increments can be lost.
This is a simple example of how a concurrency bug can be detected by checking the expected result.
11. Test the Same Program Using AtomicInteger
Now let's fix the counter using AtomicInteger.
import java.util.concurrent.atomic.AtomicInteger;
public class AtomicCounterTest {
private static AtomicInteger count =
new AtomicInteger(0);
public static void main(String[] args)
throws InterruptedException {
Thread thread1 = new Thread(() -> {
for (int i = 0; i < 100000; i++) {
count.incrementAndGet();
}
});
Thread thread2 = new Thread(() -> {
for (int i = 0; i < 100000; i++) {
count.incrementAndGet();
}
});
thread1.start();
thread2.start();
thread1.join();
thread2.join();
System.out.println(
"Final count: " + count.get()
);
}
}
Now the expected result is 200,000.
AtomicInteger ensures that each increment operation is atomic.
This is a simple example of testing whether our shared data is being updated correctly.
12. Common Practices for Testing Concurrent Code
Testing multithreaded applications requires more than running the program once and checking the output.
Here are some useful practices.
1. Run Tests Multiple Times
Some concurrency problems depend on timing.
A program may work correctly in one run and fail in another.
Running tests repeatedly can help expose problems that are difficult to reproduce.
2. Use Multiple Threads
Test shared resources with several threads accessing them concurrently.
This helps reveal race conditions and unexpected interactions.
3. Verify Expected Results
Always check whether the final result matches what the application should produce.
For example, if ten threads each increment a counter 1,000 times, the expected result is 10,000.
4. Test Interruption and Cancellation
We have learned about interrupting threads and cancelling tasks using Future.
Test whether your application handles these situations correctly.
5. Test Resource Cleanup
Check whether locks are released properly and whether executors are shut down when their work is complete.
This helps avoid resources being held longer than necessary.
13. A Useful Tool: ThreadMXBean
Java also provides a management API that helps us inspect threads programmatically.
One useful interface is ThreadMXBean, available through the java.lang.management package.
It provides information about threads inside the JVM.
For example, we can inspect the number of live threads and check for certain types of deadlocks.
import java.lang.management.ManagementFactory;
import java.lang.management.ThreadMXBean;
public class ThreadManagementExample {
public static void main(String[] args) {
ThreadMXBean bean =
ManagementFactory.getThreadMXBean();
int threadCount = bean.getThreadCount();
System.out.println(
"Live thread count: " + threadCount
);
long[] deadlockedThreads =
bean.findDeadlockedThreads();
if (deadlockedThreads == null) {
System.out.println(
"No deadlocked threads detected"
);
} else {
System.out.println(
"Deadlock detected!"
);
}
}
}
Let's understand the important methods:
- getThreadCount(): Returns the current number of live platform threads in the JVM.
- findDeadlockedThreads(): Checks for cycles involving monitor locks and ownable synchronizers.
- getThreadInfo(): Provides information about selected threads, including their states and stack traces.
If no deadlocked threads are detected, findDeadlockedThreads() returns null.
This API is useful when building monitoring and diagnostic features for Java applications.
14. Putting Everything Together
Let's summarize a practical approach to investigating a multithreaded application that is behaving incorrectly.
- Identify the problem. Is the application slow, stuck, or producing incorrect results?
- Inspect the logs and use meaningful thread names.
- Check thread states to understand whether threads are running, blocked, or waiting.
- Capture a thread dump if the application is unresponsive.
- Inspect stack traces and lock information.
- Check shared variables and synchronization logic.
- Write tests that reproduce the problem and verify the expected results.
- Run tests repeatedly to expose timing-dependent problems.
The key is to investigate the actual behavior of the threads rather than guessing what might be happening.
15. Quick Revision
- Debugging multithreaded applications is challenging because execution order can change.
- Meaningful thread names make debugging easier.
- Thread.getState() helps us inspect a thread's current state.
- A thread dump provides information about thread states, stack traces, and locks.
- jstack and jcmd are useful JDK tools for capturing thread information.
- IDE debugging tools help inspect threads and their execution.
- Concurrent tests should verify expected results under multiple threads.
- ThreadMXBean provides programmatic access to JVM thread information.
Test Your Understanding
Let's see how well you understood this class!
Question 1: Why is debugging multithreaded applications difficult?
Question 2: What information does a thread dump provide?
Question 3: Which JDK utility can capture a thread dump of a running Java process?
Question 4: What does Thread.getState() return?
Question 5: Which Java interface helps us inspect JVM thread information programmatically?
Class Summary
Today, we learned how to investigate and test multithreaded applications.
- Use meaningful thread names to identify responsibilities.
- Inspect thread states using getState().
- Use thread dumps to investigate blocked and waiting threads.
- Use jstack and jcmd to capture thread information.
- Use IDE debugging tools to inspect concurrent execution.
- Write tests that verify expected results under concurrent execution.
- Use ThreadMXBean for programmatic thread monitoring.
Remember: When a multithreaded application behaves unexpectedly, inspect the threads, understand what they are doing, and reproduce the problem through testing.
You have completed 45 out of 46 classes.