Skip to main content
Java · lesson 33 of 66
Open the Java page →
Java intermediate Lesson 33 of 66

Java 8 Features

Master Java 8's most impactful features — lambda expressions, functional interfaces, the Streams API, method references, Optional, and the Date/Time API.

By SkillByExample · Updated

Java 8 (2014) added lambdas, the Streams API, Optional and java.time, and changed how everyday Java is written: transformations, filters and aggregations became short pipelines instead of loops and anonymous classes. This lesson tours each feature with a runnable example; streams, Optional and dates each have a full lesson of their own. Examples run on JDK 21 with the source launcher (java File.java).

Lambda expressions

A lambda is an anonymous function that implements a single-method interface. It replaces the anonymous class you needed before Java 8.

import java.util.*;

public class Lambdas {
    public static void main(String[] args) {
        Runnable before = new Runnable() {
            @Override public void run() { System.out.println("anonymous class"); }
        };
        Runnable after = () -> System.out.println("lambda");
        before.run();
        after.run();

        List<String> words = new ArrayList<>(List.of("pear", "fig", "banana", "kiwi", "apple"));
        words.sort((a, b) -> a.length() - b.length());
        System.out.println(words);
        words.sort((a, b) -> {
            int diff = a.length() - b.length();
            return diff != 0 ? diff : a.compareTo(b);     // tie-break alphabetically
        });
        System.out.println(words);
    }
}
$ java Lambdas.java
anonymous class
lambda
[fig, pear, kiwi, apple, banana]
[fig, kiwi, pear, apple, banana]

Forms: () -> expr, x -> expr, (x, y) -> expr, and (x, y) -> { statements; return value; }. pear and kiwi have the same length: the first sort leaves them in their original order (the list sort is stable), while the second breaks the tie alphabetically.

The built-in functional interfaces

java.util.function provides the common shapes, so you rarely declare your own.

import java.util.*;
import java.util.function.*;

public class Functions {
    public static void main(String[] args) {
        Function<Integer, Integer> doubleIt = x -> x * 2;
        Function<Integer, Integer> addThree = x -> x + 3;
        System.out.println(doubleIt.andThen(addThree).apply(5) + " " + doubleIt.compose(addThree).apply(5));

        Predicate<String> longWord = s -> s.length() > 5;
        Predicate<String> startsWithP = s -> s.startsWith("p");
        System.out.println(longWord.and(startsWithP).test("program") + " " + longWord.negate().test("program"));

        Consumer<String> print = System.out::println;
        print.andThen(s -> System.out.println(s.toUpperCase())).accept("hello");

        Supplier<List<String>> newList = ArrayList::new;
        BiFunction<String, Integer, String> repeat = String::repeat;
        UnaryOperator<String> trim = String::trim;
        BinaryOperator<Integer> sum = Integer::sum;
        System.out.println(newList.get() + " " + repeat.apply("ab", 3) + " [" + trim.apply("  x ") + "] " + sum.apply(2, 3));
    }
}
$ java Functions.java
13 16
true false
hello
HELLO
[] ababab [x] 5

andThen runs left to right (5×2+3 = 13); compose runs the argument first ((5+3)×2 = 16). Function transforms, Predicate tests, Consumer acts, Supplier produces; UnaryOperator and BinaryOperator are Function/BiFunction with one type throughout.

Method references

When a lambda only calls an existing method, a method reference names that method instead.

import java.util.*;
import java.util.function.*;

public class MethodRefs {
    public static void main(String[] args) {
        Function<String, Integer> parse = Integer::parseInt;              // static method
        Function<String, String> upper = String::toUpperCase;             // method of the argument
        String prefix = "Hello, ";
        Function<String, String> greet = prefix::concat;                  // method of one object
        Function<String, StringBuilder> builder = StringBuilder::new;     // constructor

        System.out.println(parse.apply("42") + 1);
        System.out.println(upper.apply("java") + " " + greet.apply("World"));
        System.out.println(builder.apply("abc").reverse());

        List.of("banana", "apple", "cherry").stream().map(String::toUpperCase).sorted().forEach(System.out::println);
    }
}
$ java MethodRefs.java
43
JAVA Hello, World
cba
APPLE
BANANA
CHERRY

