17. IO 流

空~2022年8月13日
  • java
大约 71 分钟

17. IO 流

在丑陋的 Java I/O 编程方式诞生多年以后,Java 终于简化了文件读写的基本操作。

IO(输入/输出)是比较乏味的事情,因为看不到明显的运行效果,但输入/输出是所有程序都必需的部分——使用输入机制,允许程序读取外部数据(包括来自磁盘、光盘等存储设备的数据)、用户输入数据;使用输出机制,允许程序记录运行状态,将程序数据输出到磁盘、光盘等存储设备中。

File 类

File 类是 java.io 包下代表与平台无关的文件和目录,也就是说,如果希望在程序中操作文件和目录,都可以通过 File 类来完成。

File 能新建、删除、重命名文件和目录,但 File 不能访问文件内容本身。

访问文件和目录

File 类可以使用文件路径字符串来创建 File 实例,该文件路径字符串既可以是绝对路径,也可以是相对路径。

在默认情况下,系统总是依据用户的工作路径来解释相对路径,这个路径由系统属性“user.dir”指定,通常也就是运行 Java 虚拟机时所在的路径。

File 类提供了很多方法来操作文件和目录,下面列出一些比较常用的方法。

1. 访问文件名相关的方法:

  1. String getName():返回此 File 对象所表示的文件名或路径名(如果是路径,则返回最后一级子路径名)。
  2. String getPath():返回此 File 对象所对应的路径名。
  3. File getAbsoluteFile():返回此 File 对象所对应的绝对路径所对应的 File 对象。
  4. String getAbsolutePath():返回此 File 对象所对应的绝对路径名。
  5. String getParent():返回此 File 对象所对应目录(最后一级子目录)的父目录名。
  6. boolean renameTo(File newName):重命名此 File 对象所对应的文件或目录,如果重命名成功,则返回 true;否则返回 false。

2. 文件检测相关的方法:

  1. boolean exists():判断 File 对象所对应的文件或目录是否存在。
  2. boolean canWrite():判断 File 对象所对应的文件和目录是否可写。
  3. boolean canRead():判断 File 对象所对应的文件和目录是否可读。
  4. boolean isFile():判断 File 对象所对应的是否是文件,而不是目录。
  5. boolean isDirectory():判断 File 对象所对应的是否是目录,而不是文件。
  6. boolean isAbsolute():判断 File 对象所对应的文件或目录是否是绝对路径。该方法消除了不同平台的差异,可以直接判断 File 对象是否为绝对路径。在 UNIX/Linux/BSD 等系统上,如果路径名开头是一条斜线(/),则表明该 File 对象对应一个绝对路径;在 Windows 等系统上,如果路径开头是盘符,则说明它是一个绝对路径。

3. 获取常规文件信息:

  1. long lastModified():返回文件的最后修改时间。
  2. long length():返回文件内容的长度。

4. 文件操作相关的方法:

  1. boolean createNewFile():当此 File 对象所对应的文件不存在时,该方法将新建一个该 File 对象所指定的新文件,如果创建成功则返回 true;否则返回 false。
  2. boolean delete():删除 File 对象所对应的文件或路径。
  3. static File createTempFile(String prefix, String suffix) :在默认的临时文件目录中创建一个临时的空文件,使用给定前缀、系统生成的随机数和给定后缀作为文件名。这是一个静态方法,可以直接通过 File 类来调用。prefix 参数必须至少是 3 个字节长。建议前缀使用一个短的、有意义的字符串,比如"hjb"或"mail"。suffix 参数可以为 null,在这种情况下,将使用默认的后缀“.tmp”。
  4. static File createTempFile(String prefix, String suffix, File directory):在 directory 所指定的目录中创建一个临时的空文件,使用给定前缀、系统生成的随机数和给定后缀作为文件名。这是一个静态方法,可以直接通过 File 类来调用。

5. 目录操作相关的方法:

  1. boolean mkdir():试图创建一个 File 对象所对应的目录,如果创建成功,则返回 true;否则返回 false。调用该方法时 File 对象必须对应一个路径,而不是一个文件。
  2. String[] list():列出 File 对象的所有子文件名和路径名,返回 String 数组。
  3. File[] listFiles():列出 File 对象的所有子文件和路径,返回 File 数组。
  4. static File[] listRoots():列出系统所有的根路径。这是一个静态方法,可以直接通过 File 类来调用。
public class FileTest {
    public static void main(String[] args) throws IOException {
        // 以当前路径来创建一个 File 对象(只会创建对象实例,不会在电脑中创建文件)
        File file = new File(".");
        // 直接获取文件名,输出一点
        System.out.println(file.getName());
        // 获取相对路径的父路径可能出错,下面代码输出 null
        System.out.println(file.getParent());
        // 获取绝对路径
        System.out.println(file.getAbsoluteFile());
        // 获取上一级路径
        System.out.println(file.getAbsoluteFile().getParent());
        // 在当前路径下创建一个临时文件
        File tmpFile = File.createTempFile("aaa", ".txt", file);
        // 指定当 JVM 退出时删除该文件
        tmpFile.deleteOnExit();
        // 以系统当前时间作为新文件名来创建新对象(只会创建对象实例,不会在电脑中创建文件)
        File newFile = new File(System.currentTimeMillis() + "");
        System.out.println("newFile 文件是否存在:" + newFile.exists());
        System.out.println(newFile);
        // 以指定 newFile 对象来创建一个文件(创建具体的文件)
        newFile.createNewFile();
        // 以 newFile 对象来创建一个目录,因为 newFile 已经存在
        // 所以下面方法返回 false,即无法创建该目录
        newFile.mkdir();
        // 使用 list() 方法列出当前路径下的所有文件和路径
        String[] fileList = file.list();
        System.out.println("====当前路径下所有文件和路径如下====");
        for (String fileName : fileList) {
            System.out.println(fileName);
        }
        // listRoots() 静态方法列出所有的磁盘根路径
        File[] roots = File.listRoots();
        System.out.println("====系统所有根路径如下====");
        for (File root : roots) {
            System.out.println(root);
        }
    }
}

文件过滤器

在 File 类的 list()方法中可以接收一个 FilenameFilter参数,通过该参数可以只列出符合条件的文件。

FilenameFilter接口里包含了一个 accept(File dir, String name)方法,该方法将依次对指定 File 的所有子目录或者文件进行迭代,如果该方法返回 true,则 list()方法会列出该子目录或者文件。

public class FilenameFilterTest {
    public static void main(String[] args) {
        File file = new File(".");
        String[] nameList = file.list(new MyFilenameFilter());
        for (String name : nameList) {
            System.out.println(name);
        }
    }
}

// 实现自己的 FilenameFilter 实现类
class MyFilenameFilter implements FilenameFilter {
    public boolean accept(File dir, String name) {
        // 如果文件名以。java 结尾,或者文件对应一个路径,则返回 true
        return name.endsWith(".java") || new File(name).isDirectory();
    }
}

理解 Java 的 IO 流

Java 的 IO 流是实现输入/输出的基础,它可以方便地实现数据的输入/输出操作,在 Java 中把不同的输入/输出源(键盘、文件、网络连接等)抽象表述为“流”(stream),通过流的方式允许 Java 程序使用相同的方式来访问不同的输入/输出源。stream 是从起源(source)到接收(sink)的有序数据。

Java 把所有传统的流类型(类或抽象类)都放在 java.io 包中,用以实现输入/输出功能。

流的分类

1. 输入流和输出流:

  1. 输入流:只能从中读取数据,而不能向其写入数据。
  2. 输出流:只能向其写入数据,而不能从中读取数据。

数据从内存到硬盘,通常称为输出流——也就是说,这里的输入、输出都是从程序运行所在内存的角度来划分的。

epub_681336_1946

Java 的输入流主要由 InputStream和 Reader 作为基类,而输出流则主要由 OutputStream和 Writer 作为基类。它们都是一些抽象基类,无法直接创建实例。

2. 字节流和字符流:

字节流和字符流的用法几乎完全一样,区别在于字节流和字符流所操作的数据单元不同——字节流操作的数据单元是 8 位的字节,而字符流操作的数据单元是 16 位的字符。

字节流主要由 InputStreamOutputStream作为基类,而字符流则主要由 Reader 和 Writer 作为基类。

3. 节点流和处理流:

可以从/向一个特定的 IO 设备(如磁盘、网络)读/写数据的流,称为节点流,节点流也被称为低级流(Low Level Stream)。

当使用节点流进行输入/输出时,程序直接连接到实际的数据源,和实际的输入/输出节点连接。

epub_681336_1948

处理流则用于对一个已存在的流进行连接或封装,通过封装后的流来实现数据读/写功能。处理流也被称为高级流。

epub_681336_1949

当使用处理流进行输入/输出时,程序并不会直接连接到实际的数据源,没有和实际的输入/输出节点连接。

使用处理流的一个明显好处是,只要使用相同的处理流,程序就可以采用完全相同的输入/输出代码来访问不同的数据源,随着处理流所包装节点流的变化,程序实际所访问的数据源也相应地发生变化。

相关信息

Java 使用处理流来包装节点流是一种典型的装饰器设计模式,通过使用处理流来包装不同的节点流,既可以消除不同节点流的实现差异,也可以提供更方便的方法来完成输入/输出功能。因此处理流也被称为包装流。

流的概念模型

Java 把所有设备里的有序数据抽象成流模型,简化了输入/输出处理,理解了流的概念模型也就了解了 Java IO。

Java 的 IO 流的 40 多个类都是从如下 4 个抽象基类派生的。

  1. InputStream/Reader:所有输入流的基类,前者是字节输入流,后者是字符输入流。
  2. OutputStream/Writer:所有输出流的基类,前者是字节输出流,后者是字符输出流。

对于 InputStreamReader而言,它们把输入设备抽象成一个“水管”,这个水管里的每个“水滴”依次排列。

字节流和字符流的处理方式其实非常相似,只是它们处理的输入/输出单位不同而已。

输入流使用隐式的记录指针来表示当前正准备从哪个“水滴”开始读取,每当程序从 InputStreamReader 里取出一个或多个“水滴”后,记录指针自动向后移动;除此之外,InputStreamReader里都提供一些方法来控制记录指针的移动。

epub_681336_1953

对于 OutputStreamWriter而言,它们同样把输出设备抽象成一个“水管”,只是这个水管里没有任何水滴。

当执行输出时,程序相当于依次把“水滴”放入到输出流的水管中,输出流同样采用隐式的记录指针来标识当前水滴即将放入的位置,每当程序向 OutputStreamWriter里输出一个或多个水滴后,记录指针自动向后移动。

epub_681336_1954

处理流的功能主要体现在以下两个方面。

  1. 性能的提高:主要以增加缓冲的方式来提高输入/输出的效率。
  2. 操作的便捷:处理流可能提供了一系列便捷的方法来一次输入/输出大批量的内容,而不是输入/输出一个或多个“水滴”。

处理流可以“嫁接”在任何已存在的流的基础之上,这就允许 Java 应用程序采用相同的代码、透明的方式来访问不同的输入/输出设备的数据流。

epub_681336_1957

通过使用处理流,Java 程序无须理会输入/输出节点是磁盘、网络还是其他的输入/输出设备,程序只要将这些节点流包装成处理流,就可以使用相同的输入/输出代码来读写不同的输入/输出设备的数据。

字节流和字符流

它们的操作方式几乎完全一样,区别只是操作的数据单元不同而已——字节流操作的数据单元是字节,字符流操作的数据单元是字符。

InputStream 和 Reader

InputStreamReader是所有输入流的抽象基类,本身并不能创建实例来执行输入,但它们将成为所有输入流的模板,所以它们的方法是所有输入流都可使用的方法。

