前言
到目前为止, 我们知道 Nio 当中有三个最最核心的组件, 分别是: Selelctor,Channel,Buffer. 在 Netty 基础系列 (3) -- 彻底理解 NIO 这一篇文章中只是进行了大致的介绍.
我们现在来深入理解一下 Buffer 在 堆内创建内存 和 堆外创建内存 的底层原理, 与 零拷贝 的具体实现.
Buffer
Buffer 是一个抽象类, 首先我们来看看 Buffer 有哪些实现类.
我们从上面这张截图可以看出, Buffer 的直接子类有 7 种. 除了 Java 中 Boolean 类型. 剩余的 7 种基本类型都有与之对应的 Buffer. 不同类型的 Buffer 存储的内容也不同, 比如说 ByteBuffer 存储的就是 byte.IntBuffer 存储的就是 int. 不要想得太复杂, 把底层想象成数组即可.
接下来我们着重对 ByteBuffer 来进行讲解. 理解了一个其他的理解起来都差不多.
首先我们来看 ByteBuffer 的继承关系图
由上面的继承关系图可以看出, ByteBuffer 的子类有五个, 分别为:
HeapByteBuffer: 代表的是 jvm 堆内的缓存.
HeapByteBufferR: 代表的是 jvm 堆内的只读缓存.
MappedByteBuffer: 直接缓存的抽象基类.
DirectByteBuffer: 代表的是操作系统内存的缓存.
DirectByteBufferR: 代表的是操作系统内存的只读缓存
上面这几个类看名字和我的介绍我想你应该知道有什么区别了, 这里其实只分为两大类.
分配在堆内存的缓存和分配在操作系统内存的缓存.
HeapByteBuffer
我们首先来看在堆内分配缓存的底层原理.
先来看一段代码.
- public static void main(String args[]){
- ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
- }
我们直接调用 ByteBuffer 的静态方法创建了一个 1024 个字节的 ByteBuffer 缓存. 那么 ByteBuffer 的静态方法 allocate() 在底层到底做了些什么呢?
我们再来看看 ByteBuffer 类对于静态方法 allocate() 的实现.
- public abstract class ByteBuffer extends Buffer implements Comparable<ByteBuffer>
- {
- public static ByteBuffer allocate(int capacity) {
- if (capacity <0)
- throw new IllegalArgumentException();
- return new HeapByteBuffer(capacity, capacity);
- }
- }
没错, 就是很简单. 直接 new 了一个 HeapByteBuffer 对象, 并指定大小为 1024 个字节. 这里暂时不用管 capacity 是什么, 后面我们会详细的讲解, 在这里 capacity 就是我们传入的 1024.
到目前为止, 我们已经创建了一个 HeapByteBuffer 对象. 我们创建这个对象的意义就是用来对 Channel 进行读写. 此时我们内存模型已经变成了如下图所示:
对照着上图我们再来看看之前写的这个方法.
ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
首先再栈空间的某个栈帧中创建了 byteBuffer, 接着将其指向堆内存中的对象 HeapByteBuffer.
好了接下来是我们的重点!!!!
此时操作系统会自动在 JVM 之外的内存中分配一块内存空间, 这部分内存空间的创建和销毁完全由操作系统来管理. 我们无需在意.
Channel 的数据无论是读还是写都是与操作系统分配的这块内存打交道而不是我们的堆内存, 当准备读数据的时候, Channel 将数据读到操作系统分配的内存中, 然后再复制到 JVM 堆内存中的 HeapByteBuffer 对象中. 写操作也是如此, 当我们修改了 HeapByteBuffer 的数据, 会将修改后的数据复制到操作系统分配的内存中, 然后再写到 Channel 中.
我们之前学的普通的 IO 操作底层基本上都是如此, 我们思考一下, 为什么不能直接将 Channel 怼到 HeapByteBuffer 中呢?
没错, 如果你有一定的开发经验, 一定会想到垃圾回收器. 当发送垃圾回收的时候, 我们的对象在堆内存中是会发送移动的, 移动后内存地址是会改变的, 而 io 操作并不能追踪到你改变后的内存地址. 所以只能在 jvm 外分配内存来操作数据. 因为这一块内存从创建到销毁之间都是不会移动的.
DirectByteBuffer
我们来看看在堆外分配内存是如何实现的.
与前文一样, 我们首先来看在操作系统中直接分配内存的底层原理. 先来看一段代码.
- public static void main(String args[]){
- ByteBuffer byteBuffer = ByteBuffer.allocateDirect(1024);
- }
与创建堆内缓存类似, 我们直接调用 ByteBuffer 的静态方法创建了一个 1024 个字节的 DirectByteBuffer 缓存. 那么 ByteBuffer 的静态方法 allocateDirect() 方法与 allocate() 方法又有什么区别呢?
我们再来看看 ByteBuffer 类对于静态方法 allocateDirect() 的实现.
- public abstract class ByteBuffer extends Buffer implements Comparable<ByteBuffer>
- {
- public static ByteBuffer allocateDirect(int capacity) {
- return new DirectByteBuffer(capacity);
- }
- }
这里也是直接 new 了一个 DirectByteBuffer 对象, 我们进入该对象的构造函数看看干了些什么
这里调用勒 unsafe 的 allocateMemory(size) 方法. 我们进去后会发现这是一个 native 方法, 底层调用的 c 语言的代码. 就是在操作系统内存中分配了一个我们指定大小的内存用以操作数据. 并且记录了这块内存的地址.
此时我们的内存模型如下图所示:
因为内存中这块内存不再是操作系统分配的, 而是我们 java 代码调用 native 方法, 自己分配的内存, 并且记录了该内存的地址. 所以我们操作数据就不需要再堆内操作可以直接在 jvm 内存以外的内存操作. 此时每次读写操作都节省了两次内存复制操作.
这就是我们大名鼎鼎的 zero copy(零拷贝) 技术.
总结
其实我们多思考一下, 这样的优势大吗? 其实 Channel 中 IO 的操作相对于内存的复制来说是慢很多的, 即便我们在读写数据的时候多了两次复制的过程对于整体来说影响是不大的.
那么什么时候就会体现出零拷贝的优势呢? 有大量并发 io 操作, 并且 io 操作是短暂完成的. 这时由于节省了大量的内存 copy 操作, 这些节省的时间积累下来也是非常可观的.
netty 的底层就是用的零拷贝技术, 所以 netty 能做到很好并发, 之后我们会分析在 netty 中零拷贝是如何落实的.
来源: https://www.cnblogs.com/zhxiansheng/p/11327808.html