Почему передача двух аргументов строки более эффективна, чем один аргумент списка
В приведенном ниже коде указаны две простые функции по 10 миллиардов раз каждый.
public class PerfTest {
private static long l = 0;
public static void main(String[] args) {
List<String> list = Arrays.asList("a", "b");
long time1 = System.currentTimeMillis();
for (long i = 0; i < 1E10; i++) {
func1("a", "b");
}
long time2 = System.currentTimeMillis();
for (long i = 0; i < 1E10; i++) {
func2(list);
}
System.out.println((time2 - time1) + "/" + (System.currentTimeMillis() - time2));
}
private static void func1(String s1, String s2) { l++; }
private static void func2(List<String> sl) { l++; }
}
Мое предположение заключалось в том, что производительность этих двух вызовов будет близка к идентичной. Если что-нибудь, я бы догадался, что прохождение двух аргументов будет немного медленнее, чем передача одного. Учитывая, что все аргументы являются объектными ссылками, я не ожидал, что один из них представляет собой список, который имеет значение.
Я провел тест много раз, и типичным результатом является "12781/30536". Другими словами, вызов с использованием двух строк занимает 13 секунд, а вызов с использованием списка занимает 30 секунд.
Каково объяснение этой разницы в производительности? Или это несправедливый тест? Я попытался переключить два вызова (в случае, если это было вызвано эффектами запуска), но результаты одинаковы.
Обновление
Это не справедливый тест по многим причинам. Однако он демонстрирует реальное поведение компилятора Java. Обратите внимание на следующие два дополнения, чтобы продемонстрировать это:
- Добавление выражений
s1.getClass()
и sl.getClass()
к функциям делает два вызова функций одними и теми же
- Выполнение теста с помощью
-XX:-TieredCompilation
также делает вызовы двух функций выполняющими те же самые
Объяснение этого поведения приведено в принятом ниже ответе. Очень краткое изложение ответа @apangin заключается в том, что func2
не встроен компилятором hotspot, потому что класс его аргумента (т.е. List
) не разрешен. Принудительное разрешение класса (например, с использованием getClass
) заставляет его быть встроенным, что значительно улучшает его производительность. Как указано в ответе, нерешенные классы вряд ли произойдут в реальном коде, что делает этот код нереалистичным случаем края.
Ответы
Ответ 1
Тест нечестный, однако он показал интересный эффект.
Как заметил Sotirios Delimanolis, разница в производительности вызвана тем, что func1
встроен компилятором HotSpot, а func2
- нет. Причиной является аргумент func2
типа List
, класс, который никогда не был разрешен во время выполнения эталона.
Обратите внимание, что класс List
фактически не используется: нет вызванных методов List, нет полей типа List, не заданных классов и других действий, которые обычно вызывают класс . Если вы добавите использование класса List
в любом месте кода, func2
будет вложен.
Другим критерием, который повлиял на стратегию компиляции, является простота метода. Это так просто, что JVM решил скомпилировать его в Tier 1 (C1 без дальнейшей оптимизации). Если он был скомпилирован с C2, класс List
будет разрешен. Попробуйте запустить с помощью -XX:-TieredCompilation
, и вы увидите, что func2
успешно встроен и работает так же быстро, как func1
.
Написание реалистичных микрообъектов вручную - действительно сложная задача. Есть так много аспектов, которые могут привести к запутывающим результатам, например. встраивание, удаление мертвого кода, замена на стеке, загрязнение профиля, перекомпиляция и т.д. Поэтому настоятельно рекомендуется использовать надлежащие инструменты для сравнительного анализа, такие как JMH. Рукописные тесты могут легко обмануть JVM. В частности, реальные приложения вряд ли будут иметь методы с классами, которые никогда не используются.