Оптимизиране на производителността на JVM, Част 2: Компилатори

Java компилаторите заемат централно място в тази втора статия от поредицата за оптимизация на производителността на JVM. Ева Андреасон представя различните породи компилатор и сравнява резултатите от производителността на клиентска, сървърна и диференцирана компилация. Тя завършва с общ преглед на общи JVM оптимизации като премахване на мъртъв код, вграждане и оптимизация на цикъла.

Компилаторът на Java е източникът на известната независимост на платформата на Java. Разработчик на софтуер пише най-доброто Java приложение, което може, и след това компилаторът работи зад кулисите, за да създаде ефективен и добре изпълняващ се код за изпълнение за планираната целева платформа. Различните видове компилатори отговарят на различни нужди на приложението, като по този начин дават конкретни желани резултати от производителността. Колкото повече разбирате за компилаторите от гледна точка на това как работят и какви видове са налични, толкова повече ще можете да оптимизирате производителността на Java приложенията.

Тази втора статия от поредицата за оптимизация на производителността на JVM подчертава и обяснява разликите между различните компилатори на Java виртуални машини. Ще обсъдя и някои често срещани оптимизации, използвани от компилаторите Just-In-Time (JIT) за Java. (Вижте "Оптимизиране на производителността на JVM, част 1" за JVM преглед и въведение в серията.)

Какво е компилатор?

Просто казано, компилаторът приема език за програмиране като вход и създава изпълним език като изход. Един широко известен компилатор е javac, който е включен във всички стандартни комплекти за разработка на Java (JDK). javacвзема Java код като вход и го превежда в байт код - изпълним език за JVM. Байтовият код се съхранява в .class файлове, които се зареждат в изпълнението на Java при стартиране на процеса на Java.

Байтовият код не може да се чете от стандартните процесори и трябва да бъде преведен на език с инструкции, който основната платформа за изпълнение може да разбере. Компонентът в JVM, който е отговорен за превода на байт кода в изпълними инструкции на платформата, е още един компилатор. Някои JVM компилатори се справят с няколко нива на превод; например компилаторът може да създаде различни нива на междинно представяне на байт кода, преди той да се превърне в действителни машинни инструкции, последната стъпка на превода.

Байт код и JVM

Ако искате да научите повече за байт кода и JVM, вижте "Основи на байт кода" (Bill Venners, JavaWorld).

От гледна точка на платформата-агностик искаме да запазим кода независим от платформата, доколкото е възможно, така че последното ниво на транслация - от най-ниското представяне до действителния машинен код - да бъде стъпката, която заключва изпълнението на архитектурата на процесор на конкретна платформа . Най-високото ниво на разделяне е между статичните и динамичните компилатори. Оттам имаме опции в зависимост от това към каква среда на изпълнение се насочваме, какви резултати от производителността желаем и какви ограничения на ресурсите трябва да изпълним. Накратко обсъдих статичните и динамичните компилатори в част 1 от тази поредица. В следващите раздели ще обясня малко повече.

Статична срещу динамична компилация

Пример за статичен компилатор е споменатото по-горе javac. При статичните компилатори входният код се интерпретира веднъж и изходният изпълним файл е във формата, която ще се използва при изпълнение на програмата. Освен ако не направите промени в оригиналния си източник и не прекомпилирате кода (с помощта на компилатора), изходът винаги ще доведе до същия резултат; това е така, защото входът е статичен вход, а компилаторът е статичен компилатор.

В статична компилация, следният Java код

static int add7( int x ) { return x+7; }

би довело до нещо подобно на този байт код:

iload0 bipush 7 iadd ireturn

Динамичният компилатор динамично превежда от един език на друг, което означава, че това се случва при изпълнението на кода - по време на изпълнение! Динамичната компилация и оптимизация дават предимствата на изпълнението, че могат да се адаптират към промените в натоварването на приложенията. Динамичните компилатори са много подходящи за Java Runtimes, които обикновено се изпълняват в непредсказуеми и постоянно променящи се среди. Повечето JVM използват динамичен компилатор като компилатор Just-In-Time (JIT). Уловката е, че динамичните компилатори и оптимизацията на кода понякога се нуждаят от допълнителни структури от данни, нишка и ресурси на процесора. Колкото по-напреднала е оптимизацията или анализ на контекст на байт кода, толкова повече ресурси се изразходват от компилация. В повечето среди режийните разходи все още са много малки в сравнение със значителното увеличение на производителността на изходния код.

JVM разновидности и независимост от платформата Java

Всички имплементации на JVM имат едно общо нещо, това е опитът им да превърнат байтовия код на приложението в машинни инструкции. Някои JVM интерпретират кода на приложението при зареждане и използват броячи на производителността, за да се фокусират върху „горещ“ код. Някои JVM пропускат интерпретацията и разчитат само на компилация. Ресурсната интензивност на компилацията може да бъде по-голям хит (особено за клиентски приложения), но също така дава възможност за по-разширени оптимизации. Вижте Ресурси за повече информация.

Ако сте начинаещ в Java, тънкостите на JVM ще са много, за да ви омотаят главата. Добрата новина е, че всъщност не е нужно! JVM управлява компилацията на кода и оптимизацията, така че не е нужно да се притеснявате за машинни инструкции и оптималния начин за писане на код на приложение за основна архитектура на платформата.

От байт код на Java до изпълнение

След като направите Java кода си компилиран в байт код, следващите стъпки са да преведете инструкциите за байт код в машинен код. Това може да се направи от интерпретатор или компилатор.

Интерпретация

Най-простата форма на компилация на байт кодове се нарича интерпретация. Един преводач просто поглежда нагоре инструкциите за хардуер за всеки байткод инструкция и я изпраща на разстояние трябва да бъде изпълнена от процесора.

Можете да помислите за интерпретация, подобна на използването на речник: за конкретна дума (инструкция за байт код) има точен превод (инструкция за машинен код). Тъй като интерпретаторът чете и изпълнява незабавно по една инструкция за байт код, няма възможност за оптимизиране на набор от инструкции. Интерпретаторът също трябва да прави интерпретацията всеки път, когато се извиква байт код, което го прави доста бавен. Тълкуването е точен начин за изпълнение на код, но неоптимизираният набор от инструкции за изход вероятно няма да бъде най-ефективната последователност за процесора на целевата платформа.

Компилация

А компилатор от друга страна, се зарежда целия код, за да бъде изпълнена в издръжливостта й. Тъй като превежда байт кода, той има способността да разглежда целия или частичен контекст на изпълнение и да взема решения за това как всъщност да преведе кода. Неговите решения се основават на анализ на кодови графики като различни клонове на изпълнение на инструкции и данни от контекста на изпълнението.

Когато последователност от байт кодове се преобразува в набор от инструкции на машинен код и могат да се направят оптимизации за този набор от инструкции, заместващият набор от инструкции (например оптимизираната последователност) се съхранява в структура, наречена кеш код . Следващият път, когато байтовият код се изпълни, предварително оптимизираният код може незабавно да се намери в кеша на кода и да се използва за изпълнение. В някои случаи броячът на производителността може да активира и отмени предишната оптимизация, в който случай компилаторът ще изпълни нова оптимизационна последователност. Предимството на кеша на кода е, че полученият набор от инструкции може да бъде изпълнен наведнъж - няма нужда от интерпретационни справки или компилация! Това ускорява времето за изпълнение, особено за Java приложения, при които едни и същи методи се извикват няколко пъти.

Оптимизация

Заедно с динамичната компилация идва и възможността за вмъкване на броячи на производителността. Компилаторът може например да вмъкне брояч на производителносттада се брои всеки път, когато е извикан блок на байт код (например, съответстващ на определен метод). Компилаторите използват данни за това колко "горещ" е даден байт код, за да определят къде в кодовите оптимизации ще повлияят най-добре на работещото приложение. Данните за профилиране по време на изпълнение позволяват на компилатора да направи богат набор от решения за оптимизация на кода в движение, допълнително подобрявайки производителността на изпълнение на кода. Тъй като стават достъпни по-усъвършенствани данни за профилиране на код, те могат да се използват за вземане на допълнителни и по-добри решения за оптимизация, като например: как да се по-добре последователността на инструкциите в компилирания език, дали да се замени набор от инструкции с по-ефективни набори или дори дали да се премахнат излишните операции.

Пример

Помислете за Java кода:

static int add7( int x ) { return x+7; }

Това може да бъде статично компилирано от javacбайт кода:

iload0 bipush 7 iadd ireturn

Когато методът бъде извикан, байт кодът ще се компилира динамично към машинни инструкции. Когато броячът на производителността (ако присъства за кодовия блок) достигне праг, той също може да бъде оптимизиран. Крайният резултат може да изглежда като следния набор машинни инструкции за дадена платформа за изпълнение:

lea rax,[rdx+7] ret

Различни компилатори за различни приложения

