IO是指Input/Output,即输入和输出。以内存为中心:

  • Input指从外部读入数据到内存,例如,把文件从磁盘读取到内存,从网络读取数据到内存等等。
  • Output指把数据从内存输出到外部,例如,把数据从内存写入到文件,把数据从内存输出到网络等等。

InputStream / OutputStream
IO流以byte(字节)为最小单位,因此也称为字节流
在Java中,InputStream代表输入字节流,OuputStream代表输出字节流,这是最基本的两种IO流。
Reader / Writer
如果我们需要读写的是字符,并且字符不全是单字节表示的ASCII字符,那么,按照char来读写显然更方便,这种流称为字符流
Java提供了Reader和Writer表示字符流,字符流传输的最小数据单位是char。
同步和异步
同步IO是指,读写IO时代码必须等待数据返回后才继续执行后续代码,它的优点是代码编写简单,缺点是CPU执行效率低。
而异步IO是指,读写IO时仅发出请求,然后立刻执行后续代码,它的优点是CPU执行效率高,缺点是代码编写复杂。
Java标准库的包java.io提供了同步IO,而java.nio则是异步IO。上面我们讨论的InputStream、OutputStream、Reader和Writer都是同步IO的抽象类,对应的具体实现类,以文件为例,有FileInputStream、FileOutputStream、FileReader和FileWriter。
IO流是一种流式的数据输入/输出模型:

  • 二进制数据以byte为最小单位在InputStream/OutputStream中单向流动;
  • 字符数据以char为最小单位在Reader/Writer中单向流动。

Java标准库的java.io包提供了同步IO功能:

  • 字节流接口:InputStream/OutputStream;
  • 字符流接口:Reader/Writer。

    1.File对象

    在计算机系统中,文件是非常重要的存储方式。Java的标准库java.io提供了File对象来操作文件和目录。
    要构造一个File对象,需要传入文件路径:

    1. public class Main {
    2. public static void main(String[] args) {
    3. File f = new File("C:\\Windows\\notepad.exe");
    4. System.out.println(f);
    5. }
    6. }

    可以用.表示当前目录,..表示上级目录。
    File对象有3种形式表示的路径,一种是getPath(),返回构造方法传入的路径,一种是getAbsolutePath(),返回绝对路径,一种是getCanonicalPath,它和绝对路径类似,但是返回的是规范路径。
    绝对路径可以表示成C:\Windows\System32..\notepad.exe,而规范路径就是把.和..转换成标准的绝对路径后的路径:C:\Windows\notepad.exe。

    1. // 假设当前目录是C:\Docs
    2. File f1 = new File("sub\\javac"); // 绝对路径是C:\Docs\sub\javac
    3. File f3 = new File(".\\sub\\javac"); // 绝对路径是C:\Docs\sub\javac
    4. File f3 = new File("..\\sub\\javac"); // 绝对路径是C:\sub\javac

    1.文件和目录

    File对象既可以表示文件,也可以表示目录。特别要注意的是,构造一个File对象,即使传入的文件或目录不存在,代码也不会出错,因为构造一个File对象,并不会导致任何磁盘操作。只有当我们调用File对象的某些方法的时候,才真正进行磁盘操作。
    用File对象获取到一个文件时,还可以进一步判断文件的权限和大小:

  • boolean canRead():是否可读;

  • boolean canWrite():是否可写;
  • boolean canExecute():是否可执行;
  • long length():文件字节大小。
  • isFile(),判断该File对象是否是一个已存在的文件
  • isDirectory(),判断该File对象是否是一个已存在的目录

对目录而言,是否可执行表示能否列出它包含的文件和子目录。

2.创建和删除文件

当File对象表示一个文件时,可以通过createNewFile()创建一个新文件,用delete()删除该文件:

  1. File file = new File("/path/to/file");
  2. if (file.createNewFile()) {
  3. // 文件创建成功:
  4. // TODO:
  5. if (file.delete()) {
  6. // 删除文件成功:
  7. }
  8. }

