Zum Inhalt springen

Java Lambda-Ausdrücke (mit Beispielen)

Halbtransparente Pfeile nach rechts, violett und blau leuchtend im Dunkeln

Ein Lambda-Ausdruck ist eine Funktion ohne Namen: Er besteht aus keinem, einem oder mehreren Eingabeparametern, gefolgt von einem Pfeil und einem Rumpf, der optional einen Wert zurückgeben kann.

Ein Beispiel: book -> book.year() < 1850 nimmt als Eingabeparameter ein Buch entgegen und liefert zurück, ob dieses Buch vor 1850 erschienen ist.

Ein Lambda kannst du wiederum an eine Methode übergeben, die als Parameter eine Verhaltensdefinition erwartet – z. B. an die Stream.filter()-Methode, um aus einem Stream von Büchern eben die gewünschten Bücher herauszufiltern.

Lambda-Ausdrücke gibt es in Java seit Java 8 (März 2014) – vorher hättest du dafür eine anonyme Klasse schreiben müssen. Keine Sorge – das zeige ich dir gleich alles nochmal an Beispielen.

In diesem Artikel erfährst du,

  • was ein Lambda-Ausdruck ist und was er ersetzt,
  • welche Formen die Syntax erlaubt und welche ich empfehle,
  • wie der Compiler den Typ eines Lambdas bestimmt,
  • auf welche Variablen ein Lambda zugreifen darf und was „effectively final“ bedeutet,
  • wann eine Methodenreferenz ein Lambda ersetzt und welche vier Arten es gibt,
  • wo Lambdas außerhalb von Streams verwendet werden,
  • wie die JVM ein Lambda ausführt,
  • welche Fehler du vermeiden solltest.

Die Beispiele in diesem Artikel

Die Beispiele verwenden das Datenmodell des Artikels über Java Streams – ein Enum Genre, einen Record Book und eine kleine Bibliothek mit elf Klassikern:

public enum Genre {
  NOVEL,
  GOTHIC,
  ADVENTURE,
  FANTASY,
  SCIENCE_FICTION
}

public record Book(String title, String author, int year, Genre genre) {}
public class Library {

  public static final List<Book> BOOKS = List.of(
      new Book("Pride and Prejudice", "Jane Austen", 1813, NOVEL),
      new Book("Frankenstein", "Mary Shelley", 1818, GOTHIC),
      new Book("Moby-Dick", "Herman Melville", 1851, ADVENTURE),
      new Book("From the Earth to the Moon", "Jules Verne", 1865, SCIENCE_FICTION),
      new Book("Alice's Adventures in Wonderland", "Lewis Carroll", 1865, FANTASY),
      new Book("Around the World in Eighty Days", "Jules Verne", 1873, ADVENTURE),
      new Book("Treasure Island", "Robert Louis Stevenson", 1883, ADVENTURE),
      new Book("Kidnapped", "Robert Louis Stevenson", 1886, ADVENTURE),
      new Book("The Time Machine", "H. G. Wells", 1895, SCIENCE_FICTION),
      new Book("Dracula", "Bram Stoker", 1897, GOTHIC),
      new Book("The War of the Worlds", "H. G. Wells", 1898, SCIENCE_FICTION));
}

Den vollständigen Code aller Beispiele findest du im GitHub-Repository java-streams-examples, im Package eu.happycoders.lambdas.

Was ist ein Lambda-Ausdruck?

Angenommen, du willst die Bücher nach Erscheinungsjahr sortieren. List.sort() erwartet einen Comparator, und ein Comparator ist ein Objekt mit der Methode compare(). Vor Java 8 musstest du dafür eine anonyme Klasse schreiben:

List<Book> books = new ArrayList<>(BOOKS);
books.sort(new Comparator<Book>() {
  @Override
  public int compare(Book a, Book b) {
    return Integer.compare(a.year(), b.year());
  }
});

Sechs Zeilen für den Comparator, von denen nur eine relevant ist: die Vergleichsfunktion Integer.compare(a.year(), b.year()). Mit einem Lambda-Ausdruck schreibst du das Gleiche in nur einer Zeile:

books.sort((a, b) -> Integer.compare(a.year(), b.year()));

Alles, was die anonyme Klasse ausgeschrieben hat, leitet der Compiler jetzt aus dem Kontext ab: sort() erwartet einen Comparator<Book>, also sind a und b Bücher, und der Ausdruck rechts vom Pfeil ist der Rückgabewert von compare().

Oben die anonyme Klasse, darunter der Lambda-Ausdruck; zwei Pfeile zeigen, dass aus der Parameterliste (Book a, Book b) das Paar (a, b) wird und aus der return-Anweisung der Ausdruck rechts vom Pfeil; Klassenname, Methodenname, Rückgabetyp, die Parametertypen Book und die Annotation @Override sind grau, denn sie haben im Lambda kein Gegenstück
Ein Lambda-Ausdruck behält die Parameter und den Rumpf der einen Methode der anonymen Klasse – alles andere leitet der Compiler aus dem Zieltyp ab

