The examples on this page are compact source files (see The JDK, javac, java and Your First Program): void main() and IO.println, with all of the java.base module — java.util, java.util.function, java.util.stream — imported automatically. In an ordinary class you would add imports such as import java.util.function.Function;.

Passing behavior as a value

Many methods need a small piece of behavior from the caller: how to compare two tickets when sorting, which readings to keep, what to do with each element. Java has no standalone functions, so before Java 8 (2014) the behavior had to be wrapped in an object — usually an anonymous class that implements an interface on the spot. A lambda expression says the same in one line:

void main() {
    List<Ticket> tickets = new ArrayList<>(List.of(
            new Ticket("INC-10231", "NOC", 45),
            new Ticket("INC-10232", "Billing", 15),
            new Ticket("INC-10233", "NOC", 30)));

    // Before Java 8: an anonymous class that implements Comparator
    tickets.sort(new Comparator<Ticket>() {
        @Override
        public int compare(Ticket a, Ticket b) {
            return Integer.compare(a.minutes(), b.minutes());
        }
    });
    IO.println(tickets.getFirst().id());

    // Since Java 8: a lambda expression
    tickets.sort((a, b) -> Integer.compare(b.minutes(), a.minutes()));
    IO.println(tickets.getFirst().id());
}

record Ticket(String id, String team, int minutes) { }
INC-10232
INC-10231

The first sort puts the shortest ticket first, the second the longest. Passing functions to other functions is the core idea of functional programming; sort is a higher-order function in that sense.

Lambda expressions

A lambda expression is an anonymous function written as parameters, an arrow -> and a body. Its parameter types come from the context, so you rarely write them. The forms:

  • One parameter, no parentheses needed: amount -> amount * 1.21
  • Several or no parameters, in parentheses: (kwh, tariff) -> kwh * tariff, () -> "escalate to NOC"
  • A block body with braces and return when one expression is not enough
void main() {
    Function<Double, Double> withVat = amount -> amount * 1.21;
    BiFunction<Integer, Double, Double> cost = (kwh, tariff) -> kwh * tariff;
    Supplier<String> nextStep = () -> "escalate to NOC";
    Function<String, String> ticketLabel = id -> {
        String number = id.substring(4);
        return "Incident " + number;
    };

    IO.println(withVat.apply(100.0));
    IO.println(cost.apply(350, 0.30));
    IO.println(nextStep.get());
    IO.println(ticketLabel.apply("INC-10231"));
}
121.0
105.0
escalate to NOC
Incident 10231

A lambda is called through the method of its interface — apply, get, test — not with parentheses directly as in Python or JavaScript.

Capturing variables: effectively final

A lambda can use local variables of the surrounding method (it forms a closure), but only variables that are never changed after they are assigned — effectively final. The lambda captures the value, not the variable, so changing the variable would be ambiguous. Adding up with a lambda therefore fails:

void main() {
    List<Integer> readings = List.of(412, 415, 419);
    int total = 0;
    readings.forEach(kwh -> total += kwh);
    IO.println(total);
}
Main.java:4: error: local variables referenced from a lambda expression must be final or effectively final

Use a loop, or a stream that sums for you: readings.stream().mapToInt(Integer::intValue).sum(). Fields are not restricted, but lambdas that change shared state are hard to follow and unsafe across threads.

Functional interfaces

A functional interface is an interface with exactly one abstract method. Any lambda whose parameters and result fit that method can be used where the interface is expected; Comparator, Runnable and Callable are older examples. The optional annotation @FunctionalInterface makes the compiler check the "exactly one" rule. Your own interfaces work the same way:

void main() {
    TariffRule weekend = kwh -> kwh * 0.22;
    TariffRule standard = kwh -> kwh * 0.32;

    IO.println(bill(350, weekend));
    IO.println(bill(350, standard));
}

String bill(int kwh, TariffRule rule) {
    return "%d kWh costs %.2f".formatted(kwh, rule.price(kwh));
}

