Статьи

Обновление кода во время выполнения (подпружиненная демистификация)

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

Замена кода во время выполнения не поддерживается “из коробки” JVM в таком виде, как вы можете динамически загружать классы, например, с помощью Class.forName() . В основном у вас есть следующие варианты:

  • HotSwap: Технология, представленная в Java 1.4, которая позволяет переопределять классы в сеансе отладчика. Этот подход очень ограничен, так как он позволяет вам только изменять тело метода, но не добавлять новые методы или классы.
  • OSGi: эта технология позволяет вам определять пакеты. Во время выполнения пакет может быть заменен более новой версией этого пакета.
  • Разгрузка загрузчиков классов: оборачивая отдельный загрузчик классов во все классы вашего модуля, вы можете выбросить загрузчик классов и заменить его, как только появится новая версия вашего модуля.
  • Инструментирование классов с помощью агента Java: Агент Java может инструктировать классы до того, как они будут определены. Таким образом, он может внедрить код в загруженные классы, который связывает этот класс с одной версией файла класса. Как только новая версия доступна, новый код выполняется.

Технология Beils Grails называется подпружиненной и использует подход «Java Agent» для классов инструментов, которые загружаются из файловой системы, а не из файла JAR. Но как это работает под капотом?

Чтобы понять подпружиненный, мы настроили небольшой пример проекта, который позволяет нам изучить технологию более подробно. Этот проект состоит только из двух классов: класс Main вызывает метод print() класса ToBeChanged и некоторое время спит:

1
2
3
4
5
6
7
public static void main(String[] args) throws InterruptedException {
  while (true) {
    ToBeChanged toBeChanged = new ToBeChanged();
    toBeChanged.print();
    Thread.sleep(500);
  }
}

Метод print() просто распечатывает версию, так что мы видим, что она изменилась. Кроме того, мы также распечатываем трассировку стека, чтобы увидеть, как она меняется со временем:

1
2
3
4
5
6
7
8
public void print() {
  System.out.println("V1");
  StackTraceElement[] stackTrace = Thread.currentThread().getStackTrace();
  for (StackTraceElement element : stackTrace) {
    System.out.println("\t" + element.getClassName() + "."
      + element.getMethodName() + ":" + element.getLineNumber());
  }
}

При запуске приложения мы должны предоставить файл jar, содержащий агент Java, используя опцию javaagent . Поскольку подпружиненный загружает байт-код способом, который не нравится верификатору, мы должны отключить проверку байт-кода, передав опцию noverify в JVM. Наконец, мы передаем папку, содержащую наши файлы классов, с помощью cp и сообщаем JVM класс, который содержит метод main() :

1
2
3
4
java -javaagent:springloaded-1.2.4.BUILD-SNAPSHOT.jar
  -noverify
  -cp target/classes
  com.martinsdeveloperworld.springloaded.Main

После обновления версии в классе ToBeChanged с V1 на V2 и перестройки проекта с использованием mvn package мы видим следующий вывод:

01
02
03
04
05
06
07
08
09
10
11
12
...
V1
        java.lang.Thread.getStackTrace:-1
        com.martinsdeveloperworld.springloaded.ToBeChanged.print:7
        com.martinsdeveloperworld.springloaded.Main.main:8
V2
        java.lang.Thread.getStackTrace:-1
        com.martinsdeveloperworld.springloaded.ToBeChanged$$EPBF0gVl.print:7
        com.martinsdeveloperworld.springloaded.ToBeChanged$$DPBF0gVl.print:-1
        com.martinsdeveloperworld.springloaded.ToBeChanged.print:-1
        com.martinsdeveloperworld.springloaded.Main.main:8
...

Трассировка стека версии V1 выглядит так, как мы и ожидали. Из Main.main() ToBeChanged.print() метод ToBeChanged.print() . Это отличается для версии V2 . Здесь метод ToBeChanged.print теперь вызывает метод ToBeChanged$$DPBF0gVl.print() . Также обратите внимание, что номер строки для вызова ToBeChanged.print() изменился с 8 на -1, что указывает на то, что строка неизвестна.

