在正式学习Netty之前,还是得先学习一下基础的组件,Java NIO相比Java BIO有了较大的变化,这种变化也是面试主要问到的地方。
buffer其实英译过来就是缓冲的意思,我们可以直接理解为其实就是内存中的一块,可以将数据写入buffer,然后从buffer中读取数据。在Java中定义了不同的buffer,如下图所示(原博客:NIO的三大组件):
我们用的较多的也就是ByteBuffer,其他的buffer其实也就是对ByteBuffer进行了不同程度的包装,从某一种程度上来说,通过源码我们发现其实buffer的数据结构就是一个byte数组,同时在父类Buffer中维护了三个主要的属性,如下所示:
// Invariants: mark <= position <= limit <= capacity
private int mark = -1;
private int position = 0;
private int limit;
private int capacity;
position,limit,capacity这三属性配合完成了buffer的读写。 同时源码中也说明了这三者的大小关系,至于mark这个后面会讨论。其中capacity就是定义的buffer的容量大小,position的初始值是0 ,这个表示下一次写入元素的位置。limit:代表写操作模式下最大能写入的数据,读模式下,limit又等于buffer中的实际数据个数。
通过源码可以看出,初始化buffer有三个方法
/**
使用最多的分配方式,直接分配capacity的HeapByteBuffer
*/
public static ByteBuffer allocate(int capacity) {
if (capacity < 0)
throw new IllegalArgumentException();
return new HeapByteBuffer(capacity, capacity);
}
/**
返回一个DirectByteBuffer
*/
public static ByteBuffer allocateDirect(int capacity) {
return new DirectByteBuffer(capacity);
}
/**
通过一个byte数组分配buffer,返回HeapByteBuffer
*/
public static ByteBuffer wrap(byte[] array,
int offset, int length){
try {
return new HeapByteBuffer(array, offset, length);
} catch (IllegalArgumentException x) {
throw new IndexOutOfBoundsException();
}
}
可以看出,allocateDirect返回一个DirectByteBuffer,另外两个方法返回HeapByteBuffer,这两者的区别如下:
HeapByteBuffer:顾名思义,其实是建立在JVM堆上的buffer。
DirectByteBuffer:底层的数据其实是维护在操作系统的内存中,而不是jvm里,DirectByteBuffer里维护了一个引用address指向了数据,从而操作数据。
前者把内容写进buffer里速度会快些;并且,可以更容易回收。后者跟外设(IO设备)打交道时会快很多,因为外设读取jvm堆里的数据时,不是直接读取的,而是把jvm里的数据读到一个内存块里,再在这个块里读取的,如果使用DirectByteBuffer,则可以省去这一步,实现zero copy(零拷贝)
初始化一个capacity为11后的buffer结构如下所示:
/**
将单个元素放入到buffer中
*/
public abstract ByteBuffer put(byte b);
public abstract ByteBuffer put(int index, byte b);
/**
将数组或buffer放入到buffer中
*/
public final ByteBuffer put(byte[] src)
public ByteBuffer put(byte[] src, int offset, int length)
public ByteBuffer put(ByteBuffer src)
这里面的源码都比较简单,但是我们常用的是
channel.read(buffer)
实例:如果放入4个元素之后,buffer中的byte数组示意图如下:
这个时候只是position地址有了变化,position永远指向下一个待放入元素的索引。
如果需要读取buffer中的元素,需要切换buffer的模式,在读取数据之前,需要调用buffer的flip的方法,这个方法的源码如下:
public final Buffer flip() {
limit = position;//将limit设置为实际写入的数据数量
position = 0; //position归零
mark = -1; //mark就是一个标记
return this;
}
可理解为,将读取区域锁定,flip方法之后,buffer的示意图如下
利用channel读取数据到buffer中的方法如下:
channel.write(buffer);
buffer中提供的get方式
public abstract ByteBuffer put(byte b);
public abstract byte get(int index);
public ByteBuffer get(byte[] dst, int offset, int length)
public ByteBuffer get(byte[] dst)
这几个参数和put方式差不多。
这两个方法是用于辅助读取的方式,mark其实用于临时保存position的值,每次调用mark方法,就会将mark设置为当前的position的值
public final Buffer mark() {
mark = position;
return this;
}
如果position为5,这个时候调用mark(),之后我们开始读取元素,读取到10的时候,如果突然需要再从5读取到10,重新读取一遍,这个时候我们不用强制将position置为5,只需要调用reset即可,源码如下所示:
public final Buffer reset() {
int m = mark;
if (m < 0)
throw new InvalidMarkException();
position = m;
return this;
}
这个方法只是会简单重置position为0,从头开始读取buffer。源码如下:
public final Buffer rewind() {
position = 0;
mark = -1;
return this;
}
将limit,position和mark复原,但是没有清空buffer中的数据
public final Buffer clear() {
position = 0;
limit = capacity;
mark = -1;
return this;
}
这个方法会在重置相关索引的时候,同时清空buffer。
public ByteBuffer compact() {
System.arraycopy(hb, ix(position()), hb, ix(0), remaining());
position(remaining());
limit(capacity());
discardMark();
return this;
}
下面以FileChannel为基础,实现了一个buffer的简单实例
/**
* autor:liman
* createtime:2019/10/6
* comment:
*/
public class ByteBufferDemo {
public static void main(String[] args) throws Exception {
FileInputStream fileInputStream = new FileInputStream("E:/liman/learn/test.txt");
//创建文件的操作channel
FileChannel channel = fileInputStream.getChannel();
//分配一个长度为10的buffer
ByteBuffer buffer = ByteBuffer.allocate(10);
printBuffer("初始化",buffer);
channel.read(buffer);
//操作之前先调用flip,锁定读取buffer的范围
buffer.flip();
printBuffer("锁定读取数据的范围",buffer);
//判断有没有可读数据
System.out.println(buffer.remaining());
while(buffer.remaining()>=0){
byte b = buffer.get();
System.out.print((char)b);
channel.write(buffer);
}
printBuffer("获取buffer中的数据",buffer);
buffer.clear();
fileInputStream.close();
}
/**
* 打印buffer的一些状态
* @param step
* @param buffer
*/
private static void printBuffer(String step,ByteBuffer buffer){
System.out.println(step+":");
//容量,数组大小
System.out.print("capacity: " + buffer.capacity() + ", ");
//当前操作数据所在的位置,也可以叫做游标
System.out.print("position: " + buffer.position() + ", ");
//锁定值,flip,数据操作范围索引只能在position - limit 之间
System.out.println("limit: " + buffer.limit());
System.out.println();
}
}
所有的NIO操作,其实都是对channel的操作。常用的Channel有四种,FileChannel,SocketChannel,DatagramChannel,ServerSocketChannel。这四者的主要区别如下:
FileChannel:文件通道,用于文件的读和写
DatagramChannel:用于 UDP 连接的接收和发送
SocketChannel:把它理解为 TCP 连接通道,简单理解就是 TCP 客户端
ServerSocketChannel:TCP 对应的服务端,用于监听某个端口进来的请求
上述实例中已经利用FileChannel举了一个ByteBuffer的实例,但是需要注意的是FileChannel是不支持非阻塞的。这里重点介绍SocketChannel和ServerSocketChannel
客户端的SocketChannel创建过程如下:
InetSocketAddress inetSocketAddress = new InetSocketAddress("127.0.0.1", 8080);
SocketChannel socketChannel = SocketChannel.open();//客户端开启一个channel,在客户端实例化
socketChannel.connect(inetSocketAddress); //channel与服务端建立连接
ServerSocketChannel的创建过程如下:
ServerSocketChannel channel = ServerSocketChannel.open();
channel.configureBlocking(false);//将ServerSocketChannel设置为非阻塞,默认情况下是阻塞
channel.socket().bind(new InetSocketAddress(8080)//服务端监听8080端口
//获取客户端的SocketChannel,这里也是SocketChannel的第二个实例化方法,在服务端实例化
SocketChannel socketChannel = serverSocketChannel.accept();
到这里应该能看出SocketChannel是一个双关的网络channel,支持可读写。同时ServerSocketChannel不直接和buffer打交道,与buffer打交道的是真正的SocketChannel,ServerSocketChannel只负责监听,如果发现客户端有请求,则为这个请求创建一个SocketChannel之后ServerSocketChannel的任务就完成了。
Selector其实我个人理解为Channel的管理者或调度者,但是Selector只是管理非阻塞的channel。
先介绍一下Selector的基础操作,后面总结NIO和BIO的时候再仔细介绍selector
Selector selector = Selector.open();
如果channel交给Selector管理,就必须要将channel注册到Selector上
将channel注册到Selector上,具体的方法如下:
第一个参数就是Selector的实例,第二个参数是事件类型。返回一个SelectionKey,这个就好比一个注册之后的唯一用户名。
public final SelectionKey register(Selector sel, int ops)
throws ClosedChannelException{
return register(sel, ops, null);
}
针对第二个参数的定义,源码如下:
//通道中有数据可以进行读取
public static final int OP_READ = 1 << 0;
//可以往通道中写入数据
public static final int OP_WRITE = 1 << 2;
//成功建立 TCP 连接
public static final int OP_CONNECT = 1 << 3;
//接受 TCP 连接
public static final int OP_ACCEPT = 1 << 4;
如果需要监听多个事件,则只需要传入两个事件的异或操作的结果即可。
注册之后,该方法返回SelectionKey,这个SelectionKey包含了Channel和Selector的信息,可以简单理解为Channel注册到Selector之后的唯一标识。
主要通过select方法判断是否有事件准备好,如果已经有事件准备好了,则获取SelectorKeys的集合,处理每一个key的数据。一段简单的伪码表示Selector对事件的处理
// 判断是否有事件准备好
int readyChannels = selector.select();
if(readyChannels == 0) continue;
// 遍历
Set selectedKeys = selector.selectedKeys();
Iterator keyIterator = selectedKeys.iterator();
while(keyIterator.hasNext()) {
SelectionKey key = keyIterator.next();
if(key.isAcceptable()) {
// a connection was accepted by a ServerSocketChannel.
} else if (key.isConnectable()) {
// a connection was established with a remote server.
} else if (key.isReadable()) {
// a channel is ready for reading
} else if (key.isWritable()) {
// a channel is ready for writing
}
keyIterator.remove();
}
本文简单总结了Java NIO的三大组件,参考了大牛的博客,自己只是写入了一些实例,如果觉得本人博客写的较为晦涩的,可以直接移步大牛的博客:传送门——Java NIO 三大组件