Functional Programming in Java
Java has always been object-oriented. In Java 8, it gained a second paradigm: functional programming. Understanding why it was added — and what problem it solves — is the foundation for everything that follows.
The Problem with Anonymous Classes
Before Java 8, passing behavior around required anonymous inner classes. The intent was buried in ceremony:
List<String> names = Arrays.asList("Charlie", "Alice", "Bob"); // The intent: sort by length. // What you had to write: Collections.sort(names, new Comparator<String>() { @Override public int compare(String a, String b) { return Integer.compare(a.length(), b.length()); } }); // With Java 8 lambdas — same intent, no ceremony: names.sort((a, b) -> Integer.compare(a.length(), b.length())); // Or even cleaner with a method reference: names.sort(Comparator.comparingInt(String::length));
Two Programming Paradigms
Models the world as objects with state and behavior. Focuses on what things are. Classes, encapsulation, inheritance.
Models programs as transformations of data. Focuses on what to do with data. Functions, immutability, composition.
Java doesn't force you to choose — you can (and should) use both. OOP for modeling your domain, FP for processing data collections. They complement each other perfectly.
Core Functional Principles in Java
1. Functions as Values
In FP, functions are first-class citizens — you can store them in variables, pass them to methods, and return them from methods. Java achieves this through lambdas and functional interfaces.
// Store a function in a variable Predicate<String> isLong = s -> s.length() > 5; // Pass it to a method List<String> longNames = names.stream() .filter(isLong) // function passed as argument .toList(); // Compose functions Predicate<String> isShort = isLong.negate(); Predicate<String> startsWithA = s -> s.startsWith("A"); Predicate<String> shortAndA = isShort.and(startsWithA);
2. Immutability
Functional code avoids mutating state. Instead of modifying a list, you produce a new one. Streams embody this — operations on a stream never modify the source collection.
3. Pure Functions
A pure function's output depends only on its inputs — no side effects, no reading global state. Pure lambdas are easier to test, reason about, and parallelize.
Stream pipelines appear in virtually every Java codebase. Interviewers frequently ask you to rewrite imperative loops as stream operations. This is not optional knowledge for a junior Java dev.
Lambda Expressions
A lambda is a concise way to represent an anonymous function — a block of code you can pass around like data. Mastering the syntax variants and understanding capture rules will make you fluent in modern Java.
Lambda Syntax
Lambdas have three parts: parameters, the arrow ->, and a body. The body can be a single expression or a block.
// Form 1: No parameters Runnable r = () -> System.out.println("Running!"); // Form 2: Single parameter (parens optional) Consumer<String> print = s -> System.out.println(s); // Form 3: Multiple parameters Comparator<Integer> cmp = (a, b) -> a - b; // Form 4: Block body (multiple statements, explicit return) Function<String, String> format = name -> { String trimmed = name.trim(); return trimmed.substring(0, 1).toUpperCase() + trimmed.substring(1); }; // Form 5: With explicit types (rarely needed — compiler infers) BiFunction<Integer, Integer, Integer> add = (Integer x, Integer y) -> x + y;
The compiler infers parameter types from context. If the target type is Predicate<String>, the compiler knows the lambda takes a String. You almost never need to write the types explicitly.
Variable Capture
Lambdas can reference variables from the enclosing scope — but only if those variables are effectively final (never reassigned after initialization).
int threshold = 5; // effectively final — never reassigned Predicate<String> longEnough = s -> s.length() > threshold; // ✅ OK threshold = 10; // now reassigned — lambda above would fail to compile // ❌ This does NOT compile: int count = 0; list.forEach(s -> count++); // ERROR: count is not effectively final // ✅ Use AtomicInteger for mutable counters in lambdas: AtomicInteger count = new AtomicInteger(0); list.forEach(s -> count.incrementAndGet());
this in Lambdas vs Anonymous Classes
public class MyClass { private String name = "MyClass"; void demo() { // Anonymous class: 'this' refers to the anonymous class instance Runnable anon = new Runnable() { public void run() { System.out.println(this); // prints anonymous class } }; // Lambda: 'this' refers to the ENCLOSING class (MyClass) Runnable lam = () -> System.out.println(this.name); // "MyClass" } }
Runnable r = () -> System.out.println("ok");Predicate<String> p = s -> s.isEmpty();int x = 5; x++; Predicate<Integer> p = n -> n > x;BiFunction<Integer,Integer,Integer> f = (a,b) -> a + b;Functional Interfaces
A functional interface is any interface with exactly one abstract method. It's the type system's way of representing a function. Java ships with a rich set of them in java.util.function — learn the core four and you'll handle 90% of cases.
The Core Four
| Interface | Shape | Use for | Example |
|---|---|---|---|
| Predicate<T> | T → boolean | Testing / filtering | s -> s.length() > 3 |
| Function<T,R> | T → R | Transforming | s -> s.toUpperCase() |
| Consumer<T> | T → void | Side effects | s -> System.out.println(s) |
| Supplier<T> | () → T | Producing values | () -> new ArrayList<>() |
// Predicate — test a condition Predicate<Integer> isEven = n -> n % 2 == 0; isEven.test(4); // true isEven.test(7); // false // Function — transform a value Function<String, Integer> strLen = String::length; strLen.apply("hello"); // 5 // Consumer — do something, return nothing Consumer<String> logger = msg -> System.out.println("[LOG] " + msg); logger.accept("Started"); // prints [LOG] Started // Supplier — produce a value from nothing Supplier<List<String>> listFactory = ArrayList::new; List<String> list = listFactory.get(); // fresh list each call
Composing Functions
Functional interfaces include default methods for composition — building complex transformations from simple pieces.
Function<String, String> trim = String::trim; Function<String, String> upper = String::toUpperCase; // andThen: trim first, THEN uppercase Function<String, String> clean = trim.andThen(upper); clean.apply(" hello "); // "HELLO" // Predicate composition Predicate<String> notEmpty = s -> !s.isEmpty(); Predicate<String> notBlank = s -> !s.isBlank(); Predicate<String> valid = notEmpty.and(notBlank); // UnaryOperator — Function<T,T> shorthand UnaryOperator<Integer> doubleIt = n -> n * 2; UnaryOperator<Integer> addTen = n -> n + 10; UnaryOperator<Integer> doubleThenAdd = doubleIt.andThen(addTen); doubleThenAdd.apply(5); // (5*2)+10 = 20
Creating Your Own Functional Interface
@FunctionalInterface // optional but documents intent; compiler enforces 1 abstract method public interface TriFunction<A, B, C, R> { R apply(A a, B b, C c); } TriFunction<Integer, Integer, Integer, Integer> clamp = (val, min, max) -> Math.min(Math.max(val, min), max); clamp.apply(150, 0, 100); // 100
Method References
Method references are shorthand for lambdas that do nothing but call an existing method. When a lambda is just x -> SomeClass.someMethod(x), you can write SomeClass::someMethod instead — cleaner signal, same behavior.
Four Kinds of Method References
| Kind | Syntax | Equivalent Lambda |
|---|---|---|
| Static method | ClassName::staticMethod | x -> ClassName.staticMethod(x) |
| Instance (specific object) | obj::instanceMethod | x -> obj.instanceMethod(x) |
| Instance (arbitrary object) | ClassName::instanceMethod | x -> x.instanceMethod() |
| Constructor | ClassName::new | x -> new ClassName(x) |
// 1. Static method reference Function<String, Integer> parseInt = Integer::parseInt; parseInt.apply("42"); // 42 // 2. Bound instance method reference (specific object) String prefix = "Hello, "; Function<String, String> greet = prefix::concat; greet.apply("World"); // "Hello, World" // 3. Unbound instance method reference (applied to argument) Function<String, String> toUpper = String::toUpperCase; toUpper.apply("java"); // "JAVA" // 4. Constructor reference Function<String, StringBuilder> sbFactory = StringBuilder::new; StringBuilder sb = sbFactory.apply("initial"); // In streams — this is where they really shine List<String> names = List.of("alice", "bob", "charlie"); names.stream() .map(String::toUpperCase) // unbound instance .forEach(System.out::println); // bound to System.out
Use a method reference when the lambda body is only a method call with no extra logic. If you need to transform the argument, combine calls, or add a condition — write a lambda. Clarity wins over brevity.
Rewrite each lambda below as a method reference where possible:
list.stream().map(s -> s.trim()).collect(toList()); list.stream().filter(s -> !s.isEmpty()).collect(toList()); list.stream().forEach(s -> System.out.println(s)); list.stream().map(s -> new StringBuilder(s)).collect(toList()); list.stream().map(s -> Integer.parseInt(s)).collect(toList());
s -> s.toUpperCase()?What is a Stream?
A Stream is a sequence of elements supporting sequential and parallel aggregate operations. It's not a data structure — it doesn't store elements. It's a pipeline: data flows through it, gets transformed, and produces a result.
The Pipeline Model
Every stream pipeline has three parts: a source, zero or more intermediate operations, and a terminal operation.
Creating Streams
// From a Collection Stream<String> s1 = names.stream(); // From values directly Stream<String> s2 = Stream.of("a", "b", "c"); // From an array Stream<String> s3 = Arrays.stream(namesArray); // Infinite stream — generates values on demand Stream<Integer> naturals = Stream.iterate(1, n -> n + 1); // Range of integers (use IntStream for primitives — no boxing) IntStream range = IntStream.rangeClosed(1, 10); // 1 to 10 inclusive // From a file Stream<String> lines = Files.lines(Path.of("data.txt")); // remember to close!
Laziness — The Key Insight
Intermediate operations are lazy: they don't execute until a terminal operation is called. This enables optimization — Java can short-circuit, fuse operations, and avoid processing elements that won't appear in the result.
Stream.of("Alice", "Bob", "Charlie", "Dave") .filter(s -> { System.out.println("filter: " + s); return s.length() > 3; }) .map(s -> { System.out.println("map: " + s); return s.toUpperCase(); }) .findFirst(); // terminal operation // Output: filter: Alice → map: Alice → (done — found it!) // Bob/Charlie/Dave are never even processed
Once a terminal operation is called, the stream is consumed and cannot be reused. Attempting to operate on a consumed stream throws IllegalStateException. If you need to process the same data twice, call .stream() again on the collection.
.filter() on a stream without a terminal operation?Intermediate Operations
Intermediate operations transform a stream into another stream. They're the building blocks of your pipeline — chained together, they describe what to do with each element before the terminal operation collects the result.
The Essential Operations
| Operation | What it does | Returns |
|---|---|---|
| filter(Predicate) | Keep elements matching the predicate | Stream<T> |
| map(Function) | Transform each element T → R | Stream<R> |
| sorted() | Natural order sort | Stream<T> |
| sorted(Comparator) | Custom order sort | Stream<T> |
| distinct() | Remove duplicates (uses equals) | Stream<T> |
| limit(n) | Keep first n elements | Stream<T> |
| skip(n) | Skip first n elements | Stream<T> |
| peek(Consumer) | Observe elements without modifying (debugging) | Stream<T> |
| mapToInt/Long/Double | Convert to primitive stream (avoids boxing) | IntStream etc. |
List<String> words = List.of("stream", "lambda", "java", "stream", "api"); List<String> result = words.stream() .filter(w -> w.length() > 3) // ["stream","lambda","java","stream"] .distinct() // ["stream","lambda","java"] .sorted() // ["java","lambda","stream"] .map(String::toUpperCase) // ["JAVA","LAMBDA","STREAM"] .limit(2) // ["JAVA","LAMBDA"] .collect(Collectors.toList()); // peek() for debugging — inspect without changing the stream List<Integer> lengths = words.stream() .filter(w -> w.length() > 3) .peek(w -> System.out.println("After filter: " + w)) .map(String::length) .peek(n -> System.out.println("After map: " + n)) .collect(Collectors.toList());
map vs mapToInt — Avoid Boxing
Using map(String::length) produces a Stream<Integer> — boxed integers. For numeric processing, use primitive streams to skip the boxing overhead:
// Stream<Integer> — boxes each int (slow for large data) int total = words.stream() .map(String::length) // Stream<Integer> .reduce(0, Integer::sum); // IntStream — no boxing (fast) int total = words.stream() .mapToInt(String::length) // IntStream .sum(); // IntStream has sum(), average(), min(), max() built in // IntStream statistics IntSummaryStatistics stats = words.stream() .mapToInt(String::length) .summaryStatistics(); stats.getAverage(); // 4.8 stats.getMax(); // 6
Put filter() before map() when possible — it reduces the number of elements that need transforming. Put limit() early for infinite streams. sorted() and distinct() are stateful and require seeing all elements, so they're inherently expensive on large streams.
peek() do to the stream elements?mapToInt() over map() for numeric computations?Terminal Operations
Terminal operations trigger the pipeline. They consume the stream, produce a result (or a side effect), and leave the stream closed. Picking the right terminal operation is as important as building the right pipeline.
Reduction Operations
List<Integer> nums = List.of(1, 2, 3, 4, 5); // reduce(identity, accumulator) int sum = nums.stream().reduce(0, (acc, n) -> acc + n); // 15 int product = nums.stream().reduce(1, (acc, n) -> acc * n); // 120 // Without identity — returns Optional (stream might be empty) Optional<Integer> max = nums.stream().reduce(Integer::max); // Shorthand methods on IntStream nums.stream().mapToInt(Integer::intValue).sum(); nums.stream().mapToInt(Integer::intValue).average(); // OptionalDouble nums.stream().mapToInt(Integer::intValue).min(); nums.stream().mapToInt(Integer::intValue).max();
Finding & Matching
List<String> names = List.of("Alice", "Bob", "Charlie"); // findFirst — returns first element matching pipeline (Optional) Optional<String> first = names.stream() .filter(s -> s.startsWith("C")) .findFirst(); // Optional["Charlie"] // anyMatch / allMatch / noneMatch — return boolean boolean anyLong = names.stream().anyMatch(s -> s.length() > 4); // true boolean allShort = names.stream().allMatch(s -> s.length() < 10); // true boolean noneNum = names.stream().noneMatch(s -> s.matches("\\d+")); // true // count — number of elements after pipeline long longNames = names.stream().filter(s -> s.length() > 3).count(); // 2
forEach
// Good: printing, logging, storing to external system names.stream() .filter(s -> s.length() > 3) .forEach(System.out::println); // ❌ Bad: modifying a collection inside forEach (side-effect anti-pattern) List<String> result = new ArrayList<>(); names.stream() .filter(s -> s.length() > 3) .forEach(result::add); // works but defeats the purpose — use collect instead // ✅ Good: use collect for building collections List<String> result = names.stream() .filter(s -> s.length() > 3) .collect(Collectors.toList());
reduce(accumulator) without an identity return Optional?names.stream().filter(s -> s.startsWith("Z")).count() > 0names.stream().anyMatch(s -> s.startsWith("Z"))names.stream().filter(s -> s.startsWith("Z")).findFirst().isPresent()Collectors in Depth
collect() is the most versatile terminal operation. The Collectors utility class provides ready-made collectors for lists, sets, maps, grouping, partitioning, joining, and more. This is where streams become truly powerful.
Basic Collectors
import static java.util.stream.Collectors.*; List<String> names = List.of("Alice", "Bob", "Alice", "Charlie"); // toList() — ordered, allows duplicates List<String> list = names.stream().collect(toList()); // toSet() — no duplicates, unordered Set<String> set = names.stream().collect(toSet()); // {"Alice","Bob","Charlie"} // joining() — concatenate strings String csv = names.stream().collect(joining(", ")); // "Alice, Bob, Alice, Charlie" String fmt = names.stream().collect(joining(", ", "[", "]")); // "[Alice, Bob, Alice, Charlie]" // counting() — count elements long n = names.stream().collect(counting()); // 4 // Java 16+: toList() as terminal op directly (immutable) List<String> immutable = names.stream().filter(s -> s.length() > 3).toList();
groupingBy — The Power Move
List<String> words = List.of("cat", "car", "dog", "deer", "duck"); // Group by first letter → Map<Character, List<String>> Map<Character, List<String>> byLetter = words.stream() .collect(groupingBy(s -> s.charAt(0))); // {c=[cat,car], d=[dog,deer,duck]} // Group and count instead of listing Map<Character, Long> countByLetter = words.stream() .collect(groupingBy(s -> s.charAt(0), counting())); // {c=2, d=3} // Group employees by department, collect to names only Map<String, List<String>> namesByDept = employees.stream() .collect(groupingBy( Employee::getDepartment, mapping(Employee::getName, toList()) ));
partitioningBy & toMap
// partitioningBy — special groupingBy for boolean split Map<Boolean, List<Integer>> evenOdd = nums.stream() .collect(partitioningBy(n -> n % 2 == 0)); // {true=[2,4], false=[1,3,5]} // toMap — build a Map from stream elements Map<String, Integer> wordLengths = words.stream() .collect(toMap( w -> w, // key: the word itself String::length // value: its length )); // {cat=3, car=3, dog=3, deer=4, duck=4} // toMap with merge function (handles duplicate keys) Map<Integer, String> lengthToWords = words.stream() .collect(toMap( String::length, w -> w, (existing, newVal) -> existing + "," + newVal // merge on collision ));
Given a List<Employee> where Employee has name, department, salary, and active fields, write stream pipelines to produce:
- The average salary per department:
Map<String, Double> - The highest-paid active employee's name
- A comma-separated list of all active employees sorted by salary descending
- Count of employees per department, sorted by count descending
flatMap & Optional Streams
flatMap is the operation that trips up most developers learning streams. Once it clicks, you'll reach for it constantly. It solves the "stream of streams" problem — flattening nested structures into a single stream.
map vs flatMap
List<String> sentences = List.of("hello world", "java streams"); // map gives Stream<String[]> — nested, not useful sentences.stream().map(s -> s.split(" ")); // Stream<String[]> // flatMap flattens to Stream<String> sentences.stream() .flatMap(s -> Arrays.stream(s.split(" "))) .distinct() .sorted() .forEach(System.out::println); // hello, java, streams, world // Real-world: orders with multiple items List<Order> orders = getOrders(); List<Item> allItems = orders.stream() .flatMap(order -> order.getItems().stream()) // Order → Stream<Item> .collect(Collectors.toList()); // Get all unique tags across all blog posts Set<String> allTags = posts.stream() .flatMap(post -> post.getTags().stream()) .collect(Collectors.toSet());
Optional with Streams
// Optional.stream() converts Optional to 0 or 1 element stream // Super useful with flatMap to filter out empty Optionals: List<String> ids = List.of("1", "bad", "3", "4"); List<Integer> validIds = ids.stream() .map(MyUtils::tryParseInt) // returns Optional<Integer> .flatMap(Optional::stream) // empty Optionals disappear, present ones unwrap .collect(Collectors.toList()); // [1, 3, 4] — "bad" silently dropped // Helper method static Optional<Integer> tryParseInt(String s) { try { return Optional.of(Integer.parseInt(s)); } catch (NumberFormatException e) { return Optional.empty(); } }
List<List<Integer>> matrix. How do you get a flat List<Integer> of all values?matrix.stream().map(List::stream).collect(toList())matrix.stream().flatMap(List::stream).collect(toList())matrix.stream().flatten().collect(toList())matrix.stream().reduce(List::addAll).get()Parallel Streams & Performance
Parallel streams split your data across CPU cores and process chunks simultaneously. It's one line to enable — but knowing when it helps and when it hurts is what separates knowledgeable devs from those who cargo-cult it.
Enabling Parallelism
List<Integer> bigList = /* 10 million integers */; // Sequential — uses one thread long count = bigList.stream() .filter(n -> n % 2 == 0) .count(); // Parallel — uses ForkJoinPool.commonPool() (all available CPU cores) long count = bigList.parallelStream() .filter(n -> n % 2 == 0) .count(); // Or switch mid-pipeline long count = bigList.stream() .parallel() .filter(n -> n % 2 == 0) .count();
When Parallel Helps — and When It Doesn't
Large datasets (100k+ elements), CPU-intensive operations, no ordering requirements, stateless operations, independent elements.
Small datasets (thread overhead exceeds benefit), I/O-bound operations, operations that require ordering, shared mutable state.
// ❌ Race condition — shared mutable state List<Integer> results = new ArrayList<>(); // NOT thread-safe bigList.parallelStream().filter(n -> n > 0).forEach(results::add); // data corruption! // ✅ Use thread-safe collector instead List<Integer> results = bigList.parallelStream() .filter(n -> n > 0) .collect(Collectors.toList()); // Collectors are designed for parallel use // ❌ Order-sensitive operations with parallel (results non-deterministic) bigList.parallelStream().findFirst(); // use findAny() with parallel — faster, no order guarantee bigList.parallelStream().findAny(); // ✅ better for parallel
Parallel streams have real overhead: thread coordination, data splitting, result merging. On a list of 100 elements, parallel is almost always slower than sequential. Always benchmark with realistic data sizes before assuming parallel helps.
Stream Performance Summary
// 1. Use primitive streams to avoid boxing intList.stream().mapToInt(Integer::intValue).sum(); // faster than .reduce(0, Integer::sum) // 2. Filter early to reduce pipeline elements stream.filter(expensive).map(cheap); // ✅ stream.map(cheap).filter(expensive); // ❌ applies map to more elements // 3. Short-circuit for early exit stream.filter(p).findFirst(); // stops at first match stream.filter(p).anyMatch(q); // stops as soon as any matches // 4. Avoid sorted() on large streams when possible // sorted() requires seeing all elements (O(n log n)) — use only when needed // 5. Don't use parallel on small collections or I/O-bound tasks // Rule of thumb: parallel pays off at 10k+ elements with CPU work
Given a large List<String> of sentences, write a stream pipeline that:
- Splits all sentences into individual words (flatMap)
- Normalizes to lowercase and trims whitespace
- Filters out stop words (a, the, is, in, of, and...)
- Produces a
Map<String, Long>of word → frequency - Returns the top 10 most frequent words as a sorted
List<Map.Entry<String,Long>> - Bonus: Rerun with
parallelStream()on 1M sentences and compare timing
list.parallelStream().forEach(results::add). What's the bug?Stream.of("a","b","c").map(String::toUpperCase).filter(s -> s.compareTo("B") >= 0).findFirst().orElse("none")?You can now write functional Java that senior devs respect: lambdas, method references, the full Stream API, Collectors, flatMap, and parallel. Practice by refactoring any imperative loop you encounter into a stream pipeline. That muscle memory is what makes it stick.