Hakujemy maszynę wirtualną - memory leak

Mimo Garbage Collectora w języku Java można bardzo szybko wywołać memory leak. Trochę o tym.

Opis problemu

Podczas rekrutacji miałem pytanie o wycieki pamięci w języku Java oraz zostałem poproszony o podanie przykładów. Niestety poczułem się nieswojo, ponieważ nie potrafiłem tego zrobić. Czy możecie pomóc?

Rozwiązanie problemu

Oto dobry sposób na stworzenie prawdziwego wycieku pamięci (obiekty niedostępne przez uruchomienie kodu, ale nadal przechowywane w pamięci) w czystej Javie:

  1. Aplikacja tworzy długo działający wątek (lub można użyć puli wątków, aby wyciek nastąpił jeszcze szybciej).
  2. Wątek ładuje klasę za pomocą (opcjonalnie niestandardowego) ClassLoadera.
  3. Klasa przydziela dużą część pamięci (np. new byte[1000000]), przechowuje silne odniesienie do niej w polu statycznym, a następnie przechowuje odwołanie do siebie w ThreadLocal. Przydzielenie dodatkowej pamięci jest opcjonalne (wystarczy wyciek na instancji klasy), ale sprawi, że wyciek będzie działał znacznie szybciej.
  4. Aplikacja usuwa wszystkie odwołania do niestandardowej klasy lub ClassLoadera, z którego została załadowana.
  5. Operację należy powtórzyć.

Ze względu na sposób, w jaki ThreadLocal jest implementowany w JDK Oracle, powoduje to wyciek pamięci:

  1. Każdy wątek ma prywatne pole threadLocals, które faktycznie przechowuje wartości lokalne wątku.
  2. Każdy klucz na tej mapie jest słabym odniesieniem do obiektu ThreadLocal, więc po tym, jak obiekt ThreadLocal jest zbierany w pamięci, jego wpis jest usuwany z mapy.
  3. Ale każda wartość jest silnym odwołaniem, więc gdy wartość (bezpośrednio lub pośrednio) wskazuje na obiekt ThreadLocal, który jest jego kluczem, obiekt ten nie będzie ani usuwany, ani usuwany z mapy, dopóki żyje wątek.

W tym przykładzie łańcuch silnych odniesień wygląda następująco:

Obiekt wątku → mapa threadLocals → wystąpienie przykładowej klasy → przykładowa klasa → statyczne pole ThreadLocal → obiekt ThreadLocal.

Każda odmiana klasy pasującej do tego wzoru powoduje, że kontenery aplikacji (takie jak Tomcat) mogą mieć okropne wycieki pamięci, jeśli często ponownie wdrażasz aplikacje, które używają ThreadLocals, które w jakiś sposób wskazują na siebie. Może się to zdarzyć z wielu subtelnych powodów i często jest trudne do debugowania i / lub naprawy. Zobacz poniższą klasę:



private static final Map myCache = new HashMap<>();

public void getInfo(String key)
{
    // uses cache
    Info info = myCache.get(key);
    if (info != null) return info;

    // if it's not in cache, then fetch it from the database
    info = Database.fetch(key);
    if (info == null) return null;

    // and store it in the cache
    myCache.put(key, info);
    return info;
}

Przykładowe exploity

IBM JDK noclassagc

Odniesienie do obiektu zawierającego pole statyczne [esp pole końcowe]


class MemorableClass {
static final ArrayList list = new ArrayList (100);
}

Wywołanie String.intern () na długim String


String str = readString (); 
str.intern ();


try {
BufferedReader br = new BufferedReader (new FileReader (inputFile));
...
...
} catch (Exception e) {
e.printStacktrace ();
}

Niezamknięte połączenia:


try {
Connection conn = ConnectionFactory.getConnection ();
...
...
} catch (wyjątek e) {
e.printStacktrace ();
}

Obszary nieosiągalne z modułu śmieciowego JVM, to obszary takie jak pamięć przydzielana metodami natywnymi.

W aplikacjach internetowych niektóre obiekty są przechowywane w zakresie aplikacji, dopóki aplikacja nie zostanie wyraźnie zatrzymana lub usunięta.


getServletContext (). setAttribute („SOME_MAP”, mapa);

Niepoprawne lub nieodpowiednie opcje JVM, takie jak opcja noclassgc w IBM JDK, która zapobiega nieużywaniu odśmiecania klas, doskonale ilustrują ten problem.

HashSet i equals