@FunctionalInterface
interface TariffRule {
    double price(int kwh);
}
350 kWh costs 77.00
350 kWh costs 112.00

Usually you do not need your own: the package java.util.function has 43 ready-made ones in JDK 25. The main shapes:

Interface Method Shape Typical use
Function<T, R> R apply(T t) one value in, one out map
BiFunction<T, U, R> R apply(T t, U u) two values in, one out combining values
Predicate<T> boolean test(T t) a yes/no question filter, removeIf
Consumer<T> void accept(T t) takes a value, returns nothing forEach
Supplier<T> T get() produces a value defaults, factories
UnaryOperator<T> T apply(T t) a Function with the same type in and out replaceAll
BinaryOperator<T> T apply(T a, T b) two of a type into one reduce

Variants such as IntPredicate, ToIntFunction<T> and IntBinaryOperator work with int, long and double directly, without boxing them in wrapper objects (see Generics and primitive types). Many of the interfaces also have default methods to combine them: and, or and negate on a Predicate, andThen and compose on a Function.

void main() {
    Predicate<String> isIncident = id -> id.startsWith("INC-");
    Predicate<String> isOld = id -> Integer.parseInt(id.substring(4)) < 10300;
    Function<Double, Double> withVat = amount -> amount * 1.21;
    Function<Double, Double> rounded = amount -> Math.round(amount * 100) / 100.0;
    UnaryOperator<String> shout = String::toUpperCase;
    BinaryOperator<Integer> add = Integer::sum;
    Consumer<String> log = line -> IO.println("log: " + line);

    IO.println(isIncident.and(isOld).test("INC-10231"));
    IO.println(isIncident.and(isOld.negate()).test("INC-10231"));
    IO.println(withVat.andThen(rounded).apply(12.35));
    IO.println(shout.apply("kwh"));
    IO.println(add.apply(412, 415));
    log.accept("meter E-001 read");
}
true
false
14.94
KWH
827
log: meter E-001 read

Method references

When a lambda does nothing but call one existing method, a method reference says the same more briefly: the class or object, two colons, and the method name, without parentheses. Ticket::id means t -> t.id(). There are four kinds:

void main() {
    Function<String, Integer> parse = Integer::parseInt;          // static method
    Function<String, String> upper = String::toUpperCase;         // instance method of the argument
    String prefix = "Kestrel: ";
    Function<String, String> brand = prefix::concat;              // instance method of one object
    Function<String, Meter> create = Meter::new;                  // constructor

    IO.println(parse.apply("412") + 3);
    IO.println(upper.apply("noc"));
    IO.println(brand.apply("outage resolved"));
    IO.println(create.apply("E-001"));
}

record Meter(String id) { }
415
NOC
Kestrel: outage resolved
Meter[id=E-001]

String::toUpperCase and prefix::concat look alike but differ: the first calls the method on whatever string is passed in; the second always calls it on prefix and passes the argument along. System.out::println is the best-known example of the second kind.

Streams

The Stream API (java.util.stream, Java 8) processes a sequence of elements in a declarative way: you describe what should happen to the elements, and the library runs the loop. Compare the same task written both ways:

void main() {
    List<Ticket> tickets = List.of(
            new Ticket("INC-10231", "NOC", 45),
            new Ticket("INC-10232", "Billing", 15),
            new Ticket("INC-10233", "NOC", 30),
            new Ticket("INC-10234", "Portal", 90));

    // With a loop
    List<String> slow = new ArrayList<>();
    for (Ticket t : tickets) {
        if (t.minutes() > 20) {
            slow.add(t.id());
        }
    }
    IO.println(slow);

    // With a stream
    List<String> slowAgain = tickets.stream()
            .filter(t -> t.minutes() > 20)
            .map(Ticket::id)
            .toList();
    IO.println(slowAgain);
}