有些时候,程序需要读写一些临时文件,File对象提供了createTempFile()来创建一个临时文件,以及deleteOnExit()在JVM退出时自动删除该文件。

  1. public class Main {
  2. public static void main(String[] args) throws IOException {
  3. File f = File.createTempFile("tmp-", ".txt"); // 提供临时文件的前缀和后缀
  4. f.deleteOnExit(); // JVM退出时自动删除
  5. System.out.println(f.isFile());
  6. System.out.println(f.getAbsolutePath());
  7. }
  8. }

3.遍历文件和目录

当File对象表示一个目录时,可以使用list()和listFiles()列出目录下的文件和子目录名。listFiles()提供了一系列重载方法,可以过滤不想要的文件和目录:

  1. public class Main {
  2. public static void main(String[] args) throws IOException {
  3. File f = new File("C:\\Windows");
  4. File[] fs1 = f.listFiles(); // 列出所有文件和子目录
  5. printFiles(fs1);
  6. File[] fs2 = f.listFiles(new FilenameFilter() { // 仅列出.exe文件
  7. public boolean accept(File dir, String name) {
  8. return name.endsWith(".exe"); // 返回true表示接受该文件
  9. }
  10. });
  11. printFiles(fs2);
  12. }
  13. static void printFiles(File[] files) {
  14. System.out.println("==========");
  15. if (files != null) {
  16. for (File f : files) {
  17. System.out.println(f);
  18. }
  19. }
  20. System.out.println("==========");
  21. }
  22. }

和文件操作类似,File对象如果表示一个目录,可以通过以下方法创建和删除目录:

  • boolean mkdir():创建当前File对象表示的目录;
  • boolean mkdirs():创建当前File对象表示的目录,并在必要时将不存在的父目录也创建出来;
  • boolean delete():删除当前File对象表示的目录,当前目录必须为空才能删除成功。

    4.Path

    Java标准库还提供了一个Path对象,它位于java.nio.file包。Path对象和File对象类似,但操作更加简单:

    1. public class Main {
    2. public static void main(String[] args) throws IOException {
    3. Path p1 = Paths.get(".", "project", "study"); // 构造一个Path对象
    4. System.out.println(p1);
    5. Path p2 = p1.toAbsolutePath(); // 转换为绝对路径
    6. System.out.println(p2);
    7. Path p3 = p2.normalize(); // 转换为规范路径
    8. System.out.println(p3);
    9. File f = p3.toFile(); // 转换为File对象
    10. System.out.println(f);
    11. for (Path p : Paths.get("..").toAbsolutePath()) { // 可以直接遍历Path
    12. System.out.println(" " + p);
    13. }
    14. }
    15. }

    如果需要对目录进行复杂的拼接、遍历等操作,使用Path对象更方便。
    Java标准库的java.io.File对象表示一个文件或者目录:

  • 创建File对象本身不涉及IO操作;

  • 可以获取路径/绝对路径/规范路径:getPath()/getAbsolutePath()/getCanonicalPath();
  • 可以获取目录的文件和子目录:list()/listFiles();
  • 可以创建或删除文件和目录。

    2.InputStream

    InputStream就是Java标准库提供的最基本的输入流。它位于java.io这个包里。java.io包提供了所有同步IO的功能。
    要特别注意的一点是,InputStream并不是一个接口,而是一个抽象类,它是所有输入流的超类。这个抽象类定义的一个最重要的方法就是int read(),签名如下:

    1. public abstract int read() throws IOException;

    这个方法会读取输入流的下一个字节,并返回字节表示的int值(0~255)。如果已读到末尾,返回-1表示不能继续读取了。
    FileInputStream是InputStream的一个子类。顾名思义,FileInputStream就是从文件流中读取数据。下面的代码演示了如何完整地读取一个FileInputStream的所有字节:

    1. public void readFile() throws IOException {
    2. // 创建一个FileInputStream对象:
    3. InputStream input = new FileInputStream("src/readme.txt");
    4. for (;;) {
    5. int n = input.read(); // 反复调用read()方法,直到返回-1
    6. if (n == -1) {
    7. break;
    8. }
    9. System.out.println(n); // 打印byte的值
    10. }
    11. input.close(); // 关闭流
    12. }

    InputStream和OutputStream都是通过close()方法来关闭流。关闭流就会释放对应的底层资源。
    推荐的写法如下:

    1. public void readFile() throws IOException {
    2. try (InputStream input = new FileInputStream("src/readme.txt")) {
    3. int n;
    4. while ((n = input.read()) != -1) {
    5. System.out.println(n);
    6. }
    7. } // 编译器在此自动为我们写入finally并调用close()
    8. }

    1.缓冲

    在读取流的时候,一次读取一个字节并不是最高效的方法。很多流支持一次性读取多个字节到缓冲区,对于文件和网络流来说,利用缓冲区一次性读取多个字节效率往往要高很多。InputStream提供了两个重载方法来支持读取多个字节:

  • int read(byte[] b):读取若干字节并填充到byte[]数组,返回读取的字节数

  • int read(byte[] b, int off, int len):指定byte[]数组的偏移量和最大填充数

