所以,本小节将以字节流和字符流作为主要分割点,在其内部再细分为输入流和输出流进行讲解。
字节流对象
字节流对象大部分输入流和输出流都是成双成对地出现,所以学习的时候可以将输入流和输出流一一对应的流对象关联起来,输入流和输出流只是数据流向不同,而处理数据的方式可以是相同的。
注意不要认为用什么流读入数据,就需要用对应的流写出数据,在 Java 中没有这么规定,下图只是各个对象之间的一个对应关系,不是两个类使用时必须强制关联使用。
下面有非常多的类,我会介绍基类的方法,了解这些方法是非常有必要的,子类的功能基于父类去扩展,只有真正了解父类在做什么,学习子类的成本就会下降。
InputStream
InputStream 是字节输入流的抽象基类,提供了通用的读方法,让子类使用或重写它们。下面是 InputStream 常用的重要的方法。
| 重要方法 | 功能 |
| — | — |
| public abstract int read() | 从输入流中读取下一个字节,读到尾部时返回 -1 |
| public int read(byte b[]) | 从输入流中读取长度为 b.length 个字节放入字节数组 b 中 |
| public int read(byte b[], int off, int len) | 从输入流中读取指定范围的字节数据放入字节数组 b 中 |
| public void close() | 关闭此输入流并释放与该输入流相关的所有资源 |
还有其它一些不太常用的方法,我也列出来了。
| 其它方法 | 功能 |
| — | — |
| public long skip(long n) | 跳过接下来的 n 个字节,返回实际上跳过的字节数 |
| public long available() | 返回下一次可读取(跳过)且不会被方法阻塞的字节数的估计值 |
| public synchronized void mark(int readlimit) | 标记此输入流的当前位置,对 reset() 方法的后续调用将会重新定位在 mark() 标记的位置,可以重新读取相同的字节 |
| public boolean markSupported() | 判断该输入流是否支持 mark() 和 reset() 方法,即能否重复读取字节 |
| public synchronized void reset() | 将流的位置重新定位在最后一次调用 mark() 方法时的位置 |
(1)ByteArrayInputStream
ByteArrayInputStream 内部包含一个 buf
字节数组缓冲区,该缓冲区可以从流中读取的字节数,使用 pos
指针指向读取下一个字节的下标位置,内部还维护了一个count
属性,代表能够读取 count
个字节。
必须保证 pos 严格小于 count,而 count 严格小于 buf.length 时,才能够从缓冲区中读取数据
(2)FileInputStream
文件输入流,从文件中读入字节,通常对文件的拷贝、移动等操作,可以使用该输入流把文件的字节读入内存中,然后再利用输出流输出到指定的位置上。
(3)PipedInputStream
管道输入流,它与 PipedOutputStream 成对出现,可以实现多线程中的管道通信。PipedOutputStream 中指定与特定的 PipedInputStream 连接,PipedInputStream 也需要指定特定的 PipedOutputStream 连接,之后输出流不断地往输入流的 buffer
缓冲区写数据,而输入流可以从缓冲区中读取数据。
(4)ObjectInputStream
对象输入流,用于对象的反序列化,将读入的字节数据反序列化为一个对象,实现对象的持久化存储。
(5)PushBackInputStream
它是 FilterInputStream 的子类,是一个处理流,它内部维护了一个缓冲数组buf
。
- 在读入字节的过程中可以将读取到的字节数据回退给缓冲区中保存,下次可以再次从缓冲区中读出该字节数据。所以PushBackInputStream 允许多次读取输入流的字节数据,只要将读到的字节放回缓冲区即可。
需要注意的是如果回推字节时,如果缓冲区已满,会抛出 IOException
异常。
它的应用场景:对数据进行分类规整。
假如一个文件中存储了数字和字母两种类型的数据,我们需要将它们交给两种线程各自去收集自己负责的数据,如果采用传统的做法,把所有的数据全部读入内存中,再将数据进行分离,面对大文件的情况下,例如1G、2G,传统的输入流在读入数组后,由于没有缓冲区,只能对数据进行抛弃,这样每个线程都要读一遍文件。
使用 PushBackInputStream 可以让一个专门的线程读取文件,唤醒不同的线程读取字符:
-
第一次读取缓冲区的数据,判断该数据由哪些线程读取
-
回退数据,唤醒对应的线程读取数据
-
重复前两步
-
关闭输入流
到这里,你是否会想到 AQS
的 Condition
等待队列,多个线程可以在不同的条件上等待被唤醒。
(6)BufferedInputStream
缓冲流,它是一种处理流,对节点流进行封装并增强,其内部拥有一个 buffer
缓冲区,用于缓存所有读入的字节,当缓冲区满时,才会将所有字节发送给客户端读取,而不是每次都只发送一部分数据,提高了效率。
(7)DataInputStream
数据输入流,它同样是一种处理流,对节点流进行封装后,能够在内部对读入的字节转换为对应的 Java 基本数据类型。
(8)SequenceInputStream
将两个或多个输入流看作是一个输入流依次读取,该类的存在与否并不影响整个 IO 生态,在程序中也能够做到这种效果
(9)StringBufferInputStream
将字符串中每个字符的低 8 位转换为字节读入到字节数组中,目前已过期
InputStream 总结:
-
InputStream 是所有输入字节流的抽象基类
-
ByteArrayInputStream 和 FileInputStream 是两种基本的节点流,他们分别从字节数组 和 本地文件中读取数据
-
DataInputStream、BufferedInputStream 和 PushBackInputStream 都是处理流,对基本的节点流进行封装并增强
-
PipiedInputStream 用于多线程通信,可以与其它线程公用一个管道,读取管道中的数据。
-
ObjectInputStream 用于对象的反序列化,将对象的字节数据读入内存中,通过该流对象可以将字节数据转换成对应的对象
OutputStream
OutputStream 是字节输出流的抽象基类,提供了通用的写方法,让继承的子类重写和复用。
| 方法 | 功能 |
| — | — |
| public abstract void write(int b) | 将指定的字节写出到输出流,写入的字节是参数 b 的低 8 位 |
| public void write(byte b[]) | 将指定字节数组中的所有字节写入到输出流当中 |
| public void write(byte b[], int off, int len) | 指定写入的起始位置 offer,字节数为 len 的字节数组写入到输出流当中 |
| public void flush() | 刷新此输出流,并强制写出所有缓冲的输出字节到指定位置,每次写完都要调用 |
| public void close() | 关闭此输出流并释放与此流关联的所有系统资源 |
OutputStream 中大多数的类和 InputStream 是对应的,只不过数据的流向不同而已。从上面的图可以看出:
-
OutputStream 是所有输出字节流的抽象基类
-
ByteArrayOutputStream 和 FileOutputStream 是两种基本的节点流,它们分别向字节数组和本地文件写出数据
-
DataOutputStream、BufferedOutputStream 是处理流,前者可以将字节数据转换成基本数据类型写出到文件中;后者是缓冲字节数组,只有在缓冲区满时,才会将所有的字节写出到目的地,减少了 IO 次数。
-
PipedOutputStream 用于多线程通信,可以和其它线程共用一个管道,向管道中写入数据
-
ObjectOutputStream 用于对象的序列化,将对象转换成字节数组后,将所有的字节都写入到指定位置中
-
PrintStream 在 OutputStream 基础之上提供了增强的功能,即可以方便地输出各种类型的数据(而不仅限于byte型)的格式化表示形式,且 PrintStream 的方法从不抛出 IOEception,其原理是写出时将各个数据类型的数据统一转换为 String 类型,我会在讲解完
字符流对象
字符流对象也会有对应关系,大多数的类可以认为是操作的数据从字节数组变为字符,类的功能和字节流对象是相似的。
字符输入流和字节输入流的组成非常相似,字符输入流是对字节输入流的一层转换,所有文件的存储都是字节的存储,在磁盘上保留的不是文件的字符,而是先把字符编码成字节,再保存到文件中。在读取文件时,读入的也是一个一个字节组成的字节序列,而 Java 虚拟机通过将字节序列,按照2个字节为单位转换为 Unicode 字符,实现字节到字符的映射。
Reader
Reader 是字符输入流的抽象基类,它内部的重要方法如下所示。
| 重要方法 | 方法功能 |
| — | — |
| public int read(java.nio.CharBuffer target) | 将读入的字符存入指定的字符缓冲区中 |
| public int read() | 读取一个字符 |
| public int read(char cbuf[]) | 读入字符放入整个字符数组中 |
| abstract public int read(char cbuf[], int off, int len) | 将字符读入字符数组中的指定范围中 |
还有其它一些额外的方法,与字节输入流基类提供的方法是相同的,只是作用的对象不再是字节,而是字符。
-
Reader 是所有字
符输入流的抽象基类 -
CharArrayReader 和 StringReader 是两种基本的节点流,它们分别从读取 字符数组 和 字符串 数据,StringReader 内部是一个
String
变量值,通过遍历该变量的字符,实现读取字符串,本质上也是在读取字符数组 -
PipedReader 用于多线程中的通信,从共用地管道中读取字符数据
-
BufferedReader 是字符输入缓冲流,将读入的数据放入字符缓冲区中,实现高效地读取字符
-
InputStreamReader 是一种转换流,可以实现从字节流转换为字符流,将字节数据转换为字符
Writer
Reader 是字符输出流的抽象基类,它内部的重要方法如下所示。
| 重要方法 | 方法功能 |
| — | — |
| public void write(char cbuf[]) | 将 cbuf 字符数组写出到输出流 |
| abstract public void write(char cbuf[], int off, int len) | 将指定范围的 cbuf 字符数组写出到输出流 |
| public void write(String str) | 将字符串 str 写出到输出流,str 内部也是字符数组 |
| public void write(String str, int off, int len) | 将字符串 str 的某一部分写出到输出流 |
| abstract public void flush() | 刷新,如果数据保存在缓冲区,调用该方法才会真正写出到指定位置 |
| abstract public void close() | 关闭流对象,每次 IO 执行完毕后都需要关闭流对象,释放系统资源 |
-
Writer 是所有的输出字符流的抽象基类
-
**CharArrayWriter、StringWriter 是两种基本的节点流,它们分别向Char 数组、字符串中写入数据。**StringWriter 内部保存了 StringBuffer 对象,可以实现字符串的动态增长
-
PipedWriter 可以向共用的管道中写入字符数据,给其它线程读取。
-
BufferedWriter 是缓冲输出流,可以将写出的数据缓存起来,缓冲区满时再调用 flush() 写出数据,减少 IO 次数。
-
PrintWriter 和 PrintStream 类似,功能和使用也非常相似,只是写出的数据是字符而不是字节。
-
OutputStreamWriter 将字符流转换为字节流,将字符写出到指定位置
字节流与字符流的转换
从任何地方把数据读入到内存都是先以字节流形式读取,即使是使用字符流去读取数据,依然成立,因为数据永远是以字节的形式存在于互联网和硬件设备中,字符流是通过字符集的映射,才能够将字节转换为字符。
所以 Java 提供了两种转换流:
-
InputStreamReader:从字节流转换为字符流,将字节数据转换为字符数据读入到内存
-
OutputStreamWriter:从字符流转换为字节流,将字符数据转换为字节数据写出到指定位置
了解了 Java 传统的 BIO 中字符流和字节流的主要成员之后,至少要掌握以下两个关键点:
(1)传统的 BIO 是以
流
为基本单位处理数据的,想象成水流,一点点地传输字节数据,IO 流传输的过程永远是以字节
形式传输。
(2)字节流和字符流的区别在于操作的数据单位不相同,字符流是通过将字节数据通过字符集映射成对应的字符,字符流本质上也是字节流。
接下来我们再继续学习 NIO 知识,NIO 是当下非常火热的一种 IO 工作方式,它能够解决传统 BIO 的痛点:阻塞。
-
BIO 如果遇到 IO 阻塞时,线程将会被挂起,直到 IO 完成后才唤醒线程,线程切换带来了额外的开销。
-
BIO 中每个 IO 都需要有对应的一个线程去专门处理该次 IO 请求,会让服务器的压力迅速提高。
我们希望做到的是当线程等待 IO 完成时能够去完成其它事情,当 IO 完成时线程可以回来继续处理 IO 相关操作,不必干干的坐等 IO 完成。在 IO 处理的过程中,能够有一个专门的线程负责监听这些 IO 操作,通知服务器该如何操作。所以,我们聊到 IO,不得不去接触 NIO 这一块硬骨头。
新潮的 NIO
我们来看看 BIO 和 NIO 的区别,BIO 是面向流的 IO,它建立的通道都是单向的,所以输入和输出流的通道不相同,必须建立2个通道,通道内的都是传输==0101001···==的字节数据。
而在 NIO 中,不再是面向流的 IO 了,而是面向缓冲区,它会建立一个通道(Channel),该通道我们可以理解为铁路,该铁路上可以运输各种货物,而通道上会有一个缓冲区(Buffer)用于存储真正的数据,缓冲区我们可以理解为一辆火车。
通道(铁路)只是作为运输数据的一个连接资源,而真正存储数据的是缓冲区(火车)。即通道负责传输,缓冲区负责存储。
理解了上面的图之后,BIO 和 NIO 的主要区别就可以用下面这个表格简单概括。
| BIO | NIO |
| — | — |
| 面向流(Stream) | 面向缓冲区(Buffer) |
| 单向通道 | 双向通道 |
| 阻塞 IO | 非阻塞 IO |
| | 选择器(Selectors) |
缓冲区(Buffer)
缓冲区是存储数据的区域,在 Java 中,缓冲区就是数组,为了可以操作不同数据类型的数据,Java 提供了许多不同类型的缓冲区,除了布尔类型以外,其它基本数据类型都有对应的缓冲区数组对象。
为什么没有布尔类型的缓冲区呢?
在 Java 中,boolean 类型数据只占用
1 bit
,而在 IO 传输过程中,都是以字节为单位进行传输的,所以 boolean 的 1 bit 完全可以使用 byte 类型的某一位,或者 int 类型的某一位来表示,没有必要为了这 1 bit 而专门提供多一个缓冲区。
| 缓冲区 | 解释 |
| — | — |
| ByteBuffer | 存储字节数据的缓冲区 |
| CharBuffer | 存储字符数据的缓冲区 |
| ShortBuffer | 存储短整型数据的缓冲区 |
| IntBuffer | 存储整型数据的缓冲区 |
| LongBuffer | 存储长整型数据的缓冲区 |
| FloatBuffer | 存储单精度浮点型数据的缓冲区 |
| DoubleBuffer | 存储双精度浮点型数据的缓冲区 |
分配一个缓冲区的方式都高度一致:使用allocate(int capacity)
方法。
例如需要分配一个 1024 大小的字节数组,代码就是下面这样子。
ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
缓冲区读写数据的两个核心方法:
-
put():将数据写入到缓冲区中
-
get():从缓冲区中读取数据
缓冲区的重要属性:
-
capacity:缓冲区中最大存储数据的容量,一旦声明则无法改变
-
limit:表示缓冲区中可以操作数据的大小,limit 之后的数据无法进行读写。必须满足 limit <= capacity
-
position:当前缓冲区中正在操作数据的下标位置,必须满足 position <= limit
-
mark:标记位置,调用 reset() 将 position 位置调整到 mark 属性指向的下标位置,实现多次读取数据
缓冲区为高效读写数据而提供的其它辅助方法:
- flip():可以实现读写模式的切换,我们可以看看里面的源码
public final Buffer flip() {
limit = position;
position = 0;
mark = -1;
return this;
}
调用 flip() 会将可操作的大小 limit 设置为当前写的位置,操作数据的起始位置 position 设置为 0,即从头开始读取数据。
-
rewind():可以将 position 位置设置为 0,再次读取缓冲区中的数据
-
clear():清空整个缓冲区,它会将 position 设置为 0,limit 设置为 capacity,可以写整个缓冲区
更多的方法可以去查阅 API 文档,本文碍于篇幅原因就不贴出其它方法了,主要是要理解缓冲区的作用
我们来看一个简单的例子
public Class Main {
public static void main(String[] args) {
// 分配内存大小为11的整型缓存区
IntBuffer buffer = IntBuffer.allocate(11);
// 往buffer里写入2个整型数据
for (int i = 0; i < 2; ++i) {
int randomNum = new SecureRandom().nextInt();
buffer.put(randomNum);
}
// 将Buffer从写模式切换到读模式
buffer.flip();
System.out.println("position >> " + buffer.position()
-
"limit >> " + buffer.limit()
-
"capacity >> " + buffer.capacity());
// 读取buffer里的数据
while (buffer.hasRemaining()) {
System.out.println(buffer.get());
}
System.out.println("position >> " + buffer.position()
-
"limit >> " + buffer.limit()
-
"capacity >> " + buffer.capacity());
}
}
执行结果如下图所示,首先我们往缓冲区中写入 2 个数据,position 在写模式下指向下标 2,然后调用 flip() 方法切换为读模式,limit 指向下标 2,position 从 0 开始读数据,读到下标为 2 时发现到达 limit 位置,不可继续读。
整个过程可以用下图来理解,调用 flip() 方法以后,读出数据的同时 position 指针不断往后挪动,到达 limit 指针的位置时,该次读取操作结束。
介绍完缓冲区后,我们知道它是存储数据的空间,进程可以将缓冲区中的数据读取出来,也可以写入新的数据到缓冲区,那缓冲区的数据从哪里来,又怎么写出去呢?
通道(Channel)
上面我们介绍过,通道是作为一种连接资源,作用是传输数据,而真正存储数据的是缓冲区,所以介绍完缓冲区后,我们来学习通道这一块。
通道是可以双向读写的,传统的 BIO 需要使用输入/输出流表示数据的流向,在 NIO 中可以减少通道资源的消耗。
通道类都保存在 java.nio.channels
包下,我们日常用到的几个重要的类有 4 个:
| IO 通道类型 | 具体类 |
| — | — |
| 文件 IO | FileChannel(用于文件读写、操作文件的通道) |
| TCP 网络 IO | SocketChannel(用于读写数据的 TCP 通道)、ServerSocketChannel(监听客户端的连接) |
| UDP 网络 IO | DatagramChannel(收发 UDP 数据报的通道) |
可以通过 getChannel()
方法获取一个通道,支持获取通道的类如下:
-
文件 IO:FileInputStream、FileOutputStream、RandomAccessFile
-
TCP 网络 IO:Socket、ServerSocket
-
UDP 网络 IO:DatagramSocket
示例:文件拷贝案例
我们来看一个利用通道拷贝文件的例子,需要下面几个步骤:
-
打开原文件的输入流通道,将字节数据读入到缓冲区中
-
打开目的文件的输出流通道,将缓冲区中的数据写到目的地
-
关闭所有流和通道(重要!)
这是一张小菠萝的照片,它存在于d:\小菠萝\
文件夹下,我们将它拷贝到 d:\小菠萝分身\
文件夹下。
public class Test {
/** 缓冲区的大小 */
public static final int SIZE = 1024;
public static void main(String[] args) throws IOException {
// 打开文件输入流
FileChannel inChannel = new FileInputStream(“d:\小菠萝\小菠萝.jpg”).getChannel();
// 打开文件输出流
FileChannel outChannel = new FileOutputStream(“d:\小菠萝分身\小菠萝-拷贝.jpg”).getChannel();
// 分配 1024 个字节大小的缓冲区
ByteBuffer dsts = ByteBuffer.allocate(SIZE);
// 将数据从通道读入缓冲区
while (inChannel.read(dsts) != -1) {
// 切换缓冲区的读写模式
dsts.flip();
// 将缓冲区的数据通过通道写到目的地
outChannel.write(dsts);
// 清空缓冲区,准备下一次读
dsts.clear();
}
inChannel.close();
outChannel.close();
}
}
我画了一张图帮助你理解上面的这一个过程。
有人会问,NIO 的文件拷贝和传统 IO 流的文件拷贝有何不同呢?我们在编程时感觉它们没有什么区别呀,貌似只是 API 不同罢了,我们接下来就去看看这两者之间的区别吧。
BIO 和 NIO 拷贝文件的区别
这个时候就要来了解了解操作系统底层是怎么对 IO 和 NIO 进行区别的,我会用尽量通俗的文字带你理解,可能并不是那么严谨。
操作系统最重要的就是内核,它既可以访问受保护的内存,也可以访问底层硬件设备,所以为了保护内核的安全,操作系统将底层的虚拟空间分为了用户空间和内核空间,其中用户空间就是给用户进程使用的,内核空间就是专门给操作系统底层去使用的。
接下来,有一个 Java 进程希望把小菠萝这张图片从磁盘上拷贝,那么内核空间和用户空间都会有一个缓冲区
- 这张照片就会从磁盘中读出到内核缓冲区中保存,然后操作系统将内核缓冲区中的这张图片字节数据拷贝到用户进程的缓冲区中保存下来,对应着下面这幅图
- 然后用户进程会希望把缓冲区中的字节数据写到磁盘上的另外一个地方,会将数据拷贝到 Socket 缓冲区中,最终操作系统再将 Socket 缓冲区的数据写到磁盘的指定位置上。
这一轮操作下来,我们数数经过了几次数据的拷贝?4
次。有 2 次是内核空间和用户空间之间的数据拷贝,这两次拷贝涉及到用户态和内核态的切换,需要CPU参与进来,进行上下文切换。而另外 2 次是硬盘和内核空间之间的数据拷贝,这个过程利用到 DMA与系统内存交换数据,不需要 CPU 的参与。
导致 IO 性能瓶颈的原因:内核空间与用户空间之间数据过多无意义的拷贝,以及多次上下文切换
| 操作 | 状态 |
| — | — |
| 用户进程请求读取数据 | 用户态 -> 内核态 |
| 操作系统内核返回数据给用户进程 | 内核态 -> 用户态 |
| 用户进程请求写数据到硬盘 | 用户态 -> 内核态 |
| 操作系统返回操作结果给用户进程 | 内核态 -> 用户态 |
在用户空间与内核空间之间的操作,会涉及到上下文的切换,这里需要 CPU 的干预,而数据在两个空间之间来回拷贝,也需要 CPU 的干预,这无疑会增大 CPU 的压力,NIO 是如何减轻 CPU 的压力?运用操作系统的零拷贝技术。
操作系统的零拷贝
所以,操作系统出现了一个全新的概念,解决了 IO 瓶颈:零拷贝。零拷贝指的是内核空间与用户空间之间的零次拷贝。
零拷贝可以说是 IO 的一大救星,操作系统底层有许多种零拷贝机制,我这里仅针对 Java NIO 中使用到的其中一种零拷贝机制展开讲解。
在 Java NIO 中,零拷贝是通过用户空间和内核空间的缓冲区共享一块物理内存实现的,也就是说上面的图可以演变成这个样子。
这时,无论是用户空间还是内核空间操作自己的缓冲区,本质上都是操作这一块共享内存中的缓冲区数据,省去了用户空间和内核空间之间的数据拷贝操作。
现在我们重新来拷贝文件,就会变成下面这个步骤:
-
用户进程通过系统调用
read()
请求读取文件到用户空间缓冲区(第一次上下文切换),用户态 -> 核心态,数据从硬盘读取到内核空间缓冲区中(第一次数据拷贝) -
系统调用返回到用户进程(第二次上下文切换),此时用户空间与内核空间共享这一块内存(缓冲区),所以不需要从内核缓冲区拷贝到用户缓冲区
-
用户进程发出
write()
系统调用请求写数据到硬盘上(第三次上下文切换),此时需要将内核空间缓冲区中的数据拷贝到内核的 Socket 缓冲区中(第二次数据拷贝) -
由 DMA 将 Socket 缓冲区的内容写到硬盘上(第三次数据拷贝),
write()
系统调用返回(第四次上下文切换)
整个过程就如下面这幅图所示。
图中,需要 CPU 参与工作的步骤只有第③个步骤,对比于传统的 IO,CPU 需要在用户空间与内核空间之间参与拷贝工作,需要无意义地占用 2 次 CPU 资源,导致 CPU 资源的浪费。
下面总结一下操作系统中零拷贝的优点:
-
降低 CPU 的压力:避免 CPU 需要参与内核空间与用户空间之间的数据拷贝工作
-
减少不必要的拷贝:避免用户空间与内核空间之间需要进行数据拷贝
上面的图示可能并不严谨,对于你理解零拷贝会有一定的帮助,关于零拷贝的知识点可以去查阅更多资料哦,这是一门大学问。
介绍完通道后,我们知道它是用于传输数据的一种介质,而且是可以双向读写的,那么如果放在网络 IO 中,这些通道如果有数据就绪时,服务器是如何发现并处理的呢?接下来我们去学习 NIO 中的最后一个重要知识点:选择器(Selector)
选择器(Selectors)
选择器是提升 IO 性能的灵魂之一,它底层利用了多路复用 IO机制,让选择器可以监听多个 IO 连接,根据 IO 的状态响应到服务器端进行处理。通俗地说:选择器可以监听多个 IO 连接,而传统的 BIO 每个 IO 连接都需要有一个线程去监听和处理。
图中很明显的显示了在 BIO 中,每个 Socket 都需要有一个专门的线程去处理每个请求,而在 NIO 中,只需要一个 Selector 即可监听各个 Socket 请求,而且 Selector 并不是阻塞的,所以不会因为多个线程之间切换导致上下文切换带来的开销。
在 Java NIO 中,选择器是使用 Selector
类表示,Selector 可以接收各种 IO 连接,在 IO 状态准备就绪时,会通知该通道注册的 Selector,Selector 在下一次轮询时会发现该 IO 连接就绪,进而处理该连接。
Selector 选择器主要用于网络 IO当中,在这里我会将传统的 BIO Socket 编程和使用 NIO 后的 Socket 编程作对比,分析 NIO 为何更受欢迎。首先先来了解 Selector 的基本结构。
| 重要方法 | 方法解析 |
| — | — |
| open() | 打开一个 Selector 选择器 |
| int select() | 阻塞地等待就绪的通道 |
| int select(long timeout) | 最多阻塞 timeout 毫秒,如果是 0 则一直阻塞等待,如果是 1 则代表最多阻塞 1 毫秒 |
| int selectNow() | 非阻塞地轮询就绪的通道 |
在这里,你会看到 select() 和它的重载方法是会阻塞的,如果用户进程轮询时发现没有就绪的通道,操作系统有两种做法:
-
一直等待直到一个就绪的通道,再返回给用户进程
-
立即返回一个错误状态码给用户进程,让用户进程继续运行,不会阻塞
这两种方法对应了同步阻塞 IO 和 同步非阻塞 IO ,这里读者的一点小的观点,请各位大神批判阅读
Java 中的 NIO 不能真正意义上称为 Non-Blocking IO,我们通过 API 的调用可以发现,select() 方法还是会存在阻塞的现象,根据传入的参数不同,操作系统的行为也会有所不同,不同之处就是阻塞还是非阻塞,所以我更倾向于把 NIO 称为 New IO,因为它不仅提供了 Non-Blocking IO,而且保留原有的 Blocking IO 的功能。
了解了选择器之后,它的作用就是:监听多个 IO 通道,当有通道就绪时选择器会轮询发现该通道,并做相应的处理。那么 IO 状态分为很多种,我们如何去识别就绪的通道是处于哪种状态呢?在 Java 中提供了选择键(SelectionKey)。
选择键(SelectionKey)
在 Java 中提供了 4 种选择键:
-
SelectionKey.OP_READ:套接字通道准备好进行读操作
-
SelectionKey.OP_WRITE:套接字通道准备好进行写操作
-
SelectionKey.OP_ACCEPT:服务器套接字通道接受其它通道
-
SelectionKey.OP_CONNECT:套接字通道准备完成连接
在 SelectionKey 中包含了许多属性
-
channel:该选择键绑定的通道
-
selector:轮询到该选择键的选择器
-
readyOps:当前就绪选择键的值
-
interesOps:该选择器对该通道感兴趣的所有选择键
选择键的作用是:在选择器轮询到有就绪通道时,会返回这些通道的就绪选择键(SelectionKey),通过选择键可以获取到通道进行操作。
简单了解了选择器后,我们可以结合缓冲区、通道和选择器来完成一个简易的聊天室应用。
示例:简易的客户端服务器通信
先说明,这里的代码非常的臭和长,不推荐细看,直接看注释附近的代码即可。
我们在服务器端会开辟两个线程
-
Thread1:专门监听客户端的连接,并把通道注册到客户端选择器上
-
Thread2:专门监听客户端的其它 IO 状态(读状态),当客户端的 IO 状态就绪时,该选择器会轮询发现,并作相应处理
public class NIOServer {
Selector serverSelector = Selector.open();
Selector clientSelector = Selector.open();
public static void main(String[] args) throws IOException {
NIOServer server = nwe NIOServer();
new Thread(() -> {
try {
// 对应IO编程中服务端启动
ServerSocketChannel listenerChannel = ServerSocketChannel.open();
listenerChannel.socket().bind(new InetSocketAddress(3333));
listenerChannel.configureBlocking(false);
listenerChannel.register(serverSelector, SelectionKey.OP_ACCEPT);
server.acceptListener();
} catch (IOException ignored) {
}
}).start();
new Thread(() -> {
try {
server.clientListener();
} catch (IOException ignored) {
}
}).start();
}
}
// 监听客户端连接
public void acceptListener() {
while (true) {
if (serverSelector.select(1) > 0) {
Set set = serverSelector.selectedKeys();
Iterator keyIterator = set.iterator();
while (keyIterator.hasNext()) {
SelectionKey key = keyIterator.next();
if (key.isAcceptable()) {
try {
// (1) 每来一个新连接,注册到clientSelector
SocketChannel clientChannel = ((ServerSocketChannel) key.channel()).accept();
clientChannel.configureBlocking(false);
clientChannel.register(clientSelector, SelectionKey.OP_READ);
} finally {
// 从就绪的列表中移除这个key
keyIterator.remove();
}
}
}
}
}
}
// 监听客户端的 IO 状态就绪
public void clientListener() {
while (true) {
// 批量轮询是否有哪些连接有数据可读
if (clientSelector.select(1) > 0) {
Set set = clientSelector.selectedKeys();
Iterator keyIterator = set.iterator();
while (keyIterator.hasNext()) {
SelectionKey key = keyIterator.next();
// 判断该通道是否读就绪状态
if (key.isReadable()) {
try {
// 获取客户端通道读入数据
SocketChannel clientChannel = (SocketChannel) key.channel();
ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
clientChannel.read(byteBuffer);
byteBuffer.flip();
System.out.println(
LocalDateTime.now().toString() + " Server 端接收到来自 Client 端的消息: " +
Charset.defaultCharset().decode(byteBuffer).toString());
} finally {
// 从就绪的列表中移除这个key
keyIterator.remove();
key.interestOps(SelectionKey.OP_READ);
}
}
}
}
}
}