«1. Введение

Java 8 дает нам лямбда-выражения и, по ассоциации, понятие эффективных конечных переменных. Вы когда-нибудь задумывались, почему локальные переменные, захваченные в лямбда-выражениях, должны быть окончательными или фактически окончательными?

Что ж, JLS дает нам небольшую подсказку, когда говорит: «Ограничение на фактически окончательные переменные запрещает доступ к динамически изменяющимся локальным переменным, захват которых, вероятно, приведет к проблемам параллелизма». Но что это значит? ?

В следующих разделах мы углубимся в это ограничение и выясним, почему оно было введено в Java. Мы покажем примеры, чтобы продемонстрировать, как это влияет на однопоточные и параллельные приложения, а также развенчаем распространенный антишаблон для обхода этого ограничения.

2. Захват лямбда-выражений

Лямбда-выражения могут использовать переменные, определенные во внешней области видимости. Мы называем эти лямбда-выражения лямбда-выражениями захвата. Они могут захватывать статические переменные, переменные экземпляра и локальные переменные, но только локальные переменные должны быть окончательными или фактически окончательными.

В более ранних версиях Java мы сталкивались с этим, когда анонимный внутренний класс захватывал переменную, локальную для метода, который его окружал — нам нужно было добавить ключевое слово final перед локальной переменной, чтобы компилятор был счастлив.

Немного синтаксического сахара: теперь компилятор может распознавать ситуации, когда ключевое слово final отсутствует, а ссылка вообще не меняется, то есть фактически является final. Мы могли бы сказать, что переменная фактически final, если бы компилятор не жаловался, если бы мы объявили ее final.

3. Локальные переменные при захвате лямбда-выражений

Проще говоря, это не будет компилироваться:

Supplier<Integer> incrementer(int start) {
  return () -> start++;
}

start — это локальная переменная, и мы пытаемся изменить ее внутри лямбда-выражения.

Основная причина того, что это не скомпилируется, заключается в том, что лямбда захватывает значение start, то есть делает его копию. Принудительное определение переменной как final позволяет избежать впечатления, что увеличение start внутри лямбды может фактически изменить параметр метода start.

Но почему он делает копию? Обратите внимание, что мы возвращаем лямбду из нашего метода. Таким образом, лямбда не запустится до тех пор, пока параметр метода start не будет собран мусором. Java должен сделать копию start, чтобы эта лямбда жила вне этого метода.

3.1. Проблемы параллелизма

Ради интереса давайте представим на мгновение, что Java позволяет локальным переменным каким-то образом оставаться связанными с их захваченными значениями.

Что нам здесь делать:

public void localVariableMultithreading() {
    boolean run = true;
    executor.execute(() -> {
        while (run) {
            // do operation
        }
    });
    
    run = false;
}

Хотя это выглядит невинно, у него есть коварная проблема «видимости». Вспомните, что каждый поток получает свой собственный стек, и как мы можем гарантировать, что наш цикл while увидит изменение переменной run в другом стеке? Ответом в других контекстах может быть использование синхронизированных блоков или ключевого слова volatile.

Однако, поскольку Java накладывает фактически окончательное ограничение, нам не нужно беспокоиться о подобных сложностях.

4. Статические переменные или переменные экземпляра при захвате лямбда-выражений

Приведенные выше примеры могут вызвать некоторые вопросы, если мы сравним их с использованием статических переменных или переменных экземпляра в лямбда-выражении.

Мы можем скомпилировать наш первый пример, просто преобразовав нашу начальную переменную в переменную экземпляра:

private int start = 0;

Supplier<Integer> incrementer() {
    return () -> start++;
}

Но почему мы можем изменить здесь значение start?

Проще говоря, речь идет о том, где хранятся переменные-члены. Локальные переменные находятся в стеке, а переменные-члены — в куче. Поскольку мы имеем дело с динамической памятью, компилятор может гарантировать, что лямбда-выражение будет иметь доступ к последнему значению start.

Мы можем исправить наш второй пример, сделав то же самое:

private volatile boolean run = true;

public void instanceVariableMultithreading() {
    executor.execute(() -> {
        while (run) {
            // do operation
        }
    });

    run = false;
}

Переменная run теперь видна лямбде, даже если она выполняется в другом потоке, так как мы добавили ключевое слово volatile.

Вообще говоря, при захвате переменной экземпляра мы могли бы думать об этом как о захвате конечной переменной this. В любом случае, тот факт, что компилятор не жалуется, не означает, что мы не должны принимать меры предосторожности, особенно в многопоточных средах.

5. Избегайте обходных путей

«Чтобы обойти ограничение на локальные переменные, кто-то может подумать об использовании держателей переменных для изменения значения локальной переменной.

Давайте рассмотрим пример, использующий массив для хранения переменной в однопоточном приложении:

public int workaroundSingleThread() {
    int[] holder = new int[] { 2 };
    IntStream sums = IntStream
      .of(1, 2, 3)
      .map(val -> val + holder[0]);

    holder[0] = 0;

    return sums.sum();
}

Мы могли бы подумать, что поток суммирует 2 для каждого значения, но на самом деле он суммирует 0, поскольку это последнее значение, доступное при выполнении лямбды.

Давайте сделаем еще один шаг и выполним суммирование в другом потоке:

public void workaroundMultithreading() {
    int[] holder = new int[] { 2 };
    Runnable runnable = () -> System.out.println(IntStream
      .of(1, 2, 3)
      .map(val -> val + holder[0])
      .sum());

    new Thread(runnable).start();

    // simulating some processing
    try {
        Thread.sleep(new Random().nextInt(3) * 1000L);
    } catch (InterruptedException e) {
        throw new RuntimeException(e);
    }

    holder[0] = 0;
}

Какое значение мы здесь суммируем? Это зависит от того, сколько времени займет наша смоделированная обработка. Если он достаточно короткий, чтобы выполнение метода завершилось до того, как будет выполнен другой поток, он напечатает 6, в противном случае он напечатает 12.

В общем, такие обходные пути подвержены ошибкам и могут привести к непредсказуемым результаты, поэтому мы всегда должны избегать их.

6. Заключение

В этой статье мы объяснили, почему лямбда-выражения могут использовать только конечные или фактически конечные локальные переменные. Как мы видели, это ограничение происходит из-за различной природы этих переменных и того, как Java хранит их в памяти. Мы также показали опасность использования общего обходного пути.

Как всегда, полный исходный код примеров доступен на GitHub.