Mythen über Speicherverwaltung in der JVM zerstreuen



In einer Reihe von Artikeln möchte ich die mit der Speicherverwaltung verbundenen Missverständnisse widerlegen und die Struktur in einigen modernen Programmiersprachen - Java, Kotlin, Scala, Groovy und Clojure - genauer untersuchen. Hoffentlich hilft Ihnen dieser Artikel dabei, herauszufinden, was unter der Haube dieser Sprachen vor sich geht. Zunächst betrachten wir die Speicherverwaltung in der Java Virtual Machine (JVM) , die in Java, Kotlin, Scala, Clojure, Groovy und anderen Sprachen verwendet wird. Im ersten Artikel habe ich auch den Unterschied zwischen einem Stapel und einem Heap behandelt, was zum Verständnis dieses Artikels hilfreich ist.



JVM-Speicherstruktur



Schauen wir uns zunächst die JVM-Speicherstruktur an. Diese Struktur wird seit JDK 11 verwendet . Dies ist der Speicher, der dem JVM-Prozess zur Verfügung steht und vom Betriebssystem zugewiesen wird:





Dies ist der vom Betriebssystem zugewiesene native Speicher, dessen Größe von System, Prozessor und JRE abhängt. Für welche Bereiche und wofür sind sie bestimmt?



Haufen



Hier speichert die JVM Objekte und dynamische Daten. Dies ist der größte Speicherbereich, in dem der Garbage Collector arbeitet. Die Heap-Größe kann mit den Flags Xms



(Anfangsgröße) und Xmx



(Maximalgröße) gesteuert werden . Der Heap wird nicht auf die gesamte virtuelle Maschine übertragen, ein Teil ist als virtueller Speicherplatz reserviert, wodurch der Heap in Zukunft wachsen kann. Der Haufen ist in Räume der "jungen" und "alten" Generation unterteilt.



  • Die junge Generation oder "neuer Raum": der Bereich, in dem neue Objekte leben. Es ist in Eden Space und Survivor Space unterteilt. Der Kontrollbereich der jungen Generation, " der jüngere Müllsammler " (Minor GC), der auch "der junge" (Young GC) genannt wird.

    • Paradies : Hier wird Speicher zugewiesen, wenn wir neue Objekte erstellen.
    • Überlebensbereich : Hier werden die vom kleinen Müllsammler übrig gebliebenen Objekte gespeichert. Der Bereich ist in zwei Hälften unterteilt, S0 und S1 .
  • Alte Generation oder "Speicher" (Tenured Space): Dies schließt Objekte ein, die während der Lebensdauer eines Junior-Müllsammlers die maximale Speicherschwelle erreicht haben. Dieser Raum wird von einem Major GC verwaltet.


Fadenstapel



Dies ist ein Stapelbereich, in dem ein Stapel pro Thread zugewiesen wird. Hier werden threadspezifische statische Daten gespeichert, einschließlich Methoden- und Funktionsrahmen sowie Zeiger auf Objekte. Die Größe des Stapelspeichers kann mithilfe eines Flags festgelegt werden Xss



.



Metaspace



Dies ist Teil des nativen Speichers. Standardmäßig gibt es keine Obergrenze. In früheren Versionen der JVM wird dieser Speicher als permanenter Generierungsraum (Permanent Generation (PermGen) Space) bezeichnet . Klassenlader haben darin Klassendefinitionen gespeichert. Wenn dieser Speicherplatz größer wird, kann das Betriebssystem die hier gespeicherten Daten aus dem RAM in den virtuellen Speicher verschieben, wodurch die Anwendung verlangsamt werden kann. Dies kann durch Einstellen der Größe metaSpace über Fahnen vermieden werden XX:MetaspaceSize



und -XX:MaxMetaspaceSize



in diesem Fall kann die Anwendung einen Speicherfehler ausgeben.



Code-Cache



