Debugging




1. What is Debugging?

Debugging is the process of finding and fixing defects (bugs) in your code. The term dates back to the 1940s when an actual moth was found inside a computer relay at Harvard, causing a malfunction. Grace Hopper taped the moth into a logbook and labeled it the “first actual case of a bug being found.” While modern bugs are not insects, the process of hunting them down remains one of the most important skills a developer can master.

Every developer, no matter how experienced, writes bugs. The difference between a junior developer and a senior developer is not the number of bugs they produce — it is how quickly and systematically they find and fix them. A junior developer might stare at code for hours, changing random things and hoping something works. A senior developer applies a methodical process that narrows down the cause efficiently.

Why Bugs Happen

Bugs are not random acts of nature. They have specific causes:

  • Misunderstanding requirements — You built what you thought was asked, not what was actually needed.
  • Incorrect assumptions — You assumed a value would never be null, a list would never be empty, or a network call would always succeed.
  • Off-by-one errors — Loop boundaries, array indices, and string slicing are common sources.
  • Copy-paste mistakes — Duplicating code and forgetting to update a variable name or condition.
  • Integration mismatches — Your code works in isolation but breaks when connected to other components.
  • Concurrency issues — Multiple threads accessing shared state without proper synchronization.
  • Environment differences — Code works on your machine but fails in production due to different configurations, JVM versions, or OS behavior.

The Debugging Mindset

Effective debugging is not about guessing. It follows the scientific method:

Step Action Example
1. Observe Gather facts about the bug. What is the actual behavior? What is the expected behavior? When does it happen? Is it reproducible? “The application returns 0 for the total price when the cart has 3 items.”
2. Hypothesize Form a theory about what might be causing the bug based on the evidence. “Maybe the calculateTotal() method is not iterating through all items in the cart.”
3. Test Design an experiment to prove or disprove your hypothesis. Use a debugger, add logging, or write a unit test. “I will set a breakpoint inside the loop in calculateTotal() and inspect the item list.”
4. Conclude Analyze the results. If your hypothesis was wrong, form a new one based on what you learned. If it was right, implement the fix. “The loop was correct, but the price field was never set because the constructor had a typo.”

The key insight is that each hypothesis you test — even the wrong ones — eliminates possibilities and narrows down the root cause. Debugging is a process of elimination, not a guessing game.

2. Types of Bugs

Not all bugs are created equal. Understanding the category of a bug helps you choose the right debugging technique. Here are the five major types you will encounter in Java:

2.1 Compile-Time Errors (Syntax Errors)

These are the easiest bugs to fix because the compiler catches them before your code runs. The Java compiler will tell you exactly which file, line, and character has the problem. Common causes include missing semicolons, mismatched braces, undeclared variables, and type mismatches.

public class SyntaxErrors {
    public static void main(String[] args) {
        // Error 1: Missing semicolon
        int x = 10  // <-- compiler error: ';' expected

        // Error 2: Type mismatch
        int y = "hello"; // <-- compiler error: incompatible types

        // Error 3: Undeclared variable
        System.out.println(z); // <-- compiler error: cannot find symbol

        // Error 4: Missing closing brace
    // <-- compiler error: reached end of file while parsing
}

The fix is usually straightforward -- read the compiler message, go to the indicated line, and correct the syntax. Modern IDEs like IntelliJ IDEA and Eclipse highlight these errors in real time as you type, so you rarely encounter them at compile time.

2.2 Runtime Errors (Exceptions)

Runtime errors occur when the code compiles successfully but crashes during execution. Java signals these through the exception mechanism. The program throws an exception, and if nothing catches it, the JVM prints a stack trace and terminates.

public class RuntimeErrors {
    public static void main(String[] args) {
        // NullPointerException -- calling a method on null
        String name = null;
        System.out.println(name.length()); // crashes at runtime

        // ArrayIndexOutOfBoundsException -- accessing invalid index
        int[] numbers = {1, 2, 3};
        System.out.println(numbers[5]); // crashes at runtime

        // ArithmeticException -- division by zero
        int result = 10 / 0; // crashes at runtime

        // NumberFormatException -- invalid string to number conversion
        int parsed = Integer.parseInt("abc"); // crashes at runtime
    }
}

2.3 Logic Errors (Wrong Output)

Logic errors are the hardest bugs to find because the code compiles, runs without crashing, and produces output -- but the output is wrong. The compiler and runtime cannot help you here because, from their perspective, nothing is broken. Only you (or your tests) know that the result should be different.

public class LogicError {
    // Bug: This method should calculate the average, but it has a logic error
    public static double calculateAverage(int[] numbers) {
        int sum = 0;
        for (int i = 0; i <= numbers.length; i++) { // Bug: <= should be <
            sum += numbers[i]; // Will throw ArrayIndexOutOfBoundsException
        }
        return sum / numbers.length; // Bug: integer division truncates decimals
    }

    // Corrected version
    public static double calculateAverageFixed(int[] numbers) {
        if (numbers == null || numbers.length == 0) {
            return 0.0;
        }
        int sum = 0;
        for (int i = 0; i < numbers.length; i++) { // Fixed: < instead of <=
            sum += numbers[i];
        }
        return (double) sum / numbers.length; // Fixed: cast to double for decimal result
    }

    public static void main(String[] args) {
        int[] scores = {85, 90, 78, 92, 88};
        System.out.println(calculateAverageFixed(scores)); // 86.6
    }
}

2.4 Concurrency Bugs (Race Conditions)

Concurrency bugs appear when multiple threads access shared data without proper synchronization. These bugs are notoriously difficult to reproduce because they depend on the exact timing of thread execution, which varies between runs.

public class RaceCondition {
    private static int counter = 0;

    public static void main(String[] args) throws InterruptedException {
        Runnable increment = () -> {
            for (int i = 0; i < 100_000; i++) {
                counter++; // Bug: not thread-safe. counter++ is read-modify-write
            }
        };

        Thread t1 = new Thread(increment);
        Thread t2 = new Thread(increment);
        t1.start();
        t2.start();
        t1.join();
        t2.join();

        // Expected: 200000
        // Actual: some number less than 200000 (varies each run)
        System.out.println("Counter: " + counter);

        // Fix: use AtomicInteger or synchronized blocks
    }
}

2.5 Performance Bugs (Memory Leaks, Slow Code)

Performance bugs do not produce wrong results or crash your program -- they make it slow or consume excessive resources. A memory leak, for example, happens when objects are created but never become eligible for garbage collection, causing the JVM to run out of heap space over time.

import java.util.ArrayList;
import java.util.List;

public class MemoryLeak {
    // Bug: This list grows forever because we never remove elements
    private static final List cache = new ArrayList<>();

    public static void main(String[] args) {
        while (true) {
            // Each iteration adds 1 MB to the list
            cache.add(new byte[1024 * 1024]);
            System.out.println("Cache size: " + cache.size() + " MB");
            // Eventually: java.lang.OutOfMemoryError: Java heap space
        }
    }
}

3. Reading Error Messages

When a Java program crashes, the JVM prints a stack trace -- a detailed report of exactly what happened and where. Learning to read stack traces is the single most important debugging skill you can develop. Many junior developers see a wall of text and panic. A senior developer reads it top to bottom and knows exactly where to look in 30 seconds.

3.1 Anatomy of a Stack Trace

Every Java stack trace has three parts:

