JVM之直接内存

2,125 阅读6分钟

本文以直接内存为引子,会涉及到NIO,零拷贝,OS等内容,具体相关内容读者感兴趣的话可自行查阅资料。同样欢迎各位大佬在评论区指正。

直接内存概述

《深入理解Java虚拟机》:直接内存(Direct Memory 并不是虚拟机运行时数据区的一部分,也不是《Java虚拟机规范》中定义的内存区域。

  • 直接内存是由本地内存分配的,因此直接内存受本机总内存大小的限制。
  • 堆外内存:把内存对象分配在Java虚拟机堆以外的内存
  • 堆外内存直接受操作系统管理(而不是虚拟机),这样做的结果就是能够在一定程度上减少垃圾回收(GC)对应用程序造成的影响。
  • 在一些文章中将直接内存等价于堆外内存。

直接内存&堆外内存&本地内存的关系【仅供参考】

image-20230202153619992.png

操作直接内存

堆外内存优势在 IO 操作上,对于网络 IO,使用 Socket 发送数据时,能够节省堆内内存到堆外内存的数据拷贝

  • 在 NIO[jdk1.4] 中引入了一种基于通道和缓冲的IO方式。,它可以使用本地函数库直接分配堆外内存,然后通过一个存储在Java堆里面的DirectByteBuffer对象作为这块内存的引用进行操作,如下图所示【图片来源】:
  • 直接内存的回收需要做进一步的手动清理,一般配合虚引用完成。

image-20230202112722223.png

1)分配直接内存

// java.nio.ByteBuffer
public static ByteBuffer allocateDirect(int capacity) {
    return new DirectByteBuffer(capacity);
}

DirectByteBuffer底层通过Unsafe去实现内存分配的,对堆内存的分配、读写、回收都做了封装。

DirectByteBuffer(int cap) {                   // package-private
    super(-1, 0, cap, cap);
    boolean pa = VM.isDirectMemoryPageAligned();
    int ps = Bits.pageSize();
    long size = Math.max(1L, (long)cap + (pa ? ps : 0));
    //预分配内存,确认是否有足够的堆外内存,没有的话会触发GC回收一部分
    Bits.reserveMemory(size, cap);

    long base = 0;
    try {
        //分配堆外内存
        base = unsafe.allocateMemory(size);
    } catch (OutOfMemoryError x) {
        Bits.unreserveMemory(size, cap);
        throw x;
    }
    // 将刚分配的内存空间初始化为0
    unsafe.setMemory(base, size, (byte) 0);
    //为address赋值,address就是分配内存的起始地址
    if (pa && (base % ps != 0)) {
        // Round up to page boundary
        address = base + ps - (base & (ps - 1));
    } else {
        address = base;
    }
    //创建一个Cleaner
    cleaner = Cleaner.create(this, new Deallocator(base, size, cap));
    att = null;
}
  • allocateMemory是一个native方法,并不是jvm能够控制的内存区域,通常称为堆外内存,一般是通过c/c++分配的内存(malloc)。
  • 上图中提到的HeapByteBuffer,数据的分配存储都在java堆上。
  • 对于DirectByteBuffer所生成的ByteBuffer对象,对象存在Java堆上,而申请的堆外空间位于OS中的堆内存中,总的来说,就是通过一个存储在Java堆里面的DirectByteBuffer对象作为这块内存的引用进行操作。
  • 那么为了操作堆外内存,在java堆上的对象有一个堆外内存的引用(在DirectByteBuffer的父类Buffer中有一个全局变量address,这个就是表示堆外内存所分配对象的地址)

当需要和io设备打交道的时候,HeapByteBuffer会将jvm堆上所维护的byte[]拷贝至堆外内存,然后堆外内存直接和io设备交互。如果直接使用DirectByteBuffer,那么就不需要拷贝这一步,将大大提升io的效率,这种称之为零拷贝(zero-copy

2)前置知识:虚引用

虚引用

  • 顾名思义,就是形同虚设。与其他几种引用都不同,虚引用并不会决定对象的生命周期。如果一个对象仅持有虚引用,那么他就和没有任何引用一样,在任何时候都可能被垃圾回收器回收。

