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

required
required


Introduction to Java

Java is one of the most widely used programming languages in the world, and for good reason. If you are starting your software development journey or looking to add a battle-tested language to your toolkit, Java is an excellent choice. In this tutorial, I will walk you through what Java is, why it matters, and how to write your first program. This is not a surface-level overview. I want you to walk away with a real understanding of the language and the ecosystem around it.

What is Java?

Java is a general-purpose, object-oriented programming language created by James Gosling and his team at Sun Microsystems in 1995. It was originally designed for interactive television, but it turned out to be too advanced for the cable TV industry at the time. The language was later repositioned for the internet, and it took off from there.

Sun Microsystems was acquired by Oracle Corporation in 2010, and Oracle has maintained and evolved the language ever since. Java now follows a six-month release cadence, with Long-Term Support (LTS) releases every two years. As of 2024, the latest LTS version is Java 21, and Java 25 is expected as the next LTS release.

The original philosophy behind Java was “Write Once, Run Anywhere” (WORA). This means you write your Java code once, compile it, and that compiled code can run on any device that has a Java Virtual Machine (JVM) installed, whether it is Windows, macOS, or Linux. This portability is one of the key reasons Java became so dominant in enterprise software.

Key Features of Java

Java has survived and thrived for nearly three decades because of a set of core features that make it reliable for production software. Here are the features that matter most from a practical standpoint:

1. Platform Independent (via the JVM)

Java source code compiles to bytecode, not machine code. This bytecode runs on the Java Virtual Machine, which is available for virtually every operating system. You do not need to recompile your code for different platforms. This is a massive advantage for enterprise applications that need to run across diverse infrastructure.

2. Object-Oriented

Everything in Java revolves around objects and classes. Java enforces object-oriented programming principles such as encapsulation, inheritance, polymorphism, and abstraction. This makes Java code naturally organized and easier to maintain as projects grow in complexity.

3. Strongly Typed

Java is a statically and strongly typed language. Every variable must be declared with a type, and the compiler catches type mismatches before the code ever runs. This may feel restrictive if you are coming from Python or JavaScript, but in professional settings it prevents entire categories of bugs from reaching production.

4. Automatic Garbage Collection

Java manages memory for you through its garbage collector. You do not need to manually allocate and free memory like in C or C++. The JVM tracks which objects are no longer referenced and reclaims that memory automatically. Understanding how the garbage collector works becomes important when you start building high-performance applications, but as a beginner, you can trust that Java handles memory responsibly.

5. Multithreaded

Java has built-in support for multithreading. You can write programs that perform multiple tasks concurrently, which is essential for modern applications that need to handle many users or process large amounts of data simultaneously. The java.util.concurrent package provides powerful tools for writing thread-safe code.

6. Secure

Java was designed with security in mind. It runs inside the JVM sandbox, which restricts what a Java program can do on the host machine. There is no direct access to memory via pointers (unlike C/C++), which eliminates a whole class of security vulnerabilities like buffer overflows. The Java Security Manager and the bytecode verifier add additional layers of protection.

7. Robust and Mature Ecosystem

Java has one of the largest ecosystems of any programming language. Build tools like Maven and Gradle, frameworks like Spring Boot, and libraries for virtually everything you can think of, from HTTP clients to machine learning. When you learn Java, you gain access to this entire ecosystem.

JDK vs JRE vs JVM

These three acronyms cause a lot of confusion for beginners, but they are straightforward once you understand the relationship between them.

JVM (Java Virtual Machine)

The JVM is the engine that runs your Java bytecode. It is an abstract machine that provides the runtime environment. When you run a Java program, the JVM reads the compiled .class files and executes the bytecode. Each operating system has its own JVM implementation, which is how Java achieves platform independence. Your code does not change. Only the JVM changes per platform.

JRE (Java Runtime Environment)

The JRE is the JVM plus the standard Java class libraries needed to run Java applications. If you are an end user who just needs to run Java programs, the JRE is all you need. It includes the JVM, core libraries like java.lang, java.util, and java.io, and other supporting files.

JDK (Java Development Kit)

The JDK is the JRE plus the development tools you need to write and compile Java code. It includes the Java compiler (javac), the debugger (jdb), the archiver (jar), and other utilities. If you are a developer, you need the JDK. Here is how they nest together:

JDK = JRE + Development Tools (javac, jdb, jar, etc.)

JRE = JVM + Standard Libraries

JVM = The engine that executes bytecode

In practice, since Java 11, Oracle no longer distributes a standalone JRE. When you download the JDK, you get everything you need to both develop and run Java applications.

How to Install Java

Before you can write and run Java code, you need to install the JDK on your machine. Here are the most common approaches:

Option 1: Download from Oracle or Adoptium

Go to Oracle’s Java Downloads or Eclipse Adoptium (the community-driven distribution) and download the latest LTS version for your operating system. Run the installer and follow the prompts.

Option 2: Using SDKMAN (macOS/Linux)

SDKMAN is a tool for managing multiple JDK versions. It is my preferred method because it makes switching between Java versions painless.

# Install SDKMAN
curl -s "https://get.sdkman.io" | bash
source "$HOME/.sdkman/bin/sdkman-init.sh"

# List available Java versions
sdk list java

# Install Java 21 (Temurin distribution)
sdk install java 21.0.2-tem

# Verify installation
java -version

Option 3: Using Homebrew (macOS)

# Install the latest LTS JDK via Homebrew
brew install openjdk@21

# Symlink so the system can find it
sudo ln -sfn /opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk \
  /Library/Java/JavaVirtualMachines/openjdk-21.jdk

# Verify installation
java -version

Verify Your Installation

Regardless of how you installed Java, open a terminal and run:

java -version

You should see output similar to:

openjdk version "21.0.2" 2024-01-16
OpenJDK Runtime Environment Temurin-21.0.2+13 (build 21.0.2+13)
OpenJDK 64-Bit Server VM Temurin-21.0.2+13 (build 21.0.2+13, mixed mode)

If you see a version number, you are ready to write Java code.

Your First Java Program

Let us write the classic Hello World program and break down every part of it. Create a file called HelloWorld.java and add the following code:

public class HelloWorld {

    public static void main(String[] args) {
        System.out.println("Hello, World!");
    }
}

This is only five lines of code, but there is a lot happening. Let me explain each part:

public class HelloWorld

This declares a public class named HelloWorld. In Java, every piece of code lives inside a class. The public keyword means this class is accessible from anywhere. The file name must match the class name exactly, including capitalization. That is why the file is called HelloWorld.java.

public static void main(String[] args)

This is the entry point of any Java application. When you run a Java program, the JVM looks for this exact method signature. Let us break it down further:

  • public – The method is accessible from outside the class (the JVM needs to call it)
  • static – The method belongs to the class itself, not to an instance of the class. The JVM calls this method without creating an object
  • void – The method does not return any value
  • main – The name of the method. This is not arbitrary. The JVM specifically looks for a method named main
  • String[] args – An array of strings that holds any command-line arguments passed to the program

System.out.println("Hello, World!");

This prints the text “Hello, World!” to the console followed by a new line. System is a built-in class, out is its static output stream field, and println is a method that prints a line of text. Every statement in Java ends with a semicolon.

How to Compile and Run

Java is a compiled language, but it does not compile directly to machine code. Instead, the Java compiler (javac) converts your source code into bytecode, which the JVM then interprets and executes. Here is the two-step process:

Step 1: Compile

Open your terminal, navigate to the directory where you saved HelloWorld.java, and run:

javac HelloWorld.java

If there are no errors, this command produces a file called HelloWorld.class in the same directory. This .class file contains the bytecode that the JVM can execute.

Step 2: Run

java HelloWorld

Notice that you use java (not javac) and you do not include the .class extension. The output will be:

Hello, World!

Here is what happened behind the scenes:

  1. javac read your .java source file and compiled it into bytecode stored in a .class file
  2. java launched the JVM, which loaded the HelloWorld.class file
  3. The JVM found the main method and began executing it
  4. System.out.println sent the text to your terminal

Pro tip: Starting with Java 11, you can compile and run single-file programs in one step:

java HelloWorld.java

This compiles and runs the file in memory without creating a .class file on disk. It is great for quick prototyping and learning.

A More Practical Example

The Hello World program is a good starting point, but let me show you something slightly more realistic. Here is a program that accepts a name as a command-line argument and greets the user:

public class Greeter {

    public static void main(String[] args) {
        if (args.length == 0) {
            System.out.println("Usage: java Greeter <your-name>");
            System.out.println("Example: java Greeter Folau");
            return;
        }

        String name = args[0];
        String greeting = buildGreeting(name);
        System.out.println(greeting);
    }

    /**
     * Builds a personalized greeting message.
     *
     * @param name the name of the person to greet
     * @return a formatted greeting string
     */
    public static String buildGreeting(String name) {
        return "Hello, " + name + "! Welcome to Java programming.";
    }
}

Compile and run it:

javac Greeter.java
java Greeter Folau

Output:

Hello, Folau! Welcome to Java programming.

This example demonstrates several important concepts you will use constantly in Java:

  • Reading command-line arguments from the args array
  • Declaring variables with explicit types (String name)
  • Extracting logic into a separate method (buildGreeting)
  • Using Javadoc comments to document methods
  • String concatenation with the + operator
  • Using return to exit a method early

Where Java is Used

One of the most common questions I get from developers starting out is “Where is Java actually used?” The answer is: almost everywhere in the enterprise world. Here are the major areas:

Enterprise and Backend Systems

Java dominates enterprise backend development. Banks, insurance companies, healthcare systems, and government agencies run massive Java applications. Frameworks like Spring Boot make it straightforward to build REST APIs, microservices, and full-stack web applications. If you look at job postings for backend developers, Java consistently appears at the top of the list.

Android Development

Java was the primary language for Android app development for years. While Kotlin has become the preferred language for new Android projects, a huge amount of existing Android code is written in Java, and understanding Java is still valuable for Android developers.

Microservices

The microservices architecture pattern has become the standard for building scalable systems, and Java is one of the most popular languages for implementing it. Spring Boot, Quarkus, and Micronaut are Java frameworks specifically optimized for building lightweight, fast-starting microservices.

Big Data and Data Engineering

Many of the most important big data tools are written in Java or run on the JVM. Apache Hadoop, Apache Kafka, Apache Spark, and Elasticsearch are all Java-based. If you work in data engineering, you will encounter Java or JVM languages regularly.

Cloud and DevOps

Java applications run on every major cloud platform, including AWS, Google Cloud, and Azure. AWS Lambda supports Java natively, and tools like Docker and Kubernetes work seamlessly with Java applications. Spring Cloud provides a complete toolkit for building cloud-native Java applications.

Internet of Things (IoT)

Java’s portability makes it a natural fit for IoT devices. Java ME (Micro Edition) and frameworks like Eclipse IoT are used to build software for embedded systems, sensors, and connected devices.

Why Learn Java

With so many programming languages available today, you might wonder if Java is still worth learning. From my experience as a senior developer who has worked with multiple languages, here is why I would still recommend Java:

Job Market Demand

Java consistently ranks in the top three most in-demand programming languages globally. Enterprise companies have massive Java codebases that need to be maintained and extended. The demand for experienced Java developers remains strong, and Java roles tend to offer competitive salaries.

Mature and Stable Ecosystem

Java has been around since 1995, which means almost every problem you will encounter has already been solved by someone. There are well-established libraries, frameworks, and patterns for virtually everything. Maven Central hosts over 500,000 libraries. You are not going to be stuck Googling obscure errors with no answers.

Transferable Knowledge

Java’s syntax and concepts transfer directly to many other languages. If you learn Java well, picking up C#, Kotlin, Scala, or even TypeScript becomes significantly easier. The object-oriented principles, design patterns, and software architecture concepts you learn in Java apply universally across software development.

Modern and Evolving

Java is not the verbose, boilerplate-heavy language it was ten years ago. Modern Java (versions 17 and above) includes features like records, sealed classes, pattern matching, text blocks, and virtual threads. The language has evolved significantly while maintaining backward compatibility, which is a remarkable engineering achievement.

Strong Community

The Java community is one of the largest and most active in software development. Stack Overflow, GitHub, conferences like JavaOne and Devoxx, and countless blogs and YouTube channels provide an enormous amount of learning resources. When you get stuck, help is readily available.

What is Next?

Now that you understand what Java is, how to install it, and how to write and run a basic program, you are ready to dive deeper. In the next tutorial, we will cover Java Variables, where you will learn how to store and manipulate data in your programs. From there, we will build up your knowledge step by step through data types, operators, control flow, methods, classes, and beyond.

Take the time to install the JDK on your machine and run the examples from this tutorial. The best way to learn programming is by writing code, not just reading about it.

March 9, 2019

How to solve java problems

March 8, 2019

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.

March 8, 2019

Java String

What is a String?

A String in Java is a sequence of characters used to represent text. It is one of the most commonly used data types in any Java program. Whether you are storing a user’s name, reading data from a file, building SQL queries, or formatting output, you are working with Strings.

