Java
共支持 3
种网络编程 I/O
模型:BIO
、NIO
、AIO
。
Java BIO
:同步阻塞(传统阻塞型),服务器实现模式为一个连接一个线程,即客户端有连接请求时服务器端就需要启动一个线程进行处理,如果这个连接不做任何事情会造成不必要的线程开销。Java NIO
:同步非阻塞,服务器实现模式为一个线程处理多个请求(连接),即客户端发送的连接请求都会注册到多路复用器上,多路复用器轮询到连接有 I/O
请求就进行处理。Java AIO(NIO.2)
:异步非阻塞,AIO
引入异步通道的概念,采用了 Proactor
模式,简化了程序编写,有效的请求才启动线程,它的特点是先由操作系统完成后才通知服务端程序启动线程去处理,一般适用于连接数较多且连接时间较长的应用。需要注意的是,Java 的 NIO 并不等同于操作系统层面上的 NIO,Java NIO 实际上是基于 IO 多路复用模型的,同时所用的 NIO 组件在 Linux 系统上是使用 epoll 系统调用实现的。这一点我一开始也弄混了,看了书才搞清楚。
BIO
方式适用于连接数目比较小且固定的架构,这种方式对服务器资源要求比较高,并发局限于应用中,JDK1.4
以前的唯一选择,但程序简单易理解。NIO
方式适用于连接数目多且连接比较短(轻操作)的架构,比如聊天服务器,弹幕系统,服务器间通讯等。编程比较复杂,JDK1.4
开始支持。AIO
方式使用于连接数目多且连接比较长(重操作)的架构,比如相册服务器,充分调用 OS
参与并发操作,编程比较复杂,JDK7
开始支持。Java NIO
全称 Java non-blocking IO
,是指 JDK
提供的新 API
。从 JDK1.4
开始,Java
提供了一系列改进的输入/输出的新特性,被统称为 NIO
(即 NewIO
),是同步非阻塞的。NIO
有三大核心部分:Channel
(通道)、Buffer
(缓冲区)、Selector
(选择器) 。NIO
是面向缓冲区/块编程的。数据读取到一个它稍后处理的缓冲区,需要时可在缓冲区中前后移动,这就增加了处理过程中的灵活性,使用它可以提供非阻塞式的高伸缩性网络。Java NIO
的非阻塞模式,使一个线程从某通道发送请求或者读取数据,但是它仅能得到目前可用的数据,如果目前没有数据可用时,就什么都不会获取,而不是保持线程阻塞,所以直至数据变的可以读取之前,该线程可以继续做其他的事情。非阻塞写也是如此,一个线程请求写入一些数据到某通道,但不需要等待它完全写入,这个线程同时可以去做别的事情。NIO
是可以做到用一个线程来处理多个操作的。假设有 10000
个请求过来,根据实际情况,可以分配 50
或者 100
个线程来处理。不像之前的阻塞 IO
那样,非得分配 10000
个。HTTP 2.0
使用了多路复用的技术,做到同一个连接并发处理多个请求,而且并发请求的数量比 HTTP 1.1
大了好几个数量级。NIO 是一种基于通道和缓冲区的 I/O 方式,它可以使用 native 函数库直接分配堆外内存(区别于 JVM 的运行时数据区),然后通过一个存储在 Java 堆里面的 DirectByteBuffer 对象作为这块内存的直接引用进行操作。这样能在一些场景显著提高性能,因为避免了在 Java 堆和 Native 堆中来回复制数据。
在 Java NIO 中的通道(Channel)就相当于操作系统的内核空间(kernel space)的缓冲区,而缓冲区(Buffer)对应的相当于操作系统的用户空间(user space)中的用户缓冲区(user buffer)。
传统 Socket 的 accept()
方法阻塞(等待客户端连接),输入流的 read()
方法阻塞(等待 OS 将数据从内核拷贝到用户空间)。也就是说 BIO 会让主线程进入阻塞状态,这就非常影响程序的性能,不能充分利用机器资源。但是这样就会有人提出疑问了,那我使用多线程不就可以了吗?
但是在高并发的情况下,会创建很多线程,线程会占用内存,线程之间的切换也会浪费资源开销。
而 NIO 只有在连接/通道真正有读写事件发生时(事件驱动),才会进行读写,就大大地减少了系统的开销。不必为每一个连接都创建一个线程,也不必去维护多个线程。避免了多个线程之间的上下文切换,导致资源的浪费。
BIO
以流的方式处理数据,而 NIO
以块的方式处理数据,块 I/O
的效率比流 I/O
高很多。BIO
是阻塞的,NIO
则是非阻塞的。BIO
基于字节流和字符流进行操作,而 NIO
基于 Channel
(通道)和 Buffer
(缓冲区)进行操作,数据总是从通道读取到缓冲区中,或者从缓冲区写入到通道中。Selector
(选择器)用于监听多个通道的事件(比如:连接请求,数据到达等),因此使用单个线程就可以监听多个客户端通道。Buffer
和 Channel
之间的数据流向是双向的。在实现群聊系统之前,有必要介绍以下 Java NIO 的三大核心组件。分别是:Channel(通道),Buffer(缓冲区),Selector(选择器),也是 Reactor 模型在代码层面的体现。Selector 能让单线程同时处理多个客户端 Channel,非常适用于高并发,传输数据量较小的场景。
传统 IO 是基于字节流和字符流进行操作(面向流编程),而 NIO 基于 Channel 通道和 Buffer 缓冲区进行操作,数据总是从通道读取到缓冲区中,或者从缓冲区写入到通道中。Selector(选择器)用于监听多个通道的事件(如连接打开、数据到达)。因此单个线程可以监听多个数据通道。
图源:https://blog.csdn.net/leo187/article/details/116787166
Channel
都会对应一个 Buffer
。Selector
对应一个线程,一个线程对应多个 Channel
(连接)。Channel
注册到该 Selector
。Channel
是由事件决定的,Event
就是一个重要的概念。Selector
会根据不同的事件,在各个通道上切换。Buffer
就是一个内存块,底层是一个数组。NIO
数据的读取写入是通过 Buffer
,这个和 BIO
是不同的,BIO
中要么是输入流,要么是输出流,不能是双向的。但是 NIO
的 Buffer
可以读也可以写,需要 flip
方法切换。 Channel
是双向的,可以反映底层操作系统的情况,比如 Linux
,底层的操作系统通道就是双向的。Buffer 缓冲区是一个用于存储特定基本类型数据的容器,缓冲区实质上是一个数组。该对象提供了一组方法,可以更轻松地使用内存块,缓冲区对象内置了一些机制,能够跟踪和记录缓冲区的状态变化情况。Channel
提供从文件、网络读取数据的渠道,但是读取或写入的数据都必须经由 Buffer
。
在 NIO
中,Buffer
是一个顶层父类,它是一个抽象类,除了 boolean 其他数据类型都有 Buffer:
Buffer
类定义了所有的缓冲区都具有的四个属性来提供关于其所包含的数据元素的信息:
private int mark = -1; //标记
private int position = 0; //当前位置,默认是从第一个开始(即索引为0)
private int limit; //限制,不能读取或者写入的位置索引
private int capacity; //容量,缓冲区所包含的元素的数量
public static void main(String[] args) {
//ByteBuffer buffer = ByteBuffer.allocateDirect(5); 创建堆外内存块DirectByteBuffer
IntBuffer intBuffer = IntBuffer.allocate(5); //创建堆内内存块HeapByteBuffer
for (int i = 0; i < intBuffer.capacity(); i++) {
intBuffer.put(i * 2);
}
//缓存区是双向的,既可以往缓冲区写入数据,也可以从缓冲区读取数据。但是不能同时进行,需要切换
intBuffer.flip(); //读写模式切换
while (intBuffer.hasRemaining()) { //判断position的索引是否小于limit
System.out.println(intBuffer.get()); //每get一次position就加一
}
}
Channel 是一个对象,可以表示磁盘文件、Socket 套接字。Channel本身并不存储数据,只是负责数据的运输,当然所有数据都通过 Buffer 对象来处理。我们永远不会将字节直接写入通道,而是将数据写入包含一个或者多个字节的缓冲区。同样也不会直接从通道中读取字节,而是将数据从通道读入缓冲区,再从缓冲区获取这个字节。
BIO是面向流(Stream)编程的。NIO
的通道类似于流,但有些区别如下:
Channel 可以同时支持读和写,而 Stream 只能支持单向的读或写(所以分成 InputStream 和 OutputStream)。
Channel 支持异步读写,Stream 通常只支持同步。
NIO
中的通道(Channel
)是双向的,可以从缓冲读数据,也可以写数据到缓冲,当然还需要经过 Buffer。而 BIO
中的 Stream
是单向的,例如 FileInputStream
对象只能进行读取数据的操作。
常见的 Channel 有以下四种:
Socket
。ServerSocket
。FileChannel
主要用来对本地文件进行 IO
操作,常见的方法有:
public int read(ByteBuffer dst)
,从通道读取数据并放到缓冲区中public int write(ByteBuffer src)
,把缓冲区的数据写到通道中public long transferFrom(ReadableByteChannel src, long position, long count)
,从目标通道中复制数据到当前通道public long transferTo(long position, long count, WritableByteChannel target)
,把数据从当前通道复制给目标通道(零拷贝)public static void main(String[] args) throws Exception {
File file = new File("D:\\file1.txt");
FileInputStream fileInputStream = new FileInputStream(file);
FileOutputStream fileOutputStream = new FileOutputStream("D:\\file2.txt");
//获取输入流通道
FileChannel inputStreamChannel = fileInputStream.getChannel();
//获取输出流的通道
FileChannel outputStreamChannel = fileOutputStream.getChannel();
//创建一个缓冲区
ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
while (true) { //循环读取
byteBuffer.clear(); //重置标志位
//从通道读取数据写入缓冲区
int read = inputStreamChannel.read(byteBuffer);
if (read == -1) break; //读取结束
//切换成读模式(Buffer既可读又可写)
byteBuffer.flip();
//将buffer缓冲区数据写入通道channel
outputStreamChannel.write(byteBuffer);
}
fileInputStream.close();
fileOutputStream.close();
}
FileChannel 定义了 transferFrom()
和 transferTo()
两个抽象方法,它通过在通道和通道之间建立连接实现数据传输的。底层就是 sendfile()
系统调用函数。
如果你追溯 Kafka 文件传输的代码,你会发现,最终它调用了 Java NIO 库里的 transferTo
方法:
@Overridepublic
long transferFrom(FileChannel fileChannel, long position, long count) throws IOException{
return fileChannel.transferTo(position, count, socketChannel);
}
这就是 Kafka 为什么那么快的原因,使用了零拷贝技术。
通过ServerSocketChannel.open()
方法可以获取服务器的通道,然后绑定一个地址端口号,接着accept()
方法可获得一个SocketChannel
通道,也就是客户端的连接通道。
public static void main(String[] args) throws Exception{
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
InetSocketAddress address = new InetSocketAddress("127.0.0.1", 6666);
//绑定IP地址、端口号
serverSocketChannel.bind(address);
//创建一个缓冲区
ByteBuffer byteBuffer = ByteBuffer.allocate(10);
while (true) {
SocketChannel socketChannel = serverSocketChannel.accept();
while (socketChannel.read(byteBuffer) != -1) {
System.out.println(new String(byteBuffer.array()));
byteBuffer.clear(); //清空缓冲区,重置标志位
}
}
}
实际上面的例子是阻塞式的,要做到非阻塞还需要使用选择器Selector
。
Selector
翻译成选择器,有些人也会翻译成多路复用器,实际上指的是同一样东西。只有网络IO才会使用选择器,文件IO是不需要使用的。
NIO 中实现非阻塞 I/O 的核心对象是 Selector,Selector 是注册各种 I/O 事件的地方,它可以监听通道的状态,当那些事件发生时,就是 Selector 告诉我们所发生的事件。换句话说就是事件驱动,以此实现**单线程管理多个 Channel **的目的。
Java
的 NIO
是非阻塞 IO
。使用 Selector
选择器可以让一个线程处理多个的客户端连接。Selector
能够检测多个注册的通道上是否有事件发生(注意:多个 Channel
以事件的方式可以注册到同一个 Selector
),如果有事件发生,便获取事件然后针对每个事件进行相应的处理。这样就可以只用一个单线程去管理多个通道,也就是管理多个连接和请求。要使用 Selector,首先要将对应的 Channel 以及 IO 事件(读、写、连接)注册到 Selector,注册后会产生一个 SelectionKey 对象,用于关联 Selector 和 Channel,及后续的 IO 事件处理。
Selector
可以通过它自己的open()
方法创建,借助SelectorProvider
类创建一个新的 Selector 选择器。也可以通过实现SelectorProvider
类的抽象方法openSelector()
来自定义实现一个Selector。Selector 一旦创建将会一直处于 open 状态,直到调用了close()
方法为止。
Selector selector = Selector.open();
/**
* 初始化 ServerSocketChannel
*/
private ServerSocketChannel getServerChannel(Selector selector) throws IOException {
// 开辟一个Channel通道
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
// 通道设置为非阻塞模式
serverSocketChannel.configureBlocking(false);
// 为了将Channel跟Selector绑定在一起,我们需要将Channel注册到Selector上,调用Channel的register()方法
// 通道中数据的事件类型为OP_ACCEPT(通道与选择器之间的桥梁 SelectionKey )
serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
// 通道绑定端口,开始监听
serverSocketChannel.socket().bind(new InetSocketAddress("127.0.0.1", 6666));
return serverSocketChannel;
}
管道 Channel 和 选择器 Selector 的关系:
Selector 与 Channel 的关系图如下:
如上图所示,将管道与注册到选择器上时,register()
方法需要传递2个参数,一个是 Selector 选择器对象,另一个是管道中事件的类型 SelectionKey(用来识别不同管道中的事件内容)。所以SelectionKey
又可以看作是 Channel 和 Selector 之间的一座桥梁,把两者绑定在了一起。
在SelectionKey
类中有四个常量表示四种事件类型:
public abstract class SelectionKey {
//读事件
public static final int OP_READ = 1 << 0;
//写事件
public static final int OP_WRITE = 1 << 2;
//连接事件,Client端支持的一种操作
public static final int OP_CONNECT = 1 << 3;
//连接可接受事件,仅ServerSocketChannel支持
public static final int OP_ACCEPT = 1 << 4;
}