Будет ли Comparator.comparing… в методеcompareToоптимизирован компилятором современной Java?

При реализации метода compareTo, требуемого интерфейсом Comparable, я хочу использовать удобный метод Comparator, сгенерированный Comparator.comparing….

Например:

package work.basil.example.threading.multitasking;

import java.util.Comparator;

public record Job(
        int id ,
        int amt
) implements Comparable < Job >
{
    @Override
    public int compareTo ( final Job job )
    {
        return Comparator.comparingInt( Job :: id ).thenComparingInt( Job :: amt ).compare( this , job );
    }
}

Будет ли вызов Comparator.comparingInt( … ).thenComparingInt( … ) создавать экземпляр Comparator при каждом выполнении этого метода? Или для этого будет оптимизирован компилятор Java байт-кода ( javac и т. д.) или JIT ( HotSpot , OpenJ9 и т. д.)?

Если компилятор не оптимизирует, я бы сделал константу private static final, вот так:

package work.basil.example.threading.multitasking;

import java.util.Comparator;

public record Job(
        int id ,
        int amt
) implements Comparable < Job >
{
    private static final Comparator < Job > COMPARATOR = Comparator.comparingInt( Job :: id ).thenComparingInt( Job :: amt );

    @Override
    public int compareTo ( final Job job )
    {
        return Job.COMPARATOR.compare( this , job );
    }
}

что говорит javap о скомпилированном файле класса?

Jigar Joshi 24.03.2024 01:43

Я так не думаю. Это не похоже на то, что Comparator.comparing — это макрос или что-то в этом роде...

Sweeper 24.03.2024 01:52

Вы спрашиваете, оптимизирует ли javac это? Или вы спрашиваете, оптимизирует ли это JIT? Если первое, то я сильно сомневаюсь. Не уверен насчет JIT.

Slaw 24.03.2024 01:54

Проведите сравнительный анализ и дайте нам знать о результатах.

aled 24.03.2024 02:48

Простите, если это очевидный вопрос, но вы смотрели исходный код (интерфейса Comparator)? [Статические] Метод comparing возвращает лямбду. Будет ли JIT создавать новую лямбду каждый раз при вызове метода?

Abra 24.03.2024 06:09

@Slaw Либо компилятор байт-кода, либо JIT-компилятор. (Я отредактировал Вопрос для ясности.) Я просто хочу знать, стоит ли создание отдельной константы static final моего времени и усилий (для эффективной производительности выполнения) или это ненужное отвлечение внимания в коде. Я предполагаю, что да, мне следует сделать Comparator отдельной константой, чтобы избежать повторных экземпляров, но компиляторы настолько умны, что я никогда не могу быть полностью уверен в пределах их возможностей по оптимизации.

Basil Bourque 24.03.2024 06:59

Что ж, ответ на вопрос «Будет ли Javac оптимизировать X?» почти всегда будет: «Нет». Что касается JIT, это сложнее понять. Это зависит от контекста вызова compareTo и того, насколько «горячим» compareTo во время выполнения. Вам придется оценить/профилировать его. Тем не менее, если вы сохраняете компаратор в статическом конечном поле, JIT вообще не нужно оптимизировать создание компаратора.

Slaw 25.03.2024 07:28
Пользовательский скаляр GraphQL
Пользовательский скаляр GraphQL
Листовые узлы системы типов GraphQL называются скалярами. Достигнув скалярного типа, невозможно спуститься дальше по иерархии типов. Скалярный тип...
Как вычислять биты и понимать побитовые операторы в Java - объяснение с примерами
Как вычислять биты и понимать побитовые операторы в Java - объяснение с примерами
В компьютерном программировании биты играют важнейшую роль в представлении и манипулировании данными на двоичном уровне. Побитовые операции...
Поднятие тревоги для долго выполняющихся методов в Spring Boot
Поднятие тревоги для долго выполняющихся методов в Spring Boot
Приходилось ли вам сталкиваться с требованиями, в которых вас могли попросить поднять тревогу или выдать ошибку, когда метод Java занимает больше...
Полный курс Java для разработчиков веб-сайтов и приложений
Полный курс Java для разработчиков веб-сайтов и приложений
Получите сертификат Java Web и Application Developer, используя наш курс.
2
7
126
1
Перейти к ответу Данный вопрос помечен как решенный

Ответы 1

Ответ принят как подходящий

Будет ли javac оптимизировать это?

Да. Нет. Му. Зависит от того, что вы подразумеваете под «оптимизировать».

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

Так что же здесь делает javac?

Множество руководств и в некотором смысле даже сама спецификация Java предполагают, что это:

list.forEach(x -> System.out.println(x.toLowerCase());

это просто синтаксический сахар для:

list.forEach(new Consumer<String>() {
  @Override public void consume(String x) {
    System.out.println(x.toLowerCase());
  }
}

и для большинства намерений и целей это хороший способ объяснить это тем, кто знает, что такое анонимные литералы внутреннего класса (это #ifdef - синтаксис, используемый здесь), но это неверно именно в контексте вопроса Бэзила.

Поскольку анонимный внутренний литерал класса согласно спецификации должен быть скомпилирован в код, который создает экземпляр объекта, тогда как спецификация лямбда-выражений и ссылок на методы явно не требует этого и действительно изо всех сил старается указать, что «идентичность» «объекта», которым становится лямбда-выражение или ссылка на метод, не следует рассматривать как релевантный, поскольку javac/JVM оставляет за собой полное право сделать это бесполезным.

И действительно, это именно то, что он делает. Давайте скомпилируем ваш первый фрагмент, а затем запустим new Type() {...}, чтобы посмотреть, во что он скомпилирован:

> cat Job.java
 ... [omitted parts] ...
@Override
    public int compareTo ( final Job job )
    {
        return Comparator.comparingInt( Job :: id ).thenComparingInt( Job :: amt ).compare( this , job );
    }

> javac Job.java
> javap -c -v Job
 .. [omitted _a lot_ of output] ..
 public int compareTo(Job);
    descriptor: (LJob;)I
    flags: (0x0001) ACC_PUBLIC
    Code:
      stack=3, locals=2, args_size=2
         0: invokedynamiC#16,  0             // InvokeDynamiC#0:applyAsInt:()Ljava/util/function/ToIntFunction;
         5: invokestatic  #20                 // InterfaceMethod java/util/Comparator.comparingInt:(Ljava/util/function/ToIntFunction;)Ljava/util/Comparator;
         8: invokedynamiC#26,  0             // InvokeDynamiC#1:applyAsInt:()Ljava/util/function/ToIntFunction;
        13: invokeinterface #27,  2           // InterfaceMethod java/util/Comparator.thenComparingInt:(Ljava/util/function/ToIntFunction;)Ljava/util/Comparator;
        18: aload_0
        19: aload_1
        20: invokeinterface #30,  3           // InterfaceMethod java/util/Comparator.compare:(Ljava/lang/Object;Ljava/lang/Object;)I
        25: ireturn
      LineNumberTable:
        line 5: 0

Важнейшая вещь, на которую следует обратить внимание в этих выводах, — это то, что во всем этом блоке нет ни одной инструкции javap -c -v байт-кода (new создает новые объекты). В отличие от замены, например. new с длинной формой:

new ToIntFunction<Job>() {
  @Override public int applyAsInt(Job job) {
    return job.id();
  }
}

и если мы Job::id получим совсем другие результаты:

stack=3, locals=2, args_size=2
         0: new           #16                 // class Job$1
         3: dup
         4: aload_0
         5: invokespecial #18                 // Method Job$1."<init>":(LJob;)V
         8: invokestatic  #21                 // InterfaceMethod java/util/Comparator.comparingInt:(Ljava/util/function/ToIntFunction;)Ljava/util/Comparator;
        11: invokedynamiC#27,  0             // InvokeDynamiC#0:applyAsInt:()Ljava/util/function/ToIntFunction;
        16: invokeinterface #31,  2           // InterfaceMethod java/util/Comparator.thenComparingInt:(Ljava/util/function/ToIntFunction;)Ljava/util/Comparator;
        21: aload_0
        22: aload_1
        23: invokeinterface #34,  3           // InterfaceMethod java/util/Comparator.compare:(Ljava/lang/Object;Ljava/lang/Object;)I
        28: ireturn

Обратите внимание, как здесь происходит javap -c -v, new относится к анонимному внутреннему классу, который здесь создается (код class Job$1, вызывается его конструктор (этот конструктор ничего не делает, и javac это знает, но поскольку javac находится на рельсах, это соответствует спецификация, необходимая для вставки этой инструкции new ToIntFunction<Job>() { .. }), даже несмотря на то, что этот конструктор является неактивным... на самом деле это не совсем пустая операция, он принимает в качестве аргумента invokespecial #18, поскольку это нестатический внутренний класс, который Javac знает, что он не используется. Опять же, несущественно, Job нужно выводить весь этот ненужный хлам, этого требует спецификация).

Возвращаясь к «хорошему» коду без javac, все, что происходит, — это набор инди-вызовов (indy — это сокращение от new и было довольно огромным дополнением к формату файла JVM/класса). Они вызывают метод «#0:applyAsInt», который не принимает аргументов (InvokeDynamic) и возвращает экземпляр () (это то, что представляет собой ToIntFunction). Это 'создает значение типа ToIntFunction, но само по себе не выполняет эту работу, вместо этого он вызывает какую-то туманную вещь с InDy, чтобы создать это значение.

Так что же здесь происходит? Нам нужно прокрутить вывод Ljava/util/function/ToIntFunction; до конца, чтобы получить больше информации:

BootstrapMethods:
  0: #77 REF_invokeStatic java/lang/invoke/LambdaMetafactory.metafactory:(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodHandle;Ljava/lang/invoke/MethodType;)Ljava/lang/invoke/CallSite;
    Method arguments:
      #65 (Ljava/lang/Object;)I
      #66 REF_invokeVirtual Job.id:()I
      #69 (LJob;)I
  1: #77 REF_invokeStatic java/lang/invoke/LambdaMetafactory.metafactory:(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodHandle;Ljava/lang/invoke/MethodType;)Ljava/lang/invoke/CallSite;
    Method arguments:
      #65 (Ljava/lang/Object;)I
      #70 REF_invokeVirtual Job.amt:()I
      #69 (LJob;)I
  2: #84 REF_invokeStatic java/lang/runtime/ObjectMethods.bootstrap:(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/TypeDescriptor;Ljava/lang/Class;Ljava/lang/String;[Ljava/lang/invoke/MethodHandle;)Ljava/lang/Object;
    Method arguments:
      #8 Job
      #73 id;amt
      #75 REF_getField Job.id:I
      #76 REF_getField Job.amt:I
InnerClasses:
  public static final #96= #92 of #94;    // Lookup=class java/lang/invoke/MethodHandles$Lookup of class java/lang/invoke/MethodHandles

Вот где происходит волшебство. Эти «методы начальной загрузки» кажутся совершенно сумасшедшими, что, черт возьми, такое LambdaMetaFactory, что такое MethodHandles$Lookup, почему в огне есть упоминание внутреннего класса, при компиляции этого кода получается всего один файл javap вместо обычного файла class мы ожидаем увидеть, когда напишем настоящий внутренний класс (и который действительно появится, если мы напишем анонимный литерал внутреннего класса и скомпилируем его, тогда мы получим Job$Inner.class)?

Эти оптимизации были введены в Java8 (на самом деле, Job$1.class как инструкция байт-кода была введена в OpenJDK7, но invokedynamic не генерировала инструкции InDy до Java8). Объяснение того, как именно это работает, довольно сложно и требует полного погружения в то, что делает инди, но, подведем итог по этому конкретному вопросу: по сути, вся эта шумиха делает именно то, что вы хотите: она делает «стоимость» этой однострочной фразы идентичной. иметь отдельную константу.

Таким образом, вывод прост: нет, вам НЕ нужно переписывать ваш простой фрагмент во вторую форму; не вводите константу, быстрее не будет. Во всяком случае, это будет медленнее.

InvokeDynamic

Эти методы начальной загрузки вызываются «по мере необходимости», но в этом случае, по сути, только один раз, результаты их применения привязываются к соответствующей инструкции javac, так что метод начальной загрузки не нужно запускать каждый раз. Это настолько близко к тому, что «я создал константу, чтобы избежать каждого вызова моего invokedynamic метода, создающего объекты и запускающего кучу кода», насколько это возможно, особенно включая то, что он не создает никакого мусора, даже кратковременного мусора.

Я рекомендую эту статью в Java-журнале Oracle о InvokeDynamic, если вы хотите точно знать, как это работает. Здесь также объясняется, что такое методы начальной загрузки и как InDy их использует.

Судя по результатам тестов, постоянный подход работает практически так же, как непостоянный, как по распределению, так и по среднему времени выполнения — если метод compareTo встроен в JIT. Если бы не встроенный, то постоянный подход был бы «лучше». Или, по крайней мере, подход с константами показал лучшие результаты в тестах с -XX:-Inline (это портит работу с ignoredynamic?). Тем не менее, любой контекст «реального мира», где разница будет иметь значение, скорее всего, compareTo будет «горячим» и, следовательно, будет встроенным.

Slaw 26.03.2024 04:16

Другие вопросы по теме