Java性能优化之JVM GC详解(垃圾回收机制)

JVM GC(垃圾回收机制)

在学习Java GC之前,我们需要记住一个单词:stop-the-world。它会出现在任何一种GC算法中。stop-the-world意味着JVM因为需要执行GC而停止了应用程序的执行。当stop-the-world发生时,除GC所需的线程外,所有的线程都进入等待的状态,直到GC任务完成。

GC优化很多时候就是减少stop-the-world的发生。

 

JVM GC回收哪个区域的垃圾?

需要注意的是,JVM GC只回收堆区和方法区内的对象。而栈区的数据,在超出作用域后会被JVM自动释放掉,所以其不在JVM GC的管理范围内。

 

JVM GC怎么判断对象可以被回收?

-对象没有引用

-作用域发生未捕获异常

-程序在作用域正常执行完毕

-程序执行了System.exit()

-程序发生意外终止(被杀线程等)

在Java程序中不能显示的分配和注销缓存,因为这些事情JVM都帮我们做了,那就是GC。

有时候我们可以将相关的对象设置成null来试图显式的清楚缓存,但是并不是设置成null就会一定被标记成可回收,有可能发生逃逸。

将对象设置成null至少没有什么坏处,但是使用System.gc()便不可取了,使用System.gc()时并不是马上执行GC操作,而是会等待一段时间,甚至不执行,而且System.gc()如果被执行,会触发Full GC,这非常影响性能。

 

JVM GC什么时候执行?

eden区空间不够存放新对象的时候,执行Minro GC。升到老年代的对象大于老年代剩余空间的时候执行Full GC,小于时被HandlePromotionFailure参数强制full gc。调优主要是减少Full GC的触发次数,可以通过NewRatio控制新生代转老年代的比例,通过Max Tenuring Threshold设置对象进入老年代的年龄阀值。

 

按代的垃圾回收机制

新生代(Young generation):绝大多数最新被创建的对象都会被分配到这里,由于大部分在创建后很快变得不可达,很多对象被创建在新生代,然后“消失”。对象从这个区域“消失”的过程我们称为:Minor GC

老年代(Old generation):对象没有变得不可达,并且从新生代周期中存活下来,会被拷贝到这里。其区域分配的空间要比新生代多。也正因为其较大的空间,发生在老年代的GC次数要比新生代少的多。对象从老年代消失的过程,称之为:Major GC或者Full GC。

持久层(Permanent generation)也称为方法区(Method area):用来保存类常量以及字符串常量。注意,这个区域不是用来存储那些从老年代存活下来的对象,这个区域也可能发生GC。发生在这个区域的GC时间也被算为Major GC。只不过这个区域发生GC的条件非常苛刻,必须符合以下三种条件才会被回收:

1.所有实例被回收

2.加载该类的ClassLoader被回收

3.CLass对象无法通过任何途径访问(包括访问)

如果老年代的对象需要引用新生代的对象,会发生什么?

为了解决这个问题,老年代中存在一个card table,他是一个512byte大小的块。所有老年代的对象指向新生代对象的引用都会被记录在这个表中。当针对新生代执行GC的时候,只需要查询card table来执行是否可以被回收,而不用查询整个老年代。这个card table由一个write barrier来管理。write barrier给GC带来了很大的性能提升,虽然由此可能带来一些开销,但完全是值得的。

 

默认的新生代(Young generation)、老年代(Old generation)所占空间比例为1:2

 

新生代空间的构成与逻辑

为了更好的理解GC,我们来学习新生代的构成,它用来保存那些第一次被创建的对象,它被分成三个空间

-一个伊甸园(Eden)

-两个幸存者空间(From Survivor、To Survivor)

默认新生代空间分配:Eden:From:To=8:1:1

每个空间的执行顺序如下:

1、绝大多数刚刚被创建的对象会存放在伊甸园空间(Eden)

2、在伊甸园空间执行第一次GC(Minor GC)之后,存活的对象被移动到其中一个幸存者空间(Survivor)

3、此后,每次伊甸园空间执行GC后,存活的对象会被堆积在同一个幸存者空间。

4、当一个幸存者空间饱和,还在存活的对象会被移动到另一个幸存者空间。然后会清空已经饱和的那个幸存者空间。

