Appearance
Java 基础:输入输出
1. 输入/输出流
1.1. 读写字节
InputStream 类有一个抽象方法 read,这个方法将读入一个字节,并返回读入的字节,或者在遇到输入源结尾时返回 -1:
Java
abstract int read()InputStream 类还有若干个非抽象的方法,它们可以读入一个字节数组,或者跳过大量的字节。从 Java 9 开始,有了一个非常有用的可以读取流中所有字节的方法:
Java
byte[] bytes = in.readAllBytes();还有多个用来读取给定数量字节的方法,可以参见 API 说明。这些方法都要调用抽象的 read 方法,因此,各个子类都只需覆盖这一个方法。
与此类似,OutputStream 类定义了下面的抽象方法:
Java
abstract void write(int b)它可以向某个输出位置写出一个字节。
如果我们有一个字节数组,还可以一次性地写出它们:
Java
byte[] values = ...;
out.write(values);1
2
2
transferTo 方法可以将所有字节从一个输入流传递到一个输出流:
Java
in.transferTo(out);read 和 write 方法在执行时都将阻塞,直至字节确实被读入或写出。available 方法使我们可以去检查当前可读入的字节数量,这意味着像下面这样的代码片段不可能被阻塞:
Java
int bytesAvailable = in.available();
if (bytesAvailable < 0) {
var data = new byte[bytesAvailable];
in.read(data);
}1
2
3
4
5
2
3
4
5
当你完成对输入/输出流的读写时,应该通过调用 close 方法来关闭它。如果不关闭文件,那么写出字节的最后一个包可能永远也得不到传递。当然,我们还可以用 flush 方法来人为地冲刷这些输出。
1.2. 完整的流家族


让我们把输入/输出流家族中的成员按照它们的使用方法来进行划分,这样就形成了处理字节和字符的两个单独的层次结构。正如所见,InputStream 和 OutputStream 类可以读写单个字节或字节数组,这些类构成了图 1.1 所示的层次结构的基础。其中 DataInputStream 和 DataOutputStream 可以以二进制格式读写所有的基本 Java 类型,ZipInputStream 和 ZipOutputStream 可以以我们常见的 ZIP 压缩格式读写文件。
另一方面,对于 Unicode 文本,可以使用抽象类 Reader 和 Writer 的子类(请参见图 1.2)。Reader 和 Writer 类的基本方法与 InputStream 和 OutputStream 中的方法类似。
Java
abstract int read()
abstract void write(int c)1
2
2
read 方法将返回一个 Unicode 码元(一个在 0 ~ 65535 之间的整数),或者在碰到文件结尾时返回 -1。write 方法在被调用时,需要传递一个 Unicode 码元。
还有 4 个附加的接口:Closeable、Flushable、Readable 和 Appendable(请查看图 1.3)。前两个接口非常简单,它们分别拥有下面的方法:
Java
void close() throws IOException // Closeable
void flush() // Flushable1
2
2

