Знакомство с лямбда-выражениями в квесте Java Syntax Zero начинается с крайне специфического примера:

ArrayList<string> list = new ArrayList<>();
Collections.addAll(list, "Привет", "как", "дела?");

list.forEach( (s) -> System.out.println(s) );
Авторы лекции разбирают лямбды и ссылки на методы с помощью стандартной функции forEach класса ArrayList. Лично мне было трудно понять смысл происходящего, поскольку реализация этой функции, равно как и связанного с ней интерфейса, остается «под капотом». Откуда берётся аргумент (s), куда передаётся функция println() — вопросы, на которые нам придётся отвечать самостоятельно. К счастью, с помощью IntelliJ IDEA мы легко можем заглянуть во внутреннее устройство класса ArrayList и размотать эту лапшу от самого начала. Если вы тоже ничего не поняли и хотите разобраться, постараюсь вам в этом хоть немного помочь. Лямбда-выражение и ArrayList.forEach — как это работает Из лекции мы уже знаем, что лямбда-выражение — это реализация функционального интерфейса. То есть мы объявляем интерфейс с одной-единственной функцией, а с помощью лямбды описываем, что эта функция делает. Для этого нужно: 1. Создать функциональный интерфейс; 2. Создать переменную, тип которой соответствует функциональному интерфейсу; 3. Присвоить этой переменной лямбда-выражение, которое описывает реализацию функции; 4. Вызвать функцию через обращение к переменной (возможно, я грубоват в терминологии, но так понятнее всего). Приведу простейший пример из гугла, снабдив его подробными комментариями (спасибо авторам сайта metanit.com):

interface Operationable {
    int calculate(int x, int y);
    // Единственная функция в интерфейсе — значит, это функциональный интерфейс,
    // который можно реализовать с помощью лямбды
}

public class LambdaApp {
 
    public static void main(String[] args) {

        // Создаём переменную operation типа Operationable (так называется наш функциональный интерфейс)
        Operationable operation;
        // Прописываем реализацию функции calculate с помощью лямбды, на вход подаём x и y, на выходе возвращаем их сумму
        operation = (x,y)->x+y;
        
        // Теперь мы можем обратиться к функции calculate через переменную operation
        int result = operation.calculate(10, 20);
        System.out.println(result); //30
    }
}
Теперь вернемся к примеру из лекции. В коллекцию list добавляются несколько элементов типа String. Затем элементы выводятся с помощью стандартной функции forEach, которая вызывается у объекта list. В качестве аргумента в функцию передается лямбда-выражение с каким-то странным параметром s.

ArrayList<string> list = new ArrayList<>();
Collections.addAll(list, "Привет", "как", "дела?");

list.forEach( (s) -> System.out.println(s) );
Если вы с ходу не поняли, что тут произошло, то вы не одиноки. К счастью, в IntelliJ IDEA есть замечательная комбинация клавиш Ctrl+Левая_Кнопка_Мыши. Если навести курсор на forEach и нажать эту комбинацию, откроется исходник стандартного класса ArrayList, в котором мы увидим реализацию метода forEach:

public void forEach(Consumer<? super E> action) {
    Objects.requireNonNull(action);
    final int expectedModCount = modCount;
    final Object[] es = elementData;
    final int size = this.size;
    for (int i = 0; modCount == expectedModCount && i < size; i++)
        action.accept(elementAt(es, i));
    if (modCount != expectedModCount)
        throw new ConcurrentModificationException();
}
Мы видим, что на вход подаётся аргумент action типа Consumer. Наведем курсор на слово Consumer и снова нажмем волшебную комбинацию Ctrl+LMB. Откроется описание интерфейса Consumer. Если убрать из него default-реализацию (нам она сейчас не важна), увидим вот такой код:

public interface Consumer<t> {
   void accept(T t);
}
Итак. У нас есть интерфейс Consumer с единственной функцией accept, которая принимает один аргумент любого типа. Раз функция одна, значит интерфейс функциональный, и его реализацию можно прописать через лямбда-выражение. Мы уже выяснили, что в ArrayList есть функция forEach, которая принимает на вход реализацию интерфейса Consumer в качестве аргумента action. Кроме того, в функции forEach мы находим вот такой код:

for (int i = 0; modCount == expectedModCount && i < size; i++)
    action.accept(elementAt(es, i));
Цикл for по сути перебирает все элементы коллекции ArrayList. Внутри цикла мы видим вызов функции accept объекта action — помните, как мы обращались к operation.calculate? В функцию accept передаётся текущий элемент коллекции. Теперь мы наконец можем вернуться к исходному лямбда-выражению и понять, что же оно делает. Соберём весь код в одну кучу:

