Stream、File 与递归:数据处理和目录遍历
掌握惰性流水线、路径对象与递归的终止条件。
继续上次小节 →Java 进阶线 · 线路目录
Java 进阶线
对象与类型设计3 站
文件、配置与并发4 站
网络、框架与复习3 站
本页导读
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 原文):
- Stream 是什么?有什么作用?结合了什么技术?——简化集合、数组操作的 API,结合了 Lambda 表达式。
- 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 流操作后的结果转回到集合或者数组中去返回。
| 目标 | 写法 |
|---|---|
List | collect(Collectors.toList()) |
Set | collect(Collectors.toSet()) |
Map | collect(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<>(); | 可变长,但仍在内存中 |
它们都是内存中的数据容器;它们记住的数据,在断电或程序终止时会丢失。
2.2 文件:硬盘上的持久存储
有些数据想长久保存下来,就要写进文件。文件是非常重要的存储方式,存放在计算机硬盘中;即便断电或者程序终止了,存储在硬盘文件中的数据也不会丢失。
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 递归算法三要素
- 递归的公式:
f(n) = f(n-1) * n; - 递归的终结点:
f(1) - 递归的方向必须走向终结点:
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 这个文件,找到后直接输出其位置。
分析:
- 先找出 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 案例:删除文件夹(拓展)
需求:删除非空文件夹。
分析:
File默认不可以删除非空文件夹;- 我们需要遍历文件夹,先删除里面的内容,再删除自己(后序遍历)。
/* 片段:思路整理,删除动作不可逆,练习时请先用临时目录,并先打印待删除路径 */
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)存储;字符与字节之间的映射规则就是字符集。
5.2 标准 ASCII 字符集
ASCII(American Standard Code for Information Interchange,美国信息交换标准代码)包括了英文、符号等。
- 标准 ASCII 使用 1 个字节存储一个字符,首尾是 0,总共可表示 128 个字符,对美国来说完全够用。
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 个字节 |
| GBK | 2 个字节 | 1 个字节 |
| UTF-8 | 3 个字节 | 1 个字节 |
复习问答(PPT 原文):
- 常见字符集有哪些,各自存储数据的特点是什么?——英文和数字在任何国家的字符集中都占 1 个字节;GBK 中一个中文字符占 2 个字节;UTF-8 中一个中文一般占 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 流的作用:读写文件数据(也可以读写网络中的数据)。谁在读写?程序(进程) 以内存为基准,与磁盘/网络之间搬数据。
思考:
- 先搞清楚 IO 流的分类、体系;
- 再挨个学习每个 IO 流的作用、用法。
6.2 IO 流的分类
按流的方向分:
| 类型 | 含义 |
|---|---|
| 输入流 | 把磁盘/网络中的数据读到内存 |
| 输出流 | 把内存中的数据写出去 |
按流中数据的最小单位分:
| 类型 | 适合的场景 |
|---|---|
| 字节流 | 适合操作所有类型的文件,比如音频、视频、图片 |
| 字符流 | 只适合操作纯文本文件,比如读写 txt、java 文件等 |
6.3 IO 流的体系:四大流
| 四大流 | 抽象类 | 文件实现类 |
|---|---|---|
| 字节输入流 | InputStream | FileInputStream |
| 字节输出流 | OutputStream | FileOutputStream |
| 字符输入流 | Reader | FileReader |
| 字符输出流 | Writer | FileWriter |
定义:
- 字节输入流:以内存为基准,来自磁盘文件/网络中的数据以字节的形式读入到内存中去的流;
- 字节输出流:以内存为基准,把内存中的数据以字节写出到磁盘文件或者网络中去的流;
- 字符输入流:以内存为基准,来自磁盘文件/网络中的数据以字符的形式读入到内存中去的流;
- 字符输出流:以内存为基准,把内存中的数据以字符写出到磁盘文件或者网络介质中去的流。
记忆: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 原文):
- 如何创建字节输出流管道,如何实现写数据的追加操作?——用带
append参数的构造器,传true。 - 字节输出流写数据的方法有哪些?——
write(int)、write(byte[])、write(byte[], int, int)。 - 字节输出流如何实现写出去的数据可以换行?——写出换行符的字节,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. 拓展:拷贝文件夹
需求:将某个磁盘的文件夹拷贝到另一个文件夹下去,包括文件夹中的全部信息。
分析:
- IO 默认不可以拷贝文件夹;
- 我们需要遍历文件夹,如果是文件则拷贝过去,如果是文件夹则要进行 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. 复习要点
- Stream 流是 JDK 8 新增、位于
java.util.stream.*,用于简化集合/数组操作的 API,结合 Lambda 表达式;使用三步是获取流 → 中间方法处理(返回新流、可链式)→ 终结方法或收集结果。 - 获取流:
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()。 - 变量、数组、集合都是内存中的容器,断电或程序终止会丢失;文件存在硬盘上可以长久保存。
File负责文件本身(创建、删除、判断、获取信息、遍历),不能读写文件内容——读写内容要靠 IO 流。 new File(...)只是路径对象,路径可以不存在;绝对路径从盘符开始,相对路径不带盘符、默认到当前工程目录找。mkdir()建一级目录、mkdirs()建多级目录;delete()只能删文件和空文件夹且不进回收站。listFiles()只遍历一级文件对象:路径不存在、主调是文件、无权限时返回null,空文件夹返回长度为 0 的数组;要遍历多级必须配合方法递归。- 递归三要素:递归公式、终结点、方向必须走向终结点;缺少终结点会导致递归死循环与栈内存溢出。典型应用:阶乘
f(n)=f(n-1)*n、求和f(n)=f(n-1)+n、猴子吃桃f(x)=2f(x+1)+2(终结点f(10)=1)、文件搜索、删除非空文件夹。 - 字符集:ASCII 用 1 字节表示 128 个字符(最高位为 0);GBK 汉字 2 字节、首字节首位为 1 且兼容 ASCII;UTF-8 英文 1 字节、汉字 3 字节,开发中应统一使用 UTF-8。
- 编码与解码所用字符集必须一致,否则乱码;英文数字因各字符集都兼容 ASCII,一般不乱码。Java 中编码用
getBytes()/getBytes(String),解码用new String(byte[])/new String(byte[], String)。 - IO 流按方向分输入流/输出流,按最小单位分字节流/字符流;字节流适合所有类型文件,字符流只适合纯文本。四大抽象类
InputStream、OutputStream、Reader、Writer,文件实现类FileInputStream、FileOutputStream、FileReader、FileWriter。 - 字节流读中文乱码的根因是”按字节切分把多字节字符切断了”;一次性读完全部字节可避免乱码,但文件过大会有内存溢出风险。任何文件的底层都是字节,所以字节流适合做一切文件的复制;释放资源用
try-catch-finally(专业级做法)或try-with-resource(JDK 7 起,资源需实现AutoCloseable,自动close())。