Vielleicht noch etwas ausführlicher, weil ich gerade Lust/Zeit habe:
Die JVM spezifiziert eine eigene virtuelle Maschine durch die Java Virtual Machine Specification (JVMS).
Hier bedeutet der "Stack" jener Speicherbereich, welcher jeweils pro Thread existiert, um "Frames" für Methodenaufrufe (als LIFO- also "Stack"-Datenstruktur) zu verwalten:
https://docs.oracle.com/javase/specs/jvms/se7/html/jvms-2.html#jvms-2.5.2
Die JVMS macht auch eine Referenz auf "native" bzw. "C" Stacks für die Unterstützung von "native" methods:
https://docs.oracle.com/javase/specs/jvms/se7/html/jvms-2.html#jvms-2.5.6
Da "native" methods ja keine Java-Methoden sind, sondern durch nativen Code (also direkten Code des physischen Prozessors, auf dem die Java Virtual Machine aufsetzt) implementiert ist, benötigt dieser native Code auch eine Laufzeitumgebung, bzw. zumindest mal einen Hardware-Stack (wenn das von der physischen Maschine unterstützt wird).
Der Begriff "Heap" bedeutet im Kontext dieser Java virtuellen Maschine jener Speicherbereich, der für das Anlegen von Objekten und Arrays reserviert ist und über alle Threads geteilt wird:
https://docs.oracle.com/javase/specs/jvms/se17/html/jvms-2.html#jvms-2.5.3
Es gibt nun den Begriff "on-heap" und "off-heap" im Java Kontext.
"on-heap" bedeutet eben genau der Heap, den ich oben angesprochen hatte. Also der "Heap" Speicher der JVM.
"off-heap" bedeutet jener Speicherbereich, der weder JVM-Stack noch JVM-Heap ist. Dieser Art von Speicher wurde für die Interoperabilität/Interop mit nativem Code spätestens seit Einführung der "New I/O" (bzw. kurz "NIO") in Java 1.4 wichtig. Mit dem ByteBuffer gab es nun das erste Mal überhaupt die Möglichkeit, per standardisierter Java API auf Speicherbereiche zuzugreifen, die nicht der Java Heap ist, um z.B. Speicher zwischen einer Java-Anwendung und einer nativen Library
effizient zu kommunizieren. Vorher war diese Kommunikation zwar auch möglich, allerdings nur mit JNI Code und explizitem "Pinning" von "on-heap" Speicher, damit etwa der Garbage Collector den Speicher (der in den meisten Fällen zu einem primitiven Java Array gehörte - also wieder einem Java Objekt) nicht "verschiebt".
Mit NIO war es nun möglich, innerhalb von Java einfach mal "off-heap" Speicher zu alloziieren und zu verwenden (lesen/schreiben) bzw. woanders weiterzureichen (z.B. zu einer Schnittstelle einer nativen Library).
Desweiteren besteht mit JNI (
welche mit Java 1.4 um einige Funktionen erweitert wurde) auch die Möglichkeit, einen ByteBuffer an Java zu zurückzugeben, der auf eine
beliebige Speicheradresse zeigt und eine beliebige Kapazität hat. Dieser Speicherbereich kann
theoretisch auch auf den "nativen Stack" bzw. "C Stack" des JVM Prozesses zeigen. Hier kann einfach eine
beliebige Adresse verwendet werden.
Dies ermöglichte nun auch, Speicher, der nicht initial von deinem Java-Prozess (per ByteBuffer.allocateDirect()) alloziiert wurde, sondern
von einer nativen Library, innerhalb deiner Java Anwendung über die ByteBuffer API zu lesen/schreiben.