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

字符编码与字节 IO:文件复制为什么会损坏或乱码

更新于 24 分钟阅读 第 9 / 15 站 #java#java-advanced#day09

区分字符与字节,正确处理实际读取长度、资源释放和目录复制。

配套实验:编码与字节

我的学习 →

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

day09|java.io 包下的常用 API:字符集与 IO 流(一)

本日分两块:先补前置知识「字符集」,搞清楚字符、码点、字节之间的关系,以及乱码到底是怎么来的;再进入 java.io 包,学习 IO 流的分类与体系,重点掌握文件字节输入流 FileInputStream、文件字节输出流 FileOutputStream、文件复制案例与两种释放资源的方式。学完后应该能解释「为什么复制图片不会坏、而用字节流读中文会乱」,并能写出符合规范的复制代码。

1. 前置知识:字符集(第 3–13 页)

1.1 字符、码点与二进制

计算机只认识二进制。要把人类读的字符存进计算机,必须给每个字符定一个编号(码点),再按某种规则把编号写成字节,这个过程叫编码;反过来把字节还原成字符叫解码。

PPT 用一串对照说明了这件事:字符 '0'、'1'、'a'、'b'、'}'、'~' …… 各自对应一个十进制码点(48、49、50……97、98、99……125、126、127),码点再写成 8 位二进制就是实际存储的内容。

'0'  →  48  →  00110000
'a'  →  97  →  01100001
'~'  →  126 →  01111110

字符集(Character Set) 就是「字符 → 码点」的对照表;编码方案(编码方式) 则是「码点 → 字节序列」的转换规则。二者经常被统称,但概念上是两层。

1.2 标准 ASCII 字符集(第 6 页)

ASCII(American Standard Code for Information Interchange,美国信息交换标准代码)只包括英文、数字、标点符号等。

  • 使用 1 个字节存储一个字符;
  • 首位是 0,所以总共只能表示 128 个字符;
  • 对美国佬来说完全够用。

课件第 7 页复习这句时写成了「首尾是 0」——课件说法:首尾是 0;更准确的表述:最高位(首位)固定为 0,剩下 7 位表示 0–127。同一份课件的第 6 页写的就是「首位是 0」。

标准 ASCII 字符编码表(字符与十进制、十六进制码点对照)

1.3 GBK 字符集(第 7–8 页)

ASCII 只有 128 个位置,容纳不下汉字,于是各国各搞了一套自己的字符集——PPT 用「巴基斯坦码 / 乌兹别克斯坦码 / 吉尔吉斯斯坦码 / 迪拜码 / 哈萨克斯坦码 / 韩文码 / 岛国码」来调侃这种局面。

GBK(汉字内码扩展规范,国标):

  • 是一个汉字编码字符集,包含了 2 万多个汉字等字符;
  • 一个中文字符编码成两个字节存储;
  • GBK 兼容了 ASCII 字符集——英文、数字仍然只占 1 个字节。

GBK 靠首字节的最高位来区分「这是单字节的 ASCII,还是双字节的汉字」:

  • 单字节字符:0xxxxxxx(首位为 0)
  • 汉字:1xxxxxxx xxxxxxxx(第一个字节首位必须是 1,第二个字节首位也必须是 1)

以 “我a你” 为例(每个 x 是一位二进制):

我        a        你
1xxxxxxx xxxxxxxx  0xxxxxxx  1xxxxxxx xxxxxxxx

因为首字节首位不同,解码时才能一个字节一个字节地切分下去,不会切错。

GBK 规定——汉字的第一个字节的第一位必须是 1

课件说法:英文、数字等在任何国家的字符集中都占 1 个字节。更准确的表述:在兼容 ASCII 的字符集(GBK、UTF-8 等)中英文数字占 1 个字节;在 UTF-16 这类编码中英文字符同样占 2 个字节。(补充说明)

1.4 Unicode 字符集(第 9–10 页)

问题很明显:每个国家一套字符集,同一串字节在不同字符集下是不同内容。Unicode(统一码,也叫万国码) 由国际组织制定,目标是容纳世界上所有文字、符号,给每个字符分配一个全球唯一的码点。

Unicode 本身只是码点表,具体怎么把码点写成字节,是另外的编码方案(UTF-8、UTF-16、UTF-32):

  • 有的方案用 2 个字节表示一个字符:1xxxxxxx xxxxxxxx
  • 有的方案(UTF-32)用 4 个字节表示一个字符:00000000 00000000 1xxxxxxx xxxxxxxx
    • 字母 a 的码点 97 在 4 字节方案下变成 00000000 00000000 00000000 01100001,前 3 个字节全是 0

