1. 文章
  2. Java 进阶线
  3. 数据处理与集合

Stream、File 与递归:数据处理和目录遍历

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

掌握惰性流水线、路径对象与递归的终止条件。

我的学习 →

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

day08|java.io 包下的常用 API:IO 流(File、方法递归、IO 流入门)

本日先把集合/数组的数据处理换成更简洁的 Stream 流水线写法,再进入 java.io 包:用 File 表示文件与文件夹、用方法递归遍历目录树,最后入门 IO 字节流(FileInputStream / FileOutputStream)与资源释放的两种写法。字符集(ASCII、GBK、Unicode、UTF-8)是理解 IO 乱码的前置知识,本日一并介绍。

学完应能:写出 Stream 的获取→处理→收集三段式;用 File 完成创建、判断、获取信息、删除、遍历;按”公式 + 终结点 + 走向终结点”三要素写递归;说明字节流读中文为什么会乱码,以及如何用 try-with-resource 释放资源。

1. Stream 流:更简单地操作集合与数组

1.1 什么是 Stream 流

也叫 Stream 流,是 JDK 8 开始新增的一套 API(java.util.stream.*),可以用于操作集合或者数组的数据。

优势:Stream 流大量结合了 Lambda 语法风格来编程,提供了一种更强大、更简单的方式操作集合或数组中的数据,代码更简洁、可读性更好。

一句话定位:Stream 流是操作集合/数组的手段,集合/数组本身才是开发的目的。

1.2 为什么需要 Stream:一个体验案例

需求:把集合中所有以”张”开头、且是 3 个字的元素存储到一个新集合中。

public class StreamTest1 {
    public static void main(String[] args) {
        List<String> list = new ArrayList<>();
        list.add("张无忌");
        list.add("周芷若");
        list.add("赵敏");
        list.add("张强");
        list.add("张三丰");

        // 传统写法:手动遍历 + 判断 + 添加
        List<String> newList = new ArrayList<>();
        for (String s : list) {
            if (s.startsWith("张") && s.length() == 3) {
                newList.add(s);
            }
        }
        System.out.println(newList);

        // Stream 写法:一行完成"筛选 + 收集"
        List<String> newList2 = list.stream()
                .filter(s -> s.startsWith("张") && s.length() == 3)
                .collect(Collectors.toList());
        System.out.println(newList2); // [张无忌, 张三丰]
    }
}

1.3 Stream 流的使用步骤

步骤做什么说明
① 获取 Stream 流从数据源(集合、数组、…)拿到流Stream 流代表一条流水线,并与数据源建立连接
② 调用方法处理过滤、排序、去重、加工、…调用流水线的各种方法对数据进行处理、计算
③ 获取处理结果遍历、统计、收集收集到一个新集合或数组中返回

复习问答(PPT 原文):

  1. Stream 是什么?有什么作用?结合了什么技术?——简化集合、数组操作的 API,结合了 Lambda 表达式。
  2. Stream 流处理数据的步骤是什么?——先得到集合或数组的 Stream 流,再调用 Stream 流的方法处理数据,最后获取处理的结果。

1.4 获取 Stream 流

数据源获取方式
Collection 集合(List、Set…)default Stream<E> stream()
Map 集合keySet().stream() 键流、values().stream() 值流、entrySet().stream() 键值对流
数组Arrays.stream(数组) 或 Stream.of(数组)
零散数据Stream.of(T... values)
public class StreamTest2 {
    public static void main(String[] args) {
        // 1、获取集合的 Stream 流
        Collection<String> list = new ArrayList<>();
        Collections.addAll(list, "张无忌", "周芷若", "赵敏", "张强", "张三丰");
        Stream<String> listS = list.stream();

        // 2、获取 Map 集合的 Stream 流
        Map<String, Integer> map = new HashMap<>();
        Stream<String> keyS = map.keySet().stream();                        // 键流
        Stream<Integer> valueS = map.values().stream();                     // 值流
        Stream<Map.Entry<String, Integer>> entryS = map.entrySet().stream(); // 键值对流

        // 3、获取数组的 Stream 流
        String[] names = {"赵敏", "王菲", "小昭"};
        Stream<String> stream1 = Arrays.stream(names);
        Stream<String> stream2 = Stream.of(names);
    }
}

1.5 中间方法

中间方法指的是调用完成后会返回新的 Stream 流,可以继续使用(支持链式编程)。

