写点什么

Java heap、no-heap 和 off-heap 内存基础与实践

作者:FunTester
  • 2025-01-06
    河北
  • 本文字数:4931 字

    阅读完需:约 16 分钟

引言

在 Java 应用的内存管理中,Heap 、No-Heap 和 Off-Heap 是开发者优化性能和资源管理时不可忽视的关键组成部分。它们在 JVM 的运行中扮演着不同的角色,负责存储不同类型的数据结构和对象。随着现代应用程序的复杂性和规模不断提升,合理地分配和管理这三类内存,不仅可以提高系统的效率,还能在高并发、大数据处理等场景下有效避免性能瓶颈。


Heap 是 Java 应用最常使用的内存区域,所有动态创建的对象都存储在这里。然而,频繁的垃圾回收(GC)操作有时会带来延迟,影响应用的响应时间。为此,No-Heap 提供了一个独立的区域,用于存储类的元数据、线程栈和方法区数据,确保 JVM 稳定高效运行。而 Off-Heap 则是一个独立于 JVM 的内存空间,适合存储大数据和长生命周期的对象,减少垃圾回收的干扰。


理解和合理运用这三者之间的关系,能够帮助开发者在不同的应用场景中充分发挥内存管理的优势,实现高效的 Java 应用。

概览

以下是对 Heap、No-Heap 和 Off-Heap 三者在常见属性、功能和应用场景方面的对比:


基础知识

Heap(堆内存)

Heap(堆内存) 是 Java 虚拟机(JVM)用来存储所有对象实例和数组的内存区域。Java 中的对象在运行时通过 new 关键字动态创建,默认会存放在堆中。堆内存分为多个区域,用于管理对象的生命周期和垃圾回收机制。常见的区域包括:


  • Eden 区:新创建的对象首先分配在 Eden 区。

  • Survivor 区:存活过一次 GC 的对象会移动到 Survivor 区。

  • Old 区:生命周期较长的对象会被晋升到 Old 区,避免频繁参与 GC。


堆内存的大小可以通过 JVM 参数 -Xms-Xmx 来手动配置,以适应不同的应用需求。


Heap 是 Java 中最常用的内存区域,适用于各种需要动态分配内存的场景。常见的使用场景包括:


  • 业务逻辑中的对象创建:在常规的 Java 应用中,业务对象、数据实体和服务类的实例化都发生在堆内存中。

  • 集合类存储:ArrayListHashMapSet 等集合类的数据元素通常存储在堆内存中。

  • 临时缓存:在短生命周期的数据缓存场景中,堆内存常用于存储临时的数据结构,方便程序快速访问。


heap 内存之所以这么常用,因为以下优点:


  1. 自动管理内存:JVM 自动管理堆内存中的对象分配和释放,开发者无需显式释放内存,避免了手动管理的复杂性。

  2. 方便对象共享:堆中的对象可以被多个线程访问和共享,适合多线程环境。

  3. 垃圾回收机制:JVM 提供了自动垃圾回收(GC)机制,自动清理不再使用的对象,减少内存泄漏风险。


虽然 heap 内存有很多的优点,但也不可避免存在下面几项缺点:


  1. GC 影响性能:当堆内存较大时,频繁的垃圾回收会导致应用程序出现性能抖动,尤其在大数据处理和高并发场景中,GC 停顿可能影响系统响应速度。

  2. 延迟问题:在高负载环境下,GC 可能会带来延迟,尤其是 Full GC 可能暂停整个应用的执行,造成卡顿。

  3. 内存碎片化:随着对象频繁分配和回收,堆内存可能出现碎片化,影响内存的高效利用。


Heap 内存在 Java 开发中占据核心地位,其便捷的对象存储方式和自动化内存管理非常适合大多数业务场景。然而,随着系统规模的扩大和并发量的增加,堆内存的垃圾回收开销可能成为性能瓶颈,需要结合 Off-Heap 等优化手段进行调整。

Off-Heap(堆外内存)

Off-Heap 是指 JVM 外部的内存,即不在 JVM 的堆区管理下的内存空间。通常由开发者手动管理,比如通过 DirectByteBufferUnsafe 类或使用第三方库(如 Netty、RocksDB)来分配和释放内存。


下面是 off-heap 的主要特性:


  • 手动管理:需要手动分配和释放内存,类似于 C/C++ 中的内存管理方式。

  • 无需 GC 管理:堆外内存不受垃圾回收器的影响,因此可以避免 GC 造成的延迟。

  • 直接内存访问:堆外内存可以通过 JNI(Java Native Interface)、NIO 等技术直接访问,因此可以避免 Java 堆内存复制,提高 I/O 性能。

  • 配置:JVM 堆外内存的大小可以通过 JVM 参数 -XX:MaxDirectMemorySize 来配置,默认是最大堆内存大小。


off-heap 内存的主要使用场景如下:


  • 大数据处理:需要处理大量数据而又不希望受 GC 影响时,常使用堆外内存。例如,缓存系统、数据流处理框架(如 Kafka、Flink)通常使用 Off-Heap 内存。

  • 网络编程:Java NIO 库中的 DirectByteBuffer 允许程序员直接在操作系统的内存中进行数据操作,避免了从堆到堆外内存的多次复制。


Off-Heap 优点:


  • 减少 GC 压力:因为 Off-Heap 内存不受垃圾回收管理,避免了频繁 GC 引发的停顿,特别适合高并发和大数据处理场景。

  • 更大的内存空间:突破 JVM 堆内存限制,可以使用更多的内存,提升应用的可扩展性,像缓存和数据库这种场景特别有用。


Off-Heap 缺点:


  • 手动管理复杂:需要开发者手动分配和释放内存,容易导致内存泄漏或管理错误。

  • 开发成本高:与直接使用 Heap 相比,增加了内存管理的复杂性,需要更多的编码工作和调试。

No-Heap(非堆内存)

No-Heap(非堆内存) 是 JVM 之外的内存区域,主要用于存储类元数据、静态变量、线程栈等信息。在 Java 8 之后,元空间(Metaspace) 取代了早期的永久代(PermGen),成为 No-Heap 的重要部分。


No-Heap(非堆内存) 的主要使用场景涉及存储 Java 虚拟机运行所需的元数据、线程栈和静态变量。它的使用场景主要体现在以下几方面:


  1. 类元数据存储:No-Heap 中的元空间(Metaspace)用来存储类的定义、方法元数据等信息。适用于大规模类加载场景,如微服务架构或动态生成大量类的框架(如 Hibernate、Spring)。

  2. 多线程应用:每个线程都有独立的栈空间存储线程调用栈信息,因此 No-Heap 对并发较高的应用至关重要,特别是在高并发场景下,如 Web 服务器和大型分布式系统。

  3. 静态变量管理:静态变量不存储在堆内存中,而是在 No-Heap 区域,适用于需要共享数据的应用场景,如全局配置、缓存类静态资源等。

  4. JVM 性能调优:对于 JVM 调优,合理配置 No-Heap 区域的元空间和栈大小可以防止内存溢出,并提高系统性能。


No-Heap 优点:


  • 避免 OOM:由于类元数据存储在 No-Heap 中,内存分配更加灵活,减少了因类加载过多导致的 OutOfMemoryError

  • 不影响 GC:No-Heap 不受 JVM 垃圾回收器的直接管理,减少了 GC 停顿对服务性能的影响。


No-Heap 缺点:


  • 内存配置需精细:元空间等区域虽然灵活,但需要手动配置内存大小,配置不当可能导致元空间溢出或栈溢出。

  • 调试复杂:相比 Heap,No-Heap 的调试和监控更加复杂,特别是在涉及多线程时。

申请内存实践

要向 Heap、Off-Heap 和 No-Heap 这三种内存区域申请内存,可以通过不同的方法来操作,以下是对应的具体代码示例:

Heap 内存申请

Heap 内存是 JVM 默认分配的内存区域,通常用于分配 Java 对象的内存。要向 Heap 申请内存,只需要创建 Java 对象即可,所有对象默认存储在堆中,由 JVM 垃圾回收器(GC)管理。


下面是个使用案例:


public class HeapMemoryExample {    public static void main(String[] args) {        // 创建对象,分配在 Heap 内存中        String[] largeArray = new String[1000000];  // 分配大量对象,占用 heap        for (int i = 0; i < largeArray.length; i++) {            largeArray[i] = "String number " + i;  // 每个对象存储在 heap 内存中        }        System.out.println("在堆中为对象申请内存");    }}
复制代码

Off-Heap 内存申请

Off-Heap 内存指的是不在 JVM 堆内存中分配的内存,通常是通过 Java NIO 的 DirectByteBuffer 或使用 Unsafe 类进行手动管理。堆外内存通常用于需要高性能的 I/O 操作或避免垃圾回收影响的场景。


使用 DirectByteBuffer 分配 Off-Heap 内存:


import java.nio.ByteBuffer;
public class OffHeapMemoryExample { public static void main(String[] args) { // 分配 10 MB 的堆外内存 ByteBuffer buffer = ByteBuffer.allocateDirect(10 * 1024 * 1024); // 向堆外内存中写数据 for (int i = 0; i < buffer.capacity(); i++) { buffer.put((byte) i); } System.out.println("在堆外内存中申请内存"); }}
复制代码


tips