parse.apply("42") + 1 is 43, not “421”: the reference returns an Integer. String::toUpperCase means “call toUpperCase on whatever string is passed”, while prefix::concat always calls it on the one string prefix.

Streams: filter, map, reduce

A stream describes a pipeline over a source. Intermediate operations are lazy; nothing runs until a terminal operation asks for a result.

import java.util.*;
import java.util.stream.*;

public class Streams {
    public static void main(String[] args) {
        List<Integer> numbers = List.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);

        List<Integer> evenSquares = numbers.stream().filter(n -> n % 2 == 0).map(n -> n * n).toList();
        System.out.println(evenSquares);
        System.out.println("sum " + numbers.stream().reduce(0, Integer::sum)
            + ", max " + numbers.stream().max(Integer::compare).get()
            + ", avg " + numbers.stream().mapToInt(Integer::intValue).average().getAsDouble());
        System.out.println("any even? " + numbers.stream().anyMatch(n -> n % 2 == 0)
            + ", first > 5: " + numbers.stream().filter(n -> n > 5).findFirst());

        Stream<Integer> once = numbers.stream();
        System.out.println(once.count());
        System.out.println(once.count());
    }
}
$ java Streams.java
[4, 16, 36, 64, 100]
sum 55, max 10, avg 5.5
any even? true, first > 5: Optional[6]
10
Exception in thread "main" java.lang.IllegalStateException: stream has already been operated upon or closed
	at java.base/java.util.stream.AbstractPipeline.evaluate(AbstractPipeline.java:229)
	at java.base/java.util.stream.ReferencePipeline.count(ReferencePipeline.java:709)
	at Streams.main(Streams.java:18)

The source list never changes. A stream, though, is single-use: the second count() fails. Call numbers.stream() again for each pipeline. (toList() arrived in Java 16; Java 8 code writes collect(Collectors.toList()).)

Laziness you can see

import java.util.List;

public class Lazy {
    public static void main(String[] args) {
        var result = List.of("one", "two", "three", "four", "five").stream()
            .filter(s -> { System.out.println("filter " + s); return s.length() > 3; })
            .map(s -> { System.out.println("map " + s); return s.toUpperCase(); })
            .findFirst();
        System.out.println(result);
    }
}
$ java Lazy.java
filter one
filter two
filter three
map three
Optional[THREE]

Elements flow through the pipeline one at a time, and findFirst stops it once it has an answer: four and five are never examined, and map ran exactly once.

flatMap and Collectors

flatMap turns each element into a stream and flattens the results. Collectors gathers a stream into maps, strings and counts.

import java.util.*;
import java.util.stream.*;

public class Collecting {
    public static void main(String[] args) {
        List<String> lines = List.of("hello world", "foo bar baz");
        List<String> words = lines.stream().flatMap(l -> Arrays.stream(l.split(" "))).toList();
        System.out.println(words);

        List<String> names = List.of("Alice", "Bob", "Charlie", "Anna", "Brian");
        Map<Character, List<String>> byLetter = names.stream().collect(Collectors.groupingBy(s -> s.charAt(0), TreeMap::new, Collectors.toList()));
        Map<Character, Long> counts = names.stream().collect(Collectors.groupingBy(s -> s.charAt(0), TreeMap::new, Collectors.counting()));
        Map<Boolean, List<String>> longOrShort = names.stream().collect(Collectors.partitioningBy(s -> s.length() > 4));
        String joined = names.stream().collect(Collectors.joining(", ", "[", "]"));
        Map<String, Integer> lengths = names.stream().collect(Collectors.toMap(s -> s, String::length, (a, b) -> a, LinkedHashMap::new));
        System.out.println(byLetter + "\n" + counts + "\n" + longOrShort + "\n" + joined + "\n" + lengths);
    }
}
$ java Collecting.java
[hello, world, foo, bar, baz]
{A=[Alice, Anna], B=[Bob, Brian], C=[Charlie]}
{A=2, B=2, C=1}
{false=[Bob, Anna], true=[Alice, Charlie, Brian]}
[Alice, Bob, Charlie, Anna, Brian]
{Alice=5, Bob=3, Charlie=7, Anna=4, Brian=5}

groupingBy takes a key function and, optionally, a map factory and a downstream collector — TreeMap::new makes the key order predictable. toMap with a merge function and LinkedHashMap::new keeps the source order; plain toMap throws on a duplicate key. Each result goes into a typed variable first: passed straight into println, the groupingBy and toMap calls that take a map factory (TreeMap::new, LinkedHashMap::new) fail to compile, because println is overloaded and the compiler can’t infer which map type to build. The simpler forms, such as joining, compile fine inline.

Optional

Optional<T> makes “maybe no value” part of a method’s return type, so the caller must decide what to do when it’s empty.

import java.util.*;

public class Optionals {
    static Optional<String> findEmail(String user) {
        return Map.of("alice", "alice@example.com").containsKey(user)
            ? Optional.of(Map.of("alice", "alice@example.com").get(user))
            : Optional.empty();
    }

    public static void main(String[] args) {
        System.out.println(findEmail("alice").map(String::toUpperCase).orElse("none"));
        System.out.println(findEmail("bob").map(String::toUpperCase).orElse("none"));
        findEmail("alice").ifPresent(e -> System.out.println("send to " + e));
        findEmail("bob").ifPresentOrElse(e -> System.out.println("send to " + e), () -> System.out.println("no email for bob"));
        System.out.println(Optional.ofNullable(null).isPresent());
        System.out.println(findEmail("bob").orElseThrow());
    }
}
$ java Optionals.java
ALICE@EXAMPLE.COM
none
send to alice@example.com
no email for bob
false
Exception in thread "main" java.util.NoSuchElementException: No value present
	at java.base/java.util.Optional.orElseThrow(Optional.java:377)
	at Optionals.main(Optionals.java:16)

map only runs when a value is present, so the empty case flows straight to orElse. orElseThrow() (or the older get()) on an empty Optional throws — use them only when absence really is a bug.

The java.time API

Immutable date and time types replaced Date and Calendar. Months count from 1, and every operation returns a new object.

import java.time.*;
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoUnit;

