JVM 内存分配方式
指针碰撞(Bump the Point)、空闲列表(Free List)。
指针碰撞
对象所需内存的大小在类加载完成后便可完全确定,为对象分配空间的任务等同于把一块确定大小的内存从Java堆中划分出来。假设Java堆中内存是绝对规整的,所有用过的内存都放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空间那边挪动一段与对象大小相等的距离,这种分配方式称为“指针碰撞”(Bump thePointer)。
使用这种分配算法的垃圾收集器是Serial,ParNew等带有内存整理的垃圾收集器。
空闲列表
如果Java堆中的内存并不是规整的,已使用的内存和空闲的内存相互交错,那就没有办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式称为“空闲列表”(FreeList)
采用标记-清除算法的垃圾收集器(eg:CMS)会采用这种分配方式。但是这种分配方式很容易造成很多外部碎片,在一定程度上会造成内存的浪费。
内存分配策略
1.对象优先在新生代中分配
新生代分为Eden区和Survivor区,大小比例通常为8:1,新对象一般在Eden区进行分配,当Eden区已满时,虚拟机会发起一次GC,将Eden区还存活的对象移动至Survivor区,并一次性清扫Eden区。
2.大对象直接进入老年代
所谓的大对象是指需要大量连续内存空间的对象,比如说很长的字符串或者数组。经常出现大对象容易导致内存还有不少空间时就要进行gc,以获取足够空间来存放它们。
3.长期存活的对象将进入老年代
如果对象在Eden区出生,并能够顺利熬过第一次gc,且能被Survivor区容纳的话,那么将被移动到Survivor区,并初始化年龄为1岁,以后每熬过一次gc,年龄就加一次,当年龄增加到一定程度(默认15),就会晋升到老年代中。
垃圾回收算法
Mark-Sweep算法
标记-清除(Mark-Sweep)算法是通过可以回收的内存进行标记后做清除操作,这样做的缺点就是会产生很多的内存碎片,可能的结果就是空闲内存总量是能够进行新对象的分配的,但是由于这些空闲的空间都不连续,一个对象放不下。就必须进行另外一次垃圾回收,垃圾回收又会引起服务的暂时停顿。
Mark-Compact 算法
标记-整理(Mark-Compact)算法解决了标记-清除(Mark-Sweep)算法会产生很多不连续的空闲空间做出了改进,具体的方法就是在进行标记之后不是将对象直接清理掉,而是将存活的对象进行整理,使得存活的对象占用一块连续的内存空间,因为存活对象和可回收空间有明显的分割,所以可以直接对边界之外的内存进行直接的清理。这样的清理无疑更加有效率,虽然增加了移动对象的开支,但是整体上会比标记-清除(Mark-Sweep)算法更有效率。
Copying 算法
复制(Copying)算法将内存区域分为大小相同的两个区域,当需要内存回收的时候,只需要将存活的对象复制到另外一块空闲区域,然后将原来的区域一次清理干净,什么都不留。相较于标记-清除(Mark-Sweep)算法的优势就是在解决了在标记和清除阶段的低效率和内存碎片问题,相较于标记-整理(Mark-Compact)算法的优势则是有着固定的内存分割线,而不是动态的调整,同时由于有两块相同大小的内存区域,存活的对象的复制和内存的回收会更有效率。缺点也十分明显,就是需要的内存空间是可用空间的2倍,内存的使用率永远不会超过50%。
判断对象是否存活
1. 引用计数法
给对象添加一个计数器,当有一个地方引用它时计算器加1,当应用失效计算器减1,计算器为0时表示可回收。目前主流Java虚拟机并没有采用该方法,因其无法解决相互循环应用的问题。
2. 可达性分析算法
该算法基本思路是通过一些列称为"GC Roots"的对象作为起始点,从这些节点开始向下搜索,所走过的路径称为引用链,当一个对象到GC Roots没有任何引用链相连时,则该对象可回收。可作为GC Roots的对象如下:
虚拟机栈中引用的对象
方法区中类静态属性引用的对象
方法区中常量引用的对象
本地方法栈中JNI引用的对象
垃圾收集器简介
新生代收集器
Serial 收集器
串行垃圾收集器,采用复制算法,Serial 说明他是单线程工作的,他只会使用一个CPU,使用一个线程去完成垃圾回收工作,同时这也意味着当Serial GC进行垃圾回收的时候,用户线程也不得不暂停(STW)。Serial 收集器+Serial Old 收集器垃圾回收的时间线如下图所示:
这样的用户线程对于现在的服务来说基本是不可接受的,减少 GC 停顿也一直是很多垃圾收集器的努力目标。看似十分“无能”的Serial GC只能在客户端中发挥作用,因为一般客户端应用需要的内存并不是很大,因此停顿几十毫秒就可以完成内存回收,这样也是可以接受了,同时省去了并行 GC 切换线程的开销。
ParNew 收集器
ParNew 收集器基本上就是Serial 收集器的升级版本,在新生代中收集的时候采用了并行的方案。其他部分与Serial 收集器基本一样。该收集器在单 CPU 的环境下并不会有优于Serial的表现,,反而会由于线程的频繁切换而降低性能,而对于多核CPU就会有较好的表现了。因此比较适合服务端程序。ParNew 收集器+Serial Old 收集器的 GC 过程如下图所示:

