IO

IO流是一种流式的数据输入/输出模型:

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

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

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

java.nio则是异步IO

同步IO是指,读写IO时代码必须等待数据返回后才继续执行后续代码,它的优点是代码编写简单,缺点是CPU执行效率低。

而异步IO是指,读写IO时仅发出请求,然后立刻执行后续代码,它的优点是CPU执行效率高,缺点是代码编写复杂。

File对象

Java标准库的java.io.File对象表示一个文件或者目录:

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

    1
    2
    import java.io.*;
    File f = new File("C:\\Windows\\notepad.exe");

    Windows平台使用\作为路径分隔符,在Java字符串中需要用\\表示一个\。Linux平台使用/作为路径分隔符

  • 可以获取路径/绝对路径/规范路径:getPath()/getAbsolutePath()/getCanonicalPath()

  • 调用isFile(),判断该File对象是否是一个已存在的文件,调用isDirectory(),判断该File对象是否是一个已存在的目录

  • File对象获取到一个文件时,还可以进一步判断文件的权限和大小:

    • boolean canRead():是否可读;
    • boolean canWrite():是否可写;
    • boolean canExecute():是否可执行;
    • long length():文件字节大小。
  • 可以创建或删除文件和目录。

    • createNewFile()创建一个新文件,用delete()删除该文件
    • File对象如果表示一个目录,可以通过以下方法创建和删除目录:
      • boolean mkdir():创建当前File对象表示的目录;
      • boolean mkdirs():创建当前File对象表示的目录,并在必要时将不存在的父目录也创建出来;
      • boolean delete():删除当前File对象表示的目录,当前目录必须为空才能删除成功。
    • File对象提供了createTempFile()来创建一个临时文件,以及deleteOnExit()在JVM退出时自动删除该文件。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    import java.io.*;
    public class Main {
    public static void main(String[] args) throws IOException {
    File f = File.createTempFile("tmp-", ".txt"); // 提供临时文件的前缀和后缀
    f.deleteOnExit(); // JVM退出时自动删除
    System.out.println(f.isFile());
    System.out.println(f.getAbsolutePath());
    }
    }
  • (遍历)可以获取目录的文件和子目录:list()/listFiles()

    • listFiles()提供了一系列重载方法,可以过滤不想要的文件和目录

Path对象

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import java.io.*;
import java.nio.file.*;

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

如果需要对目录进行复杂的拼接、遍历等操作,使用Path对象更方便。

InputStream

Java标准库的java.io.InputStream定义了所有输入流的超类(抽象类)

常用方法:InputStream (Java SE 11 & JDK 11 ) (runoob.com)

  • int read(): 读取输入流的下一个字节,并返回字节表示的int值(0~255)
  • int read(byte[] b):读取若干字节并填充到byte[]数组,返回读取的字节数
  • int read(byte[] b, int off, int len):指定byte[]数组的偏移量和最大填充数

总是使用try(resource)来保证InputStream正确关闭,代替try ... finally。推荐的写法如下:

1
2
3
4
5
6
7
8
9
public void readFile() throws IOException {
try (InputStream input = new FileInputStream("src/readme.txt")) {
byte[] buffer = new byte[1000]; //定义1000个字节大小的缓冲区
int n;
while (n = input.read(buffer)) != -1 {
System.out.println("read " + n + " bytes.");
}
} // 编译器在此自动为我们写入finally并调用close()
}

所有与IO操作相关的代码都必须正确处理IOException

阻塞

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

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

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

InputStream实现类

  • FileInputStream可以从文件获取输入流,这是InputStream常用的一个实现类。

  • ByteArrayInputStream实际上是把一个byte[]数组在内存中变成一个InputStream,测试常用

  • ServletInputStream:从HTTP请求读取数据;

    属于Javax(Java扩展包)

OutputStream

整体与InputStream相似,常用方法是write

OutputStream (Java SE 11 & JDK 11 ) (runoob.com)

Java标准库的java.io.OutputStream定义了所有输出流的超类:

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

某些情况下需要手动调用OutputStreamflush()方法来强制输出缓冲区。

为什么要有flush()

因为向磁盘、网络写入数据的时候,出于效率的考虑,操作系统并不是输出一个字节就立刻写入到文件或者发送到网络,而是把输出的字节先放到内存的一个缓冲区里(本质上就是一个byte[]数组),等到缓冲区写满了,再一次性写入文件或者网络。

