资讯详情

资讯详情

Java进阶路线:从JVM内存、并发编程到线上排障实战

做 Java 做了几年之后很多人会卡在同一个地方CRUD 和业务代码已经写得很顺Spring、MyBatis 这些框架配置也烂熟于心但一旦被问到“这块的底层到底怎么运作”或者“线上突然卡死怎么排查”心里就没底了。我自己的经验是这往往不是努力不够而是缺少一条清晰的进阶路线。这套 Java 进阶系列就是想把这些年踩过的坑、调过的参、看过的源码整理出来帮助准备跨过初级门槛的人真正进入“懂原理、会排障、能调优”的阶段。1. 进阶到底进阶在哪儿先想清楚这三件事很多刚工作两三年的朋友问我怎么学 Java我一般不会直接丢书单。因为进阶和入门最大的区别不在于多背了几个 API而在于思维方式的转变。这个转变不完成看再多源码也容易走马观花。1.1 第一个转变从“会用”到“懂原理”会写HashMapString, 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 GCJDK 8 默认的收集器之一追求高吞吐量适合后台任务、离线计算这类对停顿不敏感的场景。优点是简单粗暴缺点是 GC 停顿时间不可控。G1为超大堆设计的区域化收集器把堆分成多个 Region可以预测停顿时间。现在的 JDK 17、21 里G1 已经是非常成熟的主流选择。ZGC目标是把停顿时间控制在 10ms 以内适合超大堆、超低延迟场景。代价是 CPU 占用相对高配置也复杂一些。我自己的观点是如果你不是真的需要极低停顿不要盲目追求 ZGCG1 配好参数已经能解决 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:NewRatio2按这个比例新生代和老年代大概是 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 设计里最容易被问到的点。默认容量 1616 是 2 的整数次幂这是为了配合位运算计算下标。因为hash (capacity - 1)效率比hash % capacity高而要让这个公式生效容量必须是 2 的幂。加载因子 0.75这是空间利用率与查询效率之间权衡的结果。0.75 意味着数组用了 75% 时就扩容既不会过早浪费空间也不会让链表过度变长导致查询变慢。树化阈值 8JDK 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 循环换个写法那么简单它的核心价值在于“描述怎么做”而不是“遍历元素去操作”。用流水线的思维写代码可读性会高很多ListOrder 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 内存、并发机制、集合原理和排障工具这几块组合起来从一次真实的故障开始练习很快你就会发现原来那些看起来高大上的底层知识其实就是你每天都在用的东西。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →