Лямбды і анонімні класи: хто більше жере

За мотивами недавніх обговорень тут захотілося більш широко поглянути на питання про те, хто більше їсть — новомодні хипстерские лямбды або старі перевірені анонімні класи. Давайте влаштуємо словесну перепалку між ними і подивимося, хто виграє. Як з будь-яким добротним холиваром, навіть якщо не вдасться з'ясувати переможця, можна дізнатися багато нового для себе.
Перший раунд. Простір на екрані.
Лямбда-кун: хе. Хе-хе-хе. Ні, це несерйозно. Ну як може зрівнятися ось це убозтво:
Runnable r = new Runnable() {
@Override
public void run() {
System.out.println("Hello!");
}
};

З ось цією красою:
Runnable r = () -> System.out.println("Hello!");

Анон-сан: ну-ну, хлопче, не треба так висловлюватися. В наші дні нікого не хвилює, що там у файлі насправді. Достатньо взяти хорошу IDE і різниця вже практично непомітна.
Ось як виглядає анонімний клас:Анонімний клас
А ось лямбда:Лямбда
Анон-сан: при цьому мене можна розгорнути і подивитися, що я таке насправді, а от що ви таке насправді — велика загадка.
Другий раунд. Простір на диску.
Лямбда-кун: кх-хм… Ні, це, звичайно, нечесно. Ну гаразд. Але на диску-то я займаю менше. Візьмемо простий клас:
public class Test {
Runnable r = () -> {};
}

А з тобою буде ось що:
public class Test {
Runnable r = new Runnable() {
@Override
public void run() {
}
};
}

52 байта проти 126! Яке, а?
Анон-сан: ну з байтами исходников я погоджуся, хоча кого вони хвилюють. А якщо скомпілювати?
Лямбда-кун: природно, я виграю! З мене вийде один файл, а тебе взагалі два! У двох файлах вдвічі більше заголовків і всякої метаінформації.
Анон-сан: не поспішайте, юначе, давайте перевіримо. Запускаємо
javac Test.java
для обох версій. Що ми бачимо? Варіант з анонімним класом генерує
Test.class
(308 байт) і
Test$1.class
(377 байт), всього 685 байт, а варіант з лямбдой генерує тільки
Test.class
, зате він важить 783 байта. Майже сто байтів оверхед — чи не задорого за синтаксичний цукор?
Лямбда-кун: еее, як це вийшло? Не могло бути, я ж більш легковажно! Ну-ка, а з налагоджувальною інформацією скільки буде? Все ж з нею компілюють.
Анон-сан: давайте спробуємо
javac -g Test.java
. Лямбда — 838 байт, анонімний клас — 825 байт. Так, різниця менше, але все ж хваленої легковажності не видно. Ви забуваєте, що на кожен виклик лямбды створюється розлога запис Bootsrtap methods, яка анонімним класів не потрібна.
Лямбда-кун: стій, стій. Я все зрозумів. Це не сама запис розлога, а константи, які потрапляють в пул констант. Всякі штуки на зразок
java/lang/invoke/LambdaMetafactory.metafactory:(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodHandle;Ljava/lang/invoke/MethodType;)Ljava/lang/invoke/CallSite;
. Так, вони довгі, але перевикористовуються, якщо лямбд більше однієї. Ну-ка, додамо другу:
public class Test {
Runnable r = () -> {};
Runnable r2 = () -> {};
}