InputStream里包含如下 3 个方法。

  1. int read():从输入流中读取单个字节(相当于从图 15.5 所示的水管中取出一滴水),返回所读取的字节数据(字节数据可直接转换为 int 类型)。
  2. int read(byte[] b):从输入流中最多读取 b.length 个字节的数据,并将其存储在字节数组 b 中,返回实际读取的字节数。
  3. int read(byte[] b, int off, int len):从输入流中最多读取 len 个字节的数据,并将其存储在数组 b 中,放入数组 b 中时,并不是从数组起点开始,而是从 off 位置开始,返回实际读取的字节数。

在 Reader 里包含如下 3 个方法。

  1. int read():从输入流中读取单个字符(相当于从图 15.5 所示的水管中取出一滴水),返回所读取的字符数据(字符数据可直接转换为 int 类型)。
  2. int read(char[] cbuf):从输入流中最多读取 cbuf.length 个字符的数据,并将其存储在字符数组 cbuf 中,返回实际读取的字符数。
  3. int read(char[] cbuf, int off, int len):从输入流中最多读取 len 个字符的数据,并将其存储在字符数组 cbuf 中,放入数组 cbuf 中时,并不是从数组起点开始,而是从 off 位置开始,返回实际读取的字符数。

对比 InputStreamReader所提供的方法,就不难发现这两个基类的功能基本是一样的。InputStreamReader 都是将输入数据抽象成水管,所以程序既可以通过 read()方法每次读取一个“水滴”,也可以通过 read(char[] cbuf)read(byte[] b)方法来读取多个“水滴”。

read(char[] cbuf)方法中的数组可理解成一个“竹筒”,程序每次调用输入流的 read(char[] cbuf)read(byte[] b) 方法,就相当于用“竹筒”从输入流中取出一筒“水滴”,程序得到“竹筒”里的“水滴”后,转换成相应的数据即可;程序多次重复这个“取水”过程,直到最后。直到 read(char[]cbuf) 或 read(byte[] b) 方法返回-1,即表明到了输入流的结束点。

epub_681336_1965

InputStreamReader分别有一个用于读取文件的输入流:FileInputStreamFileReader,它们都是节点流——会直接和指定文件关联。

public class FileInputStreamTest {
    public static void main(String[] args) throws IOException {
        // 创建字节输入流
        FileInputStream fis = new FileInputStream("test/src/IO 流/FileInputStreamTest.java");
        // 创建一个长度为 1024 的“竹筒”
        byte[] bbuf = new byte[1024];
        // 用于保存实际读取的字节数
        int hasRead = 0;
        // 使用循环来重复“取水”过程
        while ((hasRead = fis.read(bbuf)) > 0) {
            // 取出“竹筒”中的水滴(字节),将字节数组转换成字符串输入
            System.out.print(new String(bbuf, 0, hasRead));
        }
        // 关闭文件输入流,放在 finally 块里更安全
        fis.close();
    }
}

使用 FileReader来读取文件本身。

public class FileReaderTest {
    public static void main(String[] args) throws IOException {
        try (
            // 创建字符输入流
            FileReader fr = new FileReader("test/src/IO 流/FileReaderTest.java")) {
            // 创建一个长度为 32 的“竹筒”
            char[] cbuf = new char[32];
            // 用于保存实际读取的字符数
            int hasRead = 0;
            // 使用循环来重复“取水”过程
            while ((hasRead = fr.read(cbuf)) > 0) {
                // 取出“竹筒”中的水滴(字符),将字符数组转换成字符串输入!
                System.out.print(new String(cbuf, 0, hasRead));
            }
        } catch (IOException ex) {
            ex.printStackTrace();
        }
    }
}

除此之外,InputStreamReader还支持如下几个方法来移动记录指针。

  1. void mark(int readAheadLimit):在记录指针当前位置记录一个标记(mark)。
  2. boolean markSupported():判断此输入流是否支持 mark() 操作,即是否支持记录标记。
  3. void reset():将此流的记录指针重新定位到上一次记录标记(mark)的位置。
  4. long skip(long n):记录指针向前移动 n 个字节/字符。

OutputStream 和 Writer

两个流都提供了如下 3 个方法。

  1. void write(int c):将指定的字节/字符输出到输出流中,其中 c 既可以代表字节,也可以代表字符。
  2. void write(byte[]/char[] buf):将字节数组/字符数组中的数据输出到指定输出流中。
  3. void write(byte[]/char[] buf, int off, int len):将字节数组/字符数组中从 off 位置开始,长度为 len 的字节/字符输出到输出流中。

因为字符流直接以字符作为操作单位,所以 Writer 可以用字符串来代替字符数组,即以 String 对象作为参数。Writer 里还包含如下两个方法。

  1. void write(String str):将 str 字符串里包含的字符输出到指定输出流中。
  2. void write(String str, int off, int len):将 str 字符串里从 off 位置开始,长度为 len 的字符输出到指定输出流中。

下面程序使用 FileInputStream来执行输入,并使用 FileOutputStream来执行输出,用以实现复制 FileOutputStreamTest.java 文件的功能。

public class FileOutputStreamTest {
    public static void main(String[] args) {
        try (
            // 创建字节输入流
            FileInputStream fis = new FileInputStream("test/src/IO 流/FileOutputStreamTest.java");
            // 创建字节输出流
            FileOutputStream fos = new FileOutputStream("test/src/IO 流/newFile.txt")) {
            byte[] bbuf = new byte[32];
            int hasRead = 0;
            // 循环从输入流中取出数据
            while ((hasRead = fis.read(bbuf)) > 0) {
                // 每读取一次,即写入文件输出流,读了多少,就写多少
                fos.write(bbuf, 0, hasRead);
            }
        } catch (IOException ioe) {
            ioe.printStackTrace();
        }
    }
}

如果希望直接输出字符串内容,则使用 Writer 会有更好的效果。

public class FileWriterTest {
    public static void main(String[] args) {
        try (FileWriter fw = new FileWriter("poem.txt")) {
            fw.write("锦瑟 - 李商隐、r\n");
            fw.write("锦瑟无端五十弦,一弦一柱思华年。\r\n");
            fw.write("庄生晓梦迷蝴蝶,望帝春心托杜鹃。\r\n");
            fw.write("沧海月明珠有泪,蓝田日暖玉生烟。\r\n");
            fw.write("此情可待成追忆,只是当时已惘然。\r\n");
        } catch (IOException ioe) {
            ioe.printStackTrace();
        }
    }
}

输入/输出流体系

4 个基类使用起来有些烦琐,这就需要借助于处理流了。

处理流的用法

使用处理流时的典型思路是,使用处理流来包装节点流,程序通过处理流来执行输入/输出功能,让节点流与底层的 I/O 设备、文件交互。

只要流的构造器参数不是一个物理节点,而是已经存在的流,那么这种流就一定是处理流;而所有节点流都是直接以物理 IO 节点作为构造器参数的。

public class PrintStreamTest {
    public static void main(String[] args) {
        try (FileOutputStream fos = new FileOutputStream("test.txt"); PrintStream ps = new PrintStream(fos)) {
            // 使用 PrintStream 执行输出
            ps.println("普通字符串");
            // 直接使用 PrintStream 输出对象
            ps.println(new PrintStreamTest());
        } catch (IOException ioe) {
            ioe.printStackTrace();
        }
    }
}

PrintStream的输出功能非常强大,前面程序中一直使用的标准输出 System.out的类型就是 PrintStream

提示

在使用处理流包装了底层节点流之后,关闭输入/输出流资源时,只要关闭最上层的处理流即可。关闭最上层的处理流时,系统会自动关闭被该处理流包装的节点流。

体系

Java 的输入/输出流体系提供了近 40 个类,这些类看上去杂乱而没有规律,但如果我们将其按功能进行分类,则不难发现其是非常规则的。

epub_681336_1978

提示

如果进行输入/输出的内容是文本内容,则应该考虑使用字符流;如果进行输入/输出的内容是二进制内容,则应该考虑使用字节流。

表仅仅总结了输入/输出流体系中位于 java.io 包下的流,还有一些诸如 AudioInputStreamCipherInputStreamDeflaterInputStreamZipInputStream等具有访问音频文件、加密/解密、压缩/解压等功能的字节流,它们具有特殊的功能,位于 JDK 的其他包下。

字节流以字节数组为节点,字符流以字符数组为节点;这种以数组为物理节点的节点流除了在创建节点流对象时需要传入一个字节数组或者字符数组之外,用法上与文件节点流完全相似。

与此类似的是,字符流还可以使用字符串作为物理节点,用于实现从字符串读取内容,或将内容写入字符串(用 StringBuffer充当字符串)的功能。

public class StringNodeTest {
    public static void main(String[] args) {
        String src =
            "从明天起,做一个幸福的人、n" + "喂马,劈柴,周游世界、n" + "从明天起,关心粮食和蔬菜、n" + "我有一所房子,面朝大海,春暖花开、n" + "从明天起,和每一个亲人通信、n" + "告诉他们我的幸福、n";
        char[] buffer = new char[32];
        int hasRead = 0;
        try (StringReader sr = new StringReader(src)) {
            // 采用循环读取的方式读取字符串
            while ((hasRead = sr.read(buffer)) > 0) {
                System.out.print(new String(buffer, 0, hasRead));
            }
        } catch (IOException ioe) {
            ioe.printStackTrace();
        }
        try (
            // 创建 StringWriter 时,实际上以一个 StringBuffer 作为输出节点
            // 下面指定的 20 就是 StringBuffer 的初始长度
            StringWriter sw = new StringWriter()) {
            // 调用 StringWriter 的方法执行输出
            sw.write("有一个美丽的新世界,\n");
            sw.write("她在远方等我,\n");
            sw.write("那里有天真的孩子,\n");
            sw.write("还有姑娘的酒窝、n");
            System.out.println("----下面是 sw 字符串节点里的内容----");
            // 使用 toString() 方法返回 StringWriter 字符串节点的内容
            System.out.println(sw);
        } catch (IOException ex) {
            ex.printStackTrace();
        }
    }
}

由于 String 是不可变的字符串对象,所以 StringWriter使用 StringBuffer作为输出节点。

4 个访问管道的流:PipedInputStreamPipedOutputStreamPipedReaderPipedWriter ,它们都是用于实现进程之间通信功能的,分别是字节输入流、字节输出流、字符输入流和字符输出流。

缓冲流则增加了缓冲功能,增加缓冲功能可以提高输入、输出的效率,增加缓冲功能后需要使用 flush()才可以将缓冲区的内容写入实际的物理节点。

对象流主要用于实现对象的序列化。

转换流

输入/输出流体系中还提供了两个转换流,这两个转换流用于实现将字节流转换成字符流,其中 InputStreamReader 将字节输入流转换成字符输入流,OutputStreamWriter将字节输出流转换成字符输出流。

Java 使用 System.in 代表标准输入,即键盘输入,但这个标准输入流是 InputStream 类的实例,使用不太方便,而且键盘输入内容都是文本内容,所以可以使用 InputStreamReader将其转换成字符输入流,普通的 Reader 读取输入内容时依然不太方便,我们可以将普通的 Reader 再次包装成 BufferedReader,利用 BufferedReaderreadLine() 方法可以一次读取一行内容。

public class KeyinTest {
    public static void main(String[] args) {
        try (
            // 将 Sytem.in 对象转换成 Reader 对象
            InputStreamReader reader = new InputStreamReader(System.in);
            // 将普通的 Reader 包装成 BufferedReader
            BufferedReader br = new BufferedReader(reader)) {
            String buffer = null;
            // 采用循环方式来逐行地读取
            while ((buffer = br.readLine()) != null) {
                // 如果读取的字符串为"exit",则程序退出
                if (buffer.equals("exit")) {
                    System.exit(1);
                }
                // 打印读取的内容
                System.out.println("输入内容为:" + buffer);
            }
        } catch (IOException ioe) {
            ioe.printStackTrace();
        }
    }
}