public class Dates {
    public static void main(String[] args) {
        LocalDate birthday = LocalDate.of(2000, Month.MARCH, 15);
        LocalDate on = LocalDate.of(2026, 10, 7);
        System.out.println(birthday.getDayOfWeek() + ", leap year: " + birthday.isLeapYear());
        System.out.println("days alive: " + ChronoUnit.DAYS.between(birthday, on) + ", age: " + Period.between(birthday, on).getYears());

        LocalDateTime meeting = LocalDateTime.of(2026, 12, 24, 16, 30);
        System.out.println(meeting.plusHours(9) + " / " + meeting.format(DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm")));
        System.out.println(LocalDate.parse("15/06/2026", DateTimeFormatter.ofPattern("dd/MM/yyyy")));
    }
}
$ java Dates.java
WEDNESDAY, leap year: true
days alive: 9702, age: 26
2026-12-25T01:30 / 24/12/2026 16:30
2026-06-15

Adding nine hours to 16:30 on 24 December rolls into the next day, and LocalDateTime handles it. Fixed dates are used here so the output is repeatable; LocalDate.now() gives today.

Project: sales analytics with streams

Grouping, summing and sorting that used to need nested loops and temporary maps.

import java.util.*;
import java.util.stream.*;

public class SalesAnalytics {
    record Sale(String product, String region, int quantity, double price) {
        double revenue() { return quantity * price; }
    }

    public static void main(String[] args) {
        List<Sale> sales = List.of(
            new Sale("Widget", "North", 100, 9.99), new Sale("Gadget", "South", 50, 24.99),
            new Sale("Widget", "South", 75, 9.99), new Sale("Gadget", "North", 30, 24.99),
            new Sale("Doohickey", "North", 200, 4.99));

        System.out.printf("Total revenue: $%.2f%n", sales.stream().mapToDouble(Sale::revenue).sum());
        sales.stream()
            .collect(Collectors.groupingBy(Sale::product, Collectors.summingDouble(Sale::revenue)))
            .entrySet().stream()
            .sorted(Map.Entry.<String, Double>comparingByValue().reversed())
            .forEach(e -> System.out.printf("  %-10s $%.2f%n", e.getKey(), e.getValue()));
        sales.stream()
            .collect(Collectors.groupingBy(Sale::region, TreeMap::new, Collectors.summingInt(Sale::quantity)))
            .forEach((region, qty) -> System.out.println("  " + region + ": " + qty + " units"));
    }
}
$ java SalesAnalytics.java
Total revenue: $4745.45
  Gadget     $1999.20
  Widget     $1748.25
  Doohickey  $998.00
  North: 330 units
  South: 125 units

Each statement reads as a description of the report: group by product, sum revenue, sort descending. The TreeMap::new factory makes the region order deterministic.

Practice

  1. From List.of("apple", "banana", "cherry", "date"), produce the lengths of the words that contain an a, as a list.
Solution
import java.util.List;

public class Lengths {
    public static void main(String[] args) {
        List<Integer> lengths = List.of("apple", "banana", "cherry", "date").stream()
            .filter(w -> w.contains("a"))
            .map(String::length)
            .toList();
        System.out.println(lengths);
    }
}
$ java Lengths.java
[5, 6, 4]

cherry is filtered out before map ever sees it.

  1. Count word frequencies in "the cat and the hat and the bat" with groupingBy and counting, sorted by word.
Solution
import java.util.*;
import java.util.stream.*;

public class WordFreq {
    public static void main(String[] args) {
        Map<String, Long> freq = Arrays.stream("the cat and the hat and the bat".split(" "))
            .collect(Collectors.groupingBy(w -> w, TreeMap::new, Collectors.counting()));
        System.out.println(freq);
    }
}
$ java WordFreq.java
{and=2, bat=1, cat=1, hat=1, the=3}

Arrays.stream turns the array into a stream; the identity key function w -> w groups equal words.

  1. Write Optional<Integer> parse(String) that returns empty instead of throwing, and sum only the valid numbers in ["1", "x", "3"].
Solution
import java.util.List;
import java.util.Optional;

public class SafeParse {
    static Optional<Integer> parse(String s) {
        try { return Optional.of(Integer.parseInt(s)); }
        catch (NumberFormatException e) { return Optional.empty(); }
    }

    public static void main(String[] args) {
        int sum = List.of("1", "x", "3").stream().map(SafeParse::parse).flatMap(Optional::stream).mapToInt(Integer::intValue).sum();
        System.out.println(sum);
    }
}
$ java SafeParse.java
4

Optional::stream (Java 9) turns a present value into a one-element stream and an empty one into nothing, so flatMap drops the failures.

  1. Use a Supplier<Stream<String>> to run two terminal operations on “the same” stream without the IllegalStateException.
Solution
import java.util.List;
import java.util.function.Supplier;
import java.util.stream.Stream;

public class StreamSupplier {
    public static void main(String[] args) {
        Supplier<Stream<String>> words = () -> List.of("a", "bb", "ccc").stream();
        System.out.println(words.get().count());
        System.out.println(words.get().filter(w -> w.length() > 1).toList());
    }
}
$ java StreamSupplier.java
3
[bb, ccc]

Each get() creates a fresh stream from the list, so each terminal operation has its own.

Frequently Asked Questions

What is a lambda expression?
A lambda is an anonymous function — a concise way to pass behavior as data. Instead of creating a class that implements a single-method interface, you write (params) -> body. Lambdas only work where a functional interface (an interface with exactly one abstract method) is expected.
What is the difference between map() and flatMap() in streams?
map() transforms each element one-to-one — it wraps the result in the stream. flatMap() is used when the transformation itself returns a stream or collection — it flattens the nested streams into one. Example: if each line contains multiple words, lines.stream().flatMap(line -> Arrays.stream(line.split(" "))) gives a stream of all words.
Is Optional a replacement for null checks?
It's a better design tool, not a wholesale replacement. Use Optional as a return type when a method might return 'no value' — it forces the caller to handle the empty case explicitly. Don't use Optional as a field type or method parameter — that's overkill and adds overhead without benefit.
Can a Java stream be reused?
No. A stream can be consumed by exactly one terminal operation; a second one throws IllegalStateException saying the stream has already been operated upon or closed. Keep the source collection and call stream() again, or keep a Supplier<Stream<T>> that creates a fresh stream each time.