Debugging Java Applications: IDE, Remote, and Techniques
Executive Summary
Debugging Java applications follows one method: reproduce, isolate, hypothesize, verify. First, reproduce the failure deterministically, and narrow it to the smallest unit. Then form one falsifiable hypothesis, and collect the minimal evidence that decides it, one change at a time. The IntelliJ debugger from the setup article executes that loop locally. Line breakpoints pause the world, and conditional breakpoints fire only on the interesting case. Meanwhile, watches and Evaluate Expression inspect live state, and step into, over, and out walk the exact frames.
A stack trace is a map, and reading it is a skill. The exception type and message name the failure, and the top frame is where it happened. Your own frames below it are why. Finally, the Caused by chain, the exceptions article’s wrapping, holds the original cause at the bottom. Hangs and slowness, by contrast, need thread dumps, jstack or the architecture article’s jcmd Thread.print. In a dump, BLOCKED means lock contention, and WAITING in a pool means starvation. The deadlock detector also prints circular waits in plain text.
For containers and test environments, JDWP attaches the same debugger to another JVM with -agentlib:jdwp server flags. However, the security rule is that it must never face production traffic. Above all, one discipline beats every tool. Read the whole trace before touching anything, and fix the cause the evidence points at, not the frame that happened to break.
Debugging Java Applications: Hypothesis, Not Voodoo
When debugging Java applications, every session that takes hours instead of minutes skipped a step of this loop. In contrast, every fast one runs it explicitly:
1. REPRODUCE the failure, deterministically, in the smallest program possible
2. OBSERVE what actually happens: stack trace, log line, thread dump, debugger
3. HYPOTHESIZE one sentence: "the retry loop calls fetch even after cancel is set"
4. VERIFY the smallest experiment that can only pass if the hypothesis is true
5. FIX the cause the evidence named, not the frame that happened to break
6. PROVE the failing test now passes, and the reproduction no longer reproduces
The step people skip is usually step 4. They replace the experiment with a fix and a prayer, and that is how one bug becomes three. The step people do badly is 1, reproducing “sometimes” cases by mashing the retry button instead of isolating the input. Fortunately, the test suite article’s determinism rules apply to debugging exactly as they applied to flaky tests.
The IDE Debugger: Pausing Time
The setup article’s IntelliJ ships with the full JPDA debugger, and its four features cover most local work. Click the gutter to set a breakpoint, run in Debug mode, and the world stops there with every variable inspectable:
class RetryPolicy {
Result fetchWithRetry(URI uri) {
for (int attempt = 0; attempt < maxAttempts; attempt++) {
var result = fetch(uri);
if (result.isSuccess()) return result;
sleep(backoffMillis(attempt)); // breakpoint here: why is attempt 4?
}
...
}
}
- Conditional breakpoints stop only when a condition holds, such as attempt == 3. As a result, the interesting pass pauses, and the boring three thousand before it do not.
- Watches evaluate expressions as you step, backoffMillis(attempt), and show values updating live in the Frames and Variables panes.
- Evaluate Expression runs arbitrary code against the paused state, the experiment without the edit-compile cycle.
- Step Into descends into the next call, Step Over finishes this line and stops at the next, Step Out returns to the caller. Together, they form the navigation triangle of the whole discipline.
The debugger versus print statements is not a culture war. Instead, it is arithmetic. A print shows one value at one point and costs an edit and a recompile. In contrast, a breakpoint shows every value at every point and costs a click. Still, prints earn their keep when the failure depends on time or timing, where pausing the world changes the behavior. The logging article covered that production tier.
Reading a Stack Trace Like a Map
The stack trace is the single most information-dense artifact Java produces, yet most of it is ignored. A fully annotated failure:
java.lang.IllegalStateException: order 4011 is not payable // what
at in.imraan.catalog.OrderService.place(OrderService.java:52) // YOUR code: where
at in.imraan.catalog.OrderController.handle(OrderController.java:31)
at jakarta.servlet.http.HttpServlet.service(...) // framework frames
...
Caused by: java.time.format.DateTimeParseException: // the ORIGINAL cause
Text '2026-13-01' could not be parsed at index 5 // the real story
at java.time.format.DateTimeFormatter.doParse(...)
at in.imraan.catalog.OrderService.payableUntil(OrderService.java:78)
...
The reading order is the skill. Start at the top for what and where. However, the Caused by chain is the cause, and its first frame in your code, OrderService.java:78, is where the investigation begins. Beginners read the top frame, see a framework class, and go read framework source. That is the wrong direction, because your code called the framework. Instead, the deepest Caused by holds the mechanism. When you wrap exceptions, the exceptions article’s cause chain is why. A trace with Caused by is a confession, while a trace without it is a cover-up.
Thread Dumps: Diagnosing Hangs Without Pausing Anything
A hang (everything stopped) or a crawl (everything slow) is invisible to breakpoints. After all, pausing the world does not help when the world is already paused. Instead, the thread dump is the observation tool. It captures every thread, its state, and what it is waiting on in one instant, without stopping the JVM:
jstack <pid> // or, from the architecture article's pocketknife:
jcmd <pid> Thread.print // identical output, one command family
// the interesting excerpts, annotated:
"http-nio-8080-exec-3" ... WAITING (on lock) // pool starved?
at ...ConnectionPool.getConnection(...)
"http-nio-8080-exec-4" ... BLOCKED (on object monitor) // fighting for a lock
- waiting to lock <0x...> (a in.imraan.catalog.Cache)
"http-nio-8080-exec-5" ... RUNNABLE // busy, not stuck
at java.net.SocketInputStream.socketRead0(...) // waiting on I/O counts as RUNNABLE
The threads article’s state vocabulary becomes diagnostic here. BLOCKED means two threads fight for one intrinsic lock, and WAITING in a pool means the pool is exhausted. Similarly, the lock article’s deadlock shows up in the dump’s own words, “Found one Java-level deadlock”, with the circular wait printed as a diagram.
The technique that makes dumps decisive is taking three of them, ten seconds apart. The threads that appear in the same state across all three are the stuck ones. Meanwhile, the threads that move are fine. In other words, one dump shows a snapshot, and the comparison shows a trend. This is the profiling article’s before-and-after discipline applied to threads.
Remote Debugging: JDWP
Better still, the same debugger that pauses your local program can attach to a JVM running anywhere. It connects through JDWP, the JVM’s wire protocol. The target JVM opens a socket and waits for the IDE:
// the target JVM (a container, a test server, never production):
java -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005 \
-jar app.jar
// in IntelliJ: Run | Edit Configurations | Remote JVM Debug | host:port
// attach: the full debugger, breakpoints, watches, stepping, on the remote code
The flags read plainly. The transport runs over a socket, and the target is the server waiting for a connection. Also, suspend=n means do not wait for the debugger before starting, and the port is the attachment point.
The security line is absolute, because a JDWP connection is arbitrary code execution on the JVM. Therefore, the port must never be reachable from untrusted networks, and production JVMs never open it. That is why production debugging runs on the logging article’s evidence, thread dumps, and the jdb command-line debugger only in controlled windows. Also remember what a breakpoint does remotely. The whole JVM freezes until you resume, including every request in flight and every scheduled job. So a shared test server is frozen for everyone while you stare.
How Real Systems Do This
Production debugging splits by symptom, and the tools divide cleanly. Teams diagnose exceptions from logs and stack traces, with correlation IDs narrowing ten thousand lines to one request’s story. Likewise, hangs and slowness yield to thread dumps and the profiling article’s recordings. Finally, the interactive debugger lives on local machines and disposable test environments. Mature teams automate the evidence collection with a script or an agent. It grabs three thread dumps, the GC log tail, and a JFR minute on demand. After all, the middle of an incident is the wrong time to remember flag syntax.
The hang that taught me thread dumps was a textbook pool exhaustion with a misread first clue. A checkout service ground to a halt on a Friday afternoon, with every request hanging. The first engineer on it saw the requests stuck and assumed a downstream outage. However, three thread dumps, ten seconds apart, said otherwise. Every worker thread sat WAITING in the same frame, ConnectionPool.getConnection, across all three dumps. That meant the pool had no connections to give. It had none because an error path that a release candidate had introduced returned without releasing them.
One look at the dump converted “downstream is down” into “we leak connections in the refund path”. We patched the leak in an hour, and the runbook gained a rule I still hand to teams. For any hang, take three thread dumps before forming any opinion. The dumps are cheap, and the opinion is usually wrong. In the end, the difference between a two-hour and a two-day incident is which of the two you act on.
Decision Framework
- Is the failure an exception with a stack trace? Read the whole trace, Caused by chain first, and start at the deepest frame in your code.
- Is the failure reproducible locally? Debugger: breakpoint before the failure, conditional on the interesting case, and step forward.
- Is it a hang or a stall? Three thread dumps ten seconds apart, and the unchanging frames are the diagnosis.
- Is it slow rather than stuck? The profiling article’s method: a JFR recording, not a debugger.
- Does it only reproduce in a container or test environment? JDWP attach, with the port closed to untrusted networks and the freeze-the-world cost understood.
- Does the failure depend on timing? Breakpoints lie here. Instead, log the sequence, per the good tests article’s determinism rules, or add temporary trace-level lines.
- Did the evidence name a cause? Fix the cause, prove the reproduction is dead, and keep the failing input as a test.
When NOT to Use This
- Do not attach a debugger to a production JVM. Every breakpoint freezes the entire process, all requests and jobs. Moreover, JDWP is arbitrary code execution with no place on a trusted border.
- Do not debug a failure a test can reproduce. Instead, convert it to a red test first. Then the debugger becomes optional, while the regression stays caught forever.
- Do not debug flaky timing behavior with breakpoints. Pausing time changes timing, so the failure disappears or moves. Prints or trace logs tell that story honestly.
- Do not fix the top frame. It is where the failure surfaced, but the cause is usually frames or a Caused by level below. So fix what the evidence named.
- Do not leave debug scaffolding behind: temporary prints, disabled breakpoints with side effects, and commented-out code are the archaeology nobody wants.
Common Mistakes
- Reading only the top frame of a trace. The exception type and first line say what and where, but the Caused by chain says why. Skipping it means debugging the symptom.
- Wrapping exceptions without the cause. The cover-up trace this article showed, where a Caused by should be and is not, is the exceptions article’s missing-chain mistake. Here, you see it from the debugging side.
- One thread dump for a hang: a snapshot cannot distinguish stuck from merely busy, and three dumps ten seconds apart can.
- Forming the fix before the hypothesis: skipping verify is how a one-line defect becomes a three-file refactor with the bug intact.
- Breakpoints in hot paths with conditions that never fire. The interesting case passes at full speed, and the boring ones stop the world. So write the condition before clicking Debug.
- Debugging a shared environment forgetfully. A paused JVM is paused for every user of that server, so your ten minutes of staring is ten minutes of outage for them.
- Trusting the first theory because it is elegant. The hypothesis needs the same evidence standard as the profiling article’s. Indeed, pretty theories die on contact with dumps.
Key Takeaways
- Debugging Java applications is the scientific method at machine speed. Reproduce, observe, and hypothesize in one sentence. Then verify with the smallest experiment, fix the cause, and prove it dead.
- The IDE debugger pauses time. Conditional breakpoints fire on the interesting case, while watches and Evaluate inspect live state. Stepping walks the frames.
- A stack trace is a map. The top frame is where, and your frames are why. The Caused by chain is the cause, so always read to the bottom.
- Hangs need three thread dumps ten seconds apart. BLOCKED means lock contention, and WAITING in a pool means exhaustion. Unchanging frames across dumps are the diagnosis.
- JDWP attaches the full debugger to remote JVMs, and the security rule is absolute: test environments, never production borders.
- Timing-dependent failures are logged, not breakpointed, because pausing time changes the story.
- Every fixed bug ends as a test: the reproduction becomes the regression that can never silently return.
FAQ
How do I debug a Java application in IntelliJ IDEA?
Set a breakpoint in the gutter, and run the class in Debug mode. The JVM then pauses there with all variables live. Use conditional breakpoints to stop only on interesting cases, and Watches and Evaluate Expression to inspect and experiment. Finally, use Step Into, Over, and Out to walk the frames.
How do I read a Java stack trace?
Start at the top line for the exception type and message, and note the first frame in your own code for where. Then follow the Caused by chain to the bottom. The deepest cause holds the original failure, and its first frame in your code is where the investigation begins.
What is a thread dump used for in Java?
Diagnosing hangs, stalls, and lock contention without stopping the JVM: it lists every thread with its state and what it is waiting on. Three dumps taken ten seconds apart identify the stuck threads, and jstack or jcmd Thread.print produce one in a single command.
What is JDWP remote debugging?
The Java Debug Wire Protocol, started on the target JVM with -agentlib:jdwp, lets an IDE attach its full debugger to a JVM elsewhere. Breakpoints, watches, and stepping all work over the socket. It must never be exposed on production borders, because a JDWP connection can execute arbitrary code.
How do I debug a hung Java application?
Take three thread dumps ten seconds apart and compare them. Threads unchanged across all three are the stuck ones, and their states tell the story: BLOCKED for lock contention, WAITING in a pool for exhaustion. Also, the dump’s deadlock detector prints circular waits explicitly. Then fix the cause the dump names, not the first symptom.
Conclusion
For debugging Java applications, you now hold the complete diagnostic kit. Use the debugger for what pauses well and stack traces for what failed and why. Use thread dumps for what stopped and the remote attach for what runs elsewhere. Above the tools sits the method: reproduce, hypothesize, verify. Ultimately, the discipline of fixing causes instead of frames separates engineers who close tickets from engineers who close root causes.
The next article completes Part 7. It takes everything you now build and test, Maven, JUnit, Mockito, and the suite, and wires it into continuous integration. That means GitHub Actions, from a first workflow file to a pipeline that gates every merge on the same mvn clean package you have been running by hand.
Reproduce it, then kill it, then write the test that keeps it dead. That is the whole craft.
Last updated on 16 September 2026.
