← Back to Multithreading Course

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:

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:

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:

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.

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:

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.

  1. Place a breakpoint inside the method you want to inspect.
  2. Run the application in debug mode.
  3. Inspect the current thread.
  4. Check local variables and shared object values.
  5. 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:

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.

  1. Identify the problem. Is the application slow, stuck, or producing incorrect results?
  2. Inspect the logs and use meaningful thread names.
  3. Check thread states to understand whether threads are running, blocked, or waiting.
  4. Capture a thread dump if the application is unresponsive.
  5. Inspect stack traces and lock information.
  6. Check shared variables and synchronization logic.
  7. Write tests that reproduce the problem and verify the expected results.
  8. 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

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.

Next Class: Building a Real-World Multithreaded Java Application

We have reached the final class of our Java Multithreading course!

So far, we have studied individual concepts and explored practical examples.

In our final class, we will bring these concepts together by building a practical multithreaded Java application.

We will apply what we have learned about ExecutorService, thread pools, shared data, synchronization, concurrent collections, and thread coordination.

The goal is to understand how these concepts work together when developing an application.

See you in the final class — Class 46!

0% Completed

You have completed 45 out of 46 classes.