Key facts about Java Strings:

  • Reference type, not a primitive — String is a class (java.lang.String), not a primitive like int or char. A String variable holds a reference to an object on the heap.
  • Immutable — Once a String object is created, its character content can never be changed. Every operation that appears to modify a String actually creates a new String object.
  • Backed by a char array (or byte array since Java 9) — Internally, a String stores its characters in an array. Since Java 9, it uses a compact byte[] with an encoding flag for memory efficiency.
  • Implements CharSequence, Comparable<String>, and Serializable — This means Strings can be compared, sorted, and transmitted across networks or saved to files natively.

In Java, String literals are enclosed in double quotes ("Hello"). Single quotes are reserved for the char primitive type ('H').

// String -- a sequence of characters in double quotes
String greeting = "Hello, World!";
System.out.println(greeting);        // Hello, World!
System.out.println(greeting.length()); // 13

// char -- a single character in single quotes
char letter = 'A';

// This will NOT compile -- single quotes are for char, not String
// String wrong = 'Hello';  // Compilation error!

Creating Strings

There are two ways to create a String in Java, and the difference between them matters for both memory usage and object identity.

1. String Literal (String Pool)

When you create a String using a literal (double quotes), Java checks a special memory area called the String Pool (also known as the String Intern Pool). If an identical String already exists in the pool, Java returns a reference to that existing object instead of creating a new one. If no match is found, a new String object is created in the pool.

// Both variables point to the SAME object in the String Pool
String a = "Hello";
String b = "Hello";

// Since they reference the same object, == returns true
System.out.println(a == b); // true

2. Using the new Keyword (Heap Allocation)

When you use the new keyword, Java always creates a new String object on the heap, even if an identical String already exists in the pool. This means two Strings with the same content can be different objects in memory.

// Forces a new object on the heap, bypassing the String Pool
String c = new String("Hello");
String d = new String("Hello");

// Different objects in memory, so == returns false
System.out.println(c == d);    // false

// But their content is the same, so equals() returns true
System.out.println(c.equals(d)); // true

Memory Diagram: Literal vs new

Here is what happens in memory when you create Strings both ways:

  Stack                    Heap
  ------                   ------
  a ----+
        |---> [ "Hello" ]  <-- String Pool
  b ----+

  c ----------> [ "Hello" ]  <-- new object on heap (not in pool)
  d ----------> [ "Hello" ]  <-- another new object on heap

Best practice: Always use String literals unless you have a specific reason to use new String(). Literals benefit from the String Pool, which saves memory and enables faster comparisons.

String Immutability

Immutability means that once a String object is created, its contents cannot be changed. There is no method on String that modifies the original object. Every method that seems to change a String (like toUpperCase(), replace(), concat()) actually returns a new String object.

String original = "Hello";
String modified = original.toUpperCase();

System.out.println(original); // Hello  -- the original is unchanged!
System.out.println(modified); // HELLO  -- a brand new String object

// The variable can be reassigned, but the String object "Hello" still exists
String name = "Folau";
name = "Lisa";
// "Folau" is still in the String Pool -- we just moved the reference
// name now points to a different String object "Lisa"

Why Immutability Matters

String immutability is a deliberate design decision in Java, and it provides three major benefits:

  1. Thread Safety — Because Strings cannot be modified, they can be shared freely between threads without synchronization. No thread can corrupt a String that another thread is reading.
  2. Security — Strings are used for class loading, network connections, file paths, and database URLs. If Strings were mutable, malicious code could alter a validated file path or network address after security checks have passed.
  3. Caching and Performance — Because a String’s content never changes, its hash code can be computed once and cached. This makes Strings extremely efficient as keys in HashMap and HashSet. The String Pool itself is only possible because of immutability.

The String Pool (Intern Pool)

The String Pool is a special area of the heap where Java stores String literals. It is an optimization that prevents creating duplicate String objects for the same content.

How the pool works:

  1. When your code contains a String literal like "Hello", the JVM checks the pool.
  2. If "Hello" already exists in the pool, the JVM returns a reference to the existing object.
  3. If not, a new String is created in the pool and the reference is returned.

You can manually add a heap-allocated String to the pool using the intern() method:

String heapString = new String("Hello"); // created on the heap
String poolString = "Hello";              // from the String Pool

System.out.println(heapString == poolString);           // false -- different objects

// intern() returns a reference to the pool's copy
String internedString = heapString.intern();
System.out.println(internedString == poolString);       // true -- same pool object
System.out.println(internedString.equals(poolString));  // true

Note: In modern Java, the String Pool lives in the main heap (since Java 7), not in the PermGen space. This means it can grow dynamically and is subject to garbage collection. You rarely need to call intern() explicitly — the JVM handles it for literals automatically.

String Comparison (Critical Topic)

String comparison is one of the most common sources of bugs for Java beginners. The key rule to remember is: use equals() for content comparison, never ==.

== vs equals()

Operator/Method What it compares Use case
== Object references (memory addresses) Rarely useful for Strings
equals() Character-by-character content Almost always what you want
equalsIgnoreCase() Content, ignoring upper/lower case Case-insensitive matching
String literal1 = "Hello";
String literal2 = "Hello";
String heapStr  = new String("Hello");

// == compares references (memory addresses)
System.out.println(literal1 == literal2); // true  -- same pool object
System.out.println(literal1 == heapStr);  // false -- different objects

// equals() compares content
System.out.println(literal1.equals(literal2)); // true
System.out.println(literal1.equals(heapStr));  // true

// equalsIgnoreCase() ignores case
String upper = "HELLO";
System.out.println(literal1.equalsIgnoreCase(upper)); // true
System.out.println(literal1.equals(upper));            // false

Common bug: User input from Scanner, HTTP requests, file reads, and database queries always creates new String objects on the heap. Using == on these will almost always return false, even when the text matches. Always use equals().

compareTo() for Ordering

The compareTo() method compares two Strings lexicographically (dictionary order). It returns:

  • 0 if the strings are equal
  • A negative number if the calling string comes before the argument
  • A positive number if the calling string comes after the argument
String apple  = "apple";
String banana = "banana";
String cherry = "cherry";

System.out.println(apple.compareTo(banana));  // negative (a < b)
System.out.println(banana.compareTo(apple));  // positive (b > a)
System.out.println(apple.compareTo(apple));   // 0 (equal)

// compareToIgnoreCase() ignores case
String upper = "APPLE";
System.out.println(apple.compareToIgnoreCase(upper)); // 0

// compareTo() is used for sorting
List<String> fruits = Arrays.asList("cherry", "apple", "banana");
Collections.sort(fruits); // uses compareTo() internally
System.out.println(fruits); // [apple, banana, cherry]

Essential String Methods

The String class provides dozens of useful methods. Here are the most important ones every Java developer should know, organized by category.

Length and Character Access

length() returns the number of characters. charAt(int index) returns the character at a specific position (zero-indexed). toCharArray() converts the entire String to a char[].

String name = "Folau";

// length() -- number of characters
System.out.println(name.length()); // 5

// charAt() -- character at index (0-based)
System.out.println(name.charAt(0)); // F
System.out.println(name.charAt(4)); // u

// toCharArray() -- convert to char array
char[] chars = name.toCharArray();
for (char c : chars) {
    System.out.print(c + " "); // F o l a u
}
System.out.println();

// Looping through characters
for (int i = 0; i < name.length(); i++) {
    System.out.println("Index " + i + ": " + name.charAt(i));
}
// Index 0: F
// Index 1: o
// Index 2: l
// Index 3: a
// Index 4: u

Searching: indexOf(), lastIndexOf(), contains()

indexOf() returns the position of the first occurrence of a character or substring, or -1 if not found. lastIndexOf() searches from the end. contains() returns a simple boolean.

String sentence = "Java is powerful and Java is everywhere";

// indexOf() -- first occurrence
System.out.println(sentence.indexOf("Java"));    // 0
System.out.println(sentence.indexOf("Java", 1)); // 24 (search from index 1)
System.out.println(sentence.indexOf("Python"));  // -1 (not found)

// lastIndexOf() -- last occurrence
System.out.println(sentence.lastIndexOf("Java")); // 24

// contains() -- boolean check
System.out.println(sentence.contains("powerful")); // true
System.out.println(sentence.contains("weak"));     // false

// startsWith() and endsWith()
String url = "https://lovemesomecoding.com/java";
System.out.println(url.startsWith("https")); // true
System.out.println(url.endsWith(".com/java")); // true
System.out.println(url.startsWith("java", 8)); // false

Extracting: substring()

substring(int beginIndex) returns everything from beginIndex to the end. substring(int beginIndex, int endIndex) returns from beginIndex up to (but not including) endIndex.

String text = "Hello, World!";

// substring(beginIndex) -- from index to end
System.out.println(text.substring(7));    // World!

// substring(beginIndex, endIndex) -- from begin up to (not including) end
System.out.println(text.substring(0, 5)); // Hello
System.out.println(text.substring(7, 12)); // World

// Practical example: extract domain from email
String email = "folau@lovemesomecoding.com";
String domain = email.substring(email.indexOf("@") + 1);
System.out.println(domain); // lovemesomecoding.com

// Extract file extension
String filename = "report.pdf";
String extension = filename.substring(filename.lastIndexOf(".") + 1);
System.out.println(extension); // pdf

Concatenation: concat() and join()

concat() appends one String to another. String.join() is a static method that joins multiple Strings with a delimiter between them. Both return new String objects.

// concat() -- appends a string
String first = "Folau";
String last = "Kaveinga";
String fullName = first.concat(" ").concat(last);
System.out.println(fullName); // Folau Kaveinga

// The + operator does the same thing
String fullName2 = first + " " + last;
System.out.println(fullName2); // Folau Kaveinga

// String.join() -- joins with a delimiter
String csv = String.join(", ", "Java", "Python", "JavaScript");
System.out.println(csv); // Java, Python, JavaScript

// join() with a List
List<String> items = Arrays.asList("apple", "banana", "cherry");
String joined = String.join(" | ", items);
System.out.println(joined); // apple | banana | cherry

// Building a file path
String path = String.join("/", "home", "user", "documents", "file.txt");
System.out.println(path); // home/user/documents/file.txt

Replacing: replace() and replaceAll()

replace() replaces all occurrences of a character or exact substring. replaceAll() takes a regular expression pattern, giving you more powerful replacements. replaceFirst() replaces only the first match.

String text = "Java is great. Java is powerful.";

// replace() -- replaces exact matches (all occurrences)
System.out.println(text.replace("Java", "Python"));
// Python is great. Python is powerful.

// replace() with char
System.out.println("hello".replace('l', 'r')); // herro

// replaceAll() -- uses regex pattern
String messy = "Hello   World   Java";
System.out.println(messy.replaceAll("\\s+", " ")); // Hello World Java

// Remove all non-alphanumeric characters
String dirty = "Hello! @World# 123";
System.out.println(dirty.replaceAll("[^a-zA-Z0-9 ]", "")); // Hello World 123

// replaceFirst() -- only first match
System.out.println(text.replaceFirst("Java", "Python"));
// Python is great. Java is powerful.

Splitting: split()

split(String regex) divides a String into an array of substrings based on a delimiter pattern. This is one of the most useful methods for parsing data.

// Basic split with comma
String csv = "Java,Python,JavaScript,Go";
String[] languages = csv.split(",");
for (String lang : languages) {
    System.out.println(lang);
}
// Java
// Python
// JavaScript
// Go

// Split with limit -- maximum number of pieces
String[] limited = csv.split(",", 2);
System.out.println(limited[0]); // Java
System.out.println(limited[1]); // Python,JavaScript,Go

// Split on whitespace (handles multiple spaces)
String sentence = "Hello   World   Java";
String[] words = sentence.split("\\s+");
System.out.println(Arrays.toString(words)); // [Hello, World, Java]

// Split on pipe (pipe is a regex special char, must escape)
String data = "name|age|city";
String[] fields = data.split("\\|");
System.out.println(Arrays.toString(fields)); // [name, age, city]

// Practical example: parse a CSV line
String record = "Folau,Kaveinga,folau@email.com,35";
String[] parts = record.split(",");
String firstName = parts[0]; // Folau
String lastName  = parts[1]; // Kaveinga
String email     = parts[2]; // folau@email.com
int age          = Integer.parseInt(parts[3]); // 35

Trimming: trim() and strip()

trim() removes leading and trailing whitespace characters (ASCII value <= 32). strip(), introduced in Java 11, removes leading and trailing whitespace including Unicode whitespace characters, making it the preferred choice in modern Java.

String padded = "   Hello, World!   ";

// trim() -- removes ASCII whitespace from both ends
System.out.println("[" + padded.trim() + "]"); // [Hello, World!]

// strip() -- Java 11+ (handles Unicode whitespace too)
System.out.println("[" + padded.strip() + "]"); // [Hello, World!]

// stripLeading() -- only the beginning
System.out.println("[" + padded.stripLeading() + "]"); // [Hello, World!   ]

// stripTrailing() -- only the end
System.out.println("[" + padded.stripTrailing() + "]"); // [   Hello, World!]