  1. Exception type and message -- The first line tells you what went wrong (e.g., NullPointerException) and sometimes why (e.g., "Cannot invoke method because 'user' is null").
  2. Stack frames -- Each at line is a frame showing the class, method, file name, and line number. The frames are ordered from the point of failure (top) to the entry point (bottom).
  3. "Caused by" chain -- If the exception was wrapped, you will see nested causes. The root cause is always at the bottom of the chain.
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "String.length()" because "name" is null
    at com.example.UserService.validateName(UserService.java:42)
    at com.example.UserService.createUser(UserService.java:28)
    at com.example.Application.main(Application.java:15)

// How to read this:
// 1. Exception: NullPointerException
// 2. Message: Cannot invoke "String.length()" because "name" is null
// 3. The crash happened at UserService.java, line 42, in the validateName() method
// 4. validateName() was called by createUser() at line 28
// 5. createUser() was called by main() at line 15
// 6. Start investigating at line 42 of UserService.java

3.2 Reading Common Exceptions

Here are the most common exceptions you will encounter and how to interpret their stack traces:

NullPointerException

The most common Java exception. It means you tried to use a reference that points to null. Since Java 14, the JVM provides helpful NullPointerException messages that tell you exactly which variable was null.

public class NPEExample {
    public static void main(String[] args) {
        String[] names = new String[3]; // [null, null, null]
        System.out.println(names[0].toUpperCase());
    }
}

// Java 8-13 output (unhelpful):
// Exception in thread "main" java.lang.NullPointerException
//     at NPEExample.main(NPEExample.java:4)

// Java 14+ output (helpful -- enable with -XX:+ShowCodeDetailsInExceptionMessages):
// Exception in thread "main" java.lang.NullPointerException:
//     Cannot invoke "String.toUpperCase()" because "names[0]" is null
//     at NPEExample.main(NPEExample.java:4)

ArrayIndexOutOfBoundsException

Thrown when you access an array index that does not exist. The message tells you the invalid index and the array length.

public class ArrayOOB {
    public static void main(String[] args) {
        int[] data = {10, 20, 30}; // valid indices: 0, 1, 2
        System.out.println(data[3]); // index 3 does not exist
    }
}

// Output:
// Exception in thread "main" java.lang.ArrayIndexOutOfBoundsException: Index 3 out of bounds for length 3
//     at ArrayOOB.main(ArrayOOB.java:4)
//
// The message tells you: you tried index 3, but the array only has 3 elements (indices 0-2).

ClassCastException

Thrown when you try to cast an object to a type it is not an instance of.

public class CastError {
    public static void main(String[] args) {
        Object obj = "Hello";
        Integer num = (Integer) obj; // String cannot be cast to Integer
    }
}

// Output:
// Exception in thread "main" java.lang.ClassCastException:
//     class java.lang.String cannot be cast to class java.lang.Integer
//     at CastError.main(CastError.java:4)
//
// Fix: use instanceof to check the type before casting
// if (obj instanceof Integer) { Integer num = (Integer) obj; }

3.3 Reading "Caused by" Chains

In real applications, exceptions are often wrapped inside other exceptions. When you see a "Caused by" chain, always scroll to the last "Caused by" -- that is the root cause.

// A realistic stack trace from a Spring Boot application:

Exception in thread "main" org.springframework.beans.factory.BeanCreationException:
    Error creating bean with name 'userController': Injection of autowired dependencies failed
    at org.springframework.beans.factory.annotation.AutowiredAnnotationBeanPostProcessor.postProcessProperties(...)
    at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.populateBean(...)
    ... 25 more
Caused by: org.springframework.beans.factory.UnsatisfiedDependencyException:
    Error creating bean with name 'userService': Unsatisfied dependency expressed through field 'userRepository'
    at org.springframework.beans.factory.annotation.AutowiredAnnotationBeanPostProcessor$AutowiredFieldElement.resolveFieldValue(...)
    ... 30 more
Caused by: org.springframework.beans.factory.NoSuchBeanDefinitionException:
    No qualifying bean of type 'com.example.UserRepository' available
    at org.springframework.beans.factory.support.DefaultListableBeanFactory.raiseNoMatchingBeanFound(...)
    ... 40 more

// How to read this:
// Skip to the LAST "Caused by" -- that is the root cause:
// "No qualifying bean of type 'com.example.UserRepository' available"
// This means Spring cannot find a UserRepository bean.
// Fix: Make sure UserRepository has @Repository annotation and is in a scanned package.

4. Print Debugging (System.out)

The simplest form of debugging is adding System.out.println() statements to your code to see what values your variables hold at specific points. It is quick, requires no tools, and works everywhere. Every developer uses it, even senior ones. There is no shame in print debugging -- but you should know its limitations and when to reach for better tools.

4.1 When Print Debugging is Appropriate

  • Quick investigation of a small, isolated bug
  • When you do not have access to a debugger (e.g., remote server with no IDE)
  • Verifying a specific value at a specific point in execution
  • Understanding the flow of execution (which methods are called and in what order)

4.2 Techniques

public class PrintDebugging {

    public static double calculateDiscount(double price, String customerType) {
        // Technique 1: Print method entry and parameters
        System.out.println("=== calculateDiscount called ===");
        System.out.println("  price: " + price);
        System.out.println("  customerType: " + customerType);

        double discount;

        if ("premium".equals(customerType)) {
            discount = price * 0.20;
        } else if ("regular".equals(customerType)) {
            discount = price * 0.10;
        } else {
            discount = 0;
        }

        // Technique 2: Print computed values before returning
        System.out.println("  discount: " + discount);
        System.out.println("  final price: " + (price - discount));

        return price - discount;
    }

    public static void main(String[] args) {
        // Technique 3: Use System.err for debug output (separate stream)
        System.err.println("[DEBUG] Starting price calculation...");

        double result = calculateDiscount(100.0, "premium");

        // Technique 4: Use String.format for cleaner output
        System.out.printf("[DEBUG] Result: %.2f%n", result);
    }
}

// Output:
// [DEBUG] Starting price calculation...
// === calculateDiscount called ===
//   price: 100.0
//   customerType: premium
//   discount: 20.0
//   final price: 80.0
// [DEBUG] Result: 80.00

4.3 Limitations of Print Debugging

While print debugging is convenient, it has serious drawbacks that make it unsuitable for complex debugging scenarios:

  • You must recompile and restart every time you want to see a different variable or add a new print statement.
  • You must remember to remove the print statements before committing your code. Forgotten debug output in production logs is a common problem.
  • It does not let you pause execution and inspect the full state of the program (all variables, the call stack, thread states).
  • It slows down the program -- I/O operations like printing to the console are expensive.
  • It is useless for concurrency bugs -- adding print statements changes the timing of thread execution and can make the bug disappear (a Heisenbug).

For anything beyond a quick check, use a debugger or logging framework instead.

5. Using a Debugger (IDE)

An IDE debugger is the most powerful tool in your debugging arsenal. It lets you pause your program at any line, inspect every variable, step through code line by line, and evaluate expressions on the fly -- all without modifying your source code. If you are not using a debugger regularly, you are working much harder than you need to.

5.1 Breakpoints

A breakpoint is a marker you place on a line of code. When the JVM reaches that line, it pauses execution and hands control to the debugger. There are several types:

Breakpoint Type Description When to Use
Line breakpoint Pauses when execution reaches that line. Most common -- use this when you know approximately where the bug is.
Conditional breakpoint Pauses only when a specified condition is true (e.g., i == 50). When a bug only occurs on a specific iteration or with specific data.
Exception breakpoint Pauses when a specific exception is thrown, anywhere in the program. When you know the exception type but not where it is thrown.
Method breakpoint Pauses when a specific method is entered or exited. When you want to catch all calls to a method regardless of the caller.
Field watchpoint Pauses when a specific field is read or modified. When you want to know who is changing a variable's value.

5.2 Stepping Through Code

Once the debugger pauses at a breakpoint, you control execution with these commands:

Action IntelliJ Shortcut Eclipse Shortcut What It Does
Step Over F8 F6 Execute the current line and move to the next line. If the line contains a method call, the method runs to completion without stopping inside it.
Step Into F7 F5 If the current line calls a method, enter that method and pause at its first line.
Step Out Shift+F8 F7 Run the rest of the current method and pause when it returns to the caller.
Resume F9 F8 Continue running until the next breakpoint is hit (or the program ends).
Run to Cursor Alt+F9 Ctrl+R Run until execution reaches the line where your cursor is.

5.3 Inspecting Variables

While paused, the debugger shows you the value of every variable in the current scope. You can:

