IO流是一种流式的数据输入/输出模型:
- 二进制数据以
byte
为最小单位在InputStream
/OutputStream
中单向流动; - 字符数据以
char
为最小单位在Reader
/Writer
中单向流动。
Java标准库的java.io
包提供了同步IO功能:
- 字节流接口:
InputStream
/OutputStream
; - 字符流接口:
Reader
/Writer
。
File对象
构造File对象
- 构造File对象时,既可以传入绝对路径,也可以传入相对路径。
File f = new File("C:\\Windows\\notepad.exe");
File f = new File("/usr/bin/javac");
-
Windows平台使用
\
作为路径分隔符,在Java字符串中需要用\\
表示一个\
。Linux平台使用/
作为路径分隔符java。 -
可以用
.
表示当前目录,..
表示上级目录
// 假设当前目录是C:\Docs
File f1 = new File("sub\\javac"); // 绝对路径是C:\Docs\sub\javac
File f3 = new File(".\\sub\\javac"); // 绝对路径是C:\Docs\sub\javac
File f3 = new File("..\\sub\\javac"); // 绝对路径是C:\sub\javac
- Windows和Linux的路径分隔符不同,File对象有一个静态变量用于表示当前平台的系统分隔符:
System.out.println(File.separator); // 根据当前平台打印"\"或"/"
- File对象有3种形式表示的路径,一种是
getPath()
,返回构造方法传入的路径,一种是getAbsolutePath()
,返回绝对路径,一种是getCanonicalPath
,它和绝对路径类似,但是返回的是规范路径。
例如:绝对路径可以表示成C:\Windows\System32\..\notepad.exe
,而规范路径就是把.
和..
转换成标准的绝对路径后的路径:C:\Windows\notepad.exe
。
文件和目录
构造一个File
对象,即使传入的文件或目录不存在,代码也不会出错,因为构造一个File
对象,并不会导致任何磁盘操作。只有当我们调用File
对象的某些方法的时候,才真正进行磁盘操作。
- 调用
isFile()
,判断该File
对象是否是一个已存在的文件 - 调用
isDirectory()
,判断该File
对象是否是一个已存在的目录
创建和删除文件
可以通过createNewFile()
创建一个新文件,用delete()
删除该文件。
File file = new File("/path/to/file");
if (file.createNewFile()) {
// 文件创建成功:
// TODO:
if (file.delete()) {
// 删除文件成功:
}
}
遍历文件和目录
当File对象表示一个目录时,可以使用list()
和listFiles()
列出目录下的文件和子目录名。listFiles()
提供了一系列重载方法,可以过滤不想要的文件和目录:
File f = new File("C:\\Windows");
File[] fs1 = f.listFiles(); // 列出所有文件和子目录
printFiles(fs1);
File[] fs2 = f.listFiles(new FilenameFilter() { // 仅列出.exe文件
public boolean accept(File dir, String name) {
return name.endsWith(".exe"); // 返回true表示接受该文件
}
});
printFiles(fs2);
和文件操作类似,File对象如果表示一个目录,可以通过以下方法创建和删除目录:
boolean mkdir()
:创建当前File对象表示的目录;boolean mkdirs()
:创建当前File对象表示的目录,并在必要时将不存在的父目录也创建出来;boolean delete()
:删除当前File对象表示的目录,当前目录必须为空才能删除成功。
Path
Java标准库还提供了一个Path
对象,它位于java.nio.file
包。Path
对象和File
对象类似,但操作更加简单:
public class Main {
public static void main(String[] args) throws IOException {
Path p1 = Paths.get(".", "project", "study"); // 构造一个Path对象
System.out.println(p1);// ./project/study
Path p2 = p1.toAbsolutePath(); // 转换为绝对路径
System.out.println(p2);// /app/./project/study
Path p3 = p2.normalize(); // 转换为规范路径
System.out.println(p3); // /app/project/study
File f = p3.toFile(); // 转换为File对象
System.out.println(f);
for (Path p : Paths.get("..").toAbsolutePath()) { // 可以直接遍历Path
System.out.println(" " + p);
}
}
}
InputStream
InputStream
并不是一个接口,而是一个抽象类,它是所有输入流的超类。这个抽象类定义的一个最重要的方法就是int read()
,签名如下:
public abstract int read() throws IOException;
FileInputStream
是InputStream
的一个子类,FileInputStream
就是从文件流中读取数据(最后一定要关闭IO流):
public void readFile() throws IOException {
InputStream input = null;
try {
input = new FileInputStream("src/readme.txt");
int n;
while ((n = input.read()) != -1) { // 利用while同时读取并判断
System.out.println(n);
}
} finally {
if (input != null) { input.close(); }
}
}
用try ... finally
来编写上述代码会感觉比较复杂,更好的写法是利用Java 7引入的新的try(resource)
的语法,只需要编写try
语句,让编译器自动为我们关闭资源。推荐的写法如下:
public void readFile() throws IOException {
try (InputStream input = new FileInputStream("src/readme.txt")) {
int n;
while ((n = input.read()) != -1) {
System.out.println(n);
}
} // 编译器在此自动为我们写入finally并调用close()
}
缓冲
在读取流的时候,一次读取一个字节并不是最高效的方法。很多流支持一次性读取多个字节到缓冲区,对于文件和网络流来说,利用缓冲区一次性读取多个字节效率往往要高很多。InputStream
提供了两个重载方法来支持读取多个字节:
int read(byte[] b)
:读取若干字节并填充到byte[]
数组,返回读取的字节数int read(byte[] b, int off, int len)
:指定byte[]
数组的偏移量和最大填充数
public void readFile() throws IOException {
try (InputStream input = new FileInputStream("src/readme.txt")) {
// 定义1000个字节大小的缓冲区:
byte[] buffer = new byte[1000];
int n;
while ((n = input.read(buffer)) != -1) { // 读取到缓冲区
System.out.println("read " + n + " bytes.");
}
}
}
阻塞
在调用InputStream
的read()
方法读取数据时,我们说read()
方法是阻塞(Blocking)的。它的意思是,对于下面的代码:
int n;
n = input.read(); // 必须等待read()方法返回才能执行下一行代码
int m = n;
OutputStream
OutputStream
也是抽象类,它是所有输出流的超类。这个抽象类定义的一个最重要的方法就是void write(int b)
,签名如下:
public abstract void write(int b) throws IOException;
和InputStream
类似,OutputStream
也提供了close()
方法关闭输出流,以便释放系统资源。要特别注意:OutputStream
还提供了一个flush()
方法,它的目的是将缓冲区的内容真正输出到目的地(把输出的字节先放到内存的一个缓冲区里(本质上就是一个byte[]
数组),等到缓冲区写满了,再一次性写入文件或者网络)。
- 缓冲区写满了
OutputStream
会自动调用它 - 在调用
close()
方法关闭OutputStream
之前,也会自动调用flush()
方法
手动调用:开发一款在线聊天软件,当用户输入一句话后,就通过OutputStream
的write()
方法写入网络流。对方接受不到任何信息?原因就在于写入网络流是先写入内存缓冲区,等缓冲区满了才会一次性发送到网络。此时就需要手动调用flush(),不管当前缓冲区是否已满,强迫操作系统把缓冲区的内容立刻发送出去.
FileOutputStream
每次写入一个字节非常麻烦,更常见的方法是一次性写入若干字节。这时,可以用OutputStream
提供的重载方法void write(byte[])
来实现:
public void writeFile() throws IOException {
OutputStream output = new FileOutputStream("out/readme.txt");
output.write("Hello".getBytes("UTF-8")); // Hello
output.close();
}
和InputStream
一样,上述代码没有考虑到在发生异常的情况下如何正确地关闭资源。写入过程也会经常发生IO错误,例如,磁盘已满,无权限写入等等。我们需要用try(resource)
来保证OutputStream
在无论是否发生IO错误的时候都能够正确地关闭:
public void writeFile() throws IOException {
try (OutputStream output = new FileOutputStream("out/readme.txt")) {
output.write("Hello".getBytes("UTF-8")); // Hello
} // 编译器在此自动为我们写入finally并调用close()
}
阻塞
和InputStream
一样,OutputStream
的write()
方法也是阻塞的。
Filter模式
当我们需要给一个“基础”InputStream
附加各种功能时,我们先确定这个能提供数据源的InputStream
,因为我们需要的数据总得来自某个地方,例如,FileInputStream
,数据来源自文件:
InputStream file = new FileInputStream("test.gz");
紧接着,我们希望FileInputStream
能提供缓冲的功能来提高读取的效率,因此我们用BufferedInputStream
包装这个InputStream
,得到的包装类型是BufferedInputStream
,但它仍然被视为一个InputStream
:
InputStream buffered = new BufferedInputStream(file);
最后,假设该文件已经用gzip压缩了,我们希望直接读取解压缩的内容,就可以再包装一个GZIPInputStream
:
InputStream gzip = new GZIPInputStream(buffered);
无论我们包装多少次,得到的对象始终是InputStream
,我们直接用InputStream
来引用它,就可以正常读取:
┌─────────────────────────┐
│GZIPInputStream │
│┌───────────────────────┐│
││BufferedFileInputStream││
││┌─────────────────────┐││
│││ FileInputStream │││
││└─────────────────────┘││
│└───────────────────────┘│
└─────────────────────────┘
Java的IO标准库使用Filter模式为InputStream
和OutputStream
增加功能:
- 可以把一个
InputStream
和任意个FilterInputStream
组合; - 可以把一个
OutputStream
和任意个FilterOutputStream
组合。
Filter模式可以在运行期动态增加功能(又称Decorator模式)。
读取classpath资源
从classpath读取文件就可以避免不同环境下文件路径不一致的问题:如果我们把default.properties
文件放到classpath中,就不用关心它的实际存放路径。
在classpath中的资源文件,路径总是以/
开头,我们先获取当前的Class
对象,然后调用getResourceAsStream()
就可以直接从classpath读取任意的资源文件:
//调用getResourceAsStream()需要特别注意的一点是,如果资源文件不存在,它将返回null。因此,我们需要检查返回的InputStream是否为null,如果为null,表示资源文件在classpath中没有找到:
try (InputStream input = getClass().getResourceAsStream("/default.properties")) {
if (input != null) {
// TODO:
}
}
序列化
序列化是指把一个Java对象变成二进制内容,本质上就是一个byte[]
数组。
序列化后可以把byte[]
保存到文件中,或者把byte[]
通过网络传输到远程,这样,就相当于把Java对象存储到文件或者通过网络传输出去了。
一个Java对象要能序列化,必须实现一个特殊的java.io.Serializable
接口,它的定义如下:
public interface Serializable {
}
Serializable
接口没有定义任何方法,它是一个空接口。我们把这样的空接口称为“标记接口”(Marker Interface),实现了标记接口的类仅仅是给自身贴了个“标记”,并没有增加任何方法。
序列化
把一个Java对象变为byte[]
数组,需要使用ObjectOutputStream
。它负责把一个Java对象写入一个字节流:
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()));
}
}
反序列化
和ObjectOutputStream
相反,ObjectInputStream
负责从一个字节流读取Java对象:
try (ObjectInputStream input = new ObjectInputStream(...)) {
int n = input.readInt();
String s = input.readUTF();
Double d = (Double) input.readObject();
}
除了能读取基本类型和String
类型外,调用readObject()
可以直接返回一个Object
对象。要把它变成一个特定类型,必须强制转型。
readObject()
可能抛出的异常有:
ClassNotFoundException
:没有找到对应的Class;InvalidClassException
:Class不匹配。
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
是所有字符输入流的超类,它最主要的方法是:
public int read() throws IOException;
//这个方法读取字符流的下一个字符,并返回字符表示的int,范围是0~65535。如果已读到末尾,返回-1。
FileReader
注意解码方式,资源流的正确关闭
try (Reader reader = new FileReader("src/readme.txt", StandardCharsets.UTF_8) {
// TODO
}
Reader
还提供了一次性读取若干字符并填充到char[]
数组的方法:
public int read(char[] c) throws IOException
它返回实际读入的字符个数,最大不超过char[]
数组的长度。返回-1
表示流结束。
利用这个方法,我们可以先设置一个缓冲区,然后,每次尽可能地填充缓冲区:
public void readFile() throws IOException {
try (Reader reader = new FileReader("src/readme.txt", StandardCharsets.UTF_8)) {
char[] buffer = new char[1000];
int n;
while ((n = reader.read(buffer)) != -1) {
System.out.println("read " + n + " chars.");
}
}
}
InputStreamReader
普通的Reader
实际上是基于InputStream
构造的,因为Reader
需要从InputStream
中读入字节流(byte
),然后,根据编码设置,再转换为char
就可以实现字符流。
// 持有InputStream:
InputStream input = new FileInputStream("src/readme.txt");
// 变换为Reader:
Reader reader = new InputStreamReader(input, "UTF-8");
- 使用InputStreamReader,可以把一个InputStream转换成一个Reader。
Writer
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) |
FileWriter
FileWriter
就是向文件中写入字符流的Writer
。它的使用方法和FileReader
类似:
try (Writer writer = new FileWriter("readme.txt", StandardCharsets.UTF_8)) {
writer.write('H'); // 写入单个字符
writer.write("Hello".toCharArray()); // 写入char[]
writer.write("Hello"); // 写入String
}
OutputStreamWriter
除了CharArrayWriter
和StringWriter
外,普通的Writer实际上是基于OutputStream
构造的,它接收char
,然后在内部自动转换成一个或多个byte
,并写入OutputStream
。因此,OutputStreamWriter
就是一个将任意的OutputStream
转换为Writer
的转换器:
try (Writer writer = new OutputStreamWriter(new FileOutputStream("readme.txt"), "UTF-8")) {
// TODO:
}
使用Files
虽然Files
和Paths
是java.nio
包里面的类
byte[] data = Files.readAllBytes(Paths.get("/path/to/file.txt"));
// 默认使用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"));
// 写入二进制文件:
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);