利用上述方法一次读取多个字节时,需要先定义一个byte[]数组作为缓冲区,read()方法会尽可能多地读取字节到缓冲区, 但不会超过缓冲区的大小。read()方法的返回值不再是字节的int值,而是返回实际读取了多少个字节。如果返回-1,表示没有更多的数据了。
利用缓冲区一次读取多个字节的代码如下:

  1. public void readFile() throws IOException {
  2. try (InputStream input = new FileInputStream("src/readme.txt")) {
  3. // 定义1000个字节大小的缓冲区:
  4. byte[] buffer = new byte[1000];
  5. int n;
  6. while ((n = input.read(buffer)) != -1) { // 读取到缓冲区
  7. System.out.println("read " + n + " bytes.");
  8. }
  9. }
  10. }

2.阻塞

在调用InputStream的read()方法读取数据时,我们说read()方法是阻塞(Blocking)的。它的意思是,对于下面的代码:

  1. int n;
  2. n = input.read(); // 必须等待read()方法返回才能执行下一行代码
  3. int m = n;

执行到第二行代码时,必须等read()方法返回后才能继续。因为读取IO流相比执行普通代码,速度会慢很多,因此,无法确定read()方法调用到底要花费多长时间。

3.InputStream实现类

用FileInputStream可以从文件获取输入流,这是InputStream常用的一个实现类。此外,ByteArrayInputStream可以在内存中模拟一个InputStream:

  1. public class Main {
  2. public static void main(String[] args) throws IOException {
  3. byte[] data = { 72, 101, 108, 108, 111, 33 };
  4. try (InputStream input = new ByteArrayInputStream(data)) {
  5. int n;
  6. while ((n = input.read()) != -1) {
  7. System.out.println((char)n);
  8. }
  9. }
  10. }
  11. }

ByteArrayInputStream实际上是把一个byte[]数组在内存中变成一个InputStream,虽然实际应用不多,但测试的时候,可以用它来构造一个InputStream。
Java标准库的java.io.InputStream定义了所有输入流的超类:

  • FileInputStream实现了文件流输入;
  • ByteArrayInputStream在内存中模拟一个字节流输入。

总是使用try(resource)来保证InputStream正确关闭。

3.OutputStream

OutputStream也是抽象类,它是所有输出流的超类。这个抽象类定义的一个最重要的方法就是void write(int b),签名如下:

  1. public abstract void write(int b) throws IOException;

OutputStream也提供了close()方法关闭输出流,以便释放系统资源。要特别注意:OutputStream还提供了一个flush()方法,它的目的是将缓冲区的内容真正输出到目的地。

