jvm—MaxDirectMemory与直接内存


MaxDirectMemory是JVM的一个运行时参数,用于设置Java程序可以直接分配的本机(非堆)内存的最大容量。直接内存通常与Java NIO包中的直接字节缓冲区(Direct ByteBuffer)关联,这类缓冲区不由JVM的垃圾收集器管理,而是直接在操作系统层面分配和释放。这意味着直接内存的使用不会直接影响到Java堆内存的大小,但它同样受限于物理机器的内存资源。当应用程序使用ByteBuffer.allocateDirect()方法分配直接内存时,这部分内存的分配和回收不遵循Java堆内存的常规机制,可能导致内存管理更加复杂。如果没有适当的限制,直接内存的过度使用可能导致系统级别的内存不足,影响整个系统的稳定性和其他运行中的进程。
通过设置-XX:MaxDirectMemorySize参数,开发者可以为直接内存的使用设定一个上限,以防止因直接内存泄漏或过度使用而导致的系统不稳定或崩溃。
例如,可以使用如下JVM启动参数来设定直接内存的最大值为1GB:
   -XX:MaxDirectMemorySize=1g 如果应用程序尝试分配的直接内存超过了这个限制,JVM将会抛出OutOfMemoryError,类似于堆内存溢出时的行为,从而强制开发者关注并处理直接内存的使用效率和限制问题。

示例程序:  MaxDirectMemory

/***
 *  donnie4w <donnie4w@gmail.com>
 *  https://github.com/donnie4w/jvmtut
 *
 *  java -XX:MaxDirectMemorySize=10M  io.donnie4w.jvm.MaxDirectMemory
 */
public class MaxDirectMemory {

    private static final int ALLOCATION_SIZE = 1024 * 1024; // 每次分配1MB
    private static final AtomicLong totalAllocated = new AtomicLong(0); // 记录总分配量
    private static final Unsafe unsafe; // 声明Unsafe实例

    static {
        try {

            // 获取Unsafe类中的一个名为"theUnsafe"的私有静态字段
            // Unsafe类提供了对JVM底层操作的能力,如直接内存访问等,通常不建议直接使用
            Field theUnsafe = Unsafe.class.getDeclaredField("theUnsafe");

            // 设置该字段为可访问,即使它是私有的
            theUnsafe.setAccessible(true);

            // 通过反射获取该字段的值,从而得到Unsafe类的一个实例
            // 这是获取Unsafe实例的标准(尽管是非官方推荐的)方式
            unsafe = (Unsafe) theUnsafe.get(null);

        } catch (Exception e) {
            // 如果在获取Unsafe实例过程中出现任何异常(如反射访问权限问题)
            // 抛出运行时异常,附带原始异常信息,以确保问题不会被静默忽略
            throw new RuntimeException("获取Unsafe实例失败", e);
        }
    }

    public static void main(String[] args) throws Exception {
        // 打印初始直接内存使用情况(这里仅为示意,实际直接内存使用量不易直接获取)
        printDirectMemoryUsage();

        try {
            // 尝试分配直到发生内存溢出
            List<ByteBuffer> list = new ArrayList<>();
            while (true) {
                ByteBuffer buffer = ByteBuffer.allocateDirect(ALLOCATION_SIZE);
                totalAllocated.addAndGet(ALLOCATION_SIZE);
                list.add(buffer);
            }
        } catch (OutOfMemoryError e) {
             System.err.printf("直接内存分配达到限制,发生OutOfMemoryError:%s%n",e);
        }
        // 再次尝试打印直接内存使用情况
        printDirectMemoryUsage();
    }

    /**
     * 打印直接内存使用的示意方法。注意:此方法并不能真正反映出直接内存的使用情况,
     * 因为直接获取直接内存使用量在JVM中通常是不可行的。这里仅用于演示。
     */
    private static void printDirectMemoryUsage() {
        // 以下代码仅为示意,并不能准确反映直接内存使用量
        long directMemoryUsed = totalAllocated.get();
        System.out.printf("当前已记录直接内存分配量: %d MB%n", directMemoryUsed / (1024 * 1024));
    }
}

说明:

  1. Unsafe
    • 类初次加载时,通过反射的方式突破Java的访问控制权限,强行获取sun.misc.Unsafe类的单例实例。
    • Unsafe类提供了很多底层操作的API,包括直接内存分配与访问等,这些操作具有很高的风险,但也为某些特殊场景下的性能优化或底层系统编程提供了可能性。
    • 由于使用Unsafe可能引入安全隐患和不稳定因素,因此这种方式仅限于教育、研究或特定的系统级优化,不建议在普通应用程序开发中使用。

