Pull to refresh

Лямбда-потрошитель

Reading time8 min
Views19K

Хотя недавно была выпущена Java 9 с новой модульной системой, многие еще продолжают пользоваться привычной восьмой версией, с лямбдами. В течение полугода я плотно работал с ней и всеми ее нововведениями. Если с новыми методами коллекций и Optional все понятно, то с лямбдами не все так очевидно. В частности, как они реализованы и как влияют на производительность. И главное — чем они отличаются от старых добрых анонимных классов.




В этой статье я не буду разбирать синтаксис Java 8 — таких статей и книг уже написано достаточно. Меня заинтересовали вопросы того, как это все работает, поэтому я решил:

  1. Разобраться с теорией
  2. Посмотреть, что внутри лямбды
  3. Понять их влияние на производительность

Совсем немного теории


Для начала было бы неплохо разобраться с типами лямбд. Тут все достаточно просто, есть две разновидности:


  • Незахватывающие (non-capturing) – самые простые, не завязаны на окружение. Не содержат ссылок на внешние переменные. Не вызывают методы экземпляров. Могут вызывать статические методы.
  • Захватывающие (capturing) – имеют связь с окружающим миром, такие функции еще называют замыканиями. Их в свою очередь условно можно разделить еще на два подтипа: те, что ссылаются на переменные внутри метода или класса, и те, что вызывают метод экземпляра

В первом приближении


Буду действовать по порядку. Посмотрим, как код компилируется с лямбдами. Возьмем самый простой пример, который создает и сразу же вызывает лямбду:


public class TestRun {
  public static void main(String[] args) throws Exception {
    ((Callable<Integer>) (() -> 10)).call();
  }
}

Пока мне хватит стандартной функциональности, встроенной в JDK. Чтобы посмотреть содержимое класс-файла, можно использовать:


javap -p -c -v -constants TestRun.class


Эта команда выведет содержимое методов и constant pool для класса:


Constant pool:
                #2 = InvokeDynamic      #0:#30         // #0:call:()Ljava/util/concurrent/Callable;
                #3 = InterfaceMethodref #31.#32        // java/util/concurrent/Callable.call:()Ljava/lang/Object;
                #4 = Methodref          #33.#34        // java/lang/Integer.valueOf:(I)Ljava/lang/Integer;

public static void main(java.lang.String[]) throws java.lang.Exception;
Code:
                0: invokedynamic #2,  0              // InvokeDynamic #0:call:()Ljava/util/concurrent/Callable;
                5: invokeinterface #3,  1            // InterfaceMethod java/util/concurrent/Callable.call:()Ljava/lang/Object;

private static java.lang.Integer lambda$main$0() throws java.lang.Exception;
 Code:
                0: bipush        10
                2: invokestatic  #4                  // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
                5: areturn

В методе main есть всего лишь две инструкции: invokedynamic создает экземпляр некоторого класса, а invokeinterface вызывает метод call() у объекта, который лежит на стеке. Еще в классе есть constant pool, в нем находится описание #2 — метода, для которого будет создана лямбда, и #3 — описание метода интерфейса. Также появился странный метод lambda$main$0(), который мы не заказывали. Но если приглядеться, то он как раз и содержит код лямбды: создает переменную типа Integer и возвращает ее. На него и ссылается структура #2 из constant pool.


Сразу пара ссылок:
 - Спецификация инструкции invokedynamic
 - Описание структуры из constant pool


Этот пример дает больше вопросов, чем ответов. Совершенно непонятно, каким образом вызов интерфейсного метода приводит нас к сгенерированному lambda$main$0(). Для выяснения этого придется залезть в содержимое лямбды.


Чем потрошить?


Чтобы двигаться дальше мне понадобятся спецсредства. Хотелось бы узнать, что находится внутри объектов, у которых мы вызываем метод. Для этих целей можно воспользоваться дополнительным параметром:


-Djdk.internal.lambda.dumpProxyClasses=[dir]


Если его добавить, то во время выполнения получим в папке [dir] прокси классы, которые генерирует фабрика.


Лямбды попроще


Дальше тоже буду двигаться от простого. Сначала разберу пример с лямбдами, которые не содержат ссылок на окружающий контекст:


public class TestNonCapturing {

  public static void main(String[] args) throws Exception {
    Callable<Integer> r = () -> 10;
  }
}

Код сгенерирует TestNonCapturing$$Lambda$1.class, который очень прост:


final class TestNonCapturing$$Lambda$1 implements Callable {
  private TestNonCapturing$$Lambda$1() {
  }

