Почему общая изменчивость плохой?
Я смотрел презентацию на Java, и в какой-то момент лектор сказал:
"Мутируемость в порядке, обмен хорош, совместная изменчивость - это работа дьявола".
То, что он имел в виду, это следующий фрагмент кода, который он считал "крайне вредной привычкой":
//double the even values and put that into a list.
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 1, 2, 3, 4, 5);
List<Integer> doubleOfEven = new ArrayList<>();
numbers.stream()
.filter(e -> e % 2 == 0)
.map(e -> e * 2)
.forEach(e -> doubleOfEven.add(e));
Затем он продолжил писать код, который должен использоваться, который:
List<Integer> doubleOfEven2 =
numbers.stream()
.filter(e -> e % 2 == 0)
.map(e -> e * 2)
.collect(toList());
Я не понимаю, почему первая часть кода - "плохая привычка". Для меня они достигают одной и той же цели.
Ответы
Ответ 1
Проблема возникает при выполнении параллельной обработки. Некоторое время назад я читал блог Хенрика Эйхенхардта, отвечая на вопрос , почему совместное изменчивое состояние является корнем всего зла.
Это короткая аргументация относительно того, почему общая изменчивость не является хорошей; извлеченный из блога.
не-детерминизм = параллельная обработка + изменяемое состояние
Это уравнение в основном означает, что как параллельная обработка, так и изменчивое состояние в совокупности приводит к недетерминированному поведению программы. Если вы просто выполняете параллельную обработку и имеете только неизменяемое состояние все в порядке, и легко рассуждать о программах. На если вы хотите выполнить параллельную обработку с изменяемыми данными, необходимо синхронизировать доступ к изменяемым переменным, которые по существу, делает эти разделы программы однопоточными. Это не совсем ново, но я не видел таких понятий, которые были так изящны. Непрерывная программа нарушена.
В этом блоге приводятся внутренние детали относительно того, почему нарушены параллельные программы без правильной синхронизации, которые вы можете найти в добавленной ссылке.
Ответ 2
Дело в том, что лекция слегка неверна в то же время. В примере, который он предоставил, используется forEach
, который документируется как:
Поведение этой операции явно недетерминировано. Для параллельных поточных конвейеров эта операция не гарантирует уважения порядка встречи потока, так как это принесет пользу parallelism...
Вы можете использовать:
numbers.stream()
.filter(e -> e % 2 == 0)
.map(e -> e * 2)
.parallel()
.forEachOrdered(e -> doubleOfEven.add(e));
И у вас всегда будет тот же гарантированный результат.
С другой стороны, пример, который использует Collectors.toList
, лучше, потому что коллекторы уважают encounter order
, поэтому он отлично работает.
Интересным моментом является то, что Collectors.toList
использует ArrayList
под ним, который не является потокобезопасной коллекцией. Он просто использует многие из них (для параллельной обработки) и объединяется в конце.
Последнее примечание о том, что параллельные и последовательные не влияют на порядок встреч, это операция, применяемая к Stream
. Отлично читайте здесь.
Нам также нужно подумать, что даже использование потоковой безопасности не полностью безопасно для Streams, особенно если вы полагаетесь на side-effects
.
List<Integer> numbers = Arrays.asList(1, 3, 3, 5);
Set<Integer> seen = Collections.synchronizedSet(new HashSet<>());
List<Integer> collected = numbers.stream()
.parallel()
.map(e -> {
if (seen.add(e)) {
return 0;
} else {
return e;
}
})
.collect(Collectors.toList());
System.out.println(collected);
collected
в этой точке может быть [0,3,0,0]
OR [0,0,3,0]
или что-то еще.
Ответ 3
Предположим, что два потока выполняют эту задачу одновременно, а вторая - одна инструкция за первой.
Первый поток создает doubleOfEven. Второй поток создает doubleOfEven, экземпляр, созданный первым потоком, будет собираться мусором. Затем оба потока добавят двойные числа всех четных чисел в doubleOfEvent, поэтому он будет содержать 0, 0, 4, 4, 8, 8, 12, 12,... вместо 0, 4, 8, 12... ( В действительности эти потоки не будут полностью синхронизированы, поэтому все, что может пойти не так, пойдет не так).
Не то, чтобы второе решение было намного лучше. У вас было бы два потока, устанавливающих один и тот же глобальный. В этом случае они устанавливают его как для логически равных значений, но если они устанавливают его на два разных значения, то вы не знаете, какое значение у вас есть. Один поток не получит желаемого результата.