  • Hover over a variable in the editor to see its value.
  • Expand objects in the Variables panel to see all their fields.
  • Add Watch expressions to track specific expressions (e.g., list.size(), user.getName()) across multiple steps.
  • Evaluate expressions (Alt+F8 in IntelliJ, Ctrl+Shift+D in Eclipse) to run arbitrary Java code in the current context -- for example, calling a method or computing a value without modifying your source code.

5.4 Practical Debugger Example

Here is a method with a bug. We will walk through how to use the debugger to find it.

import java.util.Arrays;
import java.util.List;

public class DebuggerExample {

    /**
     * Bug: This method should return the sum of all even numbers in the list.
     * It currently returns an incorrect result.
     */
    public static int sumOfEvens(List numbers) {
        int sum = 0;
        for (int i = 0; i < numbers.size(); i++) {
            int num = numbers.get(i);
            if (num % 2 == 0) {  // Set a CONDITIONAL breakpoint here: num % 2 == 0
                sum += num;
            }
        }
        return sum;  // Set a breakpoint here to check the final sum
    }

    public static void main(String[] args) {
        List data = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8);

        // Expected: 2 + 4 + 6 + 8 = 20
        int result = sumOfEvens(data);
        System.out.println("Sum of evens: " + result);

        // Debugger workflow:
        // 1. Set a breakpoint on the "sum += num" line
        // 2. Run in debug mode (Shift+F9 in IntelliJ, F11 in Eclipse)
        // 3. When it pauses, check "num" in the Variables panel
        // 4. Press Resume (F9) to hit the breakpoint again for the next even number
        // 5. Verify each even number is added correctly
        // 6. Set a conditional breakpoint: right-click the breakpoint, set condition "num == 6"
        //    Now it only pauses when num is 6
    }
}

6. Logging

Logging is the professional alternative to System.out.println(). While print statements are temporary debug aids that you delete, log statements are permanent parts of your codebase. They record what your application is doing at runtime, and you control the verbosity through configuration -- no code changes required.

6.1 Why Logging Over System.out

Feature System.out.println Logging Framework
Output destination Console only Console, file, database, remote server, email -- configurable
Verbosity control All or nothing Log levels: show only WARN and above in production, DEBUG in development
Timestamp Not included Automatic
Thread name Not included Automatic
Class/method name Not included Automatic
Performance Always evaluates Skipped if level is disabled (parameterized logging)
Production use Never Always

6.2 Log Levels

Log levels let you categorize messages by severity. In production, you typically set the level to INFO or WARN, which hides the noisy DEBUG and TRACE messages. In development or when troubleshooting a production issue, you lower the level to see more detail.

Level Purpose Example
TRACE Most detailed. Step-by-step execution flow. Entering method calculateTax with amount=100.0
DEBUG Detailed information useful during development. User query returned 42 results in 15ms
INFO Notable runtime events. Application lifecycle. Application started on port 8080
WARN Something unexpected that is not an error yet. Database connection pool is 90% full
ERROR Something failed. Requires attention. Failed to process payment for order #12345

6.3 java.util.logging (Built-in)

Java includes a logging framework in the standard library. It works without any external dependencies, but it has a clunky API and limited configuration options compared to modern alternatives.

import java.util.logging.Level;
import java.util.logging.Logger;

public class JULExample {
    // Create a logger named after this class
    private static final Logger logger = Logger.getLogger(JULExample.class.getName());

    public static void processOrder(int orderId, double amount) {
        logger.info("Processing order #" + orderId);
        logger.fine("Order amount: " + amount);  // "fine" = DEBUG level

        if (amount <= 0) {
            logger.warning("Order #" + orderId + " has invalid amount: " + amount);
            return;
        }

        try {
            // Simulate payment processing
            if (amount > 10000) {
                throw new RuntimeException("Amount exceeds limit");
            }
            logger.info("Order #" + orderId + " processed successfully");
        } catch (Exception e) {
            logger.log(Level.SEVERE, "Failed to process order #" + orderId, e);
        }
    }

    public static void main(String[] args) {
        processOrder(101, 49.99);
        processOrder(102, -5.00);
        processOrder(103, 50000.00);
    }
}

6.4 SLF4J + Logback (Industry Standard)

SLF4J (Simple Logging Facade for Java) is the industry-standard logging API. It provides a clean interface that delegates to a logging implementation like Logback or Log4j2. Most open-source libraries and frameworks (Spring Boot, Hibernate, etc.) use SLF4J.

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class Slf4jExample {
    private static final Logger log = LoggerFactory.getLogger(Slf4jExample.class);

    public static void processPayment(String userId, double amount) {
        // Parameterized logging -- the string is NOT built if the level is disabled
        // This is much more efficient than string concatenation
        log.debug("Processing payment for user={}, amount={}", userId, amount);

        if (amount <= 0) {
            log.warn("Invalid payment amount: {} for user: {}", amount, userId);
            return;
        }

        try {
            // simulate payment
            log.info("Payment of ${} processed for user {}", amount, userId);
        } catch (Exception e) {
            // Always pass the exception as the LAST argument
            log.error("Payment failed for user {}: {}", userId, e.getMessage(), e);
        }
    }

    public static void main(String[] args) {
        processPayment("user-42", 100.00);
        processPayment("user-43", -10.00);
    }
}

// Output (with default Logback pattern):
// 2025-01-15 10:30:45.123 [main] DEBUG Slf4jExample - Processing payment for user=user-42, amount=100.0
// 2025-01-15 10:30:45.125 [main] INFO  Slf4jExample - Payment of $100.0 processed for user user-42
// 2025-01-15 10:30:45.126 [main] DEBUG Slf4jExample - Processing payment for user=user-43, amount=-10.0
// 2025-01-15 10:30:45.126 [main] WARN  Slf4jExample - Invalid payment amount: -10.0 for user: user-43

6.5 MDC (Mapped Diagnostic Context)

MDC lets you attach contextual information (such as a request ID or user ID) to every log message in a thread, without passing it through every method call. This is invaluable for tracing a single request through a complex application.

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.slf4j.MDC;

public class MDCExample {
    private static final Logger log = LoggerFactory.getLogger(MDCExample.class);

    public static void handleRequest(String requestId, String userId) {
        // Set MDC values -- these appear in EVERY log message from this thread
        MDC.put("requestId", requestId);
        MDC.put("userId", userId);

        try {
            log.info("Request received");
            validateInput();
            processData();
            log.info("Request completed");
        } finally {
            // Always clean up MDC to prevent leaking to the next request
            MDC.clear();
        }
    }

    private static void validateInput() {
        // requestId and userId are automatically included in this log line
        log.debug("Validating input");
    }

    private static void processData() {
        log.debug("Processing data");
    }
}

// Logback pattern: %d [%thread] %-5level [requestId=%X{requestId}, userId=%X{userId}] %logger - %msg%n
// Output:
// 2025-01-15 10:30:45 [main] INFO  [requestId=req-001, userId=user-42] MDCExample - Request received
// 2025-01-15 10:30:45 [main] DEBUG [requestId=req-001, userId=user-42] MDCExample - Validating input
// 2025-01-15 10:30:45 [main] DEBUG [requestId=req-001, userId=user-42] MDCExample - Processing data
// 2025-01-15 10:30:45 [main] INFO  [requestId=req-001, userId=user-42] MDCExample - Request completed

