一、学习背景
最近在做监控系统,前端页面数据通过socket长连接的方式,后端定时分用户推送数据到前端页面。在做的过程中,深感此实现方案的不爽,每个页面的打开,需要后台启动一个线程,实现长连接,线程阻塞。所以想学习下NIO的socketChannel方式,非阻塞的方式,实现前后端socket连接,并实现数据的推送。
二、NIO概述
NIO主要有三大核心:Channel(通道)、Buffer(缓冲区)、Selector(选择器)。传统的IO基于字节流和字符流进行操作,一条线程在读完流中所有数据之前,是阻塞状态。NIO通过Channel和Buffer进行操作,数据总是从通道读取到缓存区Buffer,或者从缓存区Buffer写入到通道。Selector(选择器)用户监听多个通道事件,实现单线程可以监听多个通道,不需要再为每个通道开启一条线程。
传统的IO是面对流的,NIO是面对缓冲区的,这是IO和NIO的最大区别。传统IO调用read()或者write()方法时,线程是阻塞状态,知道有一些数据被读取,或者数据完全写入。NIO是非阻塞的,一个线程对某个通道发出读取数据请求,它仅能获得目前可用的数据,写入到Buffer的数据,如果没有数据可用时,是什么也获取不到,不会让线程阻塞着,所以直至数据变得可读取之前,线程可以干其它事情。
2.1 Channel
NIO中的Channel类似于IO中的stream,只不过Channel是双向的。NIO中主要的Channel有:
- FileChannel——文件的IO
- DatagramChannel——UDP报文
- SocketChannel——TCP的客户端
- ServerSocketChannel——TCP的服务端
2.2 Buffer
缓冲区,NIO中的Buffer分为:ByteBuffer、CharBuffer、DoubleBuffer、FloatBuffer、IntBuffer、LongBuffer、ShortBuffer,分别对应着基本数据类型:byte、char、double、float、int、long、short。
2.3 Selector
选择器,NIO实现单线程管理多个Channel的关键,Channel会注册到Selector上,调用Selector的select()方法,可以循环遍历所有注册的Channel,获取Channel事件,以达到单线程监听多个Channel。
由以上Selector的实现原理,可以知道,Selector适合响应消息短,流量低的连接,如果响应消息很长,很耗时间,会造成其它Channel得不到及时响应,影响整体的处理效率。
三、代码实现说明
3.1 FileChannel
文件类型的管道。通过传统IO读取文件信息代码实现和FileChannel+Buffer读取文件信息的代码实现,来说明FileChannel工作原理。
3.1.1 FileInputStream传统IO实现方式
实现方式可以看出,整个文件需要先加载到内存,变成BufferedInputStream,不适合大文件的读取。
public class IOTest {
public static void main(String[] args){
String filePath = "D:\\cc\\niotest\\nomal_io.txt";
File file =new File(filePath);
if (!file.exists()){
System.out.println("文件不存在");
return;
}
FileInputStream fileInputStream = null;
InputStream inputStream = null;
try {
fileInputStream = new FileInputStream(filePath);
inputStream = new BufferedInputStream(fileInputStream);
byte[] buf = new byte[1024];
int bytesRead = inputStream.read(buf);
while (bytesRead != -1){
for (int i=0;i<bytesRead;i++){
System.out.print((char)buf[i]);
}
bytesRead = inputStream.read(buf);
}
} catch (IOException e){
e.printStackTrace();
}finally {
if (inputStream != null) {
try {
inputStream.close();
} catch (IOException e) {
e.printStackTrace();
}
}
if (fileInputStream != null) {
try {
fileInputStream.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
}
3.1.2 NIO读取文件
public class NioTest {
private static final String FILE_PATH = "D:\\cc\\niotest\\nomal_io.txt";
public static void main(String[] args){
FileInputStream inputStream = null;
try {
inputStream = new FileInputStream(FILE_PATH);
//获取文件的管道
FileChannel fileChannel = inputStream.getChannel();
//设置缓冲区
ByteBuffer buffer = ByteBuffer.allocate(1024);
//通过管道读取数据到缓冲池
int bytesRead = fileChannel.read(buffer);
while (bytesRead != -1){
//切换缓冲池工作模式,需要缓冲池中的数据往外输出
buffer.flip();
while (buffer.hasRemaining()){
byte one = buffer.get();
System.out.print((char)one);
}
buffer.clear();
bytesRead = fileChannel.read(buffer);
}
}catch (Exception e){
e.printStackTrace();
}finally {
if (inputStream != null) {
try {
inputStream.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
}
3.2 Buffer
缓冲池,从3.1中NIO的示例代码可以看出,Buffer在使用时,一般有一下步骤:
- 分配空间(ByteBuffer buffer = ByteBuffer.allocate(1024),还有allocateDirector后面再说)
- 写入数据到Buffer,(int byteReads = fileChannel.read(buffer),其中byteReads标识读取到数据的大小,-1标识文件读取完成)
- 调用flip()方法 (标识缓冲池buffer由写入状态切换到输出状态,可调用get()方法获取)
- 调用clear()方法或者compact()方法,调整buffer池开始写入数据的索引,参见3.2.2小节。
Buffer缓冲池,实际上是一个连续的数组,Channel从文件、网络读取数据到Buffer池,池可以通过get()方法取出数据,或者供其他Channel再次读取。
向Buffer中写数据
- 从Channel中写数据到Buffer——channel.read(buffer)
- 通过Buffer的put()方法——buffer.put()
从Buffer中读取数据
- 从Buffer中读取数据到Channel——channel.write(buf)
- 使用get()方法获取buffer数据——buffer.get()
3.2.1 理解Buffer工作原理
Buffer缓冲区,主要通过四个变量(索引)来记住区中数据的位置,以达到不同通道的读取,以及重复读取。
四个变量分别是:
索引 | 说明 |
capacity | 缓冲区数组的总长度 |
position | 下一个要操作的数据元素的位置 |
limit | 缓冲区数组中不可操作的下一个元素的位置,limit<=capacity |
mark | 用来记录当前position的前一个位置,默认值为-1 |
示例说明,ByteBuffer.allocate(11),通过该代码创建一个大小为11的数组缓冲区,初始时,以上索引的位置如下图,position为0,capacity和limit默认都是数组长度11。
当写入5个字节时,变化如下图
这时,需要将buffer中的数据写入到一个Channel,调用buffer.flip()方法后,以上索引变化如下图
当5个字符都写入到Channel后,调用buffer.clear()方法,清理缓冲区,各索引又回到初始位置,就可以继续向缓冲区中写数据了。
3.2.2 buffer移动position索引的两种方法clear和compact区别
首先,buffer.clear(),真如字面意思,buffer中的数据已经被清理掉了吗?答案是否定的,只是将position索引置为0,可以从0索引位置开始写数据,其实buffer中的数据并没有被真的清理,只是在buffer写入时,被覆盖了。
clear()方法和compact()方法有什么区别?
还是从需求上来看这两个方法的区别。上面的例子,写入的5个数据,通过mark控制,只读取了3个数据,其他两个数据没有读取,这时向先向buffer中写一波数据,如果使用clear(),position索引移动到0位置,就不能在读取没有读过的数据了。
所以有了compact()方法,使用该方法,会将未读数据先拷贝到buffer的起始位置,将position置为最后一个未读元素的后面。
骚操作:
可以使用buffer.mark()方法,标记Buffer中一个特定的position,之后可以使用Buffer.reset()恢复到mark标记的position.
可以使用Buffer.rewind()方法,将position重新设置为0,实现数据重新读取。
3.3 SocketChannel
NIO的强大功能,部分来自于Channel的非阻塞性,传统的socket可能会无限期的阻塞,比如调用accept()方法时,可能会等待一个客户端的连接而阻塞。对于read()方法,可能会因为没有数据可读而阻塞,直到连接端传来数据。但是在调用方法之前,并不清楚其是否阻塞,NIO的Channel一个重要特性,可以通过配置其非阻塞行为,实现非阻塞式的通信。
channel.configureBlocking(false)
在非阻塞式信道上调用一个方法,总是会立即返回,调用的返回值,可以标识指示了所请求的操作完成程度。比如一个非阻塞式的ServerSocketChannel上调用accept()方法,如果没有客户端连接过来,立即返回null,如果有客户端连接过来,就返回SocketChannel对象。
3.3.1 代码样例说明
为了说明非阻塞式信道和阻塞式信道的区别,样例代码在客户端使用NIO实现,服务端分别使用传统IO实现和NIO实现两种方式。
client实现,采用NIO非阻塞方式
注意,在Buffer中数据写入Channel时,因为一次没有办法保证Buffer中多少数据写到Channel,所以需要while循环判断Buffer中是否还有数据没有写入到Channel。
public class Client {
public static void main(String[] args){
ByteBuffer buffer = ByteBuffer.allocate(1024);
SocketChannel socketChannel = null;
try {
socketChannel = SocketChannel.open();
socketChannel.configureBlocking(false);
socketChannel.connect(new InetSocketAddress("127.0.0.1",8080));
if (socketChannel.finishConnect()){
int i = 0;
while (true){
TimeUnit.SECONDS.sleep(5);
String info = "I'm "+i+++"-th information from client";
buffer.put(info.getBytes());
buffer.flip();
while (buffer.hasRemaining()){
socketChannel.write(buffer);
}
buffer.clear();
}
}
} catch (IOException | InterruptedException e){
e.printStackTrace();
} finally {
if (socketChannel != null) {
try {
socketChannel.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
}
server端采用传统IO阻塞方式实现,serverSocket.accept()方法会阻塞到有client接入。
public class ServerByIO {
public static void main(String[] args){
ServerSocket serverSocket = null;
InputStream in = null;
try {
serverSocket = new ServerSocket(8080);
int recvMsgSize = 0;
byte[] recvBuf = new byte[1024];
while (true){
//没有客户端接入,会一直阻塞
Socket clnSocket = serverSocket.accept();
SocketAddress address = clnSocket.getRemoteSocketAddress();
System.out.println("Handling client at "+address);
in = clnSocket.getInputStream();
while ((recvMsgSize = in.read(recvBuf)) != -1){
byte[] temp = new byte[recvMsgSize];
System.arraycopy(recvBuf,0,temp,0,recvMsgSize);
System.out.println(new String(temp));
}
}
} catch (IOException e){
e.printStackTrace();
} finally {
if (serverSocket != null) {
try {
serverSocket.close();
} catch (IOException e) {
e.printStackTrace();
}
}
if (in != null) {
try {
in.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
}
服务端的NIO非阻塞式实现,获取client的socketChannel,设置其非阻塞模式,在调用read方法时,就不会阻塞,会立即返回,大小为0时,说明客户端还在休眠,没有写入数据到Channel。
public class ServerByNIO {
public static void main(String[] args) {
ServerSocketChannel ssc = null;
ByteBuffer buffer = ByteBuffer.allocate(1024);
try {
ssc = ServerSocketChannel.open();
ssc.socket().bind(new InetSocketAddress(8080));
ssc.configureBlocking(false);
while (true) {
SocketChannel clientSocket = ssc.accept();
if (clientSocket == null) {
System.out.println("===");
TimeUnit.SECONDS.sleep(5);
continue;
}
//新的通道对象设置非阻塞模式
clientSocket.configureBlocking(false);
SocketAddress address = clientSocket.getRemoteAddress();
System.out.println("handling client at " + address.toString());
while (true){
int recvSize = clientSocket.read(buffer);
if (recvSize == 0){
System.out.println("还没有数据写入clientSocket通道");
TimeUnit.SECONDS.sleep(1);
continue;
}
buffer.flip();
while (buffer.hasRemaining()) {
System.out.print((char) buffer.get());
}
buffer.clear();
}
}
} catch (IOException | InterruptedException e) {
e.printStackTrace();
} finally {
if (ssc != null) {
try {
ssc.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
}
3.4 Selector
好东西放到最后,Selector闪亮登场。NIO能够可以用单个线程处理多个Channel,Selector功不可没。每个Channel都注册到Selector上,通过遍历注册到Selector上的Channel的准备事件,进行相应的处理,实现单条线程管理多个Channel。
这样避免线程之间切换,每个Channel启用一个线程而带来的系统开销。
3.4.1 Selector创建
selector创建很简单,是一个静态工厂方法,直接调用Selector.open()方法即可创建。
Selector selector = Selector.open()
3.4.2 向Selector注册通道
为了将Channel和Selector配置使用,需要将Channel注册到Selector上,使用方法如下:
//必须开启Channel的非阻塞模式,注意FileChannel开启不了非阻塞模式,所以使用不了Selector
channel.configureBlocking(false);
SelectionKey key = channel.register(selector,SelectionKey.OP_READ)
Channel和Selector一起使用时,Channel必须处于非阻塞模式,很好理解,如果处于阻塞模式,其他的Channel基本很难被轮询到。
注意register()第二个参数,这是一个"interest集合",意味着Selector监听Channel时,对什么事件感兴趣,可以监听四类事件:
- Connect
- Accept
- Read
- Write
通道触发了一个事件,意思是该事件已经就绪。所以
某个Channel成功连接到另一个服务器为"连接就绪";一个server socket channel准备好接收新进入的连接,成为"接收就绪";一个数据可读的通道,可以说是"读就绪";等待写数据的通道是"写就绪"。
四种事件用四个常量来标识,即SelectionKey.OP_CONNECT、SelectionKey.OP_ACCEPT、SelectionKey.OP_READ、SelectionKey.OP_WRITE。
解释下什么是"interest集合",如上四个事件,按顺序,分别对应着 8、16、1、4(可看源码位移运算) ,如果想关注connet和accept,直接对应的两个数值和即可,即24。或者通过位或计算得到,如下:
int interestSet = SelectionKey.OP_READ | SelectionKey.OP_WRITE;
3.4.2 SelectionKey
SelectionKey,是Channel注册到Selector选择器上后返回的对象,记录了Channel的基本信息。存储的信息包括:
- interest集合,记录了注册时设置的监听事件
- ready集合,可记录Channel已经准备好的事件
- Channel
- Selector
- 附加信息,一般附加缓冲区,实现客户端和服务端通道对接双向通信。
(1)interest集合
注册时,关注事件的集合,通过selectionKey.interestOps()方法可以获取,具体关注的事件,可以通过与运算来判断。
int interestSet = selectionKey.interestOps();
boolean isInterestedInAccept = (interestSet & SelectionKey.OP_ACCEPT) == SelectionKey.OP_ACCEPT;
boolean isInterestedInConnect = (interestSet & SelectionKey.OP_CONNECT) == SelectionKey.OP_CONNECT;
boolean isInterestedInRead = (interestSet & SelectionKey.OP_READ) == SelectionKey.OP_READ;
boolean isInterestedInWrite = (interestSet & SelectionKey.OP_WRITE) == SelectionKey.OP_WRITE;
(2)ready集合
ready集合,记录了Channel已经准备就绪操作的集合。selector.selectedKeys()方法可以获取SelectionKey的Set集合,可以遍历每个SelectionKey。
//服务端Channel准备好接收连接
selectionKey.isAcceptable();
//Channel成功连接到服务器
selectionKey.isConnectable();
//Channel数据已写入,可以读取到buffer
selectionKey.isReadable();
//Channel已准备好,可以将buffer数据写入
selectionKey.isWritable();
3.4.3 Selector获取注册Channel方式
Selector两类方式获取通道,select()方法和selectedKeys()方法。
(1)select()
select方法返回是注册时选择监听事件准备好的通道,比如注册时,选择的是Read准备好事件,select()方法只会返回Read准备好的Channel数量
/**
* 阻塞到有监听事件准备好的Channel
*/
select()
/**
* 设定阻塞时长
*/
select(long timeout)
/**
* 立即返回,不阻塞
*/
selectNow()
(2)selectionKeys()
常用方法,遍历所有注册到selector上的SelectionKey,通过selectionKey对象自身判断事件是否准备好(参考3.4.2小节),来执行响应的处理。
(3)wakeUp()
某个线程调用select()方法后阻塞了,即使没有通道已经就绪,也有办法让其从select()方法返回。只要让其它线程在第一个线程调用select()方法的那个对象上调用Selector.wakeup()方法即可。阻塞在select()方法上的线程会立马返回。
如果有其它线程调用了wakeup()方法,但当前没有线程阻塞在select()方法上,下个调用select()方法的线程会立即“醒来(wake up)”。
(4)close()
调用Selector的close()方法,会是注册到Selector上的所有SelectionKey对象都无效,Channel不会关闭。
3.4.4 代码示例使用Selector
重新实现3.3节中示例服务端功能,使用ServerSocketChannel+Selector方式。
需要的注意点:
- while(true)循环中判断是否需要继续走业务流程时,不需要遍历SelectionKey,使用select()方法,关注自己感兴趣的准备好事件,是否有,即可。
- 遍历的末尾处,需要调用itr.remove()方法,因为Selector不会自己移除之前查询到的SelectionKeys。
public class ServerBySelector {
public static void main(String[] args){
Selector selector = null;
ServerSocketChannel ssc = null;
try {
selector = Selector.open();
ssc = ServerSocketChannel.open();
ssc.configureBlocking(false);
ssc.socket().bind(new InetSocketAddress(8080));
ssc.register(selector, SelectionKey.OP_READ);
while (true){
if (selector.select(3000) == 0){
//读准备好的Channel数量
System.out.println("还没有Channel写入数据,等等");
continue;
}
Iterator<SelectionKey> itr = selector.selectedKeys().iterator();
while (itr.hasNext()){
SelectionKey key = itr.next();
if (key.isAcceptable()){
handleAccept(key);
}
if (key.isReadable()){
handleRead(key);
}
//key.isValid(),防止客户端连接暴力断开,这边还操作,造成整个程序不可用
if (key.isValid() && key.isWritable()){
handleWrite(key);
}
if (key.isValid() && key.isConnectable()){
System.out.println("Connectable true");
}
//必须每次移除已经遍历过的SelectionKey示例
itr.remove();
}
}
} catch (IOException e){
e.printStackTrace();
}
}
public static void handleAccept(SelectionKey key) throws IOException {
ServerSocketChannel ssChannel = (ServerSocketChannel) key.channel();
SocketChannel sc = ssChannel.accept();
sc.configureBlocking(false);
sc.register(key.selector(),SelectionKey.OP_READ, ByteBuffer.allocateDirect(1024));
}
public static void handleRead(SelectionKey key) throws IOException {
SocketChannel sc = (SocketChannel) key.channel();
ByteBuffer buf = (ByteBuffer) key.attachment();
long bytesRead = sc.read(buf);
while (bytesRead > 0){
buf.flip();
while (buf.hasRemaining()){
System.out.print((char) buf.get());
}
System.out.println();
buf.clear();
bytesRead = sc.read(buf);
}
if (bytesRead == -1)
sc.close();
}
public static void handleWrite(SelectionKey key) throws IOException {
ByteBuffer buf = (ByteBuffer) key.attachment();
buf.flip();
SocketChannel sc = (SocketChannel) key.channel();
while (buf.hasRemaining()){
sc.write(buf);
}
buf.compact();
}
}