Java的I/O演进之路
I/O模型基本说明
I/O模型:就是用什么样的通道或者说是通信模型和架构进行数据传输和接收,很大程度上决定了程序通信的性能,Java共支持三种网络编程的I/O模型:BIO、NIO、AIO。
实际通信需求下,要根据不同的业务场景和性能需求决定不同的I/O模型。
I/O模型
BIO
同步阻塞式,服务器实现模式为一个连接一个线程,即客户端有连接请求时服务端就需要启动一个进程进行处理,如果这个连接不做任何事情会造成不必要的线程开销。
NIO
同步非阻塞式,服务器实现模式为一个线程处理多个请求,即客户端发送的连接请求都会注册到多路复用器上,多路复用器轮询到连接有I/O请求就进行处理。
AIO
异步非阻塞,服务器实现模式一个有效请求一个线程,客户端的I/O请求都是由OS先完成了再通知服务器应用去启动线程进行处理,一般适用于连接数较多且连接数时间较长的应用。
BIO、NIO、AIO使用场景分析
BIO方式适用于连接数目较小且固定的架构、这种方式对服务器资源要求较高,并发局限于应用中,JDK1.4以前的唯一选择。
NIO方式使用于连接数目多且连接比较短的架构,比如聊天服务器,弹幕系统,服务器间通讯等。JDK1.4开始
AIO方式适用于连接数目多且连接比较长的架构,比如相册服务器,充分利用OS参与并发操作。JDK7开始支持。
BIO深入理解
Java BIO就是传统的Java IO编程,相关接口和类在java.io中
BIO同步阻塞式,服务器实现模式为一个连接一个线程,即客户端有连接请求时服务端就需要启动一个进程进行处理,如果这个连接不做任何事情会造成不必要的线程开销。
Client客户端:
通过Socket对象请求与服务端的连接;从Socket中得到字节输入或者是字节输出流进行数据的读写操作
Server服务端:
通过ServerSocket注册端口;服务端通过调用accept方法用于监听客户端的Socket请求;从Socket中获取字节输入或者字节输出流进行数据的读写操作。
传统BIO编程实例
1 | package cn.xiaohupao; |
1 | package cn.xiaohupao; |
以上通信中,服务端会一直等待客户端消息,如果客户端没有消息的发送,服务端将一直进入阻塞状态。同时服务端是按照行来获取消息的,这意味着客户端也必须按照行进行消息的发送,否则服务端将进入等待消息的阻塞状态。
多发多收的实现
1 | package cn.xiaohupao.two; |
1 | package cn.xiaohupao.two; |
BIO模式下接收多个客户端
上面的案例中一个服务端只能接收一个客户端的通信请求,如果服务端需要处理很多个客户端的消息通信请求应该如何处理呢,此时我们就需要在服务端引入线程,也就是说客户端每发起一个请求,服务端就创建一个新的线程来处理这个客户端的请求,这样就实现了一个客户端一个线程的模型。
1 | package cn.xiaohupao.three; |
1 | package cn.xiaohupao.three; |
1 | package cn.xiaohupao.three; |
- 每个Socket接收到,都会创建一个线程,线程的竞争,切换上下文影响性能;
- 每个线程都会占用栈空间和CPU资源
- 并不是每个Socket都进行IO操作,无意义的线程处理;
- 客户端的并发访问增加的。服务都将呈现1:1线程开销,访问量越大,系统的将发生线程栈溢出,线程创建失败,最终导致进程宕机或者僵死。
伪异步I/O编程
上述案例中:客户端的并发访问增加时。服务端将呈现1:1的线程开销,访问量越大,系统将发生线程栈溢出,线程创建失败。
我们采用一个伪异步I/O的通信框架,采用线程池和任务队列实现,当客户端接入时,将客户端的Socket封装成一个Task交给后端的线程池中进行处理。JDK的线程池维护一个消息队列和N个活跃的线程,对消息队列中的Socket任务进行处理,由于线程池可以设置消息队列的大小和最大线程数,因此,它的资源占用是可控的,无论多少个客户端并发访问,都不会导致资源的耗尽和宕机。
1 | package cn.xiaohupao.four; |
1 | package cn.xiaohupao.four; |
1 | package cn.xiaohupao.four; |
1 | package cn.xiaohupao.four; |
- 伪异步io采用线程池实现,因此避免了为每个请求创建一个独立线程造成线程资源耗尽的问题,但由于底层依然采用的同步阻塞模型,因此无法从根上解决问题。
- 如果单个消息处理的缓慢,或者服务器线程池中的全部线程都被阻塞,那么后续socket的io消息都将在队列中排队。新的Socket请求将被拒绝,客户端会发生大量连接超时。
基于BIO形式下的文件上传
支持任意文件形式的上传
1 | package cn.xiaohupao.five; |
1 | package cn.xiaohupao.five; |
1 | package cn.xiaohupao.five; |
BIO模式下的端口转发思想
需要实现一个客户端的消息可以发送给所有的客户端去接收
1 | package cn.xiaohupao.six; |
1 | package cn.xiaohupao.six; |
基于BIO模式下的即时通信
基于BIO模式下的即时通信,实现客户端与客户端的端口消息转发逻辑。
1 | package cn.xiaohupao.chat.server; |
1 | package cn.xiaohupao.chat.server; |
1 | package cn.xiaohupao.chat.util; |
1 | package cn.xiaohupao.chat.client; |
1 | package cn.xiaohupao.chat.client; |
NIO深入理解
Java NIO也有人称之为java non-blocking IO是从Java1.4开始引入的一个新的IO API,可以替代标准的Java IO API。NIO与原来的IO有同样的作用和目的,但是使用的方式完全不同,NIO支持面向缓冲区的、基于通道的IO操作。NIO将以更加高效的方式进行文件读写操作。NIO可以理解为非阻塞IO,传统的IO的read和write只能阻塞执行,线程在读写IO期间不能干其他事情,比如调用Socket.read()时,如果服务器一直没有数据传输过来,线程一直阻塞,而NIO中可以配置Socket为非阻塞模式。
NIO相关的类都被放在java.nio包及其子包下,并且对原java.io包中的很多类进行改写。
NIO有三大核心部分:Channel(通道)、Buffer(缓冲区)、Selector(选择器)
Java NIO的非阻塞模式,是一个线程从某通道发送请求或者读取数据,但它仅能得到目前可用的数据,如果目前没有数据可用,就什么也不会获取,而不是保持线程阻塞,所以直至数据变的可以读取之前,该线程可以继续做其他事情。非阻塞写也是如此,一个线程请求写入一些数据到某通道,但不需要等待它完全写入,这个线程同时可以去做别的事情。
NIO可以做到用一个线程处理多个操作。假设有1000个请求,根据实际情况,可以分配20或者80个线程来处理。不像BIO,非得分配1000个。
NIO与BIO的比较
BIO以流的方式处理数据,而NIO以块的方式处理数据,块I/O的效率要比流I/O高很多。
BIO是阻塞的,NIO是非阻塞的。
BIO基于字节流和字符流进行操作,而NIO基于Channel和Buffer进行操作,数据总是从通道读取到缓冲区,或者从缓冲区写道通道中。Selector用于监听多个通道的事件,因此使用单个线程就可以监听多个客户端通道。
NIO三大核心原理
NIO有三大核心部分:Channel(通道)、Buffer(缓冲区)、Selector(选择器)
Buffer缓冲区
缓冲区本质上是一块可以写入数据,然后可以从中读取数据的内存。这块内存被包装成NIO Buffer对象,并提供了一组方法,用来方便的访问该块内存。相比较直接对数组的操作,Buffer API更加容易操作和管理。
Buffer类及其子类
Buffer就像一个数组,可以保存多个相同类型的数据。根据数据类型不同,有以下Buffer常用的子类:
ByteBuffer、CharBuffer、ShortBuffer、IntBuffer、LongBuffer、FloatBuffer、DoubleBuffer。
1 | static XxxBuffer allocate(int capacity);//创建一个容量为Capacity的XxxBuffer对象 |
缓冲区的基本属性
Buffer中的重要概念
- 容量capacity:作为一个内存块,Buffer具有一定的固定大小,缓冲区容量不能为负,并且创建后不能更改。
- 限制limit:表示缓冲区中可以操作数据的大小(limit后数据不能进行读写)。缓冲区的限制不能为负,并且不能大于其容量。写入模式,限制等于buffer的容量。读取模式下,limit等于写入的数据量。
- 位置position:下一个要读取或写入的数据索引。缓冲区的位置不能为负,并且不能大于其限制
- 标记mark与重置reset:标记是一个索引,通过Buffer中的Mark方法指定Buffer中一个特定的position,之后可以通过调用reset()方法恢复到这个position。
标记、位置、限制、容量遵循$0<=mark<=position<=limit<=capacity$
Buffer常见方法
1 | Buffer clear();//清空缓冲区并返回对缓冲区的引用 |
1 | package cn.xiaohupao.niotest; |
直接与非直接缓冲区
什么是直接内存与非直接内存
byte buffer可以是两种类型,一种是基于直接内存;另一种是非直接内存。对于直接内存来说,JVM将会在IO操作上具有更高的性能,因为它直接作用于本地系统的IO操作。而非直接内存,也就是堆内存中的数据,如果作IO操作,会先从本进程内存复制到直接内存,再利用本地IO处理。
非直接内存的作用链:本地IO - - > 直接内存 - - > 非直接内存 - - > 直接内存 - - > 本地IO
直接内存的作用链:本地IO - - > 直接内存 - - > 本地IO
在做IO处理时,网络 发送大量数据时,直接内存会具有更高的效率。直接内存使用allocateDirect创建,但是它比申请普通的堆内存需要耗费更高的性能。不过,这部分的数据是在JVM之外的,因此它不会占用应用的内存。所以呢,当有很大的数据要缓存,并且它的生命周期又很长,那么就比较适合使用直接内存。只是一般来说,如果不是能带来很明显的性能提升,还是推荐直接使用堆内存。字节缓冲区是直接缓冲区还是非直接缓冲区可以通过调用其isDirect方法来确定。
使用场景
- 有很大的数据需要存储,它的生命周期又很长
- 适合频繁的IO操作,比如网络并发场景
1 |
|
Channel通道
Java NIO的通道类似于流,但又有些不同:即可以从通道中读取数据,又可以写数据到通道。但流的读写通常是单向的。通道可以非阻塞读取和写入通道,通道可以支持读取或写入缓冲区,也支持异步地读写。
通道:由java.nio.channel表示IO源与目标打开的连接。Channel类似于传统的“流”。只不过Channel本身不能直接访问数据,Channel只能与Buffer进行交互。
NIO的通带类似于流,但有些区别:
- 通道可以同时进行读写,而流只能读或只能写
- 通道可以实现异步读写数据
- 通道可以从缓冲区读数据,也可以写数据到缓冲区
BIO中的流是单向的,例如FileInputStream对象只能进行读取数据的操作,而NIO中的通道是双向的,可以读操作,也可以写操作。
Channel在NIO中是一个接口。
1 | public interface channel extends closeable{} |
常用的Channel实现类
- FileChannel:用于读取、写入、映射和操作文件的通道
- DatagramChannel:通过UDP读写网络中的数据通道
- SocketChannel:通过TCP读写网络中的数据
- ServerSocketChannel:可以监听新进来的TCP连接,对每一个新进来的连接都会创建一个SocketChannel。【ServerSocketChannel类似于ServerSocket;SocketChannel类似于Socket】
FileChannel类
获取通道的一种方式是支持通道的对象调用getChannel()方法。支持通道的类如下:
- FileInputStream
- FileOutputStream
- RandomAccessFile
- DatagramSocket
- Socket
- ServerSocket
获取到通道的其他方式使用Files类的静态方法newByteChannel()获取字节通道。或者通过通道的静态方法open()打开并返回指定通道。
1 | int read(ByteBuffer dst); |
使用ByteBuffer和FileChannel,将“Hello!576!”写入到data01.txt中
1 |
|
使用ByteBuffer和FileChannel,将data01.txt中的数据读入到程序。
1 |
|
使用FileChannel,完成文件拷贝。
1 |
|
分散(Scatter)和聚集(Gather)
分散:是指把Channel通道的数据读入到多个缓冲区中。
聚集写入:是指把多个Buffer中的数据聚散到Channel。
1 |
|
transferFrom()从通道中去复制原通道数据
1 |
|
Selector选择器
Selector是一个Java NIO组件,可以能够检查一个或多个NIO通道,并确定哪些通道已经准备好进行读取或写入。这样,一个单独的线程可以管理多个channel,从而管理多个网络的连接,提高效率。
选择器是SelectableChannle对象的多路复用器,Selector可以同时监控多个SelectableChannel的IO状况,也就是说,利用Selector可使一个单独的线程管理多个Channel。Selector是非阻塞IO的核心。
- Java的NIO,用非阻塞的IO方式。可以用一个线程,处理多个的客户端连接,就会使用到Selector选择器
- Selector能够检测多个注册的通道上是否有事件发生,如果有事件发生,便获取事件然后针对每个事件进行相应的处理。这样就可以只使用一个单线程去管理多个通道,也就是管理多个连接和请求。
- 只有在 连接/通道 真正有读写事件发生时,才会进行读写,就大大地减少了系统开销,并且不必为每个连接都创建一个线程,不用去维护多个线程。
- 避免了多个线程之间的上下文切换导致的开销。
选择器的应用
创建一个Selector:调用Selector.open()方法去创建一个Selector。
1 | Selector selector = Selector.open(); |
向选择器注册通道:SelectableChannel.register(Selector sel, int ops)
1 | //获取通道 |
当调用register(Selector sel, int ops)将通道注册选择器时,选择器对通道的监听事件,需要通过第二个参数ops指定。可以监听的事件类型:
- 读:SelectionKey.OP_READ
- 写:SelectionKey.OP_WRITE
- 连接:SelectionKey.OP_CONNECT
- 接收:SelectionKey.OP_ACCEPT
- 若注册时不知监听一个事件,可以使用“位或”操作符连接。
1 | int interestSET = SelectionKey.OP_READ | SelectionKey.OP_WRITE; |