第十三篇:直接内存与零拷贝——NIO性能优化的底层真相
前言恭喜你完成了GC系列的学习现在你已经掌握了JVM内存管理和垃圾回收的核心知识。但JVM的内存世界还有一个重要的组成部分我们还没有深入探讨——直接内存。为什么Netty性能那么高为什么NIO比传统IO快零拷贝到底是什么这些问题的答案都指向同一个核心直接内存。今天我们就来深入剖析直接内存的本质、零拷贝技术的原理以及它们背后的权衡艺术。读完本文你将能回答直接内存和堆内存有什么区别为什么堆内存不能实现零拷贝零拷贝技术的原理是什么直接内存是如何被回收的这是JVM系列的性能优化专题也是理解现代Java高性能编程的关键。一、直接内存的本质1.1 什么是直接内存直接内存是本地内存的一部分由JVM提供了一套APIjava.nio包下的ByteBuffer.allocateDirect()来操作。// 堆内存JVM管理受GC控制byte[]heapBuffernewbyte[1024*1024];// 1MB在堆中// 直接内存操作系统管理不受GC直接控制ByteBufferdirectBufferByteBuffer.allocateDirect(1024*1024);// 1MB在本地内存1.2 两种内存的对比对比项堆内存本地内存管理方JVMGC操作系统分配速度快只在堆内划一块慢系统调用地址是否可变会变GC复制算法固定回收方式GC自动回收需手动或依赖Cleaner适用场景绝大多数Java对象元空间、直接内存、线程栈1.3 JVM与系统的关系关键认知JVM就是一个运行在操作系统上的普通进程尽管它很特殊。操作系统内存布局 ┌─────────────────────────────────────────────────────────────────────┐ │ 物理内存 │ ├─────────────────────────────────────────────────────────────────────┤ │ ┌─────────────────────────────────────────────────────────────┐ │ │ │ JVM进程 │ │ │ │ ┌─────────────────────────────────────────────────────┐ │ │ │ │ │ 堆内存GC管理 │ │ │ │ │ │ Eden、Survivor、Old │ │ │ │ │ └─────────────────────────────────────────────────────┘ │ │ │ │ ┌─────────────────────────────────────────────────────┐ │ │ │ │ │ 本地内存OS管理 │ │ │ │ │ │ 元空间、直接内存、线程栈、JNI、Code Cache │ │ │ │ │ └─────────────────────────────────────────────────────┘ │ │ │ └─────────────────────────────────────────────────────────────┘ │ │ ┌─────────────────────────────────────────────────────────────┐ │ │ │ 其他进程 │ │ │ └─────────────────────────────────────────────────────────────┘ │ └─────────────────────────────────────────────────────────────────────┘堆、栈、方法区等都是这个进程向操作系统申请来的内存空间的逻辑划分。二、为什么需要直接内存2.1 传统IO的两次拷贝先看传统IOFileInputStreamfisnewFileInputStream(data.txt);byte[]buffernewbyte[1024];fis.read(buffer);// 这一行背后发生了什么传统IO的完整流程硬盘 → 内核空间缓冲区 → JVM堆内存 [第一次拷贝] [第二次拷贝]详细步骤用户态 → 内核态切换JVM调用操作系统的read()接口DMA拷贝第一次硬盘 → 内核空间缓冲区DMADirect Memory Access是硬件技术不占用CPUCPU拷贝第二次内核缓冲区 → JVM堆内存CPU参与把数据从内核空间复制到用户空间传统IO流程图 ┌─────────────────────────────────────────────────────────────────────┐ │ │ │ 硬盘 内核空间 用户空间 │ │ │ │ ┌─────┐ ┌─────────────┐ ┌─────────────┐ │ │ │ │ DMA拷贝 │ 内核缓冲区 │ CPU拷贝 │ JVM堆内存 │ │ │ │ 数 │ ──────────→ │ │ ──────→ │ │ │ │ │ 据 │ │ │ │ byte[] │ │ │ └─────┘ └─────────────┘ └─────────────┘ │ │ │ │ ① ② │ │ (不占用CPU) (占用CPU) │ │ │ └─────────────────────────────────────────────────────────────────────┘为什么要有第二次拷贝用户程序不能直接访问内核空间安全性必须把数据从内核“搬”到用户空间程序才能用2.2 直接内存如何实现零拷贝ByteBufferdirectBufferByteBuffer.allocateDirect(1024);FileChannelchannelnewFileInputStream(data.txt).getChannel();channel.read(directBuffer);// 零拷贝直接内存的流程硬盘 → 直接内存用户空间 [唯一一次拷贝]直接内存流程图 ┌─────────────────────────────────────────────────────────────────────┐ │ │ │ 硬盘 内核空间 用户空间 │ │ │ │ ┌─────┐ ┌─────────────┐ ┌─────────────┐ │ │ │ │ DMA拷贝 │ 内核缓冲区 │ │ 直接内存 │ │ │ │ 数 │ ──────────→ │ │ ──X──→ │ │ │ │ │ 据 │ │ │ │ ByteBuffer │ │ │ └─────┘ └─────────────┘ └─────────────┘ │ │ └───────────────────┬───────────────────┘ │ │ │ │ │ 内核可以直接访问用户空间 │ │ 不需要第二次CPU拷贝 │ │ │ └─────────────────────────────────────────────────────────────────────┘关键直接内存是用户空间的内存但通过特殊机制内核可以直接访问它。这样既安全用户程序不能访问内核又避免了数据复制。2.3 核心疑问为什么堆不行但直接内存行“直接内存也是用户空间的堆也是为什么放到堆中不行但是直接内存可以”答案在于内存地址的固定性。堆内存为什么不行byte[]heapBuffernewbyte[1024];// Minor GC时如果heapBuffer存活它可能被复制到Survivor区// 地址变了内核要是正在往里写数据就完蛋了// 所以内核坚决不肯直接将数据写入堆中直接内存为什么行ByteBufferdirectBufferByteBuffer.allocateDirect(1024);// 这块内存不受GC影响地址永远不变// 内核可以放心地直接写入这就是那个“特殊机制”直接内存是固定的、不被移动的用户空间内存。三、零拷贝技术的演进3.1 传统IO的多次拷贝传统IO从硬盘读取数据并发送到网络需要经历多次拷贝传统IO硬盘 → 应用程序 → 网卡 ┌─────────────────────────────────────────────────────────────────────┐ │ │ │ 硬盘 ──DMA──→ 内核缓冲区 ──CPU──→ 应用程序缓冲区 ──CPU──→ Socket缓冲区 ──DMA──→ 网卡 │ │ │ │ ① DMA拷贝 ② CPU拷贝 ③ CPU拷贝 ④ DMA拷贝 │ │ │ │ 总共4次拷贝2次CPU拷贝 │ │ │ └─────────────────────────────────────────────────────────────────────┘3.2 sendfile零拷贝Linux 2.1引入了sendfile系统调用实现了内核空间内的零拷贝// sendfile零拷贝FileChannelchannelFileChannel.open(Paths.get(data.txt));SocketChannelsocketSocketChannel.open();channel.transferTo(0,channel.size(),socket);// sendfile系统调用sendfile零拷贝 ┌─────────────────────────────────────────────────────────────────────┐ │ │ │ 硬盘 ──DMA──→ 内核缓冲区 ──CPU──→ Socket缓冲区 ──DMA──→ 网卡 │ │ │ │ ① DMA拷贝 ② CPU拷贝 ③ DMA拷贝 │ │ │ │ 总共3次拷贝1次CPU拷贝 │ │ 减少了应用程序缓冲区的拷贝 │ │ │ └─────────────────────────────────────────────────────────────────────┘3.3 scatter/gather零拷贝Linux 2.4引入了sendfile的scatter/gather功能实现了真正的零拷贝scatter/gather零拷贝 ┌─────────────────────────────────────────────────────────────────────┐ │ │ │ 硬盘 ──DMA──→ 内核缓冲区 ──DMA──→ 网卡 │ │ │ │ ① DMA拷贝 ② DMA拷贝 │ │ │ │ 总共2次拷贝0次CPU拷贝 │ │ │ │ 原理内核缓冲区中的描述符直接传递到网卡 │ │ CPU完全不参与数据拷贝 │ │ │ └─────────────────────────────────────────────────────────────────────┘3.4 mmap内存映射mmap将文件直接映射到内存应用程序可以直接访问// mmap内存映射FileChannelchannelFileChannel.open(Paths.get(data.txt));MappedByteBufferbufferchannel.map(FileChannel.MapMode.READ_ONLY,0,channel.size());// 现在可以直接访问buffer修改会直接写回文件mmap内存映射 ┌─────────────────────────────────────────────────────────────────────┐ │ │ │ 硬盘 ──DMA──→ 内核缓冲区 ←─── 应用程序直接访问 │ │ ↑ │ │ │ 内存映射 │ │ │ │ │ ┌─────┴─────┐ │ │ │ 直接内存 │ │ │ └───────────┘ │ │ │ │ 优点应用程序可以直接操作内核缓冲区 │ │ 缺点文件修改会立即写回可能导致数据不一致 │ │ │ └─────────────────────────────────────────────────────────────────────┘四、直接内存的源码实现4.1 DirectByteBuffer的创建// DirectByteBuffer源码简化publicclassDirectByteBufferextendsMappedByteBuffer{DirectByteBuffer(intcap){super(-1,0,cap,cap);// 1. 分配直接内存baseunsafe.allocateMemory(size);// 2. 设置内存地址addressbase;// 3. 创建Cleaner虚引用cleanerCleaner.create(this,newDeallocator(base,size,cap));}// 分配内存的底层实现longallocateMemory(longsize){// 调用系统调用分配内存returnunsafe.allocateMemory(size);}}4.2 Cleaner的实现// Cleaner源码简化publicclassCleanerextendsPhantomReferenceObject{privatefinalRunnablethunk;privateCleaner(Objectreferent,Runnablethunk){super(referent,dummyQueue);this.thunkthunk;}publicstaticCleanercreate(Objectob,Runnablethunk){returnnewCleaner(ob,thunk);}publicvoidclean(){if(remove(this)){try{thunk.run();// 释放直接内存}catch(Throwablex){}}}}4.3 Deallocator的实现// Deallocator源码简化privatestaticclassDeallocatorimplementsRunnable{privatelongaddress;privatelongsize;Deallocator(longaddress,longsize){this.addressaddress;this.sizesize;}publicvoidrun(){if(address0){return;}// 释放直接内存unsafe.freeMemory(address);address0;}}4.4 直接内存的回收流程直接内存回收流程 ┌─────────────────────────────────────────────────────────────────────┐ │ │ │ 1. 创建DirectByteBuffer │ │ ├─ 分配直接内存本地内存 │ │ └─ 创建Cleaner虚引用关联DirectByteBuffer │ │ │ │ 2. DirectByteBuffer对象存活 │ │ └─ 直接内存被使用 │ │ │ │ 3. DirectByteBuffer对象不再被引用 │ │ └─ 成为垃圾堆中 │ │ │ │ 4. GC回收DirectByteBuffer对象 │ │ ├─ 虚引用Cleaner被放入ReferenceQueue │ │ └─ Cleaner线程处理队列调用clean() │ │ │ │ 5. clean()执行 │ │ └─ 调用unsafe.freeMemory()释放直接内存 │ │ │ └─────────────────────────────────────────────────────────────────────┘五、直接内存的权衡5.1 直接内存的优缺点✅ 优点零拷贝减少一次内存拷贝提升IO性能地址固定不受GC影响内核可以直接访问大内存友好不占用堆内存避免GC压力❌ 缺点分配慢每次分配需要系统调用回收复杂依赖Cleaner可能延迟回收内存泄漏风险忘记释放会导致物理内存耗尽上限不明确默认等于堆内存大小容易OOM5.2 什么时候用直接内存// 场景A小对象频繁分配不适合直接内存for(inti0;i1000000;i){// 每次都要系统调用性能极差ByteBufferdirectBufferByteBuffer.allocateDirect(64);}// 场景B大文件传输适合直接内存ByteBufferdirectBufferByteBuffer.allocateDirect(100*1024*1024);channel.read(directBuffer);// 一次拷贝性能极佳// 场景C网络IONetty场景// Netty默认使用直接内存因为网络IO频繁且数据量大5.3 直接内存的调优# 设置直接内存大小默认等于堆内存大小-XX:MaxDirectMemorySize512m# 查看直接内存使用情况jdk.nio.BufferPool.direct.capacity jdk.nio.BufferPool.direct.used jdk.nio.BufferPool.direct.count# 禁用显式GC可能影响直接内存回收-XX:DisableExplicitGC# 慎用可能阻止Cleaner工作六、一个帮你通透的类比快递柜模型传统IO两次拷贝你JVM想收快递读硬盘 ↓ 快递员内核把快递放到快递柜内核缓冲区 ↓ 你从快递柜取出快递搬到自己家堆内存 ↓ 现在快递在家了可以用了问题多了一次“从柜子搬回家”的动作。直接内存你提前买了一个特殊的快递柜直接内存这个柜子就在你家院子里用户空间 但快递员内核有钥匙可以直接把快递放进去 ↓ 你直接去院子里拿不用再从柜子搬回家为什么堆不行因为你的家堆会移动GC时搬家快递员不敢把快递直接放进去。七、Netty中的直接内存Netty是直接内存的典型应用场景。7.1 Netty的ByteBuf// Netty中的直接内存分配ByteBufdirectBufferUnpooled.directBuffer(1024);ByteBufdirectBufferPooledByteBufAllocator.DEFAULT.directBuffer(1024);// 对比堆内存ByteBufheapBufferUnpooled.buffer(1024);ByteBufheapBufferPooledByteBufAllocator.DEFAULT.heapBuffer(1024);7.2 Netty的内存池Netty实现了内存池来缓解直接内存分配慢的问题// Netty内存池源码简化publicclassPooledByteBufAllocator{privatefinalPoolArena[]directArenas;// 直接内存池privatefinalPoolArena[]heapArenas;// 堆内存池publicByteBufdirectBuffer(intcapacity){// 从内存池中获取避免频繁分配returndirectArena.allocate(capacity);}}7.3 Netty的零拷贝// Netty的零拷贝文件传输publicvoidsendFile(FileChannelfile,SocketChannelsocket){// 使用FileRegion实现零拷贝FileRegionregionnewDefaultFileRegion(file,0,file.size());socket.write(region);// 底层调用transferTo}八、常见面试题Q1直接内存和堆内存的区别是什么答管理方式堆内存由JVM管理受GC控制直接内存由操作系统管理不受GC直接控制地址可变性堆内存地址会因GC移动而改变直接内存地址固定分配速度堆内存分配快直接内存分配慢系统调用访问速度堆内存需要两次拷贝直接内存可以实现零拷贝Q2为什么堆内存不能实现零拷贝答因为堆内存中的对象可能被GC移动复制算法地址会变化。如果内核正在向堆内存写入数据时发生GC对象被移动会导致数据写入错误地址。直接内存地址固定不存在这个问题。Q3直接内存是如何被回收的答直接内存通过DirectByteBuffer对象关联的Cleaner虚引用回收。当DirectByteBuffer对象被GC回收时Cleaner被放入引用队列Cleaner线程处理队列调用unsafe.freeMemory()释放直接内存。Q4直接内存的默认大小是多少答默认等于堆内存大小-Xmx。如果堆内存是2GB直接内存默认也是2GB。可以通过-XX:MaxDirectMemorySize单独设置。Q5Netty为什么使用直接内存答Netty主要处理网络IO使用直接内存有以下优势零拷贝数据从内核直接写入直接内存减少拷贝减少GC压力直接内存不占用堆内存减少GC频率内存池Netty实现了内存池缓解直接内存分配慢的问题九、总结9.1 核心要点概念一句话解释直接内存用户空间的固定内存内核可以直接访问实现零拷贝零拷贝减少数据在内核空间和用户空间之间的拷贝次数传统IO硬盘 → 内核缓冲区 → 堆内存2次拷贝1次CPU拷贝sendfile硬盘 → 内核缓冲区 → 网卡2次拷贝0次CPU拷贝mmap文件直接映射到内存应用程序直接访问内核缓冲区9.2 直接内存的权衡┌─────────────────────────────────────────────────────────────────────┐ │ 直接内存的权衡 │ ├─────────────────────────────────────────────────────────────────────┤ │ │ │ 优点 │ │ ├─ 零拷贝减少CPU拷贝提升IO性能 │ │ ├─ 地址固定不受GC影响 │ │ └─ 减少GC压力不占用堆内存 │ │ │ │ 缺点 │ │ ├─ 分配慢每次需要系统调用 │ │ ├─ 回收复杂依赖Cleaner可能延迟 │ │ ├─ 内存泄漏风险需要显式管理 │ │ └─ 上限不明确默认等于堆大小容易OOM │ │ │ └─────────────────────────────────────────────────────────────────────┘9.3 面试金句如果面试官问你“直接内存和零拷贝”你可以这样回答“直接内存是用户空间的固定内存不受GC影响内核可以直接访问。传统IO需要两次拷贝硬盘到内核缓冲区DMA内核缓冲区到堆内存CPU。直接内存可以实现零拷贝因为内核可以直接将数据写入用户空间的直接内存省去了一次CPU拷贝。直接内存通过DirectByteBuffer分配关联一个Cleaner虚引用当DirectByteBuffer被GC回收时Cleaner会释放直接内存。Netty使用直接内存和内存池结合sendfile系统调用实现了高性能的零拷贝网络传输。但直接内存分配慢、回收复杂需要权衡使用。”下篇预告掌握了直接内存和零拷贝你已经理解了Java高性能编程的核心技术之一。接下来我们将把这些知识应用到实战中——JVM参数调优与OOM排查。下一篇《JVM参数调优实战——从GC日志到参数调整》将带你学习如何通过分析GC日志找到性能瓶颈并进行针对性的参数调优。如果你觉得本文有帮助欢迎点赞、评论、转发
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/2443796.html
如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!