浅谈JVM垃圾回收
JVM内存区域
要想搞懂啊垃圾回收机制,首先就要知道垃圾回收主要回收的是哪些数据,这些数据主要在哪一块区域。
Java8和Java8之前的相同点有很多。
都有虚拟机栈,本地方法栈,程序计数器,这三个是线程隔离的也称是线程独有的;
本地内存和堆是线程共享的。
Java8和之前JVM内存区域不同的是,Java8中增加了元空间,取消了永久代,Java8之前永久代是在堆中的,而之后方法区搬到了元空间中,元空间存在于本地内存中。
下面详细说一下各个内存区域的特点。
- 虚拟机栈:描述的是方法执行时的内存模型,是线程私有的,生命周期与线程同步,每个方法被执行的时候都会创建自己的栈帧,主要保存的是局部变量表,操作数栈,动态链接和方法的返回地址等信息。方法执行完成后就清空了栈帧的信息,入栈出栈实际都很明确,并且这块区域不需要进行GC。
- 本地方法栈:与虚拟机栈功能非常类似。主要区别是虚拟机栈是为虚拟机执行java方法,而本地方法栈是为虚拟机执行本地方法,因此这块区域也不需要进行GC。
- 程序计数器:用来记录每个线程执行到了哪一条指令。线程隔离的。比如每个字节码之前都有一个数字,我们可以认为他就是程序计数器存储的内容。这些数字的作用就是记录线程运行时的状态,方便线程下一次被唤醒的时候能从上次执行的位置继续执行,需要注意的是程序计数器是唯一一个在Java虚拟机中没有规定任何OOM情况的区域,因此这块区域也不需要进行GC。
- 堆:对象实例和数组都是在堆上分配的,GC主要对这两类数据进行回收。
- 本地内存:线程共享区域。本地内存也叫堆外内存,包含元空间和直接内存。从Java8开始,有了元空间的概念,我们来看一下为什么要取消永久代,永久代实际上指的是HotSpot虚拟机上的永久代,他用永久代实现了JVM规范定义方法区的功能,永久代主要存放类的信息,常量,静态变量,即时编译器编译后的代码等,永久代的大小是有限的,可以通过
XX:MaxPermSize
参数指定上限,所以如果动态生成类信息或者大量执行String.intern方法(直接将字符串放入永久代)就会造成永久代内存溢出引起OOM。因此在Java8中就将方法区的实现移动到本地内存中的元空间中,这样方法区就不受JVM的控制了,也就不进行GC,因此有一定的性能提升,同样这样方法区也方便在元空间中进行统一管理。
如何识别垃圾
引用计数法
引用计数法就是每个对象引用你一次,你的对象头上就+1,如果没有对象引用你(引用次数为0),那你凉凉,等着被回收吧。
听着引用计数确实可以解决我们无法识别哪些对象该被回收的问题,但是他还有个主要问题没被解决,那就是循环引用。什么是循环引用呢?
例如
A a = new Instance("a");
B b = new Instance("b");
a.instance=b;
b.instance=a;
a=null;
b=null;
虽然到最后a和b两个对象都被置为null,但是因为他们之前都互相引用过,所以引用的次数都是1,因此无法被回收。所以现代虚拟机都不使用这种方法来判断对象是否该回收了。
可达性分析算法
现代虚拟机主要是采用这种算法进行判断独享是不是该被回收。它的原理是从一个叫做GC Root对象为起点出发,引出他们指向的下一个节点,再从下一个节点出发,继续引出下一个,以此类推。这样就通过GCRoot节点串成了一条引用链,如果相关对象不是这个引用链上的节点,则会被判定为垃圾,然后会被回收。
可达性分析算法可以解决上述循环引用的问题,因为两个对象a,b都没有在GC Root所在的引用链上。
对象最后一次垂死挣扎的机会,finalize
方法。
当发生GC时,finalize方法给对象一个催死挣扎的机会,当对象可回收的时候,首先会判断这个对象是不是执行了finalize
方法,如果未执行,则会先执行finalize
,我们可以在finalize方法内部将本对象和GC Root关联起来,这样执行完方法后,GC会再次判断对象是否可被回收,如果可达则不会进行回收。
finalize方法只会执行一次,如果第一次执行方法这个对象变成了可达确实不会回收但是再次对这个对象进行回收的时候,则会忽略finalize方法。
哪些对象可以作为GC Root呢?
- 虚拟机栈中引用的对象(本地变量表中的对象)
- 方法区中静态属性引用的对象。
- 方法去中常量引用的对象。
- 本地方法Native中引用的对象。
再谈引用
JDK1.2后,Java对引用的概念进行了补充,将引用分为强引用,软引用,弱引用,虚引用。强度依次递减。
- 强引用:强引用就是new出来的引用,只要强引用存在,垃圾收集器就不会回收掉对象。
- 软引用:用来描述一些有用但是未必须的引用,在进行发生内存溢出之前会对软引用进行回收,如果内存空间充足不会回收软引用指向的对象,提供了SoftReferemce来实现软引用。
- 弱引用也是用来描述非必须对象。但是他的强度比软引用还要弱,弱引用关联的对象只能存活到下一次GC之前,无论内存是否充足都会回收弱引用关联的对象。弱引用用WeakReference类来实现。
- 虚引用:也叫幽灵引用或者幻影引用,是最弱的一种引用关系,一个对象是否有虚引用的存在完全不影响对象的生存时间,虚引用存在的目的就是能在这个对象被回收时收到一个系统通知。PhantomReference类来实现虚引用。
垃圾回收算法
上面讲了如何通过可达性分析算法来是被哪些数据是垃圾,那具体该通过什么方式回收垃圾呢?
垃圾回收算法主要由以下几种方式
- 标记清除法
- 复制算法
- 标记整理法
标记清除法
先用可达性分析算法标记处可回收的对象。
对可回收对象进行回收。
操作简单不需要移动数据,但是缺点也很明显,就是存在内存碎片。如果想要再申请的内存空间大小大于碎片的大小就会申请失败,那要是将回收过的内存区域和原先没有数据的区域都合并到一块就可以了。
复制算法
将堆等分成两块内存区域,我们暂且把他记作区域A和区域B,A负责分配对象,区域B不分配,A区域中的对象标记为可回收时,将A中所有不可回收的对象都赶到B中,对A进行统一清除,B中存活的对象紧邻排列。
这种算法的缺陷也很明显,我明明堆中还有很多空余的空间但是不能分配,只能使用一半的空间,另外每次回收都要移动对象,这是很浪费资源并且效率低下。
标记整理法
标记整理法与标记清除法不同的是他多了一步整理内存碎片的操作。将所有存活对象都往一端移动,紧邻排列,再清除另一端的所有区域,这样就解决了内存碎片的问题。
但是还有缺点:每次清除可回收对象都要进行对象的移动,效率很低下。
分代收集算法
分代收集算法整合了上面所讲的所有算法,综合以上算法优点,最大程度避免他们的缺点,因此使现代虚拟机采用的首选算法,于其说他是算法,倒不是说它是一种收集策略。
经过有关专家研究表明,大部分对象(98%)都是朝生夕死,经过一次年轻代的GC就会被回收,所以分代收集算法是根据对象存活周期的不同将堆分成新生代和老年代,在Java8之前还有永久代,新生代和老年代的比例是1:2,新生代又分为Eden区,from Survivor区,to Survivor区,简称S0区和S1区,Eden:S0:S1=8:1:1,我们将新生代发生的GC叫做Young GC或Minor GC,将老年代发生的GC叫做 Old GC也叫Full GC。
工作原理
新生代的分配和回收
新生代对象一般在Eden区分配,当Eden满的时候,会发生一次Minor GC,这次Minor GC很少有对象存活,因为大部分对象都是朝生夕死的,少部分存活的对象会被移动到S0区,同时这些对象的年龄+1,最后将Eden区中的所有对象都清除,释放空间。(复制算法
)
当发生下一次Minor GC时,会把Eden区中存活的对象和S0中存活的对象都移动到S1,这些对象的年龄+1,同时清空Eden和S0空间。
若再次发生MinorGC重复上面的步骤,只不过这次是将Eden和S1中存活的对象移动到S1,每次Young GC都是S0和S1来回之间移动。因为S0和S1区域比较小,所以降低复制算法频繁拷贝带来的开销。
对象是如何进入到老年代的
大对象直接进入老年代
大对象一般指的是很长的字符串或者数组,当出现大对象时,会导致提前触发GC,虚拟机提供了一个-XX:PertenureSizeThreshold
参数如果对象大小大于这个参数设置的阈值,就认为是大对象,直接分配到老年代,这样做的目的是避免Eden和S1,S0区域之间发生大对象的拷贝。
长期存活的对象进入老年代
虚拟机给每个对象都定义了一个年龄计数器,每次经过Minor GC后还存活下来的对象,他们的年龄+1,当计数器的值加到一定程度(默认是15),就会晋升到老年代,对象晋升老年代的阈值可以通过参数-XX:MaxTenuringThershold
设置。
动态对象年龄判定
这种情况也会晋升到老年代,如果Survivor区中相同年龄的对象大小之和大于Survivor区空间大小的一半,这时候年龄大于等于该年龄的对象也会直接进入老年代,无需和MaxTrnuringThershold参数进行比较。
空间分配担保
在发生Minor GC之前,虚拟机会检查老年代最大可用的连续空间是否大于新生代所有对象的总空间,如果大于,那么就可以确保Minor GC是安全的,如果不大于,虚拟机会查看HandlePromotionFailure
设置值是否允许担保失败,如果允许的话,那么会继续检查老年代对象的平均大小,如果大于则进行GC,否者可能进行一次Full GC。尽管空间分配担保绕的圈子很大,但是平时还是会开启担保的,因为可以减少Full GC的频率。
Stop The World
如果老年代满了,会触发Full GC,Full GC会同时回收新生代和老年代,也就是对整个堆进行GC,他会导致Stop The World,造成很大的性能开销。Stop The World就是指在这个GC期间,除了垃圾回收线程在工作,其他线程会被挂起。
一般Full GC会导致工作线程停顿时间过长,如果再次期间,服务端收到了客户端很多的请求,则会被拒绝服务,所以才要尽量减少Full GC的次数。
因此虚拟机设计成新生代分为Eden,S0,S1,并且设置对象年龄阈值,默认新生代和老年代的比例是1:2都是为了避免对象过早的进入老年代,尽可能晚的触发Full GC。
老年代采用标记整理法进行垃圾回收。
因为GC都会影响性能,所以我们要在一个合适的时间点发起GC,这个时间点被称为安全点(Safe Point),这个时间点的选定既不能太少让GC时间太长,也不能过于频繁以至于过分的增大运行时的负荷,安全点一般是以下特定的位置:
- 循环的末尾
- 方法返回前
- 调用方法的call之后
- 抛出异常的位置。
垃圾收集器的种类
收集算法其实是理论层面的,垃圾收集器才是这些理论具体的实现。
新生代收集器
Serial收集器
Serial收集器收集的是新生代,单线程的垃圾收集器,单线程意味着他只会使用一个CPU或者一个收集线程来进行垃圾回收,他在进行垃圾回收的时候,其他用户线程会暂停,在GC期间这个应用不可用。但是在用户端模式下,他是简单有效的,对于限定单个CPU的环境来说,Serial单线程模式无需与其他线程进行交互,较少了开销,专心做GC能将单线程的优势发挥到极致,在桌面应用场景下,一般不会给虚拟机分配很大的内存,因此STW(Stop The World)的时间会在100ms以内,这点停顿是可以接受的,所以对于Client模式下的虚拟机,Serial收集器是新生代的默认收集器。
ParNew收集器
ParNew收集器是Serial收集器的多线程版本,除了使用多线程,其他收集算法以及对象分配,回收策略都和Serial一样。ParNew主要工作在服务端,服务端如果接受的请求多了,响应时间就很重要,多线程可以让垃圾与回收更快,也就是减少了STW时间,提升响应速度,所以许多运行在服务端的虚拟机采用的新生代垃圾收集器是ParNew ,还有一点,他只能和CMS收集器配合工作,CMS是一个完全并发的收集器,第一次实现了垃圾收集线程和用户线程同时工作,采用的是传统的GC收集器代码框架,与Serial,ParNew共用一套代码框架,所以可以和这两个收集器配合工作。
在多CPU情况下,ParNew收集器垃圾收集更快,可以有效减少STW时间,提升服务端响应速度。
Parallel Scavenge收集器
Parallel Scavenge收集器也是一个使用复制算法,多线程,工作在新生代的垃圾收集器。看起来他的功能和ParNew收集器一样。但是还有一些不同。
关注点不同:CMS等垃圾收集器关注的是尽可能缩短垃圾收集时用户线程停顿的时间,而Parallel Scavenge目标是达到一个可控制的吞吐量。
CMS等垃圾收集器更适合用于与用户交互的应用,提升用户体验。而Parallel Scavenge收集器关注的是吞吐量,所以更适合用于后台运算等不需要太多用户交互的任务。
Parallel Scavenge收集器提供了两个参数来精确控制吞吐量,分别是控制最大垃圾手机时间的-XX:MaxGCPauseMillis
以及设置吞吐量大小的-XX:GCtimeRatio
默认是99%。
除了这两个参数外,还有第三个参数-XX:UseAdaptiveSizePolicy
开启这个参数后,就不要手工指定新生代大小比例等细节,只需要设置好堆的大小,以及最大垃圾收集时间和吞吐量,虚拟机就会根据当前系统运行情况动态调整这些参数尽可能的达到设定的最大垃圾收集时间和吞吐量,自适应策略是ParallelScavenger和ParNew的重要区别。
老年代收集器
Serial Old
Serial收集器是工作在新生代的单线程收集器。Serial Old是工作在老年代的单线程收集器。这个收集器的主要意义是给Client模式下的虚拟机使用,如果在Server模式下,他还有两大用途,一种是和JDK1.5以及之前的版本的Parallel Scavenge收集器配合使用,另一种是作为CMS的备用方案。
Parallel Old
Parallel Old收集器是相对于Parallel Scavenge收集器的老年代版本,使用多线程和标记整理法。
CMS
CMS收集器是以实现最短STW时间为目标的收集器,如果应艳红很重视服务的相应速度,希望给用户最好的体验,则CMS收集器是不错的选择。
CMS虽然工作在老年代但是回收算法使用的是标记清除法。
1、初始标记
2、并发标记
3、重新标记
4、并发清除
在这四个步骤中,初始标记和重新标记两个阶段会发生STW,造成用户线程挂起,不过初始标记仅仅标记GC Root能够关联的对象,速度很快,重新标记是进行GC Root跟踪引用链的过程,是为了修正并发标记期间因为用户线程继续运行而导致标记产生变动的哪一部分对象的标记记录,这一阶段停顿时间一般比初始标记更长,但比并发标记短。
整个过程执行时间最长的是并发标记和标记整理,不过这两个阶段用户线程都可以工作,所以不影响应用的正常使用,所以总体上看,可以认为CMS是内存回收线程和用户线程一起并发执行的。
但是他有三个缺点:
- CMS收集器对CPU资源非常敏感。比如本来有10个用户线程处理请求,现在要分出三个线程做垃圾回收工作,吞吐量下降了30%,CMS默认启动的回收线程数=(CPU数量+3)/4,如果CPU是2个,那么吞吐量直接降低50%。显然是不可接受的。
- CMS无法处理浮动垃圾,什么是浮动垃圾?因为并发清理阶段,用户线程还在工作,所以还会出现新的可回收对象,这部分垃圾只能在下一次GC时再清理,所以这部分垃圾就是浮动垃圾。因为垃圾收集阶段用户线程还在运行所以需要预留足够多的空间确保用户线程正常执行,这就意味着CMS收集器要提前进行Full GC,JDK1.5默认当老年代使用68%空间就后被激活,这个比例可以通过
-XX:CMSInitiatingOccupancyFraction
来设置,但是如果设置太高容易导致CMS运行期间预留的内存不够,导致Concurrent Model Failure,这时会启用Serial Old收集器进行老年代的收集工作,但是Serial old 是单线程的,这就导致STW时间更长了。 - CMS因为采用的是标记清除法,所以会存在大量的内存碎片,如果无法找到足够的内存空间进行分配,就会触发FUllGC进行垃圾回收,影响应用的性能,我们可以开启
-XX:+UseCMSCompactAtFullCollection
,这个参数是当CMS顶不住要进行Full GC时开启内存碎片的合并整理过程,内存整理会导致STW,停顿时间会变长,还可以用另一个参数-XX:CMSFullGCsBeforeCompation
用来设置执行多少次不压缩的Full GC过后再进行一次压缩。
G1(Garbage First)
G1收集器欧式面向服务端的垃圾收集器,被称为驾驭一切的垃圾回收器。
特点如下:
- 向CMS收集器一样,能与应用程序线程并发执行。
- 整理空闲空间更快。
- 需要GC停顿时间更好预测。
- 不会像CMS那样牺牲大量的吞吐性能。
- 不需要打的java 堆。
与CMS相比,它有以下方面表现得更为出色。
- 运行期间不会产生内存碎片。整体采用标记整理法,局部采用复制算法,两种算法都不会产生内部碎片。
- STW建立在可预测的停顿时间模型,用户可以指定期望停顿时间,G1将会停顿时间控制在用户设定的停顿时间以内。
他为什么能建立可预测模型呢?
主要原因是他和传统的内存分配存储方式不一样。传统内存分配是连续的,新生代,老年代。但是G1的存储地址不是连续的,每一代都是用N个不连续的大小相同的Region,每个Region占有一块连续的虚拟内存地址。和传统相比还多了一个H区,代表Humongous,标会存储的是大对象。当对象大小大于Region的一般,就直接分给老年代,防止GC时反复拷贝大对象。
这样做G1就可以根据Region的价值大小(回收所获得的空间大小以及回收经验值)进行排序,维护成一个优先级列表,根据允许的时间,回收截止最大的Region,也就避免了整个老年代的回收,减少了STW造成的停顿时间。
G1收集器工作步骤
- 初始标记
- 并发标记
- 最终标记
- 筛选回收
筛选阶段会根据各个Region的回收价值和成本进行排序,根据用户期望的GC停顿时间来制定回收计划。