Java NIO内存管理与零拷贝深度剖析
Java传统NIO内存管理机制剖析前言Java传统NIO内存管理机制剖析一、 NIO 内存管理DirectByteBuffer 剖析1. 为什么需要堆外内存2. DirectByteBuffer 对象的创建Java 层源码3. 底层 C 内存分配JVM 源码4. 堆外内存的回收机制Cleaner 与虚引用二、 NIO 零拷贝原理剖析1. 机制一MappedByteBuffer 与 mmapJava 源码入口Native 底层实现系统级零拷贝原理解析mmap2. 机制二FileChannel.transferTo() 与 sendfileJava 源码入口Native 底层实现系统级零拷贝原理解析sendfile三、 架构总结前言本文旨在记录近期研读Java源码的学习心得与疑难问题。由于个人理解水平有限文中内容难免存在疏漏恳请读者不吝指正。Java传统NIO内存管理机制剖析作为软件开发程师深入理解 Java NIONew I/O的内存管理与零拷贝Zero-Copy原理需要将目光从 Java 堆内延伸到 JVM 源码OpenJDK 8u、C 运行时库以及 Linux 内核的系统调用。以下结合 OpenJDK 8u 源码逐层剖析DirectByteBuffer 内存管理与MappedByteBuffer /FileChannel.transferTo零拷贝的底层实现。一、 NIO 内存管理DirectByteBuffer 剖析NIO 引入了DirectByteBuffer直接缓冲区允许 Java 程序直接访问堆外内存Off-Heap Memory。1. 为什么需要堆外内存避免二次拷贝如果是传统的堆内内存HeapByteBuffer当进行 I/O 操作如write系统调用时JVM 必须先将堆内数据拷贝到堆外的一块临时缓冲区C 堆然后再传给操作系统。因为 JVM 存在 GC垃圾回收如果直接把堆内内存地址传给内核一旦发生 GC 导致内存地址移动CompactI/O 数据就会损坏。减少 GC 压力堆外内存不受 JVM 堆大小限制也不直接参与垃圾回收器的常规扫描。2. DirectByteBuffer 对象的创建Java 层源码当我们调用ByteBuffer.allocateDirect(size)时实际上初始化的是DirectByteBuffer。// openjdk/jdk/src/share/classes/java/nio/DirectByteBuffer.javaDirectByteBuffer(intcap){// package-privatesuper(-1,0,cap,cap);// 1. 是否需要按页对齐默认不需要booleanpaVM.isDirectMemoryPageAligned();intpsBits.pageSize();longsizeMath.max(1L,(long)cap(pa?ps:0));// 2. 预分配内存计数如果超出了 -XX:MaxDirectMemorySize 则触发 GC 或抛出 OOMBits.reserveMemory(size,cap);longbase0;try{// 3. 调用底层 Unsafe 分配堆外内存返回 64 位内存绝对地址baseunsafe.allocateMemory(size);}catch(OutOfMemoryErrorx){Bits.unreserveMemory(size,cap);throwx;}// 4. 初始化内存空间为 0unsafe.setMemory(base,size,(byte)0);// 5. 计算实际使用的地址if(pa(base%ps!0)){addressbaseps-(base(ps-1));}else{addressbase;}// 6. 注册 Cleaner用于堆外内存的虚引用异步回收cleanerCleaner.create(this,newDeallocator(base,size,cap));attnull;}3. 底层 C 内存分配JVM 源码unsafe.allocateMemory(size)映射到 JVM 源码中的unsafe.cpp// openjdk/hotspot/src/share/vm/prims/unsafe.cppUNSAFE_ENTRY(jlong,Unsafe_AllocateMemory(JNIEnv*env,jobject unsafe,jlong size))UnsafeWrapper(Unsafe_AllocateMemory);size_t sz(size_t)size;if(sz!size)return0;// 调用 standard C 的 os::malloc 分配堆外内存void*xos::malloc(sz,mtInternal);if(xNULL){THROW_0(vmSymbols::java_lang_OutOfMemoryError());}// 返回内存指针的 64 位长整型值给 Java 层returnaddr_to_java(x);UNSAFE_END系统级原理os::malloc在 Linux 环境下底层调用的是 glibc 的malloc()。根据申请空间的大小内核会选择使用brk()小内存移动堆顶指针或mmap()大内存匿名私有映射系统调用来分配虚拟内存。4. 堆外内存的回收机制Cleaner 与虚引用DirectByteBuffer本身是一个普通的 Java 对象占用堆内极小的空间仅保存address、capacity等属性。如果长寿代对象持有堆外内存极易导致堆内空间充足但堆外内存被耗尽OOM。OpenJDK 使用sun.misc.Cleaner一种基于虚引用PhantomReference的机制来追踪DirectByteBuffer的生命周期// openjdk/jdk/src/share/classes/java/nio/DirectByteBuffer.javaprivatestaticclassDeallocatorimplementsRunnable{privatestaticunsafeUnsafe.getUnsafe();privatelongaddress;privatelongsize;privateintcapacity;privateDeallocator(longaddress,longsize,intcapacity){this.addressaddress;this.sizesize;this.capacitycapacity;}publicvoidrun(){if(address0){return;}// 1. 释放堆外内存unsafe.freeMemory(address);address0;// 2. 更新 Bits 中记录的直接内存使用量Bits.unreserveMemory(size,capacity);}}触发时机当DirectByteBuffer对象在 Java 堆中不再被强引用并被垃圾回收器GC标记为可回收时JVM 会将其加入到ReferenceQueue。Java 的ReferenceHandler线程会周期性地执行Cleaner.clean()最终调用Deallocator.run()内的unsafe.freeMemory(address)释放底层的 C 堆内存。二、 NIO 零拷贝原理剖析传统 I/O如InputStream.readOutputStream.write涉及4 次上下文切换和4 次数据拷贝读网卡/磁盘→ \rightarrow→内核读缓冲区DMA 拷贝→ \rightarrow→用户空间缓冲区CPU 拷贝。写用户空间缓冲区→ \rightarrow→内核写Socket缓冲区CPU 拷贝→ \rightarrow→网卡/磁盘DMA 拷贝。NIO 主要通过两种方式实现“零拷贝”通常指减少或消除 CPU 拷贝FileChannel.map()基于mmap系统调用FileChannel.transferTo()基于sendfile系统调用1. 机制一MappedByteBuffer 与 mmapFileChannel.map()将文件的一部分直接映射到进程的虚拟内存空间中。Java 源码入口// openjdk/jdk/src/share/classes/sun/nio/ch/FileChannelImpl.javapublicMappedByteBuffermap(MapModemode,longposition,longsize)throwsIOException{// ... 权限与边界检查 ...intpagePosition(int)(position%allocationGranularity);longmapPositionposition-pagePosition;longmapSizesizepagePosition;try{// 调用 native 方法进行内存映射addrmap0(imode,mapPosition,mapSize);}catch(IOExceptionx){// ... 异常处理 ...}// 返回 DirectByteBuffer 的子类 MappedByteBufferintisize(int)size;UnmapperumnewUnmapper(addr,mapSize,isize,fd);returnUtil.newMappedByteBuffer(isize,addrpagePosition,fd,um);}Native 底层实现map0对应FileChannelImpl.c// openjdk/jdk/src/solaris/native/sun/nio/ch/FileChannelImpl.cJNIEXPORT jlong JNICALLJava_sun_nio_ch_FileChannelImpl_map0(JNIEnv*env,jobject this,jint imode,jlong jpos,jlong size){// ... 获取文件描述符 fd ...void*mapAddress0;// 调用 Linux 标准的 mmap64 系统调用mapAddressmmap64(0,// 由内核决定映射区的起始虚拟地址size,// 映射空间的长度protections,// 映射区的读写权限 (PROT_READ / PROT_WRITE)flags,// MAP_SHARED (共享映射修改会同步到磁盘)fd,// 映射文件的描述符jpos// 文件偏移量);if(mapAddressMAP_FAILED){JNU_ThrowIOExceptionWithLastError(env,Map failed);return0;}return((jlong)mapAddress);}系统级零拷贝原理解析mmapmmap建立了用户虚拟空间到内核文件页缓存Page Cache的直接映射。拷贝次数减半当用户进程读写这块内存时通过缺页中断Page Fault将磁盘数据通过 DMA 拷贝到 Page Cache。由于用户空间和内核空间共享这段物理内存不需要进行从内核态到用户态的 CPU 拷贝。代价与限制数据仍然需要经过 Page Cache写出时需要 DMA 拷贝到网卡。适合大文件的随机读写。2. 机制二FileChannel.transferTo() 与 sendfile当需要将文件发送到 Socket 网卡时transferTo可以实现真正意义上的零CPU拷贝。Java 源码入口// openjdk/jdk/src/share/classes/sun/nio/ch/FileChannelImpl.javapubliclongtransferTo(longposition,longcount,WritableByteChanneltarget)throwsIOException{// ... 检查通道是否开启 ...// 1. 尝试使用 Linux 特有的 sendfile 机制最高效longn;if((ntransferToDirectly(position,icount,target))0)returnn;// 2. 如果 target 不是普通 Socket 通道退化为使用 MappedByteBufferif((ntransferToTrustedChannel(position,icount,target))0)returnn;// 3. 最坏情况普通循环读写拷贝returntransferToArbitraryChannel(position,icount,target);}我们重点看transferToDirectly如何触发系统调用// openjdk/jdk/src/share/classes/sun/nio/ch/FileChannelImpl.javaprivatelongtransferToDirectly(longposition,inticount,WritableByteChanneltarget)throwsIOException{// 确保 target 是一个来自 SocketChannel 的文件描述符FileDescriptortargetFD((SelChImpl)target).getFD();intthisFDValIOUtil.fdVal(fd);inttargetFDValIOUtil.fdVal(targetFD);longn0;try{// 调用 Native 的 transferTo0ntransferTo0(fd,position,icount,targetFDVal);}finally{// ... 跟踪统计 ...}returnn;}Native 底层实现transferTo0映射到 Linux 平台下的FileChannelImpl.c// openjdk/jdk/src/solaris/native/sun/nio/ch/FileChannelImpl.cJNIEXPORT jlong JNICALLJava_sun_nio_ch_FileChannelImpl_transferTo0(JNIEnv*env,jobject this,jobject srcFDOBuffer,jlong position,jlong count,jint dstFD){// ... 获取源文件描述符 srcFD ...off64_toffset(off64_t)position;// 直接调用 Linux 内核的 sendfile64 系统调用ssize_tresultsendfile64(dstFD,// 目标描述符通常是 Socket fdsrcFD,// 源描述符文件 fdoffset,// 文件读取的偏移量(size_t)count// 传输的字节数);if(result0){// 处理 EAGAIN 或 EINTR 信号if(errnoEAGAIN)returnIOS_UNAVAILABLE;if(errnoEINTR)returnIOS_INTERRUPTED;// 如果系统不支持 sendfile例如不支持该文件类型返回 UNSUPPORTED 让 Java 层退化处理if(errnoENOSYS||errnoEINVAL)returnIOS_UNSUPPORTED_CASE;JNU_ThrowIOExceptionWithLastError(env,Transfer failed);return0;}returnresult;}系统级零拷贝原理解析sendfilesendfile系统调用将上下文切换从 4 次减少到了 2 次。数据流转过程如下Linux 内核 2.1 版本标准 sendfile磁盘→ DMA \xrightarrow{\text{DMA}}DMA内核 Page Cache。CPU 拷贝内核 Page Cache→ CPU \xrightarrow{\text{CPU}}CPUSocket 缓冲区。Socket 缓冲区→ DMA \xrightarrow{\text{DMA}}DMA网卡。总计2 次上下文切换3 次拷贝2次DMA1次CPU。Linux 内核 2.4 版本带 SG-DMA 技术的 sendfile磁盘→ DMA \xrightarrow{\text{DMA}}DMA内核 Page Cache。消除 CPU 拷贝不再拷贝整个数据到 Socket 缓冲区而是仅仅把内存地址和偏移量的描述符Descriptor复制到 Socket 缓冲区。网卡的 DMA 引擎直接通过Scatter-Gather (分散-聚集)功能根据描述符的指引直接去 Page Cache 读取数据并发送。总计2 次上下文切换2 次拷贝均为 DMA 拷贝0 次 CPU 拷贝。三、 架构总结机制OpenJDK 8u 底层实现Linux 核心系统调用CPU 拷贝次数主要解决痛点DirectByteBufferunsafe.allocateMemorymalloc/brk/mmapI/O 时 0 次 (不需复制到C堆)消除 JVM 堆到 C 堆的二次拷贝减轻 GC 压力MappedByteBufferFileChannelImpl.map0mmap640 次进程与内核共享内存大文件高频随机读写减少内核态到用户态的拷贝FileChannel.transferToFileChannelImpl.transferTo0sendfile64(带 SG-DMA)完全 0 次消除网络文件传输如 Kafka、Tomcat中的所有 CPU 拷贝