程序将 System.in 包装成 BufferedReaderBufferedReader流具有缓冲功能,它可以一次读取一行文本——以换行符为标志,如果它没有读到换行符,则程序阻塞,等到读到换行符为止。

运行上面程序可以发现这个特征,当我们在控制台执行输入时,只有按下回车键,程序才会打印出刚刚输入的内容。

推回输入流

在输入/输出流体系中,有两个特殊的流与众不同,就是 PushbackInputStreamPushbackReader,它们都提供了如下 3 个方法。

  1. void unread(byte[]/char[] buf):将一个字节/字符数组内容推回到推回缓冲区里,从而允许重复读取刚刚读取的内容。
  2. void unread(byte[]/char[] b, int off, int len):将一个字节/字符数组里从 off 开始,长度为 len 字节/字符的内容推回到推回缓冲区里,从而允许重复读取刚刚读取的内容。
  3. void unread(int b):将一个字节/字符推回到推回缓冲区里,从而允许重复读取刚刚读取的内容。

这两个推回输入流都带有一个推回缓冲区,当程序调用这两个推回输入流的 unread()方法时,系统将会把指定数组的内容推回到该缓冲区里,而推回输入流每次调用 read() 方法时总是先从推回缓冲区读取,只有完全读取了推回缓冲区的内容后,但还没有装满 read()所需的数组时才会从原输入流中读取。

epub_681336_1985

当我们创建一个 PushbackInputStreamPushbackReader时需要指定推回缓冲区的大小,默认的推回缓冲区的长度为 1。

如果程序中推回到推回缓冲区的内容超出了推回缓冲区的大小,将会引发 Pushback buffer overflow 的 IOException异常。

提示

推回缓冲区的长度与 read()方法的数组参数的长度没有任何关系,完全可以更大。

下面程序试图找出程序中的"new PushbackReader"字符串,当找到该字符串后,程序只是打印出目标字符串之前的内容。

public class PushbackTest {
    public static void main(String[] args) {
        try (
            // 创建一个 PushbackReader 对象,指定推回缓冲区的长度为 64
            PushbackReader pr = new PushbackReader(new FileReader("test/src/IO 流/PushbackTest.java"), 64)) {
            char[] buf = new char[32];
            // 用以保存上次读取的字符串内容
            String lastContent = "";
            int hasRead = 0;
            // 循环读取文件内容
            while ((hasRead = pr.read(buf)) > 0) {
                // 将读取的内容转换成字符串
                String content = new String(buf, 0, hasRead);
                int targetIndex = 0;
                // 将上次读取的字符串和本次读取的字符串拼起来
                // 查看是否包含目标字符串,如果包含目标字符串
                if ((targetIndex = (lastContent + content).indexOf("new PushbackReader")) > 0) {
                    // 将本次内容和上次内容一起推回缓冲区
                    pr.unread((lastContent + content).toCharArray());
                    // 指定读取前面 len 个字符
                    int len = targetIndex > 32 ? 32 : targetIndex;
                    // 再次读取指定长度的内容(就是目标字符串之前的内容)
                    pr.read(buf, 0, len);
                    // 打印读取的内容
                    System.out.print(new String(buf, 0, len));
                    System.exit(0);
                } else {
                    // 打印上次读取的内容
                    System.out.print(lastContent);
                    // 将本次内容设为上次读取的内容
                    lastContent = content;
                }
            }
        } catch (IOException ioe) {
            ioe.printStackTrace();
        }
    }
}

重定向标准输入/输出

当程序通过 System.in来获取输入时,实际上是从键盘读取输入;当程序试图通过 System.out执行输出时,程序总是输出到控制台。

在 System 类里提供了如下 3 个重定向标准输入/输出的方法。

  1. static void setErr(PrintStream err):重定向 “标准”错误输出流。
  2. static void setIn(InputStream in):重定向“标准”输入流。
  3. static void setOut(PrintStream out):重定向 “标准”输出流。

System.out的输出重定向到文件输出。

public class RedirectOut {
    public static void main(String[] args) {
        try (
            // 一次性创建 PrintStream 输出流
            PrintStream ps = new PrintStream(new FileOutputStream("out.txt"))) {
            // 将标准输出重定向到 ps 输出流
            System.setOut(ps);
            // 向标准输出输出一个字符串
            System.out.println("普通字符串");
            // 向标准输出输出一个对象
            System.out.println(new RedirectOut());
        } catch (IOException ex) {
            ex.printStackTrace();
        }
    }
}

System.in重定向到指定文件,而不是键盘输入。

public class RedirectIn {
    public static void main(String[] args) {
        try (FileInputStream fis = new FileInputStream("test/src/IO 流/RedirectIn.java")) {
            // 将标准输入重定向到 fis 输入流
            System.setIn(fis);
            // 使用 System.in 创建 Scanner 对象,用于获取标准输入
            Scanner sc = new Scanner(System.in);
            // 增加下面一行只把回车作为分隔符
            sc.useDelimiter("\n");
            // 判断是否还有下一个输入项
            while (sc.hasNext()) {
                // 输出输入项
                System.out.println("输入的内容是:" + sc.next());
            }
        } catch (IOException ex) {
            ex.printStackTrace();
        }
    }
}

Java 虚拟机读写其他进程的数据

使用 Runtime 对象的 exec()方法可以运行平台上的其他程序,该方法产生一个 Process 对象,Process 对象代表由该 Java 程序启动的子进程。Process 类提供了如下 3 个方法,用于让程序和其子进程进行通信。

  1. InputStream getErrorStream():获取子进程的错误流。
  2. InputStream getInputStream():获取子进程的输入流。
  3. OutputStream getOutputStream():获取子进程的输出流。

提示

此处的输入流、输出流非常容易混淆,如果我们试图让子进程读取程序中的数据,那么应该用输入流还是输出流?不是输入流,而是输出流。我们要站在程序的角度来看问题,子进程读取程序的数据,就是让程序把数据输出到子进程中(就像把数据输出到文件中一样,只是现在由子进程节点代替了文件节点),所以应该使用输出流。

public class ReadFromProcess {
    public static void main(String[] args) throws IOException {
        // 运行 javac 命令,返回运行该命令的子进程
        Process p = Runtime.getRuntime().exec("javac");
        try (
            // 以 p 进程的错误流创建 BufferedReader 对象
            // 这个错误流对本程序是输入流,对 p 进程则是输出流
            BufferedReader br = new BufferedReader(new InputStreamReader(p.getErrorStream()))) {
            String buff = null;
            // 采取循环方式来读取 p 进程的错误输出
            while ((buff = br.readLine()) != null) {
                System.out.println(buff);
            }
        }
    }
}

数据流对 p 进程(javac 进程)而言,它是输出流;但对本程序(ReadFromProcess)而言,它是输入流——我们衡量输入、输出时总是站在运行本程序所在内存的角度,所以该数据流应该是输入流。

epub_681336_1992

image-20220816110942756

也可以通过 Process 的 getOutputStream()方法获得向进程输入数据的流。

RandomAccessFile

RandomAccessFile是 Java 输入/输出流体系中功能最丰富的文件内容访问类,它提供了众多的方法来访问文件内容,它既可以读取文件内容,也可以向文件输出数据。

RandomAccessFile支持“任意访问”的方式,程序可以直接跳转到文件的任意地方来读写数据。

RandomAccessFile对象也包含了一个记录指针,用以标识当前读写处的位置,当程序新创建一个 RandomAccessFile 对象时,该对象的文件记录指针位于文件头(也就是 0 处),当读/写了 n 个字节后,文件记录指针将会向后移动 n 个字节。

除此之外,RandomAccessFile可以自由移动该记录指针,既可以向前移动,也可以向后移动。RandomAccessFile包含了如下两个方法来操作文件记录指针。

  1. long getFilePointer():返回文件记录指针的当前位置。
  2. void seek(long pos):将文件记录指针定位到 pos 位置。

RandomAccessFile类有两个构造器,其实这两个构造器基本相同,只是指定文件的形式不同而已——一个使用 String 参数来指定文件名,一个使用 File 参数来指定文件本身。

除此之外,创建 RandomAccessFile对象时还需要指定一个 mode 参数,该参数指定 RandomAccessFile的访问模式,该参数有如下 4 个值。

  1. r:以只读方式打开指定文件。如果试图对该 RandomAccessFile执行写入方法,都将抛出 IOException异常。
  2. rw:以读、写方式打开指定文件。如果该文件尚不存在,则尝试创建该文件。
  3. rws:以读、写方式打开指定文件。相对于 rw模式,还要求对文件的内容或元数据的每个更新都同步写入到底层存储设备。
  4. rwd:以读、写方式打开指定文件。相对于 rw模式,还要求对文件内容的每个更新都同步写入到底层存储设备。
public class RandomAccessFileTest {
    public static void main(String[] args) {
        try (RandomAccessFile raf = new RandomAccessFile("test/src/IO 流/RandomAccessFileTest.java", "r")) {
            // 获取 RandomAccessFile 对象文件指针的位置,初始位置是 0
            System.out.println("RandomAccessFile 的文件指针的初始位置:" + raf.getFilePointer());
            // 移动 raf 的文件记录指针的位置
            raf.seek(300);
            byte[] bbuf = new byte[1024];
            // 用于保存实际读取的字节数
            int hasRead = 0;
            // 使用循环来重复“取水”过程
            while ((hasRead = raf.read(bbuf)) > 0) {
                // 取出“竹筒”中的水滴(字节),将字节数组转换成字符串输入
                System.out.print(new String(bbuf, 0, hasRead));
            }
        } catch (IOException ex) {
            ex.printStackTrace();
        }
    }
}

运行上面程序,将看到程序只读取后面部分的效果。

下面程序示范了如何向指定文件后追加内容,为了追加内容,程序应该先将记录指针移动到文件最后,然后开始向文件中输出内容。

public class AppendContent {
    public static void main(String[] args) {
        try (
            // 以读、写方式打开一个 RandomAccessFile 对象
            RandomAccessFile raf = new RandomAccessFile("out.txt", "rw")) {
            // 将记录指针移动到 out.txt 文件的最后
            raf.seek(raf.length());
            raf.write("追加的内容!\r\n".getBytes());
        } catch (IOException ex) {
            ex.printStackTrace();
        }
    }
}

提示

RandomAccessFile不能向文件的指定位置插入内容,如果直接将文件记录指针移动到中间某位置后开始输出,则新输出的内容会覆盖文件中原有的内容。

如果需要向指定位置插入内容,程序需要先把插入点后面的内容读入缓冲区,等把需要插入的数据写入文件后,再将缓冲区的内容追加到文件后面。

public class InsertContent {
    public static void main(String[] args) throws IOException {
        insert("test/src/IO 流/InsertContent.java", 45, "插入的内容、r\n");
    }

    public static void insert(String fileName, long pos, String insertContent) throws IOException {
        File tmp = File.createTempFile("tmp", null);
        tmp.deleteOnExit();
        try (RandomAccessFile raf = new RandomAccessFile(fileName, "rw");
            // 创建一个临时文件来保存插入点后的数据
            FileOutputStream tmpOut = new FileOutputStream(tmp); FileInputStream tmpIn = new FileInputStream(tmp)) {
            raf.seek(pos);
            // ------下面代码将插入点后的内容读入临时文件中保存------
            byte[] bbuf = new byte[64];
            // 用于保存实际读取的字节数
            int hasRead = 0;
            // 使用循环方式读取插入点后的数据
            while ((hasRead = raf.read(bbuf)) > 0) {
                // 将读取的数据写入临时文件
                tmpOut.write(bbuf, 0, hasRead);
            }
            // ----------下面代码用于插入内容----------
            // 把文件记录指针重新定位到 pos 位置
            raf.seek(pos);
            // 追加需要插入的内容
            raf.write(insertContent.getBytes());
            // 追加临时文件中的内容
            while ((hasRead = tmpIn.read(bbuf)) > 0) {
                raf.write(bbuf, 0, hasRead);
            }
        }
    }
}

数据刚好插入到 javadoc 注释中。

image-20220816151451359

上面程序中使用 File 的 createTempFile(String prefix, String suffix) 方法创建了一个临时文件(该临时文件将在 JVM 退出时被删除),用以保存被插入文件的插入点后面的内容。

程序先将文件中插入点后的内容读入临时文件中,然后重新定位到插入点,将需要插入的内容添加到文件后面,最后将临时文件的内容添加到文件后面,通过这个过程就可以向指定文件、指定位置插入内容。

对象序列化

对象序列化的目标是将对象保存到磁盘中,或允许在网络中直接传输对象。

对象序列化机制允许把内存中的 Java 对象转换成平台无关的二进制流,从而允许把这种二进制流持久地保存在磁盘上,通过网络将这种二进制流传输到另一个网络节点。

其他程序一旦获得了这种二进制流(无论是从磁盘中获取的,还是通过网络获取的),都可以将这种二进制流恢复成原来的 Java 对象。

如果需要让某个对象支持序列化机制,则必须让它的类是可序列化的(serializable)。为了让某个类是可序列化的,该类必须实现如下两个接口之一:

  1. Serializable
  2. Externalizable

该接口是一个标记接口,实现该接口无须实现任何方法,它只是表明该类的实例是可序列化的。

使用对象流实现序列化

一旦某个类实现了 Serializable 接口,该类的对象就是可序列化的,程序可以通过如下两个步骤来序列化该对象。

  1. 创建一个 ObjectOutputStream ,这个输出流是一个处理流,所以必须建立在其他节点流的基础之上。
  2. 调用 ObjectOutputStream 对象的 writeObject() 方法输出可序列化对象。

定义一个 Person 类,实现 Serializable 接口。

@Data
public class Person implements Serializable {
    private String name;
    private int age;

    // 注意此处没有提供无参数的构造器
    public Person(String name, int age) {
        System.out.println("有参数的构造器");
        this.name = name;
        this.age = age;
    }
}

使用 ObjectOutputStream 将一个 Person 对象写入磁盘文件。

public class WriteObject {
    public static void main(String[] args) {
        try (
            // 创建一个 ObjectOutputStream 输出流
            ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("object.txt"))) {
            Person per = new Person("孙悟空", 500);
            // 将 per 对象写入输出流
            oos.writeObject(per);
        } catch (IOException ex) {
            ex.printStackTrace();
        }
    }
}

运行上面程序,将会看到生成了一个 object.txt 文件,该文件的内容就是 Person 对象。

如果希望从二进制流中恢复 Java 对象,则需要使用反序列化。

  1. 创建一个 ObjectInputStream 输入流,这个输入流是一个处理流,所以必须建立在其他节点流的基础之上。
  2. 调用 ObjectInputStream 对象的 readObject() 方法读取流中的对象,该方法返回一个 Object 类型的 Java 对象,如果程序知道该 Java 对象的类型,则可以将该对象强制类型转换成其真实的类型。
public class ReadObject {
    public static void main(String[] args) {
        try (
            // 创建一个 ObjectInputStream 输入流
            ObjectInputStream ois = new ObjectInputStream(new FileInputStream("object.txt"))) {
            // 从输入流中读取一个 Java 对象,并将其强制类型转换为 Person 类
            Person p = (Person)ois.readObject();
            System.out.println("名字为:" + p.getName() + "\n 年龄为:" + p.getAge());
        } catch (Exception ex) {
            ex.printStackTrace();
        }
    }
}

反序列化读取的仅仅是 Java 对象的数据,而不是 Java 类,因此采用反序列化恢复 Java 对象时,必须提供该 Java 对象所属类的 class 文件,否则将会引发 ClassNotFoundException异常。

Person 类只有一个有参数的构造器,没有无参数的构造器,而且该构造器内有一个普通的打印语句。当反序列化读取 Java 对象时,并没有看到程序调用该构造器,这表明反序列化机制无须通过构造器来初始化 Java 对象。

提示

ObjectInputStream输入流中的 readObject() 方法声明抛出了 ClassNotFoundException异常。

如果使用序列化机制向文件中写入了多个 Java 对象,使用反序列化机制恢复对象时必须按实际写入的顺序读取。

当一个可序列化类有多个父类时(包括直接父类和间接父类),这些父类要么有无参数的构造器,要么也是可序列化的——否则反序列化时将抛出 InvalidClassException 异常。

如果父类是不可序列化的,只是带有无参数的构造器,则该父类中定义的 Field 值不会序列化到二进制流中。

对象引用的序列化

如果某个类的 Field 类型不是基本类型或 String 类型,而是另一个引用类型,那么这个引用类必须是可序列化的,否则拥有该类型的 Field 的类也是不可序列化的。

如下 Teacher 类持有一个 Person 类的引用,只有 Person 类是可序列化的,Teacher 类才是可序列化的。

@Data
public class Teacher implements java.io.Serializable {
    private String name;
    private Person student;

    public Teacher(String name, Person student) {
        this.name = name;
        this.student = student;
    }
}

现在假设有如下一种特殊情形:程序中有两个 Teacher 对象,它们的 student 实例变量都引用到同一个 Person 对象,而且该 Person 对象还有一个引用变量引用它。如下代码所示:

Person per = new Person("孙悟空", 500);
Teacher t1 = new Teacher("唐僧", per);
Teacher t2 = new Teacher("菩提祖师", per);

这三个对象在内存中的存储示意图如图所示。

epub_681336_2005

这里产生了一个问题——如果先序列化 t1 对象,则系统将该 t1 对象所引用的 Person 对象一起序列化;如果程序再序列化 t2 对象,系统将一样会序列化该 t2 对象,并且将再次序列化该 t2 对象所引用的 Person 对象;如果程序再显式序列化 per 对象,系统将再次序列化该 Person 对象。这个过程似乎会向输出流中输出 3 个 Person 对象。

这违背了 Java 序列化机制的初衷。

Java 序列化机制采用了一种特殊的序列化算法,其算法内容如下。

  1. 所有保存到磁盘中的对象都有一个序列化编号。
  2. 当程序试图序列化一个对象时,程序将先检查该对象是否已经被序列化过,只有该对象从未(在本次虚拟机中)被序列化过,系统才会将该对象转换成字节序列并输出。
  3. 如果某个对象已经序列化过,程序将只是直接输出一个序列化编号,而不是再次重新序列化该对象。

假设有如下顺序的序列化代码:

oos.writeObject(t1);
oos.writeObject(t2);
oos.writeObject(per);

序列化后磁盘文件的存储示意图如图。

epub_681336_2009

当多次调用 writeObject() 方法输出同一个对象时,只有第一次调用 writeObject() 方法时才会将该对象转换成字节序列并输出。

public class WriteTeacher {
    public static void main(String[] args) {
        try (
            // 创建一个 ObjectOutputStream 输出流
            ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("teacher.txt"))) {
            Person per = new Person("孙悟空", 500);
            Teacher t1 = new Teacher("唐僧", per);
            Teacher t2 = new Teacher("菩提祖师", per);
            // 依次将 4 个对象写入输出流
            oos.writeObject(t1);
            oos.writeObject(t2);
            oos.writeObject(per);
            oos.writeObject(t2);
        } catch (IOException ex) {
            ex.printStackTrace();
        }
    }
}

读取序列化文件中的对象。

public class ReadTeacher {
    public static void main(String[] args) {
        try (
            // 创建一个 ObjectInputStream 输入流
            ObjectInputStream ois = new ObjectInputStream(new FileInputStream("teacher.txt"))) {
            // 依次读取 ObjectInputStream 输入流中的 4 个对象
            Teacher t1 = (Teacher)ois.readObject();
            Teacher t2 = (Teacher)ois.readObject();
            Person p = (Person)ois.readObject();
            Teacher t3 = (Teacher)ois.readObject();
            // 输出 true
            System.out.println("t1 的 student 引用和 p 是否相同:" + (t1.getStudent() == p));
            // 输出 true
            System.out.println("t2 的 student 引用和 p 是否相同:" + (t2.getStudent() == p));
            // 输出 true
            System.out.println("t2 和 t3 是否是同一个对象:" + (t2 == t3));
        } catch (Exception ex) {
            ex.printStackTrace();
        }
    }
}

image-20220816172307334

当程序序列化一个可变对象时,只有第一次使用 writeObject() 方法输出时才会将该对象转换成字节序列并输出,当程序再次调用 writeObject() 方法时,程序只是输出前面的序列化编号,即使后面该对象的 Field 值已被改变,改变的 Field 值也不会被输出。

public class SerializeMutable {
    public static void main(String[] args) {
        try (
            // 创建一个 ObjectOutputStream 输出流
            ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("mutable.txt"));
            // 创建一个 ObjectInputStream 输入流
            ObjectInputStream ois = new ObjectInputStream(new FileInputStream("mutable.txt"))) {
            Person per = new Person("孙悟空", 500);
            // 系统将 per 对象转换成字节序列并输出
            oos.writeObject(per);
            // 改变 per 对象的 name Field 的值
            per.setName("猪八戒");
            // 系统只是输出序列化编号,所以改变后的 name 不会被序列化
            oos.writeObject(per);
            Person p1 = (Person)ois.readObject();   //①
            Person p2 = (Person)ois.readObject();   //②
            // 下面输出 true,即反序列化后 p1 等于 p2
            System.out.println(p1 == p2);
            // 下面依然看到输出“孙悟空”,即改变后的 Field 没有被序列化
            System.out.println(p2.getName());
        } catch (Exception ex) {
            ex.printStackTrace();
        }
    }
}

自定义序列化

在一些特殊的场景下,如果一个类里包含的某些 Field 值是敏感信息,例如银行账户信息等,这时不希望系统将该 Field 值进行序列化;或者某个 Field 的类型是不可序列化的,因此不希望对该 Field 进行递归序列化,以避免引发 java.io.NotSerializableException 异常。

通过在 Field 前面使用 transient 关键字修饰,可以指定 Java 序列化时无须理会该 Field。

@Data
public class Person implements Serializable {
    private String name;
    private transient int age;

    // 注意此处没有提供无参数的构造器
    public Person(String name, int age) {
        System.out.println("有参数的构造器");
        this.name = name;
        this.age = age;
    }
}

先序列化一个 Person 对象,然后再反序列化该 Person 对象,得到反序列化的 Person 对象后程序输出该对象的 age Field 值。

public class TransientTest {
    public static void main(String[] args) {
        try (
            // 创建一个 ObjectOutputStream 输出流
            ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("transient.txt"));
            // 创建一个 ObjectInputStream 输入流
            ObjectInputStream ois = new ObjectInputStream(new FileInputStream("transient.txt"))) {
            Person per = new Person("孙悟空", 500);
            // 系统将 per 对象转换成字节序列并输出
            oos.writeObject(per);
            Person p = (Person)ois.readObject();
            System.out.println(p.getAge());
        } catch (Exception ex) {
            ex.printStackTrace();
        }
    }
}

由于本程序中的 Person 类的 age Field 使用 transient 关键字修饰,所以 age 将输出 0。

Java 还提供了一种自定义序列化机制,通过这种自定义序列化机制可以让程序控制如何序列化各 Field,甚至完全不序列化某些 Field(与使用 transient 关键字的效果相同)。

在序列化和反序列化过程中需要特殊处理的类应该提供如下特殊签名的方法,这些特殊的方法用以实现自定义序列化。

  1. private void writeObject(java.io.ObjectOutputStream out)throws IOException;
  2. private void readObject(java.io.ObjectInputStream in)throws IOException, ClassNotFoundException;
  3. private void readObjectNoData()throws ObjectStreamException;

writeObject() 方法负责写入特定类的实例状态,以便相应的 readObject() 方法可以恢复它。通过重写该方法,程序员可以完全获得对序列化机制的控制,可以自主决定哪些 Field 需要序列化,需要怎样序列化。在默认情况下,该方法会调用 out.defaultWriteObject 来保存 Java 对象的各 Field,从而可以实现序列化 Java 对象状态的目的。

readObject() 方法负责从流中读取并恢复对象 Field,通过重写该方法,程序员可以完全获得对反序列化机制的控制,可以自主决定需要反序列化哪些 Field,以及如何进行反序列化。在默认情况下,该方法会调用 in.defaultReadObject 来恢复 Java 对象的非静态和非瞬态 Field。在通常情况下,readObject() 方法与 writeObject() 方法对应,如果 writeObject() 方法中对 Java 对象的 Field 进行了一些处理,则应该在 readObject() 方法中对其 Field 进行相应的反处理,以便正确恢复该对象。

当序列化流不完整时,readObjectNoData() 方法可以用来正确地初始化反序列化的对象。例如,接收方使用的反序列化类的版本不同于发送方,或者接收方版本扩展的类不是发送方版本扩展的类,或者序列化流被篡改时,系统都会调用 readObjectNoData() 方法来初始化反序列化的对象。

@Data
public class Person implements java.io.Serializable {
    private String name;
    private int age;

    // 注意此处没有提供无参数的构造器
    public Person(String name, int age) {
        System.out.println("有参数的构造器");
        this.name = name;
        this.age = age;
    }

    private void writeObject(java.io.ObjectOutputStream out) throws IOException {
        // 将 name Field 值反转后写入二进制流
        out.writeObject(new StringBuffer(name).reverse());
        out.writeInt(age);
    }

    private void readObject(java.io.ObjectInputStream in) throws IOException, ClassNotFoundException {
        // 将读取的字符串反转后赋给 name Field
        this.name = ((StringBuffer)in.readObject()).reverse().toString();
        this.age = in.readInt();
    }
}

还有一种更彻底的自定义机制,它甚至可以在序列化对象时将该对象替换成其他对象。如果需要实现序列化某个对象时替换该对象,则应为序列化类提供如下特殊方法。

ANY-ACCESS-MODIFIER Object writeReplace() throws ObjectStreamException;

writeReplace() 方法将由序列化机制调用,只要该方法存在。因为该方法可以拥有私有(private)、受保护的(protected)和包私有(package-private)等访问权限,所以其子类有可能获得该方法。

@Data
public class Person implements Serializable {
    private String name;
    private int age;

    // 注意此处没有提供无参数的构造器
    public Person(String name, int age) {
        System.out.println("有参数的构造器");
        this.name = name;
        this.age = age;
    }

    // 重写 writeReplace 方法,程序在序列化该对象之前,先调用该方法
    private Object writeReplace() throws ObjectStreamException {
        ArrayList<Object> list = new ArrayList<Object>();
        list.add(name);
        list.add(age);
        return list;
    }
}

如下程序表面上是序列化 Person 对象 ,但实际上序列化的是 ArrayList

public class ReplaceTest {
    public static void main(String[] args) {
        try (
            // 创建一个 ObjectOutputStream 输出流
            ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("replace.txt"));
            // 创建一个 ObjectInputStream 输入流
            ObjectInputStream ois = new ObjectInputStream(new FileInputStream("replace.txt"))) {
            Person per = new Person("孙悟空", 500);
            // 系统将 per 对象转换成字节序列并输出
            oos.writeObject(per);
            // 反序列化读取得到的是 ArrayList
            ArrayList list = (ArrayList)ois.readObject();
            System.out.println(list);
        } catch (Exception ex) {
            ex.printStackTrace();
        }
    }
}

Java 的序列化机制保证在序列化某个对象之前,先调用该对象的 writeReplace() 方法,如果该方法返回另一个 Java 对象,则系统转为序列化另一个对象。

writeReplace() 方法相对的是,序列化机制里还有一个特殊的方法,它可以实现保护性复制整个对象。这个方法就是:

ANY-ACCESS-MODIFIER Object readResolve() throws ObjectStreamException;

这个方法会紧接着 readObject() 之后被调用,该方法的返回值将会代替原来反序列化的对象,而原来 readObject()反序列化的对象将会被立即丢弃。

Orientation 类的构造器私有,程序只有两个 Orientation 对象,分别通过 Orientation 的 HORIZONTALVERTICAL 两个常量来引用。

public class Orientation implements Serializable {
    public static final Orientation HORIZONTAL = new Orientation(1);
    public static final Orientation VERTICAL = new Orientation(2);
    private final int value;

    private Orientation(int value) {
        this.value = value;
    }

    public static void main(String[] args) throws IOException, ClassNotFoundException {

        ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("transient.txt"));
        // 写入 Orientation.HORIZONTAL 值
        oos.writeObject(Orientation.HORIZONTAL);
        // 创建一个 ObjectInputStream 输入流
        ObjectInputStream ois = new ObjectInputStream(new FileInputStream("transient.txt"));
        // 读取刚刚序列化的值
        Orientation ori = (Orientation)ois.readObject();
        System.out.println(Orientation.HORIZONTAL.equals(ori));
    }
}

将一个 Orientation.HORIZONTAL 值序列化后再读出并立即拿 oriOrientation.HORIZONTAL 值进行比较,将会发现返回 false。

在这种情况下,我们可以通过为 Orientation 类提供一个 readResolve() 方法来解决该问题。

public class Orientation implements Serializable {
    public static final Orientation HORIZONTAL = new Orientation(1);
    public static final Orientation VERTICAL = new Orientation(2);
    private final int value;

    private Orientation(int value) {
        this.value = value;
    }

    public static void main(String[] args) throws IOException, ClassNotFoundException {

        ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("transient.txt"));
        // 写入 Orientation.HORIZONTAL 值
        oos.writeObject(Orientation.HORIZONTAL);
        // 创建一个 ObjectInputStream 输入流
        ObjectInputStream ois = new ObjectInputStream(new FileInputStream("transient.txt"));
        // 读取刚刚序列化的值
        Orientation ori = (Orientation)ois.readObject();
        System.out.println(Orientation.HORIZONTAL.equals(ori));
    }

    // 为枚举类增加 readResolve() 方法
    private Object readResolve() throws ObjectStreamException {
        if (value == 1) {
            return HORIZONTAL;
        }
        if (value == 2) {
            return VERTICAL;
        }
        return null;
    }
}

提示

因为反序列化机制在恢复 Java 对象时无须调用构造器来初始化 Java 对象,所以序列化机制可以用来“克隆”对象。

writeReplace() 方法类似的是,readResolve() 方法也可以使用任意的访问控制符,因此父类的 readResolve() 方法可能被其子类继承。

注意

当父类已经实现了 readResolve() 方法后,子类将变得无从下手。如果父类包含一个 protected 或 public 的 readResolve() 方法,而且子类也没有重写该方法,将会使得子类反序列化时得到一个父类的对象,而且也不容易发现这种错误。

总是让子类重写 readResolve() 方法无疑是一个负担,因此对于要被作为父类继承的类而言,实现 readResolve() 方法可能有一些潜在的危险。

通常的建议是,对于 final 类重写 readResolve() 方法不会有任何问题;否则,重写 readResolve() 方法时应尽量使用 private 修饰该方法。

另一种自定义序列化机制

Java 还提供了另一种序列化机制,这种序列化方式完全由程序员决定存储和恢复对象数据。要实现该目标,Java 类必须实现 Externalizable 接口,该接口里定义了如下两个方法。

  1. void readExternal(ObjectInput in):需要序列化的类实现 readExternal() 方法来实现反序列化。该方法调用 DataInput (它是 ObjectInput 的父接口)的方法来恢复基本类型的 Field 值,调用 ObjectInputreadObject() 方法来恢复引用类型的 Field 值。
  2. void writeExternal(ObjectOutput out):需要序列化的类实现 writeExternal() 方法来保存对象的状态。该方法调用 DataOutput (它是 ObjectOutput 的父接口)的方法来保存基本类型的 Field 值,调用 ObjectOutputwriteObject()方法来保存引用类型的 Field 值。

采用实现 Externalizable 接口方式的序列化与前面介绍的自定义序列化非常相似,只是 Externalizable 接口强制自定义序列化。

@Data
public class Person implements java.io.Externalizable {
    private String name;
    private int age;

    // 注意此处没有提供无参数的构造器
    public Person(String name, int age) {
        System.out.println("有参数的构造器");
        this.name = name;
        this.age = age;
    }

    public void writeExternal(java.io.ObjectOutput out) throws IOException {
        // 将 name Field 值反转后写入二进制流
        out.writeObject(new StringBuffer(name).reverse());
        out.writeInt(age);
    }

    public void readExternal(java.io.ObjectInput in) throws IOException, ClassNotFoundException {
        // 将读取的字符串反转后赋给 name Field
        this.name = ((StringBuffer)in.readObject()).reverse().toString();
        this.age = in.readInt();
    }
}

Person 类实现了 java.io.Externalizable 接口,和 readExternal()writeExternal() 两个方法,这两个方法除了方法签名和 readObject()writeObject() 两个方法的方法签名不同之外,其方法体完全一样。

如果程序需要序列化实现 Externalizable 接口的对象,一样调用 ObjectOutputStreamwriteObject() 方法输出该对象即可;反序列化该对象,则调用 ObjectInputStreamreadObject() 方法。

关于两种序列化机制的对比。

epub_681336_2017

由于实现 Externalizable 接口导致了编程复杂度的增加,所以大部分时候都是采用实现 Serializable 接口方式来实现序列化。

关于对象序列化,还有如下几点需要注意。

  1. 对象的类名、Field(包括基本类型、数组、对其他对象的引用)都会被序列化;方法、static Field (即静态 Field)、transientField(也被称为瞬态 Field)都不会被序列化。
  2. 实现 Serializable 接口的类如果需要让某个 Field 不被序列化,则可在该 Field 前加 transient 修饰符,而不是加 static 关键字。虽然 static 关键字也可达到这个效果,但 static 关键字不能这样用。
  3. 保证序列化对象的 Field 类型也是可序列化的,否则需要使用 transient 关键字来修饰该 Field,要不然,该类是不可序列化的。
  4. 反序列化对象时必须有序列化对象的 class 文件。
  5. 当通过文件、网络来读取序列化后的对象时,必须按实际写入的顺序读取。

版本

Java 序列化机制允许为序列化类提供一个 private static finalserialVersionUID 值,该 Field 值用于标识该 Java 类的序列化版本,也就是说,如果一个类升级后,只要它的 serialVersionUID Field 值保持不变,序列化机制也会把它们当成同一个序列化版本。

pubic class Test {
    //为该类指定一个 serialVersionUID Field 值
    private static final long serialVersionUID = 512L;
}

为了在反序列化时确保序列化版本的兼容性,最好在每个要序列化的类中加入 private static final long serialVersionUID 这个 Field。

如果不显示定义 serialVersionUID Field 值,该 Field 值将由 JVM 根据类的相关信息计算,而修改后的类的计算结果与修改前的类的计算结果往往不同,从而造成对象的反序列化因为类版本不兼容而失败。

可以通过 JDK 安装路径的 bin 目录下的 serialver.exe 工具来获得该类的 serialVersionUID Field 值

image-20220816184508254

在运行 serialver 命令时指定-show 选项,还可以启动一个图形用户界面。

image-20220816184631201

image-20220816184727609

不显式指定 serialVersionUID Field 值的另一个坏处是,不利于程序在不同的 JVM 之间移植。因为不同的编译器计算该 Field 值的计算策略可能不同,从而造成虽然类完全没有改变,但是因为 JVM 不同,也会出现序列化版本不兼容而无法正确反序列化的现象。

NIO

前面介绍的输入流、输出流都是阻塞式的输入、输出。不仅如此,传统的输入流、输出流都是通过字节的移动来处理的(即使我们不直接去处理字节流,但底层的实现还是依赖于字节处理),也就是说,面向流的输入/输出系统一次只能处理一个字节,因此面向流的输入/输出系统通常效率不高。

从 JDK 1.4 开始,Java 提供了一系列改进的输入/输出处理的新功能,这些功能被统称为新 IO(New IO,简称 NIO),新增了许多用于处理输入/输出的类,这些类都被放在 java.nio 包以及子包下,并且对原 java.io 包中的很多类都以 NIO 为基础进行了改写,新增了满足 NIO 的功能。

NIO 概述

NIO 采用内存映射文件的方式来处理输入/输出,NIO 将文件或文件的一段区域映射到内存中,这样就可以像访问内存一样来访问文件了(这种方式模拟了操作系统上的虚拟内存的概念),通过这种方式来进行输入/输出比传统的输入/输出要快得多。

NIO 相关的包如下。

  1. java.nio 包:主要包含各种与 Buffer 相关的类。
  2. java.nio.channels 包:主要包含与 Channel 和 Selector 相关的类。
  3. java.nio.charset 包:主要包含与字符集相关的类。
  4. java.nio.channels.spi 包:主要包含与 Channel 相关的服务提供者编程接口。
  5. java.nio.charset.spi 包:包含与字符集相关的服务提供者编程接口。

Channel(通道)和 Buffer(缓冲)是 NIO 中的两个核心对象,Channel 是对传统的输入/输出系统的模拟,在 NIO 系统中所有的数据都需要通过通道传输;Channel 与传统的 InputStreamOutputStream 最大的区别在于它提供了一个 map() 方法,通过该 map() 方法可以直接将“一块数据”映射到内存中。

如果说传统的输入/输出系统是面向流的处理,则新 IO 则是面向块的处理。Buffer 可以被理解成一个容器,它的本质是一个数组,发送到 Channel 中的所有对象都必须首先放到 Buffer 中,而从 Channel 中读取的数据也必须先放到 Buffer 中。

此处的 Buffer 有点类似于前面介绍的“竹筒”,但该 Buffer 既可以像“竹筒”那样一次次去 Channel 中取水,也允许使用 Channel 直接将文件的某块数据映射成 Buffer。除了 Channel 和 Buffer 之外,新 IO 还提供了用于将 Unicode 字符串映射成字节序列以及逆映射操作的 Charset 类,也提供了用于支持非阻塞式输入/输出的 Selector 类。

使用 Buffer

从内部结构上来看,Buffer 就像一个数组,它可以保存多个类型相同的数据。Buffer 是一个抽象类,其最常用的子类是 ByteBuffer ,它可以在底层字节数组上进行 get/set 操作。

除了 ByteBuffer 之外,对应于其他基本数据类型(boolean 除外)都有相应的 Buffer 类:CharBufferShortBufferIntBufferLongBufferFloatBufferDoubleBuffer。除了 ByteBuffer 之外,它们都采用相同或相似的方法来管理数据,只是各自管理的数据类型不同而已。这些 Buffer 类都没有提供构造器,通过使用如下方法来得到一个 Buffer 对象。

  • static XxxBuffer allocate(int capacity):创建一个容量为 capacity 的 XxxBuffer 对象。

使用较多的是 ByteBufferCharBuffer。其中 ByteBuffer 类还有一个子类:MappedByteBuffer,它用于表示 Channel 将磁盘文件的部分或全部内容映射到内存中后得到的结果,通常 MappedByteBuffer 对象由 Channel 的 map() 方法返回。

在 Buffer 中有 3 个重要的概念:容量(capacity)、界限(limit)和位置(position)。

  1. 容量(capacity):缓冲区的容量(capacity)表示该 Buffer 的最大数据容量,即最多可以存储多少数据。缓冲区的容量不可能为负值,创建后不能改变。
  2. 界限(limit):第一个不应该被读出或者写入的缓冲区位置索引。也就是说,位于 limit 后的数据既不可被读,也不可被写。
  3. 位置(position):用于指明下一个可以被读出的或者写入的缓冲区位置索引(类似于 IO 流中的记录指针)。当使用 Buffer 从 Channel 中读取数据时,position 的值恰好等于已经读到了多少数据。当刚刚新建一个 Buffer 对象时,其 position 为 0;如果从 Channel 中读取了 2 个数据到该 Buffer 中,则 position 为 2,指向 Buffer 中第 3 个(第 1 个位置的索引为 0)位置。

除此之外,Buffer 里还支持一个可选的标记(mark,类似于传统 IO 流中的 mark),Buffer 允许直接将 position 定位到该 mark 处。这些值满足如下关系。

0≤mark≤position≤limit≤capacity

某个 Buffer 读入了一些数据后的示意图。

epub_681336_2039

提示

Buffer 中包含两个重要的方法,即 flip()clear()flip() 为从 Buffer 中取出数据做好准备,而 clear() 为再次向 Buffer 中装入数据做好准备。

Buffer 的主要作用就是装入数据,然后输出数据(其作用类似于前面介绍的取水的“竹筒”),开始时 Buffer 的 position 为 0,limit 为 capacity,程序可通过 put() 方法向 Buffer 中放入一些数据(或者从 Channel 中获取一些数据),每放入一些数据,Buffer 的 position 相应地向后移动一些位置。

当 Buffer 装入数据结束后,调用 Buffer 的 flip() 方法,该方法将 limit 设置为 position 所在位置,并将 position 设为 0,这就使得 Buffer 的读写指针又移到了开始位置。

也就是说,Buffer 调用 flip() 方法之后,Buffer 为输出数据做好准备;当 Buffer 输出数据结束后,Buffer 调用 clear() 方法,clear() 方法不是清空 Buffer 的数据,它仅仅将 position 置为 0,将 limit 置为 capacity,这样为再次向 Buffer 中装入数据做好准备。

除此之外,Buffer 还包含如下一些常用的方法。

  1. int capacity():返回 Buffer 的 capacity 大小。
  2. boolean hasRemaining():判断当前位置(position)和界限(limit)之间是否还有元素可供处理。
  3. int limit():返回 Buffer 的界限(limit)的位置。
  4. Buffer limit(int newLt):重新设置界限(limit)的值,并返回一个具有新的 limit 的缓冲区对象。
  5. Buffer mark():设置 Buffer 的 mark 位置,它只能在 0 和位置(position)之间做 mark。
  6. int position():返回 Buffer 中的 position 值。
  7. Buffer position(int newPs):设置 Buffer 的 position,并返回 position 被修改后的 Buffer 对象。
  8. int remaining():返回当前位置和界限(limit)之间的元素个数。
  9. Buffer reset():将位置(position)转到 mark 所在的位置。
  10. Buffer rewind():将位置(position)设置成 0,取消设置的 mark。

Buffer 的所有子类还提供了两个重要的方法:put()get() 方法,用于向 Buffer 中放入数据和从 Buffer 中取出数据。当使用 put()get() 方法放入、取出数据时,Buffer 既支持对单个数据的访问,也支持对批量数据的访问(以数组作为参数)。

使用 put()get() 来访问 Buffer 中的数据时,分为相对和绝对两种。

  1. 相对(Relative):从 Buffer 的当前 position 处开始读取或写入数据,然后将位置(position)的值按处理元素的个数增加。
  2. 绝对(Absolute):直接根据索引向 Buffer 中读取或写入数据,使用绝对方式访问 Buffer 里的数据时,并不会影响位置(position)的值。
public class BufferTest {
    public static void main(String[] args) {
        // 创建 Buffer
        CharBuffer buff = CharBuffer.allocate(8);   //①
        System.out.println("capacity: " + buff.capacity());
        System.out.println("limit: " + buff.limit());
        System.out.println("position: " + buff.position());
        // 放入元素
        buff.put('a');
        buff.put('b');
        buff.put('c');     //②
        System.out.println("加入三个元素后,position=" + buff.position());
        // 调用 flip() 方法
        buff.flip();      //③
        System.out.println("执行 flip() 后,limit=" + buff.limit());
        System.out.println("position=" + buff.position());
        // 取出第一个元素
        System.out.println("第一个元素 (position=0):" + buff.get());   //④
        System.out.println("取出一个元素后,position=" + buff.position());
        // 调用 clear() 方法
        buff.clear();    //⑤
        System.out.println("执行 clear() 后,limit=" + buff.limit());
        System.out.println("执行 clear() 后,position=" + buff.position());
        System.out.println("执行 clear() 后,缓冲区内容并没有被清除:" + "第三个元素为:" + buff.get(2));   // ⑥
        System.out.println("执行绝对读取后,position=" + buff.position());
    }
}

在上面程序的 ① 号代码处,通过 CharBuffer 的一个静态方法 allocate() 创建了一个 capacity 为 8 的 CharBuffer,此时该 Buffer 的 limit 和 capacity 为 8,position 为 0。

epub_681336_2052

接下来程序执行到 ② 号代码处,程序向 CharBuffer 中放入 3 个数值,放入 3 个数值后的 CharBuffer

epub_681336_2053

程序执行到 ③ 号代码处,调用了 Buffer 的 flip() 方法,该方法将把 limit 设为 position 处,把 position 设为 0。

epub_681336_2054

当 Buffer 调用了 flip() 方法之后,limit 就移到了原来 position 所在位置,这样相当于把 Buffer 中没有数据的存储空间“封印”起来,从而避免读取 Buffer 数据时读到 null 值。

接下来程序在 ④ 号代码处取出一个元素,取出一个元素后 position 向后移动一位,也就是该 Buffer 的 position 等于 1。程序执行到 ⑤ 号代码处,Buffer 调用 clear() 方法将 position 设为 0,将 limit 设为与 capacity 相等。

epub_681336_2055

对 Buffer 执行 clear() 方法后,该 Buffer 对象里的数据依然存在,所以程序在 ⑥ 号代码处依然可以取出位置为 2 的值,也就是字符 c。因为 ⑥ 号代码采用的是根据索引来取值的方式,所以该方法不会影响 Buffer 的 position。

使用 Channel

Channel 与传统的流对象有两个主要区别。

  1. Channel 可以直接将指定文件的部分或全部直接映射成 Buffer。
  2. 程序不能直接访问 Channel 中的数据,包括读取、写入都不行,Channel 只能与 Buffer 进行交互。

也就是说,如果要从 Channel 中取得数据,必须先用 Buffer 从 Channel 中取出一些数据,然后让程序从 Buffer 中取出这些数据;如果要将程序中的数据写入 Channel,一样先让程序将数据放入 Buffer 中,程序再将 Buffer 里的数据写入 Channel 中。

Channel 接口提供了 DatagramChannelFileChannelPipe.SinkChannelPipe.SourceChannelSelectableChannelServerSocketChannelSocketChannel等实现类。

NIO 里的 Channel 是按功能来划分的,例如 Pipe.SinkChannelPipe.SourceChannel是用于支持线程之间通信的管道 Channel;ServerSocketChannelSocketChannel是用于支持 TCP 网络通信的 Channel;而 DatagramChannel则是用于支持 UDP 网络通信的 Channel。