6.6 Logging Best Practices

  • Use parameterized logging -- Write log.debug("User {} logged in", userId), not log.debug("User " + userId + " logged in"). The parameterized version avoids string concatenation when the debug level is disabled.
  • Always include context -- A log message like "Processing failed" is useless. Write "Processing failed for orderId={}, reason={}" instead.
  • Log at the right level -- Do not log expected behavior as ERROR. A 404 response is INFO, not ERROR. A payment failure due to insufficient funds is WARN. An unhandled database exception is ERROR.
  • Do not log sensitive data -- Never log passwords, credit card numbers, SSNs, or API keys. Mask or redact them.
  • Pass exceptions as the last argument -- log.error("Failed: {}", message, exception) logs the full stack trace. Omitting the exception parameter loses critical debugging information.
  • Use MDC for request tracing -- It makes correlating log entries for a single request across multiple classes and threads trivial.

7. Common Java Bugs and Fixes

Certain bugs appear so frequently in Java code that every developer should recognize them instantly. This section covers the most common ones with clear examples and fixes.

7.1 NullPointerException -- Causes and Prevention

NullPointerException (NPE) is the most common runtime exception in Java. It occurs when you try to use a reference that is null. Here are the most frequent causes:

import java.util.Optional;
import java.util.Objects;

public class NPECausesAndFixes {

    // === CAUSE 1: Calling a method on null ===
    public static void methodOnNull() {
        String name = null;
        // name.length();  // NPE!

        // Fix 1: Null check
        if (name != null) {
            System.out.println(name.length());
        }

        // Fix 2: Optional (Java 8+)
        Optional optName = Optional.ofNullable(name);
        optName.ifPresent(n -> System.out.println(n.length()));

        // Fix 3: Objects.requireNonNull for fail-fast at method entry
        // Objects.requireNonNull(name, "name must not be null");
    }

    // === CAUSE 2: Uninitialized object fields ===
    static class User {
        String name;  // default value is null, not ""
        String email; // default value is null
    }

    public static void uninitializedFields() {
        User user = new User();
        // user.name.toUpperCase();  // NPE! name is null

        // Fix: Initialize fields with default values
        // String name = "";
        // Or use constructor to enforce initialization
    }

    // === CAUSE 3: Method returns null unexpectedly ===
    public static String findUser(int id) {
        if (id == 1) return "Alice";
        return null;  // Danger! Callers might not expect null
    }

    public static void nullReturn() {
        String user = findUser(99);
        // user.toUpperCase();  // NPE!

        // Fix: Return Optional instead of null
        // public static Optional findUser(int id) { ... }
    }

    // === CAUSE 4: Auto-unboxing null wrapper types ===
    public static void autoUnboxing() {
        Integer count = null;
        // int x = count;  // NPE! Unboxing null Integer to int

        // Fix: Check for null before unboxing
        int x = (count != null) ? count : 0;
    }

    public static void main(String[] args) {
        methodOnNull();
        uninitializedFields();
        nullReturn();
        autoUnboxing();
        System.out.println("All NPE examples handled safely.");
    }
}

7.2 ConcurrentModificationException

This exception occurs when you modify a collection while iterating over it with a for-each loop or an iterator. The collection detects the structural modification and throws this exception as a safety measure.

import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;

public class ConcurrentModExample {

    public static void main(String[] args) {
        List names = new ArrayList<>(List.of("Alice", "Bob", "Charlie", "David"));

        // BUG: Modifying list during for-each loop
        // for (String name : names) {
        //     if (name.startsWith("C")) {
        //         names.remove(name);  // ConcurrentModificationException!
        //     }
        // }

        // Fix 1: Use Iterator.remove()
        Iterator it = names.iterator();
        while (it.hasNext()) {
            String name = it.next();
            if (name.startsWith("C")) {
                it.remove(); // Safe -- iterator manages the removal
            }
        }
        System.out.println("After Iterator.remove(): " + names);
        // Output: [Alice, Bob, David]

        // Fix 2: Use removeIf() (Java 8+) -- cleanest solution
        List names2 = new ArrayList<>(List.of("Alice", "Bob", "Charlie", "David"));
        names2.removeIf(name -> name.startsWith("C"));
        System.out.println("After removeIf(): " + names2);
        // Output: [Alice, Bob, David]

        // Fix 3: Use CopyOnWriteArrayList for concurrent access
        List threadSafe = new CopyOnWriteArrayList<>(List.of("Alice", "Bob", "Charlie"));
        for (String name : threadSafe) {
            if (name.startsWith("C")) {
                threadSafe.remove(name); // Safe -- iterates over a snapshot
            }
        }
        System.out.println("After CopyOnWriteArrayList: " + threadSafe);
        // Output: [Alice, Bob]
    }
}

7.3 StackOverflowError

A StackOverflowError means a method called itself (directly or indirectly) without a proper base case, causing infinite recursion until the call stack runs out of space.

public class StackOverflowExample {

    // BUG: No base case -- infinite recursion
    public static int factorialBroken(int n) {
        return n * factorialBroken(n - 1); // Never stops!
        // StackOverflowError
    }

    // FIXED: Add a base case
    public static int factorialFixed(int n) {
        if (n <= 1) {
            return 1; // Base case stops the recursion
        }
        return n * factorialFixed(n - 1);
    }

    // BUG: Accidental infinite recursion through toString()
    static class Employee {
        String name;
        Employee manager;

        // This toString calls manager.toString() which calls its manager.toString()...
        // If there is a circular reference, this causes StackOverflowError
        // @Override
        // public String toString() {
        //     return name + " reports to " + manager;  // calls manager.toString()
        // }

        // FIXED: Only print the manager's name, not the full object
        @Override
        public String toString() {
            return name + " reports to " + (manager != null ? manager.name : "nobody");
        }
    }

    public static void main(String[] args) {
        System.out.println(factorialFixed(5)); // 120

        Employee alice = new Employee();
        alice.name = "Alice";
        Employee bob = new Employee();
        bob.name = "Bob";
        alice.manager = bob;
        bob.manager = alice; // Circular reference
        System.out.println(alice); // Alice reports to Bob (safe)
    }
}

7.4 OutOfMemoryError

An OutOfMemoryError means the JVM has exhausted its available heap memory. Common causes include memory leaks (objects accumulate and are never garbage collected), loading very large files into memory, or creating too many objects in a loop.

import java.util.HashMap;
import java.util.Map;

public class MemoryLeakExample {

    // BUG: This map grows without bound because entries are never removed
    private static final Map sessionCache = new HashMap<>();

    public static void simulateRequests() {
        for (int i = 0; i < 10_000_000; i++) {
            // Each entry stays in the map forever
            sessionCache.put(i, "Session data for request " + i);

            if (i % 1_000_000 == 0) {
                long usedMB = (Runtime.getRuntime().totalMemory()
                             - Runtime.getRuntime().freeMemory()) / (1024 * 1024);
                System.out.println("Requests: " + i + ", Memory used: " + usedMB + " MB");
            }
        }
        // Eventually: java.lang.OutOfMemoryError: Java heap space
    }

    // FIX: Use a cache with eviction policy (e.g., LinkedHashMap with removeEldestEntry)
    private static final Map boundedCache = new java.util.LinkedHashMap<>() {
        @Override
        protected boolean removeEldestEntry(Map.Entry eldest) {
            return size() > 1000; // Keep only the 1000 most recent entries
        }
    };

    public static void main(String[] args) {
        // Use boundedCache instead of sessionCache to prevent memory leaks
        System.out.println("Use bounded caches to prevent OutOfMemoryError.");
    }
}

7.5 ClassNotFoundException vs NoClassDefFoundError

These two errors are related but different:

  • ClassNotFoundException -- Thrown at runtime when you try to load a class by name using Class.forName(), ClassLoader.loadClass(), or reflection, and the class is not on the classpath. This is a checked exception.
  • NoClassDefFoundError -- Thrown when a class was available at compile time but is missing at runtime. This usually means a dependency JAR is missing from the classpath. This is an Error, not an Exception.
