Imagine you have a massive pile of unsorted exam papers — thousands of them. You could sit down alone and sort them one by one. Or, you could divide the pile among several teaching assistants, have each sort their portion, and then merge the sorted piles back together. That second approach is exactly what Arrays.parallelSort() does.
Introduced in Java 8, Arrays.parallelSort() is a sorting method in the java.util.Arrays class that uses the Fork/Join framework to sort arrays in parallel across multiple CPU cores. Under the hood, it splits the array into sub-arrays, sorts each sub-array concurrently on separate threads, and then merges the results back together — a classic parallel merge sort.
| Feature | Arrays.sort() | Arrays.parallelSort() |
|---|---|---|
| Introduced | Java 1.2 | Java 8 |
| Thread Model | Single-threaded | Multi-threaded (Fork/Join) |
| Algorithm (primitives) | Dual-Pivot Quicksort | Parallel Merge Sort |
| Algorithm (objects) | TimSort | Parallel Merge Sort |
| Small Arrays | Optimal | Falls back to Arrays.sort() |
| Large Arrays | Slower | Significantly faster |
| API Compatibility | Same signature | Drop-in replacement |
The key insight is that parallelSort() is a drop-in replacement for Arrays.sort(). Same method signatures, same behavior, same result — just faster on large arrays with multi-core hardware.
import java.util.Arrays;
public class ParallelSortIntro {
public static void main(String[] args) {
int[] numbers = {9, 3, 7, 1, 5, 8, 2, 6, 4};
// Regular sort -- single-threaded
int[] copy1 = numbers.clone();
Arrays.sort(copy1);
System.out.println("Arrays.sort(): " + Arrays.toString(copy1));
// Output: Arrays.sort(): [1, 2, 3, 4, 5, 6, 7, 8, 9]
// Parallel sort -- multi-threaded (for large arrays)
int[] copy2 = numbers.clone();
Arrays.parallelSort(copy2);
System.out.println("Arrays.parallelSort(): " + Arrays.toString(copy2));
// Output: Arrays.parallelSort(): [1, 2, 3, 4, 5, 6, 7, 8, 9]
// Same result, different execution strategy
System.out.println("Available processors: " + Runtime.getRuntime().availableProcessors());
// Output: Available processors: 8 (varies by machine)
}
}
Arrays.parallelSort() works with all primitive array types and object arrays. The API mirrors Arrays.sort() exactly — you can swap one for the other with no other code changes.
parallelSort() is available for int[], long[], double[], float[], short[], byte[], and char[]. It sorts in natural ascending order.
import java.util.Arrays;
public class ParallelSortPrimitives {
public static void main(String[] args) {
// int array
int[] integers = {42, 17, 93, 5, 68, 31, 84, 12};
Arrays.parallelSort(integers);
System.out.println("int[]: " + Arrays.toString(integers));
// Output: int[]: [5, 12, 17, 31, 42, 68, 84, 93]
// double array
double[] doubles = {3.14, 1.41, 2.72, 0.58, 1.73};
Arrays.parallelSort(doubles);
System.out.println("double[]: " + Arrays.toString(doubles));
// Output: double[]: [0.58, 1.41, 1.73, 2.72, 3.14]
// long array
long[] longs = {100_000_000L, 50_000L, 999_999_999L, 1L};
Arrays.parallelSort(longs);
System.out.println("long[]: " + Arrays.toString(longs));
// Output: long[]: [1, 50000, 100000000, 999999999]
// char array
char[] chars = {'z', 'a', 'm', 'b', 'x'};
Arrays.parallelSort(chars);
System.out.println("char[]: " + Arrays.toString(chars));
// Output: char[]: [a, b, m, x, z]
// float array
float[] floats = {2.5f, 1.1f, 3.7f, 0.9f};
Arrays.parallelSort(floats);
System.out.println("float[]: " + Arrays.toString(floats));
// Output: float[]: [0.9, 1.1, 2.5, 3.7]
// short array
short[] shorts = {300, 100, 500, 200};
Arrays.parallelSort(shorts);
System.out.println("short[]: " + Arrays.toString(shorts));
// Output: short[]: [100, 200, 300, 500]
// byte array
byte[] bytes = {50, 10, 127, -128, 0};
Arrays.parallelSort(bytes);
System.out.println("byte[]: " + Arrays.toString(bytes));
// Output: byte[]: [-128, 0, 10, 50, 127]
}
}
String arrays are sorted in lexicographic (dictionary) order by default, since String implements Comparable<String>.
import java.util.Arrays;
public class ParallelSortStrings {
public static void main(String[] args) {
String[] languages = {"Python", "Java", "Rust", "Go", "TypeScript", "C++", "Kotlin"};
Arrays.parallelSort(languages);
System.out.println(Arrays.toString(languages));
// Output: [C++, Go, Java, Kotlin, Python, Rust, TypeScript]
// Note: uppercase letters come before lowercase in Unicode
String[] mixed = {"banana", "Apple", "cherry", "Blueberry"};
Arrays.parallelSort(mixed);
System.out.println(Arrays.toString(mixed));
// Output: [Apple, Blueberry, banana, cherry]
}
}
Any object array can be sorted with parallelSort() as long as the objects implement Comparable. If they do not, you must provide a Comparator (covered in section 4).
import java.util.Arrays;
public class ParallelSortObjects {
static class Student implements Comparable {
String name;
double gpa;
Student(String name, double gpa) {
this.name = name;
this.gpa = gpa;
}
@Override
public int compareTo(Student other) {
// Natural ordering: by GPA descending (highest first)
return Double.compare(other.gpa, this.gpa);
}
@Override
public String toString() {
return name + "(" + gpa + ")";
}
}
public static void main(String[] args) {
Student[] students = {
new Student("Alice", 3.8),
new Student("Bob", 3.5),
new Student("Charlie", 3.9),
new Student("Diana", 3.7)
};
Arrays.parallelSort(students);
System.out.println(Arrays.toString(students));
// Output: [Charlie(3.9), Alice(3.8), Diana(3.7), Bob(3.5)]
}
}
Understanding the internal mechanics of parallelSort() helps you decide when to use it and predict its behavior. The algorithm is a parallel merge sort built on top of Java’s Fork/Join framework.
The algorithm follows these steps:
Arrays.sort() — the overhead of parallelism is not worth it for small arrays.array.length / (availableProcessors << 3). On an 8-core machine with 100,000 elements, each chunk is about 1,562 elements.ForkJoinPool.commonPool().
Original Array: [9, 3, 7, 1, 5, 8, 2, 6, 4, 0]
FORK (split)
/ \
[9, 3, 7, 1, 5] [8, 2, 6, 4, 0]
/ \ / \
[9, 3, 7] [1, 5] [8, 2, 6] [4, 0]
SORT (each chunk)
[3, 7, 9] [1, 5] [2, 6, 8] [0, 4]
MERGE (join)
[1, 3, 5, 7, 9] [0, 2, 4, 6, 8]
\ /
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
| Threshold | Value | Purpose |
|---|---|---|
| MIN_ARRAY_SORT_GRAN | 8192 (0x2000) | Minimum sub-array size for parallel decomposition within the algorithm |
| Fallback threshold | 4096 or single core | Below this, parallelSort() delegates entirely to Arrays.sort() |
| Granularity formula | length / (processors << 3) | Determines how many chunks to create |
import java.util.Arrays;
import java.util.concurrent.ForkJoinPool;
public class ParallelSortInternals {
public static void main(String[] args) {
int processors = Runtime.getRuntime().availableProcessors();
System.out.println("Available processors: " + processors);
// Output: Available processors: 8 (varies)
// Show the common pool that parallelSort uses
ForkJoinPool commonPool = ForkJoinPool.commonPool();
System.out.println("Common pool parallelism: " + commonPool.getParallelism());
// Output: Common pool parallelism: 7 (processors - 1)
// Calculate granularity for different array sizes
int[] sizes = {1_000, 10_000, 100_000, 1_000_000};
for (int size : sizes) {
int granularity = size / (processors << 3);
int chunks = granularity > 0 ? size / granularity : 1;
System.out.printf("Array size: %,d -> granularity: %,d -> ~%d chunks%n",
size, granularity, chunks);
}
// Output (on 8-core machine):
// Array size: 1,000 -> granularity: 15 -> ~66 chunks
// Array size: 10,000 -> granularity: 156 -> ~64 chunks
// Array size: 100,000 -> granularity: 1,562 -> ~64 chunks
// Array size: 1,000,000 -> granularity: 15,625 -> ~64 chunks
// Demonstrate that small arrays use sequential sort internally
int[] small = new int[100];
Arrays.fill(small, 42);
long start = System.nanoTime();
Arrays.parallelSort(small); // Falls back to Arrays.sort()
long elapsed = System.nanoTime() - start;
System.out.println("Small array sort time: " + elapsed + " ns");
// Output: Small array sort time: ~1000 ns (negligible overhead)
}
}
For object arrays, parallelSort() accepts a Comparator to define custom ordering. Combined with Java 8’s lambda syntax and Comparator factory methods, this makes complex sorting logic concise and readable.
import java.util.Arrays;
import java.util.Comparator;
public class ParallelSortComparator {
public static void main(String[] args) {
String[] words = {"banana", "Apple", "cherry", "Blueberry", "avocado"};
// Default: Unicode order (uppercase before lowercase)
String[] copy1 = words.clone();
Arrays.parallelSort(copy1);
System.out.println("Default: " + Arrays.toString(copy1));
// Output: Default: [Apple, Blueberry, avocado, banana, cherry]
// Case-insensitive sort
String[] copy2 = words.clone();
Arrays.parallelSort(copy2, String.CASE_INSENSITIVE_ORDER);
System.out.println("Case-insensitive: " + Arrays.toString(copy2));
// Output: Case-insensitive: [Apple, avocado, banana, Blueberry, cherry]
// Reverse order
String[] copy3 = words.clone();
Arrays.parallelSort(copy3, Comparator.reverseOrder());
System.out.println("Reverse: " + Arrays.toString(copy3));
// Output: Reverse: [cherry, banana, avocado, Blueberry, Apple]
// Sort by length, then alphabetically
String[] copy4 = words.clone();
Arrays.parallelSort(copy4,
Comparator.comparingInt(String::length)
.thenComparing(Comparator.naturalOrder()));
System.out.println("By length then alpha: " + Arrays.toString(copy4));
// Output: By length then alpha: [Apple, banana, cherry, avocado, Blueberry]
}
}
Java 8’s Comparator.comparing() factory methods enable multi-level sorting with chained .thenComparing() calls. This is powerful with parallelSort() for sorting large datasets by multiple fields.
import java.util.Arrays;
import java.util.Comparator;
public class ParallelSortChainedComparator {
static class Employee {
String department;
String name;
double salary;
int yearsOfService;
Employee(String department, String name, double salary, int yearsOfService) {
this.department = department;
this.name = name;
this.salary = salary;
this.yearsOfService = yearsOfService;
}
@Override
public String toString() {
return String.format("%s/%s/$%.0f/%dy", department, name, salary, yearsOfService);
}
}
public static void main(String[] args) {
Employee[] employees = {
new Employee("Engineering", "Alice", 95000, 5),
new Employee("Marketing", "Bob", 75000, 3),
new Employee("Engineering", "Charlie", 105000, 8),
new Employee("Marketing", "Diana", 80000, 6),
new Employee("Engineering", "Eve", 95000, 2),
new Employee("Sales", "Frank", 70000, 4)
};
// Sort by department, then by salary descending, then by name
Arrays.parallelSort(employees,
Comparator.comparing((Employee e) -> e.department)
.thenComparing(e -> e.salary, Comparator.reverseOrder())
.thenComparing(e -> e.name));
System.out.println("Sorted by department -> salary desc -> name:");
for (Employee e : employees) {
System.out.println(" " + e);
}
// Output:
// Sorted by department -> salary desc -> name:
// Engineering/Charlie/$105000/8y
// Engineering/Alice/$95000/5y
// Engineering/Eve/$95000/2y
// Marketing/Diana/$80000/6y
// Marketing/Bob/$75000/3y
// Sales/Frank/$70000/4y
// Sort by years of service descending using method reference
Arrays.parallelSort(employees,
Comparator.comparingInt((Employee e) -> e.yearsOfService).reversed());
System.out.println("\nSorted by years of service descending:");
for (Employee e : employees) {
System.out.println(" " + e);
}
// Output:
// Sorted by years of service descending:
// Engineering/Charlie/$105000/8y
// Marketing/Diana/$80000/6y
// Engineering/Alice/$95000/5y
// Sales/Frank/$70000/4y
// Marketing/Bob/$75000/3y
// Engineering/Eve/$95000/2y
}
}
When arrays may contain null elements, use Comparator.nullsFirst() or Comparator.nullsLast() to avoid NullPointerException.
import java.util.Arrays;
import java.util.Comparator;
public class ParallelSortNullSafe {
public static void main(String[] args) {
String[] words = {"cherry", null, "apple", null, "banana"};
// nullsFirst -- nulls go to the beginning
String[] copy1 = words.clone();
Arrays.parallelSort(copy1, Comparator.nullsFirst(Comparator.naturalOrder()));
System.out.println("nullsFirst: " + Arrays.toString(copy1));
// Output: nullsFirst: [null, null, apple, banana, cherry]
// nullsLast -- nulls go to the end
String[] copy2 = words.clone();
Arrays.parallelSort(copy2, Comparator.nullsLast(Comparator.naturalOrder()));
System.out.println("nullsLast: " + Arrays.toString(copy2));
// Output: nullsLast: [apple, banana, cherry, null, null]
}
}
parallelSort() supports sorting a sub-range of an array using parallelSort(array, fromIndex, toIndex). The range is inclusive of fromIndex and exclusive of toIndex (the standard Java convention). Only the elements in the specified range are reordered; elements outside the range remain untouched.
import java.util.Arrays;
public class ParallelSortRange {
public static void main(String[] args) {
int[] numbers = {9, 3, 7, 1, 5, 8, 2, 6, 4, 0};
System.out.println("Original: " + Arrays.toString(numbers));
// Output: Original: [9, 3, 7, 1, 5, 8, 2, 6, 4, 0]
// Sort only indices 2-7 (elements: 7, 1, 5, 8, 2, 6)
Arrays.parallelSort(numbers, 2, 8);
System.out.println("Range [2,8): " + Arrays.toString(numbers));
// Output: Range [2,8): [9, 3, 1, 2, 5, 6, 7, 8, 4, 0]
// ^ ^ sorted portion ^ ^
// untouched untouched
// Sort the first 5 elements
int[] data = {50, 30, 10, 40, 20, 99, 88, 77};
Arrays.parallelSort(data, 0, 5);
System.out.println("First 5: " + Arrays.toString(data));
// Output: First 5: [10, 20, 30, 40, 50, 99, 88, 77]
// Sort the last 3 elements
int[] data2 = {50, 30, 10, 40, 20, 99, 88, 77};
Arrays.parallelSort(data2, 5, 8);
System.out.println("Last 3: " + Arrays.toString(data2));
// Output: Last 3: [50, 30, 10, 40, 20, 77, 88, 99]
// String range sort
String[] names = {"Eve", "Alice", "Diana", "Bob", "Charlie"};
Arrays.parallelSort(names, 1, 4); // Sort indices 1-3
System.out.println("Names [1,4): " + Arrays.toString(names));
// Output: Names [1,4): [Eve, Alice, Bob, Diana, Charlie]
}
}
You can combine range sorting with a comparator for object arrays:
import java.util.Arrays;
import java.util.Comparator;
public class ParallelSortRangeComparator {
public static void main(String[] args) {
Integer[] numbers = {90, 30, 70, 10, 50, 80, 20, 60};
// Sort indices 2-6 in descending order
Arrays.parallelSort(numbers, 2, 6, Comparator.reverseOrder());
System.out.println(Arrays.toString(numbers));
// Output: [90, 30, 80, 70, 50, 10, 20, 60]
// ^ ^ sorted desc ^ ^
// untouched untouched
// Invalid range throws ArrayIndexOutOfBoundsException
try {
Arrays.parallelSort(numbers, -1, 5);
} catch (ArrayIndexOutOfBoundsException e) {
System.out.println("Error: " + e.getMessage());
// Output: Error: Array index out of range: -1
}
// fromIndex > toIndex throws IllegalArgumentException
try {
Arrays.parallelSort(numbers, 5, 2);
} catch (IllegalArgumentException e) {
System.out.println("Error: " + e.getMessage());
// Output: Error: fromIndex(5) > toIndex(2)
}
}
}
The big question: when should you use parallelSort() instead of sort()? The answer depends on array size, CPU cores, and what else your application is doing.
| Array Size | Arrays.sort() Time | Arrays.parallelSort() Time | Winner |
|---|---|---|---|
| 100 | ~1 us | ~1 us (falls back) | Tie (same code path) |
| 1,000 | ~50 us | ~60 us | sort() — overhead not justified |
| 10,000 | ~500 us | ~300 us | parallelSort() — starting to help |
| 100,000 | ~6 ms | ~2 ms | parallelSort() — 3x faster |
| 1,000,000 | ~80 ms | ~20 ms | parallelSort() — 4x faster |
| 10,000,000 | ~900 ms | ~200 ms | parallelSort() — 4-5x faster |
Note: Times are approximate and vary by hardware. Measured on an 8-core CPU with random int data.
import java.util.Arrays;
import java.util.Random;
public class SortBenchmark {
public static void main(String[] args) {
int[] sizes = {1_000, 10_000, 100_000, 1_000_000, 10_000_000};
Random random = new Random(42);
System.out.printf("%-12s %-14s %-14s %-10s%n",
"Array Size", "sort() (ms)", "parallelSort()", "Speedup");
System.out.println("-".repeat(58));
for (int size : sizes) {
// Generate random data
int[] data = random.ints(size, 0, size * 10).toArray();
// Warm up (JIT compilation)
for (int i = 0; i < 3; i++) {
Arrays.sort(data.clone());
Arrays.parallelSort(data.clone());
}
// Benchmark Arrays.sort()
long sortTotal = 0;
int runs = 5;
for (int i = 0; i < runs; i++) {
int[] copy = data.clone();
long start = System.nanoTime();
Arrays.sort(copy);
sortTotal += System.nanoTime() - start;
}
double sortMs = sortTotal / runs / 1_000_000.0;
// Benchmark Arrays.parallelSort()
long parallelTotal = 0;
for (int i = 0; i < runs; i++) {
int[] copy = data.clone();
long start = System.nanoTime();
Arrays.parallelSort(copy);
parallelTotal += System.nanoTime() - start;
}
double parallelMs = parallelTotal / runs / 1_000_000.0;
double speedup = sortMs / parallelMs;
System.out.printf("%,12d %10.2f ms %10.2f ms %8.2fx%n",
size, sortMs, parallelMs, speedup);
}
// Sample Output (8-core machine):
// Array Size sort() (ms) parallelSort() Speedup
// ----------------------------------------------------------
// 1,000 0.05 ms 0.06 ms 0.83x
// 10,000 0.52 ms 0.31 ms 1.68x
// 100,000 6.12 ms 1.89 ms 3.24x
// 1,000,000 78.45 ms 19.67 ms 3.99x
// 10,000,000 912.33 ms 198.56 ms 4.59x
}
}
Arrays.parallelPrefix() is a related parallel array method introduced in Java 8. It performs cumulative operations on an array in-place, using a given binary operator. Each element at index i is replaced with the result of applying the operator to all elements from index 0 through i.
Common use cases include running sums, running products, and running maximums.
import java.util.Arrays;
public class ParallelPrefixDemo {
public static void main(String[] args) {
// ===== Running Sum =====
int[] numbers = {1, 2, 3, 4, 5};
System.out.println("Original: " + Arrays.toString(numbers));
// Output: Original: [1, 2, 3, 4, 5]
Arrays.parallelPrefix(numbers, Integer::sum);
System.out.println("Running sum: " + Arrays.toString(numbers));
// Output: Running sum: [1, 3, 6, 10, 15]
// Explanation: [1, 1+2, 1+2+3, 1+2+3+4, 1+2+3+4+5]
// ===== Running Product =====
int[] factors = {1, 2, 3, 4, 5};
Arrays.parallelPrefix(factors, (a, b) -> a * b);
System.out.println("Running product: " + Arrays.toString(factors));
// Output: Running product: [1, 2, 6, 24, 120]
// These are factorials: 1!, 2!, 3!, 4!, 5!
// ===== Running Maximum =====
int[] values = {3, 1, 4, 1, 5, 9, 2, 6};
Arrays.parallelPrefix(values, Integer::max);
System.out.println("Running max: " + Arrays.toString(values));
// Output: Running max: [3, 3, 4, 4, 5, 9, 9, 9]
// ===== Running Minimum =====
int[] temps = {72, 68, 75, 65, 70, 63, 71};
Arrays.parallelPrefix(temps, Integer::min);
System.out.println("Running min: " + Arrays.toString(temps));
// Output: Running min: [72, 68, 68, 65, 65, 63, 63]
// ===== With double arrays =====
double[] prices = {10.0, 20.0, 30.0, 40.0};
Arrays.parallelPrefix(prices, Double::sum);
System.out.println("Running total: " + Arrays.toString(prices));
// Output: Running total: [10.0, 30.0, 60.0, 100.0]
// ===== Range prefix (partial) =====
int[] data = {5, 3, 8, 2, 7, 1, 9, 4};
System.out.println("Before range prefix: " + Arrays.toString(data));
Arrays.parallelPrefix(data, 2, 6, Integer::sum); // indices 2-5
System.out.println("After range [2,6): " + Arrays.toString(data));
// Output: Before range prefix: [5, 3, 8, 2, 7, 1, 9, 4]
// Output: After range [2,6): [5, 3, 8, 10, 17, 18, 9, 4]
}
}
import java.util.Arrays;
public class CumulativeRevenue {
public static void main(String[] args) {
String[] months = {"Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec"};
double[] monthlyRevenue = {
12_500, 15_300, 18_200, 14_800, 22_100, 19_500,
25_000, 23_400, 20_100, 28_300, 31_500, 35_200
};
// Keep a copy of original monthly values
double[] monthly = monthlyRevenue.clone();
// Calculate cumulative (year-to-date) revenue
Arrays.parallelPrefix(monthlyRevenue, Double::sum);
System.out.println("Month Monthly YTD");
System.out.println("-".repeat(35));
for (int i = 0; i < months.length; i++) {
System.out.printf("%-5s $%,10.0f $%,10.0f%n",
months[i], monthly[i], monthlyRevenue[i]);
}
// Output:
// Month Monthly YTD
// -----------------------------------
// Jan $ 12,500 $ 12,500
// Feb $ 15,300 $ 27,800
// Mar $ 18,200 $ 46,000
// Apr $ 14,800 $ 60,800
// May $ 22,100 $ 82,900
// Jun $ 19,500 $ 102,400
// Jul $ 25,000 $ 127,400
// Aug $ 23,400 $ 150,800
// Sep $ 20,100 $ 170,900
// Oct $ 28,300 $ 199,200
// Nov $ 31,500 $ 230,700
// Dec $ 35,200 $ 265,900
}
}
Arrays.parallelSetAll() fills an array by computing each element's value from its index, using a generator function. It runs in parallel, making it efficient for initializing large arrays with computed values.
The method signature is:
Arrays.parallelSetAll(array, index -> computeValue(index));
There is also a sequential version, Arrays.setAll(), which does the same thing on a single thread.
import java.util.Arrays;
public class ParallelSetAllDemo {
public static void main(String[] args) {
// ===== Fill with squares =====
int[] squares = new int[10];
Arrays.parallelSetAll(squares, i -> i * i);
System.out.println("Squares: " + Arrays.toString(squares));
// Output: Squares: [0, 1, 4, 9, 16, 25, 36, 49, 64, 81]
// ===== Fill with Fibonacci-like pattern =====
long[] fib = new long[15];
fib[0] = 0;
fib[1] = 1;
// Note: parallelSetAll isn't ideal for dependent computations
// Use setAll (sequential) instead for dependencies
Arrays.setAll(fib, i -> i < 2 ? fib[i] : fib[i - 1] + fib[i - 2]);
System.out.println("Fibonacci: " + Arrays.toString(fib));
// Output: Fibonacci: [0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 233, 377]
// ===== Initialize multiplication table =====
int size = 5;
int[][] table = new int[size][size];
Arrays.parallelSetAll(table, i -> {
int[] row = new int[size];
Arrays.parallelSetAll(row, j -> (i + 1) * (j + 1));
return row;
});
System.out.println("Multiplication table:");
for (int[] row : table) {
System.out.println(" " + Arrays.toString(row));
}
// Output:
// Multiplication table:
// [1, 2, 3, 4, 5]
// [2, 4, 6, 8, 10]
// [3, 6, 9, 12, 15]
// [4, 8, 12, 16, 20]
// [5, 10, 15, 20, 25]
// ===== Generate test data (large array) =====
double[] sineWave = new double[1_000_000];
Arrays.parallelSetAll(sineWave, i -> Math.sin(2 * Math.PI * i / 1000.0));
System.out.printf("Sine wave: first=%.4f, middle=%.4f, last=%.4f%n",
sineWave[0], sineWave[500_000], sineWave[999_999]);
// Output: Sine wave: first=0.0000, middle=0.0000, last=-0.0063
// ===== Comparison: setAll vs parallelSetAll =====
int bigSize = 10_000_000;
double[] arr1 = new double[bigSize];
double[] arr2 = new double[bigSize];
long start1 = System.nanoTime();
Arrays.setAll(arr1, i -> Math.sqrt(i) * Math.log(i + 1));
long time1 = System.nanoTime() - start1;
long start2 = System.nanoTime();
Arrays.parallelSetAll(arr2, i -> Math.sqrt(i) * Math.log(i + 1));
long time2 = System.nanoTime() - start2;
System.out.printf("setAll: %d ms%n", time1 / 1_000_000);
System.out.printf("parallelSetAll: %d ms%n", time2 / 1_000_000);
System.out.printf("Speedup: %.2fx%n", (double) time1 / time2);
// Sample Output:
// setAll: 185 ms
// parallelSetAll: 52 ms
// Speedup: 3.56x
}
}
Let's measure the actual performance difference between sort() and parallelSort() across different array sizes and data patterns. This section provides empirical data to guide your decision.
The crossover point where parallelSort() becomes faster than sort() is typically around 5,000-10,000 elements, depending on hardware.
import java.util.Arrays;
import java.util.Random;
public class DetailedBenchmark {
private static final Random RANDOM = new Random(42);
public static void main(String[] args) {
System.out.println("=== Array Size Impact (random int data) ===");
System.out.println("Cores: " + Runtime.getRuntime().availableProcessors());
System.out.println();
int[] sizes = {100, 500, 1_000, 5_000, 10_000, 50_000,
100_000, 500_000, 1_000_000, 5_000_000};
System.out.printf("%-12s %-10s %-10s %-8s %-10s%n",
"Size", "sort()", "parallel()", "Speedup", "Verdict");
System.out.println("-".repeat(58));
for (int size : sizes) {
int[] data = RANDOM.ints(size).toArray();
// Warm up
for (int i = 0; i < 5; i++) {
Arrays.sort(data.clone());
Arrays.parallelSort(data.clone());
}
// Benchmark
double sortMs = benchmark(() -> Arrays.sort(data.clone()), 10);
double parallelMs = benchmark(() -> Arrays.parallelSort(data.clone()), 10);
double speedup = sortMs / parallelMs;
String verdict = speedup > 1.2 ? "PARALLEL" :
speedup < 0.8 ? "SORT" : "TIE";
System.out.printf("%,12d %7.2f ms %7.2f ms %6.2fx %-10s%n",
size, sortMs, parallelMs, speedup, verdict);
}
}
private static double benchmark(Runnable task, int iterations) {
long total = 0;
for (int i = 0; i < iterations; i++) {
long start = System.nanoTime();
task.run();
total += System.nanoTime() - start;
}
return total / iterations / 1_000_000.0;
}
// Sample Output (8-core machine):
// === Array Size Impact (random int data) ===
// Cores: 8
//
// Size sort() parallel() Speedup Verdict
// ----------------------------------------------------------
// 100 0.00 ms 0.00 ms 1.00x TIE
// 500 0.02 ms 0.02 ms 1.00x TIE
// 1,000 0.04 ms 0.05 ms 0.80x TIE
// 5,000 0.25 ms 0.18 ms 1.39x PARALLEL
// 10,000 0.55 ms 0.30 ms 1.83x PARALLEL
// 50,000 3.10 ms 1.12 ms 2.77x PARALLEL
// 100,000 6.50 ms 2.05 ms 3.17x PARALLEL
// 500,000 37.20 ms 10.30 ms 3.61x PARALLEL
// 1,000,000 78.00 ms 20.50 ms 3.80x PARALLEL
// 5,000,000 420.00 ms 98.00 ms 4.29x PARALLEL
}
The initial ordering of data affects how much benefit you get from parallel sorting:
| Data Pattern | sort() Behavior | parallelSort() Behavior | Winner |
|---|---|---|---|
| Random | Full work | Full parallel work | parallelSort() for large arrays |
| Already sorted | Very fast (TimSort optimized) | Still splits and merges | sort() -- adaptive wins |
| Reverse sorted | Moderate (TimSort handles) | Full parallel work | parallelSort() for large arrays |
| Nearly sorted | Very fast (few corrections) | Still splits and merges | sort() -- fewer operations |
| Many duplicates | Moderate | Parallel helps | parallelSort() for large arrays |
import java.util.Arrays;
import java.util.Random;
public class DataPatternBenchmark {
private static final int SIZE = 1_000_000;
private static final Random RANDOM = new Random(42);
public static void main(String[] args) {
System.out.println("=== Data Pattern Impact (1,000,000 elements) ===\n");
// Random data
int[] random = RANDOM.ints(SIZE).toArray();
benchmarkPattern("Random", random);
// Already sorted
int[] sorted = random.clone();
Arrays.sort(sorted);
benchmarkPattern("Already sorted", sorted);
// Reverse sorted
int[] reversed = new int[SIZE];
for (int i = 0; i < SIZE; i++) reversed[i] = SIZE - i;
benchmarkPattern("Reverse sorted", reversed);
// Nearly sorted (5% elements displaced)
int[] nearlySorted = sorted.clone();
for (int i = 0; i < SIZE / 20; i++) {
int a = RANDOM.nextInt(SIZE);
int b = RANDOM.nextInt(SIZE);
int temp = nearlySorted[a];
nearlySorted[a] = nearlySorted[b];
nearlySorted[b] = temp;
}
benchmarkPattern("Nearly sorted", nearlySorted);
// All same value
int[] allSame = new int[SIZE];
Arrays.fill(allSame, 42);
benchmarkPattern("All same value", allSame);
}
private static void benchmarkPattern(String name, int[] data) {
// Warm up
for (int i = 0; i < 3; i++) {
Arrays.sort(data.clone());
Arrays.parallelSort(data.clone());
}
long sortTotal = 0, parallelTotal = 0;
int runs = 5;
for (int i = 0; i < runs; i++) {
long start = System.nanoTime();
Arrays.sort(data.clone());
sortTotal += System.nanoTime() - start;
start = System.nanoTime();
Arrays.parallelSort(data.clone());
parallelTotal += System.nanoTime() - start;
}
double sortMs = sortTotal / runs / 1_000_000.0;
double parallelMs = parallelTotal / runs / 1_000_000.0;
System.out.printf("%-16s sort: %7.1f ms parallel: %7.1f ms speedup: %.2fx%n",
name, sortMs, parallelMs, sortMs / parallelMs);
}
// Sample Output:
// === Data Pattern Impact (1,000,000 elements) ===
//
// Random sort: 78.5 ms parallel: 20.1 ms speedup: 3.90x
// Already sorted sort: 5.2 ms parallel: 8.3 ms speedup: 0.63x
// Reverse sorted sort: 7.1 ms parallel: 12.5 ms speedup: 0.57x
// Nearly sorted sort: 12.3 ms parallel: 14.8 ms speedup: 0.83x
// All same value sort: 3.1 ms parallel: 6.7 ms speedup: 0.46x
}
parallelSort() uses the ForkJoinPool.commonPool() to execute its parallel tasks. This has important implications for thread safety and resource sharing.
The common pool is a JVM-wide shared thread pool. All parallel operations that do not specify a custom pool -- including parallelSort(), parallel streams, and CompletableFuture.supplyAsync() -- compete for the same threads.
| Property | Detail |
|---|---|
| Default parallelism | Runtime.getRuntime().availableProcessors() - 1 |
| Shared by | parallelSort, parallel streams, CompletableFuture (default) |
| Override | -Djava.util.concurrent.ForkJoinPool.common.parallelism=N |
| Thread type | Daemon threads (do not prevent JVM shutdown) |
Your Comparator must be thread-safe because parallelSort() invokes it from multiple threads simultaneously. This means:
compare(a, b) must always return the same value for the same inputsa > b and b > c, then a > cimport java.util.Arrays;
import java.util.Comparator;
public class ThreadSafetyConsiderations {
// ===== BAD: Stateful comparator with shared mutable state =====
static int comparisonCount = 0; // NOT thread-safe!
static Comparator badComparator = (a, b) -> {
comparisonCount++; // Race condition! Multiple threads modify this
return Integer.compare(a, b);
};
// ===== GOOD: Stateless comparator =====
static Comparator goodComparator = Integer::compare;
// ===== GOOD: Thread-safe counting with AtomicInteger =====
static final java.util.concurrent.atomic.AtomicInteger safeCount =
new java.util.concurrent.atomic.AtomicInteger(0);
static Comparator safeCountingComparator = (a, b) -> {
safeCount.incrementAndGet(); // Thread-safe
return Integer.compare(a, b);
};
public static void main(String[] args) {
Integer[] data = new Integer[100_000];
Arrays.parallelSetAll(data, i -> (int)(Math.random() * 100_000));
// Using the safe comparator
safeCount.set(0);
Arrays.parallelSort(data.clone(), safeCountingComparator);
System.out.println("Comparisons made: " + safeCount.get());
// Output: Comparisons made: ~1,600,000 (varies)
// Comparable contract: compareTo must be consistent with equals
System.out.println("\n--- Comparable Contract ---");
String[] words = {"apple", "APPLE", "Apple"};
Arrays.parallelSort(words); // Uses String.compareTo (case-sensitive)
System.out.println("Case-sensitive: " + Arrays.toString(words));
// Output: Case-sensitive: [APPLE, Apple, apple]
Arrays.parallelSort(words, String.CASE_INSENSITIVE_ORDER);
System.out.println("Case-insensitive: " + Arrays.toString(words));
// Output: Case-insensitive: [APPLE, Apple, apple]
}
}
Even though parallelSort() is a drop-in replacement for sort(), there are pitfalls that can negate its benefits or introduce bugs.
For arrays under ~5,000 elements, parallelSort() has no advantage. It internally falls back to Arrays.sort() for very small arrays, but there is still minor overhead from the method dispatch. Do not optimize prematurely.
Because parallelSort() calls your comparator from multiple threads, any side effects create race conditions:
import java.util.Arrays;
import java.util.Comparator;
import java.util.ArrayList;
import java.util.List;
public class CommonMistakes {
public static void main(String[] args) {
// ===== Mistake: Collecting during comparison =====
List log = new ArrayList<>(); // NOT thread-safe!
String[] names = {"Charlie", "Alice", "Bob", "Diana", "Eve"};
// BAD: Writing to shared ArrayList from comparator
// Arrays.parallelSort(names, (a, b) -> {
// log.add(a + " vs " + b); // ConcurrentModificationException possible!
// return a.compareTo(b);
// });
// GOOD: Use a thread-safe collection if you must log
List safeLog = java.util.Collections.synchronizedList(new ArrayList<>());
Arrays.parallelSort(names, (a, b) -> {
safeLog.add(a + " vs " + b);
return a.compareTo(b);
});
System.out.println("Comparisons: " + safeLog.size());
// Output: Comparisons: 7 (for 5-element array)
// ===== Mistake: Non-Comparable objects without Comparator =====
// This class does NOT implement Comparable
class Product {
String name;
double price;
Product(String n, double p) { name = n; price = p; }
}
Product[] products = {
new Product("Widget", 9.99),
new Product("Gadget", 24.99),
new Product("Doohickey", 4.99)
};
// BAD: ClassCastException at runtime
// Arrays.parallelSort(products);
// GOOD: Provide a Comparator
Arrays.parallelSort(products, Comparator.comparingDouble(p -> p.price));
for (Product p : products) {
System.out.println(p.name + ": $" + p.price);
}
// Output:
// Doohickey: $4.99
// Widget: $9.99
// Gadget: $24.99
// ===== Mistake: Inconsistent Comparator =====
// A comparator that violates the contract can cause unpredictable results
Integer[] nums = {3, 1, 4, 1, 5, 9, 2, 6};
// BAD: Not transitive (random results each time)
// Comparator broken = (a, b) -> (int)(Math.random() * 3) - 1;
// Arrays.parallelSort(nums, broken); // IllegalArgumentException possible!
// GOOD: Consistent comparator
Arrays.parallelSort(nums, Integer::compare);
System.out.println("Sorted: " + Arrays.toString(nums));
// Output: Sorted: [1, 1, 2, 3, 4, 5, 6, 9]
}
}
Like Arrays.sort(), parallelSort() sorts the array in place. If you need to preserve the original order, clone the array first.
import java.util.Arrays;
public class InPlaceMistake {
public static void main(String[] args) {
int[] original = {5, 3, 8, 1, 9, 2};
// BAD: Original array is modified
// Arrays.parallelSort(original);
// System.out.println(Arrays.toString(original)); // [1, 2, 3, 5, 8, 9] -- original gone!
// GOOD: Sort a copy
int[] sorted = original.clone();
Arrays.parallelSort(sorted);
System.out.println("Original: " + Arrays.toString(original));
System.out.println("Sorted: " + Arrays.toString(sorted));
// Output:
// Original: [5, 3, 8, 1, 9, 2]
// Sorted: [1, 2, 3, 5, 8, 9]
}
}
If your application already uses parallel streams or CompletableFuture heavily, adding parallelSort() creates contention for the shared common pool. In CPU-bound applications, this can actually slow things down.
Arrays.parallelSort() for object arrays is a stable sort (equal elements maintain their relative order). However, for primitive arrays, stability is meaningless since there is no concept of "identity" for equal primitive values. Be aware that the object version guarantees stability, which matters when sorting by one field after another.
Follow these guidelines to get the most out of Java 8's parallel array operations.
| # | Do | Don't |
|---|---|---|
| 1 | Use parallelSort() for arrays with 10,000+ elements |
Don't use it for small arrays -- overhead exceeds benefit |
| 2 | Keep comparators stateless and side-effect-free | Don't write to shared mutable state in comparators |
| 3 | Clone the array if you need the original order preserved | Don't forget that parallelSort modifies in-place |
| 4 | Use Comparator.comparing() chains for multi-field sorting |
Don't write manual comparison logic with subtraction |
| 5 | Benchmark with your actual data before switching | Don't assume parallel is always faster |
| 6 | Use parallelSetAll() to initialize large arrays |
Don't use it for arrays with element dependencies |
| 7 | Use parallelPrefix() for cumulative operations |
Don't use non-associative operators with parallelPrefix |
| 8 | Consider the common pool's capacity before using parallel ops | Don't combine many parallel operations without considering pool contention |
| 9 | Use Comparator.nullsFirst()/nullsLast() for nullable arrays |
Don't let NullPointerException crash your comparator |
| 10 | Profile on the target deployment hardware | Don't benchmark on a dev laptop and assume production results |
Is the array size > 10,000?
├── NO --> Use Arrays.sort()
└── YES --> Is the data mostly sorted?
├── YES --> Use Arrays.sort() (adaptive algorithms win)
└── NO --> Is your app CPU-bound with heavy parallel workloads?
├── YES --> Benchmark both; common pool contention may hurt
└── NO --> Use Arrays.parallelSort()
This example simulates a real-world scenario: sorting a large dataset of sales transactions and computing analytics. It demonstrates parallelSort(), parallelSetAll(), parallelPrefix(), range sorting, and custom comparators -- all benchmarked against their sequential counterparts.
import java.util.Arrays;
import java.util.Comparator;
import java.util.Random;
public class SalesAnalytics {
static class Transaction implements Comparable {
final int id;
final String region;
final String product;
final double amount;
final long timestamp;
Transaction(int id, String region, String product, double amount, long timestamp) {
this.id = id;
this.region = region;
this.product = product;
this.amount = amount;
this.timestamp = timestamp;
}
@Override
public int compareTo(Transaction other) {
return Double.compare(this.amount, other.amount);
}
@Override
public String toString() {
return String.format("TX#%d[%s/%s/$%.2f]", id, region, product, amount);
}
}
private static final String[] REGIONS = {"North", "South", "East", "West", "Central"};
private static final String[] PRODUCTS = {"Widget", "Gadget", "Doohickey", "Thingamajig", "Whatchamacallit"};
public static void main(String[] args) {
int datasetSize = 500_000;
Random random = new Random(42);
// ===== 1. Generate large dataset with parallelSetAll =====
System.out.println("=== Sales Analytics System ===");
System.out.println("Dataset size: " + String.format("%,d", datasetSize) + " transactions\n");
Transaction[] transactions = new Transaction[datasetSize];
long genStart = System.nanoTime();
Arrays.parallelSetAll(transactions, i -> new Transaction(
i + 1,
REGIONS[Math.abs(hash(i, 1)) % REGIONS.length],
PRODUCTS[Math.abs(hash(i, 2)) % PRODUCTS.length],
10.0 + (hash(i, 3) & 0x7FFFFFFF) % 99000 / 100.0,
System.currentTimeMillis() - (long)(Math.abs(hash(i, 4)) % 86_400_000)
));
long genTime = System.nanoTime() - genStart;
System.out.printf("Data generation (parallelSetAll): %d ms%n%n", genTime / 1_000_000);
// ===== 2. Sort by amount -- sequential vs parallel =====
Transaction[] seqCopy = transactions.clone();
long seqStart = System.nanoTime();
Arrays.sort(seqCopy);
long seqTime = System.nanoTime() - seqStart;
Transaction[] parCopy = transactions.clone();
long parStart = System.nanoTime();
Arrays.parallelSort(parCopy);
long parTime = System.nanoTime() - parStart;
System.out.println("--- Sort by Amount (natural order) ---");
System.out.printf("Sequential sort: %d ms%n", seqTime / 1_000_000);
System.out.printf("Parallel sort: %d ms%n", parTime / 1_000_000);
System.out.printf("Speedup: %.2fx%n%n", (double) seqTime / parTime);
// ===== 3. Multi-field sort with Comparator chains =====
Transaction[] multiSort = transactions.clone();
Comparator multiComparator =
Comparator.comparing((Transaction t) -> t.region)
.thenComparing(t -> t.product)
.thenComparing(Comparator.comparingDouble((Transaction t) -> t.amount).reversed());
long multiStart = System.nanoTime();
Arrays.parallelSort(multiSort, multiComparator);
long multiTime = System.nanoTime() - multiStart;
System.out.println("--- Multi-field Sort (region -> product -> amount desc) ---");
System.out.printf("Parallel sort time: %d ms%n", multiTime / 1_000_000);
System.out.println("First 5:");
for (int i = 0; i < 5; i++) System.out.println(" " + multiSort[i]);
System.out.println("Last 5:");
for (int i = datasetSize - 5; i < datasetSize; i++) System.out.println(" " + multiSort[i]);
System.out.println();
// ===== 4. Extract amounts and compute running total with parallelPrefix =====
double[] amounts = new double[datasetSize];
Arrays.parallelSetAll(amounts, i -> parCopy[i].amount); // sorted amounts
double[] cumulative = amounts.clone();
long prefixStart = System.nanoTime();
Arrays.parallelPrefix(cumulative, Double::sum);
long prefixTime = System.nanoTime() - prefixStart;
System.out.println("--- Cumulative Revenue (parallelPrefix) ---");
System.out.printf("parallelPrefix time: %d ms%n", prefixTime / 1_000_000);
System.out.printf("Total revenue: $%,.2f%n", cumulative[cumulative.length - 1]);
System.out.printf("Median transaction: $%,.2f%n", amounts[amounts.length / 2]);
System.out.printf("Lowest: $%,.2f | Highest: $%,.2f%n%n", amounts[0], amounts[amounts.length - 1]);
// ===== 5. Top 10 transactions (range sort) =====
Transaction[] topTen = transactions.clone();
// Sort only the last 10 positions in descending order
Arrays.parallelSort(topTen, Comparator.comparingDouble((Transaction t) -> t.amount).reversed());
System.out.println("--- Top 10 Transactions ---");
for (int i = 0; i < 10; i++) {
System.out.printf(" #%d: %s%n", i + 1, topTen[i]);
}
// ===== Summary =====
System.out.println("\n--- Performance Summary ---");
System.out.println("Operation Time");
System.out.println("-".repeat(50));
System.out.printf("Data generation (parallelSetAll) %d ms%n", genTime / 1_000_000);
System.out.printf("Sequential sort %d ms%n", seqTime / 1_000_000);
System.out.printf("Parallel sort %d ms%n", parTime / 1_000_000);
System.out.printf("Multi-field parallel sort %d ms%n", multiTime / 1_000_000);
System.out.printf("Cumulative sum (parallelPrefix) %d ms%n", prefixTime / 1_000_000);
}
// Simple deterministic hash for reproducible test data
private static int hash(int value, int seed) {
int h = value * 0x9E3779B9 + seed;
h ^= h >>> 16;
h *= 0x85EBCA6B;
h ^= h >>> 13;
return h;
}
// Sample Output (8-core machine):
// === Sales Analytics System ===
// Dataset size: 500,000 transactions
//
// Data generation (parallelSetAll): 45 ms
//
// --- Sort by Amount (natural order) ---
// Sequential sort: 312 ms
// Parallel sort: 89 ms
// Speedup: 3.51x
//
// --- Multi-field Sort (region -> product -> amount desc) ---
// Parallel sort time: 156 ms
// First 5:
// TX#482312[Central/Doohickey/$999.90]
// TX#311204[Central/Doohickey/$998.50]
// TX#91826[Central/Doohickey/$997.30]
// TX#228445[Central/Doohickey/$996.80]
// TX#405632[Central/Doohickey/$995.20]
// Last 5:
// TX#174523[West/Widget/$12.40]
// TX#389201[West/Widget/$11.90]
// TX#23456[West/Widget/$11.20]
// TX#256789[West/Widget/$10.80]
// TX#445123[West/Widget/$10.10]
//
// --- Cumulative Revenue (parallelPrefix) ---
// parallelPrefix time: 12 ms
// Total revenue: $249,875,312.50
// Median transaction: $505.00
// Lowest: $10.00 | Highest: $1,000.00
//
// --- Top 10 Transactions ---
// #1: TX#142857[North/Gadget/$999.99]
// #2: TX#482312[Central/Doohickey/$999.90]
// ...
//
// --- Performance Summary ---
// Operation Time
// --------------------------------------------------
// Data generation (parallelSetAll) 45 ms
// Sequential sort 312 ms
// Parallel sort 89 ms
// Multi-field parallel sort 156 ms
// Cumulative sum (parallelPrefix) 12 ms
}
All parallel array methods introduced in Java 8, at a glance:
| Method | Description | Supported Types |
|---|---|---|
parallelSort(T[]) |
Sort entire array in parallel (natural order) | All primitives + Comparable objects |
parallelSort(T[], Comparator) |
Sort entire array with custom comparator | Object arrays |
parallelSort(T[], from, to) |
Sort range [from, to) in parallel | All primitives + Comparable objects |
parallelSort(T[], from, to, Comparator) |
Sort range with custom comparator | Object arrays |
parallelPrefix(T[], BinaryOperator) |
Cumulative operation across entire array | int[], long[], double[], T[] |
parallelPrefix(T[], from, to, BinaryOperator) |
Cumulative operation on range [from, to) | int[], long[], double[], T[] |
parallelSetAll(T[], IntFunction) |
Set each element using index-based generator | int[], long[], double[], T[] |
setAll(T[], IntFunction) |
Sequential version of parallelSetAll | int[], long[], double[], T[] |
| Scenario | Recommended Method | Why |
|---|---|---|
| Small array (< 5,000 elements) | Arrays.sort() |
No parallel benefit, avoid overhead |
| Large array (> 10,000 elements, random) | Arrays.parallelSort() |
3-5x faster on multi-core |
| Already-sorted or nearly-sorted data | Arrays.sort() |
TimSort's adaptive algorithm excels |
| Initialize large array with computed values | Arrays.parallelSetAll() |
Parallel computation per index |
| Running sum/max/min across array | Arrays.parallelPrefix() |
Parallel cumulative operation |
| CPU-bound app with heavy ForkJoin usage | Arrays.sort() |
Avoid common pool contention |
| Sorting with nullable elements | parallelSort(arr, nullsFirst(...)) |
Null-safe with Comparator factory |
Key takeaway: Arrays.parallelSort() is a powerful tool in the Java 8 toolbox, but it is not a silver bullet. Use it for large arrays with random data on multi-core hardware, and always benchmark with your actual dataset. For small arrays or already-sorted data, stick with Arrays.sort().
Before Java 8, working with dates and times in Java was painful. The original java.util.Date and java.util.Calendar classes were introduced in JDK 1.0 and 1.1 respectively, and they were riddled with design flaws that caused bugs in virtually every codebase.
| Problem | Description | Example |
|---|---|---|
| Mutable | Date objects can be modified after creation. If you pass a Date to a method, that method can change your date. | date.setTime(0) silently changes the date everywhere it is referenced |
| Not thread-safe | SimpleDateFormat is not thread-safe. Using it from multiple threads causes corrupted output or exceptions. | Shared SimpleDateFormat in a web server causes random NumberFormatException |
| Months start at 0 | January is month 0, December is month 11. This causes off-by-one bugs constantly. | new Date(2024, 1, 15) is actually February 15th, not January |
| Year offset from 1900 | The year in Date(year, month, day) is offset from 1900. Year 2024 is represented as 124. |
new Date(2024, 0, 1) is actually year 3924 |
| Poor API design | Date represents both a date and a time. There is no way to represent just a date or just a time. | No clean way to say “March 15th” without a time component |
| Time zone confusion | Date stores a UTC timestamp but toString() converts to the local time zone. Calendar’s time zone handling is inconsistent. |
date.toString() shows local time, but date.getTime() returns UTC millis |
| SQL Date mess | java.sql.Date extends java.util.Date but throws exceptions if you call time methods. Inheritance is broken. |
sqlDate.getHours() throws IllegalArgumentException |
import java.util.Date;
import java.util.Calendar;
import java.text.SimpleDateFormat;
public class OldDateProblems {
public static void main(String[] args) {
// Problem 1: Months start at 0
Calendar cal = Calendar.getInstance();
cal.set(2024, 1, 15); // You think this is January 15, but it's FEBRUARY 15!
System.out.println("Month 1 is: " + cal.getTime());
// Output: Month 1 is: Thu Feb 15 ... 2024
// Problem 2: Date is mutable
Date startDate = new Date();
Date endDate = startDate; // Both point to the same object!
endDate.setTime(0); // This ALSO changes startDate
System.out.println("Start date changed to: " + startDate);
// Output: Start date changed to: Thu Jan 01 00:00:00 GMT 1970
// Problem 3: Year offset from 1900
@SuppressWarnings("deprecation")
Date badDate = new Date(2024, 0, 1); // Year 3924, not 2024!
System.out.println("Year 2024? " + badDate);
// Output: Year 2024? Sat Jan 01 ... 3924
// Problem 4: SimpleDateFormat is NOT thread-safe
// This shared formatter will cause bugs in multi-threaded code:
// static final SimpleDateFormat FORMAT = new SimpleDateFormat("yyyy-MM-dd");
// Thread 1: FORMAT.parse("2024-01-15");
// Thread 2: FORMAT.parse("2024-06-20"); // Can corrupt Thread 1's result!
// Problem 5: No concept of "just a date" or "just a time"
Date appointment = new Date(); // Always includes date AND time AND timezone
// There is no way to say "March 15th" without a time attached
System.out.println("\nAll these problems are solved by java.time (Java 8+)");
}
}
Java 8 introduced the java.time package, designed by Stephen Colebourne (the creator of Joda-Time) under JSR 310. It fixes every problem with the old API by following three core principles:
of(), now(), plus(), minus(), with() make code readable and intuitive.| Class | Represents | Example Value | Use Case |
|---|---|---|---|
LocalDate |
Date without time or timezone | 2024-03-15 |
Birthdays, holidays, deadlines |
LocalTime |
Time without date or timezone | 14:30:00 |
Store opening hours, alarm times |
LocalDateTime |
Date and time without timezone | 2024-03-15T14:30:00 |
Appointments, event timestamps |
ZonedDateTime |
Date, time, and timezone | 2024-03-15T14:30:00+05:30[Asia/Kolkata] |
Flight departures, global meetings |
Instant |
Machine timestamp (UTC) | 2024-03-15T09:00:00Z |
Database timestamps, logs, APIs |
Period |
Date-based amount | P2Y3M5D (2 years, 3 months, 5 days) |
“How old is someone?” |
Duration |
Time-based amount | PT2H30M (2 hours, 30 minutes) |
“How long did this take?” |
DateTimeFormatter |
Formatting and parsing | dd/MM/yyyy HH:mm |
Display dates, parse user input |
Ask yourself: “Does time zone matter?”
LocalDate, LocalTime, or LocalDateTime. These are “local” to wherever the user is.ZonedDateTime. Essential for scheduling across regions.Instant. A single point on the UTC timeline, independent of human calendars.import java.time.*;
public class JavaTimeOverview {
public static void main(String[] args) {
// Each class represents a different level of precision
LocalDate date = LocalDate.now();
LocalTime time = LocalTime.now();
LocalDateTime dateTime = LocalDateTime.now();
ZonedDateTime zonedDateTime = ZonedDateTime.now();
Instant instant = Instant.now();
System.out.println("LocalDate: " + date); // 2024-03-15
System.out.println("LocalTime: " + time); // 14:30:45.123456
System.out.println("LocalDateTime: " + dateTime); // 2024-03-15T14:30:45.123456
System.out.println("ZonedDateTime: " + zonedDateTime); // 2024-03-15T14:30:45.123456-07:00[America/Los_Angeles]
System.out.println("Instant: " + instant); // 2024-03-15T21:30:45.123456Z
// All are immutable -- "modifying" creates a NEW object
LocalDate tomorrow = date.plusDays(1);
System.out.println("\nOriginal date: " + date); // Unchanged!
System.out.println("Tomorrow: " + tomorrow); // New object
}
}
LocalDate represents a date without a time or timezone — just year, month, and day. Think of it as what you would write on a calendar: March 15, 2024. It is the most commonly used date class.
import java.time.LocalDate;
import java.time.Month;
public class LocalDateCreation {
public static void main(String[] args) {
// Current date
LocalDate today = LocalDate.now();
System.out.println("Today: " + today);
// Output: Today: 2024-03-15
// Specific date using of()
LocalDate christmas = LocalDate.of(2024, 12, 25);
System.out.println("Christmas: " + christmas);
// Output: Christmas: 2024-12-25
// Using Month enum (more readable, no off-by-one errors!)
LocalDate newYear = LocalDate.of(2025, Month.JANUARY, 1);
System.out.println("New Year: " + newYear);
// Output: New Year: 2025-01-01
// Parse from string (ISO format: yyyy-MM-dd)
LocalDate parsed = LocalDate.parse("2024-07-04");
System.out.println("Parsed: " + parsed);
// Output: Parsed: 2024-07-04
// Special dates
LocalDate epoch = LocalDate.EPOCH; // 1970-01-01
LocalDate min = LocalDate.MIN; // -999999999-01-01
LocalDate max = LocalDate.MAX; // +999999999-12-31
System.out.println("Epoch: " + epoch);
// Output: Epoch: 1970-01-01
}
}
Since LocalDate is immutable, all manipulation methods return a new LocalDate.
import java.time.LocalDate;
import java.time.Month;
import java.time.DayOfWeek;
import java.time.temporal.ChronoUnit;
import java.time.temporal.TemporalAdjusters;
public class LocalDateManipulation {
public static void main(String[] args) {
LocalDate date = LocalDate.of(2024, Month.MARCH, 15);
// Adding and subtracting
System.out.println("Original: " + date); // 2024-03-15
System.out.println("+5 days: " + date.plusDays(5)); // 2024-03-20
System.out.println("+2 weeks: " + date.plusWeeks(2)); // 2024-03-29
System.out.println("+3 months: " + date.plusMonths(3)); // 2024-06-15
System.out.println("+1 year: " + date.plusYears(1)); // 2025-03-15
System.out.println("-10 days: " + date.minusDays(10)); // 2024-03-05
// Using with() to set specific fields
System.out.println("\nwithYear(2025): " + date.withYear(2025)); // 2025-03-15
System.out.println("withMonth(1): " + date.withMonth(1)); // 2024-01-15
System.out.println("withDayOfMonth: " + date.withDayOfMonth(1)); // 2024-03-01
// Temporal adjusters -- powerful date adjustments
System.out.println("\nFirst day of month: " + date.with(TemporalAdjusters.firstDayOfMonth()));
// Output: 2024-03-01
System.out.println("Last day of month: " + date.with(TemporalAdjusters.lastDayOfMonth()));
// Output: 2024-03-31
System.out.println("First day of year: " + date.with(TemporalAdjusters.firstDayOfYear()));
// Output: 2024-01-01
System.out.println("Next Monday: " + date.with(TemporalAdjusters.next(DayOfWeek.MONDAY)));
// Output: 2024-03-18
System.out.println("Previous Friday: " + date.with(TemporalAdjusters.previous(DayOfWeek.FRIDAY)));
// Output: 2024-03-08
// Getting information
System.out.println("\nYear: " + date.getYear()); // 2024
System.out.println("Month: " + date.getMonth()); // MARCH
System.out.println("Month value: " + date.getMonthValue()); // 3 (not 2!)
System.out.println("Day of month: " + date.getDayOfMonth()); // 15
System.out.println("Day of week: " + date.getDayOfWeek()); // FRIDAY
System.out.println("Day of year: " + date.getDayOfYear()); // 75
System.out.println("Leap year: " + date.isLeapYear()); // true (2024)
System.out.println("Days in month: " + date.lengthOfMonth()); // 31
System.out.println("Days in year: " + date.lengthOfYear()); // 366
}
}
import java.time.LocalDate;
import java.time.Month;
import java.time.Period;
import java.time.temporal.ChronoUnit;
public class LocalDateComparison {
public static void main(String[] args) {
LocalDate today = LocalDate.of(2024, Month.MARCH, 15);
LocalDate birthday = LocalDate.of(1990, Month.JULY, 20);
LocalDate deadline = LocalDate.of(2024, Month.APRIL, 1);
// Comparison methods
System.out.println("Is today before deadline? " + today.isBefore(deadline)); // true
System.out.println("Is today after birthday? " + today.isAfter(birthday)); // true
System.out.println("Are they equal? " + today.isEqual(today)); // true
// Calculate age
Period age = Period.between(birthday, today);
System.out.println("\nAge: " + age.getYears() + " years, "
+ age.getMonths() + " months, "
+ age.getDays() + " days");
// Output: Age: 33 years, 7 months, 24 days
// Days between dates
long daysBetween = ChronoUnit.DAYS.between(today, deadline);
System.out.println("Days until deadline: " + daysBetween);
// Output: Days until deadline: 17
long monthsBetween = ChronoUnit.MONTHS.between(birthday, today);
System.out.println("Months since birth: " + monthsBetween);
// Output: Months since birth: 403
// Age calculation utility
System.out.println("\n--- Age Calculator ---");
LocalDate[] birthdays = {
LocalDate.of(2000, 1, 1),
LocalDate.of(1985, 6, 15),
LocalDate.of(2010, 12, 25)
};
for (LocalDate bday : birthdays) {
int years = Period.between(bday, today).getYears();
System.out.printf("Born %s -> Age %d%n", bday, years);
}
// Output:
// Born 2000-01-01 -> Age 24
// Born 1985-06-15 -> Age 38
// Born 2010-12-25 -> Age 13
}
}
LocalTime represents a time without a date or timezone — hours, minutes, seconds, and nanoseconds. Think of it as what you see on a clock: 14:30:00. Use it for things like store opening hours, alarm times, or daily schedules.
import java.time.LocalTime;
import java.time.temporal.ChronoUnit;
public class LocalTimeDemo {
public static void main(String[] args) {
// Creating LocalTime
LocalTime now = LocalTime.now();
LocalTime morning = LocalTime.of(9, 0); // 09:00
LocalTime afternoon = LocalTime.of(14, 30, 0); // 14:30:00
LocalTime precise = LocalTime.of(10, 15, 30, 500_000_000); // 10:15:30.5
LocalTime parsed = LocalTime.parse("18:45:00"); // 18:45:00
System.out.println("Now: " + now);
System.out.println("Morning: " + morning);
System.out.println("Afternoon: " + afternoon);
System.out.println("Precise: " + precise);
// Special constants
System.out.println("\nMidnight: " + LocalTime.MIDNIGHT); // 00:00
System.out.println("Noon: " + LocalTime.NOON); // 12:00
System.out.println("Min: " + LocalTime.MIN); // 00:00
System.out.println("Max: " + LocalTime.MAX); // 23:59:59.999999999
// Manipulation
LocalTime meetingTime = LocalTime.of(10, 0);
System.out.println("\nMeeting: " + meetingTime);
System.out.println("+2 hours: " + meetingTime.plusHours(2)); // 12:00
System.out.println("+30 mins: " + meetingTime.plusMinutes(30)); // 10:30
System.out.println("-1 hour: " + meetingTime.minusHours(1)); // 09:00
// Getting components
System.out.println("\nHour: " + afternoon.getHour()); // 14
System.out.println("Minute: " + afternoon.getMinute()); // 30
System.out.println("Second: " + afternoon.getSecond()); // 0
// Comparing
System.out.println("\nMorning before afternoon? " + morning.isBefore(afternoon)); // true
System.out.println("Morning after afternoon? " + morning.isAfter(afternoon)); // false
// Time between
long minutesBetween = ChronoUnit.MINUTES.between(morning, afternoon);
System.out.println("Minutes from morning to afternoon: " + minutesBetween);
// Output: Minutes from morning to afternoon: 330
// Business hours check
LocalTime openTime = LocalTime.of(9, 0);
LocalTime closeTime = LocalTime.of(17, 0);
LocalTime checkTime = LocalTime.of(14, 30);
boolean isOpen = !checkTime.isBefore(openTime) && checkTime.isBefore(closeTime);
System.out.println("\nStore open at " + checkTime + "? " + isOpen);
// Output: Store open at 14:30? true
}
}
LocalDateTime combines LocalDate and LocalTime into a single object — a date and time without a timezone. It is the most commonly used class when you need both date and time together, such as for appointments, event timestamps, or log entries in a single-timezone application.
import java.time.LocalDate;
import java.time.LocalTime;
import java.time.LocalDateTime;
import java.time.Month;
import java.time.temporal.ChronoUnit;
public class LocalDateTimeDemo {
public static void main(String[] args) {
// Creating LocalDateTime
LocalDateTime now = LocalDateTime.now();
LocalDateTime specific = LocalDateTime.of(2024, Month.DECEMBER, 31, 23, 59, 59);
LocalDateTime parsed = LocalDateTime.parse("2024-07-04T10:30:00");
System.out.println("Now: " + now);
System.out.println("Specific: " + specific); // 2024-12-31T23:59:59
System.out.println("Parsed: " + parsed); // 2024-07-04T10:30
// Combining LocalDate and LocalTime
LocalDate date = LocalDate.of(2024, 6, 15);
LocalTime time = LocalTime.of(14, 30);
LocalDateTime combined = LocalDateTime.of(date, time);
System.out.println("Combined: " + combined); // 2024-06-15T14:30
// Alternative: atTime() and atDate()
LocalDateTime fromDate = date.atTime(9, 0); // 2024-06-15T09:00
LocalDateTime fromTime = time.atDate(date); // 2024-06-15T14:30
LocalDateTime startOfDay = date.atStartOfDay(); // 2024-06-15T00:00
System.out.println("Start of day: " + startOfDay);
// Extracting date and time
LocalDate extractedDate = combined.toLocalDate();
LocalTime extractedTime = combined.toLocalTime();
System.out.println("\nExtracted date: " + extractedDate); // 2024-06-15
System.out.println("Extracted time: " + extractedTime); // 14:30
// Manipulation
LocalDateTime event = LocalDateTime.of(2024, 3, 15, 10, 0);
System.out.println("\nEvent: " + event);
System.out.println("+3 days: " + event.plusDays(3));
System.out.println("+2 hours: " + event.plusHours(2));
System.out.println("-30 minutes: " + event.minusMinutes(30));
System.out.println("Set hour to 8: " + event.withHour(8));
// Comparing
LocalDateTime meeting1 = LocalDateTime.of(2024, 3, 15, 10, 0);
LocalDateTime meeting2 = LocalDateTime.of(2024, 3, 15, 14, 0);
System.out.println("\nMeeting1 before Meeting2? " + meeting1.isBefore(meeting2)); // true
long hoursBetween = ChronoUnit.HOURS.between(meeting1, meeting2);
System.out.println("Hours between meetings: " + hoursBetween);
// Output: Hours between meetings: 4
}
}
ZonedDateTime is a date-time with full time zone information. Use it whenever the timezone matters — scheduling meetings across countries, flight departure/arrival times, or any scenario where the same “local time” has different meanings in different regions.
A time zone is identified by a ZoneId like "America/New_York" or "Asia/Tokyo". It encodes the UTC offset AND the daylight saving rules for that region. Do not use fixed offsets like "GMT+5" unless you specifically want to ignore DST.
import java.time.*;
import java.time.format.DateTimeFormatter;
import java.util.Set;
public class ZonedDateTimeDemo {
public static void main(String[] args) {
// Creating ZonedDateTime
ZonedDateTime nowHere = ZonedDateTime.now();
ZonedDateTime nowTokyo = ZonedDateTime.now(ZoneId.of("Asia/Tokyo"));
ZonedDateTime nowLondon = ZonedDateTime.now(ZoneId.of("Europe/London"));
System.out.println("Here: " + nowHere);
System.out.println("Tokyo: " + nowTokyo);
System.out.println("London: " + nowLondon);
// Specific date-time in a zone
ZonedDateTime meeting = ZonedDateTime.of(
2024, 3, 15, 10, 0, 0, 0,
ZoneId.of("America/New_York")
);
System.out.println("\nMeeting (NY): " + meeting);
// Output: Meeting (NY): 2024-03-15T10:00-04:00[America/New_York]
// Converting between time zones
ZonedDateTime meetingLA = meeting.withZoneSameInstant(ZoneId.of("America/Los_Angeles"));
ZonedDateTime meetingLondon = meeting.withZoneSameInstant(ZoneId.of("Europe/London"));
ZonedDateTime meetingTokyo = meeting.withZoneSameInstant(ZoneId.of("Asia/Tokyo"));
ZonedDateTime meetingIndia = meeting.withZoneSameInstant(ZoneId.of("Asia/Kolkata"));
DateTimeFormatter fmt = DateTimeFormatter.ofPattern("HH:mm z (MMM dd)");
System.out.println("\nMeeting at 10:00 AM New York is:");
System.out.println(" Los Angeles: " + meetingLA.format(fmt));
System.out.println(" London: " + meetingLondon.format(fmt));
System.out.println(" Tokyo: " + meetingTokyo.format(fmt));
System.out.println(" India: " + meetingIndia.format(fmt));
// Output:
// Meeting at 10:00 AM New York is:
// Los Angeles: 07:00 PDT (Mar 15)
// London: 14:00 GMT (Mar 15)
// Tokyo: 23:00 JST (Mar 15)
// India: 19:30 IST (Mar 15)
// withZoneSameLocal vs withZoneSameInstant
// withZoneSameInstant: Same moment, different clock reading
// withZoneSameLocal: Same clock reading, different moment
ZonedDateTime noonNY = ZonedDateTime.of(2024, 6, 15, 12, 0, 0, 0,
ZoneId.of("America/New_York"));
ZonedDateTime sameInstant = noonNY.withZoneSameInstant(ZoneId.of("Asia/Tokyo"));
ZonedDateTime sameLocal = noonNY.withZoneSameLocal(ZoneId.of("Asia/Tokyo"));
System.out.println("\nNoon in NY: " + noonNY);
System.out.println("Same instant: " + sameInstant); // Next day 1 AM in Tokyo
System.out.println("Same local time: " + sameLocal); // Noon in Tokyo (different moment!)
// Available zone IDs
Set zones = ZoneId.getAvailableZoneIds();
System.out.println("\nTotal time zones available: " + zones.size());
// Output: Total time zones available: ~600
// Handling Daylight Saving Time
// Spring forward: 2:00 AM -> 3:00 AM (1 hour gap)
ZonedDateTime beforeDST = ZonedDateTime.of(
2024, 3, 10, 1, 30, 0, 0, ZoneId.of("America/New_York"));
ZonedDateTime afterDST = beforeDST.plusHours(1);
System.out.println("\nBefore DST: " + beforeDST); // 01:30 EST
System.out.println("After +1h: " + afterDST); // 03:30 EDT (skips 2:00-3:00!)
}
}
Instant represents a single point on the UTC timeline — a machine timestamp measured in seconds and nanoseconds from the Unix epoch (January 1, 1970, 00:00:00 UTC). It has no concept of human-readable dates, months, or time zones. Think of it as what a computer stores internally when tracking “when something happened.”
Use Instant for:
import java.time.Instant;
import java.time.Duration;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.temporal.ChronoUnit;
public class InstantDemo {
public static void main(String[] args) throws InterruptedException {
// Current instant
Instant now = Instant.now();
System.out.println("Now: " + now);
// Output: Now: 2024-03-15T21:30:45.123456Z (always UTC, denoted by Z)
// From epoch
Instant epoch = Instant.EPOCH;
System.out.println("Epoch: " + epoch);
// Output: Epoch: 1970-01-01T00:00:00Z
Instant fromEpochSeconds = Instant.ofEpochSecond(1_700_000_000);
System.out.println("From epoch seconds: " + fromEpochSeconds);
// Output: From epoch seconds: 2023-11-14T22:13:20Z
Instant fromEpochMillis = Instant.ofEpochMilli(System.currentTimeMillis());
System.out.println("From epoch millis: " + fromEpochMillis);
// Parse ISO-8601 string
Instant parsed = Instant.parse("2024-01-15T10:30:00Z");
System.out.println("Parsed: " + parsed);
// Getting epoch values
System.out.println("\nEpoch second: " + now.getEpochSecond());
System.out.println("Nano adjust: " + now.getNano());
// Measuring execution time
System.out.println("\n--- Execution Time ---");
Instant start = Instant.now();
// Simulate work
long sum = 0;
for (int i = 0; i < 10_000_000; i++) {
sum += i;
}
Instant end = Instant.now();
Duration elapsed = Duration.between(start, end);
System.out.println("Sum: " + sum);
System.out.printf("Elapsed: %d ms (%d ns)%n",
elapsed.toMillis(), elapsed.toNanos());
// Output: Elapsed: 15 ms (15234567 ns)
// Comparing instants
Instant past = Instant.now().minus(1, ChronoUnit.HOURS);
Instant future = Instant.now().plus(1, ChronoUnit.HOURS);
System.out.println("\nPast is before now: " + past.isBefore(now)); // true
System.out.println("Future is after now: " + future.isAfter(now)); // true
// Converting Instant to ZonedDateTime (for human display)
ZonedDateTime inNY = now.atZone(ZoneId.of("America/New_York"));
ZonedDateTime inTokyo = now.atZone(ZoneId.of("Asia/Tokyo"));
System.out.println("\nSame instant, different zones:");
System.out.println(" New York: " + inNY);
System.out.println(" Tokyo: " + inTokyo);
}
}
Java 8 provides two classes for representing amounts of time:
Period -- A date-based amount: years, months, and days. Use it to answer "How many years/months/days between two dates?"Duration -- A time-based amount: hours, minutes, seconds, and nanoseconds. Use it to answer "How long did this operation take?"| Feature | Period | Duration |
|---|---|---|
| Measures | Years, months, days | Hours, minutes, seconds, nanos |
| Pair with | LocalDate |
LocalTime, Instant |
| ISO format | P2Y3M5D |
PT2H30M |
| Factory method | Period.between(date1, date2) |
Duration.between(time1, time2) |
| Use case | "Contract expires in 2 years" | "API call took 350ms" |
import java.time.*;
import java.time.temporal.ChronoUnit;
public class PeriodDurationDemo {
public static void main(String[] args) {
// ========== PERIOD (date-based) ==========
System.out.println("=== Period ===");
// Creating periods
Period twoYears = Period.ofYears(2);
Period threeMonths = Period.ofMonths(3);
Period tenDays = Period.ofDays(10);
Period custom = Period.of(1, 6, 15); // 1 year, 6 months, 15 days
System.out.println("Two years: " + twoYears); // P2Y
System.out.println("Three months: " + threeMonths); // P3M
System.out.println("Custom: " + custom); // P1Y6M15D
// Between two dates
LocalDate hired = LocalDate.of(2020, Month.MARCH, 1);
LocalDate today = LocalDate.of(2024, Month.MARCH, 15);
Period tenure = Period.between(hired, today);
System.out.println("\nTenure: " + tenure.getYears() + " years, "
+ tenure.getMonths() + " months, "
+ tenure.getDays() + " days");
// Output: Tenure: 4 years, 0 months, 14 days
// Adding period to a date
LocalDate contractStart = LocalDate.of(2024, 1, 1);
LocalDate contractEnd = contractStart.plus(Period.of(2, 0, 0));
System.out.println("Contract: " + contractStart + " to " + contractEnd);
// Output: Contract: 2024-01-01 to 2026-01-01
// ========== DURATION (time-based) ==========
System.out.println("\n=== Duration ===");
// Creating durations
Duration twoHours = Duration.ofHours(2);
Duration thirtyMinutes = Duration.ofMinutes(30);
Duration fiveSeconds = Duration.ofSeconds(5);
Duration halfSecond = Duration.ofMillis(500);
System.out.println("Two hours: " + twoHours); // PT2H
System.out.println("Thirty minutes: " + thirtyMinutes); // PT30M
System.out.println("Five seconds: " + fiveSeconds); // PT5S
System.out.println("Half second: " + halfSecond); // PT0.5S
// Between two times
LocalTime start = LocalTime.of(9, 0);
LocalTime end = LocalTime.of(17, 30);
Duration workday = Duration.between(start, end);
System.out.println("\nWorkday: " + workday);
System.out.println("In hours: " + workday.toHours()); // 8
System.out.println("In minutes: " + workday.toMinutes()); // 510
System.out.println("In seconds: " + workday.getSeconds()); // 30600
// Between two instants
Instant begin = Instant.parse("2024-03-15T10:00:00Z");
Instant finish = Instant.parse("2024-03-15T10:05:30.500Z");
Duration apiCall = Duration.between(begin, finish);
System.out.println("\nAPI duration: " + apiCall); // PT5M30.5S
System.out.println("In millis: " + apiCall.toMillis()); // 330500
// Manipulation
Duration meeting = Duration.ofHours(1);
System.out.println("\nMeeting: " + meeting);
System.out.println("+30 min: " + meeting.plusMinutes(30)); // PT1H30M
System.out.println("x2: " + meeting.multipliedBy(2)); // PT2H
// Parse ISO-8601
Duration parsed = Duration.parse("PT2H30M");
System.out.println("Parsed: " + parsed); // PT2H30M
// ChronoUnit for total amounts
long totalDays = ChronoUnit.DAYS.between(
LocalDate.of(2024, 1, 1), LocalDate.of(2024, 12, 31));
System.out.println("\nDays in 2024: " + totalDays);
// Output: Days in 2024: 365
}
}
DateTimeFormatter is the replacement for SimpleDateFormat. It is immutable and thread-safe, which means you can safely share a single instance across your entire application. It handles both formatting (date to string) and parsing (string to date).
Java provides several built-in formatters for common ISO formats:
| Formatter | Example Output | Use Case |
|---|---|---|
ISO_LOCAL_DATE |
2024-03-15 |
Standard date format |
ISO_LOCAL_TIME |
14:30:00 |
Standard time format |
ISO_LOCAL_DATE_TIME |
2024-03-15T14:30:00 |
Standard date-time format |
ISO_ZONED_DATE_TIME |
2024-03-15T14:30:00-04:00[America/New_York] |
Date-time with zone |
ISO_INSTANT |
2024-03-15T18:30:00Z |
UTC timestamp |
ISO_DATE |
2024-03-15 or 2024-03-15-04:00 |
Date with optional offset |
RFC_1123_DATE_TIME |
Fri, 15 Mar 2024 14:30:00 -0400 |
HTTP headers |
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.ZonedDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.time.format.FormatStyle;
import java.util.Locale;
public class DateTimeFormatterDemo {
public static void main(String[] args) {
LocalDateTime dateTime = LocalDateTime.of(2024, 3, 15, 14, 30, 0);
LocalDate date = dateTime.toLocalDate();
// Predefined formatters
System.out.println("=== Predefined Formatters ===");
System.out.println("ISO_LOCAL_DATE: " + date.format(DateTimeFormatter.ISO_LOCAL_DATE));
System.out.println("ISO_LOCAL_DATE_TIME: " + dateTime.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME));
// Output:
// ISO_LOCAL_DATE: 2024-03-15
// ISO_LOCAL_DATE_TIME: 2024-03-15T14:30:00
// Custom patterns
System.out.println("\n=== Custom Patterns ===");
DateTimeFormatter f1 = DateTimeFormatter.ofPattern("dd/MM/yyyy");
DateTimeFormatter f2 = DateTimeFormatter.ofPattern("MMMM dd, yyyy");
DateTimeFormatter f3 = DateTimeFormatter.ofPattern("MM-dd-yyyy HH:mm:ss");
DateTimeFormatter f4 = DateTimeFormatter.ofPattern("EEE, MMM d, yyyy h:mm a");
DateTimeFormatter f5 = DateTimeFormatter.ofPattern("yyyy/MM/dd HH:mm");
System.out.println("dd/MM/yyyy: " + date.format(f1));
System.out.println("MMMM dd, yyyy: " + date.format(f2));
System.out.println("MM-dd-yyyy HH:mm:ss: " + dateTime.format(f3));
System.out.println("EEE, MMM d, ...: " + dateTime.format(f4));
System.out.println("yyyy/MM/dd HH:mm: " + dateTime.format(f5));
// Output:
// dd/MM/yyyy: 15/03/2024
// MMMM dd, yyyy: March 15, 2024
// MM-dd-yyyy HH:mm:ss: 03-15-2024 14:30:00
// EEE, MMM d, ...: Fri, Mar 15, 2024 2:30 PM
// yyyy/MM/dd HH:mm: 2024/03/15 14:30
// Localized formatters
System.out.println("\n=== Localized Formatters ===");
DateTimeFormatter french = DateTimeFormatter.ofLocalizedDate(FormatStyle.FULL)
.withLocale(Locale.FRENCH);
DateTimeFormatter german = DateTimeFormatter.ofLocalizedDate(FormatStyle.LONG)
.withLocale(Locale.GERMAN);
DateTimeFormatter japanese = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)
.withLocale(Locale.JAPANESE);
System.out.println("French (FULL): " + date.format(french));
System.out.println("German (LONG): " + date.format(german));
System.out.println("Japanese (MEDIUM):" + date.format(japanese));
// Output (varies by JDK):
// French (FULL): vendredi 15 mars 2024
// German (LONG): 15. Marz 2024
// Japanese (MEDIUM):2024/03/15
// Parsing strings to dates
System.out.println("\n=== Parsing ===");
LocalDate parsed1 = LocalDate.parse("2024-03-15"); // ISO format, no formatter needed
LocalDate parsed2 = LocalDate.parse("15/03/2024", f1);
LocalDate parsed3 = LocalDate.parse("March 15, 2024", f2);
LocalDateTime parsed4 = LocalDateTime.parse("03-15-2024 14:30:00", f3);
System.out.println("Parsed ISO: " + parsed1);
System.out.println("Parsed dd/MM: " + parsed2);
System.out.println("Parsed Month: " + parsed3);
System.out.println("Parsed full: " + parsed4);
}
}
| Symbol | Meaning | Example |
|---|---|---|
y |
Year | 2024 (yyyy), 24 (yy) |
M |
Month | 3 (M), 03 (MM), Mar (MMM), March (MMMM) |
d |
Day of month | 5 (d), 05 (dd) |
E |
Day of week | Fri (EEE), Friday (EEEE) |
H |
Hour (0-23) | 14 (HH) |
h |
Hour (1-12) | 2 (h), 02 (hh) |
m |
Minute | 30 (mm) |
s |
Second | 45 (ss) |
S |
Fraction of second | 123 (SSS) |
a |
AM/PM | PM |
z |
Time zone name | PST, PDT |
Z |
Time zone offset | -0800 |
V |
Time zone ID | America/Los_Angeles (VV) |
If you are working with legacy code that uses java.util.Date or java.util.Calendar, Java 8 provides bridge methods to convert to and from the new API. The key conversion point is Instant -- all legacy classes can convert to an Instant, and from there you can get any java.time class.
import java.time.*;
import java.util.Date;
import java.util.Calendar;
import java.util.GregorianCalendar;
import java.util.TimeZone;
import java.sql.Timestamp;
public class LegacyConversion {
public static void main(String[] args) {
// ========== java.util.Date <-> java.time ==========
System.out.println("=== java.util.Date Conversion ===");
// Date -> Instant -> LocalDateTime
Date oldDate = new Date();
Instant instant = oldDate.toInstant();
LocalDateTime ldt = LocalDateTime.ofInstant(instant, ZoneId.systemDefault());
LocalDate ld = ldt.toLocalDate();
ZonedDateTime zdt = instant.atZone(ZoneId.systemDefault());
System.out.println("Old Date: " + oldDate);
System.out.println("Instant: " + instant);
System.out.println("LocalDateTime: " + ldt);
System.out.println("LocalDate: " + ld);
System.out.println("ZonedDateTime: " + zdt);
// LocalDateTime -> Date (reverse)
LocalDateTime newDateTime = LocalDateTime.of(2024, 6, 15, 14, 30);
Date backToDate = Date.from(newDateTime.atZone(ZoneId.systemDefault()).toInstant());
System.out.println("\nBack to Date: " + backToDate);
// ========== Calendar <-> java.time ==========
System.out.println("\n=== Calendar Conversion ===");
// Calendar -> ZonedDateTime
Calendar calendar = Calendar.getInstance();
if (calendar instanceof GregorianCalendar gc) {
ZonedDateTime fromCal = gc.toZonedDateTime();
System.out.println("From Calendar: " + fromCal);
}
// ZonedDateTime -> Calendar
ZonedDateTime zonedNow = ZonedDateTime.now();
GregorianCalendar backToCal = GregorianCalendar.from(zonedNow);
System.out.println("Back to Cal: " + backToCal.getTime());
// ========== TimeZone <-> ZoneId ==========
System.out.println("\n=== TimeZone Conversion ===");
TimeZone oldTZ = TimeZone.getTimeZone("America/New_York");
ZoneId newZoneId = oldTZ.toZoneId();
TimeZone backToTZ = TimeZone.getTimeZone(newZoneId);
System.out.println("Old TimeZone: " + oldTZ.getID());
System.out.println("New ZoneId: " + newZoneId);
// ========== java.sql.Timestamp <-> java.time ==========
System.out.println("\n=== SQL Timestamp Conversion ===");
// Timestamp -> LocalDateTime (no timezone conversion)
Timestamp sqlTimestamp = Timestamp.valueOf("2024-03-15 14:30:00");
LocalDateTime fromTimestamp = sqlTimestamp.toLocalDateTime();
System.out.println("From Timestamp: " + fromTimestamp);
// LocalDateTime -> Timestamp
Timestamp backToTimestamp = Timestamp.valueOf(newDateTime);
System.out.println("Back to Timestamp: " + backToTimestamp);
// Timestamp -> Instant
Instant fromTimestampInstant = sqlTimestamp.toInstant();
System.out.println("Timestamp Instant: " + fromTimestampInstant);
}
}
| Old API | New API | Conversion Method |
|---|---|---|
java.util.Date |
Instant |
date.toInstant() |
Instant |
java.util.Date |
Date.from(instant) |
java.util.Date |
LocalDate |
date.toInstant().atZone(zone).toLocalDate() |
LocalDate |
java.util.Date |
Date.from(localDate.atStartOfDay(zone).toInstant()) |
GregorianCalendar |
ZonedDateTime |
gc.toZonedDateTime() |
ZonedDateTime |
GregorianCalendar |
GregorianCalendar.from(zdt) |
java.sql.Timestamp |
LocalDateTime |
ts.toLocalDateTime() |
LocalDateTime |
java.sql.Timestamp |
Timestamp.valueOf(ldt) |
java.sql.Date |
LocalDate |
sqlDate.toLocalDate() |
LocalDate |
java.sql.Date |
java.sql.Date.valueOf(localDate) |
TimeZone |
ZoneId |
timeZone.toZoneId() |
SimpleDateFormat |
DateTimeFormatter |
No direct conversion; rewrite pattern |
This example demonstrates a realistic event scheduling system that uses every major class from the java.time package. It handles creating events across time zones, calculating durations, formatting for different locales, and checking for schedule conflicts.
import java.time.*;
import java.time.format.DateTimeFormatter;
import java.time.format.FormatStyle;
import java.time.temporal.ChronoUnit;
import java.time.temporal.TemporalAdjusters;
import java.util.*;
import java.util.stream.Collectors;
public class EventSchedulingSystem {
// Immutable Event record
record Event(
String name,
ZonedDateTime start,
Duration duration,
ZoneId creatorZone
) {
ZonedDateTime end() {
return start.plus(duration);
}
boolean conflictsWith(Event other) {
return this.start.isBefore(other.end()) && other.start.isBefore(this.end());
}
String formatForZone(ZoneId zone) {
DateTimeFormatter fmt = DateTimeFormatter.ofPattern("EEE, MMM d yyyy 'at' h:mm a z");
ZonedDateTime inZone = start.withZoneSameInstant(zone);
return name + ": " + inZone.format(fmt)
+ " (" + duration.toHours() + "h " + (duration.toMinutesPart()) + "m)";
}
}
public static void main(String[] args) {
System.out.println("=== Event Scheduling System ===\n");
// Define time zones for participants
ZoneId newYork = ZoneId.of("America/New_York");
ZoneId london = ZoneId.of("Europe/London");
ZoneId tokyo = ZoneId.of("Asia/Tokyo");
ZoneId india = ZoneId.of("Asia/Kolkata");
// ---- Create Events ----
System.out.println("--- Creating Events ---");
// Event 1: Team standup at 9 AM New York time
Event standup = new Event(
"Team Standup",
ZonedDateTime.of(2024, 3, 18, 9, 0, 0, 0, newYork),
Duration.ofMinutes(30),
newYork
);
// Event 2: Client demo at 2 PM London time
Event clientDemo = new Event(
"Client Demo",
ZonedDateTime.of(2024, 3, 18, 14, 0, 0, 0, london),
Duration.ofHours(1).plusMinutes(30),
london
);
// Event 3: Sprint planning at 10 AM Tokyo time
Event sprintPlanning = new Event(
"Sprint Planning",
ZonedDateTime.of(2024, 3, 18, 10, 0, 0, 0, tokyo),
Duration.ofHours(2),
tokyo
);
List events = List.of(standup, clientDemo, sprintPlanning);
// ---- Display Events in All Time Zones ----
System.out.println("\n--- Events in Each Time Zone ---");
List zones = List.of(newYork, london, tokyo, india);
for (ZoneId zone : zones) {
System.out.println("\n" + zone.getId() + ":");
for (Event event : events) {
System.out.println(" " + event.formatForZone(zone));
}
}
// ---- Check for Conflicts ----
System.out.println("\n--- Conflict Detection ---");
for (int i = 0; i < events.size(); i++) {
for (int j = i + 1; j < events.size(); j++) {
Event e1 = events.get(i);
Event e2 = events.get(j);
if (e1.conflictsWith(e2)) {
System.out.println("CONFLICT: " + e1.name() + " overlaps with " + e2.name());
} else {
System.out.println("OK: " + e1.name() + " and " + e2.name() + " do not overlap");
}
}
}
// ---- Calculate Time Until Events ----
System.out.println("\n--- Time Until Events (from now) ---");
ZonedDateTime now = ZonedDateTime.of(2024, 3, 15, 12, 0, 0, 0, newYork);
for (Event event : events) {
Duration until = Duration.between(now.toInstant(), event.start().toInstant());
long days = until.toDays();
long hours = until.toHoursPart();
long minutes = until.toMinutesPart();
System.out.printf(" %s: %d days, %d hours, %d minutes%n",
event.name(), days, hours, minutes);
}
// ---- Find Next Available Slot ----
System.out.println("\n--- Next Available Slot ---");
LocalDate meetingDate = LocalDate.of(2024, 3, 18);
Duration meetingLength = Duration.ofHours(1);
// Check each hour of the business day (9 AM - 5 PM New York)
LocalTime checkStart = LocalTime.of(9, 0);
LocalTime checkEnd = LocalTime.of(17, 0);
LocalTime candidate = checkStart;
while (candidate.plus(meetingLength).isBefore(checkEnd)
|| candidate.plus(meetingLength).equals(checkEnd)) {
ZonedDateTime candidateStart = ZonedDateTime.of(meetingDate, candidate, newYork);
ZonedDateTime candidateEnd = candidateStart.plus(meetingLength);
boolean hasConflict = false;
for (Event event : events) {
ZonedDateTime eventStartNY = event.start().withZoneSameInstant(newYork);
ZonedDateTime eventEndNY = event.end().withZoneSameInstant(newYork);
if (candidateStart.isBefore(eventEndNY) && eventStartNY.isBefore(candidateEnd)) {
hasConflict = true;
break;
}
}
if (!hasConflict) {
System.out.println(" Available: " + candidate + " - " + candidate.plus(meetingLength) + " (New York)");
break;
}
candidate = candidate.plusMinutes(30);
}
// ---- Date Calculations ----
System.out.println("\n--- Date Calculations ---");
// Recurring weekly event -- next 4 occurrences
LocalDate nextMonday = meetingDate.with(TemporalAdjusters.nextOrSame(DayOfWeek.MONDAY));
System.out.println("Next 4 Monday standups:");
for (int i = 0; i < 4; i++) {
LocalDate occurrence = nextMonday.plusWeeks(i);
System.out.println(" " + occurrence + " (" + occurrence.getDayOfWeek() + ")");
}
// Business days until deadline
LocalDate deadline = LocalDate.of(2024, 4, 1);
long businessDays = 0;
LocalDate check = meetingDate;
while (check.isBefore(deadline)) {
DayOfWeek dow = check.getDayOfWeek();
if (dow != DayOfWeek.SATURDAY && dow != DayOfWeek.SUNDAY) {
businessDays++;
}
check = check.plusDays(1);
}
System.out.println("\nBusiness days until " + deadline + ": " + businessDays);
// Quarter end date
int currentQuarter = (meetingDate.getMonthValue() - 1) / 3 + 1;
LocalDate quarterEnd = LocalDate.of(meetingDate.getYear(), currentQuarter * 3, 1)
.with(TemporalAdjusters.lastDayOfMonth());
Period untilQuarterEnd = Period.between(meetingDate, quarterEnd);
System.out.println("Quarter " + currentQuarter + " ends: " + quarterEnd
+ " (" + untilQuarterEnd.getMonths() + " months, "
+ untilQuarterEnd.getDays() + " days away)");
// ---- Age and Anniversary ----
System.out.println("\n--- Age Calculator ---");
LocalDate birthday = LocalDate.of(1990, Month.JULY, 20);
LocalDate today = LocalDate.of(2024, 3, 15);
Period age = Period.between(birthday, today);
System.out.println("Birthday: " + birthday);
System.out.println("Age: " + age.getYears() + " years, "
+ age.getMonths() + " months, " + age.getDays() + " days");
long totalDaysLived = ChronoUnit.DAYS.between(birthday, today);
System.out.println("Total days lived: " + String.format("%,d", totalDaysLived));
// Next birthday
LocalDate nextBirthday = birthday.withYear(today.getYear());
if (nextBirthday.isBefore(today) || nextBirthday.isEqual(today)) {
nextBirthday = nextBirthday.plusYears(1);
}
long daysUntilBirthday = ChronoUnit.DAYS.between(today, nextBirthday);
System.out.println("Next birthday: " + nextBirthday
+ " (" + daysUntilBirthday + " days away)");
// ---- Summary ----
System.out.println("\n=== Classes Used ===");
System.out.println("LocalDate - date without time");
System.out.println("LocalTime - time without date");
System.out.println("LocalDateTime - date and time without zone");
System.out.println("ZonedDateTime - date, time, and zone");
System.out.println("Instant - machine timestamp (UTC)");
System.out.println("Period - date-based amount (years/months/days)");
System.out.println("Duration - time-based amount (hours/minutes/seconds)");
System.out.println("ZoneId - time zone identifier");
System.out.println("DateTimeFormatter - formatting and parsing");
System.out.println("TemporalAdjusters - date adjustments (first/last day, next Monday)");
System.out.println("ChronoUnit - unit-based calculations");
}
}
// Sample Output:
// === Event Scheduling System ===
//
// --- Creating Events ---
//
// --- Events in Each Time Zone ---
//
// America/New_York:
// Team Standup: Mon, Mar 18 2024 at 9:00 AM EDT (0h 30m)
// Client Demo: Mon, Mar 18 2024 at 10:00 AM EDT (1h 30m)
// Sprint Planning: Sun, Mar 17 2024 at 9:00 PM EDT (2h 0m)
//
// Europe/London:
// Team Standup: Mon, Mar 18 2024 at 1:00 PM GMT (0h 30m)
// Client Demo: Mon, Mar 18 2024 at 2:00 PM GMT (1h 30m)
// Sprint Planning: Mon, Mar 18 2024 at 1:00 AM GMT (2h 0m)
//
// Asia/Tokyo:
// Team Standup: Mon, Mar 18 2024 at 10:00 PM JST (0h 30m)
// Client Demo: Mon, Mar 18 2024 at 11:00 PM JST (1h 30m)
// Sprint Planning: Mon, Mar 18 2024 at 10:00 AM JST (2h 0m)
//
// Asia/Kolkata:
// Team Standup: Mon, Mar 18 2024 at 6:30 PM IST (0h 30m)
// Client Demo: Mon, Mar 18 2024 at 7:30 PM IST (1h 30m)
// Sprint Planning: Mon, Mar 18 2024 at 6:30 AM IST (2h 0m)
//
// --- Conflict Detection ---
// CONFLICT: Team Standup overlaps with Client Demo
// OK: Team Standup and Sprint Planning do not overlap
// OK: Client Demo and Sprint Planning do not overlap
//
// --- Business days until 2024-04-01: 10
// --- Age: 33 years, 7 months, 24 days
// === Classes Used ===
// LocalDate, LocalTime, ZonedDateTime, Duration, Period, ZoneId,
// DateTimeFormatter, TemporalAdjusters, ChronoUnit
| Task | Code |
|---|---|
| Current date | LocalDate.now() |
| Current time | LocalTime.now() |
| Current date-time | LocalDateTime.now() |
| Current instant | Instant.now() |
| Specific date | LocalDate.of(2024, Month.MARCH, 15) |
| Parse date string | LocalDate.parse("2024-03-15") |
| Format date | date.format(DateTimeFormatter.ofPattern("dd/MM/yyyy")) |
| Add days | date.plusDays(5) |
| Subtract months | date.minusMonths(3) |
| Days between dates | ChronoUnit.DAYS.between(date1, date2) |
| Period between dates | Period.between(date1, date2) |
| Duration between times | Duration.between(time1, time2) |
| Compare dates | date1.isBefore(date2), isAfter(), isEqual() |
| First day of month | date.with(TemporalAdjusters.firstDayOfMonth()) |
| Next Monday | date.with(TemporalAdjusters.next(DayOfWeek.MONDAY)) |
| Convert time zone | zdt.withZoneSameInstant(ZoneId.of("Asia/Tokyo")) |
| Date to Instant | oldDate.toInstant() |
| Instant to Date | Date.from(instant) |
| Leap year check | date.isLeapYear() |
| Day of week | date.getDayOfWeek() |
In 2009, Sir Tony Hoare — the computer scientist who invented the null reference in 1965 for the ALGOL W language — delivered a now-famous keynote titled “Null References: The Billion Dollar Mistake.” He said:
“I call it my billion-dollar mistake. It was the invention of the null reference in 1965… This has led to innumerable errors, vulnerabilities, and system crashes, which have probably caused a billion dollars of pain and damage in the last forty years.”
That “billion dollars” is almost certainly an understatement. NullPointerException is the single most common runtime exception in Java. Stack Overflow data shows NPE questions outnumber every other Java exception combined. It crashes Android apps, takes down Spring Boot microservices at 3 AM, and hides in code that “works fine in dev” until the one edge case nobody tested reaches production.
The problem is not that “absence” needs to be represented. Every language needs a way to say “nothing here.” The problem is that in Java, null is a valid value for every reference type, and the compiler does nothing to force you to handle it. You can pass null anywhere, assign it to anything, and return it from any method. The NPE only appears at runtime, often far from where the null was introduced.
| Problem | Why It Hurts |
|---|---|
null is invisible in signatures |
User findByEmail(String email) — does it return null when not found? Throw an exception? You must read Javadoc or source code to find out. |
null propagates silently |
A null returned in one method gets passed through three more before it finally causes an NPE in a completely unrelated class. |
| Defensive checks pollute code | The only pre-Java 8 defense is scattering if (x != null) checks everywhere, obscuring actual business logic. |
| Bugs appear at runtime, not compile time | The compiler is happy. Your tests pass because they don’t cover that one path. Production breaks. |
public class NullProblemDemo {
public static void main(String[] args) {
// This compiles perfectly. No warnings. No errors.
String name = getUserName(42);
// But if getUserName returns null, this crashes at runtime
System.out.println(name.toUpperCase());
// Exception in thread "main" java.lang.NullPointerException
// The null traveled from getUserName() to here silently.
// With 50 methods between them, good luck finding the source.
}
// Nothing in this signature hints that null is possible
static String getUserName(int id) {
if (id == 1) return "Alice";
return null; // silent bomb -- compiles fine, blows up later
}
}
Before Optional, the standard defense was layered null checks. When objects are nested — User has an Address, Address has a City — the checks pile up into a deeply nested pyramid that buries the actual intent of the code.
// The "null check pyramid" -- real code from real projects
public String getCityName(User user) {
if (user != null) {
Address address = user.getAddress();
if (address != null) {
City city = address.getCity();
if (city != null) {
return city.getName();
}
}
}
return "Unknown";
}
// Three levels of nesting just to safely access a nested property.
// The business logic (get city name) is buried under defensive code.
Java was late to address this problem. Other languages had solutions long before Java 8:
| Language | Solution | Year |
|---|---|---|
| Haskell | Maybe monad |
1990 |
| Scala | Option[T] |
2004 |
| Groovy | Safe navigation operator ?. |
2007 |
| Kotlin | Nullable types with ? suffix |
2011 |
| Java | Optional<T> |
2014 (Java 8) |
| Swift | Optionals with ? and ! |
2014 |
Java’s Optional was directly inspired by Scala’s Option and Haskell’s Maybe. It was part of the larger Java 8 revolution that also brought lambdas and streams — and it was specifically designed to make the absence of a value explicit and safe.
Optional<T> is a container object introduced in Java 8 (package java.util) that may or may not hold a non-null value. Instead of returning null to mean “not found,” a method returns an Optional that explicitly says: “this result might be empty, and you must handle that case.”
Think of it like a gift box. When someone hands you a gift box, you know it might be empty. You would naturally open it and check before trying to use what is inside. Optional is that box — it wraps a value and reminds you to check before using it.
| Before Optional | With Optional |
|---|---|
Return null to mean “not found” |
Return Optional.empty() |
Check if (result != null) everywhere |
Use ifPresent(), orElse(), map() |
| Javadoc says “@return the user, or null” | Return type Optional<User> says it all |
| NullPointerException at runtime | Compiler-visible intent, handled at call site |
| Defensive null checks (nested ifs) | Functional chaining with map() and flatMap() |
This is critical to understand from the start. Optional is not a replacement for all nulls. It was specifically designed for one purpose: method return types where absence is a valid outcome.
Brian Goetz, Java Language Architect at Oracle, said it clearly:
“Optional is intended to provide a limited mechanism for library method return types where there is a clear need to represent ‘no result,’ and where using null for that is overwhelmingly likely to cause errors.”
import java.util.Optional;
public class OptionalBasicDemo {
public static void main(String[] args) {
// Before: returns null, caller must "just know" to check
String resultOld = findNicknameOld("Bob");
// resultOld.toUpperCase(); // NPE if Bob has no nickname!
// After: return type makes absence explicit
Optional resultNew = findNickname("Bob");
// resultNew.get().toUpperCase(); // You're reminded: it might be empty!
// The safe way:
String nickname = resultNew.orElse("No nickname");
System.out.println(nickname);
// Output: No nickname
}
// OLD: returns null -- no indication in the signature
static String findNicknameOld(String name) {
if (name.equals("Alice")) return "Ally";
return null;
}
// NEW: Optional makes absence explicit in the signature
static Optional findNickname(String name) {
if (name.equals("Alice")) return Optional.of("Ally");
return Optional.empty();
}
}
There are exactly three ways to create an Optional. Each has a specific purpose, and using the wrong one is a common source of bugs.
Use Optional.of() when you are absolutely certain the value is not null. If you pass null, it throws NullPointerException immediately. This is intentional — it fails fast rather than hiding a bug.
import java.util.Optional;
public class OptionalOfDemo {
public static void main(String[] args) {
// Use Optional.of() when you KNOW the value exists
Optional name = Optional.of("Alice");
System.out.println(name);
// Output: Optional[Alice]
// If you accidentally pass null, you get NPE immediately
// This is GOOD -- it fails fast at the source
try {
Optional bad = Optional.of(null);
} catch (NullPointerException e) {
System.out.println("Optional.of(null) throws NPE: " + e.getMessage());
}
// Output: Optional.of(null) throws NPE: null
// Real-world use: wrapping a value you just created or validated
String username = "admin";
if (username != null && !username.isEmpty()) {
Optional validUser = Optional.of(username); // safe: already checked
System.out.println("Valid user: " + validUser.get());
}
// Output: Valid user: admin
}
}
Use Optional.ofNullable() when the value might be null. If the value is non-null, it wraps it. If the value is null, it returns Optional.empty(). This is the most commonly used factory method.
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
public class OptionalOfNullableDemo {
public static void main(String[] args) {
// Map.get() returns null if key is not found
Map config = new HashMap<>();
config.put("timeout", "30");
// Wrap a possibly-null value
Optional timeout = Optional.ofNullable(config.get("timeout"));
Optional retries = Optional.ofNullable(config.get("retries"));
System.out.println("timeout: " + timeout);
// Output: timeout: Optional[30]
System.out.println("retries: " + retries);
// Output: retries: Optional.empty
// Safely use the values
System.out.println("Timeout value: " + timeout.orElse("10"));
// Output: Timeout value: 30
System.out.println("Retries value: " + retries.orElse("3"));
// Output: Retries value: 3
// Common pattern: wrap external/legacy API results
String systemProp = System.getProperty("app.mode"); // might be null
String mode = Optional.ofNullable(systemProp).orElse("development");
System.out.println("Mode: " + mode);
// Output: Mode: development
}
}
Use Optional.empty() to explicitly return “nothing.” This replaces return null in methods that return Optional.
import java.util.Optional;
import java.util.List;
public class OptionalEmptyDemo {
public static void main(String[] args) {
// Use Optional.empty() when a search/lookup finds nothing
Optional found = findUserById(1);
Optional notFound = findUserById(99);
System.out.println("ID 1: " + found);
// Output: ID 1: Optional[Alice]
System.out.println("ID 99: " + notFound);
// Output: ID 99: Optional.empty
// Pattern: conditional return
Optional result = isFeatureEnabled("dark-mode")
? Optional.of("enabled")
: Optional.empty();
System.out.println("Feature: " + result);
// Output: Feature: Optional.empty
}
static Optional findUserById(int id) {
List users = List.of("Alice", "Bob", "Charlie");
if (id >= 0 && id < users.size()) {
return Optional.of(users.get(id));
}
return Optional.empty(); // NOT return null!
}
static boolean isFeatureEnabled(String feature) {
return false; // simplified
}
}
| Method | When to Use | If Null Passed |
|---|---|---|
Optional.of(value) |
Value is guaranteed non-null (you just created it, validated it, or it comes from a non-null source) | Throws NullPointerException |
Optional.ofNullable(value) |
Value might be null (from a Map lookup, legacy API, database query, external input) | Returns Optional.empty() |
Optional.empty() |
You know there is no value (failed search, unmet condition, default case in switch) | N/A -- no argument |
Rule of thumb: If you are wrapping a value that came from somewhere else and you are not 100% sure it is non-null, use ofNullable(). If you just created the value yourself, use of(). If there is nothing to return, use empty().
isPresent() returns true if the Optional contains a value. isEmpty() (added in Java 11) is the logical opposite -- returns true if the Optional is empty. Both are simple boolean checks.
import java.util.Optional;
public class IsPresentDemo {
public static void main(String[] args) {
Optional full = Optional.of("Hello");
Optional empty = Optional.empty();
// isPresent() -- available since Java 8
System.out.println("full.isPresent(): " + full.isPresent());
// Output: full.isPresent(): true
System.out.println("empty.isPresent(): " + empty.isPresent());
// Output: empty.isPresent(): false
// isEmpty() -- available since Java 11
System.out.println("full.isEmpty(): " + full.isEmpty());
// Output: full.isEmpty(): false
System.out.println("empty.isEmpty(): " + empty.isEmpty());
// Output: empty.isEmpty(): true
// Conditional logic with isPresent
Optional username = findUsername("admin@test.com");
if (username.isPresent()) {
System.out.println("Found: " + username.get());
} else {
System.out.println("User not found");
}
// Output: Found: admin
}
static Optional findUsername(String email) {
if (email.contains("admin")) return Optional.of("admin");
return Optional.empty();
}
}
get() returns the contained value if present, or throws NoSuchElementException if empty. It is almost always wrong to call get() directly. It defeats the entire purpose of Optional by reintroducing the same problem as null -- an unchecked exception at runtime.
The Java API designers have acknowledged this was a design mistake. In fact, get() has been deprecated-for-replacement in more recent JDK builds, with orElseThrow() recommended as the explicit alternative when you truly want to throw if empty.
import java.util.NoSuchElementException;
import java.util.Optional;
public class GetAntiPatternDemo {
public static void main(String[] args) {
Optional empty = Optional.empty();
// BAD: get() on empty Optional throws NoSuchElementException
try {
String value = empty.get();
} catch (NoSuchElementException e) {
System.out.println("get() on empty: " + e.getClass().getSimpleName());
}
// Output: get() on empty: NoSuchElementException
// BAD: isPresent() + get() -- just a null check with extra steps
Optional name = Optional.of("Alice");
if (name.isPresent()) {
System.out.println(name.get()); // Works, but misses the point
}
// This is the SAME pattern as: if (name != null) { use(name); }
// You replaced a null check with an isPresent check. No improvement.
// GOOD alternatives (covered in detail in the next sections):
System.out.println(name.orElse("Unknown")); // default value
name.ifPresent(n -> System.out.println(n)); // consume if present
String upper = name.map(String::toUpperCase) // transform
.orElse("UNKNOWN");
System.out.println(upper);
// Output: Alice
// Output: Alice
// Output: ALICE
}
}
| Instead of | Use | Why |
|---|---|---|
opt.get() |
opt.orElse(default) |
Provides a safe fallback |
opt.get() |
opt.orElseThrow() |
Throws explicitly -- your intent is clear |
if (opt.isPresent()) opt.get() |
opt.ifPresent(value -> ...) |
Cleaner, no redundant check |
if (opt.isPresent()) return opt.get().getX() |
opt.map(v -> v.getX()) |
Functional transformation |
When an Optional is empty, you usually want a fallback value. Java provides three methods for this, and understanding the difference between them -- especially eager vs lazy evaluation -- is critical.
orElse() returns the contained value if present, or the provided default value if empty. The default value is always evaluated, even when the Optional is not empty.
import java.util.Optional;
public class OrElseDemo {
public static void main(String[] args) {
Optional present = Optional.of("Alice");
Optional empty = Optional.empty();
// orElse with a present Optional -- returns the contained value
String name1 = present.orElse("Default");
System.out.println(name1);
// Output: Alice
// orElse with an empty Optional -- returns the default
String name2 = empty.orElse("Default");
System.out.println(name2);
// Output: Default
// Great for configuration defaults
String port = Optional.ofNullable(System.getenv("PORT")).orElse("8080");
System.out.println("Server port: " + port);
// Output: Server port: 8080
// Great for display values
String displayName = Optional.ofNullable(getUserNickname())
.orElse("Anonymous");
System.out.println("Display: " + displayName);
// Output: Display: Anonymous
}
static String getUserNickname() {
return null; // user has no nickname
}
}
orElseGet() takes a Supplier<T> that is only called when the Optional is empty. This is the lazy alternative to orElse(). Use it when the default value is expensive to compute (database query, network call, complex calculation).
import java.util.Optional;
public class OrElseGetDemo {
public static void main(String[] args) {
Optional present = Optional.of("Alice");
Optional empty = Optional.empty();
// orElseGet with a present Optional -- supplier is NEVER called
String name1 = present.orElseGet(() -> {
System.out.println("Computing default...");
return "Default";
});
System.out.println(name1);
// Output: Alice
// Note: "Computing default..." was NOT printed
// orElseGet with an empty Optional -- supplier IS called
String name2 = empty.orElseGet(() -> {
System.out.println("Computing default...");
return "Default";
});
// Output: Computing default...
System.out.println(name2);
// Output: Default
// Real-world: expensive fallback
Optional cachedConfig = Optional.empty();
String config = cachedConfig.orElseGet(() -> loadConfigFromDatabase());
System.out.println("Config: " + config);
// Output: Config: default-config-from-db
}
static String loadConfigFromDatabase() {
// Simulate expensive DB query
System.out.println(" (querying database...)");
return "default-config-from-db";
}
}
This is the most commonly misunderstood aspect of Optional. orElse() evaluates its argument eagerly -- the default is computed regardless of whether the Optional is empty or not. This can cause bugs when the default has side effects.
import java.util.Optional;
public class EagerVsLazyDemo {
public static void main(String[] args) {
System.out.println("===== orElse() -- EAGER evaluation =====");
Optional present = Optional.of("Alice");
// Even though Optional has a value, the fallback method STILL runs
String result1 = present.orElse(expensiveDefault());
System.out.println("Result: " + result1);
// Output:
// Expensive operation executed!
// Result: Alice
// The expensive operation ran UNNECESSARILY.
System.out.println();
System.out.println("===== orElseGet() -- LAZY evaluation =====");
// With orElseGet, the supplier is NOT called when value is present
String result2 = present.orElseGet(() -> expensiveDefault());
System.out.println("Result: " + result2);
// Output:
// Result: Alice
// The expensive operation did NOT run. Much better.
System.out.println();
System.out.println("===== Both with empty Optional =====");
Optional empty = Optional.empty();
String r1 = empty.orElse(expensiveDefault());
System.out.println("orElse: " + r1);
// Output:
// Expensive operation executed!
// orElse: fallback-value
String r2 = empty.orElseGet(() -> expensiveDefault());
System.out.println("orElseGet: " + r2);
// Output:
// Expensive operation executed!
// orElseGet: fallback-value
// When empty, both execute the default. The difference only matters
// when the Optional HAS a value.
}
static String expensiveDefault() {
System.out.println(" Expensive operation executed!");
return "fallback-value";
}
}
| Aspect | orElse(value) |
orElseGet(supplier) |
|---|---|---|
| Evaluation | Eager -- always computed | Lazy -- only when empty |
| Use when default is | A simple constant or already-computed value | Expensive (DB, network, calculation) |
| Side effects | Dangerous -- runs even when not needed | Safe -- only runs when needed |
| Performance | Wasteful if default is expensive and Optional is present | Optimal -- no unnecessary computation |
| Example | .orElse("N/A") |
.orElseGet(() -> queryDB()) |
Sometimes an empty Optional means something went wrong. orElseThrow() lets you throw a specific exception. Java 10 added a no-arg version that throws NoSuchElementException.
import java.util.Optional;
public class OrElseThrowDemo {
public static void main(String[] args) {
Optional user = findUser("admin@test.com");
// orElseThrow with custom exception (Java 8+)
String name = user.orElseThrow(
() -> new IllegalArgumentException("User not found: admin@test.com")
);
System.out.println("Found: " + name);
// Output: Found: admin
// orElseThrow no-arg version (Java 10+)
// Throws NoSuchElementException with no message
Optional empty = Optional.empty();
try {
empty.orElseThrow(); // Java 10+
} catch (Exception e) {
System.out.println("No-arg: " + e.getClass().getSimpleName());
}
// Output: No-arg: NoSuchElementException
// Common pattern: service layer validation
// User currentUser = userRepository.findById(userId)
// .orElseThrow(() -> new UserNotFoundException(userId));
}
static Optional findUser(String email) {
if (email.contains("admin")) return Optional.of("admin");
return Optional.empty();
}
}
or() was added in Java 9. When the current Optional is empty, it returns the Optional produced by the supplier. This allows chaining fallback sources.
import java.util.Optional;
public class OrMethodDemo {
public static void main(String[] args) {
// or() chains multiple Optional sources (Java 9+)
// Try each source in order until one has a value
String result = findInCache("theme")
.or(() -> findInDatabase("theme"))
.or(() -> findInDefaults("theme"))
.orElse("system-default");
System.out.println("Theme: " + result);
// Output:
// Cache miss for: theme
// DB miss for: theme
// Default found for: theme
// Theme: dark-mode
// When the first source has a value, later sources are skipped
String result2 = findInCache("language")
.or(() -> findInDatabase("language"))
.or(() -> findInDefaults("language"))
.orElse("en");
System.out.println("Language: " + result2);
// Output:
// Cache hit for: language
// Language: en-US
}
static Optional findInCache(String key) {
if (key.equals("language")) {
System.out.println(" Cache hit for: " + key);
return Optional.of("en-US");
}
System.out.println(" Cache miss for: " + key);
return Optional.empty();
}
static Optional findInDatabase(String key) {
System.out.println(" DB miss for: " + key);
return Optional.empty();
}
static Optional findInDefaults(String key) {
if (key.equals("theme")) {
System.out.println(" Default found for: " + key);
return Optional.of("dark-mode");
}
return Optional.empty();
}
}
Optional's real power comes from its functional transformation methods: map(), flatMap(), and filter(). These let you chain operations on the contained value without ever unwrapping the Optional or writing if statements.
map() takes a function, applies it to the contained value (if present), and wraps the result in a new Optional. If the original Optional is empty, map() returns Optional.empty() without calling the function.
import java.util.Optional;
public class MapDemo {
public static void main(String[] args) {
Optional name = Optional.of("alice");
Optional empty = Optional.empty();
// map transforms the value inside the Optional
Optional upper = name.map(String::toUpperCase);
System.out.println(upper);
// Output: Optional[ALICE]
// map on empty returns empty -- function is never called
Optional upperEmpty = empty.map(String::toUpperCase);
System.out.println(upperEmpty);
// Output: Optional.empty
// Chaining multiple maps
Optional length = name
.map(String::trim)
.map(String::toUpperCase)
.map(String::length);
System.out.println("Length: " + length.orElse(0));
// Output: Length: 5
// map with method that returns a different type
Optional email = Optional.of("alice@example.com");
Optional domain = email.map(e -> e.substring(e.indexOf('@') + 1));
System.out.println("Domain: " + domain.orElse("unknown"));
// Output: Domain: example.com
// Before Optional (ugly null chain):
// String domain = null;
// if (email != null) {
// domain = email.substring(email.indexOf('@') + 1);
// }
}
}
When your transformation function itself returns an Optional, using map() would produce a nested Optional<Optional<T>>. flatMap() solves this by "flattening" the result into a single Optional<T>.
This is essential when chaining methods that return Optional -- for example, navigating a chain of objects where each getter might return Optional.
import java.util.Optional;
public class FlatMapDemo {
public static void main(String[] args) {
// Setup: User -> Address -> ZipCode, each getter returns Optional
User user = new User("Alice",
new Address("123 Main St",
new ZipCode("90210")));
User userNoAddress = new User("Bob", null);
// PROBLEM with map(): nested Optionals
Optional>> nested = Optional.of(user)
.map(User::getAddress) // Optional>
.map(opt -> opt.map(Address::getZipCode)); // gets ugly fast
// This is unworkable.
// SOLUTION with flatMap(): flat chain
String zip1 = Optional.of(user)
.flatMap(User::getAddress) // Optional
.flatMap(Address::getZipCode) // Optional
.map(ZipCode::getCode) // Optional
.orElse("N/A");
System.out.println("Alice's zip: " + zip1);
// Output: Alice's zip: 90210
// Same chain with a user who has no address
String zip2 = Optional.of(userNoAddress)
.flatMap(User::getAddress) // Optional.empty
.flatMap(Address::getZipCode) // skipped
.map(ZipCode::getCode) // skipped
.orElse("N/A");
System.out.println("Bob's zip: " + zip2);
// Output: Bob's zip: N/A
// No NPE, no null checks, clean chain.
}
}
class User {
private String name;
private Address address;
User(String name, Address address) {
this.name = name;
this.address = address;
}
Optional getAddress() {
return Optional.ofNullable(address);
}
String getName() { return name; }
}
class Address {
private String street;
private ZipCode zipCode;
Address(String street, ZipCode zipCode) {
this.street = street;
this.zipCode = zipCode;
}
Optional getZipCode() {
return Optional.ofNullable(zipCode);
}
String getStreet() { return street; }
}
class ZipCode {
private String code;
ZipCode(String code) { this.code = code; }
String getCode() { return code; }
}
| Use | When | Function Returns | Result |
|---|---|---|---|
map(f) |
Transformation returns a plain value | T -> R |
Optional<R> |
flatMap(f) |
Transformation returns an Optional | T -> Optional<R> |
Optional<R> |
If the function returns Optional, use flatMap(). If it returns anything else, use map().
filter() checks if the contained value matches a predicate. If it does, the Optional is returned as-is. If it does not, an empty Optional is returned. If the Optional is already empty, filter() returns empty without calling the predicate.
import java.util.Optional;
public class FilterDemo {
public static void main(String[] args) {
Optional name = Optional.of("Alice");
// filter keeps the value only if it matches the predicate
Optional startsWithA = name.filter(n -> n.startsWith("A"));
System.out.println(startsWithA);
// Output: Optional[Alice]
Optional startsWithB = name.filter(n -> n.startsWith("B"));
System.out.println(startsWithB);
// Output: Optional.empty
// Practical: validate input
Optional password = Optional.of("Str0ngP@ss!");
boolean isValid = password
.filter(p -> p.length() >= 8)
.filter(p -> p.matches(".*[A-Z].*"))
.filter(p -> p.matches(".*[0-9].*"))
.filter(p -> p.matches(".*[!@#$%^&*].*"))
.isPresent();
System.out.println("Password valid: " + isValid);
// Output: Password valid: true
// Chaining filter with map
Optional age = Optional.of(25);
String category = age
.filter(a -> a >= 18)
.map(a -> "Adult")
.orElse("Minor");
System.out.println("Category: " + category);
// Output: Category: Adult
// filter on empty Optional -- predicate never called
Optional empty = Optional.empty();
Optional filtered = empty.filter(s -> {
System.out.println("This never prints");
return true;
});
System.out.println(filtered);
// Output: Optional.empty
}
}
The real elegance of Optional appears when you chain map(), flatMap(), and filter() together. Compare the before and after:
import java.util.Optional;
import java.util.Map;
import java.util.HashMap;
public class ChainingDemo {
public static void main(String[] args) {
Map userEmails = new HashMap<>();
userEmails.put("alice", "Alice.Smith@Company.COM");
userEmails.put("bob", "");
// BEFORE Optional: nested null checks + validation
// String email = userEmails.get("alice");
// String domain = null;
// if (email != null) {
// email = email.trim();
// if (!email.isEmpty()) {
// email = email.toLowerCase();
// int at = email.indexOf('@');
// if (at >= 0) {
// domain = email.substring(at + 1);
// }
// }
// }
// if (domain == null) domain = "unknown";
// AFTER Optional: clean chain
String domain = Optional.ofNullable(userEmails.get("alice"))
.map(String::trim)
.filter(e -> !e.isEmpty())
.map(String::toLowerCase)
.filter(e -> e.contains("@"))
.map(e -> e.substring(e.indexOf('@') + 1))
.orElse("unknown");
System.out.println("Alice domain: " + domain);
// Output: Alice domain: company.com
// Same chain with empty email
String domain2 = Optional.ofNullable(userEmails.get("bob"))
.map(String::trim)
.filter(e -> !e.isEmpty()) // "" fails here -> empty
.map(String::toLowerCase) // skipped
.filter(e -> e.contains("@")) // skipped
.map(e -> e.substring(e.indexOf('@') + 1)) // skipped
.orElse("unknown");
System.out.println("Bob domain: " + domain2);
// Output: Bob domain: unknown
// Same chain with unknown user
String domain3 = Optional.ofNullable(userEmails.get("charlie"))
.map(String::trim) // null from map -> empty
.filter(e -> !e.isEmpty()) // skipped
.map(String::toLowerCase) // skipped
.filter(e -> e.contains("@")) // skipped
.map(e -> e.substring(e.indexOf('@') + 1)) // skipped
.orElse("unknown");
System.out.println("Charlie domain: " + domain3);
// Output: Charlie domain: unknown
}
}
ifPresent() accepts a Consumer<T> that is called only when the Optional contains a value. If the Optional is empty, nothing happens. This is the ideal replacement for the if (x != null) { use(x); } pattern.
import java.util.Optional;
public class IfPresentDemo {
public static void main(String[] args) {
Optional present = Optional.of("Alice");
Optional empty = Optional.empty();
// ifPresent with lambda
present.ifPresent(name -> System.out.println("Hello, " + name + "!"));
// Output: Hello, Alice!
// Nothing happens for empty Optional
empty.ifPresent(name -> System.out.println("This never prints"));
// (no output)
// ifPresent with method reference
Optional email = Optional.of("alice@test.com");
email.ifPresent(System.out::println);
// Output: alice@test.com
// Practical: save only if value exists
Optional userInput = getUserInput();
userInput.ifPresent(input -> {
System.out.println("Saving: " + input);
// saveToDatabase(input);
});
// Output: Saving: user-provided-value
}
static Optional getUserInput() {
return Optional.of("user-provided-value");
}
}
ifPresentOrElse() was added in Java 9. It handles both cases: if the value is present, the consumer runs; if empty, the empty-action runnable runs. This replaces the if/else pattern completely.
import java.util.Optional;
public class IfPresentOrElseDemo {
public static void main(String[] args) {
Optional present = Optional.of("Alice");
Optional empty = Optional.empty();
// Java 9+: handle both present and empty cases
present.ifPresentOrElse(
name -> System.out.println("Welcome back, " + name + "!"),
() -> System.out.println("Welcome, guest!")
);
// Output: Welcome back, Alice!
empty.ifPresentOrElse(
name -> System.out.println("Welcome back, " + name + "!"),
() -> System.out.println("Welcome, guest!")
);
// Output: Welcome, guest!
// Practical: logging
Optional config = loadConfig("database.url");
config.ifPresentOrElse(
url -> System.out.println("Using database: " + url),
() -> System.out.println("WARNING: No database URL configured, using default")
);
// Output: WARNING: No database URL configured, using default
// Before Java 9, you had to write:
// if (config.isPresent()) {
// System.out.println("Using: " + config.get());
// } else {
// System.out.println("WARNING: not configured");
// }
}
static Optional loadConfig(String key) {
return Optional.empty();
}
}
Optional and Streams were both introduced in Java 8 and are designed to work together. Java 9 strengthened this integration with Optional.stream().
stream() converts an Optional into a Stream: a one-element stream if present, an empty stream if not. This is extremely useful when you have a collection of Optionals and want to extract only the present values.
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
public class OptionalStreamDemo {
public static void main(String[] args) {
// Optional.stream() -- Java 9+
Optional present = Optional.of("Alice");
Optional empty = Optional.empty();
present.stream().forEach(System.out::println);
// Output: Alice
empty.stream().forEach(System.out::println);
// (no output -- empty stream)
// Real power: filtering a list of Optionals
List> results = List.of(
Optional.of("Alice"),
Optional.empty(),
Optional.of("Bob"),
Optional.empty(),
Optional.of("Charlie")
);
// Java 9+: use Optional.stream() with flatMap
List names = results.stream()
.flatMap(Optional::stream)
.collect(Collectors.toList());
System.out.println("Names: " + names);
// Output: Names: [Alice, Bob, Charlie]
// Before Java 9, you had to write:
// List names = results.stream()
// .filter(Optional::isPresent)
// .map(Optional::get)
// .collect(Collectors.toList());
}
}
Several Stream terminal operations return Optionals: findFirst(), findAny(), min(), max(), and reduce(). These are natural places to use Optional's functional methods.
import java.util.Arrays;
import java.util.Comparator;
import java.util.List;
import java.util.Optional;
public class StreamOptionalDemo {
public static void main(String[] args) {
List names = Arrays.asList("Alice", "Bob", "Charlie", "David");
List empty = List.of();
// findFirst() returns Optional
Optional first = names.stream()
.filter(n -> n.startsWith("C"))
.findFirst();
System.out.println("First C-name: " + first.orElse("none"));
// Output: First C-name: Charlie
// min() returns Optional
Optional shortest = names.stream()
.min(Comparator.comparingInt(String::length));
shortest.ifPresent(s -> System.out.println("Shortest: " + s));
// Output: Shortest: Bob
// reduce() returns Optional (when no identity)
Optional sum = names.stream()
.map(String::length)
.reduce(Integer::sum);
System.out.println("Total chars: " + sum.orElse(0));
// Output: Total chars: 20
// findFirst on empty stream returns Optional.empty
Optional fromEmpty = empty.stream()
.findFirst();
System.out.println("From empty: " + fromEmpty);
// Output: From empty: Optional.empty
// Chain stream result with Optional operations
String result = names.stream()
.filter(n -> n.length() > 10)
.findFirst()
.map(String::toUpperCase)
.orElse("No long names found");
System.out.println(result);
// Output: No long names found
}
}
A common real-world scenario: you have a list of objects, each with a method that returns Optional. You want to collect only the present values into a flat list.
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
public class StreamFlatMapOptionalDemo {
public static void main(String[] args) {
List employees = List.of(
new Employee("Alice", "Engineering"),
new Employee("Bob", null), // no department
new Employee("Charlie", "Marketing"),
new Employee("David", null), // no department
new Employee("Eve", "Engineering")
);
// Collect only assigned departments (unique)
List departments = employees.stream()
.map(Employee::getDepartment) // Stream>
.flatMap(Optional::stream) // Stream -- empties removed
.distinct()
.sorted()
.collect(Collectors.toList());
System.out.println("Active departments: " + departments);
// Output: Active departments: [Engineering, Marketing]
// Count employees with departments
long assigned = employees.stream()
.map(Employee::getDepartment)
.filter(Optional::isPresent)
.count();
System.out.println("Employees with departments: " + assigned + "/" + employees.size());
// Output: Employees with departments: 3/5
}
}
class Employee {
private String name;
private String department;
Employee(String name, String department) {
this.name = name;
this.department = department;
}
String getName() { return name; }
Optional getDepartment() {
return Optional.ofNullable(department);
}
}
The most important aspect of Optional is not how to use its methods, but where to use Optional and where not to. This section covers the API design rules that the Java team intended.
This is the primary and intended use of Optional: when a method might legitimately return no value.
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
public class ReturnTypeDemo {
private static Map users = new HashMap<>(Map.of(
1, "Alice",
2, "Bob"
));
// GOOD: return Optional when "not found" is a normal outcome
public static Optional findById(int id) {
return Optional.ofNullable(users.get(id));
}
// GOOD: search that may find nothing
public static Optional findByNamePrefix(String prefix) {
return users.values().stream()
.filter(name -> name.startsWith(prefix))
.findFirst();
}
// BAD: do NOT return Optional when there is always a result
// public static Optional> getAllUsers() -- WRONG
// Return an empty list instead!
// public static List getAllUsers() -- CORRECT
public static void main(String[] args) {
System.out.println(findById(1).orElse("Not found"));
// Output: Alice
System.out.println(findById(99).orElse("Not found"));
// Output: Not found
findByNamePrefix("A").ifPresent(n -> System.out.println("Found: " + n));
// Output: Found: Alice
}
}
Using Optional as a method parameter is an anti-pattern. It forces callers to wrap their values, adds complexity, and the caller already knows whether they have a value or not. Use method overloading instead.
import java.util.Optional;
public class ParameterAntiPatternDemo {
// BAD: Optional as parameter
// Forces callers to write: sendEmail(Optional.of("Custom Subject"))
// or sendEmail(Optional.empty())
// Callers already know if they have a subject or not!
static void sendEmailBad(String to, Optional subject) {
String subj = subject.orElse("No Subject");
System.out.println("To: " + to + ", Subject: " + subj);
}
// GOOD: method overloading
static void sendEmail(String to) {
sendEmail(to, "No Subject");
}
static void sendEmail(String to, String subject) {
System.out.println("To: " + to + ", Subject: " + subject);
}
public static void main(String[] args) {
// BAD: caller is forced to wrap
sendEmailBad("alice@test.com", Optional.of("Hello"));
sendEmailBad("bob@test.com", Optional.empty());
// GOOD: clean calls
sendEmail("alice@test.com", "Hello");
sendEmail("bob@test.com");
// Output:
// To: alice@test.com, Subject: Hello
// To: bob@test.com, Subject: No Subject
// To: alice@test.com, Subject: Hello
// To: bob@test.com, Subject: No Subject
}
}
Optional was intentionally designed to not implement Serializable. This was a deliberate choice by the Java team to discourage its use as a field type. Using Optional as a field wastes memory (extra object allocation), breaks serialization frameworks (Jackson, JPA, etc.), and is not what the API was designed for.
import java.util.Optional;
public class FieldAntiPatternDemo {
// BAD: Optional as a field
// - Not Serializable
// - Extra memory overhead (wraps every value in an object)
// - Breaks JPA, Jackson, and most serialization frameworks
static class UserBad {
private String name;
private Optional nickname; // DON'T DO THIS
UserBad(String name, String nickname) {
this.name = name;
this.nickname = Optional.ofNullable(nickname);
}
}
// GOOD: nullable field with Optional getter
// The field is a plain nullable type. The Optional is used only
// in the return type of the getter, which is its intended purpose.
static class UserGood {
private String name;
private String nickname; // nullable -- that's fine
UserGood(String name, String nickname) {
this.name = name;
this.nickname = nickname;
}
public String getName() { return name; }
// Return Optional from getter -- this IS the intended use
public Optional getNickname() {
return Optional.ofNullable(nickname);
}
}
public static void main(String[] args) {
UserGood user = new UserGood("Alice", null);
user.getNickname().ifPresentOrElse(
nick -> System.out.println("Nickname: " + nick),
() -> System.out.println("No nickname set")
);
// Output: No nickname set
}
}
| Context | Use Optional? | Why |
|---|---|---|
| Method return type (value may be absent) | Yes | This is its designed purpose |
| Method parameter | No | Use overloading or @Nullable |
| Class field / instance variable | No | Not Serializable, memory overhead |
| Constructor parameter | No | Same as method parameter |
| Collection element | No | Use filter to remove nulls instead |
| Map key or value | No | Use Map.getOrDefault() or computeIfAbsent() |
| Return type with collections | No | Return empty collection, not Optional<List> |
| Getter for potentially absent data | Yes | Return Optional from getter, store field as nullable |
Optional is one of the most misused features in Java. Here are eight anti-patterns that experienced developers still fall into.
This is the #1 Optional anti-pattern. It is just a null check with extra steps and completely defeats the purpose of Optional.
import java.util.Optional;
public class AntiPattern1Demo {
public static void main(String[] args) {
Optional name = Optional.of("Alice");
// BAD: isPresent + get = null check with extra steps
if (name.isPresent()) {
System.out.println("Hello, " + name.get());
} else {
System.out.println("Hello, stranger");
}
// This is identical to:
// if (name != null) { use(name); } else { useDefault(); }
// You gained NOTHING from Optional.
// GOOD: use functional methods
// Option A: ifPresentOrElse (Java 9+)
name.ifPresentOrElse(
n -> System.out.println("Hello, " + n),
() -> System.out.println("Hello, stranger")
);
// Option B: map + orElse
String greeting = name.map(n -> "Hello, " + n)
.orElse("Hello, stranger");
System.out.println(greeting);
// Output:
// Hello, Alice
// Hello, Alice
// Hello, Alice
}
}
Calling Optional.of(null) throws NullPointerException. If the value might be null, always use ofNullable().
import java.util.Optional;
import java.util.Map;
public class AntiPattern2Demo {
public static void main(String[] args) {
Map config = Map.of("host", "localhost");
// BAD: Map.get() can return null -- this throws NPE!
try {
Optional port = Optional.of(config.get("port")); // NPE!
} catch (NullPointerException e) {
System.out.println("NPE from Optional.of(null)!");
}
// Output: NPE from Optional.of(null)!
// GOOD: use ofNullable when value might be null
Optional port = Optional.ofNullable(config.get("port"));
System.out.println("Port: " + port.orElse("8080"));
// Output: Port: 8080
}
}
Already covered in section 9.3. Optional is not Serializable, wastes memory, and breaks most frameworks.
Already covered in section 9.2. Use method overloading or @Nullable annotations instead.
Never store Optionals in a List, Set, or Map. Filter out nulls instead.
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.stream.Collectors;
public class AntiPattern5Demo {
public static void main(String[] args) {
// BAD: List of Optionals
List> bad = new ArrayList<>();
bad.add(Optional.of("Alice"));
bad.add(Optional.empty());
bad.add(Optional.of("Bob"));
// Now you have to unwrap every element when using the list.
// GOOD: filter out nulls, keep a clean list
List rawData = List.of("Alice", "", "Bob", "", "Charlie");
List good = rawData.stream()
.filter(s -> !s.isEmpty())
.collect(Collectors.toList());
System.out.println("Clean list: " + good);
// Output: Clean list: [Alice, Bob, Charlie]
// GOOD: filter nulls from a list that may contain null
List withNulls = new ArrayList<>();
withNulls.add("Alice");
withNulls.add(null);
withNulls.add("Bob");
List filtered = withNulls.stream()
.filter(Objects::nonNull)
.collect(Collectors.toList());
System.out.println("Filtered: " + filtered);
// Output: Filtered: [Alice, Bob]
}
}
Some developers, after learning about Optional, try to use it for every single variable. Optional has a purpose: method return types where absence is valid. Do not wrap method parameters, local variables, or fields in Optional.
import java.util.Optional;
public class AntiPattern6Demo {
public static void main(String[] args) {
// BAD: wrapping local variables
Optional name = Optional.of("Alice");
Optional age = Optional.of(30);
System.out.println(name.get() + " is " + age.get());
// This is absurd. You KNOW these values are not null.
// You just created them on the previous line!
// GOOD: use plain variables when you know the value exists
String name2 = "Alice";
int age2 = 30;
System.out.println(name2 + " is " + age2);
// Output:
// Alice is 30
// Alice is 30
}
}
Because orElse() is eagerly evaluated, putting a method call with side effects inside it is dangerous. The method runs even when the Optional has a value.
import java.util.Optional;
public class AntiPattern7Demo {
private static int counter = 0;
public static void main(String[] args) {
Optional name = Optional.of("Alice");
// BAD: side effect in orElse -- runs even though Optional is present
String result = name.orElse(createDefaultUser());
System.out.println("Result: " + result);
System.out.println("Counter: " + counter); // counter was incremented!
// Output:
// Creating default user...
// Result: Alice
// Counter: 1
counter = 0; // reset
// GOOD: use orElseGet for methods with side effects
String result2 = name.orElseGet(() -> createDefaultUser());
System.out.println("Result2: " + result2);
System.out.println("Counter2: " + counter); // counter was NOT incremented
// Output:
// Result2: Alice
// Counter2: 0
}
static String createDefaultUser() {
System.out.println(" Creating default user...");
counter++;
return "DefaultUser";
}
}
If your method signature promises Optional<T>, never return null. This is the worst possible pattern because callers trust that they can safely call methods on the return value without null checking.
import java.util.Optional;
public class AntiPattern8Demo {
// BAD: method says Optional but returns null
static Optional findUserBad(int id) {
if (id < 0) return null; // TERRIBLE -- breaks the contract
if (id == 1) return Optional.of("Alice");
return Optional.empty();
}
// GOOD: always return Optional.empty(), never null
static Optional findUserGood(int id) {
if (id < 0) return Optional.empty(); // correct!
if (id == 1) return Optional.of("Alice");
return Optional.empty();
}
public static void main(String[] args) {
// Caller trusts the Optional contract
try {
String name = findUserBad(-1).orElse("Unknown");
// NPE! Because findUserBad returned null, not Optional.empty()
} catch (NullPointerException e) {
System.out.println("NPE because method returned null instead of Optional.empty()");
}
// Output: NPE because method returned null instead of Optional.empty()
// With the good version, everything works
String name = findUserGood(-1).orElse("Unknown");
System.out.println("Good: " + name);
// Output: Good: Unknown
}
}
| # | Anti-Pattern | Why It Is Wrong | Correct Approach |
|---|---|---|---|
| 1 | isPresent() + get() |
Just a null check with more code | Use ifPresent(), map(), orElse() |
| 2 | Optional.of(null) |
Throws NPE immediately | Use Optional.ofNullable() |
| 3 | Optional as field | Not Serializable, memory overhead | Nullable field + Optional getter |
| 4 | Optional as parameter | Forces caller to wrap | Method overloading |
| 5 | Optional in collections | Every element needs unwrapping | Filter out nulls |
| 6 | Wrapping everything | Unnecessary overhead, hides intent | Use only for return types |
| 7 | orElse() with side effects |
Runs even when value is present | Use orElseGet() |
| 8 | Returning null from Optional method |
Breaks the Optional contract | Return Optional.empty() |
Java provides three specialized Optional classes for primitives: OptionalInt, OptionalLong, and OptionalDouble. These avoid the autoboxing overhead of wrapping primitives in Optional<Integer>, Optional<Long>, and Optional<Double>.
import java.util.OptionalDouble;
import java.util.OptionalInt;
import java.util.OptionalLong;
import java.util.stream.IntStream;
public class PrimitiveOptionalDemo {
public static void main(String[] args) {
// OptionalInt
OptionalInt maxAge = findMaxAge(new int[]{25, 30, 22, 35, 28});
maxAge.ifPresent(age -> System.out.println("Max age: " + age));
// Output: Max age: 35
OptionalInt emptyMax = findMaxAge(new int[]{});
System.out.println("Empty max: " + emptyMax.orElse(-1));
// Output: Empty max: -1
// OptionalLong
OptionalLong fileSize = getFileSize("/some/file.txt");
System.out.println("File size: " + fileSize.orElse(0L) + " bytes");
// Output: File size: 0 bytes
// OptionalDouble
OptionalDouble average = IntStream.of(10, 20, 30).average();
System.out.println("Average: " + average.orElse(0.0));
// Output: Average: 20.0
OptionalDouble emptyAvg = IntStream.of().average();
System.out.println("Empty average: " + emptyAvg.orElse(0.0));
// Output: Empty average: 0.0
// Key differences from Optional:
// - getAsInt(), getAsLong(), getAsDouble() instead of get()
// - No map(), flatMap(), filter() -- these are limited types
// - No or() method
// - Primarily returned by IntStream, LongStream, DoubleStream operations
// When to use primitive Optionals:
// - When working with streams of primitives (IntStream, etc.)
// - When performance matters and you want to avoid autoboxing
// - When the Optional comes from stream.max(), stream.min(), stream.average()
}
static OptionalInt findMaxAge(int[] ages) {
return IntStream.of(ages).max();
}
static OptionalLong getFileSize(String path) {
// Simplified: would normally check if file exists
return OptionalLong.empty();
}
}
| Primitive Optional | Value Method | Default Method | Typical Source |
|---|---|---|---|
OptionalInt |
getAsInt() |
orElse(int) |
IntStream.max(), .min(), .findFirst() |
OptionalLong |
getAsLong() |
orElse(long) |
LongStream.max(), .min(), .findFirst() |
OptionalDouble |
getAsDouble() |
orElse(double) |
IntStream.average(), DoubleStream.max() |
Note: Primitive Optionals have a much more limited API than Optional<T>. They lack map(), flatMap(), filter(), or(), and stream(). If you need these operations, use Optional<Integer> etc. and accept the autoboxing cost.
These rules summarize how experienced Java developers use Optional. Each one comes from real-world pain.
Optional was designed for method return types where absence is a valid outcome. Do not use it for parameters, fields, or constructor arguments.
If your method returns Optional<T>, returning null violates the contract. Always return Optional.empty() instead.
Use map(), flatMap(), filter(), ifPresent(), orElse(), and orElseGet() instead of the imperative isPresent() check.
If the fallback value involves computation, network calls, or database queries, always use orElseGet() (lazy) instead of orElse() (eager).
If a method returns a list, set, or map, return an empty collection when there are no results. Optional<List<T>> adds no value -- the empty list already represents "no results."
When absence is an error (not a normal case), use orElseThrow() with a meaningful exception. This is clearer than get().
Optional's map()/flatMap()/filter() chain replaces the nested-null-check pyramid. Write declarative chains instead of imperative nesting.
Do not wrap values that you know are never null. Do not use Optional for performance-sensitive code paths (it creates an extra object). Primitive Optionals (OptionalInt, etc.) avoid boxing but have a limited API.
| # | Do | Do Not |
|---|---|---|
| 1 | Optional<User> findById(int id) |
void process(Optional<User> user) |
| 2 | return Optional.empty() |
return null from an Optional method |
| 3 | opt.map(User::getName).orElse("N/A") |
if (opt.isPresent()) return opt.get().getName() |
| 4 | opt.orElseGet(() -> loadFromDB()) |
opt.orElse(loadFromDB()) when DB call is expensive |
| 5 | List<User> findAll() returning empty list |
Optional<List<User>> findAll() |
| 6 | opt.orElseThrow(() -> new NotFoundException(id)) |
opt.get() hoping it is present |
| 7 | opt.flatMap(User::getAddress).map(Address::getCity) |
Nested if (user != null) { if (addr != null) { ... } } |
| 8 | Plain String name = "Alice" |
Optional<String> name = Optional.of("Alice") |
This example builds a user profile system that demonstrates every Optional concept covered in this tutorial. The domain model uses nullable fields with Optional getters (the correct pattern), and the service layer chains Optional operations to safely navigate the data.
import java.util.*;
import java.util.stream.Collectors;
// ===== DOMAIN MODEL =====
// Fields are nullable. Getters return Optional. This is the correct pattern.
class City {
private String name;
private String zipCode;
City(String name, String zipCode) {
this.name = name;
this.zipCode = zipCode;
}
String getName() { return name; }
String getZipCode() { return zipCode; }
}
class Address {
private String street;
private City city;
Address(String street, City city) {
this.street = street;
this.city = city;
}
String getStreet() { return street; }
Optional getCity() { return Optional.ofNullable(city); }
}
class UserProfile {
private String name;
private String email;
private String nickname;
private Address address;
private String phoneNumber;
UserProfile(String name, String email, String nickname,
Address address, String phoneNumber) {
this.name = name;
this.email = email;
this.nickname = nickname;
this.address = address;
this.phoneNumber = phoneNumber;
}
String getName() { return name; }
String getEmail() { return email; }
Optional getNickname() { return Optional.ofNullable(nickname); }
Optional getAddress() { return Optional.ofNullable(address); }
Optional getPhoneNumber() { return Optional.ofNullable(phoneNumber); }
}
// ===== REPOSITORY =====
class UserRepository {
private Map users = new HashMap<>();
UserRepository() {
users.put(1, new UserProfile(
"Alice Smith", "alice@example.com", "Ally",
new Address("123 Main St", new City("Springfield", "62701")),
"555-0101"
));
users.put(2, new UserProfile(
"Bob Johnson", "bob@example.com", null,
new Address("456 Oak Ave", null), // no city
null // no phone
));
users.put(3, new UserProfile(
"Charlie Brown", "charlie@example.com", "Chuck",
null, // no address at all
"555-0303"
));
users.put(4, new UserProfile(
"Diana Prince", "diana@example.com", null,
new Address("789 Hero Blvd", new City("Metropolis", "10001")),
null
));
}
// Returns Optional because user might not exist
Optional findById(int id) {
return Optional.ofNullable(users.get(id));
}
// Returns List (never Optional) -- empty list = no results
List findAll() {
return new ArrayList<>(users.values());
}
}
// ===== SERVICE =====
class UserService {
private UserRepository repository;
UserService(UserRepository repository) {
this.repository = repository;
}
// map() chain: User -> Address -> City -> name
String getCityName(int userId) {
return repository.findById(userId)
.flatMap(UserProfile::getAddress)
.flatMap(Address::getCity)
.map(City::getName)
.orElse("Unknown city");
}
// flatMap + map chain: User -> Address -> City -> zipCode
String getZipCode(int userId) {
return repository.findById(userId)
.flatMap(UserProfile::getAddress)
.flatMap(Address::getCity)
.map(City::getZipCode)
.orElse("N/A");
}
// map with transformation: extract domain from email
String getEmailDomain(int userId) {
return repository.findById(userId)
.map(UserProfile::getEmail)
.filter(email -> email.contains("@"))
.map(email -> email.substring(email.indexOf('@') + 1))
.orElse("unknown domain");
}
// Nickname with fallback to first name
String getDisplayName(int userId) {
return repository.findById(userId)
.flatMap(user -> user.getNickname()
.or(() -> Optional.of(user.getName().split(" ")[0])))
.orElse("Guest");
}
// Build mailing label using Optional chaining
String getMailingLabel(int userId) {
return repository.findById(userId)
.map(user -> {
StringBuilder label = new StringBuilder();
label.append(user.getName());
user.getAddress().ifPresent(addr -> {
label.append("\n").append(addr.getStreet());
addr.getCity().ifPresent(city ->
label.append("\n").append(city.getName())
.append(", ").append(city.getZipCode())
);
});
return label.toString();
})
.orElse("No mailing address available");
}
// orElseThrow: user MUST exist
UserProfile getRequiredUser(int userId) {
return repository.findById(userId)
.orElseThrow(() -> new IllegalArgumentException(
"User not found: " + userId));
}
// Stream + Optional: find all users in a specific city
List findUsersInCity(String cityName) {
return repository.findAll().stream()
.filter(user -> user.getAddress()
.flatMap(Address::getCity)
.map(City::getName)
.filter(name -> name.equalsIgnoreCase(cityName))
.isPresent())
.map(UserProfile::getName)
.collect(Collectors.toList());
}
// Stream + Optional: collect all unique email domains
List getAllEmailDomains() {
return repository.findAll().stream()
.map(UserProfile::getEmail)
.filter(email -> email.contains("@"))
.map(email -> email.substring(email.indexOf('@') + 1))
.distinct()
.sorted()
.collect(Collectors.toList());
}
// Stream + Optional: contact info summary
String getContactSummary(int userId) {
return repository.findById(userId)
.map(user -> {
List contacts = new ArrayList<>();
contacts.add("Email: " + user.getEmail());
user.getPhoneNumber().ifPresent(p -> contacts.add("Phone: " + p));
user.getAddress().ifPresent(a ->
contacts.add("Address: " + a.getStreet()));
return user.getName() + " - " + String.join(", ", contacts);
})
.orElse("User not found");
}
}
// ===== MAIN =====
public class UserProfileSystem {
public static void main(String[] args) {
UserRepository repo = new UserRepository();
UserService service = new UserService(repo);
System.out.println("===== City Names =====");
System.out.println("User 1: " + service.getCityName(1));
System.out.println("User 2: " + service.getCityName(2));
System.out.println("User 3: " + service.getCityName(3));
System.out.println("User 99: " + service.getCityName(99));
System.out.println("\n===== Zip Codes =====");
System.out.println("User 1: " + service.getZipCode(1));
System.out.println("User 2: " + service.getZipCode(2));
System.out.println("\n===== Display Names (nickname or first name) =====");
System.out.println("User 1 (has nickname): " + service.getDisplayName(1));
System.out.println("User 2 (no nickname): " + service.getDisplayName(2));
System.out.println("User 3 (has nickname): " + service.getDisplayName(3));
System.out.println("\n===== Email Domains =====");
System.out.println("User 1: " + service.getEmailDomain(1));
System.out.println("All domains: " + service.getAllEmailDomains());
System.out.println("\n===== Mailing Labels =====");
System.out.println("--- User 1 (full address) ---");
System.out.println(service.getMailingLabel(1));
System.out.println("--- User 2 (no city) ---");
System.out.println(service.getMailingLabel(2));
System.out.println("--- User 3 (no address) ---");
System.out.println(service.getMailingLabel(3));
System.out.println("\n===== Users in Springfield =====");
System.out.println(service.findUsersInCity("Springfield"));
System.out.println("\n===== Contact Summaries =====");
for (int id = 1; id <= 4; id++) {
System.out.println(service.getContactSummary(id));
}
System.out.println("\n===== Required User (throws if not found) =====");
UserProfile alice = service.getRequiredUser(1);
System.out.println("Required: " + alice.getName());
try {
service.getRequiredUser(99);
} catch (IllegalArgumentException e) {
System.out.println("Expected error: " + e.getMessage());
}
}
}
// ===== City Names ===== // User 1: Springfield // User 2: Unknown city // User 3: Unknown city // User 99: Unknown city // // ===== Zip Codes ===== // User 1: 62701 // User 2: N/A // // ===== Display Names (nickname or first name) ===== // User 1 (has nickname): Ally // User 2 (no nickname): Bob // User 3 (has nickname): Chuck // // ===== Email Domains ===== // User 1: example.com // All domains: [example.com] // // ===== Mailing Labels ===== // --- User 1 (full address) --- // Alice Smith // 123 Main St // Springfield, 62701 // --- User 2 (no city) --- // Bob Johnson // 456 Oak Ave // --- User 3 (no address) --- // Charlie Brown // // ===== Users in Springfield ===== // [Alice Smith] // // ===== Contact Summaries ===== // Alice Smith - Email: alice@example.com, Phone: 555-0101, Address: 123 Main St // Bob Johnson - Email: bob@example.com, Address: 456 Oak Ave // Charlie Brown - Email: charlie@example.com, Phone: 555-0303 // Diana Prince - Email: diana@example.com, Address: 789 Hero Blvd // // ===== Required User (throws if not found) ===== // Required: Alice Smith // Expected error: User not found: 99
| # | Concept | Where Used |
|---|---|---|
| 1 | Optional.ofNullable() |
Repository findById(), all nullable getters |
| 2 | Optional.empty() |
Implicit when ofNullable receives null |
| 3 | map() |
getCityName(), getEmailDomain(), getContactSummary() |
| 4 | flatMap() |
getCityName(), getZipCode(), findUsersInCity() |
| 5 | filter() |
getEmailDomain(), findUsersInCity() |
| 6 | orElse() |
All service methods with string defaults |
| 7 | orElseThrow() |
getRequiredUser() |
| 8 | ifPresent() |
getMailingLabel(), getContactSummary() |
| 9 | or() (Java 9+) |
getDisplayName() -- nickname or first name fallback |
| 10 | Nullable field + Optional getter | All domain classes (UserProfile, Address) |
| 11 | Stream + Optional integration | findUsersInCity(), getAllEmailDomains() |
| 12 | Return List not Optional of List | findAll(), findUsersInCity(), getAllEmailDomains() |
Every Optional method, organized by category, with the Java version it was introduced.
| Category | Method | Description | Java |
|---|---|---|---|
| Creation | Optional.of(value) |
Wraps non-null value; throws NPE if null | 8 |
Optional.ofNullable(value) |
Wraps value; returns empty if null | 8 | |
Optional.empty() |
Returns an empty Optional | 8 | |
| Checking | isPresent() |
Returns true if value is present | 8 |
isEmpty() |
Returns true if value is absent | 11 | |
| Getting | get() |
Returns value or throws NoSuchElementException (avoid) | 8 |
orElse(default) |
Returns value or default (eager evaluation) | 8 | |
orElseGet(supplier) |
Returns value or supplier result (lazy evaluation) | 8 | |
orElseThrow(supplier) |
Returns value or throws supplied exception | 8 | |
orElseThrow() |
Returns value or throws NoSuchElementException | 10 | |
or(supplier) |
Returns this Optional or supplier's Optional if empty | 9 | |
| Transforming | map(function) |
Transforms value if present; wraps result in Optional | 8 |
flatMap(function) |
Transforms value; function must return Optional | 8 | |
filter(predicate) |
Returns this if predicate matches; empty otherwise | 8 | |
| Consuming | ifPresent(consumer) |
Runs consumer if value is present | 8 |
ifPresentOrElse(consumer, emptyAction) |
Runs consumer if present; runs emptyAction if empty | 9 | |
| Stream | stream() |
Returns a one-element or empty Stream | 9 |
| Identity | equals(obj) |
Compares contained values | 8 |
hashCode() |
Hash of contained value or 0 | 8 | |
toString() |
Returns "Optional[value]" or "Optional.empty" | 8 |
StringJoiner is a utility class introduced in Java 8 that builds a sequence of characters separated by a delimiter, with optional prefix and suffix. It lives in java.util and solves a problem every Java developer has faced: how do you join a list of strings with commas (or any delimiter) without an ugly trailing comma at the end?
Before Java 8, building a delimited string required manual StringBuilder logic — appending elements, tracking whether to add a delimiter, and trimming the last character. StringJoiner handles all of this automatically.
import java.util.List;
import java.util.StringJoiner;
public class StringJoinerIntro {
public static void main(String[] args) {
List fruits = List.of("Apple", "Banana", "Cherry");
// BEFORE Java 8: manual StringBuilder with trailing delimiter issue
StringBuilder sb = new StringBuilder();
for (int i = 0; i < fruits.size(); i++) {
sb.append(fruits.get(i));
if (i < fruits.size() - 1) {
sb.append(", ");
}
}
System.out.println(sb.toString());
// Output: Apple, Banana, Cherry
// WITH StringJoiner: clean and simple
StringJoiner joiner = new StringJoiner(", ");
for (String fruit : fruits) {
joiner.add(fruit);
}
System.out.println(joiner.toString());
// Output: Apple, Banana, Cherry
}
}
The simplest StringJoiner constructor takes just a delimiter. You add elements with add() and get the result with toString().
The StringJoiner(CharSequence delimiter) constructor creates a joiner with the given delimiter between elements. The add() method appends a new element. Elements are added in order, and the delimiter is automatically inserted between them.
import java.util.StringJoiner;
public class BasicStringJoiner {
public static void main(String[] args) {
// Comma delimiter
StringJoiner csv = new StringJoiner(", ");
csv.add("Alice");
csv.add("Bob");
csv.add("Charlie");
System.out.println(csv.toString());
// Output: Alice, Bob, Charlie
// Pipe delimiter
StringJoiner piped = new StringJoiner(" | ");
piped.add("Red");
piped.add("Green");
piped.add("Blue");
System.out.println(piped.toString());
// Output: Red | Green | Blue
// Newline delimiter
StringJoiner lines = new StringJoiner("\n");
lines.add("Line 1");
lines.add("Line 2");
lines.add("Line 3");
System.out.println(lines.toString());
// Output:
// Line 1
// Line 2
// Line 3
// Single element: no delimiter added
StringJoiner single = new StringJoiner(", ");
single.add("Only One");
System.out.println(single.toString());
// Output: Only One
// Empty joiner: returns empty string
StringJoiner empty = new StringJoiner(", ");
System.out.println("Empty: '" + empty.toString() + "'");
// Output: Empty: ''
}
}
By default, an empty StringJoiner (one with no elements added) returns an empty string for delimiter-only constructors, or just the prefix + suffix for the three-argument constructor. You can override this with setEmptyValue() to return a custom string when no elements are added.
import java.util.StringJoiner;
public class SetEmptyValueDemo {
public static void main(String[] args) {
// Without setEmptyValue: returns prefix + suffix (or empty string)
StringJoiner joiner1 = new StringJoiner(", ", "[", "]");
System.out.println("Default empty: " + joiner1.toString());
// Output: Default empty: []
// With setEmptyValue: returns custom value when empty
StringJoiner joiner2 = new StringJoiner(", ", "[", "]");
joiner2.setEmptyValue("No items found");
System.out.println("Custom empty: " + joiner2.toString());
// Output: Custom empty: No items found
// Once an element is added, setEmptyValue is ignored
joiner2.add("Apple");
System.out.println("After add: " + joiner2.toString());
// Output: After add: [Apple]
// Useful for displaying "N/A" or "none" in reports
StringJoiner tags = new StringJoiner(", ");
tags.setEmptyValue("(no tags)");
System.out.println("Tags: " + tags.toString());
// Output: Tags: (no tags)
}
}
The three-argument constructor StringJoiner(delimiter, prefix, suffix) adds a prefix before the first element and a suffix after the last element. This is extremely useful for building structured output like JSON arrays, SQL IN clauses, and HTML lists.
import java.util.StringJoiner;
public class PrefixSuffixDemo {
public static void main(String[] args) {
// JSON-like array output
StringJoiner json = new StringJoiner(", ", "[", "]");
json.add("\"Apple\"");
json.add("\"Banana\"");
json.add("\"Cherry\"");
System.out.println(json.toString());
// Output: ["Apple", "Banana", "Cherry"]
// SQL IN clause
StringJoiner sql = new StringJoiner(", ", "WHERE status IN (", ")");
sql.add("'ACTIVE'");
sql.add("'PENDING'");
sql.add("'APPROVED'");
System.out.println(sql.toString());
// Output: WHERE status IN ('ACTIVE', 'PENDING', 'APPROVED')
// HTML unordered list
StringJoiner html = new StringJoiner("\n The merge() method combines the content of another StringJoiner into the current one. The merged content is treated as a single element -- only the content (without the other joiner's prefix and suffix) is added, using the current joiner's delimiter.
import java.util.StringJoiner;
public class MergeDemo {
public static void main(String[] args) {
// Two separate joiners
StringJoiner fruits = new StringJoiner(", ", "[", "]");
fruits.add("Apple");
fruits.add("Banana");
StringJoiner veggies = new StringJoiner(", ", "[", "]");
veggies.add("Carrot");
veggies.add("Pea");
System.out.println("Fruits: " + fruits.toString());
System.out.println("Veggies: " + veggies.toString());
// Output:
// Fruits: [Apple, Banana]
// Veggies: [Carrot, Pea]
// Merge veggies INTO fruits
// Note: veggies' prefix/suffix are dropped, content is added
fruits.merge(veggies);
System.out.println("Merged: " + fruits.toString());
// Output: Merged: [Apple, Banana, Carrot, Pea]
// Merging an empty joiner has no effect
StringJoiner empty = new StringJoiner(", ");
StringJoiner nonEmpty = new StringJoiner(", ");
nonEmpty.add("Hello");
nonEmpty.merge(empty);
System.out.println("After merging empty: " + nonEmpty.toString());
// Output: After merging empty: Hello
// Practical: merge results from multiple data sources
StringJoiner localUsers = new StringJoiner(", ");
localUsers.add("Alice");
localUsers.add("Bob");
StringJoiner remoteUsers = new StringJoiner(", ");
remoteUsers.add("Charlie");
remoteUsers.add("Diana");
StringJoiner allUsers = new StringJoiner(", ", "Users: [", "]");
allUsers.merge(localUsers);
allUsers.merge(remoteUsers);
System.out.println(allUsers.toString());
// Output: Users: [Alice, Bob, Charlie, Diana]
}
}
Java 8 also added the String.join() static method, which internally uses StringJoiner. It is the simplest way to join strings when you do not need a prefix, suffix, or incremental building.
String.join() has two overloads:
String.join(delimiter, elements...) -- join varargsString.join(delimiter, Iterable) -- join any Iterable (List, Set, etc.)import java.util.List;
import java.util.Set;
import java.util.TreeSet;
public class StringJoinDemo {
public static void main(String[] args) {
// Join varargs
String result1 = String.join(", ", "Alice", "Bob", "Charlie");
System.out.println(result1);
// Output: Alice, Bob, Charlie
// Join a List
List languages = List.of("Java", "Python", "Go");
String result2 = String.join(" | ", languages);
System.out.println(result2);
// Output: Java | Python | Go
// Join a Set (order depends on Set implementation)
Set sorted = new TreeSet<>(Set.of("Banana", "Apple", "Cherry"));
String result3 = String.join(" -> ", sorted);
System.out.println(result3);
// Output: Apple -> Banana -> Cherry
// Building file paths
String path = String.join("/", "home", "user", "documents", "report.txt");
System.out.println(path);
// Output: home/user/documents/report.txt
// Building CSS class list
String classes = String.join(" ", "btn", "btn-primary", "btn-lg", "active");
System.out.println("class=\"" + classes + "\"");
// Output: class="btn btn-primary btn-lg active"
// Empty collection: returns empty string
String empty = String.join(", ", List.of());
System.out.println("Empty: '" + empty + "'");
// Output: Empty: ''
}
}
When working with Streams, Collectors.joining() is the preferred way to join elements. It supports delimiter, prefix, and suffix -- the same features as StringJoiner -- but integrates directly into the Stream pipeline. Internally, it uses StringJoiner.
import java.util.List;
import java.util.stream.Collectors;
public class CollectorsJoiningDemo {
public static void main(String[] args) {
List names = List.of("Alice", "Bob", "Charlie", "Diana");
// Simple delimiter
String csv = names.stream()
.collect(Collectors.joining(", "));
System.out.println(csv);
// Output: Alice, Bob, Charlie, Diana
// Delimiter + prefix + suffix
String json = names.stream()
.map(n -> "\"" + n + "\"")
.collect(Collectors.joining(", ", "[", "]"));
System.out.println(json);
// Output: ["Alice", "Bob", "Charlie", "Diana"]
// Practical: build SQL IN clause from a list of IDs
List ids = List.of(101, 205, 308, 412);
String sqlIn = ids.stream()
.map(String::valueOf)
.collect(Collectors.joining(", ", "SELECT * FROM users WHERE id IN (", ")"));
System.out.println(sqlIn);
// Output: SELECT * FROM users WHERE id IN (101, 205, 308, 412)
// Filter + transform + join
List emails = List.of(
"alice@example.com", "bob@test.org",
"admin@example.com", "diana@test.org"
);
String exampleDomainUsers = emails.stream()
.filter(e -> e.endsWith("@example.com"))
.map(e -> e.substring(0, e.indexOf("@")))
.map(String::toUpperCase)
.collect(Collectors.joining(", "));
System.out.println("Example.com users: " + exampleDomainUsers);
// Output: Example.com users: ALICE, ADMIN
// No-arg joining(): concatenates without delimiter
String together = names.stream()
.collect(Collectors.joining());
System.out.println(together);
// Output: AliceBobCharlieDiana
}
}
StringJoiner and StringBuilder both build strings incrementally, but they serve different purposes. Here is when to use each.
| Feature | StringJoiner | StringBuilder |
|---|---|---|
| Purpose | Building delimited strings | General string building |
| Delimiter handling | Automatic | Manual |
| Prefix/suffix | Built-in | Manual |
| Merge support | Yes (merge()) |
No |
| Empty value | Configurable | Returns "" |
| Index access | No | Yes (charAt, insert, delete) |
| Flexibility | Limited to delimited joining | Full string manipulation |
| Stream support | Via Collectors.joining() |
Manual |
| Best for | CSV, SQL, JSON, log output | Complex string assembly, templates |
Rule of thumb: If you are joining elements with a delimiter, use StringJoiner (or String.join() / Collectors.joining()). If you need fine-grained control like inserting at specific positions, deleting characters, or building non-delimited strings, use StringBuilder.
import java.util.List;
import java.util.StringJoiner;
public class JoinerVsBuilder {
public static void main(String[] args) {
List items = List.of("Apple", "Banana", "Cherry");
// StringBuilder approach: manual delimiter logic
StringBuilder sb = new StringBuilder("[");
for (int i = 0; i < items.size(); i++) {
sb.append(items.get(i));
if (i < items.size() - 1) {
sb.append(", ");
}
}
sb.append("]");
System.out.println("StringBuilder: " + sb.toString());
// Output: StringBuilder: [Apple, Banana, Cherry]
// StringJoiner approach: automatic delimiter, prefix, suffix
StringJoiner sj = new StringJoiner(", ", "[", "]");
items.forEach(sj::add);
System.out.println("StringJoiner: " + sj.toString());
// Output: StringJoiner: [Apple, Banana, Cherry]
// StringBuilder is better for non-delimited building
StringBuilder html = new StringBuilder();
html.append("\n");
html.append(" ").append("Title").append("
\n");
html.append(" ").append("Description").append("
\n");
html.append("");
System.out.println(html.toString());
}
}
These examples show StringJoiner solving real-world problems you will encounter in production Java code.
import java.util.List;
import java.util.StringJoiner;
public class CsvGenerator {
public static void main(String[] args) {
// Generate CSV with header and data rows
StringJoiner csv = new StringJoiner("\n");
// Header row
csv.add(String.join(",", "Name", "Age", "City", "Email"));
// Data rows
String[][] data = {
{"Alice", "30", "New York", "alice@example.com"},
{"Bob", "25", "London", "bob@test.org"},
{"Charlie", "35", "Tokyo", "charlie@mail.com"}
};
for (String[] row : data) {
csv.add(String.join(",", row));
}
System.out.println(csv.toString());
// Output:
// Name,Age,City,Email
// Alice,30,New York,alice@example.com
// Bob,25,London,bob@test.org
// Charlie,35,Tokyo,charlie@mail.com
}
}
import java.util.List;
import java.util.StringJoiner;
public class SqlBuilder {
public static void main(String[] args) {
// INSERT statement
List columns = List.of("name", "email", "age", "city");
List values = List.of("'Alice'", "'alice@example.com'", "30", "'New York'");
StringJoiner colJoiner = new StringJoiner(", ", "(", ")");
columns.forEach(colJoiner::add);
StringJoiner valJoiner = new StringJoiner(", ", "(", ")");
values.forEach(valJoiner::add);
String insert = "INSERT INTO users " + colJoiner + " VALUES " + valJoiner;
System.out.println(insert);
// Output: INSERT INTO users (name, email, age, city) VALUES ('Alice', 'alice@example.com', 30, 'New York')
// WHERE clause with multiple conditions
StringJoiner where = new StringJoiner(" AND ", "WHERE ", "");
where.add("status = 'ACTIVE'");
where.add("age >= 18");
where.add("city IN ('New York', 'London')");
System.out.println("SELECT * FROM users " + where);
// Output: SELECT * FROM users WHERE status = 'ACTIVE' AND age >= 18 AND city IN ('New York', 'London')
// UPDATE SET clause
StringJoiner setClause = new StringJoiner(", ", "SET ", "");
setClause.add("name = 'Bob'");
setClause.add("email = 'bob@new.com'");
setClause.add("updated_at = NOW()");
System.out.println("UPDATE users " + setClause + " WHERE id = 1");
// Output: UPDATE users SET name = 'Bob', email = 'bob@new.com', updated_at = NOW() WHERE id = 1
}
}
import java.time.LocalDateTime;
import java.util.StringJoiner;
public class LogFormatter {
public static void main(String[] args) {
// Structured log line: timestamp | level | class | message
StringJoiner log1 = new StringJoiner(" | ");
log1.add(LocalDateTime.now().toString());
log1.add("INFO");
log1.add("UserService");
log1.add("User alice@example.com logged in");
System.out.println(log1.toString());
// Output: 2026-02-28T10:30:00.123 | INFO | UserService | User alice@example.com logged in
// Key-value pairs for structured logging
StringJoiner log2 = new StringJoiner(", ", "{", "}");
log2.add("\"action\": \"login\"");
log2.add("\"user\": \"alice\"");
log2.add("\"ip\": \"192.168.1.1\"");
log2.add("\"success\": true");
System.out.println(log2.toString());
// Output: {"action": "login", "user": "alice", "ip": "192.168.1.1", "success": true}
}
}
This example builds a report generator that can output data in three different formats -- CSV, JSON, and HTML -- all using StringJoiner. It demonstrates every concept covered in this tutorial.
import java.util.List;
import java.util.StringJoiner;
import java.util.stream.Collectors;
class Employee {
private final String name;
private final String department;
private final double salary;
Employee(String name, String department, double salary) {
this.name = name;
this.department = department;
this.salary = salary;
}
String getName() { return name; }
String getDepartment() { return department; }
double getSalary() { return salary; }
}
class ReportGenerator {
// === CSV FORMAT ===
String generateCsv(List employees) {
StringJoiner report = new StringJoiner("\n");
report.setEmptyValue("No employee data available");
// Header row using String.join()
report.add(String.join(",", "Name", "Department", "Salary"));
// Data rows
for (Employee emp : employees) {
StringJoiner row = new StringJoiner(",");
row.add(emp.getName());
row.add(emp.getDepartment());
row.add(String.format("%.2f", emp.getSalary()));
report.add(row.toString());
}
return report.toString();
}
// === JSON FORMAT ===
String generateJson(List employees) {
// Each employee as a JSON object using StringJoiner
String items = employees.stream()
.map(emp -> {
StringJoiner obj = new StringJoiner(", ", " {", "}");
obj.add("\"name\": \"" + emp.getName() + "\"");
obj.add("\"department\": \"" + emp.getDepartment() + "\"");
obj.add("\"salary\": " + emp.getSalary());
return obj.toString();
})
.collect(Collectors.joining(",\n", "[\n", "\n]"));
return items;
}
// === HTML TABLE FORMAT ===
String generateHtml(List employees) {
StringJoiner table = new StringJoiner("\n");
table.setEmptyValue("No employees found.
");
table.add("");
// Header using StringJoiner with prefix/suffix
StringJoiner header = new StringJoiner("", " ", " ");
header.add("Name");
header.add("Department");
header.add("Salary");
table.add(header.toString());
// Data rows
for (Employee emp : employees) {
StringJoiner row = new StringJoiner("", " ", " ");
row.add(emp.getName());
row.add(emp.getDepartment());
row.add(String.format("$%.2f", emp.getSalary()));
table.add(row.toString());
}
table.add("
");
return table.toString();
}
// === SUMMARY using merge() ===
String generateSummary(List employees) {
// Merge department groups
StringJoiner engineering = new StringJoiner(", ");
StringJoiner marketing = new StringJoiner(", ");
StringJoiner other = new StringJoiner(", ");
for (Employee emp : employees) {
switch (emp.getDepartment()) {
case "Engineering" -> engineering.add(emp.getName());
case "Marketing" -> marketing.add(emp.getName());
default -> other.add(emp.getName());
}
}
StringJoiner summary = new StringJoiner("\n");
summary.add("=== Department Summary ===");
StringJoiner engLine = new StringJoiner(", ", "Engineering: ", "");
engLine.merge(engineering);
summary.add(engLine.toString());
StringJoiner mktLine = new StringJoiner(", ", "Marketing: ", "");
mktLine.merge(marketing);
summary.add(mktLine.toString());
// Total salary using Collectors.joining for labels
String totalLine = "Total employees: " + employees.size();
summary.add(totalLine);
double totalSalary = employees.stream()
.mapToDouble(Employee::getSalary)
.sum();
summary.add(String.format("Total salary: $%.2f", totalSalary));
return summary.toString();
}
}
public class ReportApp {
public static void main(String[] args) {
List employees = List.of(
new Employee("Alice", "Engineering", 95000),
new Employee("Bob", "Marketing", 72000),
new Employee("Charlie", "Engineering", 105000),
new Employee("Diana", "Marketing", 68000),
new Employee("Eve", "Engineering", 115000)
);
ReportGenerator generator = new ReportGenerator();
// CSV Report
System.out.println("=== CSV Report ===");
System.out.println(generator.generateCsv(employees));
System.out.println();
// JSON Report
System.out.println("=== JSON Report ===");
System.out.println(generator.generateJson(employees));
System.out.println();
// HTML Report
System.out.println("=== HTML Report ===");
System.out.println(generator.generateHtml(employees));
System.out.println();
// Summary with merge()
System.out.println(generator.generateSummary(employees));
System.out.println();
// Empty report: demonstrates setEmptyValue
System.out.println("=== Empty CSV Report ===");
System.out.println(generator.generateCsv(List.of()));
System.out.println();
System.out.println("=== Empty HTML Report ===");
System.out.println(generator.generateHtml(List.of()));
}
}
// Output:
// === CSV Report ===
// Name,Department,Salary
// Alice,Engineering,95000.00
// Bob,Marketing,72000.00
// Charlie,Engineering,105000.00
// Diana,Marketing,68000.00
// Eve,Engineering,115000.00
//
// === JSON Report ===
// [
// {"name": "Alice", "department": "Engineering", "salary": 95000.0},
// {"name": "Bob", "department": "Marketing", "salary": 72000.0},
// {"name": "Charlie", "department": "Engineering", "salary": 105000.0},
// {"name": "Diana", "department": "Marketing", "salary": 68000.0},
// {"name": "Eve", "department": "Engineering", "salary": 115000.0}
// ]
//
// === HTML Report ===
//
// Name Department Salary
// Alice Engineering $95000.00
// Bob Marketing $72000.00
// Charlie Engineering $105000.00
// Diana Marketing $68000.00
// Eve Engineering $115000.00
//
//
// === Department Summary ===
// Engineering: Alice, Charlie, Eve
// Marketing: Bob, Diana
// Total employees: 5
// Total salary: $455000.00
//
// === Empty CSV Report ===
// No employee data available
//
// === Empty HTML Report ===
// No employees found.
| Concept | Where Used |
|---|---|
StringJoiner(delimiter) |
CSV row builder, department grouping |
StringJoiner(delimiter, prefix, suffix) |
JSON objects, HTML table rows, SQL-style output |
add() |
Every joiner instance |
merge() |
Department summary -- merging grouped joiners |
setEmptyValue() |
CSV and HTML empty report fallback |
String.join() |
CSV header row |
Collectors.joining() |
JSON array with stream pipeline |
| Stream + StringJoiner | JSON generation with map + collect |
| Method / Class | Description | Example |
|---|---|---|
new StringJoiner(delim) |
Create with delimiter only | new StringJoiner(", ") |
new StringJoiner(delim, prefix, suffix) |
Create with delimiter, prefix, suffix | new StringJoiner(", ", "[", "]") |
add(element) |
Append an element | joiner.add("Alice") |
merge(other) |
Merge another joiner's content | joiner.merge(otherJoiner) |
setEmptyValue(value) |
Custom string when no elements added | joiner.setEmptyValue("N/A") |
toString() |
Get the joined result | joiner.toString() |
length() |
Length of the current result | joiner.length() |
String.join(delim, elements...) |
Static join with varargs | String.join(", ", "A", "B") |
String.join(delim, iterable) |
Static join with Iterable | String.join(", ", list) |
Collectors.joining() |
Stream collector, no delimiter | stream.collect(Collectors.joining()) |
Collectors.joining(delim) |
Stream collector with delimiter | stream.collect(Collectors.joining(", ")) |
Collectors.joining(delim, pre, suf) |
Stream collector with all options | stream.collect(Collectors.joining(", ", "[", "]")) |
When you use the Stream API, the collect() terminal operation is how you transform a stream back into a concrete data structure — a List, a Set, a Map, a String, or even a custom container. But collect() does not know how to build these structures on its own. That is where Collectors come in.
The java.util.stream.Collectors class is a utility class (introduced in Java 8) that provides dozens of factory methods for creating Collector objects. Each Collector defines three things:
new ArrayList<>())list.add(element))You rarely need to think about these three parts because the pre-built Collectors handle it. Here is the big picture:
| Category | Collectors |
|---|---|
| Collection builders | toList(), toSet(), toMap(), toCollection(), toUnmodifiableList() |
| String builders | joining() |
| Grouping | groupingBy(), partitioningBy() |
| Aggregation | counting(), summingInt(), averagingDouble(), summarizingInt() |
| Reduction | reducing(), mapping(), flatMapping(), filtering() |
| Min/Max | minBy(), maxBy() |
| Custom | Collector.of() |
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
public class CollectorsIntro {
public static void main(String[] args) {
List names = List.of("Alice", "Bob", "Charlie", "Alice", "David");
// Without Collectors -- manual collect (verbose)
List manual = names.stream()
.filter(n -> n.length() > 3)
.collect(
() -> new java.util.ArrayList<>(), // supplier
(list, item) -> list.add(item), // accumulator
(list1, list2) -> list1.addAll(list2) // combiner
);
// With Collectors -- one method call
List easy = names.stream()
.filter(n -> n.length() > 3)
.collect(Collectors.toList());
System.out.println("Manual: " + manual);
System.out.println("Easy: " + easy);
// Both output: [Alice, Charlie, Alice, David]
}
}
These are the workhorses of the Collectors class — used in the vast majority of stream pipelines.
| Collector | Returns | Duplicates | Order |
|---|---|---|---|
toList() |
List<T> |
Allowed | Preserved |
toSet() |
Set<T> |
Removed | Not guaranteed |
toUnmodifiableList() (Java 10+) |
List<T> |
Allowed | Preserved, immutable |
toUnmodifiableSet() (Java 10+) |
Set<T> |
Removed | Not guaranteed, immutable |
toCollection(Supplier) |
Any Collection | Depends on type | Depends on type |
import java.util.*;
import java.util.stream.Collectors;
public class BasicCollectors {
public static void main(String[] args) {
List names = List.of("Charlie", "Alice", "Bob", "Alice", "David", "Bob");
// toList() -- ArrayList (mutable)
List list = names.stream()
.filter(n -> n.length() > 3)
.collect(Collectors.toList());
list.add("Extra"); // works -- list is mutable
System.out.println("toList: " + list);
// Output: toList: [Charlie, Alice, Alice, David, Extra]
// toSet() -- HashSet (no duplicates)
Set set = names.stream()
.collect(Collectors.toSet());
System.out.println("toSet: " + set);
// Output: toSet: [Alice, Bob, Charlie, David] (order may vary)
// toUnmodifiableList() -- Java 10+ (immutable)
List immutable = names.stream()
.filter(n -> n.length() > 3)
.collect(Collectors.toUnmodifiableList());
System.out.println("toUnmodifiableList: " + immutable);
// immutable.add("Fail"); // throws UnsupportedOperationException
// toUnmodifiableSet() -- Java 10+ (immutable, no duplicates)
Set immutableSet = names.stream()
.collect(Collectors.toUnmodifiableSet());
System.out.println("toUnmodifiableSet: " + immutableSet);
// toCollection() -- specify the exact collection type
TreeSet treeSet = names.stream()
.collect(Collectors.toCollection(TreeSet::new));
System.out.println("TreeSet (sorted): " + treeSet);
// Output: TreeSet (sorted): [Alice, Bob, Charlie, David]
LinkedList linkedList = names.stream()
.collect(Collectors.toCollection(LinkedList::new));
System.out.println("LinkedList: " + linkedList);
// Java 16+: Stream.toList() shorthand (returns unmodifiable list)
List java16List = names.stream()
.filter(n -> n.startsWith("A"))
.toList();
System.out.println("Stream.toList(): " + java16List);
// Output: Stream.toList(): [Alice, Alice]
}
}
Collectors.toMap() builds a Map from stream elements. It requires two functions: one to extract the key, and one to extract the value. It has three overloaded forms to handle duplicate keys and specify the Map implementation.
import java.util.*;
import java.util.stream.Collectors;
public class ToMapBasic {
public static void main(String[] args) {
List names = List.of("Alice", "Bob", "Charlie", "David");
// Basic: name -> length
Map nameLengths = names.stream()
.collect(Collectors.toMap(
name -> name, // key mapper
String::length // value mapper
));
System.out.println("Name lengths: " + nameLengths);
// Output: Name lengths: {Alice=5, Bob=3, Charlie=7, David=5}
// Map from index to value
List colors = List.of("Red", "Green", "Blue");
Map indexed = new HashMap<>();
for (int i = 0; i < colors.size(); i++) {
indexed.put(i, colors.get(i));
}
// Or with streams using an AtomicInteger
java.util.concurrent.atomic.AtomicInteger counter = new java.util.concurrent.atomic.AtomicInteger(0);
Map indexedStream = colors.stream()
.collect(Collectors.toMap(
color -> counter.getAndIncrement(),
color -> color
));
System.out.println("Indexed: " + indexedStream);
// Output: Indexed: {0=Red, 1=Green, 2=Blue}
}
}
If two elements produce the same key, toMap() throws IllegalStateException by default. To handle duplicates, provide a merge function as the third argument.
import java.util.*;
import java.util.stream.Collectors;
public class ToMapDuplicates {
public static void main(String[] args) {
List names = List.of("Alice", "Anna", "Bob", "Brian", "Charlie");
// Group by first letter -- FAILS if duplicate keys
// Map bad = names.stream()
// .collect(Collectors.toMap(n -> n.charAt(0), n -> n));
// IllegalStateException: Duplicate key A (attempted merging Alice and Anna)
// Handle duplicates: keep the first value
Map keepFirst = names.stream()
.collect(Collectors.toMap(
n -> n.charAt(0), // key
n -> n, // value
(existing, replacement) -> existing // merge: keep first
));
System.out.println("Keep first: " + keepFirst);
// Output: Keep first: {A=Alice, B=Bob, C=Charlie}
// Handle duplicates: keep the last value
Map keepLast = names.stream()
.collect(Collectors.toMap(
n -> n.charAt(0),
n -> n,
(existing, replacement) -> replacement // merge: keep last
));
System.out.println("Keep last: " + keepLast);
// Output: Keep last: {A=Anna, B=Brian, C=Charlie}
// Handle duplicates: concatenate values
Map concat = names.stream()
.collect(Collectors.toMap(
n -> n.charAt(0),
n -> n,
(a, b) -> a + ", " + b // merge: join with comma
));
System.out.println("Concatenated: " + concat);
// Output: Concatenated: {A=Alice, Anna, B=Bob, Brian, C=Charlie}
// Count occurrences using toMap
List words = List.of("apple", "banana", "apple", "cherry", "banana", "apple");
Map wordCount = words.stream()
.collect(Collectors.toMap(
w -> w,
w -> 1,
Integer::sum
));
System.out.println("Word counts: " + wordCount);
// Output: Word counts: {apple=3, banana=2, cherry=1}
}
}
By default, toMap() returns a HashMap. The four-argument form lets you specify a different Map implementation, such as TreeMap (sorted) or LinkedHashMap (insertion order).
import java.util.*;
import java.util.stream.Collectors;
public class ToMapType {
public static void main(String[] args) {
List names = List.of("Charlie", "Alice", "Bob", "David");
// TreeMap -- keys sorted alphabetically
TreeMap sorted = names.stream()
.collect(Collectors.toMap(
n -> n,
String::length,
(a, b) -> a, // merge function (required for 4-arg form)
TreeMap::new // map factory
));
System.out.println("TreeMap: " + sorted);
// Output: TreeMap: {Alice=5, Bob=3, Charlie=7, David=5}
// LinkedHashMap -- preserves insertion order
LinkedHashMap ordered = names.stream()
.collect(Collectors.toMap(
n -> n,
String::length,
(a, b) -> a,
LinkedHashMap::new
));
System.out.println("LinkedHashMap: " + ordered);
// Output: LinkedHashMap: {Charlie=7, Alice=5, Bob=3, David=5}
}
}
Collectors.joining() concatenates CharSequence elements (Strings) into a single String. It has three overloaded forms.
import java.util.List;
import java.util.stream.Collectors;
public class JoiningCollector {
public static void main(String[] args) {
List languages = List.of("Java", "Python", "Go", "Rust", "C++");
// joining() -- no separator
String noSep = languages.stream().collect(Collectors.joining());
System.out.println("No separator: " + noSep);
// Output: No separator: JavaPythonGoRustC++
// joining(delimiter) -- with separator
String commaSep = languages.stream().collect(Collectors.joining(", "));
System.out.println("Comma: " + commaSep);
// Output: Comma: Java, Python, Go, Rust, C++
// joining(delimiter, prefix, suffix)
String formatted = languages.stream()
.collect(Collectors.joining(" | ", "Languages: [", "]"));
System.out.println(formatted);
// Output: Languages: [Java | Python | Go | Rust | C++]
// Practical: CSV row
List fields = List.of("John", "Doe", "john@example.com", "555-1234");
String csvRow = fields.stream().collect(Collectors.joining(","));
System.out.println("CSV: " + csvRow);
// Output: CSV: John,Doe,john@example.com,555-1234
// Practical: SQL IN clause
List ids = List.of(101, 205, 310, 422);
String sql = ids.stream()
.map(String::valueOf)
.collect(Collectors.joining(", ", "SELECT * FROM users WHERE id IN (", ");"));
System.out.println("SQL: " + sql);
// Output: SQL: SELECT * FROM users WHERE id IN (101, 205, 310, 422);
// Practical: HTML list
List items = List.of("Home", "About", "Contact");
String html = items.stream()
.map(item -> " " + item + " ")
.collect(Collectors.joining("\n", "\n", "\n
"));
System.out.println(html);
// joining() on empty stream returns empty prefix+suffix
String empty = List.of().stream()
.collect(Collectors.joining(", ", "[", "]"));
System.out.println("Empty: " + empty);
// Output: Empty: []
}
}
Collectors.groupingBy() is the stream equivalent of SQL’s GROUP BY. It classifies elements by a key and groups them into a Map<K, List<T>>. With downstream collectors, you can perform aggregations within each group.
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
public class GroupingByBasic {
record Employee(String name, String department, double salary) {}
public static void main(String[] args) {
List employees = List.of(
new Employee("Alice", "Engineering", 95000),
new Employee("Bob", "Engineering", 110000),
new Employee("Charlie", "Marketing", 72000),
new Employee("Diana", "Sales", 68000),
new Employee("Eve", "Engineering", 125000),
new Employee("Frank", "Marketing", 78000),
new Employee("Grace", "Sales", 71000)
);
// Group by department
Map> byDept = employees.stream()
.collect(Collectors.groupingBy(Employee::department));
byDept.forEach((dept, emps) -> {
System.out.println(dept + ":");
emps.forEach(e -> System.out.println(" " + e.name()));
});
// Output:
// Engineering:
// Alice
// Bob
// Eve
// Marketing:
// Charlie
// Frank
// Sales:
// Diana
// Grace
// Group strings by length
List words = List.of("cat", "dog", "fish", "bird", "ant", "fox", "bear");
Map> byLength = words.stream()
.collect(Collectors.groupingBy(String::length));
System.out.println("By length: " + byLength);
// Output: By length: {3=[cat, dog, ant, fox], 4=[fish, bird, bear]}
}
}
The real power of groupingBy() comes from the second argument — a downstream collector that processes each group. Instead of getting List<T>, you can count, sum, average, or further transform each group.
import java.util.*;
import java.util.stream.Collectors;
public class GroupingByDownstream {
record Employee(String name, String department, double salary) {}
public static void main(String[] args) {
List employees = List.of(
new Employee("Alice", "Engineering", 95000),
new Employee("Bob", "Engineering", 110000),
new Employee("Charlie", "Marketing", 72000),
new Employee("Diana", "Sales", 68000),
new Employee("Eve", "Engineering", 125000),
new Employee("Frank", "Marketing", 78000),
new Employee("Grace", "Sales", 71000)
);
// Count per group
Map countByDept = employees.stream()
.collect(Collectors.groupingBy(Employee::department, Collectors.counting()));
System.out.println("Count: " + countByDept);
// Output: Count: {Engineering=3, Marketing=2, Sales=2}
// Average salary per department
Map avgByDept = employees.stream()
.collect(Collectors.groupingBy(
Employee::department,
Collectors.averagingDouble(Employee::salary)
));
System.out.println("Average salary: " + avgByDept);
// Sum of salaries per department
Map totalByDept = employees.stream()
.collect(Collectors.groupingBy(
Employee::department,
Collectors.summingDouble(Employee::salary)
));
System.out.println("Total salary: " + totalByDept);
// Max salary per department
Map> topEarner = employees.stream()
.collect(Collectors.groupingBy(
Employee::department,
Collectors.maxBy(Comparator.comparingDouble(Employee::salary))
));
topEarner.forEach((dept, emp) ->
emp.ifPresent(e -> System.out.println(dept + " top earner: " + e.name())));
// Map to names only (mapping downstream)
Map> namesByDept = employees.stream()
.collect(Collectors.groupingBy(
Employee::department,
Collectors.mapping(Employee::name, Collectors.toList())
));
System.out.println("Names: " + namesByDept);
// Output: Names: {Engineering=[Alice, Bob, Eve], Marketing=[Charlie, Frank], Sales=[Diana, Grace]}
// Join names as a comma-separated string
Map joinedNames = employees.stream()
.collect(Collectors.groupingBy(
Employee::department,
Collectors.mapping(Employee::name, Collectors.joining(", "))
));
System.out.println("Joined: " + joinedNames);
// Output: Joined: {Engineering=Alice, Bob, Eve, Marketing=Charlie, Frank, Sales=Diana, Grace}
// Collect names into a Set (no duplicates)
Map> nameSetByDept = employees.stream()
.collect(Collectors.groupingBy(
Employee::department,
Collectors.mapping(Employee::name, Collectors.toSet())
));
System.out.println("Name sets: " + nameSetByDept);
}
}
You can nest groupingBy() calls to create multi-level groupings — similar to SQL’s GROUP BY col1, col2.
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
public class MultiLevelGrouping {
record Employee(String name, String department, String level, double salary) {}
public static void main(String[] args) {
List employees = List.of(
new Employee("Alice", "Engineering", "Senior", 120000),
new Employee("Bob", "Engineering", "Junior", 75000),
new Employee("Charlie", "Engineering", "Senior", 115000),
new Employee("Diana", "Marketing", "Junior", 60000),
new Employee("Eve", "Marketing", "Senior", 85000),
new Employee("Frank", "Sales", "Junior", 55000),
new Employee("Grace", "Sales", "Senior", 80000),
new Employee("Hannah", "Sales", "Junior", 58000)
);
// Two-level grouping: department -> level -> list of employees
Map>> twoLevel = employees.stream()
.collect(Collectors.groupingBy(
Employee::department,
Collectors.groupingBy(Employee::level)
));
twoLevel.forEach((dept, levels) -> {
System.out.println(dept + ":");
levels.forEach((level, emps) -> {
System.out.println(" " + level + ":");
emps.forEach(e -> System.out.println(" " + e.name() + " - $" + e.salary()));
});
});
// Two-level grouping with counting
Map> headcount = employees.stream()
.collect(Collectors.groupingBy(
Employee::department,
Collectors.groupingBy(Employee::level, Collectors.counting())
));
System.out.println("\nHeadcount: " + headcount);
// Output: Headcount: {Engineering={Junior=1, Senior=2}, Marketing={Junior=1, Senior=1}, Sales={Junior=2, Senior=1}}
// Two-level grouping with average salary
Map> avgSalary = employees.stream()
.collect(Collectors.groupingBy(
Employee::department,
Collectors.groupingBy(
Employee::level,
Collectors.averagingDouble(Employee::salary)
)
));
System.out.println("\nAvg salary: " + avgSalary);
}
}
Collectors.partitioningBy() is a special case of groupingBy() that splits elements into exactly two groups based on a predicate: true and false. The result is always a Map<Boolean, List<T>> with both keys present (even if one group is empty).
import java.util.*;
import java.util.stream.Collectors;
public class PartitioningByExample {
public static void main(String[] args) {
List numbers = List.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
// Partition into even and odd
Map> evenOdd = numbers.stream()
.collect(Collectors.partitioningBy(n -> n % 2 == 0));
System.out.println("Even: " + evenOdd.get(true));
System.out.println("Odd: " + evenOdd.get(false));
// Output: Even: [2, 4, 6, 8, 10]
// Odd: [1, 3, 5, 7, 9]
// Partition with downstream counting
Map evenOddCount = numbers.stream()
.collect(Collectors.partitioningBy(
n -> n % 2 == 0,
Collectors.counting()
));
System.out.println("Even count: " + evenOddCount.get(true)); // 5
System.out.println("Odd count: " + evenOddCount.get(false)); // 5
// Partition students into pass/fail
record Student(String name, int score) {}
List students = List.of(
new Student("Alice", 85), new Student("Bob", 42),
new Student("Charlie", 91), new Student("Diana", 58),
new Student("Eve", 73), new Student("Frank", 35)
);
int passingScore = 60;
Map> passFail = students.stream()
.collect(Collectors.partitioningBy(s -> s.score() >= passingScore));
System.out.println("\nPassing:");
passFail.get(true).forEach(s ->
System.out.println(" " + s.name() + ": " + s.score()));
System.out.println("Failing:");
passFail.get(false).forEach(s ->
System.out.println(" " + s.name() + ": " + s.score()));
// Partition with mapping downstream
Map> passFailNames = students.stream()
.collect(Collectors.partitioningBy(
s -> s.score() >= passingScore,
Collectors.mapping(Student::name, Collectors.toList())
));
System.out.println("Pass names: " + passFailNames.get(true));
System.out.println("Fail names: " + passFailNames.get(false));
// Empty partition -- both keys still present
Map> allPass = List.of(100, 90, 80).stream()
.collect(Collectors.partitioningBy(n -> n >= 60));
System.out.println("True: " + allPass.get(true)); // [100, 90, 80]
System.out.println("False: " + allPass.get(false)); // [] (empty, but key exists)
}
}
Collectors provides several methods for numeric aggregation. These are most commonly used as downstream collectors inside groupingBy().
| Collector | Returns | Description |
|---|---|---|
counting() |
Long |
Count of elements |
summingInt/Long/Double() |
Integer/Long/Double |
Sum of extracted values |
averagingInt/Long/Double() |
Double |
Average of extracted values |
summarizingInt/Long/Double() |
IntSummaryStatistics |
Count, sum, min, max, average in one pass |
minBy(Comparator) |
Optional<T> |
Minimum element |
maxBy(Comparator) |
Optional<T> |
Maximum element |
import java.util.*;
import java.util.stream.Collectors;
public class CountingStatsExample {
record Product(String name, String category, double price, int quantity) {}
public static void main(String[] args) {
List products = List.of(
new Product("Laptop", "Electronics", 999.99, 50),
new Product("Phone", "Electronics", 699.99, 200),
new Product("Tablet", "Electronics", 449.99, 100),
new Product("Desk", "Furniture", 299.99, 30),
new Product("Chair", "Furniture", 199.99, 80),
new Product("Notebook", "Office", 4.99, 1000),
new Product("Pen", "Office", 1.99, 5000)
);
// counting() -- total number of products
long totalProducts = products.stream()
.collect(Collectors.counting());
System.out.println("Total products: " + totalProducts);
// counting() as downstream -- products per category
Map countPerCategory = products.stream()
.collect(Collectors.groupingBy(Product::category, Collectors.counting()));
System.out.println("Per category: " + countPerCategory);
// Output: Per category: {Electronics=3, Furniture=2, Office=2}
// summingDouble() -- total inventory value
double totalValue = products.stream()
.collect(Collectors.summingDouble(p -> p.price() * p.quantity()));
System.out.printf("Total inventory value: $%,.2f%n", totalValue);
// averagingDouble() -- average price
double avgPrice = products.stream()
.collect(Collectors.averagingDouble(Product::price));
System.out.printf("Average price: $%,.2f%n", avgPrice);
// summarizingDouble() -- all stats in one pass
DoubleSummaryStatistics priceStats = products.stream()
.collect(Collectors.summarizingDouble(Product::price));
System.out.println("\nPrice statistics:");
System.out.printf(" Count: %d%n", priceStats.getCount());
System.out.printf(" Sum: $%,.2f%n", priceStats.getSum());
System.out.printf(" Min: $%,.2f%n", priceStats.getMin());
System.out.printf(" Max: $%,.2f%n", priceStats.getMax());
System.out.printf(" Avg: $%,.2f%n", priceStats.getAverage());
// summarizingDouble per category
Map statsByCategory = products.stream()
.collect(Collectors.groupingBy(
Product::category,
Collectors.summarizingDouble(Product::price)
));
System.out.println("\nStats by category:");
statsByCategory.forEach((cat, stats) ->
System.out.printf(" %s: count=%d, avg=$%,.2f, max=$%,.2f%n",
cat, stats.getCount(), stats.getAverage(), stats.getMax()));
// minBy and maxBy
Optional cheapest = products.stream()
.collect(Collectors.minBy(Comparator.comparingDouble(Product::price)));
cheapest.ifPresent(p -> System.out.println("\nCheapest: " + p.name() + " $" + p.price()));
Optional mostExpensive = products.stream()
.collect(Collectors.maxBy(Comparator.comparingDouble(Product::price)));
mostExpensive.ifPresent(p -> System.out.println("Most expensive: " + p.name() + " $" + p.price()));
}
}
Collectors.reducing() and Collectors.mapping() are general-purpose downstream collectors that give you fine-grained control when the built-in aggregation collectors are not enough.
reducing() is the Collector equivalent of Stream.reduce(). It is typically used as a downstream collector inside groupingBy() when you need a custom reduction per group.
import java.util.*;
import java.util.stream.Collectors;
public class ReducingCollector {
record Employee(String name, String department, double salary) {}
public static void main(String[] args) {
List employees = List.of(
new Employee("Alice", "Engineering", 95000),
new Employee("Bob", "Engineering", 110000),
new Employee("Charlie", "Marketing", 72000),
new Employee("Diana", "Marketing", 78000),
new Employee("Eve", "Sales", 68000)
);
// reducing() with identity -- total salary per department
Map totalByDept = employees.stream()
.collect(Collectors.groupingBy(
Employee::department,
Collectors.reducing(0.0, Employee::salary, Double::sum)
));
System.out.println("Total by dept: " + totalByDept);
// Output: Total by dept: {Engineering=205000.0, Marketing=150000.0, Sales=68000.0}
// reducing() without identity -- highest earner per department
Map> topPerDept = employees.stream()
.collect(Collectors.groupingBy(
Employee::department,
Collectors.reducing((e1, e2) ->
e1.salary() > e2.salary() ? e1 : e2)
));
topPerDept.forEach((dept, emp) ->
emp.ifPresent(e -> System.out.println(dept + " top: " + e.name())));
// reducing() to concatenate names per department
Map namesByDept = employees.stream()
.collect(Collectors.groupingBy(
Employee::department,
Collectors.reducing("",
Employee::name,
(a, b) -> a.isEmpty() ? b : a + ", " + b)
));
System.out.println("Names by dept: " + namesByDept);
}
}
mapping() transforms elements before passing them to a downstream collector. flatMapping() (Java 9+) does the same but flattens the result. Both are used as downstream collectors.
import java.util.*;
import java.util.stream.Collectors;
import java.util.stream.Stream;
public class MappingCollector {
record Employee(String name, String department, List skills) {}
public static void main(String[] args) {
List employees = List.of(
new Employee("Alice", "Engineering", List.of("Java", "Python", "SQL")),
new Employee("Bob", "Engineering", List.of("Java", "Go", "Docker")),
new Employee("Charlie", "Marketing", List.of("SEO", "Analytics")),
new Employee("Diana", "Marketing", List.of("Content", "SEO", "Social"))
);
// mapping() -- extract names per department
Map> namesByDept = employees.stream()
.collect(Collectors.groupingBy(
Employee::department,
Collectors.mapping(Employee::name, Collectors.toList())
));
System.out.println("Names: " + namesByDept);
// Output: Names: {Engineering=[Alice, Bob], Marketing=[Charlie, Diana]}
// mapping() with joining downstream
Map joinedByDept = employees.stream()
.collect(Collectors.groupingBy(
Employee::department,
Collectors.mapping(Employee::name, Collectors.joining(", "))
));
System.out.println("Joined: " + joinedByDept);
// flatMapping() (Java 9+) -- collect all skills per department
Map> skillsByDept = employees.stream()
.collect(Collectors.groupingBy(
Employee::department,
Collectors.flatMapping(
e -> e.skills().stream(),
Collectors.toSet()
)
));
System.out.println("Skills: " + skillsByDept);
// Output: Skills: {Engineering=[Docker, Go, Java, Python, SQL], Marketing=[Analytics, Content, SEO, Social]}
// filtering() (Java 9+) -- filter within each group
Map> seniorDevs = employees.stream()
.collect(Collectors.groupingBy(
Employee::department,
Collectors.filtering(
e -> e.skills().size() >= 3,
Collectors.mapping(Employee::name, Collectors.toList())
)
));
System.out.println("3+ skills: " + seniorDevs);
// Output: 3+ skills: {Engineering=[Alice, Bob], Marketing=[Diana]}
}
}
When the built-in collectors do not fit your needs, you can create a custom collector using Collector.of(). You provide four components:
| Component | Type | Description |
|---|---|---|
| Supplier | Supplier<A> |
Creates the mutable result container |
| Accumulator | BiConsumer<A, T> |
Adds an element to the container |
| Combiner | BinaryOperator<A> |
Merges two containers (for parallel streams) |
| Finisher | Function<A, R> |
Transforms the container into the final result |
import java.util.*;
import java.util.stream.Collector;
import java.util.stream.Collectors;
public class CustomCollectorExample {
public static void main(String[] args) {
List numbers = List.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
// Custom collector: collect into a comma-separated string with brackets
String result = numbers.stream()
.collect(Collector.of(
StringBuilder::new, // supplier
(sb, num) -> { // accumulator
if (sb.length() > 0) sb.append(", ");
sb.append(num);
},
(sb1, sb2) -> { // combiner
if (sb1.length() > 0 && sb2.length() > 0) sb1.append(", ");
return sb1.append(sb2);
},
sb -> "[" + sb.toString() + "]" // finisher
));
System.out.println("Custom string: " + result);
// Output: Custom string: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
// Custom collector: compute running average
double avg = numbers.stream()
.collect(Collector.of(
() -> new double[]{0, 0}, // [sum, count]
(acc, num) -> { acc[0] += num; acc[1]++; }, // accumulate
(a1, a2) -> { // combine
a1[0] += a2[0];
a1[1] += a2[1];
return a1;
},
acc -> acc[1] == 0 ? 0.0 : acc[0] / acc[1] // finish
));
System.out.println("Average: " + avg); // Output: Average: 5.5
// Custom collector: toImmutableList using Collector.of with UNORDERED characteristic
List names = List.of("Alice", "Bob", "Charlie");
List immutable = names.stream()
.filter(n -> n.length() > 3)
.collect(Collector.of(
ArrayList::new,
ArrayList::add,
(list1, list2) -> { list1.addAll(list2); return list1; },
Collections::unmodifiableList
));
System.out.println("Immutable: " + immutable);
// immutable.add("Fail"); // throws UnsupportedOperationException
// Custom collector: group into even-indexed and odd-indexed elements
List items = List.of("A", "B", "C", "D", "E", "F");
Map> evenOddIndex = items.stream()
.collect(Collector.of(
() -> {
Map> map = new HashMap<>();
map.put("even-index", new ArrayList<>());
map.put("odd-index", new ArrayList<>());
map.put("_counter", new ArrayList<>()); // track index
return map;
},
(map, item) -> {
int index = map.get("_counter").size();
map.get("_counter").add("x");
if (index % 2 == 0) {
map.get("even-index").add(item);
} else {
map.get("odd-index").add(item);
}
},
(m1, m2) -> { m1.get("even-index").addAll(m2.get("even-index"));
m1.get("odd-index").addAll(m2.get("odd-index")); return m1; },
map -> { map.remove("_counter"); return map; }
));
System.out.println("Even-index: " + evenOddIndex.get("even-index"));
System.out.println("Odd-index: " + evenOddIndex.get("odd-index"));
// Output: Even-index: [A, C, E]
// Odd-index: [B, D, F]
}
}
For collectors you use frequently, define them as static methods so they can be imported and reused like the built-in collectors.
import java.util.*;
import java.util.stream.Collector;
public class ReusableCollector {
// Custom collector: toLinkedList()
public static Collector> toLinkedList() {
return Collector.of(
LinkedList::new,
LinkedList::add,
(list1, list2) -> { list1.addAll(list2); return list1; }
);
}
// Custom collector: toReversedList()
public static Collector> toReversedList() {
return Collector.of(
ArrayList::new,
(list, item) -> list.add(0, item),
(list1, list2) -> { list2.addAll(list1); return list2; }
);
}
public static void main(String[] args) {
List names = List.of("Alice", "Bob", "Charlie", "David");
// Use custom toLinkedList()
LinkedList linked = names.stream()
.filter(n -> n.length() > 3)
.collect(toLinkedList());
System.out.println("LinkedList: " + linked);
System.out.println("Type: " + linked.getClass().getSimpleName());
// Output: LinkedList: [Alice, Charlie, David]
// Type: LinkedList
// Use custom toReversedList()
List reversed = names.stream()
.collect(toReversedList());
System.out.println("Reversed: " + reversed);
// Output: Reversed: [David, Charlie, Bob, Alice]
}
}
Let us combine everything into a realistic scenario. We have a list of sales transactions and need to perform various analytics using the full power of the Collectors class.
import java.time.LocalDate;
import java.time.Month;
import java.util.*;
import java.util.stream.Collectors;
public class SalesAnalytics {
record Sale(String product, String category, String region,
double amount, int quantity, LocalDate date) {}
public static void main(String[] args) {
List sales = List.of(
new Sale("Laptop", "Electronics", "North", 999.99, 5, LocalDate.of(2024, 1, 15)),
new Sale("Phone", "Electronics", "South", 699.99, 12, LocalDate.of(2024, 1, 20)),
new Sale("Tablet", "Electronics", "North", 449.99, 8, LocalDate.of(2024, 2, 5)),
new Sale("Desk", "Furniture", "East", 299.99, 3, LocalDate.of(2024, 2, 10)),
new Sale("Chair", "Furniture", "West", 199.99, 15, LocalDate.of(2024, 2, 14)),
new Sale("Laptop", "Electronics", "East", 999.99, 7, LocalDate.of(2024, 3, 1)),
new Sale("Phone", "Electronics", "North", 699.99, 20, LocalDate.of(2024, 3, 5)),
new Sale("Notebook", "Office", "South", 4.99, 500,LocalDate.of(2024, 3, 10)),
new Sale("Pen", "Office", "West", 1.99, 1000, LocalDate.of(2024, 3, 15)),
new Sale("Chair", "Furniture", "North", 199.99, 10, LocalDate.of(2024, 4, 1)),
new Sale("Desk", "Furniture", "South", 299.99, 5, LocalDate.of(2024, 4, 10)),
new Sale("Laptop", "Electronics", "West", 999.99, 3, LocalDate.of(2024, 4, 20)),
new Sale("Phone", "Electronics", "East", 699.99, 15, LocalDate.of(2024, 5, 1)),
new Sale("Tablet", "Electronics", "South", 449.99, 6, LocalDate.of(2024, 5, 10)),
new Sale("Chair", "Furniture", "East", 199.99, 20, LocalDate.of(2024, 5, 15))
);
// ===== 1. Revenue by Category =====
System.out.println("=== Revenue by Category ===");
Map revenueByCategory = sales.stream()
.collect(Collectors.groupingBy(
Sale::category,
Collectors.summingDouble(s -> s.amount() * s.quantity())
));
revenueByCategory.entrySet().stream()
.sorted(Map.Entry.comparingByValue().reversed())
.forEach(e -> System.out.printf(" %-15s $%,12.2f%n", e.getKey(), e.getValue()));
// ===== 2. Top Products by Total Revenue =====
System.out.println("\n=== Top Products by Revenue ===");
sales.stream()
.collect(Collectors.groupingBy(
Sale::product,
Collectors.summingDouble(s -> s.amount() * s.quantity())
))
.entrySet().stream()
.sorted(Map.Entry.comparingByValue().reversed())
.limit(3)
.forEach(e -> System.out.printf(" %-15s $%,12.2f%n", e.getKey(), e.getValue()));
// ===== 3. Average Order Value by Region =====
System.out.println("\n=== Average Order Value by Region ===");
Map avgByRegion = sales.stream()
.collect(Collectors.groupingBy(
Sale::region,
Collectors.averagingDouble(s -> s.amount() * s.quantity())
));
avgByRegion.forEach((region, avg) ->
System.out.printf(" %-10s $%,10.2f%n", region, avg));
// ===== 4. Monthly Revenue Trend =====
System.out.println("\n=== Monthly Revenue ===");
Map monthlyRevenue = sales.stream()
.collect(Collectors.groupingBy(
s -> s.date().getMonth(),
TreeMap::new, // sorted by month
Collectors.summingDouble(s -> s.amount() * s.quantity())
));
monthlyRevenue.forEach((month, rev) ->
System.out.printf(" %-12s $%,12.2f%n", month, rev));
// ===== 5. Products per Category with Quantities =====
System.out.println("\n=== Products per Category ===");
Map> productsByCat = sales.stream()
.collect(Collectors.groupingBy(
Sale::category,
Collectors.groupingBy(
Sale::product,
Collectors.summingInt(Sale::quantity)
)
));
productsByCat.forEach((cat, products) -> {
System.out.println(" " + cat + ":");
products.forEach((prod, qty) ->
System.out.printf(" %-15s %,d units%n", prod, qty));
});
// ===== 6. Partition: High-Value vs Low-Value Transactions =====
System.out.println("\n=== High vs Low Value Transactions ===");
double threshold = 3000;
Map highLow = sales.stream()
.collect(Collectors.partitioningBy(
s -> s.amount() * s.quantity() >= threshold,
Collectors.counting()
));
System.out.println(" High-value (>= $" + threshold + "): " + highLow.get(true));
System.out.println(" Low-value (< $" + threshold + "): " + highLow.get(false));
// ===== 7. Region with Highest Total Revenue =====
sales.stream()
.collect(Collectors.groupingBy(
Sale::region,
Collectors.summingDouble(s -> s.amount() * s.quantity())
))
.entrySet().stream()
.max(Map.Entry.comparingByValue())
.ifPresent(e -> System.out.printf(
"%n=== Top Region ===%n %s with $%,.2f total revenue%n",
e.getKey(), e.getValue()));
// ===== 8. Summary Statistics per Category =====
System.out.println("\n=== Revenue Stats per Category ===");
sales.stream()
.collect(Collectors.groupingBy(
Sale::category,
Collectors.summarizingDouble(s -> s.amount() * s.quantity())
))
.forEach((cat, stats) -> System.out.printf(
" %s: %d transactions, avg=$%,.2f, min=$%,.2f, max=$%,.2f, total=$%,.2f%n",
cat, stats.getCount(), stats.getAverage(),
stats.getMin(), stats.getMax(), stats.getSum()));
// ===== 9. Category-Region Cross Report =====
System.out.println("\n=== Category-Region Revenue Matrix ===");
Map> matrix = sales.stream()
.collect(Collectors.groupingBy(
Sale::category,
Collectors.groupingBy(
Sale::region,
Collectors.summingDouble(s -> s.amount() * s.quantity())
)
));
matrix.forEach((cat, regions) -> {
System.out.println(" " + cat + ":");
regions.entrySet().stream()
.sorted(Map.Entry.comparingByValue().reversed())
.forEach(e -> System.out.printf(" %-10s $%,10.2f%n", e.getKey(), e.getValue()));
});
// ===== 10. Full Report as Formatted String =====
System.out.println("\n=== Full Sales Report ===");
String report = sales.stream()
.sorted(Comparator.comparing(Sale::date))
.map(s -> String.format(" %s | %-10s | %-12s | %-6s | %3d units | $%,10.2f",
s.date(), s.product(), s.category(), s.region(),
s.quantity(), s.amount() * s.quantity()))
.collect(Collectors.joining("\n"));
System.out.println(String.format(" %-10s | %-10s | %-12s | %-6s | %9s | %12s",
"Date", "Product", "Category", "Region", "Qty", "Revenue"));
System.out.println(" " + "-".repeat(75));
System.out.println(report);
}
}
| Collector | Use Case | Example |
|---|---|---|
toList() |
Collect to mutable List | stream.collect(Collectors.toList()) |
toSet() |
Collect to Set (no duplicates) | stream.collect(Collectors.toSet()) |
toMap() |
Build key-value pairs | Collectors.toMap(k, v, merge) |
joining() |
Concatenate strings | Collectors.joining(", ", "[", "]") |
groupingBy() |
Group by classifier | Collectors.groupingBy(f, downstream) |
partitioningBy() |
Split into true/false | Collectors.partitioningBy(pred) |
counting() |
Count elements in group | groupingBy(f, counting()) |
summingDouble() |
Sum numeric values | groupingBy(f, summingDouble(g)) |
averagingDouble() |
Average numeric values | groupingBy(f, averagingDouble(g)) |
summarizingDouble() |
All stats in one pass | groupingBy(f, summarizingDouble(g)) |
minBy() / maxBy() |
Min/max in group | groupingBy(f, minBy(comp)) |
reducing() |
Custom reduction per group | groupingBy(f, reducing(id, mapper, op)) |
mapping() |
Transform before downstream | groupingBy(f, mapping(g, toList())) |
flatMapping() |
Flatten before downstream | groupingBy(f, flatMapping(g, toSet())) |
filtering() |
Filter within each group | groupingBy(f, filtering(pred, toList())) |
Collector.of() |
Build a custom collector | Collector.of(supplier, acc, comb, fin) |
collect() terminal operation relies on them entirely.Collector.of() give you full control when built-in options are insufficient.