Статьи

Java8 Lambdas: Объяснение проблемы производительности сортировки

Написано в сотрудничестве с Питером Лоури .

Несколько дней назад у меня возникла серьезная проблема с производительностью сортировки с использованием нового декларативного стиля Java8. Смотрите блог пост здесь . В этом посте я только указал на проблему, но в этом посте я собираюсь углубиться в понимание и объяснение причин проблемы. Это будет сделано путем воспроизведения проблемы с использованием декларативного стиля и постепенного изменения кода до тех пор, пока мы не удалим проблему производительности и не останемся с производительностью, которую мы ожидаем, используя сравнение старого стиля.

Напомним, мы сортируем экземпляры этого класса:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
private static class MyComparableInt{
        private int a,b,c,d;
 
        public MyComparableInt(int i) {
            a = i%2;
            b = i%10;
            c = i%1000;
            d = i;
        }
 
        public int getA() return a;
        public int getB() return b;
        public int getC() return c;
        public int getD() return d;
}

Используя декларативный стиль Java 8 (ниже), потребовалось ~ 6 секунд для сортировки 10-метровых экземпляров:

1
2
3
4
5
6
List mySortedList = myComparableList.stream()
      .sorted(Comparator.comparing(MyComparableInt::getA)
                              .thenComparing(MyComparableInt::getB)
                              .thenComparing(MyComparableInt::getC)
                              .thenComparing(MyComparableInt::getD))
      .collect(Collectors.toList());

Использование сортировщика (см. Ниже) заняло ~ 1,6 с для сортировки 10 млн экземпляров.

Это код вызова для сортировки:

1
2
3
List mySortedList = myComparableList.stream()
                    .sorted(MyComparableIntSorter.INSTANCE)
                    .collect(Collectors.toList());

Используя этот собственный компаратор:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
public enum MyComparableIntSorter
    implements Comparator<MyComparableInt>{
        INSTANCE;
 
        @Override
        public int compare(MyComparableInt o1, MyComparableInt o2) {
            int comp = Integer.compare(o1.getA(), o2.getA());
            if(comp==0){
                comp = Integer.compare(o1.getB(), o2.getB());
                if(comp==0){
                    comp = Integer.compare(o1.getC(), o2.getC());
                    if(comp==0){
                        comp = Integer.compare(o1.getD(), o2.getD());
                    }
                }
            }
            return comp;
        }
 }

Давайте создадим метод comparing в нашем классе, чтобы мы могли более тщательно анализировать код. Причина метода comparing заключается в том, что мы можем легко менять реализации, но оставить вызывающий код прежним.

Во всех случаях так будет вызываться метод comparing :

1
2
3
4
5
6
7
List mySortedList = myComparableList.stream()
                    .sorted(comparing(
                            MyComparableInt::getA,
                            MyComparableInt::getB,
                            MyComparableInt::getC,
                            MyComparableInt::getD))
                    .collect(Collectors.toList());

Первая реализация сравнения в значительной степени является копией в jdk.

01
02
03
04
05
06
07
08
09
10
public static <T, U extends Comparable<? super U>> Comparator<T>
   comparing(
            Function<? super T, ? extends U> ke1,
            Function<? super T, ? extends U> ke2,
            Function<? super T, ? extends U> ke3,
            Function<? super T, ? extends U> ke4)
    {
        return  Comparator.comparing(ke1).thenComparing(ke2)
                  .thenComparing(ke3).thenComparing(ke4);
    }

Неудивительно, что для прохождения теста потребовалось ~ 6 секунд, но, по крайней мере, мы воспроизвели проблему и создали основу для продвижения вперед.

Давайте посмотрим на запись полета для этого теста:

Снимок экрана 2015-01-22 в 11.56.20

Как видно, есть две большие проблемы:

  1. Проблема производительности в методе lambda$comparing
  2. Повторный вызов Integer.valueOf ( Integer.valueOf )

Давайте попробуем разобраться с первым, который находится в методе сравнения. На первый взгляд это кажется странным, потому что когда вы смотрите на код, в этом методе ничего особенного не происходит. Одна вещь, которая здесь происходит в основном, это поиск виртуальных таблиц, так как код находит правильную реализацию функции. Виртуальные таблицы используются, когда из одной строки кода вызывается несколько методов. Мы можем устранить этот источник задержки с помощью следующей реализации comparing . Расширяя все применения интерфейса Function каждая строка может вызывать только одну реализацию, и, следовательно, метод является встроенным.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
public static <T, U extends Comparable<? super U>> Comparator<T>
       comparing(
            Function<? super T, ? extends U> ke1,
            Function<? super T, ? extends U> ke2,
            Function<? super T, ? extends U> ke3,
            Function<? super T, ? extends U> ke4)
    {
        return  (c1, c2) -> {
            int comp = compare(ke1.apply(c1), ke1.apply(c2));
            if (comp == 0) {
                comp = compare(ke2.apply(c1), ke2.apply(c2));
                if (comp == 0) {
                    comp = compare(ke3.apply(c1), ke3.apply(c2));
                    if (comp == 0) {
                        comp = compare(ke4.apply(c1), ke4.apply(c2));
                    }
                }
            }
            return comp;
        };
    }

Размотав метод, JIT должен иметь возможность встроить поиск метода.

Действительно, время почти вдвое сокращается до 3,5 с, давайте посмотрим на запись полета для этого прогона:

Снимок экрана 2015-01-22 в 11.57.58

Когда я впервые увидел это, я был очень удивлен, потому что пока мы не сделали никаких изменений, чтобы уменьшить количество вызовов Integer.valueOf но этот процент упал! На самом деле здесь произошло то, что из-за изменений, которые мы внесли, чтобы разрешить встраивание, Integer.valueOf был встроен, а время, Integer.valueOf на Integer.valueOf , обвиняется в вызывающем ( lambda$comparing ), который встроил вызываемый ( Integer.valueOf ). Это распространенная проблема в профилировщиках, так как они могут ошибаться в отношении того, какой метод винить, особенно когда происходит встраивание.

Но мы знаем, что в предыдущей записи полета Integer.valueOf
было выделено, поэтому давайте удалим это с помощью этой реализации comparing и посмотрим, сможем ли мы сократить время еще больше.

01
02
03
04
05
06
07
08
09
10
11
12
13
return  (c1, c2) -> {
    int comp = compare(ke1.applyAsInt(c1), ke1.applyAsInt(c2));
    if (comp == 0) {
        comp = compare(ke2.applyAsInt(c1), ke2.applyAsInt(c2));
        if (comp == 0) {
           comp = compare(ke3.applyAsInt(c1), ke3.applyAsInt(c2));
           if (comp == 0) {
             comp = compare(ke4.applyAsInt(c1), ke4.applyAsInt(c2));
           }
         }
    }
    return comp;
};

С этой реализацией время сокращается до 1,6 с, чего мы могли бы достичь с помощью специального компаратора.

Давайте снова посмотрим на запись полета для этого запуска:

Снимок экрана 2015-01-22 в 12.59.27

Сейчас все время идет в реальных методах сортировки, а не в накладных.

В заключение мы узнали несколько интересных вещей из этого исследования:

  1. Использование новой декларативной сортировки Java8 в некоторых случаях будет в 4 раза медленнее, чем написание собственного компаратора, из-за стоимости автобоксирования и поиска виртуальных таблиц.
  2. Хотя FlightRecorder лучше других профилировщиков (см. Мой первый пост в блоге по этому вопросу), он все равно будет приписывать время неправильным методам, особенно когда происходит встраивание.