// Practical difference: Unicode whitespace
String unicode = "\u2000 Hello \u2000"; // Unicode "En Quad" space
System.out.println("[" + unicode.trim() + "]");  // [ Hello  ] -- trim misses it
System.out.println("[" + unicode.strip() + "]"); // [Hello]    -- strip catches it

Case Conversion: toUpperCase() and toLowerCase()

String mixed = "Hello World";

System.out.println(mixed.toUpperCase()); // HELLO WORLD
System.out.println(mixed.toLowerCase()); // hello world

// Useful for case-insensitive comparison without equalsIgnoreCase
String input = "YES";
if (input.toLowerCase().equals("yes")) {
    System.out.println("User confirmed"); // User confirmed
}

// Locale-aware conversion (important for Turkish, German, etc.)
String turkish = "TITLE";
System.out.println(turkish.toLowerCase(Locale.forLanguageTag("tr"))); // tıtle (note dotless i)

Formatting: format() and formatted()

String.format() works like C’s printf. It uses format specifiers (%s for string, %d for integer, %f for floating point, %n for newline) to build formatted output. Java 15 introduced the formatted() instance method as a shorthand.

// String.format() -- static method
String name = "Folau";
int age = 30;
double gpa = 3.85;

String info = String.format("Name: %s, Age: %d, GPA: %.1f", name, age, gpa);
System.out.println(info); // Name: Folau, Age: 30, GPA: 3.9

// Common format specifiers:
// %s  -- String
// %d  -- integer (decimal)
// %f  -- floating point
// %.2f -- floating point with 2 decimal places
// %n  -- platform-specific newline
// %10s -- right-padded to 10 characters
// %-10s -- left-padded to 10 characters

// Formatting a table
String header = String.format("%-15s %-10s %10s", "Name", "Language", "Years");
String row1   = String.format("%-15s %-10s %10d", "Folau", "Java", 10);
String row2   = String.format("%-15s %-10s %10d", "Lisa", "Python", 5);
System.out.println(header);
System.out.println(row1);
System.out.println(row2);
// Name            Language        Years
// Folau           Java               10
// Lisa            Python              5

// formatted() -- Java 15+ instance method (same thing, shorter syntax)
String message = "Hello, %s! You have %d new messages.".formatted(name, 5);
System.out.println(message); // Hello, Folau! You have 5 new messages.

Empty and Blank Checks: isEmpty() and isBlank()

isEmpty() returns true if the String has zero length. isBlank() (Java 11+) returns true if the String is empty OR contains only whitespace characters. This distinction is important for input validation.

String empty      = "";
String blank      = "   ";
String hasContent = "Hello";

// isEmpty() -- true if length is 0
System.out.println(empty.isEmpty());      // true
System.out.println(blank.isEmpty());      // false  -- has spaces
System.out.println(hasContent.isEmpty()); // false

// isBlank() -- Java 11+ -- true if empty OR only whitespace
System.out.println(empty.isBlank());      // true
System.out.println(blank.isBlank());      // true   -- only whitespace
System.out.println(hasContent.isBlank()); // false

// Practical input validation
public static boolean isValidInput(String input) {
    return input != null && !input.isBlank();
}

// Always check for null first!
String userInput = null;
// userInput.isEmpty();  // NullPointerException!
// Safe approach:
if (userInput != null && !userInput.isBlank()) {
    System.out.println("Valid input");
} else {
    System.out.println("Invalid input"); // Invalid input
}

String Concatenation Performance

Because Strings are immutable, every concatenation with the + operator creates a new String object. For a few concatenations, this is fine. But inside a loop, it becomes a serious performance problem because the JVM creates and discards many temporary String objects.

Java provides two mutable alternatives for building Strings efficiently:

Class Thread-Safe? Performance When to Use
String with + Yes (immutable) Slow in loops Simple, small concatenations
StringBuilder No Fast Building strings in loops (single-threaded)
StringBuffer Yes (synchronized) Slightly slower Building strings in loops (multi-threaded)
// BAD: String concatenation in a loop
// Each += creates a new String object, copies all previous characters, then discards the old one
String result = "";
for (int i = 0; i < 10000; i++) {
    result += i + ", ";  // Creates ~10,000 temporary String objects!
}
// For 10,000 iterations, this copies roughly 50 million characters total


// GOOD: StringBuilder -- mutable, no unnecessary copies
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 10000; i++) {
    sb.append(i).append(", ");
}
String result2 = sb.toString();
// Uses a single internal buffer that grows as needed -- much faster


// StringBuffer -- same API, but thread-safe (synchronized)
StringBuffer sbuf = new StringBuffer();
for (int i = 0; i < 10000; i++) {
    sbuf.append(i).append(", ");
}
String result3 = sbuf.toString();

Performance Comparison

// Simple benchmark to demonstrate the difference
int iterations = 100_000;

// String concatenation with +
long start = System.currentTimeMillis();
String s = "";
for (int i = 0; i < iterations; i++) {
    s += "a";
}
long stringTime = System.currentTimeMillis() - start;

// StringBuilder
start = System.currentTimeMillis();
StringBuilder sb = new StringBuilder();
for (int i = 0; i < iterations; i++) {
    sb.append("a");
}
String s2 = sb.toString();
long builderTime = System.currentTimeMillis() - start;

System.out.println("String +:      " + stringTime + " ms");   // ~3000-5000 ms
System.out.println("StringBuilder: " + builderTime + " ms");  // ~2-5 ms
// StringBuilder is roughly 1000x faster for large loops!

Rule of thumb: Use + for simple, one-line concatenations. Use StringBuilder whenever you are building a String inside a loop or across many steps. Use StringBuffer only when multiple threads are appending to the same builder simultaneously (this is rare).

Note: The Java compiler automatically converts simple + concatenation (outside loops) into StringBuilder calls. Since Java 9, it uses invokedynamic for even better optimization. So String a = first + " " + last; is already efficient — the concern is specifically about loops.

Text Blocks (Java 13+)

Text Blocks, introduced as a preview in Java 13 and finalized in Java 15, allow you to write multi-line String literals using triple double-quotes ("""). They eliminate the need for escape sequences and string concatenation when working with multi-line text like JSON, SQL, HTML, or XML.

// BEFORE text blocks -- messy and error-prone
String jsonOld = "{\n" +
    "  \"name\": \"Folau\",\n" +
    "  \"age\": 30,\n" +
    "  \"language\": \"Java\"\n" +
    "}";

// AFTER text blocks -- clean and readable
String jsonNew = """
        {
          "name": "Folau",
          "age": 30,
          "language": "Java"
        }
        """;
System.out.println(jsonNew);

Text Block Rules

  • The opening """ must be followed by a line break — no content on the same line.
  • The closing """ determines the indentation baseline. Content indented beyond it is preserved; content aligned with it has no leading whitespace.
  • Single and double quotes do not need escaping inside text blocks.
  • Use \ (backslash at end of line) to suppress the newline at that point — this is a line continuation character.
  • Use \s to preserve trailing whitespace that would otherwise be stripped.
// SQL query -- much cleaner with text blocks
String sql = """
        SELECT u.name, u.email, o.total
        FROM users u
        JOIN orders o ON u.id = o.user_id
        WHERE o.total > 100
        ORDER BY o.total DESC
        """;

// HTML template
String html = """
        <html>
          <body>
            <h1>Welcome</h1>
            <p>Hello, World!</p>
          </body>
        </html>
        """;

// Line continuation with \ (no newline inserted)
String singleLine = """
        This is actually \
        a single line \
        of text.""";
System.out.println(singleLine);
// This is actually a single line of text.

// Text blocks work with format() and formatted()
String template = """
        Dear %s,
        Your order #%d has been shipped.
        Expected delivery: %s
        """.formatted("Folau", 12345, "March 15, 2026");
System.out.println(template);

Common Mistakes and Best Practices

Mistake 1: Comparing Strings with ==

This is the number one String bug in Java. Always use equals() for content comparison.

// BAD -- works sometimes by accident (String Pool), fails with user input
String input = scanner.nextLine();
if (input == "yes") {  // WRONG -- compares references, not content
    // This will almost always be false even if user types "yes"
}

// GOOD -- always compare content
if (input.equals("yes")) {
    // Correct!
}

// BETTER -- put the literal first to avoid NullPointerException
if ("yes".equals(input)) {
    // Safe even if input is null
}

Mistake 2: Ignoring the Return Value of String Methods

Since Strings are immutable, methods like trim(), replace(), and toUpperCase() return a new String. The original is unchanged. You must capture the return value.

// BAD -- the result is thrown away
String name = "  Folau  ";
name.trim();           // returns a new String, but we ignore it
name.toUpperCase();    // same problem
System.out.println(name); // "  Folau  " -- unchanged!

// GOOD -- capture the return value
String name2 = "  Folau  ";
name2 = name2.trim();
name2 = name2.toUpperCase();
System.out.println(name2); // "FOLAU"

// Or chain the methods
String result = "  Folau  ".trim().toUpperCase();
System.out.println(result); // "FOLAU"

Mistake 3: NullPointerException on String Methods

Calling any method on a null reference throws a NullPointerException. Always check for null or use a defensive pattern.

String value = null;

// BAD -- crashes at runtime
// value.length();  // NullPointerException!

// GOOD -- null check first
if (value != null && value.length() > 0) {
    System.out.println(value);
}

// GOOD -- use Objects.requireNonNullElse() (Java 9+)
String safe = java.util.Objects.requireNonNullElse(value, "default");
System.out.println(safe); // default

// GOOD -- put literal first for equals()
if ("expected".equals(value)) {
    // Safe -- "expected" is never null
}

Mistake 4: String Concatenation in Loops

As covered in the performance section, using += in a loop creates many unnecessary objects. Use StringBuilder instead.

Best Practices Summary

  1. Use equals(), not ==, for String comparison. Put the literal on the left side: "value".equals(variable).
  2. Use StringBuilder for concatenation inside loops.
  3. Use isBlank() instead of isEmpty() for input validation (Java 11+).
  4. Use strip() instead of trim() for Unicode-correct whitespace removal (Java 11+).
  5. Use Text Blocks for multi-line strings like SQL, JSON, and HTML (Java 15+).
  6. Use String.format() or formatted() instead of complex + concatenation for readability.
  7. Always check for null before calling String methods.
  8. Prefer String literals over new String() to take advantage of the String Pool.

Real-World Examples

Here are practical examples that demonstrate how String methods are used together in real applications.

Example 1: Parsing and Validating an Email Address

public static boolean isValidEmail(String email) {
    if (email == null || email.isBlank()) {
        return false;
    }

    email = email.strip().toLowerCase();

    // Must contain exactly one @
    int atIndex = email.indexOf("@");
    if (atIndex <= 0 || atIndex != email.lastIndexOf("@")) {
        return false;
    }

    // Must have a domain with a dot
    String domain = email.substring(atIndex + 1);
    if (!domain.contains(".") || domain.startsWith(".") || domain.endsWith(".")) {
        return false;
    }

    return true;
}

// Test
System.out.println(isValidEmail("folau@email.com"));    // true
System.out.println(isValidEmail("invalid@"));            // false
System.out.println(isValidEmail("no-at-sign.com"));      // false
System.out.println(isValidEmail("  FOLAU@Email.COM  ")); // true

Example 2: Converting a String to Title Case

public static String toTitleCase(String input) {
    if (input == null || input.isBlank()) {
        return input;
    }

    String[] words = input.strip().toLowerCase().split("\\s+");
    StringBuilder result = new StringBuilder();

    for (String word : words) {
        if (!result.isEmpty()) {
            result.append(" ");
        }
        // Capitalize first letter, keep rest lowercase
        result.append(Character.toUpperCase(word.charAt(0)));
        result.append(word.substring(1));
    }

    return result.toString();
}

System.out.println(toTitleCase("hello world"));          // Hello World
System.out.println(toTitleCase("jAVA STRING tutorial")); // Java String Tutorial
System.out.println(toTitleCase("  multiple   spaces ")); // Multiple Spaces

Example 3: Building a URL with Query Parameters

public static String buildUrl(String baseUrl, Map<String, String> params) {
    if (params == null || params.isEmpty()) {
        return baseUrl;
    }

    StringBuilder url = new StringBuilder(baseUrl);
    url.append("?");

    boolean first = true;
    for (Map.Entry<String, String> entry : params.entrySet()) {
        if (!first) {
            url.append("&");
        }
        url.append(entry.getKey())
           .append("=")
           .append(entry.getValue());
        first = false;
    }

    return url.toString();
}

// Usage
Map<String, String> params = new LinkedHashMap<>();
params.put("q", "java+string");
params.put("page", "1");
params.put("lang", "en");

String url = buildUrl("https://example.com/search", params);
System.out.println(url);
// https://example.com/search?q=java+string&page=1&lang=en

Example 4: Masking Sensitive Data

public static String maskCreditCard(String cardNumber) {
    // Remove any spaces or dashes
    String cleaned = cardNumber.replaceAll("[\\s-]", "");

    if (cleaned.length() < 4) {
        return "****";
    }

    // Show only last 4 digits
    String lastFour = cleaned.substring(cleaned.length() - 4);
    String masked = "*".repeat(cleaned.length() - 4) + lastFour;

    // Format in groups of 4
    StringBuilder formatted = new StringBuilder();
    for (int i = 0; i < masked.length(); i++) {
        if (i > 0 && i % 4 == 0) {
            formatted.append(" ");
        }
        formatted.append(masked.charAt(i));
    }

    return formatted.toString();
}

System.out.println(maskCreditCard("4532 1234 5678 9012"));
// **** **** **** 9012

System.out.println(maskCreditCard("1234-5678-9012-3456"));
// **** **** **** 3456

Example 5: Parsing a Log Line

// Parse a typical log format: "2026-02-28 10:30:45 [ERROR] NullPointerException in UserService.java:42"
public static void parseLogLine(String logLine) {
    // Split into parts
    String date = logLine.substring(0, 10);                  // 2026-02-28
    String time = logLine.substring(11, 19);                 // 10:30:45

    // Extract log level between [ and ]
    int levelStart = logLine.indexOf("[") + 1;
    int levelEnd   = logLine.indexOf("]");
    String level   = logLine.substring(levelStart, levelEnd); // ERROR

    // Extract message after "] "
    String message = logLine.substring(levelEnd + 2);         // NullPointerException in UserService.java:42

    System.out.println("Date:    " + date);
    System.out.println("Time:    " + time);
    System.out.println("Level:   " + level);
    System.out.println("Message: " + message);

    // Check severity
    if ("ERROR".equals(level) || "FATAL".equals(level)) {
        System.out.println("ALERT: High severity issue detected!");
    }
}

parseLogLine("2026-02-28 10:30:45 [ERROR] NullPointerException in UserService.java:42");
// Date:    2026-02-28
// Time:    10:30:45
// Level:   ERROR
// Message: NullPointerException in UserService.java:42
// ALERT: High severity issue detected!

String Methods Quick Reference

Method Returns Description
length() int Number of characters
charAt(int) char Character at index
indexOf(String) int First index of substring, or -1
lastIndexOf(String) int Last index of substring, or -1
substring(int, int) String Portion of the string
contains(CharSequence) boolean Whether it contains the sequence
startsWith(String) boolean Whether it starts with prefix
endsWith(String) boolean Whether it ends with suffix
equals(Object) boolean Content equality
equalsIgnoreCase(String) boolean Case-insensitive equality
compareTo(String) int Lexicographic comparison
concat(String) String Appends a string
replace(old, new) String Replaces all occurrences
replaceAll(regex, new) String Regex-based replacement
split(regex) String[] Splits into array
trim() String Removes ASCII whitespace
strip() String Removes Unicode whitespace (Java 11+)
toUpperCase() String All uppercase
toLowerCase() String All lowercase
isEmpty() boolean True if length is 0
isBlank() boolean True if empty or only whitespace (Java 11+)
toCharArray() char[] Converts to character array
format(String, Object...) String Printf-style formatting
formatted(Object...) String Instance format method (Java 15+)
join(delim, elements) String Joins elements with delimiter
repeat(int) String Repeats string N times (Java 11+)
intern() String Returns pool reference
March 8, 2019

Collections

1. What is the Collections Framework?

Imagine you are building an application that manages users. You need to store a list of users, look up users by their email, and maintain a set of unique roles. You could write your own data structures from scratch — arrays that resize, linked lists that traverse, hash tables that resolve collisions — but that would be reinventing the wheel for every project.

The Java Collections Framework (JCF) is a unified architecture for representing and manipulating groups of objects. Introduced in Java 2 (JDK 1.2), it provides:

  • Interfaces — Abstract data types that define operations (e.g., List, Set, Map)
  • Implementations — Concrete classes that implement those interfaces (e.g., ArrayList, HashSet, HashMap)
  • Algorithms — Static methods in the Collections utility class for sorting, searching, and transforming collections

Collection vs Collections

A common point of confusion:

Name Type Purpose
Collection Interface (java.util.Collection) Root interface for List, Set, and Queue. Defines core methods like add(), remove(), size(), iterator()
Collections Utility class (java.util.Collections) Contains static helper methods like sort(), unmodifiableList(), synchronizedMap(), emptyList()

Think of Collection as the blueprint and Collections as the toolbox.

2. Collections Hierarchy

The framework is organized into two main hierarchies: the Collection hierarchy (for single-element containers) and the Map hierarchy (for key-value pairs). Map does not extend Collection — this is an important distinction.

                     Iterable
                        |
                    Collection
                   /    |     \
                List   Set    Queue
                 |      |       |
          ArrayList  HashSet  PriorityQueue
          LinkedList TreeSet  ArrayDeque
                     LinkedHashSet

                      Map (separate hierarchy)
                   /    |       \
              HashMap TreeMap  LinkedHashMap
Interface Duplicates? Ordered? Key-Value? Key Implementations
List Yes Yes (insertion order) No ArrayList, LinkedList
Set No Depends on impl No HashSet, LinkedHashSet, TreeSet
Queue Yes FIFO or priority No PriorityQueue, ArrayDeque
Map No (keys) Depends on impl Yes HashMap, LinkedHashMap, TreeMap

Every collection in the Collection hierarchy is Iterable, meaning you can use a for-each loop on it. Map is not Iterable directly, but you can iterate over its keySet(), values(), or entrySet() views.

3. List Interface

A List is an ordered collection (also called a sequence). Lists allow:

  • Positional access — elements are accessed by their integer index (0-based)
  • Duplicate elements — the same value can appear multiple times
  • Insertion order — elements are maintained in the order they were added

The List interface defines operations like get(index), set(index, element), add(index, element), remove(index), indexOf(element), and subList(fromIndex, toIndex).

3.1 ArrayList

ArrayList is the most commonly used List implementation. Under the hood, it is backed by a resizable array. When the internal array fills up, ArrayList creates a new array (typically 1.5x the old size) and copies the elements over.

Performance characteristics:

Operation Time Complexity Why
get(index) O(1) Direct array index access
add(element) (at end) O(1) amortized Append to end; occasional resize
add(index, element) (in middle) O(n) Must shift elements to the right
remove(index) O(n) Must shift elements to the left
contains(element) O(n) Linear scan
size() O(1) Tracked internally

When to use ArrayList: When you need fast random access by index and most additions are at the end of the list. This covers the vast majority of real-world use cases.

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

public class ArrayListExample {
    public static void main(String[] args) {
        // Program to interface: declare as List, instantiate as ArrayList
        List languages = new ArrayList<>();

        // Adding elements
        languages.add("Java");
        languages.add("Python");
        languages.add("JavaScript");
        languages.add("Go");
        languages.add("Java"); // Duplicates are allowed
        System.out.println("Languages: " + languages);
        // Output: Languages: [Java, Python, JavaScript, Go, Java]

        // Access by index
        String first = languages.get(0);
        System.out.println("First: " + first); // Output: First: Java

        // Update an element
        languages.set(3, "Rust");
        System.out.println("After set(3): " + languages);
        // Output: After set(3): [Java, Python, JavaScript, Rust, Java]

        // Insert at specific position
        languages.add(1, "TypeScript");
        System.out.println("After add(1): " + languages);
        // Output: After add(1): [Java, TypeScript, Python, JavaScript, Rust, Java]

        // Remove by index
        languages.remove(4); // Removes "Rust"
        System.out.println("After remove(4): " + languages);
        // Output: After remove(4): [Java, TypeScript, Python, JavaScript, Java]

        // Remove by object (removes first occurrence)
        languages.remove("Java"); // Removes first "Java" at index 0
        System.out.println("After remove(\"Java\"): " + languages);
        // Output: After remove("Java"): [TypeScript, Python, JavaScript, Java]

        // Useful methods
        System.out.println("Size: " + languages.size());         // Output: Size: 4
        System.out.println("Contains Python? " + languages.contains("Python")); // Output: Contains Python? true
        System.out.println("Index of Java: " + languages.indexOf("Java"));      // Output: Index of Java: 3
        System.out.println("Is empty? " + languages.isEmpty());   // Output: Is empty? false

        // Sublist (fromIndex inclusive, toIndex exclusive)
        List subset = languages.subList(1, 3);
        System.out.println("Sublist(1,3): " + subset);
        // Output: Sublist(1,3): [Python, JavaScript]
    }
}

3.2 LinkedList

LinkedList is implemented as a doubly-linked list. Each element (node) holds a reference to both the previous and next nodes. It also implements the Deque interface, making it usable as both a queue and a stack.

Performance characteristics:

Operation Time Complexity Why
get(index) O(n) Must traverse from head or tail
add(element) (at end) O(1) Direct pointer update at tail
addFirst(element) O(1) Direct pointer update at head
remove() (first or last) O(1) Pointer update, no shifting
remove(index) (in middle) O(n) Must traverse to the node first
contains(element) O(n) Linear scan

When to use LinkedList: When you need frequent insertions/removals at the beginning or end of the list (queue or stack behavior). In practice, ArrayList outperforms LinkedList for most workloads because of CPU cache locality — the contiguous memory layout of an array is much more cache-friendly than scattered nodes on the heap.

import java.util.LinkedList;
import java.util.Deque;

public class LinkedListExample {
    public static void main(String[] args) {
        // LinkedList as a Deque (double-ended queue)
        Deque taskQueue = new LinkedList<>();

        // Add to front and back
        taskQueue.addFirst("Review PR");
        taskQueue.addLast("Write tests");
        taskQueue.addLast("Deploy to staging");
        taskQueue.addFirst("Fix critical bug"); // Urgent task goes first!
        System.out.println("Task Queue: " + taskQueue);
        // Output: Task Queue: [Fix critical bug, Review PR, Write tests, Deploy to staging]

        // Peek at first and last without removing
        System.out.println("Next task: " + taskQueue.peekFirst());
        // Output: Next task: Fix critical bug
        System.out.println("Last task: " + taskQueue.peekLast());
        // Output: Last task: Deploy to staging

        // Process (remove) from front
        String completed = taskQueue.pollFirst();
        System.out.println("Completed: " + completed);
        // Output: Completed: Fix critical bug
        System.out.println("Remaining: " + taskQueue);
        // Output: Remaining: [Review PR, Write tests, Deploy to staging]

        // Use as a stack (LIFO) -- push/pop operate on the head
        Deque undoStack = new LinkedList<>();
        undoStack.push("Type 'Hello'");
        undoStack.push("Bold text");
        undoStack.push("Change font");
        System.out.println("\nUndo Stack: " + undoStack);
        // Output: Undo Stack: [Change font, Bold text, Type 'Hello']

        String undone = undoStack.pop();
        System.out.println("Undone: " + undone);
        // Output: Undone: Change font
    }
}

3.3 ArrayList vs LinkedList

Criteria ArrayList LinkedList
Internal structure Resizable array Doubly-linked list
Random access (get) O(1) — fast O(n) — slow
Insert/remove at end O(1) amortized O(1)
Insert/remove at beginning O(n) — shifts everything O(1) — pointer update
Insert/remove in middle O(n) O(n) (traversal) + O(1) (insert)
Memory overhead Low (contiguous array) High (each node has 2 pointers + object overhead)
Cache performance Excellent (contiguous memory) Poor (nodes scattered on heap)
Implements Deque? No Yes
Best for Most general-purpose list needs Queue/stack patterns, frequent add/remove at ends

Rule of thumb: Use ArrayList by default. Only reach for LinkedList if you specifically need Deque operations and profiling shows it matters. In practice, even ArrayDeque is usually faster than LinkedList for queue/stack operations because of cache locality.

4. Set Interface

A Set is a collection that contains no duplicate elements. It models the mathematical set abstraction. If you try to add an element that is already in the set, the add() method returns false and the set remains unchanged.

Sets determine equality using the equals() method. For hash-based sets (HashSet, LinkedHashSet), the hashCode() method is also critical — objects that are equals() must have the same hashCode().

4.1 HashSet

HashSet is the most commonly used Set implementation. Internally, it is backed by a HashMap (the set elements are stored as keys with a dummy value). It provides:

  • O(1) average time for add(), remove(), contains()
  • No ordering guarantee — elements may come out in any order during iteration
  • Allows one null element
import java.util.HashSet;
import java.util.Set;

public class HashSetExample {
    public static void main(String[] args) {
        Set uniqueEmails = new HashSet<>();

        // Adding elements
        uniqueEmails.add("alice@example.com");
        uniqueEmails.add("bob@example.com");
        uniqueEmails.add("charlie@example.com");

        // Duplicate is rejected
        boolean added = uniqueEmails.add("alice@example.com");
        System.out.println("alice added again? " + added); // Output: alice added again? false
        System.out.println("Size: " + uniqueEmails.size()); // Output: Size: 3

        // Check membership
        System.out.println("Contains bob? " + uniqueEmails.contains("bob@example.com"));
        // Output: Contains bob? true

        // Remove an element
        uniqueEmails.remove("charlie@example.com");
        System.out.println("After remove: " + uniqueEmails);
        // Output: After remove: [bob@example.com, alice@example.com] (order may vary)

        // Iteration -- order is NOT guaranteed
        for (String email : uniqueEmails) {
            System.out.println("Email: " + email);
        }
    }
}

4.2 LinkedHashSet

LinkedHashSet extends HashSet and maintains a doubly-linked list across all entries, preserving insertion order. It has slightly higher memory overhead than HashSet because of the linked list, but iteration is predictable.

When to use: When you need the uniqueness guarantee of a Set but also need to iterate elements in the order they were added.

import java.util.LinkedHashSet;
import java.util.Set;

public class LinkedHashSetExample {
    public static void main(String[] args) {
        Set visitedPages = new LinkedHashSet<>();

        visitedPages.add("/home");
        visitedPages.add("/about");
        visitedPages.add("/products");
        visitedPages.add("/home");     // Duplicate -- ignored
        visitedPages.add("/contact");

        // Iteration preserves insertion order
        System.out.println("Browsing history (unique pages):");
        for (String page : visitedPages) {
            System.out.println("  " + page);
        }
        // Output:
        //   /home
        //   /about
        //   /products
        //   /contact
    }
}

4.3 TreeSet

TreeSet stores elements in sorted order using a Red-Black tree (a self-balancing binary search tree). It implements the NavigableSet interface, which provides methods for range queries and navigation.

  • O(log n) for add(), remove(), contains()
  • Elements must be Comparable (implement Comparable interface) OR you must provide a Comparator at construction time
  • Does not allow null (would throw NullPointerException — cannot compare null)
import java.util.TreeSet;
import java.util.NavigableSet;

public class TreeSetExample {
    public static void main(String[] args) {
        NavigableSet scores = new TreeSet<>();

        scores.add(85);
        scores.add(92);
        scores.add(78);
        scores.add(95);
        scores.add(88);
        scores.add(85); // Duplicate -- ignored

        // Automatically sorted in natural order (ascending)
        System.out.println("Scores: " + scores);
        // Output: Scores: [78, 85, 88, 92, 95]

        // NavigableSet methods
        System.out.println("First (lowest): " + scores.first());   // Output: First (lowest): 78
        System.out.println("Last (highest): " + scores.last());    // Output: Last (highest): 95
        System.out.println("Lower than 88: " + scores.lower(88));  // Output: Lower than 88: 85
        System.out.println("Higher than 88: " + scores.higher(88));// Output: Higher than 88: 92
        System.out.println("Floor of 90: " + scores.floor(90));    // Output: Floor of 90: 88
        System.out.println("Ceiling of 90: " + scores.ceiling(90));// Output: Ceiling of 90: 92

        // Subset (fromInclusive, toExclusive)
        System.out.println("Scores 80-90: " + scores.subSet(80, true, 90, true));
        // Output: Scores 80-90: [85, 88]

        // Descending order
        System.out.println("Descending: " + scores.descendingSet());
        // Output: Descending: [95, 92, 88, 85, 78]
    }
}

4.4 When to Use Each Set

Set Type Ordering Performance Best For
HashSet None O(1) average General-purpose uniqueness checks; fastest option
LinkedHashSet Insertion order O(1) average Unique elements with predictable iteration order
TreeSet Sorted (natural or custom) O(log n) Sorted unique elements, range queries, finding nearest values

5. Map Interface

A Map stores data as key-value pairs. Each key maps to exactly one value. Keys must be unique — if you put a new value with an existing key, the old value is replaced. Map does not extend Collection; it is a separate interface.

5.1 HashMap

HashMap is the workhorse of key-value storage in Java. It uses a hash table internally: keys are hashed to determine their bucket location, giving O(1) average-case performance for most operations.

  • O(1) average for put(), get(), containsKey(), remove()
  • No ordering guarantee for iteration
  • Allows one null key and multiple null values
  • Not thread-safe — use ConcurrentHashMap for concurrent access
import java.util.HashMap;
import java.util.Map;

public class HashMapExample {
    public static void main(String[] args) {
        Map wordCount = new HashMap<>();

        // put() -- add or update key-value pairs
        wordCount.put("java", 15);
        wordCount.put("python", 12);
        wordCount.put("javascript", 8);
        wordCount.put("go", 5);
        System.out.println("Word counts: " + wordCount);

        // get() -- retrieve value by key
        int javaCount = wordCount.get("java");
        System.out.println("Java count: " + javaCount); // Output: Java count: 15

        // getOrDefault() -- avoid NullPointerException for missing keys
        int rubyCount = wordCount.getOrDefault("ruby", 0);
        System.out.println("Ruby count: " + rubyCount); // Output: Ruby count: 0

        // put() with existing key -- replaces old value
        wordCount.put("java", 20);
        System.out.println("Java updated: " + wordCount.get("java")); // Output: Java updated: 20

        // putIfAbsent() -- only adds if key is not already present
        wordCount.putIfAbsent("java", 999);    // Ignored -- key exists
        wordCount.putIfAbsent("kotlin", 3);     // Added -- key is new
        System.out.println("Java: " + wordCount.get("java"));     // Output: Java: 20
        System.out.println("Kotlin: " + wordCount.get("kotlin")); // Output: Kotlin: 3

        // Checking keys and values
        System.out.println("Contains 'python' key? " + wordCount.containsKey("python"));   // Output: true
        System.out.println("Contains value 8? " + wordCount.containsValue(8));              // Output: true

        // Size
        System.out.println("Size: " + wordCount.size()); // Output: Size: 5

        // Remove
        wordCount.remove("go");
        System.out.println("After removing 'go': " + wordCount);

        // Iteration -- three ways to iterate a Map
        System.out.println("\n--- keySet() ---");
        for (String key : wordCount.keySet()) {
            System.out.println(key + " -> " + wordCount.get(key));
        }

        System.out.println("\n--- entrySet() (preferred) ---");
        for (Map.Entry entry : wordCount.entrySet()) {
            System.out.println(entry.getKey() + " -> " + entry.getValue());
        }

        System.out.println("\n--- values() ---");
        for (int count : wordCount.values()) {
            System.out.println("Count: " + count);
        }

        // Java 8+ forEach with lambda
        System.out.println("\n--- forEach lambda ---");
        wordCount.forEach((key, value) ->
            System.out.println(key + " = " + value)
        );
    }
}

5.2 LinkedHashMap

LinkedHashMap extends HashMap and maintains a doubly-linked list of entries, preserving insertion order (or optionally, access order). This makes iteration predictable.

Access-order mode is particularly useful for implementing LRU (Least Recently Used) caches. When access-order is enabled, every get() or put() moves the entry to the end of the linked list.

import java.util.LinkedHashMap;
import java.util.Map;

public class LinkedHashMapExample {
    public static void main(String[] args) {
        // Insertion-order LinkedHashMap
        Map config = new LinkedHashMap<>();
        config.put("host", "localhost");
        config.put("port", "8080");
        config.put("protocol", "https");

        // Iteration follows insertion order
        System.out.println("Config (insertion order):");
        config.forEach((key, value) ->
            System.out.println("  " + key + " = " + value)
        );
        // Output:
        //   host = localhost
        //   port = 8080
        //   protocol = https

        // LRU Cache using access-order LinkedHashMap
        // Parameters: initialCapacity, loadFactor, accessOrder
        int maxSize = 3;
        Map lruCache = new LinkedHashMap<>(16, 0.75f, true) {
            @Override
            protected boolean removeEldestEntry(Map.Entry eldest) {
                return size() > maxSize; // Remove oldest when cache exceeds max size
            }
        };

        lruCache.put("A", "Alpha");
        lruCache.put("B", "Bravo");
        lruCache.put("C", "Charlie");
        System.out.println("\nCache: " + lruCache); // {A=Alpha, B=Bravo, C=Charlie}

        lruCache.get("A"); // Access "A" -- moves it to end
        lruCache.put("D", "Delta"); // Exceeds max size -- evicts least recently used ("B")
        System.out.println("After access A, add D: " + lruCache);
        // Output: After access A, add D: {C=Charlie, A=Alpha, D=Delta}
        // "B" was evicted because it was the least recently used
    }
}

5.3 TreeMap

TreeMap stores entries sorted by their keys using a Red-Black tree. It implements the NavigableMap interface, providing methods for range queries, floor/ceiling lookups, and submap views.

  • O(log n) for put(), get(), remove()
  • Keys must be Comparable or a Comparator must be provided
  • Does not allow null keys (but allows null values)
import java.util.TreeMap;
import java.util.NavigableMap;
import java.util.Map;

public class TreeMapExample {
    public static void main(String[] args) {
        NavigableMap studentGrades = new TreeMap<>();

        studentGrades.put("Charlie", 3.5);
        studentGrades.put("Alice", 3.9);
        studentGrades.put("Eve", 3.2);
        studentGrades.put("Bob", 3.7);
        studentGrades.put("Diana", 3.8);

        // Automatically sorted by key (alphabetical for Strings)
        System.out.println("Grades (sorted by name): " + studentGrades);
        // Output: {Alice=3.9, Bob=3.7, Charlie=3.5, Diana=3.8, Eve=3.2}

        // NavigableMap methods
        System.out.println("First: " + studentGrades.firstEntry()); // Alice=3.9
        System.out.println("Last: " + studentGrades.lastEntry());   // Eve=3.2

        // Range query: students from "Bob" to "Diana" (inclusive)
        Map range = studentGrades.subMap("Bob", true, "Diana", true);
        System.out.println("Bob to Diana: " + range);
        // Output: Bob to Diana: {Bob=3.7, Charlie=3.5, Diana=3.8}

        // Floor and ceiling
        System.out.println("Floor of 'D': " + studentGrades.floorKey("D"));     // Charlie
        System.out.println("Ceiling of 'D': " + studentGrades.ceilingKey("D")); // Diana

        // Descending map
        System.out.println("Reverse: " + studentGrades.descendingMap());
        // Output: {Eve=3.2, Diana=3.8, Charlie=3.5, Bob=3.7, Alice=3.9}
    }
}

5.4 Hashtable (Legacy — Avoid)

Hashtable is a legacy class from Java 1.0 (before the Collections Framework existed). It is similar to HashMap but with two important differences:

  • Synchronized — every method is synchronized, which makes it thread-safe but slow
  • No nulls — does not allow null keys or null values

Do not use Hashtable in new code. If you need a thread-safe map, use ConcurrentHashMap, which uses lock striping for much better concurrent performance. If you do not need thread safety, use HashMap.

5.5 When to Use Each Map

Map Type Ordering Performance Null Keys? Thread-Safe? Best For
HashMap None O(1) average Yes (one) No General-purpose key-value storage
LinkedHashMap Insertion or access order O(1) average Yes (one) No Predictable iteration order, LRU caches
TreeMap Sorted by key O(log n) No No Sorted key-value pairs, range queries
Hashtable None O(1) average No Yes (but slow) Legacy code only — use ConcurrentHashMap instead
ConcurrentHashMap None O(1) average No Yes (efficient) Multi-threaded concurrent access

6. Queue and Deque

A Queue is a collection designed for holding elements prior to processing, typically in FIFO (First-In, First-Out) order. A Deque (Double-Ended Queue, pronounced “deck”) extends Queue and supports element insertion and removal at both ends.

Key Queue/Deque Methods

Operation Throws Exception Returns null/false
Insert add(e) offer(e)
Remove remove() poll()
Examine element() peek()

Prefer the “returns null/false” variants (offer, poll, peek) in most code — they let you handle empty queues gracefully without try/catch blocks.

6.1 PriorityQueue

PriorityQueue is a min-heap by default. Elements are dequeued in natural order (smallest first for numbers, alphabetical for strings) or by a custom Comparator. It is not a sorted data structure — only the head is guaranteed to be the minimum. The internal ordering of other elements is not guaranteed.

import java.util.PriorityQueue;
import java.util.Queue;
import java.util.Comparator;

public class PriorityQueueExample {
    public static void main(String[] args) {
        // Min-heap (default) -- lowest number = highest priority
        Queue minHeap = new PriorityQueue<>();
        minHeap.offer(30);
        minHeap.offer(10);
        minHeap.offer(20);
        minHeap.offer(5);

        System.out.println("Peek (min): " + minHeap.peek()); // Output: 5

        System.out.print("Polling (ascending): ");
        while (!minHeap.isEmpty()) {
            System.out.print(minHeap.poll() + " ");
        }
        // Output: Polling (ascending): 5 10 20 30
        System.out.println();

        // Max-heap using Comparator.reverseOrder()
        Queue maxHeap = new PriorityQueue<>(Comparator.reverseOrder());
        maxHeap.offer(30);
        maxHeap.offer(10);
        maxHeap.offer(20);
        maxHeap.offer(5);

        System.out.print("Polling (descending): ");
        while (!maxHeap.isEmpty()) {
            System.out.print(maxHeap.poll() + " ");
        }
        // Output: Polling (descending): 30 20 10 5
        System.out.println();

        // Priority queue with custom objects
        record Task(String name, int priority) {}

        Queue taskQueue = new PriorityQueue<>(
            Comparator.comparingInt(Task::priority) // Lower number = higher priority
        );

        taskQueue.offer(new Task("Send email", 3));
        taskQueue.offer(new Task("Fix production bug", 1));
        taskQueue.offer(new Task("Update docs", 5));
        taskQueue.offer(new Task("Code review", 2));

        System.out.println("\nProcessing tasks by priority:");
        while (!taskQueue.isEmpty()) {
            Task task = taskQueue.poll();
            System.out.println("  [P" + task.priority() + "] " + task.name());
        }
        // Output:
        //   [P1] Fix production bug
        //   [P2] Code review
        //   [P3] Send email
        //   [P5] Update docs
    }
}

6.2 ArrayDeque

ArrayDeque is the go-to implementation for both stacks and queues. It is backed by a resizable circular array and outperforms both LinkedList (when used as a queue) and Stack (a legacy class).

  • O(1) amortized for push, pop, offer, poll, peek
  • Not thread-safe
  • Does not allow null elements (null is used as a sentinel internally)
import java.util.ArrayDeque;
import java.util.Deque;

public class ArrayDequeExample {
    public static void main(String[] args) {
        // Use as a STACK (LIFO) -- push/pop at the head
        Deque callStack = new ArrayDeque<>();
        callStack.push("main()");
        callStack.push("processOrder()");
        callStack.push("validatePayment()");
        callStack.push("chargeCard()");

        System.out.println("Call stack: " + callStack);
        // Output: [chargeCard(), validatePayment(), processOrder(), main()]

        System.out.println("Current: " + callStack.peek()); // chargeCard()
        callStack.pop(); // chargeCard() returns
        System.out.println("After pop: " + callStack.peek()); // validatePayment()

        // Use as a QUEUE (FIFO) -- offer at tail, poll from head
        Deque printQueue = new ArrayDeque<>();
        printQueue.offer("Document1.pdf");
        printQueue.offer("Photo.jpg");
        printQueue.offer("Report.xlsx");

        System.out.println("\nPrint queue: " + printQueue);
        System.out.println("Printing: " + printQueue.poll()); // Document1.pdf
        System.out.println("Printing: " + printQueue.poll()); // Photo.jpg
        System.out.println("Remaining: " + printQueue);       // [Report.xlsx]
    }
}

7. Iterating Collections

Java provides several ways to loop through collections. Each approach has its strengths, and choosing the right one depends on whether you need the index, need to modify the collection during iteration, or prefer a functional style.

7.1 For-Each Loop (Enhanced For)

The simplest and most readable way to iterate any Iterable. Use this when you need to process every element and do not need the index or the ability to remove elements.

List names = List.of("Alice", "Bob", "Charlie");

// For-each loop -- clean and simple
for (String name : names) {
    System.out.println(name);
}
// Output:
// Alice
// Bob
// Charlie

7.2 Iterator

Use an Iterator when you need to remove elements safely during iteration. Calling collection.remove() inside a for-each loop throws a ConcurrentModificationException — the Iterator.remove() method is the safe way to do it.

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

public class IteratorExample {
    public static void main(String[] args) {
        List numbers = new ArrayList<>(List.of(1, 2, 3, 4, 5, 6, 7, 8));

        // Remove all even numbers using Iterator
        Iterator it = numbers.iterator();
        while (it.hasNext()) {
            int num = it.next();
            if (num % 2 == 0) {
                it.remove(); // Safe removal during iteration
            }
        }
        System.out.println("Odd numbers: " + numbers);
        // Output: Odd numbers: [1, 3, 5, 7]
    }
}

7.3 ListIterator

ListIterator extends Iterator and is available only for List implementations. It adds the ability to traverse backwards, get the current index, and replace or add elements during iteration.

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

public class ListIteratorExample {
    public static void main(String[] args) {
        List items = new ArrayList<>(List.of("apple", "banana", "cherry"));

        ListIterator lit = items.listIterator();
        while (lit.hasNext()) {
            int index = lit.nextIndex();
            String item = lit.next();
            lit.set(item.toUpperCase()); // Replace current element
            System.out.println("[" + index + "] " + item + " -> " + item.toUpperCase());
        }
        // Output:
        // [0] apple -> APPLE
        // [1] banana -> BANANA
        // [2] cherry -> CHERRY

        System.out.println("Updated list: " + items);
        // Output: Updated list: [APPLE, BANANA, CHERRY]

        // Traverse backwards
        System.out.print("Reverse: ");
        while (lit.hasPrevious()) {
            System.out.print(lit.previous() + " ");
        }
        // Output: Reverse: CHERRY BANANA APPLE
    }
}

7.4 forEach with Lambda (Java 8+)

The forEach() method accepts a Consumer lambda. It is concise for simple operations but less flexible than an explicit loop (no break, no continue, no checked exceptions).

import java.util.List;
import java.util.Map;

public class ForEachLambdaExample {
    public static void main(String[] args) {
        // Collection.forEach()
        List fruits = List.of("Apple", "Banana", "Cherry");
        fruits.forEach(fruit -> System.out.println("Fruit: " + fruit));

        // Map.forEach() -- provides both key and value
        Map inventory = Map.of("Apple", 50, "Banana", 30, "Cherry", 20);
        inventory.forEach((item, qty) ->
            System.out.println(item + ": " + qty + " in stock")
        );

        // Method reference -- even more concise
        fruits.forEach(System.out::println);

        // stream().forEach() -- use when chaining stream operations
        fruits.stream()
              .filter(f -> f.startsWith("B"))
              .forEach(f -> System.out.println("Starts with B: " + f));
        // Output: Starts with B: Banana
    }
}

7.5 ConcurrentModificationException

This is one of the most common errors Java developers encounter. It occurs when you modify a collection while iterating over it with a for-each loop.

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

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

        // BAD -- throws ConcurrentModificationException
        // for (String name : names) {
        //     if (name.startsWith("C")) {
        //         names.remove(name); // Modifying collection during for-each!
        //     }
        // }

        // SOLUTION 1: Use Iterator.remove()
        var it = names.iterator();
        while (it.hasNext()) {
            if (it.next().startsWith("C")) {
                it.remove();
            }
        }
        System.out.println("After iterator remove: " + names);
        // Output: After iterator remove: [Alice, Bob, David]

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

        // SOLUTION 3: Collect items to remove, then removeAll
        List names3 = new ArrayList<>(List.of("Alice", "Bob", "Charlie", "David"));
        List toRemove = new ArrayList<>();
        for (String name : names3) {
            if (name.startsWith("C")) {
                toRemove.add(name);
            }
        }
        names3.removeAll(toRemove);
        System.out.println("After removeAll: " + names3);
        // Output: After removeAll: [Alice, Bob, David]
    }
}

7.6 When to Use Each Iteration Method

Method Best For Can Remove? Can Break?
For-each loop Simple read-only iteration No Yes (break)
Iterator Safe removal during iteration Yes (it.remove()) Yes (break)
ListIterator Bidirectional traversal, replace/add during iteration Yes Yes (break)
forEach + lambda Concise one-liner operations No No
removeIf() Conditional bulk removal Yes (built in) N/A
stream().forEach() Chaining filter/map/collect operations No No

8. Sorting

Sorting is one of the most common operations on collections. Java provides two mechanisms for defining sort order: the Comparable interface (natural ordering built into the class) and the Comparator interface (external, custom ordering).

8.1 Comparable — Natural Ordering

A class implements Comparable to define its natural ordering. The compareTo() method returns:

  • Negative if this is less than the other
  • Zero if equal
  • Positive if this is greater than the other

Built-in classes like String, Integer, Double, and LocalDate already implement Comparable. For your own classes, you implement it yourself.

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

// Employee implements Comparable -- natural ordering by salary
public class Employee implements Comparable {
    private String name;
    private double salary;

    public Employee(String name, double salary) {
        this.name = name;
        this.salary = salary;
    }

    public String getName() { return name; }
    public double getSalary() { return salary; }

    @Override
    public int compareTo(Employee other) {
        // Natural ordering: by salary ascending
        return Double.compare(this.salary, other.salary);
    }

    @Override
    public String toString() {
        return name + " ($" + String.format("%.0f", salary) + ")";
    }

    public static void main(String[] args) {
        List team = new ArrayList<>();
        team.add(new Employee("Alice", 85000));
        team.add(new Employee("Bob", 72000));
        team.add(new Employee("Charlie", 95000));
        team.add(new Employee("Diana", 78000));

        // Sort using natural ordering (compareTo)
        Collections.sort(team);
        System.out.println("By salary (natural): " + team);
        // Output: By salary (natural): [Bob ($72000), Diana ($78000), Alice ($85000), Charlie ($95000)]

        // Or use List.sort() -- same result
        team.sort(null); // null means use natural ordering
    }
}

8.2 Comparator — Custom Ordering

A Comparator is an external comparison strategy. Use it when:

  • You want to sort by a different field than the natural ordering
  • You do not own the class and cannot modify it to implement Comparable
  • You need multiple sort orders for the same class

Java 8 introduced Comparator.comparing() and method chaining, which replaced the old anonymous class approach.

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

public class ComparatorExample {
    record Employee(String name, String department, double salary) {}

    public static void main(String[] args) {
        List team = new ArrayList<>();
        team.add(new Employee("Alice", "Engineering", 85000));
        team.add(new Employee("Bob", "Marketing", 72000));
        team.add(new Employee("Charlie", "Engineering", 95000));
        team.add(new Employee("Diana", "Marketing", 78000));
        team.add(new Employee("Eve", "Engineering", 85000));

        // Sort by name (alphabetical)
        team.sort(Comparator.comparing(Employee::name));
        System.out.println("By name:");
        team.forEach(e -> System.out.println("  " + e));
        // Alice, Bob, Charlie, Diana, Eve

        // Sort by salary descending
        team.sort(Comparator.comparingDouble(Employee::salary).reversed());
        System.out.println("\nBy salary (descending):");
        team.forEach(e -> System.out.println("  " + e));
        // Charlie (95000), Alice (85000), Eve (85000), Diana (78000), Bob (72000)

        // Sort by department, then by salary descending within department
        team.sort(Comparator.comparing(Employee::department)
                            .thenComparing(Comparator.comparingDouble(Employee::salary).reversed()));
        System.out.println("\nBy department, then salary desc:");
        team.forEach(e -> System.out.println("  " + e));
        // Output:
        //   Employee[name=Charlie, department=Engineering, salary=95000.0]
        //   Employee[name=Alice, department=Engineering, salary=85000.0]
        //   Employee[name=Eve, department=Engineering, salary=85000.0]
        //   Employee[name=Diana, department=Marketing, salary=78000.0]
        //   Employee[name=Bob, department=Marketing, salary=72000.0]

        // Handling nulls
        List items = new ArrayList<>(List.of("Banana", "Apple"));
        items.add(null);
        items.add("Cherry");

        items.sort(Comparator.nullsLast(Comparator.naturalOrder()));
        System.out.println("\nWith nulls last: " + items);
        // Output: With nulls last: [Apple, Banana, Cherry, null]
    }
}

8.3 Comparable vs Comparator

Aspect Comparable Comparator
Package java.lang java.util
Method compareTo(T o) compare(T o1, T o2)
Location Inside the class itself External (separate class or lambda)
Sort orders One (natural ordering) Many (create multiple Comparators)
Modifies class? Yes (class must implement it) No (works externally)
Use with Collections.sort(list), TreeSet list.sort(comparator), new TreeSet<>(comparator)

Best practice: Implement Comparable for the most natural, obvious ordering (e.g., employees by ID). Use Comparator for any alternative sort orders (by name, by department, by salary).

9. Collections Utility Class

The java.util.Collections class provides static methods that operate on or return collections. Think of it as the “Swiss Army knife” for collections. Here are the most useful methods:

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

public class CollectionsUtilityExample {
    public static void main(String[] args) {
        List numbers = new ArrayList<>(List.of(5, 3, 8, 1, 9, 2, 7));

        // --- Sorting ---
        Collections.sort(numbers);
        System.out.println("Sorted: " + numbers);
        // Output: Sorted: [1, 2, 3, 5, 7, 8, 9]

        // --- Reverse ---
        Collections.reverse(numbers);
        System.out.println("Reversed: " + numbers);
        // Output: Reversed: [9, 8, 7, 5, 3, 2, 1]

        // --- Shuffle (random order) ---
        Collections.shuffle(numbers);
        System.out.println("Shuffled: " + numbers);
        // Output: Shuffled: [3, 5, 1, 9, 7, 2, 8] (random each time)

        // --- Min and Max ---
        Collections.sort(numbers); // Re-sort for clarity
        System.out.println("Min: " + Collections.min(numbers)); // Output: Min: 1
        System.out.println("Max: " + Collections.max(numbers)); // Output: Max: 9

        // --- Frequency ---
        List words = List.of("java", "python", "java", "go", "java", "python");
        System.out.println("'java' appears: " + Collections.frequency(words, "java") + " times");
        // Output: 'java' appears: 3 times

        // --- Binary Search (list must be sorted!) ---
        List sorted = List.of(10, 20, 30, 40, 50);
        int index = Collections.binarySearch(sorted, 30);
        System.out.println("Index of 30: " + index); // Output: Index of 30: 2

        // --- Unmodifiable List (read-only wrapper) ---
        List mutable = new ArrayList<>(List.of("A", "B", "C"));
        List readOnly = Collections.unmodifiableList(mutable);
        // readOnly.add("D"); // Throws UnsupportedOperationException!
        System.out.println("Read-only: " + readOnly);

        // --- Empty collections (immutable) ---
        List empty = Collections.emptyList();
        System.out.println("Empty list: " + empty); // Output: Empty list: []
        // empty.add("X"); // Throws UnsupportedOperationException!

        // --- Singleton (immutable single-element collection) ---
        List single = Collections.singletonList("OnlyOne");
        System.out.println("Singleton: " + single); // Output: Singleton: [OnlyOne]

        // --- Fill ---
        List template = new ArrayList<>(List.of("A", "B", "C", "D"));
        Collections.fill(template, "X");
        System.out.println("Filled: " + template); // Output: Filled: [X, X, X, X]

        // --- Swap ---
        List letters = new ArrayList<>(List.of("A", "B", "C"));
        Collections.swap(letters, 0, 2);
        System.out.println("Swapped: " + letters); // Output: Swapped: [C, B, A]

        // --- nCopies (immutable) ---
        List copies = Collections.nCopies(5, "Hello");
        System.out.println("Copies: " + copies); // Output: Copies: [Hello, Hello, Hello, Hello, Hello]
    }
}

Java 9+ Immutable Factory Methods

Java 9 introduced convenient factory methods that are now the preferred way to create small, immutable collections:

import java.util.List;
import java.util.Set;
import java.util.Map;

public class ImmutableFactoryExample {
    public static void main(String[] args) {
        // List.of() -- immutable list
        List colors = List.of("Red", "Green", "Blue");
        System.out.println("Colors: " + colors);
        // colors.add("Yellow"); // UnsupportedOperationException!

        // Set.of() -- immutable set (no duplicates allowed)
        Set primes = Set.of(2, 3, 5, 7, 11);
        System.out.println("Primes: " + primes);

        // Map.of() -- immutable map (up to 10 entries)
        Map scores = Map.of(
            "Alice", 95,
            "Bob", 87,
            "Charlie", 92
        );
        System.out.println("Scores: " + scores);

        // Map.ofEntries() -- immutable map (any number of entries)
        Map config = Map.ofEntries(
            Map.entry("host", "localhost"),
            Map.entry("port", "8080"),
            Map.entry("env", "production"),
            Map.entry("debug", "false")
        );
        System.out.println("Config: " + config);

        // To create a mutable copy from an immutable collection:
        List mutableColors = new java.util.ArrayList<>(colors);
        mutableColors.add("Yellow"); // This works!
        System.out.println("Mutable colors: " + mutableColors);
    }
}

10. Choosing the Right Collection

Choosing the wrong collection is a common source of performance bugs and unnecessary complexity. Use this decision guide:

Decision Table

Need Recommended Collection Why
Ordered list, fast random access ArrayList O(1) get by index, contiguous memory
Frequent insert/remove at both ends ArrayDeque O(1) push/pop, better cache performance than LinkedList
Unique elements, fast lookup HashSet O(1) contains/add/remove
Unique elements, sorted TreeSet O(log n), red-black tree, supports range queries
Unique elements, insertion order LinkedHashSet O(1) with predictable iteration order
Key-value lookup HashMap O(1) average get/put
Key-value, sorted by key TreeMap O(log n), sorted keys, range queries
Key-value, insertion order LinkedHashMap O(1) with predictable iteration
Priority-based processing PriorityQueue Min-heap, always gives smallest element first
Thread-safe map ConcurrentHashMap Lock striping for high concurrent throughput
Thread-safe list (read-heavy) CopyOnWriteArrayList No locking for reads, copies on write
Producer-consumer queue LinkedBlockingQueue Blocking operations for thread coordination

Quick Decision Flowchart

Do you need key-value pairs?
├── YES -> Do keys need to be sorted?
│   ├── YES -> TreeMap
│   └── NO -> Need insertion order?
│       ├── YES -> LinkedHashMap
│       └── NO -> Need thread safety?
│           ├── YES -> ConcurrentHashMap
│           └── NO -> HashMap
└── NO -> Do you need uniqueness (no duplicates)?
    ├── YES -> Need sorted order?
    │   ├── YES -> TreeSet
    │   └── NO -> Need insertion order?
    │       ├── YES -> LinkedHashSet
    │       └── NO -> HashSet
    └── NO -> Do you need FIFO/LIFO/Priority?
        ├── YES -> Need priority ordering?
        │   ├── YES -> PriorityQueue
        │   └── NO -> ArrayDeque (for both stack and queue)
        └── NO -> ArrayList (default choice for lists)

11. Thread-Safe Collections

The standard collections (ArrayList, HashMap, HashSet) are not thread-safe. If multiple threads access them concurrently and at least one thread modifies the collection, you will get unpredictable behavior or ConcurrentModificationException.

Options for Thread Safety

Approach How Performance When to Use
Synchronized wrappers Collections.synchronizedList(), Collections.synchronizedMap() Poor (single lock for all operations) Quick fix; rarely the best choice
ConcurrentHashMap Lock striping + CAS operations Excellent for concurrent reads/writes Multi-threaded map access (most common need)
CopyOnWriteArrayList Creates a new array on every write Excellent reads, expensive writes Read-heavy, write-rare scenarios (e.g., listener lists)
BlockingQueue implementations LinkedBlockingQueue, ArrayBlockingQueue Good for producer-consumer Thread coordination, work queues
ConcurrentSkipListMap Skip list (concurrent sorted map) O(log n) with concurrency Need sorted map + thread safety
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.List;
import java.util.Map;

public class ThreadSafeCollectionsExample {
    public static void main(String[] args) {
        // ConcurrentHashMap -- the go-to thread-safe map
        Map concurrentMap = new ConcurrentHashMap<>();
        concurrentMap.put("counter", 0);

        // Safe atomic operations
        concurrentMap.compute("counter", (key, value) -> value + 1);
        concurrentMap.merge("counter", 1, Integer::sum);
        System.out.println("Counter: " + concurrentMap.get("counter")); // Output: Counter: 2

        // putIfAbsent -- atomic check-and-put
        concurrentMap.putIfAbsent("newKey", 100);
        System.out.println("New key: " + concurrentMap.get("newKey")); // Output: New key: 100

        // CopyOnWriteArrayList -- great for listener/observer patterns
        List listeners = new CopyOnWriteArrayList<>();
        listeners.add("LoggingListener");
        listeners.add("MetricsListener");
        listeners.add("AlertListener");

        // Safe to iterate even if another thread modifies the list
        // (iterates over a snapshot)
        for (String listener : listeners) {
            System.out.println("Notifying: " + listener);
        }
    }
}

12. Common Mistakes

Even experienced developers fall into these traps. Understanding them will save you hours of debugging.

Mistake 1: Not Overriding hashCode() When You Override equals()

If two objects are equals(), they must have the same hashCode(). Failing to do this breaks HashSet, HashMap, and any other hash-based collection.

import java.util.HashSet;
import java.util.Objects;
import java.util.Set;

public class HashCodeMistake {

    // BAD -- overrides equals() but NOT hashCode()
    static class BadProduct {
        String name;
        BadProduct(String name) { this.name = name; }

        @Override
        public boolean equals(Object o) {
            if (this == o) return true;
            if (!(o instanceof BadProduct)) return false;
            return name.equals(((BadProduct) o).name);
        }
        // Missing hashCode()!
    }

    // GOOD -- overrides both equals() AND hashCode()
    static class GoodProduct {
        String name;
        GoodProduct(String name) { this.name = name; }

        @Override
        public boolean equals(Object o) {
            if (this == o) return true;
            if (!(o instanceof GoodProduct)) return false;
            return name.equals(((GoodProduct) o).name);
        }

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

    public static void main(String[] args) {
        // BAD: Two equal objects, different hash codes -> set treats them as different
        Set badSet = new HashSet<>();
        badSet.add(new BadProduct("Laptop"));
        badSet.add(new BadProduct("Laptop")); // Should be duplicate!
        System.out.println("Bad set size: " + badSet.size());
        // Output: Bad set size: 2 (BUG! Should be 1)

        // GOOD: Two equal objects, same hash code -> set correctly rejects duplicate
        Set goodSet = new HashSet<>();
        goodSet.add(new GoodProduct("Laptop"));
        goodSet.add(new GoodProduct("Laptop"));
        System.out.println("Good set size: " + goodSet.size());
        // Output: Good set size: 1 (Correct!)
    }
}

Mistake 2: Autoboxing Pitfalls with remove()

When you have a List<Integer>, the remove() method is overloaded: remove(int index) removes by index, while remove(Object o) removes by value. Autoboxing can cause confusion.

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

public class AutoboxingPitfall {
    public static void main(String[] args) {
        List numbers = new ArrayList<>(List.of(10, 20, 30, 40, 50));

        // This removes the element at INDEX 2 (which is 30), NOT the value 2!
        numbers.remove(2);
        System.out.println("After remove(2): " + numbers);
        // Output: After remove(2): [10, 20, 40, 50]

        // To remove the VALUE 20, you must box it explicitly:
        numbers.remove(Integer.valueOf(20));
        System.out.println("After remove(Integer.valueOf(20)): " + numbers);
        // Output: After remove(Integer.valueOf(20)): [10, 40, 50]
    }
}

Mistake 3: Using Raw Types Instead of Generics

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

public class RawTypeMistake {
    public static void main(String[] args) {
        // BAD -- raw type (no generics). Compiles but dangerous!
        List rawList = new ArrayList();
        rawList.add("Hello");
        rawList.add(42);       // No compile error -- anything goes
        rawList.add(true);     // No compile error
        // String s = (String) rawList.get(1); // ClassCastException at runtime!

        // GOOD -- parameterized type. Type safety at compile time.
        List typedList = new ArrayList<>();
        typedList.add("Hello");
        // typedList.add(42);  // Compile error! Cannot add Integer to List
        String s = typedList.get(0); // No cast needed -- compiler knows it's a String
        System.out.println(s);
    }
}

Mistake 4: Modifying a Map Key After Insertion

If you use a mutable object as a key in a HashMap and then modify the object, its hash code changes. The map can no longer find the entry — it is effectively lost.

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

public class MutableKeyMistake {
    static class MutableKey {
        String value;
        MutableKey(String value) { this.value = value; }

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

        @Override
        public boolean equals(Object o) {
            if (!(o instanceof MutableKey)) return false;
            return value.equals(((MutableKey) o).value);
        }
    }

    public static void main(String[] args) {
        Map map = new HashMap<>();
        MutableKey key = new MutableKey("original");
        map.put(key, "some data");

        System.out.println("Before mutation: " + map.get(key)); // Output: some data

        // Mutate the key -- hash code changes!
        key.value = "mutated";
        System.out.println("After mutation: " + map.get(key));  // Output: null (LOST!)
        System.out.println("Map size: " + map.size());          // Output: Map size: 1 (entry exists but unreachable)

        // Lesson: Always use immutable objects as Map keys (String, Integer, record, etc.)
    }
}

Mistake 5: Assuming List.of() / Map.of() Return Mutable Collections

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

public class ImmutableMistake {
    public static void main(String[] args) {
        // List.of() returns an IMMUTABLE list
        List immutable = List.of("A", "B", "C");

        try {
            immutable.add("D"); // UnsupportedOperationException!
        } catch (UnsupportedOperationException e) {
            System.out.println("Cannot modify List.of() -- it's immutable!");
        }

        // If you need to modify it, create a mutable copy first
        List mutable = new ArrayList<>(immutable);
        mutable.add("D"); // This works
        System.out.println("Mutable copy: " + mutable);
        // Output: Mutable copy: [A, B, C, D]
    }
}

13. Best Practices

Follow these guidelines to write clean, maintainable, and performant collection code:

1. Program to Interfaces, Not Implementations

Declare variables using the interface type, not the concrete class. This makes your code flexible — you can swap implementations without changing any other code.

// BAD -- coupled to the implementation
ArrayList names = new ArrayList<>();
HashMap scores = new HashMap<>();

// GOOD -- programmed to the interface
List names = new ArrayList<>();    // Can easily swap to LinkedList
Map scores = new HashMap<>(); // Can easily swap to TreeMap
Set tags = new HashSet<>();        // Can easily swap to LinkedHashSet

2. Use the Diamond Operator

Since Java 7, you do not need to repeat the type on the right side of the assignment. The compiler infers it.

// BAD -- redundant type specification (pre-Java 7 style)
Map> map = new HashMap>();

// GOOD -- diamond operator infers the type
Map> map = new HashMap<>();

3. Prefer isEmpty() Over size() == 0

List items = new ArrayList<>();

// BAD -- size() might be O(n) for some implementations
if (items.size() == 0) { /* ... */ }

// GOOD -- isEmpty() is always O(1) and more readable
if (items.isEmpty()) { /* ... */ }

4. Initialize with Expected Capacity

If you know approximately how many elements a collection will hold, set the initial capacity to avoid unnecessary resizing.

// BAD -- default capacity (10), will resize multiple times for 1000 elements
List names = new ArrayList<>();

// GOOD -- pre-sized to avoid resizing
List names = new ArrayList<>(1000);

// For HashMap, account for load factor (default 0.75)
// To hold 100 entries without resize: 100 / 0.75 = 134
Map cache = new HashMap<>(134);

5. Return Empty Collections, Not Null

Returning null forces every caller to check for null. Returning an empty collection eliminates NullPointerException risks.

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

public class ReturnEmptyExample {

    // BAD -- forces callers to null-check
    public static List getNamesBad(boolean hasData) {
        if (!hasData) {
            return null; // Caller: if (result != null) { ... }
        }
        return List.of("Alice", "Bob");
    }

    // GOOD -- callers can always iterate safely
    public static List getNamesGood(boolean hasData) {
        if (!hasData) {
            return Collections.emptyList(); // Or List.of()
        }
        return List.of("Alice", "Bob");
    }

    public static void main(String[] args) {
        // No null check needed -- this is always safe
        for (String name : getNamesGood(false)) {
            System.out.println(name); // Simply doesn't execute
        }
    }
}

6. Use Streams for Transformations, Loops for Side Effects

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

public class StreamVsLoopExample {
    public static void main(String[] args) {
        List names = List.of("alice", "bob", "charlie", "diana");

        // GOOD -- Stream for transformation (creating a new collection)
        List uppercased = names.stream()
            .filter(name -> name.length() > 3)
            .map(String::toUpperCase)
            .collect(Collectors.toList());
        System.out.println("Filtered + uppercased: " + uppercased);
        // Output: Filtered + uppercased: [ALICE, CHARLIE, DIANA]

        // GOOD -- Loop for side effects (printing, saving to DB, etc.)
        for (String name : uppercased) {
            System.out.println("Processing: " + name);
        }
    }
}

Summary of Best Practices

# Practice Example
1 Program to interfaces List not ArrayList
2 Use diamond operator new HashMap<>() not new HashMap()
3 Use isEmpty() list.isEmpty() not list.size() == 0
4 Pre-size when possible new ArrayList<>(expectedSize)
5 Return empty, not null Collections.emptyList() or List.of()
6 Use List.of() / Map.of() (Java 9+) For immutable small collections
7 Always override hashCode() with equals() Required for hash-based collections
8 Use immutable keys in Maps String, Integer, records
9 Prefer ConcurrentHashMap over synchronized wrappers For thread-safe maps
10 Use removeIf() instead of Iterator for conditional removal list.removeIf(x -> x < 0)

14. Complete Practical Example -- Contact Manager

Let us put everything together in a realistic example. This ContactManager application demonstrates how multiple collection types work together in a real-world scenario:

  • ArrayList -- stores contacts in insertion order
  • HashMap -- fast lookup by email
  • TreeSet -- maintains sorted unique tags
  • Comparator -- custom sorting
  • Iteration -- multiple iteration patterns
  • Stream operations -- filtering and transforming data
import java.util.*;
import java.util.stream.Collectors;

public class ContactManager {

    // --- Contact Record ---
    static class Contact implements Comparable {
        private final String name;
        private final String email;
        private final String phone;
        private final Set tags; // TreeSet for sorted tags

        public Contact(String name, String email, String phone, String... tags) {
            this.name = name;
            this.email = email;
            this.phone = phone;
            this.tags = new TreeSet<>(Arrays.asList(tags));
        }

        public String getName() { return name; }
        public String getEmail() { return email; }
        public String getPhone() { return phone; }
        public Set getTags() { return Collections.unmodifiableSet(tags); }

        public void addTag(String tag) { tags.add(tag); }
        public void removeTag(String tag) { tags.remove(tag); }

        @Override
        public int compareTo(Contact other) {
            return this.name.compareToIgnoreCase(other.name); // Natural ordering by name
        }

        @Override
        public boolean equals(Object o) {
            if (this == o) return true;
            if (!(o instanceof Contact)) return false;
            return email.equalsIgnoreCase(((Contact) o).email); // Email is unique identifier
        }

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

        @Override
        public String toString() {
            return String.format("%-15s %-25s %-15s %s", name, email, phone, tags);
        }
    }

    // --- Contact Manager ---
    private final List contacts;          // Ordered list of all contacts
    private final Map emailIndex; // Fast lookup by email
    private final Set allTags;             // All unique tags across contacts

    public ContactManager() {
        this.contacts = new ArrayList<>();
        this.emailIndex = new HashMap<>();
        this.allTags = new TreeSet<>();
    }

    // Add a contact
    public boolean addContact(Contact contact) {
        if (emailIndex.containsKey(contact.getEmail().toLowerCase())) {
            System.out.println("  Contact with email " + contact.getEmail() + " already exists.");
            return false;
        }
        contacts.add(contact);
        emailIndex.put(contact.getEmail().toLowerCase(), contact);
        allTags.addAll(contact.getTags());
        return true;
    }

    // Find by email -- O(1) using HashMap
    public Contact findByEmail(String email) {
        return emailIndex.get(email.toLowerCase());
    }

    // Find by tag -- filter contacts that have a specific tag
    public List findByTag(String tag) {
        return contacts.stream()
            .filter(c -> c.getTags().contains(tag))
            .collect(Collectors.toList());
    }

    // Remove a contact
    public boolean removeContact(String email) {
        Contact contact = emailIndex.remove(email.toLowerCase());
        if (contact != null) {
            contacts.remove(contact);
            rebuildTagIndex();
            return true;
        }
        return false;
    }

    // Get all contacts sorted by name
    public List getContactsSortedByName() {
        List sorted = new ArrayList<>(contacts);
        Collections.sort(sorted); // Uses Comparable (by name)
        return sorted;
    }

    // Get all contacts sorted by custom criteria
    public List getContactsSorted(Comparator comparator) {
        List sorted = new ArrayList<>(contacts);
        sorted.sort(comparator);
        return sorted;
    }

    // Get all unique tags
    public Set getAllTags() {
        return Collections.unmodifiableSet(allTags);
    }

    // Get contact count per tag
    public Map getTagCounts() {
        return contacts.stream()
            .flatMap(c -> c.getTags().stream())
            .collect(Collectors.groupingBy(tag -> tag, TreeMap::new, Collectors.counting()));
    }

    // Rebuild tag index after removal
    private void rebuildTagIndex() {
        allTags.clear();
        contacts.forEach(c -> allTags.addAll(c.getTags()));
    }

    public int size() { return contacts.size(); }

    // --- Print all contacts ---
    public void printAll() {
        System.out.printf("%-15s %-25s %-15s %s%n", "NAME", "EMAIL", "PHONE", "TAGS");
        System.out.println("-".repeat(75));
        for (Contact c : contacts) {
            System.out.println(c);
        }
    }

    // --- Main: Demonstration ---
    public static void main(String[] args) {
        ContactManager manager = new ContactManager();

        // 1. ADD CONTACTS
        System.out.println("=== Adding Contacts ===");
        manager.addContact(new Contact("Alice Smith",   "alice@example.com",   "555-0101", "friend", "work"));
        manager.addContact(new Contact("Bob Johnson",   "bob@example.com",     "555-0102", "work", "engineering"));
        manager.addContact(new Contact("Charlie Brown", "charlie@example.com", "555-0103", "friend", "school"));
        manager.addContact(new Contact("Diana Prince",  "diana@example.com",   "555-0104", "work", "engineering", "lead"));
        manager.addContact(new Contact("Eve Davis",     "eve@example.com",     "555-0105", "friend"));
        manager.addContact(new Contact("Alice Smith",   "alice@example.com",   "555-9999", "duplicate"));
        // Output: Contact with email alice@example.com already exists.

        System.out.println("\nTotal contacts: " + manager.size());
        // Output: Total contacts: 5

        // 2. DISPLAY ALL CONTACTS
        System.out.println("\n=== All Contacts ===");
        manager.printAll();

        // 3. FAST LOOKUP BY EMAIL (HashMap -- O(1))
        System.out.println("\n=== Lookup by Email ===");
        Contact found = manager.findByEmail("diana@example.com");
        System.out.println("Found: " + found);

        Contact notFound = manager.findByEmail("nobody@example.com");
        System.out.println("Not found: " + notFound); // Output: Not found: null

        // 4. SEARCH BY TAG
        System.out.println("\n=== Contacts Tagged 'engineering' ===");
        List engineers = manager.findByTag("engineering");
        engineers.forEach(c -> System.out.println("  " + c.getName() + " - " + c.getEmail()));
        // Output:
        //   Bob Johnson - bob@example.com
        //   Diana Prince - diana@example.com

        // 5. ALL UNIQUE TAGS (TreeSet -- sorted)
        System.out.println("\n=== All Tags (sorted) ===");
        System.out.println(manager.getAllTags());
        // Output: [engineering, friend, lead, school, work]

        // 6. TAG FREQUENCY (TreeMap -- sorted by tag name)
        System.out.println("\n=== Tag Counts ===");
        manager.getTagCounts().forEach((tag, count) ->
            System.out.println("  " + tag + ": " + count)
        );
        // Output:
        //   engineering: 2
        //   friend: 3
        //   lead: 1
        //   school: 1
        //   work: 3

        // 7. SORTING -- by name (natural ordering via Comparable)
        System.out.println("\n=== Sorted by Name ===");
        manager.getContactsSortedByName().forEach(c ->
            System.out.println("  " + c.getName())
        );
        // Output: Alice Smith, Bob Johnson, Charlie Brown, Diana Prince, Eve Davis

        // 8. SORTING -- by email (custom Comparator)
        System.out.println("\n=== Sorted by Email ===");
        manager.getContactsSorted(Comparator.comparing(Contact::getEmail)).forEach(c ->
            System.out.println("  " + c.getEmail())
        );

        // 9. SORTING -- by number of tags (descending), then by name
        System.out.println("\n=== Sorted by Tag Count (desc), then Name ===");
        manager.getContactsSorted(
            Comparator.comparingInt((Contact c) -> c.getTags().size())
                      .reversed()
                      .thenComparing(Contact::getName, String.CASE_INSENSITIVE_ORDER)
        ).forEach(c ->
            System.out.println("  " + c.getName() + " (" + c.getTags().size() + " tags) " + c.getTags())
        );
        // Diana Prince (3 tags), Alice Smith (2 tags), Bob Johnson (2 tags), ...

        // 10. REMOVE A CONTACT
        System.out.println("\n=== Removing Charlie ===");
        boolean removed = manager.removeContact("charlie@example.com");
        System.out.println("Removed: " + removed); // Output: Removed: true
        System.out.println("Total after removal: " + manager.size()); // Output: 4
        System.out.println("Tags after removal: " + manager.getAllTags());
        // "school" tag is gone because Charlie was the only one with it

        // 11. ITERATING WITH INDEX (traditional for loop)
        System.out.println("\n=== Final Contact List with Index ===");
        List sorted = manager.getContactsSortedByName();
        for (int i = 0; i < sorted.size(); i++) {
            System.out.println("  " + (i + 1) + ". " + sorted.get(i).getName());
        }
        // Output:
        //   1. Alice Smith
        //   2. Bob Johnson
        //   3. Diana Prince
        //   4. Eve Davis
    }
}

Summary

The Java Collections Framework is one of the most important parts of the Java standard library. Here is a recap of what we covered:

Topic Key Takeaway
Collections Framework A unified architecture of interfaces, implementations, and algorithms for managing groups of objects
List (ArrayList, LinkedList) Ordered, allows duplicates, index-based access. ArrayList for 95% of cases.
Set (HashSet, LinkedHashSet, TreeSet) No duplicates. HashSet for speed, TreeSet for sorted order.
Map (HashMap, LinkedHashMap, TreeMap) Key-value pairs. HashMap for general use, TreeMap for sorted keys.
Queue/Deque (PriorityQueue, ArrayDeque) FIFO/LIFO processing. ArrayDeque for stacks and queues.
Iteration For-each for reading, Iterator for safe removal, removeIf() for conditional removal, forEach+lambda for concise operations.
Sorting Comparable for natural ordering, Comparator for custom. Use Comparator.comparing() chains (Java 8+).
Collections utility class sort, reverse, shuffle, unmodifiableList, emptyList, frequency, binarySearch, and more.
Thread safety Use ConcurrentHashMap, not Hashtable or synchronizedMap. CopyOnWriteArrayList for read-heavy lists.
Best practices Program to interfaces, use generics, return empty collections (not null), pre-size when possible, override hashCode with equals.

Master these collections and their trade-offs, and you will be well-equipped to choose the right data structure for any situation you encounter in real-world Java development.

March 8, 2019