Статьи

Самовосстанавливающаяся JVM

Исправление себя, чтобы очистить слабость и обрести превосходство Этот пост является примером приложения, в котором первое решение каждой ИТ-проблемы – «пытались ли вы выключить и снова включить его» – может иметь неприятные последствия и принести больше вреда, чем пользы.

Вместо того, чтобы выключать и включать, у нас есть приложение, которое буквально лечит себя: оно вначале дает сбой, но через некоторое время начинает работать без сбоев. Чтобы привести пример такого приложения в действии, мы воссоздали его в самой простой из возможных форм, черпая вдохновение в том, что сейчас является пятилетней записью из бюллетеня Java Heinz Kabutz ‘ :

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
28
package eu.plumbr.test;
 
public class HealMe {
  private static final int SIZE = (int) (Runtime.getRuntime().maxMemory() * 0.6);
 
  public static void main(String[] args) throws Exception {
    for (int i = 0; i < 1000; i++) {
      allocateMemory(i);
    }
  }
 
  private static void allocateMemory(int i) {
    try {
      {
        byte[] bytes = new byte[SIZE];
        System.out.println(bytes.length);
      }
 
      byte[] moreBytes = new byte[SIZE];
      System.out.println(moreBytes.length);
 
      System.out.println("I allocated memory successfully " + i);
 
    } catch (OutOfMemoryError e) {
      System.out.println("I failed to allocate memory " + i);
    }
  }
}

Приведенный выше код выделяет две части памяти в цикле. Каждое из этих распределений равно 60% от общего доступного размера кучи. Поскольку выделения происходят последовательно в одном и том же методе, можно ожидать, что этот код будет продолжать генерировать java.lang.OutOfMemoryError: ошибки пространства кучи Java и никогда не завершать успешно метод allocateMemory () .

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

  1. После первого быстрого изучения этот код действительно не может быть завершен, потому что мы стараемся выделить больше памяти, чем доступно JVM.
  2. Если мы посмотрим поближе, то заметим, что первое распределение происходит в блоке с областью видимости, что означает, что переменные, определенные в этом блоке, видны только этому блоку. Это указывает на то, что байты должны быть пригодны для GC после завершения блока. И поэтому наш код на самом деле должен нормально работать с самого начала, так как в то время, когда он пытается выделить moreBytes, предыдущие байты выделения должны быть мертвыми.
  3. Если мы теперь посмотрим на скомпилированный файл классов, то увидим следующий байт-код:
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
private static void allocateMemory(int);
    Code:
       0: getstatic     #3                  // Field SIZE:I
       3: newarray       byte
       5: astore_1     
       6: getstatic     #4                  // Field java/lang/System.out:Ljava/io/PrintStream;
       9: aload_1      
      10: arraylength  
      11: invokevirtual #5                  // Method java/io/PrintStream.println:(I)V
      14: getstatic     #3                  // Field SIZE:I
      17: newarray       byte
      19: astore_1     
      20: getstatic     #4                  // Field java/lang/System.out:Ljava/io/PrintStream;
      23: aload_1      
      24: arraylength  
      25: invokevirtual #5                  // Method java/io/PrintStream.println:(I)V
---- cut for brevity ----

Здесь мы видим, что на смещениях 3-5 первый массив размещается и сохраняется в локальной переменной с индексом 1. Затем, на смещении 17 будет выделен другой массив. Но на первый массив все еще ссылается локальная переменная, поэтому второе выделение всегда должно завершаться с OOM. Интерпретатор байт-кода просто не может позволить GC очистить первый массив, потому что на него все еще сильно ссылаются.

Наш статический анализ кода показал нам, что по двум основным причинам представленный код не должен успешно выполняться, а в одном случае – должен. Какой из этих трех является правильным? Давайте на самом деле запустим его и посмотрим сами. Оказывается, оба вывода были правильными. Во-первых, приложение не может выделить память. Но через некоторое время (на моем Mac OS X с Java 8 это происходит на итерации # 255), распределение начинает выполняться успешно:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
java -Xmx2g eu.plumbr.test.HealMe
1145359564
I failed to allocate memory 0
1145359564
I failed to allocate memory 1
 
… cut for brevity ...
 
I failed to allocate memory 254
1145359564
I failed to allocate memory 255
1145359564
1145359564
I allocated memory successfully 256
1145359564
1145359564
I allocated memory successfully 257
1145359564
1145359564
Self-healing code is a reality! Skynet is near...

Чтобы понять, что на самом деле происходит, нам нужно подумать, что меняется во время выполнения программы? Очевидный ответ, конечно, может произойти компиляция Just-In-Time. Если вы помните, компиляция Just-In-Time – это встроенная механика JVM для оптимизации горячих точек кода. Для этого JIT отслеживает работающий код, а при обнаружении горячей точки JIT компилирует ваш байт-код в собственный код, выполняя различные оптимизации, такие как встраивание метода и удаление мертвого кода в процессе.

Посмотрим, так ли это, включив следующие параметры командной строки и перезапустив программу:

1
-XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -XX:+LogCompilation

Это создаст файл журнала, в нашем случае с именем hotspot_pid38139.log, где 38139 был PID вашего процесса Java. В этом файле можно найти следующую строку:

1
<task_queued compile_id='94' method='HealMe allocateMemory (I)V' bytes='83' count='256' iicount='256' level='3' stamp='112.305' comment='tiered' hot_count='256'/>

Это означает, что после выполнения методов allocateMemory () 256 раз компилятор C1 решил поставить этот метод в очередь для компиляции уровня 3 C1. Вы можете получить больше информации об уровнях многоуровневой компиляции и различных пороговых значениях здесь . И поэтому наши первые 256 итераций были выполнены в интерпретируемом режиме, где интерпретатор байт-кода, будучи простой машиной стека, не может заранее знать, будет ли какая-то переменная, байты в этом случае, использоваться дальше или нет. Но JIT видит весь метод сразу и поэтому может вывести, что байты больше не будут использоваться и, по сути, GC имеет право. Таким образом, сборка мусора может в конечном итоге иметь место, и наша программа волшебным образом излечилась. Теперь я могу только надеяться, что ни один из читателей не должен отвечать за отладку такого случая в производстве. Но если вы хотите сделать чью-то жизнь несчастной, внедрение подобного кода в производство было бы верным способом достижения этого.

Ссылка: Самовосстанавливающаяся JVM от нашего партнера JCG Никиты Сальникова Тарновского в блоге Plumbr Blog .