5、在以上步骤中重复N次(N = MaxTenuringThreshold(年龄阀值设定,默认15))依然存活的对象,就会被移动到老年代。

从上面的步骤可以发现,两个幸存者空间,必须有一个是保持空的。如果两个两个幸存者空间都有数据,或两个空间都是空的,那一定是你的系统出现了某种错误。

我们需要重点记住的是,对象在刚刚被创建之后,是保存在伊甸园空间的(Eden)。那些长期存活的对象会经由幸存者空间(Survivor)转存到老年代空间(Old generation)。

也有例外出现,对于一些比较大的对象(需要分配一块比较大的连续内存空间)则直接进入到老年代。一般在Survivor 空间不足的情况下发生。

 

老年代空间的构成与逻辑

老年代空间的构成其实很简单,它不像新生代空间那样划分为几个区域,它只有一个区域,里面存储的对象并不像新生代空间绝大部分都是朝闻道,夕死矣。这里的对象几乎都是从Survivor空间中熬过来的,它们绝不会轻易的go die 。因此Full GC(Major GC)发生的次数不会有Minor GC那么频繁,并且做一次Major GC的时间比Minor GC要更长(约10倍)。

 

JVM GC算法讲解

1、根搜索算法

根搜索算法是从离散数学中的图论引入的,程序把所有引用关系看作一张图,从一个节点GC ROOT 开始,寻找对应的引用节点,找到这个节点后,继续寻找这个节点的引用节点。当所有的引用节点寻找完毕后,剩余的节点则被认为是没有被引用到的节点,即无用的节点。

Java性能优化之JVM GC详解(垃圾回收机制)

 

上图红色为无用的节点,可以被回收。

目前Java中可以作为GC ROOT的对象有:

1、虚拟机栈中引用的对象(本地变量表)

2、方法区中静态属性引用的对象

3、方法区中常亮引用的对象

4、本地方法栈中引用的对象(Native对象)

基本所有GC算法都引用根搜索算法这种概念

 

2、标记-清楚算法

Java性能优化之JVM GC详解(垃圾回收机制)

标记-清楚算法采用从根集合进行扫描,对存活的对象进行标记,标记完毕后,再扫描整个空间中未被标记的对象进行直接回收。

标记-清除算法不需要进行对象的移动,并且仅对不存活的对象进行处理,在存活的对象比较多的情况下极为高效,但由于标记-清除算法直接回收不存活的对象,并没有对还存活的对象进行整理,因此会导致内存碎片。

 

3、复制算法

Java性能优化之JVM GC详解(垃圾回收机制)

复制算法将内存划分为两个区间,使用此算法时,所有动态分配的对象都只能分配在其中一个区间(活动区间),而另外一个区间(空间区间)则是空闲的。

复制算法采用从根集合扫描,将存活的对象复制到空闲区间,当扫描完毕活动区间后,会的将活动区间一次性全部回收。此时原本的空闲区间变成了活动区间。下次GC时候又会重复刚才的操作,以此循环。

复制算法在存活对象比较少的时候,极为高效,但是带来的成本是牺牲一半的内存空间用于进行对象的移动。所以复制算法的使用场景,必须是对象的存活率非常低才行,而且最重要的是,我们需要克服50%内存的浪费。

 

4、标记-整理算法

Java性能优化之JVM GC详解(垃圾回收机制)

标记-整理算法采用 标记-清除 算法一样的方式进行对象的标记、清除,但在回收不存活的对象占用的空间后,会将所有存活的对象往左端空闲空间移动,并更新对应的指针。标记-整理 算法是在标记-清除 算法之上,又进行了对象的移动排序整理,因此成本更高,但却解决了内存碎片的问题。

JVM为了优化内存的回收,使用了分代回收的方式,对于新生代内存的回收(Minor GC)主要采用复制算法。而对于老年代的回收(Major GC),大多采用标记-整理算法

 

 

垃圾回收器简介

需要注意的是,每一个回收器都存在Stop The World 的问题,只不过各个回收器在Stop The World 时间优化程度、算法的不同,可根据自身需求选择适合的回收器。

1、Serial(-XX:+UseSerialGC)

从名字我们可以看出,这是一个串行收集器。