方法作用
filter(Predicate)过滤,保留满足条件的元素
sorted() / sorted(Comparator)排序;元素类是自定义对象时,需实现 Comparable 或传入比较器
limit(long n)取前 n 个
skip(long n)跳过前 n 个
distinct()去重(对象去重要求重写 hashCode() 与 equals())
map(Function)加工:把流上的数据加工成新数据
Stream.concat(a, b)合并两个流
public class StreamTest3 {
    public static void main(String[] args) {
        // 1、过滤
        List<String> list = new ArrayList<>();
        Collections.addAll(list, "张无忌", "周芷若", "赵敏", "张强", "张三丰");
        list.stream().filter(s -> s.startsWith("张")).forEach(System.out::println);

        // 2、准备对象集合
        List<Movie> movies = new ArrayList<>();
        movies.add(new Movie("摔跤吧,爸爸", 9.5, "阿米尔汗"));
        movies.add(new Movie("三傻宝莱坞", 8.5, "阿米尔汗2"));
        movies.add(new Movie("三傻宝莱坞", 8.5, "阿米尔汗2"));
        movies.add(new Movie("阿甘正传", 7.5, "汤姆汉克斯"));

        // 排序:Movie 实现 Comparable 后,sorted() 即可按规则排序,否则报错
        movies.stream().sorted().forEach(System.out::println);
        // 也可以传入自定义比较器:按评分降序
        movies.stream().sorted((m1, m2) -> Double.compare(m2.getScore(), m1.getScore()))
                .forEach(System.out::println);

        // 3、limit 取前几个 / 4、skip 跳过前几个
        movies.stream().limit(2).forEach(System.out::println);
        movies.stream().skip(2).forEach(System.out::println);

        // 5、distinct 去重:需要对象类重写 hashCode 和 equals
        movies.stream().distinct().forEach(System.out::println);

        // 6、map 加工:把流上的数据加工成新数据
        movies.stream().map(m -> {
            m.setScore(m.getScore() + 2);
            return m;
        }).forEach(System.out::println);

        // 7、合并流,再统计个数
        Stream<String> s1 = Stream.of("张三", "楚留香", "西门吹牛");
        Stream<String> s2 = Stream.of("李四", "石观音");
        System.out.println(Stream.concat(s1, s2).count());
    }
}

1.6 终结方法

终结方法指的是调用完成后不会返回新 Stream,没法继续使用流了。

方法作用
forEach(Consumer)遍历
count()统计个数,返回 long
max(Comparator) / min(Comparator)取最大值 / 最小值,返回 Optional
public class StreamTest4 {
    public static void main(String[] args) {
        List<Movie> movies = new ArrayList<>();
        movies.add(new Movie("摔跤吧,爸爸", 9.5, "阿米尔汗"));
        movies.add(new Movie("三傻宝莱坞", 8.5, "阿米尔汗2"));
        movies.add(new Movie("阿甘正传", 7.5, "汤姆汉克斯"));

        movies.stream().forEach(System.out::println);                 // 遍历
        System.out.println(movies.stream().skip(2).count());          // 统计

        Movie max = movies.stream()
                .max((m1, m2) -> Double.compare(m1.getScore(), m2.getScore())).get();
        Movie min = movies.stream()
                .min((m1, m2) -> Double.compare(m1.getScore(), m2.getScore())).get();
        System.out.println(max);
        System.out.println(min);
    }
}

1.7 收集 Stream 流

收集就是把 Stream 流操作后的结果转回到集合或者数组中去返回。

目标写法
Listcollect(Collectors.toList())
Setcollect(Collectors.toSet())
Mapcollect(Collectors.toMap(键函数, 值函数))
数组toArray();要指定数组类型可写 toArray(String[]::new)
// 收集到 List
List<String> names = list.stream().filter(s -> s.startsWith("张")).collect(Collectors.toList());
// 收集到 Set(自动去重)
Set<String> names2 = list.stream().filter(s -> s.startsWith("张")).collect(Collectors.toSet());
// 收集到数组
Object[] names3 = list.stream().filter(s -> s.length() == 3).toArray();
// 收集到 Map
Map<String, Double> map = movies.stream().distinct()
        .collect(Collectors.toMap(Movie::getName, Movie::getScore));

注意:同一条件收集到 List 与 Set 结果不同,Set 会去重;Collectors.toMap() 遇到重复键需要按业务指定合并规则,否则可能抛异常。(补充说明)

2. 数据的存储方案:从内存到文件

2.1 内存中的容器,断电就丢

PPT 用一个游戏”历史最高分”的例子引出问题:

存储方式示例特点
变量double money = 9999.5;只能存一个值
数组int[] age = new int[100];定长、同一类型
集合List<Student> students = new ArrayList<>();可变长,但仍在内存中

它们都是内存中的数据容器;它们记住的数据,在断电或程序终止时会丢失。

游戏界面显示历史最高分为 0(内存数据一旦程序结束即丢失)

2.2 文件:硬盘上的持久存储

有些数据想长久保存下来,就要写进文件。文件是非常重要的存储方式,存放在计算机硬盘中;即便断电或者程序终止了,存储在硬盘文件中的数据也不会丢失。

磁盘中的文件与文件夹(code 文件夹、图片、aamb.txt)

2.3 File 与 IO 流的分工

角色职责
File 类代表文件/文件夹这个对象本身:创建、删除、判断类型、获取信息、遍历目录
IO 流读写数据:把磁盘/网络中的数据读进内存,或把内存中的数据写出去

例如”历史最高分”场景:内存中初始为 0,磁盘上有 score.txt(内容 历史最高分=630)。

场景方向结果
程序启动时磁盘 → 内存(输入流)内存中的历史最高分变为 630
分数被刷新后内存 → 磁盘(输出流)把新纪录写回 score.txt

3. File 类:代表文件或文件夹