  @Hidden
  public Object call() {
    return TestNonCapturing.lambda$main$0();
  }
}

Это финальный класс, который работает со статически сгенерированным методом TestNonCapturing.lambda$main$0(). Вызывающий код из main обращается к собственному методу через обертку, которую сгенерирует инструкция invokedynamic во время выполнения.


Лямбды посложнее


Теперь посмотрим внутрь лямбд, которые ссылаются на переменные окружения. Для этого достаточно, например, обратиться к переменной метода, в котором создается лямбда:


public class TestCapturingVariable {

  public static void main(String[] args) throws Exception {
    int methodVariable = 5;
    Callable<Integer> r = () -> 10 + methodVariable;
  }
}

TestCapturingVariable$$Lambda$1.class будет немного сложнее:


final class TestCapturingVariable$$Lambda$1 implements Callable {
  private final int arg$1;

  private TestCapturingVariable$$Lambda$1(int var1) {
    this.arg$1 = var1;
  }

  private static Callable get$Lambda(int var0) {
    return new TestCapturingVariable$$Lambda$1(var0);
  }

  @Hidden
  public Object call() {
    return TestCapturingVariable.lambda$main$0(this.arg$1);
  }
}

Тут уже появился контекст, у конструктора есть аргумент int var1. Вызывая TestCapturingVariable.lambda$main$0, мы передаем локальную переменную arg$1. Экземпляр лямбды получается через геттер. Почему появился геттер над конструктором — я, честно говоря, не знаю. Полагаю, это детали имплементации в JVM. Если у вас есть ответ на этот вопрос, буду рад его узнать в комментариях.


Попробую немного усложнить пример и добавлю вызов метода экземпляра класса:


public class TestCapturingMethod {

  public static void main(String[] args) throws Exception {
    TestCapturingMethod v = new TestCapturingMethod();
    Callable<Integer> r = v::instanceMethod;
  }

  private int instanceMethod() {
    return 10;
  }
}

Внезапно: Exception in thread "main" java.lang.VerifyError


В данном случае JVM смутило то, что instanceMethod приватный и он вызывается из другого класса. Можно его сделать публичным или добавить –noverify к командной строке. Содержимое TestCapturingMethod$$Lambda$1.class будет следующим:


final class TestCapturingMethod$$Lambda$1 implements Callable {
  private final TestCapturingMethod arg$1;

  private TestCapturingMethod$$Lambda$1(TestCapturingMethod var1) {
    this.arg$1 = var1;
  }

  private static Callable get$Lambda(TestCapturingMethod var0) {
    return new TestCapturingMethod$$Lambda$1(var0);
  }

  @Hidden
  public Object call() {
    return Integer.valueOf(this.arg$1.instanceMethod());
  }
}

Как видно из декомпилированного кода, разница небольшая, arg$1 из параметра превратился в экземпляр класса, у которого вызывается метод. В методе call() еще появился автобоксинг.


Как это работает


Теперь более-менее понятно, что находится внутри самих объектов. Попробую разобраться, как это работает и есть ли различия между замыканиями и простыми лямбдами на этом примере:


public class LambdaRun {

  public static void main(String[] args) throws Exception {
    int local = 10;
    for (;;) {
      Callable<Integer> nonCapturing =  () -> 10;
      Callable<Integer> capturing =  () -> 10 + local;
      System.out.println("Non-capturing: " + nonCapturing.hashCode());
      System.out.println("Capturing: " + capturing.hashCode());
    }
  }
}

Тут в цикле генерируются захватывающие и незахватывающие лямбды, затем печатается их хеш. Вывод будет примерно следующий:


Capturing: 231987608
Non-capturing: 1595428806
Capturing: 1549385383
Non-capturing: 1595428806
Capturing: 1879451745
Non-capturing: 1595428806

Очевидно, что в одном случае каждый раз создается новый объект, а в другом — нет. Похоже, JVM кое-что оптимизировала, и лямбда-фабрика генерирует новые объекты только тогда, когда это действительно необходимо. Вполне логично снова использовать объект, если его содержимое не зависит от окружения. При вызове лямбды, захватывающей контекст, каждый раз будет создан новый объект — и этот случай представляет больший интерес для исследования, т.к. может быть неявно добавлена нагрузка на GC. Но тут оказалось тоже не все так просто.


To stack or not to stack


