前言
IO是指Input/Output,即输入和输出。以内存为中心:
- Input指从外部读入数据到内存,例如,把文件从磁盘读取到内存,从网络读取数据到内存等等。
- Output指把数据从内存输出到外部,例如,把数据从内存写入到文件,把数据从内存输出到网络等等。
IO流是一种顺序读写数据的模式,它的特点是单向流动。数据类似自来水一样在水管中流动,所以我们把它称为IO流。
Java标准库的java.io包提供了同步IO功能:
- 字节流接口:InputStream/OutputStream;
- 字符流接口:Reader/Writer。
File对象
1 | 要构造一个File对象,需要传入文件路径: |
构造File对象时,既可以传入绝对路径,也可以传入相对路径。绝对路径是以根目录开头的完整路径,例如:
1 | File f = new File("C:\\Windows\\notepad.exe"); |
传入相对路径时,相对路径前面加上当前目录就是绝对路径:
1 | // 假设当前目录是C:\Docs |
可以用 . 表示当前目录,.. 表示上级目录。
File对象有3种形式表示的路径,一种是getPath(),返回构造方法传入的路径,一种是getAbsolutePath(),返回绝对路径,一种是getCanonicalPath,它和绝对路径类似,但是返回的是规范路径。
File对象既可以表示文件,也可以表示目录。特别要注意的是,构造一个File对象,即使传入的文件或目录不存在,代码也不会出错,因为构造一个File对象,并不会导致任何磁盘操作。只有当我们调用File对象的某些方法的时候,才真正进行磁盘操作。
例如,调用isFile(),判断该File对象是否是一个已存在的文件,调用isDirectory(),判断该File对象是否是一个已存在的目录:
用File对象获取到一个文件时,还可以进一步判断文件的权限和大小:
- boolean canRead():是否可读;
- boolean canWrite():是否可写;
- boolean canExecute():是否可执行;
- long length():文件字节大小。
创建和删除文件:
当File对象表示一个文件时,可以通过createNewFile()创建一个新文件,用delete()删除该文件:
创建和删除临时文件:
有些时候,程序需要读写一些临时文件,File对象提供了createTempFile()来创建一个临时文件,以及deleteOnExit()在JVM退出时自动删除该文件。
遍历文件和目录:
1 | import java.io.*; |
和文件操作类似,File对象如果表示一个目录,可以通过以下方法创建和删除目录:
- boolean mkdir():创建当前File对象表示的目录;
- boolean mkdirs():创建当前File对象表示的目录,并在必要时将不存在的父目录也创建出来;
- boolean delete():删除当前File对象表示的目录,当前目录必须为空才能删除成功。
Path 对象
Java标准库还提供了一个Path对象,它位于java.nio.file包。Path对象和File对象类似,但操作更加简单:
1 | import java.io.*; |
InputStream
InputStream就是Java标准库提供的最基本的输入流。它位于java.io这个包里。java.io包提供了所有同步IO的功能。
要特别注意的一点是,InputStream并不是一个接口,而是一个抽象类,它是所有输入流的超类。这个抽象类定义的一个最重要的方法就是int read(),签名如下:
1 | public abstract int read() throws IOException; |
FileInputStream是InputStream的一个子类。顾名思义,FileInputStream就是从文件流中读取数据。下面的代码演示了如何完整地读取一个FileInputStream的所有字节:
1 | public void readFile() throws IOException { |
仔细观察上面的代码,会发现一个潜在的问题:如果读取过程中发生了IO错误,InputStream就没法正确地关闭,资源也就没法及时释放。
因此,我们需要用try … finally来保证InputStream在无论是否发生IO错误的时候都能够正确地关闭:
1 | public void readFile() throws IOException { |
用try … finally来编写上述代码会感觉比较复杂,更好的写法是利用Java 7引入的新的try(resource)的语法,只需要编写try语句,让编译器自动为我们关闭资源。推荐的写法如下:
1 | public void readFile() throws IOException { |
缓冲:
在读取流的时候,一次读取一个字节并不是最高效的方法。很多流支持一次性读取多个字节到缓冲区,对于文件和网络流来说,利用缓冲区一次性读取多个字节效率往往要高很多。InputStream提供了两个重载方法来支持读取多个字节:
- int read(byte[] b):读取若干字节并填充到byte[]数组,返回读取的字节数
- int read(byte[] b, int off, int len):指定byte[]数组的偏移量和最大填充数
1 | public void readFile() throws IOException { |
OutputStream
和InputStream相反,OutputStream是Java标准库提供的最基本的输出流。
和InputStream类似,OutputStream也是抽象类,它是所有输出流的超类。这个抽象类定义的一个最重要的方法就是void write(int b),签名如下:
1 | public abstract void write(int b) throws IOException; |
和InputStream类似,OutputStream也提供了close()方法关闭输出流,以便释放系统资源。要特别注意:OutputStream还提供了一个flush()方法,它的目的是将缓冲区的内容真正输出到目的地。
为什么要有flush()?
因为向磁盘、网络写入数据的时候,出于效率的考虑,操作系统并不是输出一个字节就立刻写入到文件或者发送到网络,而是把输出的字节先放到内存的一个缓冲区里(本质上就是一个byte[]数组),等到缓冲区写满了,再一次性写入文件或者网络。
对于很多IO设备来说,一次写一个字节和一次写1000个字节,花费的时间几乎是完全一样的,所以OutputStream有个flush()方法,能强制把缓冲区内容输出。
通常情况下,我们不需要调用这个flush()方法,因为缓冲区写满了OutputStream会自动调用它,并且,在调用close()方法关闭OutputStream之前,也会自动调用flush()方法。
但是,在某些情况下,我们必须手动调用flush()方法。比如聊天软件。
1 | public void writeFile() throws IOException { |
Filter
InputStream 可以派生出很多子类:
如果我们要给FileInputStream添加缓冲功能,则可以从FileInputStream派生一个类:
BufferedFileInputStream extends FileInputStream
如果要给FileInputStream添加计算签名的功能,类似的,也可以从FileInputStream派生一个类:
DigestFileInputStream extends FileInputStream
如果要给FileInputStream添加加密/解密功能,还是可以从FileInputStream派生一个类:
CipherFileInputStream extends FileInputStream
如果要给FileInputStream添加缓冲和签名的功能,那么我们还需要派生BufferedDigestFileInputStream。如果要给FileInputStream添加缓冲和加解密的功能,则需要派生BufferedCipherFileInputStream。
上述这种通过一个“基础”组件再叠加各种“附加”功能组件的模式,称之为Filter模式(或者装饰器模式:Decorator)。它可以让我们通过少量的类来实现各种功能的组合:
类似的,OutputStream也是以这种模式来提供各种功能。
操作zip
读取zip:
ZipInputStream是一种FilterInputStream,它可以直接读取zip包的内容:
我们来看看ZipInputStream的基本用法。
我们要创建一个ZipInputStream,通常是传入一个FileInputStream作为数据源,然后,循环调用getNextEntry(),直到返回null,表示zip流结束。
一个ZipEntry表示一个压缩文件或目录,如果是压缩文件,我们就用read()方法不断读取,直到返回-1:
1 | try (ZipInputStream zip = new ZipInputStream(new FileInputStream(...))) { |
写入zip
ZipOutputStream是一种FilterOutputStream,它可以直接写入内容到zip包。我们要先创建一个ZipOutputStream,通常是包装一个FileOutputStream,然后,每写入一个文件前,先调用putNextEntry(),然后用write()写入byte[]数据,写入完毕后调用closeEntry()结束这个文件的打包。
1 | try (ZipOutputStream zip = new ZipOutputStream(new FileOutputStream(...))) { |
上面的代码没有考虑文件的目录结构。如果要实现目录层次结构,new ZipEntry(name)传入的name要用相对路径。
读取classpath资源
从classpath读取文件就可以避免不同环境下文件路径不一致的问题:如果我们把default.properties文件放到classpath中,就不用关心它的实际存放路径。
在classpath中的资源文件,路径总是以/开头,我们先获取当前的Class对象,然后调用getResourceAsStream()就可以直接从classpath读取任意的资源文件:
1 | try (InputStream input = getClass().getResourceAsStream("/default.properties")) { |
Reader
Reader是Java的IO库提供的另一个输入流接口。和InputStream的区别是,InputStream是一个字节流,即以byte为单位读取,而Reader是一个字符流,即以char为单位读取:
java.io.Reader是所有字符输入流的超类,它最主要的方法是:
1 | public int read() throws IOException; |
这个方法读取字符流的下一个字符,并返回字符表示的int,范围是0~65535。如果已读到末尾,返回-1。
如果我们读取一个纯ASCII编码的文本文件,上述代码工作是没有问题的。但如果文件中包含中文,就会出现乱码,因为FileReader默认的编码与系统相关,例如,Windows系统的默认编码可能是GBK,打开一个UTF-8编码的文本文件就会出现乱码。
要避免乱码问题,我们需要在创建FileReader时指定编码:
1 | Reader reader = new FileReader("src/readme.txt", StandardCharsets.UTF_8); |
和InputStream类似,Reader也是一种资源,需要保证出错的时候也能正确关闭,所以我们需要用try (resource)来保证Reader在无论有没有IO错误的时候都能够正确地关闭:
1 | try (Reader reader = new FileReader("src/readme.txt", StandardCharsets.UTF_8) { |
Reader还提供了一次性读取若干字符并填充到char[]数组的方法:
1 | public int read(char[] c) throws IOException |
它返回实际读入的字符个数,最大不超过char[]数组的长度。返回-1表示流结束。
1 | public void readFile() throws IOException { |
既然Reader本质上是一个基于InputStream的byte到char的转换器,那么,如果我们已经有一个InputStream,想把它转换为Reader,是完全可行的。InputStreamReader就是这样一个转换器,它可以把任何InputStream转换为Reader。示例代码如下:
1 | // 持有InputStream: |
Writer
是带编码转换器的InputStream,它把byte转换为char,而Writer就是带编码转换器的OutputStream,它把char转换为byte并输出。
除了CharArrayWriter和StringWriter外,普通的Writer实际上是基于OutputStream构造的,它接收char,然后在内部自动转换成一个或多个byte,并写入OutputStream。因此,OutputStreamWriter就是一个将任意的OutputStream转换为Writer的转换器:
1 | try (Writer writer = new OutputStreamWriter(new FileOutputStream("readme.txt"), "UTF-8")) { |