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


Поводом для написания статьи стал вопрос на StackOverflow. Откройте, но не торопитесь читать ответ ;)


Далее сам вопрос:


I'm making an API wrapper library that supports multiple versions of the API. A public class was added in the recent API version. I'm trying to compile the wrapper against the latest API version and make it check in run time if the new class exists. And I'm trying to avoid reflection and instead catch NoClassDefFoundError and set a flag accordingly. It works until I add a method that returns the class which the non-existent class extends. Then my library fails to load. I mean: BaseClass exists; ChildClass does not exist; the method uses ChildClass internally. If the method returns BaseClass the library fails to load. If the method returns Object the library loads and the error is deferred and can be caught.

Код для воспроизведения
public class TestLoading {

  public static void main(String[] args) throws Exception {
    Class.forName(BaseClass.class.getName());
    URL classFileB = TestLoading.class.getResource(TestLoading.class.getSimpleName() + "$ChildClass.class");
    if (classFileB != null) {
      if (!"file".equals(classFileB.getProtocol())) {
        throw new UnsupportedOperationException();
      }
      Path path = new File(classFileB.getPath()).toPath();
      System.out.println("deleting: " + path);
      Files.delete(path);
    }

    loadMyClass(ObjectReturner.class.getName());
    loadMyClass(BaseClassReturner.class.getName());
  }

  private static void loadMyClass(String name) {
    System.out.println("loading: " + name + "...");
    try {
      Class.forName(name);
    } catch (Throwable e) {
      e.printStackTrace(System.out);
    }
  }

  public static class BaseClass {
    static {
      System.out.println("loaded: " + BaseClass.class.getName());
    }
  }

  public static class ChildClass extends BaseClass {
    static {
      System.out.println("loaded: " + ChildClass.class.getName());
    }
  }

  public static class ObjectReturner {
    static {
      System.out.println("loaded: " + ObjectReturner.class.getName());
    }

    public Object getObject() {
      return new ChildClass();
    }
  }

  public static class BaseClassReturner {
    static {
      System.out.println("loaded: " + BaseClassReturner.class.getName());
    }

    public BaseClass getObject() {
      if ("".length() == 10) {
        return new ChildClass();
      } else {
        return null;
      }
    }
  }
}

Здесь для создания нужного эффекта отсутствия класса мы сами удаляем ChildClass из classpath-а приложения и внезапно ловим исключение NoClassDefFoundError:


java.lang.NoClassDefFoundError: snippet/TestLoading$ChildClass
    at java.base/java.lang.Class.forName0(Native Method)
    at java.base/java.lang.Class.forName(Class.java:377)
    at snippet.TestLoading.loadMyClass(TestLoading.java:31)
    at snippet.TestLoading.main(TestLoading.java:25)
Caused by: java.lang.ClassNotFoundException: snippet.TestLoading$ChildClass
    at java.base/jdk.internal.loader.BuiltinClassLoader.loadClass(BuiltinClassLoader.java:606)
    at java.base/jdk.internal.loader.ClassLoaders$AppClassLoader.loadClass(ClassLoaders.java:168)
    at java.base/java.lang.ClassLoader.loadClass(ClassLoader.java:522)
    ... 4 more

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


Уверен, каждому хотя бы раз задавали вопрос вроде "расскажите о загрузке классов", на который отвечали примерно так:


1) загрузка классов в Java выполняется лениво;


2) виртуальная машина сама определяет порядок загрузки и инициализации необходимых ей классов из JRE;


3) загрузка пользовательских классов (т.е. не относящихся к JRE) происходит при первом:


  • создании объекта класса;
  • создании объекта унаследованного класса;
  • обращении к статическому полю/методу класса;
  • использовании рефлексии.

