JIT-компілятор оптимізує не круто, а дуже круто

Нещодавно Лукас Едер зацікавився у своєму блозі, здатний JIT-компілятор Java оптимізувати такий код, щоб прибрати непотрібний обхід списку з одного елемента:
// ... а тут ми "знаємо", що список містить тільки одне значення
for (Object object : Collections.singletonList("abc")) {
doSomethingWith(object);
}

Ось моя відповідь: JIT може навіть більше. Ми будемо говорити про HotSpot JVM 64 bit восьмої версії. Давайте розглянемо ось такий простий метод, який вважає сумарну довжину рядків з переданого списку:
static int testIterator(List<String> list) {
int sum = 0;
for (String s : list) {
sum += s.length();
}
return sum;
}

Багатьом Java-програмістів відомо, що цей код повністю еквівалентний наступному:
static int testIterator(List<String> list) {
int sum = 0;
Iterator<String> it = list.iterator();
while(it.hasNext()) {
String s = it.next();
sum += s.length();
}
return sum;
}

Звичайно, у загальному випадку
list
може виявитися все що завгодно і тому JIT-компілятора доведеться згенерувати чесні віртуальні виклики на місці
iterator()
,
hasNext()
та
next()
, що, звичайно, не дуже швидко. Але що трапиться, якщо ми завжди будемо викликати цей метод, подаючи йому на вхід
singletonList
? Давайте додамо простенький метод
main()
:
public class Test {
static int res = 0;

public static void main(String[] args) {
for (int i = 0; i < 100000; i++) {
res += testIterator(Collections.singletonList("x"));
}
System.out.println(res);
}
}

Тут ми викликаємо наш
testIterator
в циклі достатню кількість разів, щоб скомпілювати метод JIT-компілятором C2. Як деякі з вас вже знають, у віртуальній машині HotSpot є два JIT-компілятора: C1 (клієнтський) і C2 (серверна). В 64-бітної версії Java 8 з налаштуваннями за замовчуванням вони працюють спільно. Спершу метод компілюється за допомогою C1 (який компілює швидко, але створює не дуже оптимальний код). При цьому в код додаються додаткові інструкції, які збирають деяку статистику (це називається "профілювання"). Це, звичайно, уповільнює виконання, але знадобиться в подальшому. Серед різних профілів збирається профіль типів. У нашому випадку JVM уважно стежить, який тип має параметр
list
при кожному виклику. І тут віртуальна машина зауважує, що в 100% випадків на вході був список типу Collections$SingletonList (який повертає метод
singletonList
).
Коли кількість викликів методу досягає деякого порогу, метод перекомпилируется компілятором C2, яким доступний зібраний профіль. C2 робить розумне припущення, що дотепер завжди був
SingletonList
, і далі він буде часто попадатися. А значить,
iterator()
точно викличе метод singletonIterator(). Але там вже нетривіальний об'єкт, який, приміром, містить поле
hasNext
, щоб відстежити, що його не викликали двічі, і якщо треба кинути
NoSuchElementException
. Здатний C2 з цим поборотися?
Щоб дізнатися відповідь, ми можемо попросити JIT-компілятор вивести асемблер згенерований для методів. Для цього нам буде потрібно встановити hsdis. Потім можна скористатися зручними інструментами на зразок JITWatch або написати JMH-бенчмарк і скористатися опцією
perfasm
. Але тут у нас приклад простий, тому ми обійдемося без сторонніх інструментів і просто запустимо віртуальну машину з такими чарівними параметрами:
$ java -XX:+UnlockDiagnosticVMOptions -XX:+PrintCompilation -XX:+PrintAssembly Test >output.txt

Будьте обережні: висновок цієї команди може налякати маленьких дітей. Але порившись в ньому, ви знайдете код, згенерований для нашого методу
testIterator
. Ось що згенерував C2 на платформі Intel x64 з купою до 4 Гб:
Асемблер, можна не вчитуватися
# {method} {0x0000000055120518} 'testIterator' '(Ljava/util/List;)I' in 'Test'
# parm0: rdx:rdx = 'java/util/List'
# [sp+0x20] (sp of caller)
0x00000000028e7560: mov %eax,-0x6000(%rsp)
0x00000000028e7567: push %rbp
0x00000000028e7568: sub $0x10,%rsp ;*synchronization entry
; - Test::testIterator@-1 (line 15)

0x00000000028e756c: mov 0x8(%rdx),%r10d ; implicit exception: dispatches to 0x00000000028e75bd
0x00000000028e7570: cmp $0x14d66a20,%r10d ; {metadata('java/util/Collections$SingletonList')}
0x00000000028e7577: jne 0x00000000028e75a0 ;*synchronization entry
; - java.util.Collections::singletonIterator@-1
; - java.util.Collections$SingletonList::iterator@4
; - Test::testIterator@3 (line 16)

0x00000000028e7579: mov 0x10(%rdx),%ebp ;*getfield element
; - java.util.Collections$SingletonList::iterator@1
; - Test::testIterator@3 (line 16)

0x00000000028e757c: mov 0x8(%rbp),%r11d ; implicit exception: dispatches to 0x00000000028e75c9
0x00000000028e7580: cmp $0x14d216d0,%r11d ; {metadata('java/lang/String')}
0x00000000028e7587: jne 0x00000000028e75b1
0x00000000028e7589: mov %rbp,%r10 ;*checkcast
; - Test::testIterator@24 (line 16)

0x00000000028e758c: mov 0xc(%r10),%r10d ;*getfield value
; - java.lang.String::length@1
; - Test::testIterator@30 (line 17)

0x00000000028e7590: mov 0xc(%r10),%eax ;*synchronization entry
; - Test::testIterator@-1 (line 15)
; implicit exception: dispatches to 0x00000000028e75d5
0x00000000028e7594: add $0x10,%rsp
0x00000000028e7598: pop %rbp
0x00000000028e7599: test %eax,-0x27b759f(%rip) # 0x0000000000130000
; {poll_return}
0x00000000028e759f: retq 
... // далі холодні шляху

Перше, що кидається в очі — це стислість коду. З вашого дозволу я прокоментую, що тут відбувається:
// Стандартний стековий фрейм - подібним чином починається всякий JIT-компільований метод
mov %eax,-0x6000(%rsp)
push %rbp
sub $0x10,%rsp 
// Завантажуємо ідентифікатор класу об'єкта з змінної list (покажчик на об'єкт прийшов в метод в регістрі rdx).
// Ідентифікатор класу лежить в об'єкті по зсуву 0x8. Це схоже на виклик list.getClass().
// При цьому тут відбувається неявна перевірка на значення null. Якщо виявиться, що передали в метод null,
// процесор згенерує апаратне виняток у зв'язку з зверненням з забороненого адресою.
// Виключення перехопить віртуальна машина і дбайливо транслює його в NullPointerException
mov 0x8(%rdx),%r10d
// Порівнюємо list.getClass() з ідентифікатором класу Collections$SingletonList. Цей ідентифікатор не змінюється
// за час роботи JVM і, звичайно, JIT його знає, тому це просто порівняння з константою
cmp $0x14d66a20,%r10d
// Якщо list - це не SingletonList, вискакуємо на холодний шлях
jne 0x00000000028e75a0
// Читаємо приватне поле Collections$SingletonList.element в регістр rbp. Хоча покажчики 64-бітні, при розмірі купи 
// менше 4 Гб верхні 32 біта завжди нулі, тому віртуальна машина їх не зберігає і копіює тільки 32 нижніх біта в ebp
mov 0x10(%rdx),%ebp
// Читаємо ідентифікатор класу елемента та звіряємо його з ідентифікатором класу String (аналогічно тому, що вище)
mov 0x8(%rbp),%r11d
cmp $0x14d216d0,%r11d
// Якщо елемент списку - не рядок, вискакуємо надвір на холодний шлях (там буде створено і викинуто ClassCastException)
jne 0x00000000028e75b1
// Читаємо приватне поле String.value в регістр r10. Це масив char[], в якому зберігається сама рядок
mov %rbp,%r10
mov 0xc(%r10),%r10d
// Читаємо довжину масиву в регістр eax, який стандартно використовується для передачі значення, що повертається методу
mov 0xc(%r10),%eax
// Відновлення стекового фрейму
add $0x10,%rsp
pop %rbp
// Перевірка на safe-point. З її допомогою JVM може забрати контроль у скомпільованого коду, наприклад, для збирання сміття.
test %eax,-0x27b759f(%rip)
// Вихід з методу
retq 

Якщо комусь все ще складно це зрозуміти, давайте перепишемо на псевдокоде:
if (list.class != Collections$SingletonList) {
goto SLOW_PATH;
}
str = ((Collections$SingletonList)list).element;
if (str.class != String) {
goto EXCEPTIONAL_PATH;
}
return ((String)str).value.length;

Бачите? На гарячому шляху немає ні циклу, ні виділення пам'яті під ітератор, жодного виклику методу. Всього лише кілька разыменований і дві швидкі перевірки (які завжди були помилкові, тому пророкування розгалужень в процесорі відпрацює на ура). JIT-компілятор заинлайнил все, що можна, зрозумів, що ітератор з методу не тікає, позбувся виділення пам'яті, розгорнув цикл і навіть зміг видалити прапор
hasNext
та пов'язані з ним перевірки, статично довівши, що вони не потрібні! Додавання і мінлива
sum
також випарувалися. І тим не менш, метод повністю коректним. Якщо виявиться, що при наступному виклику йому не передадуть
singletonList
, а щось інше, він просто піде на холодний шлях (який, звичайно, значно повільніше). Інші виключні ситуації теж обробляються. Можна передати
null
замість
list
або підсунути в список не рядок (слава type erasure) — все це буде оброблено згідно з семантикою мови.
Що ж станеться, якщо сценарій роботи програми зміниться? Припустимо, через деякий час ми взагалі перестали передавати цей метод
singletonList
і передаємо тепер всякі інші списки. На повільному шляху віртуальна машина продовжує збирати статистику. Якщо вона виявить, що повільний шлях відбувається дуже часто, JIT-компілятор перекомпилирует метод, прибравши спеціальну обробку
singletonList
і вставивши відразу чесні віртуальні виклики. Може ще, до речі, зробити дві гілки, якщо ви використовуєте тільки дві різні реалізації списків. Цим JIT відрізняється від додатків скомпільованих заздалегідь: виконуваний машинний код вашої програми може змінюватися, слідуючи за змінами в її поведінці.
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.