Netty如何高效接收网络数据?聊透ByteBuffer动态自适应扩缩 七
5. 使用堆外内存为ByteBuffer分配内存
AdaptiveRecvByteBufAllocator类只是负责动态调整ByteBuffer的容量,而具体为ByteBuffer申请内存空间的是由PooledByteBufAllocator负责。
5.1 类名前缀Pooled的来历
在我们使用Java进行日常开发过程中,在为对象分配内存空间的时候我们都会选择在JVM堆中为对象分配内存,这样做对我们Java开发者特别的友好,我们只管使用就好而不必过多关心这块申请的内存如何回收,因为JVM堆完全受Java虚拟机控制管理,Java虚拟机会帮助我们回收不再使用的内存。
但是JVM在进行垃圾回收时候的stop the world会对我们应用程序的性能造成一定的影响。
除此之外我们在?《聊聊Netty那些事儿之从内核角度看IO模型》一文中介绍IO模型的时候提到,当数据达到网卡时,网卡会通过DMA的方式将数据拷贝到内核空间中,这是第一次拷贝。当用户线程在用户空间发起系统IO调用时,CPU会将内核空间的数据再次拷贝到用户空间。这是第二次拷贝。
于此不同的是当我们在JVM中发起IO调用时,比如我们使用JVM堆内存读取Socket接收缓冲区中的数据时,会多一次内存拷贝,CPU在第二次拷贝中将数据从内核空间拷贝到用户空间时,此时的用户空间站在JVM角度是堆外内存,所以还需要将堆外内存中的数据拷贝到堆内内存中。这就是第三次内存拷贝。
同理当我们在JVM中发起IO调用向Socket发送缓冲区写入数据时,JVM会将IO数据先拷贝到堆外内存,然后才能发起系统IO调用。
那为什么操作系统不直接使用JVM的堆内内存进行IO操作呢?
因为JVM的内存布局和操作系统分配的内存是不一样的,操作系统不可能按照JVM规范来读写数据,所以就需要第三次拷贝中间做个转换将堆外内存中的数据拷贝到JVM堆中。
所以基于上述内容,在使用JVM堆内内存时会产生以下两点性能影响:
1.JVM在垃圾回收堆内内存时,会发生stop the world导致应用程序卡顿。
2.在进行IO操作的时候,会多产生一次由堆外内存到堆内内存的拷贝。
基于以上两点使用JVM堆内内存对性能造成的影响,于是对性能有卓越追求的Netty采用堆外内存也就是DirectBuffer来为ByteBuffer分配内存空间。
采用堆外内存为ByteBuffer分配内存的好处就是:
•堆外内存直接受操作系统的管理,不会受JVM的管理,所以JVM垃圾回收对应用程序的性能影响就没有了。
•网络数据到达之后直接在堆外内存上接收,进程读取网络数据时直接在堆外内存中读取,所以就避免了第三次内存拷贝。
所以Netty在进行 I/O 操作时都是使用的堆外内存,可以避免数据从 JVM 堆内存到堆外内存的拷贝。但是由于堆外内存不受JVM的管理,所以就需要额外关注对内存的使用和释放,稍有不慎就会造成内存泄露,于是Netty就引入了内存池对堆外内存进行统一管理。
PooledByteBufAllocator类的这个前缀Pooled就是内存池的意思,这个类会使用Netty的内存池为ByteBuffer分配堆外内存。
5.2 PooledByteBufAllocator的创建
创建时机
在服务端NioServerSocketChannel的配置类NioServerSocketChannelConfig以及客户端NioSocketChannel的配置类NioSocketChannelConfig实例化的时候会触发
PooledByteBufAllocator的创建。
public class DefaultChannelConfig implements ChannelConfig {
//PooledByteBufAllocator
private volatile ByteBufAllocator allocator = ByteBufAllocator.DEFAULT;
..........省略......
}
创建出来的PooledByteBufAllocator实例保存在DefaultChannelConfig类中的ByteBufAllocator allocator字段中。
创建过程
public interface ByteBufAllocator {
ByteBufAllocator DEFAULT = ByteBufUtil.DEFAULT_ALLOCATOR;
..................省略............
}
public final class ByteBufUtil {
static final ByteBufAllocator DEFAULT_ALLOCATOR;
static {
String allocType = SystemPropertyUtil.get(
"io.netty.allocator.type", PlatformDependent.isAndroid() ? "unpooled" : "pooled");
allocType = allocType.toLowerCase(Locale.US).trim();
ByteBufAllocator alloc;
if ("unpooled".equals(allocType)) {
alloc = UnpooledByteBufAllocator.DEFAULT;
logger.debug("-Dio.netty.allocator.type: {}", allocType);
} else if ("pooled".equals(allocType)) {
alloc = PooledByteBufAllocator.DEFAULT;
logger.debug("-Dio.netty.allocator.type: {}", allocType);
} else {
alloc = PooledByteBufAllocator.DEFAULT;
logger.debug("-Dio.netty.allocator.type: pooled (unknown: {})", allocType);
}
DEFAULT_ALLOCATOR = alloc;
...................省略..................
}
}
从ByteBufUtil类的初始化过程我们可以看出,在为ByteBuffer分配内存的时候是否使用内存池在Netty中是可以配置的。
•通过系统变量-D io.netty.allocator.type 可以配置是否使用内存池为ByteBuffer分配内存。默认情况下是需要使用内存池的。但是在安卓系统中默认是不使用内存池的。
•通过PooledByteBufAllocator.DEFAULT获取内存池ByteBuffer分配器。
public static final PooledByteBufAllocator DEFAULT =
new PooledByteBufAllocator(PlatformDependent.directBufferPreferred());
由于本文的主线是介绍Sub Reactor处理OP_READ事件的完整过程,所以这里只介绍主线相关的内容,这里只是简单介绍下在接收数据的时候为什么会用PooledByteBufAllocator来为ByteBuffer分配内存。而内存池的架构设计比较复杂,所以笔者后面会单独写一篇关于Netty内存管理的文章。
总结
本文介绍了Sub Reactor线程在处理OP_READ事件的整个过程。并深入剖析了AdaptiveRecvByteBufAllocator类动态调整ByteBuffer容量的原理。
同时也介绍了Netty为什么会使用堆外内存来为ByteBuffer分配内存,并由此引出了Netty的内存池分配器PooledByteBufAllocator 。
在介绍AdaptiveRecvByteBufAllocator类和PooledByteBufAllocator一起组合实现动态地为ByteBuffer分配容量的时候,笔者不禁想起了多年前看过的《Effective Java》中第16条 复合优先于继承。
Netty在这里也遵循了这条军规,首先两个类设计的都是单一的功能。
•AdaptiveRecvByteBufAllocator类只负责动态的调整ByteBuffer容量,并不管具体的内存分配。
•PooledByteBufAllocator类负责具体的内存分配,用内存池的方式。
这样设计的就比较灵活,具体内存分配的工作交给具体的ByteBufAllocator,可以使用内存池的分配方式PooledByteBufAllocator,也可以不使用内存池的分配方式UnpooledByteBufAllocator。具体的内存可以采用JVM堆内内存(HeapBuffer),也可以使用堆外内存(DirectBuffer)。
而AdaptiveRecvByteBufAllocator只需要关注调整它们的容量工作就可以了,而并不需要关注它们具体的内存分配方式。
最后通过io.netty.channel.RecvByteBufAllocator.Handle#allocate方法灵活组合不同的内存分配方式。这也是装饰模式的一种应用。
byteBuf = allocHandle.allocate(allocator);