Serial收集器是Java虚拟机中最基本、历史最悠久的收集器。在JDK1.3之前是Java虚拟机新生代收集器的唯一选择。目前也是ClientVM下ServerVM 4核4GB以下机器默认垃圾回收器。Serial收集器并不是只能使用一个CPU进行收集,而是当JVM需要进行垃圾回收的时候,需暂停所有的用户线程,直到回收结束。

使用算法:复制算法

Java性能优化之JVM GC详解(垃圾回收机制)

JVM中文名称为Java虚拟机,因此它像一台虚拟的电脑在工作,而其中的每一个线程都被认为是JVM的一个处理器,因此图中的CPU0、CPU1实际上为用户的线程,而不是真正的机器CPU,不要误解哦。Serial收集器虽然是最老的,但是它对于限定单个CPU的环境来说,由于没有线程交互的开销,专心做垃圾收集,所以它在这种情况下是相对于其他收集器中最高效的。

 

2、SerialOld(-XX:+UseSerialGC)

SerialOld是Serial收集器的老年代收集器版本,它同样是一个单线程收集器,这个收集器目前主要用于Client模式下使用。如果在Server模式下,它主要还有两大用途:一个是在JDK1.5及之前的版本中与Parallel Scavenge收集器搭配使用,另外一个就是作为CMS收集器的后备预案,如果CMS出现Concurrent Mode Failure,则SerialOld将作为后备收集器。

使用算法:标记 - 整理算法

运行示意图与上图一致。

 

3、ParNew(-XX:+UseParNewGC)

ParNew其实就是Serial收集器的多线程版本。除了Serial收集器外,只有它能与CMS收集器配合工作。

使用算法:复制算法

Java性能优化之JVM GC详解(垃圾回收机制)

ParNew是许多运行在Server模式下的JVM首选的新生代收集器。但是在单CPU的情况下,它的效率远远低于Serial收集器,所以一定要注意使用场景。

 

4、ParallelScavenge(-XX:+UseParallelGC)

ParallelScavenge又被称为吞吐量优先收集器,和ParNew 收集器类似,是一个新生代收集器

使用算法:复制算法

ParallelScavenge收集器的目标是达到一个可控件的吞吐量,所谓吞吐量就是CPU用于运行用户代码的时间与CPU总消耗时间的比值,即吞吐量 = 运行用户代码时间 / (运行用户代码时间 + 垃圾收集时间)。如果虚拟机总共运行了100分钟,其中垃圾收集花了1分钟,那么吞吐量就是99% 。

 

5、ParallelOld(-XX:+UseParallelOldGC)

ParallelOld是并行收集器,和SerialOld一样,ParallelOld是一个老年代收集器,是老年代吞吐量优先的一个收集器。这个收集器在JDK1.6之后才开始提供的,在此之前,ParallelScavenge只能选择SerialOld来作为其老年代的收集器,这严重拖累了ParallelScavenge整体的速度。而ParallelOld的出现后,“吞吐量优先”收集器才名副其实!

使用算法:标记 - 整理算法

Java性能优化之JVM GC详解(垃圾回收机制)

在注重吞吐量与CPU数量大于1的情况下,都可以优先考虑ParallelScavenge + ParalleloOld收集器。

 

6、CMS (-XX:+UseConcMarkSweepGC)

CMS是一个老年代收集器,全称 Concurrent Low Pause Collector,是JDK1.4后期开始引用的新GC收集器,在JDK1.5、1.6中得到了进一步的改进。它是对于响应时间的重要性需求大于吞吐量要求的收集器。对于要求服务器响应速度高的情况下,使用CMS非常合适。

CMS的一大特点,就是用两次短暂的暂停来代替串行或并行标记整理算法时候的长暂停。

使用算法:标记 - 清理

CMS的执行过程如下:

· 初始标记(STW initial mark)

在这个阶段,需要虚拟机停顿正在执行的应用线程,官方的叫法STW(Stop Tow World)。这个过程从根对象扫描直接关联的对象,并作标记。这个过程会很快的完成。

· 并发标记(Concurrent marking)

这个阶段紧随初始标记阶段,在“初始标记”的基础上继续向下追溯标记。注意这里是并发标记,表示用户线程可以和GC线程一起并发执行,这个阶段不会暂停用户的线程哦。

· 并发预清理(Concurrent precleaning)

这个阶段任然是并发的,JVM查找正在执行“并发标记”阶段时候进入老年代的对象(可能这时会有对象从新生代晋升到老年代,或被分配到老年代)。通过重新扫描,减少在一个阶段“重新标记”的工作,因为下一阶段会STW。