Различните приложения на Java имат различни нужди. Дълго работещите корпоративни сървърни приложения могат да позволят повече оптимизации, докато по-малките клиентски приложения може да се нуждаят от бързо изпълнение с минимално потребление на ресурси. Нека разгледаме три различни настройки на компилатора и съответните им плюсове и минуси.

Клиентски компилатори

Добре известен оптимизиращ компилатор е C1, компилаторът, който е активиран чрез -clientопцията за стартиране на JVM. Както подсказва името му при стартиране, C1 е компилатор от страна на клиента. Той е предназначен за клиентски приложения, които имат по-малко налични ресурси и в много случаи са чувствителни към времето за стартиране на приложението. C1 използва броячи на производителността за профилиране на код, за да даде възможност за прости, относително ненатрапчиви оптимизации.

Компилатори от страна на сървъра

За дълго работещи приложения като корпоративни Java приложения от страна на сървъра компилаторът от страна на клиента може да не е достатъчен. Вместо това може да се използва компилатор от страна на сървъра като C2. C2 обикновено се активира чрез добавяне на опцията -serverза стартиране на JVM към вашия команден ред за стартиране. Тъй като повечето сървърни програми се очаква да работят дълго време, активирането на C2 означава, че ще можете да съберете повече профилиращи данни, отколкото бихте направили с кратко работещо леко клиентско приложение. Така че ще можете да приложите по-усъвършенствани техники за оптимизация и алгоритми.

Съвет: Загрейте вашия компилатор от страна на сървъра

За разполагане от страна на сървъра може да отнеме известно време, преди компилаторът да оптимизира първоначалните „горещи“ части на кода, така че разполаганията от страна на сървъра често изискват фаза „загряване“. Преди да правите каквото и да е измерване на производителността при внедряване от страна на сървъра, уверете се, че вашето приложение е достигнало стабилно състояние! Предоставянето на достатъчно време на компилатора да се компилира правилно ще работи във ваша полза! (Вижте статията на JavaWorld „Гледайте вашия компилатор на HotSpot“ за повече информация относно подгряването на вашия компилатор и механиката на профилиране.)

Сървърният компилатор отчита повече профилиращи данни от компилатора от страна на клиента и позволява по-сложен анализ на клонове, което означава, че ще прецени кой път за оптимизация би бил по-полезен. Наличието на повече данни за профилиране дава по-добри резултати от приложението. Разбира се, извършването на по-обширно профилиране и анализ изисква изразходване на повече ресурси за компилатора. JVM с активиран C2 ще използва повече нишки и повече цикли на процесора, ще изисква по-голям кеш код и т.н.

Многостепенна компилация

Многостепенна компилациякомбинира компилация от страна на клиента и от страна на сървъра. Azul за първи път направи диференцирана компилация достъпна в своя Zing JVM. Съвсем наскоро (от Java SE 7) той е приет от Oracle Java Hotspot JVM. Многоетапната компилация се възползва от предимствата на компилатора на клиент и сървър във вашия JVM. Клиентският компилатор е най-активен по време на стартиране на приложението и обработва оптимизации, задействани от по-ниски прагове за брояч на производителността. Клиентският компилатор също вмъква броячи на производителността и подготвя набори от инструкции за по-разширени оптимизации, които ще бъдат адресирани на по-късен етап от сървърния компилатор. Многостепенната компилация е много ефективен начин за профилиране, тъй като компилаторът е в състояние да събира данни по време на активност на компилатора с ниско въздействие, което може да се използва за по-напреднали оптимизации по-късно.Този подход също така дава повече информация, отколкото ще получите от използването само на интерпретирани броячи на профилни кодове.

Схемата на диаграмата на Фигура 1 показва разликите в производителността между чиста интерпретация, клиентска, сървърна и диференцирана компилация. Оста X показва времето за изпълнение (единица време) и ефективността на оста Y (ops / единица време).

Фигура 1. Разлики в производителността между компилаторите (щракнете, за да увеличите)

В сравнение с чисто интерпретирания код, използването на компилатор от страна на клиента води до приблизително 5 до 10 пъти по-добро изпълнение на изпълнението (в ops / s), като по този начин подобрява производителността на приложението. Разликата в печалбата разбира се зависи от това колко ефективен е компилаторът, какви оптимизации са разрешени или внедрени и (в по-малка степен) колко добре е проектирано приложението по отношение на целевата платформа за изпълнение. Последното е наистина нещо, за което разработчикът на Java никога не трябва да се притеснява.

В сравнение с компилатора от страна на клиента, компилаторът от страна на сървъра обикновено увеличава производителността на кода с измерими 30% до 50%. В повечето случаи подобряването на производителността ще балансира допълнителните разходи за ресурси.