做 Java 做了几年之后,很多人会卡在同一个地方:CRUD 和业务代码已经写得很顺,Spring、MyBatis 这些框架配置也烂熟于心,但一旦被问到“这块的底层到底怎么运作”或者“线上突然卡死怎么排查”,心里就没底了。我自己的经验是,这往往不是努力不够,而是缺少一条清晰的进阶路线。这套 Java 进阶系列,就是想把这些年踩过的坑、调过的参、看过的源码整理出来,帮助准备跨过初级门槛的人,真正进入“懂原理、会排障、能调优”的阶段。
1. 进阶到底进阶在哪儿:先想清楚这三件事
很多刚工作两三年的朋友问我怎么学 Java,我一般不会直接丢书单。因为进阶和入门最大的区别,不在于多背了几个 API,而在于思维方式的转变。这个转变不完成,看再多源码也容易走马观花。
1.1 第一个转变:从“会用”到“懂原理”
会写HashMap<String, Object> map = new HashMap<>()很简单,但要知道它扩容时数据怎么迁移、哈希冲突到多少才转红黑树、为什么加载因子是 0.75,这才是进阶的开始。
拿我自己举个例子。前几年接了一个定时任务,每天凌晨跑批量数据,日志里一直有告警,任务偶尔超时。我一开始只会在业务代码里加日志、看耗时分布,查了两天没头绪。后来想起来用jstat看一眼 GC 情况,发现 Full GC 频繁得吓人。再一查,是某张表查询把临时数据全塞进了堆里,批量执行时内存不断被撑爆。那种感觉就是:如果不懂 JVM 内存模型,你连排查的入口都找不到。
所以进阶的第一步,是把“工具使用者”的身份放下,去理解底层机制。Java 的底层虽然被虚拟机屏蔽了很多细节,但内存区域怎么划分、对象怎么分配、锁怎么升级,这些都是编程时能直接影响决策的东西。
1.2 第二个转变:从“单线程”到“并发视野”
初级开发者写代码,默认请求是串行处理的,一个方法走完再走下一个。到了真实业务场景,几百上千个请求同时进来,线程之间共享资源、争夺锁、互相等待,这时候很多问题就出现了。
我见过一个典型的例子:某同事用SimpleDateFormat处理日期,单机测试没问题,一上线就偶发报错,而且报错时间毫无规律。原因就是SimpleDateFormat不是线程安全的,多线程共用同一个实例时内部状态被并发修改,解析结果就乱了。这种问题,没有并发视野的时候完全想不到。
进阶阶段的“并发视野”,不是说会用ThreadPoolExecutor和synchronized就行,而是要在设计之初就考虑:哪些数据会被多个线程读到?状态要不要共享?锁的粒度怎么控制才能既安全又高效?这些意识,需要不断在实际问题中练习。
1.3 第三个转变:从“跑得起来”到“扛得住故障”
刚入门时,代码能跑通、接口能返回正确结果,就已经很满足了。但进阶阶段,判断标准完全不一样:
- 接口在高峰期每秒几千次请求时,响应时间会不会抖动?
- 某个依赖服务挂掉之后,你的线程池会不会被拖垮?
- 内存占用曲线持续上升,是正常缓存还是泄漏?
- 突然告警时,你能不能从线程栈、GC 日志、慢查询日志里快速定位根因?
这些问题的背后,是稳定性意识和对运行时的理解。这也是为什么进阶系列一定要包含 JVM、并发、集合框架底层这几个主题,因为它们共同决定了一个系统能不能在压力下稳定工作。
2. JVM 内存与垃圾回收:绕不开的第一道硬门槛
JVM 是 Java 开发者最容易忽略却又躲不掉的部分。你说它抽象,其实它就在每一次对象创建、每一次 GC 停顿里;你说它难,但把几个核心区域和回收流程搞明白之后,很多线上问题一眼就能看出方向。
2.1 这些内存区域都放些什么
JVM 的运行时数据区可以粗略分成两大块:线程共享的和线程私有的。
线程共享的主要是堆和方法区。堆是对象分配的主战场,几乎所有实例对象都在这上面;方法区在 JDK 8 之后被改成了元空间(Metaspace),主要存类的元信息、静态变量、常量池这些。线程私有的包括虚拟机栈、本地方法栈和程序计数器。虚拟机栈里是一个个栈帧,每个方法调用对应一个栈帧,里面存着局部变量表、操作数栈、方法返回地址等信息。
这里有一个很常见的认知误区:很多人以为元空间还在 JVM 堆内部。实际上 JDK 8 把永久代移除后,元空间直接使用本地内存,默认情况下大小只受机器物理内存限制。这样设计的初衷是避免永久代内存溢出,但也带来了新问题——如果加载的类无限多(比如热部署反复加载类,或者反射生成大量代理类),元空间一样会爆,而且表现为OutOfMemoryError: Metaspace。
再补充一个容易被忽略的区域:直接内存(Direct Memory)。NIO里的DirectByteBuffer用的就是这块内存,它不归堆管,但会占用进程地址空间。如果项目里大量使用 Netty,又设置了较大的-XX:MaxDirectMemorySize,一旦用超了,也会抛 OOM。排查这类问题时要记得,堆内存正常不代表整个 JVM 内存正常。
2.2 垃圾回收算法与现实中的收集器
垃圾回收这件事,讲理论很容易,无非就是标记-清除、复制、标记-整理三种基本算法。但真正困难的是如何在“回收干净”和“应用停顿短”之间做权衡。
新生代对象存活率低,适合复制算法。Eden 区分配对象,Minor GC 时把存活对象搬到 Survivor 区,反复几次之后还活着的晋升到老年代。老年代对象存活率高,不适合频繁复制,所以用标记-清除或标记-整理来回收。
现实中的收集器,我重点说三个:
- Parallel GC:JDK 8 默认的收集器之一,追求高吞吐量,适合后台任务、离线计算这类对停顿不敏感的场景。优点是简单粗暴,缺点是 GC 停顿时间不可控。
- G1:为超大堆设计的区域化收集器,把堆分成多个 Region,可以预测停顿时间。现在的 JDK 17、21 里,G1 已经是非常成熟的主流选择。
- ZGC:目标是把停顿时间控制在 10ms 以内,适合超大堆、超低延迟场景。代价是 CPU 占用相对高,配置也复杂一些。我自己的观点是,如果你不是真的需要极低停顿,不要盲目追求 ZGC,G1 配好参数已经能解决 90% 的问题。
有个细节值得注意:对象进入老年代不一定非要熬过 15 次 Minor GC。如果在一次 Minor GC 时,Survivor 区放不下这些对象,它们会直接晋升到老年代,这叫“动态年龄判定”。另外大对象(比如大数组)也会直接进入老年代,用-XX:PretenureSizeThreshold可以控制阈值。
2.3 内存排查与参数调整实录
分享一次我实际调优的经历,不算多复杂,但很有典型性。
某项目的核心接口在高峰期 RT 从 50ms 涨到 2000ms,我先看了监控,发现 CPU 正常,但停顿频率很高。用jstat -gcutil <pid> 1000连续观察,发现老年代使用率一直稳定在 95% 以上,Full GC 每秒触发一次,每次耗时接近 800ms。这说明老年代内存不够,大量对象长期存活,回收器根本来不及腾出空间。
当时堆参数设的是-Xmx4g -Xms4g -XX:NewRatio=2,按这个比例,新生代和老年代大概是 1:2,老年代有 2.6G 左右。从 dump 文件来看,问题不在参数,而是有个缓存 Map 只往里放数据、从不清理,导致老年代被打满。
处理方案分两步:先临时把-Xmx调大到 8G 止血,然后定位到那份缓存代码,改成了带过期策略的本地缓存。调整之后,GC 频率降到了每十分钟一次,接口 RT 恢复正常。后来我还加了内存泄漏检测,专门盯那些增长不符合规律的集合对象。
注意:调 JVM 参数之前,一定要先搞清楚内存是被谁消耗掉的。盲目加大堆内存,可能只是把爆发时间往后推,甚至因为 GC 时间变长让问题更严重。
3. 并发编程:从锁的底层到并发工具的正确用法
并发是 Java 进阶的重头戏,也是面试和实战中最容易暴露水平差距的地方。很多人会用synchronized、会写ThreadPoolExecutor,但问到锁怎么升级、内存可见性怎么保证,就答不上来了。
3.1 synchronized 到底锁住了什么:对象头与锁升级
synchronized锁的是对象,不是代码块。任何对象都有一个对象头(Mark Word),里面保存了锁的状态信息。JDK 早期版本里,synchronized是重量级锁,直接依赖操作系统 mutex,线程阻塞唤醒的成本很高。后来 JVM 做了锁升级优化,不再是“一上来就重量级”。
锁升级的大致路径是:无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁。
- 偏向锁:假设同一个线程反复获取同一把锁,那就把锁记录在该线程的 ID,之后该线程再次进来时无需额外竞争。
- 轻量级锁:一旦有第二个线程来竞争,偏向锁撤销,升级为轻量级锁。通过 CAS 自旋在用户态做等待,避免立刻陷入内核态。
- 重量级锁:如果自旋超过一定次数、或者竞争的线程太多,就升级为重量级锁,挂起线程、等待唤醒。
理解这个过程,是为了解释为什么很多人说“不要用小对象做锁”,也解释了为什么热点代码里并发竞争严重时,synchronized的性能不一定比显式锁差——因为 JVM 做了大量优化,比如锁粗化、锁消除、自适应自旋。
不过要注意,从 JDK 15 开始,偏向锁已经被默认禁用,因为维护偏向锁撤销的成本在现代硬件和高竞争场景下不划算了。所以网上那些很老的文章里“偏向锁一定更快”的说法,现在不一定成立。
3.2 volatile 与先行发生原则:并发问题的逻辑起点
很多初学者把volatile理解为“让变量可见”,其实它做的两件事都重要:一是保证可见性,二是禁止指令重排序。
在多线程环境下,每个线程在工作内存里会缓存变量的副本,一个线程改了值,另一个线程未必立刻能看到。volatile写的变量在修改后会强制刷新到主内存,读的时候也直接从主内存拿,这样就避免了脏读。同时,volatile通过内存屏障禁止了上下文的指令重排,这在双重检查锁单例模式中很关键。
举个例子,最经典的单例写法:
public class Singleton { private static volatile Singleton instance; public static Singleton getInstance() { if (instance == null) { synchronized (Singleton.class) { if (instance == null) { instance = new Singleton(); } } } return instance; } }如果不加volatile,由于new Singleton()不是原子操作,可能先把引用地址赋给instance,再执行构造函数。另一个线程读到时,会拿到一个尚未完成初始化的对象。这属于指令重排序造成的问题,用volatile可以挡住。
深入一点讲,Java 内存模型定义了一套先行发生原则,如锁释放先行发生于后面的加锁、volatile写入先行发生于后续读取。以后排查并发问题,不要只盯着代码表面,先想想你的变量是不是存在可见性问题、锁的范围够不够,方向就容易对了。
3.3 线程池参数与高并发场景的关键选择
线程池的难点不在怎么用,而在参数怎么定。默认的Executors.newFixedThreadPool()很简单,但如果你不清楚内部队列和拒绝策略,就可能踩坑。
一个实际案例:某项目用newFixedThreadPool(10)处理短信发送任务,高峰期突然出现任务全部失败。原因是默认使用了无界的LinkedBlockingQueue,任务不断堆积,发送线程忙不过来,最后接口超时,客户端全部重试,反而加重了负担。
线程池参数计算,我心里一般这么考虑:
- 核心线程数:如果是 CPU 密集型任务,参考
CPU 核数 + 1;如果是 IO 密集型,可以按CPU 核数 * 2或通过压测观察,找到一个让 CPU 利用率比较合理、队列又不堆积的值。 - 最大线程数:不能拍脑袋定。要结合任务类型、队列容量、系统能承受的负载来定,我习惯把最大线程数压到实际能够承受的上限,而不是随意填个 200。
- 队列容量:用有界队列(比如
ArrayBlockingQueue)比无界队列安全得多。无界队列会让任务无限堆积,表面上“没拒绝”,实际上可能已经耗尽了内存。
除了ThreadPoolExecutor,很多项目里还会用到CountDownLatch、Semaphore、CompletableFuture。我的建议是:工具不在多,关键是要理解工具背后的语义。比如Semaphore适合做限流,CountDownLatch适合等一组任务全部完成,CyclicBarrier适合多个线程互相等齐再同时执行。选错了,代码也能跑,但语义不清晰,后面维护的人会很痛苦。
4. 集合框架底层逻辑:HashMap、ArrayList 背后的真相
集合是 Java 里最常用的工具,但正因为常用,很多人反而没有认真想过它为什么这么设计。不是说每个集合都要背源码,但几个核心机制一定要清楚。
4.1 为什么 HashMap 默认容量 16、加载因子 0.75、树化阈值 8
这三个数字是 HashMap 设计里最容易被问到的点。
- 默认容量 16:16 是 2 的整数次幂,这是为了配合位运算计算下标。因为
hash & (capacity - 1)效率比hash % capacity高,而要让这个公式生效,容量必须是 2 的幂。 - 加载因子 0.75:这是空间利用率与查询效率之间权衡的结果。0.75 意味着数组用了 75% 时就扩容,既不会过早浪费空间,也不会让链表过度变长导致查询变慢。
- 树化阈值 8:JDK 8 引入红黑树来应对哈希冲突严重的情况。为什么是 8 而不是 5 或 10?官方注释里给了泊松分布推导:当加载因子是 0.75 时,一个桶内链表长度达到 8 的概率已经非常非常低(小于千万分之一)。既然能到这个程度,说明哈希函数大概率出了问题(比如很多 key 撞到同一个槽位),用红黑树换查询效率是划算的。
不过红黑树不是越多越好。如果 key 的 hashCode 分布正常,链表一般都很短,根本不会触发树化。我见过有人调小树化阈值想提高性能,效果反而不好,因为红黑树的节点结构比链表大,维护平衡也有开销。默认参数是设计者算过的,没充分理由不要乱改。
还有一个经常被忽略的点:为什么 JDK 8 之后的扩容不会出现 JDK 7 里的死循环问题?因为 JDK 7 用的是头插法,并发扩容时链表会形成环;JDK 8 改用尾插法,而且引入了红黑树,同时优化了扩容时的迁移方式。虽然 JDK 8 依然不适合直接用 HashMap 做并发共享,但至少减少了死循环这种极端风险。
4.2 从 HashMap 到 ConcurrentHashMap:并发安全的代价与收益
说到并发场景下的 Map,绕不开ConcurrentHashMap。JDK 7 里的实现是分段锁:把数组分成多个 Segment,每个 Segment 拥有一把锁,不同段之间可以并发操作。JDK 8 改成了更细粒度的方式,放弃了 Segment,直接对桶位(数组下标)做 CAS +synchronized同步。这样做的好处是,锁的粒度从“一段”缩小到“一个桶”,并发度更高;而且只有发生哈希冲突时才会锁住,冲突少时性能好很多。
但ConcurrentHashMap也不是万能药。它的弱一致性迭代器(遍历过程中能读到新增的数据但不能保证强一致)在某些需要“快照读”的场景里不适用;同时,它的size()方法并不是 O(1),而是通过统计各个桶的计数来估算,在高并发修改频繁时可能不准。
我自己在项目里的一个教训是:如果把ConcurrentHashMap当成普通全局缓存,一边遍历一边删除,容易出现与预期不一致的结果。后来改成先收集删除键,遍历完成后再统一 remove,问题就没了。
4.3 ArrayList、LinkedList 与写时复制容器的适用边界
这可能是被误解最多的一个题目。很多人背过“ArrayList 查询快、增删慢;LinkedList 增删快、查询慢”,但真实场景完全不是这么简单。
ArrayList扩容是每次加大约 50%(新容量 = 旧容量 + 右移一位),然后Arrays.copyOf整段拷贝。如果你预先知道大小,用ArrayList<>(expectedSize)可以避免多次扩容。LinkedList的“增删快”其实只体现在头尾操作很快;如果在中部插入,你要先遍历找到那个位置,复杂度依然是 O(n)。
我建议实际项目里,绝大部分场景直接用ArrayList。LinkedList 那个双链表结构在内存上不连续,对 CPU 缓存不友好,遍历性能往往低于 ArrayList。真正适合 LinkedList 的场景非常少,比如你确定需要高效的头部插入/删除,并且使用迭代器遍历。
写时复制容器CopyOnWriteArrayList是另一个容易被误用的集合。它读的时候不加锁,写的时候复制底层的数组,所以适合“读多写极少”的场景。如果写频率高,每次写都复制整个数组,性能就是灾难。这个容器典型的使用场景是事件监听器列表、配置项列表这类基本不会变更的数据。
5. 现代 Java 特性的进阶用法:不是花哨,而是刚需
很多老开发者还在用 JDK 8 的老写法,不是不好,但如果你已经在用 JDK 17 或 21,有些新特性确实能显著改善代码质量。
5.1 Stream API 的短路求值与流水线思维
Stream 不是把 for 循环换个写法那么简单,它的核心价值在于“描述怎么做”,而不是“遍历元素去操作”。用流水线的思维写代码,可读性会高很多:
List<Order> result = orders.stream() .filter(o -> o.getAmount() > 100) .sorted(comparing(Order::getCreateTime).reversed()) .limit(20) .toList();这个表达式的执行过程并不是先做完 filter 再排序再截断,而是惰性求值的:只有遇到终止操作(比如toList())时,才会真正执行。limit(20)可以帮助短路,不用把过滤后的所有数据都排序。
但凡是都有代价。Stream 在复杂嵌套、需要变量中间状态的时候,反而会写出很难懂的代码。我的原则是:能用 Stream 表达清晰的数据转换就用;涉及循环内外部状态多变、需要提前 break 的场景,传统 for 循环更直白。工具是为人服务的,不要为了“函数式”而函数式。
5.2 record 带来的不可变数据实践
record是 Java 16 正式引入的,它用很少的代码定义不可变数据载体:
public record UserInfo(Long userId, String name, String email) {}它会自动生成构造方法、equals()、hashCode()、toString()。更重要的是,它的设计目标就是不可变:成员都是 final 的,不能用 setter 改。在一个并发系统里,不可变对象天生线程安全,可以放心共享。
我自己用record的感受是:它特别适合做 DTO、接口返回值、领域事件这些“传完就不改”的数据结构。以前写一个 DTO 要几十行代码,现在一两行就完事。要注意record不是用来替代所有类的,如果一个对象有复杂的行为、状态变化,还是定义一个普通类。
5.3 新版 switch 和文本块:小改动带来的体验提升
JDK 17 之后,switch表达式的写法变得更精炼了。比如可以这样:
int days = switch (month) { case 1, 3, 5, 7, 8, 10, 12 -> 31; case 4, 6, 9, 11 -> 30; case 2 -> 28; default -> throw new IllegalArgumentException("非法月份"); };箭头语法不需要写break,返回值可以直接放在箭头后面。这种小改进在代码量大的项目里,长期积累下来,维护成本能降低不少。
文本块(Text Block)也是我常用的特性,写 SQL、JSON、HTML 模板的时候再也不用一堆\n和转义符了:
String sql = """ SELECT id, name, amount FROM orders WHERE create_time > ? """;要提醒的是,文本块里缩进会被保留,要按需要调整对齐。上手成本极低,属于“用过就回不去”的小特性。
6. 进阶路上的排障与学习方法
理论说再多,最后还是得落到“能不能解决问题”上。这里分享几个典型的排障现场,以及我平时怎么练进阶。
6.1 用一个线上线程卡顿问题认识 jstack
某次线上告警,系统接口大面积超时,用top -H看 CPU 占用并不高,但很多线程处于阻塞状态。这时候最有用的操作是执行:
jstack <pid> > thread.log然后把输出里大量出现的java.lang.Thread.State: BLOCKED或WAITING线程找出来,重点是看它们等待的锁是什么、被谁持有。那次定位结果是某个第三方 SDK 内部用了同步块,阻塞在了一个全局锁上。问题不在业务代码,但通过线程栈一眼就能看到等待关系,进而排查到引入的 SDK 和调用方式。
线程 dump 有时候拿不到有效信息,可以隔几秒多取几次,看同一个线程的状态是否一直在变、卡在哪个方法上。如果多次 dump 都停在同一个方法名,那个方法大概率就是瓶颈所在。
6.2 用一个堆溢出场景走通内存分析流程
OOM 不可怕,可怕的是不知道怎么下手。我习惯是,先用参数把现场留出来:
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/app/logs/heapdump.hprof这样 JVM 在抛出堆溢出异常时,会自动把堆内存快照写到文件里。之后用 MAT 或者类似的堆分析工具打开 dump,直接看“支配树”或“可疑泄漏报告”,通常能快速找到占用最多内存的对象以及它的引用链。
我记得分析过一次:dump 里有一张很大的HashMap,里面全是没人使用的会话对象,时间永远停留在昨天。顺着引用链追下去,发现是某个定时任务没清缓存,每次都往同一个 key 下追加数据,导致无限增长。这种问题从业务日志很难看出来,但内存快照一目了然。
还要区分一下:StackOverflowError通常是递归过深,OutOfMemoryError: Java heap space是堆对象太多,OutOfMemoryError: Metaspace是类元信息过多,三种情况的处理思路完全不一样。不要一看到 OOM 就只想到加内存,先搞清楚是哪块内存出了问题。
6.3 哪些学习方法能让你真正进阶
学了这么多知识,怎么安排学习顺序?我给朋友的建议一般是:
- 先把自己天天用的集合和并发类源码看明白,比如 HashMap、ArrayList、ThreadPoolExecutor。不要从头读到尾,带着问题看:为什么这么设计?扩容为什么不一次扩两倍?
- 再学会用工具看运行状态,
jstat、jstack、jmap这三个命令是底线。不用背参数,但要留个印象,出了问题知道用哪个工具。 - 然后做一次完整的线上问题复盘。不管是你自己遇到的还是同事遇到的,把根因分析、临时措施、长期修复整理出来。一次完整复盘,比刷十篇文章管用。
- 最后是常用框架源码的阅读,比如 Spring 的核心加载流程、MyBatis 的连接管理。不需要全部读懂,抓住关键路径、理解设计意图就够了。
还有一个习惯我觉得特别重要:写代码的时候多想一步“如果这个数据变成十年前的十倍,这段代码还会不会正常工作”。经常这样自问,你会在数据结构和并发设计上慢慢形成直觉,而不是等到系统卡死再来临时补救。
说实话,Java 进阶这条路没有捷径,但也没那么陡峭。我自己的体会是,卡住人的往往不是某一门技术,而是“不知道从哪里下口”。把 JVM 内存、并发机制、集合原理和排障工具这几块组合起来,从一次真实的故障开始练习,很快你就会发现,原来那些看起来高大上的底层知识,其实就是你每天都在用的东西。