1.FileOutputStream

用try(resource)来保证OutputStream在无论是否发生IO错误的时候都能够正确地关闭:

  1. public void writeFile() throws IOException {
  2. try (OutputStream output = new FileOutputStream("out/readme.txt")) {
  3. output.write("Hello".getBytes("UTF-8")); // Hello
  4. } // 编译器在此自动为我们写入finally并调用close()
  5. }

2.阻塞

和InputStream一样,OutputStream的write()方法也是阻塞的。

3.OutputStream实现类

用FileOutputStream可以从文件获取输出流,这是OutputStream常用的一个实现类。此外,ByteArrayOutputStream可以在内存中模拟一个OutputStream:

  1. public class Main {
  2. public static void main(String[] args) throws IOException {
  3. byte[] data;
  4. try (ByteArrayOutputStream output = new ByteArrayOutputStream()) {
  5. output.write("Hello ".getBytes("UTF-8"));
  6. output.write("world!".getBytes("UTF-8"));
  7. data = output.toByteArray();
  8. }
  9. System.out.println(new String(data, "UTF-8"));
  10. }
  11. }

ByteArrayOutputStream实际上是把一个byte[]数组在内存中变成一个OutputStream,虽然实际应用不多,但测试的时候,可以用它来构造一个OutputStream。
Java标准库的java.io.OutputStream定义了所有输出流的超类:

  • FileOutputStream实现了文件流输出;
  • ByteArrayOutputStream在内存中模拟一个字节流输出。

某些情况下需要手动调用OutputStream的flush()方法来强制输出缓冲区。
总是使用try(resource)来保证OutputStream正确关闭。

4.Filter模式

通过一个“基础”组件再叠加各种“附加”功能组件的模式,称之为Filter模式(或者装饰器模式:Decorator)。
Java的IO标准库使用Filter模式为InputStream和OutputStream增加功能:

  • 可以把一个InputStream和任意个FilterInputStream组合;
  • 可以把一个OutputStream和任意个FilterOutputStream组合。

Filter模式可以在运行期动态增加功能(又称Decorator模式)。

5.操作Zip

ZipInputStream是一种FilterInputStream,它可以直接读取zip包的内容:
image.png
另一个JarInputStream是从ZipInputStream派生,它增加的主要功能是直接读取jar文件里面的MANIFEST.MF文件。因为本质上jar包就是zip包,只是额外附加了一些固定的描述文件。

1.读取zip包

我们来看看ZipInputStream的基本用法。
我们要创建一个ZipInputStream,通常是传入一个FileInputStream作为数据源,然后,循环调用getNextEntry(),直到返回null,表示zip流结束。
一个ZipEntry表示一个压缩文件或目录,如果是压缩文件,我们就用read()方法不断读取,直到返回-1:

  1. try (ZipInputStream zip = new ZipInputStream(new FileInputStream(...))) {
  2. ZipEntry entry = null;
  3. while ((entry = zip.getNextEntry()) != null) {
  4. String name = entry.getName();
  5. if (!entry.isDirectory()) {
  6. int n;
  7. while ((n = zip.read()) != -1) {
  8. ...
  9. }
  10. }
  11. }
  12. }

2.写入zip包

ZipOutputStream是一种FilterOutputStream,它可以直接写入内容到zip包。我们要先创建一个ZipOutputStream,通常是包装一个FileOutputStream,然后,每写入一个文件前,先调用putNextEntry(),然后用write()写入byte[]数据,写入完毕后调用closeEntry()结束这个文件的打包。

  1. try (ZipOutputStream zip = new ZipOutputStream(new FileOutputStream(...))) {
  2. File[] files = ...
  3. for (File file : files) {
  4. zip.putNextEntry(new ZipEntry(file.getName()));
  5. zip.write(Files.readAllBytes(file.toPath()));
  6. zip.closeEntry();
  7. }
  8. }