Лямбда-кун: вооот, вже я виграю! З налагоджувальною інформацією 957 байт, а якщо замінити на анонімні класи, буде ажно 1351 байт у трьох файлах. Навіть без налагоджувальної інформації я виграю. Адже можуть і інші константи ефективно переиспользоваться! Будь-які поля, методи, класи, використовувані усередині лямбд. Якщо вони використовуються в декількох лямбдах або в лямбда і навколо неї, то схлопнутся в одну константу. А з анонімними класами в кожному буде копія. То-то ж!
Третій раунд. Класи в рантайме.
Анон-сан: мабуть, тут мені доведеться поступитися. Якщо лямбд багато, то ви дійсно компактніше в скомпільованому вигляді. Однак більш цікаво, що відбувається в рантайме, в пам'яті віртуальної машини. Нехай для тебе немає анонімного класу на диску, але точно такий же клас, а то й більший, буде згенерований при запуску і зжере все ті ж ресурси.
Лямбда-кун: а ось і не ті ж! Я ж більш легковажно! Там напевно сгенерируется маленький компактний класик, який не містить всякої непотрібної всячини. Та й як ти це перевіриш? Воно ж все рантайме в пам'яті!
Анон-сан: а ось цього вам соромно не знати. Повинні ж вас якось налагоджувати розробники. недокументовану системне властивість
jdk.internal.lambda.dumpProxyClasses
, за допомогою якого можна вказати, якою каталог скидати згенеровані рантайм-подання лямбд. Запускаємо додаток з
Djdk.internal.lambda.dumpProxyClasses=.
та все бачимо.
Лямбда-кун: ага, тільки поки лямбду жодного разу не використовуєш, рантайм-уявлення не буде згенеровано взагалі, а анонімні класи існують завжди, навіть якщо жодного разу не придалися!
Анон-сан: немає ніякої різниці. Навіть навпаки, різниця не на вашу користь. Анонімний клас завжди існує на диску, але він не буде завантажений в пам'ять, поки не використовується. Рантайм-подання лямбды, звичайно, кидати не буде, проте її тіло у вигляді приватного синтетичного методу завантажується фактично разом з класом, у якому вона оголошена. Навіть якщо тіло жодного разу не використовується, воно пам'ять від'їсть. Втім, до цього питання повернемося пізніше. Подивимося спершу, що відбувається, якщо лямбда використовується. Для цього нам буде потрібно трохи модифікувати програму:
public class Test {
static Runnable r = () -> {};

public static void main(String[] args) { }
}

Компілюємо (добре, нехай з налагоджувальною інформацією), запускаємо
java -Djdk.internal.lambda.dumpProxyClasses=. Test
і бачимо: лямбда створила клас
Test$$Lambda$1.class
, який важить 308 байт. Це крім основного класу
Test.class
, який важить 1004 байта. Замінюємо лямбду на аналогічний анонімний клас, маємо 508+399 байт в двох класах відразу, але в рантайме нічого не створюється. Багато ви все-таки їсте, молодий чоловік, на 405 байт більше за мене.
Лямбда-кун: ну ми ж домовилися, що однією лямбдой мірятися нечесно. Давай додамо другу.
Анон-сан: та хоч десять. Дописуємо
static Runnable r1 = () -> {};
і так далі. Виходить 11 класів, з лямбдами 5174 байта, а з анонімними — 5059 байт. Поспішаючи доганяю я вас, звичайно, але, погодьтеся, вже 10 лямбд не в кожному класі є. Десь після 14-го анонімного класу тільки ви починаєте їсти менше.
Лямбда-кун: так-так. А давай-ка ці всі лямбды помістимо прямо в метод
main()
. Погодься, нечасто вони в статичних полях лежать, нє?
public class Test {
public static void main(String[] args) { 
Runnable r = new Runnable() {public void run() {}};
Runnable r1 = new Runnable() {public void run() {}};
Runnable r2 = new Runnable() {public void run() {}};
...
}
}

Анон-сан: хм, а в чому різниця?
Лямбда-кун: а скомпилируй, і побачиш. У мене-то якраз різниці немає, згенеровані в рантайме класи важать стільки ж. А у тебе кожен байт на 40 погладшав. Тепер на десяти лямбдах я їм менше (5290 байт проти 4995). Вже навіть на шести я тебе випереджаю!
Анон-сан: ах, он воно що. Для налагодження в кожен анонімний клас тепер додана рядок
EnclosingMethod: Test.main
, що, звичайно, з'їдає додаткове місце. Ех, даремно я на налагоджувальну інформацію погодився.
Лямбда-кун: цей запис додається навіть при повністю відключеному налагоджувальної інформації (
javac -g:none
). Цей атрибут зобов'язаний бути специфікації незалежно від налагодження. А моє рантайм-подання формально не є анонімним класом, і йому цей атрибут не потрібен. Тобі ще пощастило, що ім'я методу
main
таке коротке. Якщо анонімні класи в методі з довгою назвою, кожен буде від'їдати додатково пропорційно його довжині!
Анон-сан: по-моєму, наша гра вже перетікає в нечесну площину. Так що ось, з вашого дозволу, повторний удар: замикання на змінних. Приймаю ваше умова і залишаюся всередині методу. Але захопимо-ка змінну:
import java.util.function.IntSupplier;

public class Test {
public static void main(String[] args) { 
int i = 0;
IntSupplier s = () -> i;
IntSupplier s1 = () -> i;
IntSupplier s2 = () -> i;
...
}
}

