Хотя недавно была выпущена 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 компилятор. В большинстве случаев он спасает. Но и тут не стоит забывать про такие простые правила, как например, не делать большие методы. Это влияет не только на читабельность.
 - В критическом по нагрузке коде, нужно быть внимательным с лямбдами. Если они вдруг превратятся в замыкания, у этого могут быть последствия. Поэтому:
 - Стоит избегать ссылок на переменные метода или экземпляра класса
 - Ссылаться лучше всего на статические методы

Комментарии (10)


  1. vba
    01.12.2017 15:43
    +1

    Интересно а как устроены ламбда(простите но когда слышу вариант через "ля", сразу думается о чем-то другом) функци в Котлине.


    1. Dimezis
      01.12.2017 19:16

      Вы можете декомпилировать Котлин код прямо в IDEA и посмотреть, времени займет немногим больше, чем написание комментария :)
      Вот есть отличный доклад, но тут не только Котлин и лямбды рассматриваются.
      skillsmatter.com/skillscasts/10012-keynote-sinking-your-teeth-into-bytecode


      1. vba
        01.12.2017 19:47

        Что это даст? Не думаю что построение догадок на базе байткода будет быстрее и эквивалентно состоятельному объяснению знающих людей.


        1. khim
          01.12.2017 21:46
          +1

          Я с Java плотно не работаю, но когда «знающие люди» что-то обсуждают по поводу «внутренностей» C++, то godbolt является наиболее часто цитируемым источником. Почему вдруг Java должна быть устроена иначе?

          «Знающие люди» всего тоже не помнят, а декомпиляция показывает не как оно «должно бы быть в идеальном мире», а «как оно реально есть здесь и сейчас».


          1. 0xd34df00d
            01.12.2017 23:58

            Более того, можно посмотреть, как это делают разные компиляторы разных версий под разные архитектуры с разными флагами оптимизации, получив некоторое представление о спектре возможностей.


          1. vba
            02.12.2017 02:14

            Как убивать свое свободное время решай сам, хочешь байткод изучать дело твое. Кстати в данном случае чтение спецификации куда более эффективно.


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


            1. khim
              02.12.2017 04:13

              Кстати в данном случае чтение спецификации куда более эффективно.
              Ну если оно так необычайно эффективно, то зачем вы тратите своё (и чужое) время на комментирование «неправильной» статьи, написанной с использованием javap, а не спецификаций? Вам, наверное, нужно на какой-то «правильный» сайт сходить, а то я как вижу статью о тонкостях Java — так люди всё больше по инструментарию, а не по спекам ударяют. Ну или самому статью написать и посрамить этих «ничего не понимающих» «гуру».

              Некоторым людям интересно знать не только как но и почему, поэтому такие люди предпочитают общаться с другими людьми так как никакая спецификация и никакой байткод на вопрос, почему, ответить не сможет.
              Отвечать на вопрос «почему» не имея ответа на вопрос «как» — бессмысленно. Кроме всего прочего часто ответа на вопрос «почему» никто не знает, а получить его можно, опять-таки, дизассемблированием. Как с той пресловутой буквой Ж.


              1. vba
                02.12.2017 14:49

                Ну если оно так необычайно эффективно, то зачем вы тратите своё (и чужое)
                время на комментирование «неправильной» статьи

                Возвращаю тебе твой вопрос, зачем комментировать статьи на сайте для неправильных людей ?


                Отвечать на вопрос «почему» не имея ответа на вопрос «как» — бессмысленно.

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


    1. PqDn
      04.12.2017 11:24
      +1

      В котлине для поддержки 6й джавы, всё генерируется в обычные классы (На этапе компиляции). Для производительности там советуют лямбда делать инлайн (Чтобы при компиляции код подставлялся заместо лямбда). Однако для больших функций это не советуется.


      1. vba
        04.12.2017 11:51

        Спасибо