public class ClassLoadingErrors {

    public static void main(String[] args) {
        // ClassNotFoundException -- loading by name, class not found
        try {
            Class clazz = Class.forName("com.example.NonExistentClass");
        } catch (ClassNotFoundException e) {
            System.out.println("ClassNotFoundException: " + e.getMessage());
            // Fix: Check your classpath. Is the JAR included? Is the class name spelled correctly?
        }

        // NoClassDefFoundError -- class was compiled against but missing at runtime
        // Example: You compiled against gson-2.10.jar, but forgot to include it in deployment
        // try {
        //     Gson gson = new Gson(); // NoClassDefFoundError if Gson JAR is missing
        // } catch (NoClassDefFoundError e) {
        //     System.out.println("NoClassDefFoundError: " + e.getMessage());
        //     // Fix: Add the missing JAR to your runtime classpath
        // }

        System.out.println("Check your classpath when you see these errors.");
    }
}

7.6 equals() vs == for Objects

One of the most common bugs in Java is using == to compare objects when you should use equals(). The == operator compares references (memory addresses), while equals() compares values (content).

public class EqualsVsDoubleEquals {

    public static void main(String[] args) {
        // Strings -- == can be deceptive because of the String Pool
        String a = "hello";
        String b = "hello";
        String c = new String("hello");

        System.out.println(a == b);       // true  (same object in String Pool)
        System.out.println(a == c);       // false (different objects in memory)
        System.out.println(a.equals(c));  // true  (same content)

        // Integers -- == works for values -128 to 127 (Integer cache), fails for larger values
        Integer x = 127;
        Integer y = 127;
        System.out.println(x == y);       // true  (cached -- same object)

        Integer p = 128;
        Integer q = 128;
        System.out.println(p == q);       // false (not cached -- different objects!)
        System.out.println(p.equals(q));  // true  (same value)

        // Rule: ALWAYS use .equals() for object comparison
        // Use == only for primitives (int, double, boolean, etc.) and null checks
    }
}

8. Debugging Collections

Collections (Lists, Maps, Sets) are everywhere in Java code, and bugs often hide in how you create, modify, and search them. This section covers common collection debugging techniques.

8.1 Printing Collections Properly

Java collections have sensible toString() implementations, but arrays do not. This trips up many developers.

import java.util.*;

public class PrintingCollections {

    public static void main(String[] args) {
        // Lists, Sets, Maps -- toString() works out of the box
        List list = List.of("Alice", "Bob", "Charlie");
        System.out.println(list); // [Alice, Bob, Charlie]

        Map map = Map.of("a", 1, "b", 2);
        System.out.println(map); // {a=1, b=2}

        // Arrays -- toString() prints a useless memory address!
        int[] numbers = {1, 2, 3, 4, 5};
        System.out.println(numbers);                  // [I@6d06d69c  (useless!)
        System.out.println(Arrays.toString(numbers));  // [1, 2, 3, 4, 5]

        // 2D arrays -- need deepToString()
        int[][] matrix = {{1, 2}, {3, 4}};
        System.out.println(Arrays.toString(matrix));     // [[I@..., [I@...]  (useless!)
        System.out.println(Arrays.deepToString(matrix)); // [[1, 2], [3, 4]]

        // Custom objects -- override toString() in your classes
        // Otherwise you get: com.example.User@1a2b3c4d
    }
}

8.2 Debugging Stream Operations with peek()

Java Streams are powerful but hard to debug because operations are chained and lazily evaluated. The peek() method lets you inspect elements as they flow through the pipeline, without modifying them.

import java.util.List;
import java.util.stream.Collectors;

public class StreamDebugging {

    public static void main(String[] args) {
        List names = List.of("Alice", "Bob", "Charlie", "David", "Eve");

        // Without peek -- hard to see what is happening at each stage
        List result = names.stream()
            .filter(name -> name.length() > 3)
            .map(String::toUpperCase)
            .sorted()
            .collect(Collectors.toList());

        // With peek -- see what passes through each stage
        List debugResult = names.stream()
            .peek(name -> System.out.println("Original: " + name))
            .filter(name -> name.length() > 3)
            .peek(name -> System.out.println("After filter: " + name))
            .map(String::toUpperCase)
            .peek(name -> System.out.println("After map: " + name))
            .sorted()
            .collect(Collectors.toList());

        System.out.println("Final result: " + debugResult);

        // Output:
        // Original: Alice
        // After filter: Alice
        // After map: ALICE
        // Original: Bob
        // Original: Charlie
        // After filter: Charlie
        // After map: CHARLIE
        // Original: David
        // After filter: David
        // After map: DAVID
        // Original: Eve
        // Final result: [ALICE, CHARLIE, DAVID]

        // Note: "Bob" (3 chars) and "Eve" (3 chars) were filtered out by length > 3
        // Remove peek() calls before committing your code
    }
}

8.3 Debugging Map Keys (hashCode/equals Contract)

A subtle and frustrating bug occurs when you use custom objects as Map keys or Set elements without properly implementing hashCode() and equals(). If these two methods are not consistent, objects that are "equal" to you may not be found in the Map.

import java.util.HashMap;
import java.util.Map;
import java.util.Objects;

public class MapKeyBug {

    // BUG: This class overrides equals() but NOT hashCode()
    static class ProductBroken {
        String sku;

        ProductBroken(String sku) { this.sku = sku; }

        @Override
        public boolean equals(Object o) {
            if (this == o) return true;
            if (!(o instanceof ProductBroken)) return false;
            return Objects.equals(sku, ((ProductBroken) o).sku);
        }
        // hashCode() NOT overridden -- uses default Object.hashCode() (memory address)
    }

    // FIXED: Override both equals() AND hashCode()
    static class ProductFixed {
        String sku;

        ProductFixed(String sku) { this.sku = sku; }

        @Override
        public boolean equals(Object o) {
            if (this == o) return true;
            if (!(o instanceof ProductFixed)) return false;
            return Objects.equals(sku, ((ProductFixed) o).sku);
        }

        @Override
        public int hashCode() {
            return Objects.hash(sku);
        }
    }

    public static void main(String[] args) {
        // Broken: Two "equal" products hash to different buckets
        Map brokenMap = new HashMap<>();
        brokenMap.put(new ProductBroken("SKU-001"), 29.99);
        Double price = brokenMap.get(new ProductBroken("SKU-001"));
        System.out.println("Broken map lookup: " + price); // null! Bug!

        // Fixed: Equal products hash to the same bucket
        Map fixedMap = new HashMap<>();
        fixedMap.put(new ProductFixed("SKU-001"), 29.99);
        Double price2 = fixedMap.get(new ProductFixed("SKU-001"));
        System.out.println("Fixed map lookup: " + price2); // 29.99
    }
}

9. Unit Testing for Debugging

Unit tests are not just for verifying that code works -- they are one of the most effective debugging tools available. When you encounter a bug, the first thing you should do is write a test that reproduces it. This gives you a fast, repeatable way to verify the fix without manually running the application.

9.1 Test-Driven Debugging

The test-driven debugging workflow is:

  1. Write a test that demonstrates the bug (it should fail).
  2. Debug the code using the test as your entry point (set breakpoints in the test).
  3. Fix the bug and verify the test passes.
  4. Keep the test -- it prevents the bug from returning (regression).

9.2 JUnit Example for Bug Isolation

import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;

class ShoppingCartTest {

    // Step 1: Write a test that reproduces the bug
    @Test
    void totalShouldIncludeTaxForAllItems() {
        ShoppingCart cart = new ShoppingCart();
        cart.addItem("Laptop", 1000.00);
        cart.addItem("Mouse", 25.00);

        double total = cart.calculateTotal(0.10); // 10% tax

        // Bug report: total should be 1127.50 but returns 1025.00
        // (tax is only applied to the first item)
        assertEquals(1127.50, total, 0.01,
            "Total should include 10% tax on all items");
    }

    @Test
    void totalShouldBeZeroForEmptyCart() {
        ShoppingCart cart = new ShoppingCart();
        assertEquals(0.0, cart.calculateTotal(0.10), 0.01);
    }

    @Test
    void totalShouldHandleZeroTaxRate() {
        ShoppingCart cart = new ShoppingCart();
        cart.addItem("Book", 20.00);
        assertEquals(20.00, cart.calculateTotal(0.0), 0.01);
    }
}
import java.util.ArrayList;
import java.util.List;

public class ShoppingCart {
    private final List items = new ArrayList<>();

    public void addItem(String name, double price) {
        items.add(new Item(name, price));
    }

    // BUGGY VERSION: Tax is only applied to the first item
    // public double calculateTotal(double taxRate) {
    //     double total = 0;
    //     for (int i = 0; i < items.size(); i++) {
    //         double price = items.get(i).price;
    //         if (i == 0) {                    // Bug: should not check index
    //             price += price * taxRate;
    //         }
    //         total += price;
    //     }
    //     return total;
    // }

    // FIXED VERSION: Tax is applied to every item
    public double calculateTotal(double taxRate) {
        double total = 0;
        for (Item item : items) {
            total += item.price + (item.price * taxRate);
        }
        return total;
    }

    private static class Item {
        String name;
        double price;

        Item(String name, double price) {
            this.name = name;
            this.price = price;
        }
    }
}

10. Remote Debugging

Sometimes the bug only occurs in a remote environment (staging server, container, cloud VM) and cannot be reproduced locally. Remote debugging lets you connect your IDE's debugger to a JVM running on another machine. You get the full debugging experience -- breakpoints, variable inspection, stepping -- over a network connection.

10.1 Enabling Remote Debug on the JVM

To enable remote debugging, start the remote JVM with the following flag:

# Java 9+ (modern syntax)
java -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005 -jar myapp.jar

# Java 8 (older syntax)
java -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=5005 -jar myapp.jar

# Parameter explanation:
# transport=dt_socket  -- use TCP/IP socket for communication
# server=y             -- this JVM listens for debugger connections
# suspend=n            -- do NOT wait for debugger to attach before starting
#                         (use suspend=y if you need to debug startup code)
# address=*:5005       -- listen on all interfaces, port 5005
#                         (* allows connections from any IP -- restrict in production!)

10.2 Connecting Your IDE

IntelliJ IDEA:

  1. Go to Run > Edit Configurations > + > Remote JVM Debug
  2. Set Host to the remote server's IP address
  3. Set Port to 5005 (or whatever you configured)
  4. Click Debug to connect

Eclipse:

  1. Go to Run > Debug Configurations > Remote Java Application > New
  2. Set Host to the remote server's IP
  3. Set Port to 5005
  4. Click Debug to connect

10.3 When to Use Remote Debugging

  • The bug only occurs in a specific environment (staging, production-like)
  • The bug depends on data that only exists in the remote database
  • The bug involves interaction between multiple services
  • You need to inspect the state of a running application without stopping it

Warning: Never leave remote debugging enabled in production. It is a security risk (anyone who can reach port 5005 can execute code in your JVM) and a performance risk (breakpoints pause the entire application).

11. Debugging Tools

The JDK ships with several command-line tools that are invaluable for diagnosing problems in running Java applications. These tools work without an IDE, making them especially useful on production servers.

11.1 jps -- List Java Processes

Before you can use any JDK tool, you need the process ID (PID) of your Java application. jps lists all running Java processes.

# List all Java processes
jps
# Output:
# 12345 MyApplication
# 67890 Jps

# Show full main class name and JVM arguments
jps -lv
# Output:
# 12345 com.example.MyApplication -Xmx512m -Dspring.profiles.active=prod
# 67890 jdk.jcmd/sun.tools.jps.Jps -Dapplication.home=/usr/lib/jvm/java-17

11.2 jstack -- Thread Dumps

A thread dump is a snapshot of all threads in a JVM, showing what each thread is doing at that exact moment. Thread dumps are essential for diagnosing deadlocks, high CPU usage (finding which thread is busy), and hangs (finding which thread is blocked).

# Take a thread dump of process 12345
jstack 12345

# Save to a file for analysis
jstack 12345 > threaddump_$(date +%Y%m%d_%H%M%S).txt

# Detect deadlocks
jstack -l 12345

# Example thread dump output (abbreviated):
# "main" #1 prio=5 os_prio=0 tid=0x00007f... nid=0x1 RUNNABLE
#    at com.example.DataProcessor.process(DataProcessor.java:42)
#    at com.example.Application.main(Application.java:15)
#
# "http-nio-8080-exec-1" #25 prio=5 WAITING (on object monitor)
#    at java.lang.Object.wait(Native Method)
#    - waiting on <0x00000007...> (a java.util.concurrent.locks.ReentrantLock)
#    at com.example.ResourcePool.acquire(ResourcePool.java:88)
#
# Found 1 deadlock.
# "Thread-A": waiting to lock <0x000...> (held by "Thread-B")
# "Thread-B": waiting to lock <0x000...> (held by "Thread-A")

11.3 jmap -- Heap Dumps

A heap dump is a snapshot of all objects in the JVM's memory. It is used to diagnose memory leaks and high memory usage. You can analyze heap dumps with tools like Eclipse MAT (Memory Analyzer Tool) or VisualVM.

# Create a heap dump file (binary format)
jmap -dump:format=b,file=heapdump.hprof 12345

# Print a histogram of object counts and sizes (quick overview)
jmap -histo 12345
# Output:
#  num     #instances   #bytes    class name
#  ---     ----------   ------    ----------
#    1:      5000000    120000000 java.lang.String
#    2:      3000000     72000000 char[]
#    3:      1000000     24000000 com.example.Order
# ...

# Tip: If the top entries are unexpected (e.g., millions of Order objects),
# that is likely your memory leak. Use Eclipse MAT to find what holds
# references to those objects.

11.4 jconsole / VisualVM -- Real-Time Monitoring

jconsole and VisualVM are graphical tools that connect to a running JVM and show real-time metrics: memory usage, thread counts, CPU usage, and garbage collection activity. VisualVM is the more feature-rich option and supports plugins for heap dump analysis, profiling, and more.

# Launch jconsole (built into JDK)
jconsole
# A GUI window opens showing all local Java processes. Select one to monitor.

# Launch VisualVM (may need separate download since Java 9+)
# Download from: https://visualvm.github.io/
visualvm

# VisualVM features:
# - Monitor tab: CPU, memory, threads, classes in real time
# - Threads tab: See all threads, detect deadlocks, color-coded states
# - Sampler tab: CPU and memory sampling (lightweight profiling)
# - Heap Dump: Take and analyze heap dumps within the tool
# - Thread Dump: Take and display thread dumps

11.5 Summary of JDK Tools

Tool Purpose Key Command
jps List Java processes and their PIDs jps -lv
jstack Thread dump -- diagnose deadlocks, hangs, high CPU jstack <pid>
jmap Heap dump -- diagnose memory leaks jmap -dump:format=b,file=heap.hprof <pid>
jconsole Real-time JVM monitoring (GUI) jconsole
VisualVM Advanced monitoring, profiling, analysis (GUI) visualvm
jcmd Send diagnostic commands to a running JVM jcmd <pid> VM.flags

12. Debugging Strategies

Knowing the tools is only half the battle. You also need strategies -- systematic approaches for narrowing down the root cause of a bug. Here are the most effective techniques used by experienced developers.

12.1 Binary Search Debugging