应用场景

  • 虚引用主要用来跟踪对象被垃圾回收器回收的活动。程序可以通过判断引用队列中是否已经加入了虚引用,来了解被引用的对象是否将要进行垃圾回收。如果程序发现某个虚引用已经加入到引用队列,那么就可以在所引用的对象的内存被回收之前采取必要的行动(调用Cleaner的clean方法)。

  • 虚引用必须和引用队列(ReferenceQueue)联合使用。当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用加入到与之关联的引用队列中。

    String str = new String("abc");
    ReferenceQueue queue = new ReferenceQueue();
    // 创建虚引用,要求必须与一个引用队列关联
    PhantomReference pr = new PhantomReference(str, queue);
    

    当你想取出其中的值时(get),得到的却总是null。

  • 在hotspot的jvm中,有一个叫做Cleaner的类,其实就是虚引用典型的应用。可以看到Cleaner是直接简单粗暴的继承了PhantomReference,所以它本质上就是一个虚引用,只不过多了一些便捷的操作。

    public class Cleaner extends PhantomReference<Object>
    

DirectByteBuffer中用到了Cleaner中的create方法

create方法

cleaner = Cleaner.create(this, new Deallocator(base, size, cap)); 
-----------------------------------------------------------------
public static Cleaner create(Object var0, Runnable var1) {
    return var1 == null ? null : add(new Cleaner(var0, var1));
}

clean方法

public void clean() {
    if (remove(this)) {
        try {
            this.thunk.run();
        } catch (final Throwable var2) {
            AccessController.doPrivileged(new PrivilegedAction<Void>() {
                public Void run() {
                    if (System.err != null) {
                        (new Error("Cleaner terminated abnormally", var2)).printStackTrace();
                    }

                    System.exit(1);
                    return null;
                }
            });
        }
    }
}

3)回收直接内存

自动回收

  • GC 时会扫描 DirectByteBuffer 对象是否有有效引用指向该对象,如没有,在回收 DirectByteBuffer 对象的同时且会回收其占用的堆外内存

  • GC过程中如果发现某个对象除了只有虚引用引用它之外,并没有其他的地方引用它了,那将会把这个引用放到java.lang.ref.Reference.pending队列里,ReferenceHandler这个守护线程会处理pending队列里,执行一些后置处理,这里是调用Cleaner的clean方法

  • DirectByteBuffer构造方法内创建了一个Cleaner对象, Cleaner继承了PhantomReference,其referent为DirectByteBuffer,也是通过Cleaner调用unsafe.freeMemory(address)来释放直接内存

    public void run() { 
      if (address == 0) { 
        // Paranoia 
        return; 
      } 
      unsafe.freeMemory(address); 
      address = 0; 
      Bits.unreserveMemory(size, capacity); 
    } 
    

手动回收

  • DirectByteBuffer 实现了 DirectBuffer 接口,这个接口有 cleaner 方法可以获取 cleaner 对象。
public static void clean(final ByteBuffer byteBuffer) {  
  if (byteBuffer.isDirect()) {  
      ((DirectBuffer)byteBuffer).cleaner().clean();  
  }  
}

4)代码示例

public static void main(String[] args) throws InterruptedException{
    //分配1G直接缓存
    ByteBuffer buffer = ByteBuffer.allocateDirect(1024*1024*1024);

    TimeUnit.SECONDS.sleep(10);

    //清除直接缓存
    ((DirectBuffer)buffer).cleaner().clean();

    TimeUnit.SECONDS.sleep(10);

    System.out.println("测试完毕");
}

image-20230202162825593.png

5)OOM

  • -XX:MaxDirectMemorySize来指定最大的堆外内存大小。如果不指定,默认与堆的最大值-Xmx参数保持一致
  • 服务器管理员在配置虚拟机参数时,会根据实际内存设置-Xmx等参数信息,但经常忽略直接内存,使得各个内存区域总和大于物理内存限制,从而导致动态扩展时出现OutOfMemoryError异常。

image-20230202172650074.png