传统的 BIO
Java IO流是一个庞大的生态环境,其内部提供了很多不同的输入流和输出流,细分下去还有字节流和字符流,甚至还有缓冲流提高 IO 性能,转换流将字节流转换为字符流······看到这些就已经对 IO 产生恐惧了,在日常开发中少不了对文件的 IO 操作,虽然 apache 已经提供了 Commons IO
这种封装好的组件,但面对特殊场景时,我们仍需要自己去封装一个高性能的文件 IO 工具类,本文将会解析 Java IO 中涉及到的各个类,以及讲解如何正确、高效地使用它们。
BIO NIO 和 AIO 的区别
我们会以一个经典的烧开水的例子通俗地讲解它们之间的区别
类型 | 烧开水 |
BIO | 一直监测着某个水壶,该水壶烧开水后再监测下一个水壶 |
NIO | 每隔一段时间就看看所有水壶的状态,哪个水壶烧开水就去处理哪个水壶 |
AIO | 不用监测水壶,每个水壶烧开水后都会主动通知线程说:“我的水烧开了,来处理我吧” |
BIO (同步阻塞 I/O)
这里假设一个烧开水的场景,有一排水壶在烧开水,BIO的工作模式就是, 小菠萝一直看着着这个水壶,直到这个水壶烧开,才去处理下一个水壶。线程在等待水壶烧开的时间段什么都没有做。
NIO(同步非阻塞 I/O)
还拿烧开水来说,NIO的做法是小菠萝一边玩着手机,每隔一段时间就看一看每个水壶的状态,看看是否有水壶的状态发生了改变,如果某个水壶烧开了,可以先处理那个水壶,然后继续玩手机,继续隔一段时间又看看每个水壶的状态。
AIO (异步非阻塞 I/O)
小菠萝觉得每隔一段时间就去看一看水壶太费劲了,于是购买了一批烧开水时可以哔哔响的水壶,于是开始烧水后,小菠萝就直接去客厅玩手机了,水烧开时,就发出“哔哔”的响声,通知小菠萝来关掉水壶。
什么是流
知识科普:我们知道任何一个文件都是以二进制形式存在于设备中,计算机就只有 0
和 1
,你能看见的东西全部都是由这两个数字组成,你看这篇文章时,这篇文章也是由01组成,只不过这些二进制串经过各种转换演变成一个个文字、一张张图片跃然屏幕上。
而流就是将这些二进制串在各种设备之间进行传输,如果你觉得有些抽象,我举个例子就会好理解一些:
“下图是一张图片,它由01串组成,我们可以通过程序把一张图片拷贝到一个文件夹中,
把图片转化成二进制数据集,把数据一点一点地传递到文件夹中 , 类似于水的流动 , 这样整体的数据就是一个数据流
”
IO 流读写数据的特点:
- 顺序读写。读写数据时,大部分情况下都是按照顺序读写,读取时从文件开头的第一个字节到最后一个字节,写出时也是也如此(RandomAccessFile 可以实现随机读写)
- 字节数组。读写数据时本质上都是对字节数组做读取和写出操作,即使是字符流,也是在字节流基础上转化为一个个字符,所以字节数组是 IO 流读写数据的本质。
流的分类
根据数据流向不同分类:输入流 和 输出流
- 输入流:从磁盘或者其它设备中将数据输入到进程中
- 输出流:将进程中的数据输出到磁盘或其它设备上保存
1
图示中的硬盘只是其中一种设备,还有非常多的设备都可以应用在IO流中,例如:打印机、硬盘、显示器、手机······
根据处理数据的基本单位不同分类:字节流 和 字符流
- 字节流:以字节(8 bit)为单位做数据的传输
- 字符流:以字符为单位(1字符 = 2字节)做数据的传输
“字符流的本质也是通过字节流读取,Java 中的字符采用 Unicode 标准,在读取和输出的过程中,通过以字符为单位,查找对应的码表将字节转换为对应的字符。
”
面对字节流和字符流,很多读者都有疑惑:什么时候需要用字节流,什么时候又要用字符流?
我这里做一个简单的概括,你可以按照这个标准去使用:
字符流只针对字符数据进行传输,所以如果是文本数据,优先采用字符流传输;除此之外,其它类型的数据(图片、音频等),最好还是以字节流传输。
根据这两种不同的分类,我们就可以做出下面这个表格,里面包含了 IO 中最核心的 4 个顶层抽象类:
数据流向 / 数据类型 | 字节流 | 字符流 |
输入流 | InputStream | Reader |
输出流 | OutputStream | Writer |
现在看 IO 是不是有一些思路了,不会觉得很混乱了,我们来看这四个类下的所有成员。
[来自于 cxuan 的 《Java基础核心总结》]
看到这么多的类是不是又开始觉得混乱了,不要慌,字节流和字符流下的输入流和输出流大部分都是一一对应的,有了上面的表格支撑,我们不需要再担心看见某个类会懵逼的情况了。
看到 Stream
就知道是字节流,看到 Reader / Writer
就知道是字符流。
这里还要额外补充一点:Java IO 提供了字节流转换为字符流的转换类,称为转换流。
转换流 / 数据类型 | 字节流与字符流之间的转换 |
(输入)字节流 => 字符流 | InputStreamReader |
(输出)字符流 => 字节流 | OutputStreamWriter |
注意字节流与字符流之间的转换是有严格定义的:
- 输入流:可以将字节流 => 字符流
- 输出流:可以将字符流 => 字节流
为什么在输入流不能字符流 => 字节流,输出流不能字节流 => 字符流?
“在存储设备上,所有数据都是以字节为单位存储的,所以输入到内存时必定是以字节为单位输入,输出到存储设备时必须是以字节为单位输出,字节流才是计算机最根本的存储方式,而字符流是在字节流的基础上对数据进行转换,输出字符,但每个字符依旧是以字节为单位存储的。
”
节点流和处理流
在这里需要额外插入一个小节讲解节点流和处理流。
- 节点流:节点流是真正传输数据的流对象,用于向特定的一个地方(节点)读写数据,称为节点流。例如 FileInputStream
- 处理流:处理流是对节点流的封装,使用外层的处理流读写数据,本质上是利用节点流的功能,外层的处理流可以提供额外的功能。处理流的基类都是以
Filter
开头。
1
上图将 ByteArrayInputStream
封装成 DataInputStream
,可以将输入的字节数组转换为对应数据类型的数据。例如希望读入int
类型数据,就会以2
个字节为单位转换为一个数字。
Java IO 的核心类 File
Java 提供了 File类,它指向计算机操作系统中的文件和目录,通过该类只能访问文件和目录,无法访问内容。它内部主要提供了 3
种操作:
- 访问文件的属性:绝对路径、相对路径、文件名······
- 文件检测:是否文件、是否目录、文件是否存在、文件的读/写/执行权限······
- 操作文件:创建目录、创建文件、删除文件······
上面举例的操作都是在开发中非常常用的,File 类远不止这些操作,更多的操作可以直接去 API 文档中根据需求查找。
访问文件的属性:
API | 功能 |
String getAbsolutePath() | 返回该文件处于系统中的绝对路径名 |
String getPath() | 返回该文件的相对路径,通常与 new File() 传入的路径相同 |
String getName() | 返回该文件的文件名 |
文件检测:
API | 功能 |
boolean isFIle() | 校验该路径指向是否一个文件 |
boolean isDirectory() | 校验该路径指向是否一个目录 |
boolean isExist() | 校验该路径指向的文件/目录是否存在 |
boolean canWrite() | 校验该文件是否可写 |
boolean canRead() | 校验该文件是否可读 |
boolean canExecute() | 校验该文件/目录是否可以被执行 |
操作文件:
API | 功能 |
mkdirs() | 递归创建多个文件夹,路径中间有可能某些文件夹不存在 |
createNewFile() | 创建新文件,它是一个原子操作,有两步:检查文件是否存在、创建新文件 |
delete() | 删除文件或目录,删除目录时必须保证该目录为空 |
多了解一些
文件的读/写/执行权限,在 Windows
中通常表现不出来,而在 Linux
中可以很好地体现这一点,原因是 Linux
有严格的用户权限分组,不同分组下的用户对文件有不同的操作权限,所以这些方法在 Linux
下会比在 Windows
下更好理解。下图是 redis 文件夹中的一些文件的详细信息,被红框标注的是不同用户的执行权限:
- r(Read):代表该文件可以被当前用户读,操作权限的序号是
4
- w(Write):代表该文件可以被当前用户写,操作权限的序号是
2
- x(Execute):该文件可以被当前用户执行,操作权限的序号是
1
分别代表:当前文件的所有者,当前文件所属的用户分组。Linux 下文件的操作权限分为三种用户:
root root
- 文件所有者:拥有的权限是红框中的前三个字母,
-
代表没有某个权限 - 文件所在组的所有用户:拥有的权限是红框中的中间三个字母
- 其它组的所有用户:拥有的权限是红框中的最后三个字母
Java IO 流对象
回顾流的分类有2种:
- 根据数据流向分为输入流和输出流
- 根据数据类型分为字节流和字符流
所以,本小节将以字节流和字符流作为主要分割点,在其内部再细分为输入流和输出流进行讲解。
字节流对象
字节流对象大部分输入流和输出流都是成双成对地出现,所以学习的时候可以将输入流和输出流一一对应的流对象关联起来,输入流和输出流只是数据流向不同,而处理数据的方式可以是相同的。
注意不要认为用什么流读入数据,就需要用对应的流写出数据,在 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() 方法时的位置 |