Note:关于
Closeable与AutoCloseable接口的区别详见:这里。
Readable 接口只有一个方法:
Java
int read(CharBuffer cb)CharBuffer 类拥有按顺序和随机地进行读写访问的方法,它表示一个内存中的缓冲区或者一个内存映像的文件。
Appendable 接口有两个用于添加单个字符和字符序列的方法:
Java
Appendable append(char c)
Appendable append(CharSequence s)1
2
2
CharSequence 接口描述了一个 char 值序列的基本属性,String、CharBuffer、StringBuilder 和 StringBuffer 都实现了它。
1.3. 组合输入/输出流过滤器
FileInputStream 和 FileOutputStream 可以提供附着在一个磁盘文件上的输入流和输出流,而你只需向其构造器提供文件名或文件的完整路径名。例如:
Java
var fin = new FileInputStream("employee.dat");这行代码可以查看用户目录下名为 employee.dat 的文件。
Tip:所有在
java.io中的类都将相对路径名解释为以用户工作目录开始(比如,Java 虚拟机启动目录的位置;在命令行执行java MyProg命令启动程序,启动目录就是命令解释器的当前目录;如果使用集成开发环境,那么启动目录将由 IDE 控制)。你可以通过调用System.getProperty("user.dir")来获得这个信息。
Tip:由于反斜杠字符在 Java 字符串中是转义字符,因此要确保在 Windows 风格的路径名中使用
\\(例如,C:\\Windows\\win.ini)。在 Windows 中,还可以使用单斜杠字符(C:/Windows/win.ini),因为大部分 Windows 文件处理的系统调用都会将斜杠解释成文件分隔符。但是,并不推荐这样做,因为 Windows 系统函数的行为会因与时俱进而发生变化。因此,对于可移植的程序来说,应该使用程序所运行平台的文件分隔符,我们可以通过常量字符串java.io.File.separator获得它。
某些输入流(例如 FileInputStream 和由 URL 类的 openStream 方法返回的输入流)可以从文件和其他更外部的位置上获取字节,而其他的输入流(例如 DataInputStream)可以将字节组装到更有用的数据类型中。Java 程序员必须对二者进行组合。例如,为了从文件中读入数字,首先需要创建一个 FileInputStream,然后将其传递给 DataInputStream 的构造器:
Java
var fin = new FileInputStream("employee.dat");
var din = new DataInputStream(fin);
double x = din.readDouble();1
2
3
2
3
可以通过嵌套过滤器来添加多重功能。例如,输入流在默认情况下是不被缓冲区缓存的,也就是说,每个对 read 的调用都会请求操作系统再分发一个字节。相比之下,请求一个数据块并将其置于缓冲区中会显得更加高效。如果我们想使用缓冲机制和用于文件的数据输入方法,那么就需要使用下面这种比较复杂的构造器序列:
Java
var din = new DataInputStream(new BufferedInputStream(new FileInputStream("employee.dat")));注意,我们把 DataInputStream 置于构造器链的最后,这是因为我们希望使用 DataInputStream 的方法,并且希望它们能够使用带缓冲机制的 read 方法。
有时当多个输入流链接在一起时,你需要跟踪各个中介输入流(intermediate input stream)。例如,当读入输入时,你经常需要预览下一个字节,以了解它是否是你想要的值。Java 提供了用于此目的的 PushbackInputStream:
Java
var pbin = new PushbackInputStream(new BufferedInputStream(new FileInputStream("employee.dat")));现在你可以预读下一个字节:
Java
int b = pbin.read();并且在它并非你所期望的值时将其推回流中:
Java
if (b != ‘<’) pbin.unread(b);1.4. 文本输入与输出
在存储文本字符串时,需要考虑字符编码(character encoding)方式。在 Java 内部使用的 UTF-16 编码方式中,字符串 José 编码为 00 4A 00 6F 00 73 00 E9(十六进制)。但是,许多程序都希望文本文件按照其他的编码方式编码。在 UTF-8 这种在互联网上最常用的编码方式中,这个字符串将写出为 4A 6F 73 C3 A9,其中并没有用于前 3 个字母的任何 0 字节,而字符 é 占用了两个字节。
OutputStreamWriter 类将使用选定的字符编码方式,把 Unicode 码元的输出流转换为字节流。而 InputStreamReader 类将包含字节(用某种字符编码方式表示的字符)的输入流转换为可以产生 Unicode 码元的读入器。
例如,下面的代码就展示了如何让输入读入器从控制台读入键盘敲击信息,并将其转换为 Unicode:
Java
var in = new InputStreamReader(System.in);这个输入流读入器会假定使用主机系统所使用的默认字符编码方式。比较推荐的方式是,你应该总是在 InputStreamReader 的构造器中选择一种具体的编码方式。例如:
Java
var in = new InputStreamReader(new FileInputStream("data.txt"), StandardCharsets.UTF_8);1.5. 如何写出文本输出
对于文本输出,可以使用 PrintWriter,这个类拥有以文本格式打印字符串和数字的方法。为了打印文件,需要用文件名和字符编码方式构建一个 PrintStream 对象:
Java
var out = new PrintWriter("employee.txt", StandardCharsets.UTF_8);考虑下面的代码:
Java
String name = "Harry Hacker";
double salary = 75000;
out.print(name);
out.print(' ');
out.println(salary);1
2
3
4
5
2
3
4
5
它将把字符:
Text
Harry Hacker 75000.0输出到写出器 out,之后这些字符将会被转换成字节并最终写入 employee.txt 中。
Note:
println方法在行中添加了对目标系统来说恰当的行结束符(Windows 系统是\r\n,UNIX 系统是\n)。也就是通过调用System.getProperty("line.separator")而获得的字符串。
如果写出器设置为自动冲刷模式,那么只要 println 被调用,缓冲区中的所有字符都会被发送到它们的目的地(打印写出器总是带缓冲区的)。默认情况下,自动冲刷机制是禁用的,你可以通过使用 PrintWriter(Writer writer, boolean autoFlush) 来启用或禁用自动冲刷机制:
Java
var out = new PrintWriter(
new OutputStreamWriter(new FileOutputStream("employee.txt"), StandardCharsets.UTF_8),
true // autoflush
);1
2
3
4
2
3
4
1.6. 如何读入文本输入
最简单的处理任意文本的方式就是使用广泛使用的 Scanner 类。我们可以从任何输入流中构建 Scanner 对象。
或者,我们也可以将短小的文本文件像下面这样读入到一个字符串中:
Java
var content = new String(Files.readAllBytes(path), charset);但是,如果想要将这个文件一行行地读入,那么可以调用:
Java
List<String> lines = Files.readAllLines(path, charset);如果文件太大,那么可以将行惰性处理为一个 Stream<String> 对象:
Java
try (Stream<String> lines = Files.lines(path, charset)) {
// ...
}1
2
3
2
3
还可以使用扫描器来读入符号(token),即由分隔符分隔的字符串,默认的分隔符是空白字符。可以将分隔符修改为任意的正则表达式。例如,下面的代码:
Java
Scanner in = ...;
in.useDelimiter("\\PL+");1
2
2
将接受任何非 Unicode 字母作为分隔符。之后,这个扫描器将只接受 Unicode 字母。
调用 next 方法可以产生下一个符号(token):
Java
while (in.hasNext()) {
String word = in.next();
// ...
}1
2
3
4
2
3
4
或者,可以像下面这样获取一个包含所有符号的流:
Java
Stream<String> words = in.tokens();在早期的 Java 版本中,处理文本输入的唯一方式就是通过 BufferedReader 类。它的 readLine 方法会产生一行文本,或者在无法获得更多的输入时返回 null。典型的输入循环看起来像下面这样:
Java
InputStream inputStream = ...;
try (var in = new BufferedReader(new InputStreamReader(inputStream, charset))) {
String line;
while ((line = in.readLine()) != null) {
// do something with line
}
}1
2
3
4
5
6
7
2
3
4
5
6
7
如今,BufferedReader 类又有了一个 lines 方法,可以产生一个 Stream<String> 对象。但是,与 Scanner 不同,BufferedReader 没有用于任何读入数字的方法。
Note
因为输入是可见的,所以
Scanner类不适用于从控制台读取密码。Java 6 特别引入了Console类来实现这个目的。要想读取一个密码,可以使用下列代码:JavaConsole cons = System.console(); String username = cons.readLine("User name: "); char[] passwd = cons.readPassword("Password: ");1
2
3为安全起见,返回的密码存放在一个字符数组中,而不是字符串中。在对密码处理完成之后,应该马上用一个填充值覆盖数组元素。
采用
Console对象处理输入不如采用Scanner方便。必须每次读取一行输入,而没有能够读取单个单词或数值的方法。
1.7. 格式化输出
Java
System.out.printf("Hello, %s. Next year, you'll be %d", name, age);每一个以字符开始的格式说明符都用相应的参数替换。格式说明符尾部的转换符指示要格式化的数值的类型:f 表示浮点数,s 表示字符串,d 表示十进制整数。表 1.1 列出了所有转换符。
| 转换符 | 类型 | 示例 |
|---|---|---|
d | 十进制整数 | 159 |
x | 十六进制整数 | 9f |
o | 八进制整数 | 237 |
f | 定点浮点数 | 15.9 |
e | 指数浮点数 | 1.59e+01 |
g | 通用浮点数(e 和 f 中较短的一个) | — |
a | 十六进制浮点数 | 0x1.fccdp3 |
s | 字符串 | Hello |
c | 字符 | H |
b | 布尔 | true |
h | 散列码 | 42628b2 |
tx 或 Tx | 日期或时间 | 己经过时,应当改为使用 java.time 类,参见日期和时间 API |
% | 百分号 | % |
n | 与平台有关的行分隔符 | — |
另外,还可以指定控制格式化输出外观的各种标志。表 1.2 列出了所有的标志。例如,逗号标志可以增加分组分隔符。即:
Java
System.out.printf("%,.2f", 10000.0 / 3.0);会打印:
Java
3,333.33| 标志 | 目的 | 示例 |
|---|---|---|
+ | 打印正数和负数的符号 | +3333.33 |
| 空格 | 在正数之前添加空格 | | 3333.33| |
0 | 数字前面补 0 | 003333.33 |
- | 左对齐 | |3333.33 | |
( | 将负数括在括号内 | (3333.33) |
, | 添加分组分隔符 | 3,333.33 |
#(对于 f 格式) | 包含小数点 | 3,333. |
#(对于 x 或 o 格式) | 添加前缀 0x 或 0 | 0xcafe |
$ | 指定要格式化的参数索引。例如,%1$d %1$x 将以十进制和十六进制格式打印第 1 个参数 | 159 9F |
< | 格式化前面说明的数值。例如,%d %<x 将以十进制和十六进制打印同一个数值 | 159 9F |
Tip:可以使用
s转换符格式化任意的对象。对于实现了Formattable接口的任意对象,将调用这个对象的formatTo方法;否则调用toString方法将这个对象转换为字符串。
可以使用静态的 String.format 方法创建一个格式化的字符串,而不打印输出:
Java
String message = String.format("Hello, %s. Next year, you'll be %d", name, age);Warning
参数索引值从 1 开始,而不是从 0 开始,
%1$...一对第 1 个参数格式化。这就避免了与 0 标志混淆。JavaSystem.out.printf("%1$s %2$tB %2$te, %2$tY", "Due date:", new Date()); // Due date: February 9, 2015
1.8. 示例:以文本格式存储对象
Java
package textFile;
import java.io.*;
import java.nio.charset.*;
import java.time.*;
import java.util.*;
/**
* @author Cay Horstmann
* @version 1.15 2018-03-17
*/
public class TextFileTest {
public static void main(String[] args) throws IOException {
var staff = new Employee[3];
staff[0] = new Employee("Carl Cracker", 75000, 1987, 12, 15);
staff[1] = new Employee("Harry Hacker", 50000, 1989, 10, 1);
staff[2] = new Employee("Tony Tester", 40000, 1990, 3, 15);
// save all employee records to the file employee.dat
try (var out = new PrintWriter("employee.dat", StandardCharsets.UTF_8)) {
writeData(staff, out);
}
// retrieve all records into a new array
try (var in = new Scanner(new FileInputStream("employee.dat"), "UTF-8")) {
Employee[] newStaff = readData(in);
// print the newly read employee records
for (Employee e : newStaff)
System.out.println(e);
}
}
/**
* Writes all employees in an array to a print writer
*
* @param employees an array of employees
* @param out a print writer
*/
private static void writeData(Employee[] employees, PrintWriter out)
throws IOException {
// write number of employees
out.println(employees.length);
for (Employee e : employees)
writeEmployee(out, e);
}
/**
* Reads an array of employees from a scanner
*
* @param in the scanner
* @return the array of employees
*/
private static Employee[] readData(Scanner in) {
// retrieve the array size
int n = in.nextInt();
in.nextLine(); // consume newline
var employees = new Employee[n];
for (int i = 0; i < n; i++) {
employees[i] = readEmployee(in);
}
return employees;
}
/**
* Writes employee data to a print writer
*
* @param out the print writer
*/
public static void writeEmployee(PrintWriter out, Employee e) {
out.println(e.getName() + "|" + e.getSalary() + "|" + e.getHireDay());
}
/**
* Reads employee data from a buffered reader
*
* @param in the scanner
*/
public static Employee readEmployee(Scanner in) {
String line = in.nextLine();
String[] tokens = line.split("\\|");
String name = tokens[0];
double salary = Double.parseDouble(tokens[1]);
LocalDate hireDate = LocalDate.parse(tokens[2]);
int year = hireDate.getYear();
int month = hireDate.getMonthValue();
int day = hireDate.getDayOfMonth();
return new Employee(name, salary, year, month, day);
}
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
1.9. 字符编码方式
Java 针对字符使用的是 Unicode 标准。每个字符或 “编码点” 都具有一个 21 位的整数有多种不同的字符编码方式,也就是说,将这些 21 位数字包装成字节的方法有多种。
最常见的编码方式是 UTF-8,它会将每个 Unicode 编码点编码为 1 到 4 个字节的序列(请参阅表 1.3)。UTF-8 的好处是传统的包含了英语中用到的所有字符的 ASCII 字符集中的每个字符都只会占用一个字节。
| 字符范围 | 编码方式 |
|---|---|
| 0...7F | |
| 80...7FF | |
| 800..FFFF | |
| 10000...10FFFF |
另一种常见的编码方式是 UTF-16,它会将每个 Unicode 编码点编码为 1 个或 2 个 16 位值(请参阅表 1.4)。这是一种在 Java 字符串中使用的编码方式。实际上,有两种形式的 UTF-16,被称为 “高位优先” 和 “低位优先”。考虑一下 16 位值 0x2122。在高位优先格式中,高位字节会先出现:0x21 后面跟着 0x22。但是在低位优先格式中,是另外一种排列方式:0x22 0x21。为了表示使用的是哪一种格式,文件可以以 “字节顺序标记” 开头,这个标记为 16 位数值 0xFEFF。读入器可以使用这个值来确定字节顺序,然后丢弃它。
| 字符范围 | 编码方式 |
|---|---|
| 0...FFFF | |
| 10000...10FFFF | 其中 |
Note:有些程序,包括 Microsoft Notepad(微软记事本)在内,都在 UTF-8 编码的文件开头处添加了一个字节顺序标记。很明显,这并不需要,因为在 UTF-8 中,并不存在字节顺序的问题。但是 Unicode 标准允许这样做,甚至认为这是一种好的做法,因为这样做可以使编码机制不留疑惑。遗憾的是,Java 并没有这么做,有关这个问题的缺陷报告最终是以 “will not fix”(不做修正)关闭的。对你来说,最好的做法是将输入中发现的所有先导的
\uFEFF都剥离掉。
不存在任何可靠的方式可以自动地探测出字节流中所使用的字符编码方式。某些 API 方法让我们使用 “默认字符集”,即计算机的操作系统首选的字符编码方式。这种字符编码方式与我们的字节源中所使用的编码方式相同吗?字节源中的字节可能来自世界上的其他国家或地区,因此,你应该总是明确指定编码方式。例如,在编写网页时,应该检查 Content-Type 头信息。
Tip:平台使用的编码方式可以由静态方法
Charset.defaultCharset返回。静态方法Charset.availableCharsets会返回所有可用的Charset实例,返回结果是一个从字符集的规范名称到Charset对象的映射表。
Note:Oracle 的 Java 实现有一个用于覆盖平台默认值的系统属性
file.encoding。但是它并非官方支持的属性,并且 Java 库的 Oracle 实现的所有部分并非都以一致的方式处理该属性,因此,你不应该设置它。
StandardCharsets 类具有类型为 Charset 的静态变量,用于表示每种 Java 虚拟机都必须支持的字符编码方式:
Java
StandardCharsets.UTF_8
StandardCharsets.UTF_16
StandardCharsets.UTF_16BE
StandardCharsets.UTF_16LE
StandardCharsets.ISO_8859_1
StandardCharsets.US_ASCII1
2
3
4
5
6
2
3
4
5
6
为了获得另一种编码方式的 Charset,可以使用静态的 forName 方法:
Java
Charset shiftJIS = Charset.forName("Shift-JIS");在读入或写出文本时,应该使用 Charset 对象。例如,我们可以像下面这样将一个字节数组转换为字符串:
Java
var str = new String(bytes, StandardCharsets.UTF_8);Tip:在不指定任何编码方式时,有些方法(例如
String(byte[])构造器)会使用默认的平台编码方式,而其他方法(例如Files.readAllLines)会使用 UTF-8。
2. 读写二进制数据
2.1. DataInput 和 DataOutput 接口
DataOutput 接口定义了下面用于以二进制格式写数组、字符、boolean 值和字符串的方法:
Text
writeChars writeFloat
writeByte writeDouble
writeInt writeChar
writeShort writeBoolean
writeLong writeUTF1
2
3
4
5
2
3
4
5
例如,writeInt 总是将一个整数写出为 4 字节的二进制数量值,而不管它有多少位。
Note:根据你所使用的处理器类型,在内存存储整数和浮点数有两种不同的方法。例如,假设你使用的是 4 字节的
int,如果有一个十进制数1234,也就是十六进制的0x0000 04D2,那么它可以按照内存中 4 字节的第一个字节存储最高位字节的方式来存储为00 00 04 D2,这就是所谓的高位在前顺序(MSB);我们也可以从最低位字节开始,即D2 04 00 00,这种方式自然就是所谓的低位在前顺序(LSB)。例如,SPARC 使用的是高位在前顺序,而 Pentium 使用的则是低位在前顺序这就可能会带来问题,当存储 C 或者 C++ 文件时,数据会精确地按照处理器存储它们的方式来存储,这就使得即使是最简单的数据在从一个平台迁移到另一个平台上时也是一种挑战。在 Java 中,所有的值都按照高位在前的模式写出,不管使用何种处理器,这使得 Java 数据文件可以独立于平台。
其中 writeUTF 方法使用修订版的 8 位 Unicode 转换格式写出字符串。这种方式与直接使用标准的 UTF-8 编码方式不同,其中,Unicode 码元序列首先用 UTF-16 表示,其结果之后使用 UTF-8 规则进行编码。修订后的编码方式对于编码大于 0xFFFF 的字符的处理有所不同,这是为了向后兼容在 Unicode 还没有超过 16 位时构建的虚拟机。
因为没有其他方法会使用 UTF-8 的这种修订,所以你应该只在写出用于 Java 虚拟机的字符串时才使用 writeUTF 方法,例如,当你需要编写一个生成字节码的程序时。对于其他场合,都应该使用 writeChars 方法。
为了读回数据,可以使用在 DataInput 接口中定义的下列方法:
Text
readInt readDouble
readShort readChar
readLong readBoolean
readFloat readUTF1
2
3
4
2
3
4
DataInputStream 类实现了 DataInput 接口,为了从文件中读入二进制数据,可以将 DataInputStream 与某个字节源相组合,例如 FileInputStream:
Java
var in = new DataInputStream(new FileInputStream("employee.dat"));与此类似,要想写出二进制数据,你可以使用实现了 DataOutput 接口的 DataOutputStream 类:
Java
var out = new DataOutputStream(new FileOutputStream("employee.dat"));2.2. 随机访问文件
RandomAccessFile 类可以在文件中的任何位置查找或写入数据。磁盘文件都是随机访问的,但是与网络套接字通信的输入/输出流却不是。你可以打开一个随机访问文件,只用于读入或者同时用于读写,你可以通过使用字符串 r(用于读入访问)或 rw(用于读入/写出访问)作为构造器的第二个参数来指定这个选项。
Java
var in = new RandomAccessFile("employee.dat", "r");
var inOut = new RandomAccessFile("employee.dat", "rw");1
2
2
随机访问文件有一个表示下一个将被读入或写出的字节所处位置的文件指针,seek 方法可以用来将这个文件指针设置到文件中的任意字节位置,seek 的参数是一个 long 类型的整数,它的值位于 0 到文件按照字节来度量的长度之间。
getFilePointer 方法将返回文件指针的当前位置。
除了以上方法,RandomAccessFile 类同时还实现了 DataInput 和 DataOutput 接口。
以下是 RandomAccessFile 的一个应用参考示例:
Java
package randomAccess;
import java.io.*;
import java.time.*;
/**
* @author Cay Horstmann
* @version 1.14 2018-05-01
*/
public class RandomAccessTest {
public static void main(String[] args) throws IOException {
var staff = new Employee[3];
staff[0] = new Employee("Carl Cracker", 75000, 1987, 12, 15);
staff[1] = new Employee("Harry Hacker", 50000, 1989, 10, 1);
staff[2] = new Employee("Tony Tester", 40000, 1990, 3, 15);
try (var out = new DataOutputStream(new FileOutputStream("employee.dat"))) {
// save all employee records to the file employee.dat
for (Employee e : staff)
writeData(out, e);
}
try (var in = new RandomAccessFile("employee.dat", "r")) {
// retrieve all records into a new array
// compute the array size
int n = (int) (in.length() / Employee.RECORD_SIZE);
var newStaff = new Employee[n];
// read employees in reverse order
for (int i = n - 1; i >= 0; i--) {
in.seek(i * Employee.RECORD_SIZE);
newStaff[i] = readData(in);
}
// print the newly read employee records
for (Employee e : newStaff)
System.out.println(e);
}
}
/**
* Writes employee data to a data output
*
* @param out the data output
* @param e the employee
*/
public static void writeData(DataOutput out, Employee e) throws IOException {
DataIO.writeFixedString(e.getName(), Employee.NAME_SIZE, out);
out.writeDouble(e.getSalary());
LocalDate hireDay = e.getHireDay();
out.writeInt(hireDay.getYear());
out.writeInt(hireDay.getMonthValue());
out.writeInt(hireDay.getDayOfMonth());
}
/**
* Reads employee data from a data input
*
* @param in the data input
* @return the employee
*/
public static Employee readData(DataInput in) throws IOException {
String name = DataIO.readFixedString(Employee.NAME_SIZE, in);
double salary = in.readDouble();
int y = in.readInt();
int m = in.readInt();
int d = in.readInt();
return new Employee(name, salary, y, m - 1, d);
}
}
public class DataIO {
public static String readFixedString(int size, DataInput in) throws IOException {
var b = new StringBuilder(size);
int i = 0;
var done = false;
while (!done && i < size) {
char ch = in.readChar();
i++;
if (ch == 0) done = true;
else b.append(ch);
}
in.skipBytes(2 * (size - i));
return b.toString();
}
public static void writeFixedString(String s, int size, DataOutput out) throws IOException {
for (int i = 0; i < size; i++) {
char ch = 0;
if (i < s.length()) ch = s.charAt(i);
out.writeChar(ch);
}
}
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
2.3. ZIP 文档
ZIP 文档(通常)以压缩格式存储了一个或多个文件,每个 ZIP 文档都有一个头,包含诸如每个文件名字和所使用的压缩方法等信息。在 Java 中,可以使用 ZipInputStream 来读入 ZIP 文档。你可能需要浏览文档中每个单独的项,getNextEntry 方法就可以返回一个描述这些项的 ZipEntry 类型的对象。该方法会从流中读入数据直至项的末尾,然后调用 closeEntry 来读入下一项。在读入最后一项之前,不要关闭 zin。下面是典型的通读 ZIP 文件的代码序列:
Java
var zin = new ZipInputStream(new FileInputStream(zipname));
ZipEntry entry;
while ((entry = zin.getNextEntry()) != null) {
// read the contents of zin
zin.closeEntry();
}
zin.close();1
2
3
4
5
6
7
2
3
4
5
6
7
要写出到 ZIP 文件,可以使用 ZipOutputStream,而对于你希望放入到 ZIP 文件中的每一项,都应该创建一个 ZipEntry 对象,并将文件名传递给 ZipEntry 的构造器,它将设置其他诸如文件日期和解压缩方法等参数。如果需要,你可以覆盖这些设置。然后,你需要调用 ZipOutputStream 的 putNextEntry 方法来写出新文件,并将文件数据发送到 ZIP 输出流中。当完成时,需要调用 closeEntry。然后,你需要对所有希望存储的文件都重复这个过程。下面是代码框架:
Java
var zout = new ZipOutputStream(new FileOutputStream(zipname));
// for all files
{
var ze = new ZipEntry(filename);
zout.putNextEntry(ze);
// send data to zout
zout.closeEntry();
}
zout.close();1
2
3
4
5
6
7
8
9
2
3
4
5
6
7
8
9
Note 1:JAR 文件只是带有一个特殊项的 ZIP 文件,这个项称作清单。你可以使用
JarInputStream和JarOutputStream类来读写清单项。
Note 2:当你读入以压缩格式存储的数据时,不必担心边请求边解压数据的问题,而且格式的字节源并非必须是文件,也可以是来自网络连接的 ZIP 数据。
3. 对象输入/输出流与序列化
3.1. 保存和加载序列化对象
为了保存对象数据,首先需要打开一个 ObjectOutputStream 对象:
Java
var out = new ObjectOutputStream(new FileOutputStream("employee.dat"));现在,可以直接使用 ObjectOutputStream 的 writeObject 方法,如下所示:
Java
var harry = new Employee("Harry Hacker", 50000, 1989, 10, 1);
var boss = new Manager("Carl Cracker", 80000, 1987, 12, 15);
out.writeObject(harry);
out.writeObject(boss);1
2
3
4
2
3
4
为了将这些对象读回,首先需要获得一个 ObjectInputStream 对象:
Java
var in = new ObjectInputStream(new FileInputStream("employee.dat"));然后,用 readObject 方法以这些对象被写出时的顺序获得它们:
Java
var e1 = (Employee) in.readObject();
var e2 = (Employee) in.readObject();1
2
2
希望在对象输出流中存储或从对象输入流中恢复的所有类,类及类的属性都必须实现 Serializable 接口:
Java
class Employee implements Serializable { ... }Serializable 接口没有任何方法,因此你不需要对这些类做任何改动。
Note:只有在写出/读入对象时才能用
writeObject/readObject方法,对于基本类型值,你需要使用诸如writeInt/readInt这样的方法。(对象流类都实现了DataInput/DataOutput接口。)
有一种重要的情况需要考虑:当一个对象被多个对象共享,作为它们各自状态的一部分时,会发生什么呢?为了说明这个问题,我们对 Manager 类稍微做些修改,假设每个经理都有一个秘书:
Java
class Manager extends Employee {
private Employee secretary;
...
}1
2
3
4
2
3
4
现在每个对象都包含一个表示秘书的 Employee 对象的引用,当然,两个经理可以共用一个秘书,正如下面的代码所示的那样:
Java
var harry = new Employee("Harry Hacker", ...);
var carl = new Manager("Carl Cracker", ...);
carl.setSecretary(harry);
var tony = new Manager("Tony Tester", ...);
tony.setSecretary(harry);1
2
3
4
5
2
3
4
5
在这里当然不能去保存和恢复秘书对象的内存地址,因为当对象被重新加载时,它可能占据的是与原来完全不同的内存地址。与此不同的是,每个对象都是用一个序列号(serial number)保存的,这就是这种机制之所以称为对象序列化的原因。下面是其算法:
- 遇到的每一个对象引用都关联一个序列号;
- 每个对象,当第一次遇到时,保存其对象数据到输出流中;
- 如果某个对象之前已经被保存过,那么只写出 “与之前保存过的序列号为 x 的对象相同”;
在读回对象时,整个过程是反过来的:
- 对于对象输入流中的对象,在第一次遇到其序列号时,构建它,并使用流中数据来初始化它,然后记录这个顺序号和新对象之间的关联;
- 当遇到 “与之前保存过的序列号为 x 的对象相同” 这一标记时,获取与这个序列号相关联的对象引用;
3.2. 理解对象序列化的文件格式
每个文件都是以下面这两个字节的 “魔幻数字” 开始的:
Text
AC ED后面紧跟着对象序列化格式的版本号,目前是:
Text
00 05然后是它包含的对象序列,其顺序即它们存储的顺序。
字符串对象被存为:
EBNF
'74' TwoByteLength Characters例如,字符串 “Harry” 被存为:
Text
74 00 05 Harry字符串中的 Unicode 字符被存储为修订过的 UTF-8 格式。
当存储一个对象时,这个对象所属的类也必须存储。这个类的描述包含:
- 类名;
- 序列化的版本唯一的 ID,它是数据域类型和方法签名的指纹;
- 描述序列化方法的标志集;
- 对数据域的描述;
指纹是通过对类、超类、接口、域类型和方法签名按照规范方式排序,然后将安全散列算法(SHA)应用于这些数据而获得的。
在读入一个对象时,会拿其指纹与它所属的类的当前指纹进行比对,如果它们不匹配,那么就说明这个类的定义在该对象被写出之后发生过变化,因此会产生一个异常。在实际情况下,类当然是会演化的,因此对于程序来说,读入较旧版本的对象可能是必需的。我们将在后续讨论这个问题。
下面表示了类标识符是如何存储的:
- 72;
- 2 字节的类名长度;
- 类名;
- 8 字节长的指纹;
- 1 字节长的标志;
- 2 字节长的数据域描述符的数量;
- 数据域描述符;
- 78(结束标记);
- 超类类型(如果没有就是 70);
标志字节是由在 java.io.ObjectStreamConstants 中定义的 3 位掩码构成的:
Java
static final byte SC_WRITE_METHOD = 1; // class has a writeObject method that writes additional data
static final byte SC_SERIALIZABLE = 2; // class implements the Serializable interface
static final byte SC_EXTERNALIZABLE = 4; // class implements the Externalizable interface1
2
3
2
3
我们会在稍后讨论 Externalizable 接口。可外部化的类提供了定制的接管其实例域输出的读写方法。我们要写出的这些类实现了 Serializable 接口,并且其标志值为 02,而可序列化的 java.util.Date 类定义了它自己的 readObject/writeObject 方法,并且其标志值为 03。
每个数据域描述符的格式如下:
- 1 字节长的类型编码;
- 2 字节长的域名长度;
- 域名;
- 类名(如果域是对象);
其中类型编码是下列取值之一:
| 类型编码 | 含义 |
|---|---|
B | byte |
C | char |
D | double |
F | float |
I | int |
J | long |
L | object |
S | short |
Z | boolean |
[ | array |
当类型编码为 L 时,域名后面紧跟域的类型。类名和域名字符串不是以字符串编码 74 开头的,但域类型是。域类型使用的是与域名稍有不同的编码机制,即本地方法使用的格式。
例如,Employee 类的薪水域被编码为:
Text
D 00 06 salary下面是 Employee 类完整的类描述符:
Text
72 00 08 Employee
E6 D2 86 7D AE AC 18 1B 02 指纹和标志
00 03 实例域的数量
D 00 06 salary 实例域的类型和名字
L 00 07 hireDay 实例域的类型和名字
74 00 10 Ljava/util/Date; 实例域的类名:Date
L 00 04 name 实例域的类型和名字
74 00 12 Ljava/lang/String; 实例域的类名:String
78 结束标记
70 无超类1
2
3
4
5
6
7
8
9
10
2
3
4
5
6
7
8
9
10
这些描述符相当长,如果在文件中再次需要相同的类描述符,可以使用一种缩写版:
EBNF
'71' FourByteSerialNumber这个序列号(FourByteSerialNumber)将引用前面已经描述过的类描述符,我们稍后将讨论编号模式。
对象将被存储为:
EBNF
'73' ClassDescriptor ObjectData例如,下面展示的就是 Employee 对象如何存储:
Text
40 E8 6A 00 00 00 00 00 salary 域的值:double
73 hireDate 域的值:新对象
71 00 7E 00 08 已有的类 java.util.Date
77 08 00 00 00 91 1B 4E B1 80 78 外部存储,稍后讨论细节
74 00 0C Harry Hacker name 域的值:String1
2
3
4
5
2
3
4
5
正如你所看见的,数据文件包含了足够的信息来恢复这个 Employee 对象。
数组总是被存储成下面的格式:
EBNF
'75' ClassDescriptor FourByteNumberOfEntries Entries在类描述符中的数组类名的格式与本地方法中使用的格式相同(它与在其他的类描述符中的类名稍微有些差异)。在这种格式中,类名以 L 开头,以分号结束。
例如,3 个 Employee 对象构成的数组写出时就像下面一样:
Text
75 数组
72 00 0B [LEmployee; 新类,字符串长度,类名 Employee[]
FC BF 36 11 C5 91 11 C7 02 指纹和标志
00 00 实例域的数量
78 结束标记
70 无超类
00 00 00 03 数组项的数量1
2
3
4
5
6
7
2
3
4
5
6
7
注意,Employee 对象数组的指纹与 Employee 类自身的指纹并不相同。
所有对象(包含数组和字符串)和所有的类描述符在存储到输出文件时都被赋予了一个序列号,这个数字以 00 7E 00 00 开头。
我们已经看到过,任何给定类的完整类描述符只保存一次,后续的描述符将引用它。例如,在前面的示例中,对 Date 类的重复引用就被编码为:
Text
71 00 7E 00 08相同的机制还被用于对象。如果要写出一个对之前存储过的对象的引用,那么这个引用也会以完全相同的方式存储,即 71 后面跟随序列号,从上下文中可以很清楚地了解这个特殊的序列引用表示的是类描述符还是对象。
最后,空引用被存储为:
Text
70你应该记住:
- 对象流输出中包含所有对象的类型和数据域;
- 每个对象都被赋予一个序列号;
- 相同对象的重复出现将被存储为对这个对象的序列号的引用;
3.3. 修改默认的序列化机制
某些数据域是不可以序列化的,例如,只对本地方法有意义的存储文件句柄或窗口句柄的整数值,这种信息在稍后重新加载对象或将其传送到其他机器上时都是没有用处的。事实上,这种域的值如果不恰当,还会引起本地方法崩溃。Java 拥有一种很简单的机制来防止这种域被序列化,那就是将它们标记成 transient 的。如果这些域属于不可序列化的类,你也需要将它们标记成 transient 的。瞬时的域在对象被序列化时总是被跳过的。
序列化机制为单个的类提供了一种方式,去向默认的读写行为添加验证或任何其他想要的行为。可序列化的类可以定义具有下列签名的方法:
Java
private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException;
private void writeObject(ObjectOutputStream out) throws IOException;1
2
2
之后,数据域就再也不会被自动序列化,取而代之的是调用这些方法。
下面是一个典型的示例。在 java.awt.geom 包中有大量的类都是不可序列化的,例如 Point2D.Double。现在假设你想要序列化一个 LabeledPoint 类,它存储了一个 String 和一个 Point2D.Double。首先,你需要将 Point2D.Double 标记成 transient,以避免抛出 NotSerializableException。
Java
public class LabeledPoint implements Serializable {
private String label;
private transient Point2D.Double point;
...
}1
2
3
4
5
2
3
4
5
在 writeObject 方法中,我们首先通过调用 defaultWriteObject 方法写出对象描述符和 String 域 label,这是 ObjectOutputStream 类中的一个特殊的方法,它只能在可序列化类的 writeObject 方法中被调用。然后,我们使用标准的 DataOutput 调用写出点的坐标。
Java
private void writeObject(ObjectOutputStream out) throws IOException {
out.defaultWriteObject();
out.writeDouble(point.getX());
out.writeDouble(point.getY());
}1
2
3
4
5
2
3
4
5
在 readObject 方法中,我们反过来执行上述过程:
Java
private void readObject(ObjectInputStream in) throws IOException {
in.defaultReadObject();
double x = in.readDouble();
double y = in.readDouble();
point = new Point2D.Double(x, y);
}1
2
3
4
5
6
2
3
4
5
6
另一个例子是 java.util.Date 类,它提供了自己的 readObject 和 writeObject 方法,这些方法将日期写出为从纪元(UTC 时间 1970 年 1 月 1 日 0 点)开始的毫秒数。Date 类有一个复杂的内部表示,为了优化查询,它存储了一个 Calendar 对象和一个毫秒计数值。Calendar 的状态是冗余的,因此并不需要保存。
readObject 和 writeObject 方法只需要保存和加载它们的数据域,而不需要关心超类数据和任何其他类的信息。
除了让序列化机制来保存和恢复对象数据,类还可以定义它自己的机制。为了做到这一点,这个类必须实现 Externalizable 接口,这需要它定义两个方法:
Java
public void readExternal(ObjectInputStream in) throws IOException, ClassNotFoundException;
public void writeExternal(ObjectOutputStream out) throws IOException;1
2
2
与前面一节描述的 readObject 和 writeObject 不同,这些方法对包括超类数据在内的整个对象的存储和恢复负全责。在写出对象时,序列化机制在输出流中仅仅只是记录该对象所属的类。在读入可外部化的类时,对象输入流将用无参构造器创建一个对象,然后调用 readExternal 方法。下面展示了如何为 Employee 类实现这些方法:
Java
public void readExternal(ObjectInput s) throws IOException {
name = s.readUTF();
salary = s.readDouble();
hireDay = LocalDate.ofEpochDay(s.readLong());
}
public void writeExternal(ObjectOutput s) throws IOException {
s.writeUTF(name);
s.writeDouble(salary);
s.writeLong(hireDay.toEpochDay());
}1
2
3
4
5
6
7
8
9
10
11
2
3
4
5
6
7
8
9
10
11
Note:
readObject和writeObject方法是私有的,并且只能被序列化机制调用。与此不同的是,readExternal和writeExternal方法是公共的。特别是,readExternal还潜在地允许修改现有对象的状态。
3.4. 序列化单例和类型安全的枚举
在序列化和反序列化时,如果目标对象是唯一的,那么你必须加倍当心,这通常会在实现单例和类型安全的枚举时发生。
如果你使用 Java 语言的 enum 结构,那么你就不必担心序列化,它能够正常工作。但是,假设你在维护遗留代码,其中包含下面这样的枚举类型:
Java
public class Orientation {
public static final Orientation HORIZONTAL = new Orientation(1);
public static final Orientation VERTICAL = new Orientation(2);
private int value;
private Orientation(int v) { value = v; }
}1
2
3
4
5
6
7
8
2
3
4
5
6
7
8
这种风格在枚举被添加到 Java 语言中之前是很普遍的。注意,其构造器是私有的。因此,不可能创建出超出 Orientation.HORIZONTAL 和 Orientation.VERTICAL 之外的对象。特别是,你可以使用 == 操作符来测试对象的等同性:
Java
if (orientation == Orientation.HORIZONTAL) ...当类型安全的枚举实现 Serializable 接口时,你必须牢记存在着一种重要的变化,此时,默认的序列化机制是不适用的。假设我们写出一个 0 类型的值,并再次将其读回:
Java
Orientation original = Orientation.HORIZONTAL;
ObjectOutputStream out = ...;
out.write(original);
out.close();
ObjectInputStream in = ...;
var saved = (Orientation) in.read();1
2
3
4
5
6
2
3
4
5
6
现在,下面的测试:
Java
if (saved == Orientation.HORIZONTAL) ...将失败。事实上,saved 的值是 Orientation 类型的一个全新的对象,它与任何预定义的常量都不等同。即使构造器是私有的,序列化机制也可以创建新的对象!
为了解决这个问题,你需要定义另外一种称为 readResolve 的特殊序列化方法。如果定义了 readResolve 方法,在对象被序列化之后就会调用它。它必须返回一个对象,而该对象之后会成为 readObject 的返回值。在上面的情况中,readResolve 方法将检查 value 域并返回恰当的枚举常量:
Java
protected Object readResolve() throws ObjectStreamException {
if (value == 1) return Orientation.HORIZONTAL;
if (value == 2) return Orientation.VERTICAL;
throw new ObjectStreamException(); // this shouldn’t happen
}1
2
3
4
5
2
3
4
5
请记住向遗留代码中所有类型安全的枚举以及向所有支持单例设计模式的类中添加 readResolve 方法。
3.5. 版本管理
如果使用序列化来保存对象,就需要考虑在程序演化时会有什么问题。例如,1.1 版本可以读入旧文件吗?仍旧使用 1.0 版本的用户可以读入新版本产生的文件吗?显然,如果对象文件可以处理类的演化问题,那它正是我们想要的。
乍一看,这好像是不可能的。无论类的定义产生了什么样的变化,它的 SHA 指纹也会跟着变化,而我们都知道对象输入流将拒绝读入具有不同指纹的对象。但是,类可以表明它对其早期版本保持兼容,要想这样做,就必须首先获得这个类的早期版本的指纹。我们可以使用 JDK 中的单机程序 serialver 来获得这个数字,例如,运行下面的命令:
Text
serialver Employee将会打印出:
Text
Employee: static final long serialVersionUID = -1814239825517340645L;这个类的所有较新的版本都必须把 serialVersionUID 常量定义为与最初版本的指纹相同。
Java
class Employee implements Serializable { // version 1.1
...
public static final long serialVersionUID = -1814239825517340645L;
}1
2
3
4
2
3
4
如果一个类具有名为 serialVersionUID 的静态数据成员,它就不再需要人工计算指纹,而只需直接使用这个值。
一旦这个静态数据成员被置于某个类的内部,那么序列化系统就可以读入这个类的对象的不同版本。
如果这个类只有方法产生了变化,那么在读入新对象数据时是不会有任何问题的。但是,如果数据域产生了变化,那么就可能会有问题。例如,旧文件对象可能比程序中的对象具有更多或更少的数据域,或者数据域的类型可能有所不同。在这些情况中,对象输入流将尽力将流对象转换成这个类当前的版本。
对象输入流会将这个类当前版本的数据域与被序列化的版本中的数据域进行比较,当然,对象流只会考虑非瞬时和非静态的数据域。如果这两部分数据域之间名字匹配而类型不匹配,那么对象输入流不会尝试将一种类型转换成另一种类型,因为这两个对象不兼容;如果被序列化的对象具有在当前版本中所没有的数据域,那么对象输入流会忽略这些额外的数据;如果当前版本具有在被序列化的对象中所没有的数据域,那么这些新添加的域将被设置成它们的默认值(如果是对象则是 null,如果是数字则为 0,如果是 boolean 值则是 false)。
这种处理是安全的吗?视情况而定。这个问题取决于类的设计者是否能够在 readObject 方法中实现额外的代码去订正版本不兼容问题,或者是否能够确保所有的方法在处理任何情况的数据时都足够健壮。
Note:在将
serialVersionUID域添加到类中之前,需要问问自己为什么要让这个类是可序列化的。如果序列化只是用于短期持久化,例如在应用服务器中的分布式方法调用,那么就不需要关心版本机制和serialVersionUID。如果碰巧要扩展一个可序列化的类,但是又从来没想过要持久化该扩展类的任何实例,那么同样不需要关心它们。如果 IDE 总是报有关此问题的烦人的警告消息,那么可以修改 IDE 偏好,将它们关闭,或者添加@SuppressWarnings("serial")注解。这样做比添加serialVersionUID要更安全,因为也许后续我们会忘记修改serialVersionUID。
3.6. 为克隆使用序列化
序列化机制有一种很有趣的用法:即提供了一种克隆对象的简便途径,只要对应的类是可序列化的即可。其做法很简单:直接将对象序列化到输出流中,然后将其读回。这样产生的新对象是对现有对象的一个深拷贝(deep copy)。在此过程中,我们不必将对象写出到文件中,因为可以用 ByteArrayOutputStream 将数据保存到字节数组中。
正如以下程序所示,要想得到 clone 方法,只需扩展 SerialCloneable 类,这样就完事了。
Java
package serialClone;
/**
* @version 1.22 2018-05-01
* @author Cay Horstmann
*/
import java.io.*;
import java.time.*;
public class SerialCloneTest {
public static void main(String[] args) throws CloneNotSupportedException {
var harry = new Employee("Harry Hacker", 35000, 1989, 10, 1);
// clone harry
var harry2 = (Employee) harry.clone();
// mutate harry
harry.raiseSalary(10);
// now harry and the clone are different
System.out.println(harry);
System.out.println(harry2);
}
}
/**
* A class whose clone method uses serialization.
*/
class SerialCloneable implements Cloneable, Serializable {
public Object clone() throws CloneNotSupportedException {
try {
// save the object to a byte array
var bout = new ByteArrayOutputStream();
try (var out = new ObjectOutputStream(bout)) {
out.writeObject(this);
}
// read a clone of the object from the byte array
try (var bin = new ByteArrayInputStream(bout.toByteArray())) {
var in = new ObjectInputStream(bin);
return in.readObject();
}
} catch (IOException | ClassNotFoundException e) {
var e2 = new CloneNotSupportedException();
e2.initCause(e);
throw e2;
}
}
}
/**
* The familiar Employee class, redefined to extend the
* SerialCloneable class.
*/
class Employee extends SerialCloneable {
private String name;
private double salary;
private LocalDate hireDay;
public Employee(String n, double s, int year, int month, int day) {
name = n;
salary = s;
hireDay = LocalDate.of(year, month, day);
}
public String getName() {
return name;
}
public double getSalary() {
return salary;
}
public LocalDate getHireDay() {
return hireDay;
}
/**
* Raises the salary of this employee.
*
* @byPercent the percentage of the raise
*/
public void raiseSalary(double byPercent) {
double raise = salary * byPercent / 100;
salary += raise;
}
public String toString() {
return getClass().getName()
+ "[name=" + name
+ ",salary=" + salary
+ ",hireDay=" + hireDay
+ "]";
}
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
4. 操作文件
Path 和 Files 类封装了在用户机器上处理文件系统所需的所有功能。例如,Files 类可以用来移除或重命名文件,或者查询文件最后被修改的时间。换句话说,输入/输出流类关心的是文件的内容,而本节要讨论的类关心的是文件在磁盘上的存储。
Note:
Path接口和Files类是在 Java 7 中新添加进来的,它们用起来比自 JDK 1.0 以来就一直使用的File类要方便得多。
4.1. Path
静态的 Paths.get 方法接受一个或多个字符串,并将它们用默认文件系统的路径分隔符(类 UNIX 文件系统是 /,Windows 是 \)连接起来。然后它解析连接起来的结果,如果其表示的不是给定文件系统中的合法路径,那么就抛出 InvalidPathException 异常。这个连接起来的结果就是一个 Path 对象。
Java
String baseDir = props.getProperty("base.dir"); // May be a string such as /opt/myprog or c:\Program Files\myprog
Path basePath = Paths.get(baseDir); // OK that baseDir has separators1
2
2
Note:路径不必对应着某个实际存在的文件,它仅仅是一个抽象的名字序列。在接下来的小节中将会看到,当你想要创建文件时,首先要创建一个路径,然后才调用方法去创建对应的文件。
组合或解析路径是司空见惯的操作,调用 p.resolve(q) 将按照下列规则返回一个路径:
- 如果
q是绝对路径,则结果就是q; - 否则,根据文件系统的规则,将 “
p后面跟着q” 作为结果;
还有一个很方便的方法 resolveSibling,它通过解析指定路径的父路径产生其兄弟路径。例如,如果 workPath 是 /opt/myapp/work,那么下面的调用:
Java
Path tempPath = workPath.resolveSibling("temp");将创建 /opt/myapp/temp。
resolve 的对立面是 relativize,即调用 p.relativize(r) 将产生路径 q,而对 q 进行 resolve 的结果正是 r。例如,以 /home/harry 为目标对 /home/fred/input.txt 进行相对化操作,会产生 ../fred/input.txt。
normalize 方法将移除所有冗余的 . 和 .. 部件(或者文件系统认为冗余的所有部件)。例如,规范化 /home/harry/../fred/input.txt 将产生 /home/fred/input.txt。
toAbsolutePath 方法将产生给定路径的绝对路径,该绝对路径从根部件开始,例如 /home/fred/input.txt 或 C:\Users\fred\input.txt。
Path 类有许多有用的方法用来将路径断开。下面的代码示例展示了其中部分最有用的方法:
Java
Path p = Paths.get("/home", "fred", "myprog.properties");
Path parent = p.getParent(); // the path /home/fred
Path file = p.getFileName(); // the path myprog.properties
Path root = p.getRoot(); // the path /1
2
3
4
2
3
4
Note:偶尔,你可能需要与遗留系统的 API 交互,它们使用的是
File类而不是Path接口。庆幸的是,Path接口有一个toFile方法,File类有一个toPath方法。
4.2. 读写文件
Files 类可以使得普通文件操作变得快捷。例如,可以用下面的方式很容易地读取文件的所有内容:
Java
byte[] bytes = Files.readAllBytes(path);我们还可以如下从文本文件中读取内容:
Java
var content = Files.readString(path, charset);但是如果希望将文件当作行序列读入,那么可以调用:
Java
List<String> lines = Files.readAllLines(path, charset);相反,如果希望写出一个字符串到文件中,可以调用:
Java
Files.write(path, content.getBytes(charset));向指定文件追加内容,可以调用:
Java
Files.write(path, content.getBytes(charset), StandardOpenOption.APPEND);还可以用下面的语句将一个行的集合写出到文件中:
Java
Files.write(path, lines, charset);这些简便方法适用于处理中等长度的文本文件,如果要处理的文件长度比较大,或者是二进制文件,那么还是应该使用所熟知的输入/输出流或者读入器/写出器:
Java
InputStream in = Files.newInputStream(path);
OutputStream out = Files.newOutputStream(path);
Reader in = Files.newBufferedReader(path, charset);
Writer out = Files.newBufferedWriter(path, charset);1
2
3
4
2
3
4
这些便捷方法可以将你从处理 FileInputStream、FileOutputStream、BufferedReader 和 BufferedWriter 的繁复操作中解脱出来。
4.3. 创建文件和目录
创建新目录可以调用:
Java
Files.createDirectory(path);其中,路径中除最后一个部件外,其他部分都必须是已存在的。要创建路径中的中间目录,应该使用
Java
Files.createDirectories(path);可以使用下面的语句创建一个空文件:
Java
Files.createFile(path);如果文件已经存在了,那么这个调用就会抛出异常。检查文件是否存在和创建文件是原子性的,如果文件不存在,该文件就会被创建,并且其他程序在此过程中是无法执行文件创建操作的。
有些便捷方法可以用来在给定位置或者系统指定位置创建临时文件或临时目录:
Java
Path newPath = Files.createTempFile(dir, prefix, suffix);
Path newPath = Files.createTempFile(prefix, suffix);
Path newPath = Files.createTempDirectory(dir, prefix);
Path newPath = Files.createTempDirectory(prefix);1
2
3
4
2
3
4
其中,dir 是一个 Path 对象,prefix 和 suffix 是可以为 null 的字符串。例如,调用 Files.createTempFile(null, ".txt") 可能会返回一个像 /tmp/1234405522364837194.txt 这样的路径。
4.4. 复制、移动和删除文件
将文件从一个位置复制到另一个位置可以直接调用:
Java
Files.copy(fromPath, toPath);移动文件(即复制并删除原文件)可以调用:
Java
Files.move(fromPath, toPath);如果目标路径已经存在,那么复制或移动将失败。如果想要覆盖已有的目标路径,可以使用 REPLACE_EXISTING 选项。如果想要复制所有的文件属性,可以使用 COPY_ATTRIBUTES 选项。可以同时选择这两个选项:
Java
Files.copy(fromPath, toPath, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.COPY_ATTRIBUTES);你可以将移动操作定义为原子性的,这样就可以保证要么移动操作成功完成,要么源文件继续保持在原来位置。具体可以使用 ATOMIC_MOVE 选项来实现:
Java
Files.move(fromPath, toPath, StandardCopyOption.ATOMIC_MOVE);你还可以将一个输入流复制到 Path 中,这表示想要将该输入流存储到硬盘上。类似地,你可以将一个 Path 复制到输出流中。可以使用下面的调用:
Java
Files.copy(inputStream, toPath);
Files.copy(fromPath, outputStream);1
2
2
至于其他对 COPY 的调用,可以根据需要提供相应的复制选项。
最后,删除文件可以调用:
Java
Files.delete(path);如果要删除的文件不存在,这个方法就会抛出异常。因此,可转而使用下面的方法:
Java
boolean deleted = Files.deleteIfExists(path);该删除方法还可以用来移除空目录。
| 选项 | 描述 |
|---|---|
StandardOpenOption: | 与 newBufferedWriter、newInputStream、newOutputStream、write 一起使用 |
READ | 用于读取而打开 |
WRITE | 用于写入而打开 |
APPEND | 如果用于写入而打开,那么在文件末尾追加 |
TRUNCATE_EXISTING | 如果用于写入而打开,那么移除已有内容 |
CREATE_NEW | 创建新文件并且在文件已存在的情况下会创建失败 |
CREATE | 自动在文件不存在的情况下创建新文件 |
DELETE_ON_CLOSE | 当文件被关闭时,尽 “可能” 地删除该文件 |
SPARSE | 给文件系统一个提示,表示该文件是稀疏的 |
DSYNC or SYNC | 要求对文件数据和元数据的每次更新都必须同步地写入到存储设备中 |
StandardCopyOption: | 于 copy 和 move 一起使用 |
ATOMIC_MOVE | 原子性地移动文件 |
COPY_ATTRIBUTES | 复制文件的属性 |
REPLACE_EXISTING | 如果目标已存在,则替换它 |
LinkOption: | 与上面所有方法以及 exists、isDirectory、isRegularFile 等一起使用 |
NOFOLLOW_LINKS | 不要跟踪符号链接 |
FileVisitOption: | 与 find、walk、walkFileTree 一起使用 |
FOLLOW_LINKS | 跟踪符号链接 |
4.5. 获取文件信息
下面的静态方法都将返回一个 boolean 值,表示检查路径的某个属性的结果:
existsisHiddenisReadable、isWritable、isExecutableisRegularFile、isDirectory、isSymbolicLink
size 方法将返回文件的字节数:
Java
long fileSize = Files.size(path);getOwner 方法将文件的拥有者作为 java.nio.file.attribute.UserPrincipal 的一个实例返回。
所有的文件系统都会报告一个基本属性集,它们被封装在 BasicFileAttributes 接口中,这些属性与上述信息有部分重叠。基本文件属性包括:
- 创建文件、最后一次访问以及最后一次修改文件的时间,这些时间都表示成
java.nio.file.attribute.FileTime; - 文件是常规文件、目录还是符号链接,抑或这三者都不是;
- 文件尺寸;
- 文件主键,这是某种类的对象,具体所属类与文件系统相关,有可能是文件的唯一标识符,也可能不是;
要获取这些属性,可以调用:
Java
BasicFileAttributes attributes = Files.readAttributes(path, BasicFileAttributes.class);如果你了解到用户的文件系统兼容 POSIX,那么你可以获取一个 PosixFileAttributes 实例:
Java
PosixFileAttributes attributes = Files.readAttributes(path, PosixFileAttributes.class);然后从中找到组拥有者,以及文件的拥有者、组和访问权限。我们不会详细讨论其细节,因为这种信息中很多内容在操作系统之间并不具备可移植性。
4.6. 访问目录中的项
静态的 Files.list 方法会返回一个可以读取目录中各个项的 Stream<Path> 对象。目录是被惰性读取的,这使得处理具有大量项的目录可以变得更高效。
因为读取目录涉及需要关闭的系统资源,所以应该使用 try 块:
Java
try (Stream<Path> entries = Files.list(pathToDirectory)) {
...
}1
2
3
2
3
list 方法不会进入子目录。为了处理目录中的所有子目录,需要使用 Files.walk 方法:
Java
try (Stream<Path> entries = Files.walk(pathToRoot)) {
// Contains all descendants, visited in depth-first order
}1
2
3
2
3
在 Files.walk 方法中,只要遍历的项是目录,那么在继续访问它的兄弟项之前,会先进入它。
可以通过调用 Files.walk(pathToRoot, depth) 来限制想要访问的树的深度。两种 walk 方法都具有 FileVisitOption ... 的可变长参数,但是你只能提供一种选项 —— FOLLOW_LINKS,即跟踪符号链接。
Note:如果要过滤
walk返回的路径,并且过滤标准涉及与目录存储相关的文件属性,例如尺寸、创建时间和类型(文件、目录、符号链接),那么应该使用find方法来替代walk方法。可以用某个谓词函数来调用这个方法,该函数接受一个路径和一个BasicFileAttributes对象。这样做唯一的优势就是效率高。因为路径总是会被读入,所以这些属性很容易获取。
这段代码使用了 Files.walk 方法来将一个目录复制到另一个目录:
Java
Files.walk(source).forEach(p -> {
try {
Path q = target.resolve(source.relativize(p));
if (Files.isDirectory(p))
Files.createDirectory(q);
else
Files.copy(p, q);
} catch (IOException ex) {
throw new UncheckedIOException(ex);
}
});1
2
3
4
5
6
7
8
9
10
11
2
3
4
5
6
7
8
9
10
11
遗憾的是,你无法很容易地使用 Files.walk 方法来删除目录树,因为你必须在删除父目录之前先删除子目录。下一节将展示如何克服此问题。
4.7. 使用目录流
正如在前一节中所看到的,Files.walk 方法会产生一个可以遍历目录中所有子孙的 Stream<Path> 对象。有时,需要对遍历过程进行更加细粒度的控制。在这种情况下,应该使用 Files.newDirectoryStream 对象,它会产生一个 DirectoryStream。注意,它不是 java.util.stream.Stream 的子接口,而是专门用于目录遍历的接口。它是 Iterable 的子接口,因此可以在增强的 for 循环中使用目录流。下面是其使用模式:
Java
try (DirectoryStream<Path> entries = Files.newDirectoryStream(dir)) {
for (Path entry : entries)
// Process entries
}1
2
3
4
2
3
4
带资源的 try 语句块用来确保目录流可以被正确关闭。访问目录中的项并没有具体的顺序。
可以用 glob 模式来过滤文件:
Java
try (DirectoryStream<Path> entries = Files.newDirectoryStream(dir, "*.java"))表 4.2 展示了所有的 glob 模式:
| 模式 | 描述 | 示例 |
|---|---|---|
* | 匹配路径组成部分中 0 个或多个字符 | *.java 匹配当前目录中的所有 Java 文件 |
** | 匹配跨目录边界的 0 个或多个字符 | **.java 匹配在所有子目录中的 Java 文件 |
? | 匹配一个字符 | ????.java 匹配所有四个字符的 Java 文件(不包括扩展名) |
[...] | 匹配一个字符集合,可以使用连线符 [0-9] 和取反符即 [!0-9] | Test[0-9A-F].java 匹配 Testx.java,其中 x 是一个十六进制数字 |
{...} | 匹配由逗号隔开的多个可选项之一 | *.{java,class} 匹配所有的 Java 文件和类文件 |
\ | 转义上述任意模式中的字符以及 \ 字符 | *\** 匹配所有文件名中包含 * 的文件 |
如果想要访问某个目录的所有子孙成员,可以转而调用 walkFileTree 方法,并向其传递一个 FileVisitor 类型的对象,这个对象会得到下列通知:
- 在遇到一个文件或目录时:
FileVisitResult visitFile(T path, BasicFileAttributes attrs); - 在一个目录被处理前:
FileVisitResult preVisitDirectory(T dir, IOException ex); - 在一个目录被处理后:
FileVisitResult postVisitDirectory(T dir, IOException ex); - 在试图访问文件或目录时发生错误,例如没有权限打开目录:
FileVisitResult visitFileFailed(T path, IOException ex);
对于上述每种情况,都可以指定是否希望执行下面的操作:
- 继续访问下一个文件:
FileVisitResult.CONTINUE; - 继续访问,但是不再访问这个目录下的任何项了:
FileVisitResult.SKIP_SUBTREE; - 继续访问,但是不再访问这个文件的兄弟文件(和该文件在同一个目录下的文件)了:
FileVisitResult.SKIP_SIBLINGS; - 终止访问:
FileVisitResult.TERMINATE;
当有任何方法抛出异常时,就会终止访问,而这个异常会从 walkFileTree 方法中抛出。
便捷类 SimpleFileVisitor 实现了 FileVisitor 接口,但是其除 visitFileFailed 方法之外的所有方法并不做任何处理而是直接继续访问,而 visitFileFailed 方法会抛出由失败导致的异常,并进而终止访问。
下面的示例代码展示了如何打印出给定目录下的所有子目录:
Java
Files.walkFileTree(Paths.get("/"), new SimpleFileVisitor<Path>() {
public FileVisitResult preVisitDirectory(Path path, BasicFileAttributes attrs) throws IOException {
System.out.println(path);
return FileVisitResult.CONTINUE;
}
public FileVisitResult postVisitDirectory(Path dir, IOException exc) {
return FileVisitResult.CONTINUE;
}
public FileVisitResult visitFileFailed(Path path, IOException exc) throws IOException {
return FileVisitResult.SKIP_SUBTREE;
}
});1
2
3
4
5
6
7
8
9
10
11
12
13
14
2
3
4
5
6
7
8
9
10
11
12
13
14
以上值得注意的是,我们需要覆盖 postVisitDirectory 方法和 visitFileFailed 方法,否则,访问会在遇到不允许打开的目录或不允许访问的文件时立即失败。另外还值得注意的是,路径的众多属性是作为 preVisitDirectory 和 visitFile 方法的参数传递的,由于 FileVisitor 需要区分文件和目录,它已经在内部发出了操作系统调用以获取这些属性,因此您无需再次调用系统函数。
如果你需要在进入或离开一个目录时执行某些操作,那么 FileVisitor 接口的其他方法就显得非常有用了。例如,在删除目录树时,需要在移除当前目录的所有文件之后,才能移除该目录。下面是删除目录树的完整代码:
Java
// Delete the directory tree starting at root
Files.walkFileTree(root, new SimpleFileVisitor<Path>() {
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
Files.delete(file);
return FileVisitResult.CONTINUE;
}
public FileVisitResult postVisitDirectory(Path dir, IOException e) throws IOException {
if (e != null) throw e;
Files.delete(dir);
return FileVisitResult.CONTINUE;
}
});1
2
3
4
5
6
7
8
9
10
11
12
13
2
3
4
5
6
7
8
9
10
11
12
13
4.8. ZIP 文件系统
Paths 类会在默认文件系统中查找路径,即在用户本地磁盘中的文件。你也可以有别的文件系统,其中最有用的之一是 ZIP 文件系统。如果 zipname 是某个 ZIP 文件的名字,那么下面的调用:
Java
FileSystem fs = FileSystems.newFileSystem(Paths.get(zipname), null);将建立一个文件系统,它包含 ZIP 文档中的所有文件。如果知道文件名,那么从 ZIP 文档中复制出这个文件就会变得很容易:
Java
Files.copy(fs.getPath(sourceName), targetPath);其中的 fs.getPath 对于任意文件系统来说都与 Paths.get 类似。
要列出 ZIP 文档中的所有文件,可以遍历文件树:
Java
FileSystem fs = FileSystems.newFileSystem(Paths.get(zipname), null);
Files.walkFileTree(fs.getPath("/"), new SimpleFileVisitor<Path>() {
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
System.out.println(file);
return FileVisitResult.CONTINUE;
}
});1
2
3
4
5
6
7
2
3
4
5
6
7
5. 内存映射文件
大多数操作系统都可以利用虚拟内存实现来将一个文件或者文件的一部分 “映射” 到内存中。然后,这个文件就可以被当作内存数组一样地访问,这比传统的文件操作要快得多。
5.1. 内存映射文件的性能
在同一台机器上,我们对 JDK 的 jre/lib 目录中 37MB 的 rt.jar 文件用不同的方式来计算 CRC32 校验和,记录下来的时间数据如表 5.1 所示。
| 方法 | 时间 |
|---|---|
| 普通输入流 | 110 秒 |
| 带缓冲的输入流 | 9.9 秒 |
| 随机访问文件 | 162 秒 |
| 内存映射文件 | 7.2 秒 |
正如你所见,在这台特定的机器上,内存映射比使用带缓冲的顺序输入要稍微快一点,但是比使用 RandomAccessFile 快很多。
java.nio 包使内存映射变得十分简单。首先,从文件中获得一个通道(channel),通道是用于磁盘文件的一种抽象,它使我们可以访问诸如内存映射、文件加锁机制以及文件间快速数据传递等操作系统特性。
Java
FileChannel channel = FileChannel.open(path, options);然后,通过调用 FileChannel 类的 map 方法从这个通道中获得一个 ByteBuffer。你可以指定想要映射的文件区域与映射模式,支持的模式有三种:
FileChannel.MapMode.READ_ONLY:所产生的缓冲区是只读的,任何对该缓冲区写入的尝试都会导致ReadOnlyBufferException异常;FileChannel.MapMode.READ_WRITE:所产生的缓冲区是可写的,任何修改都会在某个时刻写回到文件中。注意,其他映射同一个文件的程序可能不能立即看到这些修改,多个程序同时进行文件映射的确切行为是依赖于操作系统的;FileChannel.MapMode.PRIVATE:所产生的缓冲区是可写的,但是任何修改对这个缓冲区来说都是私有的,不会传播到文件中;
一旦有了缓冲区,就可以使用 ByteBuffer 类和 Buffer 超类的方法读写数据了。
缓冲区支持顺序和随机数据访问,它有一个可以通过 get 和 put 操作来移动的位置。例如,可以像下面这样顺序遍历缓冲区中的所有字节:
Java
while (buffer.hasRemaining()) {
byte b = buffer.get();
...
}1
2
3
4
2
3
4
或者,像下面这样进行随机访问:
Java
for (int i = 0; i < buffer.limit(); i++) {
byte b = buffer.get(i);
...
}1
2
3
4
2
3
4
你可以用下面的方法来读写字节数组:
Java
get(byte[] bytes)
get(byte[], int offset, int length)1
2
2
最后,还有下面的方法:
Text
getInt getChar
getLong getFloat
getShort getDouble1
2
3
2
3
用来读入在文件中存储为二进制值的基本类型值。正如我们提到的,Java 对二进制数据使用高位在前的排序机制,但是,如果需要以低位在前的排序方式处理包含二进制数字的文件,那么只需调用:
Java
buffer.order(ByteOrder.LITTLE_ENDIAN);要查询缓冲区内当前的字节顺序,可以调用:
Java
ByteOrder b = buffer.order();要向缓冲区写数字,可以使用下列的方法:
Text
putInt putChar
putLong putFloat
putShort putDouble1
2
3
2
3
在恰当的时机,以及当通道关闭时,会将这些修改写回到文件中。
5.2. 缓冲区数据结构
本节将简要地介绍 Buffer 对象上的基本操作。缓冲区是由具有相同类型的数值构成的数组,Buffer 类是一个抽象类,它有众多的具体子类,包括 ByteBuffer、CharBuffer、DoubleBuffer、IntBuffer、LongBuffer 和 ShortBuffer。
Note:
StringBuffer类与这些缓冲区没有关系。
在实践中,最常用的是 ByteBuffer 和 CharBuffer。如图 5.1 所示,每个缓冲区都具有:
- 一个容量(capacity),它永远不能改变;
- 一个读写位置(position),下一个值将在此进行读写;
- 一个界限(limit),超过它进行读写是没有意义的;
- 一个可选的标记(mark),用于重复一个读入或写出操作;

这些值满足下面的条件:
使用缓冲区的主要目的是执行 “写,然后读入” 循环。假设我们有一个缓冲区,在一开始,它的 position 为 0,limit 等于 capacity。我们不断地调用 put 将值添加到这个缓冲区中,当我们耗尽所有的数据或者写出的数据量达到 capacity 大小时,就该切换到读入操作了。
这时调用 flip 方法将 limit 设置到当前 position,并把 position 复位到 0。现在在 remaining 方法返回正数时(它返回的值是 limit ~ position),不断地调用 get。在我们将缓冲区中所有的值都读入之后,调用 clear 使缓冲区为下一次写循环做好准备。clear 方法将 position 复位到 0,并将 limit 复位到 capacity。
如果你想重读缓冲区,可以使用 rewind 或 mark/reset 方法,详细内容请查看 API 注释。
要获取缓冲区,可以调用诸如 ByteBuffer.allocate 或 ByteBuffer.wrap 这样的静态方法。
然后,可以用来自某个通道的数据填充缓冲区,或者将缓冲区的内容写出到通道中:
Java
ByteBuffer buffer = ByteBuffer.allocate(RECORD_SIZE);
channel.read(buffer);
channel.position(newpos);
buffer.flip();
channel.write(buffer);1
2
3
4
5
2
3
4
5
这是一种非常有用的方法,可以替代随机访问文件。
6. 文件加锁机制
要锁定一个文件,可以调用 FileChannel 类的 lock 或 tryLock 方法:
Java
FileChannel = FileChannel.open(path);
FileLock lock = channel.lock();1
2
2
或:
Java
FileLock lock = channel.tryLock();第一个调用会阻塞直至可获得锁,而第二个调用将立即返回,要么返回锁,要么在锁不可获得的情况下返回 null。这个文件将保持锁定状态,直至通道关闭,或者在锁上调用了 release 方法。
你还可以通过下面的调用锁定文件的一部分:
Java
FileLock lock(long start, long size, boolean shared)或:
Java
FileLock tryLock(long start, long size, boolean shared)如果 shared 标志为 false,则锁定文件的目的是读写;而如果为 true,则这是一个共享锁,允许多个进程从文件中读入,并阻止任何进程获得独占的锁。并非所有的操作系统都支持共享锁,因此你可能会在请求共享锁的时候得到独占的锁。调用类的 isShared 方法可以查询所持有的锁的类型。
Note:如果你锁定了文件的尾部,而这个文件的长度随后增长并超过了锁定的部分,那么增长出来的额外区域是未锁定的,要想锁定所有的字节,可以使用
Long.MAX_VALUE来表示尺寸。
要确保在操作完成时释放锁,与往常一样,最好在一个带资源的 try 语句中执行释放锁的操作:
Java
try (FileLock lock = channel.lock()) {
// access the locked file or segment
}1
2
3
2
3
请记住,文件加锁机制是依赖于操作系统的,下面是需要注意的几点:
- 在某些系统中,文件加锁仅仅是建议性的,如果一个应用未能得到锁,它仍旧可以向被另一个应用并发锁定的文件执行写操作;
- 在某些系统中,不能在锁定一个文件的同时将其映射到内存中;
- 文件锁是由整个 Java 虚拟机持有的。如果有两个程序是由同一个虚拟机启动的(例如 Applet 和应用程序启动器),那么它们不可能每一个都获得一个在同一个文件上的锁。当调用
lock和tryLock方法时,如果虚拟机已经在同一个文件上持有了另一个重叠的锁,那么这两个方法将抛出OverlappingFileLockException; - 在一些系统中,关闭一个通道会释放由 Java 虚拟机持有的底层文件上的所有锁。因此,在同一个锁定文件上应避免使用多个通道;
- 在网络文件系统上锁定文件是高度依赖于系统的,因此应该尽量避免;