CodeGym /Courses /JAVA 25 SELF /Diagnosing and debugging multithreaded programs

Diagnosing and debugging multithreaded programs

JAVA 25 SELF
Level 53 , Lesson 4
Available

1. Thread Dump and analysis of thread states

Thread Dump (thread dump) is a snapshot of the state of all threads in the application at a specific moment in time. It’s like a group photo of all your threads: who is doing what, who is stuck where, who is waiting for whom. Thread Dump — your primary tool for finding deadlocks, livelocks, and other mysterious hangs.

How to get a Thread Dump?

Via terminal (jstack):

If you have the Java process PID, run:

jstack <PID>

The command will print to the console the state of all threads, indicating which state each is in and which monitors (locks) it holds.

Via the IDE (IntelliJ IDEA):
In the “Run” menu → “Show Running List” → select the process → “Thread Dump”.

Via VisualVM or JConsole:
Open the process, find the “Threads” tab, and take a snapshot.

Thread Dump example

Dump fragment:

"Thread-1" #12 prio=5 os_prio=0 tid=0x000000001e0c7800 nid=0x1a48 waiting for monitor entry [0x000000001f00f000]
   java.lang.Thread.State: BLOCKED (on object monitor)
    at com.example.DeadlockDemo.lambda$main$0(DeadlockDemo.java:25)
    - waiting to lock <0x00000000d6d6baf8> (a java.lang.Object)
    - locked <0x00000000d6d6bb08> (a java.lang.Object)

Here you can see that the “Thread-1” thread is blocked (BLOCKED), holds one monitor, but is waiting for another. If you see several such threads where one holds resource A and waits for B, and another holds B and waits for A — that’s a classic deadlock.

Thread states

Status Description
RUNNABLE The thread is running or ready to run
BLOCKED Waiting to acquire a monitor (lock)
WAITING Waiting for notify()/notifyAll() (e.g., triggered by wait())
TIMED_WAITING Waiting with a timeout (e.g., sleep, wait(timeout))
TERMINATED Thread has finished

Important: the RUNNABLE status does not always mean that the thread is executing right now — it is only ready to execute (the JVM scheduler may not run it immediately).

How to tell you have a deadlock?

In the dump, several threads are in the BLOCKED state, each waiting on a monitor held by another thread from the same set.

At the end of the dump, jstack usually prints:

Found one Java-level deadlock:
=============================
"Thread-1":
  waiting to lock monitor 0x00000000d6d6baf8 (object 0x00000000d6d6baf8, a java.lang.Object),
  which is held by "Thread-2"
"Thread-2":
  waiting to lock monitor 0x00000000d6d6bb08 (object 0x00000000d6d6bb08, a java.lang.Object),
  which is held by "Thread-1"

If threads remain in BLOCKED or WAITING for a long time, that’s a reason to investigate.

2. Monitoring and profiling threads

VisualVM
VisualVM — a free utility included with most JDKs. Lets you attach to a process, view thread states, take a Thread Dump, see CPU load, active and “hung” threads.

Threads tab: shows how many threads are created, their states, and the activity history.

Thread Dump: the “Thread Dump” button takes a snapshot similar to jstack.

Java Mission Control and Flight Recorder

Java Mission Control (JMC): an advanced tool for analyzing the JVM in real time. Helps explore locks, execution time, allocations, and latencies.

Java Flight Recorder (JFR): a built-in JVM profiler that collects events about threads, locks, pauses, etc.

Example: monitoring locks

In VisualVM or JMC you might see that:

  • Thread “A” is blocked on object X.
  • Thread “B” holds object X but is waiting for object Y.
  • Thread “C” holds object Y but is waiting for object X.

This is a classic circular deadlock.

How to use these tools in practice?

  • Run the application with -XX:+FlightRecorder (or just use JDK 11+).
  • Open JMC, connect to the process, start a recording.
  • Analyze hotspots, long-held locks, and contention between threads.

3. Logging and tracing

In multithreaded programs, debugging “by eye” is painful. Log entry/exit from critical sections (synchronized blocks), operations on shared variables, thread waits and wakeups — this way you’ll understand who acquired or released a resource and when.

How to log?

  • Use standard tools: java.util.logging, SLF4J, Log4j.
  • Log the thread name: Thread.currentThread().getName().
  • Log timestamps and thread IDs.
  • Log lock acquire/release events.

Logging example

synchronized(lock) {
    System.out.println(Thread.currentThread().getName() + " acquired lock");
    // critical section
    System.out.println(Thread.currentThread().getName() + " is leaving lock");
}

Using thread names

Give threads meaningful names!

