1. Введение
Java 8 дает нам лямбда-выражения и, по ассоциации, понятие эффективных конечных
переменных. Вы когда-нибудь задумывались, почему локальные переменные, захваченные в лямбда-выражениях, должны быть окончательными или фактически окончательными?
Что ж, JLS дает нам небольшую подсказку, когда говорит: «Ограничение на фактически окончательные переменные запрещает доступ к динамически изменяющимся локальным переменным, захват которых, вероятно, приведет к проблемам параллелизма». Но что это значит?
В следующих разделах мы углубимся в это ограничение и выясним, почему оно было введено в Java. Мы покажем примеры, чтобы продемонстрировать , как это влияет на однопоточные и параллельные приложения , а также развенчаем распространенный антишаблон для обхода этого ограничения.
2. Захват лямбд
Лямбда-выражения могут использовать переменные, определенные во внешней области. Мы называем эти лямбда-выражения лямбда-выражениями захвата
. Они могут захватывать статические переменные, переменные экземпляра и локальные переменные, но только локальные переменные должны быть окончательными или фактически окончательными.
В более ранних версиях Java мы сталкивались с этим, когда анонимный внутренний класс захватывал переменную, локальную для метода, который его окружал — нам нужно было добавить ключевое слово 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;
}
Переменная выполнения
теперь видна для лямбды, даже если она выполняется в другом потоке, поскольку мы добавили ключевое слово 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 .