执行:

java -XX:MaxDirectMemorySize=10M  io.donnie4w.jvm.MaxDirectMemory

执行结果:

当前已记录直接内存分配量: 0 MB
直接内存分配达到限制,发生OutOfMemoryError:java.lang.OutOfMemoryError: Cannot reserve 1048576 bytes of direct buffer memory (allocated: 10485760, limit: 10485760)
当前已记录直接内存分配量: 10 MB



直接内存(Direct Memory)在java中有非常重要的作用,Java NIO基于直接内存,实现直接缓冲区(Direct Buffers)

直接内存的高效性原因:

  • 独立于JVM堆内存:直接内存直接在操作系统层面分配,不占用JVM的堆空间,因此不受JVM垃圾回收(GC)的影响。这减少了因GC暂停而导致的性能波动,对于高性能、低延迟的应用特别有利。
  • 自己分配与释放管理:虽然直接内存不由JVM的垃圾收集器管理,但并不是说它完全脱离管理。在Java中,通过DirectByteBuffer使用直接内存,虽然分配和释放(通过ByteBuffer.allocateDirect()和ByteBuffer.cleaner().clean()或自动管理)需要手动控制,但这一过程相比堆内存分配更加直接,减少了JVM堆的负担。直接内存的分配和释放通常涉及到JNI调用,直接与操作系统交互,这比堆内分配更昂贵,但一旦分配完成,访问速度与效率可以非常高。
  • 减少内存拷贝:在进行I/O操作时,直接内存可以直接被操作系统用于读写操作,减少了数据从内核空间到用户空间(JVM堆)的往返拷贝。例如,网络数据可以直接从网卡读到直接内存,或直接从直接内存写入网卡,这在一定程度上模拟了零拷贝的效果,尽管真正的零拷贝概念更侧重于内核层面的优化(如DMA直接传输)。


Netty的高性能很大程度上归功于它对直接缓冲区(Direct Buffers)的优秀封装和利用。大致说明如下:

  1. 封装与简化直接缓冲区的使用:Netty通过ByteBuf接口优雅地封装了直接缓冲区和非直接缓冲区的管理,使得开发者可以方便地使用这些缓冲区,而无需直接处理复杂的直接内存分配和释放细节。
  2. 内存池技术:Netty不仅使用直接缓冲区,还实现了内存池来管理这些缓冲区,通过重用缓冲区减少分配和回收的开销,进一步提升了性能,尤其是在高并发和大数据量的场景下。
  3. 零拷贝技术:通过直接缓冲区,结合Java NIO的特性,Netty实现了数据在操作系统和网络之间的直接传输,避免了不必要的数据复制,减少了内存拷贝的次数,这是提高吞吐量和降低延迟的关键。

Netty的零拷贝技术实现,不直接等同于传统意义上的零拷贝(mmap与sendfile),而是利用Java NIO和直接内存来减少数据拷贝,间接实现类似零拷贝的效果(注意,这里只讨论netty的实现,FileChannel.transferTo接口非netty实现):

  1. 直接内存(Direct Buffer)的使用:这是Netty中最直接相关的“零拷贝”概念应用。通过使用DirectByteBuffer(直接字节缓冲区),数据可以存储在直接内存中,减少JVM堆内存到内核空间的往返,间接减少数据复制。当通过NIO的通道(如SocketChannel)进行读写操作时,直接内存可以减少数据在用户空间与内核空间之间的拷贝次数。通俗点说,当涉及IO时,无论写文件还是发网络数据,先把数据写入直接内存中,再调用IO接口发送直接内存中的数据,来减少复制。
  2. 组合与分散(Composite Buffers与Gathering/Scattering):虽然不直接等同于传统意义上的零拷贝,但Netty提供的CompositeByteBuf(组合缓冲区)和分散读/聚集写的特性,能够在逻辑上减少数据重组和拆分时的内存操作,间接提升数据处理效率。这有助于减少数据在用户空间内部的复制。
    • 组合缓冲区(CompositeByteBuf)
      • CompositeByteBuf是Netty中一种特殊的缓冲区,它能够将多个ByteBuf实例组合在一起,对外表现得如同一个单一的连续缓冲区。这意味着你可以将多个小的缓冲区合并为一个逻辑上的大缓冲区,而不需要实际合并数据,从而减少了数据复制和内存分配的开销。这对于需要处理多个小消息或片段化数据的场景非常有用。例如,当你从网络接收一系列小的TCP包,但希望作为一个整体处理时,使用CompositeByteBuf可以简化操作。
      • CompositeBufferExample
public class CompositeBufferExample {
    public static void main(String[] args) {
        // 创建两个ByteBuf
        ByteBuf buf1 = Unpooled.buffer(4);
        buf1.writeBytes(new byte[]{1, 2, 3, 4});

        ByteBuf buf2 = Unpooled.buffer(4);
        buf2.writeBytes(new byte[]{5, 6, 7, 8});

        // 创建CompositeByteBuf
        CompositeByteBuf composite = Unpooled.compositeBuffer();

        // 将两个ByteBuf添加到组合缓冲区
        composite.addComponents(true, buf1, buf2);

        // 打印组合缓冲区的数据
        printBuffer(composite);

        // 清理资源
        composite.release();
    }

    private static void printBuffer(ByteBuf buffer) {
        StringBuilder sb = new StringBuilder();
        for (int i = buffer.readerIndex(); i < buffer.writerIndex(); i++) {
            sb.append(buffer.getByte(i)).append(",");
        }
        System.out.println("Combined buffer as bytes: [" + sb.deleteCharAt(sb.length() - 1) + "]");
    }
}

    • 分散读(Scattering Reads)
      • 分散读是NIO中的一个特性,允许你将数据读取到多个缓冲区中。在Netty中,你可以利用通道(如SocketChannel)的read(ByteBuffer[] dsts)方法,一次性从通道中读取数据并分散到多个缓冲区中。这样,即使数据跨多个缓冲区,也可以一次性读取,避免了逐个缓冲区读取的循环操作,提高了效率。分散读适用于接收数据结构复杂或大小不一的场景,可以按需将数据分配到不同缓冲区。
      • ScatteringReadExample
 public class ScatteringReadExample {
    public static void main(String[] args) throws Exception {
        RandomAccessFile file = new RandomAccessFile("./jvmtut/test1.txt", "r");
        FileChannel channel = file.getChannel();

        ByteBuffer body1 = ByteBuffer.allocate(10);
        ByteBuffer body2 = ByteBuffer.allocate(20);

        ByteBuffer[] buffers = {body1, body2};

        long bytesRead = channel.read(buffers);
        System.out.println("Bytes read: " + bytesRead);

        for (ByteBuffer buffer : buffers) {
            buffer.flip();
            while (buffer.hasRemaining()) {
                System.out.print((char) buffer.get());
            }
        }

        // 清理资源
        channel.close();
        file.close();
    }
}

    • 聚集写(Gathering Writes)
      • 与分散读相对应,聚集写允许你将多个缓冲区的数据一次性写入到通道中。使用通道的write(ByteBuffer[] srcs)方法,可以将数据从多个源缓冲区聚集起来写入到目标通道,减少了逐个缓冲区写入的开销。这对于需要构造复杂消息或聚合多个数据片段为单个数据包的场景特别有用,例如,构建HTTP响应头和响应体时。
      • GatheringWriteExample
 public class GatheringWriteExample {
    public static void main(String[] args) throws Exception {
        RandomAccessFile fromFile = new RandomAccessFile("./jvmtut/test1.txt", "r");
        FileChannel fromChannel = fromFile.getChannel();

        RandomAccessFile toFile = new RandomAccessFile("./jvmtut/test2.txt", "rw");
        FileChannel toChannel = toFile.getChannel();

        ByteBuffer body1 = ByteBuffer.allocate(10);
        ByteBuffer body2 = ByteBuffer.allocate(20);

        ByteBuffer[] buffers = {body1, body2};
        long bytesRead = fromChannel.read(buffers);
        System.out.println("Bytes read: " + bytesRead);
        for (ByteBuffer buffer : buffers) {
            buffer.flip();
            while (buffer.hasRemaining()) {
                System.out.print((char) buffer.get());
            }
            buffer.flip(); //将position(当前写入位置或读取位置)设置为0,从缓冲区的开始位置读取所有之前写入的数据
        }

        toChannel.write(buffers);
        // 清理资源
        fromChannel.close();
        toChannel.close();
        fromFile.close();
        toFile.close();
    }
}