Hier speichert der Just In Time (JIT) -Compiler kompilierte Codeblöcke, auf die Sie häufig zugreifen müssen. Normalerweise interpretiert die JVM den Bytecode in nativen Maschinencode. Der vom JIT-Compiler kompilierte Code muss jedoch nicht interpretiert werden. Er liegt bereits im nativen Format vor und wird in diesem Speicherbereich zwischengespeichert.



Gemeinsame Bibliotheken



Hier wird der native Code für alle gemeinsam genutzten Bibliotheken gespeichert. Dieser Speicherbereich wird vom Betriebssystem für jeden Prozess nur einmal geladen.



JVM-Speichernutzung: Stapel und Heap



Schauen wir uns nun an, wie das ausführbare Programm die wichtigsten Teile des Speichers verwendet. Verwenden wir den folgenden Code. Es ist nicht auf Korrektheit optimiert. Ignorieren Sie daher Probleme wie unnötige Zwischenvariablen, falsche Modifikatoren und mehr. Seine Aufgabe ist es, die Verwendung des Stapels und des Heaps zu visualisieren.



class Employee {
    String name;
    Integer salary;
    Integer sales;
    Integer bonus;

    public Employee(String name, Integer salary, Integer sales) {
        this.name = name;
        this.salary = salary;
        this.sales = sales;
    }
}

public class Test {
    static int BONUS_PERCENTAGE = 10;

    static int getBonusPercentage(int salary) {
        int percentage = salary * BONUS_PERCENTAGE / 100;
        return percentage;
    }

    static int findEmployeeBonus(int salary, int noOfSales) {
        int bonusPercentage = getBonusPercentage(salary);
        int bonus = bonusPercentage * noOfSales;
        return bonus;
    }

    public static void main(String[] args) {
        Employee john = new Employee("John", 5000, 5);
        john.bonus = findEmployeeBonus(john.salary, john.sales);
        System.out.println(john.bonus);
    }
}

      
      





Hier können Sie sehen, wie das obige Programm ausgeführt wird und wie der Stapel und der Heap verwendet werden:



https://files.speakerdeck.com/presentations/9780d352c95f4361bd8c6fa164554afc/JVM_memory_use.pdf



Wie Sie sehen können:



  • Jeder Funktionsaufruf wird als Frame-Block auf den Thread des Ausführungsstapels übertragen.
  • Alle lokalen Variablen, einschließlich Argumente und Rückgabewerte, werden auf dem Stapel in Funktionsrahmenblöcken gespeichert.
  • int .
  • Employee, Integer String , . .
  • , , .
  • , .
  • ().
  • , .


Der Stack wird automatisch vom Betriebssystem verwaltet, nicht von der JVM. Daher besteht keine Notwendigkeit, sich besonders um ihn zu kümmern. Der Heap wird jedoch nicht mehr auf diese Weise verwaltet. Da dies der größte Speicherbereich ist, der dynamische Daten enthält, kann er exponentiell wachsen und das Programm kann im Laufe der Zeit den gesamten Speicher belegen. Darüber hinaus wird der Heap allmählich fragmentiert, wodurch die Leistung von Anwendungen verlangsamt wird. Die JVM hilft bei der Lösung dieser Probleme. Es verwaltet den Heap automatisch mithilfe der Speicherbereinigung.



JVM-Speicherverwaltung: Speicherbereinigung



Werfen wir einen Blick auf die automatische Heap-Verwaltung, die eine sehr wichtige Rolle für die Anwendungsleistung spielt. Wenn ein Programm versucht, mehr Speicher auf dem Heap zuzuweisen, als verfügbar ist (abhängig vom Wert Xmx



), treten Speicherfehler auf .



Die JVM verwaltet den Heap mithilfe der Speicherbereinigung. Um Platz für die Erstellung eines neuen Objekts zu schaffen, bereinigt die JVM den Speicher, der von verwaisten Objekten belegt wird, dh von Objekten, auf die nicht mehr direkt oder indirekt vom Stapel verwiesen wird.





