Before Java 8, Java was a purely object-oriented language. Every piece of behavior — sorting a list, filtering a collection, handling a button click — had to be wrapped inside a class. If you wanted to pass a comparator to Collections.sort(), you created an anonymous inner class with five lines of boilerplate just to express one line of logic. Java 8 changed that.
Released in March 2014, Java 8 was the most significant update to the language since generics arrived in Java 5. It introduced lambda expressions, which brought functional programming capabilities to Java for the first time. This was not just a syntax convenience — it was a paradigm shift. Developers could now treat behavior as data: pass functions as arguments, return them from methods, and store them in variables.
Why this matters:
The three pillars of Java 8’s functional programming support work together:
| Feature | Purpose | Example |
|---|---|---|
| Lambda Expressions | Define inline anonymous functions | (a, b) -> a + b |
| Functional Interfaces | Provide the type system for lambdas | Predicate<T>, Function<T,R> |
| Method References | Shorthand for lambdas that call existing methods | String::toUpperCase |
Here is the before-and-after that summarizes the entire shift:
import java.util.Arrays;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
public class Java8ParadigmShift {
public static void main(String[] args) {
// ===== BEFORE Java 8: Anonymous inner class =====
List names = Arrays.asList("Charlie", "Alice", "Bob");
Collections.sort(names, new Comparator() {
@Override
public int compare(String a, String b) {
return a.compareTo(b);
}
});
System.out.println("Before Java 8: " + names);
// Output: Before Java 8: [Alice, Bob, Charlie]
// ===== Java 8: Lambda expression =====
List names2 = Arrays.asList("Charlie", "Alice", "Bob");
Collections.sort(names2, (a, b) -> a.compareTo(b));
System.out.println("Lambda: " + names2);
// Output: Lambda: [Alice, Bob, Charlie]
// ===== Java 8: Method reference (even shorter) =====
List names3 = Arrays.asList("Charlie", "Alice", "Bob");
names3.sort(String::compareTo);
System.out.println("Method ref: " + names3);
// Output: Method ref: [Alice, Bob, Charlie]
// ===== Java 8: List.sort() replaces Collections.sort() =====
List names4 = Arrays.asList("Charlie", "Alice", "Bob");
names4.sort(Comparator.naturalOrder());
System.out.println("Comparator factory: " + names4);
// Output: Comparator factory: [Alice, Bob, Charlie]
}
}
Seven lines of anonymous class code reduced to a single expression. That is the power of Java 8.
A lambda expression has three parts separated by the arrow operator ->:
(parameters) -> expression // Single expression, implicit return
(parameters) -> { statements; } // Block body, explicit return needed
The left side defines the input. The right side defines the output or action. The compiler infers parameter types, return type, and the functional interface from the context where the lambda is used. This is called target typing.
| Variation | Syntax | Example |
|---|---|---|
| No parameters | () -> expression |
() -> System.out.println("Hello") |
| Single parameter (no parens) | param -> expression |
name -> name.toUpperCase() |
| Single parameter (with parens) | (param) -> expression |
(name) -> name.toUpperCase() |
| Multiple parameters | (p1, p2) -> expression |
(a, b) -> a + b |
| Expression body (implicit return) | (params) -> expression |
(x) -> x * x |
| Block body (explicit return) | (params) -> { return expr; } |
(x) -> { return x * x; } |
| Block body (void) | (params) -> { statements; } |
(msg) -> { System.out.println(msg); } |
| Explicit parameter types | (Type p1, Type p2) -> expr |
(String a, String b) -> a.compareTo(b) |
import java.util.function.BiFunction;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.function.Supplier;
public class LambdaSyntaxVariations {
public static void main(String[] args) {
// 1. No parameters
Runnable greet = () -> System.out.println("Hello, Java 8!");
greet.run();
// Output: Hello, Java 8!
// 2. Single parameter - parentheses optional
Function upper = name -> name.toUpperCase();
System.out.println(upper.apply("lambda"));
// Output: LAMBDA
// 3. Single parameter - with parentheses (same thing)
Function upper2 = (name) -> name.toUpperCase();
System.out.println(upper2.apply("expressions"));
// Output: EXPRESSIONS
// 4. Multiple parameters - parentheses required
BiFunction add = (a, b) -> a + b;
System.out.println("Sum: " + add.apply(10, 20));
// Output: Sum: 30
// 5. Expression body - implicit return
Function square = x -> x * x;
System.out.println("Square of 7: " + square.apply(7));
// Output: Square of 7: 49
// 6. Block body - explicit return required
Function classify = x -> {
if (x > 0) return "positive";
else if (x < 0) return "negative";
else return "zero";
};
System.out.println(classify.apply(-5));
// Output: negative
// 7. Block body - void (no return)
Consumer logger = msg -> {
String timestamp = java.time.LocalTime.now().toString();
System.out.println("[" + timestamp + "] " + msg);
};
logger.accept("Application started");
// Output: [14:30:22.123456] Application started
// 8. Explicit parameter types
BiFunction concat = (String a, String b) -> a + " " + b;
System.out.println(concat.apply("Hello", "World"));
// Output: Hello World
}
}
The Java compiler determines the type of a lambda from the context in which it appears. This is called target typing. The same lambda expression can match different functional interfaces depending on where it is used:
import java.util.concurrent.Callable;
import java.util.function.Supplier;
public class TargetTyping {
public static void main(String[] args) throws Exception {
// Same lambda, different target types
Supplier supplier = () -> "Hello from Supplier";
Callable callable = () -> "Hello from Callable";
System.out.println(supplier.get());
// Output: Hello from Supplier
System.out.println(callable.call());
// Output: Hello from Callable
// The compiler infers parameter types from context
// No need to write (Integer a, Integer b) -> ...
java.util.Comparator ascending = (a, b) -> a - b;
java.util.Comparator descending = (a, b) -> b - a;
java.util.List nums = new java.util.ArrayList<>(
java.util.Arrays.asList(3, 1, 4, 1, 5)
);
nums.sort(ascending);
System.out.println("Ascending: " + nums);
// Output: Ascending: [1, 1, 3, 4, 5]
nums.sort(descending);
System.out.println("Descending: " + nums);
// Output: Descending: [5, 4, 3, 1, 1]
}
}
Lambdas can reference local variables from the enclosing scope, but those variables must be effectively final — meaning they are never reassigned after initialization. You do not need the final keyword explicitly, but the compiler enforces that the value does not change.
import java.util.function.Function;
public class EffectivelyFinal {
public static void main(String[] args) {
// This works - 'prefix' is effectively final
String prefix = "Hello";
Function greeter = name -> prefix + ", " + name + "!";
System.out.println(greeter.apply("Alice"));
// Output: Hello, Alice!
// This would NOT compile:
// String greeting = "Hi";
// greeting = "Hello"; // reassignment makes it NOT effectively final
// Function broken = name -> greeting + name;
// Error: Variable used in lambda should be final or effectively final
// Arrays and objects CAN be modified (the reference is final, not the contents)
int[] counter = {0};
Runnable increment = () -> counter[0]++;
increment.run();
increment.run();
increment.run();
System.out.println("Counter: " + counter[0]);
// Output: Counter: 3
}
}
A functional interface is an interface with exactly one abstract method. This is also called a SAM (Single Abstract Method) interface. Every lambda expression in Java corresponds to a functional interface — the lambda provides the implementation of that single abstract method.
The @FunctionalInterface annotation is optional but strongly recommended. It tells the compiler to enforce the single-abstract-method rule. If someone accidentally adds a second abstract method, the compiler will produce an error immediately.
// Custom functional interface @FunctionalInterface public interface Transformer{ T transform(T input); // Default methods are allowed - they are not abstract default Transformer andThen(Transformer after) { return input -> after.transform(this.transform(input)); } // Static methods are allowed static Transformer identity() { return input -> input; } // Methods from Object are allowed (toString, equals, hashCode) // They do NOT count as abstract methods String toString(); }
Rules for functional interfaces:
Object (toString, equals, hashCode) — these do not count@FunctionalInterface is optional but recommended for compile-time safetyJava 8 added the java.util.function package with 43 functional interfaces. The seven most important ones cover the vast majority of use cases:
| Interface | Method | Input | Output | Use Case |
|---|---|---|---|---|
Predicate<T> |
test(T) |
T | boolean | Filtering, matching, validating |
Function<T, R> |
apply(T) |
T | R | Transforming, mapping, converting |
Consumer<T> |
accept(T) |
T | void | Side effects: print, log, save |
Supplier<T> |
get() |
none | T | Factory, lazy evaluation, defaults |
BiFunction<T, U, R> |
apply(T, U) |
T, U | R | Two-input transformation |
UnaryOperator<T> |
apply(T) |
T | T (same type) | Modify and return same type |
BinaryOperator<T> |
apply(T, T) |
T, T | T (same type) | Combining two values of same type |
Predicate<T> takes an input and returns boolean. Think of it as a yes/no question about the input. It also provides composition methods: and(), or(), and negate() for combining predicates.
import java.util.Arrays;
import java.util.List;
import java.util.function.Predicate;
import java.util.stream.Collectors;
public class PredicateExamples {
public static void main(String[] args) {
// Basic predicate: is the number even?
Predicate isEven = n -> n % 2 == 0;
System.out.println("Is 4 even? " + isEven.test(4));
// Output: Is 4 even? true
System.out.println("Is 7 even? " + isEven.test(7));
// Output: Is 7 even? false
// Composing predicates with and(), or(), negate()
Predicate isPositive = n -> n > 0;
Predicate isEvenAndPositive = isEven.and(isPositive);
Predicate isEvenOrPositive = isEven.or(isPositive);
Predicate isOdd = isEven.negate();
System.out.println("Is -4 even AND positive? " + isEvenAndPositive.test(-4));
// Output: Is -4 even AND positive? false
System.out.println("Is -4 even OR positive? " + isEvenOrPositive.test(-4));
// Output: Is -4 even OR positive? true
System.out.println("Is 7 odd? " + isOdd.test(7));
// Output: Is 7 odd? true
// Practical: filtering a list with composed predicates
List numbers = Arrays.asList(-5, -2, 0, 3, 4, 7, 10, -8);
List evenAndPositive = numbers.stream()
.filter(isEven.and(isPositive))
.collect(Collectors.toList());
System.out.println("Even and positive: " + evenAndPositive);
// Output: Even and positive: [4, 10]
// String predicate: validation
Predicate isNotEmpty = s -> s != null && !s.isEmpty();
Predicate isEmail = s -> s.contains("@") && s.contains(".");
Predicate isValidEmail = isNotEmpty.and(isEmail);
System.out.println("Valid email? " + isValidEmail.test("user@example.com"));
// Output: Valid email? true
System.out.println("Valid email? " + isValidEmail.test("not-an-email"));
// Output: Valid email? false
// Predicate.isEqual() - static factory method
Predicate isAdmin = Predicate.isEqual("ADMIN");
System.out.println("Is ADMIN? " + isAdmin.test("ADMIN"));
// Output: Is ADMIN? true
System.out.println("Is USER? " + isAdmin.test("USER"));
// Output: Is USER? false
}
}
Function<T, R> takes one input of type T and produces an output of type R. It is the most general-purpose functional interface. The composition methods andThen() and compose() let you chain transformations into a pipeline.
import java.util.Arrays;
import java.util.List;
import java.util.function.Function;
import java.util.stream.Collectors;
public class FunctionExamples {
public static void main(String[] args) {
// Basic function: string to its length
Function length = String::length;
System.out.println("Length of 'Lambda': " + length.apply("Lambda"));
// Output: Length of 'Lambda': 6
// andThen: apply this function, THEN apply the next
Function trim = String::trim;
Function toUpper = String::toUpperCase;
Function cleanAndUpperCase = trim.andThen(toUpper);
System.out.println(cleanAndUpperCase.apply(" hello world "));
// Output: HELLO WORLD
// compose: apply the OTHER function first, THEN this one
// compose is the reverse order of andThen
Function doubleIt = x -> x * 2;
Function addTen = x -> x + 10;
// doubleIt.andThen(addTen) means: double first, then add 10
System.out.println("Double then add 10: " + doubleIt.andThen(addTen).apply(5));
// Output: Double then add 10: 20 (5*2=10, 10+10=20)
// doubleIt.compose(addTen) means: add 10 first, then double
System.out.println("Add 10 then double: " + doubleIt.compose(addTen).apply(5));
// Output: Add 10 then double: 30 (5+10=15, 15*2=30)
// Function.identity() - returns input unchanged
Function identity = Function.identity();
System.out.println(identity.apply("unchanged"));
// Output: unchanged
// Practical: build a transformation pipeline
Function pipeline = ((Function) String::trim)
.andThen(String::toLowerCase)
.andThen(s -> s.replaceAll("\\s+", "-"))
.andThen(s -> s.replaceAll("[^a-z0-9-]", ""));
System.out.println(pipeline.apply(" Hello World! Java 8 "));
// Output: hello-world-java-8
// Using Function with streams
List names = Arrays.asList("alice", "bob", "charlie");
Function capitalize = s -> s.substring(0, 1).toUpperCase() + s.substring(1);
List capitalized = names.stream()
.map(capitalize)
.collect(Collectors.toList());
System.out.println(capitalized);
// Output: [Alice, Bob, Charlie]
}
}
Consumer<T> takes an input and returns nothing (void). It represents a side effect — printing, logging, saving to a database, sending a notification. The andThen() method chains multiple consumers.
import java.util.Arrays;
import java.util.List;
import java.util.function.Consumer;
public class ConsumerExamples {
public static void main(String[] args) {
// Basic consumer: print a value
Consumer printer = System.out::println;
printer.accept("Hello from Consumer!");
// Output: Hello from Consumer!
// Chaining consumers with andThen
Consumer log = msg -> System.out.println("[LOG] " + msg);
Consumer save = msg -> System.out.println("[SAVE] Persisting: " + msg);
Consumer logAndSave = log.andThen(save);
logAndSave.accept("User signed up");
// Output: [LOG] User signed up
// Output: [SAVE] Persisting: User signed up
// Practical: process a list of users
List users = Arrays.asList("Alice", "Bob", "Charlie");
Consumer welcome = name -> System.out.println("Welcome, " + name + "!");
Consumer assignRole = name -> System.out.println("Assigning default role to " + name);
Consumer onboardUser = welcome.andThen(assignRole);
System.out.println("--- Onboarding users ---");
users.forEach(onboardUser);
// Output:
// --- Onboarding users ---
// Welcome, Alice!
// Assigning default role to Alice
// Welcome, Bob!
// Assigning default role to Bob
// Welcome, Charlie!
// Assigning default role to Charlie
// BiConsumer: two inputs
java.util.Map scores = new java.util.HashMap<>();
scores.put("Alice", 95);
scores.put("Bob", 87);
scores.put("Charlie", 92);
scores.forEach((name, score) ->
System.out.println(name + " scored " + score)
);
// Output: Alice scored 95
// Output: Bob scored 87
// Output: Charlie scored 92
}
}
Supplier<T> takes no input and returns a value. It is a factory or generator. Common uses include providing default values, lazy initialization, and deferred computation.
import java.util.Random;
import java.util.function.Supplier;
import java.util.stream.Stream;
public class SupplierExamples {
public static void main(String[] args) {
// Basic supplier: return a constant
Supplier greeting = () -> "Hello, World!";
System.out.println(greeting.get());
// Output: Hello, World!
// Supplier as a factory
Supplier sbFactory = StringBuilder::new;
StringBuilder sb1 = sbFactory.get();
StringBuilder sb2 = sbFactory.get();
sb1.append("First");
sb2.append("Second");
System.out.println(sb1 + ", " + sb2);
// Output: First, Second
// Lazy evaluation with Supplier
Supplier randomValue = () -> Math.random();
// Nothing computed yet - only computed when get() is called
System.out.println("Random 1: " + randomValue.get());
System.out.println("Random 2: " + randomValue.get());
// Output: Random 1: 0.7234... (varies)
// Output: Random 2: 0.1456... (varies)
// Generate infinite stream from Supplier
Supplier diceRoll = () -> new Random().nextInt(6) + 1;
System.out.print("Five dice rolls: ");
Stream.generate(diceRoll)
.limit(5)
.forEach(n -> System.out.print(n + " "));
System.out.println();
// Output: Five dice rolls: 3 1 5 2 6 (varies)
// Practical: providing default values
String configValue = null;
Supplier defaultConfig = () -> "localhost:8080";
String result = configValue != null ? configValue : defaultConfig.get();
System.out.println("Config: " + result);
// Output: Config: localhost:8080
}
}
BiFunction<T, U, R> takes two inputs and produces one output. It is useful when a transformation needs two pieces of data. It has andThen() but no compose() (since compose would need a function that returns two values, which Java does not support).
import java.util.function.BiFunction;
public class BiFunctionExamples {
public static void main(String[] args) {
// Basic: combine two values
BiFunction fullName =
(first, last) -> first + " " + last;
System.out.println(fullName.apply("John", "Doe"));
// Output: John Doe
// Math operation
BiFunction power =
(base, exponent) -> Math.pow(base, exponent);
System.out.println("2^10 = " + power.apply(2, 10));
// Output: 2^10 = 1024.0
// andThen: transform the result
BiFunction greet =
(greeting, name) -> greeting + ", " + name;
String result = greet.andThen(String::toUpperCase).apply("hello", "world");
System.out.println(result);
// Output: HELLO, WORLD
// Practical: calculate discounted price
BiFunction priceWithDiscount =
(price, discountPercent) -> {
double discounted = price * (1 - discountPercent / 100);
return String.format("$%.2f (%.0f%% off $%.2f)",
discounted, discountPercent, price);
};
System.out.println(priceWithDiscount.apply(100.0, 25.0));
// Output: $75.00 (25% off $100.00)
System.out.println(priceWithDiscount.apply(49.99, 10.0));
// Output: $44.99 (10% off $49.99)
}
}
UnaryOperator<T> extends Function<T, T>. It takes a value and returns a value of the same type. Use it when you are modifying a value without changing its type — formatting a string, incrementing a number, transforming a list element in place.
import java.util.Arrays;
import java.util.List;
import java.util.function.UnaryOperator;
public class UnaryOperatorExamples {
public static void main(String[] args) {
// Basic: increment a number
UnaryOperator increment = x -> x + 1;
System.out.println(increment.apply(10));
// Output: 11
// String transformation
UnaryOperator addExclamation = s -> s + "!";
UnaryOperator toUpper = String::toUpperCase;
UnaryOperator shout = toUpper.andThen(addExclamation)::apply;
System.out.println(shout.apply("hello"));
// Output: HELLO!
// Practical: List.replaceAll() uses UnaryOperator
List names = Arrays.asList("alice", "bob", "charlie");
names.replaceAll(String::toUpperCase);
System.out.println(names);
// Output: [ALICE, BOB, CHARLIE]
// UnaryOperator.identity() - returns input unchanged
UnaryOperator noChange = UnaryOperator.identity();
System.out.println(noChange.apply("same"));
// Output: same
// Chaining: build text formatter
UnaryOperator trim = String::trim;
UnaryOperator lower = String::toLowerCase;
UnaryOperator format = trim.andThen(lower)::apply;
System.out.println(format.apply(" HELLO WORLD "));
// Output: hello world
}
}
BinaryOperator<T> extends BiFunction<T, T, T>. It takes two inputs of the same type and returns a result of that same type. It is the ideal type for reduction operations like summing, finding max/min, or merging.
import java.util.Arrays;
import java.util.List;
import java.util.function.BinaryOperator;
public class BinaryOperatorExamples {
public static void main(String[] args) {
// Basic: add two integers
BinaryOperator add = (a, b) -> a + b;
System.out.println("Sum: " + add.apply(10, 20));
// Output: Sum: 30
// Concatenate strings
BinaryOperator concat = (a, b) -> a + " " + b;
System.out.println(concat.apply("Hello", "World"));
// Output: Hello World
// BinaryOperator.maxBy() and minBy() - factory methods
BinaryOperator max = BinaryOperator.maxBy(Integer::compareTo);
BinaryOperator min = BinaryOperator.minBy(Integer::compareTo);
System.out.println("Max of 10, 20: " + max.apply(10, 20));
// Output: Max of 10, 20: 20
System.out.println("Min of 10, 20: " + min.apply(10, 20));
// Output: Min of 10, 20: 10
// Practical: reduce a list
List numbers = Arrays.asList(1, 2, 3, 4, 5);
BinaryOperator sum = Integer::sum;
int total = numbers.stream().reduce(0, sum);
System.out.println("Total: " + total);
// Output: Total: 15
// Practical: find longest string
List words = Arrays.asList("java", "lambda", "expression", "functional");
BinaryOperator longer = BinaryOperator.maxBy(
java.util.Comparator.comparingInt(String::length)
);
String longest = words.stream().reduce(longer).orElse("");
System.out.println("Longest word: " + longest);
// Output: Longest word: expression
}
}
Java 8 added new methods directly to the Collection framework interfaces that accept lambdas. These let you work with collections without streams, using simple method calls on the collection itself.
The forEach() method was added to Iterable (the parent of all collections). It takes a Consumer and executes it for each element.
import java.util.Arrays;
import java.util.List;
import java.util.HashMap;
import java.util.Map;
public class ForEachExamples {
public static void main(String[] args) {
// List.forEach()
List languages = Arrays.asList("Java", "Python", "JavaScript");
languages.forEach(lang -> System.out.println("I know " + lang));
// Output: I know Java
// Output: I know Python
// Output: I know JavaScript
// Method reference shorthand
languages.forEach(System.out::println);
// Output: Java
// Output: Python
// Output: JavaScript
// Map.forEach() - takes BiConsumer
Map ages = new HashMap<>();
ages.put("Alice", 30);
ages.put("Bob", 25);
ages.put("Charlie", 35);
ages.forEach((name, age) ->
System.out.println(name + " is " + age + " years old")
);
// Output: Alice is 30 years old
// Output: Bob is 25 years old
// Output: Charlie is 35 years old
}
}
These methods modify the collection in place using lambda predicates and functions.
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.List;
public class CollectionLambdaMethods {
public static void main(String[] args) {
// ===== removeIf(Predicate) - remove elements that match =====
List numbers = new ArrayList<>(Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10));
numbers.removeIf(n -> n % 2 == 0); // Remove even numbers
System.out.println("Odds only: " + numbers);
// Output: Odds only: [1, 3, 5, 7, 9]
// Remove empty strings
List items = new ArrayList<>(Arrays.asList("apple", "", "banana", "", "cherry"));
items.removeIf(String::isEmpty);
System.out.println("Non-empty: " + items);
// Output: Non-empty: [apple, banana, cherry]
// ===== replaceAll(UnaryOperator) - transform every element =====
List names = new ArrayList<>(Arrays.asList("alice", "bob", "charlie"));
names.replaceAll(String::toUpperCase);
System.out.println("Uppercased: " + names);
// Output: Uppercased: [ALICE, BOB, CHARLIE]
List prices = new ArrayList<>(Arrays.asList(10.0, 20.0, 30.0));
prices.replaceAll(p -> p * 1.1); // 10% price increase
System.out.println("After 10% increase: " + prices);
// Output: After 10% increase: [11.0, 22.0, 33.0]
// ===== sort(Comparator) - sort with lambda =====
List fruits = new ArrayList<>(Arrays.asList("banana", "apple", "cherry", "date"));
fruits.sort((a, b) -> a.compareTo(b));
System.out.println("Alphabetical: " + fruits);
// Output: Alphabetical: [apple, banana, cherry, date]
// Sort by length, then alphabetically
fruits.sort(Comparator.comparingInt(String::length).thenComparing(Comparator.naturalOrder()));
System.out.println("By length: " + fruits);
// Output: By length: [date, apple, banana, cherry]
// Reverse sort
fruits.sort(Comparator.reverseOrder());
System.out.println("Reverse: " + fruits);
// Output: Reverse: [date, cherry, banana, apple]
}
}
Java 8 added several powerful lambda-accepting methods to Map that simplify common patterns.
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
public class MapLambdaMethods {
public static void main(String[] args) {
// ===== computeIfAbsent(key, mappingFunction) =====
// If the key is absent, compute and insert the value
Map> groups = new HashMap<>();
// BEFORE Java 8:
// if (!groups.containsKey("fruits")) {
// groups.put("fruits", new ArrayList<>());
// }
// groups.get("fruits").add("apple");
// AFTER Java 8:
groups.computeIfAbsent("fruits", k -> new ArrayList<>()).add("apple");
groups.computeIfAbsent("fruits", k -> new ArrayList<>()).add("banana");
groups.computeIfAbsent("vegetables", k -> new ArrayList<>()).add("carrot");
System.out.println(groups);
// Output: {fruits=[apple, banana], vegetables=[carrot]}
// ===== merge(key, value, remappingFunction) =====
// Combine old and new values
Map wordCount = new HashMap<>();
String[] words = {"hello", "world", "hello", "java", "hello", "world"};
for (String word : words) {
wordCount.merge(word, 1, Integer::sum);
}
System.out.println("Word counts: " + wordCount);
// Output: Word counts: {java=1, world=2, hello=3}
// ===== getOrDefault(key, defaultValue) =====
Map config = new HashMap<>();
config.put("host", "localhost");
config.put("port", "8080");
String host = config.getOrDefault("host", "127.0.0.1");
String timeout = config.getOrDefault("timeout", "30");
System.out.println("Host: " + host + ", Timeout: " + timeout);
// Output: Host: localhost, Timeout: 30
// ===== replaceAll(BiFunction) =====
Map scores = new HashMap<>();
scores.put("Alice", 85);
scores.put("Bob", 92);
scores.put("Charlie", 78);
// Add 5 bonus points to everyone
scores.replaceAll((name, score) -> score + 5);
System.out.println("After bonus: " + scores);
// Output: After bonus: {Alice=90, Bob=97, Charlie=83}
// ===== compute(key, BiFunction) =====
Map inventory = new HashMap<>();
inventory.put("apples", 10);
inventory.compute("apples", (key, val) -> val == null ? 1 : val + 5);
inventory.compute("oranges", (key, val) -> val == null ? 1 : val + 5);
System.out.println("Inventory: " + inventory);
// Output: Inventory: {oranges=1, apples=15}
}
}
Lambdas have access to variables from the enclosing scope. Understanding exactly what they can and cannot access is critical for writing correct lambda code.
| Variable Type | Can Access? | Can Modify? | Notes |
|---|---|---|---|
| Local variables | Yes | No | Must be effectively final |
| Method parameters | Yes | No | Must be effectively final |
Instance fields (this.field) |
Yes | Yes | Captured via this reference |
| Static fields | Yes | Yes | Accessed directly |
import java.util.function.IntSupplier;
import java.util.function.Supplier;
public class LambdaScope {
private int instanceCounter = 0;
private static int staticCounter = 0;
public void demonstrateScope() {
int localVar = 10; // effectively final - never reassigned
// 1. Accessing local variable (must be effectively final)
Supplier getLocal = () -> localVar;
System.out.println("Local: " + getLocal.get());
// Output: Local: 10
// This would NOT compile:
// localVar = 20; // Makes localVar not effectively final
// Supplier broken = () -> localVar;
// 2. Accessing and MODIFYING instance fields - allowed
Runnable incrementInstance = () -> instanceCounter++;
incrementInstance.run();
incrementInstance.run();
System.out.println("Instance counter: " + instanceCounter);
// Output: Instance counter: 2
// 3. Accessing and MODIFYING static fields - allowed
Runnable incrementStatic = () -> staticCounter++;
incrementStatic.run();
incrementStatic.run();
incrementStatic.run();
System.out.println("Static counter: " + staticCounter);
// Output: Static counter: 3
}
public static void main(String[] args) {
new LambdaScope().demonstrateScope();
}
}
A critical difference between lambdas and anonymous classes is what this refers to. In a lambda, this refers to the enclosing class. In an anonymous class, this refers to the anonymous class itself.
public class ThisInLambda {
private String name = "Enclosing class";
public void demonstrate() {
// Lambda: 'this' refers to ThisInLambda instance
Runnable lambdaRunnable = () -> {
System.out.println("Lambda this: " + this.name);
System.out.println("Lambda this class: " + this.getClass().getSimpleName());
};
// Anonymous class: 'this' refers to the anonymous class instance
Runnable anonRunnable = new Runnable() {
@Override
public void run() {
// 'this' here refers to the anonymous Runnable
System.out.println("Anon this class: " + this.getClass().getSimpleName());
// To access the enclosing class, use EnclosingClass.this
System.out.println("Anon enclosing: " + ThisInLambda.this.name);
}
};
lambdaRunnable.run();
// Output: Lambda this: Enclosing class
// Output: Lambda this class: ThisInLambda
anonRunnable.run();
// Output: Anon this class: ThisInLambda$1
// Output: Anon enclosing: Enclosing class
}
public static void main(String[] args) {
new ThisInLambda().demonstrate();
}
}
If you need to accumulate state inside a lambda, you cannot use a plain int or String variable. Instead, use a mutable container like an array, AtomicInteger, or a custom holder object.
import java.util.Arrays;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
public class EffectivelyFinalWorkarounds {
public static void main(String[] args) {
List names = Arrays.asList("Alice", "Bob", "Charlie");
// Approach 1: Single-element array
int[] count1 = {0};
names.forEach(name -> count1[0]++);
System.out.println("Array counter: " + count1[0]);
// Output: Array counter: 3
// Approach 2: AtomicInteger (preferred, thread-safe)
AtomicInteger count2 = new AtomicInteger(0);
names.forEach(name -> count2.incrementAndGet());
System.out.println("Atomic counter: " + count2.get());
// Output: Atomic counter: 3
// Approach 3: Use streams instead (cleanest solution)
long count3 = names.stream().count();
System.out.println("Stream count: " + count3);
// Output: Stream count: 3
// Approach 4: StringBuilder for string accumulation
StringBuilder sb = new StringBuilder();
names.forEach(name -> sb.append(name).append(", "));
System.out.println("Accumulated: " + sb.toString());
// Output: Accumulated: Alice, Bob, Charlie,
}
}
Lambdas and anonymous classes both create implementations of interfaces on the fly. However, they differ in important ways beyond just syntax.
| Feature | Anonymous Class | Lambda Expression |
|---|---|---|
| Syntax | Verbose (class + method) | Concise (arrow notation) |
| Can implement | Any interface (including multi-method) or extend a class | Only functional interfaces (single abstract method) |
this keyword |
Refers to the anonymous class instance | Refers to the enclosing class instance |
| Has own state | Yes (can have fields) | No (stateless, captures from enclosing scope) |
| Compiled to | Separate .class file (Outer$1.class) |
invokedynamic bytecode (no extra class file) |
| Performance | New class loaded per instantiation | Lightweight, JVM can optimize via method handles |
| Serializable? | Yes (if implements Serializable) | Only with explicit cast |
| Shadowing | Can shadow enclosing scope variables | Cannot shadow (shares enclosing scope) |
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
public class AnonClassToLambda {
public static void main(String[] args) {
// ===== Example 1: Runnable =====
// Anonymous class
Runnable oldWay = new Runnable() {
@Override
public void run() {
System.out.println("Running the old way");
}
};
// Lambda
Runnable newWay = () -> System.out.println("Running the new way");
oldWay.run(); // Output: Running the old way
newWay.run(); // Output: Running the new way
// ===== Example 2: Comparator =====
List names = new ArrayList<>();
names.add("Charlie");
names.add("Alice");
names.add("Bob");
// Anonymous class
Collections.sort(names, new java.util.Comparator() {
@Override
public int compare(String a, String b) {
return a.length() - b.length();
}
});
System.out.println("Anonymous: " + names);
// Output: Anonymous: [Bob, Alice, Charlie]
// Lambda
Collections.sort(names, (a, b) -> a.length() - b.length());
System.out.println("Lambda: " + names);
// Output: Lambda: [Bob, Alice, Charlie]
// Method reference + Comparator factory (cleanest)
names.sort(java.util.Comparator.comparingInt(String::length));
System.out.println("Factory: " + names);
// Output: Factory: [Bob, Alice, Charlie]
// ===== Example 3: ActionListener (event handling) =====
// Anonymous class (Swing)
// button.addActionListener(new ActionListener() {
// @Override
// public void actionPerformed(ActionEvent e) {
// System.out.println("Button clicked!");
// }
// });
// Lambda
// button.addActionListener(e -> System.out.println("Button clicked!"));
// ===== Example 4: Thread =====
// Anonymous class
Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
System.out.println("Thread via anonymous class: " + Thread.currentThread().getName());
}
});
// Lambda
Thread t2 = new Thread(() ->
System.out.println("Thread via lambda: " + Thread.currentThread().getName())
);
t1.start();
t2.start();
// Output: Thread via anonymous class: Thread-0
// Output: Thread via lambda: Thread-1
}
}
Lambdas cannot replace anonymous classes in every situation. Use an anonymous class when:
this to refer to the anonymous instance itselfLambdas make the callback pattern clean and readable. Instead of creating a named class for each callback, define the behavior inline.
import java.util.function.Consumer;
public class CallbackPattern {
// Method that accepts a callback
static void fetchData(String url, Consumer onSuccess, Consumer onError) {
try {
// Simulate fetching data
if (url.startsWith("http")) {
String data = "{\"status\": \"ok\", \"source\": \"" + url + "\"}";
onSuccess.accept(data);
} else {
throw new IllegalArgumentException("Invalid URL: " + url);
}
} catch (Exception e) {
onError.accept(e.getMessage());
}
}
public static void main(String[] args) {
// Pass lambdas as callbacks
fetchData("http://api.example.com",
data -> System.out.println("Success: " + data),
error -> System.out.println("Error: " + error)
);
// Output: Success: {"status": "ok", "source": "http://api.example.com"}
fetchData("invalid-url",
data -> System.out.println("Success: " + data),
error -> System.out.println("Error: " + error)
);
// Output: Error: Invalid URL: invalid-url
}
}
The Strategy pattern defines a family of algorithms and lets you swap them at runtime. Before Java 8, each strategy was a separate class. With lambdas, you define strategies inline.
import java.util.Arrays;
import java.util.List;
import java.util.function.Predicate;
import java.util.stream.Collectors;
public class StrategyPattern {
// The "strategy" is just a Predicate
static List filter(List items, Predicate strategy) {
return items.stream().filter(strategy).collect(Collectors.toList());
}
public static void main(String[] args) {
List numbers = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
// Different strategies passed as lambdas
List evens = filter(numbers, n -> n % 2 == 0);
List odds = filter(numbers, n -> n % 2 != 0);
List greaterThan5 = filter(numbers, n -> n > 5);
System.out.println("Evens: " + evens);
// Output: Evens: [2, 4, 6, 8, 10]
System.out.println("Odds: " + odds);
// Output: Odds: [1, 3, 5, 7, 9]
System.out.println("Greater than 5: " + greaterThan5);
// Output: Greater than 5: [6, 7, 8, 9, 10]
// Compose strategies
Predicate evenAndGreaterThan5 = ((Predicate) n -> n % 2 == 0).and(n -> n > 5);
System.out.println("Even AND > 5: " + filter(numbers, evenAndGreaterThan5));
// Output: Even AND > 5: [6, 8, 10]
// Store strategies in variables for reuse
Predicate isShort = s -> s.length() <= 4;
Predicate startsWithJ = s -> s.startsWith("J");
List langs = Arrays.asList("Java", "Python", "JavaScript", "Go", "C", "Rust");
System.out.println("Short: " + filter(langs, isShort));
// Output: Short: [Java, Go, C, Rust]
System.out.println("Starts with J: " + filter(langs, startsWithJ));
// Output: Starts with J: [Java, JavaScript]
}
}
Lambdas enable lazy evaluation — the computation is not performed until the result is actually needed. This is useful for expensive operations where the result might not always be required.
import java.util.function.Supplier;
public class LazyEvaluation {
static String expensiveOperation() {
System.out.println(" [Computing expensive result...]");
// Simulate expensive computation
return "expensive result";
}
// Eager: always computes the default, even if not needed
static String getValueEager(String value, String defaultValue) {
return value != null ? value : defaultValue;
}
// Lazy: only computes default if value is null
static String getValueLazy(String value, Supplier defaultSupplier) {
return value != null ? value : defaultSupplier.get();
}
public static void main(String[] args) {
String cached = "cached result";
// Eager: expensiveOperation() is ALWAYS called
System.out.println("--- Eager (value exists) ---");
String eager = getValueEager(cached, expensiveOperation());
System.out.println("Result: " + eager);
// Output:
// --- Eager (value exists) ---
// [Computing expensive result...]
// Result: cached result
// Lazy: expensiveOperation() is NOT called when value exists
System.out.println("--- Lazy (value exists) ---");
String lazy = getValueLazy(cached, () -> expensiveOperation());
System.out.println("Result: " + lazy);
// Output:
// --- Lazy (value exists) ---
// Result: cached result
// Lazy: expensiveOperation() IS called when value is null
System.out.println("--- Lazy (value is null) ---");
String lazy2 = getValueLazy(null, () -> expensiveOperation());
System.out.println("Result: " + lazy2);
// Output:
// --- Lazy (value is null) ---
// [Computing expensive result...]
// Result: expensive result
}
}
While the built-in interfaces cover most cases, sometimes you need a custom one. Common scenarios: checked exceptions, three or more parameters, or domain-specific naming.
import java.io.IOException;
import java.util.function.Function;
public class CustomFunctionalInterfaces {
// Custom interface for operations that throw checked exceptions
@FunctionalInterface
interface ThrowingFunction {
R apply(T input) throws Exception;
// Convert to standard Function with exception wrapping
static Function unchecked(ThrowingFunction f) {
return input -> {
try {
return f.apply(input);
} catch (Exception e) {
throw new RuntimeException(e);
}
};
}
}
// Custom interface with three parameters
@FunctionalInterface
interface TriFunction {
R apply(A a, B b, C c);
}
// Domain-specific interface
@FunctionalInterface
interface Validator {
ValidationResult validate(T item);
}
record ValidationResult(boolean valid, String message) {
static ValidationResult ok() { return new ValidationResult(true, "Valid"); }
static ValidationResult error(String msg) { return new ValidationResult(false, msg); }
}
public static void main(String[] args) {
// ThrowingFunction: handle checked exceptions in lambdas
ThrowingFunction parse = Integer::parseInt;
Function safeParse = ThrowingFunction.unchecked(parse);
System.out.println("Parsed: " + safeParse.apply("42"));
// Output: Parsed: 42
// TriFunction: three inputs
TriFunction formatUser =
(first, last, age) -> first + " " + last + " (age " + age + ")";
System.out.println(formatUser.apply("John", "Doe", 30));
// Output: John Doe (age 30)
// Domain validator
Validator emailValidator = email -> {
if (email == null || email.isEmpty()) {
return ValidationResult.error("Email cannot be empty");
}
if (!email.contains("@")) {
return ValidationResult.error("Email must contain @");
}
return ValidationResult.ok();
};
System.out.println(emailValidator.validate("user@example.com"));
// Output: ValidationResult[valid=true, message=Valid]
System.out.println(emailValidator.validate("bad-email"));
// Output: ValidationResult[valid=false, message=Email must contain @]
}
}
Lambdas dramatically simplify GUI and event-driven code where you previously needed verbose anonymous classes for every event handler.
import java.util.ArrayList;
import java.util.List;
import java.util.function.Consumer;
public class EventListenerPattern {
// Simple event system using lambdas
static class EventEmitter {
private final List> listeners = new ArrayList<>();
void on(Consumer listener) {
listeners.add(listener);
}
void emit(T event) {
listeners.forEach(listener -> listener.accept(event));
}
}
public static void main(String[] args) {
// Create an emitter for String events
EventEmitter userEvents = new EventEmitter<>();
// Register listeners with lambdas
userEvents.on(event -> System.out.println("[Logger] " + event));
userEvents.on(event -> System.out.println("[Analytics] Tracking: " + event));
userEvents.on(event -> {
if (event.contains("signup")) {
System.out.println("[Email] Sending welcome email");
}
});
// Emit events
userEvents.emit("user.signup");
// Output: [Logger] user.signup
// Output: [Analytics] Tracking: user.signup
// Output: [Email] Sending welcome email
System.out.println();
userEvents.emit("user.login");
// Output: [Logger] user.login
// Output: [Analytics] Tracking: user.login
}
}
Writing good lambda code requires discipline. These best practices come from real-world Java 8+ production codebases.
If a lambda exceeds 3-4 lines, extract it into a named method and use a method reference. Long lambdas defeat the readability purpose.
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
public class KeepLambdasShort {
// BAD: Lambda is too long -- hard to read inline
static List processNamesBad(List names) {
return names.stream()
.filter(name -> {
if (name == null || name.isEmpty()) return false;
if (name.length() < 2) return false;
if (!Character.isUpperCase(name.charAt(0))) return false;
return true;
})
.collect(Collectors.toList());
}
// GOOD: Extract to a named method, use method reference
static boolean isValidName(String name) {
if (name == null || name.isEmpty()) return false;
if (name.length() < 2) return false;
if (!Character.isUpperCase(name.charAt(0))) return false;
return true;
}
static List processNamesGood(List names) {
return names.stream()
.filter(KeepLambdasShort::isValidName)
.collect(Collectors.toList());
}
public static void main(String[] args) {
List names = Arrays.asList("Alice", "", "b", "Charlie", null, "David");
System.out.println(processNamesGood(names));
// Output: [Alice, Charlie, David]
}
}
If your lambda simply calls an existing method, use a method reference instead. It is shorter and communicates intent more clearly.
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
public class MethodReferencePreference {
public static void main(String[] args) {
List names = Arrays.asList("Alice", "Bob", "Charlie");
// Lambda (ok, but wordy)
names.forEach(name -> System.out.println(name));
// Method reference (better)
names.forEach(System.out::println);
// Lambda
List upper1 = names.stream()
.map(s -> s.toUpperCase())
.collect(Collectors.toList());
// Method reference (better)
List upper2 = names.stream()
.map(String::toUpperCase)
.collect(Collectors.toList());
// Lambda
List lengths1 = names.stream()
.map(s -> s.length())
.collect(Collectors.toList());
// Method reference (better)
List lengths2 = names.stream()
.map(String::length)
.collect(Collectors.toList());
System.out.println(upper2);
// Output: [ALICE, BOB, CHARLIE]
System.out.println(lengths2);
// Output: [5, 3, 7]
}
}
Lambdas used in streams and functional-style code should be pure functions — they should produce a result based on input without modifying external state. Side effects in lambdas make code harder to reason about and can cause bugs in parallel streams.
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
public class AvoidSideEffects {
public static void main(String[] args) {
List names = Arrays.asList("Alice", "Bob", "Charlie");
// BAD: Side effect inside a lambda (modifying external list)
List resultsBad = new java.util.ArrayList<>();
names.stream()
.filter(n -> n.length() > 3)
.forEach(n -> resultsBad.add(n)); // Side effect!
System.out.println("Bad approach: " + resultsBad);
// Output: Bad approach: [Alice, Charlie]
// GOOD: Pure functional style (collect result)
List resultsGood = names.stream()
.filter(n -> n.length() > 3)
.collect(Collectors.toList());
System.out.println("Good approach: " + resultsGood);
// Output: Good approach: [Alice, Charlie]
// BAD: Side effect modifying counter (NOT thread-safe with parallel streams)
int[] count = {0};
names.parallelStream().forEach(n -> count[0]++); // Race condition!
// GOOD: Use stream operations
long goodCount = names.stream().count();
System.out.println("Count: " + goodCount);
// Output: Count: 3
}
}
Single-letter parameter names are fine for simple lambdas, but use descriptive names when the context is not obvious.
import java.util.Arrays;
import java.util.Comparator;
import java.util.List;
import java.util.stream.Collectors;
public class MeaningfulParamNames {
public static void main(String[] args) {
// OK for simple cases - single letter is fine
List numbers = Arrays.asList(1, 2, 3, 4, 5);
numbers.stream().filter(n -> n > 3).forEach(System.out::println);
// BAD: unclear what a, b, c represent
// (a, b) -> a.getLastName().compareTo(b.getLastName())
// GOOD: descriptive names make the comparison obvious
// (employee1, employee2) -> employee1.getLastName().compareTo(employee2.getLastName())
// Practical example with meaningful names
List emails = Arrays.asList(
"alice@company.com", "bob@gmail.com",
"charlie@company.com", "diana@yahoo.com"
);
// BAD: what does 'e' represent?
List bad = emails.stream().filter(e -> e.endsWith("@company.com"))
.collect(Collectors.toList());
// GOOD: clear meaning
List companyEmails = emails.stream()
.filter(email -> email.endsWith("@company.com"))
.collect(Collectors.toList());
System.out.println("Company emails: " + companyEmails);
// Output: Company emails: [alice@company.com, charlie@company.com]
}
}
| Practice | Do | Don’t |
|---|---|---|
| Length | Keep lambdas to 1-3 lines | Write 10+ line lambdas inline |
| Method references | String::toUpperCase |
s -> s.toUpperCase() |
| Side effects | .collect(Collectors.toList()) |
.forEach(list::add) |
| Parameter names | employee -> employee.getSalary() |
e -> e.getSalary() in complex contexts |
| Type declarations | Let the compiler infer types | (String s) -> s.length() when unnecessary |
| Parentheses | x -> x * 2 for single param |
(x) -> x * 2 (unnecessary parens) |
| Return statement | x -> x + 1 |
x -> { return x + 1; } for single expressions |
| Exception handling | Use custom functional interfaces for checked exceptions | Wrap lambdas in try/catch making them unreadable |
| Reusable logic | Store as named Predicate/Function variables |
Duplicate same lambda across multiple places |
Let us build an Employee Analytics System that uses lambdas throughout — every concept covered in this tutorial appears in this example. This is the kind of code you would write in real production systems when processing business data.
import java.util.*;
import java.util.function.*;
import java.util.stream.Collectors;
public class EmployeeAnalytics {
// Simple Employee record
static class Employee {
String name;
String department;
double salary;
int yearsOfService;
Employee(String name, String department, double salary, int yearsOfService) {
this.name = name;
this.department = department;
this.salary = salary;
this.yearsOfService = yearsOfService;
}
@Override
public String toString() {
return String.format("%s (%s, $%.0f, %dy)", name, department, salary, yearsOfService);
}
}
// Custom functional interface for business rules
@FunctionalInterface
interface SalaryAdjuster {
double adjust(Employee employee);
}
public static void main(String[] args) {
// ===== Setup: Create employee list =====
List employees = new ArrayList<>(Arrays.asList(
new Employee("Alice", "Engineering", 95000, 5),
new Employee("Bob", "Engineering", 88000, 3),
new Employee("Charlie", "Marketing", 72000, 7),
new Employee("Diana", "Engineering", 105000, 8),
new Employee("Eve", "Marketing", 68000, 2),
new Employee("Frank", "Sales", 78000, 4),
new Employee("Grace", "Sales", 82000, 6),
new Employee("Hank", "Marketing", 91000, 10)
));
// ===== 1. Predicate: Filter employees =====
Predicate isSenior = emp -> emp.yearsOfService >= 5;
Predicate isHighEarner = emp -> emp.salary > 85000;
Predicate isEngineering = emp -> emp.department.equals("Engineering");
// Composed predicate: senior engineers earning > 85k
Predicate seniorHighEarningEngineers =
isSenior.and(isHighEarner).and(isEngineering);
List elite = employees.stream()
.filter(seniorHighEarningEngineers)
.collect(Collectors.toList());
System.out.println("=== Senior high-earning engineers ===");
elite.forEach(System.out::println);
// Output: Alice (Engineering, $95000, 5y)
// Output: Diana (Engineering, $105000, 8y)
// ===== 2. Function: Transform employees =====
Function toSummary = emp ->
emp.name + " - " + emp.department;
List summaries = employees.stream()
.map(toSummary)
.collect(Collectors.toList());
System.out.println("\n=== Employee summaries ===");
summaries.forEach(System.out::println);
// Output: Alice - Engineering
// Output: Bob - Engineering
// Output: Charlie - Marketing
// ... (all 8)
// ===== 3. Consumer: Process employees =====
Consumer printBonus = emp -> {
double bonus = emp.salary * 0.10;
System.out.printf(" %s gets $%.0f bonus%n", emp.name, bonus);
};
System.out.println("\n=== Year-end bonuses (senior employees) ===");
employees.stream()
.filter(isSenior)
.forEach(printBonus);
// Output: Alice gets $9500 bonus
// Output: Charlie gets $7200 bonus
// Output: Diana gets $10500 bonus
// Output: Grace gets $8200 bonus
// Output: Hank gets $9100 bonus
// ===== 4. Supplier: Factory and defaults =====
Supplier defaultEmployee = () ->
new Employee("New Hire", "Unassigned", 50000, 0);
Employee newHire = defaultEmployee.get();
System.out.println("\n=== Default employee ===");
System.out.println(newHire);
// Output: New Hire (Unassigned, $50000, 0y)
// ===== 5. BinaryOperator: Reduce =====
BinaryOperator sum = Double::sum;
double totalSalary = employees.stream()
.map(emp -> emp.salary)
.reduce(0.0, sum);
System.out.printf("%n=== Total salary: $%.0f ===%n", totalSalary);
// Output: === Total salary: $679000 ===
// ===== 6. Comparator with lambdas =====
employees.sort(Comparator.comparingDouble((Employee emp) -> emp.salary).reversed());
System.out.println("\n=== Employees by salary (highest first) ===");
employees.forEach(emp -> System.out.printf(" %-10s $%.0f%n", emp.name, emp.salary));
// Output: Diana $105000
// Output: Alice $95000
// Output: Hank $91000
// Output: Bob $88000
// Output: Grace $82000
// Output: Frank $78000
// Output: Charlie $72000
// Output: Eve $68000
// ===== 7. Map operations with lambdas =====
Map> byDepartment = new HashMap<>();
employees.forEach(emp ->
byDepartment.computeIfAbsent(emp.department, k -> new ArrayList<>()).add(emp)
);
System.out.println("\n=== Employees by department ===");
byDepartment.forEach((dept, emps) -> {
System.out.println(dept + ":");
emps.forEach(emp -> System.out.println(" - " + emp.name));
});
// Output: Engineering:
// Output: - Diana
// Output: - Alice
// Output: - Bob
// Output: Marketing:
// Output: - Hank
// Output: - Charlie
// Output: - Eve
// Output: Sales:
// Output: - Grace
// Output: - Frank
// ===== 8. Department salary totals using merge =====
Map deptSalaries = new HashMap<>();
employees.forEach(emp ->
deptSalaries.merge(emp.department, emp.salary, Double::sum)
);
System.out.println("\n=== Department salary totals ===");
deptSalaries.forEach((dept, total) ->
System.out.printf(" %-15s $%.0f%n", dept, total)
);
// Output: Engineering $288000
// Output: Marketing $231000
// Output: Sales $160000
// ===== 9. Custom functional interface: salary adjustments =====
SalaryAdjuster standardRaise = emp -> emp.salary * 1.05;
SalaryAdjuster seniorRaise = emp -> emp.yearsOfService >= 5
? emp.salary * 1.10
: emp.salary * 1.03;
SalaryAdjuster departmentRaise = emp -> {
switch (emp.department) {
case "Engineering": return emp.salary * 1.08;
case "Sales": return emp.salary * 1.06;
default: return emp.salary * 1.04;
}
};
System.out.println("\n=== Salary adjustments (department-based) ===");
employees.forEach(emp -> {
double newSalary = departmentRaise.adjust(emp);
System.out.printf(" %-10s $%.0f -> $%.0f%n", emp.name, emp.salary, newSalary);
});
// Output: Diana $105000 -> $113400
// Output: Alice $95000 -> $102600
// Output: Bob $88000 -> $95040
// Output: Hank $91000 -> $94640
// Output: Charlie $72000 -> $74880
// Output: Eve $68000 -> $70720
// Output: Grace $82000 -> $86920
// Output: Frank $78000 -> $82680
// ===== 10. removeIf and replaceAll =====
List departments = new ArrayList<>(
Arrays.asList("Engineering", "Marketing", "Sales", "Legal", "HR")
);
departments.removeIf(dept ->
!byDepartment.containsKey(dept) // Remove departments with no employees
);
departments.replaceAll(dept ->
dept + " (" + byDepartment.get(dept).size() + " people)"
);
System.out.println("\n=== Active departments ===");
departments.forEach(System.out::println);
// Output: Engineering (3 people)
// Output: Marketing (3 people)
// Output: Sales (2 people)
}
}
| # | Concept | Where Used |
|---|---|---|
| 1 | Predicate (test, and, or, negate) | Filtering employees by seniority, salary, department |
| 2 | Function (apply, andThen) | Transforming employees to summary strings |
| 3 | Consumer (accept, andThen) | Printing bonus calculations |
| 4 | Supplier (get) | Factory for default employee |
| 5 | BinaryOperator (reduce) | Summing total salary |
| 6 | Comparator with lambdas | Sorting by salary descending |
| 7 | Map.computeIfAbsent() | Grouping employees by department |
| 8 | Map.merge() | Department salary totals |
| 9 | Custom functional interface | SalaryAdjuster with different strategies |
| 10 | removeIf() and replaceAll() | Filtering and transforming department list |
| 11 | forEach() on List and Map | Iterating and printing throughout |
| 12 | Method references | System.out::println, Double::sum |
| 13 | Strategy pattern | Multiple SalaryAdjuster strategies |
| 14 | Effectively final | Lambda captures in forEach blocks |
| Topic | Key Point |
|---|---|
| Lambda syntax | (params) -> expression or (params) -> { statements; } |
| Type inference | Compiler infers param types from context (target typing) |
| Effectively final | Local variables captured by lambdas must never be reassigned |
Predicate<T> |
T -> boolean. Composable with and(), or(), negate() |
Function<T,R> |
T -> R. Chain with andThen(), compose() |
Consumer<T> |
T -> void. Side effects. Chain with andThen() |
Supplier<T> |
() -> T. Factories, lazy defaults |
BiFunction<T,U,R> |
(T, U) -> R. Two inputs, one output |
UnaryOperator<T> |
T -> T. Same-type transformation. Used by replaceAll() |
BinaryOperator<T> |
(T, T) -> T. Used by reduce(), merge() |
this in lambdas |
Refers to the enclosing class (not the lambda itself) |
| Lambda vs anonymous class | Lambda: invokedynamic, no class file, shares enclosing scope |
| forEach() | Iterable method. Takes Consumer. Use for simple iteration |
| removeIf() | Collection method. Takes Predicate. Removes matching elements |
| replaceAll() | List method. Takes UnaryOperator. Transforms every element |
| computeIfAbsent() | Map method. Initializes missing keys with lambda factory |
| merge() | Map method. Combines old and new values with BiFunction |
| Best practice | Keep short, use method references, avoid side effects, name params |