record Ticket(String id, String team, int minutes) { }
[INC-10231, INC-10233, INC-10234]
[INC-10231, INC-10233, INC-10234]

A stream is not a collection: it stores nothing, and it does not change its source. (It is also unrelated to the byte streams of java.io, such as InputStream, which Files, I/O and JSON covers.)

The pipeline

A stream pipeline has three parts: a source (collection.stream(), Stream.of(…), Files.lines(path)), any number of intermediate operations, and exactly one terminal operation.

  • An intermediate operation such as filter, map, sorted or limit returns a new stream and does no work yet: it only adds a step to the pipeline.
  • A terminal operation such as toList, count, forEach or collect produces a result or a side effect. Only then does the pipeline run, and afterward the stream is used up.
A stream pipeline: the source tickets.stream() feeds filter, which feeds map, which feeds toList. filter and map are intermediate operations, which are lazy and return a new stream. toList is the terminal operation that runs the pipeline and produces the result. tickets.stream() filter(…) map(…) toList() source intermediate operations lazy: each returns a new stream terminal operation runs the pipeline elements flow through one at a time
Nothing happens until the terminal operation asks for elements; then each element passes through all the steps before the next one is read.

Laziness, in practice

Printing from inside the lambdas shows when the work really happens. Nothing runs while the pipeline is built; once toList() starts, each element goes through filter and map before the next one is read, and limit(2) stops the source as soon as two elements have come through — G-001 and E-003 are never looked at.

void main() {
    List<String> meters = List.of("E-001", "E-002", "G-001", "E-003");

    Stream<String> pipeline = meters.stream()
            .filter(id -> {
                IO.println("filter " + id);
                return id.startsWith("E-");
            })
            .map(id -> {
                IO.println("map    " + id);
                return id.toLowerCase();
            });
    IO.println("Pipeline built, nothing has run yet");

    List<String> firstTwo = pipeline.limit(2).toList();
    IO.println(firstTwo);
}
Pipeline built, nothing has run yet
filter E-001
map    E-001
filter E-002
map    E-002
[e-001, e-002]

Lambdas in a stream should not print or change outside state in real code; this is only a demonstration. Because a stream is used up by its terminal operation, reusing it fails:

void main() {
    Stream<String> meters = Stream.of("E-001", "E-002", "G-001");

    IO.println(meters.count());
    IO.println(meters.count());
}
3
Exception in thread "main" java.lang.IllegalStateException: stream has already been operated upon or closed

Keep the collection and call .stream() again for each pipeline; that is cheap.

Common operations

Sorting, de-duplicating and slicing

sorted takes an optional Comparator; Comparator.comparingInt(Reading::kwh).reversed() sorts by usage, highest first. distinct drops duplicates (by equals), limit(n) keeps the first n elements and skip(n) drops them.

void main() {
    List<Reading> readings = List.of(
            new Reading("E-003", 388), new Reading("E-001", 412),
            new Reading("E-002", 1510), new Reading("E-001", 415),
            new Reading("E-004", 1280), new Reading("E-003", 391));

    List<String> topThree = readings.stream()
            .sorted(Comparator.comparingInt(Reading::kwh).reversed())
            .limit(3)
            .map(r -> r.meterId() + "=" + r.kwh())
            .toList();
    IO.println(topThree);

    List<String> meters = readings.stream()
            .map(Reading::meterId)
            .distinct()
            .sorted()
            .toList();
    IO.println(meters);
}

record Reading(String meterId, int kwh) { }
[E-002=1510, E-004=1280, E-001=415]
[E-001, E-002, E-003, E-004]

Stream.toList() (Java 16) returns an unmodifiable list; calling add on it throws an UnsupportedOperationException. Use collect(Collectors.toCollection(ArrayList::new)) when you need to change the result.

Counting, summing and searching

Terminal operations that reduce a stream to one value:

void main() {
    List<Ticket> tickets = List.of(
            new Ticket("INC-10231", "NOC", 45),
            new Ticket("INC-10232", "Billing", 15),
            new Ticket("INC-10233", "NOC", 30),
            new Ticket("INC-10234", "Portal", 90));

    IO.println(tickets.stream().filter(t -> t.team().equals("NOC")).count());
    IO.println(tickets.stream().mapToInt(Ticket::minutes).sum());
    IO.println(tickets.stream().mapToInt(Ticket::minutes).average().orElse(0));
    IO.println(tickets.stream().max(Comparator.comparingInt(Ticket::minutes)).map(Ticket::id).orElse("none"));
    IO.println(tickets.stream().anyMatch(t -> t.minutes() > 60));
    IO.println(tickets.stream().allMatch(t -> t.minutes() > 10));
    IO.println(tickets.stream().filter(t -> t.team().equals("Field")).findFirst());
    IO.println(tickets.stream().map(Ticket::minutes).reduce(0, Integer::sum));
}

record Ticket(String id, String team, int minutes) { }
2
180
45.0
INC-10234
true
true
Optional.empty
180
  • mapToInt turns a Stream<Ticket> into an IntStream of plain ints, which has sum, average, max and summaryStatistics.
  • Operations that may find nothing — max, findFirst, average — return an Optional (or OptionalDouble), never null; see Null, Optional and NullPointerException.
  • reduce(0, Integer::sum) combines all elements with a start value and a BinaryOperator; the specialized methods are clearer when they exist.

Number streams

IntStream, LongStream and DoubleStream can also be sources by themselves:

void main() {
    IO.println(IntStream.rangeClosed(1, 5).sum());
    IO.println(IntStream.of(412, 415, 419).max().getAsInt());
    IO.println(IntStream.of(412, 415, 419).summaryStatistics());
    IO.println(Stream.iterate(1, n -> n * 2).limit(6).toList());
}
15
419
IntSummaryStatistics{count=3, sum=1246, min=412, average=415.333333, max=419}
[1, 2, 4, 8, 16, 32]

Stream.iterate produces an endless stream; limit makes it finite. Without limit, toList() would never finish.

Collecting results

collect is the most flexible terminal operation. It takes a collector: a recipe that says how to accumulate the elements into a result — a string, a list, a map of groups. The class Collectors has the ready-made ones:

void main() {
    List<Ticket> tickets = List.of(
            new Ticket("INC-10231", "NOC", 45),
            new Ticket("INC-10232", "Billing", 15),
            new Ticket("INC-10233", "NOC", 30),
            new Ticket("INC-10234", "Portal", 90),
            new Ticket("INC-10235", "Billing", 30));

    String ids = tickets.stream()
            .map(Ticket::id)
            .collect(Collectors.joining(", ", "[", "]"));
    IO.println(ids);

    Map<String, Long> perTeam = tickets.stream()
            .collect(Collectors.groupingBy(Ticket::team, TreeMap::new, Collectors.counting()));
    IO.println(perTeam);

    Map<String, Integer> minutesPerTeam = tickets.stream()
            .collect(Collectors.groupingBy(Ticket::team, TreeMap::new,
                    Collectors.summingInt(Ticket::minutes)));
    IO.println(minutesPerTeam);

    Map<Boolean, List<String>> slow = tickets.stream()
            .collect(Collectors.partitioningBy(t -> t.minutes() > 30,
                    Collectors.mapping(Ticket::id, Collectors.toList())));
    IO.println(slow);

    Map<String, Integer> minutesById = tickets.stream()
            .collect(Collectors.toMap(Ticket::id, Ticket::minutes, (a, b) -> a, TreeMap::new));
    IO.println(minutesById.get("INC-10234"));
}

record Ticket(String id, String team, int minutes) { }
[INC-10231, INC-10232, INC-10233, INC-10234, INC-10235]
{Billing=2, NOC=2, Portal=1}
{Billing=45, NOC=75, Portal=90}
{false=[INC-10232, INC-10233, INC-10235], true=[INC-10231, INC-10234]}
90
  • joining(separator, prefix, suffix) concatenates strings.
  • groupingBy(classifier, mapFactory, downstream) groups by a key and then applies a second collector to each group: counting(), summingInt(…), mapping(…), toList().
  • partitioningBy is grouping by a Predicate: always exactly two groups, false and true.
  • toMap(key, value, merge, mapFactory) builds a map. Without the merge function, a duplicate key throws an IllegalStateException.

Gatherers: custom intermediate steps

Since Java 24 a stream can also use gather(…), an intermediate operation that takes a gatherer. The class Gatherers provides a few that were hard to write with the other operations, such as fixed-size windows and running totals. Twelve monthly readings become four quarters:

void main() {
    List<Integer> monthlyKwh = List.of(410, 395, 380, 300, 240, 210, 205, 215, 260, 330, 390, 420);

    List<Integer> quarters = monthlyKwh.stream()
            .gather(Gatherers.windowFixed(3))
            .map(quarter -> quarter.stream().mapToInt(Integer::intValue).sum())
            .toList();
    IO.println(quarters);

    List<Integer> runningTotal = monthlyKwh.stream()
            .gather(Gatherers.scan(() -> 0, Integer::sum))
            .toList();
    IO.println(runningTotal.getLast());
}
[1185, 750, 680, 1140]
3755

windowFixed(3) turns the stream of numbers into a stream of lists of three; windowSliding(n) makes overlapping windows (for moving averages), and scan emits a running total after each element.

Lambdas and checked exceptions

The methods of Function, Predicate and the other standard interfaces do not declare any checked exceptions. A lambda that calls a method throwing one, such as Files.readString, therefore does not compile (see checked exceptions):

void main() {
    List<Path> files = List.of(Path.of("readings-1.csv"), Path.of("readings-2.csv"));
    List<String> contents = files.stream()
            .map(path -> Files.readString(path))
            .toList();
    IO.println(contents);
}
Main.java:4: error: unreported exception IOException; must be caught or declared to be thrown

The usual solution is a small method that catches the checked exception and rethrows it wrapped in an unchecked one, with the original as its cause. The example writes its two files first:

void main() throws IOException {
    Files.writeString(Path.of("readings-1.csv"), "E-001;412");
    Files.writeString(Path.of("readings-2.csv"), "E-002;388");

    List<String> contents = Stream.of("readings-1.csv", "readings-2.csv")
            .map(Path::of)
            .map(this::read)
            .toList();
    IO.println(contents);
}

String read(Path path) {
    try {
        return Files.readString(path);
    } catch (IOException e) {
        throw new UncheckedIOException(e);
    }
}
[E-001;412, E-002;388]

this::read refers to the method of the current object; in a compact source file, main and read are instance methods of the same implicit class.

Streams or loops?

Streams shine for pipelines of filtering, transforming, grouping and summarizing — the kind of work SQL does on tables. A plain loop is often clearer when the logic has several branches, needs to stop early on a complex condition, changes variables outside, or handles checked exceptions. Neither is "more professional"; choose what reads best. Some practical points:

  • Keep lambdas in a pipeline short and free of side effects. A long lambda is better as a named method, used through a method reference.
  • Stack traces from streams contain many frames from java.util.stream; look for the lines of your own lambdas, which appear as lambda$main$0 and similar.
  • parallelStream() splits work over several threads. It helps only for large, CPU-heavy work without shared state, and it uses a pool shared by the whole JVM; measure before using it (see Concurrency: Threads, Executors and Virtual Threads).
Java stream Python C# LINQ
filter(t -> …) [t for t in xs if …] Where
map(t -> …) [f(t) for t in xs] Select
sorted(comparator) sorted(xs, key=…) OrderBy
collect(groupingBy(…)) itertools.groupby (after sorting) GroupBy
reduce(…) functools.reduce Aggregate
toList() list(…) ToList()