目录
IP知识一网打尽
java IO流是一个庞大的生态环境,其内部提供了很多不同的输入流和输出流,细分下去还有字节流和字符流,甚至还有缓冲流来提高IO的性能,以及转换流将字节流转换为字符流。本文将会解析Java IO中涉及到的各个类,以及讲述如何正确、高效的使用。
BIO、NIO、AIO傻傻分不清楚
类型 | 烧开水 |
---|---|
BIO | 一直检测,直到结束后才去监测下一个 |
NIO | 每隔一段时间就查看所有水壶的状态,那个好了就去处理那个 |
AIO | 不用检测水壶,每个水壶烧开后会主动通知线程。 |
什么是流
任何一个文件都是以二进制形式存在于设备中,而流就是将这些二进制串在各种设备之间进行传输。
- IO流读写数据的特点
- 顺序读写。读写数据的时候,大部分是按照顺序读写。读取时从文件开头到最后一个,写出时也是如此。(RandomAccessFile,可以实现随机读写)
- 字节数组:读写数据时本质上都是对自己数组做出读取和写出操作,即使是字符流,也是字节流基础上转化为一个个字符,所以字节数组是IO流读写数据的本质
流的分类
按数据流向
- 输入流:从磁盘或者其他设备将数据输入到进程中
- 输出流:将进程的数据输出到磁盘或者其他设备上保存
按处理数据的基本单位不同
- 字节流:以字节(8bit)为单位做数据的传输
- 字符流:以字符为单位(1字符=2字节)做数据的传输
字符流的本质也是通过字节流读取,Java 中的字符采用 Unicode 标准,在读取和输出的过程中,通过以字符为单位,查找对应的码表将字节转换为对应的字符。
- 什么时候使用字节流、什么时候使用字符流呢?
- 字符流只针对字符数据进行传输,所以如果是文本数据,优先采用字符流传输
- 字节流:一些其他的数据类型:图片、音频等
按照上面两种不同的分类,我们可以做出下面这个表格:
数据流向/数据类型 | 字节流 | 字符流 |
---|---|---|
输入流 | InputStream | Reader |
输出流 | Outputstream | Writer |
除此之外,Java IO还提供了字节流转换为字符流的转换类,称为转换流
转化流/数据类型 | 字节流与字符流之间的转换 |
---|---|
(输入)字节流=》字符流 | InputStreamReader |
(输出)字符流=》字节流 | OutputStreamWriter |
需要注意的是字节流和字符流之间的转换是有严格定义的:
- 输入流:可以将字节流=》字符流
- 输出流:可以将字符流=》字节流
- 为什么会有这种规定呢?
- 在存储设备上,所有数据都是以字节为单位存储的,所以输入到内存时必定是以字节为单位输入,输出到存储设备时必须是以字节为单位输出,字节流才是计算机最根本的存储方式,而字符流是在字节流的基础上对数据进行转换,输出字符,但每个字符依旧是以字节为单位存储的。
节点流和处理流
- 节点流:节点流是真正传输数据的流对象,用于向特定的一个地方(节点)读写数据,称为节点流
- 处理流:处理流是对结点流的封装,使用外层的处理流读写数据,本质上是利用节点流功能,外层的处理流可以提供额外的功能。
Java IO的核心类File
java提供了File类,它指向计算机操作系统中的文件和目录,通过该类只能访问文件和目录,无法访问内容,它的内部主要提供了三种操作:
- 访问文件的属性:绝对路径、相对路径、文件名等
- 文件检测:是否文件、是否目录、文件是否存在、文件的读写执行权限等
- 操作文件:创建目录、创建删除文件等
文件的读/写/执行权限
- R:代表文件可以读,操作权限序号是4
- W:代表文件可以写,操作权限序号是2
- X:代表文件可以执行,操作权限序号是1
如上图所示:root root 分别代表:当前文件的所有者,当前文件所属的用户分组。Linux下文件的操作权限分为三种用户:
- 文件所有者:拥有的权限是红框中的前三个字母,-表示没有某个权限
- 文件所在组的所有用户:拥有的权限是红框中的中间三个字母
- 其他组的所有用户:拥有的权限是红框中最后的三个字母
Java IO 流对象
流对象的分类有两种:
- 根据数据流向:输入流和输出流
- 根据数据类型:字节流和字符流
字节流对象
字节流对象大部分输入流和输出流都是成双成对出现的,只是数据流向不同,而处理数据的方式可以是相同的
InputStream
InputStream是字节输入流的抽象基类,提供了通用的读方法,让子类使用或重用它们
-
ByteArrayInputStream
其内部包含了一个buf字节数组缓冲区,该缓冲区可以从流中读取的字节数,使用pos指针指向读取下一个字节的下标位置,其内部还维护了一个count属性,代表能够读取count个字节
必须保证pos严格小于count,而count严格小于buf.length时,才能从缓冲区中读取数据
-
FileInputStream
文件输入流,从文件中读入字节,通常对文件的拷贝、移动等操作,可以使用该输入流把文件读入内存中,然后再利用输出流输出到指定位置
-
PipedInputStream
管道输入流,常与PipedOutputStream成对出现,可以实现多线程中的管道通信。PipedOutputStream 中指定与特定的 PipedInputStream 连接,PipedInputStream 也需要指定特定的 PipedOutputStream 连接,之后输出流不断地往输入流的
buffer
缓冲区写数据,而输入流可以从缓冲区中读取数据。 -
ObjectInputStream
对象输入流,用于对象的反序列化,将读入的字节数据反序列化为一个对象,实现对象的持久化存储
-
PushBackInputStream
其是FileInputStream的子类,是一个处理流,内部维护了一个缓冲数组buf
-
在读入字节的过程中可以将读取到的字节数据回退给缓冲区中保存,下次可以再从缓冲区中读取该字节数据,所以其允许多次读取输入流的字节数据,只要将督导的字节放回缓冲区即可
当回退字节时,遇到缓冲区已满,会抛出IOException异常
-
应用场景:对数据进行分类规整
-
-
BufferedInputStream
缓冲流,它是一种处理流,对节点流进行封装并增强,其内部拥有一个buffer缓冲区,用于缓存所有读入的字节,当缓冲区满时才会将所有字节发送给客户端读取,而不是每次只发送一部分数据,提高了效率
-
DataInputStream
数据输入流,它同样是一种处理流,对节点进行封装后,能够在内部对读入的字节转化为对应的java基本数据类型
-
SequenceinputStream
将两个或多个输入流看做一个输入流一次读取,该类的存在与否并不影响整个IO生态,在程序中也能够做到这种效果
总结
- InputStream是所有输入字节流的抽象基类
- ByteArrayInputStream和FileInputStream是两种基本的节点流,他们分别从字节数组和本地文件中读取数据
- DataInputStream、BufferedInputStream和PushBackInputStream都是处理流,对基本的节点流进行封装并增强
- PipiedInputStream:用于多线程通信,可以与其它线程公用一个管道,读取管道中的数据
- ObjectInputStream用于对象的反序列化,将对象的字节数据读入内存中,通过对流对象可以将字节数据转换成对应的对象
OutputStream
OutputSstream是字节输出流的抽象基类,提供了通用的写方法,让继承的子类重写和复用。其中的大多数和InputStream是对应的,只不过数据的流向不同
- OutputStream是所有输出字节流的抽象基类
- ByteArrayOutputStream 和 FileOutputStream 是两种基本的节点流,它们分别向字节数组和本地文件写出数据
- DataOutputStream、BufferedOutputStream 是处理流,前者可以将字节数据转换成基本数据类型写出到文件中;后者是缓冲字节数组,只有在缓冲区满时,才会将所有的字节写出到目的地,减少了 IO 次数。
- PipedOutputStream 用于多线程通信,可以和其它线程共用一个管道,向管道中写入数据
- ObjectOutputStream 用于对象的序列化,将对象转换成字节数组后,将所有的字节都写入到指定位置中
- PrintStream 在 OutputStream 基础之上提供了增强的功能,即可以方便地输出各种类型的数据(而不仅限于byte型)的格式化表示形式,且 PrintStream 的方法从不抛出 IOEception,其原理是写出时将各个数据类型的数据统一转换为 String 类型。
字符流对象
字符流对象也会有对应关系,大多数的类可以认为是操作的数据从字节数组变为字符,类的功能和字节流对象是相似的
字符输入流和字节输入流的组成非常相似,字符输入流是对字节输入流的一层转换,所有文件的存储都是字节的存储,在磁盘上保留的不是文件的字符,而是先把字符编码成字节,再保存到文件中。在读取文件时,读入的也是一个一个字节组成的字节序列,而 Java 虚拟机通过将字节序列,按照2个字节为单位转换为 Unicode 字符,实现字节到字符的映射。
Reader
其是字符输入流的抽象基类
- Reader 是所有字符输入流的抽象基类
- CharArrayReader 和 StringReader 是两种基本的节点流,它们分别从读取 字符数组 和 字符串 数据,StringReader 内部是一个
String
变量值,通过遍历该变量的字符,实现读取字符串,本质上也是在读取字符数组 - PipedReader 用于多线程中的通信,从共用地管道中读取字符数据
- BufferedReader 是字符输入缓冲流,将读入的数据放入字符缓冲区中,实现高效地读取字符
- InputStreamReader 是一种转换流,可以实现从字节流转换为字符流,将字节数据转换为字符
Writer
其是字符输出流的抽象基类
- Writer是所有输出字符流的抽象基类
- CharArrayWriter、StringWriter 是两种基本的节点流,它们分别向Char 数组、字符串中写入数据。StringWriter 内部保存了 StringBuffer 对象,可以实现字符串的动态增长
- PipedWriter 可以向共用的管道中写入字符数据,给其它线程读取。
- BufferedWriter 是缓冲输出流,可以将写出的数据缓存起来,缓冲区满时再调用 flush() 写出数据,减少 IO 次数。
- PrintWriter 和 PrintStream 类似,功能和使用也非常相似,只是写出的数据是字符而不是字节。
- OutputStreamWriter 将字符流转换为字节流,将字符写出到指定位置
字节流与字符流的转换
从任何地方把数据读入到内存都是先以字节流形式读取,即使是使用字符流去读取数据。因为数据永远是以字节的形式存在于互联网和硬件设备中,字符流是通过字符集的映射,才能将字节转换为字符
所以java提供了两种转换流
- InputStreamReader:从字节流转换为字符流,将字节数据转换为字符数据读入到内存中
- OutputStreamWrite:从字符流转换为字节流,将字符数据转换为字节数据写出到指定位置
- 传统的BIO是以流为基本单位处理数据的,想象成水流,一点点地传输字节数据,IO流传输的过程永远是以字节的形式传递的
- 字节流和字符流的区别在于操作的数据单位不同,字符流是通过将字节数据通过字符集映射成对应的字符,字符流本质上也是字节流
NIO
NIO是当下比较火热的一种IO工作方式,解决了传统BIO的痛点:阻塞
- BIO如果需要IO阻塞,线程将会被挂起,直到IO完成后才唤醒线程,线程切换带来了额外的开销
- BIO中每个IO都需要有对应的一个线程去专门处理该次IO请求,这会让服务器压力迅速提高
我们希望的是当线程等待IO完成时能够去完成其他的事情,当IO完成时线程可以回来继续处理IO相关操作,不必等着IO完成。所以需要一个专门的线程负责监听这些IO操作,通知服务器该如何操作。
- 三大核心部分:
- Channel(通道)
- Buffer(缓冲区)
- Selector(选择器)
NIO和BIO之间的区别
BIO | NIO |
---|---|
面向流 | 面向缓冲区 |
单向通道 | 双向通道 |
阻塞IO | 非阻塞IO |
选择器 |
缓冲区
缓冲区是存储数据的区域,在java中缓冲区就是数组,为了可以操作不同数据类型的数据,java提供了很多不同类型的缓冲区,除了布尔类型外,其他基本数据类型都有对应的缓冲区数组对象。
为啥没有布尔类型的缓冲区呢?
因为在java中,boolean类型数据只占用1bit,而在IO传输过程中,都是以字节为单位进行传输的,所以boolean的1bit完全可以使用byte类型的某一位,或者int类型的某一位表示,没有必要单独提供一个缓冲区
分配一个缓冲区的方式都是: allocate(int capacity)
缓冲区读写数据的两个核心方法:
- put():将数据写入到缓冲区中
- get():从缓冲区中读取数据
缓冲区中的重要属性:
- Capacity:缓冲区中最大存储数据的容量,一旦声明无法更改
- limit:表示缓冲区中可以操作数据的大小,limit以后的数据无法进行读写,必须满足limit<=capaity
- position:当前缓冲区正在操作数据的下标位置,要小于等于limit
- mark:标记位置,调用reset()将position位置调整到mark属性指向的下标位置,实现多次读取数据
缓冲区为高效读写数据而提供的其他辅助方法
- flip():可以实现读写模式的切换
- rewind():可以将position位置设置为0,再次读取缓冲区中的数据
- clear():清空整个缓冲器,它会将postion设置为0,limit设置为capacity,可以写整个缓冲区
测试代码:
public class Test {
public static void main(String[] args) {
// 创建一个缓冲区并将最大容量设为11
IntBuffer buffer = IntBuffer.allocate(11);
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());
}
}
通道
通过缓冲区的介绍我们知道,缓冲区时存储数据的空间,进程可以将缓冲区中的数据读取出来,也可以写入到缓冲区中。其中通道就是作为一种连接资源,作用是传输数据。
通道是可以双向读写的,传统BIO需要使用输入/输出流表示数据的流向,在NIO中可以减少通道资源的消耗
思维导图
实例
利用通道拷贝文件的例子,其中的步骤:
- 打开原文件的输入流通道,将字节数据读入到缓冲区中
- 打开目的文件的输出流通道,将缓冲区中的数据写到目的地
- 关闭所有流和通道(重要)
例如将这个照片进行拷贝
public class Test {
/** 缓冲区的大小 */
public static final int SIZE = 1024;
public static void main(String[] args) throws IOException {
// 打开文件输入流
FileChannel inChannel = new FileInputStream("d:\\in\\1.png").getChannel();
// 打开文件输出流
FileChannel outChannel = new FileOutputStream("d:\\out\\1-拷贝.jpg").getChannel();
// 分配 1024 个字节大小的缓冲区
ByteBuffer dsts = ByteBuffer.allocate(SIZE);
// 将数据从通道读入缓冲区
while (inChannel.read(dsts) != -1) {
// 切换缓冲区的读写模式
dsts.flip();
// 将缓冲区的数据通过通道写到目的地
outChannel.write(dsts);
// 清空缓冲区,准备下一次读
dsts.clear();
}
inChannel.close();
outChannel.close();
}
}
IO性能瓶颈
在讲IO性能瓶颈的时候,首先先引入操作系统中的内核概念。
内核既可以访问受保护的内存,也可以访问底层硬件设备,所以为了保留内核的安全,操作系统将底层的虚拟空间分为了用户空间和内核空间。
拿上面的例子为例:
-
这张照片首先会从磁盘中读出到内核缓冲区中保存,然后操作系统将内核缓冲区中的这张照片字节数据拷贝到用户进程的缓冲区中保存下来
-
然后用户进程会希望把缓冲区中的字节数据写到磁盘上的另外一个地方,会将数据拷贝到Socket缓冲区中,最终操作系统再将Socket缓冲区中的数据写到磁盘指定位置上
在这样的一轮操作下,我们进行了四次数据的拷贝,有两次是内核空间和用户空间之间的数据拷贝,这两次涉及道路用户态和内核态的切换,需要CPU参与进来,进行上下文的切换,另外两次是硬盘和内核空间之间的数据拷贝,这时候需要DMA与操作内存交换数据,不需要CPU参见
导致IO性能瓶颈的原因:内核空间与用户空间之间数据过多无意义的拷贝,以及多次上下文切换
操作系统的零拷贝
针对上面的问题,出现了零拷贝。即内核空间与用户空间之间的零次拷贝。
而在Java NIO中,零拷贝就是通过用户空间和内核空间的缓冲区去共享一块物理内存实现的。
此时无论是用户空间还是内核空间操作自己的缓冲区,本质上都是操作这一块共享内存中的缓冲区数据,省去了用户空间和内核空间之间的数据拷贝操作。
此时重新拷贝,步骤变为:
- 用户进程通过系统调用 read()请求读取文件到用户空间缓冲区(第一次上下文切换),用户态 -> 核心态,数据从硬盘读取到内核空间缓冲区中(第一次数据拷贝**)
- 系统调用返回到用户进程(第二次上下文切换),此时用户空间与内核空间共享这一块内存(缓冲区),所以不需要从内核缓冲区拷贝到用户缓冲区
- 用户进程发出
write()
系统调用请求写数据到硬盘上(第三次上下文切换),此时需要将内核空间缓冲区中的数据拷贝到内核的 Socket 缓冲区中(第二次数据拷贝) - 由 DMA 将 Socket 缓冲区的内容写到硬盘上(第三次数据拷贝),write() 系统调用返回(第四次上下文切换)
需要CPU参与工作的步骤只有第三步
优点
- 降低CPU压力:避免 CPU 需要参与内核空间与用户空间之间的数据拷贝工作
- 减少不必要的拷贝:避免用户空间与内核空间之间需要进行数据拷贝
选择器
选择器是提升 IO 性能的灵魂之一,它底层利用了多路复用 IO机制,让选择器可以监听多个 IO 连接,根据 IO 的状态响应到服务器端进行处理。通俗地说:选择器可以监听多个 IO 连接,而传统的 BIO 每个 IO 连接都需要有一个线程去监听和处理。
思维导图
在BIO中,每个Socket都需要一个专门的线程去处理每个请求,而在NIO中,只需要一个Selector即可监听各个Socket请求,而且Selector并不是阻塞的,所以不会因为多个线程之间切换导致上下文切换带来的开销
在NIO中,选择器是使用户Selector类表示,Selector可以接受各种IO连接,在IO状态准备继续时,会通知该通道注册的Selector,Selector在下一次轮询时会发现该IO连接就绪,进而处理该连接。
NIO的作用就是监听多个IO通道,当有通道就绪的时候选择器会轮询发现该通道,并做相应的处理。并且使用选择键去识别就绪的通道处于那种状态。
选择键
在 Java 中提供了 4 种选择键:
- SelectionKey.OP_READ:套接字通道准备好进行读操作
- SelectionKey.OP_WRITE:套接字通道准备好进行写操作
- SelectionKey.OP_ACCEPT:服务器套接字通道接受其它通道
- SelectionKey.OP_CONNECT:套接字通道准备完成连接
在 SelectionKey 中包含了许多属性
- channel:该选择键绑定的通道
- selector:轮询到该选择键的选择器
- readyOps:当前就绪选择键的值
- interesOps:该选择器对该通道感兴趣的所有选择键
选择键的作用是:在选择器轮询到有就绪通道时,会返回这些通道的就绪选择键(SelectionKey),通过选择键可以获取到通道进行操作。
总结
至此NIO 的三大板块基本上都介绍完了,内容总结一下就是:
- Java IO 体系的组成部分:BIO 和 NIO
- BIO 的基本组成部分:字节流,字符流,转换流和处理流
- NIO 的三大重要模块:缓冲区(Buffer),通道(Channel),选择器(Selector)以及它们的作用
- NIO 与 BIO 两者的对比:同步/非同步、阻塞/非阻塞,在文件 IO 和 网络 IO 中,使用 NIO 相对于使用 BIO 有什么优势
彩蛋
最后希望能得到各位的多多支持,要是反响好的话,最近抽时间写一个demo用原生的NIO实现一下:简易的客户端服务器通信
最后
- 如果觉得看完有收获,希望能给我点个赞,这将会是我更新的最大动力,感谢各位的支持
- 欢迎各位关注我的公众号【java冢狐】,专注于java和计算机基础知识,保证让你看完有所收获,不信你打我
- 如果看完有不同的意见或者建议,欢迎多多评论一起交流。感谢各位的支持以及厚爱。
的组成部分:BIO 和 NIO
- BIO 的基本组成部分:字节流,字符流,转换流和处理流
- NIO 的三大重要模块:缓冲区(Buffer),通道(Channel),选择器(Selector)以及它们的作用
- NIO 与 BIO 两者的对比:同步/非同步、阻塞/非阻塞,在文件 IO 和 网络 IO 中,使用 NIO 相对于使用 BIO 有什么优势