When you have a large codebase and do not know where the bug is, use binary search. Comment out (or skip) half the code. Does the bug still happen? If yes, the bug is in the remaining half. If no, the bug is in the half you removed. Repeat until you find the exact location.

This technique is especially useful for:

  • A method with 200 lines that produces the wrong result
  • A configuration file with many entries where one is causing a failure
  • A build with many dependencies where one is causing a conflict

12.2 Rubber Duck Debugging

Explain the problem out loud, line by line, as if you were teaching it to someone who knows nothing about the code (traditionally, a rubber duck on your desk). The act of verbalizing forces you to slow down and examine assumptions you normally skip over. It is surprisingly effective -- many developers report finding the bug mid-sentence.

12.3 Divide and Conquer

Break the problem into smaller parts. If a complex method produces the wrong output:

  1. Test each part in isolation (write small unit tests for sub-steps).
  2. Verify that each part produces the correct intermediate result.
  3. The bug is in the part that produces the wrong intermediate result.

12.4 Reading the Code vs Running the Code

Sometimes the fastest way to find a bug is to read the code carefully instead of running it. Step through the logic in your head, tracing the values of variables on paper. This is especially effective for logic errors where the code runs without crashing but produces wrong results.

Other times, you need to run the code because the behavior depends on runtime data, external systems, or complex state that is hard to trace mentally. A good debugger makes this easy.

Senior developers switch between these two modes fluidly, choosing whichever is faster for the situation.

12.5 Ask the Right Questions

Before diving into the code, ask yourself:

  • What changed? -- If it worked yesterday, what commits were made since then? Use git log and git diff.
  • What is different? -- If it works on your machine but not in staging, what is different about the environments?
  • What do I know for certain? -- Separate facts from assumptions. "The database is returning the wrong data" is an assumption until you verify it with a direct query.
  • What is the simplest explanation? -- Before suspecting a JVM bug or a library defect, check your own code first. 99% of the time, the bug is in your code.

13. Best Practices

These are the principles that separate an effective debugger from someone who wastes hours chasing symptoms.

13.1 Reproduce First, Then Fix

Never try to fix a bug you cannot reproduce. If you cannot reliably trigger the bug, you have no way to verify that your fix actually works. The first step is always: find the exact steps to reproduce the issue.

13.2 Understand the Bug Before Fixing It

Do not apply the first change that makes the symptom disappear. Understand why the bug exists. Otherwise, you may be masking the real problem, and it will resurface later in a worse form.

13.3 Write a Test First

Before writing the fix, write a test that fails because of the bug. After applying the fix, the test should pass. This test now serves as a regression test -- it ensures the bug never comes back.

13.4 Check the Simplest Explanation

Before investigating complex race conditions or obscure library bugs, check the simple things:

  • Is the right version of the code deployed?
  • Is the configuration correct?
  • Is there a typo in a variable name?
  • Are you looking at the right log file?
  • Did you save and recompile?

13.5 Use Version Control to Find When the Bug Was Introduced

git bisect is a powerful tool that uses binary search across your commit history to find the exact commit that introduced a bug.

# Start bisecting
git bisect start

# Tell Git which commit is bad (has the bug) -- usually the current commit
git bisect bad

# Tell Git which commit is good (does not have the bug)
# Use a commit hash where you know the feature worked
git bisect good a1b2c3d

# Git checks out a commit halfway between good and bad.
# Test whether the bug exists at this commit.
# Then tell Git:
git bisect good  # if the bug is NOT present at this commit
# OR
git bisect bad   # if the bug IS present at this commit

# Git will narrow down the range until it finds the exact commit.
# Output: "abc1234 is the first bad commit"

# When done, reset to your original position:
git bisect reset

13.6 Do Not Fix Symptoms

If a method throws a NullPointerException, the fix is not to wrap everything in if (x != null). The fix is to find why the value is null in the first place. Maybe the object was never initialized, a method returned null unexpectedly, or data was missing from the database. Addressing the root cause prevents the null from propagating through your system.

13.7 Keep a Bug Journal

When you find and fix a non-trivial bug, write down what happened and how you found it. Over time, this journal becomes a personal reference that dramatically speeds up debugging. You will start recognizing patterns: "I have seen this symptom before -- last time the cause was X."

13.8 Summary of Best Practices

# Practice Why It Matters
1 Reproduce the bug reliably Cannot verify a fix for a bug you cannot trigger
2 Understand root cause before fixing Prevents masking the real problem
3 Write a failing test first Prevents regressions and verifies the fix
4 Check simple explanations first Saves hours of investigation
5 Use git bisect to find the introducing commit Narrows the search to one commit
6 Fix root causes, not symptoms Prevents the bug from recurring
7 Keep a bug journal Builds pattern recognition over time

14. Complete Practical Example

Let us put everything together with a realistic example. Below is a StudentGradeCalculator class that contains four intentional bugs. We will walk through finding each bug using different debugging techniques: reading the stack trace, using a debugger, adding logging, and writing tests.

First, read the code below and try to spot the bugs yourself before reading the walkthrough.

import java.util.*;

public class StudentGradeCalculator {

    // Bug 1 is here
    public static double calculateAverage(List grades) {
        int sum = 0;
        for (int i = 1; i <= grades.size(); i++) {  // Bug 1: starts at 1 and uses <=
            sum += grades.get(i);
        }
        return sum / grades.size();  // Bug 2: integer division
    }

    // Bug 3 is here
    public static String getLetterGrade(double average) {
        if (average >= 90) {
            return "A";
        } else if (average >= 80) {
            return "B";
        } else if (average >= 70) {
            return "C";
        } else if (average >= 60) {
            return "D";
        }
        return null;  // Bug 3: returns null instead of "F" for averages below 60
    }

    // Bug 4 is here
    public static Map processStudents(Map> studentGrades) {
        Map results = new HashMap<>();
        for (String student : studentGrades.keySet()) {
            List grades = studentGrades.get(student);
            double average = calculateAverage(grades);
            String letter = getLetterGrade(average);
            results.put(student, letter + " (avg: " + average + ")");  // Bug 4: NPE if letter is null
        }
        return results;
    }

    public static void main(String[] args) {
        Map> students = new HashMap<>();
        students.put("Alice", Arrays.asList(95, 87, 92, 88));
        students.put("Bob", Arrays.asList(45, 55, 40, 50));
        students.put("Charlie", Arrays.asList(75, 82, 78, 80));

        Map results = processStudents(students);
        for (Map.Entry entry : results.entrySet()) {
            System.out.println(entry.getKey() + ": " + entry.getValue());
        }
    }
}

Bug 1: ArrayIndexOutOfBoundsException (Found by Reading the Stack Trace)

When we run the program, it crashes immediately:

// Stack trace:
// Exception in thread "main" java.lang.IndexOutOfBoundsException: Index 4 out of bounds for length 4
//     at java.base/java.util.Arrays$ArrayList.get(Arrays.java:4351)
//     at StudentGradeCalculator.calculateAverage(StudentGradeCalculator.java:8)
//     at StudentGradeCalculator.processStudents(StudentGradeCalculator.java:27)
//     at StudentGradeCalculator.main(StudentGradeCalculator.java:36)

// Reading the stack trace:
// 1. IndexOutOfBoundsException at line 8 of calculateAverage()
// 2. "Index 4 out of bounds for length 4" -- trying index 4 in a 4-element list (valid: 0-3)
// 3. Look at line 8: grades.get(i) where i goes from 1 to grades.size() (which is 4)
// 4. The loop should be: for (int i = 0; i < grades.size(); i++)
//    - Start at 0 (not 1) because list indices start at 0
//    - Use < (not <=) because the last valid index is size()-1

// Fix:
// for (int i = 0; i < grades.size(); i++) {

Bug 2: Integer Division Truncation (Found Using the Debugger)