Новая строка -1 указывает на то, что Java-агент инструментировал метод ToBeChanged.print() таким образом, чтобы он мог вызывать новый метод вместо выполнения старого кода. Чтобы подтвердить это предположение, я добавил несколько операторов регистрации в код подпружиненного кода и функцию, которая выгружает каждый файл instrumtend на локальный жесткий диск. Таким образом, мы можем проверить, как метод ToBeChanged.print() выглядит после инструментария:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
  0 getstatic #16 <com/martinsdeveloperworld/springloaded/ToBeChanged.r$type>
  3 ldc #72 <0>
  5 invokevirtual #85 <org/springsource/loaded/ReloadableType.changed>
  8 dup
  9 ifeq 42 (+33)
 12 iconst_1
 13 if_icmpeq 26 (+13)
 16 new #87 <java/lang/NoSuchMethodError>
 19 dup
 20 ldc #89 <com.martinsdeveloperworld.springloaded.ToBeChanged.print()V>
 22 invokespecial #92 <java/lang/NoSuchMethodError.<init>>
 25 athrow
 26 getstatic #16 <com/martinsdeveloperworld/springloaded/ToBeChanged.r$type>
 29 invokevirtual #56 <org/springsource/loaded/ReloadableType.fetchLatest>
 32 checkcast #58 <com/martinsdeveloperworld/springloaded/ToBeChanged__I>
 35 aload_0
 36 invokeinterface #94 <com/martinsdeveloperworld/springloaded/ToBeChanged__I.print> count 2
 41 return
 42 pop
 43 getstatic #100 <java/lang/System.out>
 46 ldc #102 <V1>
 48 invokevirtual #107 <java/io/PrintStream.println>
 51 invokestatic #113 <java/lang/Thread.currentThread>
 54 invokevirtual #117 <java/lang/Thread.getStackTrace>
 57 astore_1
...
152 return

Код операции getstatic извлекает значение для нового поля r$type и помещает его в стек (код операции ldc ). Затем ReloadableType.changed() метод ReloadableType.changed() для ссылки на объект, который был ReloadableType.changed() в стек ранее. Как видно из названия, метод ReloadableType.changed() проверяет, существует ли новая версия этого типа. Возвращает 0, если метод не изменился, и 1, если он изменился. Следующий код операции ifeq переходит к строке 42, если возвращаемое значение было нулевым, т.е. метод не изменился. Начиная со строки 42 мы видим оригинальную реализацию, которую я здесь немного сократил.

Если значение равно 1, инструкция if_icmpeq переходит к строке 26, где статическое поле r$type читается еще раз. Эта ссылка используется для вызова метода ReloadableType.fetchLatest() для него. Следующая инструкция checkcast проверяет, что возвращаемая ссылка имеет тип ToBeChanged__I . Здесь мы впервые натыкаемся на этот искусственный интерфейс, который подпружиненный генерирует для каждого типа. Он отражает методы, которые были у исходного класса, когда он был инструментирован. Через две строки этот интерфейс используется для вызова метода print() для ссылки, которая была возвращена ReloadableType.fetchLatest() .

Эта ссылка – не ссылка на новую версию класса, а на так называемый диспетчер. Диспетчер реализует интерфейс ToBeChanged__I и реализует метод print() со следующими инструкциями:

1
2
3
0 aload_1
1 invokestatic #21 <com/martinsdeveloperworld/springloaded/ToBeChanged$$EPBF0gVl.print>
4 return

Динамически генерируемый класс ToBeChanged$$EPBF0gVl является так называемым исполнителем и воплощает новую версию типа. Для каждой новой версии создается новый диспетчер и исполнитель, только интерфейс остается прежним. Как только новая версия становится доступной, интерфейсный метод вызывается в новом диспетчере, и этот метод в простейшем случае перенаправляет на новую версию кода, воплощенную в исполнителе. Причиной, по которой интерфейсный метод не вызывается непосредственно в exeuctor, является тот факт, что подпружиненные могут также обрабатывать случаи, когда методы добавляются в новую версию класса. Так как эти методы не существуют в старой версии, к интерфейсу и диспетчеру добавляется универсальный метод __execute() . Этот динамический метод может затем отправлять вызовы новым методам, как показано в следующем наборе команд, взятых из сгенерированного диспетчера:

01
02
03
04
05
06
07
08
09
10
11
12
0 aload_3
 1 ldc #25 <newMethod()V>
 3 invokevirtual #31 <java/lang/String.equals>
 6 ifeq 18 (+12)
 9 aload_2
10 checkcast #33 <com/martinsdeveloperworld/springloaded/ToBeChanged>
13 invokestatic #36 <com/martinsdeveloperworld/springloaded/ToBeChanged$$EPBFaboY.newMethod>
16 aconst_null
17 areturn
18 aload_3
...
68 areturn

В этом случае я добавил новый метод newMethod() в класс ToBeChanged . Начало метода __execute() сравнивает, соответствует ли вызванный дескриптор новому методу. Если это так, он перенаправляет вызов новому исполнителю. Чтобы позволить этой работе, все вызовы нового метода должны быть переписаны в метод __execute() . Это также делается с помощью инструментов оригинальных классов и также работает для отражения.

Вывод

подпружиненный демонстрирует, что можно «заменить» класс более новой версией во время выполнения. Для достижения этого используется ряд технологий Java, таких как Java-агент и инструментарий байт-кода. При более внимательном рассмотрении реализации можно многое узнать о JVM и Java в целом.