File 是 java.io 包下的类,File 类的对象用于代表当前操作系统的文件(可以是文件、也可以是文件夹)。它能做:获取文件信息(大小、文件名、修改时间)、创建文件/文件夹、删除文件/文件夹、判断文件类型、遍历文件夹等。

注意:File 类只能对文件本身进行操作,不能读写文件里面存储的数据。

3.1 创建 File 类的对象

构造器说明
File(String pathname)用路径名字符串创建对象
File(String parent, String child)把父路径与子路径拼接起来
File(File parent, String child)父路径是 File 对象时使用

注意:

  • File 对象既可以代表文件、也可以代表文件夹。
  • File 封装的对象仅仅是一个路径名,这个路径可以是存在的,也允许是不存在的。

绝对路径与相对路径

  • 绝对路径:从盘符开始(如 E:\resource\meinv.jpg)。
  • 相对路径:不带盘符,默认直接到当前工程下的目录寻找文件(如 day08-stream-file-charset/src/dlei01.txt)。
// 1、创建 File 对象,代表具体的文件
File f1 = new File("E:/resource/meinv.jpg");     // 正斜杠在 Windows 上同样可用
// File f1 = new File("E:\\resource\\meinv.jpg"); // 反斜杠需要转义
// File f1 = new File("E:" + File.separator + "resource" + File.separator + "meinv.jpg");
System.out.println(f1.length());                 // 返回文件的字节大小

// 2、File 对象可以代表文件,也可以代表文件夹
File f2 = new File("E:/resource");
System.out.println(f2.exists());

// 3、File 对象代表的文件路径可以是不存在的
File f3 = new File("E:/resource/aaa");
System.out.println(f3.mkdir());                  // 创建文件夹(不存在时才会创建)

// 4、绝对路径与相对路径
File f4 = new File("E:/resource/meinv.jpg");                 // 绝对路径
File f5 = new File("day08-stream-file-charset/src/dlei01.txt"); // 相对路径:默认到工程下找
System.out.println(f5.length());

课件说法:相对路径”默认直接到当前工程下的目录寻找文件”。更准确的表述:相对路径是相对进程的当前工作目录解析的,在 IDEA 中默认就是工程的根目录,但换一种运行方式(打包后运行、命令行运行)工作目录可能不同。(补充说明)

3.2 常用方法一:判断文件类型、获取文件信息

方法作用
public boolean exists()判断当前文件对象对应的路径是否存在
public boolean isFile()判断指代的是否是文件
public boolean isDirectory()判断指代的是否是文件夹
public String getName()获取文件名称(包含后缀)
public long length()获取文件大小,返回字节个数
public long lastModified()获取最后修改时间(返回时间毫秒值)
public String getPath()获取创建文件对象时使用的路径
public String getAbsolutePath()获取绝对路径
File f1 = new File("E:/resource");

System.out.println(f1.exists());          // 路径是否存在
System.out.println(f1.isFile());          // 是否是文件
System.out.println(f1.isDirectory());     // 是否是文件夹
System.out.println(f1.getName());         // 名称(含后缀)
System.out.println(f1.length());          // 字节个数
System.out.println(f1.getPath());         // 创建时使用的路径
System.out.println(f1.getAbsolutePath()); // 绝对路径

// 最后修改时间:拿到毫秒值后交给日期格式化
long time = f1.lastModified();
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss EEE a");
System.out.println(sdf.format(time));

文件夹的 length() 不代表里面所有文件大小之和,只能作为”是否为空目录”等粗略判断的依据。(补充说明)

3.3 常用方法二:创建文件、删除文件

方法作用
public boolean createNewFile()创建一个新文件(内容为空),创建成功返回 true
public boolean mkdir()创建一级文件夹
public boolean mkdirs()创建多级文件夹(重点)
public boolean delete()删除文件或空文件夹

注意:delete() 默认只能删除文件和空文件夹,删除后的文件不会进入回收站。

public class FileTest3 {
    public static void main(String[] args) throws Exception {
        // 1、创建一个新文件(内容为空)
        File f = new File("day08-stream-file-charset/src/dlei02.txt");
        System.out.println(f.createNewFile());

        // 2、mkdir 只能创建一级文件夹
        File f2 = new File("E:/resource/aaa2");
        System.out.println(f2.mkdir());

        // 3、mkdirs(重点)可以创建多级文件夹
        File f3 = new File("E:/resource/aaa3/bb/cc/dd/ee/ff/gg");
        System.out.println(f3.mkdirs());

        // 4、delete 删除文件(文件里有内容也一并删除)
        System.out.println(f.delete());

        // 5、delete 只能删除空文件夹,非空文件夹删不掉
        File f4 = new File("E:/resource/aaa2");
        System.out.println(f4.delete());
    }
}

复习问答(PPT 原文):创建多级目录用 public boolean mkdirs();删除文件需要注意——可以删除文件、空文件夹,默认不能删除非空文件夹。

3.4 常用方法三:遍历文件夹

方法作用
public String[] list()获取当前目录下所有”一级文件名称”,返回字符串数组
public File[] listFiles()获取当前目录下所有”一级文件对象”,返回 File 数组(常用、重点)