PPT 的评价很直接:UTF-32 虽然简单,但「占存储空间,通信效率变低!」,太「奢侈」了。

1.5 UTF-8 编码方案(第 11 页)

UTF-8 是 Unicode 字符集的一种编码方案,采取可变长编码,共分四个长度区:1 个字节、2 个字节、3 个字节、4 个字节。

  • 英文字符、数字等只占 1 个字节(兼容标准 ASCII 编码);
  • 汉字字符一般占 3 个字节。

各长度区的前缀规则(PPT 抽取文本中该表格为空,下表按 PPT 正文中出现的 0xxxxxxx、1110xxxx 10xxxxxx 10xxxxxx 等前缀示例还原):

占用字节二进制格式适用字符
1 字节0xxxxxxxASCII 范围的英文、数字、符号
2 字节110xxxxx 10xxxxxx部分拉丁字母、希腊字母等
3 字节1110xxxx 10xxxxxx 10xxxxxx常用汉字(BMP 基本平面)
4 字节11110xxx 10xxxxxx 10xxxxxx 10xxxxxx增补平面字符(部分生僻汉字、emoji 等)

以 “a我m” 为例:

a(97)     我(25105)                     m(109)
01100001  1110xxxx 10xxxxxx 10xxxxxx    01101101
            0110   001000   010001

我 的码点 25105 写成二进制是 110 001000 010001,按 4/6/6 位拆开依次填入三字节模板的 x 位,得到 11100110 10001000 10010001(即 E6 88 91)。

同理,解码时只要看到首字节是 1110 开头,就知道后面还要连续读两个 10 开头的字节。

注意:技术人员在开发时都应该使用 UTF-8 编码!

1.6 乱码是怎么产生的(第 12–13 页)

注意 1:字符编码时使用的字符集,和解码时使用的字符集必须一致,否则会出现乱码。

用 GBK 编码得到 E6 88 91 这样的字节,却用 UTF-8 去解码,UTF-8 会把 E6 当成三字节汉字的起始字节,于是从别的字节里拼出一个错误的字符;更糟的是它继续往下切,后面整段内容都会跟着错位乱掉。

注意 2:英文、数字一般不会乱码,因为很多字符集都兼容 ASCII 编码。 英文数字在 GBK、UTF-8 中都只占 1 个字节,二进制形式完全一样,所以怎么解都对。

三种常见字符集的对比:

字符集汉字英文、数字
ASCII不支持1 个字节
GBK2 个字节1 个字节
UTF-83 个字节1 个字节

本节小结:

  • 英文和数字等在任何国家的字符集中都占 1 个字节;
  • GBK 中一个中文字符占 2 个字节;
  • UTF-8 中一个中文一般占 3 个字节;
  • 编码前与解码时的字符集必须一致,否则会出现字符乱码;
  • 英文和数字在常见字符集中不会乱码。

1.7 用 Java 完成编码与解码(第 14–16 页)

  • 编码:把字符按照指定字符集编码成字节。
  • 解码:把字节按照指定字符集解码成字符。

String 类提供了对应的方法:

功能API说明
编码byte[] getBytes()使用默认编码
编码byte[] getBytes(String charsetName)指定编码
解码String(byte[] bytes)使用默认编码解码
解码String(byte[] bytes, String charsetName)指定编码解码

编码——把字符按照指定字符集编码成字节

解码——把字节按照指定字符集解码成字符

import java.util.Arrays;

public class CharsetDemo {
    public static void main(String[] args) throws Exception {
        String name = "abc中国123";

        // 1、编码:把字符串编码成字节
        byte[] bytes1 = name.getBytes();          // 默认使用平台编码(IDEA 默认 UTF-8)
        System.out.println(bytes1.length);        // 3 + 3*2 + 3 = 12
        System.out.println(Arrays.toString(bytes1));

        byte[] bytes2 = name.getBytes("GBK");     // 指定使用 GBK 编码
        System.out.println(bytes2.length);        // 3 + 2*2 + 3 = 10
        System.out.println(Arrays.toString(bytes2));

        // 2、解码:编码与解码的字符集必须一致
        String rs = new String(bytes2, "GBK");    // 指定使用 GBK 解码
        System.out.println(rs);                   // abc中国123

        String bad = new String(bytes2, "UTF-8"); // 用错字符集解码 → 乱码
        System.out.println(bad);
    }
}

