目前很多高性能的Java RPC框架都是基于Netty实现的,而Netty的设计原理又离不开Java NIO。本篇笔记是对NIO核心三件套:缓冲区(Buffer)、选择器 (Selector)和通道(Channel),其中之一的缓冲区Buffer实现原理的学习总结。
1、Buffer的继承体系
如上图所示,对于Java中的所有基本类型,都会有一个具体的Buffer类型与之对应,一般我们最经常使用的是ByteBuffer。
2、Buffer的操作API使用案例
举一个IntBuffer的使用案例:
public class IntBufferDemo {
public static void main(String[] args) {
IntBuffer buffer = IntBuffer.allocate(8);
for (int i = 0; i < buffer.capacity(); i++) {
int j = 2 * (i + 1);
buffer.put(j);
}
buffer.flip();
while (buffer.hasRemaining()){
int j = buffer.get();
System.out.print(j + " ");
}
}
}
运行结果:
2 4 6 8 10 12 14 16
从该案例中可以看出,其实本质上这里就是把IntBuffer看作成一个数组容器使用,可以通过get 方法向容器中读取数据(put 方法向容器中写入数据)。
3、Buffer的基本原理
Buffer缓冲区本质上就是一个特殊类型的数组对象,与普通数组不同的地方在于,其内置了一些机制,能够跟踪和记录缓冲区的状态变化情况,如果我们使用get() 方法从缓冲区获取数据或者使用put() 方法把数据写入缓冲区,都会引起缓冲区状态的变化。
Buffer内置数组实现状态变化与追踪的原理,本质上是通过三个字段变量实现的:
- position:指定下一个将要被写入或者读取的元素索引,它的值由
get()/put() 方法自动更新,在新创建一个Buffer对象时,position被初始化为0。 - limit:指定还有多少数据需要取出(在从缓冲区写入通道时),或者还有多少空间可以放入数据(在从通道读入缓冲区时)。
- capacity:指定了可以存储在缓冲区中的最大数据容量,实际上,它指定了底层数组的大小,或者至少是指定了准许我们 使用的底层数组的容量。
源码如下:
public abstract class Buffer {
private int position = 0;
private int limit;
private int capacity;
...
}
如果我们创建一个新的容量大小为10的ByteBuffer对象,在初始化的时候,position设置为0,limit和 capacity设置为10,在以后使用ByteBuffer对象过程中,capacity的值不会再发生变化,而其他两个将会随着使用而变化。
我们来看一下《Netty核心原理与手写PRC框架实战》这本书中介绍的一个例子:
准备一个txt文档,存放在项目目录下,文档中输入以下内容:
Java
我们用一段代码来验证position、limit和capacity这三个值的变 化过程,代码如下:
public class BufferDemo {
public static void main(String[] args) throws IOException {
FileInputStream fileInputStream = new FileInputStream("/Users/csp/IdeaProjects/netty-study/test.txt");
FileChannel channel = fileInputStream.getChannel();
ByteBuffer buffer = ByteBuffer.allocate(10);
output("初始化", buffer);
channel.read(buffer);
output("调用read()", buffer);
buffer.flip();
output("调用flip()", buffer);
while (buffer.remaining() > 0){
byte b = buffer.get();
}
output("调用get()", buffer);
buffer.clear();
output("调用clear()", buffer);
fileInputStream.close();
}
public static void output(String step, Buffer buffer) {
System.out.println(step + " : ");
System.out.print("capacity" + buffer.capacity() + " , ");
System.out.print("position" + buffer.position() + " , ");
System.out.println("limit" + buffer.limit());
System.out.println();
}
}
输出结果如下:
初始化 :
capacity10 , position0 , limit10
调用read() :
capacity10 , position4 , limit10
调用flip() :
capacity10 , position0 , limit4
调用get() :
capacity10 , position4 , limit4
调用clear() :
capacity10 , position0 , limit10
下面我们来对上面代码的执行结果进行图解分析(围绕position、limit、capacity三个字段值):
ByteBuffer buffer = ByteBuffer.allocate(10);
channel.read(buffer);
output("调用read()", buffer);
首先从通道中读取一些数据到缓冲区中(注意从通道读取数据,相当于往缓冲区写入数据)。如果读取4个字节的数据,则此时 position的值为4,即下一个将要被写入的字节索引为4,而limit仍然是10,如下图所示。
buffer.flip();
output("调用flip()", buffer);
下一步把读取的数据写入输出通道,相当于从缓冲区中读取数据,在此之前,必须调用flip() 方法。该方法将会完成以下两件事情:
- 一是把limit设置为当前的position值。
- 二是把position设置为 0。
由于position被设置为0,所以可以保证在下一步输出时读取的是缓冲区的第一个字节,而limit被设置为当前的position,可以保证读取的数据正好是之前写入缓冲区的数据,如下图所示。
while (buffer.remaining() > 0){
byte b = buffer.get();
}
output("调用get()", buffer);
调用get() 方法从缓冲区中读取数据写入输出通道,这会导致 position的增加而limit保持不变,但position不会超过limit的值, 所以在读取之前写入缓冲区的4字节之后,position和limit的值都为 4,如下图所示。
buffer.clear();
output("调用clear()", buffer);
fileInputStream.close();
在从缓冲区中读取数据完毕后,limit的值仍然保持在调用flip() 方法时的值,调用clear() 方法能够把所有的状态变化设置为初始化时的值,最后关闭流,如下图所示。
通过上述案例,更能突出Buffer是一个特殊的数组容器,与普通数组区别就在于其内置三个 “指针变量”:position、limit、capacity 用于跟踪和记录缓冲区的状态变化情况!
4、allocate方法初始化一个指定容量大小的缓冲区
在创建一个缓冲区对象时,会调用静态方法allocate() 来指定缓冲区的容量,其实调用allocate() 方法相当于创建了一个指定大小的数组,并把它包装为缓冲区对象。
allocate() 源码如下:
public static ByteBuffer allocate(int capacity) {
if (capacity < 0)
throw new IllegalArgumentException();
return new HeapByteBuffer(capacity, capacity);
}
HeapByteBuffer(int cap, int lim) {
super(-1, 0, lim, cap, new byte[cap], 0);
}
ByteBuffer(int mark, int pos, int lim, int cap,
byte[] hb, int offset){
super(mark, pos, lim, cap);
this.hb = hb;
this.offset = offset;
}
Buffer(int mark, int pos, int lim, int cap) {
if (cap < 0)
throw new IllegalArgumentException("Negative capacity: " + cap);
this.capacity = cap;
limit(lim);
position(pos);
if (mark >= 0) {
if (mark > pos)
throw new IllegalArgumentException("mark > position: ("
+ mark + " > " + pos + ")");
this.mark = mark;
}
}
本质上等同于如下代码:
byte[] bytes = new byte[10];
ByteBuffer buffer = ByteBuffer.wrap(bytes);
5、slice方法缓冲区分片
Java NIO中,可以根据先用的缓冲区Buffer对象创建一个子缓冲区。即,在现有缓冲区上切出一片作为一个新的缓冲区,但现有的缓冲区与创建的子缓冲区在底层数组层面上是数据共享的。
示例代码如下所示:
public class BufferSlice {
public static void main(String[] args) {
ByteBuffer buffer = ByteBuffer.allocate(10);
for (int i = 0; i < buffer.capacity(); i++) {
buffer.put((byte) i);
}
buffer.position(3);
buffer.limit(7);
ByteBuffer slice = buffer.slice();
for (int i = 0; i < slice.capacity(); i++) {
byte b = slice.get(i);
b *= 10;
slice.put(i, b);
}
buffer.position(0);
buffer.limit(buffer.capacity());
while (buffer.hasRemaining()) {
System.out.println(buffer.get());
}
}
}
在该示例中,分配了一个容量大小为10的缓冲区,并在其中放入 了数据0~9,而在该缓冲区基础上又创建了一个子缓冲区,并改变子缓冲区中的内容,从最后输出的结果来看,只有子缓冲区“可见的” 那部分数据发生了变化,并且说明子缓冲区与原缓冲区是数据共享 的,输出结果如下所示:
0
1
2
30
40
50
60
7
8
9
6、只读缓冲区
只读缓冲区,顾名思义就是只可以从缓冲区中读取数据,而不可以向其中写入数据。
将现有缓冲区让其调用asReadOnlyBuffer() 方法,使其转换成只读缓冲区。这个方法返回一个与原缓冲区完全相同的缓冲区,并与原缓冲区共享数据,只不过它是只读的。如果原缓冲区的 内容发生了变化,只读缓冲区的内容也随之发生变化。
示例代码如下所示:
public class ReadOnlyBuffer {
public static void main(String[] args) {
ByteBuffer buffer = ByteBuffer.allocate(10);
for (int i = 0; i < buffer.capacity(); i++) {
buffer.put((byte) i);
}
ByteBuffer readOnlyBuffer = buffer.asReadOnlyBuffer();
for (int i = 0; i < buffer.capacity(); i++) {
byte b = buffer.get(i);
b *= 10;
buffer.put(i, b);
}
readOnlyBuffer.position(0);
readOnlyBuffer.limit(buffer.capacity());
while (readOnlyBuffer.hasRemaining()) {
System.out.println(readOnlyBuffer.get());
}
}
}
输出结果如下:
0
10
20
30
40
50
60
70
80
90
如果尝试修改只读缓冲区的内容,则会报 ReadOnlyBufferException异常。只可以把常规缓冲区转换为只读缓冲区,而不能将只读缓冲区转换为 可写的缓冲区。
7、直接缓冲区
参考文章:Java NIO学习篇之直接缓冲区和非直接缓冲区
对于直接缓冲区的定义,《深入理解Java虚拟机》这本书是这样介绍的:
- Java NIO字节缓冲区(ByteBuffer)要么是直接的,要么是非直接的。如果为直接字节缓冲区,则java虚拟机会尽最大努力直接在此缓冲区上执行本机的IO操作,也就是说,在每次调用基础操作系统的一个本机IO操作前后,虚拟机都会尽量避免将内核缓冲区内容复制到用户进程缓冲区中,或者反过来,尽量避免从用户进程缓冲区复制到内核缓冲区中。
- 直接缓冲区可以通过调用该缓冲区类的
allocateDirect(int capacity) 方法创建,此方法返回的缓冲区进行分配和取消分配所需的成本要高于非直接缓冲区。直接缓冲区的内容驻留在垃圾回收堆之外,因此他们对应用程序内存(JVM内存)需求不大。所以建议直接缓冲区要分配给那些大型,持久(就是缓冲区的数据会被重复利用)的缓冲区,一般情况下,最好仅在直接缓冲区能在程序性能带来非常明显的好处时才分配它们。 - 直接缓冲区还可以通过FileCHannel的
map() 方法将文件区域映射到内存中来创建,该方法返回MappedByteBuffer。Java平台的实现有助于通过JNI本地代码创建直接字节缓冲区,如果以上这些缓冲区中某个缓冲区实例指向的是不可访问的内存区域,则试图方法该区域不会更改缓冲区的内容,并且会在访问期间或者稍后的某个时间导致报出不确定性异常。 - 字节缓冲区是直接缓冲区还是非直接缓冲区可以通过调用其
isDIrect() 方法来判断。
案例代码:
public class DirectBuffer {
public static void main(String[] args) throws IOException {
FileInputStream fileInputStream = new FileInputStream("/Users/csp/IdeaProjects/netty-study/test.txt");
FileChannel inputStreamChannel = fileInputStream.getChannel();
FileOutputStream fileOutputStream = new FileOutputStream("/Users/csp/IdeaProjects/netty-study/test2.txt");
FileChannel outputStreamChannel = fileOutputStream.getChannel();
ByteBuffer byteBuffer = ByteBuffer.allocateDirect(1024);
while (true){
byteBuffer.clear();
int read = inputStreamChannel.read(byteBuffer);
if (read == -1){
break;
}
byteBuffer.flip();
outputStreamChannel.write(byteBuffer);
}
}
}
要分配 直接缓冲区,需要调用allocateDirect() 方法,而不是allocate() 方 法,使用方式与普通缓冲区并无区别。
8、内存映射
内存映射是一种读和写文件数据的方法,可以比常规的基于流或者基于通道的I/O快得多。内存映射文件I/O通过使文件中的数据表现为内存数组的内容来完成,这初听起来似乎不过就是将整个文件读到内存中,但事实上并不是这样的。一般来说,只有文件中实际读取或 写入的部分才会映射到内存中。来看下面的示例代码:
public class MapperBuffer {
static private final int start = 0;
static private final int size = 10;
public static void main(String[] args) throws IOException {
RandomAccessFile randomAccessFile = new RandomAccessFile("/Users/csp/IdeaProjects/netty-study/test.txt", "rw");
FileChannel channel = randomAccessFile.getChannel();
MappedByteBuffer mappedByteBuffer = channel.map(FileChannel.MapMode.READ_WRITE, start, size);
mappedByteBuffer.put(4, (byte) 97);
mappedByteBuffer.put(5, (byte) 122);
randomAccessFile.close();
}
}
原来test.txt文件内容为:
Java
执行完上述代码之后,test.txt文件内容更新为:
Javaaz
参考书籍:《Netty核心原理与手写PRC框架实战》,该书籍PDF可以从我的公众号【兴趣使然的草帽路飞】回复001获取!
推荐资料:Java NIO 缓冲区
|