Prostą rzeczą jest użycie zestawu HashSet z niepoprawnym (lub nieistniejącym) hashCode () lub equals (), a następnie dodawanie „duplikatów”. Zamiast ignorować duplikaty tak, jak powinno, zestaw będzie się powiększał i nie będzie można ich usunąć.

Jeśli chcesz, aby te złe klucze / elementy wisiały w pamięci, możesz użyć pola statycznego, takiego jak:


class BadKey {
   // no hashCode or equals();
   public final String key;
   public BadKey(String key) { this.key = key; }
}

Map map = System.getProperties();
map.put(new BadKey("key"), "value");

JDBC to też twój wróg

Poniższy przykład jest dość bezcelowy, jeśli nie rozumiesz zasad działania JDBC lub przynajmniej tego, w jaki sposób JDBC oczekuje, że programista zamknie instancje Connection, Statement i ResultSet przed ich odrzuceniem lub utratą odniesień do nich, zamiast polegać na implementacji finalizacji.


void doWork()
{
   try
   {
       Connection conn = ConnectionFactory.getConnection();
       PreparedStatement stmt = conn.preparedStatement("some query"); // executes a valid query
       ResultSet rs = stmt.executeQuery();
       while(rs.hasNext())
       {
          ... process the result set
       }
   }
   catch(SQLException sqlEx)
   {
       log(sqlEx);
   }
}

ArrayList.remove() - wiesz o tym?

Prawdopodobnie jednym z najprostszych przykładów potencjalnego wycieku pamięci i tego, jak tego uniknąć, jest implementacja ArrayList.remove (int):


public E remove(int index) {
    RangeCheck(index);

    modCount++;
    E oldValue = (E) elementData[index];

    int numMoved = size - index - 1;
    if (numMoved > 0)
        System.arraycopy(elementData, index + 1, elementData, index,
                numMoved);
    elementData[--size] = null; // (!) Let gc do its work

    return oldValue;
}

Gdybyś sam to wdrażał, czy pomyślałbyś o wyczyszczeniu nieużywanego elementu tablicy (elementData [- size] = null)? Ta referencja może utrzymać przy życiu ogromny obiekt …

sun.misc.Unsafe

Jesteś w stanie spowodować wyciek pamięci dzięki klasie sun.misc.Unsafe. W rzeczywistości ta klasa usług jest używana w różnych klasach standardowych (na przykład w klasach java.nio). Nie możesz bezpośrednio utworzyć instancji tej klasy, ale możesz użyć do tego refleksji.


import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import sun.misc.Unsafe;


public class TestUnsafe {

    public static void main(String[] args) throws Exception{
        Class unsafeClass = Class.forName("sun.misc.Unsafe");
        Field f = unsafeClass.getDeclaredField("theUnsafe");
        f.setAccessible(true);
        Unsafe unsafe = (Unsafe) f.get(null);
        System.out.print("4..3..2..1...");
        try
        {
            for(;;)
                unsafe.allocateMemory(1024*1024);
        } catch(Error e) {
            System.out.println("Boom :)");
            e.printStackTrace();
        }
    }

}

Substring także może okazać się zabójczy

Ponieważ podciąg odnosi się do wewnętrznej reprezentacji oryginalnego, znacznie dłuższego łańcucha, oryginał pozostaje w pamięci. Tak więc, dopóki masz załadowany StringLeaker, masz również cały oryginalny ciąg w pamięci, nawet jeśli możesz pomyśleć, że trzymasz się tylko jednego znaku.


public class StringLeaker
{
    private final String muchSmallerString;

    public StringLeaker()
    {
        // Imagine the whole Declaration of Independence here
        String veryLongString = "We hold these truths to be self-evident...";

        // The substring here maintains a reference to the internal char[]
        // representation of the original string.
        this.muchSmallerString = veryLongString.substring(0, 1);
    }
}

Log4J także stanowi problem

Log4j ma ten mechanizm o nazwie Nested Diagnostic Context (NDC), który jest instrumentem służącym do odróżniania przeplatanych danych wyjściowych dziennika z różnych źródeł. Granulacja, w której działa NDC, to wątki, dlatego rozróżnia oddzielnie wyniki dziennika z różnych wątków.