上面的代码没有考虑文件的目录结构。如果要实现目录层次结构,new ZipEntry(name)传入的name要用相对路径。
ZipInputStream可以读取zip格式的流,ZipOutputStream可以把多份数据写入zip包;
配合FileInputStream和FileOutputStream就可以读写zip文件。

6.读取classpath资源

从classpath读取文件就可以避免不同环境下文件路径不一致的问题:如果我们把default.properties文件放到classpath中,就不用关心它的实际存放路径。
在classpath中的资源文件,路径总是以/开头,我们先获取当前的Class对象,然后调用getResourceAsStream()就可以直接从classpath读取任意的资源文件:

  1. try (InputStream input = getClass().getResourceAsStream("/default.properties")) {
  2. // TODO:
  3. }

调用getResourceAsStream()需要特别注意的一点是,如果资源文件不存在,它将返回null。因此,我们需要检查返回的InputStream是否为null,如果为null,表示资源文件在classpath中没有找到:

  1. try (InputStream input = getClass().getResourceAsStream("/default.properties")) {
  2. if (input != null) {
  3. // TODO:
  4. }
  5. }

如果我们把默认的配置放到jar包中,再从外部文件系统读取一个可选的配置文件,就可以做到既有默认的配置文件,又可以让用户自己修改配置:

  1. Properties props = new Properties();
  2. props.load(inputStreamFromClassPath("/default.properties"));
  3. props.load(inputStreamFromFile("./conf.properties"));

把资源存储在classpath中可以避免文件路径依赖;
Class对象的getResourceAsStream()可以从classpath中读取指定资源;
根据classpath读取资源时,需要检查返回的InputStream是否为null。

7.序列化

序列化是指把一个Java对象变成二进制内容,本质上就是一个byte[]数组。
为什么要把Java对象序列化呢?因为序列化后可以把byte[]保存到文件中,或者把byte[]通过网络传输到远程,这样,就相当于把Java对象存储到文件或者通过网络传输出去了。
有序列化,就有反序列化,即把一个二进制内容(也就是byte[]数组)变回Java对象。有了反序列化,保存到文件中的byte[]数组又可以“变回”Java对象,或者从网络上读取byte[]并把它“变回”Java对象。
我们来看看如何把一个Java对象序列化。
一个Java对象要能序列化,必须实现一个特殊的java.io.Serializable接口,它的定义如下:

  1. public interface Serializable {
  2. }

Serializable接口没有定义任何方法,它是一个空接口。我们把这样的空接口称为“标记接口”(Marker Interface),实现了标记接口的类仅仅是给自身贴了个“标记”,并没有增加任何方法。
可序列化的Java对象必须实现java.io.Serializable接口,类似Serializable这样的空接口被称为“标记接口”(Marker Interface);
反序列化时不调用构造方法,可设置serialVersionUID作为版本号(非必需);
Java的序列化机制仅适用于Java,如果需要与其它语言交换数据,必须使用通用的序列化方法,例如JSON。

8.Reader

Reader是Java的IO库提供的另一个输入流接口。和InputStream的区别是,InputStream是一个字节流,即以byte为单位读取,而Reader是一个字符流,即以char为单位读取:

InputStream Reader
字节流,以byte为单位 字符流,以char为单位
读取字节(-1,0~255):int read() 读取字符(-1,0~65535):int read()
读到字节数组:int read(byte[] b) 读到字符数组:int read(char[] c)

java.io.Reader是所有字符输入流的超类,它最主要的方法是:

  1. public int read() throws IOException;

这个方法读取字符流的下一个字符,并返回字符表示的int,范围是0~65535。如果已读到末尾,返回-1。

1.FileReader