  • ByteBuffer.allocateDirect() 方法分配了一块大小为 10 MB 的堆外内存。堆外内存不会受到 JVM 垃圾回收器的管理,适合需要大量数据缓冲和高性能 I/O 的场景。

  • 堆外内存需要手动管理,尤其是及时释放资源(可以通过 sun.misc.Cleaner 来释放)。


释放堆外内存方式:


ByteBuffer buffer = ByteBuffer.allocateDirect(1024);sun.misc.Cleaner cleaner = ((DirectBuffer) buffer).cleaner();cleaner.clean();  // 立即释放堆外内存
复制代码


还可以使用使用 Unsafe 类分配 Off-Heap 内存(不推荐用于生产环境):


import sun.misc.Unsafe;import java.lang.reflect.Field;
public class OffHeapMemoryWithUnsafe { private static Unsafe getUnsafe() { try { Field field = Unsafe.class.getDeclaredField("theUnsafe"); field.setAccessible(true); return (Unsafe) field.get(null); } catch (Exception e) { throw new RuntimeException(e); } }
public static void main(String[] args) { Unsafe unsafe = getUnsafe(); long memoryAddress = unsafe.allocateMemory(1024 * 1024); // 分配 1 MB 堆外内存 unsafe.setMemory(memoryAddress, 1024 * 1024, (byte) 0); // 初始化为 0 System.out.println("不安全分配的堆外内存。"); // 释放堆外内存 unsafe.freeMemory(memoryAddress); System.out.println("已释放不安全的堆外内存。"); }}
复制代码


tips


  • 使用 Unsafe 类直接分配堆外内存。这是更底层的操作,提供了对原始内存的完全控制,但需要谨慎,因为如果不及时释放,可能会导致内存泄漏。

No-Heap 内存申请

No-Heap 内存包括 Metaspace、线程栈(Thread Stack) 和 代码缓存(Code Cache)。Java 类的元数据存储在 Metaspace 中,而每个线程都有独立的栈空间。No-Heap 内存通常由 JVM 在运行时自动管理,开发者不能直接控制其分配。


在 Java 8 及以上版本,Metaspace 用于存储类的元数据。当类加载器加载一个类时,会将该类的元数据信息存放到 Metaspace 中。要增加 Metaspace 的使用,可以加载大量类。


import java.util.ArrayList;import javassist.ClassPool;
public class MetaspaceMemoryExample { public static void main(String[] args) { // 使用 Javassist 工具库动态创建类,占用 Metaspace 空间 ClassPool classPool = ClassPool.getDefault(); ArrayList<Class<?>> classes = new ArrayList<>(); try { for (int i = 0; i < 10000; i++) { // 动态创建类 Class<?> newClass = classPool.makeClass("Class" + i).toClass(); classes.add(newClass); // 将类加载到 JVM Metaspace 中 } } catch (Exception e) { e.printStackTrace(); }
System.out.println("通过加载许多类来分配元空间内存。"); }}
复制代码


tips


  • 这个例子使用 Javassist 工具库动态生成大量类。每个类的元数据会存放在 Metaspace 中,从而增加 Metaspace 的内存占用。可以通过 JVM 参数 -XX:MaxMetaspaceSize 来限制 Metaspace 的最大大小。


使用线程栈来占用 no-heap 内存:每个 Java 线程启动时,JVM 会为其分配线程栈。线程栈大小可以通过 JVM 参数 -Xss 配置。增加线程栈的使用,可以通过创建大量线程来实现。


public class ThreadStackExample {    public static void main(String[] args) {        for (int i = 0; i < 1000; i++) {            new Thread(() -> {                try {                    Thread.sleep(10000);  // 让线程等待,消耗栈内存                } catch (InterruptedException e) {                    e.printStackTrace();                }            }).start();        }
System.out.println("线程启动,堆栈内存分配。"); }}
复制代码


tips


  • 每创建一个新线程,JVM 就会为其分配一个独立的线程栈。在大量创建线程时,可以看到栈内存的增长。

发布于: 刚刚阅读数: 6
用户头像

FunTester

关注

公众号:FunTester,800篇原创,欢迎关注 2020-10-20 加入

Fun·BUG挖掘机·性能征服者·头顶锅盖·Tester

评论

发布
暂无评论
Java heap、no-heap 和 off-heap 内存基础与实践_FunTester_InfoQ写作社区