所有的 Channel 都不应该通过构造器来直接创建,而是通过传统的节点 InputStreamOutputStreamgetChannel() 方法来返回对应的 Channel,不同的节点流获得的 Channel 不一样。例如,FileInputStreamFileOutputStreamgetChannel() 返回的是 FileChannel,而 PipedInputStreamPipedOutputStreamgetChannel() 返回的是 Pipe.SinkChannelPipe.SourceChannel

Channel 中最常用的 3 类方法是 map()read()write(),其中 map() 方法用于将 Channel 对应的部分或全部数据映射成 ByteBuffer;而 read()write() 方法都有一系列重载形式,这些方法用于从 Buffer 中读取数据或向 Buffer 中写入数据。

map() 方法的方法签名为:MappedByteBuffer map(FileChannel.MapMode mode, long position, long size) ,第一个参数执行映射时的模式,分别有只读、读写等模式;第二个、第三个参数用于控制将 Channel 的哪些数据映射成 ByteBuffer

public class FileChannelTest {
    public static void main(String[] args) {
        File f = new File("test/src/IO 流/FileChannelTest.java");
        try (
            // 创建 FileInputStream,以该文件输入流创建 FileChannel
            FileChannel inChannel = new FileInputStream(f).getChannel();
            // 以文件输出流创建 FileChannel,用以控制输出
            FileChannel outChannel = new FileOutputStream("a.txt").getChannel()) {
            // 将 FileChannel 里的全部数据映射成 ByteBuffer
            MappedByteBuffer buffer = inChannel.map(FileChannel.MapMode.READ_ONLY, 0, f.length());   // ①
            // 使用 UTF-8 的字符集来创建解码器
            Charset charset = StandardCharsets.UTF_8;
            // 直接将 buffer 里的数据全部输出
            outChannel.write(buffer);    // ②
            // 再次调用 buffer 的 clear() 方法,复原 limit、position 的位置
            buffer.clear();
            // 创建解码器(CharsetDecoder)对象
            CharsetDecoder decoder = charset.newDecoder();
            // 使用解码器将 ByteBuffer 转换成 CharBuffer
            CharBuffer charBuffer = decoder.decode(buffer);
            // CharBuffer 的 toString 方法可以获取对应的字符串
            System.out.println(charBuffer);
        } catch (IOException ex) {
            ex.printStackTrace();
        }
    }
}

分别使用 FileInputStreamFileOutputStream 来获取 FileChannel,虽然 FileChannel 既可以读取也可以写入,但 FileInputStream 获取的 FileChannel 只能读,而 FileOutputStream 获取的 FileChannel 只能写。

程序中 ① 号代码处直接将指定 Channel 中的全部数据映射成 ByteBuffer,然后程序中 ② 号代码处直接将整个 ByteBuffer 的全部数据写入一个输出 FileChannel 中,这就完成了文件的复制。

为了能将 FileChannelTest.java 文件里的内容打印出来,使用了 Charset 类和 CharsetDecoder 类将 ByteBuffer 转换成 CharBuffer

不仅 InputStreamOutputStream 包含了 getChannel() 方法,在 RandomAccessFile 中也包含了一个 getChannel() 方法,由 RandomAccessFile 返回的 FileChannel() 是只读的还是读写的 Channel,则取决于 RandomAccessFile 打开文件的模式。

public class RandomFileChannelTest {

  public static void main(String[] args)
      throws IOException {
    File f = new File("a.txt");
    try (
        // 创建一个 RandomAccessFile 对象
        RandomAccessFile raf = new RandomAccessFile(f, "rw");
        // 获取 RandomAccessFile 对应的 Channel
        FileChannel randomChannel = raf.getChannel()) {
      // 将 Channel 中的所有数据映射成 ByteBuffer
      ByteBuffer buffer = randomChannel.map(FileChannel
          .MapMode.READ_ONLY, 0, f.length());
      // 把 Channel 的记录指针移动到最后
      randomChannel.position(f.length());
      // 将 buffer 中的所有数据输出
      randomChannel.write(buffer);
    }
  }
}

randomChannel.position(f.length());可以将 Channel 的记录指针移动到该 Channel 的最后,从而可以让程序将指定 ByteBuffer 的数据追加到该 Channel 的后面。每次运行上面程序,都会把 a.txt 文件的内容复制一份,并将全部内容追加到该文件的后面。

字符集和 Charset

计算机里的文件、数据、图片文件只是一种表面现象,所有文件在底层都是二进制文件,即全部都是字节码。图片、音乐文件暂时先不说,对于文本文件而言,之所以可以看到一个个的字符,这完全是因为系统将底层的二进制序列转换成字符的缘故。在这个过程中涉及两个概念:编码(Encode)和解码(Decode),通常而言,把明文的字符序列转换成计算机理解的二进制序列(普通人看不懂)称为编码,把二进制序列转换成普通人能看懂的明文字符串称为解码。

epub_681336_2060

当需要保存文本文件时,程序必须先把文件中的每个字符翻译成二进制序列;当需要读取文本文件时,程序必须把二进制序列转换为一个个的字符。

Java 默认使用 Unicode 字符集,但很多操作系统并不使用 Unicode 字符集,那么当从系统中读取数据到 Java 程序中时,就可能出现乱码等问题 JDK 1.4 提供了 Charset 来处理字节序列和字符序列(字符串)之间的转换关系,该类包含了用于创建解码器和编码器的方法,还提供了获取 Charset 所支持字符集的方法,Charset 类是不可变的。Charset 类提供了一个 availableCharsets() 静态方法来获取当前 JDK 所支持的所有字符集。所以程序可以使用如下程序来获取该 JDK 所支持的全部字符集。

相关信息

为了解决二进制序列与字符之间的对应关系,这就需要字符集了。关于字符集的介绍,太多书籍介绍得“云里雾里”了。其实很简单,所谓字符集,就是为每个字符编个号码而已。不存在任何的技术难度!任何人都可制定自己独有的字符集,只要为每个字符编个号码即可。

比如将“刚”字编号为 65,这样“刚”字就转换成 01000001;反过来,01000001 也可被恢复成“刚”字。当然,如果每个人都制定自己独有的字符集,那程序就没法交流了——A 程序使用 A 字符集(A 字符集中“刚”字编号为 65),A 程序保存“刚”字时保存的是 01000001;B 程序使用 B 字符集(B 字符集中编号为 65 的可能是其他字符,或者根本没有字符编号 65),那么 B 程序读取 01000001 后,再按 B 字符集恢复出来自然就得到不到“刚”字了。

public class CharsetTest {

  public static void main(String[] args) {
    // 获取 Java 支持的全部字符集
    SortedMap<String, Charset> map = Charset.availableCharsets();
    for (String alias : map.keySet()) {
      // 输出字符集的别名和对应的 Charset 对象
      System.out.println(alias + "----->" + map.get(alias));
    }
  }
}

每个字符集都有一个字符串名称,也被称为字符串别名。对于中国的程序员而言,下面几个字符串别名是常用的。

  1. GBK:简体中文字符集。
  2. BIG5:繁体中文字符集。
  3. ISO-8859-1:ISO 拉丁字母表 No.1,也叫做 ISO-LATIN-1。
  4. UTF-8:8 位 UCS 转换格式。
  5. UTF-16BE:16 位 UCS 转换格式,Big-endian(最低地址存放高位字节)字节顺序。
  6. UTF-16LE:16 位 UCS 转换格式,Little-endian(最高地址存放低位字节)字节顺序。
  7. UTF-16:16 位 UCS 转换格式,字节顺序由可选的字节顺序标记来标识。

相关信息

可以使用 System 类的 getProperties() 方法来访问本地系统的文件编码格式,文件编码格式的属性名为 file.encoding。

知道了字符集的别名之后,程序就可以调用 Charset 的 forName() 方法来创建对应的 Charset 对象,forName() 方法的参数就是相应字符集的别名。

Charset cs = Charset.forName("ISO-8859-1");
Charset csCn = Charset.forName("GBK");

获得了 Charset 对象之后,就可以通过该对象的 newDecoder()newEncoder() 这两个方法分别返回 CharsetDecoderCharsetEncoder 对象,代表该 Charset 的解码器和编码器。调用 CharsetDecoderdecode() 方法就可以将 ByteBuffer (字节序列)转换成 CharBuffer(字符序列),调用 CharsetEncoderencode() 方法就可以将 CharBufferString (字符序列)转换成 ByteBuffer(字节序列)。

public class CharsetTransform {

  public static void main(String[] args) throws Exception {
    // 创建简体中文对应的 Charset
    Charset cn = Charset.forName("GBK");
    // 获取 cn 对象对应的编码器和解码器
    CharsetEncoder cnEncoder = cn.newEncoder();
    CharsetDecoder cnDecoder = cn.newDecoder();
    // 创建一个 CharBuffer 对象
    CharBuffer cbuff = CharBuffer.allocate(8);
    cbuff.put('孙');
    cbuff.put('悟');
    cbuff.put('空');
    cbuff.flip();
    // 将 CharBuffer 中的字符序列转换成字节序列
    ByteBuffer bbuff = cnEncoder.encode(cbuff);
    // 循环访问 ByteBuffer 中的每个字节
    for (int i = 0; i < bbuff.capacity(); i++) {
      System.out.print(bbuff.get(i) + " ");
    }
    // 将 ByteBuffer 的数据解码成字符序列
    System.out.println("\n" + cnDecoder.decode(bbuff));
  }
}

Charset 类也提供了如下 3 个方法。

  1. CharBuffer decode(ByteBuffer bb):将 ByteBuffer 中的字节序列转换成字符序列的便捷方法。
  2. ByteBuffer encode(CharBuffer cb):将 CharBuffer 中的字符序列转换成字节序列的便捷方法。
  3. ByteBuffer encode(String str):将 String 中的字符序列转换成字节序列的便捷方法。

NIO.2

Java 7 对原有的 NIO 进行了重大改进,改进主要包括如下两方面的内容。

  • 提供了全面的文件 IO 和文件系统访问支持。
  • 基于异步 Channel 的 IO。

第一个改进表现为 Java7 新增的 java.nio.file 包及各个子包;第二个改进表现为 Java7 在 java.nio.channels 包下增加了多个以 Asynchronous 开头的 Channel 接口和类。

Path、Paths 和 Files 核心 API

早期的 Java 只提供了一个 File 类来访问文件系统,但 File 类的功能比较有限,它不能利用特定文件系统的特性,File 所提供的方法的性能也不高。而且,其大多数方法在出错时仅返回失败,并不会提供异常信息。

NIO.2 为了弥补这种不足,引入了一个 Path 接口,Path 接口代表一个平台无关的平台路径。除此之外,NIO.2 还提供了 Files、Paths 两个工具类,其中 Files 包含了大量静态的工具方法来操作文件;Paths 则包含了两个返回 Path 的静态工厂方法。

public class PathTest {

  public static void main(String[] args)
      throws Exception {
    // 以当前路径来创建 Path 对象
    Path path = Paths.get(".");
    System.out.println("path 里包含的路径数量:"
        + path.getNameCount());
    System.out.println("path 的根路径:" + path.getRoot());
    // 获取 path 对应的绝对路径
    Path absolutePath = path.toAbsolutePath();
    System.out.println(absolutePath);
    // 获取绝对路径的根路径
    System.out.println("absolutePath 的跟路径:"
        + absolutePath.getRoot());
    // 获取绝对路径所包含的路径数量
    System.out.println("absolutePath 里包含的路径数量:"
        + absolutePath.getNameCount());
    System.out.println(absolutePath.getName(2));
    // 以多个 String 来构建 Path 对象
    Path path2 = Paths.get("e:", "publish", "codes");
    System.out.println(path2);
  }
}