同一个字节数组 bytes2,用 GBK 解码得到 abc中国123,用 UTF-8 解码就是乱码——这就是「注意 1」在代码层面的样子。

补充说明:Java 内部 String 使用 UTF-16 代码单元保存字符,getBytes() 做的是「UTF-16 → 目标编码」的转换,解码 new String(...) 则相反。

2. IO 流概述(第 17–24 页)

2.1 什么是 IO 流(第 18 页)

三个学习切入点:1、认识 IO 流;2、应用场景;3、如何学 IO 流。

  • I 指 Input,称为输入流:负责把数据读到内存中去;
  • O 指 Output,称为输出流:负责写数据出去。

数据在「磁盘 / 网络 / ……」这类外部介质和「内存 / 程序(进程)」之间流动。输入输出始终以内存(程序)为基准:读文件是输入,写文件是输出。

IO 的数据来源——磁盘、网络等外部介质

2.2 IO 流的应用场景(第 19 页)

IO 就是读写文件数据:

  • 记事本打开 ab.txt,把磁盘上的字节读进程序再显示出来(读文件);
  • 小游戏把「历史最高分 630」写进 score.txt,下次启动再读回来(写文件 + 读文件);
  • 聊天软件把消息发出去、把对方的消息收回来(网络 IO);
  • 复制 / 移动文件(一个流读、另一个流写)。

用记事本打开 ab.txt 查看文本内容

游戏把历史最高分写入 score.txt,下次启动读回

聊天软件的消息收发也是 IO(网络 IO)

2.3 IO 流的分类与体系(第 21–24 页)

怎么学 IO 流?(第 20 页)

  1. 先搞清楚 IO 流的分类、体系;
  2. 再挨个学习每个 IO 流的作用、用法。

两个分类维度:

按流的方向分:输入流 / 输出流。

按流中数据的最小单位分:

类型适合操作的文件典型用途
字节流所有类型的文件(音频、视频、图片、文本都行)数据的转移,如文件复制、转移
字符流只适合操作纯文本文件(txt、java 等)读写文本内容

所以「IO 流总体来看就有四大流」:

方向字节字符
输入(读到内存)字节输入流 InputStream字符输入流 Reader
输出(写出内存)字节输出流 OutputStream字符输出流 Writer

四者的完整定义:

  • 字节输入流:以内存为基准,来自磁盘文件 / 网络中的数据以字节的形式读入到内存中去的流;
  • 字节输出流:以内存为基准,把内存中的数据以字节的形式写出到磁盘文件或者网络中去的流;
  • 字符输入流:以内存为基准,来自磁盘文件 / 网络中的数据以字符的形式读入到内存中去的流;
  • 字符输出流:以内存为基准,把内存中的数据以字符的形式写出到磁盘文件或者网络介质中去的流。

3. IO 流 - 字节流(第 25–46 页)

3.1 体系结构:抽象类与实现类(第 26、40 页)

java.io 包下,四大流都是抽象类,具体功能由实现类提供:

抽象类(四大流)常用实现类
InputStream(字节输入流)FileInputStream
OutputStream(字节输出流)FileOutputStream
Reader(字符输入流)FileReader
Writer(字符输出流)FileWriter

本日先学字节流的两兄弟:FileInputStream 与 FileOutputStream。

3.2 FileInputStream:每次读取一个字节(第 27–30 页)

作用:以内存为基准,可以把磁盘文件中的数据以字节的形式读入到内存中去。

FileInputStream 的构造器(PPT 中该表格在抽取文本里为空,下表按课件代码用到的形式整理):

构造器说明
FileInputStream(String pathname)按路径字符串绑定源文件
FileInputStream(File file)按 File 对象绑定源文件

核心读取方法:

方法说明
public int read()每次读取一个字节返回;没有字节可读了返回 -1
public void close()关闭流,释放资源
import java.io.FileInputStream;
import java.io.InputStream;

public class FileInputStreamDemo1 {
    public static void main(String[] args) throws Exception {
        // 1、创建一个文件字节输入流管道(对象)与源文件接通
        InputStream is = new FileInputStream("day09-charset-io/src/dlei02.txt");

        // 2、每次读取一个字节返回,没有字节可读了返回 -1
        int b;
        while ((b = is.read()) != -1) {
            System.out.print((char) b);
        }

        // 3、流是资源,用完必须关闭
        is.close();
    }
}

