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+.
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.
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"));
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
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
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
| 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. |
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:
List.of(), Set.of(), Map.of())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.
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?
::new constructor reference pattern you already use in streamsThese 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.
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}
}
}
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
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
| 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 |
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.
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]
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
Java 11 added Optional.isEmpty(), which is simply the logical complement of Optional.isPresent(). It returns true when the Optional contains no value.
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
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
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.
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!
| 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 |
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]
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.
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:
IllegalAccessError even though direct access worked fine.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
| 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.
Here is a comprehensive before-and-after comparison showing how Java 11 simplifies common coding patterns:
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);
}
}
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
Here are the key guidelines for adopting Java 11’s API improvements effectively:
Files.readString() for small-to-medium files (under ~50MB). For large files, continue using Files.lines() or BufferedReader for streaming.StandardOpenOption.APPEND when you want to add to a file. The default behavior overwrites.Path.of() over Paths.get() in new code for consistency with the modern Java factory method pattern.toArray(Type[]::new) instead of toArray(new Type[0]) — it is cleaner and equally performant.List.copyOf() for defensive copies in constructors and getters. Remember it rejects nulls, which is usually desirable.copyOf() returns an independent copy while Collections.unmodifiableList() returns a view. Choose based on whether you want isolation or live reads.Predicate.not() for negating method references — it is more readable than s -> !s.isBlank() when the positive predicate is a method reference.Optional.isEmpty() for guard clauses (early returns). For transformations and default values, stick with functional methods like map(), orElse(), and ifPresent().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.| 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.
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+.
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.
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
}
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
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
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.
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.
| 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]
}
}
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!]
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.
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.
There are three good reasons to prefer lines() over split("\\n"):
lines() handles \n (Unix), \r\n (Windows), and \r (old Mac) automatically. With split(), you need split("\\r?\\n|\\r").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.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
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
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)
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.
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)
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
// ============================================================
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
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
}
}
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();
}
}
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" |
Let us combine all the new methods in real-world scenarios that you will encounter in production Java applications.
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
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;
}
}
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
Here are the key guidelines for using Java 11's new String methods effectively in production code:
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 ), or files saved with different encodings.
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).
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.
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"; ListcleanedLines = 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"]
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 |
|---|---|
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 |
| 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.
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:
When you run java HelloWorld.java, the JVM detects that the argument ends with .java (a source file, not a class name). It then:
.class file is written to disk)main method in the first class declared in the filemain methodKey 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 |
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.
// 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
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.
// 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
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]
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.
#!/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:
#!/usr/bin/java --source 11 (specify the source version).java extension — name it like a script (e.g., sysinfo, deploy, cleanup)#!/usr/bin/env java --source 11 for portability (uses the java from PATH)#!/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!
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. |
// 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]
## 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
Here is a side-by-side comparison showing how single-file execution simplifies various workflows:
| 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 |
| 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 |
| 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 |
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:
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.
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
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
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
// 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.
// 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
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:
.java files: Use PascalCase matching the main class name (e.g., FileStats.java, HealthCheck.java)health-check, file-stats, deploy)~/scripts or ~/bin directory added to your PATHTips for writing good single-file programs:
args[] for input rather than hardcoding values. Print usage instructions when no arguments are given.java.nio.file, java.net.http, java.time, and java.util.stream cover most scripting needs.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?
getInputStream() throws on 4xx/5xx but getErrorStream() does notWhat Java 11 HttpClient provides:
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.
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.
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).
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 |
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
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.
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.
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:
HttpRequest.newBuilder() starts a builder. Set the URI, method, headers, and timeout.client.send() is synchronous (blocks until response arrives). The second parameter is a BodyHandler that tells the client how to process the response body.response.statusCode() gives the HTTP status code. response.body() gives the body in whatever format the BodyHandler specified (here, String).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.
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
}
}
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.
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.
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 }
}
}
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());
}
}
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 |
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().
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()));
}
}
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());
}
}
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.
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...
}
}
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
}
}
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...
}
}
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: ...
}
}
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.
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}
}
}
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: {}
}
}
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..."}
}
}
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 |
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.
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
}
}
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
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.
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);
}
}
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
}
}
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 |
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.
| 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 |
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());
}
}
}
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) + "...");
}
}
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:
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().
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");
}
}
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 ===
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.
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 |
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()) |
After mastering the basics, here are advanced techniques that will make you significantly more productive with 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?"
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
Continue where you left off:
# Resume the most recent conversation claude --resume # Continue a specific session claude --resume session-id
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.
# 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
Help Claude Code work efficiently with large codebases:
/compact — When conversations get long, compact the context to free up spaceSpeed 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"
]
}
}
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 }}
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
| Shortcut | Action |
|---|---|
Ctrl+C |
Cancel current operation |
Ctrl+D |
Exit Claude Code |
Up Arrow |
Cycle through previous prompts |
Escape |
Cancel current input |
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!
Previous: Using MCP Servers with Claude Code