前言
在一个风和日丽的中午,和同事小勇一起走在公司楼下的小公园里面,看到很多的小姐姐,心想什么时候能够和这些小姐姐一起讨论人生呀,美滋滋,嘿嘿嘿。
收起你的哈喇子好不好,小勇总是在这个时候发出声音,挺让人喜(fu)欢(ck)的。
小勇:小农,现在不是推崇垃圾分类吗,你说到底什么是垃圾?小勇总是在我和他散步的时候,问这么让人深思的问题!
我:什么是垃圾啊,你不就是垃圾吗?
小勇:去你大爷的,正经的。
我:小勇啊,答应我以后散步的时候我们讨论点轻松点的问题好嘛?垃圾是啥,垃圾就是没有引用的对象就是垃圾啊
小勇:。。。。,我们还是去午休吧
我:别啊,都讲到这里了,给你普及一下,你难道不想以后你的简历上出现——熟悉GC常用算法,熟悉常见垃圾收集器,具有实际JVM调优实战经验吗?保证让你豁然开朗,等你以后去面试的时候,给面试官讲这些保证妥妥的。
小勇:你这么说我倒是有点兴趣,但是如果讲不明白,那你就浪费了我时间了,晚饭就你请吧。
我是没问题,但是我的三个粉丝不会答应你的
小勇:你没问题就行了,请开始你的表演吧~
什么是垃圾
什么是垃圾,就是没有任何引用指向的一个对象或者多个对象(循环引用),但是他们却依然占据着内存空间。
GC是一种自动的存储管理机制。当一些被占用的内存不再需要时,就应该予以释放。这种存储资源管理,称为垃圾回收。
就像我们的衣柜一样,我们里面可能存放这很多衣服,有可能几个月或者几年都不会穿过一次,但是这些我们不穿的衣服一直霸占着我们的衣柜(内存),我们把这些不会穿的衣服扔掉的或者捐赠出去,这样我们就可以放更多可以穿的衣服,这个就类似于“垃圾回收”。
在GC里面,只分为可回收和不可回收,如下图所示:
1.1 Java 和 C++ 垃圾回收的区别
Java是你只管扔垃圾就可以,Java会自动帮你处理,而C++要手动处理,但是容易造成一个问题就是忘记回收或者回收多次
-
java
- GC处理垃圾
- 开发效率高,执行效率低
-
C++
- 手工处理垃圾
- 忘记回收,会导致内存泄漏
- 回收多次,非法访问
- 开发效率地,执行效率高
怎么找垃圾?
上面我们知道了什么是垃圾,那么我们如何去找到垃圾呢?
在堆里面存放这Java中几乎所有的对象实例,垃圾收集器在对堆进行回收前,首先要做的事情就是确定这些对象哪些还 “存活”,哪些是需要进行回收的(即不再被引用的对象)
找到垃圾有两种算法
- reference count (引用计数算法)
- Root Searching (根可达算法)
1. 引用计数法
会给对象中添加一个引用计数器,每当有一个地方引用它的时候,计数器的值就 +1 ,当引用失效时,计数器值就 -1 ,计数器的值为 0 的对象不可能在被使用,这个时候就可以判定这个对象是垃圾。
当图中的数值变成0时,这个时候使用引用计数算法就可以判定它是垃圾了,但是引用计数法不能解决一个问题,就是当对象是循环引用的时候,计数器值都不为0,这个时候引用计数器无法通知GC收集器来回收他们,如下图所示:
这个时候就需要使用到我们的根可达算法
2. 根可达算法
根可达算法的意思是说从根上开始搜索,当一个程序启动后,马上需要的那些个对象就叫做根对象,所谓的根可达算法就是首先找到根对象,然后跟着这根线一直往外找到那些有用的,例如我们Java程序 main() 方法运行,一个main() 方法会启动一个线程。
线程栈变量: 线程里面会有线程栈和main栈帧,从这个main() 里面开始的这些对象都是我们的根对象。
静态变量: 一个class 它有一个静态的变量,load到内存之后马上就得对静态变量进行初始化,所以静态变量到的对象这个叫做根对象。
常量池: 如果你这个class会用到其他的class的那些个类的对象,这些就是根对象。
JNI: 如果我们调用了 C和C++ 写的那些本地方法所用到的那些个类或者对象
图中的 object5 和object6 虽然他们之间互相引用了,但是从根找不到它,所以就是垃圾,而object8没有任何引用自然而然也是垃圾,其他的Object对象都有可以从根找到的,所以是有用的,不会被垃圾回收掉。
3. 区别
算法 | 思想 | 优点 | 缺点 |
---|---|---|---|
引用计数法 | 给对象添加一个引用计数器,每当一个地方引用这个对象的时候,计数器值就+1;当引用失效时,计数器值-1 | 判定效率高 | 不能解决对象之间相互引用的情况,开销比较大,频繁且大量的引用变化,带来大量的额外运算 |
可达性分析 | 通过一系列称为 "GC Roots" 的对象作为起始点,从这些节点向下搜索,当GC Roots到某个对象不可达时,这个对象就是可回收的 | 更加精确和严谨,可以分析出循环数据结构相互引用的情况 | 实现比较复杂,需要分析大量的数据,消耗大量时间 |
如何清理垃圾
我们找到对应的垃圾之后,我们如果去清理垃圾呢?GC常用的算法有三种:
- Mark-Sweep(标记清除)
- Copying(拷贝)
- Mark-Compact(标记压缩)
1. 标记 - 清除算法
就和它的名字一样 ,算法分为 “标记” 和 “清除” 两个阶段,首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象,这个是最基础的收集算法,为什么说它是最基础的,因为后续的收集器都是基础这种思路并对其不足进行改进而得到的。
标记清除算法它有自己的小问题,大家可以看到上面这张图,我们从GC的根找到那些不可回收的,绿色是不可回收的,紫色是可以回收的,我们把它回收之后就变成空闲的了,这种算法相对比较简单,在存活对象比较多的情况下效率比较高,它需要经历两次扫描,第一遍扫描是找到那些有用的,第二遍扫描是把那些没用的找出来清理掉,这里会有两个问题:一个是效率问题,标记和清除两个过程的效率都不高,另一个是空间问题,标记清除之后会产生大量不连续的空间碎片,如果空间碎片太多会导致以后的程序在运行过程中需要分配较大对象的时候,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。
2. 复制算法
为了解决效率的问题,所以有了复制(Copying)算法的出现,它将可用的内存按容量划分为大小相等的两块,每次只使用其中的一块,当这一块的内存用完了,就将还存活着的对象赋值到另一块上面,然后再把已使用过的内存空间一次清理掉,这样使得每次都对整个半区进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只需要移动堆顶的指针,这种适用于存活对象较少的情况,所以比较适合eden区,只扫描一次,效率提高了没有碎片,但是会造成空间的浪费,将内存缩小为原来的一半,未免太高了一点,而且移动复制对象,需要调整对象的引用
3. 标记 压缩算法
标记压缩就是把所有的东西整理的过程,清理的过程同时压缩到头上去。回收之前,有用的往前面走,将剩下的清理出来,但是标记压缩算法依然有它的问题,我们都是通过GC Roots 找到那些不可回收的对象,然后把不可回收的往前挪,这个时候我们需要扫描两次而且需要移动对象,第一遍扫描出有用的对象,第二遍进行移动,而且移动如果是多线程还需要进行同步,所以这个效率会低很多,但是它不会产生碎片,分配对象也不会产生内存减半。
4. 总结
- Mark-Sweep(标记清除): 标记为垃圾之后就给清理掉,别的空间还是固定的,效率还可以,就是容易产生碎片
- Copying(拷贝): 将内存一分为二,只使用一半,如果垃圾太多了,拷贝有用的到另外一边,剩下的清理就直接整个内存进行清理,效率比较高
- Mark-Compact(标记压缩): 将所有的对象凑在一起,把垃圾全部清理走,接下来剩下的这个空间还是连续的,在里面分配任何内容的时候直接往里面分配就行了
堆内存逻辑分区
JVM中的Hot Spot 用的是分代算法
新生代分为:eden、survivor
eden(伊甸): 默认比例8:是我们刚刚新 new出来对象之后往里扔的那块区域
survivor: 默认比例是1:是回收一次之后跑到这个区域,这里面由于装的对象不同,所以采取的算法也不同
由于新生代存活对象特别少,死去对象特别多所以使用的算法是 复制算法
old 老年代:tenured(终身)
老年代活着的对象特别多适用于:标记清除和标记压缩算法
一个对象从出生到消亡
一个对象产生之后首先进行栈上分配,栈上如果分配不下会进入伊甸区,伊甸区经过一次垃圾回收之后进入surivivor区,survivor区在经过一次垃圾回收之后又进入另外一个survivor,与此同时伊甸区的某些对象也跟着进入另外一个survivot,什么时候年龄够了就会进入old区,这是整个对象的一个逻辑上的移动过程。
那什么时候会在栈上分配,什么时候会在伊甸区分配?
1 栈上分配
栈上分配:
- 线程私有小对象:小对象、线程私有的
- 无逃逸:就在某一段代码中使用,除了这段代码就没有人认识它了
- 支持标量替换:意思是用普通的属性、把普通的类型代替对象就叫标量替换
栈上分配会比在堆上分配快一点,如果在栈上分配不下,会优先进行本地分配,也就是 线程本地分配TLAB(Thread local Allocation Buffer): 在伊甸区很多线程都会往里面分配对象,但是分配对象的时候我们一定会进行空间的征用,谁抢到算谁的,多线程的同步,效率就会降低,所以设计了TLAB机制
- 占用eden,默认为1%,在伊甸区取用百分之一的空间,这块空间叫做线程独有,分配对象的时候首先往线程独有的这块空间进行分配
- 多线程的时候不用竞争eden就可以申请空间,提高效率
2 老年代
对象什么时候进入老年代?
回收了多少次进入老年代?
- 超过
XX:MaxTenuringThreshold
指定次数(YGC)- Parallel Scavenge 15次进入老年代
- CMS 6次进入老年代
- G1 15次进入老年代
网上有说可以次数往上调大,这个是不可能的
动态年龄判断
为了能够适用不同程序的内存状况,虚拟机并不是永远的要求对象的年龄必须达到了MaxTenuringThreshold才能晋升老年代,如果在Surivivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无需等到MaxTenuringThreshold中要求的年龄。
两个Survivor之间拷贝来拷贝去只要超过百分之50的时候把年龄最大的直接放入到old区,也就是不一定非得到15岁。
在s1里面有这么多对象拷贝到了s2里面超过百分之50的话,s1里面在加上伊甸区里面,整个一个对象一下子拷贝到s2里面,经过一次垃圾回收,过去之后,这个时候整个加起来对象已经超过s2的一半了,这里面年龄最大的一些对象直接进入老年区,这个就叫做动态年轻判断
大对象直接进入老年代 ,所谓的大对象是指,需要连续大量内存空间的Java对象,最典型的大对象就是那种很长的字符串以及数组,经常出现大对象容易导致内存还有不少空间的时候就提前触发了垃圾收集来获得足够的连续内存空间
start 先是new一个对象,然后在栈上进行分配,如果在栈上能够分配,就分配到栈上,栈直接弹出,弹出结束,如果在栈上分配不下,判断对象是否为大对象,如果是大对象,直接进入老年代,FGC后结束如果不是,进入线程本地分配(TLAB),不管怎么样都会到伊甸区进行GC清除,如果清除完毕,直接结束,如果没有清除完毕,进入S1,S1继续GC清除,如果年龄到了进入old区,如果年龄不够进入S2,然后S2再继续GC的清除,要么年龄到了,要么动态年龄达到
MinorGC/YGC: 年轻代空间耗尽时触发
MajorGC/FullGC: 在老年代无法继续分配空间时触发,新生代老年代同时进行回收
常见的垃圾回收器
新生代收集器: Serial、ParNew、Parallel Scavenge
老年代收集器: Serial Old、CMS、Parallel Old
新生代和老年代收集器: G1、ZGC、Shenandoah
每种垃圾回收器之间不是独立操作的,下图表示垃圾回收器之间有连线表示,可以协作使用:
新生代垃圾收集器
1. Serial收集器
Serial 收集器是最基础、历史最悠久的收集器,是一个单线程工作的收集器,它的“单线程”的意义不是说他只会使用一个处理器或者一条收集线程去完成垃圾收集的工作,更重要的是强调在它进行垃圾收集的时候,会暂停其他所有工作线程,直到它收集结束
根据上图中我们可以知道,当Serial收集器运行的时候,会暂停所有线程,“Stop The World” ,等到GC完成后,应用线程继续执行,就类似于 你有三个女朋友,他们同时让你陪他们去逛街,你只能陪完其中一个才能去陪另外一个,陪其中一个的时候,其他女朋友就要等待,但是垃圾收集这项工作要比这种情况要复杂的多!
优势: 因为使用的是单线程的方式,所以对于单个CPU来说,是其他类型收集器中效率最高的一个
缺点: 在用户不可知、不可控的情况下,暂停所有线程,风险性和体验感不好,让人比较难接受
使用命令:可以开启Serial 作为新生代收集器
-XX:+UserSerialGC #选择Serial作为新生代垃圾收集器
2. ParNew收集器
ParNew收集器实质上是Serial收集器的多线程并行版本,除了同时使用多条线程进行垃圾收集器之外,其余的比如Serial收集器可用的控制参数、收集算法、Stop The Wrold 、对象分配规则等等都和Serial收集器完全一样,在多核机器上,默认开启的手机线程数和CPU数量一样,但是可以通过参数进行修改
-XX:ParallelGCThreads #设置JVM垃圾收集的线程数
ParNew收集器除了支持多线程并行收集之外,其他与Serial收集器相比并没有太多创新之处,但它 却是不少运行在服务端模式下的HotSpot虚拟机,尤其是JDK 7之前的遗留系统中首选的新生代收集 器,其中有一个与功能、性能无关但其实很重要的原因是:除了Serial收集器外,目前只有它能与CMS 收集器配合工作。
优点:随着CPU的有效利用,对于GC时系统资源的有效利用有好处
缺点:同Serial一样的毛病
使用场景:ParNew是许多运行在Server模式下的虚拟机中首选的新生代收集器,因为CMS只能与Serial 或者 ParNew 配合使用,在如今的多核环境下,首选的是多线程并行的ParNew,ParNew收集器是激活CMS后(使用-XX:+UseConcMarkSweepGC选项)
的默认新生代收集器,也可以使用-XX:+/-UseParNewGC
选项来强制指定或者禁用它
3. Parallel Scavenge收集器
Parallel Scavenge收集器也是一款新生代的收集器,它同样是基于标记-复制算法那实现的收集器,也是能够并行收集器的多线程收集器,Parallel Scavenge收集器关注点与其他收集器的不用处在于,CMS等收集器的关注点是尽可能地缩短垃圾收集时用户线程的停顿时间,而Parallel Scavenge收集器的目标则是一个可控制的吞吐量,所谓的吞吐量就是处理器用于运行用户代码的时间与处理器总消耗的比值,如下图所示:
如果说虚拟机完成某个任务,用户代码加上垃圾收集总共耗费了100分钟,其中垃圾收集花掉1分钟,那么吞吐量就是99%。停顿时间越短就越适合需要与用户交互或者需要保证服务响应质量的程序,良好的响应速度能提升用户体验。
垃圾收集器每100秒收集一次,每次停顿10秒,和垃圾收集器每50秒收集一次,每次停顿时间7秒,虽然后者停顿时间变短了,但是总体吞吐量变低了,CPU总体利用率变低了。
收集频率 | 每次停顿时间 | 吞吐量 |
---|---|---|
100秒收集一次 | 10秒 | 91% |
每50秒收集一次 | 7秒 | 88% |
可以通过 -XX:MaxGCPauseMillis
来设置收集器尽可能在多长时间内完成内存回收,可以通过 -XX:GCTimeRatio
来精确控制吞吐量。
如下是 Parallel 收集器和 Parallel Old 收集器结合进行垃圾收集的示意图,在新生代,当用户线程都执行到安全点时,所有线程暂停执行,ParNew 收集器以多线程,采用复制算法进行垃圾收集工作,收集完之后,用户线程继续开始执行;在老年代,当用户线程都执行到安全点时,所有线程暂停执行,Parallel Old 收集器以多线程,采用标记整理算法进行垃圾收集工作。
老年代垃圾收集器
1. Serial Old 收集器
Serial Old 是 Serial收集器的老年代版本,它同样是一个单线程收集器,使用标记-整理算法,这个收集器的主要意义也是供客户端模式下HotSpot虚拟机使用。如果在服务端一种是与Parallel Scavenge收集器搭配使用,另外一种是作为CMS 收集器发生失败时的后备预案。
Serial收集器与Serial Old收集器的运行示意图:
适用场景: Client模式;单核服务器;与Parallel Scavenge收集器搭配;作为CMS收集器的后备方案,在并发收集发生Concurrent Mode Failure时使用
2. Parallel Old收集器
Parallel Old 是 Parallel Scavenge收集器的老年代版本,支持多线程并发收集,基于标记-整理算法实现,可以充分利用多核CPU的计算能力,虑Parallel Scavenge/Parallel Old收集器运行示意图:
2. CMS收集器
CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。CMS收集器是基于标记-清楚算法实现的,这个收集器的运作过程比前面的几个收集器更复杂一点,整个过程分为四个步骤:
1) 初始标记(CMS initial mark): 只是标记 GC Roots能够直接关联到的对象,速度很快
2) 并发标记(CMS concurrent mark): 从GC Roots 的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长但是不需要停顿用户线程,可以和垃圾收集线程一起并发运行
3) 重新标记(CMS remark): 修正并发标记期间,因用户程序继续运作导致标记产生对象的标记记录,这个阶段的停顿时间会比初始标记阶段稍长一些
4) 并发清除(CMS concurrent sweep): 清理删除掉标记阶段判断的已经死亡的对象,由于不需要移动存活对象,这个阶段也是可以与用户线程同时并发的。
其中 初始标记、重新标记这两个步骤仍然需要 “Stop The World” 暂停所有用户线程,由于在整个过程中耗时最长的并发标记和并发清理阶段中,垃圾收集器线程都可以与用户线程一起工作,总体来说,CMS收集器的内存回收过程是和用户线程一起并发执行的,如下图所示:
优点: CMS收集器是一款优秀的收集器,它主要体现为:并发收集、低停顿。
缺点:
CMS收集器对处理器资源非常敏感,在并发阶段,虽然不会导致用户线程停顿,但也会因为占用一部分线程导致应用程序变慢,降级总的吞吐量。CMS默认启动回收线程数是(处理器核心数量+3)/4,也就是说如果处理器核心数大于等于四个,并发回收时垃圾收集线程只占用不超过25%的处理器运算资源,处理器资源会随着CPU数量的增加而下降,但是当CPU数量不足四个的时候,CMS对用户程序的影响就可能变的很大。
CMS收集器无法处理 “浮动垃圾” ,有可能出现 “Concurrent Mode Failure” 失败进而导致另一次完全“Stop The World” 的Full GC 的产生,在CMS的并发标记和并发清理阶段,用户线程是还在继续进行的,程序在运行自然就还会伴随有新的垃圾对象不断产生,但这一部分垃圾对象是出现在标记过程结束以后,CMS无法在当次收集中处理掉它们,只好留待下一次垃圾收集时再清理掉。这一部分的垃圾就称为“浮动垃圾”
因为CMS是一款基于 “标记-清除”算法实现的收集器,因此收集结束时会有大量的空间碎片产生,空间碎片过多的时,将对给大对象带来很大的麻烦,有可能不得不提前进行Full GC的操作,不过通过参数:-XX:+UseCMS-CompactAtFullCollection
进行优化
新生代和老年代垃圾收集器
G1收集器
Garbage First (简称G1)收集器是垃圾收集器技术发展历史上的里程碑式的成果,它开创了收集器面向局部收集的设计思路和基于Region的内存布局形式。
G1收集器是一款面向服务器端应用的垃圾收集器,在JDK9发布的时候成为服务端模式下的默认垃圾收集器,而CMS则沦为不被推荐使用的收集器
特点:
在G1收集器出现之前所有的其他收集器,目标范围要么是新生代要么是老年代,要么就是Java堆,但是G1做了全面性,它可以面向堆内存任何部分来组成回收集进行回收,衡量标准不再是它属于哪个分代,而是那块内存中存放的垃圾数量最多,回收收益最大,这就是G1收集器的Mixed GC模式。而G1开创的基于Region的堆内存布局是它能够实现这个目标的关键。
虽然G1仍然保留了新生代和老年代的概念,但新生代和老年代不再是固定的,他们都是一系列区域的动态集合,G1可以建立可预测的停顿时间模型,是因为它将Region作为单次回收最小单元
G1不再坚持固定大小以及固定数量的分代区域划分,而是把连续的Java堆划分为多个大小相等的独立区域,每一个Region都可以根据需要,扮演新生代的Eden空间、Survivor空间或者老年代空间,收集器能够对扮演不同的角色的Region采用不同的策略去处理。
Region中海油一类特殊的Humongous区域,专门用来存储大对象。G1认为只要大小超过一个Region容量一半的对象即可判定为大对象。
G1收集器的运行过程:
-
初始标记(Initial Marking): 标记GC Roots 能直接关联到的对象,并且修改TAMS指针的值,让下一阶段用户线程并发运行时,能正确在可用的Region中分配新对象,需要耗时较短的停顿线程,但是是借用Minor GC的时候同步完成的,所以在这个阶段实际没有额外的停顿
-
并发标记(Concurrent Marking): 从GC Roots 开始对堆中对象进行可达性分析,递归扫描整个堆里面的对象图,找出要回收的对象,这个阶段耗时较长,但可以和用户程序并发执行。
-
最终标记(Final Marking): 对用户线程做另一个短暂的暂停,用户处理并发阶段结束后仍遗留下来的最后那少量的SATB记录
-
筛选回收(Live Data Counting and Evacuation): 负责更新Region的统计数据,对各个Region的回收价值和成本进行排序,根据用户锁期望的停顿时间来制定回收计划,可以只有选择任意多个Region构成回收集,然后把决定回收的那一部分Region的存活对象赋值到空的Region中,再清理整个Region的全部空间。
总结
小勇你懂了吗?小勇小勇,你别睡着了啊,我还没讲完呢!小勇醒醒啊!!!
小勇迷迷糊糊的说:怎么了,下班了吗?
。。。。,下班啥,我讲的GC你听懂了吗?
小勇:听懂了,我明天就去面试,你讲的太棒了!
。。。。。敷衍,算了我已经把东西都放在笔记里面了,你要是感兴趣就可以来看看,今天就到这里了,我们上去吧
end....
我是牧小农,怕什么真理无穷,进一步有进一步的欢喜,大家加油!