最近看zookeeper源码,发现底层的通信使用到了NIO和netty,接下来的系列记录下NIO和Netty的学习,记录完接着zookeeper源码的学习。
java.io
中最为核心的概念是流(stream),是面向流的编程,一个流要么是输入流,要么是输出流,不可能同时即是输入流又是输出流;而java.nio
是面向块(block)或面向缓冲区(buffer)编程,块或者缓冲区既可以作为输入也可以作为输出,nio
中有三个核心的概念,即Selector
、Channel
、Buffer
。
NIO中的Channel类似于IO中Stream,但与Stream又有所不同:
如下图所示为NIO中的数据流向:
在NIO中一个Channel代表一个连接,例如连接到一个设备、文件、网络socket等,Channel定义在java.nio.channels.Channel
中,有如下几个重要的实现类:
FileChannel
:从文件读取或向文件写入数据,还可以将文件映射到一块内存中(通过java.nio.channels.FileChannel#map
方法);DatagramChannel
:通过UDP协议读写网络数据;SocketChannel
:通过TCP协议读写网络数据;ServerSocketChannel
:用来监听TCP连接,一旦有连接进来通过调用java.nio.channels.ServerSocketChannel#accept
方法获取SocketChannel
连接;如下示例为通过FileChannel
将文件内容读取到Buffer中:
RandomAccessFile aFile = new RandomAccessFile("data/nio-data.txt", "rw");
// 获取FileChannel
FileChannel inChannel = aFile.getChannel();
// 分配一个48字节大小的buffer,内部其实就是一个byte数组
ByteBuffer buf = ByteBuffer.allocate(48);
// 通过FileChanne将文件内容读取到buffer中,返回的bytesRead是读取的字节数
int bytesRead = inChannel.read(buf);
while (bytesRead != -1) {
System.out.println("Read " + bytesRead);
// 将buffer切换到读取模式,之前FileChannel写入buffer是写入模式,在Buffer相关内容会学习这个方法的作用
buf.flip();
// 将buffer中的数据一个一个字节读取出来
while(buf.hasRemaining()){
System.out.print((char) buf.get());
}
// 相当于复位,为下次将文件内容写入buffer做准备buffer内容中会学习该方法作用
buf.clear();
// 继续通过FileChannel将文件内容读取到buffer
bytesRead = inChannel.read(buf);
}
aFile.close();
顾名思义,NIO中的Buffer就是缓冲区,数据都是从Channel读取到Buffer,或者从Buffer写入到Channel,Buffer就是我们编写代码和Channel之间的一个缓冲区。Buffer内部缓存数据的载体通常是一个特定类型的数组(ByteBuffer、IntBuffer…)或一块内存(DirectByteBuffer…)。
使用到了Buffer
的clear
、compact
、flip
等方法,在了解这些方法之前我们先了解Buffer中维护缓存数据的几个属性: position
、limit
、capacity
和mark
.
Buffer中维护了如下几个属性:
allocate(size)
方法中指定的,一旦Buffer创建好该值不能更改;Limit - 1
),Limit总是小于或等于Capacity;此外Buffer中还维护了Mark
属性,Mark
顾名思义就是标记,举个例子:假设要往Buffer中放入10个数字,当放到第五个数字时调用Buffer的mark()方法标记下,此时Buffer中mark属性的值就是4(从0开始),接着继续往Buffer中放入剩下的数字,此时position值为9,若此时调用Buffer的reset方法,则position被重置为mark的值(4),可以继续往Buffer中放入数据,覆盖mark位置之后的数据。
Buffer提供了几个常用的方法来修改上述几个属性的值,包括clear
、flip
、rewind
、slice
、duplicate
等。
clear方法的源代碼如下:
public Buffer clear() {
position = 0;
limit = capacity;
mark = -1;
return this;
}
可见clear方法只是将position
重置为0,将limit
重置为capacity
,相当于将Buffer恢复到初始状态,但Buffer里面保存的数据并未清除,当下次向Buffer中写入数据时会才会将这些数据清除。
flip操作的源码如下:
public Buffer flip() {
limit = position;
position = 0;
mark = -1;
return this;
}
可见flip方法只是将limit
重置为position
,将position
值重置为0,mark
重置为-1。那么为什么在切换读写buffer模式时需要调用flip方法呢,如下图所示:
灰色部分代表该位置实际不存在,可见调用flip方法的作用,将position指向第0个位置,limit指向可以读取的最后一个数据的下一个位置,这样限定了从Buffer读取数据的首末位置(读取position到limit之间的数据)。每次由向Buffer写入数据切换到从Buffer读取数据前必须调用flip方法,否则读取的数据内容不确定
。
rewind操作源码如下:
public Buffer rewind() {
position = 0;
mark = -1;
return this;
}
rewind会将position重新置为0,通常用在这样希望重复读取Buffer的场景,比如已经向Buffer中写入了数据并且调用flip限定读取范围后读取了Buffer一次,然后又想再次读取Buffer的内容,就可以调用rewind方法将position重置为0(limit位置不变),重新读取数据。
使用当前Buffer创建一个新的Buffer,新Buffer和原来的Buffer使用同一个数据载体(同一个数组或同一块内存),因此,当修改旧Buffer中数据时,会影响到新Buffer中的数据,新Buffer中的数据时旧Buffer中position到limit之间的数据,slice操作过程如下图所示:
旧Buffer如果是只读的,那么slice返回的Buffer也是只读的。新Buffer数据载体和旧Buffer是共享的,但position
、limit
、capacity
等属性是独立于旧Buffer的。
DirectByteBuffer用于管理(分配、回收、读写数据)堆外内存。堆外内存是相对于JVM堆内内存来说的,堆内内存是jvm所管控的Java进程内存,我们平时在Java中创建的对象都处于堆内内存中,并且它们遵循jvm的内存管理机制,jvm会采用GC回收机制统一管理堆内内存。堆外内存就是存在于jvm管控之外的一块内存区域,因此它是不受jvm的管控。
DirectByteBuffer是Java用于实现堆外内存的一个重要类,我们可以通过该类实现堆外内存的创建、使用和销毁。
使用如下代码即可分配一块大小为1024的堆外内存:
ByteBuffer directBuffer = ByteBuffer.allocateDirect(1024);
DirectByteBuffer提供了putXXX和getXXX系列方法来使用堆外内存,例如java.nio.DirectByteBuffer#putShort(short)
方法将一个short值放入到堆外内存当中,java.nio.DirectByteBuffer#getShort(long)
方法用于将内外内存的short数据取回来。
MappedByteBuffer是DirectByteBuffer的父类,MappedByteBuffer通过java.nio.channels.FileChannel#map
将一个文件映射到一块堆外内存区域,本质上是通过反射构造了一个DirectByteBuffer对象,并持有一个指向该文件的文件描述符(FileDescriptor
,非文件映射的堆外内存该描述符为null)。使用MappedByteBuffer减少了一次文件数据由内核空间拷贝到用户进程空间的过程。更多详细信息将会在零拷贝相关文章中分析。
Selector用语检测一个或多个NIO Channel是否可读、可写、可连接等状态,使用Selector可以实现用一个线程来管理多个Channel,从而减少线程的数量。如下示例为向Selector注册Channel:
// 打开一个监听连接的socket
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
// 设置该socket以非阻塞模式监听
serverSocketChannel.configureBlocking(false);
// 绑定 地址+端口
ServerSocket serverSocket = serverSocketChannel.socket();
serverSocket.bind(new InetSocketAddress(8899));
// 打开一个Selector
Selector selector = Selector.open();
// 向Selector注册监听连接的Channel,并传入感兴趣的操作,返回一个SelectionKey
SelectionKey listenKey = serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
注册完得到一个对SelectionKey.OP_ACCEPT
操作感兴趣的SelectionKey。
与Selector密切相关的另一组件是SelectionKey。当向Selector注册一个Channel时会返回一个SelectionKey。一个SelectionKey在以下任何一种情况都会失效:
Selector的实现中会维护三个SelectionKey的集合:
key set
集合添加一个SelectionKey;selected-key set
集合中;cancelled-key
,被cancel的key将会在下一次select操作时从key set
删除,对应的Channel也会从Selector注销;SelectionKey的实现中包含两个操作集合:
interest set
集合中时,该SelectionKey会被选中放到selected-key set
集合当中,并且该操作会加入到ready set
,表示该操作已经就绪;SelectionKey中定义了如下几种操作(OP_WRITE和OP_CONNECT还不是很理解,但貌似大多数情况下使用OP_READ和OP_ACCEPT):
interest set
包含OP_READ
,则会将OP_READ
添加到ready set
当中;interest set
包含OP_WRITE
,则会将OP_WRITE
添加到ready set
当中;interest set
包含OP_CONNECT
,则会将OP_CONNECT
添加到ready set
当中;interest set
包含OP_ACCEPT
,则会将OP_ACCEPT
添加到ready set
当中;此外SelectionKey提供了java.nio.channels.SelectionKey#attach
方法可以在注册Channel时将一个对象保存到SelectionKey当中,在select操作中SelectionKey被选中的话在调用java.nio.channels.SelectionKey#attachment
将该对象取出来,这样可以传递一些上下文信息。
Selector提供了若干个个select相关的方法:
// select本身是阻塞操作,直到有Channel准备好了对应的感兴趣操作,才会返回,返回值为选中的Channel个数
public abstract int select() throws IOException;
// select操作等待timeout时间
public abstract int select(long timeout) throws IOException;
// select立即返回,若没有ready的Channel,则返回0
public abstract int selectNow() throws IOException;
// jdk11提供,等待Channel ready并在action中消费SelectionKey,超时时间为timeout
public int select(Consumer<SelectionKey> action, long timeout) throws IOException
public int select(Consumer<SelectionKey> action) throws IOException
public int selectNow(Consumer<SelectionKey> action) throws IOException
select系列方法一旦返回并且返回值大于0的话则有Channel已经准备号(ready),接下来可以调用java.nio.channels.Selector#selectedKeys
方法获取已经ready的SelectionKey的集合。接着遍历SelectionKey从调用SelectionKey的如下方法获取对应的Channel和selector:
public abstract SelectableChannel channel();
public abstract Selector selector();
如下示例为使用Selector + Channel + Buffer实现一个简化版聊天程序的服务端服务端:
import java.net.InetSocketAddress;
import java.net.ServerSocket;
import java.nio.ByteBuffer;
import java.nio.channels.SelectionKey;
import java.nio.channels.Selector;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.nio.charset.Charset;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
public class ChatServer {
// 用于保存所有客户端
private static Map<SocketChannel, String> clientMap = new HashMap<>();
public static void main(String[] args) throws Exception {
// 获取一个监听连接的Channel
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
// 在selector编程中一定要设置为非阻塞模式
serverSocketChannel.configureBlocking(false);
// 绑定ip地址和端口
ServerSocket serverSocket = serverSocketChannel.socket();
serverSocket.bind(new InetSocketAddress(8899));
// 获取selector
Selector selector = Selector.open();
// 将监听Channel注册到selector中,对应SelectionKey感兴趣的操作为OP_ACCEPT,即接收连接
serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
while (true) {
// select阻塞直到有ready的Channel
selector.select();
// 获取ready的SelectionKey集合
Set<SelectionKey> selectionKeys = selector.selectedKeys();
// 遍历SelectionKey集合针对不同的操作做相应的处理
selectionKeys.forEach(selectionKey -> {
try {
final SocketChannel client;
final String key;
if (selectionKey.isAcceptable()) {
// SelectionKey ready的操作为接收连接,获取Channel,接收连接(客户端和服务端accept后的Channel通信)
client = ((ServerSocketChannel)selectionKey.channel()).accept();
// 设置为非阻塞模式
client.configureBlocking(false);
// 注册Channel到selector中,对应SelectionKey感兴趣的操作为OP_READ
client.register(selector, SelectionKey.OP_READ);
key = "[" + UUID.randomUUID() + "]";
// 保存客户端用于群发消息
clientMap.put(client, key);
} else if (selectionKey.isReadable()) {
// SelectionKey ready的操作为读取数据,同样先获取到Channel(与客户端通信的那个SocketChannel)
client = (SocketChannel)selectionKey.channel();
ByteBuffer readBuffer = ByteBuffer.allocate(1024);
key = clientMap.get(client);
// 从Channel将数据读取到ByteBuffer中
int count = client.read(readBuffer);
if (count <= 0) {
return;
}
readBuffer.flip();
Charset charset = Charset.forName("utf-8");
String sendMsg = key + ":" + String.valueOf(charset.decode(readBuffer).array());
// 构造群发消息
ByteBuffer sendBuffer = charset.encode(sendMsg);
// 遍历所有Channel将消息发送给所有客户端
for (SocketChannel channel : clientMap.keySet()) {
channel.write(sendBuffer);
// 因为需要重复的从sendBuffer读取数据到Channel,因此将sendBuffer的position属性置为0
sendBuffer.rewind();
}
}
} catch (Exception ex) {
ex.printStackTrace();
}
});
selectionKeys.clear();
}
}
}
客户端可以开启多个telnet连接localhost 8899端口进行测试。