public interface Consumer<t> {
   void accept(T t); // Функция, которую мы реализуем лямбда-выражением
}

public void forEach(Consumer<? super E> action) // В action хранится объект Consumer, в котором функция accept реализована нашей лямбдой {
    Objects.requireNonNull(action);
    final int expectedModCount = modCount;
    final Object[] es = elementData;
    final int size = this.size;
    for (int i = 0; modCount == expectedModCount && i < size; i++)
        action.accept(elementAt(es, i)); // Вызываем нашу реализацию функции accept интерфейса Consumer для каждого элемента коллекции
    if (modCount != expectedModCount)
        throw new ConcurrentModificationException();
}
	
//...

list.forEach( (s) -> System.out.println(s) );
Наше лямбда-выражение — это реализация функции accept, описанной в интерфейсе Consumer. С помощью лямбды мы прописали, что функция accept принимает аргумент s и выводит его на экран. Лямбда-выражение было передано в функцию forEach в качестве ее аргумента action, который как раз и хранит в себе реализацию интерфейса Consumer. Теперь функция forEach может вызвать нашу реализацию интерфейса Consumer с помощью вот такой строки:

action.accept(elementAt(es, i));
Таким образом, входной аргумент s в лямбда-выражении — это очередной элемент коллекции ArrayList, который передается в нашу реализацию интерфейса Consumer. На этом всё: логику работы лямбда-выражения в ArrayList.forEach мы разобрали. Ссылка на метод в ArrayList.forEach — а это как работает? Следующим шагом в лекции разбираются ссылки на методы. Правда, разбираются они как-то совсем странно — после прочтения лекции у меня не было никаких шансов понять, что же делает этот код:

list.forEach( System.out::println );
Для начала вновь немного теории. Ссылка на метод — это, если очень грубо, реализация функционального интерфейса, описанная другой функцией. Опять же начну с простого примера:

public interface Operationable {
    int calculate(int x, int y);
    // Единственная функция в интерфейсе — значит, это функциональный интерфейс
}

public static class Calculator {
    // Создадим статический класс Calculator и пропишем в нём метод methodReference.
    // Именно он будет реализовывать функцию calculate из интерфейса Operationable.
    public static int methodReference(int x, int y) {
        return x+y;
    }
}

public static void main(String[] args) {
    // Создаём переменную operation типа Operationable (так называется наш функциональный интерфейс)
    Operationable operation;
    // Теперь реализацией интерфейса будет не лямбда-выражение, а метод methodReference из нашего класса Calculator
    operation = Calculator::methodReference;

    // Теперь мы можем обратиться к функции интерфейса через переменную operation
    int result = operation.calculate(10, 20);
    System.out.println(result); //30
}
Вернёмся к примеру из лекции:

list.forEach( System.out::println );
Напомню, что System.out — это объект типа PrintStream, у которого есть функция println. Давайте наведём курсор на println и щёлкнем Ctrl+LMB:

public void println(String x) {
    if (getClass() == PrintStream.class) {
        writeln(String.valueOf(x));
    } else {
        synchronized (this) {
            print(x);
            newLine();
        }
    }
}
Отметим две ключевые особенности: 1. Функция println ничего не возвращает (void). 2. Функция println получает на вход один аргумент. Ничего не напоминает?

public interface Consumer<t> {
   void accept(T t);
}
Всё правильно — сигнатура функции accept — это более общий случай сигнатуры метода println! Значит, последний можно с успехом использовать в качестве ссылки на метод — то есть println становится конкретной реализацией функции accept:

list.forEach( System.out::println );
Мы передали функцию println объекта System.out в качестве аргумента функции forEach. Принцип тот же, что и с лямбдой: теперь forEach может передать элемент коллекции в функцию println через обращение action.accept(elementAt(es, i)). Фактически теперь это можно читать как System.out.println(elementAt(es, i)).

public void forEach(Consumer<? super E> action) // В action хранится объект Consumer, в котором функция accept реализована методом println {
        Objects.requireNonNull(action);
        final int expectedModCount = modCount;
        final Object[] es = elementData;
        final int size = this.size;
        for (int i = 0; modCount == expectedModCount && i < size; i++)
            action.accept(elementAt(es, i)); // Функция accept теперь реализована методом System.out.println!
        if (modCount != expectedModCount)
            throw new ConcurrentModificationException();
    }
Надеюсь, что я хоть немного прояснил ситуацию для тех, кто впервые знакомится с лямбдами и ссылками на методы. В заключение порекомендую известнейшую книгу "Java: руководство для начинающих" под авторством Роберта Шилдта — на мой взгляд, лямбды и ссылки на функции в ней описаны довольно толково.