Verhalten übergeben konnten wir also vor Java 8 nur indirekt – durch ein Objekt mit einer Methode, die eben dieses Verhalten implementiert. Ab Java 8 übergeben wir das Verhalten direkt: Statt einer anonymen Klasse mit sechs Zeilen schreiben wir nur noch einen einzeiligen Lambda-Ausdruck. Dieselbe Methode – aber ohne den Boilerplate-Code drumherum.

Damit wird das Übergeben von Verhalten so alltäglich wie das Übergeben einer Zahl, und darauf baut die Stream-API auf: Methoden wie filter(), map() und sorted() bekommen das Verhalten, das sie anwenden, als Argument. Die drei Operationen werden so zu drei Zeilen – dieselbe Pipeline mit anonymen Klassen würde niemand lesen wollen.

Die Syntax von Lambda-Ausdrücken

Jeder Lambda-Ausdruck hat die Form (Parameter) -> Rumpf. Links vom Pfeil steht die Parameterliste, rechts der Rumpf.

Parameter

Die Parameterliste hat keinen, einen oder mehrere Parameter:

// kein Parameter: leere Klammern
Supplier<Book> firstBook = () -> BOOKS.get(0);

// ein Parameter: Die Klammern sind optional
Predicate<Book> isGothic = book -> book.genre() == GOTHIC;

// mehrere Parameter: Die Klammern sind Pflicht
Comparator<Book> byYear = (a, b) -> Integer.compare(a.year(), b.year());

Supplier, Predicate und Comparator sind die Typen dieser Lambdas. Was sie sind und woher sie kommen, ist Thema des Kapitels Wie der Compiler den Typ eines Lambdas bestimmt.

Der Rumpf: Ausdruck oder Block

Der Rumpf ist entweder ein einzelner Ausdruck oder ein Block in geschweiften Klammern:

// Ausdruck als Rumpf: Der Wert des Ausdrucks ist der Rückgabewert
Function<Book, String> title = book -> book.title();

// Block als Rumpf: Anweisungen in geschweiften Klammern
// und ein `return`, wenn ein Wert erwartet wird
Function<Book, String> label = book -> {
  String decade = (book.year() / 10 * 10) + "s";
  return book.title() + " (" + decade + ")";
};

Für „Dracula“ aus der Library.BOOKS-Liste liefert label den Wert Dracula (1890s).

Ein Lambda, das nichts zurückgibt – ein Consumer oder ein Runnable z. B. –, hat als Rumpf entweder einen Block ohne return – oder einen Ausdruck, der auch als Anweisung stehen dürfte: meist einen Methodenaufruf, aber auch eine Zuweisung oder ein Inkrement wie i++.

Consumer<Book> print = book -> System.out.println(book.title());

Ich empfehle als Rumpf den Ausdruck überall dort, wo er passt. Ein Block mit mehr als zwei oder drei Anweisungen ist ein Zeichen dafür, dass der Code in eine benannte Methode gehört – mehr dazu im Abschnitt Zu lange Lambdas.

Parametertypen: abgeleitet, explizit, var

In allen bisherigen Beispielen hat der Compiler die Parametertypen abgeleitet – aus Comparator<Book> folgt, dass a und b Bücher sind. Ein solches Lambda heißt implizit typisiert. Du darfst die Typen auch ausschreiben, dann ist das Lambda explizit typisiert:

Comparator<Book> byYear = (Book a, Book b) -> Integer.compare(a.year(), b.year());

Wann ist das nötig? Wenn der Compiler den Typ nicht ableiten kann. Ein typischer Fall ist eine Kette von Aufrufen auf einer generischen Methode:

// kompiliert
Comparator<Book> byYearAscending = Comparator.comparing(book -> book.year());

// kompiliert nicht
Comparator<Book> byYearDescending =
    Comparator.comparing(book -> book.year()).reversed();
error: cannot find symbol
  symbol:   method year()
  location: variable book of type Object

Comparator.comparing() ist eine generische Methode: Welchen Typ ihr Parameter hat, leitet der Compiler aus dem Zieltyp des Aufrufs ab – in der Zeile ohne reversed() also aus der Variablen Comparator<Book>.

Mit reversed() gehört Comparator<Book> nicht mehr zum Aufruf von comparing(), sondern zum Ergebnis von reversed(). Und diese Typ-Information wandert nicht nach links zurück zu comparing(). Der Compiler typisiert comparing() deshalb für sich allein, setzt Object ein – und ein Object hat keine Methode year().

Ein expliziter Parametertyp oder eine Methodenreferenz sagt dem Compiler den Typ direkt:

Comparator<Book> byYearDescending =
    Comparator.comparing((Book book) -> book.year()).reversed();

Comparator<Book> byYearDescending =
    Comparator.comparing(Book::year).reversed();

Seit Java 11 darfst du var statt des Typs schreiben. Das ist nicht kürzer, als den Typ wegzulassen, und hat einen anderen Zweck: var gibt einer Annotation einen Platz, ohne dass du den Typ schreiben musst – (@Nonnull var book) ->. Ganz ohne Typ darfst du keine Annotation schreiben: (@Nonnull book) -> ist ein Syntaxfehler.

Die drei Formen lassen sich nicht mischen – entweder alle Parameter ohne Typ, alle mit Typ oder alle mit var.

Seit Java 22 schreibst du einen Parameter, den du nicht verwendest, als Unterstrich:

Map<String, List<Book>> byAuthor = new HashMap<>();
for (Book book : BOOKS) {
  byAuthor.computeIfAbsent(book.author(), _ -> new ArrayList<>()).add(book);
}

computeIfAbsent() übergibt dem Lambda den Schlüssel, und das Lambda braucht ihn nicht. Der Unterstrich sagt genau das aus; bei einem Namen wie key oder ignored müsstest du hingegen nachsehen, ob der Parameter irgendwo verwendet wird.

Ich empfehle grundsätzlich die implizit typisierte Form, einen expliziten Typ nur dort, wo der Compiler ihn nicht ableiten kann, var nur, wenn ein Parameter eine Annotation braucht, und den Unterstrich für jeden Parameter, den du nicht verwendest.

Wie der Compiler den Typ eines Lambdas bestimmt

Für sich allein hat ein Lambda-Ausdruck keinen Typ. Sein Typ ergibt sich aus dem Kontext, in dem er steht – dem Zieltyp (englisch target type). Vier solche Kontexte gibt es: eine Zuweisung, ein Methodenargument, eine return-Anweisung und einen Cast.

// Zuweisung: Der Zieltyp ist der deklarierte Typ der Variablen
Predicate<Book> isGothic = book -> book.genre() == GOTHIC;

// Methodenargument: Der Zieltyp ist der Parametertyp von filter()
Stream<Book> gothicBooks = BOOKS.stream().filter(book -> book.genre() == GOTHIC);

// return-Anweisung: Der Zieltyp ist der Rückgabetyp der Methode
static Predicate<Book> publishedAfter(int year) {
  return book -> book.year() > year;
}

// Cast: Der Zieltyp ist der Typ im Cast
Object byYear = (Comparator<Book>) (a, b) -> Integer.compare(a.year(), b.year());

Deshalb funktioniert var bei einem Lambda auch nicht:

var isGothic = book -> book.genre() == GOTHIC;
error: cannot infer type for local variable isGothic
  (lambda expression needs an explicit target-type)

var würde den Typ vom Lambda nehmen, und das Lambda würde ihn von var nehmen. Keines von beiden hat einen.

Funktionale Interfaces

Der Zieltyp muss ein funktionales Interface sein: ein Interface mit genau einer abstrakten Methode. Ein Beispiel für ein funktionales Interface ist Comparator<Book>: Seine abstrakte Methode ist int compare(Book a, Book b). Der Compiler gleicht das Lambda mit dieser Methode ab: Das Lambda muss entsprechend zwei Book-Parameter entgegennehmen, und sein Rumpf muss ein int liefern.

Das JDK bringt ein Package mit allgemeinen funktionalen Interfaces mit, java.util.function. Vier davon decken das meiste ab, was du schreiben wirst:

InterfaceMethodeDas Lambda …
Predicate<T>boolean test(T t)nimmt ein T entgegen und liefert true oder false
Function<T, R>R apply(T t)nimmt ein T entgegen und liefert ein R
Consumer<T>void accept(T t)nimmt ein T entgegen und liefert nichts
Supplier<T>T get()nimmt nichts entgegen und liefert ein T

Das Package enthält mehr als 40 solcher Interfaces – u. a. mit zwei Parametern (z. B. BiFunction), mit primitiven Typen (z. B. IntPredicate) und mit demselben Typ als Ein- und Ausgabe (z. B. UnaryOperator). Welches du wann brauchst, wie du sie mit andThen() und negate() kombinierst und wie du eigene schreibst, wird ein eigener Artikel zeigen.

Überladene Methoden

Angenommen, es gibt zwei Methoden namens run(): Die eine erwartet einen Consumer<String>, die andere eine Function<String, String>. Übergibst du run() ein Lambda, muss der Compiler entscheiden, welche der beiden Methoden er aufruft. Bei einem implizit typisierten Lambda (also einem ohne Nennung des Typs) kann er das nicht:

static void run(Consumer<String> consumer) { … }
static void run(Function<String, String> function) { … }

run(s -> s.trim()); // kompiliert nicht
error: reference to run is ambiguous
  both method run(Consumer<String>) and method run(Function<String,String>) match

Der Rumpf s.trim() passt zu beiden: Als Function liefert er den getrimmten String, als Consumer ruft er trim() auf und verwirft das Ergebnis.

Drei Schreibweisen lösen die Mehrdeutigkeit auf:

  • Mit einem expliziten Parametertyp prüft der Compiler den Rumpf gegen beide Überladungen und wählt die, deren Methode einen Wert liefert: run((String s) -> s.trim()) ruft die Function-Überladung auf.
  • Ein Block ohne return wie s -> { s.trim(); } liefert nichts und ist deshalb ein Consumer.
  • Ein Block mit return wie s -> { return s.trim(); } liefert einen Wert und ist deshalb eine Function.

Im JDK begegnet dir dieser Fall bei ExecutorService.submit(), das mit Runnable und Callable überladen ist. submit(() -> "done") ruft die Callable-Überladung auf, denn ein String ist keine Anweisung und kann deshalb nicht der Rumpf eines Runnable sein.

Variablenzugriff: effectively final, Gültigkeitsbereich und this

Zugriff auf lokale Variablen

Ein Lambda darf die lokalen Variablen und Parameter der Methode verwenden, in der es steht:

int minYear = 1890;
List<Book> recentBooks = BOOKS.stream()
    .filter(book -> book.year() > minYear)
    .toList();

Das Lambda erfasst (englisch captures) die Variable minYear. Die Bedingung dafür: Die Variable ist effectively final – d. h. du weist ihr nach der Initialisierung nie wieder etwas zu. Eine Variable, die sich ändert, kann hingegen vom Lambda nicht erfasst werden:

int count = 0;
BOOKS.stream().forEach(book -> count++); // kompiliert nicht
error: local variables referenced from a lambda expression must be final or effectively final

Warum ist das so? Ein Lambda läuft möglicherweise später als die Methode, die es erzeugt hat, und in einem anderen Thread. Das Lambda bekommt deshalb keine Referenz auf die Variable, sondern eine Kopie ihres Werts, angefertigt beim Erzeugen des Lambdas. Könnte sich die Variable danach ändern, würde das Lambda mit einem veralteten Wert arbeiten und die Methode mit dem aktuellen. Java schließt das aus, indem es die Änderung verbietet.

Vielleicht kennst du den Workaround mit einem Array aus nur einem Element oder einem AtomicInteger:

int[] count = {0};
BOOKS.stream().forEach(book -> count[0]++);

Das kompiliert, weil count selbst nie neu zugewiesen wird – nur sein Inhalt ändert sich. Aber das Lambda hat jetzt einen Seiteneffekt auf Zustand außerhalb der Pipeline, und in einem parallelen Stream schreiben mehrere Threads gleichzeitig in diesen Zustand, was zu Race Conditions führen könnte.

Threadsicher wäre ein AtomicInteger:

AtomicInteger count = new AtomicInteger();
BOOKS.stream().forEach(book -> count.incrementAndGet());

Ich empfehle aber, aus einer Pipeline heraus keinen Code mit Seiteneffekten aufzurufen und stattdessen die Pipeline den Wert berechnen zu lassen:

long count = BOOKS.stream().count();

Kein Shadowing

Ein Lambda-Parameter darf nicht den Namen einer lokalen Variablen tragen, die bereits sichtbar ist:

String s = "Dracula";
Function<String, String> trimmed = s -> s.trim(); // kompiliert nicht
error: variable s is already defined in method main(String[])

Der Rumpf eines Lambdas gehört zum Gültigkeitsbereich, in dem es steht, und hat keinen eigenen. Das ist ein Unterschied zur anonymen Klasse, deren Methodenparameter die umgebenden Variablen verdecken dürfen.

this ist die umgebende Instanz

Dieselbe Regel gilt für this: In einem Lambda ist this die Instanz der umgebenden Klasse. In einer anonymen Klasse ist this das anonyme Objekt:

public class Scope {

  void run() {
    Runnable lambda = () -> System.out.println(this.getClass().getName());
    Runnable anonymous = new Runnable() {
      @Override
      public void run() {
        System.out.println(this.getClass().getName());
      }
    };
    lambda.run();
    anonymous.run();
  }
}
Scope
Scope$1

Das Lambda gibt Scope aus, den Namen der umgebenden Klasse. Die anonyme Klasse gibt dagegen ihren eigenen Klassennamen aus, Scope$1, denn in ihr zeigt this auf das anonyme Objekt.

Für ein Lambda ist das bequem: Du verwendest die Felder und Methoden deiner Klasse so wie überall sonst in ihr, und auch eine Methodenreferenz wie this::describe funktioniert:

public class BookFilter {

  private final int minYear;

  public BookFilter(int minYear) {
    this.minYear = minYear;
  }

  public List<String> recentTitles() {
    return BOOKS.stream()
        .filter(book -> book.year() > minYear)
        .map(this::describe)
        .toList();
  }

  private String describe(Book book) {
    return book.title() + " (" + book.year() + ")";
  }
}

Das Lambda in filter() liest das Feld minYear, und this::describe verweist auf die Methode describe() – beide gehören zu der BookFilter-Instanz, auf der du recentTitles() aufrufst.

In einer anonymen Klasse würde this::describe dagegen nicht kompilieren, denn dort sucht der Compiler describe() im anonymen Objekt.

Methodenreferenzen

Ein Lambda, das nichts tut, als eine Methode aufzurufen – book -> book.title() z. B. –, lässt sich als Methodenreferenz schreiben: Book::title. Die zwei Doppelpunkte trennen den Typ oder das Objekt links vom Methodennamen rechts. Eine Methodenreferenz lässt die Parameter weg, denn der Compiler kennt sie aus dem Zieltyp.

Es gibt vier Arten von Methodenreferenzen, unterschieden danach, was links von den Doppelpunkten steht und was mit den Parametern des Lambdas passiert:

ArtSyntaxEntsprechendes Lambda
Statische MethodeType::staticMethodx -> Type.staticMethod(x)
Instanzmethode eines bestimmten Objektsobject::methodx -> object.method(x)
Instanzmethode eines beliebigen Objekts eines TypsType::methodx -> x.method()
KonstruktorType::newx -> new Type(x)
Vier Zeilen, eine je Art von Methodenreferenz: String::valueOf übergibt das Element x als Argument an die statische Methode; System.out::println übergibt x als Argument an println() auf dem Objekt System.out; Book::title ruft title() auf x selbst auf; ArrayList::new übergibt x an den Konstruktor – in der ersten, zweiten und vierten Zeile landet der Pfeil in der Argumentliste, in der dritten auf dem Objekt vor dem Punkt
Was mit dem Parameter passiert: Drei Arten übergeben ihn als Argument, nur die Referenz auf eine Instanzmethode eines Typs ruft die Methode auf dem Parameter selbst auf

Die folgenden vier Abschnitte zeigen jede der vier Arten zuerst als Lambda und danach als die Methodenreferenz, die daraus wird.

Referenz auf eine statische Methode

Links von den Doppelpunkten steht ein Typ, und die Methode ist statisch. Die Parameter des Lambdas werden zu den Argumenten dieser Methode.

Im folgenden Beispiel übergibt das zweite Lambda jedes Erscheinungsjahr an die statische Methode String.valueOf():

List<String> years = BOOKS.stream()
    .map(book -> book.year())
    .map(year -> String.valueOf(year))
    .toList();

Die Methodenreferenz dafür ist String::valueOf:

List<String> years = BOOKS.stream()
    .map(book -> book.year())
    .map(String::valueOf)
    .toList();

Referenz auf eine Instanzmethode eines bestimmten Objekts

Links von den Doppelpunkten steht ein Objekt, und die Parameter werden zu den Argumenten der Methode, die auf diesem Objekt aufgerufen wird.

Im folgenden Beispiel übergibt das Lambda in forEach() jeden Titel an die Methode println() des Objekts System.out:

BOOKS.stream()
    .map(book -> book.title())
    .forEach(title -> System.out.println(title));

Die Methodenreferenz dafür ist System.out::println:

BOOKS.stream()
    .map(book -> book.title())
    .forEach(System.out::println);

Das Objekt darf auch this oder super sein: this::describe aus dem BookFilter-Beispiel verweist auf eine Methode der aktuellen Instanz. Mit super:: statt this:: verweist die Referenz auf die Methode der Oberklasse – auch dann, wenn die aktuelle Klasse sie überschreibt.

Referenz auf eine Instanzmethode eines beliebigen Objekts eines Typs

Links von den Doppelpunkten steht ein Typ, und die Methode ist eine Instanzmethode dieses Typs. Anders als bei den beiden Arten davor wird der erste Parameter des Lambdas dabei nicht als Argument übergeben: Er wird zu dem Objekt, auf dem die Methode aufgerufen wird – dem Empfänger –, und nur die weiteren Parameter werden zu Argumenten.

Im folgenden Beispiel rufen beide Lambdas eine Methode auf ihrem ersten Parameter auf – title() auf dem Buch und compareToIgnoreCase() auf dem ersten der beiden Titel:

List<String> titles = BOOKS.stream()
    .map(book -> book.title())
    .sorted((a, b) -> a.compareToIgnoreCase(b))
    .toList();

Die Methodenreferenzen dafür sind Book::title und String::compareToIgnoreCase:

List<String> titles = BOOKS.stream()
    .map(Book::title)
    .sorted(String::compareToIgnoreCase)
    .toList();

Bei Book::title wird der einzige Parameter zum Empfänger. Bei String::compareToIgnoreCase wird der erste Parameter zum Empfänger und der zweite zum Argument.

Wie unterscheidest du diese Art von einer Referenz auf eine statische Methode? Bei beiden steht ein Typ links von den Doppelpunkten; der Unterschied ist, ob die Methode statisch ist. String::valueOf ist statisch, String::compareToIgnoreCase nicht. Der Compiler prüft das gegen den Zieltyp, und wo ein Typ eine statische und eine Instanzmethode mit demselben Namen hat, führt das zu der Mehrdeutigkeit, die der Abschnitt Lambda oder Methodenreferenz? beschreibt.

Referenz auf einen Konstruktor

Type::new steht für ein Lambda, das ein neues Objekt erzeugt. Welcher Konstruktor gemeint ist, folgt aus den Parametern des Zieltyps.

Die folgenden zwei Lambdas rufen je einen Konstruktor auf – das erste ohne, das zweite mit Parameter:

Supplier<List<Book>> newList = () -> new ArrayList<>();
Function<String, StringBuilder> builder = s -> new StringBuilder(s);

Die Methodenreferenzen dafür sind ArrayList::new und StringBuilder::new:

Supplier<List<Book>> newList = ArrayList::new;
Function<String, StringBuilder> builder = StringBuilder::new;

Dasselbe funktioniert für Arrays, wobei der Parameter die Länge des Arrays ist. Das ist die Form, die toArray() erwartet.

Im folgenden Beispiel erzeugt ein Lambda das Array, in das toArray() die Titel schreibt:

String[] titles = BOOKS.stream()
    .map(book -> book.title())
    .toArray(length -> new String[length]);

Die Methodenreferenz dafür ist String[]::new:

String[] titles = BOOKS.stream()
    .map(book -> book.title())
    .toArray(String[]::new);

Lambda oder Methodenreferenz?

Eine Methodenreferenz passt, wenn das Lambda seine Parameter unverändert und in derselben Reihenfolge an die Methode durchreicht. Das ist in allen Beispielen oben der Fall, und dann empfehle ich die Referenz: Sie nennt die Methode und sonst nichts, und sie hat keine Parameternamen, die in die Irre führen könnten.

Ein Lambda passt in drei Fällen. Erstens, wenn die Argumente nicht nur durchgereicht werden – book -> book.year() > 1890 enthält einen Vergleich, (a, b) -> b.compareTo(a) vertauscht die Reihenfolge. Zweitens, wenn das Lambda mehr als eine Methode aufruft. Und drittens, wenn die Referenz mehrdeutig ist:

List<String> numbers = Stream.of(1, 2, 3)
    .map(Integer::toString) // kompiliert nicht
    .toList();
error: incompatible types: cannot infer type-variable(s) R
      reference to toString is ambiguous
        both method toString(int) in Integer and method toString() in Integer match

Integer hat eine statische Methode toString(int) und eine Instanzmethode toString(), und beide passen zu einer Function<Integer, String>. Das Lambda i -> i.toString() sagt, welche du meinst; String::valueOf ebenso.

Wo Lambdas verwendet werden

Die Stream-API ist der sichtbarste Ort, aber bei weitem nicht der einzige. Überall, wo ein Methodenparameter ein funktionales Interface als Typ hat, passt ein Lambda oder eine Methodenreferenz. Im Javadoc erkennst du solche Parameter an Typen wie Predicate, Function, Comparator und Runnable. Die folgenden Beispiele stammen alle aus dem JDK:

// Sortieren mit einem Comparator
List<Book> books = new ArrayList<>(BOOKS);
books.sort(Comparator.comparing(Book::year));

// Collections: removeIf() und forEach()
books.removeIf(book -> book.year() < 1850);
books.forEach(book -> System.out.println(book.title()));

// Maps: computeIfAbsent() erzeugt den Wert beim ersten Zugriff
Map<String, List<Book>> byAuthor = new HashMap<>();
for (Book book : BOOKS) {
  byAuthor.computeIfAbsent(book.author(), _ -> new ArrayList<>()).add(book);
}

// Optional: map() läuft nur, wenn ein Wert da ist, orElseGet() nur ohne Wert
String firstGothicTitle = BOOKS.stream()
    .filter(book -> book.genre() == GOTHIC)
    .findFirst()
    .map(Book::title)
    .orElseGet(() -> "no gothic novel found");

// Threads: Runnable
Thread thread = new Thread(
    () -> System.out.println("running in " + Thread.currentThread().getName()));
thread.start();

Das computeIfAbsent()-Beispiel gruppiert die Bücher nach Autor:in und legt die Liste für einen Namen an, sobald er zum ersten Mal auftaucht. Sein Lambda verwendet den Unterstrich aus Java 22; auf einer älteren Java-Version nennst du den Parameter author.

Kürzer geht dieselbe Gruppierung mit einem Stream und dem Collector groupingBy(), der die Listen selbst anlegt:

Map<String, List<Book>> byAuthor = BOOKS.stream()
    .collect(Collectors.groupingBy(Book::author));

computeIfAbsent() bleibt die richtige Wahl, wenn die Map schon existiert und die Einträge nach und nach dazukommen.

Wie die JVM ein Lambda ausführt

Ein Lambda-Ausdruck sieht aus wie eine Kurzschreibweise für eine anonyme Klasse, wird aber anders kompiliert.

Die folgende Klasse enthält einen einzigen Lambda-Ausdruck – an ihr sehen wir uns an, was der Compiler daraus macht:

public class LambdaDemo {

  public static void main(String[] args) {
    Predicate<Book> isGothic = book -> book.genre() == GOTHIC;
    System.out.println(isGothic.getClass().getName());
  }
}

Der javac-Compiler erzeugt eine Klassendatei, LambdaDemo.class – keine zweite für das Lambda, wie er es für eine anonyme Klasse täte. javap -p zeigt, was aus dem Rumpf des Lambdas geworden ist:

public class LambdaDemo {
  public LambdaDemo();
  public static void main(java.lang.String[]);
  private static boolean lambda$main$0(Book);
}

Der Rumpf ist zu einer privaten statischen Methode lambda$main$0() der umgebenden Klasse geworden, mit dem Parameter des Lambdas als ihrem Parameter. Ein Lambda, das this verwendet, wird stattdessen zu einer Instanzmethode.

Und an der Stelle, an der der Lambda-Ausdruck stand, enthält der Bytecode von main() eine einzige Instruktion:

0: invokedynamic #7, 0  // InvokeDynamic #0:test:()Ljava/util/function/Predicate;

invokedynamic ist eine Instruktion, deren Ziel zur Laufzeit festgelegt wird, bei ihrer ersten Ausführung. Bei einem Lambda trifft diese Entscheidung die JDK-Klasse LambdaMetafactory: Sie erzeugt eine Klasse, die Predicate implementiert und deren Methode test() die Methode lambda$main$0() aufruft, und legt eine Instanz davon an. Jede weitere Ausführung der Instruktion verwendet diese Klasse erneut. Das Programm oben gibt den Namen der erzeugten Klasse aus:

LambdaDemo$$Lambda/0x000007f001040800

Die Klasse existiert nicht auf der Festplatte und hat keine Quelldatei; die Zahl ändert sich mit jedem Lauf. Was du in einem Stack Trace siehst, ist lambda$main$0 – wie, zeigt der Abschnitt Lambdas im Stack Trace lesen.

Dass LambdaMetafactory diese Klasse erzeugt und ihre Instanzen anlegt, hat zwei Folgen für deinen Code.

Erstens: Ein Lambda, das keine Variablen erfasst, wird nur einmal erzeugt – LambdaMetafactory liefert bei jeder Ausführung der invokedynamic-Instruktion dieselbe Instanz. Ein Lambda, das Variablen erfasst, ist dagegen jedes Mal ein neues Objekt, denn die erfassten Werte werden darin gespeichert. In einer heißen Schleife kostet ein erfassendes Lambda deshalb eine kleine Allokation pro Auswertung.

Zweitens: equals() vergleicht bei Lambdas nur die Identität. Zwei Lambda-Ausdrücke mit demselben Rumpf sind zwei verschiedene Objekte, und equals() liefert für sie false.

Das wirkt sich aus, sobald eine Collection ein Lambda über equals() wiederfinden muss – etwa beim Entfernen aus einer Liste:

Predicate<Book> after1890 = book -> book.year() > 1890;
Predicate<Book> sameCondition = book -> book.year() > 1890;

List<Predicate<Book>> filters = new ArrayList<>(List.of(after1890));
filters.remove(sameCondition); // false – gleicher Rumpf, anderes Objekt
filters.remove(after1890);     // true

Entfernen kannst du ein Lambda also nur über die Referenz, unter der du es hinzugefügt hast. Dasselbe gilt für Listener: Meldest du einen Listener mit einem gleich aussehenden Lambda ab, bleibt der ursprünglich registrierte aktiv. Aus demselben Grund taugen Lambdas nicht als Map-Schlüssel.

Typische Fehler und Empfehlungen

Seiteneffekte in Lambdas

Ein Lambda, das in eine Variable außerhalb von sich selbst schreibt, funktioniert – bis der Code parallel läuft. Das Array aus nur einem Element im Abschnitt Zugriff auf lokale Variablen ist das kleinste Beispiel; eine ArrayList aus forEach() heraus zu füllen ist das häufigste, das ich sehe. Der Artikel über Java Streams zeigt den parallelen Fall und seine Lösung.

Am besten, du hältst dich an folgende Regel: Ein Lambda berechnet aus seinen Parametern einen Wert und ändert nichts außerhalb.

Checked Exceptions

Ein Lambda darf nur die Checked Exceptions werfen, die die Methode seines funktionalen Interfaces deklariert. Die Interfaces in java.util.function deklarieren keine, Runnable ebenso wenig:

Runnable pause = () -> Thread.sleep(1_000); // kompiliert nicht
error: unreported exception InterruptedException; must be caught or declared to be thrown

Fang die Exception innerhalb des Lambdas und verpacke sie in eine Unchecked Exception, oder verschiebe den Aufruf in eine Hilfsmethode, die das tut. Der Artikel über Java Streams zeigt die Hilfsmethode für IOException.

Zu lange Lambdas

Ein Block als Rumpf, der über zwei oder drei Anweisungen hinauswächst, verdeckt, was die Pipeline tut:

List<String> labels = BOOKS.stream()
    .map(book -> {
      String decade = (book.year() / 10 * 10) + "s";
      String authorInitials = Arrays.stream(book.author().split(" "))
          .map(name -> name.substring(0, 1))
          .collect(Collectors.joining());
      return book.title() + " (" + authorInitials + ", " + decade + ")";
    })
    .toList();

Verschiebe den Rumpf in eine benannte Methode und übergib eine Methodenreferenz:

List<String> labels = BOOKS.stream()
    .map(Library::label)
    .toList();

Die Pipeline bleibt dann auf einer Abstraktionsebene: Sie sagt nicht mehr, wie sie etwas tut, sondern was sie tut. Und die Methode hat einen Namen und lässt sich dokumentieren und für sich testen.

Ich empfehle das immer, sobald der Block beschreibt, wie etwas getan wird (und das ist oft schon ab zwei Zeilen der Fall). Ein Methodenname wie label() sagt mehr, als ein Block je könnte.

Rekursion in einem Lambda

Ein Lambda kann sich nicht über die Variable aufrufen, der es zugewiesen wird:

Function<Integer, Integer> factorial =
    n -> n <= 1 ? 1 : n * factorial.apply(n - 1); // kompiliert nicht
error: variable factorial might not have been initialized

Die Variable hat noch keinen Wert, während das Lambda erzeugt wird.

Als Feld kompiliert das Lambda, wenn es sich über this aufruft:

private final Function<Integer, Integer> factorial =
    n -> n <= 1 ? 1 : n * this.factorial.apply(n - 1);

Eine NullPointerException gibt es dabei nicht: Das Lambda liest this.factorial nicht beim Erzeugen, sondern erst, wenn es aufgerufen wird – und dann enthält das Feld längst das Lambda.

Einfacher ist eine benannte Methode, die sich selbst aufruft:

static int factorial(int n) {
  return n <= 1 ? 1 : n * factorial(n - 1);
}

Rekursion ist ein Fall für eine Methode, nicht für ein Lambda.

Lambdas im Stack Trace lesen

Eine Exception, die in einem Lambda geworfen wird, zeigt im Stack Trace den erzeugten Methodennamen. Diese Pipeline versucht, jeden Titel als Zahl zu parsen:

public class LambdaTrace {

  public static void main(String[] args) {
    List<Integer> numbers = BOOKS.stream()
        .map(book -> Integer.parseInt(book.title()))
        .toList();
  }
}
Exception in thread "main" java.lang.NumberFormatException: For input string: "Pride and Prejudice"
    at java.base/java.lang.NumberFormatException.forInputString(NumberFormatException.java:67)
    at java.base/java.lang.Integer.parseInt(Integer.java:529)
    at java.base/java.lang.Integer.parseInt(Integer.java:626)
    at LambdaTrace.lambda$main$0(LambdaTrace.java:7)
    at java.base/java.util.stream.ReferencePipeline$3$1.accept(ReferencePipeline.java:214)

    at java.base/java.util.stream.ReferencePipeline.toList(ReferencePipeline.java:663)
    at LambdaTrace.main(LambdaTrace.java:8)

Die erste Zeile sagt, was passiert ist und warum: eine NumberFormatException, weil Integer.parseInt() den String "Pride and Prejudice" nicht als Zahl lesen kann.

Die drei Frames darunter gehören dem JDK: Dort wirft Integer.parseInt() die Exception. Der erste Frame deiner eigenen Klasse ist LambdaTrace.lambda$main$0, das erste Lambda in main() – die Zahl zählt die Lambdas dieser Methode von null an, in der Reihenfolge, in der der Compiler auf sie trifft. LambdaTrace.java:7 ist die Zeile des Lambdas.

Die Frames danach, bis hinunter zu main(), sind die Stream-Pipeline. Für die Fehlersuche genügen dir drei Zeilen: die erste mit der Exception und ihrer Ursache sowie die zwei Frames deiner eigenen Klasse (die fünfte und die letzte Zeile der Ausgabe oben).

Zusammenfassung

Ein Lambda-Ausdruck ist eine Funktion ohne Namen, die du einer Methode übergibst: Parameter, ein Pfeil, ein Rumpf. Sein Typ ist das funktionale Interface, das der Kontext erwartet, seine Parameter dürfen ohne Typ bleiben, und er darf die lokalen Variablen um sich herum lesen, solange sie effectively final sind. Wo ein Lambda nur eine Methode aufruft, ist die Methodenreferenz kürzer und lesbarer.

Drei Empfehlungen für den Alltag:

  • Halte Lambdas kurz: Sobald ein Block beschreibt, wie etwas getan wird, schreib eine Methode und referenziere sie.
  • Halte Lambdas frei von Seiteneffekten, dann funktionieren sie sequenziell wie parallel.
  • Bevorzuge eine Methodenreferenz überall dort, wo das Lambda seine Parameter unverändert an die Methode durchreicht.

Dieser Artikel hat den Lambda-Ausdruck selbst behandelt. Die funktionalen Interfaces, mit denen er typisiert wird, werden einen eigenen Artikel bekommen, ebenso Optional. Der sichtbarste Ort für Lambdas – die Stream-API – ist Thema des Artikels über Java Streams.

Was soll ich als Nächstes vertiefen? Das erfahre ich am besten durch dein Feedback: Eine Bewertung auf meinem ProvenExpert-Profil zeigt mir, welche Themen dir wichtig sind – und motiviert mich, weitere Artikel zu schreiben.

👉 Bewertung abgeben

Möchtest du benachrichtigt werden, wenn der nächste Java-Artikel erscheint? Dann klicke hier, um dich in den HappyCoders-Newsletter einzutragen.

👉 Newsletter-Anmeldung

Dieses Thema im eigenen Code?

Du hast den Artikel gelesen – im Training arbeitet ihr damit. Einen Tag lang gehen wir die Themen an euren eigenen Projekten durch, statt an konstruierten Beispielen.

Praxisnah, verständlich und direkt auf euren Projektalltag übertragbar. Statt Theorie vermittle ich Prinzipien, die euch helfen, Code langfristig besser, wartbarer und performanter zu schreiben.

Java Streams BasicsAlle Trainings ansehen

Werde ein:e bessere:r Java-Entwickler:in

Mit meinem kostenlosen Newsletter bleibst du vorn. Modernes Java: neue Versionen & Features, Performance und JVM-Insights – 1x im Monat.

Suche