Functional Programming in Java
Interview Relevance
Functional programming is tested in every FAANG interview that involves Java. You will be expected to fluently use lambdas, streams, and Optional in coding rounds. System design rounds may probe your understanding of immutability and pure functions for concurrent systems.
Functional Interfaces
A functional interface has exactly one abstract method (SAM — Single Abstract Method). The @FunctionalInterface annotation is optional but recommended — it causes a compile error if the contract is violated.
@FunctionalInterface
public interface Transformer<T> {
T transform(T input);
// Default and static methods are allowed
default Transformer<T> andThen(Transformer<T> after) {
return input -> after.transform(this.transform(input));
}
}
Key rules: exactly one abstract method; can have default/static methods; methods inherited from Object do not count.
Built-in Functional Interfaces
%%{init: {'theme': 'base', 'themeVariables': {'fontSize': '13px', 'fontFamily': 'Inter, -apple-system, sans-serif'}, 'flowchart': {'nodeSpacing': 30, 'rankSpacing': 50, 'padding': 12, 'curve': 'basis'}, 'sequence': {'actorMargin': 60, 'messageMargin': 40}, 'class': {'padding': 12}}}%%
flowchart LR
subgraph "java.util.function"
direction LR
F{{"Function<T,R><br/>T → R"}}
P{{"Predicate<T><br/>T → boolean"}}
C{{"Consumer<T><br/>T → void"}}
S{{"Supplier<T><br/>() → T"}}
UO(["UnaryOperator<T><br/>T → T"])
BO(["BinaryOperator<T><br/>(T, T) → T"])
end
F -->|"extends"| UO
F -->|"Bi version"| BF{{"BiFunction<T,U,R><br/>(T, U) → R"}}
BF -->|"extends"| BO
P -->|"Bi version"| BP(["BiPredicate<T,U><br/>(T, U) → boolean"])
C -->|"Bi version"| BC(["BiConsumer<T,U><br/>(T, U) → void"])
style BC fill:#DBEAFE,stroke:#93C5FD,color:#1E40AF
style BF fill:#D1FAE5,stroke:#6EE7B7,color:#065F46
style BO fill:#FEF3C7,stroke:#FCD34D,color:#92400E
style BP fill:#FEE2E2,stroke:#FCA5A5,color:#991B1B
style C fill:#DBEAFE,stroke:#93C5FD,color:#1E40AF
style F fill:#D1FAE5,stroke:#6EE7B7,color:#065F46
style P fill:#FEF3C7,stroke:#FCD34D,color:#92400E
style S fill:#FEE2E2,stroke:#FCA5A5,color:#991B1B
style UO fill:#DBEAFE,stroke:#93C5FD,color:#1E40AF Comparison Table
| Interface | Method | Input | Output | Use Case |
|---|---|---|---|---|
Function<T,R> | apply(T) | T | R | Transform data |
BiFunction<T,U,R> | apply(T, U) | T, U | R | Combine two inputs |
UnaryOperator<T> | apply(T) | T | T | Same-type transformation |
BinaryOperator<T> | apply(T, T) | T, T | T | Reduce two values to one |
Predicate<T> | test(T) | T | boolean | Filter/validate |
BiPredicate<T,U> | test(T, U) | T, U | boolean | Two-input condition |
Consumer<T> | accept(T) | T | void | Side effects (logging, saving) |
BiConsumer<T,U> | accept(T, U) | T, U | void | Side effects on two inputs |
Supplier<T> | get() | none | T | Lazy creation / factory |
Examples
Function<String, Integer> strLength = String::length;
Predicate<Integer> isEven = n -> n % 2 == 0;
Consumer<String> printer = System.out::println;
Supplier<List<String>> listFactory = ArrayList::new;
UnaryOperator<String> toUpper = String::toUpperCase;
BinaryOperator<Integer> sum = Integer::sum;
Lambda Expressions
Lambdas provide concise syntax for implementing functional interfaces.
Syntax Variations
// Full form
(int a, int b) -> { return a + b; }
// Type inference
(a, b) -> a + b
// Single parameter (no parentheses)
x -> x * 2
// No parameters
() -> System.out.println("Hello")
// Multi-line body
(a, b) -> {
int result = a + b;
log.info("Sum: {}", result);
return result;
}
Closures and Effectively Final
Lambdas capture variables from their enclosing scope, but those variables must be effectively final (assigned only once).
String prefix = "Hello"; // effectively final
Function<String, String> greeter = name -> prefix + " " + name; // OK
String greeting = "Hi";
greeting = "Hello"; // re-assignment breaks effectively final
Consumer<String> broken = name -> System.out.println(greeting + name); // COMPILE ERROR
Why? Lambdas capture the value, not the variable. If the variable could change, the lambda would hold a stale snapshot. Workaround: use AtomicReference or single-element array.
Method References
Method references are shorthand for lambdas that simply delegate to an existing method.
Four Types
| Type | Syntax | Lambda Equivalent | Example |
|---|---|---|---|
| Static method | ClassName::staticMethod | x -> Class.method(x) | Integer::parseInt |
| Bound instance | instance::method | x -> instance.method(x) | System.out::println |
| Unbound instance | ClassName::method | (obj, x) -> obj.method(x) | String::length |
| Constructor | ClassName::new | x -> new ClassName(x) | ArrayList::new |
Function<String, Integer> parse = Integer::parseInt; // static
Supplier<String> upper = "hello"::toUpperCase; // bound instance
Function<String, String> toUpper = String::toUpperCase; // unbound instance
Supplier<List<String>> factory = ArrayList::new; // constructor
Composition
Function Composition
Function<Integer, Integer> doubleIt = x -> x * 2;
Function<Integer, Integer> addTen = x -> x + 10;
// andThen: apply first, then second → (5*2)+10 = 20
doubleIt.andThen(addTen).apply(5);
// compose: apply second first, then first → (5+10)*2 = 30
doubleIt.compose(addTen).apply(5);
%%{init: {'theme': 'base', 'themeVariables': {'fontSize': '13px', 'fontFamily': 'Inter, -apple-system, sans-serif'}, 'flowchart': {'nodeSpacing': 30, 'rankSpacing': 50, 'padding': 12, 'curve': 'basis'}, 'sequence': {'actorMargin': 60, 'messageMargin': 40}, 'class': {'padding': 12}}}%%
flowchart LR
A(("Input: 5")) --> B{{"doubleIt<br/>5 × 2 = 10"}}
B --> C{{"addTen<br/>10 + 10 = 20"}}
C --> D(["Output: 20"])
style A fill:#EFF6FF
style D fill:#D1FAE5 Predicate Chaining
Predicate<Integer> isPositive = n -> n > 0;
Predicate<Integer> isEven = n -> n % 2 == 0;
Predicate<Integer> positiveAndEven = isPositive.and(isEven); // AND
Predicate<Integer> positiveOrEven = isPositive.or(isEven); // OR
Predicate<Integer> isOdd = isEven.negate(); // NEGATE
// Complex chain
Predicate<Integer> complex = isPositive.and(isEven).and(n -> n < 100);
complex.test(42); // true
complex.test(102); // false
Consumer Chaining
Consumer<String> log = s -> System.out.println("LOG: " + s);
Consumer<String> save = s -> database.save(s);
Consumer<String> logAndSave = log.andThen(save);
Optional
Optional<T> is a container that may or may not hold a non-null value. It forces explicit handling of absence.
Creation
Optional<String> present = Optional.of("Hello"); // throws NPE if null
Optional<String> nullable = Optional.ofNullable(name); // safe for null
Optional<String> empty = Optional.empty();
Transformations with map and flatMap
Optional<String> name = Optional.of("john doe");
// map — transform if present
Optional<String> upper = name.map(String::toUpperCase); // Optional["JOHN DOE"]
// flatMap — avoids Optional<Optional<T>>
Optional<String> city = findUser(id)
.flatMap(User::getAddress)
.flatMap(Address::getCity);
// filter — keep only if predicate matches
Optional<String> longName = name.filter(n -> n.length() > 3);
Retrieval Patterns
optional.orElse("default"); // always evaluated
optional.orElseGet(() -> computeExpensive()); // lazy — only if empty
optional.orElseThrow(() -> new NotFoundException()); // throw if empty
optional.ifPresent(val -> process(val)); // action if present
optional.ifPresentOrElse(this::process, this::fallback); // Java 9+
Anti-Patterns
// BAD: Optional as method parameter or class field
public void process(Optional<String> name) { } // Don't
// BAD: Calling get() without checking
optional.get(); // NoSuchElementException risk
// GOOD: Optional only as return type
public Optional<User> findUserById(Long id) { ... }
Stream API Pipeline
%%{init: {'theme': 'base', 'themeVariables': {'fontSize': '13px', 'fontFamily': 'Inter, -apple-system, sans-serif'}, 'flowchart': {'nodeSpacing': 30, 'rankSpacing': 50, 'padding': 12, 'curve': 'basis'}, 'sequence': {'actorMargin': 60, 'messageMargin': 40}, 'class': {'padding': 12}}}%%
graph LR
subgraph Source
S["Collection / Array / Generator"]
end
subgraph "Intermediate Ops (Lazy)"
F["filter()"] --> M["map()"]
M --> ST["sorted()"]
ST --> D["distinct()"]
D --> FL["flatMap()"]
end
subgraph "Terminal Op (Triggers)"
T["collect() / reduce() / forEach()"]
end
S --> F
FL --> T
style S fill:#EFF6FF
style T fill:#D1FAE5 Key traits: lazy evaluation, single-use, non-mutating, optionally parallel.
Intermediate Operations (Return Stream)
names.stream()
.filter(n -> n.startsWith("A")) // keep matching
.map(String::toUpperCase) // transform 1:1
.flatMap(Collection::stream) // flatten nested
.sorted() // natural order
.distinct() // remove duplicates
.limit(10) // cap at N
.skip(1) // skip first N
.peek(System.out::println); // debug only
Terminal Operations (Produce Result)
stream.collect(Collectors.toList()); // accumulate
stream.forEach(System.out::println); // side effect
numbers.stream().reduce(0, Integer::sum); // combine all
stream.count(); // count elements
stream.findFirst(); // Optional<T>
stream.anyMatch(predicate); // boolean
stream.max(Comparator.naturalOrder()); // Optional<T>
flatMap — Flattening Nested Structures
List<List<String>> nested = List.of(List.of("a","b"), List.of("c","d"));
List<String> flat = nested.stream()
.flatMap(Collection::stream)
.collect(Collectors.toList()); // [a, b, c, d]
Collectors
Essential Collectors
// toList / toSet / toMap
Map<Long, Employee> byId = employees.stream()
.collect(Collectors.toMap(Employee::getId, Function.identity()));
// joining
String csv = names.stream().collect(Collectors.joining(", ")); // "Alice, Bob"
// groupingBy
Map<Department, List<Employee>> byDept = employees.stream()
.collect(Collectors.groupingBy(Employee::getDepartment));
// groupingBy with downstream
Map<Department, Long> countByDept = employees.stream()
.collect(Collectors.groupingBy(Employee::getDepartment, Collectors.counting()));
// partitioningBy — split into true/false
Map<Boolean, List<Employee>> split = employees.stream()
.collect(Collectors.partitioningBy(e -> e.getSalary() > 100000));
// reducing
Optional<Employee> highest = employees.stream()
.collect(Collectors.reducing(BinaryOperator.maxBy(
Comparator.comparingDouble(Employee::getSalary))));
// summarizingDouble — stats in one pass
DoubleSummaryStatistics stats = employees.stream()
.collect(Collectors.summarizingDouble(Employee::getSalary));
Custom Collector
Collector<String, StringJoiner, String> custom = Collector.of(
() -> new StringJoiner(", ", "[", "]"), // supplier
StringJoiner::add, // accumulator
StringJoiner::merge, // combiner (parallel)
StringJoiner::toString // finisher
);
Stream.of("a", "b", "c").collect(custom); // "[a, b, c]"
Parallel Streams
Parallel streams split work across multiple threads using the ForkJoinPool.
%%{init: {'theme': 'base', 'themeVariables': {'fontSize': '13px', 'fontFamily': 'Inter, -apple-system, sans-serif'}, 'flowchart': {'nodeSpacing': 30, 'rankSpacing': 50, 'padding': 12, 'curve': 'basis'}, 'sequence': {'actorMargin': 60, 'messageMargin': 40}, 'class': {'padding': 12}}}%%
flowchart LR
S(["Source"]) --> SP{{"Spliterator splits data"}}
SP --> T1[/"Thread 1"/] & T2[/"Thread 2"/] & T3[/"Thread 3"/] & T4[/"Thread 4"/]
T1 & T2 & T3 & T4 --> CM[["Combine Results"]]
CM --> R(("Final Result"))
style S fill:#EFF6FF
style R fill:#D1FAE5 When to Use vs Avoid
| Good Candidates | Bad Candidates |
|---|---|
| Large datasets (100K+ elements) | Small collections (< 10K) |
| CPU-intensive per-element work | I/O-bound operations |
| Stateless, associative operations | Shared mutable state |
| ArrayList / array sources | LinkedList / TreeSet (poor splitting) |
ForkJoinPool Connection
// Default: uses common ForkJoinPool (parallelism = availableProcessors - 1)
// Custom pool for isolation:
ForkJoinPool pool = new ForkJoinPool(8);
List<Result> results = pool.submit(() ->
data.parallelStream()
.map(this::process)
.collect(Collectors.toList())
).get();
pool.shutdown();
Pitfalls
// BUG: Shared mutable state — race condition
List<Integer> results = new ArrayList<>();
numbers.parallelStream().map(n -> n * 2).forEach(results::add); // WRONG
// FIX: Use collect()
List<Integer> results = numbers.parallelStream()
.map(n -> n * 2).collect(Collectors.toList()); // SAFE
// BUG: Non-associative operation
numbers.parallelStream().reduce(0, (a, b) -> a - b); // Wrong results!
Immutability and Pure Functions
Immutable Objects
public final class Money {
private final BigDecimal amount;
private final Currency currency;
public Money(BigDecimal amount, Currency currency) {
this.amount = amount;
this.currency = currency;
}
public Money add(Money other) {
return new Money(this.amount.add(other.amount), this.currency); // new object
}
}
// Java 16+ Records — immutable by default
public record Point(int x, int y) {
public Point translate(int dx, int dy) { return new Point(x + dx, y + dy); }
}
// Unmodifiable collections (Java 9+)
List<String> list = List.of("a", "b", "c");
Map<String, Integer> map = Map.of("key", 1);
Pure Functions
A pure function has no side effects and is deterministic (same input = same output).
// PURE — safe for parallel streams, cacheable, easy to test
Function<Integer, Integer> square = x -> x * x;
// IMPURE — depends on external state
Function<Integer, Integer> addRandom = x -> x + new Random().nextInt();
Benefits: thread-safe without synchronization, easy to test, cacheable (memoization), safe for parallel operations.
Interview Questions
What is the difference between map() and flatMap() in streams?
map() transforms each element 1-to-1. flatMap() transforms each element into a stream and flattens all results into one stream. Use flatMap() when each element maps to multiple values (e.g., List<List<T>> to List<T>).
Why must variables captured by lambdas be effectively final?
Lambdas capture the value of local variables, not the variable itself. If the variable could be reassigned, the lambda would hold a stale copy. The JVM does not support mutable closures over stack-allocated locals. This ensures thread safety and predictable behavior.
Explain the difference between orElse() and orElseGet() in Optional.
orElse(value) always evaluates the default, even when Optional is present. orElseGet(supplier) only invokes the supplier when empty. Use orElseGet() when the default is expensive to compute.
How do you run a parallel stream on a custom ForkJoinPool?
Submit the stream operation as a task to a custom pool. The stream uses that pool's threads instead of the shared common pool (which could starve other tasks).
What is the difference between reduce() and collect()?
reduce() is immutable reduction — combines elements by creating new values. collect() is mutable reduction — accumulates into a container (List, StringBuilder). Use reduce() for numerics; use collect() for building collections or strings.
What are the risks of parallel streams with shared mutable state?
Parallel streams split data across threads. Writing to shared mutable state (e.g., ArrayList) causes race conditions — missing elements, duplicates, or exceptions. Fix: use collect() for thread-safe accumulation or ensure all operations are stateless.
Implement a custom Collector that builds a bracketed comma-separated string.
Four components: Supplier (create container), Accumulator (add element), Combiner (merge for parallel), Finisher (final transform).How does andThen() differ from compose() in Function?
Both chain functions but in opposite order: f.andThen(g) = g(f(x)); f.compose(g) = f(g(x)).
Key Takeaways
- Master the Big Four:
Function,Predicate,Consumer,Supplier - Streams are lazy pipelines — intermediate ops build it, terminal ops execute it
- Prefer
collect()over manual accumulation into mutable state - Parallel streams are not free — measure first, ensure stateless operations
- Optional is a return type signal, not a general-purpose null wrapper
- Immutability + pure functions = thread safety without locks