Thread t = new Thread(runnable, "MyWorker-1");

Tracing example using a logger

import java.util.logging.Logger;

public class Example {
    private static final Logger logger = Logger.getLogger(Example.class.getName());

    public void doWork() {
        logger.info(Thread.currentThread().getName() + " started work");
        synchronized (this) {
            logger.info(Thread.currentThread().getName() + " entered synchronized");
            // ...
        }
        logger.info(Thread.currentThread().getName() + " finished work");
    }
}

4. Diagnostics best practices

Minimize lock scope

Hold locks for as little time as possible.

Bad example:

synchronized(lock) {
    // long I/O
    // heavy computations
    // database access
    // ... and only then work with shared data
}

Good example:

// outside synchronized: long I/O, computations

synchronized(lock) {
    // only work with shared data
}

Use thread names

Meaningful thread names save time when analyzing dumps and logs.

Write multithreading tests

Use JUnit + CountDownLatch to simulate concurrency scenarios.

CountDownLatch latch = new CountDownLatch(2);
Runnable task = () -> {
    // ...
    latch.countDown();
};
new Thread(task, "Worker-1").start();
new Thread(task, "Worker-2").start();
latch.await(); // wait for both threads to finish

Use try-finally for ReentrantLock

Lock lock = new ReentrantLock();
lock.lock();
try {
    // critical section
} finally {
    lock.unlock();
}

This way you won’t forget to release the lock even on exceptions. To avoid deadlocks, use tryLock() with a timeout.

Document why synchronization is needed

Comments like “synchronized is needed here because…” will help you understand the intent later.

5. Practice: analyzing a deadlock in a test program

Example code with a deadlock

public class DeadlockDemo {
    private static final Object lockA = new Object();
    private static final Object lockB = new Object();

    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            synchronized (lockA) {
                System.out.println("Thread-1: acquired lockA");
                try { Thread.sleep(100); } catch (InterruptedException ignored) {}
                synchronized (lockB) {
                    System.out.println("Thread-1: acquired lockB");
                }
            }
        }, "Thread-1");

        Thread t2 = new Thread(() -> {
            synchronized (lockB) {
                System.out.println("Thread-2: acquired lockB");
                try { Thread.sleep(100); } catch (InterruptedException ignored) {}
                synchronized (lockA) {
                    System.out.println("Thread-2: acquired lockA");
                }
            }
        }, "Thread-2");

        t1.start();
        t2.start();
    }
}

How to catch a deadlock

  1. Run the program — it will hang.
  2. Capture a thread dump (jstack or via VisualVM).
  3. Find “Thread-1” and “Thread-2” — you’ll see that each holds one lock and is waiting for the other.
  4. At the end of the dump there will be a “Found one Java-level deadlock” section.

How to fix

  • Always acquire locks in the same order.
  • Use ReentrantLock with tryLock() and a timeout: if you fail to acquire all locks — release and try again.

6. Common mistakes when diagnosing multithreaded programs

Mistake #1: Not knowing how to read a thread dump. Beginners get scared by a dump: “What are these weird stack traces and statuses?” In fact, it’s enough to know the main states and look for BLOCKED/WAITING to simplify the analysis.

Mistake #2: Ignoring thread names. Without meaningful names, understanding a dump is like looking for a needle in a haystack. Don’t be lazy — set names!

Mistake #3: Overly large synchronized blocks. If you synchronize large chunks of code, threads will block each other more often — this is visible by frequent BLOCKED in the dump.

Mistake #4: Confusing RUNNABLE with an actually running thread. RUNNABLE does not always mean it’s running on the CPU. The JVM scheduler decides whom to run.

Mistake #5: Not using monitoring tools. Many don’t know about VisualVM, JMC, Flight Recorder and struggle with println. Use the tools — they make life much easier.

Mistake #6: No logging of critical operations. Without logs, it’s almost impossible to understand who acquired/released a lock and when.

Mistake #7: Trying to catch data races “by eye”. Races don’t always manifest immediately — use tests with CountDownLatch, provoke contention via Thread.yield(), and analyze the state of shared variables.

1
Task
JAVA 25 SELF, level 53, lesson 4
Locked
Detective inside the program: Determining the current thread's state 🕵️‍♂️
Detective inside the program: Determining the current thread's state 🕵️‍♂️
1
Task
JAVA 25 SELF, level 53, lesson 4
Locked
Access to the Secret Room: Logging entry and exit from the critical section 🚪
Access to the Secret Room: Logging entry and exit from the critical section 🚪
1
Survey/quiz
Multithreading Problems, level 53, lesson 4
Unavailable
Multithreading Problems
Multithreading Problems
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION