首页 > 代码库 > JVM初探- 内存分配、GC原理与垃圾收集器
JVM初探- 内存分配、GC原理与垃圾收集器
JVM初探- 内存分配、GC原理与垃圾收集器
标签 : JVM
JVM内存的分配与回收大致可分为如下4个步骤: 何时分配 -> 怎样分配 -> 何时回收 -> 怎样回收.
除了在概念上可简单认为new
时分配外, 我们着重介绍后面的3个步骤:
I. 怎样分配- JVM内存分配策略
对象内存主要分配在新生代Eden区, 如果启用了本地线程分配缓冲, 则优先在TLAB上分配, 少数情况能会直接分配在老年代, 或被拆分成标量类型在栈上分配(JIT优化). 分配的规则并不是百分百固定, 细节主要取决于垃圾收集器组合, 以及VM内存相关的参数.
对象分配
- 优先在Eden区分配
在JVM内存模型一文中, 我们大致了解了VM年轻代堆内存可以划分为一块Eden区和两块Survivor区. 在大多数情况下, 对象在新生代Eden区中分配, 当Eden区没有足够空间分配时, VM发起一次Minor GC, 将Eden区和其中一块Survivor区内尚存活的对象放入另一块Survivor区域, 如果在Minor GC期间发现新生代存活对象无法放入空闲的Survivor区, 则会通过空间分配担保机制使对象提前进入老年代(空间分配担保见下). - 大对象直接进入老年代
Serial和ParNew两款收集器提供了-XX:PretenureSizeThreshold的参数, 令大于该值的大对象直接在老年代分配, 这样做的目的是避免在Eden区和Survivor区之间产生大量的内存复制(大对象一般指 需要大量连续内存的Java对象, 如很长的字符串和数组), 因此大对象容易导致还有不少空闲内存就提前触发GC以获取足够的连续空间.
对象晋升
年龄阈值
VM为每个对象定义了一个对象年龄(Age)计数器, 对象在Eden出生如果经第一次Minor GC后仍然存活, 且能被Survivor容纳的话, 将被移动到Survivor空间中, 并将年龄设为1. 以后对象在Survivor区中每熬过一次Minor GC年龄就+1. 当增加到一定程度(-XX:MaxTenuringThreshold, 默认15), 将会晋升到老年代.提前晋升: 动态年龄判定
然而VM并不总是要求对象的年龄必须达到MaxTenuringThreshold才能晋升老年代: 如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半, 年龄大于或等于该年龄的对象就可以直接进入老年代, 而无须等到晋升年龄.
II. 何时回收-对象生死判定
(哪些内存需要回收/何时回收)
在堆里面存放着Java世界中几乎所有的对象实例, 垃圾收集器在对堆进行回收前, 第一件事就是判断哪些对象已死(可回收).
可达性分析算法
在主流商用语言(如Java、C#)的主流实现中, 都是通过可达性分析算法来判定对象是否存活的: 通过一系列的称为 GC Roots 的对象作为起点, 然后向下搜索; 搜索所走过的路径称为引用链/Reference Chain, 当一个对象到 GC Roots 没有任何引用链相连时, 即该对象不可达, 也就说明此对象是不可用的, 如下图: Object5、6、7 虽然互有关联, 但它们到GC Roots是不可达的, 因此也会被判定为可回收的对象:
- 在Java, 可作为GC Roots的对象包括:
- 方法区: 类静态属性引用的对象;
- 方法区: 常量引用的对象;
- 虚拟机栈(本地变量表)中引用的对象.
- 本地方法栈JNI(Native方法)中引用的对象。
注: 即使在可达性分析算法中不可达的对象, VM也并不是马上对其回收, 因为要真正宣告一个对象死亡, 至少要经历两次标记过程: 第一次是在可达性分析后发现没有与GC Roots相连接的引用链, 第二次是GC对在F-Queue执行队列中的对象进行的小规模标记(对象需要覆盖
finalize()
方法且没被调用过).
III. GC原理- 垃圾收集算法
分代收集算法 VS 分区收集算法
- 分代收集
当前主流VM垃圾收集都采用”分代收集”(Generational Collection)算法, 这种算法会根据对象存活周期的不同将内存划分为几块, 如JVM中的 新生代、老年代、永久代. 这样就可以根据各年代特点分别采用最适当的GC算法:
- 在新生代: 每次垃圾收集都能发现大批对象已死, 只有少量存活. 因此选用复制算法, 只需要付出少量存活对象的复制成本就可以完成收集.
- 在老年代: 因为对象存活率高、没有额外空间对它进行分配担保, 就必须采用“标记—清理”或“标记—整理”算法来进行回收, 不必进行内存复制, 且直接腾出空闲内存.
- 分区收集
上面介绍的分代收集算法是将对象的生命周期按长短划分为两个部分, 而分区算法则将整个堆空间划分为连续的不同小区间, 每个小区间独立使用, 独立回收. 这样做的好处是可以控制一次回收多少个小区间.
在相同条件下, 堆空间越大, 一次GC耗时就越长, 从而产生的停顿也越长. 为了更好地控制GC产生的停顿时间, 将一块大的内存区域分割为多个小块, 根据目标停顿时间, 每次合理地回收若干个小区间(而不是整个堆), 从而减少一次GC所产生的停顿.
分代收集
新生代-复制算法
该算法的核心是将可用内存按容量划分为大小相等的两块, 每次只用其中一块, 当这一块的内存用完, 就将还存活的对象复制到另外一块上面, 然后把已使用过的内存空间一次清理掉.
(图片来源: jvm垃圾收集算法)
这使得每次只对其中一块内存进行回收, 分配也就不用考虑内存碎片等复杂情况, 实现简单且运行高效.
现代商用VM的新生代均采用复制算法, 但由于新生代中的98%的对象都是生存周期极短的, 因此并不需完全按照1∶1的比例划分新生代空间, 而是将新生代划分为一块较大的Eden区和两块较小的Survivor区(HotSpot默认Eden和Survivor的大小比例为8∶1), 每次只用Eden和其中一块Survivor. 当发生MinorGC时, 将Eden和Survivor中还存活着的对象一次性地拷贝到另外一块Survivor上, 最后清理掉Eden和刚才用过的Survivor的空间. 当Survivor空间不够用(不足以保存尚存活的对象)时, 需要依赖老年代进行空间分配担保机制, 这部分内存直接进入老年代.
老年代-标记清除算法
该算法分为“标记”和“清除”两个阶段: 首先标记出所有需要回收的对象(可达性分析), 在标记完成后统一清理掉所有被标记的对象.
该算法会有以下两个问题:
1. 效率问题: 标记和清除过程的效率都不高;
2. 空间问题: 标记清除后会产生大量不连续的内存碎片, 空间碎片太多可能会导致在运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集.
老年代-标记整理算法
标记清除算法会产生内存碎片问题, 而复制算法需要有额外的内存担保空间, 于是针对老年代的特点, 又有了标记整理算法. 标记整理算法的标记过程与标记清除算法相同, 但后续步骤不再对可回收对象直接清理, 而是让所有存活的对象都向一端移动,然后清理掉端边界以外的内存.
永久代-方法区回收
- 在方法区进行垃圾回收一般”性价比”较低, 因为在方法区主要回收两部分内容: 废弃常量和无用的类. 回收废弃常量与回收其他年代中的对象类似, 但要判断一个类是否无用则条件相当苛刻:
- 该类所有的实例都已经被回收, Java堆中不存在该类的任何实例;
- 该类对应的
Class
对象没有在任何地方被引用(也就是在任何地方都无法通过反射访问该类的方法); - 加载该类的ClassLoader已经被回收.
但即使满足以上条件也未必一定会回收, Hotspot VM还提供了-Xnoclassgc参数控制(关闭CLASS的垃圾回收功能). 因此在大量使用动态代理、CGLib等字节码框架的应用中一定要关闭该选项, 开启VM的类卸载功能, 以保证方法区不会溢出.
补充: 空间分配担保
在执行Minor GC前, VM会首先检查老年代是否有足够的空间存放新生代尚存活对象, 由于新生代使用复制收集算法, 为了提升内存利用率, 只使用了其中一个Survivor作为轮换备份, 因此当出现大量对象在Minor GC后仍然存活的情况时, 就需要老年代进行分配担保, 让Survivor无法容纳的对象直接进入老年代, 但前提是老年代需要有足够的空间容纳这些存活对象. 但存活对象的大小在实际完成GC前是无法明确知道的, 因此Minor GC前, VM会先首先检查老年代连续空间是否大于新生代对象总大小或历次晋升的平均大小, 如果条件成立, 则进行Minor GC, 否则进行Full GC(让老年代腾出更多空间).
然而取历次晋升的对象的平均大小也是有一定风险的, 如果某次Minor GC存活后的对象突增,远远高于平均值的话,依然可能导致担保失败(Handle Promotion Failure, 老年代也无法存放这些对象了), 此时就只好在失败后重新发起一次Full GC(让老年代腾出更多空间).
IX. GC实现- 垃圾收集器
GC实现目标: 准确、高效、低停顿、空闲内存规整.
新生代
1. Serial收集器
Serial收集器是Hotspot运行在Client模式下的默认新生代收集器, 它的特点是 只用一个CPU/一条收集线程去完成GC工作, 且在进行垃圾收集时必须暂停其他所有的工作线程(“Stop The World” -后面简称STW).
虽然是单线程收集, 但它却简单而高效, 在VM管理内存不大的情况下(收集几十M~一两百M的新生代), 停顿时间完全可以控制在几十毫秒~一百多毫秒内.
2. ParNew收集器
ParNew收集器其实是前面Serial的多线程版本, 除使用多条线程进行GC外, 包括Serial可用的所有控制参数、收集算法、STW、对象分配规则、回收策略等都与Serial完全一样(也是VM启用CMS收集器-XX: +UseConcMarkSweepGC
的默认新生代收集器).
由于存在线程切换的开销, ParNew在单CPU的环境中比不上Serial, 且在通过超线程技术实现的两个CPU的环境中也不能100%保证能超越Serial. 但随着可用的CPU数量的增加, 收集效率肯定也会大大增加(ParNew收集线程数与CPU的数量相同, 因此在CPU数量过大的环境中, 可用-XX:ParallelGCThreads
参数控制GC线程数).
3. Parallel Scavenge收集器
与ParNew类似, Parallel Scavenge也是使用复制算法, 也是并行多线程收集器. 但与其他收集器关注尽可能缩短垃圾收集时间不同, Parallel Scavenge更关注系统吞吐量:
停顿时间越短就越适用于用户交互的程序-良好的响应速度能提升用户的体验;而高吞吐量则适用于后台运算而不需要太多交互的任务-可以最高效率地利用CPU时间,尽快地完成程序的运算任务. Parallel Scavenge提供了如下参数设置系统吞吐量:
Parallel Scavenge参数 | 描述 |
---|---|
MaxGCPauseMillis |
(毫秒数) 收集器将尽力保证内存回收花费的时间不超过设定值, 但如果太小将会导致GC的频率增加. |
GCTimeRatio |
(整数:0 < GCTimeRatio < 100 ) 是垃圾收集时间占总时间的比率 |
-XX:+UseAdaptiveSizePolicy |
启用GC自适应的调节策略: 不再需要手工指定-Xmn 、-XX:SurvivorRatio 、-XX:PretenureSizeThreshold 等细节参数, VM会根据当前系统的运行情况收集性能监控信息, 动态调整这些参数以提供最合适的停顿时间或最大的吞吐量 |
老年代
Serial Old收集器
Serial Old是Serial收集器的老年代版本, 同样是单线程收集器,使用“标记-整理”算法:
- Serial Old应用场景如下:
- JDK 1.5之前与Parallel Scavenge收集器搭配使用;
- 作为CMS收集器的后备预案, 在并发收集发生
Concurrent Mode Failure
时启用(见下:CMS收集器).
Parallel Old收集器
Parallel Old是Parallel Scavenge收老年代版本, 使用多线程和“标记-整理”算法, 吞吐量优先, 主要与Parallel Scavenge配合在 注重吞吐量 及 CPU资源敏感 系统内使用:
CMS收集器
CMS(Concurrent Mark Sweep)收集器是一款具有划时代意义的收集器, 一款真正意义上的并发收集器, 虽然现在已经有了理论意义上表现更好的G1收集器, 但现在主流互联网企业线上选用的仍是CMS(如Taobao、微店).
CMS是一种以获取最短回收停顿时间为目标的收集器(CMS又称多并发低暂停的收集器), 基于”标记-清除”算法实现, 整个GC过程分为以下4个步骤:
1. 初始标记(CMS initial mark)
2. 并发标记(CMS concurrent mark: GC Roots Tracing过程)
3. 重新标记(CMS remark)
4. 并发清除(CMS concurrent sweep: 已死象将会就地释放, 注意: 此处没有压缩)
其中两个加粗的步骤(初始标记、重新标记)仍需STW. 但初始标记仅只标记一下GC Roots能直接关联到的对象, 速度很快; 而重新标记则是为了修正并发标记期间因用户程序继续运行而导致标记产生变动的那一部分对象的标记记录, 虽然一般比初始标记阶段稍长, 但要远小于并发标记时间.
(由于整个GC过程耗时最长的并发标记和并发清除阶段的GC线程可与用户线程一起工作, 所以总体上CMS的GC过程是与用户线程一起并发地执行的.
由于CMS收集器将整个GC过程进行了更细粒度的划分, 因此可以实现并发收集、低停顿的优势, 但它也并非十分完美, 其存在缺点及解决策略如下:
- <script type="math/tex; mode=display" id="MathJax-Element-2">CMS默认启动的回收线程数=\frac{(CPU数目 + 3)}{4}</script>
CMS默认启动的回收线程数=(CPU数目+3)4
当CPU数>4时, GC线程最多占用不超过25%
的CPU资源, 但是当CPU数<=4时, GC线程可能就会过多的占用用户CPU资源, 从而导致应用程序变慢, 总吞吐量降低. - 无法处理浮动垃圾, 可能出现Promotion Failure、Concurrent Mode Failure而导致另一次Full GC的产生: 浮动垃圾是指在CMS并发清理阶段用户线程运行而产生的新垃圾. 由于在GC阶段用户线程还需运行, 因此还需要预留足够的内存空间给用户线程使用, 导致CMS不能像其他收集器那样等到老年代几乎填满了再进行收集. 因此CMS提供了
-XX:CMSInitiatingOccupancyFraction
参数来设置GC的触发百分比(以及-XX:+UseCMSInitiatingOccupancyOnly
来启用该触发百分比), 当老年代的使用空间超过该比例后CMS就会被触发(JDK 1.6之后默认92%). 但当CMS运行期间预留的内存无法满足程序需要, 就会出现上述Promotion Failure等失败, 这时VM将启动后备预案: 临时启用Serial Old收集器来重新执行Full GC(CMS通常配合大内存使用, 一旦大内存转入串行的Serial GC, 那停顿的时间就是大家都不愿看到的了). - 最后, 由于CMS采用”标记-清除”算法实现, 可能会产生大量内存碎片. 内存碎片过多可能会导致无法分配大对象而提前触发Full GC. 因此CMS提供了
-XX:+UseCMSCompactAtFullCollection
开关参数, 用于在Full GC后再执行一个碎片整理过程. 但内存整理是无法并发的, 内存碎片问题虽然没有了, 但停顿时间也因此变长了, 因此CMS还提供了另外一个参数-XX:CMSFullGCsBeforeCompaction
用于设置在执行N次不进行内存整理的Full GC后, 跟着来一次带整理的(默认为0: 每次进入Full GC时都进行碎片整理).
分区收集- G1收集器
G1(Garbage-First)是一款面向服务端应用的收集器, 主要目标用于配备多颗CPU的服务器治理大内存.
- G1 is planned as the long term replacement for the Concurrent Mark-Sweep Collector (CMS).
--XX:+UseG1GC
启用G1收集器.
与其他基于分代的收集器不同, G1将整个Java堆划分为多个大小相等的独立区域(Region), 虽然还保留有新生代和老年代的概念, 但新生代和老年代不再是物理隔离的了, 它们都是一部分Region(不需要连续)的集合.
每块区域既有可能属于O区、也有可能是Y区, 因此不需要一次就对整个老年代/新生代回收. 而是当线程并发寻找可回收的对象时, 有些区块包含可回收的对象要比其他区块多很多. 虽然在清理这些区块时G1仍然需要暂停应用线程, 但可以用相对较少的时间优先回收垃圾较多的Region(这也是G1命名的来源). 这种方式保证了G1可以在有限的时间内获取尽可能高的收集效率.
新生代收集
G1的新生代收集跟ParNew类似: 存活的对象被转移到一个/多个Survivor Regions. 如果存活时间达到阀值, 这部分对象就会被提升到老年代.
- G1的新生代收集特点如下:
- 一整块堆内存被分为多个Regions.
- 存活对象被拷贝到新的Survivor区或老年代.
- 年轻代内存由一组不连续的heap区组成, 这种方法使得可以动态调整各代区域尺寸.
- Young GCs会有STW事件, 进行时所有应用程序线程都会被暂停.
- 多线程并发GC.
老年代收集
G1老年代GC会执行以下阶段:
注: 一下有些阶段也是年轻代垃圾收集的一部分.
index | Phase | Description |
---|---|---|
(1) | 初始标记 (Initial Mark: Stop the World Event) | 在G1中, 该操作附着一次年轻代GC, 以标记Survivor中有可能引用到老年代对象的Regions. |
(2) | 扫描根区域 (Root Region Scanning: 与应用程序并发执行) | 扫描Survivor中能够引用到老年代的references. 但必须在Minor GC触发前执行完. |
(3) | 并发标记 (Concurrent Marking : 与应用程序并发执行) | 在整个堆中查找存活对象, 但该阶段可能会被Minor GC中断. |
(4) | 重新标记 (Remark : Stop the World Event) | 完成堆内存中存活对象的标记. 使用snapshot-at-the-beginning(SATB, 起始快照)算法, 比CMS所用算法要快得多(空Region直接被移除并回收, 并计算所有区域的活跃度). |
(5) | 清理 (Cleanup : Stop the World Event and Concurrent) | 见下 5-1、2、3 |
5-1 (Stop the world) | 在含有存活对象和完全空闲的区域上进行统计 | |
5-2 (Stop the world) | 擦除Remembered Sets. | |
5-3 (Concurrent) | 重置空regions并将他们返还给空闲列表(free list) | |
(*) | Copying/Cleanup (Stop the World Event) | 选择”活跃度”最低的区域(这些区域可以最快的完成回收). 拷贝/转移存活的对象到新的尚未使用的regions. 该阶段会被记录在gc-log内(只发生年轻代[GC pause (young)] , 与老年代一起执行则被记录为[GC Pause (mixed)] . |
详细步骤可参考 Oracle官方文档-The G1 Garbage Collector Step by Step.
- G1老年代GC特点如下:
- 并发标记阶段(index 3)
- 在与应用程序并发执行的过程中会计算活跃度信息.
- 这些活跃度信息标识出那些regions最适合在STW期间回收(which regions will be best to reclaim during an evacuation pause).
- 不像CMS有清理阶段.
- 再次标记阶段(index 4)
- 使用Snapshot-at-the-Beginning(SATB)算法比CMS快得多.
- 空region直接被回收.
- 拷贝/清理阶段(Copying/Cleanup Phase)
- 年轻代与老年代同时回收.
- 老年代内存回收会基于他的活跃度信息.
- 并发标记阶段(index 3)
补充: 关于Remembered Set
G1收集器中, Region之间的对象引用以及其他收集器中的新生代和老年代之间的对象引用都是使用Remembered Set来避免扫描全堆. G1中每个Region都有一个与之对应的Remembered Set, VM发现程序对Reference类型数据进行写操作时, 会产生一个Write Barrier暂时中断写操作, 检查Reference引用的对象是否处于不同的Region中(在分代例子中就是检查是否老年代中的对象引用了新生代的对象), 如果是, 便通过CardTable把相关引用信息记录到被引用对象所属的Region的Remembered Set中. 当内存回收时, 在GC根节点的枚举范围加入Remembered Set即可保证不对全局堆扫描也不会有遗漏.
V. JVM小工具
在${JAVA_HOME}/bin/目录下Sun/Oracle给我们提供了一些处理应用程序性能问题、定位故障的工具, 包含
bin | 描述 | 功能 |
---|---|---|
jps | 打印Hotspot VM进程 | VMID、JVM参数、main() 函数参数、主类名/Jar路径 |
jstat | 查看Hotspot VM 运行时信息 | 类加载、内存、GC[可分代查看]、JIT编译 |
jinfo | 查看和修改虚拟机各项配置 | -flag name=value |
jmap | heapdump: 生成VM堆转储快照、查询finalize执行队列、Java堆和永久代详细信息 | jmap -dump:live,format=b,file=heap.bin [VMID] |
jstack | 查看VM当前时刻的线程快照: 当前VM内每一条线程正在执行的方法堆栈集合 | Thread.getAllStackTraces() 提供了类似的功能 |
javap | 查看经javac之后产生的JVM字节码代码 | 自动解析.class 文件, 避免了去理解class文件格式以及手动解析class文件内容 |
jcmd | 一个多功能工具, 可以用来导出堆, 查看Java进程、导出线程信息、 执行GC、查看性能相关数据等 | 几乎集合了jps、jstat、jinfo、jmap、jstack所有功能 |
jconsole | 基于JMX的可视化监视、管理工具 | 可以查看内存、线程、类、CPU信息, 以及对JMX MBean进行管理 |
jvisualvm | JDK中最强大运行监视和故障处理工具 | 可以监控内存泄露、跟踪垃圾回收、执行时内存分析、CPU分析、线程分析… |
VI. VM常用参数整理
参数 | 描述 |
---|---|
-Xms |
最小堆大小 |
-Xmx |
最大堆大小 |
-Xmn |
新生代大小 |
-XX:PermSize |
永久代大小 |
-XX:MaxPermSize |
永久代最大大小 |
-XX:+PrintGC |
输出GC日志 |
-verbose:gc |
- |
-XX:+PrintGCDetails |
输出GC的详细日志 |
-XX:+PrintGCTimeStamps |
输出GC时间戳(以基准时间的形式) |
-XX:+PrintHeapAtGC |
在进行GC的前后打印出堆的信息 |
-Xloggc:/path/gc.log |
日志文件的输出路径 |
-XX:+PrintGCApplicationStoppedTime |
打印由GC产生的停顿时间 |
在此处无法列举所有的参数以及他们的应用场景, 详细移步Oracle官方文档-Java HotSpot VM Options.
- 参考 & 扩展
- 深入理解Java虚拟机
- JVM内幕:Java虚拟机详解 (力荐)
- JVM中的G1垃圾回收器
- G1垃圾收集器入门
- Getting Started with the G1 Garbage Collector
- 深入理解G1垃圾收集器
- 解析JDK 7的Garbage-First收集器
- The Garbage-First Garbage Collector
- Memory Management in the Java HotSpot Virtual Machine
- Java HotSpot VM Options
- JVM实用参数(一)JVM类型以及编译器模式
- JVM内存回收理论与实现
- 基于OpenJDK深度定制的淘宝JVM(TaobaoVM)
JVM初探- 内存分配、GC原理与垃圾收集器