每次读取一个字节存在两个问题:

  1. 读取性能极差(一个字节一次系统调用);
  2. 无法避免读取汉字输出乱码——UTF-8 下一个汉字占 3 个字节,按单字节读出后强转 char,等于把一个汉字拆成三段分别显示。

3.3 FileInputStream:每次读取多个字节(第 31–34 页)

改用 read(byte[] buffer):

方法说明
public int read(byte[] buffer)每次读取一个字节数组的字节,返回本次实际读取的字节个数;没有字节可读返回 -1
import java.io.FileInputStream;
import java.io.InputStream;

public class FileInputStreamDemo2 {
    public static void main(String[] args) throws Exception {
        InputStream is = new FileInputStream("day09-charset-io/src/dlei03.txt");

        byte[] buffer = new byte[3];

        int len = is.read(buffer);
        String rs = new String(buffer);              // 不推荐:没用上 len
        System.out.println("读取了几个字节:" + len);
        System.out.println("读取的内容:" + rs);

        int len2 = is.read(buffer);
        String rs2 = new String(buffer, 0, len2);    // 规范:读取多少就转换多少
        System.out.println("读取了几个字节:" + len2);
        System.out.println("读取的内容:" + rs2);

        int len3 = is.read(buffer);                  // 没有字节可读返回 -1
        System.out.println(len3);
        is.close();
    }
}

为什么必须读多少转换多少? 数组是一个反复使用的「桶」:假设文件内容是 abc3mc,

  • 第一桶:buffer = [a, b, c],len = 3;
  • 第二桶:只读到 3、m、c 三个字节,恰好装满;如果第二次只读到 2 个字节,桶里就会残留上一轮的第 3 个字节,若整桶转换就会多输出内容。

用循环改造:

byte[] buffer = new byte[3];
int len;
while ((len = is.read(buffer)) != -1) {
    String rs = new String(buffer, 0, len);   // 读取多少就倒出多少
    System.out.print(rs);
}

优势与问题:

  • 优势:性能得到了提升,读取性能较好;
  • 问题:读取中文字符输出依然无法避免乱码——buffer 大小是固定的,一个 3 字节的汉字可能被切在两次 read 之间,任何一次单独转换都可能错位。

3.4 FileInputStream:一次读取完全部字节(第 35–38 页)

使用字节流读取中文,如何保证输出不乱码? 答案是:一次性读取完全部字节,再做一次整体解码。

方式一:自己定义一个和被读取文件一样大的字节数组。

File f = new File("day09-charset-io/src/dlei05.txt");
long size = f.length();
System.out.println("文件大小:" + size);

InputStream is = new FileInputStream(f);
byte[] buffer = new byte[(int) size];     // 数组长度必须是 int
int len = is.read(buffer);
System.out.println("读取的字节数:" + len);

String rs = new String(buffer);           // 一次整体解码,不会拆散汉字
System.out.println(rs);
is.close();

方式二:Java 官方为 InputStream 提供的方法 readAllBytes(),直接把文件的全部字节读取到一个字节数组中返回。

InputStream is = new FileInputStream("day09-charset-io/src/dlei05.txt");
byte[] buffer = is.readAllBytes();    // 一次性读取完全部字节
String rs = new String(buffer);
System.out.println(rs);
is.close();

问题:如果文件过大,创建的字节数组也会过大,可能引起内存溢出。 所以这套写法只适用于小文件;大文本的读取应该交给后续课程要学的字符流(Reader)。

3.5 FileOutputStream:写字节出去(第 39–43 页)

作用:以内存为基准,把内存中的数据以字节的形式写出到文件中去。

构造器(覆盖 / 追加的区别是本节的考点):

构造器说明
FileOutputStream(String pathname)覆盖管道:目标文件已存在时先清空再写
FileOutputStream(String pathname, boolean append)append 为 true 时是追加管道,在原内容后面接着写

写出数据的方法:

方法说明
public void write(int a)每次写出去一个字节
public void write(byte[] buffer)每次写出去一个字节数组的数据
public void write(byte[] buffer, int pos, int len)写出字节数组的一部分:从 pos 开始,共写出 len 个字节
public void close()关闭流,释放资源
import java.io.FileOutputStream;
import java.io.OutputStream;

public class FileOutputStreamDemo4 {
    public static void main(String[] args) throws Exception {
        // 1、创建一个文件字节输出流管道与目标文件接通
        // OutputStream os = new FileOutputStream("day09-charset-io/src/dlei06-out.txt");        // 覆盖管道
        OutputStream os = new FileOutputStream("day09-charset-io/src/dlei06-out.txt", true);     // 追加管道

        // 2、每次写出去一个字节
        os.write(97);          // 'a'
        os.write('a');
        // os.write('徐');     // 只写出去第一个字节,会乱码!
        os.write("\r\n".getBytes());   // 换行符号

        // 3、每次写一个字节数组的数据出去
        byte[] bytes = "abc我爱你中国".getBytes();
        os.write(bytes);
        os.write("\r\n".getBytes());

        // 4、写一个字节数组的一部分出去
        os.write(bytes, 3, 9);         // 从索引 3 开始,写出 9 个字节
        os.write("\r\n".getBytes());

        // 流是一种资源,用完必须关闭,为了节省系统资源、提升计算机性能
        os.close();
    }
}

三个问答:

  • 如何创建字节输出流管道、如何实现追加?——new FileOutputStream(路径, true);
  • 写数据的方法有哪些?——write(int)、write(byte[])、write(byte[], pos, len);
  • 如何实现写出去的数据换行?——写出换行符的字节:os.write("\r\n".getBytes())。

3.6 案例:文件复制(第 44–46 页)

思路是「一个桶、两个管道」:一个字节数组在 C 盘源文件和 D 盘目标文件之间来回搬运——读多少,就写多少。

文件的复制 / 移动是 IO 最典型的应用场景

import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.InputStream;
import java.io.OutputStream;

