Статьи

Случайный случай в Java SE 7

В прошлом году я написал серию постов ( часть 1 часть 2 часть 3 ) об использовании новой платформы Java fork / join для симуляции Монте-Карло.

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

Нить локальных случайных чисел

Чтобы перейти к более интересным вещам: я читал  заметки о выпуске Oracle для Java SE 7  и заметил, что они включают новую возможность одновременных случайных чисел. Так как симуляция Монте-Карло генерирует миллионы случайных чисел, мне было очень интересно посмотреть, как это повлияет на его производительность.

В  Javadoc for ThreadLocalRandom  упоминается конфликт, когда несколько потоков используют обычные  Math.random(). Regular  Math.random() использует атомарные переменные для хранения текущего начального числа, так что два вызывающих потока одновременно получают псевдослучайные числа последовательно, а не одно и то же число дважды. В моем случае я использую отдельный экземпляр  Random для каждой случайной переменной в симуляции, но эти экземпляры распределяются по всем прогонам симуляции. В результате, существует большая вероятность раздоров, поэтому мы должны ожидать улучшения.

ThreadLocalRandom не создается непосредственно; вместо этого есть статический метод,  current() который создает новый экземпляр при первом вызове из данного потока. Таким образом, переходя от  Math.random() к  ThreadLocalRandomмы меняем две вещи о программе:

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

Random против ThreadLocalRandom

Чтобы установить базовый уровень, вот новый прогон с использованием регулярного  Math.random():

StopWatch 'Monte Carlo NPV': running time (millis) = 44637
-----------------------------------------
ms     %     Task name
-----------------------------------------
12202  027%  Sequential
02576  006%  DivideByTwo (children=2, min fork size=100)
02465  006%  DivideByTwo (children=2, min fork size=500)
02615  006%  DivideByTwo (children=2, min fork size=1000)
02515  006%  DivideByTwo (children=2, min fork size=2000)
02502  006%  DivideByP (children=8, min fork size=100)
02490  006%  DivideByP (children=8, min fork size=500)
02445  005%  DivideByP (children=8, min fork size=1000)
02450  005%  DivideByP (children=8, min fork size=2000)
02477  006%  Sqrt(n) (children=-1, min fork size=100)
02458  006%  Sqrt(n) (children=-1, min fork size=500)
02466  006%  Sqrt(n) (children=-1, min fork size=1000)
02468  006%  Sqrt(n) (children=-1, min fork size=2000)
02508  006%  Parfor (children=20000, min fork size=500)

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

Изменение очень незначительное:

// double u = r.nextDouble();
double u = ThreadLocalRandom.current().nextDouble();

Результирующее изменение является существенным:

StopWatch 'Monte Carlo NPV': running time (millis) = 34942
-----------------------------------------
ms     %     Task name
-----------------------------------------
11347  032%  Sequential
02004  006%  DivideByTwo (children=2, min fork size=100)
01831  005%  DivideByTwo (children=2, min fork size=500)
01838  005%  DivideByTwo (children=2, min fork size=1000)
01784  005%  DivideByTwo (children=2, min fork size=2000)
01781  005%  DivideByP (children=8, min fork size=100)
01782  005%  DivideByP (children=8, min fork size=500)
01772  005%  DivideByP (children=8, min fork size=1000)
01776  005%  DivideByP (children=8, min fork size=2000)
01781  005%  Sqrt(n) (children=-1, min fork size=100)
01788  005%  Sqrt(n) (children=-1, min fork size=500)
01805  005%  Sqrt(n) (children=-1, min fork size=1000)
01799  005%  Sqrt(n) (children=-1, min fork size=2000)
01854  005%  Parfor (children=20000, min fork size=500)

Улучшение составляет около 25%, что стоит того.

наблюдения

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

Также интересно, что в последовательном случае наблюдается улучшение. Это показывает, что не весь прирост производительности был создан за счет устранения конфликтов. Это говорит о том, что даже в обычном коде Java есть преимущество использования,  ThreadLocalRandom если потребуется много случайных чисел. Это похоже на разницу между  StringBuffer и StringBuilder; перенося безопасность потока на создание экземпляров, а не во время использования, можно улучшить производительность. Возможно, в Java-программировании существуют другие случаи, когда  synchronized используются блоки кода, которые были бы более производительными с отдельными  ThreadLocal экземплярами.

В приведенных выше числах случай «разделить на два» с двумя дочерними элементами и наименьшим размером фрагмента оказывается значительно хуже, чем в других вариантах. Обратите внимание, что этот метод порождает большинство задач, но из-за того, как работает структура fork / join, это не означает, что он порождает больше потоков, чем другие. Последующие прогоны по-прежнему показали некоторую разницу, но не так, как отмечено, поэтому это может быть не значительным.

Вывод

Фреймворк Java fork / join, на мой взгляд, является ценным вкладом в Java SE 7.  ThreadLocalRandom Класс может быть значительно меньшим и менее сложным дополнением, но, похоже, у него есть веские основания для его использования, возможно, даже в «обычном» Java-код