对于很多IO设备来说,一次写一个字节和一次写1000个字节,花费的时间几乎是完全一样的,所以OutputStream有个flush()方法,能强制把缓冲区内容输出。

实际上,InputStream也有缓冲区。例如,从FileInputStream读取一个字节时,操作系统往往会一次性读取若干字节到缓冲区,并维护一个指针指向未读的缓冲区。然后,每次我们调用int read()读取下一个字节时,可以直接返回缓冲区的下一个字节,避免每次读一个字节都导致IO操作。当缓冲区全部读完后继续调用read(),则会触发操作系统的下一次读取并再次填满缓冲区。

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

输入写入到输出

  • long transferTo(OutputStream out): 从该输入流中读取所有字节,并按读取顺序将字节写入给定的输出流
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class CopyFile {

public static void main(String[] args) throws IOException {
if (args.length != 2) {
System.err.println("Usage:\n java CopyFile.java <source> <target>");
System.exit(1);
}
copy(args[0], args[1]);
}

static void copy(String source, String target) throws IOException {
// 友情提示:测试时请使用无关紧要的文件
// TODO:
try(InputStream input = new FileInputStream(source);
OutputStream output = new FileOutputStream(target)){
input.transferTo(output);
}
}
}

Filter模式(或者装饰器模式:Decorator)

Ⅰ. 直接提供数据的基础InputStream Ⅱ. 提供额外附加功能的InputStream
FilterInputSream
FileInputStream BufferedInputStream
ByteArrayInputStream DigestInputStream
ServletInputStream CipherInputStream

可以用第Ⅱ类对第Ⅰ类进行包装

1
2
3
InputStream file = new FileInputStream("test.gz");
InputStream buffered = new BufferedInputStream(file);
InputStream gzip = new GZIPInputStream(buffered);
1
2
3
4
5
6
7
8
9
┌─────────────────────────┐
│GZIPInputStream │
│┌───────────────────────┐│
││BufferedFileInputStream││
││┌─────────────────────┐││
│││ FileInputStream │││
││└─────────────────────┘││
│└───────────────────────┘│
└─────────────────────────┘
  • OutputStream同理
  • 可以自己编写FilterInputStream,以便可以把自己的FilterInputStream“叠加”到任何一个InputStream
1
class CountInputStream extends FilterInputStream {...}

操作Zip

ZipInputStream是一种FilterInputStream,它可以直接读取zip包的内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
┌───────────────────┐
│ InputStream │
└───────────────────┘


┌───────────────────┐
│ FilterInputStream │
└───────────────────┘


┌───────────────────┐
│InflaterInputStream│
└───────────────────┘


┌───────────────────┐
│ ZipInputStream │
└───────────────────┘


┌───────────────────┐
│ JarInputStream │
└───────────────────┘

读Zip

传入一个FileInputStream作为数据源,然后,循环调用getNextEntry(),直到返回null,表示zip流结束。

一个ZipEntry表示一个压缩文件或目录,如果是压缩文件,我们就用read()方法不断读取,直到返回-1

1
2
3
4
5
6
7
8
9
10
11
12
try(ZipInputStream zip = new ZipInputStream(new FileInputStream(...))){
ZipEntry entry = null;//定义entry
while(entry = zip.getNextEntry()!=null){
String name = entry.getName();//获得文件名
if(!entry.isDirectory()){//如果是文件
int n;
while((n = zip.read()) != -1){
...
}
}
}
}

写Zip

先创建一个ZipOutputStream,通常是包装一个FileOutputStream

然后,每写入一个文件前,先调用putNextEntry(),然后用write()写入byte[]数据,写入完毕后调用closeEntry()结束这个文件的打包。

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

如果要实现目录层次结构,new ZipEntry(name)传入的name要用相对路径。

读取classpath资源

路径无关的读取文件的方式:把资源存储在classpath中可以避免文件路径依赖;

在classpath中的资源文件,路径总是以开头,我们先获取当前的Class对象,然后调用getResourceAsStream()就可以直接从classpath读取任意的资源文件;

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

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

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

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

这样读取配置文件,应用程序启动就更加灵活。

序列化

概念

把一个Java对象变成二进制内容,本质上就是一个byte[]数组。

可以把byte[]保存到文件中,或者把byte[]通过网络传输到远程

通过反序列化,保存到文件中的byte[]数组又可以“变回”Java对象,或者从网络上读取byte[]并把它“变回”Java对象。

条件

一个Java对象要能序列化,必须实现一个特殊的java.io.Serializable接口,它的定义如下:

1
2
public interface Serializable {
}