public class CopyDemo {
    public static void main(String[] args) {
        try {
            // 1、创建一个文件字节输入流管道与源文件接通
            InputStream is = new FileInputStream("E:\\resource\\aaa.png");
            // 2、创建一个文件字节输出流管道与目标文件接通
            OutputStream os = new FileOutputStream("D:\\resource\\meinv.png");

            // 3、准备一个字节数组(一个桶)
            byte[] buffer = new byte[1024];   // 1KB

            // 4、开始转移数据
            int len;                          // 记录每次读取的字节个数
            while ((len = is.read(buffer)) != -1) {
                // 5、读取多少就写出去多少
                os.write(buffer, 0, len);
            }

            // 6、释放资源
            os.close();
            is.close();
            System.out.println("复制完成!");
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

结论:任何文件的底层都是字节,字节流做复制,是一字不漏地转移完全部字节,只要复制后的文件格式一致就没问题!

也就是说,复制 .png、.mp4 时根本不需要知道它是什么编码——复制只搬字节,不解释字节。这也是「复制文件」和「读出文字」是两个不同目标的原因:前者保持字节完全一致,后者必须按正确的字符集解码。

4. 释放资源的方式(第 47–52 页)

流是一种资源,用完必须关闭。如果中间抛异常,close() 就可能被跳过,所以需要用 try 语句保证释放。PPT 给出两种方案:try-catch-finally 与 try-with-resource。

4.1 try-catch-finally(第 48、50 页)

finally 代码区的特点:无论 try 中的程序是正常执行了,还是出现了异常,最后都一定会执行 finally 区,除非 JVM 终止。

作用:一般用于在程序执行完成后进行资源的释放操作(专业级做法)。

try {
    ...
} catch (IOException e) {
    e.printStackTrace();
} finally {
    ...
}

课件的资源释放标准写法:

InputStream is = null;
OutputStream os = null;
try {
    // 可能出异常的代码 + 业务代码
    is = new FileInputStream("E:\\resource\\aaa.png");
    os = new FileOutputStream("D:\\resource\\meinv.png");
    byte[] buffer = new byte[1024];
    int len;
    while ((len = is.read(buffer)) != -1) {
        os.write(buffer, 0, len);
    }
    System.out.println("复制完成!");
} catch (Exception e) {
    e.printStackTrace();
} finally {
    // 关闭资源!无论上面是否异常都会执行
    try {
        if (os != null) os.close();
    } catch (Exception e) {
        e.printStackTrace();
    }
    try {
        if (is != null) is.close();
    } catch (Exception e) {
        e.printStackTrace();
    }
}

要点:

  • 流变量必须定义在 try 之外并先赋值为 null,否则 finally 里访问不到;
  • 关闭前要判空(创建管道本身就抛异常时,变量还是 null);
  • close() 自己也会抛异常,所以要在 finally 里再套一层 try-catch;
  • 先关闭后创建的资源(这里是 os),再关闭先创建的 is。

代码能跑,但写法臃肿、不优雅——多资源的释放代码会成倍增长。

(补充说明:课件示例代码的注释里还提到「finally 中不要 return 数据,会覆盖前面的 return 数据」,这是 finally 的另一条常见注意事项。)

4.2 try-with-resource(第 51 页)

JDK 7 开始提供了更简单的资源释放方案:

try (定义资源1; 定义资源2; …) {
    可能出现异常的代码;
} catch (异常类名 变量名) {
    异常的处理代码;
}

规则:

  • () 中只能放置资源,否则报错(例如 int age = 12; 会编译报错);
  • 什么是资源? 资源一般指的是最终实现了 AutoCloseable 接口的对象;
  • 该资源使用完毕后,会自动调用其 close() 方法,完成对资源的释放。

看继承关系就明白了:

public abstract class InputStream implements Closeable { }
public abstract class OutputStream implements Closeable, Flushable { }
public interface Closeable extends AutoCloseable { }

Closeable 继承自 AutoCloseable,所以所有 IO 流都是资源。改写后的复制代码:

import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.InputStream;
import java.io.OutputStream;

public class TryWithResourceDemo {
    public static void main(String[] args) {
        // 特点:这里只能放资源,程序执行完毕后自动调用它们的 close 方法关闭
        try (
                InputStream is = new FileInputStream("E:\\resource\\aaa.png");
                OutputStream os = new FileOutputStream("D:\\resource\\meinv.png")
        ) {
            byte[] buffer = new byte[1024];
            int len;
            while ((len = is.read(buffer)) != -1) {
                os.write(buffer, 0, len);
            }
            System.out.println("复制完成!");
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

try() 里也能放自定义的资源,只要实现 AutoCloseable 接口:

class MyConnection implements AutoCloseable {
    private String name;

    public MyConnection(String name) {
        this.name = name;
    }

    @Override
    public void close() throws Exception {
        System.out.println(name + "被自动关闭了~~");
    }
}
try (
        InputStream is = new FileInputStream("E:\\resource\\aaa.png");
        OutputStream os = new FileOutputStream("D:\\resource\\meinv.png");
        MyConnection conn = new MyConnection("数据库1号链接")
) {
    ...
}

运行结束后会自动打印「数据库1号链接被自动关闭了~~」,说明 close() 被框架自动调用了。

5. 拷贝文件夹(第 53 页)

需求:将某个磁盘的文件夹拷贝到另一个文件夹下去,包括文件夹中的全部信息。

分析:

  1. IO 默认不可以拷贝文件夹(FileInputStream 只能绑定文件,不能绑定目录);
  2. 我们需要遍历文件夹:如果是文件则拷贝过去,如果是文件夹则要进行 1:1 创建,继续复制内容。

也就是说,文件夹的复制 = 递归遍历 + 文件字节流复制(递归遍历的写法见 day08)。实现要点:

  • 先定义「复制文件到文件夹」的方法 copyFile2Dir(File file, File dir):用 FileInputStream/FileOutputStream + byte[] 循环读写;
  • 再定义「复制文件夹到文件夹」的方法 copyDir2Dir(File srcDir, File destDir):在目标位置先建同名目录,listFiles() 拿到子项数组,是文件就调 copyFile2Dir,是文件夹就递归调自己。
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;

public class CopyDir {
    /** 1、把文件复制到目标文件夹中 */
    public static void copyFile2Dir(File file, File dir) throws IOException {
        InputStream is = new FileInputStream(file);
        byte[] buffer = new byte[1024 * 10];
        int len;
        File destFile = new File(dir, file.getName());
        OutputStream os = new FileOutputStream(destFile);
        while ((len = is.read(buffer)) != -1) {
            os.write(buffer, 0, len);
        }
        is.close();
        os.close();
    }

    /** 2、把源文件夹整体复制到目标文件夹中 */
    public static void copyDir2Dir(File srcDir, File destDir) throws IOException {
        // 2.1、在目标文件夹中创建源文件夹
        File newDir = new File(destDir, srcDir.getName());
        if (!newDir.exists()) {
            newDir.mkdirs();
        }
        // 2.2、获取源文件夹中所有文件和文件夹对应的 File 对象数组
        File[] files = srcDir.listFiles();
        // 2.3、数组为 null 或没有内容,结束方法
        if (files == null || files.length == 0) {
            return;
        }
        // 2.4、遍历
        for (File file : files) {
            if (file.isFile()) {
                // 2.5、是文件:复制
                copyFile2Dir(file, newDir);
            } else {
                // 2.6、是文件夹:递归
                copyDir2Dir(file, newDir);
            }
        }
    }

    public static void main(String[] args) throws IOException {
        File srcDir = new File("D:/from/day11");
        File destDir = new File("E:/to");
        copyDir2Dir(srcDir, destDir);
        System.out.println("文件夹复制完毕!!!");
    }
}

细节提醒:listFiles() 对空目录返回空数组,路径不是目录或发生 IO 问题时可能返回 null,所以判空和判长度都要做;目标目录不能位于源目录内部,否则递归会不断发现刚复制出来的新目录。(补充说明)

6. 作业与练习

作业文档:day09-字符集、IO流(一)/作业/IO_每日练习.md(三道综合扩展题,核心训练目标是递归调用定义方法)。

题目需求训练目标关键步骤
题目 1从键盘接收两个文件夹路径,把其中一个文件夹(含内容)拷贝到另一个文件夹下(Java 没有直接复制文件夹的方法)递归复制文件夹定义 copyFile2Dir(File, File);定义 copyDir2Dir(File, File):建目录 → listFiles() → 判空 → 遍历 → 文件复制 / 目录递归
题目 2实现文件夹的剪切递归剪切文件夹复用 copyFile2Dir;定义 cutDir2Dir:复制完一个文件就 file.delete(),遍历结束后 srcDir.delete()
题目 3把源文件夹中所有指定类型的文件复制到目标文件夹递归搜索文件夹定义 copyTypeFiles(File, String type, File):用 getName().toLowerCase().endsWith(type.toLowerCase()) 判断类型,目录则递归

三道题共用的「复制文件到文件夹」方法 copyFile2Dir(File file, File dir) 就是本日 3.6 节的写法:FileInputStream 绑源文件、byte[] 当桶、while ((len = is.read(buffer)) != -1) os.write(buffer, 0, len) 循环读、最后 close()。题目 2 在此基础上每复制完一个文件就 file.delete(),遍历结束后 srcDir.delete();题目 3 在此基础上多一个扩展名判断。完整参考答案见作业文档「参考答案」小节。

7. 复习要点

  1. 字符集是「字符 → 码点」的表,编码方案是「码点 → 字节」的规则;编码和解码必须使用同一个字符集,否则乱码。
  2. 标准 ASCII 用 1 个字节存一个字符、首位为 0,共 128 个字符;GBK 中一个汉字占 2 个字节且兼容 ASCII;UTF-8 中一个汉字一般占 3 个字节、英文数字占 1 个字节。
  3. 英文数字一般不会乱码,因为常见字符集都兼容 ASCII;乱码通常从第一个非 ASCII 字符开始,并且后面会整段错位。
  4. Java 中编码用 getBytes() / getBytes(String charsetName),解码用 String(byte[]) / String(byte[], String charsetName)。
  5. IO 流以内存为基准:InputStream/OutputStream 处理字节,Reader/Writer 处理字符;字节流适合所有文件类型(如复制图片、视频),字符流只适合纯文本。
  6. read() 每次读一个字节,返回 -1 表示读完,性能差且读中文必乱码;read(byte[] buffer) 返回本次实际读取的字节数,必须「读取多少就转换多少」,否则会带出上一轮的残留字节。
  7. 想用字节流读中文不乱码,只能一次性读完再整体解码(自定义等长数组,或 Java 官方 readAllBytes());文件过大会内存溢出,大文本应使用字符流。
  8. FileOutputStream 默认是覆盖管道,第二个参数传 true 才是追加;写数据用 write(int)、write(byte[])、write(byte[], pos, len),换行写 "\r\n".getBytes()。
  9. 文件复制是「一个桶、两个管道」:while ((len = is.read(buffer)) != -1) os.write(buffer, 0, len);,字节流复制不解码,所以格式不会坏。
  10. 释放资源两套方案:try-catch-finally(finally 一定会执行,除非 JVM 终止)与 JDK 7 起的 try-with-resource(() 里只能放实现 AutoCloseable 的资源,用完自动 close())。文件夹复制 = 递归遍历 + 文件字节流复制。