(三)JVM-直接内存

182 阅读5分钟

一、定义

Direct Memory 直接内存,是属于系统内存的。

  • 常见于NIO操作时,用于数据缓冲区。
  • 分配回收成本较高,但读写性能高。
  • 不受JVM内存回收管理。

二、使用直接内存的优点

下面这段代码展示了不使用直接内存(io())与使用直接内存(directBuffer())在耗时上的区别。

public class DirectMemoryDemo {

    static final String  FROM = "D:\\BaiduNetdiskDownload\\AutoCAD 2020\\AutoCAD_2020_Simplified_Chinese_Win_64bit_dlm.sfx";
    static final String TO = "D:\\documents";

    static final int _1MB = 1024 * 1024;

    public static void main(String[] args) {
        //io 用时 1535.586957
        io();
        //directBuffer 用时 479.295165
        directBuffer();
    }

    private static void io() {
        //获取正在运行的Java虚拟机的高分辨率时间源的当前值,单位为纳秒。1000000纳秒 = 1000微秒 = 1毫秒
        long start = System.nanoTime();
        try (FileInputStream from = new FileInputStream(FROM); //FileInputStream从文件系统中的文件中获取输入字节。
             FileOutputStream to = new FileOutputStream(TO);  //FileOutputStream是用于将字节数据写入File 或 FileDescriptor。

        ) {
            //初始化字节数组,大小是1M
            byte[] buf = new byte[_1MB];
            while (true) {
                // 将from输入流中len个长度字节的数据读取到字节数组中。
                // 此方法会阻塞,直到有可用的输入为止。
                int len = from.read(buf);
                // -1表示已到达文件末尾而没有更多数据。
                if (len == -1) {
                    break;
                }
                //将从偏移量off开始的指定字节数组中的len个字节写入此文件输出流。
                to.write(buf, 0 , len);
            }

            long end = System.nanoTime();
            System.out.println("io 用时:" + (end - start) / 1000_000.0);
        } catch (FileNotFoundException e) {
            throw new RuntimeException(e);
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }

    private static void directBuffer() {
        long start = System.nanoTime();
        try (FileChannel from = new FileInputStream(FROM).getChannel();
            FileChannel to = new FileOutputStream(TO).getChannel();
        ) {
            //由操作系统分配大小为1M的直接内存区域
            ByteBuffer bb = ByteBuffer.allocateDirect(_1MB);
            while (true) {
                int len = from.read(bb);
                if (len == -1) {
                    break;
                }
                // 翻转缓冲区。
                // 在一系列 channel-read 或 put 操作之后,调用此方法为一系列channel-write 或 relative get 操作做准备。
                bb.flip();
                to.write(bb);
                bb.clear();
            }

            long end = System.nanoTime();
            System.out.println("directBuffer 用时:" + (end - start) / 1000_000.0);
        } catch (FileNotFoundException e) {
            throw new RuntimeException(e);
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }
}

经过测试发现,io()用时1535.586957s,directBuffer()用时479.295165s。可以看到,使用直接内存可以使接口性能提高好几倍。

为什么使用直接内存后,接口响应会变得这么快呢?

结合下面的两张图给大家分析一下。

1693964352023(1).jpg

第一张图展示了不使用直接内存时文件的读写过程。

  1. JDK本身不具备磁盘读写的能力,需要调用操作系统提供的native方法。当需要调用操作系统的Native方法时,CPU的运行状态由用户态切换到内核态。
  2. CPU切换到内核态后,开始准备读取磁盘文件。首先将文件内容读取到在系统内存中开辟的系统缓存区中,然后再从系统缓存区中读取数据存入到Java堆内存的Java缓冲区中。
  3. CPU再由内核态切换到用户态后,Java程序就可以读取Java缓冲区中的数据了。

1693965882314.jpg

第二张图展示了使用直接内存时文件的读写过程。

  1. 由操作系统分配的直接内存区域,系统接口和Java程序都可以直接访问。
  2. CPU切换到内核态后,由CPU函数开始读取磁盘文件存放到直接内存区域。
  3. CPU再由内核态切换到用户态后,Java程序就可以读取直接内存区域中的数据了。

总结

使用直接内存时,数据只需要读取一次就可以,与不使用直接内存相比,少了一次读取过程,占用空间少,而且读写性能高。

三、直接内存-内存溢出

内存溢出错误:java.lang.OutOfMemoryError: Direct buffer memory.

四、直接内存-分配及回收原理

既然直接内存不受JVM内存回收管理,那么直接内存如何回收呢?

   DirectByteBuffer(int cap) {
        // 此处省略代码

        // base:直接内存的首地址
        long base = 0;
        //分配直接内存
        base = UNSAFE.allocateMemory(size);
        UNSAFE.setMemory(base, size, (byte) 0);
        
        // 此处省略代码
        
        // Cleaner 继承了PhantomReference(虚引用),虚引用对象的特点是:当它关联的对象(DirectByteBuffer)被回收时,会触发Cleaner对象的clean()方法。
        // Deallocator 实现了Runnable,
        cleaner = Cleaner.create(this, new DirectByteBuffer.Deallocator(base, size, cap));
    }

    // 实现了Runnable接口,run()中释放内存
    private static class Deallocator implements Runnable
    {
        /**
         * 释放直接内存
         */
        public void run() {
            // address:直接内存的起始地址
            UNSAFE.freeMemory(address);
            address = 0;
            // size:直接内存的大小
            // capacity:初始容量
            Bits.unreserveMemory(size, capacity);
        }

    }

    /**
     * 调用Deallocator.run()方法,释放直接内存。
     * 此方法不在主线程中执行,后台有一个ReferenceHandler线程,专门监控虚引用对象关联的对象,一旦关联对象被回收,就会调用此方法。
     */
    public void clean() {
        // thunk就是Deallocator
        thunk.run();
    }

上述代码展示了,底层如何分配以及释放直接内存。

  1. 调用Unsafe.allocateMemory方法、setMemory方法分配直接内存。
  2. ByteBuffer的实现类内部,使用了Cleaner对象,它是一个虚引用对象,其特点是:当它关联的对象(DirectByteBuffer)被回收时,后台的ReferenceHandler线程(守护线程)会触发Cleaner对象的clean()方法,clean()方法中调用了Deallocator的run()。
  3. Deallocator实现了Runnable接口,在run()中实现了释放直接内存,调用Unsafe.freeMemory方法。

五、禁用显式垃圾回收

当在代码中执行System.gc()时,就会执行一次Full GC,这个回收会阻塞线程的执行并且比较耗时。

为了使System.gc()无效执行,可以设置禁用显式垃圾回收。

-XX:+DisableExplicitGC //禁用显式垃圾回收

禁用显式垃圾回收后,如果直接内存一直没有被回收,可以显式调用unsafe.freeMemory方法。