Почему возврат ссылки на объект Java намного медленнее, чем возврат примитива


мы работаем над чувствительным к задержке приложением и были microbenchmarking все виды методов (используя jmh). После microbenchmarking метод поиска и будучи удовлетворенным результатами, я реализовал окончательную версию, только чтобы найти, что окончательная версия была в 3 раза медленнее чем то, что я только что проверил.

виновником было то, что реализованный метод возвращал enum объект вместо int. Вот упрощенная версия кода бенчмарка:

@OutputTimeUnit(TimeUnit.MICROSECONDS)
@State(Scope.Thread)
public class ReturnEnumObjectVersusPrimitiveBenchmark {

    enum Category {
        CATEGORY1,
        CATEGORY2,
    }

    @Param( {"3", "2", "1" })
    String value;

    int param;

    @Setup
    public void setUp() {
        param = Integer.parseInt(value);
    }

    @Benchmark
    public int benchmarkReturnOrdinal() {
        if (param < 2) {
            return Category.CATEGORY1.ordinal();
        }
        return Category.CATEGORY2.ordinal();        
    }


    @Benchmark
    public Category benchmarkReturnReference() {
        if (param < 2) {
            return Category.CATEGORY1;
        }
        return Category.CATEGORY2;      
    }


    public static void main(String[] args) throws RunnerException {
            Options opt = new OptionsBuilder().include(ReturnEnumObjectVersusPrimitiveBenchmark.class.getName()).warmupIterations(5)
                .measurementIterations(4).forks(1).build();
        new Runner(opt).run();
    }

}

результаты теста Для выше:

# VM invoker: C:Program FilesJavajdk1.7.0_40jrebinjava.exe
# VM options: -Dfile.encoding=UTF-8

Benchmark                   (value)   Mode  Samples     Score     Error   Units
benchmarkReturnOrdinal            3  thrpt        4  1059.898 ±  71.749  ops/us
benchmarkReturnOrdinal            2  thrpt        4  1051.122 ±  61.238  ops/us
benchmarkReturnOrdinal            1  thrpt        4  1064.067 ±  90.057  ops/us
benchmarkReturnReference          3  thrpt        4   353.197 ±  25.946  ops/us
benchmarkReturnReference          2  thrpt        4   350.902 ±  19.487  ops/us
benchmarkReturnReference          1  thrpt        4   339.578 ± 144.093  ops/us

просто изменение типа возврата функции изменило производительность почти в 3 раза.

Я думал, что единственное различие между возвращением объекта enum и целым числом заключается в том, что один возвращает 64-битное значение (ссылка), а другой возвращает 32-битное значение. Один из моих коллег предполагал, что возвращение перечисления добавило дополнительные накладные расходы из-за необходимости отслеживать на ГК. (Но учитывая, что объекты enum являются статическими конечными ссылками, кажется странным, что это нужно будет сделать).

каково объяснение разницы в производительности?


обновление

я поделился проектом maven здесь так что любой может клонировать его и запустить тест. Если у кого-то есть время/интерес, было бы полезно посмотреть, есть ли другие можно повторить те же результаты. (Я реплицировал на 2 разных машинах, Windows 64 и Linux 64, оба используя ароматы Oracle Java 1.7 JVMs). @ZhekaKozlov говорит, что не видит никакой разницы между методами.

для запуска: (после клонирования репозитория)

mvn clean install
java -jar .targetmicrobenchmarks.jar function.ReturnEnumObjectVersusPrimitiveBenchmark -i 5 -wi 5 -f 1
2 72

2 ответа:

TL; DR: вы не должны слепо доверять ничему.

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

во-вторых, экспериментаторы должны четко понимать, что они контролируют и что они не. В вашем конкретном примере, вы возвращаете значение из @Benchmark методы, но можете ли вы быть уверены, что вызывающие снаружи будут делать то же самое для примитива и ссылки? Если вы зададите себе этот вопрос, то поймете, что вы в основном измеряете тестовую инфраструктуру.

вплоть до сути. На моей машине (i5-4210U, Linux x86_64, JDK 8u40) тест дает:

Benchmark                    (value)   Mode  Samples  Score   Error   Units
...benchmarkReturnOrdinal          3  thrpt        5  0.876 ± 0.023  ops/ns
...benchmarkReturnOrdinal          2  thrpt        5  0.876 ± 0.009  ops/ns
...benchmarkReturnOrdinal          1  thrpt        5  0.832 ± 0.048  ops/ns
...benchmarkReturnReference        3  thrpt        5  0.292 ± 0.006  ops/ns
...benchmarkReturnReference        2  thrpt        5  0.286 ± 0.024  ops/ns
...benchmarkReturnReference        1  thrpt        5  0.293 ± 0.008  ops/ns

Итак, эталонные тесты появляются в 3 раза медленнее. Но подождите, он использует старый JMH (1.1.1), давайте обновим до текущего последнего (1.7.1):

Benchmark                    (value)   Mode  Cnt  Score   Error   Units
...benchmarkReturnOrdinal          3  thrpt    5  0.326 ± 0.010  ops/ns
...benchmarkReturnOrdinal          2  thrpt    5  0.329 ± 0.004  ops/ns
...benchmarkReturnOrdinal          1  thrpt    5  0.329 ± 0.004  ops/ns
...benchmarkReturnReference        3  thrpt    5  0.288 ± 0.005  ops/ns
...benchmarkReturnReference        2  thrpt    5  0.288 ± 0.005  ops/ns
...benchmarkReturnReference        1  thrpt    5  0.288 ± 0.002  ops/ns

Ой, теперь они только чуть медленнее. Кстати, это также говорит нам, что тест связан с инфраструктурой. Хорошо, мы можем посмотреть, что происходит на самом деле?

если вы строите ориентиры и посмотрите вокруг, что именно вызывает @Benchmark методы, то вы увидите что-то вроде:

public void benchmarkReturnOrdinal_thrpt_jmhStub(InfraControl control, RawResults result, ReturnEnumObjectVersusPrimitiveBenchmark_jmh l_returnenumobjectversusprimitivebenchmark0_0, Blackhole_jmh l_blackhole1_1) throws Throwable {
    long operations = 0;
    long realTime = 0;
    result.startTime = System.nanoTime();
    do {
        l_blackhole1_1.consume(l_longname.benchmarkReturnOrdinal());
        operations++;
    } while(!control.isDone);
    result.stopTime = System.nanoTime();
    result.realTime = realTime;
    result.measuredOps = operations;
}

это l_blackhole1_1 есть consume метод, который "потребляет" значения (см. Blackhole обоснование). Blackhole.consume имеет перегрузки для ссылки и примитивы и этого одного достаточно, чтобы оправдать разницу в производительности.

есть объяснение, почему эти методы выглядят по-разному: они пытаются быть как можно быстрее для своих типов аргументов. Они не обязательно демонстрируют одинаковые характеристики производительности, даже если мы пытаемся их сопоставить, следовательно, более симметричный результат с более новым JMH. Сейчас, вы даже можете пойти в -prof perfasm чтобы увидеть сгенерированный код для тестов и понять, почему производительность отличается, но это не суть.

если вы действительно хочу чтобы понять, как возврат примитива и / или ссылки отличается по производительности, вам нужно будет ввести большая страшная серая зона тонкого бенчмаркинга производительности. Например, что-то вроде этого теста:

