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

required
required


Java 11 New File and Collection Methods

1. Introduction

When most developers think of Java 11, they think of the new HttpClient API and the String improvements. But Java 11 introduced a wide range of smaller API enhancements across the standard library that are just as impactful in day-to-day coding. These are the kind of changes that save you 3-5 lines of boilerplate every time you use them — and over the course of a project, that adds up to hundreds of lines of code you never have to write, read, or debug.

This tutorial covers the “quiet heroes” of Java 11: file I/O improvements, collection upgrades, predicate utilities, Optional additions, local variable syntax for lambdas, and a few other enhancements that make your code cleaner and more expressive.

Here is what we will cover:

Feature Category What It Does
Files.readString() / writeString() File I/O Read/write entire files as strings in one line
Path.of() File I/O Cleaner factory method for creating Path instances
Collection.toArray(IntFunction) Collections Type-safe array conversion without the empty-array hack
List.copyOf(), Set.copyOf(), Map.copyOf() Collections Create unmodifiable copies of existing collections
Predicate.not() Functional Negate a predicate for cleaner stream filtering
Optional.isEmpty() Optional Complement to isPresent() for cleaner conditionals
var in lambda parameters Language Use var in lambda parameters for annotation support
Nest-Based Access Control JVM Simplified access between nested classes at the bytecode level

Prerequisites: Java 11 or later. All code examples compile and run on Java 11+.

2. Files.readString() and Files.writeString()

Before Java 11, reading an entire file into a String required either a multi-line BufferedReader dance, a Files.readAllBytes() plus new String() conversion, or reaching for Apache Commons IO. Java 11 finally gives us one-line file I/O.

The Problem: File Reading Before Java 11

import java.io.*;
import java.nio.file.*;
import java.nio.charset.StandardCharsets;

// Before Java 11: Approach 1 -- BufferedReader (verbose)
String content;
try (BufferedReader reader = new BufferedReader(
        new FileReader("config.txt"))) {
    StringBuilder sb = new StringBuilder();
    String line;
    while ((line = reader.readLine()) != null) {
        sb.append(line).append("\n");
    }
    content = sb.toString();
}

// Before Java 11: Approach 2 -- readAllBytes (better, but still awkward)
byte[] bytes = Files.readAllBytes(Paths.get("config.txt"));
String content = new String(bytes, StandardCharsets.UTF_8);

// Java 11: one line
String content = Files.readString(Path.of("config.txt"));

Files.readString()

The readString() method reads the entire contents of a file into a String. It accepts an optional Charset parameter (defaults to UTF-8).

import java.nio.file.*;
import java.nio.charset.StandardCharsets;

public class ReadStringDemo {
    public static void main(String[] args) throws Exception {
        // First, create a test file
        Path testFile = Path.of("test-config.properties");
        Files.writeString(testFile, """
                app.name=MyApplication
                app.version=2.1.0
                app.debug=false
                db.host=localhost
                db.port=5432
                """);

        // Read the entire file as a String -- default charset is UTF-8
        String content = Files.readString(testFile);
        System.out.println("File content:");
        System.out.println(content);

        // Read with explicit charset
        String contentLatin = Files.readString(testFile, StandardCharsets.ISO_8859_1);

        // Process the content using Java 11 String methods
        long propertyCount = content.lines()
                .filter(line -> !line.isBlank())
                .filter(line -> !line.startsWith("#"))
                .count();
        System.out.println("Number of properties: " + propertyCount);

        // Cleanup
        Files.deleteIfExists(testFile);
    }
}

// Output:
// File content:
// app.name=MyApplication
// app.version=2.1.0
// app.debug=false
// db.host=localhost
// db.port=5432
//
// Number of properties: 5

Files.writeString()

writeString() writes a CharSequence to a file. It supports standard OpenOption parameters for controlling write behavior (create, append, truncate, etc.):

import java.nio.file.*;
import java.nio.file.StandardOpenOption;

public class WriteStringDemo {
    public static void main(String[] args) throws Exception {
        Path logFile = Path.of("application.log");

        // Write a new file (creates or overwrites)
        Files.writeString(logFile, "=== Application Log ===\n");

        // Append to the file
        Files.writeString(logFile,
                "2024-01-15 08:30:00 INFO  Application started\n",
                StandardOpenOption.APPEND);

        Files.writeString(logFile,
                "2024-01-15 08:30:05 INFO  Database connected\n",
                StandardOpenOption.APPEND);

        Files.writeString(logFile,
                "2024-01-15 08:30:10 WARN  Cache warming up\n",
                StandardOpenOption.APPEND);

        // Read back and display
        String content = Files.readString(logFile);
        System.out.println(content);

        // Cleanup
        Files.deleteIfExists(logFile);
    }
}

// Output:
// === Application Log ===
// 2024-01-15 08:30:00 INFO  Application started
// 2024-01-15 08:30:05 INFO  Database connected
// 2024-01-15 08:30:10 WARN  Cache warming up

Practical Use: JSON Configuration Manager

Here is a practical example that reads, modifies, and writes JSON-like configuration using the new file I/O methods:

import java.nio.file.*;
import java.io.IOException;
import java.util.*;
import java.util.stream.Collectors;

public class SimpleConfigManager {

    private final Path configPath;
    private final Map properties = new LinkedHashMap<>();

    public SimpleConfigManager(Path configPath) {
        this.configPath = configPath;
    }

    /**
     * Loads properties from a key=value file.
     */
    public void load() throws IOException {
        if (!Files.exists(configPath)) {
            System.out.println("Config file not found, using defaults");
            return;
        }

        String content = Files.readString(configPath);

        content.lines()
                .map(String::strip)
                .filter(line -> !line.isBlank())
                .filter(line -> !line.startsWith("#"))
                .filter(line -> line.contains("="))
                .forEach(line -> {
                    int eq = line.indexOf('=');
                    properties.put(
                        line.substring(0, eq).strip(),
                        line.substring(eq + 1).strip()
                    );
                });
    }

    /**
     * Saves properties back to the file.
     */
    public void save() throws IOException {
        String content = properties.entrySet().stream()
                .map(e -> e.getKey() + "=" + e.getValue())
                .collect(Collectors.joining("\n", "# Auto-generated config\n\n", "\n"));

        Files.writeString(configPath, content);
    }

    public String get(String key, String defaultValue) {
        return properties.getOrDefault(key, defaultValue);
    }

    public void set(String key, String value) {
        properties.put(key, value);
    }

    public static void main(String[] args) throws IOException {
        Path configFile = Path.of("app.config");

        SimpleConfigManager config = new SimpleConfigManager(configFile);

        // Set some defaults and save
        config.set("server.host", "0.0.0.0");
        config.set("server.port", "8080");
        config.set("db.url", "jdbc:postgresql://localhost:5432/mydb");
        config.set("db.pool.size", "10");
        config.set("logging.level", "INFO");
        config.save();

        // Read the saved file
        System.out.println("Saved config file:");
        System.out.println(Files.readString(configFile));

        // Load into a new instance
        SimpleConfigManager loaded = new SimpleConfigManager(configFile);
        loaded.load();
        System.out.println("Loaded server.port: " + loaded.get("server.port", "N/A"));
        System.out.println("Loaded db.pool.size: " + loaded.get("db.pool.size", "N/A"));

        // Cleanup
        Files.deleteIfExists(configFile);
    }
}

// Output:
// Saved config file:
// # Auto-generated config
//
// server.host=0.0.0.0
// server.port=8080
// db.url=jdbc:postgresql://localhost:5432/mydb
// db.pool.size=10
// logging.level=INFO
//
// Loaded server.port: 8080
// Loaded db.pool.size: 10

Important Considerations

Consideration Details
File size readString() loads the entire file into memory. For very large files (hundreds of MB), use Files.lines() or BufferedReader for streaming instead.
Default charset Both methods default to UTF-8, which is the right choice for modern applications.
Atomic writes writeString() is not atomic. For atomic writes, write to a temp file and rename.
Line endings readString() preserves the original line endings. If you need normalized endings, process with .lines() and rejoin.
Exceptions Both throw IOException. readString() also throws OutOfMemoryError on very large files.

3. Path.of()

Java 11 added Path.of() as a static factory method on the Path interface itself. It does exactly the same thing as Paths.get(), but the code reads more naturally because you are calling a method on the type you want to create.

import java.nio.file.Path;
import java.nio.file.Paths;
import java.net.URI;

public class PathOfDemo {
    public static void main(String[] args) {
        // Before Java 11: Paths.get()
        Path oldWay = Paths.get("/home", "user", "documents", "report.pdf");

        // Java 11: Path.of() -- same result, reads better
        Path newWay = Path.of("/home", "user", "documents", "report.pdf");

        System.out.println("Paths.get(): " + oldWay);
        System.out.println("Path.of():   " + newWay);
        System.out.println("Equal: " + oldWay.equals(newWay));  // true

        // Single string path
        Path config = Path.of("/etc/myapp/config.properties");
        System.out.println("Config: " + config);

        // Multiple path components (joined with OS-specific separator)
        Path source = Path.of("src", "main", "java", "com", "example", "App.java");
        System.out.println("Source: " + source);

        // From URI
        Path fromUri = Path.of(URI.create("file:///tmp/test.txt"));
        System.out.println("From URI: " + fromUri);

        // Useful Path operations
        Path project = Path.of("/home/dev/myproject/src/Main.java");
        System.out.println("\nPath operations:");
        System.out.println("  File name:  " + project.getFileName());
        System.out.println("  Parent:     " + project.getParent());
        System.out.println("  Root:       " + project.getRoot());
        System.out.println("  Name count: " + project.getNameCount());
    }
}

// Output:
// Paths.get(): /home/user/documents/report.pdf
// Path.of():   /home/user/documents/report.pdf
// Equal: true
// Config: /etc/myapp/config.properties
// Source: src/main/java/com/example/App.java
// From URI: /tmp/test.txt
//
// Path operations:
//   File name:  Main.java
//   Parent:     /home/dev/myproject/src
//   Root:       /
//   Name count: 5

Why use Path.of() instead of Paths.get()? Both produce identical results. Path.of() is preferred because:

  • It follows the modern Java factory method naming convention (List.of(), Set.of(), Map.of())
  • The method lives on the interface itself, which is the type you are creating
  • Paths.get() is now just a thin wrapper that delegates to Path.of()

Paths.get() is not deprecated and still works fine. But in new code, prefer Path.of() for consistency with the rest of the modern Java API style.

4. Collection.toArray(IntFunction)

Before Java 11, converting a collection to a typed array required the awkward “empty array” pattern:

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

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

        // Before Java 11: empty array idiom
        String[] oldWay = names.toArray(new String[0]);

        // Even older way: pre-sized array (actually slower due to JIT optimization)
        String[] olderWay = names.toArray(new String[names.size()]);

        // Java 11: IntFunction generator -- clean and type-safe
        String[] newWay = names.toArray(String[]::new);

        System.out.println("Old way:  " + java.util.Arrays.toString(oldWay));
        System.out.println("New way:  " + java.util.Arrays.toString(newWay));

        // Works with any Collection type
        Set numbers = Set.of(1, 2, 3, 4, 5);
        Integer[] numArray = numbers.toArray(Integer[]::new);
        System.out.println("Set to array: " + java.util.Arrays.toString(numArray));

        // Useful in stream pipelines
        String[] uppercased = names.stream()
                .map(String::toUpperCase)
                .toArray(String[]::new);
        System.out.println("Uppercased: " + java.util.Arrays.toString(uppercased));

        // Combining with filter
        String[] longNames = names.stream()
                .filter(name -> name.length() > 4)
                .toArray(String[]::new);
        System.out.println("Long names: " + java.util.Arrays.toString(longNames));
    }
}

// Output:
// Old way:  [Alice, Bob, Charlie, Diana]
// New way:  [Alice, Bob, Charlie, Diana]
// Set to array: [5, 4, 3, 2, 1]   (order may vary for Set)
// Uppercased: [ALICE, BOB, CHARLIE, DIANA]
// Long names: [Alice, Charlie, Diana]

Why is toArray(String[]::new) better?

  • Readable — The method reference clearly says “create a String array”
  • Type-safe — The compiler catches type mismatches at compile time
  • No wasted allocation — The JVM allocates the exact right size on the first try
  • Consistent — Uses the same ::new constructor reference pattern you already use in streams

5. List.copyOf(), Set.copyOf(), Map.copyOf()

These factory methods, introduced in Java 10 and commonly used from Java 11 onwards, create unmodifiable copies of existing collections. They are the defensive-copy methods you have always needed.

Why Unmodifiable Copies Matter

Imagine you have a method that returns a list. If you return the internal list directly, callers can modify it and corrupt your object’s state. Before Java 10, defensive copying was verbose:

import java.util.*;

public class CopyOfDemo {
    public static void main(String[] args) {
        // ---- List.copyOf() ----
        List original = new ArrayList<>(List.of("Alice", "Bob", "Charlie"));

        // Create an unmodifiable copy
        List copy = List.copyOf(original);

        // Modifying the original does NOT affect the copy
        original.add("Diana");
        System.out.println("Original: " + original);  // [Alice, Bob, Charlie, Diana]
        System.out.println("Copy:     " + copy);       // [Alice, Bob, Charlie]

        // The copy is unmodifiable
        try {
            copy.add("Eve");
        } catch (UnsupportedOperationException e) {
            System.out.println("Cannot modify copy: " + e.getClass().getSimpleName());
        }

        // ---- Set.copyOf() ----
        Set mutableSet = new HashSet<>(Set.of(1, 2, 3));
        Set immutableSet = Set.copyOf(mutableSet);
        mutableSet.add(4);
        System.out.println("\nMutable set:   " + mutableSet);    // [1, 2, 3, 4]
        System.out.println("Immutable set: " + immutableSet);    // [1, 2, 3]

        // ---- Map.copyOf() ----
        Map mutableMap = new HashMap<>();
        mutableMap.put("a", 1);
        mutableMap.put("b", 2);
        Map immutableMap = Map.copyOf(mutableMap);
        mutableMap.put("c", 3);
        System.out.println("\nMutable map:   " + mutableMap);    // {a=1, b=2, c=3}
        System.out.println("Immutable map: " + immutableMap);    // {a=1, b=2}
    }
}

Null Rejection

A critical feature of copyOf() is that it rejects null elements and null keys/values. This catches bugs early:

import java.util.*;

public class CopyOfNullRejection {
    public static void main(String[] args) {
        // ArrayList allows null elements
        List withNulls = new ArrayList<>();
        withNulls.add("hello");
        withNulls.add(null);
        withNulls.add("world");
        System.out.println("Original list: " + withNulls);  // [hello, null, world]

        // copyOf() rejects nulls -- throws NullPointerException
        try {
            List copy = List.copyOf(withNulls);
        } catch (NullPointerException e) {
            System.out.println("List.copyOf() rejects nulls: " + e.getClass().getSimpleName());
        }

        // Same for Map with null keys or values
        Map mapWithNull = new HashMap<>();
        mapWithNull.put("key1", "value1");
        mapWithNull.put("key2", null);  // null value

        try {
            Map copy = Map.copyOf(mapWithNull);
        } catch (NullPointerException e) {
            System.out.println("Map.copyOf() rejects null values: " + e.getClass().getSimpleName());
        }
    }
}

// Output:
// Original list: [hello, null, world]
// List.copyOf() rejects nulls: NullPointerException
// Map.copyOf() rejects null values: NullPointerException

Practical Use: Defensive Copies in Domain Objects

import java.util.*;

public class Team {
    private final String name;
    private final List members;

    public Team(String name, List members) {
        this.name = name;
        // Defensive copy: callers cannot modify our internal list
        this.members = List.copyOf(members);
    }

    public String getName() { return name; }

    // Return the unmodifiable list directly -- it is already safe
    public List getMembers() { return members; }

    @Override
    public String toString() {
        return name + ": " + members;
    }

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

        Team team = new Team("Engineering", memberList);

        // Modifying the original list does NOT affect the team
        memberList.add("Eve");
        System.out.println("External list: " + memberList);
        System.out.println("Team members:  " + team.getMembers());

        // Trying to modify the returned list also fails
        try {
            team.getMembers().add("Mallory");
        } catch (UnsupportedOperationException e) {
            System.out.println("Cannot modify team: " + e.getClass().getSimpleName());
        }
    }
}

// Output:
// External list: [Alice, Bob, Charlie, Eve]
// Team members:  [Alice, Bob, Charlie]
// Cannot modify team: UnsupportedOperationException

copyOf() vs Collections.unmodifiableList()

Feature List.copyOf() Collections.unmodifiableList()
Creates a new copy? Yes — independent of the original No — it is a view of the original
Reflects changes to original? No Yes
Allows null elements? No (throws NullPointerException) Yes
Duplicate handling (Set) Throws IllegalArgumentException N/A (wraps existing Set)
Best for Defensive copies, immutable snapshots Read-only views without copying

6. Predicate.not()

Java 11 added Predicate.not() as a static method that returns the negation of a given predicate. This is a small addition that makes stream filtering significantly more readable.

The Problem: Negating Method References

In Java 8, if you wanted to filter using the negation of a method reference, you had to either write a lambda or cast and negate:

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

public class PredicateNotDemo {
    public static void main(String[] args) {
        List items = List.of("hello", "", "  ", "world", "", "java", " ");

        // Before Java 11: awkward negation approaches
        // Approach 1: Lambda (verbose)
        List nonEmpty1 = items.stream()
                .filter(s -> !s.isBlank())
                .collect(Collectors.toList());

        // Approach 2: Predicate variable (even more verbose)
        Predicate isBlank = String::isBlank;
        List nonEmpty2 = items.stream()
                .filter(isBlank.negate())
                .collect(Collectors.toList());

        // Java 11: Predicate.not() -- clean and readable
        List nonEmpty3 = items.stream()
                .filter(Predicate.not(String::isBlank))
                .collect(Collectors.toList());

        System.out.println("Non-blank items: " + nonEmpty3);

        // More examples of Predicate.not()
        List numbers = List.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);

        // Filter out even numbers (keep odd)
        List odds = numbers.stream()
                .filter(Predicate.not(n -> n % 2 == 0))
                .collect(Collectors.toList());
        System.out.println("Odd numbers: " + odds);

        // Filter non-null values from a list that allows nulls
        List mixed = new java.util.ArrayList<>();
        mixed.add("hello");
        mixed.add(null);
        mixed.add("world");
        mixed.add(null);

        List nonNulls = mixed.stream()
                .filter(Predicate.not(java.util.Objects::isNull))
                .collect(Collectors.toList());
        System.out.println("Non-null items: " + nonNulls);
    }
}

// Output:
// Non-blank items: [hello, world, java]
// Odd numbers: [1, 3, 5, 7, 9]
// Non-null items: [hello, world]

Combining Predicate.not() with Other Predicates

Predicate.not() works naturally with and() and or() for complex filtering:

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

public class PredicateComposition {

    record Employee(String name, String department, double salary, boolean active) {}

    public static void main(String[] args) {
        List employees = List.of(
            new Employee("Alice", "Engineering", 120000, true),
            new Employee("Bob", "Marketing", 75000, false),
            new Employee("Charlie", "Engineering", 95000, true),
            new Employee("Diana", "HR", 85000, true),
            new Employee("Eve", "Marketing", 110000, true),
            new Employee("Frank", "Engineering", 130000, false)
        );

        // Active employees not in Engineering
        Predicate isActive = Employee::active;
        Predicate isEngineering = e -> "Engineering".equals(e.department());

        List result = employees.stream()
                .filter(isActive.and(Predicate.not(isEngineering)))
                .collect(Collectors.toList());

        System.out.println("Active non-engineering employees:");
        result.forEach(e -> System.out.printf("  %s (%s) $%,.0f%n",
                e.name(), e.department(), e.salary()));

        // Using Predicate.not() inline for readability
        long inactiveCount = employees.stream()
                .filter(Predicate.not(Employee::active))
                .count();
        System.out.println("\nInactive employees: " + inactiveCount);
    }
}

// Output:
// Active non-engineering employees:
//   Diana (HR) $85,000
//   Eve (Marketing) $110,000
//
// Inactive employees: 2

7. Optional.isEmpty()

Java 11 added Optional.isEmpty(), which is simply the logical complement of Optional.isPresent(). It returns true when the Optional contains no value.

Why Was This Needed?

Before Java 11, checking for an empty Optional required negating isPresent(), which reads awkwardly in conditional statements:

import java.util.Optional;
import java.util.Map;

public class OptionalIsEmptyDemo {
    // Simulated database
    private static final Map USER_DB = Map.of(
        "john", "john@example.com",
        "jane", "jane@example.com"
    );

    public static Optional findEmail(String username) {
        return Optional.ofNullable(USER_DB.get(username));
    }

    public static void main(String[] args) {
        Optional email = findEmail("unknown");

        // Before Java 11: negated isPresent() -- reads awkwardly
        if (!email.isPresent()) {
            System.out.println("Old way: User not found");
        }

        // Java 11: isEmpty() -- reads naturally
        if (email.isEmpty()) {
            System.out.println("New way: User not found");
        }

        // Practical pattern: early return guard clause
        String username = "unknown";
        Optional result = findEmail(username);
        if (result.isEmpty()) {
            System.out.println("No email found for: " + username);
            return;
        }
        System.out.println("Email: " + result.get());
    }
}

// Output:
// Old way: User not found
// New way: User not found
// No email found for: unknown

When to Use isEmpty() vs Functional Style

While isEmpty() is useful for guard clauses and early returns, prefer the functional methods (map, orElse, ifPresent) for most Optional operations:

import java.util.Optional;

public class OptionalStyleGuide {
    public static void main(String[] args) {
        Optional name = Optional.of("Alice");
        Optional empty = Optional.empty();

        // Good: isEmpty() for guard clauses
        if (empty.isEmpty()) {
            System.out.println("Guard clause: value missing, returning early");
        }

        // Good: Functional style for transformations
        String greeting = name
                .map(n -> "Hello, " + n + "!")
                .orElse("Hello, stranger!");
        System.out.println(greeting);

        // Avoid: isEmpty() + get() -- use orElse() instead
        // Bad pattern:
        String bad;
        if (name.isEmpty()) {
            bad = "default";
        } else {
            bad = name.get();  // Don't do this
        }

        // Better:
        String good = name.orElse("default");

        System.out.println("Result: " + good);
    }
}

// Output:
// Guard clause: value missing, returning early
// Hello, Alice!
// Result: Alice

8. var in Lambda Parameters

Java 10 introduced var for local variable type inference. Java 11 extended this to lambda parameters. At first glance, this might seem pointless — lambda parameters already have implicit types. The real value is that var allows you to add annotations to lambda parameters, which was not possible with implicitly typed parameters.

The Syntax

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

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

        // Implicit types (Java 8+) -- works fine
        names.stream()
                .map(name -> name.toUpperCase())
                .forEach(name -> System.out.println(name));

        // Explicit types (Java 8+)
        names.stream()
                .map((String name) -> name.toUpperCase())
                .forEach((String name) -> System.out.println(name));

        // var types (Java 11+) -- new option
        names.stream()
                .map((var name) -> name.toUpperCase())
                .forEach((var name) -> System.out.println(name));

        // The REAL value: annotations on lambda parameters
        // Without var, you cannot annotate lambda parameters
        BiFunction concat =
                (@Deprecated var a, @Deprecated var b) -> a + b;
        // This would NOT compile without var:
        // (@Deprecated a, @Deprecated b) -> a + b   // Syntax error!

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

// Output:
// ALICE
// BOB
// CHARLIE
// ALICE
// BOB
// CHARLIE
// Hello, World!

Rules for var in Lambdas

Rule Example Compiles?
All parameters must use var or none (var x, var y) -> x + y Yes
Cannot mix var with explicit types (var x, String y) -> x + y No
Cannot mix var with implicit types (var x, y) -> x + y No
Parentheses required with var var x -> x.length() No (need (var x))
Works with annotations (@NonNull var x) -> x.length() Yes

Practical Use: Annotation Support in Lambdas

The most practical use case for var in lambdas is adding null-safety annotations when using frameworks like Checker Framework, SpotBugs, or Spring’s @Nullable/@NonNull:

import java.lang.annotation.*;
import java.util.List;
import java.util.function.Function;
import java.util.stream.Collectors;

public class VarAnnotationExample {

    // Custom annotation for demonstration
    @Retention(RetentionPolicy.RUNTIME)
    @Target(ElementType.PARAMETER)
    @interface NonNull {}

    @Retention(RetentionPolicy.RUNTIME)
    @Target(ElementType.PARAMETER)
    @interface Validated {}

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

        // Using @NonNull annotation on lambda parameter
        Function toUpper =
                (@NonNull var name) -> name.toUpperCase();

        List upperNames = names.stream()
                .map(toUpper)
                .collect(Collectors.toList());

        System.out.println("Uppercased: " + upperNames);

        // Using custom @Validated annotation
        List validated = names.stream()
                .filter((@Validated var name) -> name.length() > 3)
                .collect(Collectors.toList());

        System.out.println("Names > 3 chars: " + validated);
    }
}

// Output:
// Uppercased: [ALICE, BOB, CHARLIE]
// Names > 3 chars: [Alice, Charlie]

9. Nest-Based Access Control

This is a JVM-level change (JEP 181) that most developers will never interact with directly, but it is worth understanding because it affects reflection and nested class access.

The Problem Before Java 11

In Java, an outer class and its inner classes can access each other’s private members. This works fine in source code, but at the bytecode level, the JVM had no concept of this relationship. The compiler secretly generated synthetic bridge methods (package-private accessor methods) to make the access work. This caused two problems:

  • Reflection inconsistency — If you used reflection to access a private field of an outer class from an inner class, you would get IllegalAccessError even though direct access worked fine.
  • Security model confusion — The synthetic bridge methods appeared in stack traces and security audits, confusing developers.

The Java 11 Solution: Nestmates

Java 11 introduced the concept of nests. A nest is a group of classes (an outer class and all its inner classes) that are logically part of the same code entity. The JVM now understands this relationship natively:

import java.util.Arrays;

public class NestMatesDemo {

    private String outerSecret = "I am the outer secret";

    class Inner {
        private String innerSecret = "I am the inner secret";

        void accessOuter() {
            // This always worked in source code
            System.out.println("Inner accessing outer: " + outerSecret);
        }
    }

    static class StaticNested {
        private String nestedSecret = "I am the nested secret";
    }

    public static void main(String[] args) throws Exception {
        // Java 11: Check nest relationships
        System.out.println("Nest host: " + NestMatesDemo.class.getNestHost().getSimpleName());

        System.out.println("\nNest members:");
        Arrays.stream(NestMatesDemo.class.getNestMembers())
                .map(Class::getSimpleName)
                .forEach(name -> System.out.println("  " + name));

        // Check if two classes are nestmates
        boolean areNestmates = NestMatesDemo.class.isNestmateOf(Inner.class);
        System.out.println("\nNestMatesDemo and Inner are nestmates: " + areNestmates);

        boolean notNestmates = NestMatesDemo.class.isNestmateOf(String.class);
        System.out.println("NestMatesDemo and String are nestmates: " + notNestmates);

        // Java 11: Reflection now works consistently with nest-based access
        NestMatesDemo outer = new NestMatesDemo();
        Inner inner = outer.new Inner();

        // Before Java 11, this would require setAccessible(true) for inner class private fields
        // Java 11: nestmates can access each other's privates via reflection naturally
        var field = Inner.class.getDeclaredField("innerSecret");
        // In Java 11, nestmates have access without setAccessible() workarounds
        // (Though setAccessible is still needed if Security Manager restricts it)
        field.setAccessible(true);
        System.out.println("\nAccessed via reflection: " + field.get(inner));
    }
}

// Output:
// Nest host: NestMatesDemo
//
// Nest members:
//   NestMatesDemo
//   Inner
//   StaticNested
//
// NestMatesDemo and Inner are nestmates: true
// NestMatesDemo and String are nestmates: false
//
// Accessed via reflection: I am the inner secret

New Reflection Methods

Method Returns Description
Class.getNestHost() Class<?> Returns the nest host (the outermost enclosing class)
Class.getNestMembers() Class<?>[] Returns all classes in the same nest
Class.isNestmateOf(Class) boolean Checks if two classes belong to the same nest

When does this matter in practice? If you write frameworks, serialization libraries, or code that uses reflection heavily, nest-based access control eliminates confusing IllegalAccessError exceptions when reflecting over inner class members. For most application developers, this “just works” transparently — which is exactly the point.

10. Comparison with Previous Java Versions

Here is a comprehensive before-and-after comparison showing how Java 11 simplifies common coding patterns:

File I/O

import java.io.*;
import java.nio.file.*;
import java.nio.charset.StandardCharsets;

public class BeforeAfterFileIO {
    public static void main(String[] args) throws Exception {
        Path file = Path.of("demo.txt");

        // ===== WRITING =====
        // Java 8: multiple lines, byte conversion, explicit charset
        byte[] data = "Hello, World!".getBytes(StandardCharsets.UTF_8);
        Files.write(Paths.get("demo.txt"), data);

        // Java 11: one clean line
        Files.writeString(Path.of("demo.txt"), "Hello, World!");

        // ===== READING =====
        // Java 8: two-step process
        String content8 = new String(
            Files.readAllBytes(Paths.get("demo.txt")),
            StandardCharsets.UTF_8
        );

        // Java 11: one clean line
        String content11 = Files.readString(Path.of("demo.txt"));

        System.out.println("Content: " + content11);

        // ===== PATH CREATION =====
        // Java 8
        Path old = Paths.get("src", "main", "java");

        // Java 11
        Path modern = Path.of("src", "main", "java");

        System.out.println("Paths equal: " + old.equals(modern));

        Files.deleteIfExists(file);
    }
}

Collections

import java.util.*;
import java.util.function.Predicate;
import java.util.stream.Collectors;

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

        // ===== toArray() =====
        // Java 8: empty array idiom
        String[] old = names.toArray(new String[0]);

        // Java 11: method reference
        String[] modern = names.toArray(String[]::new);

        System.out.println("Array: " + Arrays.toString(modern));

        // ===== Defensive Copy =====
        // Java 8: Collections.unmodifiableList (only a VIEW, not a copy!)
        List view = Collections.unmodifiableList(names);
        // If names changes, view changes too!

        // Java 10/11: List.copyOf() (true independent copy)
        List copy = List.copyOf(names);
        names.add("Diana");
        System.out.println("Original: " + names);   // [Alice, Bob, Charlie, Diana]
        System.out.println("View:     " + view);     // [Alice, Bob, Charlie, Diana] -- view changed!
        System.out.println("Copy:     " + copy);     // [Alice, Bob, Charlie] -- copy is independent

        // ===== Predicate Negation =====
        List items = List.of("hello", "", "  ", "world");

        // Java 8: lambda negation
        List filtered8 = items.stream()
                .filter(s -> !s.isBlank())
                .collect(Collectors.toList());

        // Java 11: Predicate.not()
        List filtered11 = items.stream()
                .filter(Predicate.not(String::isBlank))
                .collect(Collectors.toList());

        System.out.println("Filtered: " + filtered11);

        // ===== Optional =====
        Optional empty = Optional.empty();

        // Java 8: awkward negation
        if (!empty.isPresent()) {
            System.out.println("Java 8: empty");
        }

        // Java 11: natural isEmpty()
        if (empty.isEmpty()) {
            System.out.println("Java 11: empty");
        }
    }
}

// Output:
// Array: [Alice, Bob, Charlie]
// Original: [Alice, Bob, Charlie, Diana]
// View:     [Alice, Bob, Charlie, Diana]
// Copy:     [Alice, Bob, Charlie]
// Filtered: [hello, world]
// Java 8: empty
// Java 11: empty

11. Best Practices

Here are the key guidelines for adopting Java 11’s API improvements effectively:

File I/O

  • Use Files.readString() for small-to-medium files (under ~50MB). For large files, continue using Files.lines() or BufferedReader for streaming.
  • Always specify StandardOpenOption.APPEND when you want to add to a file. The default behavior overwrites.
  • Prefer Path.of() over Paths.get() in new code for consistency with the modern Java factory method pattern.

Collections

  • Use toArray(Type[]::new) instead of toArray(new Type[0]) — it is cleaner and equally performant.
  • Use List.copyOf() for defensive copies in constructors and getters. Remember it rejects nulls, which is usually desirable.
  • Understand that copyOf() returns an independent copy while Collections.unmodifiableList() returns a view. Choose based on whether you want isolation or live reads.

Functional Programming

  • Use Predicate.not() for negating method references — it is more readable than s -> !s.isBlank() when the positive predicate is a method reference.
  • Use Optional.isEmpty() for guard clauses (early returns). For transformations and default values, stick with functional methods like map(), orElse(), and ifPresent().

Lambda Syntax

  • Use var in lambdas only when you need annotations on the parameters. If you do not need annotations, the implicit type form (x -> x.length()) is more concise.

Summary Table

Do Don’t
Files.readString(Path.of("f.txt")) new String(Files.readAllBytes(Paths.get("f.txt")))
Files.writeString(path, content) Files.write(path, content.getBytes())
Path.of("src", "main") Paths.get("src", "main") (in new code)
list.toArray(String[]::new) list.toArray(new String[0])
List.copyOf(mutableList) Collections.unmodifiableList(mutableList) (when you need a true copy)
filter(Predicate.not(String::isBlank)) filter(s -> !s.isBlank()) (when negating method references)
if (optional.isEmpty()) return; if (!optional.isPresent()) return;
(@NonNull var x) -> x.process() (var x) -> x.process() (without annotations, just use x ->)

These improvements may not be as flashy as the HttpClient API, but they are the ones you will use every day. They reduce boilerplate, eliminate common mistakes, and make your code more expressive. Adopting them is one of the easiest ways to modernize a Java 8 codebase after migrating to Java 11.

March 1, 2026

Java 11 New String Methods

1. Introduction

If you have worked with Java for any length of time, you know that String is the single most used class in the language. Every validation check, every log message, every API response, every database query — they all involve strings. Despite this, Java’s String class went largely unchanged from Java 1.0 through Java 8. Developers had to reach for Apache Commons Lang, Guava, or hand-rolled utility methods just to do basic things like check if a string was blank or repeat a character.

Java 11 finally addressed these long-standing gaps by adding six new methods to the String class. These methods are small individually, but collectively they eliminate a surprising amount of boilerplate code and third-party dependencies. If you have ever written str != null && !str.trim().isEmpty(), you will appreciate what Java 11 brings to the table.

Here is what Java 11 added to String:

Method What It Does What It Replaces
isBlank() Returns true if the string is empty or contains only whitespace str.trim().isEmpty() or Apache Commons StringUtils.isBlank()
strip() Removes leading and trailing whitespace (Unicode-aware) trim() (ASCII-only)
stripLeading() Removes leading whitespace only Manual regex or substring logic
stripTrailing() Removes trailing whitespace only Manual regex or substring logic
lines() Splits string into a Stream<String> by line terminators split("\\n") or BufferedReader.lines()
repeat(int) Repeats the string n times String.join() hacks or StringUtils.repeat()

In this tutorial, we will explore each method in depth with practical, compilable examples that show exactly when and why you would use them in real-world Java applications.

Prerequisites: Java 11 or later. All code examples compile and run on Java 11+.

2. isBlank()

The isBlank() method returns true if the string is empty ("") or contains only whitespace characters. This is the method Java developers have been asking for since approximately forever.

The Problem It Solves

Before Java 11, checking whether a string was “blank” (empty or whitespace-only) required either a multi-step check or a third-party library:

// Before Java 11: verbose and error-prone
if (name != null && !name.trim().isEmpty()) {
    // name has actual content
}

// Or using Apache Commons Lang
if (StringUtils.isNotBlank(name)) {
    // name has actual content
}

// Java 11: clean and built-in
if (!name.isBlank()) {
    // name has actual content
}

isBlank() vs isEmpty()

This is the most common question developers ask. The difference is simple but critical:

Method Returns true when "" " " "\t\n" "hello"
isEmpty() Length is 0 true false false false
isBlank() Length is 0 or all characters are whitespace true true true false

Think of it this way: isEmpty() checks if the box is empty. isBlank() checks if the box is empty or filled with packing peanuts (whitespace) — either way, there is nothing useful inside.

public class IsBlankDemo {
    public static void main(String[] args) {
        // Basic comparisons
        String empty = "";
        String spaces = "   ";
        String tab = "\t";
        String newline = "\n";
        String mixed = " \t \n ";
        String content = "  hello  ";

        System.out.println("String        | isEmpty | isBlank");
        System.out.println("------------- | ------- | -------");
        printComparison("\"\"", empty);           // true  | true
        printComparison("\"   \"", spaces);       // false | true
        printComparison("\"\\t\"", tab);          // false | true
        printComparison("\"\\n\"", newline);      // false | true
        printComparison("\" \\t \\n \"", mixed);  // false | true
        printComparison("\"  hello  \"", content);// false | false
    }

    static void printComparison(String label, String value) {
        System.out.printf("%-14s| %-8s| %s%n",
            label, value.isEmpty(), value.isBlank());
    }
}

// Output:
// String        | isEmpty | isBlank
// ------------- | ------- | -------
// ""            | true    | true
// "   "         | false   | true
// "\t"          | false   | true
// "\n"          | false   | true
// " \t \n "     | false   | true
// "  hello  "   | false   | false

Practical Use: Form Validation

isBlank() shines in form validation where users might submit fields filled with spaces:

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

public class FormValidator {

    public static List validate(Map formData) {
        List errors = new ArrayList<>();

        String username = formData.getOrDefault("username", "");
        String email = formData.getOrDefault("email", "");
        String password = formData.getOrDefault("password", "");
        String bio = formData.getOrDefault("bio", "");

        // isBlank() catches spaces-only submissions that isEmpty() would miss
        if (username.isBlank()) {
            errors.add("Username is required");
        } else if (username.length() < 3) {
            errors.add("Username must be at least 3 characters");
        }

        if (email.isBlank()) {
            errors.add("Email is required");
        } else if (!email.contains("@")) {
            errors.add("Email must contain @");
        }

        if (password.isBlank()) {
            errors.add("Password is required");
        }

        // Bio is optional -- only validate length if user typed something
        if (!bio.isBlank() && bio.strip().length() > 500) {
            errors.add("Bio must be 500 characters or less");
        }

        return errors;
    }

    public static void main(String[] args) {
        // Simulating a form where a user typed only spaces in the username field
        var formData = Map.of(
            "username", "   ",      // spaces only -- isBlank() catches this!
            "email", "john@example.com",
            "password", "secret123",
            "bio", ""
        );

        List errors = validate(formData);
        if (errors.isEmpty()) {
            System.out.println("Form is valid!");
        } else {
            errors.forEach(e -> System.out.println("ERROR: " + e));
        }
    }
}

// Output:
// ERROR: Username is required

Unicode Awareness

isBlank() recognizes all Unicode whitespace characters, not just ASCII space (\u0020). This matters when processing text from international sources:

public class IsBlankUnicode {
    public static void main(String[] args) {
        // Various Unicode whitespace characters
        String noBreakSpace = "\u00A0";          // Non-breaking space
        String emSpace = "\u2003";               // Em space (typography)
        String ideographicSpace = "\u3000";      // CJK ideographic space (Chinese/Japanese)
        String zeroWidthSpace = "\u200B";        // Zero-width space

        System.out.println("Non-breaking space isBlank: " + noBreakSpace.isBlank());
        System.out.println("Em space isBlank: " + emSpace.isBlank());
        System.out.println("Ideographic space isBlank: " + ideographicSpace.isBlank());
        System.out.println("Zero-width space isBlank: " + zeroWidthSpace.isBlank());
    }
}

// Output:
// Non-breaking space isBlank: true
// Em space isBlank: true
// Ideographic space isBlank: true
// Zero-width space isBlank: false   <-- not classified as whitespace by Character.isWhitespace()

Key takeaway: isBlank() uses Character.isWhitespace() internally, which covers most Unicode whitespace categories but not all code points (zero-width space is excluded). For the vast majority of real-world applications, this is exactly what you want.

3. strip(), stripLeading(), and stripTrailing()

Java has had trim() since version 1.0, so why did Java 11 add strip()? The answer is Unicode awareness. The trim() method only removes characters with a code point less than or equal to U+0020 (the ASCII space). It completely ignores Unicode whitespace characters like non-breaking spaces, em spaces, and ideographic spaces.

trim() vs strip() -- The Critical Difference

Feature trim() strip()
Available since Java 1.0 Java 11
Whitespace definition Characters <= '\u0020' Character.isWhitespace()
Handles Unicode whitespace No Yes
Non-breaking space (\u00A0) Not removed Removed
Em space (\u2003) Not removed Removed
Ideographic space (\u3000) Not removed Removed
public class StripVsTrim {
    public static void main(String[] args) {
        // ASCII whitespace -- both trim() and strip() handle this
        String asciiSpaces = "   hello   ";
        System.out.println("trim():  [" + asciiSpaces.trim() + "]");   // [hello]
        System.out.println("strip(): [" + asciiSpaces.strip() + "]");  // [hello]

        // Unicode whitespace -- only strip() handles this
        String unicodeSpaces = "\u2003 hello \u2003";  // Em spaces
        System.out.println("\ntrim():  [" + unicodeSpaces.trim() + "]");   // [ hello  ] -- em spaces remain!
        System.out.println("strip(): [" + unicodeSpaces.strip() + "]");    // [hello]

        // Non-breaking space from HTML  
        String nbspString = "\u00A0\u00A0data\u00A0\u00A0";
        System.out.println("\ntrim():  [" + nbspString.trim() + "]");   // [  data  ] -- nbsp not removed!
        System.out.println("strip(): [" + nbspString.strip() + "]");    // [data]

        // CJK ideographic space (common in Chinese/Japanese text)
        String cjkString = "\u3000Tokyo\u3000";
        System.out.println("\ntrim():  [" + cjkString.trim() + "]");    // [ Tokyo ] -- not removed!
        System.out.println("strip(): [" + cjkString.strip() + "]");     // [Tokyo]
    }
}

stripLeading() and stripTrailing()

Before Java 11, removing whitespace from only one side of a string required regex or manual character counting. Now it is a single method call:

public class StripDirectional {
    public static void main(String[] args) {
        String text = "   Hello, World!   ";

        System.out.println("Original:      [" + text + "]");
        System.out.println("strip():       [" + text.strip() + "]");
        System.out.println("stripLeading():[" + text.stripLeading() + "]");
        System.out.println("stripTrailing():[" + text.stripTrailing() + "]");
    }
}

// Output:
// Original:      [   Hello, World!   ]
// strip():       [Hello, World!]
// stripLeading():[Hello, World!   ]
// stripTrailing():[   Hello, World!]

Practical Use: Cleaning User Input

Here is a real-world scenario where strip() and its variants clean up messy input from different sources:

import java.util.List;

public class InputCleaner {

    /**
     * Cleans a CSV value that may have been copied from a spreadsheet.
     * Spreadsheets often add non-breaking spaces or other Unicode whitespace.
     */
    public static String cleanCsvField(String raw) {
        if (raw == null) return "";
        return raw.strip();  // Unicode-aware -- handles nbsp from Excel copy-paste
    }

    /**
     * Formats log lines by preserving leading indentation structure
     * but removing trailing whitespace (common in log files).
     */
    public static String cleanLogLine(String line) {
        return line.stripTrailing();
    }

    /**
     * Normalizes code indentation by removing leading whitespace
     * for a code formatter.
     */
    public static String removeIndentation(String codeLine) {
        return codeLine.stripLeading();
    }

    public static void main(String[] args) {
        // CSV field with non-breaking spaces from Excel
        String excelField = "\u00A0\u00A0John Smith\u00A0";
        System.out.println("Cleaned CSV: [" + cleanCsvField(excelField) + "]");

        // Log lines with trailing whitespace
        List logLines = List.of(
            "2024-01-15 INFO  Starting server...   ",
            "  2024-01-15 DEBUG   Connection pool initialized   ",
            "    2024-01-15 ERROR   Failed to bind port   "
        );
        System.out.println("\nCleaned log lines:");
        logLines.stream()
                .map(InputCleaner::cleanLogLine)
                .forEach(line -> System.out.println("[" + line + "]"));

        // Code with mixed indentation
        String code = "        return result;    ";
        System.out.println("\nStripped leading: [" + removeIndentation(code) + "]");
    }
}

// Output:
// Cleaned CSV: [John Smith]
//
// Cleaned log lines:
// [2024-01-15 INFO  Starting server...]
// [  2024-01-15 DEBUG   Connection pool initialized]
// [    2024-01-15 ERROR   Failed to bind port]
//
// Stripped leading: [return result;    ]

Rule of thumb: Always prefer strip() over trim() in new code. There is no performance penalty, and it handles edge cases that trim() silently ignores. The only reason to keep using trim() is backward compatibility with code that explicitly depends on its ASCII-only behavior.

4. lines()

The lines() method splits a string into a Stream<String> using line terminators (\n, \r, or \r\n). This is a huge improvement over the old approaches of splitting by regex or wrapping strings in a BufferedReader.

Why Not Just Use split()?

There are three good reasons to prefer lines() over split("\\n"):

  • Cross-platform line endings -- lines() handles \n (Unix), \r\n (Windows), and \r (old Mac) automatically. With split(), you need split("\\r?\\n|\\r").
  • Lazy evaluation -- lines() returns a Stream, so it processes lines on demand. If you only need the first 10 lines of a 10,000-line string, it does not split the entire string.
  • No trailing empty strings -- lines() does not produce a trailing empty string if the text ends with a newline. With split(), you need the -1 limit parameter to control this behavior, and even then the behavior is different.
public class LinesDemo {
    public static void main(String[] args) {
        String multiline = "Line 1\nLine 2\nLine 3\n";

        // Old way: split -- note the trailing empty string issue
        String[] splitResult = multiline.split("\n");
        System.out.println("split() count: " + splitResult.length);   // 3

        // split with -1 limit to include trailing empties
        String[] splitAll = multiline.split("\n", -1);
        System.out.println("split(-1) count: " + splitAll.length);    // 4 (includes trailing "")

        // Java 11: lines() -- clean, no trailing empty string
        long linesCount = multiline.lines().count();
        System.out.println("lines() count: " + linesCount);           // 3

        // lines() handles all line ending styles
        String mixedEndings = "Windows\r\nUnix\nOld Mac\rEnd";
        mixedEndings.lines().forEach(System.out::println);
    }
}

// Output:
// split() count: 3
// split(-1) count: 4
// lines() count: 3
// Windows
// Unix
// Old Mac
// End

Practical Use: Log File Processing

One of the best use cases for lines() is processing multiline text like log entries, CSV data, or configuration files:

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

public class LogProcessor {

    public static void main(String[] args) {
        String logContent = """
                2024-01-15 08:30:00 INFO  UserService - User login: john@example.com
                2024-01-15 08:30:05 DEBUG ConnectionPool - Acquired connection #42
                2024-01-15 08:30:10 ERROR PaymentService - Payment failed: insufficient funds
                2024-01-15 08:30:15 WARN  SecurityFilter - Rate limit approached: 192.168.1.100
                2024-01-15 08:30:20 INFO  UserService - User logout: john@example.com
                2024-01-15 08:30:25 ERROR PaymentService - Payment timeout after 30s
                2024-01-15 08:31:00 INFO  HealthCheck - System status: OK
                """;

        // Count lines by log level
        Map levelCounts = logContent.lines()
                .filter(line -> !line.isBlank())
                .collect(Collectors.groupingBy(
                    line -> line.split("\\s+")[2],  // Extract log level (3rd token)
                    Collectors.counting()
                ));
        System.out.println("Log level counts: " + levelCounts);

        // Extract only ERROR lines
        System.out.println("\nError lines:");
        logContent.lines()
                .filter(line -> line.contains("ERROR"))
                .forEach(line -> System.out.println("  " + line.strip()));

        // Get unique services mentioned
        List services = logContent.lines()
                .filter(line -> !line.isBlank())
                .map(line -> line.split("\\s+")[3])   // 4th token is the service name
                .distinct()
                .collect(Collectors.toList());
        System.out.println("\nServices: " + services);

        // Get first 3 lines (lazy -- does not process the rest)
        System.out.println("\nFirst 3 lines:");
        logContent.lines()
                .limit(3)
                .forEach(System.out::println);
    }
}

// Output:
// Log level counts: {WARN=1, ERROR=2, DEBUG=1, INFO=3}
//
// Error lines:
//   2024-01-15 08:30:10 ERROR PaymentService - Payment failed: insufficient funds
//   2024-01-15 08:30:25 ERROR PaymentService - Payment timeout after 30s
//
// Services: [UserService, ConnectionPool, PaymentService, SecurityFilter, HealthCheck]
//
// First 3 lines:
// 2024-01-15 08:30:00 INFO  UserService - User login: john@example.com
// 2024-01-15 08:30:05 DEBUG ConnectionPool - Acquired connection #42
// 2024-01-15 08:30:10 ERROR PaymentService - Payment failed: insufficient funds

Practical Use: Parsing Structured Text

The lines() method combined with stream operations makes parsing structured text (like CSV or configuration) clean and expressive:

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

public class CsvParser {

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

    public static void main(String[] args) {
        String csvData = """
                Name,Department,Salary
                Alice Johnson,Engineering,95000
                Bob Smith,Marketing,72000
                Carol Williams,Engineering,105000
                David Brown,Marketing,68000
                Eve Davis,Engineering,110000
                """;

        // Parse CSV into Employee records (skip header)
        List employees = csvData.lines()
                .skip(1)                              // Skip header row
                .filter(line -> !line.isBlank())       // Skip blank lines
                .map(line -> line.split(","))
                .map(parts -> new Employee(
                    parts[0].strip(),
                    parts[1].strip(),
                    Double.parseDouble(parts[2].strip())
                ))
                .collect(Collectors.toList());

        // Average salary by department
        employees.stream()
                .collect(Collectors.groupingBy(
                    Employee::department,
                    Collectors.averagingDouble(Employee::salary)
                ))
                .forEach((dept, avg) ->
                    System.out.printf("%s: $%,.0f average%n", dept, avg));

        // Top earner
        employees.stream()
                .max((a, b) -> Double.compare(a.salary(), b.salary()))
                .ifPresent(e ->
                    System.out.printf("\nTop earner: %s ($%,.0f)%n", e.name(), e.salary()));
    }
}

// Output:
// Engineering: $103,333 average
// Marketing: $70,000 average
//
// Top earner: Eve Davis ($110,000)

5. repeat(int)

The repeat(int count) method returns a string whose value is the concatenation of the original string repeated count times. If count is 0, it returns an empty string. If count is 1, it returns the original string. If count is negative, it throws IllegalArgumentException.

Before Java 11

Repeating a string was surprisingly awkward before Java 11. Here are the workarounds developers used:

// Before Java 11: various hacky approaches

// Approach 1: StringBuilder loop
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 5; i++) {
    sb.append("ha");
}
String laughOld = sb.toString();  // "hahahahaha"

// Approach 2: String.join hack (Java 8)
String stars = String.join("", java.util.Collections.nCopies(5, "*"));

// Approach 3: char array fill (only works for single characters)
char[] dashes = new char[50];
java.util.Arrays.fill(dashes, '-');
String line = new String(dashes);

// Java 11: simple, readable, one line
String laugh = "ha".repeat(5);       // "hahahahaha"
String border = "-".repeat(50);      // "--------------------------------------------------"
String indent = "  ".repeat(3);      // "      " (6 spaces)

Practical Use: Formatted Output and Reports

repeat() is incredibly useful for building formatted console output, ASCII art, and text reports:

import java.util.List;

public class ReportFormatter {

    record Product(String name, int quantity, double price) {
        double total() { return quantity * price; }
    }

    public static void main(String[] args) {
        List products = List.of(
            new Product("Laptop", 3, 999.99),
            new Product("Mouse", 15, 29.99),
            new Product("Keyboard", 10, 79.99),
            new Product("Monitor", 5, 449.99)
        );

        int width = 60;
        String border = "=".repeat(width);
        String thinBorder = "-".repeat(width);

        // Header
        System.out.println(border);
        System.out.println(centerText("INVENTORY REPORT", width));
        System.out.println(border);

        // Column headers
        System.out.printf("%-20s %8s %10s %12s%n",
                "Product", "Qty", "Price", "Total");
        System.out.println(thinBorder);

        // Data rows
        double grandTotal = 0;
        for (Product p : products) {
            System.out.printf("%-20s %8d %10.2f %12.2f%n",
                    p.name(), p.quantity(), p.price(), p.total());
            grandTotal += p.total();
        }

        // Footer
        System.out.println(thinBorder);
        System.out.printf("%-20s %8s %10s %12.2f%n",
                "GRAND TOTAL", "", "", grandTotal);
        System.out.println(border);
    }

    static String centerText(String text, int width) {
        int padding = (width - text.length()) / 2;
        return " ".repeat(Math.max(0, padding)) + text;
    }
}

// Output:
// ============================================================
//                       INVENTORY REPORT
// ============================================================
// Product                   Qty      Price        Total
// ------------------------------------------------------------
// Laptop                      3     999.99      2999.97
// Mouse                      15      29.99       449.85
// Keyboard                   10      79.99       799.90
// Monitor                     5     449.99      2249.95
// ------------------------------------------------------------
// GRAND TOTAL                                    6499.67
// ============================================================

Practical Use: Building Tree Structures

repeat() combined with depth tracking makes it easy to display hierarchical data:

import java.util.List;

public class TreePrinter {

    record TreeNode(String name, List children) {
        TreeNode(String name) { this(name, List.of()); }
    }

    public static void printTree(TreeNode node, int depth) {
        String indent = "  ".repeat(depth);
        String prefix = depth == 0 ? "" : "|" + "--".repeat(1) + " ";
        System.out.println(indent + prefix + node.name());

        for (TreeNode child : node.children()) {
            printTree(child, depth + 1);
        }
    }

    public static void main(String[] args) {
        TreeNode root = new TreeNode("src", List.of(
            new TreeNode("main", List.of(
                new TreeNode("java", List.of(
                    new TreeNode("com.example", List.of(
                        new TreeNode("Application.java"),
                        new TreeNode("config", List.of(
                            new TreeNode("AppConfig.java"),
                            new TreeNode("SecurityConfig.java")
                        )),
                        new TreeNode("service", List.of(
                            new TreeNode("UserService.java"),
                            new TreeNode("OrderService.java")
                        ))
                    ))
                )),
                new TreeNode("resources", List.of(
                    new TreeNode("application.properties")
                ))
            )),
            new TreeNode("test", List.of(
                new TreeNode("java")
            ))
        ));

        printTree(root, 0);
    }
}

// Output:
// src
//   |-- main
//     |-- java
//       |-- com.example
//         |-- Application.java
//         |-- config
//           |-- AppConfig.java
//           |-- SecurityConfig.java
//         |-- service
//           |-- UserService.java
//           |-- OrderService.java
//     |-- resources
//       |-- application.properties
//   |-- test
//     |-- java

Edge Cases

public class RepeatEdgeCases {
    public static void main(String[] args) {
        // repeat(0) returns empty string
        System.out.println("[" + "hello".repeat(0) + "]");  // []

        // repeat(1) returns the original string
        System.out.println("[" + "hello".repeat(1) + "]");  // [hello]

        // Empty string repeated is still empty
        System.out.println("[" + "".repeat(100) + "]");     // []

        // Negative count throws IllegalArgumentException
        try {
            "hello".repeat(-1);
        } catch (IllegalArgumentException e) {
            System.out.println("Negative count: " + e.getMessage());
        }

        // Very large repeat can cause OutOfMemoryError
        // "x".repeat(Integer.MAX_VALUE);  // OutOfMemoryError
    }
}

6. String Improvements for Null Safety

While not new methods on the String class itself, Java 11 also improved how strings work with null values in the broader ecosystem. Understanding these patterns helps you write safer string-handling code:

import java.util.Objects;

public class NullSafeStrings {
    public static void main(String[] args) {
        String nullStr = null;
        String emptyStr = "";
        String blankStr = "   ";
        String validStr = "Hello";

        // Pattern 1: Null-safe blank check
        System.out.println("--- Null-safe blank check ---");
        System.out.println(isNullOrBlank(nullStr));    // true
        System.out.println(isNullOrBlank(emptyStr));   // true
        System.out.println(isNullOrBlank(blankStr));   // true
        System.out.println(isNullOrBlank(validStr));   // false

        // Pattern 2: Objects.toString() with default
        System.out.println("\n--- Objects.toString() ---");
        System.out.println(Objects.toString(nullStr, "N/A"));   // N/A
        System.out.println(Objects.toString(validStr, "N/A"));  // Hello

        // Pattern 3: String.valueOf() -- never returns null
        System.out.println("\n--- String.valueOf() ---");
        System.out.println(String.valueOf(nullStr));    // "null" (the string literal)
        System.out.println(String.valueOf(12345));      // "12345"
        System.out.println(String.valueOf(true));       // "true"

        // Pattern 4: Combining new methods with null checks
        System.out.println("\n--- Combined pattern ---");
        String input = "   ";
        String result = (input != null && !input.isBlank())
                ? input.strip()
                : "default";
        System.out.println("Result: " + result);  // "default"
    }

    /**
     * Utility method: null-safe blank check.
     * Combines null check with Java 11's isBlank().
     */
    static boolean isNullOrBlank(String str) {
        return str == null || str.isBlank();
    }
}

7. Comparison Table -- All New String Methods

Here is a complete reference of every String method added in Java 11:

Method Signature Return Type Description Example
isBlank() isBlank() boolean Returns true if the string is empty or contains only whitespace (Unicode-aware) " ".isBlank() returns true
strip() strip() String Removes leading and trailing Unicode whitespace " hi ".strip() returns "hi"
stripLeading() stripLeading() String Removes only leading Unicode whitespace " hi ".stripLeading() returns "hi "
stripTrailing() stripTrailing() String Removes only trailing Unicode whitespace " hi ".stripTrailing() returns " hi"
lines() lines() Stream<String> Splits by line terminators (\n, \r\n, \r) into a lazy stream "a\nb".lines().count() returns 2
repeat(int) repeat(int count) String Repeats the string count times "ha".repeat(3) returns "hahaha"

8. Practical Examples -- Putting It All Together

Let us combine all the new methods in real-world scenarios that you will encounter in production Java applications.

Example 1: Configuration File Parser

This example parses a properties-style configuration, handling comments, blank lines, and messy whitespace:

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

public class ConfigParser {

    public static Map parse(String configContent) {
        Map config = new LinkedHashMap<>();

        configContent.lines()                              // Split into stream of lines
                .map(String::strip)                         // Remove leading/trailing whitespace
                .filter(line -> !line.isBlank())             // Skip blank lines
                .filter(line -> !line.startsWith("#"))       // Skip comments
                .filter(line -> line.contains("="))          // Must be key=value
                .forEach(line -> {
                    int eq = line.indexOf('=');
                    String key = line.substring(0, eq).strip();
                    String value = line.substring(eq + 1).strip();
                    config.put(key, value);
                });

        return config;
    }

    public static String toConfigString(Map config) {
        StringBuilder sb = new StringBuilder();
        sb.append("# Generated Configuration\n");
        sb.append("#").append("=".repeat(40)).append("\n\n");

        config.forEach((key, value) ->
            sb.append(key).append(" = ").append(value).append("\n"));

        return sb.toString();
    }

    public static void main(String[] args) {
        String configFile = """
                # Database Configuration
                # ======================

                db.host     =   localhost
                db.port     =   5432
                db.name     =   myapp

                # Connection Pool
                pool.size   =   10
                pool.timeout =  30000

                # Feature Flags
                feature.darkMode = true
                feature.beta     = false
                """;

        Map config = parse(configFile);

        // Display parsed configuration
        System.out.println("Parsed " + config.size() + " properties:");
        String separator = "-".repeat(45);
        System.out.println(separator);
        config.forEach((key, value) ->
            System.out.printf("  %-20s = %s%n", key, value));
        System.out.println(separator);

        // Regenerate config file
        System.out.println("\nRegenerated config:");
        System.out.println(toConfigString(config));
    }
}

// Output:
// Parsed 7 properties:
// ---------------------------------------------
//   db.host              = localhost
//   db.port              = 5432
//   db.name              = myapp
//   pool.size            = 10
//   pool.timeout         = 30000
//   feature.darkMode     = true
//   feature.beta         = false
// ---------------------------------------------
//
// Regenerated config:
// # Generated Configuration
// #========================================
//
// db.host = localhost
// db.port = 5432
// db.name = myapp
// pool.size = 10
// pool.timeout = 30000
// feature.darkMode = true
// feature.beta = false

Example 2: Text Report Generator

This example uses repeat(), strip(), and lines() together to generate a formatted text report:

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

public class SalesReport {

    record Sale(String region, String product, double amount) {}

    public static void main(String[] args) {
        List sales = List.of(
            new Sale("North", "Laptop", 2999.97),
            new Sale("North", "Phone", 1599.98),
            new Sale("South", "Laptop", 999.99),
            new Sale("South", "Phone", 3199.96),
            new Sale("South", "Tablet", 1499.97),
            new Sale("East", "Laptop", 5999.94),
            new Sale("East", "Phone", 799.99),
            new Sale("West", "Tablet", 2999.94)
        );

        printReport(sales);
    }

    static void printReport(List sales) {
        int width = 55;
        String doubleLine = "=".repeat(width);
        String singleLine = "-".repeat(width);

        // Title
        System.out.println("\n" + doubleLine);
        System.out.println(center("QUARTERLY SALES REPORT", width));
        System.out.println(center("Q4 2024", width));
        System.out.println(doubleLine);

        // Group by region
        Map> byRegion = sales.stream()
                .collect(Collectors.groupingBy(Sale::region));

        double grandTotal = 0;

        for (var entry : byRegion.entrySet()) {
            String region = entry.getKey();
            List regionSales = entry.getValue();
            double regionTotal = regionSales.stream()
                    .mapToDouble(Sale::amount).sum();
            grandTotal += regionTotal;

            System.out.println("\n" + " ".repeat(2) + region + " Region");
            System.out.println(" ".repeat(2) + "-".repeat(region.length() + 7));

            for (Sale sale : regionSales) {
                System.out.printf("  %-20s $%,10.2f%n",
                        sale.product(), sale.amount());
            }
            System.out.printf("  %-20s $%,10.2f%n",
                    "Subtotal:", regionTotal);
        }

        System.out.println("\n" + doubleLine);
        System.out.printf("  %-20s $%,10.2f%n", "GRAND TOTAL:", grandTotal);
        System.out.println(doubleLine + "\n");
    }

    static String center(String text, int width) {
        int pad = (width - text.length()) / 2;
        return " ".repeat(Math.max(0, pad)) + text;
    }
}

Example 3: Input Sanitizer for Web Applications

This example demonstrates using the new String methods for a realistic web input sanitization pipeline:

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

public class InputSanitizer {

    record ValidationResult(boolean valid, String cleanValue, List errors) {}

    public static ValidationResult sanitizeAndValidate(String fieldName, String rawInput,
                                                        int minLength, int maxLength,
                                                        boolean required) {
        List errors = new java.util.ArrayList<>();

        // Step 1: Handle null
        if (rawInput == null) {
            if (required) {
                errors.add(fieldName + " is required");
            }
            return new ValidationResult(!required, "", errors);
        }

        // Step 2: strip() removes Unicode whitespace (better than trim())
        String cleaned = rawInput.strip();

        // Step 3: isBlank() detects whitespace-only submissions
        if (cleaned.isBlank()) {
            if (required) {
                errors.add(fieldName + " cannot be blank");
            }
            return new ValidationResult(!required, "", errors);
        }

        // Step 4: Normalize internal whitespace using lines() + joining
        // This handles embedded newlines, tabs, and multiple spaces
        cleaned = cleaned.lines()
                .map(String::strip)
                .filter(line -> !line.isBlank())
                .collect(Collectors.joining(" "));

        // Step 5: Length validation
        if (cleaned.length() < minLength) {
            errors.add(fieldName + " must be at least " + minLength + " characters");
        }
        if (cleaned.length() > maxLength) {
            errors.add(fieldName + " must be at most " + maxLength + " characters");
            cleaned = cleaned.substring(0, maxLength);
        }

        return new ValidationResult(errors.isEmpty(), cleaned, errors);
    }

    public static void main(String[] args) {
        // Simulate messy form submissions
        Map formInputs = new LinkedHashMap<>();
        formInputs.put("username", "  john_doe  ");
        formInputs.put("email", "\u00A0\u00A0user@example.com\u00A0");  // Non-breaking spaces
        formInputs.put("bio", "  Hello!\n\n  I am a\n   developer.  \n\n ");
        formInputs.put("name", "   \t\n   ");     // Whitespace only
        formInputs.put("website", null);            // Missing field

        System.out.println("=".repeat(60));
        System.out.println("Input Sanitization Results");
        System.out.println("=".repeat(60));

        formInputs.forEach((field, raw) -> {
            boolean required = !field.equals("website");
            var result = sanitizeAndValidate(field, raw, 2, 200, required);
            System.out.printf("\nField: %s%n", field);
            System.out.printf("  Raw:    [%s]%n", raw);
            System.out.printf("  Clean:  [%s]%n", result.cleanValue());
            System.out.printf("  Valid:  %s%n", result.valid());
            if (!result.errors().isEmpty()) {
                result.errors().forEach(e -> System.out.printf("  Error:  %s%n", e));
            }
        });
    }
}

// Output:
// ============================================================
// Input Sanitization Results
// ============================================================
//
// Field: username
//   Raw:    [  john_doe  ]
//   Clean:  [john_doe]
//   Valid:  true
//
// Field: email
//   Raw:    [  user@example.com ]
//   Clean:  [user@example.com]
//   Valid:  true
//
// Field: bio
//   Raw:    [  Hello!
//
//   I am a
//    developer.
//
//  ]
//   Clean:  [Hello! I am a developer.]
//   Valid:  true
//
// Field: name
//   Raw:    [
//    ]
//   Clean:  []
//   Valid:  false
//   Error:  name cannot be blank
//
// Field: website
//   Raw:    [null]
//   Clean:  []
//   Valid:  true

9. Best Practices

Here are the key guidelines for using Java 11's new String methods effectively in production code:

Always Prefer strip() Over trim()

In new code, always use strip() instead of trim(). It handles all Unicode whitespace, has no performance penalty, and prevents subtle bugs when processing text from international sources, web forms (where browsers may insert &nbsp;), or files saved with different encodings.

Use isBlank() for Validation, isEmpty() for Protocol Checks

Use isBlank() when you need to know whether a string has meaningful content (form validation, business logic). Use isEmpty() when you need to know whether a string is literally empty (protocol handling, serialization, exact-length checks).

Use lines() Instead of split() for Multiline Text

Whenever you are splitting a string by line breaks, prefer lines() over split("\\n"). It handles all line ending formats, is lazy (better for large strings), and has cleaner semantics around trailing newlines.

Combine Methods in Pipelines

The new methods work beautifully together in stream pipelines:

// Common pattern: parse multiline input, clean each line, skip blanks
String rawInput = "  line one  \n\n  line two  \n   \n  line three  \n";

List cleanedLines = rawInput.lines()     // Split into stream
        .map(String::strip)                       // Clean each line
        .filter(line -> !line.isBlank())           // Remove blank lines
        .collect(Collectors.toList());

// Result: ["line one", "line two", "line three"]

Use repeat() for Formatting, Not Concatenation Loops

Replace any loop that builds a repeated string pattern with repeat(). It is more readable, and internally it uses an optimized algorithm (not simple concatenation).

Do/Don't Summary

Do Don't
input.isBlank() input.trim().isEmpty()
input.strip() input.trim() (in new code)
text.lines().filter(...) text.split("\\n") (for multiline processing)
"-".repeat(50) Loop with StringBuilder.append("-")
line.stripTrailing() line.replaceAll("\\s+$", "")
Null-check before calling isBlank() Calling isBlank() on a potentially null reference

10. Quick Reference

Method Signature Returns Key Behavior Replaces
isBlank() boolean isBlank() boolean True if empty or all whitespace (Unicode-aware) trim().isEmpty(), StringUtils.isBlank()
strip() String strip() String Removes leading + trailing Unicode whitespace trim()
stripLeading() String stripLeading() String Removes leading Unicode whitespace only Regex replaceAll("^\\s+", "")
stripTrailing() String stripTrailing() String Removes trailing Unicode whitespace only Regex replaceAll("\\s+$", "")
lines() Stream<String> lines() Stream<String> Lazy split by \n, \r\n, or \r split("\\r?\\n"), BufferedReader.lines()
repeat(int) String repeat(int count) String Repeats string count times. 0 returns "". Negative throws IllegalArgumentException StringUtils.repeat(), StringBuilder loop

These six methods may seem small individually, but they collectively eliminate a significant amount of boilerplate code. If you are still using Apache Commons Lang's StringUtils solely for isBlank() and repeat(), you can likely remove that dependency now. Java 11's built-in methods are well-tested, well-optimized, and available everywhere.

March 1, 2026

Java 11 Running Java Files Directly

1. Introduction

For over 20 years, running a Java program meant two steps: compile it, then run it. Even a simple “Hello, World!” required two commands in the terminal. Want to test a quick idea? Open an IDE, create a project, set up a package, write a class, compile, and then run. Compared to languages like Python or JavaScript where you just type python script.py or node script.js, Java felt heavy even for the smallest tasks.

Java 11 changed that. With JEP 330, you can now run a single Java source file directly without compiling it first. One command. No .class files. No project setup.

The old workflow (before Java 11):

// Step 1: Write the file (HelloWorld.java)
public class HelloWorld {
    public static void main(String[] args) {
        System.out.println("Hello, World!");
    }
}
## Step 2: Compile it
javac HelloWorld.java

## Step 3: Run the compiled class
java HelloWorld

## Output: Hello, World!

The new workflow (Java 11+):

## One step: Run the source file directly
java HelloWorld.java

## Output: Hello, World!

That is it. One command. No javac. No .class file created on disk. The JVM compiles the source code in memory and executes it immediately.

Think of it like the difference between writing a formal letter (old way — draft, print, mail) versus sending a text message (new way — type and send). Both communicate, but one has zero ceremony for quick messages.

Why this matters:

  • Scripting — Java can now compete with Python/Bash for quick automation scripts
  • Learning — beginners can run code without understanding compilation, classpaths, or IDEs
  • Prototyping — test an idea in 10 seconds instead of 2 minutes
  • DevOps — write utility scripts in Java instead of switching to another language
  • Unix shebang support — Java files can be made directly executable on Linux/macOS

2. How Single-File Execution Works

When you run java HelloWorld.java, the JVM detects that the argument ends with .java (a source file, not a class name). It then:

  1. Reads the entire source file into memory
  2. Compiles it in memory using the Java compiler (no .class file is written to disk)
  3. Loads the compiled bytecode into the JVM
  4. Finds the main method in the first class declared in the file
  5. Executes the main method

Key detail: The main method must be in the first class in the file. If your file has multiple classes, the first one must contain main.

// File: Demo.java
// The FIRST class must have the main method

public class Demo {
    public static void main(String[] args) {
        Helper helper = new Helper();
        helper.greet("Java 11");
    }
}

// Additional classes are allowed in the same file
class Helper {
    void greet(String name) {
        System.out.println("Hello from " + name + "!");
    }
}

// Run: java Demo.java
// Output: Hello from Java 11!

What happens behind the scenes:

Step Traditional (javac + java) Single-File (java only)
Source file Read from disk Read from disk
Compilation javac writes .class to disk Compiled in memory (no disk I/O)
Class loading JVM loads .class from disk JVM loads bytecode from memory
Execution Runs main in specified class Runs main in first class
Artifacts .class files remain on disk Nothing written to disk
Speed Slightly faster (pre-compiled) Adds compile time on each run

2.1 The Source File is Not a Class Name

Pay close attention to the difference between these two commands:

## This runs a COMPILED class named HelloWorld (looks for HelloWorld.class)
java HelloWorld

## This runs a SOURCE FILE named HelloWorld.java (compiles in memory first)
java HelloWorld.java

The .java extension is what tells the JVM to use single-file execution mode. Without it, the JVM assumes you are specifying a class name and looks for a compiled .class file.

3. Basic Examples

3.1 Simple Program

// File: Greeting.java
public class Greeting {
    public static void main(String[] args) {
        String message = "Welcome to Java 11 single-file execution!";
        System.out.println(message);
        System.out.println("Java version: " + System.getProperty("java.version"));
        System.out.println("OS: " + System.getProperty("os.name"));
    }
}

// Run: java Greeting.java
// Output:
// Welcome to Java 11 single-file execution!
// Java version: 11.0.2
// OS: Mac OS X

3.2 With Command-Line Arguments

Command-line arguments work exactly like they do with compiled classes. Arguments after the filename are passed to main(String[] args):

// File: Greeter.java
public class Greeter {
    public static void main(String[] args) {
        if (args.length == 0) {
            System.out.println("Usage: java Greeter.java  [...]");
            System.exit(1);
        }

        for (String name : args) {
            System.out.println("Hello, " + name + "!");
        }

        System.out.println("Greeted " + args.length + " people.");
    }
}

// Run: java Greeter.java Alice Bob Charlie
// Output:
// Hello, Alice!
// Hello, Bob!
// Hello, Charlie!
// Greeted 3 people.

3.3 Reading User Input

// File: Calculator.java
import java.util.Scanner;

public class Calculator {
    public static void main(String[] args) {
        Scanner scanner = new Scanner(System.in);

        System.out.print("Enter first number: ");
        double a = scanner.nextDouble();

        System.out.print("Enter second number: ");
        double b = scanner.nextDouble();

        System.out.print("Enter operator (+, -, *, /): ");
        String op = scanner.next();

        double result;
        switch (op) {
            case "+": result = a + b; break;
            case "-": result = a - b; break;
            case "*": result = a * b; break;
            case "/":
                if (b == 0) {
                    System.out.println("Error: Division by zero");
                    return;
                }
                result = a / b;
                break;
            default:
                System.out.println("Error: Unknown operator '" + op + "'");
                return;
        }

        System.out.printf("%.2f %s %.2f = %.2f%n", a, op, b, result);
    }
}

// Run: java Calculator.java
// Enter first number: 15
// Enter second number: 4
// Enter operator (+, -, *, /): *
// 15.00 * 4.00 = 60.00

3.4 Using Standard Library Classes

You can use any class from the Java standard library. Just import it as usual:

// File: DateInfo.java
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.time.DayOfWeek;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import java.util.stream.IntStream;

public class DateInfo {
    public static void main(String[] args) {
        LocalDateTime now = LocalDateTime.now();
        DateTimeFormatter formatter =
            DateTimeFormatter.ofPattern("EEEE, MMMM d, yyyy 'at' h:mm a");

        System.out.println("Current time: " + now.format(formatter));
        System.out.println("Day of year: " + now.getDayOfYear());
        System.out.println("Week of year: " +
            now.getDayOfYear() / 7 + 1);

        // Days until weekend
        DayOfWeek today = now.getDayOfWeek();
        int daysUntilSaturday = (DayOfWeek.SATURDAY.getValue()
            - today.getValue() + 7) % 7;
        System.out.println("Days until Saturday: " +
            (daysUntilSaturday == 0 ? "It's Saturday!" : daysUntilSaturday));

        // Quick stream example
        Map> evenOdd = IntStream.rangeClosed(1, 20)
            .boxed()
            .collect(Collectors.partitioningBy(n -> n % 2 == 0));
        System.out.println("Even numbers (1-20): " + evenOdd.get(true));
        System.out.println("Odd numbers  (1-20): " + evenOdd.get(false));
    }
}

// Run: java DateInfo.java
// Output:
// Current time: Friday, February 28, 2026 at 3:42 PM
// Day of year: 59
// Week of year: 9
// Days until Saturday: 1
// Even numbers (1-20): [2, 4, 6, 8, 10, 12, 14, 16, 18, 20]
// Odd numbers  (1-20): [1, 3, 5, 7, 9, 11, 13, 15, 17, 19]

4. Shebang Scripts

On Unix-like systems (Linux, macOS), you can make Java source files directly executable using a shebang line (#!). This means you can run a Java file just like a shell script or Python script — by typing its name, without even writing java in front of it.

4.1 Creating a Shebang Java Script

#!/usr/bin/java --source 11

// File: sysinfo (no .java extension!)
// This file is executable directly on Unix systems

public class SysInfo {
    public static void main(String[] args) {
        System.out.println("=== System Information ===");
        System.out.println("OS:        " + System.getProperty("os.name")
            + " " + System.getProperty("os.version"));
        System.out.println("Arch:      " + System.getProperty("os.arch"));
        System.out.println("Java:      " + System.getProperty("java.version"));
        System.out.println("JVM:       " + System.getProperty("java.vm.name"));
        System.out.println("User:      " + System.getProperty("user.name"));
        System.out.println("Home:      " + System.getProperty("user.home"));
        System.out.println("Directory: " + System.getProperty("user.dir"));

        Runtime runtime = Runtime.getRuntime();
        long maxMem = runtime.maxMemory() / (1024 * 1024);
        long totalMem = runtime.totalMemory() / (1024 * 1024);
        long freeMem = runtime.freeMemory() / (1024 * 1024);
        System.out.println("Max Memory:   " + maxMem + " MB");
        System.out.println("Total Memory: " + totalMem + " MB");
        System.out.println("Free Memory:  " + freeMem + " MB");
        System.out.println("Processors:   " + runtime.availableProcessors());
    }
}

To make it executable:

## Make the file executable
chmod +x sysinfo

## Run it directly (no 'java' command needed!)
./sysinfo

## Output:
## === System Information ===
## OS:        Linux 5.15.0
## Arch:      amd64
## Java:      11.0.2
## JVM:       OpenJDK 64-Bit Server VM
## User:      developer
## Home:      /home/developer
## Directory: /home/developer/scripts
## Max Memory:   3641 MB
## Total Memory: 245 MB
## Free Memory:  239 MB
## Processors:   8

Shebang rules:

  • The shebang line must be the very first line of the file
  • Use #!/usr/bin/java --source 11 (specify the source version)
  • The file should not have a .java extension — name it like a script (e.g., sysinfo, deploy, cleanup)
  • The Java compiler ignores the shebang line during compilation
  • This only works on Unix-like systems (Linux, macOS), not Windows
  • You can also use #!/usr/bin/env java --source 11 for portability (uses the java from PATH)

4.2 Shebang with Arguments

#!/usr/bin/java --source 11

// File: hello (executable shebang script)

public class Hello {
    public static void main(String[] args) {
        if (args.length == 0) {
            System.out.println("Hello, World!");
        } else {
            System.out.println("Hello, " + String.join(" and ", args) + "!");
        }
    }
}
chmod +x hello
./hello
## Output: Hello, World!

./hello Alice Bob
## Output: Hello, Alice and Bob!

5. Rules and Limitations

Single-file execution is powerful but has clear boundaries. Understanding these limitations helps you know when to use it and when a full project setup is the right choice.

Rule Detail
Single source file only You cannot reference classes in other .java files. All code must be in one file.
Must have a main method The first class in the file must have public static void main(String[] args).
No external dependencies You cannot use --classpath to add JARs. Only the Java standard library is available.
No package statement needed The file runs in the unnamed (default) package. You can include a package statement, but it is ignored.
File name does not matter Unlike compiled Java, the filename does not need to match the class name.
Compiled every time No caching — the file is recompiled on every execution. This adds startup overhead.
Multiple classes allowed You can define multiple classes in one file. The first one must have main.
Source version Use --source N flag to specify the Java language version to compile against.

5.1 Demonstrating the Rules

// File: RulesDemo.java
// The filename does NOT need to match the class name for single-file execution
// (But it must match for traditional javac compilation)

// No package statement needed (it would be ignored anyway)

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

// First class MUST have main
public class RulesDemo {
    public static void main(String[] args) {
        // Using a helper class defined later in this file -- works fine
        MathHelper math = new MathHelper();
        System.out.println("Factorial of 10: " + math.factorial(10));
        System.out.println("Is 17 prime? " + math.isPrime(17));
        System.out.println("Is 18 prime? " + math.isPrime(18));

        // Using another helper class from this same file
        StringHelper strings = new StringHelper();
        System.out.println("Reversed: " + strings.reverse("Java 11"));
        System.out.println("Palindrome 'racecar': "
            + strings.isPalindrome("racecar"));
        System.out.println("Palindrome 'hello': "
            + strings.isPalindrome("hello"));

        // Using Java standard library -- all of it is available
        List primes = List.of(2, 3, 5, 7, 11, 13, 17, 19, 23, 29)
            .stream()
            .filter(n -> n > 10)
            .collect(Collectors.toList());
        System.out.println("Primes > 10: " + primes);
    }
}

// Additional classes in the same file -- no problem
class MathHelper {
    long factorial(int n) {
        long result = 1;
        for (int i = 2; i <= n; i++) {
            result *= i;
        }
        return result;
    }

    boolean isPrime(int n) {
        if (n < 2) return false;
        for (int i = 2; i * i <= n; i++) {
            if (n % i == 0) return false;
        }
        return true;
    }
}

class StringHelper {
    String reverse(String s) {
        return new StringBuilder(s).reverse().toString();
    }

    boolean isPalindrome(String s) {
        String clean = s.toLowerCase().replaceAll("[^a-z0-9]", "");
        return clean.equals(new StringBuilder(clean).reverse().toString());
    }
}

// Run: java RulesDemo.java
// Output:
// Factorial of 10: 3628800
// Is 17 prime? true
// Is 18 prime? false
// Reversed: 11 avaJ
// Palindrome 'racecar': true
// Palindrome 'hello': false
// Primes > 10: [11, 13, 17, 19, 23, 29]

5.2 What Does NOT Work

## ERROR: Cannot reference classes in other files
## If Helper.java is a separate file, this will NOT work:
## java Main.java    (where Main.java imports from Helper.java)

## ERROR: Cannot add classpath dependencies
## java --classpath lib/gson.jar Script.java    -- NOT SUPPORTED

## ERROR: If first class has no main method
## The first class MUST contain main(String[] args)

## WORKS: Specifying source version
java --source 11 Script.java

## WORKS: Passing JVM options before the filename
java -Xmx512m -Denv=prod Script.java arg1 arg2

6. Before vs After Comparison

Here is a side-by-side comparison showing how single-file execution simplifies various workflows:

6.1 Running a Quick Program

Before (Java 10 and earlier) After (Java 11+)
1. Write HelloWorld.java 1. Write HelloWorld.java
2. javac HelloWorld.java 2. java HelloWorld.java
3. Verify HelloWorld.class was created Done.
4. java HelloWorld
5. Clean up .class files

6.2 Testing an Algorithm Idea

Before After
Open IDE Open any text editor
Create new project Write code in a single file
Configure build settings java Algorithm.java
Create package directory Done.
Write code
Build project
Run
Delete project when done

6.3 Feature Comparison

Feature Traditional (javac + java) Single-File (java only)
Commands needed 2 (compile + run) 1 (run)
Files created .class files on disk None (in-memory only)
Multiple source files Yes No (single file only)
External JARs Yes (–classpath) No
Startup speed Faster (pre-compiled) Slower (compiles each run)
Shebang support No Yes
Best for Production applications Scripts, prototypes, learning

7. Use Cases

Single-file execution is not meant to replace build tools for production applications. It fills a specific niche — situations where creating a full project is overkill. Here are the sweet spots:

7.1 Quick Scripting

Need to rename 500 files, parse a log, or generate test data? Write a Java script and run it directly:

// File: RenameFiles.java
// Quick script to rename files in a directory

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.stream.Stream;

public class RenameFiles {
    public static void main(String[] args) throws IOException {
        if (args.length < 2) {
            System.out.println(
                "Usage: java RenameFiles.java  ");
            System.out.println(
                "Example: java RenameFiles.java ./photos vacation_");
            return;
        }

        Path dir = Paths.get(args[0]);
        String prefix = args[1];

        if (!Files.isDirectory(dir)) {
            System.out.println("Error: " + dir + " is not a directory");
            return;
        }

        int[] counter = {1};
        try (Stream files = Files.list(dir)) {
            files.filter(Files::isRegularFile)
                .sorted()
                .forEach(file -> {
                    String ext = getExtension(file.getFileName().toString());
                    String newName = String.format("%s%03d%s",
                        prefix, counter[0]++, ext);
                    Path target = file.resolveSibling(newName);
                    try {
                        Files.move(file, target);
                        System.out.println(
                            file.getFileName() + " -> " + newName);
                    } catch (IOException e) {
                        System.err.println("Failed to rename "
                            + file + ": " + e.getMessage());
                    }
                });
        }

        System.out.println("Renamed " + (counter[0] - 1) + " files.");
    }

    static String getExtension(String filename) {
        int dot = filename.lastIndexOf('.');
        return dot > 0 ? filename.substring(dot) : "";
    }
}

// Run: java RenameFiles.java ./photos vacation_
// Output:
// IMG_001.jpg -> vacation_001.jpg
// IMG_002.jpg -> vacation_002.jpg
// IMG_003.png -> vacation_003.png
// Renamed 3 files.

7.2 Prototyping and Learning

Want to test how a new Java feature works? Just write a quick file and run it. No project setup, no IDE required:

// File: TryNewFeatures.java
// Quick prototype to test Java features

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

public class TryNewFeatures {
    public static void main(String[] args) {

        // Test List.of() (Java 9+)
        List fruits = List.of("Apple", "Banana", "Cherry");
        System.out.println("Immutable list: " + fruits);

        // Test Map.of() (Java 9+)
        Map scores = Map.of(
            "Alice", 95,
            "Bob", 87,
            "Charlie", 92
        );
        System.out.println("Scores: " + scores);

        // Test Optional
        Optional found = fruits.stream()
            .filter(f -> f.startsWith("B"))
            .findFirst();
        found.ifPresent(f -> System.out.println("Found: " + f));

        // Test String methods (Java 11)
        String text = "   Hello, Java 11!   ";
        System.out.println("strip(): '" + text.strip() + "'");
        System.out.println("isBlank: " + "   ".isBlank());
        System.out.println("lines: " + "a\nb\nc".lines().count());
        System.out.println("repeat: " + "ha".repeat(3));
    }
}

// Run: java TryNewFeatures.java
// Output:
// Immutable list: [Apple, Banana, Cherry]
// Scores: {Alice=95, Charlie=92, Bob=87}
// Found: Banana
// strip(): 'Hello, Java 11!'
// isBlank: true
// lines: 3
// repeat: hahaha

7.3 DevOps Utilities

System administrators and DevOps engineers who know Java can write utilities without switching to Bash or Python:

// File: HealthCheck.java
// Quick health check script for web services

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.time.Instant;
import java.util.List;

public class HealthCheck {
    public static void main(String[] args) {
        List endpoints = args.length > 0
            ? List.of(args)
            : List.of(
                "https://www.google.com",
                "https://api.github.com",
                "https://jsonplaceholder.typicode.com/posts/1"
            );

        HttpClient client = HttpClient.newBuilder()
            .connectTimeout(Duration.ofSeconds(5))
            .build();

        System.out.printf("%-45s %-8s %-10s%n",
            "ENDPOINT", "STATUS", "TIME(ms)");
        System.out.println("-".repeat(65));

        for (String url : endpoints) {
            checkEndpoint(client, url);
        }
    }

    static void checkEndpoint(HttpClient client, String url) {
        try {
            HttpRequest request = HttpRequest.newBuilder()
                .uri(URI.create(url))
                .timeout(Duration.ofSeconds(10))
                .build();

            Instant start = Instant.now();
            HttpResponse response = client.send(request,
                HttpResponse.BodyHandlers.discarding());
            long elapsed = Duration.between(start, Instant.now())
                .toMillis();

            String status = response.statusCode() < 400 ? "OK" : "FAIL";
            System.out.printf("%-45s %-8s %d ms%n",
                url, status + " (" + response.statusCode() + ")", elapsed);
        } catch (Exception e) {
            System.out.printf("%-45s %-8s %s%n",
                url, "ERROR", e.getClass().getSimpleName());
        }
    }
}

// Run: java HealthCheck.java
// Output:
// ENDPOINT                                      STATUS   TIME(ms)
// -----------------------------------------------------------------
// https://www.google.com                         OK (200) 142 ms
// https://api.github.com                         OK (200) 287 ms
// https://jsonplaceholder.typicode.com/posts/1   OK (200) 198 ms

8. Practical Examples

8.1 File Processor Script

A script that reads a text file and produces statistics -- word count, line count, character frequencies, and the longest line:

// File: FileStats.java
// Analyze a text file and print statistics

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.Comparator;
import java.util.Map;
import java.util.stream.Collectors;

public class FileStats {
    public static void main(String[] args) throws IOException {
        if (args.length == 0) {
            System.out.println("Usage: java FileStats.java ");
            return;
        }

        Path file = Path.of(args[0]);
        if (!Files.exists(file)) {
            System.out.println("Error: File not found: " + file);
            return;
        }

        String content = Files.readString(file);
        var lines = content.lines().collect(Collectors.toList());
        String[] words = content.split("\\s+");

        System.out.println("=== File Statistics: " + file.getFileName()
            + " ===");
        System.out.println("Lines:      " + lines.size());
        System.out.println("Words:      " + words.length);
        System.out.println("Characters: " + content.length());
        System.out.println("Bytes:      " + Files.size(file));

        // Average word length
        double avgWordLen = Arrays.stream(words)
            .mapToInt(String::length)
            .average()
            .orElse(0);
        System.out.printf("Avg word length: %.1f chars%n", avgWordLen);

        // Longest line
        String longestLine = lines.stream()
            .max(Comparator.comparingInt(String::length))
            .orElse("");
        System.out.println("Longest line: " + longestLine.length()
            + " chars");
        System.out.println("  > " + longestLine.substring(0,
            Math.min(80, longestLine.length()))
            + (longestLine.length() > 80 ? "..." : ""));

        // Top 5 most common words
        Map wordFreq = Arrays.stream(words)
            .map(w -> w.toLowerCase().replaceAll("[^a-z]", ""))
            .filter(w -> w.length() > 2)
            .collect(Collectors.groupingBy(w -> w,
                Collectors.counting()));

        System.out.println("\nTop 5 words:");
        wordFreq.entrySet().stream()
            .sorted(Map.Entry.comparingByValue()
                .reversed())
            .limit(5)
            .forEach(e -> System.out.printf(
                "  %-15s %d%n", e.getKey(), e.getValue()));
    }
}

// Run: java FileStats.java myfile.txt
// Output:
// === File Statistics: myfile.txt ===
// Lines:      42
// Words:      350
// Characters: 2150
// Bytes:      2150
// Avg word length: 5.1 chars
// Longest line: 78 chars
//   > This is the longest line in the file containing various words and information
//
// Top 5 words:
//   the             23
//   java            15
//   and             12
//   with            8
//   for             7

8.2 System Information Script

// File: SystemReport.java
// Generate a system information report

import java.lang.management.ManagementFactory;
import java.lang.management.MemoryMXBean;
import java.lang.management.OperatingSystemMXBean;
import java.lang.management.RuntimeMXBean;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Properties;

public class SystemReport {
    public static void main(String[] args) {
        System.out.println("=" .repeat(50));
        System.out.println("     SYSTEM REPORT - " + LocalDateTime.now()
            .format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
        System.out.println("=".repeat(50));

        // Operating System
        OperatingSystemMXBean os = ManagementFactory
            .getOperatingSystemMXBean();
        System.out.println("\n--- Operating System ---");
        System.out.println("Name:         " + os.getName());
        System.out.println("Version:      " + os.getVersion());
        System.out.println("Architecture: " + os.getArch());
        System.out.println("Processors:   " + os.getAvailableProcessors());
        System.out.printf("Load Average: %.2f%n", os.getSystemLoadAverage());

        // JVM Info
        RuntimeMXBean runtime = ManagementFactory.getRuntimeMXBean();
        System.out.println("\n--- JVM ---");
        System.out.println("Name:    " + runtime.getVmName());
        System.out.println("Vendor:  " + runtime.getVmVendor());
        System.out.println("Version: " + runtime.getVmVersion());
        Duration uptime = Duration.ofMillis(runtime.getUptime());
        System.out.printf("Uptime:  %d min %d sec%n",
            uptime.toMinutes(), uptime.toSecondsPart());

        // Memory
        MemoryMXBean memory = ManagementFactory.getMemoryMXBean();
        long heapUsed = memory.getHeapMemoryUsage().getUsed() / (1024 * 1024);
        long heapMax = memory.getHeapMemoryUsage().getMax() / (1024 * 1024);
        long nonHeapUsed = memory.getNonHeapMemoryUsage().getUsed()
            / (1024 * 1024);
        System.out.println("\n--- Memory ---");
        System.out.println("Heap Used:     " + heapUsed + " MB");
        System.out.println("Heap Max:      " + heapMax + " MB");
        System.out.println("Non-Heap Used: " + nonHeapUsed + " MB");

        // Environment Variables (selected)
        System.out.println("\n--- Environment ---");
        printEnv("JAVA_HOME");
        printEnv("PATH");
        printEnv("HOME");
        printEnv("USER");

        System.out.println("\n" + "=".repeat(50));
        System.out.println("Report complete.");
    }

    static void printEnv(String name) {
        String value = System.getenv(name);
        if (value != null) {
            // Truncate long values
            if (value.length() > 60) {
                value = value.substring(0, 57) + "...";
            }
            System.out.println(name + ": " + value);
        }
    }
}

// Run: java SystemReport.java
// Output:
// ==================================================
//      SYSTEM REPORT - 2026-02-28 15:42:00
// ==================================================
//
// --- Operating System ---
// Name:         Mac OS X
// Version:      13.4
// Architecture: aarch64
// Processors:   10
// Load Average: 2.45
//
// --- JVM ---
// Name:    OpenJDK 64-Bit Server VM
// Vendor:  Eclipse Adoptium
// Version: 11.0.2+9
// Uptime:  0 min 1 sec
//
// --- Memory ---
// Heap Used:     12 MB
// Heap Max:      4096 MB
// Non-Heap Used: 8 MB
//
// --- Environment ---
// JAVA_HOME: /Library/Java/JavaVirtualMachines/temurin-11.jdk/...
// PATH: /usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:/Library...
// HOME: /Users/developer
// USER: developer
//
// ==================================================
// Report complete.

8.3 CSV Parser Script

// File: CsvParser.java
// Parse a CSV file and display formatted results

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.DoubleSummaryStatistics;
import java.util.List;
import java.util.stream.Collectors;

public class CsvParser {
    public static void main(String[] args) throws IOException {
        if (args.length == 0) {
            // Create a sample CSV for demonstration
            String sampleCsv = "Name,Department,Salary,YearsExp\n"
                + "Alice,Engineering,95000,8\n"
                + "Bob,Marketing,72000,5\n"
                + "Charlie,Engineering,105000,12\n"
                + "Diana,HR,68000,3\n"
                + "Eve,Engineering,115000,15\n"
                + "Frank,Marketing,78000,7\n"
                + "Grace,HR,71000,4\n"
                + "Henry,Engineering,92000,6";

            Files.writeString(Path.of("sample.csv"), sampleCsv);
            System.out.println("Created sample.csv");
            System.out.println(
                "Run: java CsvParser.java sample.csv\n");
            args = new String[]{"sample.csv"};
        }

        Path csvFile = Path.of(args[0]);
        List lines = Files.readAllLines(csvFile);

        if (lines.isEmpty()) {
            System.out.println("Error: Empty CSV file");
            return;
        }

        // Parse header
        String[] headers = lines.get(0).split(",");

        // Parse rows
        List rows = new ArrayList<>();
        for (int i = 1; i < lines.size(); i++) {
            rows.add(lines.get(i).split(","));
        }

        // Print formatted table
        System.out.println("\n=== CSV Data (" + rows.size()
            + " rows) ===\n");
        System.out.printf("%-12s %-15s %-10s %-10s%n",
            (Object[]) headers);
        System.out.println("-".repeat(50));
        for (String[] row : rows) {
            System.out.printf("%-12s %-15s $%-9s %-10s%n",
                (Object[]) row);
        }

        // Statistics (assuming Salary is column 2)
        System.out.println("\n=== Statistics ===\n");
        DoubleSummaryStatistics salaryStats = rows.stream()
            .mapToDouble(row -> Double.parseDouble(row[2]))
            .summaryStatistics();

        System.out.printf("Total Records:  %d%n", salaryStats.getCount());
        System.out.printf("Average Salary: $%,.0f%n",
            salaryStats.getAverage());
        System.out.printf("Min Salary:     $%,.0f%n",
            salaryStats.getMin());
        System.out.printf("Max Salary:     $%,.0f%n",
            salaryStats.getMax());
        System.out.printf("Total Payroll:  $%,.0f%n",
            salaryStats.getSum());

        // Group by department
        System.out.println("\n=== By Department ===\n");
        rows.stream()
            .collect(Collectors.groupingBy(
                row -> row[1],
                Collectors.averagingDouble(row ->
                    Double.parseDouble(row[2]))))
            .forEach((dept, avg) ->
                System.out.printf("%-15s Avg Salary: $%,.0f%n",
                    dept, avg));
    }
}

// Run: java CsvParser.java sample.csv
// Output:
// === CSV Data (8 rows) ===
//
// Name         Department      Salary     YearsExp
// --------------------------------------------------
// Alice        Engineering     $95000     8
// Bob          Marketing       $72000     5
// Charlie      Engineering     $105000    12
// Diana        HR              $68000     3
// Eve          Engineering     $115000    15
// Frank        Marketing       $78000     7
// Grace        HR              $71000     4
// Henry        Engineering     $92000     6
//
// === Statistics ===
//
// Total Records:  8
// Average Salary: $87,000
// Min Salary:     $68,000
// Max Salary:     $115,000
// Total Payroll:  $696,000
//
// === By Department ===
//
// Engineering     Avg Salary: $101,750
// Marketing       Avg Salary: $75,000
// HR              Avg Salary: $69,500

9. Best Practices

Single-file execution is a tool, and like any tool, it works best when you use it for the right job:

Situation Use Single-File Use Full Project
Quick prototype / test an idea Yes
One-time script (rename files, parse data) Yes
Learning / teaching Java Yes
Code interview practice Yes
Need external libraries (Gson, JDBC driver) Yes
Multiple source files / packages Yes
Production application Yes
Needs unit tests Yes
Startup time matters (called frequently) Yes (pre-compiled)
Shared/reusable code Yes

Naming conventions:

  • For .java files: Use PascalCase matching the main class name (e.g., FileStats.java, HealthCheck.java)
  • For shebang scripts: Use lowercase with hyphens, no extension (e.g., health-check, file-stats, deploy)
  • Put utility scripts in a ~/scripts or ~/bin directory added to your PATH

Tips for writing good single-file programs:

  • Keep it focused -- one file, one purpose. If you need more than 200-300 lines, consider a proper project.
  • Handle errors -- a script that crashes with a stack trace is not useful. Print clear error messages for bad input.
  • Accept arguments -- use args[] for input rather than hardcoding values. Print usage instructions when no arguments are given.
  • Use Java standard library -- you have the entire JDK available. java.nio.file, java.net.http, java.time, and java.util.stream cover most scripting needs.
  • Add comments -- scripts are often revisited months later. A few lines at the top explaining what the script does saves future-you time.
  • Know when to graduate -- if your single file grows beyond 300 lines, needs external libraries, or will be maintained by others, create a proper Maven/Gradle project.
March 1, 2026

Java 11 HttpClient API

1. Introduction

For nearly two decades, Java developers who needed to make HTTP requests were stuck with HttpURLConnection — a class from Java 1.1 that was clunky, verbose, and painful to use. Making a simple GET request required 20+ lines of boilerplate code with manual stream handling, and doing anything asynchronous meant managing your own threads. Third-party libraries like Apache HttpClient, OkHttp, and Unirest filled the gap, but it was always awkward that the language itself lacked a modern HTTP client.

Java 11 changed that. The java.net.http package introduced a brand new HttpClient API that is modern, fluent, and built for today’s web. It was first incubated in Java 9, refined in Java 10, and finalized as a standard API in Java 11 (JEP 321).

What was wrong with HttpURLConnection?

  • Designed in 1997 for HTTP/1.0 — before REST APIs, JSON, or microservices existed
  • No builder pattern — configuration through scattered setter methods
  • No native async support — blocking I/O only
  • Manual stream management for reading responses
  • No HTTP/2 support
  • Confusing error handling — getInputStream() throws on 4xx/5xx but getErrorStream() does not
  • No built-in timeout configuration at the request level

What Java 11 HttpClient provides:

  • Fluent builder API — clean, readable code with method chaining
  • Synchronous and asynchronous requests out of the box
  • HTTP/2 support with automatic fallback to HTTP/1.1
  • WebSocket support built in
  • Immutable and thread-safe — one client instance shared across your application
  • CompletableFuture integration for async operations
  • BodyHandlers and BodyPublishers for flexible request/response body handling

Think of the new HttpClient like upgrading from a rotary phone to a smartphone. Both make calls, but one is built for the modern world. The old HttpURLConnection is the rotary phone — it works, but every interaction is painful. The new HttpClient is the smartphone — intuitive, powerful, and designed for how we actually use HTTP today.

The three core classes:

Class Purpose Analogy
HttpClient Sends requests, manages connections, holds configuration The web browser itself
HttpRequest Represents an HTTP request (URL, method, headers, body) Typing a URL and clicking Send
HttpResponse Represents the server’s response (status, headers, body) The web page that loads

All three classes are immutable and thread-safe. You build them using the builder pattern, which means no setters, no mutable state, and no surprises in concurrent code.

// The old way: HttpURLConnection (Java 1.1 - painful)
URL url = new URL("https://api.example.com/users/1");
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod("GET");
conn.setRequestProperty("Accept", "application/json");
conn.setConnectTimeout(5000);
conn.setReadTimeout(5000);

int status = conn.getResponseCode();
BufferedReader reader = new BufferedReader(
    new InputStreamReader(conn.getInputStream()));
StringBuilder response = new StringBuilder();
String line;
while ((line = reader.readLine()) != null) {
    response.append(line);
}
reader.close();
conn.disconnect();
String body = response.toString();

Now compare that to the Java 11 HttpClient:

// The new way: Java 11 HttpClient (clean and modern)
HttpClient client = HttpClient.newHttpClient();

HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create("https://api.example.com/users/1"))
    .header("Accept", "application/json")
    .timeout(Duration.ofSeconds(5))
    .GET()
    .build();

HttpResponse response = client.send(request,
    HttpResponse.BodyHandlers.ofString());

int status = response.statusCode();
String body = response.body();

That is 5 lines of meaningful code versus 15+. No manual stream handling, no try-catch soup, no forgetting to close connections. And the async version is just as clean — replace send() with sendAsync() and you get a CompletableFuture.

2. Creating an HttpClient

The HttpClient is your connection manager. You create one instance and reuse it for all your HTTP calls — it manages connection pooling, thread pools, and protocol negotiation internally. Creating a new HttpClient for every request is like opening a new browser window for every web page — wasteful and slow.

2.1 Quick Client (Defaults)

The simplest way to create a client uses the static factory method:

// Simplest creation -- uses all defaults
HttpClient client = HttpClient.newHttpClient();
// Equivalent to:
// HttpClient client = HttpClient.newBuilder().build();

The defaults are sensible for most cases: HTTP/2 preferred (with HTTP/1.1 fallback), no redirects followed, no proxy, system default SSL context, and a default executor (thread pool).

2.2 Configured Client (Builder Pattern)

For production applications, you will want to configure the client explicitly:

import java.net.http.HttpClient;
import java.net.ProxySelector;
import java.time.Duration;
import java.util.concurrent.Executors;
import javax.net.ssl.SSLContext;

public class HttpClientCreation {
    public static void main(String[] args) throws Exception {

        // Production-ready client with full configuration
        HttpClient client = HttpClient.newBuilder()
            .version(HttpClient.Version.HTTP_2)          // Prefer HTTP/2
            .followRedirects(HttpClient.Redirect.NORMAL) // Follow redirects (not HTTPS->HTTP)
            .connectTimeout(Duration.ofSeconds(10))       // Connection timeout
            .proxy(ProxySelector.getDefault())            // Use system proxy
            .executor(Executors.newFixedThreadPool(5))    // Custom thread pool for async
            .build();

        System.out.println("Client version: " + client.version());
        // Output: Client version: HTTP_2
    }
}

Builder options explained:

Method Options Default Description
version() HTTP_2, HTTP_1_1 HTTP_2 Preferred HTTP version. HTTP/2 falls back to 1.1 if server does not support it
followRedirects() NEVER, ALWAYS, NORMAL NEVER NORMAL follows redirects except HTTPS to HTTP (security)
connectTimeout() Any Duration No timeout Max time to establish TCP connection
proxy() ProxySelector No proxy Proxy configuration for all requests
executor() Any Executor Default pool Thread pool used for async operations
authenticator() Authenticator None Basic/Digest HTTP authentication
sslContext() SSLContext System default Custom SSL/TLS configuration
cookieHandler() CookieHandler None Cookie management (e.g., CookieManager)
priority() 1 to 256 Not set HTTP/2 stream priority

2.3 Client with Cookie Management

import java.net.CookieManager;
import java.net.CookiePolicy;
import java.net.http.HttpClient;

// Client that stores and sends cookies automatically
CookieManager cookieManager = new CookieManager();
cookieManager.setCookiePolicy(CookiePolicy.ACCEPT_ORIGINAL_SERVER);

HttpClient client = HttpClient.newBuilder()
    .cookieHandler(cookieManager)
    .build();
// Now the client will store cookies from responses
// and send them with subsequent requests -- useful for session management

2.4 Client with Basic Authentication

import java.net.Authenticator;
import java.net.PasswordAuthentication;
import java.net.http.HttpClient;

// Client with built-in HTTP Basic/Digest authentication
HttpClient client = HttpClient.newBuilder()
    .authenticator(new Authenticator() {
        @Override
        protected PasswordAuthentication getPasswordAuthentication() {
            return new PasswordAuthentication(
                "username",
                "password".toCharArray()
            );
        }
    })
    .build();
// The client handles 401 challenges automatically

Key rule: Create one HttpClient instance and reuse it. The client manages an internal connection pool. Creating a new client per request wastes resources and loses connection reuse benefits.

3. Sending GET Requests

GET requests are the most common HTTP operation — fetching data from an API, downloading a page, checking if a resource exists. With Java 11’s HttpClient, a GET request is straightforward: build the request, send it, read the response.

3.1 Basic GET Request

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;

public class BasicGetRequest {
    public static void main(String[] args) throws Exception {
        HttpClient client = HttpClient.newHttpClient();

        HttpRequest request = HttpRequest.newBuilder()
            .uri(URI.create("https://jsonplaceholder.typicode.com/posts/1"))
            .GET()  // GET is the default, so this line is optional
            .build();

        HttpResponse response = client.send(request,
            HttpResponse.BodyHandlers.ofString());

        System.out.println("Status: " + response.statusCode());
        System.out.println("Body: " + response.body());
        // Output:
        // Status: 200
        // Body: {
        //   "userId": 1,
        //   "id": 1,
        //   "title": "sunt aut facere repellat provident occaecati...",
        //   "body": "quia et suscipit\nsuscipit recusandae..."
        // }
    }
}

Let us break down the flow:

  1. Build the requestHttpRequest.newBuilder() starts a builder. Set the URI, method, headers, and timeout.
  2. Send the requestclient.send() is synchronous (blocks until response arrives). The second parameter is a BodyHandler that tells the client how to process the response body.
  3. Read the responseresponse.statusCode() gives the HTTP status code. response.body() gives the body in whatever format the BodyHandler specified (here, String).

3.2 GET with Headers and Timeout

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;

public class GetWithHeaders {
    public static void main(String[] args) throws Exception {
        HttpClient client = HttpClient.newBuilder()
            .connectTimeout(Duration.ofSeconds(10))
            .build();

        HttpRequest request = HttpRequest.newBuilder()
            .uri(URI.create("https://jsonplaceholder.typicode.com/posts"))
            .header("Accept", "application/json")
            .header("User-Agent", "Java11-HttpClient-Tutorial")
            .timeout(Duration.ofSeconds(30))  // Request-level timeout
            .GET()
            .build();

        HttpResponse response = client.send(request,
            HttpResponse.BodyHandlers.ofString());

        System.out.println("Status: " + response.statusCode());
        System.out.println("Content-Type: " +
            response.headers().firstValue("content-type").orElse("unknown"));
        System.out.println("Body length: " + response.body().length() + " chars");
        // Output:
        // Status: 200
        // Content-Type: application/json; charset=utf-8
        // Body length: 27520 chars
    }
}

Important distinction: connectTimeout on the client controls how long to wait for a TCP connection. timeout on the request controls how long to wait for the entire request-response cycle. In production, set both.

3.3 GET with Response to File

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Path;

public class GetToFile {
    public static void main(String[] args) throws Exception {
        HttpClient client = HttpClient.newHttpClient();

        HttpRequest request = HttpRequest.newBuilder()
            .uri(URI.create("https://jsonplaceholder.typicode.com/posts"))
            .build();

        // Save response body directly to a file
        HttpResponse response = client.send(request,
            HttpResponse.BodyHandlers.ofFile(Path.of("posts.json")));

        System.out.println("Status: " + response.statusCode());
        System.out.println("Saved to: " + response.body());
        // Output:
        // Status: 200
        // Saved to: posts.json
    }
}

4. Sending POST Requests

POST requests send data to a server — creating resources, submitting forms, uploading files. The key difference from GET is that POST requests have a request body. Java 11 provides BodyPublishers to create request bodies from various sources.

4.1 POST with String Body (JSON)

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;

public class PostJsonString {
    public static void main(String[] args) throws Exception {
        HttpClient client = HttpClient.newHttpClient();

        // JSON body as a plain string
        String json = """
            {
                "title": "New Post",
                "body": "This is the content of my new post.",
                "userId": 1
            }
            """;

        HttpRequest request = HttpRequest.newBuilder()
            .uri(URI.create("https://jsonplaceholder.typicode.com/posts"))
            .header("Content-Type", "application/json")
            .POST(HttpRequest.BodyPublishers.ofString(json))
            .build();

        HttpResponse response = client.send(request,
            HttpResponse.BodyHandlers.ofString());

        System.out.println("Status: " + response.statusCode());
        System.out.println("Response: " + response.body());
        // Output:
        // Status: 201
        // Response: {
        //   "title": "New Post",
        //   "body": "This is the content of my new post.",
        //   "userId": 1,
        //   "id": 101
        // }
    }
}

Note: The text block syntax (""") shown above requires Java 13+. For Java 11 strictly, concatenate the JSON string or use a single-line string. We use text blocks here for readability since most Java 11+ projects eventually upgrade.

4.2 POST with Form Data

import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;

public class PostFormData {
    public static void main(String[] args) throws Exception {
        HttpClient client = HttpClient.newHttpClient();

        // URL-encoded form data
        String formData = "title=" + URLEncoder.encode("My Post", StandardCharsets.UTF_8)
            + "&body=" + URLEncoder.encode("Post content here", StandardCharsets.UTF_8)
            + "&userId=1";

        HttpRequest request = HttpRequest.newBuilder()
            .uri(URI.create("https://jsonplaceholder.typicode.com/posts"))
            .header("Content-Type", "application/x-www-form-urlencoded")
            .POST(HttpRequest.BodyPublishers.ofString(formData))
            .build();

        HttpResponse response = client.send(request,
            HttpResponse.BodyHandlers.ofString());

        System.out.println("Status: " + response.statusCode());
        System.out.println("Response: " + response.body());
        // Output:
        // Status: 201
        // Response: { "title": "My Post", "body": "Post content here", "userId": "1", "id": 101 }
    }
}

4.3 POST with File Body

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Path;

public class PostFromFile {
    public static void main(String[] args) throws Exception {
        HttpClient client = HttpClient.newHttpClient();

        // Read request body from a file
        HttpRequest request = HttpRequest.newBuilder()
            .uri(URI.create("https://jsonplaceholder.typicode.com/posts"))
            .header("Content-Type", "application/json")
            .POST(HttpRequest.BodyPublishers.ofFile(Path.of("data.json")))
            .build();

        HttpResponse response = client.send(request,
            HttpResponse.BodyHandlers.ofString());

        System.out.println("Status: " + response.statusCode());
    }
}

4.4 POST with Byte Array

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;

public class PostByteArray {
    public static void main(String[] args) throws Exception {
        HttpClient client = HttpClient.newHttpClient();

        byte[] data = "{\"title\":\"Byte Post\",\"userId\":1}"
            .getBytes(StandardCharsets.UTF_8);

        HttpRequest request = HttpRequest.newBuilder()
            .uri(URI.create("https://jsonplaceholder.typicode.com/posts"))
            .header("Content-Type", "application/json")
            .POST(HttpRequest.BodyPublishers.ofByteArray(data))
            .build();

        HttpResponse response = client.send(request,
            HttpResponse.BodyHandlers.ofString());

        System.out.println("Status: " + response.statusCode());
        System.out.println("Response: " + response.body());
    }
}

BodyPublishers summary:

BodyPublisher Source Use Case
ofString(String) String in memory JSON, XML, form data, text
ofString(String, Charset) String with specific encoding Non-UTF-8 data
ofFile(Path) File on disk Uploading files, large payloads
ofByteArray(byte[]) Byte array in memory Binary data, pre-serialized content
ofByteArray(byte[], int, int) Byte array slice Partial byte array
ofInputStream(Supplier) InputStream supplier Streaming data, lazy reading
noBody() Empty body DELETE, HEAD requests

5. Handling Responses

Every HTTP call returns an HttpResponse object. This object contains everything the server sent back — the status code, headers, and body. The type of the body depends on which BodyHandler you passed to send() or sendAsync().

5.1 Response Basics

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.List;
import java.util.Map;

public class ResponseDetails {
    public static void main(String[] args) throws Exception {
        HttpClient client = HttpClient.newHttpClient();

        HttpRequest request = HttpRequest.newBuilder()
            .uri(URI.create("https://jsonplaceholder.typicode.com/posts/1"))
            .build();

        HttpResponse response = client.send(request,
            HttpResponse.BodyHandlers.ofString());

        // Status code
        int status = response.statusCode();
        System.out.println("Status: " + status);   // 200

        // Response body
        String body = response.body();
        System.out.println("Body length: " + body.length());

        // The URI that was requested (useful after redirects)
        System.out.println("URI: " + response.uri());

        // HTTP version used
        System.out.println("Version: " + response.version());

        // The original request
        HttpRequest originalRequest = response.request();

        // All response headers
        Map> headers = response.headers().map();
        headers.forEach((name, values) ->
            System.out.println(name + ": " + values));
        // Output includes:
        // content-type: [application/json; charset=utf-8]
        // cache-control: [max-age=43200]
        // ...

        // Get a specific header
        String contentType = response.headers()
            .firstValue("content-type")
            .orElse("unknown");
        System.out.println("Content-Type: " + contentType);

        // Get all values for a header (some headers appear multiple times)
        List cacheHeaders = response.headers()
            .allValues("cache-control");
        System.out.println("Cache-Control: " + cacheHeaders);

        // Previous response (if redirected)
        response.previousResponse().ifPresent(prev ->
            System.out.println("Redirected from: " + prev.uri()));
    }
}

5.2 BodyHandlers

The BodyHandler determines how the response body is processed. Think of it as telling the client “I want the response as a ___”:

BodyHandler Response Type Use Case
ofString() HttpResponse<String> JSON, HTML, text responses
ofFile(Path) HttpResponse<Path> Download files directly to disk
ofByteArray() HttpResponse<byte[]> Binary data, images
ofInputStream() HttpResponse<InputStream> Large responses, streaming
ofLines() HttpResponse<Stream<String>> Line-by-line processing
discarding() HttpResponse<Void> Only care about status/headers
import java.io.InputStream;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Path;
import java.util.stream.Stream;

public class BodyHandlerExamples {
    public static void main(String[] args) throws Exception {
        HttpClient client = HttpClient.newHttpClient();
        String url = "https://jsonplaceholder.typicode.com/posts";

        HttpRequest request = HttpRequest.newBuilder()
            .uri(URI.create(url))
            .build();

        // 1. As String (most common)
        HttpResponse stringResp = client.send(request,
            HttpResponse.BodyHandlers.ofString());
        System.out.println("String body length: " + stringResp.body().length());

        // 2. As byte array
        HttpResponse bytesResp = client.send(request,
            HttpResponse.BodyHandlers.ofByteArray());
        System.out.println("Byte array length: " + bytesResp.body().length);

        // 3. As file download
        HttpResponse fileResp = client.send(request,
            HttpResponse.BodyHandlers.ofFile(Path.of("output.json")));
        System.out.println("Saved to: " + fileResp.body());

        // 4. As InputStream (for large responses)
        HttpResponse streamResp = client.send(request,
            HttpResponse.BodyHandlers.ofInputStream());
        try (InputStream is = streamResp.body()) {
            byte[] firstBytes = is.readNBytes(100);
            System.out.println("First 100 bytes: " + new String(firstBytes));
        }

        // 5. As Stream of lines
        HttpResponse> linesResp = client.send(request,
            HttpResponse.BodyHandlers.ofLines());
        linesResp.body()
            .limit(5)
            .forEach(line -> System.out.println("Line: " + line));

        // 6. Discarding body (only care about status)
        HttpResponse discardResp = client.send(request,
            HttpResponse.BodyHandlers.discarding());
        System.out.println("Status only: " + discardResp.statusCode());
    }
}

6. Asynchronous Requests

Synchronous requests block the calling thread until the response arrives. That is fine for simple scripts, but in a server application handling thousands of requests, blocking threads is expensive. The sendAsync() method returns a CompletableFuture, allowing you to fire off requests without blocking and process results when they arrive.

Think of it like ordering food at a restaurant. Synchronous is like standing at the counter waiting for your order. Asynchronous is like taking a buzzer — you go sit down, do other things, and the buzzer notifies you when your food is ready.

6.1 Basic Async Request

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.concurrent.CompletableFuture;

public class AsyncBasic {
    public static void main(String[] args) throws Exception {
        HttpClient client = HttpClient.newHttpClient();

        HttpRequest request = HttpRequest.newBuilder()
            .uri(URI.create("https://jsonplaceholder.typicode.com/posts/1"))
            .build();

        // sendAsync returns immediately with a CompletableFuture
        CompletableFuture> future = client.sendAsync(
            request, HttpResponse.BodyHandlers.ofString());

        System.out.println("Request sent, doing other work...");

        // Process the response when it arrives
        future.thenApply(HttpResponse::body)
              .thenAccept(body -> System.out.println("Got response: " +
                  body.substring(0, 50) + "..."))
              .join();  // Wait for completion (in main thread)

        // Output:
        // Request sent, doing other work...
        // Got response: {
        //   "userId": 1,
        //   "id": 1,
        //   "title": "su...
    }
}

6.2 Chaining Async Operations

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.concurrent.CompletableFuture;

public class AsyncChaining {
    public static void main(String[] args) {
        HttpClient client = HttpClient.newHttpClient();

        HttpRequest request = HttpRequest.newBuilder()
            .uri(URI.create("https://jsonplaceholder.typicode.com/posts/1"))
            .build();

        client.sendAsync(request, HttpResponse.BodyHandlers.ofString())
            .thenApply(response -> {
                System.out.println("Status: " + response.statusCode());
                return response.body();
            })
            .thenApply(body -> {
                // Parse or transform the body
                return body.length();
            })
            .thenAccept(length -> {
                System.out.println("Response body was " + length + " characters");
            })
            .exceptionally(ex -> {
                System.err.println("Request failed: " + ex.getMessage());
                return null;
            })
            .join();

        // Output:
        // Status: 200
        // Response body was 292 characters
    }
}

6.3 Parallel Requests with allOf

The real power of async shines when you need to make multiple requests in parallel. Instead of sending them one-by-one and waiting 5 seconds total, you send all of them simultaneously and wait for the slowest one:

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.stream.Collectors;

public class ParallelRequests {
    public static void main(String[] args) {
        HttpClient client = HttpClient.newHttpClient();

        // 5 different endpoints to fetch in parallel
        List urls = List.of(
            "https://jsonplaceholder.typicode.com/posts/1",
            "https://jsonplaceholder.typicode.com/posts/2",
            "https://jsonplaceholder.typicode.com/posts/3",
            "https://jsonplaceholder.typicode.com/users/1",
            "https://jsonplaceholder.typicode.com/comments/1"
        );

        Instant start = Instant.now();

        // Launch all requests in parallel
        List> futures = urls.stream()
            .map(url -> HttpRequest.newBuilder()
                .uri(URI.create(url))
                .timeout(Duration.ofSeconds(10))
                .build())
            .map(request -> client.sendAsync(request,
                    HttpResponse.BodyHandlers.ofString())
                .thenApply(HttpResponse::body)
                .exceptionally(ex -> "Error: " + ex.getMessage()))
            .collect(Collectors.toList());

        // Wait for ALL requests to complete
        CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]))
            .join();

        // Collect results
        List results = futures.stream()
            .map(CompletableFuture::join)
            .collect(Collectors.toList());

        Duration elapsed = Duration.between(start, Instant.now());
        System.out.println("Fetched " + results.size() + " responses");
        System.out.println("Total time: " + elapsed.toMillis() + " ms");
        results.forEach(r ->
            System.out.println("Response preview: " +
                r.substring(0, Math.min(60, r.length())) + "..."));

        // Output (times vary):
        // Fetched 5 responses
        // Total time: 312 ms    <-- All 5 run in parallel, not 5x sequential time
        // Response preview: {
        //   "userId": 1,
        //   "id": 1,
        //   "title": "sunt aut...
    }
}

6.4 Async with Error Handling

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.concurrent.CompletableFuture;

public class AsyncErrorHandling {
    public static void main(String[] args) {
        HttpClient client = HttpClient.newBuilder()
            .connectTimeout(Duration.ofSeconds(5))
            .build();

        // Request to a non-existent host
        HttpRequest badRequest = HttpRequest.newBuilder()
            .uri(URI.create("https://this-does-not-exist-12345.com/api"))
            .timeout(Duration.ofSeconds(3))
            .build();

        client.sendAsync(badRequest, HttpResponse.BodyHandlers.ofString())
            .thenApply(response -> {
                if (response.statusCode() >= 400) {
                    throw new RuntimeException(
                        "HTTP error: " + response.statusCode());
                }
                return response.body();
            })
            .thenAccept(body -> System.out.println("Success: " + body))
            .exceptionally(ex -> {
                // This catches both network errors and our thrown exceptions
                System.err.println("Failed: " + ex.getMessage());
                // Return a fallback value or handle the error
                return null;
            })
            .join();

        // Output:
        // Failed: java.net.ConnectException: ...
    }
}

7. PUT, DELETE, and Other Methods

Besides GET and POST, REST APIs commonly use PUT (update a resource), DELETE (remove a resource), and PATCH (partial update). Java 11's HttpClient supports all of these.

7.1 PUT Request

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;

public class PutRequest {
    public static void main(String[] args) throws Exception {
        HttpClient client = HttpClient.newHttpClient();

        String updatedJson = "{\"id\":1,\"title\":\"Updated Title\","
            + "\"body\":\"Updated content\",\"userId\":1}";

        HttpRequest request = HttpRequest.newBuilder()
            .uri(URI.create("https://jsonplaceholder.typicode.com/posts/1"))
            .header("Content-Type", "application/json")
            .PUT(HttpRequest.BodyPublishers.ofString(updatedJson))
            .build();

        HttpResponse response = client.send(request,
            HttpResponse.BodyHandlers.ofString());

        System.out.println("Status: " + response.statusCode());
        System.out.println("Response: " + response.body());
        // Output:
        // Status: 200
        // Response: {"id":1,"title":"Updated Title","body":"Updated content","userId":1}
    }
}

7.2 DELETE Request

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;

public class DeleteRequest {
    public static void main(String[] args) throws Exception {
        HttpClient client = HttpClient.newHttpClient();

        HttpRequest request = HttpRequest.newBuilder()
            .uri(URI.create("https://jsonplaceholder.typicode.com/posts/1"))
            .DELETE()
            .build();

        HttpResponse response = client.send(request,
            HttpResponse.BodyHandlers.ofString());

        System.out.println("Status: " + response.statusCode());
        System.out.println("Response: " + response.body());
        // Output:
        // Status: 200
        // Response: {}
    }
}

7.3 PATCH Request

The HttpRequest builder does not have a dedicated PATCH() method, but you can use the generic method() to set any HTTP method:

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;

public class PatchRequest {
    public static void main(String[] args) throws Exception {
        HttpClient client = HttpClient.newHttpClient();

        // PATCH: partial update -- only send the fields you want to change
        String patchJson = "{\"title\":\"Patched Title Only\"}";

        HttpRequest request = HttpRequest.newBuilder()
            .uri(URI.create("https://jsonplaceholder.typicode.com/posts/1"))
            .header("Content-Type", "application/json")
            .method("PATCH", HttpRequest.BodyPublishers.ofString(patchJson))
            .build();

        HttpResponse response = client.send(request,
            HttpResponse.BodyHandlers.ofString());

        System.out.println("Status: " + response.statusCode());
        System.out.println("Response: " + response.body());
        // Output:
        // Status: 200
        // Response: {"userId":1,"id":1,"title":"Patched Title Only",
        //            "body":"original body unchanged..."}
    }
}

7.4 HEAD Request

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;

public class HeadRequest {
    public static void main(String[] args) throws Exception {
        HttpClient client = HttpClient.newHttpClient();

        // HEAD: get headers only, no body
        HttpRequest request = HttpRequest.newBuilder()
            .uri(URI.create("https://jsonplaceholder.typicode.com/posts/1"))
            .method("HEAD", HttpRequest.BodyPublishers.noBody())
            .build();

        HttpResponse response = client.send(request,
            HttpResponse.BodyHandlers.discarding());

        System.out.println("Status: " + response.statusCode());
        response.headers().firstValue("content-type")
            .ifPresent(ct -> System.out.println("Content-Type: " + ct));
        response.headers().firstValue("content-length")
            .ifPresent(cl -> System.out.println("Content-Length: " + cl));
        // Output:
        // Status: 200
        // Content-Type: application/json; charset=utf-8
    }
}

HTTP methods summary:

Method Builder Method Body Required Typical Use
GET .GET() No Fetch resource
POST .POST(bodyPublisher) Yes Create resource
PUT .PUT(bodyPublisher) Yes Replace resource
DELETE .DELETE() No Delete resource
PATCH .method("PATCH", bodyPublisher) Yes Partial update
HEAD .method("HEAD", noBody()) No Headers only check

8. Query Parameters and Headers

Real-world API calls almost always involve query parameters (for filtering, pagination, searching) and custom headers (for authentication, content negotiation, versioning). The HttpClient API does not have a dedicated query parameter builder, so you construct the URL yourself. Headers, however, have clean builder support.

8.1 Building URLs with Query Parameters

import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import java.util.stream.Collectors;

public class QueryParameters {

    // Utility method to build query strings from a map
    public static String buildQueryString(Map params) {
        return params.entrySet().stream()
            .map(e -> URLEncoder.encode(e.getKey(), StandardCharsets.UTF_8)
                + "="
                + URLEncoder.encode(e.getValue(), StandardCharsets.UTF_8))
            .collect(Collectors.joining("&"));
    }

    public static void main(String[] args) throws Exception {
        HttpClient client = HttpClient.newHttpClient();

        // Approach 1: Manual URL construction
        String baseUrl = "https://jsonplaceholder.typicode.com/posts";
        HttpRequest request1 = HttpRequest.newBuilder()
            .uri(URI.create(baseUrl + "?userId=1&_limit=5"))
            .build();

        // Approach 2: Using a utility method (safer for special characters)
        Map params = Map.of(
            "userId", "1",
            "_limit", "5",
            "q", "search term with spaces"
        );
        String queryString = buildQueryString(params);
        HttpRequest request2 = HttpRequest.newBuilder()
            .uri(URI.create(baseUrl + "?" + queryString))
            .build();

        HttpResponse response = client.send(request1,
            HttpResponse.BodyHandlers.ofString());

        System.out.println("Status: " + response.statusCode());
        System.out.println("URI: " + response.uri());
        // Output:
        // Status: 200
        // URI: https://jsonplaceholder.typicode.com/posts?userId=1&_limit=5
    }
}

8.2 Custom Headers

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.Base64;

public class CustomHeaders {
    public static void main(String[] args) throws Exception {
        HttpClient client = HttpClient.newHttpClient();

        // Multiple headers on a request
        HttpRequest request = HttpRequest.newBuilder()
            .uri(URI.create("https://jsonplaceholder.typicode.com/posts"))
            .header("Accept", "application/json")
            .header("Accept-Language", "en-US")
            .header("X-Custom-Header", "my-value")
            .header("X-Request-ID", "req-12345")
            .build();

        // Bearer token authentication
        String token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...";
        HttpRequest authRequest = HttpRequest.newBuilder()
            .uri(URI.create("https://api.example.com/protected"))
            .header("Authorization", "Bearer " + token)
            .header("Accept", "application/json")
            .build();

        // Basic authentication via header
        String credentials = Base64.getEncoder()
            .encodeToString("username:password".getBytes());
        HttpRequest basicAuthRequest = HttpRequest.newBuilder()
            .uri(URI.create("https://api.example.com/basic-auth"))
            .header("Authorization", "Basic " + credentials)
            .build();

        // API key in header
        HttpRequest apiKeyRequest = HttpRequest.newBuilder()
            .uri(URI.create("https://api.example.com/data"))
            .header("X-API-Key", "your-api-key-here")
            .header("Accept", "application/json")
            .build();

        HttpResponse response = client.send(request,
            HttpResponse.BodyHandlers.ofString());
        System.out.println("Status: " + response.statusCode());
    }
}

header() vs headers(): The header(name, value) method adds a single header. If you call it twice with the same header name, both values are included (multi-valued header). The headers(String...) method accepts alternating name-value pairs for bulk setting:

// Adding multiple headers at once with headers()
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create("https://api.example.com/data"))
    .headers(
        "Accept", "application/json",
        "Content-Type", "application/json",
        "Authorization", "Bearer my-token",
        "X-Request-ID", "12345"
    )
    .build();
// Same result as calling .header() four times

9. Handling JSON

Most modern APIs communicate using JSON. Java does not include a JSON parser in the standard library, so you have two choices: parse JSON manually (for simple cases) or use a library like Gson or Jackson (for production code). Let us look at both approaches.

9.1 Manual JSON Parsing (No Dependencies)

For simple JSON structures, you can extract values using basic string operations. This is not recommended for production but is useful for scripts and learning:

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;

public class ManualJsonParsing {
    public static void main(String[] args) throws Exception {
        HttpClient client = HttpClient.newHttpClient();

        HttpRequest request = HttpRequest.newBuilder()
            .uri(URI.create("https://jsonplaceholder.typicode.com/users/1"))
            .build();

        HttpResponse response = client.send(request,
            HttpResponse.BodyHandlers.ofString());

        String json = response.body();

        // Very basic extraction -- works for simple flat JSON
        // DO NOT use this for production code
        String name = extractValue(json, "name");
        String email = extractValue(json, "email");
        String phone = extractValue(json, "phone");

        System.out.println("Name: " + name);
        System.out.println("Email: " + email);
        System.out.println("Phone: " + phone);
        // Output:
        // Name: Leanne Graham
        // Email: Sincere@april.biz
        // Phone: 1-770-736-8031 x56442
    }

    // Naive JSON value extractor -- only for simple cases
    static String extractValue(String json, String key) {
        String searchKey = "\"" + key + "\"";
        int keyIndex = json.indexOf(searchKey);
        if (keyIndex == -1) return null;

        int colonIndex = json.indexOf(":", keyIndex);
        int valueStart = json.indexOf("\"", colonIndex) + 1;
        int valueEnd = json.indexOf("\"", valueStart);
        return json.substring(valueStart, valueEnd);
    }
}

9.2 JSON with Gson

Gson is Google's JSON library. It maps JSON to Java objects automatically. Add it to your project with Maven (com.google.code.gson:gson:2.10.1) or Gradle:

import com.google.gson.Gson;
import com.google.gson.reflect.TypeToken;
import java.lang.reflect.Type;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.List;

public class JsonWithGson {

    // Define a Java class matching the JSON structure
    static class Post {
        int userId;
        int id;
        String title;
        String body;

        @Override
        public String toString() {
            return "Post{id=" + id + ", title='" + title + "'}";
        }
    }

    public static void main(String[] args) throws Exception {
        HttpClient client = HttpClient.newHttpClient();
        Gson gson = new Gson();

        // GET: Fetch a single post and deserialize to Java object
        HttpRequest getRequest = HttpRequest.newBuilder()
            .uri(URI.create("https://jsonplaceholder.typicode.com/posts/1"))
            .build();

        HttpResponse getResponse = client.send(getRequest,
            HttpResponse.BodyHandlers.ofString());

        Post post = gson.fromJson(getResponse.body(), Post.class);
        System.out.println("Single post: " + post);
        System.out.println("Title: " + post.title);
        // Output:
        // Single post: Post{id=1, title='sunt aut facere...'}
        // Title: sunt aut facere repellat provident occaecati...

        // GET: Fetch a list of posts
        HttpRequest listRequest = HttpRequest.newBuilder()
            .uri(URI.create(
                "https://jsonplaceholder.typicode.com/posts?_limit=3"))
            .build();

        HttpResponse listResponse = client.send(listRequest,
            HttpResponse.BodyHandlers.ofString());

        // For generic types like List, use TypeToken
        Type postListType = new TypeToken>(){}.getType();
        List posts = gson.fromJson(listResponse.body(), postListType);
        System.out.println("Got " + posts.size() + " posts:");
        posts.forEach(p -> System.out.println("  " + p));

        // POST: Serialize Java object to JSON and send
        Post newPost = new Post();
        newPost.userId = 1;
        newPost.title = "My New Post";
        newPost.body = "Content of the new post";

        String jsonBody = gson.toJson(newPost);
        System.out.println("Sending JSON: " + jsonBody);

        HttpRequest postRequest = HttpRequest.newBuilder()
            .uri(URI.create("https://jsonplaceholder.typicode.com/posts"))
            .header("Content-Type", "application/json")
            .POST(HttpRequest.BodyPublishers.ofString(jsonBody))
            .build();

        HttpResponse postResponse = client.send(postRequest,
            HttpResponse.BodyHandlers.ofString());

        Post createdPost = gson.fromJson(postResponse.body(), Post.class);
        System.out.println("Created: " + createdPost);
        System.out.println("Assigned ID: " + createdPost.id);
        // Output:
        // Created: Post{id=101, title='My New Post'}
        // Assigned ID: 101
    }
}

9.3 JSON with Jackson

Jackson is the other major JSON library in the Java ecosystem. It is the default in Spring Boot. Add it with Maven (com.fasterxml.jackson.core:jackson-databind:2.16.1):

import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.List;

public class JsonWithJackson {

    // Jackson annotation to ignore unknown fields in JSON
    @JsonIgnoreProperties(ignoreUnknown = true)
    static class User {
        public int id;
        public String name;
        public String email;
        public String phone;

        @Override
        public String toString() {
            return "User{id=" + id + ", name='" + name +
                "', email='" + email + "'}";
        }
    }

    public static void main(String[] args) throws Exception {
        HttpClient client = HttpClient.newHttpClient();
        ObjectMapper mapper = new ObjectMapper();

        // Fetch and deserialize a single user
        HttpRequest request = HttpRequest.newBuilder()
            .uri(URI.create("https://jsonplaceholder.typicode.com/users/1"))
            .build();

        HttpResponse response = client.send(request,
            HttpResponse.BodyHandlers.ofString());

        User user = mapper.readValue(response.body(), User.class);
        System.out.println("User: " + user);
        // Output: User{id=1, name='Leanne Graham', email='Sincere@april.biz'}

        // Fetch a list of users
        HttpRequest listRequest = HttpRequest.newBuilder()
            .uri(URI.create("https://jsonplaceholder.typicode.com/users"))
            .build();

        HttpResponse listResponse = client.send(listRequest,
            HttpResponse.BodyHandlers.ofString());

        List users = mapper.readValue(listResponse.body(),
            new TypeReference>(){});
        System.out.println("Total users: " + users.size());
        users.stream().limit(3).forEach(u -> System.out.println("  " + u));

        // Serialize and send
        User newUser = new User();
        newUser.name = "John Doe";
        newUser.email = "john@example.com";

        String json = mapper.writeValueAsString(newUser);

        HttpRequest postRequest = HttpRequest.newBuilder()
            .uri(URI.create("https://jsonplaceholder.typicode.com/users"))
            .header("Content-Type", "application/json")
            .POST(HttpRequest.BodyPublishers.ofString(json))
            .build();

        HttpResponse postResp = client.send(postRequest,
            HttpResponse.BodyHandlers.ofString());

        System.out.println("Created: " + postResp.body());
    }
}

Gson vs Jackson comparison:

Feature Gson Jackson
Default in Spring Boot No Yes
Speed Fast Faster
Annotations needed None for basic use @JsonIgnoreProperties recommended
Unknown fields Ignored by default Throws by default (configurable)
Streaming API Yes Yes (more powerful)
Tree model JsonElement JsonNode

10. Error Handling

HTTP requests can fail in many ways -- network issues, timeouts, invalid URLs, server errors, rate limiting. Robust error handling is what separates production code from tutorial code. The HttpClient API throws specific exceptions for different failure modes.

10.1 Exception Types

Exception When It Occurs Example Cause
IOException Network-level failure Connection refused, DNS failure, connection reset
InterruptedException Thread interrupted while waiting Thread.interrupt() called during send()
HttpTimeoutException Request or connect timeout exceeded Server too slow, network congestion
HttpConnectTimeoutException Connect timeout specifically Cannot reach server (firewall, down)
IllegalArgumentException Invalid request configuration Malformed URI, invalid header
SecurityException Security manager blocks request Sandbox restrictions

10.2 Comprehensive Error Handling

import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpConnectTimeoutException;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.http.HttpTimeoutException;
import java.time.Duration;

public class ErrorHandling {

    private static final HttpClient client = HttpClient.newBuilder()
        .connectTimeout(Duration.ofSeconds(5))
        .build();

    public static String fetchData(String url) {
        HttpRequest request = HttpRequest.newBuilder()
            .uri(URI.create(url))
            .timeout(Duration.ofSeconds(10))
            .build();

        try {
            HttpResponse response = client.send(request,
                HttpResponse.BodyHandlers.ofString());

            // Check HTTP status codes
            int status = response.statusCode();
            if (status >= 200 && status < 300) {
                return response.body();
            } else if (status == 401) {
                throw new RuntimeException("Unauthorized: check your credentials");
            } else if (status == 403) {
                throw new RuntimeException("Forbidden: insufficient permissions");
            } else if (status == 404) {
                throw new RuntimeException("Not found: " + url);
            } else if (status == 429) {
                // Rate limited -- could implement retry with backoff
                String retryAfter = response.headers()
                    .firstValue("Retry-After").orElse("unknown");
                throw new RuntimeException(
                    "Rate limited. Retry after: " + retryAfter);
            } else if (status >= 500) {
                throw new RuntimeException(
                    "Server error " + status + ": " + response.body());
            } else {
                throw new RuntimeException(
                    "Unexpected status " + status + ": " + response.body());
            }

        } catch (HttpConnectTimeoutException e) {
            throw new RuntimeException(
                "Connection timeout: cannot reach " + url, e);
        } catch (HttpTimeoutException e) {
            throw new RuntimeException(
                "Request timeout: server too slow at " + url, e);
        } catch (IOException e) {
            throw new RuntimeException(
                "Network error connecting to " + url + ": " + e.getMessage(), e);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();  // Restore interrupt flag
            throw new RuntimeException("Request interrupted", e);
        }
    }

    public static void main(String[] args) {
        // Test with a valid URL
        try {
            String data = fetchData(
                "https://jsonplaceholder.typicode.com/posts/1");
            System.out.println("Success: " + data.substring(0, 50) + "...");
        } catch (RuntimeException e) {
            System.err.println("Failed: " + e.getMessage());
        }

        // Test with a 404
        try {
            String data = fetchData(
                "https://jsonplaceholder.typicode.com/posts/99999");
            System.out.println("Got: " + data);
        } catch (RuntimeException e) {
            System.err.println("Failed: " + e.getMessage());
        }
    }
}

10.3 Retry with Exponential Backoff

For transient failures (network hiccups, 503 Service Unavailable, 429 Rate Limited), retrying with exponential backoff is a production best practice:

import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;

public class RetryWithBackoff {

    private static final HttpClient client = HttpClient.newBuilder()
        .connectTimeout(Duration.ofSeconds(5))
        .build();

    public static HttpResponse sendWithRetry(
            HttpRequest request, int maxRetries) throws Exception {

        int attempt = 0;
        while (true) {
            try {
                HttpResponse response = client.send(request,
                    HttpResponse.BodyHandlers.ofString());

                // Retry on 503 (Service Unavailable) or 429 (Rate Limited)
                if ((response.statusCode() == 503 ||
                     response.statusCode() == 429) && attempt < maxRetries) {
                    long waitMs = (long) Math.pow(2, attempt) * 1000; // 1s, 2s, 4s...
                    System.out.println("Retrying in " + waitMs +
                        "ms (attempt " + (attempt + 1) + "/" + maxRetries + ")");
                    Thread.sleep(waitMs);
                    attempt++;
                    continue;
                }
                return response;

            } catch (IOException e) {
                if (attempt < maxRetries) {
                    long waitMs = (long) Math.pow(2, attempt) * 1000;
                    System.out.println("Network error, retrying in " +
                        waitMs + "ms: " + e.getMessage());
                    Thread.sleep(waitMs);
                    attempt++;
                } else {
                    throw e;  // Max retries exceeded
                }
            }
        }
    }

    public static void main(String[] args) throws Exception {
        HttpRequest request = HttpRequest.newBuilder()
            .uri(URI.create("https://jsonplaceholder.typicode.com/posts/1"))
            .timeout(Duration.ofSeconds(10))
            .build();

        HttpResponse response = sendWithRetry(request, 3);
        System.out.println("Status: " + response.statusCode());
        System.out.println("Body: " + response.body().substring(0, 50) + "...");
    }
}

11. HTTP/2 Support

One of the headline features of Java 11's HttpClient is native HTTP/2 support. HTTP/2 is a major revision of the HTTP protocol that brings significant performance improvements over HTTP/1.1, especially for applications making many requests to the same server.

HTTP/2 advantages:

  • Multiplexing -- multiple requests and responses over a single TCP connection, no head-of-line blocking
  • Header compression -- HPACK compression reduces overhead for repeated headers
  • Binary protocol -- more efficient parsing than HTTP/1.1's text-based format
  • Server push -- server can proactively send resources (though browser support is declining)
  • Stream prioritization -- clients can hint which responses matter most

11.1 Version Negotiation

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;

public class Http2Support {
    public static void main(String[] args) throws Exception {
        // HTTP/2 is the default preference
        HttpClient http2Client = HttpClient.newBuilder()
            .version(HttpClient.Version.HTTP_2)
            .build();

        // Force HTTP/1.1 only
        HttpClient http1Client = HttpClient.newBuilder()
            .version(HttpClient.Version.HTTP_1_1)
            .build();

        HttpRequest request = HttpRequest.newBuilder()
            .uri(URI.create("https://www.google.com"))
            .build();

        // With HTTP/2 client
        HttpResponse h2Response = http2Client.send(request,
            HttpResponse.BodyHandlers.ofString());
        System.out.println("Requested: HTTP/2");
        System.out.println("Actual: " + h2Response.version());
        // Output: Actual: HTTP_2

        // With HTTP/1.1 client
        HttpResponse h1Response = http1Client.send(request,
            HttpResponse.BodyHandlers.ofString());
        System.out.println("Requested: HTTP/1.1");
        System.out.println("Actual: " + h1Response.version());
        // Output: Actual: HTTP_1_1

        // If server does not support HTTP/2, it falls back to HTTP/1.1 automatically
        HttpResponse fallbackResponse = http2Client.send(
            HttpRequest.newBuilder()
                .uri(URI.create("http://httpbin.org/get"))  // HTTP (not HTTPS)
                .build(),
            HttpResponse.BodyHandlers.ofString());
        System.out.println("HTTP (not HTTPS): " + fallbackResponse.version());
        // May output: HTTP_1_1 (HTTP/2 requires HTTPS for most servers)
    }
}

Key point: HTTP/2 is negotiated automatically via TLS ALPN (Application-Layer Protocol Negotiation). If you set HTTP_2 but the server only speaks HTTP/1.1, the client gracefully falls back. You can check which version was actually used via response.version().

11.2 Multiplexing Benefit

With HTTP/1.1, each request needs its own TCP connection (or must wait for the previous response on a shared connection). HTTP/2 multiplexes all requests over a single connection:

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.stream.Collectors;
import java.util.stream.IntStream;

public class Http2Multiplexing {
    public static void main(String[] args) {
        HttpClient client = HttpClient.newBuilder()
            .version(HttpClient.Version.HTTP_2)
            .build();

        // Create 20 requests to the same host
        List requests = IntStream.rangeClosed(1, 20)
            .mapToObj(i -> HttpRequest.newBuilder()
                .uri(URI.create(
                    "https://jsonplaceholder.typicode.com/posts/" + i))
                .build())
            .collect(Collectors.toList());

        Instant start = Instant.now();

        // Send all 20 requests in parallel
        // With HTTP/2, these all go over a SINGLE TCP connection
        List>> futures = requests.stream()
            .map(req -> client.sendAsync(req,
                HttpResponse.BodyHandlers.ofString()))
            .collect(Collectors.toList());

        // Wait for all to complete
        CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]))
            .join();

        Duration elapsed = Duration.between(start, Instant.now());

        long successCount = futures.stream()
            .map(CompletableFuture::join)
            .filter(r -> r.statusCode() == 200)
            .peek(r -> System.out.println("Post " + r.uri().getPath()
                + " - " + r.version()))
            .count();

        System.out.println("\n20 requests completed in " +
            elapsed.toMillis() + " ms");
        System.out.println("Successful: " + successCount);
        System.out.println("All via HTTP/2 multiplexing on a single connection");
    }
}

12. Real-World REST Client Example

Let us put everything together into a complete, production-quality REST client. This TodoClient performs all CRUD operations (Create, Read, Update, Delete) against the JSONPlaceholder API. It demonstrates proper error handling, JSON serialization, async operations, and clean API design.

import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.http.HttpTimeoutException;
import java.time.Duration;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.stream.Collectors;

/**
 * A complete REST client for JSONPlaceholder's /todos endpoint.
 * Demonstrates all HttpClient features in a real-world pattern.
 */
public class TodoClient {

    private static final String BASE_URL =
        "https://jsonplaceholder.typicode.com";

    private final HttpClient client;

    public TodoClient() {
        this.client = HttpClient.newBuilder()
            .version(HttpClient.Version.HTTP_2)
            .connectTimeout(Duration.ofSeconds(10))
            .followRedirects(HttpClient.Redirect.NORMAL)
            .build();
    }

    // ---- Data class ----

    static class Todo {
        int userId;
        int id;
        String title;
        boolean completed;

        Todo() {}

        Todo(int userId, String title, boolean completed) {
            this.userId = userId;
            this.title = title;
            this.completed = completed;
        }

        String toJson() {
            return "{\"userId\":" + userId
                + ",\"title\":\"" + title.replace("\"", "\\\"")
                + "\",\"completed\":" + completed + "}";
        }

        static Todo fromJson(String json) {
            Todo todo = new Todo();
            todo.id = extractInt(json, "id");
            todo.userId = extractInt(json, "userId");
            todo.title = extractString(json, "title");
            todo.completed = json.contains("\"completed\":true")
                || json.contains("\"completed\": true");
            return todo;
        }

        @Override
        public String toString() {
            return "Todo{id=" + id + ", userId=" + userId
                + ", title='" + title + "', completed=" + completed + "}";
        }

        // Simple JSON extractors (use Gson/Jackson in production)
        private static int extractInt(String json, String key) {
            String search = "\"" + key + "\":";
            int start = json.indexOf(search) + search.length();
            // Skip whitespace
            while (start < json.length() &&
                   json.charAt(start) == ' ') start++;
            int end = start;
            while (end < json.length() &&
                   Character.isDigit(json.charAt(end))) end++;
            return Integer.parseInt(json.substring(start, end));
        }

        private static String extractString(String json, String key) {
            String search = "\"" + key + "\":";
            int idx = json.indexOf(search) + search.length();
            // Skip whitespace and opening quote
            while (idx < json.length() &&
                   json.charAt(idx) != '"') idx++;
            int start = idx + 1;
            int end = json.indexOf("\"", start);
            return json.substring(start, end);
        }
    }

    // ---- API Methods ----

    /** GET /todos/{id} -- Fetch a single todo */
    public Todo getTodo(int id) throws Exception {
        HttpRequest request = HttpRequest.newBuilder()
            .uri(URI.create(BASE_URL + "/todos/" + id))
            .header("Accept", "application/json")
            .timeout(Duration.ofSeconds(15))
            .GET()
            .build();

        HttpResponse response = sendWithErrorHandling(request);
        return Todo.fromJson(response.body());
    }

    /** GET /todos?userId={userId} -- Fetch todos by user */
    public String getTodosByUser(int userId) throws Exception {
        HttpRequest request = HttpRequest.newBuilder()
            .uri(URI.create(BASE_URL + "/todos?userId=" + userId
                + "&_limit=5"))
            .header("Accept", "application/json")
            .timeout(Duration.ofSeconds(15))
            .build();

        HttpResponse response = sendWithErrorHandling(request);
        return response.body();
    }

    /** POST /todos -- Create a new todo */
    public Todo createTodo(int userId, String title,
            boolean completed) throws Exception {
        Todo todo = new Todo(userId, title, completed);

        HttpRequest request = HttpRequest.newBuilder()
            .uri(URI.create(BASE_URL + "/todos"))
            .header("Content-Type", "application/json")
            .header("Accept", "application/json")
            .timeout(Duration.ofSeconds(15))
            .POST(HttpRequest.BodyPublishers.ofString(todo.toJson()))
            .build();

        HttpResponse response = sendWithErrorHandling(request);
        return Todo.fromJson(response.body());
    }

    /** PUT /todos/{id} -- Update (replace) a todo */
    public Todo updateTodo(int id, int userId, String title,
            boolean completed) throws Exception {
        Todo todo = new Todo(userId, title, completed);
        todo.id = id;

        String json = "{\"id\":" + id + ","
            + "\"userId\":" + userId + ","
            + "\"title\":\"" + title.replace("\"", "\\\"") + "\","
            + "\"completed\":" + completed + "}";

        HttpRequest request = HttpRequest.newBuilder()
            .uri(URI.create(BASE_URL + "/todos/" + id))
            .header("Content-Type", "application/json")
            .header("Accept", "application/json")
            .timeout(Duration.ofSeconds(15))
            .PUT(HttpRequest.BodyPublishers.ofString(json))
            .build();

        HttpResponse response = sendWithErrorHandling(request);
        return Todo.fromJson(response.body());
    }

    /** PATCH /todos/{id} -- Partially update a todo */
    public Todo patchTodo(int id, boolean completed) throws Exception {
        String json = "{\"completed\":" + completed + "}";

        HttpRequest request = HttpRequest.newBuilder()
            .uri(URI.create(BASE_URL + "/todos/" + id))
            .header("Content-Type", "application/json")
            .header("Accept", "application/json")
            .timeout(Duration.ofSeconds(15))
            .method("PATCH",
                HttpRequest.BodyPublishers.ofString(json))
            .build();

        HttpResponse response = sendWithErrorHandling(request);
        return Todo.fromJson(response.body());
    }

    /** DELETE /todos/{id} -- Delete a todo */
    public int deleteTodo(int id) throws Exception {
        HttpRequest request = HttpRequest.newBuilder()
            .uri(URI.create(BASE_URL + "/todos/" + id))
            .timeout(Duration.ofSeconds(15))
            .DELETE()
            .build();

        HttpResponse response = sendWithErrorHandling(request);
        return response.statusCode();
    }

    /** Async: Fetch multiple todos in parallel */
    public List getTodosAsync(List ids) {
        List> futures = ids.stream()
            .map(id -> {
                HttpRequest request = HttpRequest.newBuilder()
                    .uri(URI.create(BASE_URL + "/todos/" + id))
                    .header("Accept", "application/json")
                    .timeout(Duration.ofSeconds(15))
                    .build();

                return client.sendAsync(request,
                        HttpResponse.BodyHandlers.ofString())
                    .thenApply(HttpResponse::body)
                    .thenApply(Todo::fromJson)
                    .exceptionally(ex -> {
                        System.err.println("Failed to fetch todo "
                            + id + ": " + ex.getMessage());
                        return null;
                    });
            })
            .collect(Collectors.toList());

        CompletableFuture.allOf(
            futures.toArray(new CompletableFuture[0])).join();

        return futures.stream()
            .map(CompletableFuture::join)
            .filter(todo -> todo != null)
            .collect(Collectors.toList());
    }

    // ---- Error Handling ----

    private HttpResponse sendWithErrorHandling(
            HttpRequest request) throws Exception {
        try {
            HttpResponse response = client.send(request,
                HttpResponse.BodyHandlers.ofString());

            int status = response.statusCode();
            if (status >= 200 && status < 300) {
                return response;
            }
            throw new RuntimeException("HTTP " + status + ": "
                + response.body());

        } catch (HttpTimeoutException e) {
            throw new RuntimeException("Request timed out: "
                + request.uri(), e);
        } catch (IOException e) {
            throw new RuntimeException("Network error: "
                + e.getMessage(), e);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new RuntimeException("Request interrupted", e);
        }
    }

    // ---- Main: Demonstrate all operations ----

    public static void main(String[] args) throws Exception {
        TodoClient todoClient = new TodoClient();

        System.out.println("=== CRUD Operations Demo ===\n");

        // CREATE
        System.out.println("--- CREATE ---");
        Todo created = todoClient.createTodo(1, "Learn HttpClient", false);
        System.out.println("Created: " + created);

        // READ (single)
        System.out.println("\n--- READ (single) ---");
        Todo todo = todoClient.getTodo(1);
        System.out.println("Fetched: " + todo);

        // READ (by user)
        System.out.println("\n--- READ (by user) ---");
        String userTodos = todoClient.getTodosByUser(1);
        System.out.println("User 1 todos (first 100 chars): "
            + userTodos.substring(0, Math.min(100, userTodos.length()))
            + "...");

        // UPDATE (full)
        System.out.println("\n--- UPDATE ---");
        Todo updated = todoClient.updateTodo(1, 1,
            "Learn HttpClient (Updated)", true);
        System.out.println("Updated: " + updated);

        // PATCH (partial)
        System.out.println("\n--- PATCH ---");
        Todo patched = todoClient.patchTodo(1, true);
        System.out.println("Patched: " + patched);

        // DELETE
        System.out.println("\n--- DELETE ---");
        int deleteStatus = todoClient.deleteTodo(1);
        System.out.println("Delete status: " + deleteStatus);

        // ASYNC (parallel fetch)
        System.out.println("\n--- ASYNC (parallel) ---");
        List todos = todoClient.getTodosAsync(
            List.of(1, 5, 10, 15, 20));
        System.out.println("Fetched " + todos.size()
            + " todos in parallel:");
        todos.forEach(t -> System.out.println("  " + t));

        System.out.println("\n=== All operations completed ===");
    }
}

// Output:
// === CRUD Operations Demo ===
//
// --- CREATE ---
// Created: Todo{id=201, userId=1, title='Learn HttpClient', completed=false}
//
// --- READ (single) ---
// Fetched: Todo{id=1, userId=1, title='delectus aut autem', completed=false}
//
// --- READ (by user) ---
// User 1 todos (first 100 chars): [
//   {
//     "userId": 1,
//     "id": 1,
//     "title": "delectus aut autem",
//     "completed": fals...
//
// --- UPDATE ---
// Updated: Todo{id=1, userId=1, title='Learn HttpClient (Updated)', completed=true}
//
// --- PATCH ---
// Patched: Todo{id=1, userId=1, title='delectus aut autem', completed=true}
//
// --- DELETE ---
// Delete status: 200
//
// --- ASYNC (parallel) ---
// Fetched 5 todos in parallel:
//   Todo{id=1, userId=1, title='delectus aut autem', completed=false}
//   Todo{id=5, userId=1, title='laboriosam mollitia...', completed=false}
//   Todo{id=10, userId=1, title='illo est ratione...', completed=true}
//   Todo{id=15, userId=1, title='ab voluptatum amet...', completed=true}
//   Todo{id=20, userId=1, title='ullam nobis libero...', completed=false}
//
// === All operations completed ===

13. HttpClient vs HttpURLConnection

If you are maintaining a legacy codebase that still uses HttpURLConnection, here is a detailed comparison to help you decide whether to migrate:

Feature HttpURLConnection (Java 1.1) HttpClient (Java 11)
API Style Setter-based, mutable Builder pattern, immutable
HTTP/2 No support Full support with auto-fallback
Async Requests No (manual thread management) Built-in with CompletableFuture
WebSocket No support Built-in WebSocket API
Thread Safety Not thread-safe Fully thread-safe and immutable
Connection Pooling Limited (keep-alive) Full connection pooling built in
Request Body Manual OutputStream writing BodyPublishers (String, File, byte[]...)
Response Body Manual InputStream reading BodyHandlers (String, File, Stream...)
Timeouts setConnectTimeout/setReadTimeout connectTimeout + per-request timeout
Redirect Handling setFollowRedirects (static, global) Per-client configuration with policies
Error Streams getInputStream vs getErrorStream confusion Unified response body regardless of status
Code Lines (GET) 15-20 lines 5-7 lines
Cookie Management Manual Built-in CookieHandler integration
SSL/TLS Cast to HttpsURLConnection SSLContext on builder

Should you migrate? If you are on Java 11+, yes. The new HttpClient is better in every measurable way. The only reason to keep HttpURLConnection is if you are stuck on Java 8 and cannot upgrade. Even then, consider Apache HttpClient or OkHttp as alternatives.

14. Best Practices

These best practices come from building production HTTP clients that handle millions of requests:

1. Reuse the HttpClient instance

Create one HttpClient per application (or per service endpoint) and share it. The client manages connection pooling internally. Creating a new client per request is like opening a new database connection for every query.

// WRONG: New client per request
public String fetchData(String url) throws Exception {
    HttpClient client = HttpClient.newHttpClient();  // Wasteful!
    HttpRequest request = HttpRequest.newBuilder()
        .uri(URI.create(url)).build();
    return client.send(request, HttpResponse.BodyHandlers.ofString()).body();
}

// RIGHT: Shared client instance
public class ApiService {
    private static final HttpClient CLIENT = HttpClient.newBuilder()
        .connectTimeout(Duration.ofSeconds(10))
        .followRedirects(HttpClient.Redirect.NORMAL)
        .build();

    public String fetchData(String url) throws Exception {
        HttpRequest request = HttpRequest.newBuilder()
            .uri(URI.create(url)).build();
        return CLIENT.send(request,
            HttpResponse.BodyHandlers.ofString()).body();
    }
}

2. Always set timeouts

A missing timeout means your thread can block forever. Set both the connection timeout (on the client) and the request timeout (on each request).

// WRONG: No timeouts -- can block forever
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create("https://slow-server.com/api"))
    .build();

// RIGHT: Both timeouts set
HttpClient client = HttpClient.newBuilder()
    .connectTimeout(Duration.ofSeconds(5))   // TCP connection timeout
    .build();

HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create("https://slow-server.com/api"))
    .timeout(Duration.ofSeconds(30))          // Full request timeout
    .build();

3. Use async for multiple independent requests

If you need to call 5 APIs and none depends on the others, send them all with sendAsync() and wait with allOf(). This turns 5 seconds of sequential calls into 1 second of parallel calls.

4. Always handle errors and check status codes

HTTP "errors" (4xx, 5xx) are not Java exceptions. The send() method returns successfully for any HTTP status. You must check response.statusCode() yourself.

5. Use a custom executor for I/O-bound async work

The default executor is a common pool with limited threads. For high-concurrency HTTP clients, provide your own executor:

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

// For high-concurrency HTTP workloads
ExecutorService executor = Executors.newFixedThreadPool(20);

HttpClient client = HttpClient.newBuilder()
    .executor(executor)
    .build();

// Remember to shut down the executor when done
// executor.shutdown();

6. Use BodyHandlers.ofFile() for large downloads

Do not read a 500MB file into a String. Use ofFile() or ofInputStream() to stream large responses directly to disk.

7. Close InputStream responses

If you use BodyHandlers.ofInputStream(), close the stream when done. String and byte[] handlers do not require cleanup.

8. Restore interrupt flag

When catching InterruptedException, always call Thread.currentThread().interrupt() to restore the interrupt flag.

Summary table:

Practice Do Do Not
Client instance Reuse one client across requests Create a new client per request
Timeouts Set connect + request timeouts Rely on defaults (no timeout)
Parallel calls Use sendAsync + allOf Sequential send() in a loop
Status codes Check statusCode() and handle 4xx/5xx Assume success after send()
Large responses Use ofFile() or ofInputStream() Read everything into a String
Thread pools Custom executor for high concurrency Use common pool for 100+ concurrent requests
InterruptedException Restore interrupt flag Swallow the exception
JSON Use Gson or Jackson Parse JSON with regex or indexOf

15. Quick Reference

This table summarizes all the key HttpClient operations for quick lookup:

Operation Code
Create default client HttpClient.newHttpClient()
Create configured client HttpClient.newBuilder().connectTimeout(...).build()
GET request HttpRequest.newBuilder().uri(URI.create(url)).GET().build()
POST with JSON .POST(BodyPublishers.ofString(json))
PUT .PUT(BodyPublishers.ofString(json))
DELETE .DELETE()
PATCH .method("PATCH", BodyPublishers.ofString(json))
Set headers .header("Accept", "application/json")
Set timeout .timeout(Duration.ofSeconds(30))
Sync send client.send(request, BodyHandlers.ofString())
Async send client.sendAsync(request, BodyHandlers.ofString())
Response body as String BodyHandlers.ofString()
Response body as File BodyHandlers.ofFile(Path.of("out.json"))
Response body as bytes BodyHandlers.ofByteArray()
Response body as Stream BodyHandlers.ofLines()
Discard body BodyHandlers.discarding()
Status code response.statusCode()
Response header response.headers().firstValue("content-type")
Parallel requests CompletableFuture.allOf(futures).join()
Error handling .exceptionally(ex -> fallback)
HTTP/2 .version(HttpClient.Version.HTTP_2)
Follow redirects .followRedirects(HttpClient.Redirect.NORMAL)
Cookie support .cookieHandler(new CookieManager())
March 1, 2026

Advanced Tips & Tricks for Claude Code

Advanced Tips & Tricks for Claude Code

After mastering the basics, here are advanced techniques that will make you significantly more productive with Claude Code.

1. Piping Input to Claude Code

Pipe command output directly to Claude Code for analysis:

# Analyze test failures
pytest tests/ 2>&1 | claude "Analyze these test failures and suggest fixes"

# Review a diff
git diff main | claude "Review this diff for potential issues"

# Analyze logs
tail -100 /var/log/app.log | claude "What errors are in these logs?"

2. Headless Mode for Automation

Use the -p flag for non-interactive mode, perfect for CI/CD and scripts:

# Generate a code review
claude -p "Review the code in src/ for security vulnerabilities" > review.md

# Generate documentation
claude -p "Generate API documentation for all endpoints in routes/" > api-docs.md

# Use in a shell script
#!/bin/bash
RESULT=$(claude -p "Is there a TODO in main.py? Reply yes or no")
if [ "$RESULT" = "yes" ]; then
  echo "Found TODOs!"
fi

3. Resuming Conversations

Continue where you left off:

# Resume the most recent conversation
claude --resume

# Continue a specific session
claude --resume session-id

4. Multi-File Editing Patterns

Claude Code excels at coordinated changes across files:

> Add a "lastLogin" field to the User model, update the migration, 
update the API serializer, and add it to the admin dashboard view

Claude Code will edit all four files in a single coordinated operation.

5. Using Claude Code with Docker

# Let Claude manage Docker containers
> Build and run the Docker container, then check if the app is healthy

# Debug container issues
> The Docker container keeps crashing on startup. Check the logs and fix the issue

6. Efficient Context Management

Help Claude Code work efficiently with large codebases:

  • Use /compact — When conversations get long, compact the context to free up space
  • Be specific about files — “Edit src/auth/login.py” is better than “Edit the login code”
  • Create a good CLAUDE.md — This reduces the need for Claude to search for basic project info

7. Permission Auto-Approval

Speed up workflows by pre-approving safe commands:

# In .claude/settings.json
{
  "permissions": {
    "allow": [
      "Bash(npm test)",
      "Bash(npm run build)",
      "Bash(git status)",
      "Bash(git diff *)",
      "Read",
      "Glob",
      "Grep"
    ]
  }
}

8. Claude Code in CI/CD

Integrate Claude Code into your development pipeline:

# GitHub Actions example
name: Code Review
on: pull_request
jobs:
  review:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm install -g @anthropic-ai/claude-code
      - run: |
          claude -p "Review the changes in this PR for bugs, \
          security issues, and code quality" > review.md
        env:
          ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}

9. Custom Slash Commands

Create reusable prompts as custom slash commands in your project’s .claude/commands/ directory:

# .claude/commands/review-component.md
Review the React component at $ARGUMENTS for:
1. Accessibility issues
2. Performance problems
3. Missing error handling
4. Proper TypeScript types

Then use it:

> /project:review-component src/components/UserProfile.tsx

10. Keyboard Shortcuts

Shortcut Action
Ctrl+C Cancel current operation
Ctrl+D Exit Claude Code
Up Arrow Cycle through previous prompts
Escape Cancel current input

Conclusion

You’ve now completed the full Claude Code tutorial series! From installation to advanced automation, you have all the tools you need to be highly productive with Claude Code. Happy coding!

Full Tutorial Series

  1. Introduction to Claude Code – Installation & Setup
  2. Your First Conversation with Claude Code
  3. Code Generation with Claude Code
  4. Debugging with Claude Code
  5. Refactoring Code with Claude Code
  6. Working with Git using Claude Code
  7. Writing Tests with Claude Code
  8. Project Configuration – CLAUDE.md & Memory
  9. Using MCP Servers with Claude Code
  10. Advanced Tips & Tricks for Claude Code

Previous: Using MCP Servers with Claude Code

February 25, 2026