Пост из серии "будни перформанс-инженеров". Мы долго пытались игнорировать вопросы по reflection, отговариваясь, что он работает достаточно быстро, чтобы не быть проблемой, что даже на коленке измеряли его производительность против обычных вызовов и cglib’а.

Настало время расставить точки на "i". <habracut />

<h4>Тест</h4> Поскольку голословным в этом вопросе быть нельзя, нам нужен референсный бенчмарк. Надо помнить, что какой бы микробенчмарк я не написал, его результаты, <i>вопреки распространённому заблуждению</i>, нельзя использовать для предсказания производительности реальных приложений, но заглянуть внутрь и понять, что же мы видим, он помогает.

Итак, вот наш тест: <source lang="java"> public class ReflectTest {

private static final int LOOPS_IN_STEP_COUNT = Integer.getInteger("loops.count", 100);
private A a;
private Method m;
private Method am;
private FastMethod fm;
private Double[] args;
private void initArgs() {
    args = new Double[LOOPS_IN_STEP_COUNT+1];
    int c = 0;
    for (double i = 0; i < Math.PI*LOOPS_IN_STEP_COUNT; i += Math.PI) {
        args[c] = i;
        c++;
    }
}
@Setup
public void setup() throws NoSuchFieldException, NoSuchMethodException {
    System.err.println("loop count = " + LOOPS_IN_STEP_COUNT);
    a = new A();
    m = A.class.getDeclaredMethod("add", Double.class);
    am = A.class.getDeclaredMethod("add", Double.class);
    am.setAccessible(true);
FastClass fc = FastClass.create(A.class);
fm = fc.getMethod(m);
    initArgs();
}
@GenerateMicroBenchmark
public void raw() {
    for (int c = 0; c < LOOPS_IN_STEP_COUNT; c++) {
        a.value += args[c];
    }
}
@GenerateMicroBenchmark
public void directMethodAccess() {
    for (int c = 0; c < LOOPS_IN_STEP_COUNT; c++) {
        a.add(args[c]);
    }
}
@GenerateMicroBenchmark
public void reflectiveMethodAccess() throws Exception {
    for (int c = 0; c < LOOPS_IN_STEP_COUNT; c++) {
        m.invoke(a, args[c]);
    }
}
@GenerateMicroBenchmark
public void reflectiveMethodAccessAccessible() throws Exception {
    for (int c = 0; c < LOOPS_IN_STEP_COUNT; c++) {
        am.invoke(a, args[c]);
    }
}
@GenerateMicroBenchmark
public void cglibMethod() throws Exception {
    for (int c = 0; c < LOOPS_IN_STEP_COUNT; c++) {
        fm.invoke(a, new Object[] {args[c]});
    }
}
public static class A {
    public double value = 0;
    public void add(Double x) {
        value += x;
    }
}

} </source> Некоторые пререквизиты и design considerations: <ul> <li>вызываем public-метод в public-классе</li> <li>Sun JDK 7b141, -d64 -server -Xmx8g -Xms8g -Xmn6g -XX:+UseNUMA</li> <li>Intel Xeon (Nehalem) 3.0 Ghz, 8x4x2 = 64 HW threads, Solaris 10</li> <li>тестируем несколько конфигураций:<ol><li><b>raw</b>: вызова метода нет, в цикл подставлено его тело</li> <li><b>direct</b>: прямой вызов</li> <li><b>cglib</b>: вызов через cglib</li> <li><b>reflective</b>: вызов через reflection</li> <li><b>reflective-accessible</b>: вызов через reflection с setAccessible(true)</li></ol></li> <li> в теле вызываемого метода — сложение double-ов. double выбраны потому, что их сложение не ассоциативно, и потому loop unrolling ничего толком не даёт, и поэтому тест более стабилен и не зависит от прихотей компилятора <i>[обжёгся, выбросил целый эксперимент, в котором складывались int’ы]</i>.</li> <li>Double сразу за-box-ены, чтобы не думать, в каком месте сработал автобоксинг, а в каком нет <i>[аналогично, выбросил ещё целый эксперимент, где в одних случаях складывались примитивные значения, а в другом — boxed]</i> </li></ul> <h4>Тест №1</h4> <img src="http://people.apache.org/~shade/articles/reflection-habr/plot-uncached.png" alt="image"/>

Что мы здесь видим?<ul><li>direct заинлайнился, и его производительность стала равной raw, скалируется идеально</li> <li>cglib тоже неплохо себя повёл, хотя и медленее в 3.5х раза, скалируется идеально</li> <li>reflective тащатся в хвосте</li></ul> На этом месте обычно "наивные бенчмаркеры"<sup>TM</sup> останавливаются и говорят "опа-опа, с рефлекшеном всё плохо". А между тем, надо бы понять, почему конкретно это плохо.

<h4>Тест №2</h4> Кажущаяся несущественной деталь: для некоторых вызовов требуется либо массив аргументов, либо vararg. Это может потребовать создания массива на лету, что при мелких методах типа "сложить два числа" превращает бенчмарк из теста на reflection в тест на GC.

В конкретном случае с reflection на большой машине мы поставили GC просто на колени, аллоцируя 2+ Гбайт/сек. Почему это коснулось только reflection?

Трудно сейчас об этом говорить, но моя <i>гипотеза</i> в том, что временный массив таки уничтожается JIT’ом в большинстве случаев, но для reflection нужны приседания над приведением типов в массиве аргументов, чтобы обеспечить семантику, схожую с вызовом прямого метода, и поэтому скаляризовать массив, подставив вместо ссылок на массив сами элементы, пропустив аллокацию, не так просто.

