GC知识点回顾:
垃圾回收(Garbage Collection)是Java虚拟机(JVM)垃圾回收器提供的一种用于在空闲时间不定时回收无任何对象引用的对象占据的内存空间的一种机制。
注意:垃圾回收回收的是无任何引用的对象占据的内存空间而不是对象本身。换言之,垃圾回收只会负责释放那些对象占有的内存。对象是个抽象的词,包括引用和其占据的内存空间。当对象没有任何引用时其占据的内存空间随即被收回备用,此时对象也就被销毁。但不能说是回收对象,可以理解为一种文字游戏。
引用 : 如果Reference类型的数据中存储的数值代表的是另外一块内存的起始地址,就称这块内存代表着一个引用。引用又分为强引用,软引用,弱引用,虚引用。
① : 强引用(Strong Reference):如“Object obj = new Object()”,这类引用是Java程序中最普遍的。只要强引用还存在,垃圾收集器就永远不会回收掉被引用的对象。
② : 软引用(Soft Reference):它用来描述一些可能还有用,但并非必须的对象。在系统内存不够用时,这类引用关联的对象将被垃圾收集器回收。JDK1.2之后提供了SoftReference类来实现软引用。
③ : 弱引用(Weak Reference):它也是用来描述非须对象的,但它的强度比软引用更弱些,被弱引用关联的对象只能生存到下一次垃圾收集发生之前。当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。在JDK1.2之后,提供了WeakReference类来实现弱引用。
④ : 虚引用(Phantom Reference):最弱的一种引用关系,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的是希望能在这个对象被收集器回收时收到一个系统通知。JDK1.2之后提供了PhantomReference类来实现虚引用。
判断对象是否是垃圾的算法
Java语言规范没有明确地说明JVM使用哪种垃圾回收算法,但是任何一种垃圾回收算法一般要做2件基本的事情:
① : 找到所有存活对象;
② : 回收被无用对象占用的内存空间,使该空间可被程序再次使用。
通常有以下几种算法来判断对象是否已死:
引用计数算法(Reference Counting Collector)
堆中每个对象(不是引用)都有一个引用计数器。当一个对象被创建并初始化赋值后,该变量计数设置为1。每当有一个地方引用它时,计数器值就加1(a = b, b被引用,则b引用的对象计数+1)。当引用失效时(一个对象的某个引用超过了生命周期(出作用域后)或者被设置为一个新值时),计数器值就减1。任何引用计数为0的对象可以被当作垃圾收集。当一个对象被垃圾收集时,它引用的任何对象计数减1。
优点:引用计数收集器执行简单,判定效率高,交织在程序运行中。对程序不被长时间打断的实时环境比较有利(OC的内存管理使用该算法)。
缺点: 难以检测出对象之间的循环引用。同时,引用计数器增加了程序执行的开销。所以Java语言并没有选择这种算法进行垃圾回收。
早期的JVM使用引用计数,现在大多数JVM采用对象引用遍历(根搜索算法)。
GC算法 :
① : 标记-清除算法:
标记-清除算法是现代垃圾回收算法的思想基础。标记-清除算法将垃圾回收分为两个阶段:标记阶段和清除阶段。一种可行的实现是,在标记阶段,首先通过根节点,标记所有从根节点开始的可达对象。因此,未被标记的对象就是未被引用的垃圾对象;然后,在清除阶段,清除所有未被标记的对象。它的做法是当堆中的有效内存空间(available memory)被耗尽的时候,就会停止整个程序(也被成为stop the world),然后进行两项工作,第一项则是标记,第二项则是清除。
标记 : 标记的过程其实就是,遍历所有的GC Roots,然后将所有GC Roots可达的对象标记为存活的对象。清除:清除的过程将遍历堆中所有的对象,将没有标记的对象全部清除掉。也就是说,就是当程序运行期间,若可以使用的内存被耗尽的时候,GC线程就会被触发并将程序暂停,随后将依旧存活的对象标记一遍,最终再将堆中所有没被标记的对象全部清除掉,接下来便让程序恢复运行。
标记-清除算法的缺点
① : 首先,它的缺点就是效率比较低(递归与全堆对象遍历),导致stop the world的时间比较长,尤其对于交互式的应用程序来说简直是无法接受。试想一下,如果你玩一个网站,这个网站一个小时就挂五分钟,你还玩吗?
② : 第二点主要的缺点,则是这种方式清理出来的空闲内存是不连续的,这点不难理解,我们的死亡对象都是随即的出现在内存的各个角落的,现在把它们清除之后,内存的布局自然会乱七八糟。而为了应付这一点,JVM就不得不维持一个内存的空闲列表,这又是一种开销。而且在分配数组对象的时候,寻找连续的内存空间会不太好找。
② : 复制算法:(新生代的GC):
将原有的内存空间分为两块,每次只使用其中一块,在垃圾回收时,将正在使用的内存中的存活对象复制到未使用的内存块中,之后,清除正在使用的内存块中的所有对象,交换两个内存的角色,完成垃圾回收。
. 与标记-清除算法相比,复制算法是一种相对高效的回收方法. 不适用于存活对象较多的场合,如老年代(复制算法适合做新生代的GC)
复制算法的最大的问题是:空间的浪费
复制算法使得每次都只对整个半区进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。只是这种算法的代价是将内存缩小为原来的一半,这个太要命了。
所以从以上描述不难看出,复制算法要想使用,最起码对象的存活率要非常低才行,而且最重要的是,我们必须要克服50%内存的浪费。
现在的商业虚拟机都采用这种收集算法来回收新生代,新生代中的对象98%都是“朝生夕死”的,所以并不需要按照1:1的比例来划分内存空间,而是将内存分为一块比较大的Eden空间和两块较小的Survivor空间,每次使用Eden和其中一块Survivor。当回收时,将Eden和Survivor中还存活着的对象一次性地复制到另外一块Survivor空间上,最后清理掉Eden和刚才用过的Survivor空间。HotSpot虚拟机默认Eden和Survivor的大小比例是8:1:1,也就是说,每次新生代中可用内存空间为整个新生代容量的90%(80%+10%),只有10%的空间会被浪费。
当然,98%的对象可回收只是一般场景下的数据,我们没有办法保证每次回收都只有不多于10%的对象存活,当Survivor空间不够用时,需要依赖于老年代进行分配担保,所以大对象直接进入老年代。
③ : 标记-整理算法:(老年代的GC):
如果在对象存活率较高时就要进行较多的复制操作,效率将会变低。更关键的是,如果不想浪费50%的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况,所以在老年代一般不能直接选中这种算法。
标记-压缩算法适合用于存活对象较多的场合,如老年代。它在标记-清除算法的基础上做了一些优化。和标记-清除算法一样,标记-压缩算法也首先需要从根节点开始,对所有可达对象做一次标记;但之后,它并不简单的清理未标记的对象,而是将所有的存活对象压缩到内存的一端;之后,清理边界外所有的空间。
标记:它的第一个阶段与标记/清除算法是一模一样的,均是遍历GC Roots,然后将存活的对象标记。整理:移动所有存活的对象,且按照内存地址次序依次排列,然后将末端内存地址以后的内存全部回收。因此,第二阶段才称为整理阶段。上图中可以看到,标记的存活对象将会被整理,按照内存地址依次排列,而未被标记的内存会被清理掉。如此一来,当我们需要给新对象分配内存时,JVM只需要持有一个内存的起始地址即可,这比维护一个空闲列表显然少了许多开销。
标记/整理算法不仅可以弥补标记/清除算法当中,内存区域分散的缺点,也消除了复制算法当中,内存减半的高额代价。
但是,标记/整理算法唯一的缺点就是效率也不高。不仅要标记所有存活对象,还要整理所有存活对象的引用地址。从效率上来说,标记/整理算法要低于复制算法。
标记-清除算法、复制算法、标记整理算法的总结:
三个算法都基于根搜索算法去判断一个对象是否应该被回收,而支撑根搜索算法可以正常工作的理论依据,就是语法中变量作用域的相关内容。因此,要想防止内存泄露,最根本的办法就是掌握好变量作用域,而不应该使用C/C++式内存管理方式。
在GC线程开启时,或者说GC过程开始时,它们都要暂停应用程序(stop the world)。
它们的区别如下:(>表示前者要优于后者,=表示两者效果一样)
① : 效率:复制算法>标记/整理算法>标记/清除算法(此处的效率只是简单的对比时间复杂度,实际情况不一定如此)。
② : 内存整齐度:复制算法=标记/整理算法>标记/清除算法。
③ : 内存利用率:标记/整理算法=标记/清除算法>复制算法。
注1:可以看到标记/清除算法是比较落后的算法了,但是后两种算法却是在此基础上建立的。
注2:时间与空间不可兼得。
④ : GC 日志 :
要查看GC日志,需要设置一下jvm的参数。关于输出GC日志的参数有以下几种:
-XX:+PrintGC 输出GC日志
-XX:+PrintGCDetails 输出GC的详细日志
-XX:+PrintGCTimeStamps 输出GC的时间戳(以基准时间的形式)
-XX:+PrintGCDateStamps 输出GC的时间戳(以日期的形式,如 2013-05-04T21:53:59.234+0800)
-XX:+PrintHeapAtGC 在进行GC的前后打印出堆的信息
-Xloggc:../logs/gc.log 日志文件的输出路径
通常GC的日志大概长这样:
[GC (System.gc()) [PSYoungGen: 3686K->664K(38400K)] 3686K->672K(125952K), 0.0016607 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[Full GC (System.gc()) [PSYoungGen: 664K->0K(38400K)] [ParOldGen: 8K->537K(87552K)] 672K->537K(125952K), [Metaspace: 2754K->2754K(1056768K)], 0.0059024 secs] [Times: user=0.00 sys=0.00, real=0.01 secs]
Heap
PSYoungGen total 38400K, used 333K [0x00000000d5c00000, 0x00000000d8680000, 0x0000000100000000)
eden space 33280K, 1% used [0x00000000d5c00000,0x00000000d5c534a8,0x00000000d7c80000)
from space 5120K, 0% used [0x00000000d7c80000,0x00000000d7c80000,0x00000000d8180000)
to space 5120K, 0% used [0x00000000d8180000,0x00000000d8180000,0x00000000d8680000)
ParOldGen total 87552K, used 537K [0x0000000081400000, 0x0000000086980000, 0x00000000d5c00000)
object space 87552K, 0% used [0x0000000081400000,0x00000000814864a0,0x0000000086980000)
Metaspace used 2761K, capacity 4486K, committed 4864K, reserved 1056768K
class space used 299K, capacity 386K, committed 512K, reserved 1048576K
GC日志开头的”[GC”和”[Full GC”说明了这次垃圾收集的停顿类型,如果有”Full”,说明这次GC发生了”Stop-The-World”。因为是调用了System.gc()方法触发的收集,所以会显示”[Full GC (System.gc())”,不然是没有后面的(System.gc())的。
“[PSYoungGen”和”[ParOldGen”是指GC发生的区域,分别代表使用Parallel Scavenge垃圾收集器的新生代和使用Parallel old垃圾收集器的老生代。为什么是这两个垃圾收集器组合呢?因为我的jvm开启的模式是Server,而Server模式的默认垃圾收集器组合便是这个,在命令行输入java -version就可以看到自己的jvm默认开启模式。还有一种是client模式,默认组合是Serial收集器和Serial Old收集器组合。
在方括号中”PSYoungGen:”后面的”3686K->664K(38400K)”代表的是”GC前该内存区域已使用的容量->GC后该内存区域已使用的容量(该内存区域总容量)”
在方括号之外的”3686K->672K(125952K)”代表的是”GC前Java堆已使用容量->GC后Java堆已使用容量(Java堆总容量)”
再往后的”0.0016607 sec”代表该内存区域GC所占用的时间,单位是秒。
再后面的”[Times: user=0.00 sys=0.00, real=0.00 secs]”,user代表进程在用户态消耗的CPU时间,sys代表代表进程在内核态消耗的CPU时间、real代表程序从开始到结束所用的时钟时间。这个时间包括其他进程使用的时间片和进程阻塞的时间(比如等待 I/O 完成)。
至于后面的”eden”代表的是Eden空间,还有”from”和”to”代表的是Survivor空间。
线上GC实际案例分析 :
此次线上调优的系统是一个计数器服务,暂且称其为F吧。系统F是一个具有滑动时间窗口的计数服务,每天有几十亿次的访问量,系统内部主要是小对象比较多,当时每台机器容量分配的是 8C16G,所以当时GC的调整参数设置成了 -xmx 12g -xms 12g -xmn 4g,咋一看这样设置没啥毛病,也是jvm建议设置的参数(年轻代占总堆的1/3)。
高峰期系统F的GC耗时在100ms左右,每分钟GC count是2,如果是一般的系统,这样的GC性能还是很不错,完全达不到调优的必要,但是系统F是一个统计服务,在高峰期QPS 达到9万,对外提供单词请求最大的耗时是50ms,如果每次GC耗时108ms,意味着在这段时间内的请求将全部超时(大约有9720个请求超时), 这是不能容忍的。
通常来说GC调优的目标有以下三个:
高可用,可用性达到几个9。
低延迟,请求必须多少毫秒内完成响应。
高吞吐,每秒完成多少次事务。
明确系统需求之所以重要,是因为上述性能指标间可能冲突。比如通常情况下,缩小延迟的代价是降低吞吐量或者消耗更多的内存或者两者同时发生。
系统F主要关注的指标是高可用和低延迟,因此调整主要是以该两项指标为主。
此次优化目标: (高峰期GC耗时降低至20ms左右,每分钟GC count为1~2个)
我们查看了下优化前的系统参数:
-Xms12g -Xmx12g -Xmn4g -XX:ParallelGCThreads=4
-XX:+PrintGCDetails -XX:+PrintGCTimeStamps -XX:+UseCompressedClassPointers
-XX:+UseConcMarkSweepGC -XX:+UseParNewGC
gc 日志:
[GC (Allocation Failure) [ParNew: 2520325K->3721K(2831168K), 0.1117350 secs] 2955342K->438993K(5976896K), 0.0120977 secs] [Times: user=0.00 sys=0.04, real=0.11 secs]
[GC (Allocation Failure) [ParNew: 2520329K->3852K(2831168K), 0.1117688 secs] 2955601K->439390K(5976896K), 0.0121465 secs] [Times: user=0.01 sys=0.04, real=0.11 secs]
GC调整前的日志情况如下:
高峰期每分钟GC count 2~3个
GC平均耗时在100ms
YGC阶段回收效率超90%(说明年轻代中的对象都是朝生夕灭的,大部分都被回收了)
几乎没有full gc
因为几乎没有出现full gc 说明老年大设置的有点大,可以适当调整小点,那是不是意味着在总堆不变的情况下,年轻代就可以设置大些呢?如果年轻代设置过大,单位时间内YGC count数量会降低,但是YGC扫描的时间就会增大,从而单次GC的耗时就会增多。 所以折中之后,我们调整了GC了以下参数作为对比:
1. -xmx8g -xms8g -xmn4g
2. -xmx8g -xms8g -xmn3g
3. -xmx8g -xms8g -xmn5g
4. -xmx6g -xms6g -xmn3g
通过观察几天这几组GC参数对比,最终第四组gc 参数表现最好,而且几乎不出现full gc。最终我们选择了 -xmx6g -xms6g -xmn3g 作为我们GC的系统参数,省下的机器配置还可以继续水平扩展(线上我们用的是docker),通过此次调整我们发现系统堆不是设置的越大越好,适当的调整效果反而会更棒(当然这里也出现了小插曲,当时还设置了一组非常激进的参数 -xmx8g -xms8g -xmn6g,高峰期YGC表现更好,但是在下午16:00左右 系统不同程度出现了full gc ,单次full gc 耗时了几秒,在这几秒内的请求全部超时,所以参数设置的时候需要考虑full gc的情况,老年代不能设置的过低,需要合理设置,否则一旦出现full gc,对于整个系统将是一场灾难。)