FileReader是Reader的一个子类,它可以打开文件并获取Reader。

  1. try (Reader reader = new FileReader("src/readme.txt", StandardCharsets.UTF_8) {
  2. for (;;) {
  3. int n = reader.read(); // 反复调用read()方法,直到返回-1
  4. if (n == -1) {
  5. break;
  6. }
  7. System.out.println((char)n); // 打印char
  8. }
  9. }

Reader定义了所有字符输入流的超类:

  • FileReader实现了文件字符流输入,使用时需要指定编码;
  • CharArrayReader和StringReader可以在内存中模拟一个字符流输入。

Reader是基于InputStream构造的:可以通过InputStreamReader在指定编码的同时将任何InputStream转换为Reader。
总是使用try (resource)保证Reader正确关闭。

9.Writer

Reader是带编码转换器的InputStream,它把byte转换为char,而Writer就是带编码转换器的OutputStream,它把char转换为byte并输出。
Writer和OutputStream的区别如下:

OutputStream Writer
字节流,以byte为单位 字符流,以char为单位
写入字节(0~255):void write(int b) 写入字符(0~65535):void write(int c)
写入字节数组:void write(byte[] b) 写入字符数组:void write(char[] c)
无对应方法 写入String:void write(String s)

Writer是所有字符输出流的超类,它提供的方法主要有:

  • 写入一个字符(0~65535):void write(int c);
  • 写入字符数组的所有字符:void write(char[] c);
  • 写入String表示的所有字符:void write(String s)。

    1.FileWriter

    FileWriter就是向文件中写入字符流的Writer。它的使用方法和FileReader类似:

    1. try (Writer writer = new FileWriter("readme.txt", StandardCharsets.UTF_8)) {
    2. writer.write('H'); // 写入单个字符
    3. writer.write("Hello".toCharArray()); // 写入char[]
    4. writer.write("Hello"); // 写入String
    5. }

    Writer定义了所有字符输出流的超类:

  • FileWriter实现了文件字符流输出;

  • CharArrayWriter和StringWriter在内存中模拟一个字符流输出。

使用try (resource)保证Writer正确关闭。
Writer是基于OutputStream构造的,可以通过OutputStreamWriter将OutputStream转换为Writer,转换时需要指定编码。

10.PrintStream和PrintWriter

PrintStream是一种FilterOutputStream,它在OutputStream的接口上,额外提供了一些写入各种数据类型的方法:

  • 写入int:print(int)
  • 写入boolean:print(boolean)
  • 写入String:print(String)
  • 写入Object:print(Object),实际上相当于print(object.toString())

以及对应的一组println()方法,它会自动加上换行符。
我们经常使用的System.out.println()实际上就是使用PrintStream打印各种数据。其中,System.out是系统默认提供的PrintStream,表示标准输出:

  1. System.out.print(12345); // 输出12345
  2. System.out.print(new Object()); // 输出类似java.lang.Object@3c7a835a
  3. System.out.println("Hello"); // 输出Hello并换行

System.err是系统默认提供的标准错误输出。
PrintStream和OutputStream相比,除了添加了一组print()/println()方法,可以打印各种数据类型,比较方便外,它还有一个额外的优点,就是不会抛出IOException,这样我们在编写代码的时候,就不必捕获IOException。

1.PrintWriter

PrintStream最终输出的总是byte数据,而PrintWriter则是扩展了Writer接口,它的print()/println()方法最终输出的是char数据。
PrintStream是一种能接收各种数据类型的输出,打印数据时比较方便:

  • System.out是标准输出;
  • System.err是标准错误输出。

PrintWriter是基于Writer的输出。

11.使用Files

Files和Paths这两个工具类,能极大地方便我们读写文件。
虽然Files和Paths是java.nio包里面的类,但他俩封装了很多读写文件的简单方法,例如,我们要把一个文件的全部内容读取为一个byte[],可以这么写:

  1. byte[] data = Files.readAllBytes(Paths.get("/path/to/file.txt"));

对于简单的小文件读写操作,可以使用Files工具类简化代码。