Imagine you are a teacher handing back graded papers. You pick up each paper from the stack, call the student’s name, and hand it over. You do not care about the paper’s position in the stack — you just process each one in order until the stack is empty. That is what forEach() does: it takes every element in a collection and performs an action on it.
Introduced in Java 8, forEach() is a default method on the Iterable interface. Since all collections (List, Set, Queue) implement Iterable, every collection in Java gets forEach() for free. Map also has its own forEach() method that accepts both key and value.
The method signature:
// On Iterable (List, Set, Queue) default void forEach(Consumer super T> action) // On Map default void forEach(BiConsumer super K, ? super V> action)
It takes a Consumer (a functional interface that accepts one argument and returns nothing) and applies it to each element.
The fundamental difference between forEach() and traditional loops is who controls the iteration:
| Aspect | External Iteration (for loop) | Internal Iteration (forEach) |
|---|---|---|
| Who controls iteration? | You (the programmer) | The collection itself |
| Index management | You manage the index variable | No index to manage |
| How elements are accessed | You call get(i) or use an iterator |
Elements are passed to your action |
| Parallelism potential | Difficult to parallelize | Collection could parallelize internally |
| Style | Imperative (how to iterate) | Declarative (what to do with each element) |
import java.util.List;
public class InternalVsExternal {
public static void main(String[] args) {
List languages = List.of("Java", "Python", "Go", "Rust");
// External iteration: YOU control the loop
System.out.println("--- External (for-i) ---");
for (int i = 0; i < languages.size(); i++) {
System.out.println(languages.get(i));
}
// External iteration: enhanced for-each (still external, compiler converts to iterator)
System.out.println("--- External (for-each) ---");
for (String lang : languages) {
System.out.println(lang);
}
// Internal iteration: THE COLLECTION controls the loop
System.out.println("--- Internal (forEach) ---");
languages.forEach(lang -> System.out.println(lang));
// Even shorter with method reference
System.out.println("--- Internal (method reference) ---");
languages.forEach(System.out::println);
}
}
// All four produce the same output:
// Java
// Python
// Go
// Rust
Lists are the most common collection type, and forEach() works naturally with them. Since List is ordered, forEach() processes elements in insertion order.
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
public class ForEachOnList {
public static void main(String[] args) {
// --- Basic lambda ---
List fruits = List.of("Apple", "Banana", "Cherry", "Date");
fruits.forEach(fruit -> System.out.println("Fruit: " + fruit));
// Output:
// Fruit: Apple
// Fruit: Banana
// Fruit: Cherry
// Fruit: Date
// --- Method reference ---
System.out.println("\n--- Method reference ---");
fruits.forEach(System.out::println);
// Output:
// Apple
// Banana
// Cherry
// Date
// --- Multi-line lambda (block body) ---
System.out.println("\n--- Multi-line lambda ---");
List numbers = List.of(1, 2, 3, 4, 5);
numbers.forEach(n -> {
int squared = n * n;
System.out.println(n + " squared = " + squared);
});
// Output:
// 1 squared = 1
// 2 squared = 4
// 3 squared = 9
// 4 squared = 16
// 5 squared = 25
// --- Building a result (collecting into external variable) ---
System.out.println("\n--- Accumulating results ---");
List names = List.of("alice", "bob", "charlie");
List uppercased = new ArrayList<>();
names.forEach(name -> uppercased.add(name.toUpperCase()));
System.out.println("Uppercased: " + uppercased);
// Output: Uppercased: [ALICE, BOB, CHARLIE]
}
}
One limitation of forEach() is that it does not provide an index. If you need the index, you have several options:
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.IntStream;
public class ForEachWithIndex {
public static void main(String[] args) {
List colors = List.of("Red", "Green", "Blue", "Yellow");
// Option 1: AtomicInteger counter
System.out.println("--- AtomicInteger ---");
AtomicInteger index = new AtomicInteger(0);
colors.forEach(color ->
System.out.println(index.getAndIncrement() + ": " + color));
// Output:
// 0: Red
// 1: Green
// 2: Blue
// 3: Yellow
// Option 2: IntStream.range (preferred -- cleaner)
System.out.println("\n--- IntStream.range ---");
IntStream.range(0, colors.size())
.forEach(i -> System.out.println(i + ": " + colors.get(i)));
// Output:
// 0: Red
// 1: Green
// 2: Blue
// 3: Yellow
// Option 3: Just use a traditional for loop (simplest when you need the index)
System.out.println("\n--- Traditional for (recommended for index access) ---");
for (int i = 0; i < colors.size(); i++) {
System.out.println(i + ": " + colors.get(i));
}
// Output: same as above
}
}
Tip: If you need the index, a traditional for loop is usually clearer than hacking an index counter into forEach(). Use the right tool for the job.
Sets have no guaranteed ordering (except LinkedHashSet and TreeSet). When you call forEach() on a HashSet, the order of elements is unpredictable and may change between runs.
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.Set;
import java.util.TreeSet;
public class ForEachOnSets {
public static void main(String[] args) {
// --- HashSet: no ordering guarantee ---
System.out.println("--- HashSet (unordered) ---");
Set hashSet = new HashSet<>();
hashSet.add("Banana");
hashSet.add("Apple");
hashSet.add("Cherry");
hashSet.add("Date");
hashSet.forEach(item -> System.out.println(" " + item));
// Output order is NOT guaranteed -- could be:
// Apple
// Cherry
// Banana
// Date
// --- LinkedHashSet: insertion order preserved ---
System.out.println("\n--- LinkedHashSet (insertion order) ---");
Set linkedSet = new LinkedHashSet<>();
linkedSet.add("Banana");
linkedSet.add("Apple");
linkedSet.add("Cherry");
linkedSet.add("Date");
linkedSet.forEach(item -> System.out.println(" " + item));
// Output: always in insertion order
// Banana
// Apple
// Cherry
// Date
// --- TreeSet: sorted order ---
System.out.println("\n--- TreeSet (sorted) ---");
Set treeSet = new TreeSet<>();
treeSet.add("Banana");
treeSet.add("Apple");
treeSet.add("Cherry");
treeSet.add("Date");
treeSet.forEach(item -> System.out.println(" " + item));
// Output: always sorted alphabetically
// Apple
// Banana
// Cherry
// Date
// --- Practical: counting unique characters ---
System.out.println("\n--- Unique characters ---");
String text = "hello world";
Set uniqueChars = new LinkedHashSet<>();
for (char c : text.toCharArray()) {
uniqueChars.add(c);
}
System.out.print("Unique chars: ");
uniqueChars.forEach(c -> System.out.print(c + " "));
System.out.println();
// Output: Unique chars: h e l o w r d
}
}
Map has its own forEach() method that accepts a BiConsumer, giving you direct access to both the key and value. Before Java 8, iterating over a map required calling entrySet() and manually extracting keys and values.
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.TreeMap;
public class ForEachOnMaps {
public static void main(String[] args) {
Map scores = new LinkedHashMap<>();
scores.put("Alice", 95);
scores.put("Bob", 87);
scores.put("Charlie", 92);
scores.put("Diana", 78);
// --- Before Java 8: verbose ---
System.out.println("--- Before Java 8 ---");
for (Map.Entry entry : scores.entrySet()) {
System.out.println(entry.getKey() + ": " + entry.getValue());
}
// Output:
// Alice: 95
// Bob: 87
// Charlie: 92
// Diana: 78
// --- Java 8 forEach with BiConsumer ---
System.out.println("\n--- Java 8 forEach ---");
scores.forEach((name, score) ->
System.out.println(name + " scored " + score));
// Output:
// Alice scored 95
// Bob scored 87
// Charlie scored 92
// Diana scored 78
// --- Conditional logic inside forEach ---
System.out.println("\n--- High scorers (90+) ---");
scores.forEach((name, score) -> {
if (score >= 90) {
System.out.println(name + ": " + score + " (Excellent!)");
}
});
// Output:
// Alice: 95 (Excellent!)
// Charlie: 92 (Excellent!)
// --- Building a formatted report ---
System.out.println("\n--- Formatted Report ---");
Map config = new TreeMap<>();
config.put("database.host", "localhost");
config.put("database.port", "5432");
config.put("database.name", "myapp");
config.put("server.port", "8080");
config.forEach((key, value) ->
System.out.printf(" %-20s = %s%n", key, value));
// Output (TreeMap sorts by key):
// database.host = localhost
// database.name = myapp
// database.port = 5432
// server.port = 8080
// --- Iterating only keys or only values ---
System.out.println("\n--- Keys only ---");
scores.keySet().forEach(name -> System.out.println("Student: " + name));
// Output:
// Student: Alice
// Student: Bob
// Student: Charlie
// Student: Diana
System.out.println("\n--- Values only ---");
scores.values().forEach(score -> System.out.println("Score: " + score));
// Output:
// Score: 95
// Score: 87
// Score: 92
// Score: 78
}
}
There are two ways to use forEach(): directly on the collection (collection.forEach()) and through a stream (collection.stream().forEach()). They look similar but have important differences.
| Aspect | collection.forEach() |
stream().forEach() |
|---|---|---|
| Source | Default method on Iterable |
Terminal operation on Stream |
| Order guarantee | Follows the collection's iteration order | No order guarantee (especially with parallel streams) |
| Can filter/map first? | No (processes all elements as-is) | Yes (chain with filter(), map(), etc.) |
| Use when... | You want to perform an action on every element | You need to transform or filter before acting |
import java.util.List;
public class ForEachVsStreamForEach {
public static void main(String[] args) {
List names = List.of("Alice", "Bob", "Charlie", "Diana", "Eve");
// collection.forEach() -- processes ALL elements
System.out.println("--- collection.forEach() ---");
names.forEach(name -> System.out.println("Hello, " + name));
// Output: Hello, Alice ... Hello, Bob ... etc.
// stream().forEach() -- can filter/map first
System.out.println("\n--- stream().filter().forEach() ---");
names.stream()
.filter(name -> name.length() > 3)
.map(String::toUpperCase)
.forEach(name -> System.out.println("Hello, " + name));
// Output:
// Hello, ALICE
// Hello, CHARLIE
// Hello, DIANA
// For simple iteration, collection.forEach() is preferred
// For filtered/transformed iteration, stream().forEach() is needed
}
}
When using parallel streams, forEach() does not guarantee processing order. If order matters, use forEachOrdered() instead.
import java.util.List;
public class ForEachOrdered {
public static void main(String[] args) {
List letters = List.of("A", "B", "C", "D", "E", "F", "G", "H");
// Parallel stream with forEach -- order is NOT guaranteed
System.out.println("--- parallelStream().forEach() ---");
letters.parallelStream()
.forEach(letter -> System.out.print(letter + " "));
System.out.println();
// Output might be: E F G H A B C D (order varies each run)
// Parallel stream with forEachOrdered -- order IS guaranteed
System.out.println("--- parallelStream().forEachOrdered() ---");
letters.parallelStream()
.forEachOrdered(letter -> System.out.print(letter + " "));
System.out.println();
// Output: A B C D E F G H (always in order)
// Note: forEachOrdered() with parallel stream may reduce
// performance benefits of parallelism, since it must
// wait for elements to be processed in order.
}
}
forEach() is not always the best choice. Understanding its limitations helps you pick the right iteration approach.
| Feature | Traditional for | Enhanced for-each | forEach() |
|---|---|---|---|
| Index access | Yes | No | No |
| Break / continue | Yes | Yes | No |
| Checked exceptions | Yes | Yes | No (must wrap) |
| Modify local variables | Yes | Yes | No (must be effectively final) |
| Modify collection during iteration | With care (by index) | No (ConcurrentModificationException) | No (ConcurrentModificationException) |
| Lambda / method reference | No | No | Yes |
| Readability for simple actions | Verbose | Good | Excellent |
| Return from enclosing method | Yes | Yes | No (return only exits the lambda) |
import java.util.List;
public class ForEachLimitations {
public static void main(String[] args) {
List items = List.of("Apple", "Banana", "Cherry", "Date", "Elderberry");
// --- LIMITATION 1: No break or continue ---
// With traditional loop: stop at "Cherry"
System.out.println("--- Traditional loop with break ---");
for (String item : items) {
if (item.equals("Cherry")) {
break;
}
System.out.println(item);
}
// Output:
// Apple
// Banana
// With forEach: return only exits the current lambda iteration, NOT the loop
System.out.println("\n--- forEach with return (NOT a break!) ---");
items.forEach(item -> {
if (item.equals("Cherry")) {
return; // This skips only "Cherry", does NOT break the loop
}
System.out.println(item);
});
// Output:
// Apple
// Banana
// Date
// Elderberry
// --- LIMITATION 2: Cannot modify local variables ---
// This will NOT compile:
// int count = 0;
// items.forEach(item -> count++); // Error: variable must be effectively final
// Workaround (but use streams or traditional loop instead):
int[] count = {0}; // Array is effectively final, its contents are not
items.forEach(item -> count[0]++);
System.out.println("\nCount: " + count[0]);
// Output: Count: 5
// --- LIMITATION 3: Cannot throw checked exceptions ---
// This will NOT compile:
// items.forEach(item -> {
// java.io.FileReader fr = new java.io.FileReader(item); // throws IOException
// });
// Workaround: wrap in try-catch
items.forEach(item -> {
try {
// Simulating a checked exception scenario
if (item.equals("Date")) {
throw new Exception("Simulated error for " + item);
}
} catch (Exception e) {
System.err.println("Error: " + e.getMessage());
}
});
}
}
| Scenario | Best Choice | Why |
|---|---|---|
| Print each element | forEach |
Simple, concise, readable |
| Need index | Traditional for |
forEach does not provide index |
| Need to break early | Traditional for or enhanced for-each |
forEach does not support break |
| Filter then act | stream().filter().forEach() |
Chain operations before iterating |
| Checked exceptions in body | Enhanced for-each |
forEach lambda cannot throw checked exceptions |
| Modify a local variable | Traditional for |
Lambda requires effectively final variables |
| Simple side effect per element | forEach |
Most concise and readable |
Modifying a collection while iterating over it with forEach() throws a ConcurrentModificationException.
import java.util.ArrayList;
import java.util.List;
import java.util.Iterator;
public class ForEachModifyMistake {
public static void main(String[] args) {
// --- WRONG: Modifying collection inside forEach ---
List names = new ArrayList<>(List.of("Alice", "Bob", "Charlie", "Diana"));
try {
names.forEach(name -> {
if (name.startsWith("C")) {
names.remove(name); // ConcurrentModificationException!
}
});
} catch (java.util.ConcurrentModificationException e) {
System.out.println("Error: ConcurrentModificationException caught!");
}
// Output: Error: ConcurrentModificationException caught!
// --- FIX 1: Use removeIf (best approach) ---
List names2 = new ArrayList<>(List.of("Alice", "Bob", "Charlie", "Diana"));
names2.removeIf(name -> name.startsWith("C"));
System.out.println("After removeIf: " + names2);
// Output: After removeIf: [Alice, Bob, Diana]
// --- FIX 2: Use Iterator.remove() ---
List names3 = new ArrayList<>(List.of("Alice", "Bob", "Charlie", "Diana"));
Iterator it = names3.iterator();
while (it.hasNext()) {
if (it.next().startsWith("C")) {
it.remove(); // Safe removal during iteration
}
}
System.out.println("After Iterator.remove: " + names3);
// Output: After Iterator.remove: [Alice, Bob, Diana]
// --- FIX 3: Collect items to remove, then removeAll ---
List names4 = new ArrayList<>(List.of("Alice", "Bob", "Charlie", "Diana"));
List toRemove = new ArrayList<>();
names4.forEach(name -> {
if (name.startsWith("C")) {
toRemove.add(name);
}
});
names4.removeAll(toRemove);
System.out.println("After removeAll: " + names4);
// Output: After removeAll: [Alice, Bob, Diana]
}
}
forEach() is designed for performing actions (side effects), but those side effects should be predictable and contained. Avoid complex state mutations inside forEach().
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
public class ForEachSideEffects {
public static void main(String[] args) {
List words = List.of("java", "python", "java", "go", "python", "java");
// --- BAD: Complex state mutation inside forEach ---
Map wordCount = new HashMap<>();
words.forEach(word -> {
if (wordCount.containsKey(word)) {
wordCount.put(word, wordCount.get(word) + 1);
} else {
wordCount.put(word, 1);
}
});
System.out.println("forEach approach: " + wordCount);
// Output: forEach approach: {python=2, java=3, go=1}
// --- BETTER: Use streams with Collectors for aggregation ---
Map wordCountStream = words.stream()
.collect(Collectors.groupingBy(w -> w, Collectors.counting()));
System.out.println("Stream approach: " + wordCountStream);
// Output: Stream approach: {python=2, java=3, go=1}
// --- ALSO GOOD: Use Map.merge() ---
Map wordCountMerge = new HashMap<>();
words.forEach(word -> wordCountMerge.merge(word, 1, Integer::sum));
System.out.println("Merge approach: " + wordCountMerge);
// Output: Merge approach: {python=2, java=3, go=1}
}
}
Inside a forEach() lambda, return does not exit the loop. It only exits the current lambda invocation (equivalent to continue in a traditional loop). This is a frequent source of confusion.
import java.util.List;
public class ReturnVsBreak {
public static void main(String[] args) {
List numbers = List.of(1, 2, 3, 4, 5, 6, 7, 8);
// --- Intention: stop processing after finding 5 ---
// WRONG: return does not break the loop
System.out.println("--- forEach with return (acts like continue, not break) ---");
numbers.forEach(n -> {
if (n > 5) {
return; // Only skips this lambda call, loop continues!
}
System.out.println("Processing: " + n);
});
// Output: Processing: 1, 2, 3, 4, 5 (but the loop still ran for 6, 7, 8)
// CORRECT: Use a stream with takeWhile (Java 9+) for early termination
System.out.println("\n--- stream takeWhile (Java 9+) ---");
numbers.stream()
.takeWhile(n -> n <= 5)
.forEach(n -> System.out.println("Processing: " + n));
// Output: Processing: 1, 2, 3, 4, 5
// CORRECT: Use a traditional loop if you need break
System.out.println("\n--- Traditional loop with break ---");
for (int n : numbers) {
if (n > 5) {
break;
}
System.out.println("Processing: " + n);
}
// Output: Processing: 1, 2, 3, 4, 5
}
}
| # | Practice | Details |
|---|---|---|
| 1 | Use forEach for simple actions | Printing, logging, sending notifications -- one-liner actions are perfect for forEach() |
| 2 | Use method references when possible | list.forEach(System.out::println) is cleaner than list.forEach(x -> System.out.println(x)) |
| 3 | Prefer streams for complex operations | If you need to filter, map, or reduce, use the Stream API instead of forEach() with conditionals |
| 4 | Use traditional loops for control flow | If you need break, continue, index access, or checked exceptions, use a traditional loop |
| 5 | Do not modify the source collection | Use removeIf() or Iterator.remove() instead of removing inside forEach() |
| 6 | Use Map.forEach for map iteration | map.forEach((k, v) -> ...) is cleaner than iterating over entrySet() |
| 7 | Use forEachOrdered with parallel streams | If order matters and you are using parallel streams, use forEachOrdered() |
| 8 | Keep lambdas short | If the lambda body exceeds 3-4 lines, extract it into a named method and use a method reference |
import java.util.List;
public class ForEachBestPractices {
public static void main(String[] args) {
List emails = List.of(
"alice@example.com",
"invalid-email",
"bob@company.org",
"",
"charlie@test.net"
);
// BAD: Complex logic crammed into forEach
System.out.println("--- BAD: Complex forEach ---");
emails.forEach(email -> {
if (email != null && !email.isEmpty()) {
if (email.contains("@")) {
String domain = email.substring(email.indexOf("@") + 1);
System.out.println("Valid: " + email + " (domain: " + domain + ")");
} else {
System.out.println("Invalid: " + email);
}
}
});
// GOOD: Use streams for filtering, forEach for the final action
System.out.println("\n--- GOOD: Stream pipeline + forEach ---");
emails.stream()
.filter(email -> email != null && !email.isEmpty())
.filter(email -> email.contains("@"))
.map(email -> email + " (domain: " + email.substring(email.indexOf("@") + 1) + ")")
.forEach(result -> System.out.println("Valid: " + result));
// Output:
// Valid: alice@example.com (domain: example.com)
// Valid: bob@company.org (domain: company.org)
// Valid: charlie@test.net (domain: test.net)
// GOOD: Extract complex logic into a method, use method reference
System.out.println("\n--- GOOD: Method reference ---");
emails.stream()
.filter(ForEachBestPractices::isValidEmail)
.forEach(ForEachBestPractices::processEmail);
// Output:
// Processing email: alice@example.com -> domain: example.com
// Processing email: bob@company.org -> domain: company.org
// Processing email: charlie@test.net -> domain: test.net
}
private static boolean isValidEmail(String email) {
return email != null && !email.isEmpty() && email.contains("@");
}
private static void processEmail(String email) {
String domain = email.substring(email.indexOf("@") + 1);
System.out.println("Processing email: " + email + " -> domain: " + domain);
}
}
This example demonstrates forEach() across all collection types in a realistic scenario: processing student enrollment data.
import java.util.*;
import java.util.stream.Collectors;
public class ForEachPracticalExample {
public static void main(String[] args) {
System.out.println("========================================");
System.out.println(" Student Enrollment Report Generator");
System.out.println("========================================\n");
// --- Setup: Student data ---
Map> courseEnrollments = new LinkedHashMap<>();
courseEnrollments.put("Java 101", List.of("Alice", "Bob", "Charlie", "Diana"));
courseEnrollments.put("Python 201", List.of("Alice", "Eve", "Frank"));
courseEnrollments.put("Data Structures", List.of("Bob", "Charlie", "Eve", "Grace"));
courseEnrollments.put("Algorithms", List.of("Alice", "Diana", "Frank", "Grace"));
Map studentGPA = new LinkedHashMap<>();
studentGPA.put("Alice", 3.9);
studentGPA.put("Bob", 3.2);
studentGPA.put("Charlie", 3.5);
studentGPA.put("Diana", 3.8);
studentGPA.put("Eve", 3.1);
studentGPA.put("Frank", 2.9);
studentGPA.put("Grace", 3.7);
// --- 1. List forEach: Print each course with student count ---
System.out.println("--- Course Summary ---");
courseEnrollments.forEach((course, students) ->
System.out.printf(" %-20s : %d students%n", course, students.size()));
// Output:
// Java 101 : 4 students
// Python 201 : 3 students
// Data Structures : 4 students
// Algorithms : 4 students
// --- 2. Set forEach: Find all unique students ---
Set allStudents = new TreeSet<>();
courseEnrollments.values().forEach(students -> allStudents.addAll(students));
System.out.println("\n--- All Enrolled Students (sorted) ---");
allStudents.forEach(student -> System.out.println(" " + student));
// Output (TreeSet = alphabetical order):
// Alice
// Bob
// Charlie
// Diana
// Eve
// Frank
// Grace
// --- 3. Map forEach: Display GPA with honors status ---
System.out.println("\n--- Student GPA Report ---");
studentGPA.forEach((name, gpa) -> {
String honors = gpa >= 3.5 ? " (Dean's List)" : "";
System.out.printf(" %-10s : %.1f%s%n", name, gpa, honors);
});
// Output:
// Alice : 3.9 (Dean's List)
// Bob : 3.2
// Charlie : 3.5 (Dean's List)
// Diana : 3.8 (Dean's List)
// Eve : 3.1
// Frank : 2.9
// Grace : 3.7 (Dean's List)
// --- 4. Stream forEach: Find students in multiple courses ---
System.out.println("\n--- Students in 2+ Courses ---");
Map courseCount = new HashMap<>();
courseEnrollments.values()
.forEach(students -> students.forEach(student ->
courseCount.merge(student, 1L, Long::sum)));
courseCount.entrySet().stream()
.filter(entry -> entry.getValue() >= 2)
.sorted(Map.Entry.comparingByValue().reversed())
.forEach(entry -> System.out.printf(" %-10s : %d courses%n",
entry.getKey(), entry.getValue()));
// Output:
// Alice : 3 courses
// Bob : 2 courses
// Charlie : 2 courses
// Diana : 2 courses
// Eve : 2 courses
// Frank : 2 courses
// Grace : 2 courses
// --- 5. forEach with conditional: Roster for each course ---
System.out.println("\n--- Detailed Rosters ---");
courseEnrollments.forEach((course, students) -> {
System.out.println(" " + course + ":");
students.forEach(student -> {
double gpa = studentGPA.getOrDefault(student, 0.0);
System.out.printf(" - %-10s (GPA: %.1f)%n", student, gpa);
});
System.out.println();
});
// Output:
// Java 101:
// - Alice (GPA: 3.9)
// - Bob (GPA: 3.2)
// - Charlie (GPA: 3.5)
// - Diana (GPA: 3.8)
//
// Python 201:
// - Alice (GPA: 3.9)
// - Eve (GPA: 3.1)
// - Frank (GPA: 2.9)
// ... etc.
// --- 6. Summary statistics using forEach ---
System.out.println("--- Summary Statistics ---");
double[] totalGPA = {0};
int[] count = {0};
studentGPA.forEach((name, gpa) -> {
totalGPA[0] += gpa;
count[0]++;
});
System.out.printf(" Total students : %d%n", count[0]);
System.out.printf(" Average GPA : %.2f%n", totalGPA[0] / count[0]);
System.out.printf(" Total courses : %d%n", courseEnrollments.size());
// Output:
// Total students : 7
// Average GPA : 3.44
// Total courses : 4
// Better approach for statistics: use streams
double avgGPA = studentGPA.values().stream()
.mapToDouble(Double::doubleValue)
.average()
.orElse(0.0);
System.out.printf(" Average GPA (stream) : %.2f%n", avgGPA);
// Output: Average GPA (stream) : 3.44
}
}
This example demonstrates forEach() in every context:
| Usage | Where Demonstrated |
|---|---|
Map.forEach(BiConsumer) |
Course summary, GPA report, detailed rosters |
Set.forEach(Consumer) |
Printing all unique students (TreeSet) |
List.forEach(Consumer) |
Printing students within each course roster |
stream().filter().forEach() |
Finding students enrolled in multiple courses |
Nested forEach |
Course roster with per-student GPA lookup |
forEach with merge() |
Counting course enrollments per student |
Streams vs forEach for aggregation |
Average GPA calculation (both approaches compared) |
| Method | On | Accepts | Key Behavior |
|---|---|---|---|
forEach(Consumer) |
Iterable (List, Set, Queue) |
Consumer<T> |
Iterates in collection order |
forEach(BiConsumer) |
Map |
BiConsumer<K, V> |
Provides key and value |
stream().forEach() |
Stream |
Consumer<T> |
Terminal op; no order guarantee with parallel |
forEachOrdered() |
Stream |
Consumer<T> |
Guarantees encounter order even in parallel |
Think of a conveyor belt in a sushi restaurant. Plates of sushi travel along the belt, and you — the customer — pick only the ones you want, maybe add some soy sauce, and eat them. You never rearrange the kitchen. You never touch the plates you skip. You simply describe what you want and let the system deliver it.
The Java Stream API, introduced in Java 8 as part of the java.util.stream package, brings this declarative processing model to collections and other data sources. Instead of writing explicit loops that tell the computer how to iterate, you write a pipeline that describes what transformations and filters to apply.
Key characteristics of Streams:
filter, map, and reduce to express complex data processing in a readable, composable way.parallelStream()), leveraging multiple CPU cores automatically.Every Stream pipeline has exactly three parts:
| Part | Description | Examples |
|---|---|---|
| Source | Where the data originates | list.stream(), Arrays.stream(arr), Stream.of() |
| Intermediate Operations | Transform the stream (lazy, return a new Stream) | filter(), map(), sorted(), distinct() |
| Terminal Operation | Produces a result or side-effect (triggers execution) | collect(), forEach(), count(), reduce() |
Here is a visual representation of how a Stream pipeline works:
Source Intermediate Ops Terminal Op
[data] ----> filter() ----> map() ----> collect()
| | |
(lazy) (lazy) (triggers all)
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
public class StreamIntro {
public static void main(String[] args) {
List names = Arrays.asList("Alice", "Bob", "Charlie", "David", "Eve");
// Stream pipeline: source -> intermediate ops -> terminal op
List result = names.stream() // Source: create stream from list
.filter(n -> n.length() > 3) // Intermediate: keep names longer than 3 chars
.map(String::toUpperCase) // Intermediate: convert to uppercase
.sorted() // Intermediate: sort alphabetically
.collect(Collectors.toList()); // Terminal: collect into a new list
System.out.println("Result: " + result);
// Output: Result: [ALICE, CHARLIE, DAVID]
// The original list is unchanged
System.out.println("Original: " + names);
// Output: Original: [Alice, Bob, Charlie, David, Eve]
// Streams are single-use -- this will throw IllegalStateException
var stream = names.stream();
stream.count(); // terminal operation consumes the stream
// stream.forEach(System.out::println); // ERROR: stream has already been operated upon
}
}
Before you can process data with the Stream API, you need to create a Stream. Java provides multiple ways depending on the data source.
The most common way. Every class implementing Collection (List, Set, Queue) inherits a stream() method.
import java.util.*;
import java.util.stream.Collectors;
public class StreamFromCollections {
public static void main(String[] args) {
// From a List
List languages = List.of("Java", "Python", "Go", "Rust");
List filtered = languages.stream()
.filter(lang -> lang.length() > 2)
.collect(Collectors.toList());
System.out.println("Filtered: " + filtered);
// Output: Filtered: [Java, Python, Rust]
// From a Set
Set numbers = new LinkedHashSet<>(Set.of(5, 3, 1, 4, 2));
int sum = numbers.stream()
.filter(n -> n % 2 != 0)
.mapToInt(Integer::intValue)
.sum();
System.out.println("Sum of odds: " + sum);
// From a Map (via entrySet, keySet, or values)
Map scores = Map.of("Alice", 92, "Bob", 78, "Charlie", 95);
scores.entrySet().stream()
.filter(e -> e.getValue() >= 90)
.forEach(e -> System.out.println(e.getKey() + " scored " + e.getValue()));
// Output: Alice scored 92
// Charlie scored 95
}
}
Use Arrays.stream() for arrays. For primitive arrays (int[], long[], double[]), it returns the corresponding primitive stream (IntStream, LongStream, DoubleStream).
import java.util.Arrays;
import java.util.stream.Stream;
public class StreamFromArrays {
public static void main(String[] args) {
String[] colors = {"Red", "Green", "Blue", "Yellow"};
// Full array stream
Arrays.stream(colors).forEach(System.out::println);
// Partial array: from index 1 (inclusive) to 3 (exclusive)
String[] partial = Arrays.stream(colors, 1, 3)
.toArray(String[]::new);
System.out.println(Arrays.toString(partial));
// Output: [Green, Blue]
// Stream.of() works with varargs
Stream.of("One", "Two", "Three").forEach(System.out::println);
// Primitive array -- returns IntStream, not Stream
int[] numbers = {10, 20, 30, 40};
int sum = Arrays.stream(numbers).sum();
System.out.println("Sum: " + sum); // Output: Sum: 100
double avg = Arrays.stream(numbers).average().orElse(0.0);
System.out.println("Average: " + avg); // Output: Average: 25.0
}
}
Java provides static factory methods on the Stream interface to create streams without needing an existing collection or array.
import java.util.List;
import java.util.UUID;
import java.util.stream.Collectors;
import java.util.stream.Stream;
public class StreamFactoryMethods {
public static void main(String[] args) {
// Stream.empty() -- useful as a return value instead of null
Stream empty = Stream.empty();
System.out.println("Empty count: " + empty.count()); // Output: 0
// Stream.of() -- create from individual elements
Stream.of("Java", "Python", "Go")
.map(String::toUpperCase)
.forEach(System.out::println);
// Stream.generate() -- infinite stream from a Supplier
// ALWAYS use limit() or the stream runs forever
List uuids = Stream.generate(UUID::randomUUID)
.limit(3)
.map(UUID::toString)
.collect(Collectors.toList());
System.out.println("UUIDs: " + uuids);
// Stream.iterate() -- Java 8 style (seed + UnaryOperator, must limit)
List powersOf2 = Stream.iterate(1, n -> n * 2)
.limit(8)
.collect(Collectors.toList());
System.out.println("Powers: " + powersOf2);
// Output: Powers: [1, 2, 4, 8, 16, 32, 64, 128]
// Stream.iterate() -- Java 9+ style (seed + predicate + UnaryOperator)
Stream.iterate(1, n -> n <= 100, n -> n * 2)
.forEach(n -> System.out.print(n + " "));
// Output: 1 2 4 8 16 32 64
System.out.println();
// Stream.concat() -- combine two streams into one
Stream s1 = Stream.of("A", "B");
Stream s2 = Stream.of("C", "D");
Stream.concat(s1, s2).forEach(System.out::println);
// Output: A, B, C, D
}
}
For generating sequences of integers, IntStream provides range() (exclusive end) and rangeClosed() (inclusive end). These are more efficient than Stream.iterate() for simple numeric ranges.
import java.util.stream.IntStream;
import java.util.stream.LongStream;
public class StreamRanges {
public static void main(String[] args) {
// range(start, endExclusive) -- does NOT include the end value
IntStream.range(1, 5).forEach(n -> System.out.print(n + " "));
// Output: 1 2 3 4
System.out.println();
// rangeClosed(start, endInclusive) -- INCLUDES the end value
IntStream.rangeClosed(1, 5).forEach(n -> System.out.print(n + " "));
// Output: 1 2 3 4 5
System.out.println();
// Sum of 1 to 100
int sum = IntStream.rangeClosed(1, 100).sum();
System.out.println("Sum 1-100: " + sum); // Output: Sum 1-100: 5050
// LongStream for larger ranges
long bigSum = LongStream.rangeClosed(1, 1_000_000).sum();
System.out.println("Sum 1-1M: " + bigSum);
// Output: Sum 1-1M: 500000500000
// Using range as a loop replacement
IntStream.range(0, 5)
.mapToObj(i -> "Item " + i)
.forEach(System.out::println);
}
}
| Method | Returns | Use Case |
|---|---|---|
collection.stream() |
Stream<T> |
Most common — stream from any Collection |
Arrays.stream(array) |
Stream<T> or primitive stream |
Stream from an array |
Stream.of(a, b, c) |
Stream<T> |
Stream from individual values |
Stream.empty() |
Stream<T> |
Empty stream (null-safe return) |
Stream.generate(supplier) |
Stream<T> |
Infinite stream from a Supplier |
Stream.iterate(seed, op) |
Stream<T> |
Infinite stream with iteration |
Stream.concat(s1, s2) |
Stream<T> |
Merge two streams |
IntStream.range(a, b) |
IntStream |
Range of ints [a, b) |
IntStream.rangeClosed(a, b) |
IntStream |
Range of ints [a, b] |
Intermediate operations transform a Stream into another Stream. They are lazy — nothing happens until a terminal operation triggers the pipeline. You can chain as many intermediate operations as you need. Each one returns a new Stream, leaving the original Stream unchanged.
filter() keeps only the elements that match the given condition. Elements that pass the test continue down the pipeline; those that fail are discarded.
import java.util.List;
import java.util.stream.Collectors;
public class FilterExample {
public static void main(String[] args) {
List numbers = List.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
// Keep only even numbers
List evens = numbers.stream()
.filter(n -> n % 2 == 0)
.collect(Collectors.toList());
System.out.println("Evens: " + evens);
// Output: Evens: [2, 4, 6, 8, 10]
// Chaining multiple filters (equivalent to AND)
List result = numbers.stream()
.filter(n -> n > 3)
.filter(n -> n < 8)
.collect(Collectors.toList());
System.out.println("Between 3 and 8: " + result);
// Output: Between 3 and 8: [4, 5, 6, 7]
// Filtering with complex predicates
List names = List.of("Alice", "Bob", "Charlie", "Anna", "Andrew");
List startsWithA = names.stream()
.filter(name -> name.startsWith("A"))
.filter(name -> name.length() > 4)
.collect(Collectors.toList());
System.out.println("Starts with A, length > 4: " + startsWithA);
// Output: Starts with A, length > 4: [Alice, Andrew]
}
}
map() transforms each element by applying a function. The input type and output type can be different. This is one of the most commonly used operations.
import java.util.List;
import java.util.stream.Collectors;
public class MapExample {
public static void main(String[] args) {
// Transform strings to uppercase
List names = List.of("alice", "bob", "charlie");
List upper = names.stream()
.map(String::toUpperCase)
.collect(Collectors.toList());
System.out.println("Upper: " + upper);
// Output: Upper: [ALICE, BOB, CHARLIE]
// Transform to different type: String -> Integer (length)
List lengths = names.stream()
.map(String::length)
.collect(Collectors.toList());
System.out.println("Lengths: " + lengths);
// Output: Lengths: [5, 3, 7]
// Chain map operations
List formatted = names.stream()
.map(String::trim)
.map(s -> s.substring(0, 1).toUpperCase() + s.substring(1))
.map(s -> "Hello, " + s + "!")
.collect(Collectors.toList());
System.out.println("Formatted: " + formatted);
// Output: Formatted: [Hello, Alice!, Hello, Bob!, Hello, Charlie!]
// Extract a field from objects
List emails = List.of("alice@mail.com", "bob@mail.com", "charlie@mail.com");
List usernames = emails.stream()
.map(email -> email.split("@")[0])
.collect(Collectors.toList());
System.out.println("Usernames: " + usernames);
// Output: Usernames: [alice, bob, charlie]
}
}
flatMap() is used when each element maps to multiple elements (a stream of streams). It flattens the nested streams into a single stream. This is essential when dealing with lists of lists, optional values, or one-to-many transformations.
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
public class FlatMapExample {
public static void main(String[] args) {
// Problem: List of lists -- map gives Stream>
List> nested = List.of(
List.of("Java", "Python"),
List.of("Go", "Rust"),
List.of("JavaScript", "TypeScript")
);
// map() would give us Stream> -- not what we want
// flatMap() flattens it into Stream
List allLanguages = nested.stream()
.flatMap(List::stream)
.collect(Collectors.toList());
System.out.println("All: " + allLanguages);
// Output: All: [Java, Python, Go, Rust, JavaScript, TypeScript]
// Split sentences into words
List sentences = List.of(
"Java is great",
"Streams are powerful",
"FlatMap flattens"
);
List words = sentences.stream()
.flatMap(sentence -> Arrays.stream(sentence.split(" ")))
.map(String::toLowerCase)
.distinct()
.sorted()
.collect(Collectors.toList());
System.out.println("Words: " + words);
// Output: Words: [are, flatmap, flattens, great, is, java, powerful, streams]
// flatMap with arrays
String[][] data = {{"a", "b"}, {"c", "d"}, {"e", "f"}};
List flat = Arrays.stream(data)
.flatMap(Arrays::stream)
.collect(Collectors.toList());
System.out.println("Flat: " + flat);
// Output: Flat: [a, b, c, d, e, f]
}
}
sorted() sorts the elements. Without arguments, it uses natural ordering (elements must implement Comparable). With a Comparator argument, you control the sort order.
import java.util.Comparator;
import java.util.List;
import java.util.stream.Collectors;
public class SortedExample {
public static void main(String[] args) {
List names = List.of("Charlie", "Alice", "Eve", "Bob", "David");
// Natural ordering (alphabetical for Strings)
List sorted = names.stream()
.sorted()
.collect(Collectors.toList());
System.out.println("Sorted: " + sorted);
// Output: Sorted: [Alice, Bob, Charlie, David, Eve]
// Reverse order
List reversed = names.stream()
.sorted(Comparator.reverseOrder())
.collect(Collectors.toList());
System.out.println("Reversed: " + reversed);
// Output: Reversed: [Eve, David, Charlie, Bob, Alice]
// Sort by length
List byLength = names.stream()
.sorted(Comparator.comparingInt(String::length))
.collect(Collectors.toList());
System.out.println("By length: " + byLength);
// Output: By length: [Bob, Eve, Alice, David, Charlie]
// Sort by length descending, then alphabetically
List complex = names.stream()
.sorted(Comparator.comparingInt(String::length)
.reversed()
.thenComparing(Comparator.naturalOrder()))
.collect(Collectors.toList());
System.out.println("Complex sort: " + complex);
// Output: Complex sort: [Charlie, Alice, David, Bob, Eve]
}
}
These operations help you control which elements pass through the pipeline and inspect elements during processing.
| Operation | Description |
|---|---|
distinct() |
Removes duplicates (uses equals()) |
peek(Consumer) |
Performs an action on each element without consuming it — useful for debugging |
limit(n) |
Truncates the stream to at most n elements |
skip(n) |
Discards the first n elements |
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.Stream;
public class MoreIntermediateOps {
public static void main(String[] args) {
// distinct() -- removes duplicates
List numbers = List.of(1, 2, 3, 2, 1, 4, 3, 5);
List unique = numbers.stream()
.distinct()
.collect(Collectors.toList());
System.out.println("Unique: " + unique);
// Output: Unique: [1, 2, 3, 4, 5]
// peek() -- inspect elements without consuming (great for debugging)
List result = List.of("one", "two", "three", "four").stream()
.filter(s -> s.length() > 3)
.peek(s -> System.out.println("After filter: " + s))
.map(String::toUpperCase)
.peek(s -> System.out.println("After map: " + s))
.collect(Collectors.toList());
// Output:
// After filter: three
// After map: THREE
// After filter: four
// After map: FOUR
System.out.println("Result: " + result);
// Output: Result: [THREE, FOUR]
// limit(n) -- take only the first n elements
List firstThree = List.of(10, 20, 30, 40, 50).stream()
.limit(3)
.collect(Collectors.toList());
System.out.println("First 3: " + firstThree);
// Output: First 3: [10, 20, 30]
// skip(n) -- skip the first n elements
List skipTwo = List.of(10, 20, 30, 40, 50).stream()
.skip(2)
.collect(Collectors.toList());
System.out.println("Skip 2: " + skipTwo);
// Output: Skip 2: [30, 40, 50]
// Pagination: skip + limit
List allItems = List.of("A", "B", "C", "D", "E", "F", "G", "H");
int pageSize = 3;
int pageNumber = 2; // 0-based
List page = allItems.stream()
.skip((long) pageNumber * pageSize)
.limit(pageSize)
.collect(Collectors.toList());
System.out.println("Page 2: " + page);
// Output: Page 2: [G, H]
}
}
| Operation | Signature | Description |
|---|---|---|
filter |
filter(Predicate<T>) |
Keep elements matching the predicate |
map |
map(Function<T, R>) |
Transform each element |
flatMap |
flatMap(Function<T, Stream<R>>) |
Transform and flatten nested streams |
sorted |
sorted() or sorted(Comparator) |
Sort elements |
distinct |
distinct() |
Remove duplicates |
peek |
peek(Consumer<T>) |
Perform action without consuming |
limit |
limit(long n) |
Truncate to first n elements |
skip |
skip(long n) |
Discard first n elements |
Terminal operations trigger the execution of the entire pipeline and produce a result (a value, a collection, or a side-effect). Once a terminal operation is called, the stream is consumed and cannot be reused.
forEach() performs an action on each element. For parallel streams, use forEachOrdered() if order matters.
import java.util.List;
public class ForEachExample {
public static void main(String[] args) {
List fruits = List.of("Apple", "Banana", "Cherry");
// forEach -- order guaranteed for sequential streams
fruits.stream().forEach(f -> System.out.println("Fruit: " + f));
// Method reference shorthand
fruits.stream().forEach(System.out::println);
// forEachOrdered -- guarantees encounter order even in parallel
fruits.parallelStream().forEachOrdered(System.out::println);
// Output (always in order): Apple, Banana, Cherry
}
}
collect() is the most versatile terminal operation. It uses a Collector to accumulate elements into a result container (List, Set, Map, String, etc.). The Collectors utility class provides dozens of pre-built collectors.
import java.util.*;
import java.util.stream.Collectors;
public class CollectExample {
public static void main(String[] args) {
List names = List.of("Alice", "Bob", "Charlie", "Alice", "David");
// Collect to List
List list = names.stream()
.filter(n -> n.length() > 3)
.collect(Collectors.toList());
System.out.println("List: " + list);
// Output: List: [Alice, Charlie, Alice, David]
// Collect to Set (removes duplicates)
Set set = names.stream()
.collect(Collectors.toSet());
System.out.println("Set: " + set);
// Collect to Map
Map nameLengths = names.stream()
.distinct()
.collect(Collectors.toMap(
name -> name, // key
String::length // value
));
System.out.println("Map: " + nameLengths);
// Output: Map: {Alice=5, Bob=3, Charlie=7, David=5}
// Join strings
String joined = names.stream()
.distinct()
.collect(Collectors.joining(", ", "[", "]"));
System.out.println("Joined: " + joined);
// Output: Joined: [Alice, Bob, Charlie, David]
}
}
reduce() combines all elements into a single result by repeatedly applying a binary operator. It has three forms: with identity, without identity, and with identity + combiner (for parallel streams).
import java.util.List;
import java.util.Optional;
public class ReduceExample {
public static void main(String[] args) {
List numbers = List.of(1, 2, 3, 4, 5);
// reduce with identity -- always returns a value
int sum = numbers.stream()
.reduce(0, Integer::sum);
System.out.println("Sum: " + sum); // Output: Sum: 15
int product = numbers.stream()
.reduce(1, (a, b) -> a * b);
System.out.println("Product: " + product); // Output: Product: 120
// reduce without identity -- returns Optional (stream could be empty)
Optional max = numbers.stream()
.reduce(Integer::max);
max.ifPresent(m -> System.out.println("Max: " + m)); // Output: Max: 5
// Concatenating strings with reduce
List words = List.of("Java", "Streams", "Are", "Powerful");
String sentence = words.stream()
.reduce("", (a, b) -> a.isEmpty() ? b : a + " " + b);
System.out.println("Sentence: " + sentence);
// Output: Sentence: Java Streams Are Powerful
// reduce on empty stream
Optional emptyResult = List.of().stream()
.reduce(Integer::sum);
System.out.println("Empty: " + emptyResult.isPresent()); // Output: Empty: false
}
}
These terminal operations return aggregate values from the stream.
import java.util.Comparator;
import java.util.List;
import java.util.Optional;
public class CountMinMaxExample {
public static void main(String[] args) {
List numbers = List.of(5, 3, 8, 1, 9, 2, 7);
// count()
long count = numbers.stream()
.filter(n -> n > 5)
.count();
System.out.println("Count > 5: " + count); // Output: Count > 5: 3
// min() with Comparator
Optional min = numbers.stream()
.min(Comparator.naturalOrder());
System.out.println("Min: " + min.orElse(-1)); // Output: Min: 1
// max() with Comparator
Optional max = numbers.stream()
.max(Comparator.naturalOrder());
System.out.println("Max: " + max.orElse(-1)); // Output: Max: 9
// Finding the longest string
List words = List.of("cat", "elephant", "dog", "hippopotamus");
Optional longest = words.stream()
.max(Comparator.comparingInt(String::length));
System.out.println("Longest: " + longest.orElse("none"));
// Output: Longest: hippopotamus
}
}
findFirst() returns the first element. findAny() returns any element (useful with parallel streams for better performance). Both return Optional.
import java.util.List;
import java.util.Optional;
public class FindExample {
public static void main(String[] args) {
List names = List.of("Charlie", "Alice", "Bob", "Andrew", "Anna");
// findFirst() -- guaranteed to return the first matching element
Optional first = names.stream()
.filter(n -> n.startsWith("A"))
.findFirst();
System.out.println("First with A: " + first.orElse("none"));
// Output: First with A: Alice
// findAny() -- may return any matching element (non-deterministic in parallel)
Optional any = names.parallelStream()
.filter(n -> n.startsWith("A"))
.findAny();
System.out.println("Any with A: " + any.orElse("none"));
// Output: Any with A: Alice (or Andrew or Anna -- non-deterministic)
// findFirst on empty result
Optional missing = names.stream()
.filter(n -> n.startsWith("Z"))
.findFirst();
System.out.println("Found Z? " + missing.isPresent());
// Output: Found Z? false
}
}
These short-circuiting operations test whether elements match a condition. They return boolean and stop processing as soon as the answer is determined.
import java.util.List;
public class MatchExample {
public static void main(String[] args) {
List numbers = List.of(2, 4, 6, 8, 10);
// anyMatch -- does at least one element match?
boolean hasEven = numbers.stream().anyMatch(n -> n % 2 == 0);
System.out.println("Has even? " + hasEven); // Output: Has even? true
// allMatch -- do ALL elements match?
boolean allEven = numbers.stream().allMatch(n -> n % 2 == 0);
System.out.println("All even? " + allEven); // Output: All even? true
// noneMatch -- does NO element match?
boolean noneNegative = numbers.stream().noneMatch(n -> n < 0);
System.out.println("None negative? " + noneNegative); // Output: None negative? true
// Short-circuiting behavior
List mixed = List.of(1, 2, 3, 4, 5);
boolean anyOver3 = mixed.stream()
.peek(n -> System.out.print("Checking " + n + "... "))
.anyMatch(n -> n > 3);
System.out.println("\nAny over 3? " + anyOver3);
// Output: Checking 1... Checking 2... Checking 3... Checking 4...
// Any over 3? true
// Note: 5 was never checked -- short-circuited after finding 4
}
}
toArray() collects the stream elements into an array. Without arguments it returns Object[]; with a generator function it returns the specific type.
import java.util.Arrays;
import java.util.List;
public class ToArrayExample {
public static void main(String[] args) {
List names = List.of("Alice", "Bob", "Charlie");
// toArray() without argument -- returns Object[]
Object[] objArray = names.stream().toArray();
System.out.println(Arrays.toString(objArray));
// toArray with generator -- returns String[]
String[] strArray = names.stream()
.map(String::toUpperCase)
.toArray(String[]::new);
System.out.println(Arrays.toString(strArray));
// Output: [ALICE, BOB, CHARLIE]
// Convert filtered stream to int array
int[] evenNumbers = List.of(1, 2, 3, 4, 5, 6).stream()
.filter(n -> n % 2 == 0)
.mapToInt(Integer::intValue)
.toArray();
System.out.println(Arrays.toString(evenNumbers));
// Output: [2, 4, 6]
}
}
| Operation | Return Type | Description |
|---|---|---|
forEach(Consumer) |
void |
Perform action on each element |
collect(Collector) |
R |
Accumulate into a container (List, Set, Map, etc.) |
reduce(identity, op) |
T |
Combine all elements into one value |
count() |
long |
Count elements |
min(Comparator) |
Optional<T> |
Find minimum element |
max(Comparator) |
Optional<T> |
Find maximum element |
findFirst() |
Optional<T> |
First element (deterministic) |
findAny() |
Optional<T> |
Any element (non-deterministic in parallel) |
anyMatch(Predicate) |
boolean |
True if any element matches |
allMatch(Predicate) |
boolean |
True if all elements match |
noneMatch(Predicate) |
boolean |
True if no element matches |
toArray() |
Object[] or T[] |
Collect to an array |
The Collectors class in java.util.stream provides a rich set of pre-built collectors for the collect() terminal operation. This section covers the most commonly used ones. For a deep dive into all Collectors, see the dedicated Java 8 Collectors Class tutorial.
import java.util.*;
import java.util.stream.Collectors;
public class BasicCollectors {
public static void main(String[] args) {
List names = List.of("Alice", "Bob", "Charlie", "Alice", "David");
// toList() -- ordered, allows duplicates
List list = names.stream()
.filter(n -> n.length() > 3)
.collect(Collectors.toList());
System.out.println("List: " + list);
// Output: List: [Alice, Charlie, Alice, David]
// toSet() -- unordered, no duplicates
Set set = names.stream()
.collect(Collectors.toSet());
System.out.println("Set: " + set);
// toMap() -- key-value pairs
Map nameLengths = names.stream()
.distinct()
.collect(Collectors.toMap(
n -> n, // key mapper
String::length // value mapper
));
System.out.println("Map: " + nameLengths);
// toMap() with merge function for duplicate keys
Map nameCount = names.stream()
.collect(Collectors.toMap(
n -> n, // key
n -> 1, // value
Integer::sum // merge: add counts
));
System.out.println("Counts: " + nameCount);
// Output: Counts: {Alice=2, Bob=1, Charlie=1, David=1}
}
}
Collectors.joining() concatenates stream elements into a single String. You can specify a delimiter, prefix, and suffix.
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");
// Simple concatenation
String simple = languages.stream().collect(Collectors.joining());
System.out.println(simple); // Output: JavaPythonGoRust
// With delimiter
String delimited = languages.stream().collect(Collectors.joining(", "));
System.out.println(delimited); // Output: Java, Python, Go, Rust
// With delimiter, prefix, and suffix
String formatted = languages.stream()
.collect(Collectors.joining(" | ", "[ ", " ]"));
System.out.println(formatted); // Output: [ Java | Python | Go | Rust ]
// Practical: building a SQL IN clause
List ids = List.of(101, 205, 310, 422);
String inClause = ids.stream()
.map(String::valueOf)
.collect(Collectors.joining(", ", "WHERE id IN (", ")"));
System.out.println(inClause);
// Output: WHERE id IN (101, 205, 310, 422)
}
}
groupingBy() groups elements by a classifier function into a Map<K, List<T>>. partitioningBy() is a special case that splits elements into two groups (true/false) based on a predicate.
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
public class GroupingExample {
public static void main(String[] args) {
List names = List.of("Alice", "Anna", "Bob", "Brian", "Charlie", "Chris");
// Group by first letter
Map> byLetter = names.stream()
.collect(Collectors.groupingBy(n -> n.charAt(0)));
System.out.println("By letter: " + byLetter);
// Output: By letter: {A=[Alice, Anna], B=[Bob, Brian], C=[Charlie, Chris]}
// Group by length with counting
Map byLength = names.stream()
.collect(Collectors.groupingBy(String::length, Collectors.counting()));
System.out.println("By length: " + byLength);
// Output: By length: {3=1, 4=1, 5=3, 7=1}
// partitioningBy -- split into two groups
Map> partitioned = names.stream()
.collect(Collectors.partitioningBy(n -> n.length() > 4));
System.out.println("Long names: " + partitioned.get(true));
System.out.println("Short names: " + partitioned.get(false));
// Output: Long names: [Alice, Brian, Charlie, Chris]
// Short names: [Anna, Bob]
}
}
Parallel streams divide the workload across multiple threads using the common ForkJoinPool. They can significantly speed up processing of large data sets, but they are not always faster and come with important caveats.
| Use Parallel When… | Avoid Parallel When… |
|---|---|
| Large data sets (100,000+ elements) | Small data sets (overhead exceeds benefit) |
| CPU-bound operations (computation) | I/O-bound operations (network, file reads) |
| No shared mutable state | Operations modify shared variables |
| Operations are independent and stateless | Order-dependent processing |
| Source supports efficient splitting (ArrayList, arrays) | Poor-splitting sources (LinkedList, Stream.iterate) |
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
public class ParallelStreamExample {
public static void main(String[] args) {
// Creating a parallel stream
List numbers = IntStream.rangeClosed(1, 1_000_000)
.boxed()
.collect(Collectors.toList());
// Sequential sum
long startSeq = System.nanoTime();
long sumSeq = numbers.stream()
.mapToLong(Integer::longValue)
.sum();
long timeSeq = System.nanoTime() - startSeq;
// Parallel sum
long startPar = System.nanoTime();
long sumPar = numbers.parallelStream()
.mapToLong(Integer::longValue)
.sum();
long timePar = System.nanoTime() - startPar;
System.out.println("Sequential sum: " + sumSeq + " in " + timeSeq / 1_000_000 + "ms");
System.out.println("Parallel sum: " + sumPar + " in " + timePar / 1_000_000 + "ms");
// Converting between sequential and parallel
List names = List.of("Alice", "Bob", "Charlie");
names.stream()
.parallel() // switch to parallel
.sequential() // switch back to sequential
.forEach(System.out::println);
}
}
Parallel streams use multiple threads. If your operations modify shared mutable state, you will get race conditions and incorrect results. Here is an example of what NOT to do:
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
public class ParallelSafetyExample {
public static void main(String[] args) {
// WRONG: Modifying a shared list from parallel stream
List unsafeList = new ArrayList<>();
IntStream.rangeClosed(1, 10000)
.parallel()
.forEach(unsafeList::add); // Race condition!
System.out.println("Unsafe size: " + unsafeList.size());
// Output: unpredictable -- could be less than 10000, could throw exception
// CORRECT: Use collect() instead of modifying shared state
List safeList = IntStream.rangeClosed(1, 10000)
.parallel()
.boxed()
.collect(Collectors.toList());
System.out.println("Safe size: " + safeList.size());
// Output: Safe size: 10000
// CORRECT: Use a synchronized collection if you must
List syncList = Collections.synchronizedList(new ArrayList<>());
IntStream.rangeClosed(1, 10000)
.parallel()
.forEach(syncList::add);
System.out.println("Sync size: " + syncList.size());
// Output: Sync size: 10000
}
}
Java generics do not work with primitives, so Stream<int> is not valid. To avoid the performance cost of boxing/unboxing (wrapping primitives in Integer, Long, Double), Java provides three specialized stream types:
| Stream Type | Element Type | Key Methods |
|---|---|---|
IntStream |
int |
sum(), average(), min(), max(), range() |
LongStream |
long |
sum(), average(), min(), max(), range() |
DoubleStream |
double |
sum(), average(), min(), max() |
| From | To | Method |
|---|---|---|
Stream<T> |
IntStream |
mapToInt(ToIntFunction) |
Stream<T> |
LongStream |
mapToLong(ToLongFunction) |
Stream<T> |
DoubleStream |
mapToDouble(ToDoubleFunction) |
IntStream |
Stream<Integer> |
boxed() |
IntStream |
LongStream |
asLongStream() |
IntStream |
DoubleStream |
asDoubleStream() |
import java.util.IntSummaryStatistics;
import java.util.List;
import java.util.OptionalDouble;
import java.util.OptionalInt;
import java.util.stream.IntStream;
public class PrimitiveStreamExample {
public static void main(String[] args) {
// IntStream basics
int sum = IntStream.of(10, 20, 30, 40, 50).sum();
System.out.println("Sum: " + sum); // Output: Sum: 150
OptionalDouble avg = IntStream.of(10, 20, 30, 40, 50).average();
System.out.println("Average: " + avg.orElse(0.0)); // Output: Average: 30.0
OptionalInt min = IntStream.of(10, 20, 30, 40, 50).min();
System.out.println("Min: " + min.orElse(-1)); // Output: Min: 10
OptionalInt max = IntStream.of(10, 20, 30, 40, 50).max();
System.out.println("Max: " + max.orElse(-1)); // Output: Max: 50
// summaryStatistics -- get all stats in one pass
IntSummaryStatistics stats = IntStream.of(10, 20, 30, 40, 50)
.summaryStatistics();
System.out.println("Count: " + stats.getCount()); // 5
System.out.println("Sum: " + stats.getSum()); // 150
System.out.println("Min: " + stats.getMin()); // 10
System.out.println("Max: " + stats.getMax()); // 50
System.out.println("Avg: " + stats.getAverage()); // 30.0
// mapToInt -- convert Stream to IntStream
List words = List.of("Java", "is", "powerful");
int totalChars = words.stream()
.mapToInt(String::length)
.sum();
System.out.println("Total chars: " + totalChars); // Output: Total chars: 15
// boxed() -- convert IntStream back to Stream
List boxed = IntStream.rangeClosed(1, 5)
.boxed()
.toList();
System.out.println("Boxed: " + boxed); // Output: Boxed: [1, 2, 3, 4, 5]
}
}
Streams are a powerful tool, but they can be misused. Following these best practices will keep your stream pipelines readable, correct, and performant.
Each operation in the pipeline should do one thing. If a lambda is more than 2-3 lines, extract it into a method and use a method reference.
import java.util.List;
import java.util.stream.Collectors;
public class BestPracticeSimple {
public static void main(String[] args) {
List emails = List.of(
"alice@GMAIL.com", " bob@yahoo.com ", "CHARLIE@gmail.com", "invalid-email"
);
// BAD: Complex logic crammed into one lambda
List bad = emails.stream()
.filter(e -> {
String trimmed = e.trim().toLowerCase();
return trimmed.contains("@") && trimmed.endsWith(".com");
})
.map(e -> e.trim().toLowerCase())
.collect(Collectors.toList());
// GOOD: Extract complex logic into named methods
List good = emails.stream()
.map(String::trim)
.map(String::toLowerCase)
.filter(BestPracticeSimple::isValidEmail)
.collect(Collectors.toList());
System.out.println("Valid emails: " + good);
// Output: Valid emails: [alice@gmail.com, bob@yahoo.com, charlie@gmail.com]
}
private static boolean isValidEmail(String email) {
return email.contains("@") && email.endsWith(".com");
}
}
Stream operations should be pure functions — they should not modify external state. The exception is forEach(), which exists specifically for side effects.
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
public class BestPracticeNoSideEffects {
public static void main(String[] args) {
List names = List.of("Alice", "Bob", "Charlie");
// BAD: Side effect in map() -- modifying an external list
List external = new ArrayList<>();
names.stream()
.map(n -> {
external.add(n); // Side effect -- do not do this!
return n.toUpperCase();
})
.collect(Collectors.toList());
// GOOD: Use collect() to build the result
List result = names.stream()
.map(String::toUpperCase)
.collect(Collectors.toList());
System.out.println("Result: " + result);
// Output: Result: [ALICE, BOB, CHARLIE]
}
}
A Stream can only be consumed once. Attempting to reuse it throws IllegalStateException. If you need to process the same data multiple times, create a new Stream each time or store the source data.
import java.util.List;
import java.util.function.Supplier;
import java.util.stream.Collectors;
import java.util.stream.Stream;
public class BestPracticeNoReuse {
public static void main(String[] args) {
List names = List.of("Alice", "Bob", "Charlie");
// BAD: Reusing a stream
Stream stream = names.stream().filter(n -> n.length() > 3);
stream.forEach(System.out::println); // works
// stream.count(); // IllegalStateException: stream has already been operated upon
// GOOD: Create a new stream each time
long count = names.stream().filter(n -> n.length() > 3).count();
List filtered = names.stream()
.filter(n -> n.length() > 3)
.collect(Collectors.toList());
// ALTERNATIVE: Use a Supplier for repeated use
Supplier> streamSupplier = () ->
names.stream().filter(n -> n.length() > 3);
long count2 = streamSupplier.get().count();
List list2 = streamSupplier.get().collect(Collectors.toList());
System.out.println("Count: " + count2 + ", List: " + list2);
}
}
The order of intermediate operations can significantly affect performance. Filter early to reduce the number of elements processed by expensive operations like sorted() or map().
import java.util.List;
import java.util.stream.Collectors;
public class BestPracticeOrder {
public static void main(String[] args) {
List names = List.of("Charlie", "Alice", "Eve", "Bob", "David",
"Frank", "Grace", "Hannah", "Ivy", "Jack");
// BAD: Sort everything, then filter
List bad = names.stream()
.sorted() // sorts all 10 elements
.filter(n -> n.length() > 3) // then filters
.map(String::toUpperCase)
.collect(Collectors.toList());
// GOOD: Filter first, then sort the smaller set
List good = names.stream()
.filter(n -> n.length() > 3) // filters to 7 elements
.sorted() // sorts only 7 elements
.map(String::toUpperCase)
.collect(Collectors.toList());
System.out.println("Result: " + good);
// Output: Result: [ALICE, CHARLIE, DAVID, FRANK, GRACE, HANNAH]
}
}
Streams and loops are both tools for processing collections. Neither is universally better — each has strengths. Knowing when to use which is a mark of a senior developer.
| Scenario | Why Streams Win |
|---|---|
| Filter + transform + collect | Readable, composable pipeline |
| Grouping and aggregation | groupingBy, counting, summarizing are expressive |
| Parallel processing | One method call to parallelize |
| Chaining multiple operations | Flat pipeline vs nested loops |
| Declarative data queries | Reads like SQL: filter-where, map-select, collect-into |
| Scenario | Why Loops Win |
|---|---|
| Need to break/continue mid-iteration | Streams have no break/continue |
| Modifying the source collection | Streams do not modify the source |
| Index-based access needed | Streams do not expose indices directly |
| Multiple return values from one pass | Loops can update multiple variables |
| Checked exceptions in the logic | Lambdas do not support checked exceptions neatly |
| Simple iteration with nothing to transform | A for-each loop is simpler |
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
public class StreamVsLoop {
public static void main(String[] args) {
List names = List.of("Alice", "Bob", "Charlie", "Anna", "David", "Andrew");
// --- STREAM WINS: Filter + Transform + Collect ---
// Stream approach -- clean, declarative
List streamResult = names.stream()
.filter(n -> n.startsWith("A"))
.map(String::toUpperCase)
.sorted()
.collect(Collectors.toList());
// Loop approach -- more verbose
List loopResult = new ArrayList<>();
for (String name : names) {
if (name.startsWith("A")) {
loopResult.add(name.toUpperCase());
}
}
java.util.Collections.sort(loopResult);
System.out.println("Stream: " + streamResult);
System.out.println("Loop: " + loopResult);
// --- STREAM WINS: Grouping ---
Map> grouped = names.stream()
.collect(Collectors.groupingBy(n -> n.charAt(0)));
System.out.println("Grouped: " + grouped);
// This would take 10+ lines with loops
// --- LOOP WINS: Early exit with break ---
String found = null;
for (String name : names) {
if (name.length() > 5) {
found = name;
break; // Stream alternative: findFirst(), but less flexible
}
}
System.out.println("Found: " + found);
// --- LOOP WINS: Index-based access ---
for (int i = 0; i < names.size(); i++) {
System.out.println(i + ": " + names.get(i));
}
}
}
Let us bring everything together with a realistic example. We have a list of employees and need to perform various analytics: filtering, grouping, aggregating, and reporting.
import java.util.*;
import java.util.stream.Collectors;
public class EmployeeAnalytics {
enum Department { ENGINEERING, MARKETING, SALES, HR, FINANCE }
record Employee(String name, Department department, double salary, int yearsOfExperience) {}
public static void main(String[] args) {
List employees = List.of(
new Employee("Alice", Department.ENGINEERING, 95000, 5),
new Employee("Bob", Department.ENGINEERING, 110000, 8),
new Employee("Charlie", Department.MARKETING, 72000, 3),
new Employee("Diana", Department.SALES, 68000, 2),
new Employee("Eve", Department.ENGINEERING, 125000, 12),
new Employee("Frank", Department.HR, 65000, 4),
new Employee("Grace", Department.FINANCE, 88000, 6),
new Employee("Hannah", Department.MARKETING, 78000, 5),
new Employee("Ivan", Department.SALES, 71000, 3),
new Employee("Julia", Department.FINANCE, 92000, 7),
new Employee("Kevin", Department.HR, 60000, 1),
new Employee("Laura", Department.ENGINEERING, 105000, 9),
new Employee("Mike", Department.SALES, 75000, 4),
new Employee("Nina", Department.MARKETING, 82000, 6),
new Employee("Oscar", Department.FINANCE, 97000, 8)
);
// 1. Total salary expense
double totalSalary = employees.stream()
.mapToDouble(Employee::salary)
.sum();
System.out.println("Total salary expense: $" + String.format("%,.2f", totalSalary));
// 2. Average salary
double avgSalary = employees.stream()
.mapToDouble(Employee::salary)
.average()
.orElse(0.0);
System.out.println("Average salary: $" + String.format("%,.2f", avgSalary));
// 3. Highest paid employee
employees.stream()
.max(Comparator.comparingDouble(Employee::salary))
.ifPresent(e -> System.out.println("Highest paid: " + e.name() + " ($" +
String.format("%,.2f", e.salary()) + ")"));
// 4. Employees earning above average
List aboveAverage = employees.stream()
.filter(e -> e.salary() > avgSalary)
.sorted(Comparator.comparingDouble(Employee::salary).reversed())
.map(e -> e.name() + " ($" + String.format("%,.2f", e.salary()) + ")")
.collect(Collectors.toList());
System.out.println("Above average: " + aboveAverage);
// 5. Group by department
Map> byDept = employees.stream()
.collect(Collectors.groupingBy(
Employee::department,
Collectors.mapping(Employee::name, Collectors.toList())
));
System.out.println("\nEmployees by department:");
byDept.forEach((dept, names) ->
System.out.println(" " + dept + ": " + names));
// 6. Average salary by department
Map avgByDept = employees.stream()
.collect(Collectors.groupingBy(
Employee::department,
Collectors.averagingDouble(Employee::salary)
));
System.out.println("\nAverage salary by department:");
avgByDept.entrySet().stream()
.sorted(Map.Entry.comparingByValue().reversed())
.forEach(e -> System.out.println(" " + e.getKey() + ": $" +
String.format("%,.2f", e.getValue())));
// 7. Department with highest average salary
avgByDept.entrySet().stream()
.max(Map.Entry.comparingByValue())
.ifPresent(e -> System.out.println("\nHighest avg dept: " + e.getKey() +
" ($" + String.format("%,.2f", e.getValue()) + ")"));
// 8. Count employees per department
Map countByDept = employees.stream()
.collect(Collectors.groupingBy(Employee::department, Collectors.counting()));
System.out.println("\nHeadcount by department: " + countByDept);
// 9. Partition into senior (5+ years) and junior
Map> seniorPartition = employees.stream()
.collect(Collectors.partitioningBy(e -> e.yearsOfExperience() >= 5));
System.out.println("\nSenior employees (5+ years):");
seniorPartition.get(true).forEach(e ->
System.out.println(" " + e.name() + " - " + e.yearsOfExperience() + " years"));
System.out.println("Junior employees (< 5 years):");
seniorPartition.get(false).forEach(e ->
System.out.println(" " + e.name() + " - " + e.yearsOfExperience() + " years"));
// 10. Salary statistics by department
System.out.println("\nSalary statistics by department:");
employees.stream()
.collect(Collectors.groupingBy(
Employee::department,
Collectors.summarizingDouble(Employee::salary)
))
.forEach((dept, stats) -> System.out.printf(
" %s: count=%d, min=$%,.0f, max=$%,.0f, avg=$%,.0f, total=$%,.0f%n",
dept, stats.getCount(), stats.getMin(), stats.getMax(),
stats.getAverage(), stats.getSum()));
// 11. Top 3 earners across the company
System.out.println("\nTop 3 earners:");
employees.stream()
.sorted(Comparator.comparingDouble(Employee::salary).reversed())
.limit(3)
.forEach(e -> System.out.println(" " + e.name() + " - $" +
String.format("%,.2f", e.salary())));
// 12. Build a salary report string
String report = employees.stream()
.sorted(Comparator.comparing(Employee::name))
.map(e -> String.format("%-10s | %-12s | $%,10.2f | %2d yrs",
e.name(), e.department(), e.salary(), e.yearsOfExperience()))
.collect(Collectors.joining("\n"));
System.out.println("\n--- Employee Report ---");
System.out.println(String.format("%-10s | %-12s | %11s | %s",
"Name", "Department", "Salary", "Experience"));
System.out.println("-".repeat(55));
System.out.println(report);
}
}
Total salary expense: $1,283,000.00 Average salary: $85,533.33 Highest paid: Eve ($125,000.00) Above average: [Eve ($125,000.00), Bob ($110,000.00), Laura ($105,000.00), Oscar ($97,000.00), Alice ($95,000.00), Julia ($92,000.00), Grace ($88,000.00)] Employees by department: ENGINEERING: [Alice, Bob, Eve, Laura] MARKETING: [Charlie, Hannah, Nina] SALES: [Diana, Ivan, Mike] HR: [Frank, Kevin] FINANCE: [Grace, Julia, Oscar] Average salary by department: ENGINEERING: $108,750.00 FINANCE: $92,333.33 MARKETING: $77,333.33 SALES: $71,333.33 HR: $62,500.00 Top 3 earners: Eve - $125,000.00 Bob - $110,000.00 Laura - $105,000.00
Imagine a city planner who designed a road system 20 years ago. Thousands of buildings were constructed along those roads. Now the city needs bike lanes. The planner cannot tear down every building and start over — the city must add bike lanes without breaking the existing infrastructure. That is exactly the problem Java 8 solved with default methods.
Before Java 8, interfaces in Java were pure contracts: they declared methods but provided no implementation. Every class that implemented an interface had to provide code for every method. This worked fine — until the Java team needed to evolve existing interfaces.
Consider the java.util.Collection interface. It has existed since Java 1.2 (1998). Millions of classes implement it — in the JDK itself, in third-party libraries, in enterprise applications worldwide. When Java 8 introduced the Stream API, the team needed to add a stream() method to Collection. But adding an abstract method to an interface would break every class that implements it.
The backward compatibility problem:
Default methods solve this by allowing interfaces to provide a method body using the default keyword. Existing implementations inherit the default behavior automatically, but can override it if needed. This is interface evolution without breaking existing code.
Here is a simple before-and-after to see the problem and solution:
// BEFORE Java 8: Adding a method breaks all implementations
interface Greeting {
void sayHello(String name);
// If we add this, every class implementing Greeting must be updated!
// void sayGoodbye(String name); // <-- Would break existing code
}
class EnglishGreeting implements Greeting {
@Override
public void sayHello(String name) {
System.out.println("Hello, " + name + "!");
}
// If sayGoodbye() were abstract, this class would fail to compile
}
// AFTER Java 8: Default methods allow safe evolution
interface GreetingV2 {
void sayHello(String name);
// Default method -- existing implementations inherit this automatically
default void sayGoodbye(String name) {
System.out.println("Goodbye, " + name + "!");
}
}
class EnglishGreetingV2 implements GreetingV2 {
@Override
public void sayHello(String name) {
System.out.println("Hello, " + name + "!");
}
// sayGoodbye() is inherited from the interface -- no changes required!
}
public class DefaultMethodMotivation {
public static void main(String[] args) {
EnglishGreetingV2 greeting = new EnglishGreetingV2();
greeting.sayHello("Alice");
// Output: Hello, Alice!
greeting.sayGoodbye("Alice");
// Output: Goodbye, Alice!
}
}
EnglishGreetingV2 did not have to change at all. It inherited the default implementation of sayGoodbye() for free. This is the fundamental value of default methods.
A default method is declared in an interface using the default keyword before the return type. Unlike regular interface methods (which are implicitly abstract), a default method has a full method body.
Syntax:
default returnType methodName(parameters) {
// method body
}
Key rules for default methods:
| Rule | Description |
|---|---|
Must use default keyword |
Without it, the method is abstract and has no body |
| Must have a body | Unlike abstract methods, a default method must provide an implementation |
Implicitly public |
All interface methods are public; you cannot make a default method private or protected |
| Can be overridden | Implementing classes can override the default method with their own version |
| Can call other interface methods | A default method can invoke abstract methods declared in the same interface |
| Cannot access instance state | Interfaces have no instance fields; default methods rely on abstract method contracts |
interface Logger {
// Abstract method -- implementing classes MUST provide this
void log(String message);
// Default method -- implementing classes inherit this, can override if needed
default void logInfo(String message) {
log("INFO: " + message);
}
default void logWarning(String message) {
log("WARNING: " + message);
}
default void logError(String message) {
log("ERROR: " + message);
}
}
class ConsoleLogger implements Logger {
@Override
public void log(String message) {
System.out.println("[Console] " + message);
}
// logInfo, logWarning, logError are all inherited!
}
class FileLogger implements Logger {
@Override
public void log(String message) {
// In a real app, this would write to a file
System.out.println("[File] " + message);
}
// Override one default method for custom behavior
@Override
public void logError(String message) {
log("CRITICAL ERROR: " + message);
// In a real app, also send an alert
}
}
public class DefaultMethodBasics {
public static void main(String[] args) {
Logger console = new ConsoleLogger();
console.logInfo("Application started");
// Output: [Console] INFO: Application started
console.logWarning("Memory usage is high");
// Output: [Console] WARNING: Memory usage is high
console.logError("Database connection failed");
// Output: [Console] ERROR: Database connection failed
System.out.println();
Logger file = new FileLogger();
file.logInfo("Application started");
// Output: [File] INFO: Application started
file.logError("Database connection failed");
// Output: [File] CRITICAL ERROR: Database connection failed
}
}
Notice how ConsoleLogger inherits all three default methods, while FileLogger overrides only logError(). The default methods call the abstract log() method, which each implementation defines differently. This is the template method pattern inside an interface.
One of the most useful patterns is having default methods call abstract methods. The interface defines the structure and the implementing class fills in the details.
interface Validator{ // Abstract: each implementation defines its own validation logic boolean isValid(T item); // Default: built on top of isValid() default void validate(T item) { if (!isValid(item)) { throw new IllegalArgumentException("Validation failed for: " + item); } } // Default: validate a collection default long countInvalid(java.util.List items) { return items.stream().filter(item -> !isValid(item)).count(); } // Default: filter to only valid items default java.util.List filterValid(java.util.List items) { return items.stream().filter(this::isValid).collect(java.util.stream.Collectors.toList()); } } class EmailValidator implements Validator { @Override public boolean isValid(String email) { return email != null && email.contains("@") && email.contains("."); } } class AgeValidator implements Validator { @Override public boolean isValid(Integer age) { return age != null && age >= 0 && age <= 150; } } public class DefaultCallingAbstract { public static void main(String[] args) { EmailValidator emailValidator = new EmailValidator(); // Using inherited default methods System.out.println(emailValidator.isValid("alice@example.com")); // Output: true java.util.List emails = java.util.List.of( "alice@example.com", "invalid", "bob@test.org", "no-at-sign" ); System.out.println("Invalid count: " + emailValidator.countInvalid(emails)); // Output: Invalid count: 2 System.out.println("Valid emails: " + emailValidator.filterValid(emails)); // Output: Valid emails: [alice@example.com, bob@test.org] // validate() throws on invalid input try { emailValidator.validate("not-an-email"); } catch (IllegalArgumentException e) { System.out.println(e.getMessage()); // Output: Validation failed for: not-an-email } } }
Each Validator implementation only needs to define isValid(). The three default methods (validate(), countInvalid(), filterValid()) are all inherited for free. If a specific validator needs different behavior for any of these, it can override just that one method.
Java 8 added dozens of default methods to existing interfaces in the JDK. Understanding these real examples shows how default methods were used to add major new capabilities without breaking any existing code.
The Iterable interface (parent of all collections) gained a forEach() default method so that every existing List, Set, and Queue could immediately use lambda-based iteration.
import java.util.List;
import java.util.ArrayList;
public class ForEachDefaultMethod {
public static void main(String[] args) {
List names = new ArrayList<>(List.of("Alice", "Bob", "Charlie"));
// forEach() is a default method in Iterable -- no List change needed
names.forEach(name -> System.out.println("Hello, " + name));
// Output:
// Hello, Alice
// Hello, Bob
// Hello, Charlie
// Using method reference
names.forEach(System.out::println);
// Output:
// Alice
// Bob
// Charlie
}
}
The Stream API is arguably the biggest Java 8 feature. It was made possible by adding stream() and parallelStream() as default methods to Collection.
import java.util.List;
import java.util.stream.Collectors;
public class StreamDefaultMethod {
public static void main(String[] args) {
List names = List.of("Alice", "Bob", "Charlie", "Diana", "Eve");
// stream() is a default method in Collection
List shortNames = names.stream()
.filter(name -> name.length() <= 3)
.collect(Collectors.toList());
System.out.println("Short names: " + shortNames);
// Output: Short names: [Bob, Eve]
// parallelStream() is also a default method
long count = names.parallelStream()
.filter(name -> name.startsWith("A") || name.startsWith("E"))
.count();
System.out.println("Names starting with A or E: " + count);
// Output: Names starting with A or E: 2
}
}
Before Java 8, you had to use Collections.sort(list, comparator). Java 8 added a sort() default method directly to the List interface.
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
public class ListSortDefaultMethod {
public static void main(String[] args) {
List names = new ArrayList<>(List.of("Charlie", "Alice", "Bob"));
// Before Java 8:
// Collections.sort(names);
// Java 8: sort() is a default method on List
names.sort(Comparator.naturalOrder());
System.out.println("Sorted: " + names);
// Output: Sorted: [Alice, Bob, Charlie]
// Sort by length, then alphabetically
names.sort(Comparator.comparingInt(String::length).thenComparing(Comparator.naturalOrder()));
System.out.println("By length: " + names);
// Output: By length: [Bob, Alice, Charlie]
// Reverse order
names.sort(Comparator.reverseOrder());
System.out.println("Reversed: " + names);
// Output: Reversed: [Charlie, Bob, Alice]
}
}
The Map interface received many default methods in Java 8. These eliminated common boilerplate patterns.
import java.util.HashMap;
import java.util.Map;
public class MapDefaultMethods {
public static void main(String[] args) {
Map scores = new HashMap<>();
scores.put("Alice", 95);
scores.put("Bob", 87);
// getOrDefault() -- no more null checks for missing keys
int aliceScore = scores.getOrDefault("Alice", 0);
int unknownScore = scores.getOrDefault("Unknown", 0);
System.out.println("Alice: " + aliceScore + ", Unknown: " + unknownScore);
// Output: Alice: 95, Unknown: 0
// putIfAbsent() -- only puts if key is not already present
scores.putIfAbsent("Bob", 100); // Bob exists, no change
scores.putIfAbsent("Charlie", 92); // Charlie is new, added
System.out.println("Bob: " + scores.get("Bob"));
// Output: Bob: 87
// forEach() with BiConsumer
scores.forEach((name, score) ->
System.out.println(name + " scored " + score)
);
// Output:
// Alice scored 95
// Bob scored 87
// Charlie scored 92
// merge() -- combine existing and new values
scores.merge("Alice", 5, Integer::sum); // Alice: 95 + 5 = 100
System.out.println("Alice after bonus: " + scores.get("Alice"));
// Output: Alice after bonus: 100
// computeIfAbsent() -- compute value only if key is missing
scores.computeIfAbsent("Diana", name -> name.length() * 10);
System.out.println("Diana: " + scores.get("Diana"));
// Output: Diana: 50
// replaceAll() -- transform all values
scores.replaceAll((name, score) -> score + 10);
scores.forEach((name, score) -> System.out.println(name + ": " + score));
// Output:
// Alice: 110
// Bob: 97
// Charlie: 102
// Diana: 60
}
}
All of these methods (getOrDefault, putIfAbsent, forEach, merge, computeIfAbsent, replaceAll) are default methods on the Map interface. Every Map implementation -- HashMap, TreeMap, LinkedHashMap, and any custom implementation -- inherited them all without any code changes.
| Interface | Default Method | Purpose |
|---|---|---|
Iterable |
forEach(Consumer) |
Lambda-based iteration over any Iterable |
Collection |
stream() |
Create a sequential Stream |
Collection |
parallelStream() |
Create a parallel Stream |
Collection |
removeIf(Predicate) |
Remove elements matching a condition |
List |
sort(Comparator) |
Sort the list in place |
List |
replaceAll(UnaryOperator) |
Transform every element |
Map |
getOrDefault(key, default) |
Get value or return default if missing |
Map |
putIfAbsent(key, value) |
Only put if key is not present |
Map |
forEach(BiConsumer) |
Iterate over key-value pairs |
Map |
merge(key, value, BiFunction) |
Merge new value with existing |
Map |
computeIfAbsent(key, Function) |
Compute value if key is missing |
Map |
replaceAll(BiFunction) |
Transform all values |
Default methods introduce a form of multiple inheritance of behavior into Java. A class can implement multiple interfaces, and if two interfaces provide a default method with the same signature, the compiler faces a conflict: which default should the class inherit?
This is called the diamond problem, named after the diamond shape formed in the inheritance diagram. Java 8 defines clear rules for resolving these conflicts.
interface Camera {
default String takePicture() {
return "Camera: snap!";
}
}
interface Phone {
default String takePicture() {
return "Phone: click!";
}
}
// COMPILER ERROR: SmartDevice inherits unrelated defaults for takePicture()
// class SmartDevice implements Camera, Phone { }
// FIX: You MUST override the conflicting method
class SmartDevice implements Camera, Phone {
@Override
public String takePicture() {
// Option 1: Provide your own implementation
return "SmartDevice: capturing with AI!";
}
}
public class DiamondProblemBasic {
public static void main(String[] args) {
SmartDevice device = new SmartDevice();
System.out.println(device.takePicture());
// Output: SmartDevice: capturing with AI!
}
}
When you override a conflicting default method, you can delegate to one of the parent interfaces using the InterfaceName.super.methodName() syntax.
interface Flyable {
default String getSpeed() {
return "Flying at 500 km/h";
}
}
interface Swimmable {
default String getSpeed() {
return "Swimming at 30 km/h";
}
}
class Duck implements Flyable, Swimmable {
@Override
public String getSpeed() {
// Delegate to Flyable's default
return Flyable.super.getSpeed();
}
public String getSwimSpeed() {
// Can also call the other interface's default in any method
return Swimmable.super.getSpeed();
}
}
class FlyingFish implements Flyable, Swimmable {
@Override
public String getSpeed() {
// Combine both!
return Flyable.super.getSpeed() + " | " + Swimmable.super.getSpeed();
}
}
public class DiamondProblemSuper {
public static void main(String[] args) {
Duck duck = new Duck();
System.out.println(duck.getSpeed());
// Output: Flying at 500 km/h
System.out.println(duck.getSwimSpeed());
// Output: Swimming at 30 km/h
FlyingFish fish = new FlyingFish();
System.out.println(fish.getSpeed());
// Output: Flying at 500 km/h | Swimming at 30 km/h
}
}
Java uses three rules (applied in order) to resolve default method conflicts:
| Priority | Rule | Description |
|---|---|---|
| 1 (Highest) | Classes win over interfaces | If a superclass provides a concrete method, it takes priority over any interface default |
| 2 | More specific interface wins | If interface B extends interface A, and both define the same default, B's version wins |
| 3 | Class must override | If neither rule 1 nor 2 resolves it (two unrelated interfaces), the implementing class must explicitly override the method |
interface Printable {
default String getOutput() {
return "Printable default";
}
}
class BasePrinter {
public String getOutput() {
return "BasePrinter concrete method";
}
}
// BasePrinter.getOutput() wins over Printable.getOutput()
class LaserPrinter extends BasePrinter implements Printable {
// No override needed -- BasePrinter's method takes priority
}
public class ClassWinsOverInterface {
public static void main(String[] args) {
LaserPrinter printer = new LaserPrinter();
System.out.println(printer.getOutput());
// Output: BasePrinter concrete method
}
}
interface Animal {
default String sound() {
return "Some generic animal sound";
}
}
interface Dog extends Animal {
@Override
default String sound() {
return "Woof!";
}
}
// Dog is more specific than Animal, so Dog.sound() wins
class GoldenRetriever implements Animal, Dog {
// No override needed -- Dog's default wins because Dog extends Animal
}
public class SpecificInterfaceWins {
public static void main(String[] args) {
GoldenRetriever dog = new GoldenRetriever();
System.out.println(dog.sound());
// Output: Woof!
}
}
Java 8 also introduced static methods in interfaces. Unlike default methods, static methods belong to the interface itself, not to any implementing class. They cannot be overridden and are called using the interface name directly: InterfaceName.staticMethod().
Why static methods in interfaces?
Collections (for Collection) or Paths (for Path)| Rule | Description |
|---|---|
Must use static keyword |
Declared with static in the interface |
| Must have a body | Static methods must provide an implementation |
| Called via interface name | MyInterface.myStaticMethod(), never via an instance |
| Not inherited by implementing classes | Unlike defaults, static methods are not inherited |
| Cannot be overridden | Implementing classes cannot override interface static methods |
Implicitly public |
All interface methods are public (private static allowed in Java 9+) |
interface StringUtils {
// Static utility methods in an interface
static boolean isNullOrEmpty(String str) {
return str == null || str.isEmpty();
}
static boolean isNullOrBlank(String str) {
return str == null || str.trim().isEmpty();
}
static String capitalize(String str) {
if (isNullOrEmpty(str)) return str;
return Character.toUpperCase(str.charAt(0)) + str.substring(1).toLowerCase();
}
static String repeat(String str, int times) {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < times; i++) {
sb.append(str);
}
return sb.toString();
}
}
public class StaticUtilityMethods {
public static void main(String[] args) {
// Called via interface name -- NOT via an instance
System.out.println(StringUtils.isNullOrEmpty(""));
// Output: true
System.out.println(StringUtils.isNullOrEmpty("Hello"));
// Output: false
System.out.println(StringUtils.isNullOrBlank(" "));
// Output: true
System.out.println(StringUtils.capitalize("jAVA"));
// Output: Java
System.out.println(StringUtils.repeat("Ha", 3));
// Output: HaHaHa
}
}
Static methods in interfaces are ideal for the factory method pattern. The interface exposes creation methods while hiding the implementation classes.
interface Shape {
double area();
String describe();
// Factory methods -- callers never need to know the implementation classes
static Shape circle(double radius) {
return new Circle(radius);
}
static Shape rectangle(double width, double height) {
return new Rectangle(width, height);
}
static Shape square(double side) {
return new Rectangle(side, side);
}
}
// Implementation classes can be package-private
class Circle implements Shape {
private final double radius;
Circle(double radius) {
this.radius = radius;
}
@Override
public double area() {
return Math.PI * radius * radius;
}
@Override
public String describe() {
return String.format("Circle(radius=%.1f)", radius);
}
}
class Rectangle implements Shape {
private final double width;
private final double height;
Rectangle(double width, double height) {
this.width = width;
this.height = height;
}
@Override
public double area() {
return width * height;
}
@Override
public String describe() {
return String.format("Rectangle(%.1f x %.1f)", width, height);
}
}
public class StaticFactoryMethods {
public static void main(String[] args) {
// Create shapes using factory methods -- no need to know Circle or Rectangle
Shape circle = Shape.circle(5.0);
Shape rect = Shape.rectangle(4.0, 6.0);
Shape square = Shape.square(3.0);
System.out.println(circle.describe() + " -> area: " + String.format("%.2f", circle.area()));
// Output: Circle(radius=5.0) -> area: 78.54
System.out.println(rect.describe() + " -> area: " + String.format("%.2f", rect.area()));
// Output: Rectangle(4.0 x 6.0) -> area: 24.00
System.out.println(square.describe() + " -> area: " + String.format("%.2f", square.area()));
// Output: Rectangle(3.0 x 3.0) -> area: 9.00
}
}
One of the most powerful static methods added in Java 8 is Comparator.comparing(). It is a static factory method on the Comparator interface that creates comparators from method references.
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
public class ComparatorComparing {
record Employee(String name, String department, int salary) {}
public static void main(String[] args) {
List employees = new ArrayList<>(List.of(
new Employee("Alice", "Engineering", 95000),
new Employee("Bob", "Marketing", 72000),
new Employee("Charlie", "Engineering", 88000),
new Employee("Diana", "Marketing", 85000),
new Employee("Eve", "Engineering", 95000)
));
// Comparator.comparing() is a STATIC method on the Comparator interface
employees.sort(Comparator.comparing(Employee::salary));
System.out.println("By salary (ascending):");
employees.forEach(e -> System.out.println(" " + e));
// Output:
// Employee[name=Bob, department=Marketing, salary=72000]
// Employee[name=Diana, department=Marketing, salary=85000]
// Employee[name=Charlie, department=Engineering, salary=88000]
// Employee[name=Alice, department=Engineering, salary=95000]
// Employee[name=Eve, department=Engineering, salary=95000]
// Chain with default methods: reversed(), thenComparing()
employees.sort(
Comparator.comparing(Employee::department)
.thenComparing(Employee::salary)
.reversed()
);
System.out.println("\nBy department, then salary, reversed:");
employees.forEach(e -> System.out.println(" " + e));
// Output:
// Employee[name=Bob, department=Marketing, salary=72000]
// Employee[name=Diana, department=Marketing, salary=85000]
// Employee[name=Charlie, department=Engineering, salary=88000]
// Employee[name=Alice, department=Engineering, salary=95000]
// Employee[name=Eve, department=Engineering, salary=95000]
}
}
Notice the interplay: Comparator.comparing() is a static method that creates a comparator, and reversed() and thenComparing() are default methods that modify it. Static and default methods work together to create a rich, fluent API.
Java 9 added private methods and private static methods to interfaces. The motivation is simple: when multiple default methods in the same interface share common logic, you need a way to extract that shared code without making it part of the public API.
Before Java 9, you had two bad options:
interface Reportable {
String getData();
default String htmlReport() {
return wrapInDocument("" + formatBody() + "");
}
default String textReport() {
return wrapInDocument("=== REPORT ===\n" + formatBody() + "\n==============");
}
default String csvReport() {
return wrapInDocument("data\n" + getData());
}
// Java 9+: Private method shared by multiple defaults
private String formatBody() {
String raw = getData();
if (raw == null || raw.isEmpty()) {
return "[No data available]";
}
return raw.trim();
}
// Java 9+: Private method to add metadata
private String wrapInDocument(String content) {
return "Generated: " + java.time.LocalDate.now() + "\n" + content;
}
}
class SalesReport implements Reportable {
@Override
public String getData() {
return "Q1: $1.2M, Q2: $1.5M, Q3: $1.1M, Q4: $1.8M";
}
}
public class PrivateMethodsDemo {
public static void main(String[] args) {
SalesReport report = new SalesReport();
System.out.println(report.textReport());
// Output:
// Generated: 2026-02-28
// === REPORT ===
// Q1: $1.2M, Q2: $1.5M, Q3: $1.1M, Q4: $1.8M
// ==============
System.out.println();
System.out.println(report.htmlReport());
// Output:
// Generated: 2026-02-28
// Q1: $1.2M, Q2: $1.5M, Q3: $1.1M, Q4: $1.8M
}
}
Private static methods can be called from both static methods and default methods. They are useful when the shared logic does not depend on instance-level abstract methods.
interface Identifiable {
String getName();
// Static factory using private static helper
static String generateId(String prefix) {
return prefix + "-" + randomSuffix(8);
}
static String generateShortId(String prefix) {
return prefix + "-" + randomSuffix(4);
}
// Java 9+: Private static method -- shared by static methods above
private static String randomSuffix(int length) {
String chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
StringBuilder sb = new StringBuilder();
java.util.Random random = new java.util.Random();
for (int i = 0; i < length; i++) {
sb.append(chars.charAt(random.nextInt(chars.length())));
}
return sb.toString();
}
// Default method can also call private static methods
default String getDisplayId() {
return randomSuffix(6) + "-" + getName().toUpperCase();
}
}
class User implements Identifiable {
private final String name;
User(String name) {
this.name = name;
}
@Override
public String getName() {
return name;
}
}
public class PrivateStaticMethodDemo {
public static void main(String[] args) {
// Static method usage
System.out.println(Identifiable.generateId("USER"));
// Output: USER-A7KF2M9B (random)
System.out.println(Identifiable.generateShortId("ORD"));
// Output: ORD-X3K9 (random)
// Default method usage
User user = new User("Alice");
System.out.println(user.getDisplayId());
// Output: R4TN2K-ALICE (random prefix)
}
}
| Method Type | Java Version | Has Body? | Access Modifier | Inherited? | Can Override? |
|---|---|---|---|---|---|
| Abstract | 1.0+ | No | public (implicit) | Yes | Must implement |
| Default | 8+ | Yes | public (implicit) | Yes | Yes (optional) |
| Static | 8+ | Yes | public (implicit) | No | No |
| Private | 9+ | Yes | private | No | No |
| Private static | 9+ | Yes | private | No | No |
With three kinds of methods now possible in interfaces, it is important to understand when to use each one.
| Feature | Abstract Method | Default Method | Static Method |
|---|---|---|---|
| Keyword | None (implicit) | default |
static |
| Has body? | No | Yes | Yes |
| Inherited? | Yes (must implement) | Yes (can override) | No |
| Can access other methods? | N/A | Yes, including abstract | Only other static methods |
| Called on | Instance | Instance | Interface name |
| Primary use | Define the contract | Provide optional behavior | Utility/factory methods |
| Example | void process(); |
default void log() {...} |
static Foo create() {...} |
interface Notification {
// Abstract: every notification type must define this
String getMessage();
String getRecipient();
// Default: common behavior with a sensible default
default String getSubject() {
return "Notification";
}
default String format() {
return String.format("To: %s\nSubject: %s\n\n%s",
getRecipient(), getSubject(), getMessage());
}
// Static: factory methods
static Notification email(String to, String subject, String body) {
return new Notification() {
@Override
public String getMessage() { return body; }
@Override
public String getRecipient() { return to; }
@Override
public String getSubject() { return subject; }
};
}
static Notification sms(String to, String body) {
return new Notification() {
@Override
public String getMessage() { return body; }
@Override
public String getRecipient() { return to; }
};
}
}
public class AbstractDefaultStaticDemo {
public static void main(String[] args) {
// Static factory methods create instances
Notification email = Notification.email(
"alice@example.com", "Welcome!", "Thanks for signing up."
);
Notification sms = Notification.sms(
"+1234567890", "Your code is 9876"
);
// Default methods provide formatting
System.out.println(email.format());
// Output:
// To: alice@example.com
// Subject: Welcome!
//
// Thanks for signing up.
System.out.println();
System.out.println(sms.format());
// Output:
// To: +1234567890
// Subject: Notification
//
// Your code is 9876
}
}
A functional interface has exactly one abstract method, making it usable with lambda expressions. But a functional interface can have any number of default and static methods. This is how Java 8 built rich, chainable APIs on top of functional interfaces.
The @FunctionalInterface annotation enforces the single-abstract-method rule. Default methods do not count toward this limit.
import java.util.List;
import java.util.function.Predicate;
import java.util.stream.Collectors;
public class PredicateDefaultMethods {
public static void main(String[] args) {
List names = List.of("Alice", "Bob", "Charlie", "Anna", "Eve", "Alex");
// Predicate has ONE abstract method: test(T t)
Predicate startsWithA = name -> name.startsWith("A");
Predicate longerThan3 = name -> name.length() > 3;
// Default method: and() -- combines two predicates with logical AND
Predicate startsWithAAndLong = startsWithA.and(longerThan3);
List result1 = names.stream()
.filter(startsWithAAndLong)
.collect(Collectors.toList());
System.out.println("Starts with A AND longer than 3: " + result1);
// Output: Starts with A AND longer than 3: [Alice, Anna, Alex]
// Default method: or() -- combines with logical OR
Predicate startsWithE = name -> name.startsWith("E");
List result2 = names.stream()
.filter(startsWithA.or(startsWithE))
.collect(Collectors.toList());
System.out.println("Starts with A OR E: " + result2);
// Output: Starts with A OR E: [Alice, Anna, Eve, Alex]
// Default method: negate() -- logical NOT
List result3 = names.stream()
.filter(startsWithA.negate())
.collect(Collectors.toList());
System.out.println("Does NOT start with A: " + result3);
// Output: Does NOT start with A: [Bob, Charlie, Eve]
// Static method: Predicate.not() (Java 11+) -- even cleaner negation
// List result4 = names.stream()
// .filter(Predicate.not(startsWithA))
// .collect(Collectors.toList());
}
}
import java.util.function.Function;
public class FunctionDefaultMethods {
public static void main(String[] args) {
Function trim = String::trim;
Function toUpper = String::toUpperCase;
Function length = String::length;
// Default method: andThen() -- apply this function, then the next
Function trimThenUpper = trim.andThen(toUpper);
System.out.println(trimThenUpper.apply(" hello world "));
// Output: HELLO WORLD
// Default method: compose() -- apply the OTHER function first, then this
Function upperThenLength = length.compose(toUpper);
System.out.println(upperThenLength.apply("hello"));
// Output: 5
// Chain multiple transformations
Function pipeline = trim
.andThen(toUpper)
.andThen(s -> s.replace(" ", "_"));
System.out.println(pipeline.apply(" hello world "));
// Output: HELLO_WORLD
// Static method: Function.identity()
Function identity = Function.identity();
System.out.println(identity.apply("unchanged"));
// Output: unchanged
}
}
Comparator is the best example of how default and static methods transform a functional interface into a powerful API. It has one abstract method (compare()) plus 18 default and static methods.
| Method | Type | Purpose |
|---|---|---|
compare(T, T) |
Abstract | The core comparison (1 abstract = functional interface) |
reversed() |
Default | Reverse the sort order |
thenComparing() |
Default | Add a secondary sort key |
thenComparingInt() |
Default | Secondary sort on int-valued key (avoids boxing) |
comparing(Function) |
Static | Create a comparator from a key extractor |
comparingInt(ToIntFunction) |
Static | Create a comparator from an int key (avoids boxing) |
naturalOrder() |
Static | Comparator for natural ordering |
reverseOrder() |
Static | Comparator for reverse natural ordering |
nullsFirst(Comparator) |
Static | Wraps a comparator to handle nulls (nulls sort first) |
nullsLast(Comparator) |
Static | Wraps a comparator to handle nulls (nulls sort last) |
Without default methods, Comparator would be a bare single-method interface, and all this chaining functionality would have to live in a separate utility class.
Default methods make it possible to evolve interfaces over time without breaking existing implementations. This is one of the most important practical use cases in library development.
Consider you have published a library with an interface, and users have implemented it. Later, you want to add new capabilities.
// Version 1.0 of your library
interface DataExporter {
void export(java.util.List data, String destination);
}
// A client's implementation (you cannot change this)
class CsvExporter implements DataExporter {
@Override
public void export(java.util.List data, String destination) {
System.out.println("Exporting " + data.size() + " rows to CSV: " + destination);
}
}
// Version 2.0 -- add new features without breaking CsvExporter
interface DataExporterV2 extends DataExporter {
// New default methods -- CsvExporter inherits these without any changes
default void exportWithHeader(java.util.List data, String destination, String header) {
System.out.println("Header: " + header);
export(data, destination);
}
default boolean validate(java.util.List data) {
return data != null && !data.isEmpty();
}
default void exportIfValid(java.util.List data, String destination) {
if (validate(data)) {
export(data, destination);
} else {
System.out.println("Skipping export: invalid data");
}
}
}
public class InterfaceEvolution {
public static void main(String[] args) {
// CsvExporter works with the extended interface (if it had implemented V2)
// Demonstrating the concept:
DataExporterV2 exporter = new DataExporterV2() {
@Override
public void export(java.util.List data, String destination) {
System.out.println("Exporting " + data.size() + " rows to: " + destination);
}
};
java.util.List data = java.util.List.of("row1", "row2", "row3");
exporter.export(data, "output.csv");
// Output: Exporting 3 rows to: output.csv
exporter.exportWithHeader(data, "output.csv", "Sales Report");
// Output:
// Header: Sales Report
// Exporting 3 rows to: output.csv
exporter.exportIfValid(java.util.List.of(), "output.csv");
// Output: Skipping export: invalid data
exporter.exportIfValid(data, "output.csv");
// Output: Exporting 3 rows to: output.csv
}
}
When you write a default method, other developers need to know what the default behavior is so they can decide whether to override it. Java introduced the @implSpec Javadoc tag for this purpose.
interface Cacheable {
String getKey();
Object getValue();
/**
* Returns the time-to-live in seconds for this cached entry.
*
* @implSpec The default implementation returns 3600 (1 hour).
* Implementations that require shorter or longer cache durations
* should override this method.
*
* @return time-to-live in seconds
*/
default int getTtlSeconds() {
return 3600;
}
/**
* Determines whether this entry should be cached.
*
* @implSpec The default implementation returns true if both key and value
* are non-null. Implementations may add additional criteria.
*
* @return true if this entry is eligible for caching
*/
default boolean isCacheable() {
return getKey() != null && getValue() != null;
}
}
class UserSession implements Cacheable {
private final String userId;
private final Object sessionData;
UserSession(String userId, Object sessionData) {
this.userId = userId;
this.sessionData = sessionData;
}
@Override
public String getKey() { return "session:" + userId; }
@Override
public Object getValue() { return sessionData; }
// Override: sessions expire faster
@Override
public int getTtlSeconds() { return 900; } // 15 minutes
}
class AppConfig implements Cacheable {
private final String name;
private final String value;
AppConfig(String name, String value) {
this.name = name;
this.value = value;
}
@Override
public String getKey() { return "config:" + name; }
@Override
public Object getValue() { return value; }
// Inherits default TTL of 3600 seconds -- fine for configuration
}
public class ImplSpecDemo {
public static void main(String[] args) {
UserSession session = new UserSession("user-42", "session-data");
System.out.println(session.getKey() + " TTL: " + session.getTtlSeconds() + "s");
// Output: session:user-42 TTL: 900s
AppConfig config = new AppConfig("theme", "dark");
System.out.println(config.getKey() + " TTL: " + config.getTtlSeconds() + "s");
// Output: config:theme TTL: 3600s
System.out.println("Session cacheable: " + session.isCacheable());
// Output: Session cacheable: true
}
}
Before Java 8, if you wanted an interface with some implemented methods, you had to use an abstract class. Now that interfaces can have default methods, when should you use each? The answer depends on what you need.
| Feature | Interface (with defaults) | Abstract Class |
|---|---|---|
| Multiple inheritance | A class can implement many interfaces | A class can extend only one abstract class |
| Instance state (fields) | Only constants (static final) |
Can have mutable instance fields |
| Constructors | No constructors | Can have constructors |
| Access modifiers on methods | All public (private in Java 9+) | public, protected, package-private, private |
| Non-static fields | Not allowed | Allowed |
| Method bodies | Default, static, private (Java 9+) | Any method can have a body |
| Lambda compatibility | Yes (if functional interface) | No |
| Object methods | Cannot override equals/hashCode/toString | Can override any Object method |
| Versioning | Adding a default is backward compatible | Adding a concrete method is also safe |
| Design intent | "Can do" -- capability contract | "Is a" -- shared base implementation |
Comparable, Serializable, Closeable).AbstractList (abstract class) implementing List (interface).// Interface: defines the capability contract
interface Auditable {
String getCreatedBy();
java.time.LocalDateTime getCreatedAt();
String getLastModifiedBy();
java.time.LocalDateTime getLastModifiedAt();
default String getAuditSummary() {
return String.format("Created by %s on %s, last modified by %s on %s",
getCreatedBy(), getCreatedAt(),
getLastModifiedBy(), getLastModifiedAt());
}
}
// Abstract class: provides shared state and constructors
abstract class AuditableEntity implements Auditable {
private final String createdBy;
private final java.time.LocalDateTime createdAt;
private String lastModifiedBy;
private java.time.LocalDateTime lastModifiedAt;
protected AuditableEntity(String createdBy) {
this.createdBy = createdBy;
this.createdAt = java.time.LocalDateTime.now();
this.lastModifiedBy = createdBy;
this.lastModifiedAt = this.createdAt;
}
@Override
public String getCreatedBy() { return createdBy; }
@Override
public java.time.LocalDateTime getCreatedAt() { return createdAt; }
@Override
public String getLastModifiedBy() { return lastModifiedBy; }
@Override
public java.time.LocalDateTime getLastModifiedAt() { return lastModifiedAt; }
protected void markModified(String modifiedBy) {
this.lastModifiedBy = modifiedBy;
this.lastModifiedAt = java.time.LocalDateTime.now();
}
}
// Concrete class: inherits state management from abstract class
class Order extends AuditableEntity {
private String status;
Order(String createdBy) {
super(createdBy);
this.status = "PENDING";
}
public void updateStatus(String newStatus, String modifiedBy) {
this.status = newStatus;
markModified(modifiedBy); // From abstract class
}
public String getStatus() { return status; }
}
public class InterfaceVsAbstractClass {
public static void main(String[] args) {
Order order = new Order("Alice");
System.out.println("Status: " + order.getStatus());
// Output: Status: PENDING
// Default method from interface
System.out.println(order.getAuditSummary());
// Output: Created by Alice on 2026-02-28T..., last modified by Alice on 2026-02-28T...
order.updateStatus("SHIPPED", "Bob");
System.out.println("Updated: " + order.getAuditSummary());
// Output: Created by Alice on ..., last modified by Bob on ...
}
}
Default methods are powerful, but misuse can lead to confusing, fragile designs. Here are the most common mistakes.
Default methods should provide optional convenience behavior, not heavy implementations. If your interface has more default methods than abstract methods, you might actually need an abstract class.
// BAD: This is basically an abstract class disguised as an interface
interface BadUserService {
default void createUser(String name) {
System.out.println("Creating user: " + name);
validateUser(name);
saveToDatabase(name);
sendWelcomeEmail(name);
}
default void validateUser(String name) {
if (name == null || name.isEmpty()) {
throw new IllegalArgumentException("Name required");
}
}
default void saveToDatabase(String name) {
System.out.println("Saving " + name + " to database");
}
default void sendWelcomeEmail(String name) {
System.out.println("Sending email to " + name);
}
// Problem: No abstract methods! What does implementing this interface mean?
// There is nothing to customize. This should be a class.
}
// GOOD: Interface defines the contract, defaults provide optional convenience
interface GoodUserService {
// Abstract: implementations must provide these
void save(String name);
void sendNotification(String name, String message);
// Default: optional convenience built on the abstract methods
default void createAndNotify(String name) {
save(name);
sendNotification(name, "Welcome, " + name + "!");
}
}
public class OverusingDefaults {
public static void main(String[] args) {
GoodUserService service = new GoodUserService() {
@Override
public void save(String name) {
System.out.println("Saved: " + name);
}
@Override
public void sendNotification(String name, String message) {
System.out.println("Notification to " + name + ": " + message);
}
};
service.createAndNotify("Alice");
// Output:
// Saved: Alice
// Notification to Alice: Welcome, Alice!
}
}
Interfaces cannot have instance fields. Attempting to simulate state with workarounds like Map-based storage creates fragile, thread-unsafe, and memory-leaking code.
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
// BAD: Simulating state in an interface -- DON'T DO THIS
interface BadStateful {
// Using a static map to fake instance state -- terrible idea!
Map
When a class implements two interfaces with the same default method, forgetting to override causes a compiler error. Always check for conflicts when implementing multiple interfaces.
Adding too many default methods to a single interface violates ISP. Just because you can keep adding defaults does not mean you should. Split large interfaces into focused ones.
// BAD: One massive interface with too many concerns
interface BadWorker {
void doWork();
default void logWork() { System.out.println("Logging..."); }
default void notifyManager() { System.out.println("Notifying..."); }
default void updateMetrics() { System.out.println("Updating metrics..."); }
default void sendReport() { System.out.println("Sending report..."); }
default void archiveResults() { System.out.println("Archiving..."); }
// A class that just needs doWork() is forced to inherit all this baggage
}
// GOOD: Segregated interfaces
interface Worker {
void doWork();
}
interface Loggable {
default void logWork() { System.out.println("Logging..."); }
}
interface Observable {
default void notifyManager() { System.out.println("Notifying..."); }
default void updateMetrics() { System.out.println("Updating metrics..."); }
}
// Classes compose only what they need
class SimpleWorker implements Worker {
@Override
public void doWork() { System.out.println("Working..."); }
// No unnecessary defaults inherited
}
class MonitoredWorker implements Worker, Loggable, Observable {
@Override
public void doWork() {
logWork();
System.out.println("Working...");
updateMetrics();
notifyManager();
}
}
public class InterfaceSegregation {
public static void main(String[] args) {
new SimpleWorker().doWork();
// Output: Working...
System.out.println();
new MonitoredWorker().doWork();
// Output:
// Logging...
// Working...
// Updating metrics...
// Notifying...
}
}
You cannot define default methods for equals(), hashCode(), or toString() in an interface. The compiler will reject it. These methods always come from Object or the implementing class.
interface Displayable {
String getDisplayName();
// COMPILER ERROR: default method toString in interface Displayable
// overrides a member of java.lang.Object
// default String toString() {
// return getDisplayName();
// }
// CORRECT: Use a differently named method
default String toDisplayString() {
return "[" + getDisplayName() + "]";
}
}
class Product implements Displayable {
private final String name;
Product(String name) { this.name = name; }
@Override
public String getDisplayName() { return name; }
// Override toString() in the CLASS, not the interface
@Override
public String toString() { return toDisplayString(); }
}
public class ObjectMethodsDemo {
public static void main(String[] args) {
Product p = new Product("Laptop");
System.out.println(p.toDisplayString());
// Output: [Laptop]
System.out.println(p);
// Output: [Laptop]
}
}
| Practice | Do | Don't |
|---|---|---|
| Keep defaults simple | Build defaults on top of abstract methods | Put complex business logic in defaults |
| Document behavior | Use @implSpec to describe default behavior |
Leave default behavior undocumented |
| Prefer composition | Use small, focused interfaces that classes compose | Create one giant interface with many defaults |
| Use for evolution | Add defaults when extending published interfaces | Use defaults as an excuse for lazy interface design |
| Static for utilities | Put factory and helper methods as static | Create separate companion utility classes |
| Respect ISP | Split interfaces by responsibility | Force implementors to inherit irrelevant defaults |
| Override intentionally | Override defaults when you have better behavior | Override defaults just to log or add trivial wrappers |
| Handle diamond conflicts | Explicitly override and delegate with super |
Assume the compiler will "figure it out" |
| Use private methods | Extract shared logic into private methods (Java 9+) | Duplicate code across multiple defaults |
| No state | Rely on abstract method contracts for data | Simulate instance state with static maps |
This example demonstrates a realistic plugin system that uses default methods, static methods, private methods (Java 9+), the diamond problem, and interface evolution -- all in one cohesive design.
The scenario: A text processing framework where plugins can transform, validate, and format text. New plugins can be added without modifying the framework.
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
// ============================================================
// Core Plugin Interface
// ============================================================
interface TextPlugin {
// Abstract: every plugin must define its transformation
String process(String input);
// Abstract: every plugin must identify itself
String getName();
// Default: chain multiple processing steps
default String processAll(List inputs) {
return inputs.stream()
.map(this::process)
.collect(Collectors.joining("\n"));
}
// Default: safe processing with error handling
default String safeProcess(String input) {
if (input == null || input.isEmpty()) {
return "[empty input]";
}
try {
return process(input);
} catch (Exception e) {
return "[error: " + e.getMessage() + "]";
}
}
// Default: logging wrapper
default String processWithLogging(String input) {
System.out.println("[" + getName() + "] Processing: " + input);
String result = process(input);
System.out.println("[" + getName() + "] Result: " + result);
return result;
}
// Static: factory methods
static TextPlugin uppercase() {
return new TextPlugin() {
@Override
public String process(String input) { return input.toUpperCase(); }
@Override
public String getName() { return "UppercasePlugin"; }
};
}
static TextPlugin replace(String target, String replacement) {
return new TextPlugin() {
@Override
public String process(String input) {
return input.replace(target, replacement);
}
@Override
public String getName() {
return "ReplacePlugin(" + target + "->" + replacement + ")";
}
};
}
static TextPlugin chain(TextPlugin... plugins) {
return new TextPlugin() {
@Override
public String process(String input) {
String result = input;
for (TextPlugin plugin : plugins) {
result = plugin.process(result);
}
return result;
}
@Override
public String getName() { return "ChainedPlugin"; }
};
}
}
// ============================================================
// Validation Capability (separate interface -- ISP)
// ============================================================
interface Validatable {
default boolean isValidInput(String input) {
return input != null && !input.isEmpty() && input.length() <= 10000;
}
default String validateAndProcess(String input, java.util.function.Function processor) {
if (!isValidInput(input)) {
throw new IllegalArgumentException("Invalid input");
}
return processor.apply(input);
}
}
// ============================================================
// Metrics Capability (separate interface -- ISP)
// ============================================================
interface Measurable {
default long measureProcessingTime(Runnable task) {
long start = System.nanoTime();
task.run();
long elapsed = System.nanoTime() - start;
return elapsed / 1_000_000; // milliseconds
}
}
// ============================================================
// Concrete Plugins
// ============================================================
class TrimPlugin implements TextPlugin {
@Override
public String process(String input) {
return input.trim();
}
@Override
public String getName() { return "TrimPlugin"; }
}
class CensorPlugin implements TextPlugin, Validatable {
private final List bannedWords;
CensorPlugin(List bannedWords) {
this.bannedWords = bannedWords;
}
@Override
public String process(String input) {
String result = input;
for (String word : bannedWords) {
result = result.replaceAll("(?i)" + word, "***");
}
return result;
}
@Override
public String getName() { return "CensorPlugin"; }
// Override Validatable default with stricter validation
@Override
public boolean isValidInput(String input) {
return Validatable.super.isValidInput(input) && !input.isBlank();
}
}
class MarkdownPlugin implements TextPlugin, Validatable, Measurable {
@Override
public String process(String input) {
return input
.replaceAll("\\*\\*(.+?)\\*\\*", "$1")
.replaceAll("\\*(.+?)\\*", "$1")
.replaceAll("`(.+?)`", "$1");
}
@Override
public String getName() { return "MarkdownPlugin"; }
}
// ============================================================
// Plugin Pipeline
// ============================================================
class PluginPipeline {
private final List plugins = new ArrayList<>();
void addPlugin(TextPlugin plugin) {
plugins.add(plugin);
}
String execute(String input) {
String result = input;
for (TextPlugin plugin : plugins) {
result = plugin.safeProcess(result);
}
return result;
}
void executeWithLogging(String input) {
String result = input;
for (TextPlugin plugin : plugins) {
result = plugin.processWithLogging(result);
}
System.out.println("Final result: " + result);
}
}
// ============================================================
// Main Application
// ============================================================
public class PluginSystemDemo {
public static void main(String[] args) {
System.out.println("=== Individual Plugins ===");
// Using static factory methods
TextPlugin upper = TextPlugin.uppercase();
System.out.println(upper.process("hello world"));
// Output: HELLO WORLD
TextPlugin replacer = TextPlugin.replace("Java", "Java 8");
System.out.println(replacer.process("Learning Java is fun!"));
// Output: Learning Java 8 is fun!
// Chained plugin via static factory
TextPlugin pipeline = TextPlugin.chain(
new TrimPlugin(),
TextPlugin.uppercase(),
TextPlugin.replace(" ", "_")
);
System.out.println(pipeline.process(" hello world "));
// Output: HELLO_WORLD
System.out.println("\n=== Censor Plugin with Validation ===");
CensorPlugin censor = new CensorPlugin(List.of("spam", "scam"));
System.out.println(censor.process("This is spam and scam content"));
// Output: This is *** and *** content
System.out.println("Valid input: " + censor.isValidInput("test"));
// Output: Valid input: true
System.out.println("Blank valid: " + censor.isValidInput(" "));
// Output: Blank valid: false (stricter override)
System.out.println("\n=== Markdown Plugin with Timing ===");
MarkdownPlugin markdown = new MarkdownPlugin();
String html = markdown.process("This is **bold** and *italic* and `code`");
System.out.println(html);
// Output: This is bold and italic and code
long ms = markdown.measureProcessingTime(() ->
markdown.process("Processing **this** text")
);
System.out.println("Processing took: " + ms + "ms");
// Output: Processing took: 0ms (very fast)
System.out.println("\n=== Plugin Pipeline ===");
PluginPipeline fullPipeline = new PluginPipeline();
fullPipeline.addPlugin(new TrimPlugin());
fullPipeline.addPlugin(new CensorPlugin(List.of("bad")));
fullPipeline.addPlugin(TextPlugin.uppercase());
String result = fullPipeline.execute(" this has bad words ");
System.out.println("Pipeline result: " + result);
// Output: Pipeline result: THIS HAS *** WORDS
System.out.println("\n=== Safe Processing (null/error handling) ===");
TextPlugin upper2 = TextPlugin.uppercase();
System.out.println(upper2.safeProcess(null));
// Output: [empty input]
System.out.println(upper2.safeProcess(""));
// Output: [empty input]
System.out.println(upper2.safeProcess("works fine"));
// Output: WORKS FINE
System.out.println("\n=== Batch Processing ===");
List batch = List.of("hello", "world", "java");
System.out.println(upper2.processAll(batch));
// Output:
// HELLO
// WORLD
// JAVA
}
}
Concepts demonstrated in this example:
| # | Concept | Where Used |
|---|---|---|
| 1 | Abstract methods in interfaces | TextPlugin.process(), TextPlugin.getName() |
| 2 | Default methods | processAll(), safeProcess(), processWithLogging() |
| 3 | Static factory methods | TextPlugin.uppercase(), TextPlugin.replace(), TextPlugin.chain() |
| 4 | Default methods calling abstract methods | safeProcess() calls process() |
| 5 | Interface Segregation Principle | Separate Validatable and Measurable interfaces |
| 6 | Overriding default methods | CensorPlugin.isValidInput() overrides Validatable's default |
| 7 | Calling super default with Interface.super |
CensorPlugin.isValidInput() delegates to Validatable.super |
| 8 | Multiple interface implementation | MarkdownPlugin implements TextPlugin, Validatable, Measurable |
| 9 | Anonymous class with interface | Static factory methods return anonymous implementations |
| 10 | Template method pattern via defaults | safeProcess() wraps process() with null/error handling |
| 11 | Strategy pattern | Different TextPlugin implementations plugged into PluginPipeline |
| 12 | Method references with defaults | this::process in processAll() |
| Topic | Key Point |
|---|---|
| Default method syntax | default returnType method() { body } |
| Static method syntax | static returnType method() { body } |
| Private method syntax (9+) | private returnType method() { body } |
| Why default methods exist | Backward-compatible interface evolution (adding methods without breaking implementors) |
| Why static methods exist | Utility and factory methods that belong to the interface, not instances |
| Diamond problem rule 1 | Classes always win over interface defaults |
| Diamond problem rule 2 | More specific (sub) interface wins over parent interface |
| Diamond problem rule 3 | If unresolved, the class must explicitly override |
| Calling a specific default | InterfaceName.super.method() |
| Functional interface compatibility | Default and static methods do NOT count toward the single abstract method limit |
| Cannot override Object methods | Defaults cannot define equals(), hashCode(), or toString() |
| Static methods not inherited | Must call via InterfaceName.staticMethod(), never via an instance |
| Use interface when | Defining a capability contract; need multiple inheritance |
| Use abstract class when | Need instance state, constructors, or protected methods |
| Best pattern | Interface for contract + abstract class for skeletal implementation |
Imagine a contract that says: “You must do exactly one thing.” It does not care what that thing is — it could be filtering data, transforming text, printing output, or computing a value. But the contract only has one obligation. That is a functional interface in Java.
A functional interface is a Java interface that contains exactly one abstract method. This is often called a SAM (Single Abstract Method) interface. Because there is only one method to implement, the compiler can unambiguously match a lambda expression or method reference to that method.
Key concept: Functional interfaces are the bridge between object-oriented Java and functional programming. Every lambda expression in Java targets a functional interface. Without functional interfaces, lambdas would not exist in Java.
Important rules:
toString(), equals(), and hashCode() are inherited from Object and do not count toward the abstract method limit@FunctionalInterface annotation is optional but recommended — it tells the compiler to enforce the single-abstract-method rule// A valid functional interface -- exactly one abstract method
@FunctionalInterface
public interface Greeting {
String greet(String name); // the single abstract method
}
// Still functional -- default methods don't count
@FunctionalInterface
public interface Transformer {
String transform(String input); // the single abstract method
default String transformAndTrim(String input) {
return transform(input).trim();
}
default String transformAndUpperCase(String input) {
return transform(input).toUpperCase();
}
}
// Still functional -- static methods and Object methods don't count
@FunctionalInterface
public interface Validator {
boolean validate(String input); // the single abstract method
static Validator alwaysTrue() {
return input -> true;
}
// toString() comes from Object -- does NOT count
String toString();
}
// NOT functional -- two abstract methods (compiler error with @FunctionalInterface)
// @FunctionalInterface // would cause compile error
interface NotFunctional {
void methodA();
void methodB();
}
The @FunctionalInterface annotation serves two purposes:
You do not need @FunctionalInterface for an interface to work with lambdas. Any interface with exactly one abstract method will work. But adding the annotation is a best practice because it prevents accidental breakage.
@FunctionalInterface
interface MathOperation {
double calculate(double a, double b);
}
public class FunctionalInterfaceBasic {
public static void main(String[] args) {
// Lambda expressions implementing the same functional interface
MathOperation addition = (a, b) -> a + b;
MathOperation subtraction = (a, b) -> a - b;
MathOperation multiplication = (a, b) -> a * b;
MathOperation division = (a, b) -> b != 0 ? a / b : 0;
System.out.println("10 + 5 = " + addition.calculate(10, 5));
System.out.println("10 - 5 = " + subtraction.calculate(10, 5));
System.out.println("10 * 5 = " + multiplication.calculate(10, 5));
System.out.println("10 / 5 = " + division.calculate(10, 5));
// Output:
// 10 + 5 = 15.0
// 10 - 5 = 5.0
// 10 * 5 = 50.0
// 10 / 5 = 2.0
// Passing a lambda to a method that accepts the functional interface
printResult("Addition", addition, 20, 3);
printResult("Division", division, 20, 3);
// Output:
// Addition: 23.0
// Division: 6.666666666666667
}
static void printResult(String label, MathOperation op, double a, double b) {
System.out.println(label + ": " + op.calculate(a, b));
}
}
Functional interfaces are not new to Java 8 — the concept existed long before, just without the name or the annotation. Many classic Java interfaces happen to have exactly one abstract method, making them functional interfaces by definition. Java 8 simply gave them a formal name and made them compatible with lambda expressions.
| Interface | Package | Abstract Method | Used For |
|---|---|---|---|
Runnable |
java.lang |
void run() |
Running code in threads |
Callable<V> |
java.util.concurrent |
V call() |
Tasks that return a value |
Comparator<T> |
java.util |
int compare(T o1, T o2) |
Sorting and ordering |
ActionListener |
java.awt.event |
void actionPerformed(ActionEvent) |
GUI event handling |
FileFilter |
java.io |
boolean accept(File) |
Filtering files |
import java.util.Arrays;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
public class PreJava8FunctionalInterfaces {
public static void main(String[] args) {
// --- Runnable: Before and After ---
// Before Java 8: Anonymous inner class
Runnable oldRunnable = new Runnable() {
@Override
public void run() {
System.out.println("Running with anonymous class");
}
};
// Java 8+: Lambda expression
Runnable newRunnable = () -> System.out.println("Running with lambda");
new Thread(oldRunnable).start();
new Thread(newRunnable).start();
new Thread(() -> System.out.println("Running inline")).start();
// --- Comparator: Before and After ---
List names = Arrays.asList("Charlie", "Alice", "Bob");
// Before Java 8
Collections.sort(names, new Comparator() {
@Override
public int compare(String a, String b) {
return a.compareTo(b);
}
});
System.out.println("Old sort: " + names);
// Java 8+
List names2 = Arrays.asList("Charlie", "Alice", "Bob");
names2.sort((a, b) -> a.compareTo(b));
System.out.println("Lambda sort: " + names2);
// Even shorter with method reference
List names3 = Arrays.asList("Charlie", "Alice", "Bob");
names3.sort(String::compareTo);
System.out.println("Method ref sort: " + names3);
}
}
Java 8 introduced the java.util.function package, which contains 43 built-in functional interfaces. These cover the most common functional programming patterns so you rarely need to create your own. The four core interfaces are Predicate, Function, Consumer, and Supplier. Everything else is a variant of these four.
A Predicate takes one argument and returns a boolean. It represents a condition or test. Its abstract method is boolean test(T t).
Default methods: and(), or(), negate()
Static method: isEqual()
import java.util.Arrays;
import java.util.List;
import java.util.function.Predicate;
import java.util.stream.Collectors;
public class PredicateExample {
public static void main(String[] args) {
// --- Basic Predicate ---
Predicate isPositive = n -> n > 0;
Predicate isEven = n -> n % 2 == 0;
System.out.println("5 is positive: " + isPositive.test(5)); // true
System.out.println("-3 is positive: " + isPositive.test(-3)); // false
System.out.println("4 is even: " + isEven.test(4)); // true
// --- Predicate.and() -- both conditions must be true ---
Predicate isPositiveAndEven = isPositive.and(isEven);
System.out.println("4 is positive AND even: " + isPositiveAndEven.test(4)); // true
System.out.println("-2 is positive AND even: " + isPositiveAndEven.test(-2)); // false
// --- Predicate.or() -- at least one condition must be true ---
Predicate isPositiveOrEven = isPositive.or(isEven);
System.out.println("-2 is positive OR even: " + isPositiveOrEven.test(-2)); // true
System.out.println("-3 is positive OR even: " + isPositiveOrEven.test(-3)); // false
// --- Predicate.negate() -- reverse the condition ---
Predicate isNotPositive = isPositive.negate();
System.out.println("-5 is NOT positive: " + isNotPositive.test(-5)); // true
// --- Predicate.isEqual() -- static factory for equality check ---
Predicate isHello = Predicate.isEqual("Hello");
System.out.println("isHello(Hello): " + isHello.test("Hello")); // true
System.out.println("isHello(World): " + isHello.test("World")); // false
// --- Using Predicate with Streams ---
List numbers = Arrays.asList(-5, -2, 0, 3, 4, 7, 8, -1, 6);
List positiveEvens = numbers.stream()
.filter(isPositive.and(isEven))
.collect(Collectors.toList());
System.out.println("Positive evens: " + positiveEvens);
// Output: Positive evens: [4, 8, 6]
}
}
A Function takes one argument of type T and returns a result of type R. Its abstract method is R apply(T t). It represents a transformation or mapping.
Default methods: andThen(), compose()
Static method: identity()
import java.util.Arrays;
import java.util.List;
import java.util.function.Function;
import java.util.stream.Collectors;
public class FunctionExample {
public static void main(String[] args) {
// --- Basic Function ---
Function stringLength = String::length;
Function toUpper = String::toUpperCase;
Function intToString = n -> "Number: " + n;
System.out.println("Length of 'Hello': " + stringLength.apply("Hello")); // 5
System.out.println("Upper of 'hello': " + toUpper.apply("hello")); // HELLO
System.out.println(intToString.apply(42)); // Number: 42
// --- andThen() -- apply this function, THEN apply the next ---
// toUpper first, then stringLength
Function upperThenLength = toUpper.andThen(stringLength);
System.out.println("'hello' -> upper -> length: " + upperThenLength.apply("hello"));
// Output: 5
// --- compose() -- apply the OTHER function first, THEN this one ---
// stringLength is applied AFTER toUpper
Function composedLength = stringLength.compose(toUpper);
System.out.println("Composed: " + composedLength.apply("hello")); // 5
// Another compose example: trim first, then upper
Function trim = String::trim;
Function trimThenUpper = toUpper.compose(trim);
System.out.println("' hello ' -> trim -> upper: " + trimThenUpper.apply(" hello "));
// Output: HELLO
// --- Function.identity() -- returns the input unchanged ---
Function noChange = Function.identity();
System.out.println("Identity: " + noChange.apply("same")); // same
// --- Using Function with Streams ---
List names = Arrays.asList("alice", "bob", "charlie");
List formatted = names.stream()
.map(toUpper.andThen(s -> ">> " + s))
.collect(Collectors.toList());
System.out.println("Formatted: " + formatted);
// Output: Formatted: [>> ALICE, >> BOB, >> CHARLIE]
}
}
| Method | Execution Order | Read As |
|---|---|---|
f.andThen(g) |
Apply f first, then g |
“Do f, and then do g” |
f.compose(g) |
Apply g first, then f |
“f is composed of g applied first” |
Think of andThen as a pipeline (left to right) and compose as mathematical composition (right to left, like f(g(x))).
A Consumer takes one argument and returns nothing (void). Its abstract method is void accept(T t). It represents a side-effect operation: printing, logging, writing to a database, etc.
Default method: andThen()
import java.util.Arrays;
import java.util.List;
import java.util.function.Consumer;
public class ConsumerExample {
public static void main(String[] args) {
// --- Basic Consumer ---
Consumer printer = System.out::println;
Consumer yeller = s -> System.out.println(s.toUpperCase() + "!!!");
printer.accept("Hello"); // Output: Hello
yeller.accept("watch out"); // Output: WATCH OUT!!!
// --- andThen() -- chain consumers ---
Consumer printThenYell = printer.andThen(yeller);
printThenYell.accept("hello");
// Output:
// hello
// HELLO!!!
// --- Practical: log before processing ---
Consumer logger = s -> System.out.println("[LOG] Processing: " + s);
Consumer processor = s -> System.out.println("[RESULT] " + s.toUpperCase());
Consumer logAndProcess = logger.andThen(processor);
List items = Arrays.asList("apple", "banana", "cherry");
items.forEach(logAndProcess);
// Output:
// [LOG] Processing: apple
// [RESULT] APPLE
// [LOG] Processing: banana
// [RESULT] BANANA
// [LOG] Processing: cherry
// [RESULT] CHERRY
// --- Using Consumer with forEach ---
List numbers = Arrays.asList(1, 2, 3, 4, 5);
Consumer printSquare = n -> System.out.println(n + " squared = " + (n * n));
numbers.forEach(printSquare);
// Output:
// 1 squared = 1
// 2 squared = 4
// 3 squared = 9
// 4 squared = 16
// 5 squared = 25
}
}
A Supplier takes no arguments and returns a value. Its abstract method is T get(). It represents a factory or lazy value provider. Suppliers are commonly used for deferred computation — the value is not computed until get() is called.
import java.util.ArrayList;
import java.util.List;
import java.util.Random;
import java.util.function.Supplier;
import java.util.stream.Collectors;
import java.util.stream.Stream;
public class SupplierExample {
public static void main(String[] args) {
// --- Basic Supplier ---
Supplier helloSupplier = () -> "Hello, World!";
Supplier randomSupplier = Math::random;
Supplier> listFactory = ArrayList::new;
System.out.println(helloSupplier.get()); // Hello, World!
System.out.println(randomSupplier.get()); // 0.7234... (random)
// --- Lazy evaluation ---
// The value is not computed until get() is called
Supplier timestamp = System::currentTimeMillis;
System.out.println("Before: " + timestamp.get());
// simulate work
for (int i = 0; i < 1000000; i++) { /* busy work */ }
System.out.println("After: " + timestamp.get());
// The two timestamps will be different
// --- Factory pattern with Supplier ---
Supplier sbFactory = StringBuilder::new;
StringBuilder sb1 = sbFactory.get(); // new instance each time
StringBuilder sb2 = sbFactory.get(); // another new instance
sb1.append("First");
sb2.append("Second");
System.out.println(sb1); // First
System.out.println(sb2); // Second
// --- Generate infinite stream with Supplier ---
Random random = new Random(42);
Supplier diceRoll = () -> random.nextInt(6) + 1;
List rolls = Stream.generate(diceRoll)
.limit(10)
.collect(Collectors.toList());
System.out.println("Dice rolls: " + rolls);
// Output: Dice rolls: [1, 5, 3, 2, 4, 2, 6, 3, 1, 4] (varies with seed)
// --- Supplier with Optional.orElseGet ---
String name = null;
Supplier defaultName = () -> "Unknown User";
String result = java.util.Optional.ofNullable(name)
.orElseGet(defaultName);
System.out.println("Result: " + result);
// Output: Result: Unknown User
}
}
UnaryOperator<T> is a specialization of Function<T, T> where the input and output types are the same. BinaryOperator<T> is a specialization of BiFunction<T, T, T> where all three types are the same.
| Interface | Extends | Abstract Method | Use Case |
|---|---|---|---|
UnaryOperator<T> |
Function<T, T> |
T apply(T t) |
Transform a value to the same type |
BinaryOperator<T> |
BiFunction<T, T, T> |
T apply(T t1, T t2) |
Combine two values of the same type |
import java.util.Arrays;
import java.util.List;
import java.util.function.BinaryOperator;
import java.util.function.UnaryOperator;
import java.util.stream.Collectors;
public class OperatorExample {
public static void main(String[] args) {
// --- UnaryOperator: same input and output type ---
UnaryOperator toUpper = String::toUpperCase;
UnaryOperator addBrackets = s -> "[" + s + "]";
UnaryOperator doubleIt = n -> n * 2;
System.out.println(toUpper.apply("hello")); // HELLO
System.out.println(addBrackets.apply("java")); // [java]
System.out.println(doubleIt.apply(5)); // 10
// --- Chaining UnaryOperators ---
UnaryOperator upperAndBracket = s -> addBrackets.apply(toUpper.apply(s));
System.out.println(upperAndBracket.apply("hello")); // [HELLO]
// --- Using UnaryOperator with List.replaceAll ---
List names = Arrays.asList("alice", "bob", "charlie");
names.replaceAll(String::toUpperCase);
System.out.println("ReplaceAll: " + names);
// Output: ReplaceAll: [ALICE, BOB, CHARLIE]
// --- BinaryOperator: combine two values of the same type ---
BinaryOperator add = Integer::sum;
BinaryOperator max = Integer::max;
BinaryOperator concat = String::concat;
System.out.println("Sum: " + add.apply(10, 20)); // 30
System.out.println("Max: " + max.apply(10, 20)); // 20
System.out.println("Concat: " + concat.apply("Hello, ", "World!")); // Hello, World!
// --- BinaryOperator with Stream.reduce ---
List numbers = Arrays.asList(1, 2, 3, 4, 5);
int sum = numbers.stream()
.reduce(0, Integer::sum);
System.out.println("Stream sum: " + sum);
// Output: Stream sum: 15
int product = numbers.stream()
.reduce(1, (a, b) -> a * b);
System.out.println("Stream product: " + product);
// Output: Stream product: 120
// --- BinaryOperator.maxBy and minBy ---
BinaryOperator longerString = BinaryOperator.maxBy(
java.util.Comparator.comparingInt(String::length));
System.out.println("Longer: " + longerString.apply("hello", "hi"));
// Output: Longer: hello
}
}
The “Bi” variants accept two parameters instead of one. They mirror the single-parameter interfaces but with an extra input.
| Interface | Abstract Method | Single-Param Equivalent |
|---|---|---|
BiFunction<T, U, R> |
R apply(T t, U u) |
Function<T, R> |
BiPredicate<T, U> |
boolean test(T t, U u) |
Predicate<T> |
BiConsumer<T, U> |
void accept(T t, U u) |
Consumer<T> |
import java.util.HashMap;
import java.util.Map;
import java.util.function.BiConsumer;
import java.util.function.BiFunction;
import java.util.function.BiPredicate;
public class BiVariantExample {
public static void main(String[] args) {
// --- BiFunction: two inputs, one output ---
BiFunction repeat = (s, n) -> s.repeat(n);
System.out.println(repeat.apply("Ha", 3));
// Output: HaHaHa
BiFunction power = Math::pow;
System.out.println("2^10 = " + power.apply(2.0, 10.0));
// Output: 2^10 = 1024.0
// --- BiPredicate: two inputs, boolean output ---
BiPredicate longerThan = (s, len) -> s.length() > len;
System.out.println("'hello' longer than 3: " + longerThan.test("hello", 3)); // true
System.out.println("'hi' longer than 3: " + longerThan.test("hi", 3)); // false
// Combining BiPredicates
BiPredicate shorterThan10 = (s, len) -> s.length() < 10;
BiPredicate validLength = longerThan.and(shorterThan10);
System.out.println("'hello' valid (>3 and <10): " + validLength.test("hello", 3)); // true
// --- BiConsumer: two inputs, no output ---
BiConsumer printEntry = (key, value) ->
System.out.println(key + " = " + value);
printEntry.accept("Score", 95);
// Output: Score = 95
// BiConsumer with Map.forEach
Map scores = new HashMap<>();
scores.put("Alice", 95);
scores.put("Bob", 87);
scores.put("Charlie", 92);
System.out.println("--- Scores ---");
scores.forEach((name, score) ->
System.out.println(name + ": " + score + (score >= 90 ? " (A)" : " (B)")));
// Output:
// --- Scores ---
// Alice: 95 (A)
// Bob: 87 (B)
// Charlie: 92 (A)
}
}
Generics in Java cannot use primitive types (int, long, double). When you use Function<Integer, Integer>, every int value gets autoboxed into an Integer object, which hurts performance. To solve this, java.util.function provides primitive specializations that work directly with int, long, and double — avoiding autoboxing entirely.
| Category | Primitive Specializations |
|---|---|
| Predicate | IntPredicate, LongPredicate, DoublePredicate |
| Function | IntFunction<R>, LongFunction<R>, DoubleFunction<R> |
| To-type Function | ToIntFunction<T>, ToLongFunction<T>, ToDoubleFunction<T> |
| Type-to-type Function | IntToDoubleFunction, IntToLongFunction, LongToIntFunction, LongToDoubleFunction, DoubleToIntFunction, DoubleToLongFunction |
| Consumer | IntConsumer, LongConsumer, DoubleConsumer |
| Supplier | IntSupplier, LongSupplier, DoubleSupplier, BooleanSupplier |
| UnaryOperator | IntUnaryOperator, LongUnaryOperator, DoubleUnaryOperator |
| BinaryOperator | IntBinaryOperator, LongBinaryOperator, DoubleBinaryOperator |
| Bi-variant | ObjIntConsumer<T>, ObjLongConsumer<T>, ObjDoubleConsumer<T> |
import java.util.function.*;
import java.util.stream.IntStream;
public class PrimitiveSpecializationExample {
public static void main(String[] args) {
// --- IntPredicate: avoids autoboxing int to Integer ---
IntPredicate isEven = n -> n % 2 == 0;
IntPredicate isPositive = n -> n > 0;
System.out.println("4 is even: " + isEven.test(4)); // true
System.out.println("4 is positive AND even: " +
isPositive.and(isEven).test(4)); // true
// --- IntFunction: int input, generic output ---
IntFunction intToLabel = n -> "Item #" + n;
System.out.println(intToLabel.apply(5));
// Output: Item #5
// --- ToIntFunction: generic input, int output ---
ToIntFunction stringLen = String::length;
System.out.println("Length of 'hello': " + stringLen.applyAsInt("hello"));
// Output: Length of 'hello': 5
// --- IntUnaryOperator: int to int ---
IntUnaryOperator doubleIt = n -> n * 2;
IntUnaryOperator addTen = n -> n + 10;
// Chain: double first, then add 10
IntUnaryOperator doubleThenAdd = doubleIt.andThen(addTen);
System.out.println("5 -> double -> add10: " + doubleThenAdd.applyAsInt(5));
// Output: 5 -> double -> add10: 20
// --- IntBinaryOperator: two ints, one int result ---
IntBinaryOperator max = Integer::max;
System.out.println("Max of 3 and 7: " + max.applyAsInt(3, 7));
// Output: Max of 3 and 7: 7
// --- Performance benefit with IntStream ---
int sum = IntStream.rangeClosed(1, 100)
.filter(isEven) // IntPredicate -- no autoboxing
.sum();
System.out.println("Sum of even numbers 1-100: " + sum);
// Output: Sum of even numbers 1-100: 2550
}
}
One of the most powerful features of functional interfaces is composition — combining simple functions into complex behavior. This is the essence of functional programming: building big things from small, reusable pieces.
Predicates can be combined using and(), or(), and negate() to build complex conditions from simple ones.
import java.util.Arrays;
import java.util.List;
import java.util.function.Predicate;
import java.util.stream.Collectors;
public class PredicateComposition {
public static void main(String[] args) {
// Individual predicates
Predicate isNotNull = s -> s != null;
Predicate isNotEmpty = s -> !s.isEmpty();
Predicate isNotBlank = s -> !s.isBlank();
Predicate startsWithJ = s -> s.startsWith("J");
Predicate longerThan3 = s -> s.length() > 3;
// Compose a complex validation predicate
Predicate isValidJavaName = isNotNull
.and(isNotEmpty)
.and(isNotBlank)
.and(startsWithJ)
.and(longerThan3);
List candidates = Arrays.asList(
"Java", "JavaScript", null, "", " ", "J", "Jolt",
"Python", "Jakarta", "JUnit"
);
List valid = candidates.stream()
.filter(isValidJavaName)
.collect(Collectors.toList());
System.out.println("Valid Java names: " + valid);
// Output: Valid Java names: [Java, JavaScript, Jolt, Jakarta, JUnit]
// Reuse individual predicates in different combinations
Predicate isValidNonJava = isNotNull
.and(isNotEmpty)
.and(startsWithJ.negate()) // does NOT start with J
.and(longerThan3);
List nonJava = candidates.stream()
.filter(isValidNonJava)
.collect(Collectors.toList());
System.out.println("Valid non-Java names: " + nonJava);
// Output: Valid non-Java names: [Python]
}
}
Functions can be chained using andThen() (left to right) or compose() (right to left) to create transformation pipelines.
import java.util.Arrays;
import java.util.List;
import java.util.function.Function;
import java.util.stream.Collectors;
public class FunctionComposition {
public static void main(String[] args) {
// Individual transformation steps
Function trim = String::trim;
Function toUpper = String::toUpperCase;
Function addPrefix = s -> ">> " + s;
Function addSuffix = s -> s + " <<";
// Build a pipeline: trim -> uppercase -> add prefix -> add suffix
Function pipeline = trim
.andThen(toUpper)
.andThen(addPrefix)
.andThen(addSuffix);
System.out.println(pipeline.apply(" hello world "));
// Output: >> HELLO WORLD <<
// Apply pipeline to a list
List rawInputs = Arrays.asList(" alice ", " bob ", " charlie ");
List processed = rawInputs.stream()
.map(pipeline)
.collect(Collectors.toList());
System.out.println(processed);
// Output: [>> ALICE <<, >> BOB <<, >> CHARLIE <<]
// Compose: build a transformation and then convert to length
Function processedLength = pipeline.andThen(String::length);
rawInputs.stream()
.map(processedLength)
.forEach(len -> System.out.println("Length: " + len));
// Output:
// Length: 14
// Length: 12
// Length: 16
}
}
Consumers can be chained using andThen() to perform multiple side-effect actions in sequence. This is useful for logging, auditing, and multi-step processing.
import java.util.Arrays;
import java.util.List;
import java.util.ArrayList;
import java.util.function.Consumer;
public class ConsumerComposition {
public static void main(String[] args) {
// Individual consumers
Consumer log = s -> System.out.println("[LOG] " + s);
Consumer validate = s -> {
if (s.length() < 2) {
System.out.println("[WARN] '" + s + "' is too short");
}
};
Consumer process = s -> System.out.println("[PROCESS] " + s.toUpperCase());
// Chain: log -> validate -> process
Consumer fullPipeline = log.andThen(validate).andThen(process);
List inputs = Arrays.asList("hello", "a", "java", "b", "streams");
System.out.println("=== Processing Pipeline ===");
inputs.forEach(fullPipeline);
// Output:
// === Processing Pipeline ===
// [LOG] hello
// [PROCESS] HELLO
// [LOG] a
// [WARN] 'a' is too short
// [PROCESS] A
// [LOG] java
// [PROCESS] JAVA
// [LOG] b
// [WARN] 'b' is too short
// [PROCESS] B
// [LOG] streams
// [PROCESS] STREAMS
// Practical: collecting and printing in one pass
List collected = new ArrayList<>();
Consumer collectAndPrint = ((Consumer) collected::add)
.andThen(s -> System.out.println("Added: " + s));
Arrays.asList("X", "Y", "Z").forEach(collectAndPrint);
System.out.println("Collected: " + collected);
// Output:
// Added: X
// Added: Y
// Added: Z
// Collected: [X, Y, Z]
}
}
Java’s built-in functional interfaces cover most common patterns: one input (Function), two inputs (BiFunction), no output (Consumer), no input (Supplier), and boolean output (Predicate). But sometimes you need something that does not fit, such as a function with three parameters or a function that throws a checked exception.
import java.util.function.Function; // Custom functional interface for three parameters @FunctionalInterface interface TriFunction { R apply(A a, B b, C c); // Default method for chaining defaultTriFunction andThen(Function super R, ? extends V> after) { return (a, b, c) -> after.apply(apply(a, b, c)); } } public class CustomTriFunction { public static void main(String[] args) { // Three-parameter function: calculate total price TriFunction orderSummary = (product, quantity, price) -> String.format("%s x%d = $%.2f", product, quantity, quantity * price); System.out.println(orderSummary.apply("Widget", 5, 9.99)); // Output: Widget x5 = $49.95 System.out.println(orderSummary.apply("Gadget", 3, 24.50)); // Output: Gadget x3 = $73.50 // Using andThen to chain TriFunction summaryLength = orderSummary.andThen(String::length); System.out.println("Summary length: " + summaryLength.apply("Widget", 5, 9.99)); // Output: Summary length: 19 // Three-parameter function: full name formatter TriFunction fullName = (first, middle, last) -> first + " " + middle + " " + last; System.out.println(fullName.apply("John", "Michael", "Smith")); // Output: John Michael Smith } }
Java’s built-in functional interfaces do not declare checked exceptions. This means you cannot use a lambda that throws IOException or SQLException with Function<T, R>. The solution is a custom functional interface with a throws clause.
import java.util.Arrays; import java.util.List; import java.util.stream.Collectors; // Functional interface that allows checked exceptions @FunctionalInterface interface CheckedFunction{ R apply(T t) throws Exception; } public class CheckedExceptionInterface { // Utility method to wrap CheckedFunction into a standard Function static java.util.function.Function unchecked(CheckedFunction f) { return t -> { try { return f.apply(t); } catch (Exception e) { throw new RuntimeException(e); } }; } // A method that throws a checked exception static Integer parseStrictly(String s) throws Exception { if (s == null || s.isBlank()) { throw new Exception("Input cannot be null or blank"); } return Integer.parseInt(s.trim()); } public static void main(String[] args) { List inputs = Arrays.asList("1", "2", "3", "4", "5"); // Without the wrapper, this would not compile because // parseStrictly throws a checked Exception. // With unchecked(), we wrap it into a standard Function. List numbers = inputs.stream() .map(unchecked(CheckedExceptionInterface::parseStrictly)) .collect(Collectors.toList()); System.out.println("Parsed: " + numbers); // Output: Parsed: [1, 2, 3, 4, 5] } }
Sometimes a custom interface with a meaningful name is clearer than a generic Function<User, Boolean>. Domain-specific names make the code self-documenting.
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
public class DomainSpecificInterface {
// Domain-specific functional interfaces
@FunctionalInterface
interface Converter {
T convert(F from);
}
@FunctionalInterface
interface Validator {
boolean isValid(T item);
default Validator and(Validator other) {
return item -> isValid(item) && other.isValid(item);
}
default Validator or(Validator other) {
return item -> isValid(item) || other.isValid(item);
}
default Validator negate() {
return item -> !isValid(item);
}
}
static class User {
String name;
int age;
String email;
User(String name, int age, String email) {
this.name = name;
this.age = age;
this.email = email;
}
public String toString() {
return name + " (age " + age + ", " + email + ")";
}
}
public static void main(String[] args) {
// Converter usage -- clear intent
Converter stringToInt = Integer::parseInt;
Converter csvToUser = csv -> {
String[] parts = csv.split(",");
return new User(parts[0].trim(), Integer.parseInt(parts[1].trim()), parts[2].trim());
};
System.out.println(stringToInt.convert("42"));
// Output: 42
User user = csvToUser.convert("Alice, 30, alice@example.com");
System.out.println(user);
// Output: Alice (age 30, alice@example.com)
// Validator usage -- composable and domain-meaningful
Validator hasName = u -> u.name != null && !u.name.isBlank();
Validator isAdult = u -> u.age >= 18;
Validator hasEmail = u -> u.email != null && u.email.contains("@");
Validator isValidUser = hasName.and(isAdult).and(hasEmail);
List users = Arrays.asList(
new User("Alice", 30, "alice@example.com"),
new User("", 25, "bob@example.com"),
new User("Charlie", 15, "charlie@example.com"),
new User("Diana", 28, null)
);
List validUsers = users.stream()
.filter(isValidUser::isValid)
.collect(Collectors.toList());
System.out.println("Valid users: " + validUsers);
// Output: Valid users: [Alice (age 30, alice@example.com)]
}
}
This table lists every functional interface in java.util.function, organized by category. Use it as a quick reference when deciding which interface to use.
| Interface | Abstract Method | Input | Output | Use Case |
|---|---|---|---|---|
Predicate<T> |
boolean test(T) |
T | boolean | Filtering, conditions |
Function<T, R> |
R apply(T) |
T | R | Transformations, mapping |
Consumer<T> |
void accept(T) |
T | void | Side effects (print, save) |
Supplier<T> |
T get() |
none | T | Factories, lazy values |
UnaryOperator<T> |
T apply(T) |
T | T | Same-type transformation |
BinaryOperator<T> |
T apply(T, T) |
T, T | T | Reducing, combining |
| Interface | Abstract Method | Input | Output | Use Case |
|---|---|---|---|---|
BiPredicate<T, U> |
boolean test(T, U) |
T, U | boolean | Two-input conditions |
BiFunction<T, U, R> |
R apply(T, U) |
T, U | R | Two-input transformations |
BiConsumer<T, U> |
void accept(T, U) |
T, U | void | Map.forEach, two-arg actions |
| Interface | Abstract Method | Avoids Boxing |
|---|---|---|
IntPredicate |
boolean test(int) |
Predicate<Integer> |
LongPredicate |
boolean test(long) |
Predicate<Long> |
DoublePredicate |
boolean test(double) |
Predicate<Double> |
| Interface | Abstract Method | Description |
|---|---|---|
IntFunction<R> |
R apply(int) |
int input, generic output |
LongFunction<R> |
R apply(long) |
long input, generic output |
DoubleFunction<R> |
R apply(double) |
double input, generic output |
ToIntFunction<T> |
int applyAsInt(T) |
generic input, int output |
ToLongFunction<T> |
long applyAsLong(T) |
generic input, long output |
ToDoubleFunction<T> |
double applyAsDouble(T) |
generic input, double output |
IntToLongFunction |
long applyAsLong(int) |
int to long |
IntToDoubleFunction |
double applyAsDouble(int) |
int to double |
LongToIntFunction |
int applyAsInt(long) |
long to int |
LongToDoubleFunction |
double applyAsDouble(long) |
long to double |
DoubleToIntFunction |
int applyAsInt(double) |
double to int |
DoubleToLongFunction |
long applyAsLong(double) |
double to long |
| Interface | Abstract Method | Description |
|---|---|---|
IntConsumer |
void accept(int) |
Consumes int without boxing |
LongConsumer |
void accept(long) |
Consumes long without boxing |
DoubleConsumer |
void accept(double) |
Consumes double without boxing |
ObjIntConsumer<T> |
void accept(T, int) |
Object + int consumer |
ObjLongConsumer<T> |
void accept(T, long) |
Object + long consumer |
ObjDoubleConsumer<T> |
void accept(T, double) |
Object + double consumer |
IntSupplier |
int getAsInt() |
Supplies int without boxing |
LongSupplier |
long getAsLong() |
Supplies long without boxing |
DoubleSupplier |
double getAsDouble() |
Supplies double without boxing |
BooleanSupplier |
boolean getAsBoolean() |
Supplies boolean without boxing |
| Interface | Abstract Method | Description |
|---|---|---|
IntUnaryOperator |
int applyAsInt(int) |
int to int |
LongUnaryOperator |
long applyAsLong(long) |
long to long |
DoubleUnaryOperator |
double applyAsDouble(double) |
double to double |
IntBinaryOperator |
int applyAsInt(int, int) |
Two ints to int |
LongBinaryOperator |
long applyAsLong(long, long) |
Two longs to long |
DoubleBinaryOperator |
double applyAsDouble(double, double) |
Two doubles to double |
Let us build a reusable validation framework using Predicate composition. This demonstrates how functional interfaces enable elegant, composable designs that would require far more code with traditional OOP approaches.
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.stream.Collectors;
public class ValidationFramework {
// --- ValidationResult: holds success/failure and messages ---
static class ValidationResult {
private final boolean valid;
private final List errors;
private ValidationResult(boolean valid, List errors) {
this.valid = valid;
this.errors = errors;
}
static ValidationResult success() {
return new ValidationResult(true, List.of());
}
static ValidationResult failure(String error) {
return new ValidationResult(false, List.of(error));
}
static ValidationResult combine(ValidationResult a, ValidationResult b) {
if (a.valid && b.valid) return success();
List allErrors = new ArrayList<>(a.errors);
allErrors.addAll(b.errors);
return new ValidationResult(false, allErrors);
}
boolean isValid() { return valid; }
List getErrors() { return errors; }
@Override
public String toString() {
return valid ? "VALID" : "INVALID: " + errors;
}
}
// --- Validator: wraps a Predicate with an error message ---
@FunctionalInterface
interface Validator {
ValidationResult validate(T t);
default Validator and(Validator other) {
return t -> ValidationResult.combine(this.validate(t), other.validate(t));
}
// Factory method: create a Validator from a Predicate and error message
static Validator of(Predicate predicate, String errorMessage) {
return t -> predicate.test(t)
? ValidationResult.success()
: ValidationResult.failure(errorMessage);
}
}
// --- Domain class ---
static class RegistrationForm {
String username;
String email;
String password;
int age;
RegistrationForm(String username, String email, String password, int age) {
this.username = username;
this.email = email;
this.password = password;
this.age = age;
}
@Override
public String toString() {
return "Form{user='" + username + "', email='" + email + "', age=" + age + "}";
}
}
public static void main(String[] args) {
// --- Build individual validators using Predicate composition ---
Validator usernameNotEmpty = Validator.of(
f -> f.username != null && !f.username.isBlank(),
"Username is required"
);
Validator usernameMinLength = Validator.of(
f -> f.username != null && f.username.length() >= 3,
"Username must be at least 3 characters"
);
Validator usernameMaxLength = Validator.of(
f -> f.username == null || f.username.length() <= 20,
"Username must be at most 20 characters"
);
Validator emailValid = Validator.of(
f -> f.email != null && f.email.matches("^[\\w.+-]+@[\\w.-]+\\.[a-zA-Z]{2,}$"),
"Email is invalid"
);
Validator passwordStrong = Validator.of(
f -> f.password != null && f.password.length() >= 8,
"Password must be at least 8 characters"
);
Validator passwordHasDigit = Validator.of(
f -> f.password != null && f.password.chars().anyMatch(Character::isDigit),
"Password must contain at least one digit"
);
Validator passwordHasUpper = Validator.of(
f -> f.password != null && f.password.chars().anyMatch(Character::isUpperCase),
"Password must contain at least one uppercase letter"
);
Validator ageValid = Validator.of(
f -> f.age >= 13 && f.age <= 120,
"Age must be between 13 and 120"
);
// --- Compose all validators into one ---
Validator fullValidator = usernameNotEmpty
.and(usernameMinLength)
.and(usernameMaxLength)
.and(emailValid)
.and(passwordStrong)
.and(passwordHasDigit)
.and(passwordHasUpper)
.and(ageValid);
// --- Test data ---
List forms = Arrays.asList(
new RegistrationForm("Alice", "alice@example.com", "SecurePass1", 25),
new RegistrationForm("Bo", "bo@x.com", "weakpw", 10),
new RegistrationForm("", "not-an-email", "12345678", 30),
new RegistrationForm("ValidUser", "user@site.org", "MyP@ssw0rd", 45)
);
// --- Validate each form ---
System.out.println("=== Registration Validation Results ===\n");
for (RegistrationForm form : forms) {
ValidationResult result = fullValidator.validate(form);
System.out.println(form);
System.out.println(" Result: " + result);
System.out.println();
}
// Output:
// === Registration Validation Results ===
//
// Form{user='Alice', email='alice@example.com', age=25}
// Result: VALID
//
// Form{user='Bo', email='bo@x.com', age=10}
// Result: INVALID: [Username must be at least 3 characters, Password must be at least 8 characters, Password must contain at least one uppercase letter, Age must be between 13 and 120]
//
// Form{user='', email='not-an-email', age=30}
// Result: INVALID: [Username is required, Username must be at least 3 characters, Email is invalid, Password must contain at least one uppercase letter]
//
// Form{user='ValidUser', email='user@site.org', age=45}
// Result: VALID
// --- Filter only valid forms ---
List validForms = forms.stream()
.filter(f -> fullValidator.validate(f).isValid())
.collect(Collectors.toList());
System.out.println("Valid forms: " + validForms);
// Output: Valid forms: [Form{user='Alice'...}, Form{user='ValidUser'...}]
// --- Transform valid forms ---
Function toWelcomeMessage =
f -> "Welcome, " + f.username + "! Your account (" + f.email + ") has been created.";
validForms.stream()
.map(toWelcomeMessage)
.forEach(System.out::println);
// Output:
// Welcome, Alice! Your account (alice@example.com) has been created.
// Welcome, ValidUser! Your account (user@site.org) has been created.
}
}
This validation framework demonstrates several functional interface concepts working together:
| Concept | Where Used | How |
|---|---|---|
| Predicate | Validator.of(predicate, message) |
Each validation rule is a Predicate |
| Composition (and) | validator1.and(validator2) |
Combines validators into a single check |
| Custom Functional Interface | Validator<T> |
Wraps Predicate with error messages |
| Function | toWelcomeMessage |
Transforms valid forms into strings |
| Method Reference | Character::isDigit, System.out::println |
Clean stream operations |
| Static Factory | Validator.of() |
Creates validators declaratively |
@FunctionalInterface annotation is optional but recommended — it prevents accidental breakage by enforcing the single-method rule at compile time.Runnable, Comparator, Callable). Java 8 formalized the concept and added the java.util.function package.and()/or()/negate(), functions with andThen()/compose(), and consumers with andThen().Imagine you are giving someone directions. You could say: “Walk to the kitchen, open the drawer, grab a knife, and cut the bread.” Or you could simply say: “Cut the bread” — because the person already knows the steps. A method reference is that shorter instruction. Instead of writing out the full steps in a lambda expression, you point directly to a method that already does the job.
A method reference is a shorthand notation introduced in Java 8 that lets you refer to an existing method by name using the :: operator (double colon). It produces the same result as a lambda expression that does nothing more than call that one method.
Key concept: If a lambda expression simply passes its arguments to an existing method without adding any logic, you can replace it with a method reference for cleaner, more readable code.
There are four types of method references in Java:
| # | Type | Syntax | Example |
|---|---|---|---|
| 1 | Reference to a static method | ClassName::staticMethod |
Integer::parseInt |
| 2 | Reference to an instance method of a particular object | instance::method |
System.out::println |
| 3 | Reference to an instance method of an arbitrary object of a particular type | ClassName::instanceMethod |
String::toLowerCase |
| 4 | Reference to a constructor | ClassName::new |
ArrayList::new |
Before diving into each type, here is a quick side-by-side to see why method references exist:
import java.util.Arrays;
import java.util.List;
public class MethodReferenceIntro {
public static void main(String[] args) {
List names = Arrays.asList("Alice", "Bob", "Charlie");
// Lambda expression -- spells out each step
names.forEach(name -> System.out.println(name));
// Method reference -- points directly to the method
names.forEach(System.out::println);
// Both produce the same output:
// Alice
// Bob
// Charlie
}
}
The lambda name -> System.out.println(name) does nothing but pass its parameter to println. The method reference System.out::println says the same thing more directly. The compiler knows the parameter type from context, so no explicit argument is needed.
When to use method references:
When NOT to use method references:
The simplest form of method reference points to a static method on a class. The syntax is ClassName::staticMethodName.
How it works: The compiler sees the functional interface’s abstract method signature, finds the matching static method, and wires them together. The lambda’s parameters become the static method’s arguments.
| Lambda Expression | Method Reference | Explanation |
|---|---|---|
s -> Integer.parseInt(s) |
Integer::parseInt |
String argument passed to parseInt |
d -> Math.abs(d) |
Math::abs |
Double argument passed to abs |
s -> String.valueOf(s) |
String::valueOf |
Object argument passed to valueOf |
(a, b) -> Math.max(a, b) |
Math::max |
Two arguments passed to max |
(list) -> Collections.unmodifiableList(list) |
Collections::unmodifiableList |
List argument passed to unmodifiableList |
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
public class StaticMethodRef {
public static void main(String[] args) {
// Example 1: Integer::parseInt -- convert strings to integers
List numberStrings = Arrays.asList("1", "2", "3", "4", "5");
List numbers = numberStrings.stream()
.map(Integer::parseInt) // same as: s -> Integer.parseInt(s)
.collect(Collectors.toList());
System.out.println("Parsed integers: " + numbers);
// Output: Parsed integers: [1, 2, 3, 4, 5]
// Example 2: Math::abs -- absolute values
List values = Arrays.asList(-5, 3, -1, 7, -4);
List absolutes = values.stream()
.map(Math::abs) // same as: n -> Math.abs(n)
.collect(Collectors.toList());
System.out.println("Absolute values: " + absolutes);
// Output: Absolute values: [5, 3, 1, 7, 4]
// Example 3: String::valueOf -- convert objects to strings
List
Here is a practical example using Collections.sort with a static helper method:
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
public class StaticMethodRefSort {
// Custom static comparison method
public static int compareByLength(String a, String b) {
return Integer.compare(a.length(), b.length());
}
public static void main(String[] args) {
List words = Arrays.asList("elephant", "cat", "dog", "butterfly", "ant");
// Lambda version
// Collections.sort(words, (a, b) -> StaticMethodRefSort.compareByLength(a, b));
// Method reference version
Collections.sort(words, StaticMethodRefSort::compareByLength);
System.out.println("Sorted by length: " + words);
// Output: Sorted by length: [cat, dog, ant, elephant, butterfly]
}
}
This type of method reference calls an instance method on a specific, known object. The syntax is objectReference::instanceMethod.
How it works: You have a particular object already in scope. The lambda’s parameter(s) become the argument(s) to that object’s method. The most common example is System.out::println, where System.out is the specific PrintStream object.
Key distinction: The object is determined before the method reference is used. It is captured just like a variable in a lambda.
import java.util.Arrays;
import java.util.List;
public class ParticularObjectMethodRef {
public static void main(String[] args) {
// Example 1: System.out::println -- the most common method reference
List fruits = Arrays.asList("Apple", "Banana", "Cherry");
// Lambda version
fruits.forEach(fruit -> System.out.println(fruit));
// Method reference version
fruits.forEach(System.out::println);
// Output (each call):
// Apple
// Banana
// Cherry
// Example 2: Instance method on a custom object
String prefix = "Hello, ";
List names = Arrays.asList("Alice", "Bob", "Charlie");
// Lambda version
names.forEach(name -> System.out.println(prefix.concat(name)));
// NOTE: prefix::concat returns the concatenated string but does not print it.
// So we need to chain or use lambda when extra logic is needed.
names.stream()
.map(prefix::concat) // same as: name -> prefix.concat(name)
.forEach(System.out::println);
// Output:
// Hello, Alice
// Hello, Bob
// Hello, Charlie
}
}
A more practical example with a custom object:
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
public class ParticularObjectAdvanced {
static class TextFormatter {
private final String delimiter;
TextFormatter(String delimiter) {
this.delimiter = delimiter;
}
public String wrap(String text) {
return delimiter + text + delimiter;
}
public boolean isLongEnough(String text) {
return text.length() > 3;
}
}
public static void main(String[] args) {
TextFormatter quoter = new TextFormatter("\"");
TextFormatter stars = new TextFormatter("***");
List words = Arrays.asList("Hi", "Java", "is", "awesome", "OK");
// Use the specific quoter object's wrap method
List quoted = words.stream()
.map(quoter::wrap) // same as: w -> quoter.wrap(w)
.collect(Collectors.toList());
System.out.println("Quoted: " + quoted);
// Output: Quoted: ["Hi", "Java", "is", "awesome", "OK"]
// Use the specific stars object's wrap method
List starred = words.stream()
.map(stars::wrap) // same as: w -> stars.wrap(w)
.collect(Collectors.toList());
System.out.println("Starred: " + starred);
// Output: Starred: [***Hi***, ***Java***, ***is***, ***awesome***, ***OK***]
// Use the quoter object's filter method
List longWords = words.stream()
.filter(quoter::isLongEnough) // same as: w -> quoter.isLongEnough(w)
.collect(Collectors.toList());
System.out.println("Long words: " + longWords);
// Output: Long words: [Java, awesome]
}
}
This is the trickiest type to understand. The syntax looks like a static method reference — ClassName::instanceMethod — but it calls an instance method. The difference is that the first parameter of the lambda becomes the object on which the method is called.
How it works:
s -> s.toLowerCase() becomes String::toLowerCase. The stream element itself is the object.(a, b) -> a.compareTo(b) becomes String::compareTo. The first parameter is the object, the second is the argument.The rule: If the first parameter of the lambda is the receiver (the object on which the method is called), and the remaining parameters (if any) are the method arguments, you can use ClassName::instanceMethod.
| Lambda | Method Reference | First Param Becomes |
|---|---|---|
s -> s.toLowerCase() |
String::toLowerCase |
The receiver (s.toLowerCase()) |
s -> s.length() |
String::length |
The receiver (s.length()) |
(a, b) -> a.compareTo(b) |
String::compareTo |
The receiver (a), b becomes the argument |
s -> s.isEmpty() |
String::isEmpty |
The receiver (s.isEmpty()) |
s -> s.trim() |
String::trim |
The receiver (s.trim()) |
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
public class ArbitraryObjectMethodRef {
public static void main(String[] args) {
// Example 1: String::toLowerCase -- each string in the stream calls its own toLowerCase
List names = Arrays.asList("ALICE", "BOB", "CHARLIE");
List lower = names.stream()
.map(String::toLowerCase) // same as: s -> s.toLowerCase()
.collect(Collectors.toList());
System.out.println("Lowercase: " + lower);
// Output: Lowercase: [alice, bob, charlie]
// Example 2: String::length -- each string calls its own length()
List lengths = names.stream()
.map(String::length) // same as: s -> s.length()
.collect(Collectors.toList());
System.out.println("Lengths: " + lengths);
// Output: Lengths: [5, 3, 7]
// Example 3: String::trim -- each string calls its own trim()
List messy = Arrays.asList(" hello ", " world ", " java ");
List trimmed = messy.stream()
.map(String::trim) // same as: s -> s.trim()
.collect(Collectors.toList());
System.out.println("Trimmed: " + trimmed);
// Output: Trimmed: [hello, world, java]
// Example 4: String::compareTo -- used for sorting
List cities = Arrays.asList("Denver", "Austin", "Chicago", "Boston");
cities.sort(String::compareTo); // same as: (a, b) -> a.compareTo(b)
System.out.println("Sorted: " + cities);
// Output: Sorted: [Austin, Boston, Chicago, Denver]
// Example 5: String::isEmpty -- used for filtering
List mixed = Arrays.asList("Hello", "", "World", "", "Java");
List nonEmpty = mixed.stream()
.filter(s -> !s.isEmpty()) // Cannot use method ref for negation
.collect(Collectors.toList());
System.out.println("Non-empty: " + nonEmpty);
// Output: Non-empty: [Hello, World, Java]
}
}
Important: Notice Example 5 above. When you need to negate a method reference (!s.isEmpty()), you cannot use String::isEmpty directly. You need either the lambda or Predicate.not(String::isEmpty) (Java 11+):
import java.util.Arrays;
import java.util.List;
import java.util.function.Predicate;
import java.util.stream.Collectors;
public class PredicateNotExample {
public static void main(String[] args) {
List mixed = Arrays.asList("Hello", "", "World", "", "Java");
// Java 11+: Predicate.not() enables method reference for negation
List nonEmpty = mixed.stream()
.filter(Predicate.not(String::isEmpty))
.collect(Collectors.toList());
System.out.println("Non-empty: " + nonEmpty);
// Output: Non-empty: [Hello, World, Java]
}
}
A constructor reference uses the syntax ClassName::new. It creates new objects by pointing to a constructor, just like other method references point to methods.
How it works: The compiler matches the functional interface’s abstract method signature to the appropriate constructor. If the interface takes one String parameter, the compiler finds the constructor that takes one String parameter.
| Lambda | Constructor Reference | Which Constructor |
|---|---|---|
() -> new ArrayList<>() |
ArrayList::new |
No-arg constructor |
s -> new StringBuilder(s) |
StringBuilder::new |
String parameter constructor |
n -> new int[n] |
int[]::new |
Array constructor |
s -> new File(s) |
File::new |
String parameter constructor |
import java.util.Arrays;
import java.util.List;
import java.util.ArrayList;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import java.util.function.Function;
import java.util.function.Supplier;
public class ConstructorRefExamples {
public static void main(String[] args) {
// Example 1: Supplier -- no-arg constructor
Supplier> listFactory = ArrayList::new; // same as: () -> new ArrayList<>()
List newList = listFactory.get();
newList.add("Created via constructor reference");
System.out.println(newList);
// Output: [Created via constructor reference]
// Example 2: Function -- single-arg constructor
Function sbFactory = StringBuilder::new;
StringBuilder sb = sbFactory.apply("Hello, Java!");
System.out.println(sb);
// Output: Hello, Java!
// Example 3: Stream.toArray with array constructor reference
List names = Arrays.asList("Alice", "Bob", "Charlie");
// Without constructor reference -- returns Object[]
Object[] objectArray = names.stream().toArray();
// With constructor reference -- returns String[]
String[] stringArray = names.stream().toArray(String[]::new);
System.out.println("Array type: " + stringArray.getClass().getSimpleName());
System.out.println("Array: " + Arrays.toString(stringArray));
// Output: Array type: String[]
// Output: Array: [Alice, Bob, Charlie]
// Example 4: Creating objects from a stream of data
List numberStrings = Arrays.asList("100", "200", "300");
List integers = numberStrings.stream()
.map(Integer::new) // same as: s -> new Integer(s)
.collect(Collectors.toList());
System.out.println("Integers: " + integers);
// Output: Integers: [100, 200, 300]
// Note: Integer::new is deprecated since Java 9, prefer Integer::valueOf
// Example 5: Better approach using valueOf
List integersModern = numberStrings.stream()
.map(Integer::valueOf) // preferred over Integer::new
.collect(Collectors.toList());
System.out.println("Integers (valueOf): " + integersModern);
// Output: Integers (valueOf): [100, 200, 300]
}
}
Constructor references with custom classes:
import java.util.Arrays;
import java.util.List;
import java.util.function.BiFunction;
import java.util.function.Function;
import java.util.stream.Collectors;
public class CustomConstructorRef {
static class Person {
private final String name;
private final int age;
// No-arg constructor
Person() {
this.name = "Unknown";
this.age = 0;
}
// Single-arg constructor
Person(String name) {
this.name = name;
this.age = 0;
}
// Two-arg constructor
Person(String name, int age) {
this.name = name;
this.age = age;
}
@Override
public String toString() {
return name + " (age " + age + ")";
}
}
// A functional interface for three-arg constructor
@FunctionalInterface
interface TriFunction {
R apply(A a, B b, C c);
}
public static void main(String[] args) {
// Single-arg constructor reference
Function personByName = Person::new;
Person alice = personByName.apply("Alice");
System.out.println(alice);
// Output: Alice (age 0)
// Two-arg constructor reference
BiFunction personByNameAge = Person::new;
Person bob = personByNameAge.apply("Bob", 30);
System.out.println(bob);
// Output: Bob (age 30)
// Using constructor reference in streams
List names = Arrays.asList("Charlie", "Diana", "Eve");
List people = names.stream()
.map(Person::new) // calls Person(String name)
.collect(Collectors.toList());
people.forEach(System.out::println);
// Output:
// Charlie (age 0)
// Diana (age 0)
// Eve (age 0)
}
}
Method references and lambda expressions are interchangeable when the lambda simply delegates to an existing method. But they are not always interchangeable, and one is not always better than the other. Here is a detailed comparison:
| Aspect | Lambda Expression | Method Reference |
|---|---|---|
| Syntax | (params) -> expression |
ClassName::method |
| Readability | Shows the how | Shows the what |
| Flexibility | Can contain any logic | Can only point to one method |
| Multi-step logic | Supports multiple statements | Not supported |
| Extra arguments | Can pass extra arguments | Cannot pass extra arguments |
| Negation | s -> !s.isEmpty() |
Needs Predicate.not() (Java 11+) |
| Debugging | Stack trace shows lambda$main$0 | Stack trace shows actual method name |
| Performance | Identical at runtime | Identical at runtime |
| IDE Support | IDEs suggest converting to method ref | IDEs can expand to lambda |
import java.util.Arrays;
import java.util.List;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.function.Consumer;
import java.util.function.Supplier;
import java.util.function.BinaryOperator;
import java.util.ArrayList;
public class LambdaVsMethodRef {
public static void main(String[] args) {
// --- CAN be converted to method reference ---
// 1. Static method
Function f1_lambda = s -> Integer.parseInt(s);
Function f1_ref = Integer::parseInt;
// 2. Instance method on particular object
Consumer c1_lambda = s -> System.out.println(s);
Consumer c1_ref = System.out::println;
// 3. Instance method on arbitrary object
Function f2_lambda = s -> s.toUpperCase();
Function f2_ref = String::toUpperCase;
// 4. Constructor
Supplier> s1_lambda = () -> new ArrayList<>();
Supplier> s1_ref = ArrayList::new;
// 5. Two-arg instance method
BinaryOperator b1_lambda = (a, b) -> a.concat(b);
BinaryOperator b1_ref = String::concat;
// --- CANNOT be converted to method reference ---
// 1. Extra logic in the lambda
Function noRef1 = s -> s.toUpperCase().trim();
// Cannot be a single method reference -- two operations
// 2. Extra arguments not from lambda parameters
int multiplier = 3;
Function noRef2 = n -> n * multiplier;
// No method to reference -- it is custom arithmetic
// 3. Negation
Predicate noRef3 = s -> !s.isEmpty();
// Cannot negate with method reference (unless Java 11+ Predicate.not)
// 4. Conditional logic
Function noRef4 = n -> n > 0 ? "positive" : "non-positive";
// Cannot express ternary as a method reference
System.out.println("Conversions demonstrated successfully.");
// Output: Conversions demonstrated successfully.
}
}
Method references are not always the right choice. Here are situations where lambdas are clearer:
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
public class WhenLambdaIsBetter {
public static void main(String[] args) {
List names = Arrays.asList("Alice", "Bob", "Charlie", "Diana");
// Lambda is clearer when you need to add context
// CONFUSING method reference with long class name:
// names.stream().map(WhenLambdaIsBetter::formatGreeting).collect(Collectors.toList());
// CLEAR lambda:
List greetings = names.stream()
.map(name -> "Hello, " + name + "!")
.collect(Collectors.toList());
System.out.println(greetings);
// Output: [Hello, Alice!, Hello, Bob!, Hello, Charlie!, Hello, Diana!]
// Lambda is better when extracting a field from a complex object
// This reads more naturally:
List lengths = names.stream()
.map(name -> name.length() + 10) // added logic -- can't be method ref
.collect(Collectors.toList());
System.out.println(lengths);
// Output: [15, 13, 17, 15]
// Lambda is better for multi-step operations
List processed = names.stream()
.map(name -> {
String upper = name.toUpperCase();
return "[" + upper + "]";
})
.collect(Collectors.toList());
System.out.println(processed);
// Output: [[ALICE], [BOB], [CHARLIE], [DIANA]]
}
}
Method references become especially powerful when used with the Stream API. Every stream operation that takes a functional interface — map, filter, forEach, sorted, flatMap, reduce — can often be written more concisely with method references.
import java.util.Arrays;
import java.util.Collection;
import java.util.List;
import java.util.stream.Collectors;
public class MethodRefWithStreams {
public static void main(String[] args) {
List words = Arrays.asList("hello", "WORLD", "Java", "streams", "METHOD");
// --- map() with method reference ---
List upper = words.stream()
.map(String::toUpperCase) // each element calls its own toUpperCase()
.collect(Collectors.toList());
System.out.println("Uppercase: " + upper);
// Output: Uppercase: [HELLO, WORLD, JAVA, STREAMS, METHOD]
// --- filter() with method reference (Java 11+ for isBlank) ---
List withBlanks = Arrays.asList("Hello", "", " ", "World", "");
List nonBlank = withBlanks.stream()
.filter(s -> !s.isBlank()) // negation still needs lambda or Predicate.not
.collect(Collectors.toList());
System.out.println("Non-blank: " + nonBlank);
// Output: Non-blank: [Hello, World]
// --- forEach() with method reference ---
System.out.print("Names: ");
List names = Arrays.asList("Alice", "Bob", "Charlie");
names.forEach(System.out::println);
// Output:
// Alice
// Bob
// Charlie
// --- sorted() with method reference ---
List cities = Arrays.asList("Denver", "Austin", "Chicago");
List sorted = cities.stream()
.sorted(String::compareToIgnoreCase) // case-insensitive sort
.collect(Collectors.toList());
System.out.println("Sorted: " + sorted);
// Output: Sorted: [Austin, Chicago, Denver]
// --- flatMap() with method reference ---
List> nested = Arrays.asList(
Arrays.asList("A", "B"),
Arrays.asList("C", "D"),
Arrays.asList("E", "F")
);
List flat = nested.stream()
.flatMap(Collection::stream) // same as: list -> list.stream()
.collect(Collectors.toList());
System.out.println("Flat: " + flat);
// Output: Flat: [A, B, C, D, E, F]
// --- reduce() with method reference ---
List numbers = Arrays.asList(1, 2, 3, 4, 5);
int sum = numbers.stream()
.reduce(0, Integer::sum); // same as: (a, b) -> Integer.sum(a, b)
System.out.println("Sum: " + sum);
// Output: Sum: 15
}
}
A more realistic stream pipeline combining multiple method references:
import java.util.Arrays;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
public class StreamPipelineMethodRef {
static class Product {
private final String name;
private final double price;
private final String category;
Product(String name, double price, String category) {
this.name = name;
this.price = price;
this.category = category;
}
public String getName() { return name; }
public double getPrice() { return price; }
public String getCategory() { return category; }
public static boolean isAffordable(Product p) {
return p.getPrice() < 50.0;
}
public String toDisplayString() {
return String.format("%s ($%.2f)", name, price);
}
@Override
public String toString() {
return name + " - $" + price;
}
}
public static void main(String[] args) {
List products = Arrays.asList(
new Product("Keyboard", 29.99, "Electronics"),
new Product("Mouse", 19.99, "Electronics"),
new Product("Monitor", 299.99, "Electronics"),
new Product("Notebook", 4.99, "Office"),
new Product("Pen", 1.99, "Office"),
new Product("Desk Lamp", 45.00, "Office")
);
// Pipeline using method references
List affordableProducts = products.stream()
.filter(Product::isAffordable) // static method reference
.map(Product::toDisplayString) // arbitrary object method reference
.collect(Collectors.toList());
System.out.println("Affordable products:");
affordableProducts.forEach(System.out::println); // particular object method reference
// Output:
// Affordable products:
// Keyboard ($29.99)
// Mouse ($19.99)
// Notebook ($4.99)
// Pen ($1.99)
// Desk Lamp ($45.00)
// Finding the cheapest product
Optional cheapest = products.stream()
.min((a, b) -> Double.compare(a.getPrice(), b.getPrice()));
// Note: This particular comparison cannot be a method reference
// because we need to extract the field first
cheapest.ifPresent(System.out::println);
// Output: Pen - $1.99
}
}
The Comparator interface works naturally with method references. Java 8 introduced Comparator.comparing(), thenComparing(), and reversed() methods that were designed specifically to work with method references.
import java.util.Arrays;
import java.util.Comparator;
import java.util.List;
public class ComparatorMethodRef {
static class Employee {
private final String name;
private final String department;
private final double salary;
private final int yearsOfService;
Employee(String name, String department, double salary, int yearsOfService) {
this.name = name;
this.department = department;
this.salary = salary;
this.yearsOfService = yearsOfService;
}
public String getName() { return name; }
public String getDepartment() { return department; }
public double getSalary() { return salary; }
public int getYearsOfService() { return yearsOfService; }
@Override
public String toString() {
return String.format("%-10s %-12s $%,.0f %d yrs",
name, department, salary, yearsOfService);
}
}
public static void main(String[] args) {
List employees = Arrays.asList(
new Employee("Alice", "Engineering", 95000, 5),
new Employee("Bob", "Marketing", 75000, 8),
new Employee("Charlie", "Engineering", 110000, 3),
new Employee("Diana", "Marketing", 85000, 6),
new Employee("Eve", "Engineering", 95000, 7)
);
// Sort by name (natural order)
employees.sort(Comparator.comparing(Employee::getName));
System.out.println("=== By Name ===");
employees.forEach(System.out::println);
// Output:
// Alice Engineering $95,000 5 yrs
// Bob Marketing $75,000 8 yrs
// Charlie Engineering $110,000 3 yrs
// Diana Marketing $85,000 6 yrs
// Eve Engineering $95,000 7 yrs
// Sort by salary descending
employees.sort(Comparator.comparingDouble(Employee::getSalary).reversed());
System.out.println("\n=== By Salary (Highest First) ===");
employees.forEach(System.out::println);
// Output:
// Charlie Engineering $110,000 3 yrs
// Alice Engineering $95,000 5 yrs
// Eve Engineering $95,000 7 yrs
// Diana Marketing $85,000 6 yrs
// Bob Marketing $75,000 8 yrs
// Multi-level sort: department, then salary descending
employees.sort(Comparator.comparing(Employee::getDepartment)
.thenComparing(Comparator.comparingDouble(Employee::getSalary).reversed()));
System.out.println("\n=== By Department, then Salary Desc ===");
employees.forEach(System.out::println);
// Output:
// Charlie Engineering $110,000 3 yrs
// Alice Engineering $95,000 5 yrs
// Eve Engineering $95,000 7 yrs
// Diana Marketing $85,000 6 yrs
// Bob Marketing $75,000 8 yrs
// Sort by years of service (ascending), using comparingInt
employees.sort(Comparator.comparingInt(Employee::getYearsOfService));
System.out.println("\n=== By Years of Service ===");
employees.forEach(System.out::println);
// Output:
// Charlie Engineering $110,000 3 yrs
// Alice Engineering $95,000 5 yrs
// Diana Marketing $85,000 6 yrs
// Eve Engineering $95,000 7 yrs
// Bob Marketing $75,000 8 yrs
// Natural order for Comparable types
List names = Arrays.asList("Charlie", "Alice", "Bob");
names.sort(Comparator.naturalOrder());
System.out.println("\nNatural order: " + names);
// Output: Natural order: [Alice, Bob, Charlie]
// Reverse natural order
names.sort(Comparator.reverseOrder());
System.out.println("Reverse order: " + names);
// Output: Reverse order: [Charlie, Bob, Alice]
// Null-safe comparator
List withNulls = Arrays.asList("Charlie", null, "Alice", null, "Bob");
withNulls.sort(Comparator.nullsLast(Comparator.naturalOrder()));
System.out.println("Nulls last: " + withNulls);
// Output: Nulls last: [Alice, Bob, Charlie, null, null]
}
}
You can design your own methods specifically to be used as method references. This is a powerful technique for creating reusable, composable utilities. The key is to ensure your method signature matches the functional interface that will be used.
Create static methods in utility classes that match common functional interface signatures:
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
public class CustomMethodRefUtils {
// --- Predicate-compatible static methods (T -> boolean) ---
public static boolean isEven(int number) {
return number % 2 == 0;
}
public static boolean isPositive(int number) {
return number > 0;
}
public static boolean isValidEmail(String email) {
return email != null && email.contains("@") && email.contains(".");
}
public static boolean isNotEmpty(String s) {
return s != null && !s.isEmpty();
}
// --- Function-compatible static methods (T -> R) ---
public static String toSlug(String text) {
return text.toLowerCase().replaceAll("\\s+", "-").replaceAll("[^a-z0-9-]", "");
}
public static String maskEmail(String email) {
int atIndex = email.indexOf('@');
if (atIndex <= 1) return email;
return email.charAt(0) + "***" + email.substring(atIndex);
}
public static String formatCurrency(double amount) {
return String.format("$%,.2f", amount);
}
// --- Consumer-compatible static methods (T -> void) ---
public static void logInfo(String message) {
System.out.println("[INFO] " + message);
}
public static void main(String[] args) {
// Using custom Predicate methods
List numbers = Arrays.asList(-3, -1, 0, 2, 4, 7, 10);
List evenNumbers = numbers.stream()
.filter(CustomMethodRefUtils::isEven)
.collect(Collectors.toList());
System.out.println("Even: " + evenNumbers);
// Output: Even: [0, 2, 4, 10]
List positiveNumbers = numbers.stream()
.filter(CustomMethodRefUtils::isPositive)
.collect(Collectors.toList());
System.out.println("Positive: " + positiveNumbers);
// Output: Positive: [2, 4, 7, 10]
// Using custom Function methods
List titles = Arrays.asList("Hello World!", "Java 8 Features", "Method Refs");
List slugs = titles.stream()
.map(CustomMethodRefUtils::toSlug)
.collect(Collectors.toList());
System.out.println("Slugs: " + slugs);
// Output: Slugs: [hello-world, java-8-features, method-refs]
// Using custom Consumer methods
List emails = Arrays.asList("alice@example.com", "bob@test.org", "charlie@mail.com");
emails.stream()
.map(CustomMethodRefUtils::maskEmail)
.forEach(CustomMethodRefUtils::logInfo);
// Output:
// [INFO] a***@example.com
// [INFO] b***@test.org
// [INFO] c***@mail.com
// Validate and transform pipeline
List inputEmails = Arrays.asList("alice@example.com", "", "invalid", "bob@test.org", null);
List validMasked = inputEmails.stream()
.filter(CustomMethodRefUtils::isNotEmpty)
.filter(CustomMethodRefUtils::isValidEmail)
.map(CustomMethodRefUtils::maskEmail)
.collect(Collectors.toList());
System.out.println("Valid masked: " + validMasked);
// Output: Valid masked: [a***@example.com, b***@test.org]
}
}
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
public class CustomInstanceMethodRef {
static class PriceCalculator {
private final double taxRate;
private final double discountPercent;
PriceCalculator(double taxRate, double discountPercent) {
this.taxRate = taxRate;
this.discountPercent = discountPercent;
}
// Designed to be used as Function
public double applyTax(double price) {
return price * (1 + taxRate);
}
// Designed to be used as Function
public double applyDiscount(double price) {
return price * (1 - discountPercent);
}
// Designed to be used as Predicate
public boolean isAboveMinimum(double price) {
return price > 10.0;
}
// Designed to be used as Function
public String formatWithTax(double price) {
double total = applyTax(price);
return String.format("$%.2f (incl. %.0f%% tax)", total, taxRate * 100);
}
}
public static void main(String[] args) {
PriceCalculator calc = new PriceCalculator(0.08, 0.15); // 8% tax, 15% discount
List prices = Arrays.asList(5.99, 12.50, 25.00, 8.75, 49.99);
// Use instance method references on a specific calculator
List formattedPrices = prices.stream()
.filter(calc::isAboveMinimum) // Predicate: only prices > $10
.map(calc::applyDiscount) // Function: apply 15% discount
.map(calc::formatWithTax) // Function: format with tax
.collect(Collectors.toList());
System.out.println("Final prices (after discount + tax):");
formattedPrices.forEach(System.out::println);
// Output:
// Final prices (after discount + tax):
// $11.48 (incl. 8% tax)
// $22.95 (incl. 8% tax)
// $45.89 (incl. 8% tax)
}
}
When a class has overloaded methods (same name, different parameters), the compiler resolves which overload to use based on the functional interface’s abstract method signature. In most cases this works seamlessly, but ambiguity can arise.
import java.util.Arrays;
import java.util.List;
import java.util.function.Function;
import java.util.function.BiFunction;
import java.util.function.UnaryOperator;
import java.util.stream.Collectors;
public class OverloadedMethodRef {
// Overloaded static methods
public static String format(String value) {
return "[" + value + "]";
}
public static String format(String value, int width) {
return String.format("%" + width + "s", value);
}
public static String format(int number) {
return String.format("%,d", number);
}
public static void main(String[] args) {
// The compiler selects the correct overload based on the functional interface
// Function --> matches format(String)
Function f1 = OverloadedMethodRef::format;
System.out.println(f1.apply("hello"));
// Output: [hello]
// BiFunction --> matches format(String, int)
BiFunction f2 = OverloadedMethodRef::format;
System.out.println(f2.apply("hello", 20));
// Output: hello
// Function --> matches format(int)
Function f3 = OverloadedMethodRef::format;
System.out.println(f3.apply(1000000));
// Output: 1,000,000
// Using in streams -- the target type determines which overload
List words = Arrays.asList("cat", "dog", "bird");
// Stream's map expects Function, so format(String) is chosen
List formatted = words.stream()
.map(OverloadedMethodRef::format)
.collect(Collectors.toList());
System.out.println("Formatted: " + formatted);
// Output: Formatted: [[cat], [dog], [bird]]
// Example of String::valueOf -- many overloads
// valueOf(int), valueOf(long), valueOf(double), valueOf(boolean), valueOf(Object), etc.
List numbers = Arrays.asList(1, 2, 3);
List strings = numbers.stream()
.map(String::valueOf) // compiler picks valueOf(Object) or valueOf(int)
.collect(Collectors.toList());
System.out.println("Strings: " + strings);
// Output: Strings: [1, 2, 3]
}
}
When the compiler cannot determine which overload to use, you get a compilation error. This typically happens when two overloads could both match the functional interface. The solution is to use a lambda expression instead, or add an explicit type cast:
import java.util.function.Function;
import java.util.function.Consumer;
public class AmbiguityResolution {
// These two overloads can cause ambiguity in certain contexts
public static void process(String value) {
System.out.println("String: " + value);
}
public static void process(Object value) {
System.out.println("Object: " + value);
}
public static void main(String[] args) {
// Direct method reference works when the target type is clear
Consumer c1 = AmbiguityResolution::process;
c1.accept("hello");
// Output: String: hello
Consumer
Method references look simple, but there are several pitfalls that trip up developers. Here are the most common mistakes and how to avoid them:
The method you reference must match the functional interface’s expected signature. The number and types of parameters must align.
import java.util.function.Function;
import java.util.function.BiFunction;
import java.util.function.Predicate;
public class MistakeWrongSignature {
public static String greet(String name, String greeting) {
return greeting + ", " + name + "!";
}
public static boolean hasMinLength(String text, int minLength) {
return text.length() >= minLength;
}
public static void main(String[] args) {
// WRONG: Function expects 1 argument, greet takes 2
// Function f = MistakeWrongSignature::greet; // COMPILE ERROR
// CORRECT: Use BiFunction for 2-argument methods
BiFunction f = MistakeWrongSignature::greet;
System.out.println(f.apply("Alice", "Hello"));
// Output: Hello, Alice!
// WRONG: Predicate expects 1 argument, hasMinLength takes 2
// Predicate p = MistakeWrongSignature::hasMinLength; // COMPILE ERROR
// CORRECT: Use a lambda to supply the extra argument
Predicate p = text -> MistakeWrongSignature.hasMinLength(text, 5);
System.out.println(p.test("Hello"));
// Output: true
System.out.println(p.test("Hi"));
// Output: false
}
}
A common error is confusing when to use ClassName::method (static or arbitrary instance) versus instance::method (specific instance).
import java.util.Arrays;
import java.util.List;
import java.util.function.Function;
import java.util.stream.Collectors;
public class MistakeStaticVsInstance {
// Instance method
public String addPrefix(String text) {
return ">> " + text;
}
// Static method
public static String addSuffix(String text) {
return text + " <<";
}
public static void main(String[] args) {
List words = Arrays.asList("Hello", "World");
MistakeStaticVsInstance obj = new MistakeStaticVsInstance();
// CORRECT: Static method uses ClassName::method
List withSuffix = words.stream()
.map(MistakeStaticVsInstance::addSuffix) // static method
.collect(Collectors.toList());
System.out.println(withSuffix);
// Output: [Hello <<, World <<]
// CORRECT: Instance method on a specific object uses instance::method
List withPrefix = words.stream()
.map(obj::addPrefix) // instance method on obj
.collect(Collectors.toList());
System.out.println(withPrefix);
// Output: [>> Hello, >> World]
// WRONG: Using ClassName::instanceMethod treats it as "arbitrary object" type
// MistakeStaticVsInstance::addPrefix would expect the FIRST parameter
// to be a MistakeStaticVsInstance object, not a String.
// Function wrong = MistakeStaticVsInstance::addPrefix; // COMPILE ERROR
// CORRECT as arbitrary object: ClassName::instanceMethod
// where the stream elements ARE of that class type
Function correct =
o -> o.addPrefix("test");
System.out.println(correct.apply(obj));
// Output: >> test
}
}
import java.util.Arrays;
import java.util.List;
import java.util.function.Consumer;
import java.util.function.Function;
public class MistakeVoidReturn {
public static void printUpper(String s) {
System.out.println(s.toUpperCase());
// returns void
}
public static String toUpper(String s) {
return s.toUpperCase();
// returns String
}
public static void main(String[] args) {
List names = Arrays.asList("Alice", "Bob");
// Consumer expects void return -- use void method
Consumer consumer = MistakeVoidReturn::printUpper; // OK
names.forEach(consumer);
// Output:
// ALICE
// BOB
// Function expects a return value -- use returning method
Function function = MistakeVoidReturn::toUpper; // OK
System.out.println(function.apply("charlie"));
// Output: CHARLIE
// WRONG: Using void method where Function is expected
// Function wrong = MistakeVoidReturn::printUpper; // COMPILE ERROR
// printUpper returns void, but Function expects String return
// NOTE: A Function CAN be used where Consumer is expected (return value is ignored)
// This is valid because Consumer.accept() discards the return value
Consumer consumerFromFunction = MistakeVoidReturn::toUpper; // OK, return ignored
consumerFromFunction.accept("test"); // toUpper runs but return value is discarded
}
}
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
public class MistakeAddingLogic {
public static void main(String[] args) {
List names = Arrays.asList("Alice", "Bob", "Charlie");
// WRONG: You cannot add logic to a method reference
// names.forEach(System.out::println + " is here"); // COMPILE ERROR
// names.forEach(String::toUpperCase::trim); // COMPILE ERROR
// CORRECT: Use a lambda when you need additional logic
names.forEach(name -> System.out.println(name + " is here"));
// Output:
// Alice is here
// Bob is here
// Charlie is here
// CORRECT: Use a lambda when chaining method calls
List processed = names.stream()
.map(name -> name.toUpperCase().trim())
.collect(Collectors.toList());
System.out.println(processed);
// Output: [ALICE, BOB, CHARLIE]
// ALTERNATIVE: Create a helper method, then use a method reference to it
List processed2 = names.stream()
.map(MistakeAddingLogic::processName)
.collect(Collectors.toList());
System.out.println(processed2);
// Output: [*** ALICE ***, *** BOB ***, *** CHARLIE ***]
}
private static String processName(String name) {
return "*** " + name.toUpperCase().trim() + " ***";
}
}
import java.util.Arrays;
import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;
public class MistakeNullRef {
public static void main(String[] args) {
// PROBLEM: Method reference on a null object
String prefix = null;
// This compiles fine, but throws NullPointerException at runtime
// when the method reference is evaluated
try {
List names = Arrays.asList("Alice", "Bob");
List result = names.stream()
.map(prefix::concat) // NPE! prefix is null
.collect(Collectors.toList());
} catch (NullPointerException e) {
System.out.println("NullPointerException: Cannot call method on null object");
}
// Output: NullPointerException: Cannot call method on null object
// SOLUTION: Null-check before using the method reference
String safePrefix = "Hello, ";
if (safePrefix != null) {
List names = Arrays.asList("Alice", "Bob");
List result = names.stream()
.map(safePrefix::concat)
.collect(Collectors.toList());
System.out.println(result);
}
// Output: [Hello, Alice, Hello, Bob]
// SOLUTION for null elements in a stream: filter with Objects::nonNull
List mixed = Arrays.asList("Alice", null, "Bob", null, "Charlie");
List upper = mixed.stream()
.filter(Objects::nonNull)
.map(String::toUpperCase)
.collect(Collectors.toList());
System.out.println(upper);
// Output: [ALICE, BOB, CHARLIE]
}
}
Method references are a powerful tool for writing clean, readable Java code. Follow these guidelines to use them effectively:
| Do | Don’t |
|---|---|
| Use method references when they are more readable than the lambda | Force a method reference when a lambda is clearer |
Prefer built-in method references like Integer::parseInt, String::toUpperCase |
Create trivial wrapper methods just to have a method reference |
Use System.out::println for debugging stream pipelines |
Leave debugging method references in production code |
| Create well-named utility methods that serve as method references | Create methods with side effects for use in map() |
Use Comparator.comparing(Entity::getField) for sorting |
Write manual comparator lambdas when Comparator.comparing works |
Use Objects::nonNull to filter null values |
Use method references on potentially null objects |
| Let the IDE suggest method references | Ignore IDE warnings about possible method references |
| Use constructor references with factory patterns | Use deprecated constructors like Integer::new |
String::toLowerCase reads well. AbstractDatabaseConnectionFactoryImpl::createConnection does not.System.out::println or Integer::parseInt. Custom methods need good naming.Method references and lambdas have identical runtime performance. The JVM compiles both into equivalent bytecode using the invokedynamic instruction. Choose based on readability, never performance.
import java.util.Arrays;
import java.util.Comparator;
import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;
public class BestPracticesDemo {
public static void main(String[] args) {
// GOOD: Clear, well-known method references
List names = Arrays.asList("Charlie", null, "Alice", null, "Bob");
List result = names.stream()
.filter(Objects::nonNull) // clear: filters nulls
.map(String::toUpperCase) // clear: converts to uppercase
.sorted(String::compareTo) // clear: sorts naturally
.collect(Collectors.toList());
result.forEach(System.out::println); // clear: prints each element
// Output:
// ALICE
// BOB
// CHARLIE
// GOOD: Comparator.comparing with method reference
List words = Arrays.asList("elephant", "cat", "butterfly", "ant");
words.sort(Comparator.comparing(String::length));
System.out.println("By length: " + words);
// Output: By length: [cat, ant, elephant, butterfly]
// BAD then GOOD: If the lambda has extra logic, don't force a method reference
// BAD: words.stream().map(SomeVeryLongClassName::someHelperMethod)...
// GOOD: words.stream().map(w -> "[" + w.toUpperCase() + "]")...
List formatted = words.stream()
.map(w -> "[" + w.toUpperCase() + "]")
.collect(Collectors.toList());
System.out.println("Formatted: " + formatted);
// Output: Formatted: [[CAT], [ANT], [ELEPHANT], [BUTTERFLY]]
}
}
This comprehensive example demonstrates an Employee Processing System that uses all four types of method references in a realistic business scenario. It processes employee data through multiple pipeline stages: validation, transformation, sorting, reporting, and output.
import java.util.Arrays;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.function.Supplier;
import java.util.stream.Collectors;
public class EmployeeProcessingSystem {
// ==============================
// Domain Model
// ==============================
static class Employee {
private final String name;
private final String department;
private final double salary;
private final int yearsOfExperience;
private final boolean active;
Employee(String name, String department, double salary,
int yearsOfExperience, boolean active) {
this.name = name;
this.department = department;
this.salary = salary;
this.yearsOfExperience = yearsOfExperience;
this.active = active;
}
// Constructor for stream-based creation (name only)
Employee(String name) {
this(name, "Unassigned", 0, 0, true);
}
public String getName() { return name; }
public String getDepartment() { return department; }
public double getSalary() { return salary; }
public int getYearsOfExperience() { return yearsOfExperience; }
public boolean isActive() { return active; }
// Instance method designed for method references
public String toReportLine() {
return String.format(" %-15s %-12s $%,10.2f %d yrs %s",
name, department, salary, yearsOfExperience,
active ? "Active" : "Inactive");
}
// Instance method for CSV export
public String toCsv() {
return String.join(",", name, department,
String.valueOf(salary), String.valueOf(yearsOfExperience),
String.valueOf(active));
}
@Override
public String toString() {
return name + " (" + department + ")";
}
}
// ==============================
// Utility class with static methods (for static method references)
// ==============================
static class EmployeeUtils {
public static boolean isSenior(Employee e) {
return e.getYearsOfExperience() >= 5;
}
public static boolean isHighEarner(Employee e) {
return e.getSalary() >= 90000;
}
public static boolean isValid(Employee e) {
return e != null && e.getName() != null && !e.getName().isEmpty();
}
public static int compareBySalary(Employee a, Employee b) {
return Double.compare(a.getSalary(), b.getSalary());
}
public static String formatForEmail(Employee e) {
return e.getName().toLowerCase().replace(" ", ".") + "@company.com";
}
}
// ==============================
// Report printer (for particular object method references)
// ==============================
static class ReportPrinter {
private final String reportTitle;
private int lineCount = 0;
ReportPrinter(String reportTitle) {
this.reportTitle = reportTitle;
}
public void printHeader() {
System.out.println("\n========================================");
System.out.println(" " + reportTitle);
System.out.println("========================================");
}
public void printLine(String line) {
lineCount++;
System.out.println(line);
}
public void printSummary() {
System.out.println("----------------------------------------");
System.out.println(" Total lines printed: " + lineCount);
System.out.println("========================================");
}
}
// ==============================
// Main processing
// ==============================
public static void main(String[] args) {
// --- Sample data ---
List employees = Arrays.asList(
new Employee("Alice Johnson", "Engineering", 105000, 7, true),
new Employee("Bob Smith", "Marketing", 72000, 3, true),
new Employee("Charlie Brown", "Engineering", 95000, 5, true),
new Employee("Diana Prince", "HR", 88000, 9, true),
new Employee("Eve Williams", "Marketing", 91000, 6, false),
new Employee("Frank Castle", "Engineering", 115000, 10, true),
new Employee("Grace Hopper", "Engineering", 98000, 4, true),
null, // intentional null for demonstration
new Employee("Henry Ford", "Operations", 78000, 2, true),
new Employee("Irene Adler", "HR", 83000, 8, true)
);
// ================================================
// TYPE 1: Static method reference (ClassName::staticMethod)
// ================================================
System.out.println("=== STEP 1: Filter valid, active, senior, high-earning employees ===");
List qualifiedEmployees = employees.stream()
.filter(Objects::nonNull) // static: remove nulls
.filter(EmployeeUtils::isValid) // static: validate
.filter(Employee::isActive) // arbitrary: check active
.filter(EmployeeUtils::isSenior) // static: >= 5 years
.filter(EmployeeUtils::isHighEarner) // static: >= $90K
.collect(Collectors.toList());
qualifiedEmployees.forEach(System.out::println);
// Output:
// Alice Johnson (Engineering)
// Charlie Brown (Engineering)
// Frank Castle (Engineering)
// ================================================
// TYPE 2: Particular object method reference (instance::method)
// ================================================
System.out.println("\n=== STEP 2: Generate formatted report ===");
ReportPrinter printer = new ReportPrinter("Qualified Employee Report");
printer.printHeader();
qualifiedEmployees.stream()
.map(Employee::toReportLine) // arbitrary: format each
.forEach(printer::printLine); // particular: print on this printer
printer.printSummary();
// Output:
// ========================================
// Qualified Employee Report
// ========================================
// Alice Johnson Engineering $105,000.00 7 yrs Active
// Charlie Brown Engineering $ 95,000.00 5 yrs Active
// Frank Castle Engineering $115,000.00 10 yrs Active
// ----------------------------------------
// Total lines printed: 3
// ========================================
// ================================================
// TYPE 3: Arbitrary object method reference (ClassName::instanceMethod)
// ================================================
System.out.println("\n=== STEP 3: Extract and process names ===");
List emailAddresses = employees.stream()
.filter(Objects::nonNull) // static
.filter(Employee::isActive) // arbitrary: each emp calls isActive()
.map(EmployeeUtils::formatForEmail) // static
.sorted(String::compareTo) // arbitrary: each string calls compareTo()
.collect(Collectors.toList());
emailAddresses.forEach(System.out::println);
// Output:
// alice.johnson@company.com
// bob.smith@company.com
// charlie.brown@company.com
// diana.prince@company.com
// frank.castle@company.com
// grace.hopper@company.com
// henry.ford@company.com
// irene.adler@company.com
// ================================================
// TYPE 4: Constructor reference (ClassName::new)
// ================================================
System.out.println("\n=== STEP 4: Create new employees from names ===");
List newHires = Arrays.asList("Jack Ryan", "Kate Bishop", "Leo Messi");
// Constructor reference -- Employee(String name)
List newEmployees = newHires.stream()
.map(Employee::new) // constructor reference
.collect(Collectors.toList());
newEmployees.forEach(System.out::println);
// Output:
// Jack Ryan (Unassigned)
// Kate Bishop (Unassigned)
// Leo Messi (Unassigned)
// Convert to array using array constructor reference
Employee[] employeeArray = newEmployees.stream()
.toArray(Employee[]::new); // array constructor reference
System.out.println("Array length: " + employeeArray.length);
// Output: Array length: 3
// ================================================
// Combining all types in a single pipeline
// ================================================
System.out.println("\n=== STEP 5: Department salary report ===");
// Supplier> for fresh data
Supplier> dataSource = ArrayList::new;
Map avgSalaryByDept = employees.stream()
.filter(Objects::nonNull) // static
.filter(Employee::isActive) // arbitrary
.collect(Collectors.groupingBy(
Employee::getDepartment, // arbitrary
Collectors.averagingDouble(
Employee::getSalary))); // arbitrary
avgSalaryByDept.entrySet().stream()
.sorted(Map.Entry.comparingByKey())
.forEach(entry -> System.out.printf(" %-15s $%,.2f%n",
entry.getKey(), entry.getValue()));
// Output:
// Engineering $103,250.00
// HR $85,500.00
// Marketing $72,000.00
// Operations $78,000.00
// ================================================
// CSV export using method reference
// ================================================
System.out.println("\n=== STEP 6: CSV export ===");
System.out.println("name,department,salary,experience,active");
employees.stream()
.filter(Objects::nonNull)
.filter(Employee::isActive)
.sorted(Comparator.comparing(Employee::getName))
.map(Employee::toCsv) // arbitrary: each calls toCsv()
.forEach(System.out::println); // particular: System.out
// Output:
// name,department,salary,experience,active
// Alice Johnson,Engineering,105000.0,7,true
// Bob Smith,Marketing,72000.0,3,true
// Charlie Brown,Engineering,95000.0,5,true
// Diana Prince,HR,88000.0,9,true
// Frank Castle,Engineering,115000.0,10,true
// Grace Hopper,Engineering,98000.0,4,true
// Henry Ford,Operations,78000.0,2,true
// Irene Adler,HR,83000.0,8,true
// ================================================
// Statistics using method references
// ================================================
System.out.println("\n=== STEP 7: Statistics ===");
List validEmployees = employees.stream()
.filter(Objects::nonNull)
.filter(Employee::isActive)
.collect(Collectors.toList());
Optional highestPaid = validEmployees.stream()
.max(Comparator.comparingDouble(Employee::getSalary));
highestPaid.map(Employee::toReportLine).ifPresent(System.out::println);
// Output: Frank Castle Engineering $115,000.00 10 yrs Active
Optional mostExperienced = validEmployees.stream()
.max(Comparator.comparingInt(Employee::getYearsOfExperience));
mostExperienced.map(Employee::toReportLine).ifPresent(System.out::println);
// Output: Frank Castle Engineering $115,000.00 10 yrs Active
double totalSalary = validEmployees.stream()
.mapToDouble(Employee::getSalary)
.sum();
System.out.printf(" Total salary budget: $%,.2f%n", totalSalary);
// Output: Total salary budget: $734,000.00
long engineeringCount = validEmployees.stream()
.map(Employee::getDepartment)
.filter("Engineering"::equals) // particular: "Engineering" string's equals()
.count();
System.out.println(" Engineering headcount: " + engineeringCount);
// Output: Engineering headcount: 4
}
}
This example demonstrates all four types of method references working together in a realistic data processing scenario:
| Type | Examples Used | Purpose |
|---|---|---|
| Static method | Objects::nonNull, EmployeeUtils::isSenior, EmployeeUtils::formatForEmail |
Validation, filtering, transformation |
| Particular object | System.out::println, printer::printLine, "Engineering"::equals |
Output, reporting, string matching |
| Arbitrary object | Employee::isActive, Employee::getDepartment, Employee::toReportLine, String::compareTo |
Field extraction, formatting, sorting |
| Constructor | Employee::new, Employee[]::new, ArrayList::new |
Object creation, array creation |
Use this summary table as a cheat sheet for method references:
| Type | Syntax | Lambda Equivalent | Common Examples |
|---|---|---|---|
| Static method | ClassName::staticMethod |
(args) -> ClassName.staticMethod(args) |
Integer::parseIntMath::absString::valueOfObjects::nonNullCollections::unmodifiableList |
| Instance method (particular object) | instance::method |
(args) -> instance.method(args) |
System.out::printlnmyList::addmyString::concat"literal"::equals |
| Instance method (arbitrary object) | ClassName::instanceMethod |
(obj, args) -> obj.method(args)or obj -> obj.method() |
String::toLowerCaseString::lengthString::compareToObject::toString |
| Constructor | ClassName::new |
(args) -> new ClassName(args) |
ArrayList::newStringBuilder::newString[]::newFile::new |
Decision flowchart for choosing between lambda and method reference:
Key takeaways:
:: operator creates a reference to a method without calling it