Статьи

Java 7: Как написать действительно быстрый код Java

Когда я впервые написал этот блог, я намеревался представить вам класс ThreadLocalRandom который является новым в Java 7 для генерации случайных чисел. Я проанализировал производительность ThreadLocalRandom в серии микро-тестов, чтобы выяснить, как он работает в однопоточной среде.

Результаты были относительно удивительными: хотя код очень похож, ThreadLocalRandom в два раза быстрее Math.random() ! Результаты заинтересовали меня, и я решил исследовать это немного дальше. Я задокументировал свой процесс анализа. Это примерное введение в этапы анализа, технологии и некоторые диагностические инструменты JVM, необходимые для понимания различий в производительности небольших сегментов кода. Некоторый опыт работы с описанным набором инструментов и технологий позволит вам писать более быстрый код Java для вашей конкретной целевой среды Hotspot.

Хорошо, хватит разговоров, начнем! Моя машина представляет собой обычный Intel 386 32-разрядный двухъядерный процессор под управлением Windows XP.

Math.random() работает со статическим одноэлементным экземпляром Random тогда как ThreadLocalRandom -> current() -> nextDouble() работает с локальным экземпляром потока ThreadLocalRandom который является подклассом Random . ThreadLocal вводит издержки на поиск переменных при каждом вызове метода current() . Учитывая то, что я только что сказал, действительно немного удивительно, что он в два раза быстрее, чем Math.random() в одном потоке, не так ли? Я не ожидал такой значительной разницы.

Опять же, я использую крошечный фреймворк для микро-бенчмаркинга, представленный в одном из блогов Heinz . Фреймворк, который разработал Хайнц, решает несколько проблем в тестировании Java-программ на современных виртуальных машинах Java. Эти проблемы включают в себя: прогрев, сборку мусора, точность API времени Javas, проверку точности теста и так далее.

Вот мои тестовые классы:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
public class ThreadLocalRandomGenerator implements BenchmarkRunnable {
 
 private double r;
  
 @Override
 public void run() {
  r = r + ThreadLocalRandom.current().nextDouble();
 }
 
 public double getR() {
  return r;
 }
 
 @Override
 public Object getResult() {
  return r;
 }
   
}
 
public class MathRandomGenerator implements BenchmarkRunnable {
 
 private double r;
 
 @Override
 public void run() {
  r = r + Math.random();
 }
 
 public double getR() {
  return r;
 }
 
 @Override
 public Object getResult() {
  return r;
 }
}

Давайте запустим бенчмарк, используя фреймворк Heinz:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
public class FirstBenchmark {
 
 private static List<BenchmarkRunnable> benchmarkTargets = Arrays.asList(new MathRandomGenerator(),
   new ThreadLocalRandomGenerator());
 
 public static void main(String[] args) {
  DecimalFormat df = new DecimalFormat("#.##");
  for (BenchmarkRunnable runnable : benchmarkTargets) {
   Average average = new PerformanceHarness().calculatePerf(new PerformanceChecker(1000, runnable), 5);
   System.out.println("Benchmark target: " + runnable.getClass().getSimpleName());
   System.out.println("Mean execution count: " + df.format(average.mean()));
   System.out.println("Standard deviation: " + df.format(average.stddev()));
   System.out.println("To avoid dead code coptimization: " + runnable.getResult());
  }
 }
}

Примечание: чтобы убедиться, что JVM не идентифицирует код как «мертвый код», я возвращаю переменную поля и немедленно распечатываю результат моего теста. Вот почему мои исполняемые классы реализуют интерфейс под названием RunnableBenchmark . Я запускаю этот тест три раза. Первый запуск в режиме по умолчанию, с включенным встраиванием и оптимизацией JIT:

1
2
3
4
5
6
7
8
Benchmark target: MathRandomGenerator
Mean execution count: 14773594,4
Standard deviation: 180484,9
To avoid dead code coptimization: 6.4005410634212025E7
Benchmark target: ThreadLocalRandomGenerator
Mean execution count: 29861911,6
Standard deviation: 723934,46
To avoid dead code coptimization: 1.0155096190946539E8

Затем снова без оптимизации JIT (опция VM -Xint ):

1
2
3
4
5
6
7
8
Benchmark target: MathRandomGenerator
Mean execution count: 963226,2
Standard deviation: 5009,28
To avoid dead code coptimization: 3296912.509302683
Benchmark target: ThreadLocalRandomGenerator
Mean execution count: 1093147,4
Standard deviation: 491,15
To avoid dead code coptimization: 3811259.7334526842

Последний тест с оптимизацией JIT, но с -XX:MaxInlineSize=0 который (почти) отключает встраивание:

1
2
3
4
5
6
7
8
Benchmark target: MathRandomGenerator
Mean execution count: 13789245
Standard deviation: 200390,59
To avoid dead code coptimization: 4.802723374491231E7
Benchmark target: ThreadLocalRandomGenerator
Mean execution count: 24009159,8
Standard deviation: 149222,7
To avoid dead code coptimization: 8.378231170741305E7

Давайте внимательно интерпретируем результаты: при полной JIT-оптимизации JVM ThreadLocalRanom в два раза быстрее Math.random() . Отключение JIT-оптимизации показывает, что оба работают одинаково хорошо (плохо). Внедрение метода, кажется, составляет 30% разницы в производительности. Другие различия могут быть связаны с другими методами оптимизации .

Одной из причин, почему JIT-компилятор может более эффективно настраивать ThreadLocalRandom является улучшенная реализация ThreadLocalRandom.next() .

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
public class Random implements java.io.Serializable {
...
    protected int next(int bits) {
        long oldseed, nextseed;
        AtomicLong seed = this.seed;
        do {
            oldseed = seed.get();
            nextseed = (oldseed * multiplier + addend) & mask;
        } while (!seed.compareAndSet(oldseed, nextseed));
        return (int)(nextseed >>> (48 - bits));
    }
...
}
 
public class ThreadLocalRandom extends Random {
...
    protected int next(int bits) {
        rnd = (rnd * multiplier + addend) & mask;
        return (int) (rnd >>> (48-bits));
    }
...
}

Первый фрагмент показывает Random.next() который интенсивно используется в тесте Math.random() . По сравнению с ThreadLocalRandom.next() метод требует значительно больше инструкций, хотя оба метода выполняют одно и то же. В классе Random seed переменная хранит глобальное общее состояние для всех потоков, оно изменяется при каждом вызове метода next() . Поэтому AtomicLong требуется для безопасного доступа и изменения seed значения в вызовах на nextDouble() . ThreadLocalRandom с другой стороны, хорошо локальный для потока 🙂 Метод next() не обязательно должен быть потокобезопасным и может использовать обычную переменную long качестве начального значения.

О методе встраивания и ThreadLocalRandom

Одной из очень эффективных JIT-оптимизаций является метод встраивания. В часто используемых горячих путях компилятор горячих точек решает встроить код вызываемых методов (дочерний метод) в метод вызывающих (родительский метод). «Встраивание имеет важные преимущества. Это значительно снижает динамическую частоту вызовов методов, что экономит время, необходимое для выполнения этих вызовов методов. Но что еще более важно, встраивание создает гораздо большие блоки кода для работы оптимизатора. Это создает ситуацию, которая значительно повышает эффективность традиционных оптимизаций компилятора, преодолевая главное препятствие для повышения производительности языка программирования Java ».

Начиная с Java 7, вы можете отслеживать встраивание методов с помощью диагностических опций JVM. Выполнение кода с -XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining ‘покажет усилия компилятора JIT. Вот соответствующие разделы вывода для Math.random() :

1
2
3
@ 13   java.util.Random::nextDouble (24 bytes)
  @ 3   java.util.Random::next (47 bytes)   callee is too large
  @ 13   java.util.Random::next (47 bytes)   callee is too large

JIT-компилятор не может Random.next() метод Random.next() который вызывается в Random.nextDouble() . Это встроенный вывод ThreaLocalRandom.next() :

1
2
3
@ 8   java.util.Random::nextDouble (24 bytes)
  @ 3   java.util.concurrent.ThreadLocalRandom::next (31 bytes)
  @ 13   java.util.concurrent.ThreadLocalRandom::next (31 bytes)

Из-за того, что next() -метод короче (31 байт), он может быть встроен. Поскольку в обоих тестах интенсивно вызывается метод next() этот журнал предполагает, что встраивание метода может быть одной из причин того, что ThreadLocalRandom работает значительно быстрее.

Чтобы убедиться в этом и узнать больше, необходимо углубиться в код сборки. В Java 7 JDK можно распечатать ассемблерный код в консоли. Смотрите здесь о том, как включить -XX:+PrintAssembly VM Option. Опция выведет оптимизированный для JIT код, это означает, что вы можете видеть код, который фактически выполняет JVM. Я скопировал соответствующий код сборки в ссылки ниже.

Код сборки ThreadLocalRandomGenerator.run () здесь .
Сборочный код MathRandomGenerator.run () здесь .
Код ассемблера Random.next (), вызываемый Math.random () здесь .

Ассемблерный код является машинно-зависимым и низкоуровневым кодом, его сложнее читать, чем байт-код . Давайте попробуем убедиться, что метод встраивания метода оказывает существенное влияние на производительность в моих тестах и: есть ли другие очевидные различия в том, как JIT-компилятор обрабатывает ThreadLocalRandom и Math.random ()? В ThreadLocalRandomGenerator.run() нет вызова процедуры для любой из подпрограмм, таких как Random.nextDouble() или ThreatLocalRandom.next() . Виден только один виртуальный (а значит, дорогой) вызов метода ThreadLocal.get() (см. ThreadLocalRandomGenerator.run() 35 в сборке ThreadLocalRandomGenerator.run() ). Весь остальной код встроен в ThreadLocalRandomGenerator.run() . В случае MathRandomGenerator.run() есть два вызова виртуальных методов для Random.next() (см. Блок B4, строка 204 и далее в коде сборки MathRandomGenerator.run() ). Этот факт подтверждает наше подозрение, что метод встраивания является одной из основных причин разницы в производительности. Более того, из-за проблем с синхронизацией в Random.next() требуются значительно более (и некоторые дорогостоящие!) Инструкции по Random.next() что также Random.next() с точки зрения скорости выполнения.

Понимание накладных invokevirtual инструкции invokevirtual

Так почему (виртуальный) вызов метода стоит дорого, а метод включения так эффективен? Указатель инструкций invokevirtual не является смещением конкретного метода в экземпляре класса. Компилятор не знает внутреннюю разметку экземпляра класса. Вместо этого он генерирует символические ссылки на методы экземпляра, которые хранятся в пуле постоянных времени выполнения. Эти постоянные элементы пула времени выполнения разрешаются во время выполнения, чтобы определить фактическое местоположение метода. Такое динамическое (во время выполнения) связывание требует проверки, подготовки и разрешения, что может значительно повлиять на производительность. (см. Методы Вызова и Связывание в Спецификации JVM для деталей)

Это все на данный момент. Отказ от ответственности: Конечно, список тем, которые вы должны понять, чтобы решить загадки производительности, бесконечен. Есть много чего, что нужно понять, чем микробанчмаркинг, оптимизация JIT, встраивание методов, Java-байт-код, язык ассемблера и так далее. Кроме того, причинами различий в производительности гораздо больше, чем просто вызовы виртуальных методов или дорогостоящие инструкции по синхронизации потоков. Тем не менее, я думаю, что темы, которые я представил, являются хорошим началом для такого глубокого погружения. Ждем критических и приятных комментариев!

Ссылки: «Java 7: Как написать действительно быстрый Java-код» от нашего партнера JCG Никласа.