Der JVM-Garbage Collector ist verantwortlich für:



  • Speicher vom Betriebssystem abrufen und an das Betriebssystem zurückgeben.
  • Übertragung des zugewiesenen Speichers an die Anwendung auf deren Anforderung.
  • Bestimmen Sie, welche Teile des zugewiesenen Speichers noch von der Anwendung verwendet werden.
  • Nicht verwendeten Speicher für die Verwendung durch die Anwendung beanspruchen.


Garbage Collectors in der JVM arbeiten generationsübergreifend (Objekte im Heap werden nach Alter gruppiert und in verschiedenen Phasen bereinigt). Es gibt viele verschiedene Garbage Collection-Algorithmen, aber Mark & ​​Sweep ist der am häufigsten verwendete .



Müllsammler Mark & ​​Sweep



Die JVM verwendet einen separaten Daemon-Thread, der im Hintergrund für die Speicherbereinigung ausgeführt wird. Dieser Prozess beginnt, wenn bestimmte Bedingungen erfüllt sind. Der Mark & ​​Sweep-Kollektor arbeitet normalerweise in zwei Schritten, manchmal wird ein dritter hinzugefügt, abhängig vom verwendeten Algorithmus.





  • Markup : Zunächst bestimmt der Kollektor, welche Objekte verwendet werden und welche nicht. Diejenigen, die von Stapelzeigern verwendet werden oder auf die zugegriffen wird, werden rekursiv als lebendig markiert.
  • Entfernen : Der Kollektor geht durch den Heap und entfernt alle Objekte, die nicht als lebendig markiert sind. Diese Speicherplätze sind als frei markiert.
  • Komprimierung : Nach dem Entfernen nicht verwendeter Objekte werden alle überlebenden Objekte so verschoben, dass sie zusammen sind. Dies reduziert die Fragmentierung und beschleunigt die Speicherzuweisung für neue Objekte.


Diese Art von Kollektor wird auch als Stop-the-World bezeichnet, da die Anwendung beim Entfernen Pausen aufweist.



Die JVM bietet verschiedene Garbage Collection-Algorithmen zur Auswahl. Abhängig von Ihrem JDK stehen möglicherweise noch mehr Optionen zur Verfügung (z. B. der Shenandoah- Collector in OpenJDK). Autoren unterschiedlicher Implementierungen zielen auf unterschiedliche Ziele ab:



  • Durchsatz : Zeitaufwand für die Speicherbereinigung, bei der die Anwendung nicht ausgeführt wird. Idealerweise sollte der Durchsatz hoch sein, dh die Speicherbereinigungspausen sind kurz.
  • Pausendauer : Wie lange der Garbage Collector die Ausführung der Anwendung stört. Idealerweise sollten die Pausen sehr kurz sein.
  • Heap-Größe : Idealerweise sollte klein sein.


Sammler in JDK 11



JDK 11 ist die aktuelle LTE-Version. Unten finden Sie eine Liste der darin verfügbaren Garbage Collectors. Die JVM wählt standardmäßig einen aus, abhängig von der aktuellen Hardware und dem aktuellen Betriebssystem. Wir können jederzeit die Auswahl eines Pickers mithilfe eines Optionsfelds erzwingen -XX



.



  • : , , . -XX:+UseSerialGC



    .
  • : , . , / . -XX:+UseParallelGC



    .
  • Garbage-First (G1): ( ). , . . -XX:+UseG1GC



    .
  • Z: , , JDK11. . , stop-the-world. , / ( ). -XX:+UseZGC



    .




Unabhängig davon, welcher Kollektor ausgewählt ist, verwendet die JVM zwei Arten von Baugruppen - den Junior-Kollektor und den Senior-Kollektor.



Junior Assembler



Es bewahrt die Sauberkeit und Kompaktheit des Raumes der jüngeren Generation. Es wird gestartet, wenn die JVM nicht den notwendigen Speicher im Himmel erhalten kann, um ein neues Objekt aufzunehmen. Anfangs sind alle Bereiche des Heaps leer. Das Paradies füllt sich zuerst, gefolgt vom Gebiet der Überlebenden und am Ende der Lagerung.



