Обновить
Поскольку было несколько комментариев о моем микробенчмарке, я могу дать общий ответ.
Спасибо за указание на недостатки в моей тестовой реализации. На этот раз я использовал фрагмент из Maciej Gorączka, чтобы проиллюстрировать подводные камни оптимизации и в то же время показать реальное влияние интерфейсных вызовов. Конечно, его пришлось модифицировать, поскольку он также страдал от ловушек оптимизации.
Есть несколько вещей в области оптимизации вызовов методов, которые необходимо учитывать при измерении производительности. Некоторые из них были описаны в главе, посвященной методам повышения производительности внутренних компонентов HotSpot.,
С моим исходным тестом я не учел нижеприведенное.
Виртуальные (и интерфейсные) вызовы с односторонним профилем типа компилируются с оптимистической проверкой в пользу исторически общего типа (или двух типов).
Таким образом, есть много правды в том, что микробенчмарки могут быть очень хитрыми.
В качестве примера того, как заблуждение об оптимизаторе может испортить ваши тесты, приведенный ниже код:
import java.math.BigDecimal; import java.util.Random; public class OptimizationTest { private static final long ITERATIONS = 1000 * 1000 * 1000; private static final int RUNS = 1; private static final SomeInterface[] IFACES = new SomeInterface[] {new Impl1(), new Impl2()}; private static final Impl1[] IMPLS = new Impl1[] {new Impl1(), new Impl1()}; private static final Random RANDOM = new Random(); private static final int CLASS_COUNT = 2; public static void main(String[] args) { performTestIface(2); //warm up final long implTime = performTestImpl(RUNS); final BigDecimal runs = new BigDecimal(RUNS); final BigDecimal implAvg = new BigDecimal(implTime).divide(runs); System.out.println("Invokevirtual: " + implAvg + " ms"); final long ifaceTime = performTestIface(RUNS); final BigDecimal ifaceAvg = new BigDecimal(ifaceTime).divide(runs); System.out.println("Invokeinterface: " + ifaceAvg + " ms"); } private static long performTestIface(final long runs) { final SomeInterface iface = IFACES[RANDOM.nextInt(CLASS_COUNT)]; long ifaceTime = 0; for (int run = 0; run < runs; run++) { System.gc(); long start = System.currentTimeMillis(); for (int i = 0; i < ITERATIONS; i++) { iface.doSomething(i); } ifaceTime += System.currentTimeMillis() - start; } return ifaceTime; } private static long performTestImpl(final long runs) { final Impl1 impl = IMPLS[RANDOM.nextInt(CLASS_COUNT)]; long implTime = 0; for (int run = 0; run < runs; run++) { System.gc(); long start = System.currentTimeMillis(); for (int i = 0; i < ITERATIONS; i++) { impl.doSomething(i); } implTime += System.currentTimeMillis() - start; } return implTime; } static interface SomeInterface { void doSomething(int i); } static class Impl1 implements SomeInterface { private int i; public void doSomething(final int i) { this.i = i; } } static class Impl2 implements SomeInterface { private int i; public void doSomething(final int i) { this.i = i; } } }
Пожалуйста, не забудьте запустить его более 10 раз . Я надеюсь, что вы получите удовольствие от результатов.
Теперь объяснение.
При запуске с флагом -XX: + PrintCompilation тест либо выдает:
142 9 test.InvokeInterfaceTest2 $ Impl1 :: DoSomething (6 байт)
142 1% test.InvokeInterfaceTest2 :: performTestIface @ 36 (77 байтов)
1701 2% test.InvokeInterfaceTest2 :: performTestImpl @ 36 (75 байт)
Invokevirtual: 745 мс
2447 10 test.InvokeInterfaceTest2 :: executeTestIface (77 байт)
Invokeinterface: 722 мс
или же:
65 3 test.InvokeInterfaceTest2 $ Impl2 :: doSomething (6 байт)
66 1% test.InvokeInterfaceTest2 :: executeTestIface @ 36 (77 байт)
1523 4 test.InvokeInterfaceTest2 $ Impl1 :: doSomething (6 байт)
1523 2% test.InvokeInterfaceTest2 : executeTestImpl @ 36 (75 байт)
Invokevirtual: 732 мс
2255 5 test.InvokeInterfaceTest2 :: executeTestIface (77 байт)
2262 1% выполнен не входящим test.InvokeInterfaceTest2 :: executeTestIface @ -2 (77 байт)
2268 3% test.InvokeInterfaceTest2: : executeTestIface @ 36 (77 байт).
Интерфейс вызова: 1816 мс
Обратите внимание, что HotSpot жалуется на метод, который не является входящим, что означает, что ранее скомпилированный метод больше не действителен. Это происходит потому, что мы используем 2 разные реализации одного и того же интерфейса, как в более быстром примере, но этот временной класс, реализующий интерфейс, изменился, поэтому целевой метод пришлось деоптимизировать.
Это становится еще более понятным, когда мы посмотрим, как оптимизация была реализована компилятором. В скором времени мы получаем собственный код для executeTestIface:
126 B16: # N1 <- B2 Freq: 1.01326e-06
126 movl RSI, # -28 # int
12b movq RBP, [rsp + # 0] # разлив
12f movl [rsp + # 4],
вызов RAX # разлив 133, статическая оболочка для: uncommon_trap (reason = ‘range_check’ action = ‘make_not_entrant’)
# OptimizationTest :: executeTestIface @ bci: 10 L [0] = RBP L [1] = _ L [2] = _ L [3] = _ L [4] = _ L [5] = _ L [6] = _ L [7] = _ L [8] = _ STK [0] = rsp + # 8 STK [1] = rsp + # 4
# OopMap {[8] = NarrowOop off = 312}
138 int3 # ShouldNotReachHere
Код перекомпилируется после того, как некоторое количество последующих мономорфных вызовов достигнет PerMethodTrapLimit или PerBytecodeRecompilationCutoff. Таким образом, для более медленного запуска код перекомпилируется в:
14f B16: # N250 <- B9 Freq: 0,25578
14f movl RSI, # -58 # int
154 movq [rsp + # 16], RBX # разлив
159 movq [rsp + # 32], R14 # разлив
15e movq [rsp + # 40],
вызов R13 # spill 163, статическая оболочка для: uncommon_trap (reason = ‘bimorphic’ action = ‘Maybe_recompile’)
# OptimizationTest :: executeTestIface @ bci: 49 L [0] = rsp + # 0 L [1] = _ L [2] = rsp + # 40 L [3] = rsp + # 16 L [4] = _ L [5] = rsp + # 24 L [6] = rsp + # 32 L [7] = _ L [ 8] = RBP STK [0] = rsp + # 40 STK [1] = RBP
# OopMap {[40] = Oop off = 360}
168 int3 # ShouldNotReachHere
168
16d B17: # B3 B18 <- B2 Freq: 0,16983
16d decode_heap_oop_not_null RSI, R10
171 movq rdi, [RSI + (sizeof (oopDesc) + Klass :: second_supers_offset_in_bytes ())]
movl rcx, [rdi + arrayOopDesc :: length_offset_in_bytes ()] # длина для сканирования
addq rdi, arrayOopDex :: base_offset_ip_OBJT (TIBT): для начала данных; набор NZ в случае подсчете равно нулю
REPNE scasq # Scan * RDI ++ для матча с бараками в то время как cx— = 0!
JNE, с мисс # Пропущенные: Флаги новозеландского
MOVQ [RSI + (SizeOf (oopDesc) + Klass :: secondary_super_cache_offset_in_bytes () )], RAX # Hit: обновление кэша
отсутствует:
2a7 je B3 P = 0,999999 C = -1,000000B18: # N250 <- B17 Freq: 1.6983e-07
2ad movl RSI, # -83 # int
2b2 movq [rsp + # 8], R13 # разлив
2b7 movq [rsp + # 16], RBX # разлив
2bc movq [rsp + # 32], R14 # spill
2c1 nop # 2 байта pad для циклов и вызовов
2c3 call, статическая оболочка для: uncommon_trap (reason = ‘unreached’ action = ‘reinterpret’)
# OptimizationTest :: executeTestIface @ bci: 36 L [0 ] = rsp + # 0 L [1] = _ L [2] = rsp + # 8 L [3] = rsp + # 16 L [4] = _ L [5] = rsp + # 24 L [6] = rsp + # 32 L [7] = _ L [8] = RBP
# OopMap {[8] = Oop off = 712}
2c8 int3 # ShouldNotReachHere
Обычно в высокопроизводительных приложениях интерфейсы, отражающие шаблоны, такие как наблюдатель или слушатель, вызываются в цикле с часто меняющимися реализациями. Вот почему я считаю, что эта проблема может иметь практические последствия.
Спасибо, господа за замечания. Я явно не смог предоставить безупречный тест, доказывающий мои аргументы с первого раза.
Надеюсь, вы найдете это солидным и интересным.
Конец обновления
Я думаю, что нам всем нравится писать хороший код; мы очень часто используем такие идеи, как композиция, разделение интересов и так далее. Прежде всего, мы совершенно правильно склонны вводить различные уровни абстракции с использованием интерфейсов и абстрактных классов. Этот подход идеально подходит для выдающегося количества сценариев, но может привести к снижению производительности, когда мы имеем дело с требованиями низкой задержки.
Каждый раз, когда мы вводим новый уровень абстракции, в то же время мы добавляем усложнение к процедуре, с помощью которой JVM разрешает целевой метод для вызова.
Методы в Java не вызываются напрямую из-за полиморфной природы языка. Например:
final Set set = getImpl(); // returns different implementations set.add(new Object());
будет иметь другие последствия, чем:
final HashSet set = getImpl(); // returns different implementations set.add(new Object());
Первый будет переведен на:
... NEW java/lang/Object DUP INVOKESPECIAL java/lang/Object.<init> ()V INVOKEINTERFACE java/util/Set.add (Ljava/lang/Object;)Z ...
Хотя последний будет выглядеть больше как:
... NEW java/lang/Object DUP INVOKESPECIAL java/lang/Object.<init> ()V INVOKEVIRTUAL java/util/HashSet.add (Ljava/lang/Object;)Z ...
Различие в поведении JVM для этих двух случаев может быть не столь очевидным даже после тщательного прочтения спецификации инструкций . Процедура поиска конкретного метода в таблице виртуальных методов практически идентична, но открытый символ семантики INVOKEINTERFACE делает некоторые оптимизации невозможными.
Давайте рассмотрим приведенные ниже примеры. В первом случае:
class Parent { public void method1() { } public void method2() { } } class Child extends Parent { public void method2() { } // overriden method public void method3() { } }
Эта настройка приведет к созданию таблицы виртуальных методов, которая выглядит примерно так:
родитель | |
1 | Parent.method1 |
2 | Parent.method2 |
ребенок | |
1 | Parent.method1 |
2 | Child.method2 |
3 | Child.method3 |
Здесь мы можем наблюдать, как таблица виртуальных методов для дочернего класса сохраняет порядок методов для родительской таблицы методов класса. Он только переопределяет ссылку / ссылку на переопределенные методы, таким образом, обеспечивая мономорфные вызовы и оптимизации, которые используют жесткую связь с целевыми методами и полностью исключают необходимость поиска таблицы методов при каждом вызове. Это, в свою очередь, предоставляет средства для встраивания методов, которые могут привести к хорошему повышению производительности.
Теперь давайте посмотрим на случай для интерфейсов:
interface SomeInterface { void someMethod(); } class Parent { public void method1() { } public void method2() { } } class Child extends Parent implements SomeInterface { public void method2() { } // overridden method from Parent public void someMethod() { } } class OtherClass implements SomeInterface { public void method3() { } public void someMethod() { } }
Таблица виртуальных методов для приведенного выше будет выглядеть так:
родитель | |
1 | Parent.method1 |
2 | Parent.method2 |
ребенок | |
1 | Parent.method1 |
2 | Child.method2 |
3 | SomeInterface.someMethod |
OtherClass | |
1 | OtherClass.method3 |
2 | SomeInterface.someMethod |
Как мы видим, порядок индексов интерфейсного метода someMethod () не сохраняется. Для дочернего класса это будет метод # 3, но для OtherClass запись для этого метода будет найдена под # 2.
Следствием введения этих мегаморфных вызовов является дополнительная стоимость поиска в таблице виртуальных методов при каждом вызове метода. Чтобы проиллюстрировать влияние мегаморфных вызовов, мы можем запустить простой микробенчмарк:
Изменить: приведенный ниже код явно ошибочен (см. Комментарии). Используйте один из верхней части статьи.
import java.math.BigDecimal; public class InvokeInterfaceTest { private static final long ITERATIONS = 1000 * 1000 * 1000; private static final int RUNS = 10; private static long implTime; private static long ifaceTime; public static void main(String[] args) { performTest(2); //warm up ifaceTime = implTime = 0; performTest(RUNS); final BigDecimal implAvg = new BigDecimal(implTime).divide(new BigDecimal(RUNS)); final BigDecimal ifaceAvg = new BigDecimal(ifaceTime).divide(new BigDecimal(RUNS)); System.out.println("Invokevirtual: " + implAvg + " ms"); System.out.println("Invokeinterface: " + ifaceAvg + " ms"); } private static void performTest(final long runs) { final FooImpl impl = new FooImpl(); final Foo iface = new FooImpl(); for (int run = 0; run < runs; run++) { System.gc(); long start = System.currentTimeMillis(); for (int i = 0; i < ITERATIONS; i++) { impl.doSomething(i); } implTime += System.currentTimeMillis() - start; System.gc(); start = System.currentTimeMillis(); for (int i = 0; i < ITERATIONS; i++) { iface.doSomething(i); } ifaceTime += System.currentTimeMillis() - start; } } private static interface Foo { void doSomething(int i); } private static class FooImpl implements Foo { private int i; public void doSomething(final int i) { this.i = i; //avoid optimization } } }
Который на моем ноутбуке с процессором Intel® Core ™ (i5-2410M) @ 2,30 ГГц с использованием Java 1.6.0_26-b03 для 64-битной Linux дает следующие результаты:
Invokevirtual: 735,4 мс
Invokeinterface: 1412,4 мс
Чтобы минимизировать влияние других процессов, запущенных в моей системе, я прикрепил тестовый поток к одному ядру, сделав его недоступным для других процессов и достигнув более стабильных результатов (как описано в моем предыдущем посте о потоках Java).
Итак, если вам приходится иметь дело с огромным количеством вызовов, а задержка является решающим фактором, то вам, вероятно, придется рассмотреть возможность устранения мегаморфных вызовов за счет менее абстрактного, хитрого дизайна.