2月4日,我把手头一个遗留模块的坑填完,顺便把这段时间零散的 Java 笔记整理了一遍。说是笔记,其实就是白天写代码踩出来的问题清单,加上晚上翻源码、翻官方文档时补的一些理解。这个过程中有好几个点值得单独拿出来说说,因为它们不仅解决了眼下的问题,后面写代码大概率还能用得上。
这篇笔记主要涉及集合框架的底层行为、并发工具在业务里的真实用法、JVM 参数调整的实践记录,还有几个 Java 新特性的上手体验。如果你平时写业务代码多、但对底层原理和疑难杂症处理感兴趣,这应该是一份能直接抄作业的参考。
1. 当天笔记的核心主线:从业务问题到底层原理
拿到需求的时候,第一反应不是写代码,而是先梳理清楚这个模块到底在干什么。2月4日的任务表面上是一个数据聚合接口的性能优化,背后牵涉到的却是集合选择、线程安全和内存占用这三件事。这三件事在面试题里经常出现,但在真实项目里同时遇到,就需要一点点抠细节。
1.1 为什么同一天要同时碰集合、并发和 JVM
因为性能优化的入口往往不是某一个独立的技术点。接口响应慢,可能是循环里用了不合适的集合类型,可能是并发处理共享变量出了问题,也可能是 JVM 堆设置不合理导致频繁 GC。这三个层面会互相影响,单独修哪一个都不彻底。比如你用 ArrayList 做大量随机删除,再好的 JVM 参数也救不了这个 O(n²) 的复杂度;反过来,你用线程池并发处理任务,却没有正确设置队列大小,堆内存再大也会被任务积压撑爆。
所以我当天的思路是:先把接口的数据流画出来,标记出哪些环节是 CPU 密集型、哪些是 IO 密集型、哪些涉及共享数据,再决定每个环节采用什么技术方案。
1.2 数据流分析是笔记里最有价值的部分
我习惯在动手优化前,把整个数据流用文字描述一遍,伪代码形式就行。比如这个聚合接口,它接收一批 ID,先做缓存查询,缓存没有命中的再去数据库查,查完之后做数据转换和分组,最后拼装成前端需要的结构。
这一步非常关键,因为它能暴露出很多“看起来正常但实际很慢”的问题。我当天的笔记里就记录了一个典型的案例:原本用了嵌套循环做数据关联,外层 500 条、内层 3000 条,虽然单次执行只要几十毫秒,但接口并发一高,CPU 使用率立刻飙升。换成 Map 索引后,时间复杂度从 O(n×m) 降到了 O(n+m),这个改动比任何 JVM 调优都立竿见影。
这里有个通用原则:做性能优化,一定要先找算法和数据结构的问题,再考虑 JVM 参数调整。顺序反了,白忙活。
2. 集合框架的实战细节:为什么 HashMap 不能乱用
当天的笔记里有很大一部分是集合框架的记录,特别是 HashMap 和 ArrayList。这两个类是 Java 里最常用的数据结构,但越是常用的东西,越容易在细节上翻车。
2.1 HashMap 的死循环问题虽然修了,但并发下的其他坑还在
很多老开发都知道 HashMap 在 JDK 7 里并发 put 可能导致死循环,JDK 8 引入了红黑树后这个问题基本解决了。但并发下的数据覆盖、size 不准确、modCount 并发修改导致的 ConcurrentModificationException 依然存在。2月4日我就在一个多线程跑批任务里踩了 ConcurrentModificationException 的坑。
原因很简单:一个线程在遍历 map,另一个线程往 map 里 put 了新的键值对,遍历线程检测到 modCount 变了,立刻抛出异常。解决办法不是去 try-catch 这个异常,而是从设计上避免这种情况。我当时改成先用 ConcurrentHashMap 收集结果,遍历完成后再做只读处理,问题就消失了。
注意:ConcurrentHashMap 也不是万能药,它的 size 在并发写入时只是近似值,如果你需要精确统计,要么加锁,要么用 LongAdder 单独计数。
2.2 ArrayList 转数组的坑与正确姿势
还有一个细节是 ArrayList 的 toArray 方法。很多人直接写list.toArray(),返回的是 Object[],然后强转成 String[],一运行就报 ClassCastException。正确做法是传一个类型数组进去:list.toArray(new String[0])。
这里有个小技巧,JDK 8 之后推荐传new String[0]而不是new String[list.size()]。因为以空数组为参数时,ArrayList 内部会判断集合大小和数组大小的关系,如果数组太小,会重新创建一个大小正好为集合长度的新数组,反而节省了创建大数组后再复制一次的开销。这个优化在集合很大的时候能省下可观的内存分配时间。
我当天在实际代码里还遇到过另一个问题:对 ArrayList 做了大量的 remove 操作,性能差到不能忍。后来查了源码才知道,ArrayList 的 remove 是数组拷贝,每删一个元素都要把后面的元素往前移动。如果要频繁删除,最优解是反向遍历,或者直接用 LinkedList,但 LinkedList 的随机 get 又是 O(n),所以最实用的方案是用迭代器的 remove 方法,或者用 Stream 过滤后重新收集。
2.3 LinkedHashMap 实现 LRU 缓存的正确写法
缓存模块里,我用了 LinkedHashMap 实现了一个简单的 LRU 缓存。这个类默认是插入顺序遍历,但只要在构造时把 accessOrder 设为 true,并重写 removeEldestEntry 方法,就能实现按访问顺序排列、自动淘汰最老元素的效果。
Map<String, Object> cache = new LinkedHashMap<String, Object>(16, 0.75f, true) { @Override protected boolean removeEldestEntry(Map.Entry<String, Object> eldest) { return size() > 1000; } };这段代码看起来简单,但有三个关键点容易忽略。第一,初始容量 16 和负载因子 0.75f 决定了扩容时机,容量满了之后会触发 rehash,虽然 LinkedHashMap 的 rehash 比 HashMap 多维护了双向链表,但影响不大,关键是初始容量别设太小。第二,accessOrder 为 true 时,每次 get 都会触发布结构变更,会移除再插入节点更新链表顺序,所以这类缓存不适合超高并发的场景,必要时得用锁保护。第三,removeEldestEntry 的返回值决定了是否淘汰最老的元素,size() 是当前元素数量。
不过要提醒一点,这个实现不是线程安全的,单机场景下可以用 Collections.synchronizedMap 包一层,但性能有损耗;如果要求高并发,建议用 Caffeine 这类专门的缓存库,它就是基于 ConcurrentHashMap 和类似 LRU 算法实现的,性能好得多。
3. 当天的并发编程笔记:线程池参数不能拍脑袋
并发编程这部分,是我2月4日笔记里花时间最多的。业务场景是:一个跑批任务需要处理几万条数据,每条数据要做独立的计算,然后汇总结果。最开始直接用 new Thread 去开线程,后来发现线程数量一多,系统资源直接被耗尽,切换线程的开销比业务本身还大。
3.1 ThreadPoolExecutor 的参数计算实操
线程池的核心参数有好几个,但真正需要你动手算的,是 corePoolSize 和 maxPoolSize。网上很多文章推荐“CPU 核数+1”或“2 倍 CPU 核数”这些经验公式,但实际业务里还要看任务是 IO 密集型还是 CPU 密集型。
我当天的经验是:CPU 密集型的计算任务,线程数设为 CPU 核数附近就够了,多了反而因为上下文切换变慢;IO 密集型任务,包括等待数据库返回、调用外部接口、读写文件,线程数可以适当增加,常见做法是 CPU 核数乘以某个系数,我习惯先用CPU 核数 * 2起步,再通过压测观察 CPU 占用和响应时间来调整。
具体计算时的代码写法如下:
int cpuCore = Runtime.getRuntime().availableProcessors(); int poolSize = cpuCore * 2; ThreadPoolExecutor executor = new ThreadPoolExecutor( poolSize, poolSize * 2, 60L, TimeUnit.SECONDS, new ArrayBlockingQueue<>(500), new ThreadPoolExecutor.CallerRunsPolicy() );这里我用的是 ArrayBlockingQueue,有界队列,能防止任务无限堆积导致内存溢出。拒绝策略选的是 CallerRunsPolicy,意思是队列满了以后,新任务不丢弃,而是由提交任务的线程自己执行。这个策略好在哪里?它起到一个天然的背压效果,提交越频繁,执行线程自己也要承担任务,系统不会崩溃,只是响应会慢一点。比 AbortPolicy 直接抛异常要安全得多。
3.2 线程池参数调整时最容易忽略的坑
第一个坑是 corePoolSize 和 maxPoolSize 之间有一个奇怪的队列入场顺序规则。很多人以为线程数从 core 增加到 max 是渐进式的,其实不是。ThreadPoolExecutor 的逻辑是:先让线程数达到 corePoolSize,如果这时候任务继续来,它们会先放到队列里,队列满了才会继续创建线程,直到 maxPoolSize。如果你把队列设得特别大,那么线程数永远不会超过 corePoolSize,机器资源就浪费了。
第二个坑是没有设置线程名。线上排查问题的时候,一个线程叫什么名字太重要了。你不想在日志里看到一堆pool-3-thread-5,完全不知道它属于哪个任务。我习惯用自定义 ThreadFactory:
ThreadFactory factory = new ThreadFactory() { private final AtomicInteger count = new AtomicInteger(0); @Override public Thread newThread(Runnable r) { Thread t = new Thread(r, "batch-task-" + count.incrementAndGet()); t.setDaemon(false); return t; } };第三个坑是忘记优雅关闭线程池。应用停机时,如果线程池里还有任务跑了一半,直接退出会把数据弄成不一致状态。需要在项目关闭时调用 shutdown 方法,再调用 awaitTermination 等待正在执行的任务完成。
3.3 用 CompletableFuture 编排并发任务的实战写法
当天的跑批任务里,我还用了 CompletableFuture 做异步编排。这个类用起来很舒服,写出来的代码比手动管理 Future 清晰得多。比如我需要同时查询三个独立数据源,然后汇总它们的结果,可以写成:
CompletableFuture<List<String>> futureA = CompletableFuture.supplyAsync(() -> queryA(), executor); CompletableFuture<List<String>> futureB = CompletableFuture.supplyAsync(() -> queryB(), executor); CompletableFuture<List<String>> futureC = CompletableFuture.supplyAsync(() -> queryC(), executor); CompletableFuture<Void> allDone = CompletableFuture.allOf(futureA, futureB, futureC); allDone.join(); List<String> combined = new ArrayList<>(); combined.addAll(futureA.join()); combined.addAll(futureB.join()); combined.addAll(futureC.join());这段代码最值得注意的地方是 supplyAsync 一定要传入自定义的 executor,否则它默认使用 ForkJoinPool.commonPool。公共线程池的线程数受机器核数影响,多个业务模块共用的话,可能出现互相阻塞的情况。我最初就吃过这个亏,后来统一改成显式传线程池。
还有一个细节是 join() 和 get() 的区别。get() 抛的是受检异常,必须处理;join() 抛的是 CompletionException,是运行时异常,可以不用显式捕获。从编程体验来说,join() 更简洁,但它包装异常的方式会让排查问题变麻烦——异常栈会被包进 CompletionException 里,需要排查到了之后拆出来看。所以线上代码我更倾向于用 get(),用 try-catch 包住,日志里能直接看到真实的异常链。
4. JVM 参数与内存分析的笔记,都是实战中磨出来的
2月4日笔记里还有一块重要内容是 JVM。背景是一个服务在高峰时段出现 Full GC 频繁,老年代持续增长,然后接口响应时间直线飙升。我一眼看到 GC 日志,就知道是内存里积压了大量对象没有及时回收。
4.1 找出内存泄漏还是内存压力大的判断流程
判断是内存泄漏还是单纯内存压力大,最直接的方法是看老年代的使用曲线。如果 Full GC 之后老年代能降下去,说明是内存压力大,调整堆大小或优化对象生命周期就行;如果每次 Full GC 后老年代还是高位运行,甚至越来越高,那大概率是泄漏,得用 dump 文件分析。
我当时用 jmap 打了一份堆 dump,然后导入分析工具,发现大量重复的中间对象是数据库查询结果集没及时清理引用导致的。虽然用了局部变量,但在某个闭包里被意外引用着,导致整个对象都无法回收。修正的方式是快速返回结果集,不在方法里保存多余的引用,同时把对象生命周期压到最小。
4.2 实际调整 JVM 参数的三条建议
第一,大对象直接进老年代,可以通过 PretenureSizeThreshold 设置阈值,但这是特定场景的技巧,通用业务不一定适用。第二,新生代空间不要太小,如果太小,大量短期对象会频繁触发 Minor GC,还可能因为存活对象超过 Survivor 区容量而直接晋升到老年代,增加老年代压力。第三,用 G1 收集器时,-XX:MaxGCPauseMillis 的目标值不要设得太激进,比如 10ms,这样会导致 GC 频繁调整回收区域,反而增加 CPU 开销。
我当时把-Xms和-Xmx设为相同值,避免了堆容量动态扩缩带来的抖动。这个在很多生产环境是默认操作,简单有效。
实际操作中不要直接照搬别人的参数,因为每台机器的内存、CPU、业务模型都不一样。正确的做法是先用默认参数跑出 GC 日志,再根据日志数据做针对性调整。
4.3 并行流在处理大数据量时的 JVM 关联问题
当天还有一个让我印象深刻的经验:在 JVM 内存本来就不宽裕的前提下,用 parallelStream 处理大列表把内存差点撑爆。并行流底层用的也是 ForkJoinPool,会将一个大任务拆成多个子任务并发执行,每个子任务都会生成中间结果,最终合并时这些中间结果全部压在堆内存里。
如果业务数据量是几十万条甚至上百万条,每一条还带一堆字段,那内存占用非常恐怖。我后来做了一个简单的压测发现,同样一批数据,用 parallelStream 处理的内存峰值比单线程 Stream 高了两倍多,因为多线程同时执行且中间结果无法及时释放。
所以我的经验是:parallelStream 适合 CPU 密集且数据量适中(比如几千条)、每条数据计算量不小的场景。如果你的场景是数据量大、内存紧张,或者涉及共享可变状态,老老实实用单线程 Stream,或者自己手动控制线程池并做批量分页处理。
5. Java 新特性笔记:record、switch 表达式与密封类的上手体验
如果说前面这些是解决线上问题的记录,那2月4日笔记的最后一节就是对新特性的尝鲜整理。Java 版本迭代越来越快,很多项目还停留在 Java 8,但新版本的一些特性确实能减少样板代码,值得抽时间熟悉。
5.1 record 让 POJO 定义精简到一行
以前定义一个数据类,要写类的成员变量、getter、setter、有参构造、无参构造、equals、hashCode、toString,代码量大且毫无营养。Java 16 正式引入 record 之后,这些都可以省略:
public record UserInfo(String userId, String name, int age) { }就这么一行,编译器会自动生成构造器、getter、equals、hashCode 和 toString。这个特性在 DTO、VO、查询参数这类不可变数据载体里非常合适。但有一点要注意,record 是 final 的,不能继承,字段也是 final 不可变的,如果你的对象需要后期修改,就不能用 record。
5.2 switch 表达式比传统写法好用太多
传统 switch 写法容易漏 break,而且只能作为语句使用。Java 14 之后的 switch 表达式支持返回值、支持箭头语法,代码紧凑很多:
String result = switch (status) { case 1 -> "待处理"; case 2, 3 -> "处理中"; case 4 -> "已完成"; default -> throw new IllegalArgumentException("未知状态"); };这个语法没有 fall-through 的问题,每个分支之间不会串,更安全。我当天把一个老代码里十几个 case 的 switch 重构成了这种写法,代码行数直接减少一半,可读性还提高了。
5.3 密封类解决了类型范围不可控的问题
密封类(sealed class)是 Java 17 正式落地的特性。它解决的核心问题是,当你设计一个抽象父类或接口时,你能明确告诉编译器“允许被哪些子类实现”,从而限制继承体系的扩展范围。写法是这样的:
public sealed interface Shape permits Circle, Rectangle, Triangle { }这样就只有 Circle、Rectangle、Triangle 三个类可以实现 Shape,其他类想继承会被编译器拦住。这个特性结合 switch 表达式用,几乎可以把原来容易漏分支的 if-else 类型判断写得干净且安全,编译器会帮你检查分支是否完整。
6. 常见问题速查表:当天遇到的坑都在这
这一节我把2月4日实际遇到的问题整理成一个速查表,方便自己和读者在开发时对照。
| 问题场景 | 错误做法 | 正确姿势 | 原理说明 |
|---|---|---|---|
| 集合遍历时被修改 | 直接 foreach 并 remove | 用迭代器或先收集再处理 | 快速失败机制靠 modCount 检测 |
| ArrayList 频繁删除 | 正序遍历 remove | 反向遍历或用 Iterator.remove | 删除涉及数组元素挪动 |
| 线程池任务堆积 | 无界队列 | ArrayBlockingQueue 设置上限 | 防止内存被任务积压耗尽 |
| 异步任务异常丢失 | 不处理异常 | get() 捕获或 exceptionally | 异常不会自己打印出来 |
| 堆内存频繁 Full GC | 盲目调大堆 | 先分析 GC 日志和 dump 文件 | 找根因比调参更有效 |
| record 用在可变对象 | 强行改字段 | 改用普通 class | record 字段不可变 |
这张表是我真实工作的缩影。很多时候出问题不是因为你不知道某个 API 怎么用,而是没想明白某个设计模式在特定场景下的边界条件。HashMap 什么时候扩容、线程池满了先丢队列还是先建线程、G1 的年轻代和老年代如何交互,这些背逻辑很容易,但要在具体场景下迅速选对方案,就得靠一次次踩坑和复盘来积累。
比如再补充一个我在当天测试时遇到的异常处理问题。用 CompletableFuture 时,如果某个异步任务抛了异常而你又没有处理,这个异常会被持有并永久吞掉,直到你调用 get() 或 join() 才重新抛出。问题就在于,很多开发者直接使用了 supplyAsync,以为方法内部 try-catch 就万事大吉,实际上异常发生在异步线程里,根本不会走调用方的 try-catch。正确做法是给每个 future 链式调用 exceptionally 方法,或者统一在一个地方 get() 并捕获 ExecutionException。
还有一个与 JVM 相关的细节是,线程池中的线程长时间空闲,会被回收,这个回收行为受 keepAliveTime 控制。如果你设置的 keepAliveTime 太长,空闲线程占用内存;如果太短,高峰期频繁创建和销毁线程又会增加开销。我一般设置 60 秒到 120 秒之间,具体要看业务流量的波峰波谷形态。
7. 当天笔记外的收藏:工具与调试方法的选择
除了代码层面的内容,2月4日我顺手整理了几个调试和测试时常用的工具。这些东西不是每天都会用到,但每次需要的时候都是雪中送炭。
7.1 jstack 分析线程状态的实用案例
服务卡住的时候,第一件事不是重启,而是用 jstack 抓线程快照。我当时遇到了一个问题:一个定时任务明明没有跑完,但日志里看不到任何报错。我连续抓了几次线程快照,发现线程几乎都停留在 WAITING 状态,而且 wait 的锁对象都是一个。顺着锁对象找过去,果然是一个资源管理器类的同步方法超长,其他线程全在门口排队等锁。这个排查过程只花了几分钟,比重启之后猜测原因效率高太多。
抓线程快照的命令很简单:
jstack <pid> > thread_dump.txt然后重点看 RUNNABLE、WAITING、BLOCKED 三个状态的数量和分布。如果大量 BLOCKED,说明锁竞争严重;如果大量 WAITING,可能是资源等待或线程池配置问题。
7.2 单元测试中的 Java 特性体验
在同一天我也给模块补了一些单元测试,顺手用上了 Mockito 的自动 mock 和多线程并发测试。给并发代码写测试,最大的难点是结果的不确定性。我用的一个辅助手段是不直接断言某一时刻的精确值,而是等一段合理的时间后断言最终状态,配合 CountDownLatch 控制线程同时开始的时机,能比较稳定地复现并发问题。
这里有个小习惯,测试并发代码时,永远不要在测试里用固定睡眠来等待结果,应该用 CountDownLatch 或 Awaitility 这类轮询等待工具。固定睡眠既不稳定,又浪费时间,CI 环境负载一高就容易误报失败。
7.3 记录一个内存分析工具的具体用法
写代码时偶尔需要分析一个方法或一段逻辑创建的临时对象,我常使用 JFR(Java Flight Recorder)来录制一段时间内的堆分配和 GC 情况。启动参数加-XX:StartFlightRecording=filename=recording.jfr,settings=profile,跑一段业务后停掉,打开 JFR 文件就能看到非常详细的事件时间线。比 jmap 有优势的地方在于它不会阻塞进程,可以在生产环境短时间开启。
用 JFR 我发现过一个很隐蔽的问题:日志框架在 debug 级别下被误开,导致每个 SQL 的绑定参数都被拼成字符串丢到堆里,高峰期光这些日志对象就占了几百兆内存。这种问题看代码很难发现,但 JFR 一看就露馅了。
8. 一点个人体会,算是这份笔记的收尾
2月4日这一天过得很充实,从早上接到性能问题,到晚上整理完笔记,中间经历了一次典型的 Java 后端调优过程。我最大的体会是:不要急躁,先从数据流和代码逻辑找到病根,再用工具验证猜想,最后才动手改。很多时候你以为是 JVM 配置问题,实际却是集合类选错或者线程池被用爆;你以为并发问题是系统的 bug,可能只是异常被吞掉没有暴露出来。
写这份 Java 笔记的另一个收获,是重新审视了那些最常见的类库和语法。HashMap 不是只有 put 和 get,LinkedHashMap 还能做 LRU;ThreadPoolExecutor 不是设几个参数就完事,它的排队机制影响整个系统的稳定性;CompletableFuture 很方便,但异常处理没做好会让你的故障排查难上加难。这些知识,面试题里见过,工作中踩过,只有在自己写出的代码里真实跑过,才算真正掌握。
最后送上一句我在实战里反复验证过的话:任何框架和工具都比不上对基础原理的熟悉程度,基础掌握了,遇到再新的问题也能很快定位到它属于哪一类场景,从而找到最合理的解法。这套笔记不是终点,我打算继续把它当成一个长期更新的项目来维护,每过一段时间就回来补充新遇到的问题和心得。如果你也在维护类似的笔记,希望在实战的道路上,我们都能越走越稳。