Serializable接口没有定义任何方法,它是一个空接口。我们把这样的空接口称为“标记接口”(Marker Interface),实现了标记接口的类仅仅是给自身贴了个“标记”,并没有增加任何方法。

实现

需要使用ObjectOutputStream包装ByteArrayOutputStream,可以写入任何实现了Serializable接口的Object

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import java.io.*;
import java.util.Arrays;

public class Main {
public static void main(String[] args) throws IOException {
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
try (ObjectOutputStream output = new ObjectOutputStream(buffer)) {
// 写入int:
output.writeInt(12345);
// 写入String:
output.writeUTF("Hello");
// 写入Object:
output.writeObject(Double.valueOf(123.456));
}
System.out.println(Arrays.toString(buffer.toByteArray()));
}
}

反序列化

ObjectInputStream负责从一个字节流读取Java对象:

1
2
3
4
5
try (ObjectInputStream input = new ObjectInputStream(...)) {
int n = input.readInt();
String s = input.readUTF();
Double d = (Double) input.readObject();
}

除了能读取基本类型和String类型外,调用readObject()可以直接返回一个Object对象。要把它变成一个特定类型,必须强制转型。

readObject()可能抛出的异常有:

  • ClassNotFoundException:没有找到对应的Class;A电脑把传递对象给B电脑,但B电脑上没有定义该对象的类
  • InvalidClassException:Class不匹配。反序列化时的字段类型与元对象不一致

为了避免这种class定义变动导致的不兼容,Java的序列化允许class定义一个特殊的serialVersionUID静态变量,用于标识Java类的序列化“版本”,通常可以由IDE自动生成。如果增加或修改了字段,可以改变serialVersionUID的值,这样就能自动阻止不匹配的class版本:

1
2
3
public class Person implements Serializable {
private static final long serialVersionUID = 2709425275741743919L;
}

要特别注意反序列化的几个重要特点:

反序列化时,由JVM直接构造出Java对象,不调用构造方法,构造方法内部的代码,在反序列化时根本不可能执行。

安全性问题

一个精心构造的byte[]数组被反序列化后可以执行特定的Java代码,从而导致严重的安全漏洞。

更好的序列化方法是通过JSON这样的通用数据结构来实现,只输出基本类型(包括String)的内容,而不存储任何与代码相关的信息。

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

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

  • FileReader实现了文件字符流输入,使用时需要指定编码;

    1
    Reader reader = new FileReader("src/readme.txt", StandardCharsets.UTF_8);
  • CharArrayReaderStringReader可以在内存中模拟一个字符流输入。

Reader是基于InputStream构造的:可以通过InputStreamReader在指定编码的同时将任何InputStream转换为Reader

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

Writer

Writer就是带编码转换器的OutputStream,它把char转换为byte并输出。

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定义了所有字符输出流的超类:

  • FileWriter实现了文件字符流输出;
  • CharArrayWriterStringWriter在内存中模拟一个字符流输出。

使用try (resource)保证Writer正确关闭。

Writer是基于OutputStream构造的,可以通过OutputStreamWriterOutputStream转换为Writer,转换时需要指定编码。

1
2
3
try (Writer writer = new OutputStreamWriter(new FileOutputStream("readme.txt"), "UTF-8")) {
// TODO:
}

PrintStream和PrintWriter

PrintStream是一种能接收各种数据类型的输出,打印数据时比较方便:

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

PrintWriter是基于Writer的输出。

使用Files

对于简单的小文件读写操作(不可一次读入几个G的大文件),可以使用Files工具类简化代码。

读文件

1
2
3
4
5
6
// 默认使用UTF-8编码读取:
String content1 = Files.readString(Paths.get("/path/to/file.txt"));
// 可指定编码:
String content2 = Files.readString(Paths.get("/path/to/file.txt"), StandardCharsets.ISO_8859_1);
// 按行读取并返回每行内容:
List<String> lines = Files.readAllLines(Paths.get("/path/to/file.txt"));

写文件

1
2
3
4
5
6
7
8
// 写入二进制文件:
byte[] data = ...
Files.write(Paths.get("/path/to/file.txt"), data);
// 写入文本并指定编码:
Files.writeString(Paths.get("/path/to/file.txt"), "文本内容...", StandardCharsets.ISO_8859_1);
// 按行写入文本:
List<String> lines = ...
Files.write(Paths.get("/path/to/file.txt"), lines);

此外,Files工具类还有copy()delete()exists()move()等快捷方法操作文件和目录。