使用 listFiles() 时的注意事项:

  • 当主调是文件,或者路径不存在时,返回 null。
  • 当主调是空文件夹时,返回一个长度为 0 的数组。
  • 当主调是一个有内容的文件夹时,将里面所有一级文件和文件夹的路径放在 File 数组中返回。
  • 当主调是文件夹且里面有隐藏文件时,同样返回,包含隐藏文件。
  • 当主调是文件夹但没有权限访问时,返回 null。
File f = new File("E:/resource");

// 1、list():得到一级文件名称
String[] names = f.list();
for (String name : names) {
    System.out.println(name);
}

// 2、listFiles():得到一级文件对象
File[] files = f.listFiles();
for (File file : files) {
    System.out.println(file.getAbsolutePath());
}

复习问答(PPT 原文):遍历文件夹下的文件对象用 public File[] listFiles()(常用),只能遍历当前文件夹对象下的一级文件对象——要遍历多级,就需要方法递归。

4. 前置知识:方法递归

4.1 什么是方法递归

递归是一种算法,在程序设计语言中广泛应用。从形式上说:方法调用自身的形式称为方法递归(recursion)。

形式说明
直接递归方法自己调用自己
间接递归方法调用其他方法,其他方法又回调方法自己

使用方法递归时需要注意的问题:递归如果没有控制好终止,会出现递归死循环,导致栈内存溢出错误(StackOverflowError)。

public class RecursionTest1 {
    public static void main(String[] args) {
        test1();
    }

    public static void test1() {
        System.out.println("==test1==");
        test1();   // 直接递归:自己调用自己
    }

    public static void test2() {
        System.out.println("==test2==");
        test3();
    }

    public static void test3() {
        System.out.println("==test3==");
        test2();   // 间接递归:test2 → test3 → test2
    }
}

复习问答(PPT 原文):什么是递归——方法调用自己的形式称为方法递归;什么是递归死循环——方法无限调用自己,无法终止,最终引起栈内存溢出。

4.2 案例导学:计算 n 的阶乘

需求:计算 n 的阶乘,5 的阶乘 = 1×2×3×4×5;6 的阶乘 = 1×2×3×4×5×6。

分析:假如存在公式 f(n) = 1*2*3*...*(n-1)*n,那么等价形式就是 f(n) = f(n-1) * n。手工推导:

f(5) = f(4) * 5
f(4) = f(3) * 4
f(3) = f(2) * 3
f(2) = f(1) * 2
f(1) = 1
public class RecursionTest2 {
    public static void main(String[] args) {
        System.out.println("1-5的阶乘:" + f(5));   // 120
    }

    public static int f(int n) {
        if (n == 1) {
            return 1;                 // 终结点
        } else {
            return f(n - 1) * n;      // 递归公式
        }
    }
}

递归求阶乘的执行流程(栈内存视角)

阶段现象
递进main 入栈 → f(5)、f(4)、f(3)、f(2)、f(1) 依次入栈,每一层都停在 n * f(n-1) 处等待返回值
回归f(1) 返回 1 → f(2) 得 2×1 → f(3) 得 3×2 → f(4) 得 4×6 → f(5) 得 5×24 = 120,逐层出栈
输出回到 main,int result = 120,输出”5的阶乘是:120”

4.3 递归算法三要素

  1. 递归的公式:f(n) = f(n-1) * n;
  2. 递归的终结点:f(1)
  3. 递归的方向必须走向终结点:
f(5) = f(4) * 5
f(4) = f(3) * 4
f(3) = f(2) * 3
f(2) = f(1) * 2
f(1) = 1

4.4 递归求 1-n 的和

需求:计算 1-n 的和,使用递归思想解决。

分析:假如 f(n) = 1 + 2 + 3 + ... + (n-1) + n,等价形式为 f(n) = f(n-1) + n,终结点 f(1) = 1。

f(5) = f(4) + 5
f(4) = f(3) + 4
f(3) = f(2) + 3
f(2) = f(1) + 2
f(1) = 1
public class RecursionTest3 {
    public static void main(String[] args) {
        System.out.println("1-5的和:" + f(5));   // 15
    }

    public static int f(int n) {
        if (n == 1) {
            return 1;
        } else {
            return f(n - 1) + n;
        }
    }
}

复习问答(PPT 原文):递归算法三要素大体可以总结为——递归的公式、递归的终结点、递归的方向必须走向终结点。

4.5 案例:猴子吃桃

需求:猴子第一天摘了若干桃子,当即吃了一半,又多吃了一个;第二天又吃了剩余桃子数量的一半,又多吃了一个;以后每天都吃前一天剩余数量的一半再多一个。等到第 10 天时发现桃子只有 1 个了。问猴子第一天摘了多少个桃子?

分析:整体来看每一天都在做同一个事件,是典型的规律化问题,考虑递归三要素。

已知:  f(x) - f(x)/2 - 1 = f(x+1)
变形:  2f(x) - f(x) - 2 = 2f(x+1)
即:    f(x) = 2 * f(x+1) + 2      ← 递归公式
终结点:f(10) = 1
方向:  x 从 1 逐步走向 10

猴子吃桃问题配图

public class RecursionTest4 {
    public static void main(String[] args) {
        // 目标:猴子吃桃问题
        // 公式:f(x) - f(x)/2 - 1 = f(x+1)
        // 变形:f(x) = 2 * f(x + 1) + 2
        System.out.println(f(1));
        System.out.println(f(2));
        System.out.println(f(3));
    }

    public static int f(int x) {
        if (x == 10) {
            return 1;                    // 终结点
        } else {
            return 2 * f(x + 1) + 2;     // 递归公式
        }
    }
}

注意方向:本题是从后往前推(第 10 天 → 第 1 天),所以递进时 x 增大、终结点是 x == 10,不要把时间方向写反。(补充说明)

4.6 案例:文件搜索

需求:从 D 盘中搜索 QQ.exe 这个文件,找到后直接输出其位置。

分析:

  1. 先找出 D 盘下的所有一级文件对象;
  2. 遍历全部一级文件对象,判断是否是文件;
  3. 如果是文件,判断是否是自己想要的;
  4. 如果是文件夹,需要继续进入该文件夹,重复上述过程。

需求场景——在 D 盘中搜索文件

public class FileSearchTest5 {
    public static void main(String[] args) throws Exception {
        File dir = new File("D:/");
        searchFile(dir, "QQ.exe");
    }

    /**
     * 文件搜索
     *
     * @param dir      需要查询的目录
     * @param fileName 需要查询的文件名
     */
    public static void searchFile(File dir, String fileName) throws Exception {
        // 1、极端情况判断:参数非法、目录不存在、传进来的是文件,直接结束
        if (dir == null || fileName == null || !dir.exists() || dir.isFile()) {
            return;
        }

        // 2、拿当前目录下的全部一级文件对象
        File[] files = dir.listFiles();

        // 3、判断是否成功获取到一级文件对象,且个数大于 0
        if (files != null && files.length > 0) {
            // 4、遍历当前每个一级文件对象
            for (File f : files) {
                if (f.isFile()) {
                    // 5、是文件:判断是否就是我们要找的
                    if (f.getName().contains(fileName)) {
                        System.out.println(f.getAbsolutePath());
                        // 找到并启动
                        Runtime r = Runtime.getRuntime();
                        r.exec(f.getAbsolutePath());
                    }
                } else {
                    // 6、是文件夹:递归进入继续寻找
                    searchFile(f, fileName);
                }
            }
        }
    }
}

复习问答(PPT 原文):文件搜索用到了什么技术?——递归,listFiles() 只能搜索到一级文件对象。

4.7 案例:删除文件夹(拓展)

需求:删除非空文件夹。

分析:

  1. File 默认不可以删除非空文件夹;
  2. 我们需要遍历文件夹,先删除里面的内容,再删除自己(后序遍历)。
/* 片段:思路整理,删除动作不可逆,练习时请先用临时目录,并先打印待删除路径 */
public static void deleteDir(File dir) {
    File[] files = dir.listFiles();
    if (files == null || files.length == 0) {
        dir.delete();
        return;
    }
    for (File file : files) {
        if (file.isFile()) {
            file.delete();          // 是文件,直接删
        } else {
            deleteDir(file);        // 是文件夹,递归先删里面
        }
    }
    dir.delete();                   // 最后删自己
}

4.8 案例:啤酒问题

需求:啤酒 2 元 1 瓶,4 个盖子可以换一瓶,2 个空瓶可以换一瓶。请问 10 元钱可以喝多少瓶酒,剩余多少空瓶和盖子?

答案:15 瓶,剩余 3 个盖子、1 个瓶子。

啤酒问题场景配图

思路:维护”已喝瓶数、空瓶数、盖子数”三个状态,反复用「2 空瓶换 1 瓶」「4 盖子换 1 瓶」把空瓶与盖子换成新酒,换来的新酒又会产生新的空瓶与盖子,直到都换不动为止。这类”每次状态转移规则相同”的问题既可以用递归表达,也可以用循环表达(PPT 给出的答案以递推/循环思路为主)。

5. 前置知识:字符集

5.1 编码与解码

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

计算机中,任何数据最终都以二进制(0/1)存储;字符与字节之间的映射规则就是字符集。

记事本打开 ab.txt,状态栏显示 Windows (CRLF)、UTF-8

5.2 标准 ASCII 字符集

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

  • 标准 ASCII 使用 1 个字节存储一个字符,首尾是 0,总共可表示 128 个字符,对美国来说完全够用。

ASCII 字符代码表(0-127 的十进制、二进制与字符对应关系)

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

  • 汉字编码字符集,包含了 2 万多个汉字等字符;
  • GBK 中一个中文字符编码成两个字节的形式存储;
  • 注意:GBK 兼容了 ASCII 字符集;
  • GBK 规定:汉字的第一个字节的第一位必须是 1。
'我'        'a'      '你'
1xxxxxxx  xxxxxxxx  0xxxxxxx  1xxxxxxx  xxxxxxxx

因为 ASCII 的最高位是 0、GBK 汉字第一个字节最高位是 1,解码时才能区分”这是 1 个 ASCII 字符”还是”这是一个汉字的前半部分”。

5.4 Unicode 字符集(统一码,万国码)

Unicode 是国际组织制定的、可以容纳世界上所有文字、符号的字符集。

  • 它解决的是”字符 → 码点”的统一编号问题,具体的”码点 → 字节”方案由不同编码方案(UTF-8、UTF-16、UTF-32 等)负责。
  • 由于各国字符都要收录,编码后的字节数变多,占存储空间、通信效率变低,于是有了可变长的 UTF-8。

5.5 UTF-8

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

字符类型占用字节
英文字符、数字等1 个字节(兼容标准 ASCII 编码)
汉字字符3 个字节

以 a我m 为例:

a  →  97     →  01100001                       (1 字节)
我 →  25105  →  1110xxxx 10xxxxxx 10xxxxxx     (3 字节)
m  →  109    →  01101101                       (1 字节)

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

5.6 乱码:什么时候会出错

  • 注意 1:字符编码时使用的字符集,和解码时使用的字符集必须一致,否则会出现乱码。
  • 注意 2:英文、数字一般不会乱码,因为很多字符集都兼容了 ASCII 编码。
字符集汉字英文、数字
ASCII不支持1 个字节
GBK2 个字节1 个字节
UTF-83 个字节1 个字节

复习问答(PPT 原文):

  1. 常见字符集有哪些,各自存储数据的特点是什么?——英文和数字在任何国家的字符集中都占 1 个字节;GBK 中一个中文字符占 2 个字节;UTF-8 中一个中文一般占 3 个字节。
  2. 编码前的字符集和解码时的字符集有什么要求?——必须一致,否则会出现乱码。
  3. 英文和数字在常见的字符集中是否会出现乱码?为什么?——不会,因为都兼容 ASCII。

5.7 Java 代码完成编码与解码

操作API
编码(字符 → 字节)byte[] getBytes():默认编码;byte[] getBytes(String charsetName):指定编码
解码(字节 → 字符)String(byte[] bytes):使用默认编码解码;String(byte[] bytes, String charsetName):指定编码解码
// 编码
String s = "我a";
byte[] bytes1 = s.getBytes();             // 使用平台默认字符集
byte[] bytes2 = s.getBytes("GBK");        // 指定字符集
System.out.println(Arrays.toString(bytes1));

// 解码:编码与解码字符集不一致会导致乱码
byte[] data = "我".getBytes("GBK");
System.out.println(new String(data));             // 用默认字符集解码 → 乱码
System.out.println(new String(data, "GBK"));      // 用 GBK 解码 → 我

6. IO 流入门

6.1 IO 流概述

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

IO 流的作用:读写文件数据(也可以读写网络中的数据)。谁在读写?程序(进程) 以内存为基准,与磁盘/网络之间搬数据。

思考:

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

6.2 IO 流的分类

按流的方向分:

类型含义
输入流把磁盘/网络中的数据读到内存
输出流把内存中的数据写出去

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

类型适合的场景
字节流适合操作所有类型的文件,比如音频、视频、图片
字符流只适合操作纯文本文件,比如读写 txt、java 文件等

6.3 IO 流的体系:四大流

四大流抽象类文件实现类
字节输入流InputStreamFileInputStream
字节输出流OutputStreamFileOutputStream
字符输入流ReaderFileReader
字符输出流WriterFileWriter

定义:

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

记忆:InputStream / OutputStream / Reader / Writer 是抽象类,都在 java.io 包下;File 系列是它们的实现类,本日先学字节流的两个。

7. 字节流

7.1 FileInputStream:每次读取一个字节

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

构造器说明
FileInputStream(File file)用 File 对象创建管道
FileInputStream(String pathname)用文件路径创建管道
方法说明
int read()每次读取一个字节,读到末尾返回 -1
int read(byte[] buffer)每次读取一个字节数组,返回本次读到的字节个数,读完返回 -1
void close()关闭管道、释放资源
public class FileInputStreamTest1 {
    public static void main(String[] args) throws Exception {
        // 1、创建字节输入流管道,与源文件接通
        InputStream is = new FileInputStream("day08-stream-file-charset/src/dlei01.txt");

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

        // 3、关闭管道、释放资源
        is.close();
    }
}

问题:使用 FileInputStream 每次读取一个字节,读取性能较差,并且读取汉字输出会乱码。

原因:UTF-8 下一个汉字占 3 个字节,一个字节一个字节地转成 char,得到的只是”半个汉字的编码”,自然显示乱码。

7.2 FileInputStream:每次读取多个字节

一次读一个字节数组,性能提升明显;但读汉字仍可能乱码——因为一个汉字的 3 个字节可能被拆到两个缓冲区里。

public class FileInputStreamTest2 {
    public static void main(String[] args) throws Exception {
        InputStream is = new FileInputStream("day08-stream-file-charset/src/dlei01.txt");

        // 每次读取多个字节:定义一个字节数组作为缓冲区
        byte[] buffer = new byte[3];
        int len;   // 记录本次读取到的字节个数
        while ((len = is.read(buffer)) != -1) {
            // 只把本次真正读到的 len 个字节转成字符串
            System.out.print(new String(buffer, 0, len));
        }

        is.close();
    }
}