· 重新标记(STW remark)

这个阶段会再次暂停正在执行的应用线程,重新重根对象开始查找并标记并发阶段遗漏的对象(在并发标记阶段结束后对象状态的更新导致),并处理对象关联。这一次耗时会比“初始标记”更长,并且这个阶段可以并行标记。

· 并发清理(Concurrent sweeping)

这个阶段是并发的,应用线程和GC清除线程可以一起并发执行。

· 并发重置(Concurrent reset)

这个阶段任然是并发的,重置CMS收集器的数据结构,等待下一次垃圾回收。

CMS的缺点:

1、内存碎片。由于使用了 标记-清理 算法,导致内存空间中会产生内存碎片。不过CMS收集器做了一些小的优化,就是把未分配的空间汇总成一个列表,当有JVM需要分配内存空间的时候,会搜索这个列表找到符合条件的空间来存储这个对象。但是内存碎片的问题依然存在,如果一个对象需要3块连续的空间来存储,因为内存碎片的原因,寻找不到这样的空间,就会导致Full GC。

2、需要更多的CPU资源。由于使用了并发处理,很多情况下都是GC线程和应用线程并发执行的,这样就需要占用更多的CPU资源,也是牺牲了一定吞吐量的原因。

3、需要更大的堆空间。因为CMS标记阶段应用程序的线程还是执行的,那么就会有堆空间继续分配的问题,为了保障CMS在回收堆空间之前还有空间分配给新加入的对象,必须预留一部分空间。CMS默认在老年代空间使用68%时候启动垃圾回收。可以通过-XX:CMSinitiatingOccupancyFraction=n来设置这个阀值。

 

7、GarbageFirst(G1)

这是一个新的垃圾回收器,既可以回收新生代也可以回收老年代,SunHotSpot1.6u14以上EarlyAccess版本加入了这个回收器,Sun公司预期SunHotSpot1.7发布正式版本。通过重新划分内存区域,整合优化CMS,同时注重吞吐量和响应时间。杯具的是Oracle收购这个收集器之后将其用于商用收费版收集器。因此目前暂时没有发现哪个公司使用它,这个放在之后再去研究吧。

 

整理一下新生代和老年代的收集器。

新生代收集器:

Serial (-XX:+UseSerialGC)

ParNew(-XX:+UseParNewGC)

ParallelScavenge(-XX:+UseParallelGC)

G1 收集器

 

老年代收集器:

SerialOld(-XX:+UseSerialOldGC)

ParallelOld(-XX:+UseParallelOldGC)

CMS(-XX:+UseConcMarkSweepGC)

G1 收集器

 

System.gc()方法解释

1. System.gc(); Runtime.getRuntime().gc() 这两种方法用于显示通知JVM可以进行一次垃圾回收,但垃圾回收机制具体在什么时间运行是无法预知的。

意思是 通知回收之后 不一定被回收 。而且这个方法对资源消耗较大尽量不要显式去调用这个方法

 

减少GC开销的措施

  根据上述GC的机制,程序的运行会直接影响系统环境的变化,从而影响GC的触发。若不针对GC的特点进行设计和编码,就会出现内存驻留等一系列负面影响。为了避免这些影响,基本的原则就是尽可能地减少垃圾和减少GC过程中的开销。具体措施包括以下几个方面:

(1)不要显式调用System.gc()

  此函数建议JVM进行主GC,虽然只是建议而非一定,但很多情况下它会触发主GC,从而增加主GC的频率,也即增加了间歇性停顿的次数。

(2)尽量减少临时对象的使用

  临时对象在跳出函数调用后,会成为垃圾,少用临时变量就相当于减少了垃圾的产生,从而延长了出现上述第二个触发条件出现的时间,减少了主GC的机会。

(3)对象不用时最好显式置为Null

  一般而言,为Null的对象都会被作为垃圾处理,所以将不用的对象显式地设为Null,有利于GC收集器判定垃圾,从而提高了GC的效率。

