最朴素的起点:标记-清除,简单扫一遍
CMS的全称是“并发标记-清除”,所以咱们得从标记-清除(Mark-Sweep)这个祖宗开始说起。想象一下,程序跑着,内存里堆满了对象,GC得收拾垃圾。最简单粗暴的办法就是:从根(比如栈上的变量、全局对象)出发,沿着引用链标记所有活着的对象,没标记的就当垃圾扫掉,内存回收,完事儿。
Demo举例
假设内存里有5000个对象,根引用了100个,这100个又连着200个,GC跑一趟,标记这300个活着的,剩下4700个没标记,清掉就行了。听起来挺美,对吧?
问题1:全停顿,程序卡死
但这朴素法有个大毛病——得停下程序(Stop-The-World)。为啥?因为标记的时候,程序要是还跑着,引用关系变来变去,GC就懵了。假设标记过程花了1秒,5000个对象不算多,但要是内存里有50万个对象呢?得10秒!用户那边就感觉程序卡死了,体验直接崩。
问题2:碎片堆积,内存乱糟糟
清完垃圾,内存里活着的对象东一块西一块,像个破筛子。假设你清掉4700个对象,剩下300个散落在各处,下次要分配个200KB的大对象,可能愣是找不到连续空间,得挪来挪去,效率低得要命。
优化方向:别全停,收拾碎片
从这看,得让GC和程序一块跑,别老停顿;另外,碎片也得处理,不然内存迟早废了。这俩方向是不是有点现代GC的味道了?CMS就是从这儿开始进化的。
第一步改进:并发标记,别停程序
好,咱们先解决停顿问题。朴素的标记-清除得全停,那能不能让GC一边标记,程序一边跑呢?这就引出了“并发”(Concurrent)的概念。CMS的核心思路就是:尽量跟程序同时干活,少卡用户。
怎么搞?
从根开始标记活对象的时候,程序还能继续操作内存。假设有5000个对象,GC慢慢标记300个活的,程序同时可能new出100个新对象。只要GC能跟上节奏,标记结束后清垃圾,用户几乎没感觉。
问题1:并发带来的混乱
但并发不是白来的,程序跑着,引用关系会变。比如GC标记到一半,对象A本来指向B,程序突然把A指向C,B没人用了,但GC可能已经标记了B,漏掉了C。结果就是“浮动垃圾”——有些该死的没清掉,内存回收不彻底。
问题2:清不干净,得再跑
假设一次并发标记后,浮动垃圾占了50MB,内存没全回收干净。GC得下次再跑一趟,效率就打了折扣。更糟的是,要是内存用得太猛,GC跟不上,可能会触发全停顿(Full GC),那就又回到老问题了。
优化方向:精准标记,控制停顿
得想办法让标记更准,少漏垃圾;同时,得保证GC不会老触发全停顿。这就指向了CMS的实际设计:多阶段标记+尽量并发。
CMS的真面目:多阶段并发标记-清除
CMS就是在并发标记-清除的基础上,加了点巧妙的设计,分了好几步走,尽量减少停顿。咱们拆开看看:
1. 初始标记(停顿)
GC先停一下程序,快速标记根直接引用的对象。比如5000个对象,根有10个引用,标记这10个很快,可能就10毫秒,停顿短得用户感觉不到。
2. 并发标记(不停)
然后GC和程序一起跑,从这10个根出发,沿着引用链标记所有活对象。假设找到300个活的,花了500毫秒,但程序没停,用户无感。
3. 预清理+重新标记(短停顿)
并发标记可能漏标(浮动垃圾问题),所以得再停一下,修正漏掉的。CMS会先预清理,分析变化,再快速重新标记。比如修正50个漏标对象,停顿20毫秒,还是很快。
4. 并发清除(不停)
最后,GC把没标记的垃圾清掉,比如4700个对象,边清边让程序跑,用户照样开心。
数字敏感点
假设内存1GB,新生代用别的GC(比如ParNew),CMS管老年代800MB。每次并发清除回收400MB,但浮动垃圾可能留50MB,实际回收350MB。只要程序分配速度别超过350MB/秒,CMS就能撑住。
CMS的坑和优化方向
CMS已经很牛了,低停顿、并发跑,但也不是没毛病。
问题1:浮动垃圾跑不掉
并发标记总会漏点垃圾。比如老年代800MB,浮动垃圾占50MB,回收不彻底,内存压力大了就得Full GC,停顿又回来了。
问题2:碎片还是老大难
CMS还是标记-清除,碎片没解决。假设老年代剩300MB活对象,散成1000块,下次分配个100MB对象,可能直接失败,得Full GC收拾。
优化方向:碎片整理+更强并发
- 碎片:得加整理功能,把活对象挪一起,像G1那样分区管理,或者ZGC直接用标记-整理。
- 并发:得让Full GC也尽量少,比如预测内存用量,提前回收。这不就跟现代GC的趋势一致了嘛,比如G1的区域化、ZGC的超低停顿。
总结:从朴素到CMS,再往现代走
- 朴素标记-清除 → 停顿长、碎片多,得优化。
- 并发标记 → 减少停顿,但漏垃圾。
- CMS多阶段 → 低停顿+并发,主流雏形有了。
- 现代方向 → 碎片收拾干净、并发更强。