Рубрики
Без рубрики

Почему локальные переменные, используемые в Лямбдах, должны быть окончательными или Фактически окончательными?

Узнайте, почему Java требует, чтобы локальные переменные были эффективно окончательными при использовании в лямбда-формуле.

Автор оригинала: Marcos Lopez Gonzalez.

1. введение

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

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

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

2. Захват Лямбд

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

private int start = 0;

Supplier 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 .