Sie können den Prozess dieses Sammlers hier sehen:



https://files.speakerdeck.com/presentations/f4783404769145f4b990154d0cc05629/JVM_minor_GC.pdf



  1. Angenommen, es gibt bereits Objekte im Paradies (die Blöcke 01 bis 06 sind als verwendet markiert).
  2. Die Anwendung erstellt ein neues Objekt (07).
  3. JVM , , JVM .
  4. ( ), — ().
  5. JVM S0 S1 «» (To Space), S0. «» , , , .
  6. , .
  7. , - , ( 07 13 ).
  8. (14).
  9. JVM , , JVM .
  10. , , « ».
  11. JVM «» S1, S0 «». «» «» (S1), , . , «», , (premature promotion). , .
  12. «» (S0), .
  13. Dies wird bei jeder Junior-Sammlersitzung wiederholt, die Überlebenden bewegen sich zwischen S0 und S1 und ihr Alter steigt. Wenn der angegebene "maximale Schwellenwert" erreicht ist, der standardmäßig 15 beträgt, wird das Objekt in den "Speicher" verschoben.


Wir haben uns angesehen, wie der Junior-Sammler das Gedächtnis im Raum der jüngeren Generation aufräumt. Dies ist ein Stop-the-World-Prozess, der jedoch so schnell ist, dass seine Dauer normalerweise vernachlässigt werden kann.



Senior Assembler



Überwacht die Sauberkeit und Kompaktheit des Raums der alten Generation (Lagerung). Läuft unter einer der folgenden Bedingungen:



  • Der Entwickler ruft das Programm auf System



    . gc()



    oder Runtime.getRunTime().gc()



    .
  • Die JVM entscheidet, dass der Speicher nicht über genügend Speicher verfügt, da er aufgrund früherer Sitzungen des Junior-Sammlers voll ist.
  • Wenn JVM während des Laufens des Junior Collector nicht genug Speicherplatz im Paradies oder im Überlebensbereich erhält.
  • Wenn wir einen Parameter in der JVM festlegen MaxMetaspaceSize



    und nicht genügend Speicher zum Laden neuer Klassen vorhanden ist.


Der Arbeitsprozess des Senior-Sammlers ist einfacher als der des Junior:



  1. Nehmen wir an, dass viele Junior-Sammlersitzungen vergangen sind und der Speicher fast voll ist. Die JVM beschließt, den älteren Kollektor auszuführen.
  2. Im Speicher wird der Objektgraph ausgehend von Stapelzeigern rekursiv durchlaufen und die verwendeten Objekte als (verwendeter Speicher), der Rest als Müll (verloren) markiert. Wenn der Seniorensammler während der Arbeit des Juniorensammlers ins Leben gerufen wurde, umfasst seine Arbeit den Raum der jüngeren Generation (Paradies und das Gebiet der Überlebenden) und das Gewölbe.
  3. Der Collector entfernt alle verwaisten Objekte und stellt den Speicher wieder her.
  4. Wenn während der Arbeit des älteren Kollektors keine Objekte mehr auf dem Heap vorhanden sind, fordert die JVM auch Speicher aus dem Metaspace zurück und entfernt geladene Klassen daraus, wenn es sich um eine vollständige Garbage Collection handelt.


Fazit



Wir haben die Struktur und Speicherverwaltung der JVM behandelt. Dies ist kein erschöpfender Artikel. Wir haben nicht über viele der komplexeren Konzepte und Anpassungsmöglichkeiten für bestimmte Anwendungsfälle gesprochen. Weitere Details können Sie hier lesen .



Für die meisten JVM-Entwickler (Java, Kotlin, Scala, Clojure, JRuby, Jython) ist diese Informationsmenge jedoch ausreichend. Hoffentlich können Sie jetzt besseren Code schreiben, effizientere Anwendungen erstellen und verschiedene Probleme mit Speicherlecks vermeiden.



Links






All Articles