(4)尽量使用StringBuffer,而不用String来累加字符串

  由于String是固定长的字符串对象,累加String对象时,并非在一个String对象中扩增,而是重新创建新的String对象,如Str5=Str1+Str2+Str3+Str4,这条语句执行过程中会产生多个垃圾对象,因为对次作“+”操作时都必须创建新的String对象,但这些过渡对象对系统来说是没有实际意义的,只会增加更多的垃圾。避免这种情况可以改用StringBuffer来累加字符串,因StringBuffer是可变长的,它在原有基础上进行扩增,不会产生中间对象。

(5)能用基本类型如Int,Long,就不用Integer,Long对象

  基本类型变量占用的内存资源比相应对象占用的少得多,如果没有必要,最好使用基本变量。

(6)尽量少用静态对象变量

  静态变量属于全局变量,不会被GC回收,它们会一直占用内存。

(7)分散对象创建或删除的时间

  集中在短时间内大量创建新对象,特别是大对象,会导致突然需要大量内存,JVM在面临这种情况时,只能进行主GC,以回收内存或整合内存碎片,从而增加主GC的频率。集中删除对象,道理也是一样的。它使得突然出现了大量的垃圾对象,空闲空间必然减少,从而大大增加了下一次创建新对象时强制主GC的机会。

 

 

关于垃圾回收的几点补充

  经过上述的说明,可以发现垃圾回收有以下的几个特点:

 

  (1)垃圾收集发生的不可预知性:由于实现了不同的垃圾回收算法和采用了不同的收集机制,所以它有可能是定时发生,有可能是当出现系统空闲CPU资源时发生,也有可能是和原始的垃圾收集一样,等到内存消耗出现极限时发生,这与垃圾收集器的选择和具体的设置都有关系。

  (2)垃圾收集的精确性:主要包括2 个方面:

(a)垃圾收集器能够精确标记活着的对象;

(b)垃圾收集器能够精确地定位对象之间的引用关系。前者是完全地回收所有废弃对象的前提,否则就可能造成内存泄漏。而后者则是实现归并和复制等算法的必要条件。所有不可达对象都能够可靠地得到回收,所有对象都能够重新分配,允许对象的复制和对象内存的缩并,这样就有效地防止内存的支离破碎。

  (3)现在有许多种不同的垃圾收集器,每种有其算法且其表现各异,既有当垃圾收集开始时就停止应用程序的运行,又有当垃圾收集开始时也允许应用程序的线程运行,还有在同一时间垃圾收集多线程运行。

  (4)垃圾收集的实现和具体的JVM 以及JVM的内存模型有非常紧密的关系。不同的JVM 可能采用不同的垃圾收集,而JVM 的内存模型决定着该JVM可以采用哪些类型垃圾收集。现在,HotSpot 系列JVM中的内存系统都采用先进的面向对象的框架设计,这使得该系列JVM都可以采用最先进的垃圾收集。

  (5)随着技术的发展,现代垃圾收集技术提供许多可选的垃圾收集器,而且在配置每种收集器的时候又可以设置不同的参数,这就使得根据不同的应用环境获得最优的应用性能成为可能。

  针对以上特点,我们在使用的时候要注意:

  (1)不要试图去假定垃圾收集发生的时间,这一切都是未知的。比如,方法中的一个临时对象在方法调用完毕后就变成了无用对象,这个时候它的内存就可以被释放。

  (2)Java中提供了一些和垃圾收集打交道的类,而且提供了一种强行执行垃圾收集的方法--调用System.gc(),但这同样是个不确定的方法。Java 中并不保证每次调用该方法就一定能够启动垃圾收集,它只不过会向JVM发出这样一个申请,到底是否真正执行垃圾收集,一切都是个未知数。

  (3)挑选适合自己的垃圾收集器。一般来说,如果系统没有特殊和苛刻的性能要求,可以采用JVM的缺省选项。否则可以考虑使用有针对性的垃圾收集器,比如增量收集器就比较适合实时性要求较高的系统之中。系统具有较高的配置,有比较多的闲置资源,可以考虑使用并行标记/清除收集器。

  (4)关键的也是难把握的问题是内存泄漏。良好的编程习惯和严谨的编程态度永远是最重要的,不要让自己的一个小错误导致内存出现大漏洞。

  (5)尽早释放无用对象的引用。大多数程序员在使用临时变量的时候,都是让引用变量在退出活动域(scope)后,自动设置为null,暗示垃圾收集器来收集该对象,还必须注意该引用的对象是否被监听,如果有,则要去掉监听器,然后再赋空值。