Недавно у меня возник спор с коллегами по поводу штрафа за ленивые игры в Scala. Это привело к набору микробенчмарков, которые сравнивают ленивую и не ленивую производительность. Все источники можно найти по адресу http://git.io/g3WMzA .
Но прежде чем перейти к результатам теста, давайте попробуем понять, что может привести к снижению производительности.
Для моего теста JMH я создал очень простой класс Scala с ленивым значением val:
@State(Scope.Benchmark)
class LazyValCounterProvider {
lazy val counter = SlowInitializer.createCounter()
}
Теперь давайте посмотрим, что скрыто под капотом ленивых ключевых слов. Сначала нам нужно скомпилировать данный код с помощью scalac , а затем он может быть декомпилирован в соответствующий код Java. Для этого я использовал декомпилятор JD . Это произвело следующий код:
@State(Scope.Benchmark) @ScalaSignature(bytes="...") public class LazyValCounterProvider { private SlowInitializer.Counter counter; private volatile boolean bitmap$0; private SlowInitializer.Counter counter$lzycompute() { synchronized (this) { if (!this.bitmap$0) { this.counter = SlowInitializer.createCounter(); this.bitmap$0 = true; } return this.counter; } } public SlowInitializer.Counter counter() { return this.bitmap$0 ? this.counter : counter$lzycompute(); } }
Как видно, ленивое ключевое слово переводится в классическую идиому блокировки с двойной проверкой для отложенной инициализации.
Таким образом, большую часть времени единственное ухудшение производительности может исходить от одного энергозависимого чтения на чтение ленивого val (за исключением времени, которое требуется для инициализации экземпляра lazy val с момента его самого первого использования). Давайте наконец измерим его влияние в цифрах.
Мой микробенчмарк на основе JMH прост:
public class LazyValsBenchmarks { @Benchmark public long baseline(ValCounterProvider eagerProvider) { return eagerProvider.counter().incrementAndGet(); } @Benchmark public long lazyValCounter(LazyValCounterProvider provider) { return provider.counter().incrementAndGet(); } }
Базовый метод обращается к конечному объекту счетчика и увеличивает целочисленное значение на 1, вызывая incrementAndGet.
И, как мы только что выяснили, основной метод эталонного теста — lazyValCounter — в дополнение к тому, что делает метод базового уровня, также выполняет одно энергозависимое чтение.
Примечание: все измерения выполняются на MBA с процессором Core i5 1,7 ГГц.
Все результаты были получены при запуске JMH в режиме пропускной способности. Столбцы с оценками и ошибками в оценках показывают количество операций в секунду Каждый прогон JMH делал 10 итераций и занимал 50 секунд. Я выполнил 6 измерений с различными вариантами JVM и JMH:
-
клиентская ВМ, 1 поток
Benchmark Score Score error baseline 412277751.619 8116731.382 lazyValCounter 352209296.485 6695318.185
-
клиентская ВМ, 2 потока
Benchmark Score Score error baseline 542605885.932 15340285.497 lazyValCounter 383013643.710 53639006.105
-
клиентская ВМ, 4 потока
Benchmark Score Score error baseline 551105008.767 5085834.663 lazyValCounter 394175424.898 3890422.327
-
серверная виртуальная машина, 1 поток
Benchmark Score Score error baseline 407010942.139 9004641.910 lazyValCounter 341478430.115 18183144.277
-
серверная виртуальная машина, 2 потока
Benchmark Score Score error baseline 531472448.578 22779859.685 lazyValCounter 428898429.124 24720626.198
-
серверная виртуальная машина, 4 потока
Benchmark Score Score error baseline 549568334.970 12690164.639 lazyValCounter 374460712.017 17742852.788
Цифры показывают, что штраф за выполнение ленивых вальсов довольно мал и может быть проигнорирован на практике.
Для дальнейшего прочтения этой темы я бы порекомендовал SIP 20 — Улучшенная инициализация Lazy Vals , которая содержит очень интересный углубленный анализ существующих проблем с реализацией отложенной инициализации в Scala.