复习问答(PPT 原文):每次读取一个字节数组的方法是哪个?有什么好处、存在什么问题?——性能得到了提升;读取中文字符输出无法避免乱码问题。

解决乱码的思路:定义一个与文件一样大的字节数组,一次性读取完文件的全部字节。

7.3 FileInputStream:一次读取完全部字节

方式一:自己定义一个与文件大小一样大的字节数组,一次读完。

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

// 方式一:自己定义与文件等大的字节数组
File f = new File("day08-stream-file-charset/src/dlei01.txt");
InputStream is1 = new FileInputStream(f);
byte[] bytes1 = new byte[(int) f.length()];
int len = is1.read(bytes1);
System.out.println(new String(bytes1, 0, len));
is1.close();

// 方式二:使用官方 API 一次性读完全部字节
InputStream is2 = new FileInputStream(f);
byte[] bytes2 = is2.readAllBytes();      // JDK 9 起提供
System.out.println(new String(bytes2));  // 用默认字符集解码
is2.close();

复习问答(PPT 原文):直接把文件数据全部读取到一个字节数组可以避免乱码,是否存在问题?——如果文件过大,创建的字节数组也会过大,可能引起内存溢出。

7.4 FileOutputStream:写字节出去

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

构造器说明
FileOutputStream(File file) / FileOutputStream(String pathname)覆盖(清空)原内容后写出
FileOutputStream(File file, boolean append) / FileOutputStream(String pathname, boolean append)append = true 时为追加,不会清空原内容
方法说明
void write(int b)写出一个字节
void write(byte[] buffer)写出一个字节数组
void write(byte[] buffer, int off, int len)写出字节数组的一部分
void close()关闭管道、释放资源
public class FileOutputStreamTest {
    public static void main(String[] args) throws Exception {
        // 1、创建字节输出流管道:第二个参数 true 表示追加
        OutputStream os = new FileOutputStream("src/dlei02.txt", true);

        // 2、写数据
        os.write('a');                                       // 写一个字节
        os.write("\r\n".getBytes());                          // 换行:写换行符的字节
        os.write("窗前明月光".getBytes());                     // 写一个字节数组

        // 3、关闭管道、释放资源
        os.close();
    }
}

复习问答(PPT 原文):

  1. 如何创建字节输出流管道,如何实现写数据的追加操作?——用带 append 参数的构造器,传 true。
  2. 字节输出流写数据的方法有哪些?——write(int)、write(byte[])、write(byte[], int, int)。
  3. 字节输出流如何实现写出去的数据可以换行?——写出换行符的字节,Windows 是 \r\n,也可以写 "\r\n".getBytes()。

7.5 案例:文件复制

核心思路:先创建字节输入流管道(源)与字节输出流管道(目标),读一个缓冲区、写一个缓冲区,循环到读完为止。

右键菜单中的复制命令——文件复制的直观含义

public class CopyFileTest {
    public static void main(String[] args) {
        try (InputStream is = new FileInputStream("C:/resource/meinv.png");
             OutputStream os = new FileOutputStream("D:/resource/meinv2.png")) {

            byte[] buffer = new byte[1024];
            int len;
            while ((len = is.read(buffer)) != -1) {
                os.write(buffer, 0, len);   // 只写本次真正读到的 len 个字节
            }
            System.out.println("复制完成!");
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

结论:任何文件的底层都是字节,字节流做复制,是一字不漏地转移完全部字节,只要复制后的文件格式一致就没问题。因此字节流非常适合做一切文件的复制操作。

8. 释放资源的方式

流对象持有操作系统资源,用完必须 close();但一旦中途抛出异常,close() 就可能被跳过,于是需要两种规范的写法。

8.1 try-catch-finally

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

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

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

}
InputStream is = null;
OutputStream os = null;
try {
    is = new FileInputStream("C:/resource/meinv.png");
    os = new FileOutputStream("D:/resource/meinv2.png");
    byte[] buffer = new byte[1024];
    int len;
    while ((len = is.read(buffer)) != -1) {
        os.write(buffer, 0, len);
    }
} 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();
    }
}

要点:先置 null → 在 try 中创建 → finally 中判空再关闭;关闭本身也会抛异常,所以要分别包一层 try-catch。

8.2 try-with-resource

JDK 7 开始提供了更简单的资源释放方案:try-with-resource。

try (定义资源1; 定义资源2; …) {
    可能出现异常的代码;
} catch (异常类名 变量名) {
    异常的处理代码;
}
  • () 中只能放置资源,否则报错。
  • 什么是资源呢?资源一般指的是最终实现了 AutoCloseable 接口的对象。
  • 该资源使用完毕后,会自动调用其 close() 方法,完成对资源的释放。
public abstract class InputStream implements Closeable { }
public abstract class OutputStream implements Closeable, Flushable { }
public interface Closeable extends AutoCloseable { }
try (InputStream is = new FileInputStream("C:/resource/meinv.png");
     OutputStream os = new FileOutputStream("D:/resource/meinv2.png")) {
    byte[] buffer = new byte[1024];
    int len;
    while ((len = is.read(buffer)) != -1) {
        os.write(buffer, 0, len);
    }
} catch (IOException e) {
    e.printStackTrace();
}

