[TOC] ## 一、IO简介 > 在学习I/O技术时,需要了解几个技术点,包括同步阻塞、同步非阻塞、异步阻塞及异步非阻塞。这些都是I/O模型,是学习I/O、NIO、AIO必须要了解的概念。只有清楚了这些概念,才能更好地理解不同I/O的优势。 - BIO - BlockingIO - 同步式阻塞式IO - NIO - NonBlockingIO - 同步式非阻塞式IO ### 同步、异步、阻塞与非阻塞之间的关系 同步、异步、阻塞与非阻塞可以组合成以下4种排列: 1.同步阻塞 在使用普通的InputStream、OutputStream类时,就是属于同步阻塞,因为执行当前读写任务一直是当前线程,并且读不到或写不出去就一直是阻塞的状态。阻塞的意思就是方法不返回,直到读到数据或写出数据为止。 2.同步非阻塞 NIO技术属于同步非阻塞。当执行`serverSocketChannel.configureBlocking(false)`代码后,也是一直由当前的线程在执行读写操作,但是读不到数据或数据写不出去时读写方法就返回了,继续执行读或写后面的代码。 3.异步阻塞 而异步当然就是指多个线程间的通信。例如,A线程发起一个读操作,这个读操作要B线程进行实现,A线程和B线程就是异步执行了。A线程还要继续做其他的事情,这时B线程开始工作,如果读不到数据,B线程就呈阻塞状态了,如果读到数据,就通知A线程,并且将拿到的数据交给A线程,这种情况是异步阻塞。 4.异步非阻塞 最后一种是异步非阻塞,是指A线程发起一个读操作,这个读操作要B线程进行实现,因为A线程还要继续做其他的事情,这时B线程开始工作,如果读不到数据,B线程就继续执行后面的代码,直到读到数据时,B线程就通知A线程,并且将拿到的数据交给A线程。 > 鉴于NIO优秀的性能和可靠性,905.4王国实际上经历过一次重构。 ## 二、NIO实战基础(三大组件) 常规的I/O(如InputStream和OutputStream)存在很大的缺点,就是它们是阻塞的,而NIO解决的就是常规I/O执行效率低的问题。即采用非阻塞高性能运行的方式来避免出现以前“笨拙”的同步I/O带来的低效率问题。NIO在大文件操作上相比常规I/O更加优秀。 NIO有三大组件:Buffer - 缓冲区,Channel - 通道,Selector - 选择器。 ![](https://img.kancloud.cn/45/a6/45a63ffdb9d909a1f60d22cb51bd96e0_401x296.png) ### 2-1 缓冲区Buffer > 本节介绍缓冲区的操作中,ByteBuffer的一些用法。 NIO中的Buffer是一个用于存储基本数据类型值的容器,它以类似于数组有序的方式来存储和组织数据。每个基本数据类型(除去boolean)都有一个子类与之对应。ByteBuffer是NIO中最常用的缓冲区。 #### 缓冲区操作 - clear(),还原缓冲区。还原缓冲区到初始的状态,包含将位置设置为0,将限制设置为容量,并丢弃标记,即“回到默认”。 - flip()。反转缓冲区。首先将限制设置为当前位置,然后将位置设置为0。为啥不叫做截取有效缓冲区呢?flip()常用在向缓冲区中写入一些数据后,下一步读取缓冲区中的数据之前调用,以改变limit与position的值。 - rewind(),重绕此缓冲区,将位置设置为0并丢弃标记。在重新读取、重新写入时可以使用。 - 使用allocateDirect()方法创建的直接缓冲区如何释放内存呢?有两种办法,一种是手动释放空间,另一种就是交给JVM进行处理。java进程分配直接缓冲区,在java进程结束后也不会马上回收内存,而是会在某个时刻触发GC垃圾回收器进行内存的回收。 - compact(),可以进行缓冲区压缩。 ![](https://img.kancloud.cn/fd/be/fdbe6085c33a9fed237baf8899d0c867_491x364.png) - order(ByteOrder bo)方法,作用:设置字节的排列顺序。不同的CPU在读取字节时的顺序是不一样的,有的CPU从高位开始读,而有的CPU从低位开始读,当这两种CPU传递数据时就要将字节排列的顺序进行统一。 > 字节顺序 > ByteOrder order()方法的作用:获取此缓冲区的字节顺序。新创建的字节缓冲区的顺序始终为BIG_ENDIAN。在读写多字节值以及为此字节缓冲区创建视图缓冲区时,使用该字节顺序。 > 1. `public static final ByteOrder BIG_ENDIAN`:表示BIG-ENDIAN字节顺序的常量。按照此顺序,多字节值的字节顺序是从最高有效位到最低有效位的。 > 2. `public static final ByteOrder LITTLE_ENDIAN`:表示LITTLE-ENDIAN字节顺序的常量。按照此顺序,多字节值的字节顺序是从最低有效位到最高有效位的 - 比较缓冲区的内容是否相同有两种方法:equals()和compareTo()。 - 分配直接内存; - 包装wrap数据的处理。 #### ByteBuffer ##### 从ByteBuffer中读取数据 第一种方法,使用ByteBuffer原生方法get(byte[] dst, int offset, int length) 使用方式: ```java //读取channel中数据 SocketChannel channel = (SocketChannel) skey.channel(); ByteBuffer bf = ByteBuffer.allocate(1024); int read = channel.read(bf); bf.flip(); byte[] bytes = new byte[read]; bf.get(bytes); ``` 查看一下ByteBuffer的`put(byte[] dst)`源码: ```java public final ByteBuffer put(byte[] src) { return put(src, 0, src.length); } public ByteBuffer put(byte[] src, int offset, int length) { checkBounds(offset, length, src.length); if (length > remaining()) throw new BufferOverflowException(); int end = offset + length; for (int i = offset; i < end; i++) this.put(src[i]); return this; } ``` 第二种方法,借助工具类取子数组: ```java //读取channel中数据 SocketChannel channel = (SocketChannel) skey.channel(); ByteBuffer bf = ByteBuffer.allocate(1024); int read = channel.read(bf); bf.flip(); byte[] bytes = ByteUtil.subBytes(bf.array(),bf.position(),bf.limit()); ``` ### 2-2 选择器技术——NIO的核心 选择器实现了I/O通道的多路复用,使用它可以节省CPU资源,提高程序运行效率。多路复用的核心目的就是使用最少的线程去操作更多的通道。 在使用选择器技术时,主要由3个对象以合作的方式来实现线程选择某个通道进行业务处理,这3个对象分别是`Selector`、`SelectionKey`和`SelectableChannel`。 - SelectionKey类的作用是一个标识,这个标识代表SelectableChannel类已经向Selector类注册了。 - ServerSocketChannel类是针对面向流的侦听套接字的可选择通道。 > ServerSocketChannel类、Selector和SelectionKey的详细使用可以参考《NIO与Socket编程技术指南》5.7节。 #### SelectionKey 通过SelectionKey对象来表示SelectableChannel(可选择通道)到选择器的注册。选择器维护了3种SelectionKey-Set(选择键集)。 1. 键集:包含的键表示当前通道到此选择器的注册,也就是通过某个通道的register()方法注册该通道时,所带来的影响是向选择器的键集中添加了一个键。此集合由keys()方法返回。键集本身是不可直接修改的。 2. 已选择键集:在首先调用select()方法选择操作期间,检测每个键的通道是否已经至少为该键的相关操作集所标识的一个操作准备就绪,然后调用selectedKeys()方法返回已就绪键的集合。已选择键集始终是键集的一个子集。 3. 已取消键集:表示已被取消但其通道尚未注销的键的集合。不可直接访问此集合。已取消键集始终是键集的一个子集。在select()方法选择操作期间,从键集中移除已取消的键。 ### 2-3 通道(Channel) 通道是用于I/O操作的连接,更具体地讲,通道代表数据到硬件设备、文件、网络套接字的连接。通道可处于打开或关闭这两种状态,当创建通道时,通道就处于打开状态,一旦将其关闭,则保持关闭状态。一旦关闭了某个通道,则试图对其调用I/O操作时就会导致ClosedChannel Exception异常被抛出,但可以通过调用通道的isOpen()方法测试通道是否处于打开状态以避免出现ClosedChannelException异常。一般情况下,通道对于多线程的访问是安全的。 在每次向选择器注册通道时,就会创建一个选择键(SelectionKey); channel类似于stream,它是读写数据的**双向通道**,可以从channel将数据读入buffer,也可以将buffer的数据写入channel。 常见Channel: - FileChannel - DatagramChannel - SocketChannel - ServerSocketChannel #### ServerSocketChannel > 如何切换ServerSocketChannel通道的阻塞与非阻塞的执行模式呢? `public final SelectableChannel configureBlocking(boolean block)`方法的作用是调整此通道的阻塞模式,传入true是阻塞模式,传入false是非阻塞模式。默认为**阻塞**模式。 设置非阻塞后,read和write方法也呈现此特性(非阻塞)。 ```java //打开通信信道,ServerSocketChannel类是针对面向流的侦听套接字的可选择通道。 ServerSocketChannel serverSocketChannel = ServerSocketChannel.open(); //设置为非阻塞 serverSocketChannel.configureBlocking(false); ``` 通道注册与选择器总结: - 相同的通道可以注册到不同的选择器,返回的SelectionKey不是同一个对象; - 相同的通道重复注册相同的选择器,返回的SelectionKey是同一个对象; ## 三、NIO SocketServer > 这是一个短连接的demo,我们会在后面的部分提供长连接的源码。 ### 3-1 基于使用NIO技术实现一个SocketServer ``` import com.zihan.evm.location.enums.LoginResponseEnum; import com.zihan.evm.location.parser.LocationParserHelper; import com.zihan.evm.location.parser.ResponseHelper; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; import org.springframework.util.Assert; import java.io.IOException; 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.Set; /** * nio socket服务端 */ @Component @Slf4j public class NIOSocketServer { //接受数据缓冲区 private static ByteBuffer sBuffer = ByteBuffer.allocate(1024); //发送数据缓冲区 private static ByteBuffer rBuffer = ByteBuffer.allocate(1024); //选择器(叫监听器更准确些吧应该) private static Selector selector; //解码buffer private Charset cs = Charset.forName("UTF-8"); /** * 启动socket服务,开启监听 * * @param port * @throws IOException */ public void startSocketServer(int port) { try { //打开通信信道 ServerSocketChannel serverSocketChannel = ServerSocketChannel.open(); //设置为非阻塞 serverSocketChannel.configureBlocking(false); //获取套接字 ServerSocket serverSocket = serverSocketChannel.socket(); //绑定端口号 serverSocket.bind(new InetSocketAddress(port)); //打开监听器 selector = Selector.open(); //将通信信道注册到监听器 serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT); //监听器会一直监听,如果客户端有请求就会进入相应的事件处理 while (true) { selector.select();//select方法会一直阻塞直到有相关事件发生或超时 Set<SelectionKey> selectionKeys = selector.selectedKeys();//监听到的事件 for (SelectionKey key : selectionKeys) { handle(key); } selectionKeys.clear();//清除处理过的事件 } } catch (Exception e) { e.printStackTrace(); } } /** * 处理不同的事件 * * @param selectionKey * @throws IOException */ private void handle(SelectionKey selectionKey) throws IOException { ServerSocketChannel serverSocketChannel = null; SocketChannel socketChannel = null; int count = 0; if (selectionKey.isAcceptable()) { //每有客户端连接,即注册通信信道为可读 serverSocketChannel = (ServerSocketChannel) selectionKey.channel(); socketChannel = serverSocketChannel.accept(); socketChannel.configureBlocking(false); socketChannel.register(selector, SelectionKey.OP_READ); } else if (selectionKey.isReadable()) { byte[] response = new byte[0]; try { socketChannel = (SocketChannel) selectionKey.channel(); rBuffer.clear(); count = socketChannel.read(rBuffer); //读取数据 if (count > 0) { rBuffer.flip(); byte[] bytes = rBuffer.array(); //根据bytes完成业务解析功能 response = LocationParserHelper.buildResponse(bytes); Assert.notNull(response,"不能为空"); } }catch (Exception e){ log.error(e.getMessage()); response = ResponseHelper.loginError(LoginResponseEnum.RSP_OTHER.getMsgId()); }finally { //返回数据 sBuffer = ByteBuffer.allocate(response.length); sBuffer.put(response); sBuffer.flip(); socketChannel.write(sBuffer); socketChannel.close(); } } } } ``` 这个NIOServer的末尾使用了`socketChannel.close()`的方法关闭通道,因此是短连接的实现。我们在后面的例子中,还是进行长连接的实现。 ### 3-2 Springboot项目中启动socket server 905.4王国构建过程中,下级平台的账号密码管理、数据的入库都需要数据库的参与。因此,我们可以先构建一个Springboot+Mybatis-plus项目,在此Springboot项目基础上启动Socket Server。 > 如果大家已经有自己的框架了,选取部分代码进行一定程度的改写即可。 那么,如何在Springboot项目中启动用于数据接收的服务端呢?这里有两个点比较重要: - 第一,服务端Server绑定的端口号应该可以设置成可配置的,代码应该实现复用; - 我们知道,Springboot项目中的配置文件是通过yml或者properties文件管理的。想要在服务端Server中读取到配置文件中的配置,首先服务端Server应交给Spring管理——即使用`@Component`标签修饰服务端Server类。 这样,我们就可以在Springboot的main函数中,通过getBean(TCPServer.class)的方式启动服务端了。 ``` @Configuration @EnableLogging @SpringBootApplication @EnableApiIdempotent @ComponentScan("com.zihan.evm.*") @MapperScan("com.zihan.evm.*.mapper,com.zihan.evm.*.dao") public class LocationApplicationMain { public static void main(String[] args) { // SocketServer启动 ApplicationContext applicationContext = SpringApplication.run(LocationApplicationMain.class, args); //使用NIO实现长连接服务端 // applicationContext.getBean(ChannelServer.class).startSocketServer(); //使用Netty实现长连接服务端 applicationContext.getBean(TcpNettyServer.class).startSocketServer(); } ```