Ну і для анонімних класів замінимо на
new IntSupplier() {public int getAsInt() {return i;}}
. Як ви думаєте, скільки тепер потрібно лямбд, щоб перемогти анонімні класи?
Лямбда-кун: ну тут-то різниці бути не повинно. У тебе генерується компілятором синтетичне поле та конструктор з одним параметром, який це поле ініціалізує. У мене приблизно те ж саме буде створено в рантайме. Якийсь приблизно такий клас генерується і для тебе, і для мене:
class Test$1 implements IntSupplier {
private final int val$i;

Test$1(int i) { val$i = i; }

@Override int getAsInt() { return val$i; }
}

Анон-сан: такий, та не такий. Пробуємо. Одна лямбда: 1493 байта, один анонімний клас: 1006 байт. Десять лямбд: 6803 байта, десять анонімних класів: 6039 байт. Двадцять лямбд: 12743 байта, двадцять анонімних класів: 11669 байт. Розрив постійно збільшується! Тут хоч тисяча лямбд, а вам мене не наздогнати.
Лямбда-кун: ее… Так. Ну-ка, декомпіліруем. Це ще що за нісенітниця? Якийсь фабричний метод? Дурість якась. Крім конструктора мені ще навіщось додають метод виду
static IntSupplier get$Lambda(int i) { return new Test$1(i);}
. Марення якесь, навіщо?
Анон-сан: не маячня, а продуктивність. Колись у вікопомні часи Walrus виправив швидкість инстанциирования лямбд в інтерпретаторі (JDK-8023984). Фабричний метод виявився швидше, ніж конструктор. Зауважте, молодий чоловік, зі мною таких дивних проблем не виникає, у мене все швидко і так.
Лямбда-кун: оце ж дурниця! Немає щоб допилити свої методхэндлы до розуму, вони милиці ліплять… Цікаво, може вже з тих пір допиляли і цей метод не потрібен став?..
Анон-сан: як знати, як знати...
Лямбда-кун: проте мій хід! Приймаю твоє умова і захоплення змінної, але давай не
IntSupplier
, а
Supplier<Integer>
:
import java.util.function.Supplier;

public class Test {
public static void main(String[] args) { 
int i = 0;
Supplier<Integer> s = new Supplier<Integer>() {public Integer get() {return i;}};
Supplier<Integer> s1 = new Supplier<Integer>() {public Integer get() {return i;}};
...
}
}

Ну а лямбда залишиться як раніше:
Supplier<Integer> s = () -> i
.
Анон-сан: хм… не бачу, де ви хочете мене околпачити… А, ну так, додасться запис типу
Signature: Ljava/lang/Object;Ljava/util/function/Supplier<Ljava/lang/Integer;>;
Це щоб всякий reflection працював правильно і
s.getClass().getGenericInterfaces()
повертав саме
Supplier<Integer>
, а не
Supplier
. А хіба вам це не треба?
Лямбда-кун: виходить, що ні. Лямбда дозволено, щоб для неї це не працювало!
Анон-сан: однак, хоча ця строчка від'їсть місце, не віриться мені, що дуже багато проти вашого фабричного методу.
Лямбда-кун: а ти не вір, а перевір. Тепер усього три лямбды їдять менше, ніж три анонімних класу (2963 проти 3034 байта) і з кожною новою рядком ти більше програєш! Кожен анонімний клас їсть на 270 байт більше відповідної лямбды. І це з урахуванням того, що у мене зайвий фабричний метод!
Анон-сан: не може бути. Що ж там ще напхав-то компілятор? Ааа, як же я міг забути. Бридж-метод. Так як в коді у нас
Integer get() {}
, а в інтерфейсі після erasure —
Object get()
, потрібен ще місток, який при виклику інтерфейсу перенаправить до
Integer get()
. А вам що місток не потрібен?
Лямбда-кун: немає, і місток нам не потрібен. Точніше навпаки, він нам потрібен завжди,
Object get()
— це місток, а реальна реалізація в основному класі в синтетичному методі виду
lambda$main$1
. Але місток завжди один, у випадку з генериками другий місток не потрібен. А ось тобі потрібен і тут стало зрозуміло, що ми все-таки насправді більш легковажно!
Анон-сан: взагалі, звичайно, наші тести не дуже надійні. Невідомо, наскільки насправді корелює розмір клас-файлів і витрата пам'яті в рантайме. Але на сьогодні бесіда і так затягнулася, так що відкладемо це питання на наступний раз.
Джерело: Хабрахабр

0 коментарів

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