9. 拓展:拷贝文件夹

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

分析:

  1. IO 默认不可以拷贝文件夹;
  2. 我们需要遍历文件夹,如果是文件则拷贝过去,如果是文件夹则要进行 1:1 创建,继续复制内容。
/* 片段:依据 PPT 分析整理的思路骨架,复制前请确认目标目录不会覆盖已有数据 */
public static void copyDir(File src, File dest) throws IOException {
    dest.mkdirs();                       // ① 在目标位置 1:1 创建同名文件夹
    File[] files = src.listFiles();
    if (files == null || files.length == 0) {
        return;
    }
    for (File file : files) {
        if (file.isFile()) {
            copyFile(file, new File(dest, file.getName()));   // ② 是文件则拷贝
        } else {
            copyDir(file, new File(dest, file.getName()));    // ③ 是文件夹则递归
        }
    }
}

10. 作业与练习

作业文档:day08-Stream、File、方法递归/作业/03_题目/File.md(文档内标题写作”day11IO流”,与文件夹的 day08 编号不一致,以文件夹为准)。

题目目标关键点
题目 1 不死神兔(加强训练)用递归解决斐波那契型问题出口:month == 1 || month == 2 返回 1;规律:getCount(month-1) + getCount(month-2)
题目 2 打印目录结构(加强训练)用递归打印文件夹目录树printDir(dir, depth),每层先打印 num 个 \t 再打印 |-名称;listFiles() 为 null 或长度为 0 时结束
题目 3 计算文件夹大小(综合扩展)递归累加文件大小是文件累加 length(),是文件夹递归累加返回值
题目 4 删除文件夹(综合扩展)递归删除非空文件夹先删子项、再删自己;files == null || length == 0 时直接 delete()

参考要点:

  • 后三题都要求先拿到 File[] files = dir.listFiles(),再判断 files == null || files.length == 0 以结束递归。
  • 题目 3 的参考答案里最后一行输出写成了 System.out.println("大小: " + size + " 字节"),但方法内定义的变量名是 length,变量 size 并不存在——这是文档里的笔误,实际应写 length(或把变量统一命名)。
  • 作业目录另附各题的 avi 视频讲解。

11. 复习要点

  1. Stream 流是 JDK 8 新增、位于 java.util.stream.*,用于简化集合/数组操作的 API,结合 Lambda 表达式;使用三步是获取流 → 中间方法处理(返回新流、可链式)→ 终结方法或收集结果。
  2. 获取流:Collection.stream()、Arrays.stream()、Stream.of(),Map 可分别取 keySet() / values() / entrySet() 的流;中间方法有 filter、sorted、limit、skip、distinct、map、Stream.concat;终结方法有 forEach、count、max、min;收集用 collect(Collectors.toList()/toSet()/toMap()) 或 toArray()。
  3. 变量、数组、集合都是内存中的容器,断电或程序终止会丢失;文件存在硬盘上可以长久保存。File 负责文件本身(创建、删除、判断、获取信息、遍历),不能读写文件内容——读写内容要靠 IO 流。
  4. new File(...) 只是路径对象,路径可以不存在;绝对路径从盘符开始,相对路径不带盘符、默认到当前工程目录找。mkdir() 建一级目录、mkdirs() 建多级目录;delete() 只能删文件和空文件夹且不进回收站。
  5. listFiles() 只遍历一级文件对象:路径不存在、主调是文件、无权限时返回 null,空文件夹返回长度为 0 的数组;要遍历多级必须配合方法递归。
  6. 递归三要素:递归公式、终结点、方向必须走向终结点;缺少终结点会导致递归死循环与栈内存溢出。典型应用:阶乘 f(n)=f(n-1)*n、求和 f(n)=f(n-1)+n、猴子吃桃 f(x)=2f(x+1)+2(终结点 f(10)=1)、文件搜索、删除非空文件夹。
  7. 字符集:ASCII 用 1 字节表示 128 个字符(最高位为 0);GBK 汉字 2 字节、首字节首位为 1 且兼容 ASCII;UTF-8 英文 1 字节、汉字 3 字节,开发中应统一使用 UTF-8。
  8. 编码与解码所用字符集必须一致,否则乱码;英文数字因各字符集都兼容 ASCII,一般不乱码。Java 中编码用 getBytes() / getBytes(String),解码用 new String(byte[]) / new String(byte[], String)。
  9. IO 流按方向分输入流/输出流,按最小单位分字节流/字符流;字节流适合所有类型文件,字符流只适合纯文本。四大抽象类 InputStream、OutputStream、Reader、Writer,文件实现类 FileInputStream、FileOutputStream、FileReader、FileWriter。
  10. 字节流读中文乱码的根因是”按字节切分把多字节字符切断了”;一次性读完全部字节可避免乱码,但文件过大会有内存溢出风险。任何文件的底层都是字节,所以字节流适合做一切文件的复制;释放资源用 try-catch-finally(专业级做法)或 try-with-resource(JDK 7 起,资源需实现 AutoCloseable,自动 close())。