虽然ParNew 收集器只是针对Serial 收集器的简单升级,但是有着十分广泛的使用,原因就是能和CMS收集器搭配使用的只有Serial 收集器和ParNew 收集器两种。CMS的广泛使用就带来了ParNew的广泛使用。
Parallel Scavenge 收集器
Parallel Scavenge 收集器也是一个新生代收集器,他也是并行的、使用复制算法的。和ParNew收集器非常相似,GC过程图也与ParNew收集器相同。然而该收集器的关注点与其他收集器不同,其他收集器关注点都是如何缩短GC停顿时间,而Parallel Scavenge 收集器关注的确实如何保持系统一个较高的吞吐量(吞吐量 = 运行用户代码的时间/(运行用户代码时间+垃圾收集时间))。因此该收集器主要适合那些交互较少,运算较多的服务。该收集器允许用户设置极少的参数就能保证一个较高的吞吐量。
该收集器提供两个参数用于精确控制吞吐量-XX:MaxGCPauseMillis和-XX:GCTimeRatio两个参数,含义分别是最大GC停顿时间和直接设置吞吐量。GC的处理优先级是MaxGCPauseMillis最高,GCTimeRatio次之,其他的空间大小配置优先级最低。除了上面的两个参数之外,还有一个参数-XX:+UseAdaptiveSizePolicy,当设置了这个参数之后,就不需要再配置新生代的大小、Eden 和 Survivor 区域的比例、晋升老年代对象年龄等参数(设置了也没用),虚拟机会根据当前系统运行状况来动态调整上面的几个参数。
Parallel Scavenge(-XX:+UseParallelGC)框架下,默认是在要触发full GC前先执行一次young GC,并且两次GC之间能让应用程序稍微运行一小下,以期降低full GC的暂停时间(因为young GC会尽量清理了young gen的死对象,减少了full GC的工作量)。控制这个行为的VM参数是-XX:+ScavengeBeforeFullGC
老年代收集器
Serial Old 收集器
Serial Old 收集器就是Serial 收集器的老年代版本,主要也是给Client模式下的虚拟机使用。在 Server 应用中用于和 PS 收集器搭配使用,还有就是在 CMS 失败后,作为后备方案来进行垃圾收集。

Parallel Old 收集器
Parallel Old 收集器是在JDK1.6中推出的,在此之前Parallel Scavenge 收集器只能和Serial Old 收集器搭配使用,由于Serial Old 收集器的拖累,导致高吞吐量的优势无法体现出来,这种组合的吞度量甚至不如ParNew 收集器+CMS收集器的组合。而Parallel Old 收集器的正式推出表示在需要关注高吞吐量的服务中可以使用Parallel Scavenge 收集器+Parallel Old 收集器的组合。这种组合的 GC 回收过程线程运行情况如下图:

