1. 文章
  2. Java 进阶线
  3. 文件、配置与并发

字符流、缓冲流与对象序列化:IO 工具如何组合

更新于 33 分钟阅读 第 10 / 15 站 #java#java-advanced#day10

按字符处理文本,用缓冲与转换流组合 IO,并理解数据格式协议。

配套实验:编码与字节

我的学习 →

Java 进阶线 · 线路目录
本页导读

day10|java.io 包下的常用 API:IO 流(二)

day09 解决的是”字节怎么搬运”和”编码是什么”;本日解决的是”文本怎么按字符读写、性能怎么提上来、对象怎么落到文件里”。主线是一条条可以叠起来的管道:原始流负责真正碰磁盘(FileReader/FileWriter),包装流在它外面加缓冲(BufferedReader/BufferedWriter、字节缓冲流),转换流负责在字节与字符之间按指定编码搭桥(InputStreamReader/OutputStreamWriter),打印流、数据流、序列化流则负责把”数据以什么格式写出去”这件事确定下来。学完后应能回答:字符输出流为什么必须 flush()/close()、readLine() 和 newLine() 多了什么能力、四种复制方式的性能差在哪、乱码该用哪一层解决、transient 起什么作用、ObjectOutputStream 写出的文件为什么不能当普通文本改。

本日 PPT 目录(第 2 页,第 13/18/26/29/31/35/40/47/52/58 页重复出现作为进度锚点):IO 流-字符流 → IO 流-缓冲流(字节缓冲流、字符缓冲流、原始流与缓冲流性能分析)→ IO 流-转换流 → IO 流-打印流 → IO 流-数据流 → IO 流-序列化流 → 补充知识:IO 框架。

三点说明(供复习时核对):

  • 本日共 62 页幻灯片。课件多处”常用方法”页在抽取文本里只剩 [表格] 占位、没有表体,代码示例也以截图形式贴在页面上。本文按课件的题目页与配套示例工程(day10-IO(二)/代码/day10-io-code)还原这些内容,还原处标注”(表格按课件题目与配套示例整理)”。
  • PPT 抽出的 28 张图片全部逐张查看过:2 张是几乎空白的损坏图(day10-s04-04.png 143 字节、day10-s15-15.png 746 字节),其余 26 张是装饰文字图(如”原始流/低级流""8KB""不同编码读取出现乱码的问题”)、卡通人物与表情包,与知识点无关,已按规范舍弃;只有第 59 页的两张 jar/工程截图有信息量,已归档内嵌。这是本日配图偏少的原因。
  • 代码按课件顺序重新拼接并规范缩进。课件里 new FileWriter(..., true) 等写法保留了原样;标”(片段)“的表示 PPT 上本身就是不完整片段。

1. IO 流体系回顾与本日路线

1.1 字符流和字节流的分工

课件第 2 页给了最朴素的选择依据:

  • 字符流:适合读写文本文件内容(读、写)。
  • 字节流:适合复制文件等;不适合读写文本文件。

第 11 页的小结把这句话展开成两行:

  • 字节流适合做一切文件数据的拷贝(音视频、文本);字节流不适合读取中文内容输出。
  • 字符流适合做文本文件的操作(读、写)。

1.2 四层抽象与实现类

课件第 3、8、14、19、32、37、41、48、53 页反复画同一张体系图,每讲一个专题就往图里加一层。按课件原图整理如下:

层次字节输入字节输出字符输入字符输出
抽象类InputStreamOutputStreamReaderWriter
文件实现类FileInputStreamFileOutputStreamFileReaderFileWriter
缓冲包装类BufferedInputStreamBufferedOutputStreamBufferedReaderBufferedWriter
转换流——InputStreamReaderOutputStreamWriter
打印流—PrintStream—PrintWriter
数据流DataInputStreamDataOutputStream——
对象流ObjectInputStreamObjectOutputStream——

课件把四层分别叫”抽象类""实现类""字节(字符)缓冲输入/输出流""字符输入/输出转换流”。课上强调的两个词:原始流/低级流(真正对接磁盘的 FileXxx)和包装流/处理流(对原始流进行包装,以提高原始流读写数据的性能)。同一支只有一种”真正碰磁盘”的实现,其余都是逐层套上去的。

(补充说明)课件图上写的 FileOutputSteam、OutputStreamWrite 是笔误,正确拼写为 FileOutputStream、OutputStreamWriter。

2. 文件字符输入流 FileReader

2.1 作用与构造

课件第 4 页的定义:以内存为基准,可以把文件中的数据以字符的形式读入到内存中去。 第 4 页配的”磁盘/网络 → 内存 → 程序”示意图说明数据流动方向,以内存为参照,进内存就是”输入”。

构造器(表格按课件题目与配套示例整理):

构造器说明
FileReader(String fileName)按文件路径接通源文件
FileReader(File file)按 File 对象接通源文件

2.2 读字符的两个方法

课件第 6 页的提问是”每次读取一个字符,每次读取多个字符的方法是啥?它们各有什么特点?“对应两个 API:

方法返回值说明
public int read()读到的字符编号(如 'a' 得到 97);无数据可读返回 -1每次读一个字符,必须用循环,性能较差
public int read(char[] buffer)本次实际读取的字符个数;结束返回 -1每次读一批字符,注意用返回长度构造字符串

2.3 每次读一个字符

package com.itheima.d1_char_stream;

import java.io.FileReader;
import java.io.Reader;

public class FileReaderDemo1 {
    public static void main(String[] args) throws Exception {
        // 目标:文件字符输入流的使用:每次读取一个字符。
        // 1、创建文件字符输入流管道与源文件接通
        Reader fr = new FileReader("day10-io-code\\src\\dlei01.txt");
        // 2、读取一个字符编号回来,没有字符可读返回 -1: public int read()
        // int c1 = fr.read();
        // System.out.println((char) c1);

        // 3、使用循环解决
        int c;
        while ((c = fr.read()) != -1) {
            char ch = (char) c;
            System.out.print(ch);
        }
        fr.close();

        // 拓展:是否解决了乱码问题:解决了!性能较差,依然是个垃圾代码!
    }
}

课件在这里的结论是两句话:中文乱码问题解决了(前提是代码编码与文件编码一致,见第 7 章),但单字符循环性能依然很差。

2.4 每次读多个字符(截至此刻最好的文本读取方案)

package com.itheima.d1_char_stream;

import java.io.FileReader;
import java.io.Reader;

public class FileReaderDemo2 {
    public static void main(String[] args) {
        // 目标:文件字符输入流的使用:每次读取多个字符。
        try (
                // 1、创建文件字符输入流管道与源文件接通
                Reader fr = new FileReader("day10-io-code\\src\\dlei01.txt");
        ) {
            // 2、定义一个字符数组用于读取多个字符
            char[] buffer = new char[1024];
            int len; // 记住每次读取了多少个字符
            while ((len = fr.read(buffer)) != -1) {
                String rs = new String(buffer, 0, len);
                System.out.print(rs);
            }

            // 拓展:可以避免乱码吗?可以。性能可以吗?可以。
            // 这是截至此刻我们学过的读取文本内容最好的方案。
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

注意两点:数组是 char[] 而不是 byte[];new String(buffer, 0, len) 必须带上 len,否则最后一轮会把上一轮的残留字符一起读出来。

(补充说明)FileReader 使用的是平台默认字符集,课件没有提到可以显式传 Charset。跨环境读文件时,更稳妥的做法是把这一层换成 new InputStreamReader(new FileInputStream(file), charset)(见第 8 章)。

3. 文件字符输出流 FileWriter

3.1 作用与构造

课件第 9 页的定义:以内存为基准,把内存中的数据以字符的形式写出到文件中去。

构造器(表格按课件题目与配套示例整理):

构造器说明
FileWriter(String fileName)覆盖原文件内容
FileWriter(String fileName, boolean append)append 为 true 时在原内容后面追加
FileWriter(File file)以 File 对象作为输出目标

第 12 页的提问正好对应这一节的四个重点:怎么创建管道和追加、写数据有哪些方法、怎么换行、写完必须注意什么。

3.2 写字符的五个重载

方法说明
public void write(int c)写一个字符(可以传字符编号)
public void write(String s)写一个字符串
public void write(String s, int pos, int len)写字符串的一部分
public void write(char[] chars)写一个字符数组
public void write(char[] chars, int pos, int len)写字符数组的一部分

3.3 换行、刷新与关闭

  • 换行:课件用的写法是 fw.write("\r\n");字符缓冲输出流有 newLine()(见第 5 章)。
  • 必须刷新或关闭:课件第 11 页原话是”字符输出流写出数据后,必须刷新流,或者关闭流,写出去的数据才能生效”。字符输出流内部有缓冲区,flush() 把缓冲内容推给底层,close() 关闭并包含刷新。
  • 字节流/字符流的使用场景(第 11 页小结,表格按课件题目整理):
目标推荐说明
拷贝任意文件(音视频、文本)字节流字节流适合做一切文件数据的拷贝
读取中文内容输出字符流字节流不适合读取中文内容输出
文本文件的读、写字符流按字符处理,天然处理中文

3.4 完整示例

package com.itheima.d1_char_stream;

import java.io.FileWriter;

public class FileWriteDemo3 {
    public static void main(String[] args) {
        try (
                // 目标:掌握文件字符输出流的使用。
                // 1、创建一个文件字符输出流管道与目标文件接通
                // Writer fw = new FileWriter("day10-io-code/src/dlei02-out.txt"); // 覆盖
                FileWriter fw = new FileWriter("day10-io-code/src/dlei02-out.txt", true); // 追加
        ) {
            // 2、写字符数据出去。
            // a.写一个字符出去 : public void write(int c)
            fw.write(97);
            fw.write('磊');
            fw.write("\r\n"); // 换行

            // b.写一个字符串出去:public void write(String s)
            fw.write("abc我爱你中国666");
            fw.write("\r\n"); // 换行

            // c、写字符串的一部分出去 : public void write(String s, int pos, int len)
            fw.write("abc我爱你中国666", 3, 3);
            fw.write("\r\n"); // 换行

            // d.写一个字符数组出去:public void write(char[] chars)
            char[] chars = "abc我爱你中国666".toCharArray();
            fw.write(chars);
            fw.write("\r\n"); // 换行

            // e、写一个字符数组的一部分出去:public void write(char[] chars, int pos, int len)
            fw.write(chars, 6, 2);
            fw.write("\r\n"); // 换行

            // fw.flush(); // 刷新
            // fw.close(); // 关闭包含刷新(这里交给 try-with-resources)
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

fw.write(97) 写出的是字符 a,不是数字 97 的文本——这一点与后面的打印流正好相反。

4. 缓冲流:给原始流加一个 8KB 的池子

4.1 概念

课件第 13 页引出缓冲流时先给了一组名词:原始流/低级流 与 包装流/处理流——包装流的作用是”对原始流进行包装,以提高原始流读写数据的性能”。第 17 页的提问把要点说全了:

  • 缓冲流有几种:字节缓冲输入流 BufferedInputStream、字节缓冲输出流 BufferedOutputStream、字符缓冲输入流 BufferedReader、字符缓冲输出流 BufferedWriter。
  • 为什么能提高性能:字节缓冲流自带 8KB 缓冲区;可以提高原始字节流、字符流读写数据的性能。
  • 怎么用:把原始流作为构造参数传进去,功能上并无很大变化,性能提升了。
包装类包装的流自带缓冲新增能力
BufferedInputStreamInputStream8KB缓冲读
BufferedOutputStreamOutputStream8KB缓冲写
BufferedReaderReader8K(8192)字符readLine() 按行读取
BufferedWriterWriter8K 字符newLine() 换行

4.2 构造器

public BufferedOutputStream(OutputStream os)
public BufferedInputStream(InputStream is)

4.3 原理:内存与磁盘之间多了一层缓冲池

课件第 15 页用”C 盘 / D 盘 / 内存 / 字节数组 / 1KB / 16KB”等标注画了字节缓冲流的作用示意:原始字节流每次只搬 1KB 字节数组,一次一次地访问磁盘;套上缓冲流后,缓冲池(8KB)先在内存里攒够一批再和磁盘交互,从而减少访问磁盘的次数。第 20 页给字符缓冲输入流画的是同一张图,只是单位换成”字符数组 1K / 16K 字符”。

4.4 字节缓冲流示例

package com.itheima.d2_buffer_stream;

import java.io.*;

public class BufferedInputStreamDemo1 {
    public static void main(String[] args) {
        // 目标:使用字节缓冲流提升原始字节流读写数据的性能的。
        try (
                // 1、创建字节输入流管道与源文件接通
                InputStream is = new FileInputStream("E:\\resource\\meinv2.jpg");
                // 使用高级的缓冲字节输入流包装低级的字节输入流
                InputStream bis = new BufferedInputStream(is);

                // 2、创建一个字节输出流管道与目标文件接通
                OutputStream os = new FileOutputStream("E:\\resource\\meinv2-bak2.txt");
                // 使用高级的缓存字节输出流包装低级的字节输出流
                OutputStream bos = new BufferedOutputStream(os);
        ) {
            // 3、准备一个字节数组
            byte[] buffer = new byte[1024];
            // 4、转移数据
            int len;
            while ((len = bis.read(buffer)) != -1) {
                bos.write(buffer, 0, len);
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

5. 字符缓冲流 BufferedReader / BufferedWriter

5.1 BufferedReader

课件第 20、21 页:自带 8K(8192)的字符缓冲池,可以提高字符输入流读取字符数据的性能;除此之外,字符缓冲输入流新增了”按照行读取字符”的功能。

构造器 / 方法说明
public BufferedReader(Reader r)包装一个字符输入流
public String readLine()一次读取一行;读完了返回 null
package com.itheima.d2_buffer_stream;

import java.io.BufferedReader;
import java.io.FileReader;
import java.io.Reader;

public class BufferedReaderDemo2 {
    public static void main(String[] args) {
        // 目标:字符缓冲输入流的使用:提升原始字符输入流的读字符性能,多了按照行读取的能力。
        try (
                // 1、创建文件字符输入流管道与源文件接通
                Reader fr = new FileReader("day10-io-code\\src\\dlei04.txt");
                // 把低级的字符输入流包装成高级的缓存字符输入流。
                BufferedReader br = new BufferedReader(fr);
        ) {
            // 2、也可以按字符数组读取(这里只演示按行读取)
            // char[] buffer = new char[1024];
            // int len;
            // while ((len = br.read(buffer)) != -1) {
            //     System.out.print(new String(buffer, 0, len));
            // }

            // 3、缓存字符输入流多了一个按照行读取内容的功能。(经典代码)
            String line;
            while ((line = br.readLine()) != null) {
                System.out.println(line);
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

readLine() 的判空条件必须是 != null(没有”行号”可读时返回 null),不是 != -1;返回的行内容不含换行符。

5.2 BufferedWriter

课件第 22、23 页:自带 8K 的字符缓冲池,可以提高字符输出流写字符数据的性能;新增了”换行”功能。

构造器 / 方法说明
public BufferedWriter(Writer w)包装一个字符输出流
public void newLine()写一个平台相关的换行符
package com.itheima.d2_buffer_stream;

import java.io.BufferedWriter;
import java.io.FileWriter;
import java.io.Writer;

public class BufferedWriterDemo3 {
    public static void main(String[] args) {
        try (
                // 目标:掌握缓冲字符输出流的使用。
                // 1、创建一个文件字符输出流管道与目标文件接通
                Writer fw = new FileWriter("day10-io-code/src/dlei05-out.txt"); // 覆盖
                BufferedWriter bw = new BufferedWriter(fw);
        ) {
            // 2、写字符数据出去。(write 的五个重载与 FileWriter 完全一致)
            bw.write(97);
            bw.write('磊');
            bw.newLine(); // 换行

            bw.write("abc我爱你中国666");
            bw.newLine();

            bw.write("abc我爱你中国666", 3, 3);
            bw.newLine();

            char[] chars = "abc我爱你中国666".toCharArray();
            bw.write(chars);
            bw.newLine();

            bw.write(chars, 6, 2);
            bw.newLine();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

第 24 页的自查结论可以背下来:字符缓冲流自带 8K 缓冲区,可以提高原始字符流读写数据的性能;BufferedReader 多了 readLine() 按行读取,BufferedWriter 多了 newLine() 换行。

6. 案例:《出师表》按编号恢复顺序

课件第 25 页的需求:把《出师表》的文章顺序进行恢复到一个新文件中。 课件给的分析步骤是六步:

  1. 定义一个缓冲(课件写作”缓存”)字符输入流管道与源文件接通。
  2. 定义一个 List 集合存储读取的每行数据。
  3. 定义一个循环按照行读取数据,存入到 List 集合中去。
  4. 对 List 集合中的每行数据按照首字符编号升序排序。
  5. 定义一个缓冲字符输出管道与目标文件接通。
  6. 遍历 List 集合中的每个元素,用缓冲输出管道写出并换行。

源文件 csb.txt 每行形如 3.侍中、侍郎郭攸之…,编号是乱的(3、8、4、2、1、9、6、7、5)。

package com.itheima.d2_buffer_stream;

import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.FileReader;
import java.io.FileWriter;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

public class Test4 {
    public static void main(String[] args) {
        // 目标:完成出师表案例。
        try (
                // 2、创建缓冲字符输入流与源文件接通
                BufferedReader br = new BufferedReader(new FileReader("day10-io-code\\src\\csb.txt"));
                // 6、创建缓冲字符输出流与目标文件接通
                BufferedWriter bw = new BufferedWriter(new FileWriter("day10-io-code\\src\\csb-new.txt"));
        ) {
            // 1、准备一个集合,存储原文每段落。
            List<String> data = new ArrayList<>();

            // 3、开始按照行读取,并存入到集合中去。
            String line;
            while ((line = br.readLine()) != null) {
                data.add(line);
            }
            System.out.println(data);

            // 4、对每段按照首字符编号进行排序
            Collections.sort(data);
            System.out.println(data);

            // 5、把排序好的集合中的每段文章写出到新文件中去,每行都要换行。
            for (String ln : data) {
                bw.write(ln);
                bw.newLine();
            }

            System.out.println("完成了!");
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

课件说法:第 4 步是”按照首字符编号升序排序”,示例代码用的是 Collections.sort(data)。更准确的表述:这里实际是按整行字符串的自然顺序排序。本例编号只有 1~9 且每行第二位都是 .,所以字符串排序碰巧得到正确的文章顺序;一旦编号出现两位数,"10.…" 会被排到 "2.…" 前面,结果就错了。要真正按编号排序,应先把 . 前面的完整编号解析成整数再比较。

7. 原始流与缓冲流的性能分析(重点)

课件第 27 页的测试用例是”分别使用原始的字节流,以及字节缓冲流复制一个很大视频”,四种写法:

  1. 使用低级的字节流按照一个一个字节的形式复制文件。
  2. 使用低级的字节流按照字节数组的形式复制文件。
  3. 使用高级的缓冲字节流按照一个一个字节的形式复制文件。
  4. 使用高级的缓冲字节流按照字节数组的形式复制文件。
package com.itheima.d2_buffer_stream;

import java.io.*;

public class TimeTest5 {
    public static final String SRC_VIDEO = "D:\\sziitheima1\\day02-面向对象进阶(二)\\视频\\11、接口的综合案例.avi";
    public static final String DEST_PATH = "E:\\resource\\";

    public static void main(String[] args) {
        // 目标:原始流和缓冲流的性能分析。
        // copy01(); // 使用低级的字节流按照一个一个字节的形式复制文件: 简直慢的让人无法忍受,直接淘汰,禁止使用!
        copy02();   // 使用低级的字节流按照字节数组的形式复制文件:还可以,但是相对较慢!
        // copy03(); // 使用高级的缓冲字节流按照一个一个字节的形式复制文件:还是特别慢,不推荐使用!
        copy04();   // 使用高级的缓冲字节流按照字节数组的形式复制文件:极快!推荐使用!
    }

    // 4、使用高级的缓冲字节流按照字节数组的形式复制文件。
    public static void copy04() {
        long start = System.currentTimeMillis();
        try (
                InputStream is = new FileInputStream(SRC_VIDEO);
                InputStream bis = new BufferedInputStream(is);
                OutputStream os = new FileOutputStream(DEST_PATH + "04.avi");
                OutputStream bos = new BufferedOutputStream(os);
        ) {
            byte[] buffer = new byte[32 * 1024];
            int len;
            while ((len = bis.read(buffer)) != -1) {
                bos.write(buffer, 0, len);
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
        long end = System.currentTimeMillis();
        System.out.println("使用高级的缓冲字节流按照字节数组的形式复制文件:" + (end - start) / 1000.0 + "s");
    }

    // 另外三种写法与上面的区别只在"用哪个流"和"怎么读",核心循环如下(片段)。
    // 1、使用低级的字节流按照一个一个字节的形式复制文件(copy01):
    //        int b;
    //        while ((b = is.read()) != -1) {
    //            os.write(b);
    //        }
    // 2、使用低级的字节流按照字节数组的形式复制文件(copy02):
    //        byte[] buffer = new byte[32 * 1024];
    //        int len;
    //        while ((len = is.read(buffer)) != -1) {
    //            os.write(buffer, 0, len);
    //        }
    // 3、使用高级的缓冲字节流按照一个一个字节的形式复制文件(copy03):
    //        int b;
    //        while ((b = bis.read()) != -1) {
    //            bos.write(b);
    //        }
}

四个方法都用 System.currentTimeMillis() 取开始/结束时间打印耗时,只有 copy04 是”缓冲流 + 字节数组”的组合。

课件第 28 页的结论:建议使用字节缓冲输入流、字节缓冲输出流,结合字节数组的方式,目前来看是性能最优的组合。

(补充说明)具体秒数取决于文件大小、磁盘类型和系统缓存。这个排序反映的是”单字节调用会产生海量系统调用”这一结构性问题,而缓冲流把每次调用变成数组搬运;换文件大小或换机器,绝对耗时都会变,但相对顺序基本一致。

8. 转换流:用指定编码搭桥

8.1 引出问题:不同编码读取时会乱码

课件第 30 页给出两条判断:

  • 如果代码编码和被读取的文本文件编码一致,使用字符流读取文本文件时不会出现乱码。
  • 如果不一致,使用字符流读取文本文件时就会出现乱码。

配套示例的注释写得更直接(代码编码 UTF-8):文件编码 UTF-8 时 a我m 正常;文件编码 GBK 时读出来就是乱码。原因和 day09 讲的一样:字节本身没有”编码”属性,字符流必须用一个字符集去解码。

8.2 字符输入转换流 InputStreamReader

课件第 33 页:解决不同编码时,字符流读取文本内容乱码的问题。 解决思路是”先获取文件的原始字节流,再将其按真实的字符集编码转成字符输入流,这样字符输入流中的字符就不乱码了”。

构造器说明
public InputStreamReader(InputStream is)按平台默认字符集解码
public InputStreamReader(InputStream is, String charset)按指定字符集解码,字符流中的字符不乱码
package com.itheima.d3_transfer_stream;

import java.io.*;

public class InputStreamReaderDemo2 {
    public static void main(String[] args) {
        // 目标:字符输入转换流。
        try (
                // 1、得到GBK文件的原始字节输入流
                InputStream is = new FileInputStream("E:\\resource\\abc.txt");
                // 2、通过字符输入转换流把原始字节流按照指定编码转换成字符输入流
                Reader isr = new InputStreamReader(is, "GBK");
                // 3、把字符输入流包装成高级的缓冲字符输入流。
                BufferedReader br = new BufferedReader(isr);
        ) {
            // 4、按照行读取
            String line;
            while ((line = br.readLine()) != null) {
                System.out.println(line);
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

对照实验(d3_transfer_stream/Test1.java):把同一份文件交给 new FileReader(...) 直接读,文件是 GBK 时就会乱码——所以乱码不是”缓冲流没用好”,而是解码用的字符集和文件真实编码不一致。

// 目标:不同编码下,字符流读取文本内容的问题。(片段)
// 代码编码:UTF-8   文件编码:UTF-8   a我m ==> o [ooo] o  不乱码
// 代码编码:UTF-8   文件编码:GBK     a我m ==> o [oo] o   乱码
Reader fr = new FileReader("E:\\resource\\abc.txt");
BufferedReader br = new BufferedReader(fr);

8.3 字符输出转换流 OutputStreamWriter

课件第 36 页先问”调用 String 提供的 getBytes 方法解决?“并给出一行代码:

String data = "我爱你中国abc";
byte[] bytes = data.getBytes("GBK");

这只是”把字符串变成指定编码的字节”这一半;要写出去还得再套一个字节输出流。课件随后给出更完整的方案——使用”字符输出转换流”实现。

课件第 38 页:OutputStreamWriter 的作用是可以控制写出去的字符使用什么字符集编码;解决思路是”获取字节输出流,再按照指定的字符集编码将其转换成字符输出流,以后写出去的字符就会用该字符集编码了”。

构造器说明
public OutputStreamWriter(OutputStream os)按平台默认字符集编码写出
public OutputStreamWriter(OutputStream os, String charset)指定写出去的字符编码
package com.itheima.d3_transfer_stream;

import java.io.*;

public class OutputStreamWriterDemo3 {
    public static void main(String[] args) {
        try (
                // 目标:掌握字符输出转换流:可以指定写出去的字符编码。
                // 1、创建一个文件字节输出流通向目标文件。
                OutputStream os = new FileOutputStream("day10-io-code/src/dlei06-out.txt");
                // 指定写出去的编码是GBK.
                Writer osw = new OutputStreamWriter(os, "GBK");
                // 2、把字符输出流包装成缓冲输出流
                BufferedWriter bw = new BufferedWriter(osw);
        ) {
            bw.write("莫道桑榆晚");
            bw.newLine();
            bw.write("为霞尚满天");
            bw.newLine();
            bw.write("abc666");
            bw.newLine();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

第 39 页的自查结论:public OutputStreamWriter(OutputStream os, String charset) 可以指定编码把字节输出流转换成字符输出流,从而指定写出去的字符编码。

(补充说明)课件用字符串写编码名("GBK")。现代写法更推荐传 Charset 对象或 StandardCharsets.UTF_8,避免拼错的编码名只在运行时才报错。

9. 打印流 PrintStream / PrintWriter

9.1 作用

课件第 42 页:打印流可以实现更方便、更高效的打印数据出去,能实现打印啥出去就是啥出去。

第 41 页的体系图把 PrintStream、PrintWriter 补进 IO 体系:PrintStream 继承自字节输出流 OutputStream,PrintWriter 继承自字符输出流 Writer。

类型父类能写什么典型方法
PrintStreamOutputStream支持写字节数据的方法println(...)、print(...)
PrintWriterWriter支持写字符数据出去println(...)、print(...)

课件第 44、45 页把区别说得很清楚:打印数据的功能上一模一样(都是使用方便、性能高效,这是核心优势),区别只在继承关系——PrintStream 继承自字节输出流,因此支持写字节数据的方法;PrintWriter 继承自字符输出流,因此支持写字符数据出去。

9.2 打印各种类型的数据

package com.itheima.d4_printstream;

import java.io.PrintStream;

public class PrintStreamDemo1 {
    public static void main(String[] args) {
        try (
                // 目标:打印流:方便,高效的写数据出去。
                PrintStream ps = new PrintStream("day10-io-code/src/ps.txt");
                // PrintWriter ps = new PrintWriter("day10-io-code/src/ps.txt");
                // PrintWriter ps = new PrintWriter(new FileWriter("day10-io-code/src/ps.txt", true));
        ) {
            ps.println(666);
            ps.println(97);
            ps.println(97.9);
            ps.println('a');
            ps.println(true);
            ps.println("深圳黑马Java!");
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

打印流可以直接以文件路径为构造参数,不需要自己先建字节输出流。写入文件里的是数据的文本形式(666、97.9、true),这一点与下一章的数据流完全不同。

9.3 输出语句的重定向

课件第 46 页给的场景是”打印流的一种应用:输出语句的重定向”:

PrintStream ps = new PrintStream("文件地址");
System.setOut(ps);
System.out.println("老骥伏枥");
System.out.println("志在千里");
System.out.println("烈士暮年");
System.out.println("壮心不已");

课件说明:可以把输出语句的打印位置改到某个文件中去。 完整示例(追加方式打开,避免每次运行都清空文件):

package com.itheima.d4_printstream;

import java.io.FileOutputStream;
import java.io.PrintStream;

public class PrintStreamDemo2 {
    public static void main(String[] args) throws Exception {
        // 目标:输出语句的重定向。
        System.out.println("红豆生南国");
        System.out.println("春来发几枝");

        PrintStream ps = new PrintStream(new FileOutputStream("day10-io-code/src/ps2.txt", true));
        System.setOut(ps); // 把系统类的打印流改成我的打印流!!

        System.out.println("愿君多采撷");
        System.out.println("此物最相思");
    }
}

前两行仍然打印到控制台,setOut 之后的两行写进了文件——System.out 本身就是一个 PrintStream,只是它的目标被换掉了。

10. 数据流 DataOutputStream / DataInputStream

课件第 49 页:DataOutputStream(数据输出流)允许把数据和其类型一并写出去。第 50 页:DataInputStream(数据输入流)用于读取数据输出流写出去的数据。

类方法(示例用到的)说明
DataOutputStreamwriteByte、writeBoolean、writeInt、writeChar、writeUTF把值和类型一起写出去
DataInputStreamreadByte、readBoolean、readInt、readChar、readUTF按对应方法读回,顺序必须与写出顺序一致
package com.itheima.d5_dataOutputStream;

import java.io.DataOutputStream;
import java.io.FileOutputStream;

public class DataOutputStreamDemo1 {
    public static void main(String[] args) {
        try (
                // 目标:掌握数据输出流写数据出去的特点:可以写数据和其类型,而且后面可以读取出来。
                DataOutputStream dos = new DataOutputStream(new FileOutputStream("day10-io-code/src/data.txt"));
        ) {
            // 2、写数据和其类型出去
            dos.writeByte(97);
            dos.writeBoolean(true);
            dos.writeInt(1000);
            dos.writeChar('a');
            dos.writeUTF("我在北京天安门666");
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}
package com.itheima.d5_dataOutputStream;

import java.io.DataInputStream;
import java.io.FileInputStream;

public class DataInputStreamDemo2 {
    public static void main(String[] args) {
        // 目标:掌握特殊数据输入流读取特殊数据输出流写出去的数据。
        try (
                // 1、创建高级的特殊数据输入流管道包装低级的字节输入流管道。
                DataInputStream dis = new DataInputStream(new FileInputStream("day10-io-code/src/data.txt"));
        ) {
            // 2、开始读取
            byte b = dis.readByte();
            System.out.println(b);

            boolean b1 = dis.readBoolean();
            System.out.println(b1);

            int i = dis.readInt();
            System.out.println(i);

            char c = dis.readChar();
            System.out.println(c);

            String s = dis.readUTF();
            System.out.println(s);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

数据流写出的文件是二进制协议,不能用记事本直接看;读取时类型和顺序必须和写出时完全一致,否则读到的是错位的字节。第 51 页的自查问题就一句:“数据输入流和数据输出流分别是谁,各自的作用是啥?”

11. 对象序列化流 ObjectOutputStream / ObjectInputStream

11.1 概念

课件第 52 页的两句定义:

  • 对象序列化:把 Java 对象写入到文件中去。
  • 对象反序列化:把文件里的 Java 对象读出来。

11.2 序列化

课件第 54 页:ObjectOutputStream(对象字节输出流)可以把 Java 对象进行序列化,把 Java 对象存入到文件中去;方法 public void writeObject(Object obj)。注意:对象如果要参与序列化,必须实现序列化接口(java.io.Serializable)。

package com.itheima.d6_object;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.io.Serializable;

@Data
@NoArgsConstructor
@AllArgsConstructor
// 注意:如果学生对象要参与序列化,必须实现序列化接口
public class Student implements Serializable {
    private String name;
    private int age;
    // transient 修饰的成员变量将不参与序列化
    private transient String passWord;
    private double height;
}
package com.itheima.d6_object;

import java.io.FileOutputStream;
import java.io.ObjectOutputStream;

public class ObjectOutputStreamDemo1 {
    public static void main(String[] args) throws Exception {
        // 目标:完成对象的序列化:把Java对象存储到文件中去
        // 1、准备一个对象。
        Student s = new Student("风驴子", 27, "mazi666", 160);
        // 2、创建对象字节输出流管道与目标文件接通
        ObjectOutputStream oos =
                new ObjectOutputStream(new FileOutputStream("day10-io-code/src/obj.txt"));
        // 3、开始写对象出去
        oos.writeObject(s);
        // 4、关闭资源
        oos.close();
    }
}

Serializable 是一个标记接口,里面没有任何方法,作用只是告诉序列化机制”这个类的对象允许被写出”。否则运行时会抛 NotSerializableException。

11.3 transient

课件写在 Student 上的注释是:transient 修饰的成员变量将不参与序列化。示例里 passWord 被 transient 修饰,反序列化后读到的会是 null(字符串类型默认值)。它适合放密码、临时状态这类”不值得/不应该落到文件里”的字段。

(补充说明)课件只讲了 transient。更完整的边界是:static 字段属于类而不属于对象实例,默认也不参与对象序列化;反序列化要求类在读写两端都存在且兼容。

11.4 反序列化

课件第 55 页:ObjectInputStream(对象字节输入流)可以把 Java 对象进行反序列化,把存储在文件中的 Java 对象读入到内存中来。

package com.itheima.d6_object;

import java.io.FileInputStream;
import java.io.ObjectInputStream;

public class ObjectInputStreamDemo2 {
    public static void main(String[] args) throws Exception {
        // 目标:对象反序列化 使用对象字节输入流管道把文件中的Java对象又读入到内存中来。
        // 1、创建对象字节输入流管道
        ObjectInputStream ois =
                new ObjectInputStream(new FileInputStream("day10-io-code\\src\\obj.txt"));

        // 2、读取对象进来(对象反序列化)
        Student s = (Student) ois.readObject();

        System.out.println(s);

        ois.close();
    }
}

readObject() 返回的是 Object,需要强转成具体类型。

11.5 一次序列化多个对象

课件第 56 页给的办法是:用一个 ArrayList 集合存储多个对象,然后直接对集合进行序列化即可;并特别注明”注意:ArrayList 集合已经实现了序列化接口”。因为集合本身可序列化,只要里面的元素也实现了 Serializable,整体就能一次写出、一次读回。

第 57 页的自查结论:序列化是把对象数据存入到文件中去,用 ObjectOutputStream 的 public void writeObject(Object obj),对象必须实现序列化接口;反序列化是把文件中的 Java 对象恢复到内存中的程序里来,用 ObjectInputStream 的 readObject()。

(补充说明)课件没有提到 serialVersionUID。类结构变化后(例如增删字段)反序列化可能因版本不匹配而失败,工程上通常会显式声明 serialVersionUID;另外,对一个来源不可信的文件做原生反序列化等于执行对方的对象图,练习只应使用自己生成的文件。

12. 补充知识:IO 框架与 Commons-io

12.1 什么是框架

课件第 59 页给了框架的定义与好处:

  • 框架:解决某类问题、编写的一套类、接口等,可以理解成一个半成品,大多框架都是第三方研发的。
  • 好处:在框架的基础上开发,可以得到优秀的软件架构,并能提高开发效率。
  • 形式:一般是把类、接口等编译成 class 形式,再压缩成一个 .jar 结尾的文件发行出去。

本日讲的 IO 框架是 Commons-io(第 61 页):它是 apache 开源基金组织提供的一组有关 IO 操作的小框架,目的是提高 IO 流的开发效率;它”封装了 Java 提供的对文件、数据进行操作的代码,对外提供了更简单的方式来对文件进行操作,对数据进行读写等”。

工程中引入的 commons-io-2.11.0.jar

12.2 引入步骤

课件第 60 页的四步:

  1. 在项目中创建一个文件夹:lib。
  2. 将 commons-io-2.6.jar 文件复制到 lib 文件夹。
  3. 在 jar 文件上点右键,选择 Add as Library -> 点击 OK。
  4. 在类中导包使用。

课件这一页的文字与配图不一致:步骤写的是 commons-io-2.6.jar,而页面上的文件名和配套工程 lib 目录里实际都是 commons-io-2.11.0.jar。以工程实际文件为准,本日使用的是 2.11.0。

解压 commons-io jar 后看到的包与类结构(file、filefilter、function、input、monitor、output、serialization 等包与 CopyUtils、FileDeleteStrategy 等类)

这张截图可以看到框架的”半成品”长什么样:comparator、file、filefilter、function、input、monitor、output、serialization 等包,加上 Charsets、CopyUtils、DirectoryWalker、FileDeleteStrategy 等类——它其实就是把前面那些 InputStream/OutputStream 的组合封装成了更省事的工具方法。

12.3 FileUtils 常用操作

课件第 61 页的表格(按课件题目与配套示例整理):

方法说明
FileUtils.copyFile(File src, File dest)复制文件
FileUtils.copyDirectory(File srcDir, File destDir)复制文件夹(含子目录)
FileUtils.deleteDirectory(File dir)删除文件夹(含内容)
package com.itheima.d7_commons_io;

/**
 * 目标:使用CommonsIO框架进行IO相关的操作。
 */
public class CommonsIOTest1 {
    public static void main(String[] args) throws Exception {
        // FileUtils.copyFile(new File("day10-io-code/src/dlei04.txt"),
        //         new File("day10-io-code/src/dlei04-new.txt"));
        // FileUtils.copyDirectory(new File("E:\\resource"), new File("E:\\resource-dlei-bak"));
        // FileUtils.deleteDirectory(new File("E:\\resource-dlei-bak"));

        // JDK 7开始也新增了单行复制相关的计数。
        Files.copy(Path.of("day10-io-code/src/dlei04.txt"),
                Path.of("day10-io-code/src/dlei04-dlei-666.txt"));
    }
}

最后一行提示:JDK 7 起 java.nio.file.Files 也提供了单行复制,简单场景不必为了复制一个文件引入第三方 jar。

12.4 递归复制文件夹

课件这套框架最典型的用法是”复制文件夹”——文件夹里可能还有文件夹,所以要递归。示例中的关键判断(d8_demo/CopyDirectoryDemo1.java):

package com.itheima.d8_demo;

import org.apache.commons.io.FileUtils;

import java.io.File;

public class CopyDirectoryDemo1 {
    public static void main(String[] args) {
        // 目标:复制文件夹。
        // 源文件夹:E:\resource
        // 目标位置:D:\
        try {
            copyDirectory(new File("E:\\resource"), new File("D:\\"));
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    public static void copyDirectory(File srcDir, File destDir) throws Exception {
        // 1、极端判断:源和目标必须是已存在的目录
        if (srcDir == null || destDir == null || !srcDir.exists() || !destDir.exists()
                || srcDir.isFile() || destDir.isFile()) {
            return;
        }
        // 2、先在目标位置创建和源文件夹同名的目录
        File destNewDir = new File(destDir, srcDir.getName());
        destNewDir.mkdirs();
        // 3、拿到一级文件对象
        File[] files = srcDir.listFiles();
        if (files == null || files.length == 0) return;
        // 4、遍历一级文件对象:是文件就复制,是文件夹就递归
        for (File file : files) {
            if (file.isFile()) {
                FileUtils.copyFile(file, new File(destNewDir, file.getName()));
            } else {
                copyDirectory(file, destNewDir);
            }
        }
    }
}

配套工程里还有一个不依赖 Commons-io 的”删除文件夹”版本(d8_demo/DeleteDirDemo2.java),它把 day08 的递归套路又走了一遍:是文件直接删,是空文件夹直接删,listFiles() 返回 null(无权限)时不删,否则先递归删子项、最后删自己。这两个例子一起说明:框架省掉的是重复代码,不改变”目录遍历 + 递归”这套逻辑本身。

13. 作业与练习

本日无配套作业文档(day10-IO(二) 目录下只有 PPT/、代码/、类库/,没有作业目录)。

课堂练习以课件与配套工程中的示例为准,建议按顺序各写一遍:

  1. FileReader 每次读一个字符 / 每次读一个字符数组,比较乱码与性能(d1_char_stream)。
  2. FileWriter 覆盖与追加两种模式,把五个 write 重载各用一次(FileWriteDemo3)。
  3. 字节缓冲流 + 字节数组复制文件,再自己计时对比四种复制方式(d2_buffer_stream/TimeTest5)。
  4. 用缓冲字符流完成《出师表》按编号恢复顺序(d2_buffer_stream/Test4,源文件 src/csb.txt)。
  5. 用 InputStreamReader 读 GBK 文件解决乱码;用 OutputStreamWriter 写出 GBK 编码文件。
  6. 用 PrintStream 把 System.out 重定向到文件。
  7. 用 DataOutputStream 写出多种类型,再用 DataInputStream 按同序读回。
  8. 让 Student 实现 Serializable,观察 transient 字段反序列化后的值;再把多个对象放进 ArrayList 一次序列化。
  9. 用 FileUtils 完成文件/文件夹的复制与删除(d7_commons_io、d8_demo)。

需要说明的是,配套工程 d8_demo 下还有 BeerDemo3.java(10 元买啤酒、2 个空瓶换 1 瓶、4 个盖子换 1 瓶的递归练习),课件正文没有对应的讲解页,只当作课堂补充练习看待。

14. 复习要点

  1. 选流:拷贝任意文件用字节流(FileXxx + 缓冲 + 字节数组);读写文本用字符流;不确定编码时用转换流指定字符集。
  2. FileReader:read() 返回字符编号、结束返回 -1;read(char[]) 返回本次读取字符数,构造字符串必须带长度。
  3. FileWriter:写出后必须 flush() 或 close() 才生效;构造器的第二个参数 true 表示追加;五个 write 重载要能对上号。
  4. 缓冲流:四种(字节/字符 × 输入/输出),自带 8KB 缓冲,构造时把原始流包进去;功能不变,性能提升。
  5. 字符缓冲流新增能力:readLine() 按行读到 null 结束(不含换行符);newLine() 换行。
  6. 性能结论:单字节读写最慢(海量系统调用),“缓冲字节流 + 字节数组”是最优组合。
  7. 乱码定位:代码编码与文件编码不一致 → 用 InputStreamReader(is, charset) 按真实编码解码;要控制写出编码用 OutputStreamWriter(os, charset)。
  8. 打印流:PrintStream/PrintWriter 打印功能一致,前者继承 OutputStream、后者继承 Writer;System.setOut(ps) 可以重定向输出。
  9. 数据流:DataOutputStream 写”值 + 类型”,DataInputStream 必须按相同类型、相同顺序读回。
  10. 序列化:对象要实现 Serializable,用 writeObject/readObject;transient 字段不参与序列化;多个对象可以装进 ArrayList 一起序列化。
  11. Commons-io:jar 放进 lib → Add as Library → 导包使用;FileUtils.copyFile/copyDirectory/deleteDirectory 省掉重复代码,逻辑仍是递归遍历目录。