Проходи я собеседование, на поставленный вопрос ответил бы так же (и был бы неправ). Даже опытные разработчики часто смешивают понятия загрузки и инициализации класса (между ними также выполняется связывание (linking), в свою очередь включающее в себя проверку (verification), подготовку (preparation) и иногда разрешение (resolution). На первый взгляд, особой разницы нет, ведь если мы создаём объект или обращаемся к статическим полям/методам, то нам в любом случае нужно выполнить загрузку (строго говоря представляющую собой только подтягивание в память двоичного представления класса и создание объекта типа Class). Зачем тогда разделять эти понятия? Разве может выполняться загрузка без последующего связывания и инициализации?


Оказывается, может, и без чёткого разграничения этих понятий мы не сможем ответить на поставленный в начале статьи вопрос.


Откроем раздел 12.2 спецификации языка Java (JLS):


Loading refers to the process of finding the binary form of a class or interface with a particular name, perhaps by computing it on the fly, but more typically by retrieving a binary representation previously computed from source code by a Java compiler, and constructing, from that binary form, a Class object to represent the class or interface (§1.4).

Про инициализацию нам расскажет раздел 12.4.1:


A class or interface T will be initialized immediately before the first occurrence of any one of the following:
  • T is a class and an instance of T is created.
  • a static method declared by T is invoked.
  • a static field declared by T is assigned.
  • a static field declared by T is used and the field is not a constant variable (§4.12.4).

Подчёркиваю: условия инициализации класса строго определены, чего не скажешь об условиях загрузки класса.


В нашем коде есть две ссылки на ChildClass, но в рантайме не удовлетворено ни одно из условий инициализации класса из JLS §12.4.1. Тем не менее ВМ загружает класс, что на первый взгляд лишено всякого смысла.


Отдельно отмечу, что исключение брошено именно из Class.forName(BaseClassReturner.class.getName()), вызов Class.forName(ObjectReturner.class.getName()) происходит без ошибок, хотя и BaseClassReturner, и ObjectReturner ссылаются на ChildClass.


Таким образом, мы видим три странные вещи:


  • загрузку класса, происходящую где-то в недрах ВМ и вызванную неким механизмом, срабатывающим даже в отсутствии обращения к целевому классу во время исполнения
  • явную зависимость загрузки класса (быстрая/ленивая) от сигнатуры метода (который нигде не вызывается)
  • выброс из пользовательского кода NoClassDefFoundError (хотя документация Class.forName(String) его вообще не предусматривает)

Разберёмся. Для начала упростим исходный вариант BaseClassReturner.getObject(), сделав его как можно более похожим на ObjectReturner.getObject():


public static class BaseClassReturner {
  static {
    System.out.println("loaded: " + BaseClassReturner.class.getName());
  }

  public BaseClass getObject() {
    return new ChildClass();
  }
}

Байт-код:


// access flags 0x1
public getObject()Lorg/example/TestLoading$BaseClass;
 L0
  LINENUMBER 49 L0
  NEW org/example/TestLoading$ChildClass
  DUP
  INVOKESPECIAL org/example/TestLoading$ChildClass.<init> ()V
  ARETURN
 L1
  LOCALVARIABLE this Lorg/example/TestLoading$BaseClassReturner; L0 L1 0
  MAXSTACK = 2
  MAXLOCALS = 1

Сравним с байт-кодом ObjectReturner.getObject():


// access flags 0x1
public getObject()Ljava/lang/Object;
 L0
  LINENUMBER 43 L0
  NEW org/example/TestLoading$ChildClass
  DUP
  INVOKESPECIAL org/example/TestLoading$ChildClass.<init> ()V
  ARETURN
 L1
  LOCALVARIABLE this Lorg/example/TestLoading$ObjectReturner; L0 L1 0
  MAXSTACK = 2
  MAXLOCALS = 1

Кроме типа this единственным отличием между методами является тип возвращаемого значения, поэтому сосредоточимся на нём.


Изначально я пошёл по неверному пути, размышляя примерно так: поскольку BaseClassReturner.getObject() возвращает тип BaseClass, то виртуальная машина должна проверить наличие всех его потомков загрузив их.


Это предположение явно ошибочно, т.к. во-первых при загрузке/инициализации классов виртуальная машина загружает/инициализирует родительские классы, а не дочерние, во-вторых выполнение подобных проверок было бы крайне неэффективным и существенно замедлило бы работу ВМ, ведь наследников класса может быть сколь угодно много и для их обнаружения пришлось бы тупо перебрать весь classpath.


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


public static class BaseClassReturner {
  static {
    System.out.println("loaded: " + BaseClassReturner.class.getName());
  }

  public BaseClass getObject() {
    return null;
  }
}

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


Получается, нам нужны какие-то дополнительные данные. Запустим программу с флагами ВМ -Xlog:class+init,class+load для получения подробного лога инициализации и загрузки классов:


loading: org.example.TestLoading$ObjectReturner...
[0.393s][info][class,init] Start class verification for: org.example.TestLoading$ObjectReturner
[0.393s][info][class,init] End class verification for: org.example.TestLoading$ObjectReturner
[0.393s][info][class,init] 770 Initializing 'org/example/TestLoading$ObjectReturner' (0x0000000800067450)
loaded: org.example.TestLoading$ObjectReturner
[0.397s][info][class,load] org.example.TestLoading$BaseClassReturner source: file:/C:/Users/STsypanov/IdeaProjects/test/target/classes/
loading: org.example.TestLoading$BaseClassReturner...
[0.397s][info][class,init] Start class verification for: org.example.TestLoading$BaseClassReturner
[0.398s][info][class,init] 771 Initializing 'java/lang/ReflectiveOperationException'(no method) (0x0000000800004028)
[0.398s][info][class,init] 772 Initializing 'java/lang/ClassNotFoundException'(no method) (0x0000000800004288)
[0.398s][info][class,init] 773 Initializing 'java/lang/LinkageError'(no method) (0x00000008000044f8)
[0.398s][info][class,init] 774 Initializing 'java/lang/NoClassDefFoundError'(no method) (0x0000000800004758)
[0.398s][info][class,init] Verification for org.example.TestLoading$BaseClassReturner has exception pending 'java.lang.NoClassDefFoundError org/example/TestLoading$ChildClass'
[0.398s][info][class,init] End class verification for: org.example.TestLoading$BaseClassReturner
[0.398s][info][class,load] java.lang.Throwable$PrintStreamOrWriter source: jrt:/java.base
[0.398s][info][class,load] java.lang.Throwable$WrappedPrintStream source: jrt:/java.base
[0.398s][info][class,init] 775 Initializing 'java/lang/Throwable$PrintStreamOrWriter'(no method) (0x00000008000a0ed8)
[0.398s][info][class,init] 776 Initializing 'java/lang/Throwable$WrappedPrintStream'(no method) (0x00000008000a10f0)
java.lang.NoClassDefFoundError: org/example/TestLoading$ChildClass
[0.399s][info][class,init] 777 Initializing 'java/lang/StackTraceElement'(no method) (0x0000000800010858)
[0.399s][info][class,load] java.lang.StackTraceElement$HashedModules source: jrt:/java.base
[0.399s][info][class,init] 778 Initializing 'java/lang/StackTraceElement$HashedModules' (0x00000008000a1320)
at java.base/java.lang.Class.forName0(Native Method)
at java.base/java.lang.Class.forName(Class.java:315)
at org.example.TestLoading.loadMyClass(TestLoading.java:29)
at org.example.TestLoading.main(TestLoading.java:23)
Caused by: java.lang.ClassNotFoundException: org.example.TestLoading$ChildClass
at java.base/jdk.internal.loader.BuiltinClassLoader.loadClass(BuiltinClassLoader.java:581)
at java.base/jdk.internal.loader.ClassLoaders$AppClassLoader.loadClass(ClassLoaders.java:178)
at java.base/java.lang.ClassLoader.loadClass(ClassLoader.java:522)
... 4 more

Тут интересна вот эта часть:


[0.397s][info][class,load] org.example.TestLoading$BaseClassReturner source: file:/C:/Users/STsypanov/IdeaProjects/test/target/classes/
loading: org.example.TestLoading$BaseClassReturner...
[0.397s][info][class,init] Start class verification for: org.example.TestLoading$BaseClassReturner
[0.398s][info][class,init] 771 Initializing 'java/lang/ReflectiveOperationException'(no method) (0x0000000800004028)
[0.398s][info][class,init] 772 Initializing 'java/lang/ClassNotFoundException'(no method) (0x0000000800004288)
[0.398s][info][class,init] 773 Initializing 'java/lang/LinkageError'(no method) (0x00000008000044f8)
[0.398s][info][class,init] 774 Initializing 'java/lang/NoClassDefFoundError'(no method) (0x0000000800004758)
[0.398s][info][class,init] Verification for org.example.TestLoading$BaseClassReturner has exception pending 'java.lang.NoClassDefFoundError org/example/TestLoading$ChildClass'
[0.398s][info][class,init] End class verification for: org.example.TestLoading$BaseClassReturner

Обратите внимание, что инициализация класса требует его верификации, в ходе которой выбрасывается NoClassDefFoundError и LinkageError. Получается, ноги растут из проверки содержимого класса, описанной в разделе 4.10 спецификации виртуальной машина (JVMS), которая в части касающейся инструкции areturn гласит:


An areturn instruction is type safe iff the enclosing method has a declared return type, ReturnType, that is a reference type, and one can validly pop a type matching ReturnType off the incoming operand stack.

Тип возвращаемого значения описан так:


If the method returns a reference type, only an areturn instruction may be used, and the type of the returned value must be assignment compatible with the return descriptor of the method (§4.3.3)

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


Вместе с -XX:TieredStopAtLevel=1 это флаг выставляется по умолчанию при запуске
Spring Boot-приложений из "Идеи", значительно ускоряя включение.



Практический вывод: ничто не мешает нам использовать этот финт с большими и медленными docker-compose файлами, содержащими множество Java-приложений (как самописных, так и разных зукиперов, эврик, эластиков и т.п.).


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


Выводы:


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

Обсуждение в рассылке core-libs-dev: https://mail.openjdk.org/pipermail/core-libs-dev/2023-May/106219.html

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


  1. Marat-onlin
    03.08.2023 05:04
    +2

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


    1. tsypanov Автор
      03.08.2023 05:04
      +1

      Обращайтесь :). Я хоть и давно работаю в отрасли, но тоже открыл для себя много нового в процессе написания статьи