Просветленный читатель заметит, что, если объект не выходит за рамки метода, то он, скорее всего, попадет под escape analysis, будет создан на стеке и никакой нагрузки на GC не будет. Но кто вызывает лямбды в том же методе, где и создает их? Основная идея здесь: лямбда – это функция высшего порядка, функция, которая принимает на вход или возвращает другую функцию. Таким образом, почти всегда лямбда выходит за границы метода, где она была создана. Любая книга или статья по Java 8 наполнена подобными примерами.


Еще более просветленный читатель заметит, что иногда методы могут быть включены друг в друга JIT компилятором во время выполнения — и тогда escape analysis сработает.


Сразу пара ссылок по теме:
 - Escape analysis
 - Method inlining


Возьмем следующий пример. Если до этого примеры были искусственные, то этот приближен к реальности. В цикле вызывается замыкание, создаваемое в отдельном методе, который можно считать функцией высшего порядка:


public class CapturingLambdaLongRun {

  int i = 10;

  public static void main(String[] args) throws Exception {
    CapturingLambdaLongRun run = new CapturingLambdaLongRun();
    while (true) {
      getLambda(run).run();
    }
  }

  public static Runnable getLambda(CapturingLambdaLongRun run) {
    return () -> {
      run.i++;
    };
  }
}

Запущу этот код под VisualVM на одну минуту:



Как ни странно, ничего криминального тут нет, хотя на каждый вызов getLambda должен создаваться новый объект. Теперь попробую отключить инлайнинг, добавив параметр -XX:MaxInlineLevel=0. И тут картина сильно меняется:



Почему вначале все было ровно и гладко, а потом поменялось? Когда JIT работал на полную и я не вставлял ему палки в колеса, метод getLambda включался в main, и новый Runnable аллоцировался на стеке метода. Поэтому проблем не возникало. При отключении инлайнинга все стало работать ровно так, как оно выглядит в Java коде, обе оптимизации отключились (inlining, а следом за ним и escape analysis), и появилась нагрузка на GC, т.к. создание объектов перешло со стека на кучу.


В этом примере я искусственно отключил оптимизацию, но, думаю, несложно представить себе следующие ситуации:


  • В процессе развития проекта метод, создающий замыкание, разросся и перестал инлайниться в виду ограничения. По умолчанию MaxInlineSize=35.
  • При рефакторинге в большой метод, создающий лямбду, была добавлена переменная окружения, таким образом, изменился тип и на каждый вызов стал создаваться новый объект на куче.

Итого


Пора подвести итог моему небольшому исследованию. Что удалось выяснить:
 - Есть разные типы лямбда-выражений: хотя синтаксис у них одинаковый, внутри они устроены по-разному и работают тоже по-разному.
 - Достаточно незаметно можно перейти от одного типа лямбд к другому, таким образом изменив нагрузку на GC.
 - Сам по себе вызов метода у лямбды ничем не отличается от вызова любого другого метода, никакой рефлексии тут нет.


И еще пара слов о старых добрых анонимных классах, попробую их сравнить с лямбда выражениями:


 - Анонимный класс генерируется во время компиляции, код лямбды создает фабрика во время выполнения.
 - Генерация кода на лету может быть быстрее, чем загрузка из classpath. Т.к. обращение к classpath может вызвать чтение с диска, некоторые тесты подтверждают, что холодный старт у лямбд быстрее, чем у анонимных классов.
 - Код лямбды помещается в сгенерированный метод того же класса, где она создается. Весь код анонимного класса в нем же и содержится.
 - Анонимные классы обладают явным синтаксисом. Мы точно знаем, что на каждый вызов будет создан один объект. Незахватывающие лямбда тут делают оптимизацию и неявно переиспользуют один объект.


Как жить дальше


Надеюсь, эта статья помогла приподнять завесу тайны. Понять, как работают лямбда-выражения и что у них внутри. Теперь я вполне осознанно добавляю зависимость замыканий на окружающий контекст, понимая, к чему это может привести. Что дальше делать со всей этой информацией:


 - Если вы разрабатываете приложение, у которого нет жестких требований по производительности, то можно положиться на JIT компилятор. В большинстве случаев он спасает. Но и тут не стоит забывать про такие простые правила, как например, не делать большие методы. Это влияет не только на читабельность.
 - В критическом по нагрузке коде, нужно быть внимательным с лямбдами. Если они вдруг превратятся в замыкания, у этого могут быть последствия. Поэтому:
 - Стоит избегать ссылок на переменные метода или экземпляра класса
 - Ссылаться лучше всего на статические методы

Tags:
Hubs:
Total votes 22: ↑21 and ↓1+20
Comments10

Articles

Information

Website
dbtc-career.ru
Registered
Founded
2001
Employees
1,001–5,000 employees
Location
Россия