Concurrent Mark Sweep 收集器
CMS 收集器收集器是以最短的停顿时间为目标的收集器,这样的目标可以保证服务的高可用性,因此也成为了各种以交互为目的的服务的首选收集器。该收集器采用的是标记-清除算法,而前面介绍的收集器都是采用的标记-整理算法。该收集器 GC 过程相对复杂一些。分为以下四个步骤:
- 初始标记(CMS initial mark)
- 并发标记(CMS concurrent mark)
- 重新标记(CMS final remark)
- 并发清除(CMS concurrent sweep)
- 重置线程(CMS concurrent reset)
这里截取一段 GC 日志
2017-11-07T16:20:41.582+0800: 2.119: [GC (CMS Initial Mark) [1 CMS-initial-mark: 0K(3670016K)] 26854K(4141888K), 0.0036737 secs] [Times: user=0.01 sys=0.00, real=0.01 secs]
2017-11-07T16:20:41.585+0800: 2.123: [CMS-concurrent-mark-start]
2017-11-07T16:20:41.587+0800: 2.125: [CMS-concurrent-mark: 0.002/0.002 secs] [Times: user=0.01 sys=0.00, real=0.00 secs]
2017-11-07T16:20:41.587+0800: 2.125: [CMS-concurrent-preclean-start]
2017-11-07T16:20:41.595+0800: 2.133: [CMS-concurrent-preclean: 0.008/0.008 secs] [Times: user=0.05 sys=0.00, real=0.01 secs]
2017-11-07T16:20:41.595+0800: 2.133: [CMS-concurrent-abortable-preclean-start]
2017-11-07T16:20:43.996+0800: 4.534: [CMS-concurrent-abortable-preclean: 1.514/2.401 secs] [Times: user=5.75 sys=0.28, real=2.40 secs]
2017-11-07T16:20:43.996+0800: 4.534: [GC (CMS Final Remark) [YG occupancy: 241272 K (471872 K)]4.534: [Rescan (parallel) , 0.0123556 secs]4.546: [weak refs processing, 0.0000203 secs]4.546: [class unloading, 0.0059852 secs]4.552: [scrub symbol table, 0.0055144 secs]4.558: [scrub string table, 0.0006434 secs][1 CMS-remark: 0K(3670016K)] 241272K(4141888K), 0.0260691 secs] [Times: user=0.11 sys=0.00, real=0.02 secs]
2017-11-07T16:20:44.023+0800: 4.560: [CMS-concurrent-sweep-start]
2017-11-07T16:20:44.023+0800: 4.560: [CMS-concurrent-sweep: 0.000/0.000 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
2017-11-07T16:20:44.023+0800: 4.560: [CMS-concurrent-reset-start]
2017-11-07T16:20:44.041+0800: 4.578: [CMS-concurrent-reset: 0.018/0.018 secs] [Times: user=0.07 sys=0.02, real=0.02 secs]
real是程序的实际运行时间,sys是内核态的时间,user是用户态的时间,单核情况,real远远大于user和sys之和。real,从程序开始到程序执行结束时所消耗的时间,包括CPU的用时和所有延迟程序执行的因素的总和。CPU用时被划分为user和sys两块。user表示程序本身,以及它所调用的库中的子例程使用的时间。sys是由程序直接或间接调用的系统调用执行的时间。
real=cpu用时+其他因素时间,cpu 用时=user+sys,所以: real> user + sys (单核情况)
GC 过程中各个阶段 GC 线程和用户线程的运行关系如下图:

初始标记、重新标记这两个步骤需要 STW, 初始标记仅仅是标记以下 GC Roots 能够直接关联到的对象,速度很快,并发标记阶段就是进行GC RootsTracing的过程。重新标记是为了修正并发标记期间由于用户线程还在继续执行而产生变动的那一部分对象,这个阶段相比初始标记要长一点,但是远比并发标记短。整体来看用时最多的几个阶段:并发标记、并发清理、重置线程都会都是并发的,这样可以让应用程序尽可能的减少停顿。
【关于CMS-concurrent-abortable-preclean】:从日志中我们还发现了一个细节叫做CMS-concurrent-abortable-preclean,这就要从Concurrent precleaning阶段说起了。Concurrent precleaning阶段的实际行为是:针对新生代做抽样,等待新生代在某个时间段(默认5秒,可以通过CMSMaxAbortablePrecleanTime参数设置)执行一次Minor GC,如果这个时间段内GC没有发生,那么就继续进行下一阶段(Remark);如果时间段内触发了Minor GC,则可能会执行一些优化(具体可以参考https://blogs.oracle.com/jonthecollector/entry/did_you_know )
并发可中止的预清理阶段。这个阶段其实跟上一个阶段做的东西一样,也是为了减少下一个STW重新标记阶段的工作量。增加这一阶段是为了让我们可以控制这个阶段的结束时机,比如扫描多长时间(默认5秒)或者Eden区使用占比达到期望比例(默认50%)就结束本阶段。
CMS 收集器也有明显的几个缺点:一是 CMS 默认启动的回收线程数是(CPU数量+3)/4,也就是当 CPU 在 4 个以上时,并发垃圾回收时 GC线程占用不少于 25% 的 CPU 资源,当 CPU 不足 4 个时,情况就变得更加严峻了。第二个缺点就是 CMS 无法处理浮动垃圾(Floating Garbage),浮动垃圾是指在并发清理阶段新产生的垃圾,由于 CMS 垃圾回收线程要和用户线程并发,因此必须要保留一部分内存在回收期间供用户线程使用,增额比例通过参数CMSInitiatingOccupancyFraction设置,表示老年代空间占用比例达到多少的时候会出发CMS GC,这个值在JDK1.6中默认值是68,在JDK1.7和JDK1.8中都是92。如果在 CMS GC 期间剩余的老年代内存空间不足已支持程序继续执行,就是触发GC降级,也就是Concurrent Mode Failure,这个时候就需要使用Serial Old收集器来完成老年代回收的任务,效率可想而知。最后一个缺点就是标记-清除算法带来的内存碎片问题,这个问题可以通过参数-XX:+UseCMSCompactAtFullCollection来缓解(默认开启),用于在CMS进行Full GC的时候进行碎片的合并整理,但是内存整理并不是并发的,会造成的应用程序的停顿,所以通常配合-XX:CMSFullGCsBeforeCompaction参数一起使用,该参数表示在允许连续几次的不整理碎片的CMS GC。
G1收集器
Garbage First 收集器应该是当今最前沿的垃圾收集器了,它的优点是:
并行与并发:G1能充分利用多CPU、多核环境下的硬件优势,使用多个CPU(CPU或者CPU核心)来缩短Stop-The-World停顿的时间,部分其他收集器原本需要停顿Java线程执行的GC动作,G1收集器仍然可以通过并发的方式让Java程序继续执行。
分代收集:与其他收集器一样,分代概念在G1中依然得以保留。虽然G1可以不需要其他收集器配合就能独立管理整个GC堆,但它能够采用不同的方式去处理新创建的对象和已经存活了一段时间、熬过多次GC的旧对象以获取更好的收集效果。
空间整合:与CMS的“标记—清理”算法不同,G1从整体来看是基于“标记—整理”算法实现的收集器,从局部(两个Region之间)上来看是基于“复制”算法实现的,但无论如何,这两种算法都意味着G1运作期间不会产生内存空间碎片,收集后能提供规整的可用内存。这种特性有利于程序长时间运行,分配大对象时不会因为无法找到连续内存空间而提前触发下一次GC。
可预测的停顿:这是G1相对于CMS的另一大优势,降低停顿时间是G1和CMS共同的关注点,但G1除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒,这几乎已经是实时Java(RTSJ)的垃圾收集器的特征了。
摘录来自: 周志明. “深入理解Java虚拟机:JVM高级特性与最佳实践(第2版)”。 iBooks.
G1 收集器虽然还有老年代和新生代的感念,但是内存布局上却没有为其划定单独的物理隔离的区域,而是分给它们不同数量(无需连续)的Region。G1 能够预测停顿的功能就是依赖于 Region 这个东西实现的,因为将内存分割成多个大小相等的区域,然后在后台维护一个垃圾回收价值列表,每次根据允许的回收时间(使用参数-XX:MaxGCPauseMillis设定,默认值为200)来确定那些 Region 进行回收。理解起来很简单的 Region 回收,实现起来却很困难,其中一个主要的原因就是 Region 之间的对象相互引用,如果到回收时才进行可达性分析要扫描整个Java堆才能完成分析,同样的问题也存在于其他分代收集器新生代和老年代相互引用的关系中。虚拟机使用Remembered Set来避免扫描全堆,程序在对 Reference 类型进行写操作的时候会检测引用的对象是否存在于不同的 Region(或者是不同年代)中,如果是就将应用信息记录到 被引用对象 的 Remembered Set 中,在内存回收时,将 Remembered Set 加入 GC Roots 即可避免全堆扫描。
G1 收集器的运作大致可以分为以下几个步骤:
- 初始标记(Initial Marking):仅仅标记 GC Roots直接关联的对象
- 并发标记(Concurrent Marking):从 GC Roots开始做可达性分析
- 最终标记(Final Marking):修正在并发标记阶段变动的标记
- 筛选回收 (Live Data Counting and Evacuation):根据回收机制和成本排序,根据用户期望的停顿时间制定回收计划
线程运行情况如下图:

本文详细探讨了JVM内存分配策略,包括指针碰撞和空闲列表,并介绍了各种垃圾回收算法如Mark-Sweep和Mark-Compact。重点讨论了新生代和老年代的收集器,如Serial、Parallel Scavenge、CMS和G1,强调了它们的特点和适用场景,特别是G1的可预测停顿时间模型和Region内存布局。
4723

被折叠的 条评论
为什么被折叠?