Paths 提供了 get(String first, String... more) 方法来获取 Path 对象,Paths 会将给定的多个字符串连缀成路径,比如 Paths.get("e:" ,"publish" , "codes") 就返回 g:\publish\codes 路径。

getNameCount() 方法会返回 Path 路径所包含的路径名的数量,例如 e:\publish\codes 调用该方法就会返回 3。

Files 是一个操作文件的工具类,它提供了大量便捷的工具方法,下面程序简单示范了 Files 类的用法。

public class FilesTest {

  public static void main(String[] args)
      throws Exception {
    // 复制文件
    Files.copy(Paths.get("test/src/IO 流/FilesTest.java")
        , new FileOutputStream("a.txt"));
    // 判断 FilesTest.java 文件是否为隐藏文件
    System.out.println("FilesTest.java 是否为隐藏文件:"
        + Files.isHidden(Paths.get("test/src/IO 流/FilesTest.java")));
    // 一次性读取 FilesTest.java 文件的所有行
    List lines = Files.readAllLines(Paths
        .get("test/src/IO 流/FilesTest.java"), Charset.forName("utf-8"));
    System.out.println(lines);
    // 判断指定文件的大小
    System.out.println("FilesTest.java 的大小为:"
        + Files.size(Paths.get("test/src/IO 流/FilesTest.java")));
    List poem = new ArrayList<>();
    poem.add("水晶潭底银鱼跃");
    poem.add("清徐风中碧竿横");
    // 直接将多个字符串内容写入指定文件中
    Files.write(Paths.get("pome.txt"), poem
        , Charset.forName("gbk"));
    FileStore cStore = Files.getFileStore(Paths.get("C:"));
    // 判断 C 盘的总空间、可用空间
    System.out.println("C: 共有空间:" + cStore.getTotalSpace());
    System.out.println("C: 可用空间:" + cStore.getUsableSpace());
  }
}

使用 FileVisitor 遍历文件和目录

Files 类提供了如下两个方法来遍历文件和子目录。

  1. walkFileTree(Path start, FileVisitor<? super Path> visitor):遍历 start 路径下的所有文件和子目录。
  2. walkFileTree(Path start, Set<FileVisitOption> options, int maxDepth,FileVisitor<? super Path>visitor) :与上一个方法的功能类似。该方法最多遍历 maxDepth 深度的文件。

两个方法都需要 FileVisitor 参数,FileVisitor 代表一个文件访问器,walkFileTree() 方法会自动遍历 start 路径下的所有文件和子目录,遍历文件和子目录都会“触发” FileVisitor 中相应的方法。FileVisitor 中定义了如下 4 个方法。

  1. FileVisitResult postVisitDirectory(T dir, IOException exc):访问子目录之后触发该方法。
  2. FileVisitResult preVisitDirectory(T dir, BasicFileAttributes attrs):访问子目录之前触发该方法。
  3. FileVisitResult visitFile(T file, BasicFileAttributes attrs):访问 file 文件时触发该方法。
  4. FileVisitResult visitFileFailed(T file, IOException exc):访问 file 文件失败时触发该方法。

上面 4 个方法都返回一个 FileVisitResult 对象,它是一个枚举类,代表了访问之后的后续行为。FileVisitResult 定义了如下几种后续行为。

  1. CONTINUE:代表“继续访问”的后续行为。
  2. SKIP_SIBLINGS:代表“继续访问”的后续行为,但不访问该文件或目录的兄弟文件或目录。
  3. SKIP_SUBTREE:代表“继续访问”的后续行为,但不访问该文件或目录的子目录树。
  4. TERMINATE:代表“中止访问”的后续行为。

实际编程时没必要为 FileVisitor 的 4 个方法都提供实现,可以通过继承 SimpleFileVisitorFileVisitor 的实现类)来实现自己的“文件访问器”,这样就根据需要、选择性地重写指定方法了。

public class FileVisitorTest {

  public static void main(String[] args)
      throws Exception {
    // 遍历 g:\publish\codes\15 目录下的所有文件和子目录
    Files.walkFileTree(Paths.get("E:", "Project", "demo", "test", "src", "IO 流")
        , new SimpleFileVisitor<Path>() {
          // 访问文件时触发该方法
          @Override
          public FileVisitResult visitFile(Path file
              , BasicFileAttributes attrs) throws IOException {
            System.out.println("正在访问" + file + "文件");
            // 找到了 FileVisitorTest.java 文件
            if (file.endsWith("FileVisitorTest.java")) {
              System.out.println("--已经找到目标文件--");
              return FileVisitResult.TERMINATE;
            }
            return FileVisitResult.CONTINUE;
          }

          // 开始访问目录时触发该方法
          @Override
          public FileVisitResult preVisitDirectory(Path dir
              , BasicFileAttributes attrs) throws IOException {
            System.out.println("正在访问:" + dir + " 路径");
            return FileVisitResult.CONTINUE;
          }
        });
  }
}

使用 WatchService 监控文件变化

NIO.2 的 Path 类提供了如下一个方法来监听文件系统的变化。

  • register(WatchService watcher, WatchEvent.Kind<?>... events):用 watcher 监听该 path 代表的目录下的文件变化。events 参数指定要监听哪些类型的事件。

在这个方法中 WatchService 代表一个文件系统监听服务,它负责监听 path 代表的目录下的文件变化。一旦使用 register() 方法完成注册之后,接下来就可调用 WatchService 的如下 3 个方法来获取被监听目录的文件变化事件。

  1. WatchKey poll():获取下一个 WatchKey,如果没有 WatchKey 发生就立即返回 null。
  2. WatchKey poll(long timeout, TimeUnit unit):尝试等待 timeout 时间去获取下一个 WatchKey
  3. WatchKey take():获取下一个 WatchKey,如果没有 WatchKey 发生就一直等待。

如果程序需要一直监控,则应该选择使用 take() 方法;如果程序只需要监控指定时间,则可考虑使用 poll() 方法。

public class WatchServiceTest {

  public static void main(String[] args)
      throws Exception {
    // 获取文件系统的WatchService对象
    WatchService watchService = FileSystems.getDefault()
        .newWatchService();
    // 为C:盘根路径注册监听
    Paths.get("C:/").register(watchService
        , StandardWatchEventKinds.ENTRY_CREATE
        , StandardWatchEventKinds.ENTRY_MODIFY
        , StandardWatchEventKinds.ENTRY_DELETE);
    while (true) {
      // 获取下一个文件变化事件
      WatchKey key = watchService.take();   //①
      for (WatchEvent<?> event : key.pollEvents()) {
        System.out.println(event.context() + " 文件发生了 " + event.kind() + "事件!");
      }
      // 重设WatchKey
      boolean valid = key.reset();
      // 如果重设失败,退出监听
      if (!valid) {
        break;
      }
    }
  }
}

运行该程序,然后在 C: 盘下新建一个文件或文件夹,再删除该文件或文件夹,将看到相应输出。

image-20220818193055857

访问文件属性

NIO.2 在 java.nio.file.attribute 包下提供了大量的工具类,通过这些工具类,开发者可以非常简单地读取、修改文件属性。这些工具类主要分为如下两类。

  1. XxxAttributeView:代表某种文件属性的“视图”。
  2. XxxAttributes:代表某种文件属性的“集合”,程序一般通过 XxxAttributeView 对象来获取XxxAttributes

在这些工具类中,FileAttributeView 是其他 XxxAttributeView 的父接口。

  1. AclFileAttributeView:通过 AclFileAttributeView,开发者可以为特定文件设置 ACL(Access ControlList)及文件所有者属性。它的 getAcl() 方法返回 List<AclEntry> 对象,该返回值代表了该文件的权限集。通过 setAcl(List)方法可以修改该文件的 ACL。
  2. BasicFileAttributeView:它可以获取或修改文件的基本属性,包括文件的最后修改时间、最后访问时间、创建时间、大小、是否为目录、是否为符号链接等。它的 readAttributes()方法返回一个 BasicFileAttributes 对象,对文件夹基本属性的修改是通过 BasicFileAttributes 对象完成的。
  3. DosFileAttributeView:它主要用于获取或修改文件 DOS 相关属性,比如文件是否只读、是否隐藏、是否为系统文件、是否是存档文件等。它的 readAttributes() 方法返回一个 DosFileAttributes 对象,对这些属性的修改其实是由 DosFileAttributes 对象来完成的。
  4. FileOwnerAttributeView:它主要用于获取或修改文件的所有者。它的 getOwner() 方法返回一个 UserPrincipal 对象来代表文件所有者;也可调用 setOwner(UserPrincipal owner) 方法来改变文件的所有者。
  5. PosixFileAttributeView:它主要用于获取或修改 POSIX(Portable Operating System Interface of INIX) 属性,它的 readAttributes() 方法返回一个 PosixFileAttributes 对象,该对象可用于获取或修改文件的所有者、组所有者、访问权限信息(就是 UNIX 的 chmod 命令负责干的事情)。这个 View 只在 UNIX、Linux 等系统上有用。
  6. UserDefinedFileAttributeView:它可以让开发者为文件设置一些自定义属性。

以管理员身份运行 idea 再运行以下程序。

public class AttributeViewTest {

  public static void main(String[] args)
      throws Exception {
    // 获取将要操作的文件
    Path testPath = Paths.get("test/src/IO 流/AttributeViewTest.java");
    // 获取访问基本属性的 BasicFileAttributeView
    BasicFileAttributeView basicView = Files.getFileAttributeView(
        testPath, BasicFileAttributeView.class);
    // 获取访问基本属性的 BasicFileAttributes
    BasicFileAttributes basicAttribs = basicView.readAttributes();
    // 访问文件的基本属性
    System.out.println("创建时间:" + new Date(basicAttribs
        .creationTime().toMillis()));
    System.out.println("最后访问时间:" + new Date(basicAttribs
        .lastAccessTime().toMillis()));
    System.out.println("最后修改时间:" + new Date(basicAttribs
        .lastModifiedTime().toMillis()));
    System.out.println("文件大小:" + basicAttribs.size());
    // 获取访问文件属主信息的 FileOwnerAttributeView
    FileOwnerAttributeView ownerView = Files.getFileAttributeView(
        testPath, FileOwnerAttributeView.class);
    // 获取该文件所属的用户
    System.out.println(ownerView.getOwner());
    // 获取系统中 guest 对应的用户
    UserPrincipal user = FileSystems.getDefault()
        .getUserPrincipalLookupService()
        .lookupPrincipalByName("guest");
    // 修改用户
    ownerView.setOwner(user);
    // 获取访问自定义属性的 FileOwnerAttributeView
    UserDefinedFileAttributeView userView = Files.getFileAttributeView(
        testPath, UserDefinedFileAttributeView.class);
    List<String> attrNames = userView.list();
    // 遍历所有的自定义属性
    for (String name : attrNames) {
      ByteBuffer buf = ByteBuffer.allocate(userView.size(name));
      userView.read(name, buf);
      buf.flip();
      String value = Charset.defaultCharset().decode(buf).toString();
      System.out.println(name + "--->" + value);
    }
    // 添加一个自定义属性
    userView.write("发行者", Charset.defaultCharset()
        .encode("疯狂 Java 联盟"));
    // 获取访问 DOS 属性的 DosFileAttributeView
    DosFileAttributeView dosView = Files.getFileAttributeView(testPath
        , DosFileAttributeView.class);
    // 将文件设置隐藏、只读
    dosView.setHidden(true);
    dosView.setReadOnly(true);
  }
}

image-20220818195525742

第一次运行后 AttributeViewTest.java 文件变成隐藏、只读文件,因此第二次运行之前一定要先取消只读属性。

image-20220818195916153

image-20220818195740640