2.输入与输出

输入与输出

2.1输入/输出流

字节序列的来源地和目的地可以是文件,而且通常都是文件,但是也可以是网络连接,甚至是内存块。

2.1.1读写字节

System.in(它是InputStream的一个子类的预定义对象)是从标准输入中读入信息,即从控制台或重定向的文件中读入信息。

从java9开始,可以读取流中的所有字节:

byte[] bytes = in.readAllBytes();

transferTo方法可以将所有字节从一个输入流传递到一个输出流:

in.transferTo(out);

readwrite方法在执行时都将阻塞,直至字节确实被读入或写出。这就意味着如果流不能被立即访问(通常是因为网络连接忙),那么当前的线程将被阻塞。这使得在这两个方法等待指定的流变为可用的这段时间里,其他的线程就有机会去执行有用的工作。

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);

为了输出到打印写出器,需要使用printprintlnprintf方法。
println方法在行中添加了对目标系统来说恰当的行结束符,也就是通过调用System.getProperty("line.separator")而获得的字符串。
如果写出器设置为自动冲刷模式,那么只要println被调用,缓冲区中的所有字符都会被发送到它们的绑定到(打印写出器总是带缓冲区的)。默认情况下,自动冲刷机制是禁用的,可以通过使用PrintWriter(Writer writer, boolean autoFlush)来启用或禁用自动冲刷机制。
由于print这类方法不抛出异常,可以调用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.1DataInputDataOutput接口

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类同时实现了DataInputDataOutput接口。为了读写随机访问文件,可以使用诸如readInt/writeIntreadChar/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文件,这个项称作清单。可以使用JarInputStreamJarOutputStream类来读写清单项。

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操作文件

PathFiles类封装了在用户机器上处理文件系统所需的所有功能。

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
  • 否则,返回通过连接thisother获得的路径。

还有一个很方便的方法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.txtc:\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对象,prefixsuffix是可以为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);

该方法还可以用来移除空目录。

用于文件操作的标准选项:

选项描述
StandardOpenOptionnewBufferedWriternewInputStreamnewOutputStreamwrite一起使用
READ用于读取而打开
WRITE用于写入而打开
APPEND如果用于写入而打开,那么在文件末尾追加
TRUNCATE_EXISTING如果用于写入而打开,那么移除已有内容
CREATE_NEW创建新文件并且在文件已存在的情况下会创建失败
CREATE自动在文件不存在的情况下创建新文件
DELETE_ON_CLOSE当文件被关闭时,尽可能地删除该文件
SPARSE给文件系统一个提示,表示该文件是稀疏的
DSYNCSYNC要求对文件数据或数据和元数据的每次更新都必须同步地写入到存储设备中
StandardCopyOptioncopymove一起使用
ATOMIC_MOVE原子性地移动文件
COPY_ATTRIBUTES复制文件的属性
REPLACE_EXISTING如果目标已存在,则替换它
LinkOption与上面所有方法以及existsisDirectoryisRegularFile等一起使用
NOFOLLOW_LINKS不要跟踪符号链接
FileVisitOptionfindwalkwalkFileTree一起使用
FOLLOW_LINKS跟踪符号链接
2.4.5获取文件信息

下面的静态方法都将返回一个boolean值,表示检查路径的某个属性的结果:

  • exists
  • isHidden
  • isReadableisWritableisExecutable
  • isRegularFileisDirectoryisSymbolicLink

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;
    }
});

值得注意的是,需要覆盖postVisitDirectoryvisitFileFailed方法,否则,访问会在遇到不允许打开的目录或不允许访问的文件时立即失败。
还应该注意的是,路径的众多属性是作为preVisitDirectoryvisitFile方法的参数传递的。访问者不得不通过操作系统调用来获得这些属性,因为它需要区分文件和目录。因此,就不需要再次执行系统调用了。
如果需要在进入或离开一个目录时执行某些操作,那么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超类的方法读写数据了。
缓冲区支持顺序和随机数据访问,它有一个可以通过getput操作来移动的位置。例如,顺序遍历缓冲区中的所有字节:

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类是一个抽象类,它有众多的具体子类,包括ByteBufferCharBufferDoubleBufferIntBufferLongBufferShortBuffer(StringBuffer类与这些缓冲区没有关系)。
在实践中,最常用的将是ByteBufferCharBuffer。每个缓冲区都具有:

  • 一个容量,它永远不能改变。
  • 一个读写位置,下一个值将在此进行读写。
  • 一个界限,超过它进行读写是没有意义的。
  • 一个可选的标记,用于重复一个读入或写出操作。

这些值满足下面的条件:0 ⩽ \leqslant 标记 ⩽ \leqslant 读写位置 ⩽ \leqslant 界限 ⩽ \leqslant 容量。

使用缓冲区的主要目的是执行写,然后读入循环。假设有一个缓冲区,在一开始,它的位置为0,界限等于容量。不断地调用put将值添加到这个缓冲区中,当耗尽所有的数据或者写出的数据量达到容量大小时,就该切换到读入操作了。

在这里插入图片描述

这时调用flip方法将界限设置到当前位置,并把位置复位到0。现在在remaining方法返回正数时(它返回的值是界限 - 位置),不断地调用get。在将缓冲区中所有的值都读入之后,调用clear使缓冲区为下一次写循环做好准备。clear方法将位置复位到0,并将界限复位到容量。
如果想重读缓冲区,可以使用rewindmark/reset方法,详细内容查看API。
要获取缓冲区,可以调用诸如ByteBuffer.allocateByteBuffer.wrap这样的静态方法。
然后,可以用来自某个通道的数据填充缓冲区,或者将缓冲区的内容写出到通道中。例如:

ByteBuffer buffer = ByteBuffer.allocate(RECORD_SIZE);
channel.read(buffer);
channel.position(newpos);
buffer.flip();
channel.write(buffer);

这是一种非常有用的方法,可以替代随机访问文件。

2.6文件加锁机制

文件锁可以控制对文件或文件中某个范围的字节的访问。
假如应用程序将用户的偏好存储在一个配置文件中,当用户调用这个应用的两个实例时,这两个实例就有可能会同时希望写配置文件。在这种情况下,第一个实例应该锁定文件,当第二个实例发现文件被锁定时,它必须决策是等待直至文件解锁,还是直接跳过这个写操作过程。

要锁定一个文件,可以调用FileChannel类的locktryLock方法:

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和应用程序启动器),那么它们不可能每一个都获得一个在同一个文件上的锁。当调用locktryLock方法时,如果虚拟机已经在同一个文件上持有了另一个重叠的锁,那么这两个方法将抛出OverlappingFileLockException
  • 在一些系统中,关闭一个通道会释放由java虚拟机持有的底层文件上的所有锁。因此,在同一个锁定文件上应避免使用多个通道。
  • 在网络文件系统上锁定文件是高度依赖于系统的,因此应该尽量避免。
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值