Aby przechowywać tagi specyficzne dla wątku, klasa NDC log4j używa tablicy mieszającej, która jest kluczowana przez sam obiekt Thread (w przeciwieństwie do powiedzenia id wątku), a zatem dopóki tag NDC nie zostanie w pamięci, wszystkie obiekty, które zwisają z wątku obiekt również pozostaje w pamięci. W naszej aplikacji internetowej używamy NDC do oznaczania logoutputs identyfikatorem żądania w celu oddzielnego oddzielenia logów od pojedynczego żądania. Kontener, który wiąże tag NDC z wątkiem, usuwa go również podczas zwracania odpowiedzi z żądania. Problem wystąpił, gdy w trakcie przetwarzania żądania pojawił się wątek potomny, podobny do następującego kodu:


public class RequestProcessor {
    private static final Logger logger = Logger.getLogger(RequestProcessor.class);
    public void doSomething()  {
        ....
        final List hugeList = new ArrayList(10000);
        new Thread() {
           public void run() {
               logger.info("Child thread spawned")
               for(String s:hugeList) {
                   ....
               }
           }
        }.start();
    }
}    

Statyczna mapa przyczyną wielu problemów

Utwórz mapę statyczną i dodawaj do niej twarde odniesienia. To rozwiązanie nigdy nie zostanie oczyszczone przez GarbageCollector.


public class Leaker {
    private static final Map CACHE = new HashMap();

    // Keep adding until failure.
    public static void addToCache(String key, Object value) { Leaker.CACHE.put(key, value); }
}

Można utworzyć przeciek pamięci ruchomej, tworząc nową instancję klasy w metodzie finalizacji tej klasy. Punkty bonusowe do wypowiedzenia dostaniesz, jeśli finalizator tworzy wiele wystąpień. Oto prosty program, który wycieka całą stertę w czasie od kilku sekund do kilku minut, w zależności od wielkości sterty:


class Leakee {
    public void check() {
        if (depth > 2) {
            Leaker.done();
        }
    }
    private int depth;
    public Leakee(int d) {
        depth = d;
    }
    protected void finalize() {
        new Leakee(depth + 1).check();
        new Leakee(depth + 1).check();
    }
}

public class Leaker {
    private static boolean makeMore = true;
    public static void done() {
        makeMore = false;
    }
    public static void main(String[] args) throws InterruptedException {
        // make a bunch of them until the garbage collector gets active
        while (makeMore) {
            new Leakee(0).check();
        }
        // sit back and watch the finalizers chew through memory
        while (true) {
            Thread.sleep(1000);
            System.out.println("memory=" +
                    Runtime.getRuntime().freeMemory() + " / " +
                    Runtime.getRuntime().totalMemory());
        }
    }
}

JVM jest zabawne

Ostatnio natrafiłem na bardziej subtelny rodzaj wycieku zasobów. Otwieramy zasoby za pomocą getResourceAsStream modułu ładującego klasy i zdarzyło się, że uchwyty strumienia wejściowego nie zostały zamknięte.

Ojoj, teraz możesz powiedzieć, co za idiota.

Cóż, to sprawia, że jest to interesujące: w ten sposób możesz tworzyć wycieki pamięci sterty bazowego procesu, a nie wycieki na stercie JVM.

Wszystko czego potrzebujesz to plik jar z plikiem wewnątrz, do którego będzie odwoływał się kod Java. Im większy plik jar, tym szybciej przydzielana jest pamięć.

Możesz łatwo utworzyć taki dziurawy jar za pomocą następującej klasy:



import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;

public class BigJarCreator {
    public static void main(String[] args) throws IOException {
        ZipOutputStream zos = new ZipOutputStream(new FileOutputStream(new File("big.jar")));
        zos.putNextEntry(new ZipEntry("resource.txt"));
        zos.write("not too much in here".getBytes());
        zos.closeEntry();
        zos.putNextEntry(new ZipEntry("largeFile.out"));
        for (int i=0 ; i<10000000 ; i++) {
            zos.write((int) (Math.round(Math.random()*100)+20));
        }
        zos.closeEntry();
        zos.close();
    }
}

I włączyć go do jakiejś innej klasy:


public class MemLeak { 
public static void main(String[] args) throws InterruptedException { 
int ITERATIONS=100000;
 for (int i=0 ; i<ITERATIONS ; i++) {
 MemLeak.class.getClassLoader().getResourceAsStream("resource.txt"); 
}
 System.out.println("finished creation of streams, now waiting to be killed"); 
Thread.sleep(Long.MAX_VALUE);
 }
 } 

Podsumowanie

MemoryLeak w javie to naprawdę dosyć poważne zjawisko, które można wykonać w bardzo prosty sposób. Naprawdę nie trzeba być mistrzem hakingu. Po prostu maszyna wirtualna tak działa.