Проверим, сделав "кешированные" аргументы:

<source lang="java"> public class ReflectTest {

// ...
private Double[][] cachedArgs;
// ...
private void initArgs() {
    cachedArgs = new Double[LOOPS_IN_STEP_COUNT+1][];
    args = new Double[LOOPS_IN_STEP_COUNT+1];
    int c = 0;
    for (double i = 0; i < Math.PI*LOOPS_IN_STEP_COUNT; i += Math.PI) {
        args[c] = i;
        cachedArgs[c] = new Double[] { args[c] };
        c++;
    }
}
@GenerateMicroBenchmark
public void raw_Cached() {
    for (int c = 0; c < LOOPS_IN_STEP_COUNT; c++) {
        a.value += cachedArgs[c][0];
    }
}
@GenerateMicroBenchmark
public void directMethodAccess() {
    for (int c = 0; c < LOOPS_IN_STEP_COUNT; c++) {
        a.add(args[c]);
    }
}
@GenerateMicroBenchmark
public void reflectiveMethodAccess_Cached() throws Exception {
    for (int c = 0; c < LOOPS_IN_STEP_COUNT; c++) {
        m.invoke(a, cachedArgs[c]);
    }
}
@GenerateMicroBenchmark
public void reflectiveMethodAccessAccessible_Cached() throws Exception {
    for (int c = 0; c < LOOPS_IN_STEP_COUNT; c++) {
        am.invoke(a, cachedArgs[c]);
    }
}
@GenerateMicroBenchmark
public void cglibMethod_Cached() throws Exception {
    for (int c = 0; c < LOOPS_IN_STEP_COUNT; c++) {
        fm.invoke(a, cachedArgs[c]);
    }
}
public static class A {
    public double value = 0;
    public void add(Double x) {
        value += x;
    }
}

} </source>

<img src="http://people.apache.org/~shade/articles/reflection-habr/plot-cached.png" alt="image"/>

Что мы видим здесь?<ul><li>reflective снова в деле</li> <li>reflective-accessible догнал cglib. Это случилось потому, что теперь мы не проверяем права на доступ к методу каждый раз при вызове. Точнее, мы вообще не проверяем права доступа :)</li> <li>raw упал по отношению к direct. Теперь там теперь выборка из <i>двух</i> массивов, а не из одного: показывает, сколько стоит лишний dereference.</li> <li>cglib <i>только слегка</i> упал, что подсказывает, что временный массив в оригинальном тесте всё-таки есть</li></ul> Посмотрев, насколько чувствителен тест на примере падения raw’а, я прикинул, что реальный оверхед от вызова сравним с оверхедом от лишнего доступа в память, и потому дальше внутрь не полез.

<h4>"38 попугаев"</h4> Теперь фильтруем тех, кто умеет читать до конца. Несмотря на дикую разницу на этом тесте, в реальных приложениях методы куда тяжелее. Поскольку все методы делают одно и то же сложение даблов (+ доступы в память), можно на него снормировать стоимость вызова, с точностью "плюс-минус километр". Вот немножко школьной математики:

<pre> (a): 1/(<время-на-исполнение-тела-метода> + <время-на-вызов>) = <throughput-теста> (b): 1/(<время-на-исполнение-тела-метода>) = <throughput-raw> из (a,b): <время-на-вызов> = <время-на-исполнение-тела-метода> * [<throughput-raw> / <throughput-теста> - 1] </pre> …​т.е. если приравнять <время-на-исполнение-тела-тестового-метода> одному "попугаю", то в попугаях стоимость вызова будет: <ul> <li><b>direct</b>: 0 попугаев</li> <li><b>cglib</b>: 2.5 попугая</li> <li><b>reflective (cached)</b>: 4.7 попугая</li> <li><b>reflective-accessible (cached)</b>: 2.5 попугая</li> <li><b>reflective</b>: 62 попугая</li> <li><b>reflective-accessible</b>: 62 попугая</li></ul> Если вы вызываете метод стоимостью X попугаев и сам вызов стоит Y попугаев, то оверхед от самого вызова будет составлять

<pre> overhead = [(X+Y)/X - 1] * 100% = Y/X * 100%. </pre> На практике удобно понимать, что падение производительности <i><b>больше</b></i> 10% будет на методах, стоимость которых <i><b>меньше</b></i> некоторого количества попугаев. Назовём эту важную характеристику "длиной удава". Предыдущее определение удобно тем, что длина удава — это стоимость вызова в попугаях, умноженная на 10.

Легко, кстати, для нашей тестовой машины оценить величину одного попугая в единицах СИ: мы каждую секунду делаем 1.5 миллионов итераций в 32 потоках, и каждая итерация это 10000 вызовов, поэтому

<pre> 1 попугай = 1 / (1.5*10^6 [iters/sec] * 10^4 [ops/iter] / 32 [threads]) =~ 21*10^(-10) [sec/ops] =~ 2 нс </pre> Тогда длина удава даже для самого плохого случая с reflective - 130 наносекунд. На практике очень мало интересных для рефлексии методов настолько легки. Звать геттеры/сеттеры через рефлексию, понятно, не стоит.<img src="http://shipilev.net/marker.png" width=1 height=1>

<b>UPD</b>: <hh user="apangin"> <a href="http://habrahabr.ru/blogs/java/119820/#comment_3921481">в комментарии</a> аккуратно описал, как конкретно рефлекшн реализован в HotSpot.