输入与输出
2.1输入/输出流
字节序列的来源地和目的地可以是文件,而且通常都是文件,但是也可以是网络连接,甚至是内存块。
2.1.1读写字节
System.in
(它是InputStream
的一个子类的预定义对象)是从标准输入中读入信息,即从控制台或重定向的文件中读入信息。
从java9开始,可以读取流中的所有字节:
byte[] bytes = in.readAllBytes();
transferTo
方法可以将所有字节从一个输入流传递到一个输出流:
in.transferTo(out);
read
和write
方法在执行时都将阻塞,直至字节确实被读入或写出。这就意味着如果流不能被立即访问(通常是因为网络连接忙),那么当前的线程将被阻塞。这使得在这两个方法等待指定的流变为可用的这段时间里,其他的线程就有机会去执行有用的工作。
available
方法可以检查当前可读入的字节数量,这意味下面的代码不可能被阻塞:
int bytesAvailable = in.available();
if (bytesAvailable > 0) {
var data = new byte[bytesAvailable];
in.read(data);
}
当完成对输入/输出流的读写时,应该通过调用
close
方法来关闭它,这个调用会释放掉十分有限的操作系统资源。
关闭一个输出流的同时还会冲刷用于该输出流的缓冲区:所有被临时置入缓冲区中,以便用更大的包的形式传递的字节在关闭输出流时都将被送出。特别是,如果不关闭文件,那么写出字节的最后一个包可能永远也得不到传递。当然,还可以用flush
方法来人为地冲刷这些输出。
2.1.3组合输入/输出流过滤器
对于可移植的程序来说,应该使用程序所运行平台的文件分隔符,可以通过常量字符串
java.io.File.separator
获得它。
2.1.5如何写出文本输出
对于文本输出,可以使用
PrintWriter
。这个类拥有以文本格式打印字符串和数字的方法。
var out = new PrintWriter("employee.txt", StandardCharsets.UTF_8);
为了输出到打印写出器,需要使用
println
和printf
方法。
println
方法在行中添加了对目标系统来说恰当的行结束符,也就是通过调用System.getProperty("line.separator")
而获得的字符串。
如果写出器设置为自动冲刷模式,那么只要println
被调用,缓冲区中的所有字符都会被发送到它们的绑定到(打印写出器总是带缓冲区的)。默认情况下,自动冲刷机制是禁用的,可以通过使用PrintWriter(Writer writer, boolean autoFlush)
来启用或禁用自动冲刷机制。
由于checkError
方法来查看输出流是否出现了某些错误。
2.1.6如何读入文本输入
最简单的处理任意文本的方式就是使用
Scanner
类。可以从任何输入流中构建Scanner
对象
或者,可以将短小的文本文件读入到一个字符串中:
var content = Files.readString(path, charset);
但是,如果想要将这个文件一行行地读入,那么可以调用:
List<String> lines = Files.readAllLines(path, charset);
如果文件太大,那么可以将行惰性处理为一个
Stream<String>
对象:
try (Stream<String> lines = Files.lines(path, charset)) {
// ...
}
还可以使用扫描器来读入符号(token),即由分隔符分隔的字符串,默认的分隔符是空白字符。可以将分隔符修改为任意的正则表达式。例如:
Scanner in = ...;
in.useDelimiter("\\PL+");
将接受任何非unicode字母作为分隔符。之后,这个扫描器将只接受unicode字母。
调用next
方法可以产生下一个符号:
while (in.hasNext()) {
String word = in.next();
}
或者,可以获取一个包含所有符号的流:
Stream<String> words = in.tokens();
在早期的java版本中,处理文本输入的唯一方式就是通过
BufferedReader
类:
InputStream inputStream = ...;
try (var in = new BufferedReader(new InputStreamReader(inputStream, charset))) {
String line;
while ((line = in.readLine()) != null) {
// do something with line
}
}
如今,
BufferedReader
类又有了一个lines
方法,可以产生一个Stream<String>
对象。但是,与Scanner
不同,BufferedReader
没有用于任何读入数字的方法。
2.1.7以文本格式存储对象
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("/Users/kirito/program/java/study/java_study/core_java_volume_two_11/src/main/resources/employee.dat", StandardCharsets.UTF_8)) {
writeData(staff, out);
}
// retrieve all records into a new array
try (var in = new Scanner(new FileInputStream("/Users/kirito/program/java/study/java_study/core_java_volume_two_11/src/main/resources/employee.dat"), StandardCharsets.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) {
// 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
* @param e the employee
*/
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
* @return the employee
*/
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 birthday = LocalDate.parse(tokens[2]);
int year = birthday.getYear();
int month = birthday.getMonthValue();
int day = birthday.getDayOfMonth();
return new Employee(name, salary, year, month, day);
}
}
2.2读写二进制数据
2.2.1DataInput
和DataOutput
接口
DataOutput
接口定义了以二进制格式写数组、字符、boolean
值和字符串的方法(读回数据的方法名差不多)。
例如,writeInt
总是将一个整数写出为4字节的二进制数量值,而不管它有多少位;writeDouble
总是将一个double
值写出为8字节的二进制数量值。这样产生的结果并非人可阅读的,但是对于给定类型的每个值,使用的空间都是相同的,而且将其读回也比解析文本要更快。
因为没有其他方法会使用utf-8的这种修订,所以应该只在写出用于java虚拟机的字符串时才使用
writeUTF
方法,例如,当需要编写一个生成字节码的程序时。对于其他场合,都应该使用writeChars
方法。
DataInputStream
类实现了DataInput
接口,为了从文件读入二进制数据,可以将DataInputStream
与某个字节源相组合,例如FileInputStream
:
var in = new DataInputStream(new FileInputStream("employee.dat"));
2.2.2随机访问文件
RandomAccessFile
类可以在文件中的任何位置查找或写入数据。磁盘文件都是随机访问的,但是与网络套接字通信的输入/输出流却不是。可以打开一个随机访问文件,只用于读入或者同时用于读写,可以通过使用字符串"r"
(用于读入访问)或"rw"
(用于读入/写出访问)作为构造器的第二个参数来指定这个选项。
var in = new RandomAccessFile("employee.dat", "r");
var inOut = new RandomAccessFile("employee.dat", "rw");
将已有文件作为
RandomAccessFile
打开时,这个文件并不会被删除。
随机访问文件有一个表示下一个将被读入或写出的字节所处位置的文件指针,
seek(long)
方法可以用来将这个文件指针设置到文件中的任意字节位置。
getFilePointer
方法将返回文件指针的当前位置。
RandomAccessFile
类同时实现了DataInput
和DataOutput
接口。为了读写随机访问文件,可以使用诸如readInt/writeInt
和readChar/writeChar
之类的方法。
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("/Users/kirito/program/java/study/java_study/core_java_volume_two_11/src/main/resources/employee.dat"))) {
// save all employee records to the file employee.dat
for (Employee e : staff) {
writeData(out, e);
}
}
try (var in = new RandomAccessFile("/Users/kirito/program/java/study/java_study/core_java_volume_two_11/src/main/resources/employee.dat", "r")) {
// retrieve all records into a new array
// compute the array size
// 确定文件中的字节总数,可以使用length方法,记录的总数等于字节总数除以每条记录的大小
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--) {
newStaff[i] = new Employee();
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
* @throws IOException
*/
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);
}
}
/**
* The type Data io.
*/
class DataIO {
/**
* Read fixed string
* 从输入流中读入字符,直至读入size个码元,或者直至遇到具有0值的字符值,
* 然后跳过输入字段中剩余的0值
* @param size the size
* @param in the in
* @return the string
* @throws IOException the io exception
*/
public static String readFixedString(int size, DataInput in)
throws IOException {
StringBuilder b = new StringBuilder(size);
int i = 0;
boolean more = true;
while (more && i < size) {
char ch = in.readChar();
i++;
if (ch == 0) more = false;
else b.append(ch);
}
in.skipBytes(2 * (size - i));
return b.toString();
}
/**
* Write fixed string
* 写出从字符串开头开始的指定数量的码元(如果码元过少,该方法将用0值来补齐字符串)
* @param s the s
* @param size the size
* @param out the out
* @throws IOException the io exception
*/
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);
}
}
}
// 每条记录都有相同的大小,这样可以很容易地读入任何记录
class Employee implements Serializable {
// 用40个字符来表示姓名字符串
public static final int NAME_SIZE = 40;
public static final int RECORD_SIZE = 2 * NAME_SIZE + 8 + 4 + 4 + 4;
// ...
}
2.2.3ZIP文档
ZIP文档(通常)以压缩格式存储了一个或多个文件,每个ZIP文档都有一个头,包含诸如每个文件名字和所使用的压缩方法等信息。
在java中,可以使用ZipInputStream
来读入ZIP文档。可能需要浏览文档中每个单独的项,getNextEntry
方法就可以返回一个描述这些项的ZipEntry
类型的对象。该方法会从流中读入数据直至末尾,实际上这里的末尾是指正在读入的项的末尾,然后调用closeEntry
来读入下一项。在读入最后一项之前,不要关闭流:
var zin = new ZipInputStream(new FileInputStream(zipname));
ZipEntry entry;
while ((entry = zin.getNextEntry()) != null) {
// read the contents of zin
zin.closeEntry();
}
zin.close();
要写出到ZIP文件,可以使用
ZipOutputStream
,而对于希望放入到ZIP文件中的每一项,都应该创建一个ZipEntry
对象,并将文件名传递给ZipEntry
的构造器,它将设置其他诸如文件日期和解压缩方法等参数。如果需要,可以覆盖这些设置。然后,需要调用putNextEntry
方法来写出新文件,并将文件数据发送到ZIP输出流中。当完成时,需要调用closeEntry
。然后,需要对所有希望存储的文件都重复这个过程:
var fout = new FileOutputStream("test.zip");
var zout = new ZipOutputStream(fout);
for all files {
var ze = new ZipEntry(filename);
zout.putNextEntry(ze);
// send data to zout
zout.closeEntry();
}
zout.close();
JAR文件只是带有一个特殊项的ZIP文件,这个项称作清单。可以使用
JarInputStream
和JarOutputStream
类来读写清单项。
2.3对象输入/输出流与序列化
当需要存储相同类型的数据时,使用固定长度的记录格式是一个不错的选择。但是,在面向对象程序中创建的对象很少全部具有相同的类型。例如,可能有一个名义上是一个
Employee
的记录数组,但是实际上却包含诸如Manager
这样的子类实例。
Java语言支持一种称为对象序列化的非常通用的机制,可以将任何对象写出到输出流中,并在之后将其读回。
2.3.1保存和加载序列化对象
为了保存对象数据,首先需要打开一个
ObjectOutputStream
对象:
var out = new ObjectOutputStream(new FileOutputStream("employee.dat"));
现在,为了保存对象,可以直接使用
writeObject
方法:
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);
为了将这些对象读回,首先需要获得一个
ObjectInputStream
对象:
var in = new ObjectInputStream(new FileOutputStream("employee.dat"));
然后,用
readObject
方法以这些对象被写出时的顺序获得它们:
var e1 = (Employee) in.readObject();
var e2 = (Employee) in.readObject();
但是,对希望在对象输出流中存储或从对象输出流中恢复的所有类都应进行一下修改,这些类必须实现
Serializable
接口。
每个对象都是用一个序列号保存的,具体的算法为:
- 对于遇到的每一个对象引用都关联一个序列号。
- 对于每个对象,当第一次遇到时,保存其对象数据到输出流中。
- 如果某个对象之前已经被保存过,那么只写出"与之前保存过的序列号为x的对象相同"。
在读回对象时,整个过程是反过来的:
- 对于对象输入流中的对象,在第一次遇到其序列号时,构建它,并使用流中数据来初始化它,然后记录这个顺序号和新对象之间的关联。
- 当遇到"与之前保存过的序列号为x的对象相同"这一标记时,获取与这个序列号相关联的对象引用。
public class ObjectStreamTest {
public static void main(String[] args) throws IOException, ClassNotFoundException {
var harry = new Employee("Harry Hacker", 50000, 1989, 10, 1);
var carl = new Manager("Carl Cracker", 80000, 1987, 12, 15);
carl.setSecretary(harry);
var tony = new Manager("Tony Tester", 40000, 1990, 3, 15);
tony.setSecretary(harry);
var staff = new Employee[3];
staff[0] = carl;
staff[1] = harry;
staff[2] = tony;
// save all employee records to the file employee.dat
try (var out = new ObjectOutputStream(new FileOutputStream("/Users/kirito/program/java/study/java_study/core_java_volume_two_11/src/main/resources/employee.dat"))) {
out.writeObject(staff);
}
try (var in = new ObjectInputStream(new FileInputStream("/Users/kirito/program/java/study/java_study/core_java_volume_two_11/src/main/resources/employee.dat"))) {
// retrieve all records into a new array
var newStaff = (Employee[]) in.readObject();
// raise secretary's salary
newStaff[1].raiseSalary(10);
// print the newly read employee records
for (Employee e : newStaff) {
System.out.println(e);
}
}
}
}
2.4操作文件
Path
和Files
类封装了在用户机器上处理文件系统所需的所有功能。
2.4.1Path
Path
表示的是一个目录名序列,其后还可以跟着一个文件名。路径中的第一个部件可以是根部件,例如/
或C:\
,而允许访问的根部件取决于文件系统。以根部件开始的路径是绝对路径;否则,就是相对路径。
Path absolute = Paths.get("/home", "harry");
Path relative = Paths.get("myprog", "conf", "user.properties");
静态的
Paths.get
方法接受一个或多个字符串,并将它们用默认文件系统的路径分隔符连接起来。然后它解析连接起来的结果,如果其表示的不是给定文件系统中的合法路径,那么就抛出InvalidPathException
。这个连接起来的结果就是一个Path
对象。
get
方法可以获取包含多个部件的单个字符串。例如,从配置文件中读取路径:
// May be a string such as /opt/myprog or c:\Program Files\myprog
String baseDir = props.getProperty("base.dir");
// OK that baseDir has separators
Path basePath = Paths.get(baseDir);
路径不必对应着某个实际存在的文件,它仅仅是一个抽象的名字序列。
组合或解析路径是司空见惯的操作,调用
resolve(Path/String other)
方法将按照下列规则返回一个路径:
- 如果
other
是绝对路径,那么就返回other
。- 否则,返回通过连接
this
和other
获得的路径。
还有一个很方便的方法
resolveSibling(Path/String other)
,它通过解析指定路径的父路径产生其兄弟路径。即,如果other
是绝对路径,那么就返回other
;否则,返回通过连接this
的父路径和other
获得的路径。
// 如果workPath是/opt/myapp/work,那么将返回/opt/myapp/temp
Path tempPath = workPath.resolveSibling("temp");
resolve
的对立面是relative(Path other)
。即,返回用this
进行解析,相对于other
的相对路径。例如,以/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
类有许多有用的方法用来将路径断开:
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 /
还可以从
Path
对象中构建Scanner
对象:
var in = new Scanner(Paths.get("/home/fred/input.txt"));
偶尔,可能需要与遗留系统的API交互,它们使用的是
File
类而不是Path
接口。Path
接口有一个toFile
方法,而File
类有一个toPath
方法。
2.4.2读写文件
Files
类可以使得普通文件操作变得便捷。例如,读取文件的所有内容:
byte[] bytes = Files.readAllBytes(path);
可以从文本文件中读取内容:
var content = Files.readString(path, charset);
但是,如果希望将文件当作行序列读入,那么可以调用:
List<String> lines = Files.readAllLines(path, charset);
相反,如果希望写出一个字符串到文件中,可以调用:
Files.writeString(path, content, charset);
向指定文件追加内容,可以调用:
Files.write(path, content.getBytes(charset), StandardOpenOption.APPEND);
还可以将一个行的集合写出到文件中:
Files.write(path, lines, charset);
这些简便的方法适用于处理中等长度的文本文件,如果要处理的文件长度比较大,或者是二进制文件,那么还是应该使用所熟知的输入/输出流或者读入器/写出器:
InputStream in = Files.newInputStream(path);
OutputStream out = Files.newOutputStream(path);
Reader in = Files.newBufferedReader(path, charset);
Writer out = Files.newBufferedWriter(path, charset);
2.4.3创建文件和目录
创建新目录可以调用:
Files.createDirectory(path);
其中,路径中除最后一个部件外,其他部分都必须是已存在的。要创建路径中的中间目录,应该使用:
Files.createDirectories(path);
可以使用下面的语句创建一个空文件:
Files.createFile(path);
如果文件已经存在了,那么这个调用就会抛出异常。检查文件是否存在和创建文件是原子性的。如果文件不存在,该文件就会被创建,并且其他程序在此过程中是无法执行文件创建操作的。
有些便捷方法可以用来在给定位置或者系统指定位置创建临时文件或临时目录:
Path newPath = Files.createTempFile(dir, prefix, suffix);
Path newPath = Files.createTempFile(prefix, suffix);
Path newPath = Files.createTempDirectory(dir, prefix);
Path newPath = Files.createTempDirectory(prefix);
其中,
dir
是一个Path
对象,prefix
和suffix
是可以为null
的字符串。例如,调用Files.createTempFile(null, ".txt")
可能会返回一个像/tmp/123444123234234.txt
这样的路径。
在创建文件或目录时,可以指定属性,例如文件的拥有者和权限。但是,这些取决于系统,具体内容查阅API文档。
2.4.4复制、移动和删除文件
将文件从一个位置复制到另一个位置可以直接调用:
Files.copy(fromPath, toPath);
移动文件(即复制并删除原文件)可以调用:
Files.move(fromPath, toPath);
如果目标路径已经存在,那么复制或移动将失败。如果想要覆盖已有的目标路径,可以使用
REPLACE_EXISTING
选项。如果想要复制所有的文件属性,可以使用COPY_ATTRIBUTES
选项。也可以同时选择这两个选项:
Files.copy(fromPath, toPath, StandardCopyOption.REPLACE_EXISTNG, StandardCopyOption.COPY_ATTRIBUTES);
可以将移动操作定义为原子性的,这样就可以保证要么移动操作成功完成,要么源文件继续保持在原来位置。具体可以使用
ATOMIC_MOVE
选项来实现:
Files.move(fromPath, toPath, StandardCopyOption.ATOMIC_MOVE);
还可以将一个输入流复制到
Path
中,这表示想要将该输入流存储到硬盘上。类似地,可以将一个Path
复制到输出流中:
Files.copy(inputStream, toPath);
Files.copy(fromPath, outputStream);
至于其他对
copy
的调用,可以根据需要提供相应的复制选项。
最后,删除文件可以调用:
Files.delete(path);
如果要删除的文件不存在,这个方法就会抛出异常。因此,可转而使用下面的方法:
boolean deleted = Files.deleteIfExists(path);
该方法还可以用来移除空目录。
用于文件操作的标准选项:
选项 | 描述 |
---|---|
StandardOpenOption | 与newBufferedWriter 、newInputStream 、newOutputStream 、write 一起使用 |
READ | 用于读取而打开 |
WRITE | 用于写入而打开 |
APPEND | 如果用于写入而打开,那么在文件末尾追加 |
TRUNCATE_EXISTING | 如果用于写入而打开,那么移除已有内容 |
CREATE_NEW | 创建新文件并且在文件已存在的情况下会创建失败 |
CREATE | 自动在文件不存在的情况下创建新文件 |
DELETE_ON_CLOSE | 当文件被关闭时,尽可能地删除该文件 |
SPARSE | 给文件系统一个提示,表示该文件是稀疏的 |
DSYNC 或SYNC | 要求对文件数据或数据和元数据的每次更新都必须同步地写入到存储设备中 |
StandardCopyOption | 与copy 和move 一起使用 |
ATOMIC_MOVE | 原子性地移动文件 |
COPY_ATTRIBUTES | 复制文件的属性 |
REPLACE_EXISTING | 如果目标已存在,则替换它 |
LinkOption | 与上面所有方法以及exists 、isDirectory 、isRegularFile 等一起使用 |
NOFOLLOW_LINKS | 不要跟踪符号链接 |
FileVisitOption | 与find 、walk 、walkFileTree 一起使用 |
FOLLOW_LINKS | 跟踪符号链接 |
2.4.5获取文件信息
下面的静态方法都将返回一个
boolean
值,表示检查路径的某个属性的结果:
exists
isHidden
isReadable
、isWritable
、isExecutable
isRegularFile
、isDirectory
、isSymbolicLink
size
方法将返回文件的字节数:
long fileSize = Files.size(path);
getOwner
方法将文件的拥有者作为java.nio.file.attribute.UserPrincipal
的一个实例返回。
所有的文件系统都会报告一个基本属性集,它们被封装在
BasicFileAttributes
接口中,这些属性与上述信息有部分重叠。基本文件属性包括:
- 创建文件、最后一次访问以及最后一次修改文件的时间,这些时间都表示成
java.nio.file.attribute.FileTime
。- 文件是常规文件、目录还是符号链接,抑或这三者都不是。
- 文件尺寸。
- 文件主键,这是某种类的对象,具体所属类与文件系统相关,有可能是文件的唯一标识符,也可能不是。
要获取这些属性,可以调用:
BasicFileAttributes attributes = Files.readAttributes(path, BasicFileAttributes.class);
如果了解到用户的文件系统兼容POSIX,那么可以获取一个
PosixFileAttributes
实例:
PosixFileAttributes attributes = Files.readAttributes(path, PosixFileAttributes.class);
然后从中找到组拥有者,以及文件的拥有者、组和访问权限。
2.4.6访问目录中的项
静态的
Files.list
方法会返回一个可以读取目录中各个项的Stream<Path>
对象。目录是被惰性读取的,这使得处理具有大量项的目录可以变得更高效。
因为读取目录涉及需要关闭的系统资源,所以应该使用try
块:
try (Stream<Path> entries = Files.list(pathToDirectory)) {
// ...
}
list
方法不会进入子目录。为了处理目录中的所有子目录,需要使用Files.walk
方法。
try (Stream<Path> entries = Files.walk(pathToRoot)) {
// Contains all descendants, visited in depth-first order
}
无论何时,只要遍历的项是目录,那么在继续访问它的兄弟项之前,会先进入它。
可以通过调用Files.walk(pathToRoot, depth)
来限制想要访问的树的深度。两种walk
方法都具有FileVisitOption...
的可变长参数,但是只能提供一种选项——FOLLOW_LINKS
,即跟踪符号链接。
如果要过滤
walk
返回的路径,并且过滤标准涉及与目录存储相关的文件属性,例如尺寸、创建时间和类型(文件、目录、符号链接),那么应该使用find
方法来替代walk
方法。可以用某个谓词函数来调用这个方法,该函数接受一个路径和一个BasicFileAttributes
对象。这样做的唯一优势就是效率高。因为路径总是会被读入,所以这些属性很容易获取。
使用
Files.walk
方法将一个目录复制到另一个目录:
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);
}
});
遗憾的是,无法很容易地使用
Files.walk
方法来删除目录树,因为必须在删除父目录之前先删除一下子目录。
2.4.7使用目录流
有时,需要对遍历过程进行更加细粒度的控制。在这种情况下,应该使用
Files.newDirectoryStream
对象,它会产生一个DirectoryStream
。注意,它不是java.util.stream.Stream
的子接口,而是专门用于目录遍历的接口。它是Iterable
的子接口,因此可以在增强的for
循环中使用目录流:
try (DirectoryStream<Path> entries = Files.newDirectoryStream(dir)) {
for (Path entry : entries) {
// Process entries
}
}
带资源的
try
语句块用来确保目录流可以被正确关闭。访问目录中的项并没有具体的顺序。
可以用
glob
模式来过滤文件:
try (DirectoryStream<Path> entries = Files.newDirectoryStream(dir, "*.java"))
所有的
glob
模式:
模式 | 描述 | 示例 |
---|---|---|
* | 匹配路径组成部分中0个或多个字符 | *.java 匹配当前目录中的所有java文件 |
** | 匹配跨目录边界的0个或多个字符 | **.java 匹配在所有子目录中的java文件 |
? | 匹配一个字符 | ????.java 匹配所有四个字符的java文件(不包括扩展名) |
[...] | 匹配一个字符集合,可以使用连线符[0-9]和取反符[!0-9] | Test[0-9A-F].java 匹配Testx.java ,其中,x 是一个十六进制数字 |
{...} | 匹配由逗号隔开的多个可选项之一 | *.{java,class} 匹配所有的java文件和类文件 |
\ | 转义上述任意模式中的字符以及\ 字符 | *\** 匹配所有文件名中包含* 的文件 |
如果使用windows的
glob
语法,则必须对反斜杠转义两次:一次为glob
语法转义,一次为java字符串转义。例如,Files.newDirectoryStream(dir, "C:\\\\")
。
如果想要访问某个目录的子孙成员,可以转而调用
walkFileTree
方法,并向其传递一个FileVisitor
类型的对象,这个对象会得到下列通知:
- 在遇到一个文件或目录时:
FileVisitResult visitFile(T path, BasicFileAttributes attrs)
。- 在一个目录被处理前:
FileVisitResult preVisitDirectory(T dir, IOException ex)
。- 在一个目录被处理后:
FileVisitResult postVisitDirectory(T dir, IOException ex)
。- 在试图访问文件或目录发生错误,例如没有权限打开目录:
FileVisitResult visitFileFailed(path, IOException)
。对于上述每种情况,都可以指定是否希望执行下面的操作:
- 继续访问下一个文件:
FileVisitResult.CONTINUE
。- 继续访问,但是不再访问这个目录下的任何项了:
FileVisitResult.SKIP_SUBTREE
。- 继续访问,但是不再访问这个文件的兄弟文件(和该文件在同一个目录下的文件)了:
FileVisitResult.SKIP_SIBLINGS
。- 终止访问:
FileVisitResult.TERMINATE
。当任何方法抛出异常时,就会终止访问,而这个异常会从
walkFileTree
方法中抛出。
便捷类
SimpleFileVisitor
实现了FileVisitor
接口,但是其除visitFileFailed
方法之外的所有方法并不做任何处理而是直接继续访问,而visitFileFailed
方法会抛出由失败导致的异常并进而终止访问。
例如,打印出给定目录下的所有子目录:
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;
}
});
值得注意的是,需要覆盖
postVisitDirectory
和visitFileFailed
方法,否则,访问会在遇到不允许打开的目录或不允许访问的文件时立即失败。
还应该注意的是,路径的众多属性是作为preVisitDirectory
和visitFile
方法的参数传递的。访问者不得不通过操作系统调用来获得这些属性,因为它需要区分文件和目录。因此,就不需要再次执行系统调用了。
如果需要在进入或离开一个目录时执行某些操作,那么FileVisitor
接口的其他方法就显得非常有用了。例如,在删除目录树时,需要在移除当前目录的所有文件之后,才能移除该目录。下面是删除目录树的完整代码:
// 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;
}
});
2.4.8ZIP文件系统
Paths
类会在默认文件系统中查找路径,即在用户本地磁盘中的文件。也可以有别的文件系统,其中最有用的之一是ZIP文件系统。如果zipname
是某个ZIP文件的名字,那么下面的调用:
FileSystem fs = FileSystems.newFileSystem(Paths.get(zipname), null);
将建立一个文件系统,它包含ZIP文档中的所有文件。如果知道文件名,那么从ZIP文档中复制出这个文件就会变得很容易:
Files.copy(fs.getPath(sourceName), targetPath);
其中的
fs.getPath
对于任意文件系统来说都与Paths.get
类似。
要列出ZIP文档中的所有文件,可以遍历文件树(这种方式更好些):
FileSystem fs = FileSystems.newFileSystem(Paths.get(zipname), null);
Files.walkFileTree(fs.getPath("/"), new SimpleFileVisitor<Path>() {
public FileVisitResult visitFile(Paht file, BasicFileAttributes attrs) throws IOException {
System.out.println(file);
return FileVisitResult.CONTINUE;
}
});
2.5内存映射文件
大多数操作系统都可以利用虚拟内存实现来将一个文件或文件的一部分映射到内存中。然后,这个文件就可以被当作内存数组一样地访问。这比传统的文件操作要快得多。
2.5.1内存映射文件的性能
首先,从文件中获得一个通道,通道是用于磁盘文件的一种抽象,它可以访问诸如内存映射、文件加锁机制以及文件间快速数据传递等操作系统特性。
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
操作来移动的位置。例如,顺序遍历缓冲区中的所有字节:
while (buffer.hasRemaining()) {
byte b = buffer.get();
// ...
}
或者,随机访问:
for (int i = 0; i < buffer.limit(); i++) {
byte b = buffer.get(i);
// ...
}
可以用下面的方法来读写字节数组:
get(byte[] bytes)
get(byte[], int offset, int length)
最后,还有下面的方法:
getInt getChar
getLong getFloat
getShort getDouble
用来读入在文件中存储为二进制值的基本类型值。Java对二进制数据使用高位在前的排序机制,但是,如果需要以低位在前的排序方式处理包含二进制数字的文件,那么只需调用:
buffer.order(ByteOrder.LITTLE_ENDING);
要查询缓冲区内当前的字节顺序,可以调用:
ByteOrder b = buffer.order();
要向缓冲区写数字,可以使用相应的
set
方法。在恰当的时机,以及当通道关闭时,会将这些修改写回到文件中。
/**
* 用于计算文件的32位的循环冗余校验和(CRC32),这个数值经常用来判断一个文件是否已损坏的校验和,
* 因为文件损坏极有可能导致校验和改变
*/
public class MemoryMapTest {
public static long checksumInputStream(Path filename) throws IOException {
try (InputStream in = Files.newInputStream(filename)) {
var crc = new CRC32();
int c;
while ((c = in.read()) != -1) {
crc.update(c);
}
return crc.getValue();
}
}
public static long checksumBufferedInputStream(Path filename) throws IOException {
try (var in = new BufferedInputStream(Files.newInputStream(filename))) {
var crc = new CRC32();
int c;
while ((c = in.read()) != -1) {
crc.update(c);
}
return crc.getValue();
}
}
public static long checksumRandomAccessFile(Path filename) throws IOException {
try (var file = new RandomAccessFile(filename.toFile(), "r")) {
long length = file.length();
var crc = new CRC32();
for (long p = 0; p < length; p++) {
file.seek(p);
int c = file.readByte();
crc.update(c);
}
return crc.getValue();
}
}
public static long checksumMappedFile(Path filename) throws IOException {
try (FileChannel channel = FileChannel.open(filename)) {
var crc = new CRC32();
int length = (int) channel.size();
MappedByteBuffer buffer = channel.map(FileChannel.MapMode.READ_ONLY, 0, length);
for (int p = 0; p < length; p++) {
int c = buffer.get(p);
crc.update(c);
}
return crc.getValue();
}
}
public static void main(String[] args) throws IOException {
System.out.println("Input Stream:");
long start = System.currentTimeMillis();
Path filename = Paths.get(args[0]);
long crcValue = checksumInputStream(filename);
long end = System.currentTimeMillis();
System.out.println(Long.toHexString(crcValue));
System.out.println((end - start) + " milliseconds");
System.out.println("Buffered Input Stream:");
start = System.currentTimeMillis();
crcValue = checksumBufferedInputStream(filename);
end = System.currentTimeMillis();
System.out.println(Long.toHexString(crcValue));
System.out.println((end - start) + " milliseconds");
System.out.println("Random Access File:");
start = System.currentTimeMillis();
crcValue = checksumRandomAccessFile(filename);
end = System.currentTimeMillis();
System.out.println(Long.toHexString(crcValue));
System.out.println((end - start) + " milliseconds");
System.out.println("Mapped File:");
start = System.currentTimeMillis();
crcValue = checksumMappedFile(filename);
end = System.currentTimeMillis();
System.out.println(Long.toHexString(crcValue));
System.out.println((end - start) + " milliseconds");
}
}
2.5.2缓冲区数据结构
在使用内存映射时,创建了单一的缓冲区横跨整个文件或感兴趣的文件区域。还可以使用更多的缓冲区来读写大小适度的信息块。
缓冲区是由具有相同类型的数值构成的数组,Buffer
类是一个抽象类,它有众多的具体子类,包括ByteBuffer
、CharBuffer
、DoubleBuffer
、IntBuffer
、LongBuffer
和ShortBuffer
(StringBuffer
类与这些缓冲区没有关系)。
在实践中,最常用的将是ByteBuffer
和CharBuffer
。每个缓冲区都具有:
- 一个容量,它永远不能改变。
- 一个读写位置,下一个值将在此进行读写。
- 一个界限,超过它进行读写是没有意义的。
- 一个可选的标记,用于重复一个读入或写出操作。
这些值满足下面的条件:0 ⩽ \leqslant ⩽ 标记 ⩽ \leqslant ⩽ 读写位置 ⩽ \leqslant ⩽ 界限 ⩽ \leqslant ⩽ 容量。
使用缓冲区的主要目的是执行写,然后读入循环。假设有一个缓冲区,在一开始,它的位置为0,界限等于容量。不断地调用
put
将值添加到这个缓冲区中,当耗尽所有的数据或者写出的数据量达到容量大小时,就该切换到读入操作了。
这时调用
flip
方法将界限设置到当前位置,并把位置复位到0。现在在remaining
方法返回正数时(它返回的值是界限 - 位置),不断地调用get
。在将缓冲区中所有的值都读入之后,调用clear
使缓冲区为下一次写循环做好准备。clear
方法将位置复位到0,并将界限复位到容量。
如果想重读缓冲区,可以使用rewind
或mark/reset
方法,详细内容查看API。
要获取缓冲区,可以调用诸如ByteBuffer.allocate
或ByteBuffer.wrap
这样的静态方法。
然后,可以用来自某个通道的数据填充缓冲区,或者将缓冲区的内容写出到通道中。例如:
ByteBuffer buffer = ByteBuffer.allocate(RECORD_SIZE);
channel.read(buffer);
channel.position(newpos);
buffer.flip();
channel.write(buffer);
这是一种非常有用的方法,可以替代随机访问文件。
2.6文件加锁机制
文件锁可以控制对文件或文件中某个范围的字节的访问。
假如应用程序将用户的偏好存储在一个配置文件中,当用户调用这个应用的两个实例时,这两个实例就有可能会同时希望写配置文件。在这种情况下,第一个实例应该锁定文件,当第二个实例发现文件被锁定时,它必须决策是等待直至文件解锁,还是直接跳过这个写操作过程。
要锁定一个文件,可以调用
FileChannel
类的lock
或tryLock
方法:
FileChannel channel = FileChannel.open(path);
FileLock lock = channel.lock();
或者是
FileLock lock = channel.tryLock();
第一个调用会阻塞直至可获得锁,而第二个调用将立即返回,要么返回锁,要么在锁不可获得的情况下返回
null
。这个文件将保持锁定状态,直至通道关闭,或者在锁上调用了release
方法。
还可以通过下面的调用锁定文件的一部分:
FileLock lock(long start, long size, boolean shared);
或者是
FileLock tryLock(long start, long size, boolean shared);
如果
shared
标志为false
,则锁定文件的目的是读写;而如果为true
,则这是一个共享锁,允许多个进程从文件中读入,并阻止任何进程获得独占的锁。并非所有的操作系统都支持共享锁,因此可能会在请求共享锁的时候得到独占的锁。调用FileLock
类的isShared
方法可以查询所持有的锁的类型。
如果锁定了文件的尾部,而这个文件的长度随后增长并超过了锁定的部分,那么增长出来的额外区域是未锁定的,要想锁定所有的字节,可以使用
Long.MAX_VALUE
来表示尺寸。
要确保在操作完成时释放锁,与往常一样,最好在一个带资源的
try
语句中执行释放锁的操作:
try (FileLock lock = channel.lock()) {
// access the locked file or segment
}
请记住,文件加锁机制是依赖于操作系统的,需要注意:
- 在某些系统中,文件加锁仅仅是建议性的,如果一个应用未能得到锁,它仍旧可以向被另一个应用并发锁定的文件执行写操作。
- 在某些系统中,不能在锁定一个文件的同时将其映射到内存中。
- 文件锁是由整个java虚拟机持有的。如果有两个程序是由同一个虚拟机启动的(例如applet和应用程序启动器),那么它们不可能每一个都获得一个在同一个文件上的锁。当调用
lock
和tryLock
方法时,如果虚拟机已经在同一个文件上持有了另一个重叠的锁,那么这两个方法将抛出OverlappingFileLockException
。- 在一些系统中,关闭一个通道会释放由java虚拟机持有的底层文件上的所有锁。因此,在同一个锁定文件上应避免使用多个通道。
- 在网络文件系统上锁定文件是高度依赖于系统的,因此应该尽量避免。