After fixing Bug 1, the program runs but the averages look wrong. Alice's grades are 95, 87, 92, 88 -- the average should be 90.5, but we get 90.0. Let us use the debugger to investigate.

// Debugger session:
// 1. Set a breakpoint on the return statement in calculateAverage()
// 2. Run in debug mode
// 3. When it pauses, inspect variables:
//    - sum = 362
//    - grades.size() = 4
//    - Return value: 362 / 4 = 90  (not 90.5!)
//
// The problem: sum (int) / grades.size() (int) performs INTEGER division
// 362 / 4 = 90 (truncated), not 90.5
//
// Fix: Cast to double before dividing
// return (double) sum / grades.size();
//
// Now: 362.0 / 4 = 90.5

// Corrected calculateAverage method:
public static double calculateAverage(List grades) {
    int sum = 0;
    for (int i = 0; i < grades.size(); i++) {  // Fixed Bug 1
        sum += grades.get(i);
    }
    return (double) sum / grades.size();  // Fixed Bug 2: cast to double
}

Bug 3: Null Return for Failing Grade (Found with Logging)

After fixing Bugs 1 and 2, the program works for Alice and Charlie but crashes for Bob. Let us add logging to understand what is happening.

// Add logging to processStudents to trace the flow:
public static Map processStudents(Map> studentGrades) {
    Map results = new HashMap<>();
    for (String student : studentGrades.keySet()) {
        List grades = studentGrades.get(student);
        System.out.println("[DEBUG] Processing student: " + student);
        System.out.println("[DEBUG] Grades: " + grades);

        double average = calculateAverage(grades);
        System.out.println("[DEBUG] Average: " + average);

        String letter = getLetterGrade(average);
        System.out.println("[DEBUG] Letter grade: " + letter);

        results.put(student, letter + " (avg: " + average + ")");
    }
    return results;
}

// Log output:
// [DEBUG] Processing student: Alice
// [DEBUG] Grades: [95, 87, 92, 88]
// [DEBUG] Average: 90.5
// [DEBUG] Letter grade: A
// [DEBUG] Processing student: Bob
// [DEBUG] Grades: [45, 55, 40, 50]
// [DEBUG] Average: 47.5
// [DEBUG] Letter grade: null    <-- Found it! getLetterGrade returns null for averages below 60

// The problem: getLetterGrade() has no case for averages below 60
// It falls through all the if/else blocks and returns null
// When we try to concatenate null + " (avg: ...)" it creates the string "null (avg: 47.5)"
// But if we later call .length() or similar on the letter, we get NPE

// Fix: Return "F" for averages below 60
// Change the last line of getLetterGrade from:
//   return null;
// to:
//   return "F";

Bug 4: Potential NullPointerException (Found with a Unit Test)

Even after fixing the null return in getLetterGrade(), the code in processStudents() was still vulnerable. Let us write a test that proves the code would fail with the original null return.

import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
import java.util.*;

class StudentGradeCalculatorTest {

    @Test
    void calculateAverageShouldReturnCorrectDecimalAverage() {
        List grades = Arrays.asList(95, 87, 92, 88);
        double average = StudentGradeCalculator.calculateAverage(grades);
        assertEquals(90.5, average, 0.01, "Average of [95,87,92,88] should be 90.5");
    }

    @Test
    void getLetterGradeShouldReturnFForLowAverages() {
        // This test catches Bug 3: getLetterGrade returned null for averages below 60
        String grade = StudentGradeCalculator.getLetterGrade(47.5);
        assertNotNull(grade, "Letter grade should never be null");
        assertEquals("F", grade, "Average of 47.5 should get letter grade F");
    }

    @Test
    void getLetterGradeShouldCoverAllRanges() {
        assertEquals("A", StudentGradeCalculator.getLetterGrade(95.0));
        assertEquals("B", StudentGradeCalculator.getLetterGrade(85.0));
        assertEquals("C", StudentGradeCalculator.getLetterGrade(75.0));
        assertEquals("D", StudentGradeCalculator.getLetterGrade(65.0));
        assertEquals("F", StudentGradeCalculator.getLetterGrade(50.0));
    }

    @Test
    void processStudentsShouldHandleFailingStudents() {
        Map> students = new HashMap<>();
        students.put("Bob", Arrays.asList(45, 55, 40, 50));

        // This test would have caught Bug 4: NPE when letter is null
        Map results = StudentGradeCalculator.processStudents(students);
        assertNotNull(results.get("Bob"));
        assertTrue(results.get("Bob").contains("F"),
            "Bob's result should contain grade F");
    }

    @Test
    void calculateAverageShouldHandleSingleGrade() {
        List grades = Arrays.asList(100);
        assertEquals(100.0, StudentGradeCalculator.calculateAverage(grades), 0.01);
    }
}

The Fully Fixed Version

Here is the complete corrected program with all four bugs fixed:

import java.util.*;

public class StudentGradeCalculator {

    public static double calculateAverage(List grades) {
        if (grades == null || grades.isEmpty()) {
            return 0.0;
        }
        int sum = 0;
        for (int i = 0; i < grades.size(); i++) {        // Fix 1: start at 0, use <
            sum += grades.get(i);
        }
        return (double) sum / grades.size();               // Fix 2: cast to double
    }

    public static String getLetterGrade(double average) {
        if (average >= 90) {
            return "A";
        } else if (average >= 80) {
            return "B";
        } else if (average >= 70) {
            return "C";
        } else if (average >= 60) {
            return "D";
        }
        return "F";                                         // Fix 3: return "F" instead of null
    }

    public static Map processStudents(Map> studentGrades) {
        Map results = new HashMap<>();
        for (String student : studentGrades.keySet()) {
            List grades = studentGrades.get(student);
            double average = calculateAverage(grades);
            String letter = getLetterGrade(average);
            // Fix 4: letter is now guaranteed non-null, but defensive check is good practice
            Objects.requireNonNull(letter, "Letter grade must not be null");
            results.put(student, letter + " (avg: " + String.format("%.1f", average) + ")");
        }
        return results;
    }

    public static void main(String[] args) {
        Map> students = new LinkedHashMap<>();
        students.put("Alice", Arrays.asList(95, 87, 92, 88));
        students.put("Bob", Arrays.asList(45, 55, 40, 50));
        students.put("Charlie", Arrays.asList(75, 82, 78, 80));

        Map results = processStudents(students);
        for (Map.Entry entry : results.entrySet()) {
            System.out.println(entry.getKey() + ": " + entry.getValue());
        }
    }
}

// Output:
// Alice: A (avg: 90.5)
// Bob: F (avg: 47.5)
// Charlie: C (avg: 78.8)

Summary of Bugs Found

Bug Type Technique Used Root Cause Fix
1 Runtime (IndexOutOfBoundsException) Reading the stack trace Loop starts at 1 and uses <= instead of starting at 0 and using < Change for (int i = 1; i <= ...) to for (int i = 0; i < ...)
2 Logic (wrong output) IDE debugger -- inspected return value Integer division truncates decimals: 362 / 4 = 90 instead of 90.5 Cast to double: (double) sum / grades.size()
3 Logic (null return) Logging -- printed letter grade value getLetterGrade() returns null for averages below 60 Return "F" instead of null
4 Runtime (potential NPE) Unit test -- test for failing student grades Concatenating null letter grade with string Fix Bug 3 and add Objects.requireNonNull() defensive check

This walkthrough demonstrates the core debugging workflow: observe the symptom, form a hypothesis, use the right tool to test it, and apply a targeted fix. With practice, this process becomes second nature, and you will find yourself debugging complex issues in minutes instead of hours.




Subscribe To Our Newsletter
You will receive our latest post and tutorial.
Thank you for subscribing!

required
required


Leave a Reply

Your email address will not be published. Required fields are marked *