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.