@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@Warmup(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
@Measurement(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
@Fork(5)
public class PrimVsRef {

    @Benchmark
    public void prim() {
        doPrim();
    }

    @Benchmark
    public void ref() {
        doRef();
    }

    @CompilerControl(CompilerControl.Mode.DONT_INLINE)
    private int doPrim() {
        return 42;
    }

    @CompilerControl(CompilerControl.Mode.DONT_INLINE)
    private Object doRef() {
        return this;
    }

}

...что дает тот же результат для примитивов и ссылок:

Benchmark       Mode  Cnt  Score   Error  Units
PrimVsRef.prim  avgt   25  2.637 ± 0.017  ns/op
PrimVsRef.ref   avgt   25  2.634 ± 0.005  ns/op

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

прим:

                  [Verified Entry Point]
 12.69%    1.81%    0x00007f5724aec100: mov    %eax,-0x14000(%rsp)
  0.90%    0.74%    0x00007f5724aec107: push   %rbp
  0.01%    0.01%    0x00007f5724aec108: sub    x30,%rsp         
 12.23%   16.00%    0x00007f5724aec10c: mov    x2a,%eax   ; load "42"
  0.95%    0.97%    0x00007f5724aec111: add    x30,%rsp
           0.02%    0x00007f5724aec115: pop    %rbp
 37.94%   54.70%    0x00007f5724aec116: test   %eax,0x10d1aee4(%rip)        
  0.04%    0.02%    0x00007f5724aec11c: retq  

ref:

                  [Verified Entry Point]
 13.52%    1.45%    0x00007f1887e66700: mov    %eax,-0x14000(%rsp)
  0.60%    0.37%    0x00007f1887e66707: push   %rbp
           0.02%    0x00007f1887e66708: sub    x30,%rsp         
 13.63%   16.91%    0x00007f1887e6670c: mov    %rsi,%rax     ; load "this"
  0.50%    0.49%    0x00007f1887e6670f: add    x30,%rsp
  0.01%             0x00007f1887e66713: pop    %rbp
 39.18%   57.65%    0x00007f1887e66714: test   %eax,0xe3e78e6(%rip)
  0.02%             0x00007f1887e6671a: retq   

[сарказм] смотрите, как это просто! [/сарказм]

шаблон: чем проще вопрос, тем больше вам придется работать, чтобы сделать обоснованный и достоверный ответ.

чтобы очистить заблуждение ссылка и некоторые из них попали в (@Mzf), давайте погрузимся в спецификацию виртуальной машины Java. Но прежде чем идти туда, нужно прояснить одну вещь -объект никогда не может быть извлечен из памяти, только его поля могут. На самом деле, нет никакого кода операции, который бы выполнял такую обширную операцию.

этот документ определяет ссылку как тип стека (так что это может быть результат или аргумент к инструкциям, выполняющим операции над стеком) 1 - й категории-категории типов, принимающих одно слово стека (32 бита). См. таблицу 2.3 A list of Java Stack Types.

кроме того, если вызов метода завершается нормально в соответствии со спецификацией, значение, выскочившее из верхней части стека, помещается в стек вызова методов (раздел 2.6.4).

Ваш вопрос заключается в том, что вызывает разницу во времени выполнения. Глава 2 предисловие ответы:

детали реализации, которые не являются частью спецификации виртуальной машины Java излишне ограничили бы творческий потенциал конструкторов. Например, компоновка памяти областей данных во время выполнения, используемый алгоритм сбора мусора и любая внутренняя оптимизация инструкций виртуальной машины Java (например, перевод их в машинный код) оставляются на усмотрение исполнителя.

в других слова, потому что никакая такая вещь, как штраф performace относительно использования ссылки, не указана в документе по логическим причинам (в конечном итоге это просто слово стека как int или float are), вы остаетесь с поиском исходного кода вашей реализации или никогда не узнаете вообще.

в степени, мы не должны на самом деле всегда обвинять реализацию, есть некоторые подсказки, которые вы можете взять при поиске ваших ответов. Java определяет отдельные инструкции для работы с числами и ссылки. Справочно-манипулирующие инструкции начинаются с a (например,astore,aload или areturn) и являются единственными инструкциями, разрешенными для работы со ссылками. В частности, вам может быть интересно посмотреть на areturnреализация s.