Java 进阶 Day03:集合、Stream、IO 流与线程入门
List 集合深入
List 接口提供带索引的增删改查:
List<String> list = new ArrayList<>(); // 经典代码list.add("hello"); list.add(1, "javaee"); // 按下标插入(第二个位置)list.remove(2); // 按下标删除list.set(2, "C"); // 按下标修改String s = list.get(2); // 按下标获取四种遍历:for + get(i) / 增强 for / list.forEach(System.out::println) / 迭代器。
List 家族独有:有索引,所以能按下标插入/删除/修改/取(Set 做不到)。
Set 家族与自定义去重
Set 三个实现类
| 实现类 | 特点 | 场景 |
|---|---|---|
HashSet | 无序、不重复、无索引 | 用的最多,去重 |
LinkedHashSet | 插入有序、不重复、无索引 | 要保序的去重 |
TreeSet | 默认按大小升序、不重复、无索引 | 要排序的去重 |
HashSet 底层:JDK8 之前是默认长度 16 的数组、加载因子 0.75、数组名 table;JDK8 之后是数组 + 链表 + 红黑树。LinkedHashSet 每个元素额外用一个双链表记录前后,所以能保插入顺序。
自定义对象去重
两个内容相同的 Student 对象,默认会被 Set 当成两个元素:
Student s1 = new Student("张三",18,"233");Student s2 = new Student("张三",18,"233"); // 逻辑相同Set<Student> set = new HashSet<>();set.add(s1); set.add(s2); set.add(s3); ...System.out.println(set); // 张三只出现一次 → 去重成功要让去重生效,Student 必须成对重写 equals 和 hashCode:
@Overridepublic boolean equals(Object o) { if (this == o) return true; // 同一个对象,短路 if (o == null || getClass() != o.getClass()) return false; // 类型不同直接 false Student student = (Student) o; return age == student.age && Objects.equals(name, student.name) && Objects.equals(address, student.address); // 字段逐个比}
@Overridepublic int hashCode() { return Objects.hash(name, age, address); // 参与 equals 的字段都要进 hash}去重流程:add 先算 hashCode → 定位桶(数组下标)→ 桶空直接放;桶里有元素 → 逐个 equals 比较 → 有相同的就丢弃(重复),全不同就挂上去。
为什么必须成对重写:
- 只重写 equals 不写 hashCode → 逻辑相同的对象 hashCode 不同 → 分到不同桶 → 永远碰不上 → 去重失效
- 只重写 hashCode 不写 equals → 同桶但
equals判 false → 也不去重 - 哈希约定:
equals相等的对象,hashCode必须相等(反向不成立:hashCode 相同而 equals 不等 = 哈希冲突,靠链表/红黑树兜底)
HashSet 底层的几个阈值
- 初始数组 16,加载因子 0.75
- 元素个数 > 12(16×0.75)→ 触发扩容(数组翻倍 resize)
- 链表长度 > 8 且数组长度 ≥ 64 → 链表转红黑树;链表 > 8 但数组没到 64 → 先扩容不转树
- 12 是扩容阈值,不是树化阈值——两对数字别混(8 和 64 才是树化)
TreeSet 自定义排序
两种指定排序规则的方式:对象所属类实现 Comparable 接口重写 compareTo;或者构造器传 Comparator 比较器(优先用)。
Set<Teacher> set = new TreeSet<>(new Comparator<Teacher>() { @Override public int compare(Teacher o1, Teacher o2) { return Double.compare(o2.getSalary(), o1.getSalary()); // 工资倒序 }});Double.compare 比手写 if (a > b) return 1; 干净(double 不能直接减)。升序写 Double.compare(o1, o2),倒序交换两个参数。
Map 家族
Map 三个实现类
| 实现类 | 特点 |
|---|---|
HashMap | 无序、不重复、无索引(用的最多) |
LinkedHashMap | 插入有序、不重复、无索引 |
TreeMap | 按键大小升序、不重复、无索引 |
Map<String,Integer> map = new LinkedHashMap<>(); // 经典代码map.put("Python",45);map.put("Python",35); // 键重复 → 覆盖旧值(值可以是任意)map.put(null, null); // 键和值都可以为 null键决定特性:键无序/有序/排序,键不重复;值无所谓(可重复、可 null)。
常用方法
| 方法 | 语义 |
|---|---|
put(k,v) | 键不存在→添加;键已存在→覆盖并返回旧值 |
putIfAbsent(k,v) | 仅键不存在时才添加 |
replace(k,v) | 仅键已存在时才替换(与 put 相反) |
get(k) | 键不存在返回 null(无法区分「没这键」和「值就是 null」) |
getOrDefault(k, 默认) | 键不存在返回默认值——安全取值的首选,避免 NPE |
containsKey(k) | O(1) 快,优先用它判断 |
containsValue(v) | 遍历整个 Map,性能差,少用 |
remove(k) / remove(k,v) | 按键删 / 键值都匹配才删(返回 boolean) |
merge(k,v,函数) | 词频统计神器:freq.merge(w, 1, Integer::sum) |
keySet() | 返回键的 Set(视图,不能 add,会抛 UnsupportedOperationException) |
values() | 返回值的 Collection(值可重复,所以不是 Set) |
put 返回值是被覆盖的旧值:
Integer old = map.put("张三", 95); // 张三已存在 → 返回被覆盖的旧值 90Integer r = map.put("新人", 1); // 键不存在 → 返回 null三种遍历方式
| 方式 | 代码 | 特点 |
|---|---|---|
| keySet 键找值 | for (String k : map.keySet()) map.get(k) | 每次 get 都再查一次哈希,略慢 |
| entrySet 键值对 | for (Map.Entry k-v : map.entrySet()) | 一次拿到键值对,零查找,推荐 |
| forEach lambda | map.forEach((k,v) -> ...) | 最简洁,JDK8+ |
Stream 流
固定套路:三大步骤
创建流 → 中间操作 → 终止操作
List<String> list1 = list.stream() // 创建 .filter(name -> name.startsWith("张")) // 中间:过滤 .filter(s -> s.length() == 3) // 中间:再过滤(可链式) .collect(Collectors.toList()); // 终止:收集成 List- 创建:
list.stream()/map.keySet().stream()/map.values().stream()/map.entrySet().stream()/Stream.of(arr)/Arrays.stream(arr) - 中间操作:
filter过滤 /sorted()默认升序 + 自定义 Comparator /limit(n)取前 n /skip(n)跳过前 n /distinct()去重 /map加工 - 终止操作:
forEach/count计数 /max/min(返回Optional,.get()取)/collect收集
收集目标
List<String> list1 = s1.collect(Collectors.toList());Set<String> set = s2.collect(Collectors.toSet());String[] arr = s3.toArray(String[]::new);Map<String,Double> map = stream.collect(Collectors.toMap(Teacher::getName, Teacher::getSalary));惰性求值
中间操作都是惰性的(lazy)——list.stream().filter(...) 这一行不会真正遍历,只是「记下规则」;只有终止操作(collect/forEach/count)触发时才真正跑完整条管道。没有终止操作,filter 等于没写。
一次性
Stream 不能重复消费:被终止操作消费后再使用,抛 IllegalStateException(要多次收集就得每次重新 list.stream())。
IO 流
File 类
File f1 = new File("G/nicshot/5.jpg"); // 代表文件/目录(只是个"代表",不代表真实存在)f1.getName(); f1.length(); f1.isFile(); f1.isDirectory();- 绝对路径:带盘符;相对路径:不带盘符,默认从工程目录找
- 创建/删除:
createNewFile()/delete()
字符集编码与解码
byte[] bytes = name.getBytes("GBK"); // 编码:字符串 → 字节(不指定字符集时默认 UTF-8)String s = new String(bytes, "GBK"); // 解码:字节 → 字符串- 编码解码用的字符集必须一致,否则乱码
- 中文宽度:GBK 占 2 字节、UTF-8 占 3 字节(一个汉字)
字节流
InputStream in = new FileInputStream(new File("...")); // 字节输入byte[] bytes = new byte[1024];int len;while ((len = in.read(bytes)) != -1) { // 批量读(比单字节快) System.out.print(new String(bytes, 0, len));}
OutputStream out = new FileOutputStream(path, true); // 第二个参数 true = 追加模式out.write(97); // 写单字节out.write("我爱你中国".getBytes()); // 写字节数组out.write(bytes, 0, 3); // 写字节数组的一部分- 单字节
read():性能差,且中文必乱码(一个汉字被拆成多个字节读) write('龙')直接写字符也会乱码——字节流眼里只有字节
字符流
try (Reader fr = new FileReader(path)) { // try-with-resources 自动关闭 char[] chs = new char[1024]; int len; while ((len = fr.read(chs)) != -1) { System.out.print(new String(chs, 0, len)); }}
try (FileWriter fw = new FileWriter(path, true)) { // true 追加 fw.write("hello world"); fw.write('蒋'); fw.write(chars, 0, 3); // 字符数组一部分 fw.flush(); // 刷缓冲}- 字符流 = 字节流 + 字符集解码:底层还是字节,包了一层编码转换,所以读文本不乱码
- 字符流也可能乱码:如果缓冲边界正好切开一个汉字(读到不完整的字符)——批量读时常见
- 读二进制(图片/音视频)必须用字节流;读文本用字符流
缓冲流
try (Reader fr = new FileReader(path); BufferedReader br = new BufferedReader(fr)) { // 包装低级流 String line; while ((line = br.readLine()) != null) { // 按行读(白送的能力) System.out.println(line); }}- 缓冲流是处理流,包装节点流(FileInput/OutputStream、FileReader/Writer)
- 作用:减少底层 IO 次数(每次真实读写都涉及系统调用,慢),缓冲攒一批再读写
BufferedReader.readLine()按行读;BufferedWriter.newLine()换行
文件复制
try (FileInputStream fis = new FileInputStream(srcPath); FileOutputStream fos = new FileOutputStream(destPath)) { byte[] bytes = new byte[1024]; int len; while ((len = fis.read(bytes)) != -1) { fos.write(bytes, 0, len); // 读多少字节,就写多少字节 }}缓冲版:把 fis/fos 再包一层 BufferedInputStream/OutputStream,性能更好。
Apache Commons IO
FileUtils.copyFile(new File(src), new File(dest)); // 一行复制用第三方库前先在 pom.xml 加依赖,否则编译失败(commons-io 引入 2.16.1 这类版本号明确的依赖)。
递归
阶乘与累加
public static int func(int n) { // 计算 5! if (n == 1) return 1; // ① 递归出口(终止条件) return n * func(n - 1); // ② 递归方程}public static int func2(int n) { // 1+2+...+n if (n == 1) return 1; return func2(n - 1) + n;}两要素:出口 + 递归方程;没有出口 → 无限递归 → StackOverflowError 栈溢出(每层递归压一帧进调用栈,栈满就崩)。
文件递归搜索
public static void searchFile(File dir, String name) { if (dir == null || !dir.exists() || !dir.isDirectory()) return; // 极端情况防御 File[] files = dir.listFiles(); if (files != null && files.length > 0) { for (File file : files) { if (file.isFile() && file.getName().contains(name)) { System.out.println(file.getAbsolutePath()); } else if (file.isDirectory()) { searchFile(file, name); // 目录 → 递归进入 } } }}分级处理:文件 → 判断匹配;目录 → 递归进入;防御 null / 不存在 / 非目录。
线程入门
创建方式一:继承 Thread
class MyThread extends Thread { @Override public void run() { // 重写 run,写线程任务 for (int i = 0; i < 6; i++) { System.out.println("线程1正在执行" + i); } }}Thread t = new MyThread();t.start(); // 启动线程(不是调 run!)for (int i = 0; i < 5; i++) { System.out.println("主线程在跑" + i); // 主线程继续跑}start() 与 run() 的区别
| 调用 | 行为 |
|---|---|
t.start() | 创建新线程,JVM 分配独立执行栈,start 立即返回,与主线程并发交替执行 |
t.run() 直接调 | 不创建线程,主线程同步执行方法体 = 普通方法调用 |
执行顺序不保证,随机(线程调度由操作系统决定)。
重点速记
- List 带索引:
add(i,ele)/remove(i)/set(i,ele)/get(i);四种遍历 - Set 三兄弟:HashSet 无序 / LinkedHashSet 插入有序 / TreeSet 排序,都不重复无索引
- 自定义去重铁律:equals + hashCode 成对重写(参与 equals 的字段全进 hashCode)
- HashSet 底层:初始 16 / 加载因子 0.75 / 12 扩容;链表 >8 且数组 ≥64 才红黑树化
- Map 三兄弟:HashMap 无序(用的最多)/ LinkedHashMap 有序 / TreeMap 按键排序;键决定特性
- Map 三遍历:keySet(每次 get 重查哈希)/ entrySet(推荐,零查找) / forEach lambda
put返回值 = 被覆盖的旧值;键不存在返回 nullgetOrDefault安全取值首选;merge词频统计;containsKeyO(1) 优先- Stream 三步骤:创建 → 中间 → 终止;链式 + 一次性(不能重复消费)
- Stream 惰性求值:中间操作 lazy,终止操作才真正遍历
- Stream 常用:filter / sorted / limit / skip / distinct / map + collect(toList/toSet/toMap/toArray)
- IO 四类流:字节 In/Out(InputStream/OutputStream)+ 字符 In/Out(Reader/Writer)
- 中文宽度:GBK 2 字节 / UTF-8 3 字节;字节流切分汉字必乱码;读二进制用字节流、读文本用字符流
- 缓冲流:包装节点流,减少底层 IO 次数;
readLine()按行读 - try-with-resources:
try(资源)自动关闭,免手动 close - 递归:出口 + 递归方程;无出口 → 栈溢出
- 线程:继承 Thread + 重写 run +
start();start 开新线程 vs run 同步调用;执行顺序随机
实战:文章数据的文件持久化
把 IO 流落到控制台博客项目:文章数据落盘,解决「重启数据不丢」。整体流程:仓库预置数据后立即保存一次;新增/删除先改内存列表再整体覆盖写回文件;启动时若文件存在则加载,并把 nextId 恢复为现有最大 id + 1(三轮重启验证:首跑落盘、二跑加载、删除后再启动新文章 id 不撞车)。
设计要点:
| 决策 | 理由 |
|---|---|
分隔符用 ~~~ 而非 | | 文章内容可能含 | 或换行,~~~ 出现概率极低(简单方案不做转义) |
显式指定 UTF-8 | FileWriter 默认平台编码(Windows 是 GBK)→ 中文乱码;字符流 = 字节流 + 字符集 |
| 一行一篇文章 | 简单、可读、易排错 |
枚举存 name()、还原 valueOf() | 字符串 ↔ 枚举双向转换,编译期安全 |
@AllArgsConstructor 还原 final 字段 | createTime 是 final,Lombok 全参构造可还原 id/createTime/status |
| 全量覆盖写 | 简单方案,数据量小无性能问题 |
| nextId 恢复 = max+1 | 防新文章 id 撞车(删除后 id 可复用) |
踩坑记录
- 工具类的
save方法误写成private:工具方法是给外部类调用的,必须public - 文件位置硬编码绝对路径不可移植,应改用相对路径
- 预置数据后忘记立即落盘,导致文件在写入第一篇新文章前一直是空的
- 删除逻辑把「保存」写在
return之后(save(articles); return remove(i);),save 永远不会执行,删除无法落盘;正确顺序是 remove → save → return @AllArgsConstructor与手写的部分参构造器可以共存,用 Lombok 全参构造还原 final 字段即可
已知限制:文章内容含换行时按行 split 会截断;数据量大时可改为增量写或引入数据库。
支持与分享
如果这篇文章对你有帮助,欢迎分享给更多人或打赏支持!





















