写Java的这些年,被问得最多、也最容易让面试官一眼看穿功力的,就是JVM内存和GC。倒不是这东西有多玄,而是大多数人停留在会背参数、会画图,真到线上OOM或者Full GC频繁时,手里的工具完全不听使唤。这篇文章我就带着你从头到尾过一遍,从对象在JVM里第一次被new出来,到它最终被垃圾回收器带走,中间经过的内存区域、判定规则、回收器选型和调优手段,全部摊开讲清楚。适合刚入门的同学建立完整认知,也适合有几年经验、想在面试和线上排查时更硬气一点的开发者。看完你能收获的只有一样东西——面试官和线上故障都拿你没办法的底气。
1. 对象诞生:一条new指令引发的多米诺效应
1.1 类加载检查:JVM比你想的更怕"走一步看一步"
一个对象怎么诞生的?我们写一行Object obj = new Object(),字节码层面其实对应了new、dup、invokespecial、astore这几条指令。new这一步,JVM首先做的不是立即分配内存,而是先检查这条指令后面的参数——也就是常量池里的类符号引用——对应的类是否已经被加载、解析、初始化过。如果没加载,就要先触发类加载流程:加载、验证、准备、解析、初始化。
这一步经常被忽略,但实际很关键。比如你new的是一个接口或者抽象类,类加载检查阶段就会直接抛InstantiationError。再比如类初始化触发了静态代码块里的异常,会包装成ExceptionInInitializerError。我见过不少同学排查ClassNotFound报错时一头雾水,其实八成是类加载阶段某个依赖类没被引入,或者依赖类初始化时自己炸了,导致整个new的动作失败。面试时能讲出"new不等于分配内存,先做类加载检查"这个层次,已经能甩开一批背书选手了。
1.2 内存分配:指针碰撞还是空闲列表,由堆规整度决定
类加载检查通过后,JVM开始为新生对象分配堆内存。分配方式和堆的规整程度强相关。
如果堆内存是规整的,用过的内存和没用的内存中间有个指针作为分界点,分配内存只需要把指针向空闲方向挪动一段与对象大小相等的距离,这叫指针碰撞。如果堆内存不规整,已用和空闲内存交错分布,JVM就得维护一个空闲列表,分配时从列表里找一块足够大的空间,这叫空闲列表。
到底用哪种,取决于垃圾回收器是否带压缩整理功能。比如Serial、ParNew这类带Compact能力的回收器用的是指针碰撞;而CMS这种基于标记-清除的回收器,用的是空闲列表。这也是为什么CMS对内存碎片敏感,老年代碎片多了之后会退化为Full GC,后面讲CMS时会细说。
这里还有一个并发分配的问题:多个线程同时new对象,同一时刻都去改那个指针,必然冲突。HotSpot的解决方案是TLAB,也就是线程本地分配缓冲区。简单说,每个线程在堆的Eden区预分配一小块私有区域,new对象优先在自己的TLAB里分配,TLAB用完了再加同步锁走CAS重试。所以绝大多数小对象分配都很快,根本不需要全局锁。这也是为什么-XX:UseTLAB默认开启,而且基本不建议关。
1.3 逃逸分析与栈上分配:对象不一定都活在堆里
传统认知是"new的对象都在堆上",这个说法放在现代JVM里已经不够准确了。HotSpot做了逃逸分析,如果发现一个对象只在方法内部使用,没有被方法外部引用,也就是没有"逃逸"出去,那这个对象可能直接拆散分配到栈上,或者做标量替换,根本不创建真正的对象实例。
举个最直观的例子:方法里定义一个Point对象做坐标计算,这个Point没有返回给调用方,也没塞进集合里,它就是个典型的非逃逸对象。JVM开启逃逸分析后,可能直接把它的x和y两个字段拆成两个局部变量放在栈帧里,省掉了堆分配和后续回收的成本。参数对应-XX:+DoEscapeAnalysis,JDK 8默认开启;-XX:+EliminateAllocations控制是否做标量替换,也是默认开启的。
这里要提醒一句:逃逸分析不是万能的,它只是编译器做的一大堆优化里的一个环节。我见过有人为了"配合逃逸分析"故意把对象写得很小或者故意内联,结果代码可读性一塌糊涂,实际收益微乎其微。绝大多数情况下,让JIT正常发挥就行,不需要业务代码去迎合它。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 内存全景:一张图看懂JVM把内存花在了哪
2.1 运行时数据区:五个区域,各自管什么
JVM把运行时内存划分成几个区域,最核心的是堆、虚拟机栈、本地方法栈、方法区、程序计数器。
- 程序计数器:每个线程一块小空间,记录当前线程执行到哪条字节码指令。分支、循环、跳转、异常恢复都靠它。这是唯一不会OOM的区域。
- 虚拟机栈:线程私有,每调用一个方法就压入一个栈帧,栈帧里有局部变量表、操作数栈、动态链接、方法出口。压得太深就会
StackOverflowError,比如无限递归。如果栈允许动态扩展,内存不够时也会OOM,不过HotSpot里栈容量本质是固定的,所以一般只谈StackOverflowError。 - 本地方法栈:给native方法用的,HotSpot直接把它和虚拟机栈合并了,所以JVM参数里没有单独的本地方法栈大小设置。
- 堆:所有线程共享,对象几乎都在这分配。GC的主要战场,大小用
-Xms和-Xmx控制。 - 方法区:存类的元信息、常量、静态变量、JIT编译后的代码。JDK 8之后用元空间实现,默认不受JVM堆内存限制,受本地内存限制,用
-XX:MaxMetaspaceSize控制上限。
很多人把"虚拟机栈"和"本地方法栈"搞混,其实区分很简单:前者服务Java方法,后者服务native方法。你在看堆栈dump时,看到的那个长长的一串方法调用链,就是虚拟机栈的栈帧堆出来的。
2.2 堆内布局与元空间上位:为什么永久代说没就没
堆内部进一步划分成新生代和老年代。新生代里又有Eden和两个Survivor区,默认比例是8:1:1。这个比例很多人知道是8:1:1,但未必知道为什么是8:1:1——因为每次Minor GC后,存活对象需要从Eden和Survivor之一挪到另一个Survivor,能容纳多少存活对象取决于Survivor的大小,8:1:1这个比例意味着有10%的堆空间用于保存那些熬过一次GC但还没到老年代的对象,留90%给短命对象去死。
方法区在JDK 8之后用元空间替代永久代,根本原因是永久代经常搞出内存溢出,而且和堆共享一块空间,调起来很别扭。元空间放在本地内存里,默认上限只受机器物理内存约束。不过别高兴太早,这也意味着如果代码里动态生成了海量类,你可能会直接把机器内存吃到爆,而不会看到传统的PermGen space报错,排查起来反而隐蔽。所以生产环境一定要设置-XX:MaxMetaspaceSize,别裸奔。
2.3 堆外内存:GC管不到的隐形杀手
除了堆和元空间,还有一块经常被遗忘的内存:直接内存,也就是堆外内存。NIO的ByteBuffer.allocateDirect()分配的就是它,不受GC管理,不受堆大小限制,受-XX:MaxDirectMemorySize控制,默认等于-Xmx。
堆外内存的优点是减少一次堆内外的拷贝,适合高吞吐网络传输;代价是回收依赖Cleaner机制,需要等到关联的DirectByteBuffer对象被GC回收时才会触发清理。如果代码里频繁分配DirectByteBuffer但一直持有引用不释放,堆外内存会悄悄吃满,free -m一看内存全没了,但JVM堆占用很低,那种"内存神秘消失"的问题多半就是它。排查时除了看GC日志,还要用jcmd、NMT(Native Memory Tracking)去看本地内存分布。
3. 生死判定:JVM如何知道一个对象该不该死
3.1 可达性分析:从GC Roots出发的引用链追踪
判定对象是否存活,主流的做法不是引用计数法,而是可达性分析。思路很简单:从一组称为GC Roots的根对象出发,沿着引用往下走,能走到的对象就是存活的,走不到的就是可回收的。
引用计数法的致命缺陷是循环引用。A引用了B,B引用了A,但外部没有任何人再引用它们俩了,各自计数还是1,垃圾回收器永远收不掉。面试时问"为什么不用引用计数法",答案就是这句,简练又致命。
GC Roots都包括哪些?虚拟机栈里引用的对象(本地变量、参数等)、静态属性引用的对象(static变量)、常量引用的对象(比如String常量池里的引用)、JNI引用、同步锁持有的对象(synchronized锁定的对象)、JMXBean和本地代码缓存等。这里有个容易忽略的点:栈上引用的对象是GC Roots,但对象内部的字段引用的其他对象不算直接的GC Roots,只是会被可达性分析遍历到。两者的区别在于,GC Roots是遍历的起点集合,而普通对象引用只是路径上的边。
3.2 三色标记与并发难题:Stop The World为什么逃不掉
可达性分析听起来简单,真正复杂的是在并发场景下做这件事。JVM里的做法是三色标记:对象初始是白色,表示未访问;被访问到后标成灰色,表示自身已访问、引用还没处理完;引用的所有对象都处理完了,标成黑色。
理想情况下,垃圾回收器从根出发,把可达对象全部标成黑色,剩下的白色就是垃圾。但垃圾回收线程和业务线程并发运行时,业务线程可能一边改引用一边被标记,就会导致两种严重后果:一种是把活着对象误标成垃圾,叫"浮动垃圾漏标";另一种是垃圾被错标成存活,叫"错标"。
为了解决这个问题,CMS用了增量更新,G1用了SATB(Snapshot-At-The-Beginning),ZGC用了读屏障。无论哪种方案,有一个环节几乎必然要停一下业务线程——重新标记阶段,把所有线程的引用状态对齐到一致。这就是Stop The World短暂停顿的由来。完全无停顿的GC理论上是存在的,但实践中总要在吞吐量和内存之间做取舍。所以面试里你听到"为什么GC会让程序停顿",本质上是并发一致性成本太高,只能靠短暂冻结来换取正确性。
3.3 引用的四种姿态:强、软、弱、虚
从JDK 1.2开始,Java把引用分成了四种:强引用、软引用、弱引用、虚引用。
- 强引用:平时写的
Object obj = new Object()就是强引用。只要强引用还在,对象绝不回收。 - 软引用:
SoftReference,内存不够时才会回收,典型用途是做缓存。比如图片缓存、大对象缓存,内存稀缺时可以兜底。 - 弱引用:
WeakReference,下一次GC就回收。典型用途是ThreadLocal的ThreadLocalMap里的Entry,就是弱引用,避免线程被ThreadLocal对象钉死在内存里。 - 虚引用:
PhantomReference,最弱的存在,拿不到对象实例,仅用于在对象被回收时收到一个系统通知。NIO的DirectByteBuffer清理、ReferenceQueue机制都依赖它。
这里有个经典避坑点:使用ThreadLocal时,ThreadLocalMap的key是WeakReference形式的ThreadLocal,value是强引用。如果线程长期存活,ThreadLocal被置空后,value还是强引用挂着,就会内存泄漏。所以ThreadLocal使用完毕后要养成remove()的习惯,这是网上任何教程都强调但实际能做到的人不多的细节。
4. 分代回收:绝大多数对象活不过第一轮GC
4.1 弱分代假说:大部分对象都是"见光死"
分代回收的理论基础是弱分代假说:绝大多数对象存活时间极短,朝生夕灭。基于这个观察,JVM把堆分成新生代和老年代。
新生代里又细分成Eden和两个Survivor区。新对象先分配到Eden区,如果TLAB空了则新开TLAB。Eden塞满时触发Minor GC,存活对象被挪到Survivor区,并且年龄加1。下一次Eden满时,Eden和一个Survivor里的存活对象一起挪到另一个Survivor。两个Survivor轮流倒腾,保证了任意时刻有一个Survivor是空的,用于承载下次晋升对象。
为什么需要两个Survivor?如果只有一个Survivor,存活对象就没地方"进阶",会被直接丢进老年代,那老年代就会堆满短命对象,Full GC频率飙升。两个Survivor来回倒,让对象在老年代门口多熬几年,很多本来就是短命对象的就被淘汰在Survivor里了。
4.2 晋升老年代:年龄阈值、动态年龄与大对象
对象什么时候升级到老年代?三个机制:
- 年龄阈值:每熬过一次Minor GC,年龄加1。默认到15就晋升,对应
-XX:MaxTenuringThreshold。HotSpot里这个值最大15,因为对象头里的分代年龄字段只有4位。 - 动态年龄判定:如果Survivor里同龄对象的总大小超过Survivor空间的一半,年龄大于等于这些对象的就直接进老年代,不需要等到15。这个机制是为了防止Survivor被一堆"半老不老"的对象塞爆。
- 大对象直接进老年代:超过
-XX:PretenureSizeThreshold的大对象直接在老年代分配,避免在新生代反复复制。不过这个参数在G1里被弱化了,G1有自己的一套判断逻辑。
补充一个经验:线上大量对象频繁晋升老年代,经常是Survivor空间设置太小,存活对象放不下,只能提前晋升。很多人习惯一遇到Full GC频繁就盲目加大-Xmx,其实先看一眼survivor的占用和晋升速率,往往比无脑扩堆更有效。
4.3 新生代回收的流程还原:Eden、S0、S1的三角戏
完整走一遍新生代Minor GC的流程:
- 新对象分配进Eden区的TLAB或者直接Eden。
- Eden空间不足时,触发Minor GC。
- 从GC Roots出发,遍历Eden和当前使用的Survivor(假设是S0),把存活对象复制到另一个空闲的Survivor(S1)。
- 存活对象年龄加1,如果年龄达到阈值或触发动态年龄判定,就晋升到老年代。
- 清空Eden和S0。下一次新对象仍然分配在Eden,等到Eden再满时,遍历Eden和S1,复制到S0,两个Survivor角色互换。
整个过程用的是复制算法,代价低、无碎片,但代价是牺牲了一部分内存空间。这也是为什么新生代绝大多数对象能立刻死掉时才划算——如果对象存活率高,复制开销会飙升,比如程序里不小心造了一堆长生命周期对象,Minor GC会持续卡顿,光看日志就是"Pause Young (Allocation Failure)"半天不停。
5. 垃圾回收器巡礼:Serial、Parallel、CMS、G1与ZGC
5.1 Serial与Parallel:单线程与多线程的原始时代
Serial是最古老的回收器,新生代用复制算法,老年代用标记-整理,全程Stop The World,而且只用一条线程。单核CPU或者Client模式下它能用,因为它简单、无线程切换开销。在多核机器上就不行了,停顿时间长到怀疑人生。
Parallel是Serial的多线程版本,新生代老年代都并行GC,停顿时间仍然不可控,但吞吐量高,适合对吞吐率敏感的批量计算、离线任务场景。它关注的是"单位时间内干活多不多",而不是"单次停顿短不短"。如果线上是Web应用,追求低延迟,Parallel通常不是首选。
这里有个概念要分清:并行(Parallel)和并发(Concurrent)不一样。并行指多个GC线程同时工作;并发指GC线程和业务线程同时工作。CMS和G1都是并发收集器,Parallel仅仅是并行。
5.2 CMS:低延迟先驱,成也并发败也碎片
CMS(Concurrent Mark Sweep)是首个真正以"低停顿"为目标的商业级别回收器。它的老年代回收分四步:初始标记、并发标记、重新标记、并发清除。初始标记和重新标记需要Stop The World,但时间很短;并发标记和并发清除可以和业务线程并行,所以整体停顿被大幅压缩。
CMS的致命问题有两个。第一个是内存碎片。它是标记-清除算法,不压缩,堆碎片多了以后没有连续空间分配大对象,会触发一次Full GC做压缩。第二个是并发模式失败,Concurrent Mode Failure。并发清理过程中,业务线程需要的老年代内存不够,JVM只能退化到Serial Old做Full GC,停顿直接爆炸。调优CMS时,通常要提前触发老年代GC,比如调低-XX:CMSInitiatingOccupancyFraction,给并发GC预留足够空间。
CMS在JDK 9被标记废弃,JDK 14被移除。但对很多老项目来说,线上跑的还是CMS,只是新项目默认都是G1了。理解CMS的原理依然有意义,因为G1很多思想是从CMS继承的。
5.3 G1:区域化与可预测停顿的集大成者
G1把堆分成一个个Region,每个Region可以扮演Eden、Survivor或者Old,甚至还有专门的Humongous区放超大对象。G1不再严格区分新生代和老年代在物理上是整块的,而是逻辑上动态划分。
G1的回收过程分为Young GC和Mixed GC。Young GC只回收新生代(跨若干Region);Mixed GC会同时回收部分老年代Region。G1的核心设计目标是可预测停顿:通过-XX:MaxGCPauseMillis指定目标停顿时间,G1会统计各Region的回收收益(回收能释放多少内存、耗时多少),然后优先回收收益最大的Region集合,这就是G1里著名的"回收集"选择机制。
G1的并发标记用SATB快照,一边标记一边允许业务线程运行,代价是可能产生浮动垃圾。如果老年代占用达到-XX:InitiatingHeapOccupancyPercent,默认45%,G1会开始并发标记周期,之后触发Mixed GC。调优G1时,别把-XX:MaxGCPauseMillis设得太激进,我看到有人设成10ms,结果GC线程频繁调整回收集,反而导致吞吐下降。一般设100ms到200ms比较合理。
G1也不是万能的,如果应用堆特别大(比如几十GB)且要求极低停顿,G1依然力不从心。这时候要考虑ZGC。
5.4 ZGC:大堆低延迟的终极答案
ZGC的目标非常明确:无论堆多大,把停顿时间压到10ms以内,甚至更少。ZGC用了染色指针和读屏障,把标记、转移等大部分工作都做成了并发。它的停顿阶段极短,不随堆大小线性增长。
ZGC在JDK 15转正,JDK 17里已经能应对绝大多数大堆场景。它适合超大堆、低延迟、高并发响应的在线服务。不过ZGC的CPU占用比G1高一些,因为读屏障影响了部分业务线程的指令流效率。如果你的堆只有几个GB,用ZGC反而不如G1。一句话:选回收器要看业务场景,ZGC是"大堆低延迟"场景下的终极答案,但不是所有场景的银弹。
| 回收器 | 线程模型 | 算法 | 目标 | 适用场景 |
|---|---|---|---|---|
| Serial | 单线程 | 复制+标记整理 | 简单可靠 | 单核、小堆 |
| Parallel | 多线程 | 复制+标记整理 | 高吞吐 | 离线计算、批处理 |
| CMS | 并发 | 标记清除 | 低停顿 | 老项目、中小堆 |
| G1 | 并发+并行 | 区域化复制 | 可预测停顿 | JDK 9+默认,中大多堆 |
| ZGC | 并发 | 染色指针+读屏障 | 超大堆低延迟 | 大堆高并发在线服务 |
6. 调优实战:从GC日志到一次线上Full GC复盘
6.1 让GC开口说话:日志参数与日志解读
调优第一步永远是看日志,没有日志谈调优就是耍流氓。常见的日志参数组合:
bash复制-Xlog:gc*:file=/opt/applogs/gc-%t.log:time,uptime,level,tags:filecount=5,filesize=20m
这是JDK 9+的写法,统一日志框架。JDK 8的话传统写法是-verbose:gc -XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:gc.log。
日志里最需要关注的几个信息:Minor GC频率、Full GC频率、每次GC后的堆占用、晋升大小、停顿时长。特别是Allocation Failure出现频率太高,说明新生代每次都是被分配请求打满才触发的GC,可能分配速率过快。
6.2 一例真实调优:两天一次的Full GC怎么被按住的
曾经有个线上服务,堆设置了8GB,老年代4GB,跑了两天就出现一次Full GC,每次停顿好几秒,接口超时告警不断。当时我拿到GC日志看到的特征是:Minor GC后,老年代占用从1GB涨到2.2GB,涨幅明显异常;Full GC之前老年代已经到3.8GB,但可回收对象不多,GC后老年代只降到3GB。
从这里能判断:第一,大量对象在快速晋升老年代;第二,老年代里大部分是活着的对象,回收收益低。进一步分析对象分布后发现,代码里有一个缓存类用了并发HashMap持有对象的强引用,缓存没设过期,越堆越多。
措施分两步:缓存改成软引用包装,允许内存紧张时回收;堆参数上把-XX:MaxTenuringThreshold从默认15调低到5,让那些短命对象尽量在新生代多经历几次GC再进老年代,避免一次性涌入。改造后Full GC频率从两天一次变成几乎两周一次,最直观的变化是线上GC日志里Full GC那一行几乎看不到了。
这个案例说明,调优不要只盯着GC参数,先看代码里有没有不该长期持有的对象。参数调优是最后一步,代码里乱持有引用才是元凶。
6.3 压测时怎么验证调优效果
调完参数和代码,必须在压测环境验证。验证维度有三个:GC频率变化、单次停顿时长、吞吐量变化。用jstat -gcutil <pid> 1000每秒采样一次,观察Eden、Survivor、Old的占比曲线;再用jstack抓几次线程快照,确认没有业务线程长时间Blocked。
压测时有个坑:压测客户端本身的吞吐波动会影响服务端GC表现,所以最好多轮压测取平均值,别用一轮数据下结论。此外,GC日志要保留多个文件轮转,方便事后回溯对比。我习惯把GC日志、堆dump、线程dump三样东西一起保留,问题复盘时缺一不可。
7. 故障排查与面试考点:OOM速查与必背知识点
7.1 OOM分型与排查思路速查表
| 异常类型 | 触发场景 | 排查重点 |
|---|---|---|
| Java heap space | 堆内存不足,创建太多对象 | 堆dump分析,查大对象和泄漏对象 |
| GC overhead limit exceeded | GC几乎不停但回收不到空间 | 看是否频繁Full GC,堆是否过小 |
| StackOverflowError | 递归过深、栈溢出 | 看线程栈,排查无限递归 |
| Metaspace | 元空间不够,动态生成类过多 | 查CGLib/反射生成类、重复加载类 |
| Direct buffer memory | 堆外内存耗尽 | 查NIO、Netty的ByteBuffer分配 |
| Unable to create new native thread | 线程数超系统限制 | 查线程数、系统ulimit |
大多数堆OOM,第一步都应该生成堆dump然后分析。命令是jmap -dump:format=b,file=heap.hprof <pid>,线上不建议直接用jmap,最好提前加-XX:+HeapDumpOnOutOfMemoryError,让OOM自动输出dump,再用MAT或者JProfiler分析Dominator Tree找大对象和引用链。
7.2 面试必问的GC细节:每个问题都是一个故事
面试里高频出现的GC问题,答案往往不是背定义,而是讲逻辑。
比如"什么对象会进入老年代",答案是年龄阈值、动态年龄判断、大对象直接进老年代三件事综合作用。再问"为什么大对象直接进老年代",是因为新生代的复制算法遇到大对象,复制成本太高,不如直接放老年代。
再比如"CMS和G1有什么区别",不要只会说CMS用标记清除、G1用Region。要说出G1能做到可预测停顿,CMS不能;CMS对碎片敏感,G1天然规避碎片;G1把堆划分成Region,能细粒度控制回收集。
还有一个挺容易被问倒的冷门点:"对象头里存了哪些东西"。对象头含Mark Word,存放哈希码、分代年龄、锁状态标记等;还含类型指针,指向类元数据。数组对象还有数组长度。Mark Word在运行时状态变化很频繁,比如无锁、偏向锁、轻量级锁、重量级锁切换时,Mark Word的内容会跟着变。这些细节能答出层次,才算真的理解了对象在JVM里的生存形态。
7.3 避坑心得:一些容易引火上身的操作
第一,别在代码里手写System.gc()。显式触发Full GC在线上绝大多数时候是帮倒忙,会让应用莫名其妙卡顿。除非你有明确目的,比如刚好在一个低峰期想主动压缩堆,否则别碰。
第二,别把-Xmx调得太大就以为万事大吉。堆太大时,Full GC的停顿时间也会变长,因为要扫描的存活对象更多。堆大小要结合对象存活率、GC频率、业务可接受的停顿时间三者平衡。
第三,-Xms和-Xmx强烈建议设成一样。避免JVM在运行中频繁收缩扩容堆内存,不仅CPU浪费,还会在扩容时产生额外停顿。默认扩容收缩是一个很蠢的默认行为。
第四,压测时一定开GC日志并保存。很多时候性能问题在压测环境不看GC日志,上了生产才暴露,那时候再细看往往已经晚了。我见过太多团队上线前不开日志,上线后出了问题才发现没有任何数据可以用来复盘。
结束语:先用日志说话,再动手调参
写到最后分享一点个人体会:JVM内存和GC这个主题,越深入越发现它就是"先看清现状,再谈优化"的典范。很多人一上来就调参数、换回收器,结果连GC日志都没瞟一眼——这走不通的。真正常见的问题,比如ThreadLocal没remove、缓存无界、一次请求加载了全表数据,这类代码层面的事远远多于JDK本身的缺陷。建议每个Java工程师都先学会看jstat、jmap、jstack,再学GC日志的参数和解读,最后才去背那一堆JVM启动参数。顺序对了,这个技能树爬下来就会特别顺畅。最后留个小技巧:遇到GC问题别急着上工具,先问三件事——堆里什么对象最多,谁持有了它,它的生命周期被谁拉长了。这三个问题回答清楚,问题基本就解决了一半。
