Java Memory Puzzle

Na portalu javaczyherbata pojawił się dzisiaj do rozwiązania ciekawy problem związany z alokacją pamięci w Java. Chodziło o wyjaśnienie przyczyny działania programu dwukrotnie alokującego tablicę bajtów o rozmiarze większym niż połowa maksymalnego rozmiaru heapa. Alokacja pierwszej tablicy odbywała się w bloku anonimowym, drugiej poza nim, więc teoretycznie mechanizm GC powinien sprzątnąć pierwszą tablicę (gdyż nie jest przetrzymywana do niej silna referencja) a program powinien zakończyć się bez rzucenia wyjątku OutOfMemoryError. Teoretycznie.

Kod wyglądał następująco

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class JVMPuzzle {
 
    private final int dataSize = (int) (Runtime.getRuntime().maxMemory() * 0.6);
 
    public void go() {
        {
            byte[] bytes1 = new byte[dataSize];
        }
 
      //  int i = 0;
 
        byte[] bytes2 = new byte[dataSize];
    }

    public static void main(String[] args) {
        new JVMPuzzle().go();
    }
}

W takiej postaci program niestety rzuca wyjątkiem. Natomiast po odkomentowaniu linii z deklaracją zmiennej “i” działa. Dlaczego? Z pomocą przychodzi oczywiście dekompilator i polecenie javap -c JVMPuzzle, który w wyniku daje kod:

1
2
3
4
5
6
7
8
9
10
11
public void go();
  Code:
   0:    aload_0
   1:    getfield    #6; //Field dataSize:I
   4:    newarray byte
   6:    astore_1
   7:    aload_0
   8:    getfield    #6; //Field dataSize:I
   11:    newarray byte
   13:    astore_1
   14:    return

Widzimy, że instrukcje 0-6 odpowiadają za blok anonimowy czyli w naszym przypadku alokację pierwszej tablicy bajtów i zapis referencji na stosie na pozycji 1. Instrukcje 7-13 odpowiadają z kolei za alokację drugiej tablicy bajtów i zapis nowej referencji na stos, także na pozycję 1. W momencie alokacji (instrukcja 11) na stosie istnieje jednak stara referencja do tablicy bytes1, co uniemożliwia mechanizmowi gc zwolnienie pamięci (mimo, iż zmienna zadeklarowana jest w bloku anonimowym).

Spójrzmy co się stanie po dodani deklaracji jakiejś zmiennej po bloku anonimowym:

1
2
3
4
5
6
7
8
9
10
11
12
13
public void go();
  Code:
   0:    aload_0
   1:    getfield    #6; //Field dataSize:I
   4:    newarray byte
   6:    astore_1
   7:    iconst_0
   8:    istore_1
   9:    aload_0
   10:    getfield    #6; //Field dataSize:I
   13:    newarray byte
   15:    astore_2
   16:    return

Linie 7 i 8 powinny dać wyjaśnienie. Odpowiadają one za deklarację zmiennej “i” - załadowanie stałej 0 oraz odłożenie jej wartości na stosie na tej samej pozycji gdzie zapisana jest referencja do tablicy bytes1. Alokacja zmiennej „i” powoduje więc nadpisanie referencji do starej tablicy bajtów. Dzięki temu instrukcja w linii 13 powoduje uruchomienie GC, zwolnienie pamięci (bo nie istnieje już silna referencja do bytes1) i umożliwiają alokację nowej tablicy bytes2.

Jak widać działanie JVM nie zawsze jest zrozumiałe bez głębszej analizy ;)

Comments