java的JVM垃圾回收之深入理解

前言

JVM的自动内存管理得益于不断发展的垃圾回收器,从最初的单线程收集到现在并发收集,垃圾回收器的开发者们一直在致力于如何降低GC过程中的停顿时间(STW)以及提高吞吐量,但直到现在也不存在一款完美的垃圾回收器,只能根据不同的场景选择最合适的。所以需要了解每款垃圾回收器出现的背景、原因,并掌握各种垃圾回收器的设计原理、算法实现细节以及各个垃圾回收器的优劣对比,这样才能让我们在调优时做出最合适的选择。这部分内容博主准备分为两篇文章进行总结讲解,本篇主要是对垃圾收集算法的思想以及目前稳定商用的垃圾回收器(具体实现)的讲解。

正文

一、垃圾收集算法

上文分析了JVM判断对象存活的两种算法:引用计数可达性分析。因此垃圾收集算法的实现也对应的分为引用计数式收集追踪式收集,而目前JVM中都没有使用引用计数算法,所以后面讲解的算法都属于追踪式收集。其细分又分为标记-复制标记-清除标记-整理分代回收

标记-复制

复制算法最初的理论是将可用内存分为1:1的两块,每次只使用其中一块,当这块内存满后,就先标记存活对象并将其复制到另一块内存,然后将满的内存释放掉。这种算法非常简单高效,只需要将标记的存活对象复制到另一半空间,同时内存始终保持规整,不会出现内存碎片,但缺点也很明显,可用内存减少了一半,另外复制的对象不能太大,否则复制的效率会比较低
因为新生代中的对象大多“朝生夕死”,在JVM新生代中的垃圾收集器都是采用的复制算法。但是为避免浪费的空间太多,提出了一种更为优化的复制算法,称为Appel式回收该算法不再是简单的“半区复制”,而是将新生代分为了三块:一块Eden区和两块Survivor区(分别标记为from和to),默认的分配比例是8:1:1(-XX:SurvivorRatio=8表示两个Survivor区和Eden区比例为2:8,即每个Survivor占10%),每次分配对象都只使用Eden区和其中一块Survivor区(from区)。其中Eden区最大,新对象都在该区域创建,当Eden区满后,会进行一次MinorGC,并将Eden区和from区中存活对象都复制到to区中,然后调换from和to指针。当然肯定是存在to区装不下一次MinorGC存活对象的情况,这时就需要老年代进行分配担保(相关概念在上一篇已经讲过)。
从上面的算法过程中堵着门应该会有一个疑惑:为什么需要两个Survivor区?这里以假设法进行分析。如果没有Survivor区,那么新生代每次GC后存活对象会直接进入老年代,导致老年代迅速填满,频繁的触发FullGC;如果只有一块Survivor区,那么为了保证复制算法的特性(内存规整和高效),Eden区经过一次MinorGC后会将对象复制到Survivor若是Survivor有对象被清理,则还需要进行整理,这时新对象只能在Survivor区创建,否则无法保证内存规整,但又由于Survivor区非常小,就会导致很快又触发有一次MinorGC;而如果有两块Survivor区就很好的解决了上面所说的问题,而更多的Survivor区就没有必要了。
如果对象过大,新生代空间不足,可能直接将对象放置到老年代中

标记-清除

标记清除是最早出现的垃圾回收算法,由Lisp之父提出。这个算法也很简单,首先标记存活的对象,然后统一回收未被标记的对象。相较于复制算法的缺点也很明显,效率更低,同时会导致内存碎片。为什么效率更低了呢,好比你删除文件,直接格式化文件夹快还是去文件夹中找到文件一个个删除更快?另外内存碎片会导致堆中明明还有足够的内存,但却没有足够的连续内存来存放大对象,导致对象直接进入老年代

标记-整理

这个算法就是建立在标记清除的基础之上,多了一步整理的工作,标记完成后首先将存活的对象移动到一边,然后清理掉另一边的内存,解决了内存碎片带来的问题。标记-清除标记-整理都适合用在老年代中,而前者相较于后者不用移动内存,而移动内存是一种非常“危险”的操作,需要暂停其它用户线程的执行,确保内存指向的正确性,所以这就是STW出现的原因,就好比你不能在你妈妈打扫屋子的同时边往地上扔垃圾。

分代回收

分代回收严格意义上并不算一种算法,而是各回收算法的实践理论。它建立在两个分代假说之上:

  • 弱分代假说(Weak Generational Hypothesis):绝大多数对象都是朝生夕灭的。
  • 强分代假说(Strong Generational Hypothesis):熬过越多次垃圾收集过程的对象就越难以消
    亡。

上面两个假说共同确定了垃圾收集器一致的设计原则,即新生代老年代。在新生代中使用复制算法,如上所说,大部分对象朝生夕灭,所以只需要将少量存活对象复制到另一块区域后再统一格式化之前的区域;而老年代因为大量对象存活,只能采用标记清除标记整理算法。
分代回收可以避免垃圾回收时总是进行全堆扫描,但是也带来另外一个问题,不同代之间可能会存在引用,若没有其它的处理手段,那么在进行新生代垃圾回收时除了遍历GC Roots外,不得不再额外遍历整个老年代中的对象,所以为解决这个问题,需要添加第三条经验法则:

  • 跨代引用假说:跨代引用相对于同代引用来说仅占极少数。

两个相互依赖的对象,基本上是应该是同生同灭的,所以跨代引用数应该是比较少的,那么JVM在扫描时自然就没必要也不应该去扫描整个老年代,可以在收集区域维护一个数据结构,记录从非收集区域指向收集区域的引用,那么在扫描时只需要额外扫描这个数据结构就行了,该结构被称为记忆集(相关细节下一篇分析)

什么时候回收

分代模型,分两个时机:

  • scavenge(minor gc)

触发时机:新对象生成时,Eden空间满了

其执行时间很短,使用复制算法执行效率较高

  • full gc

触发时机:old装不下eden的对象、old占用达到阈值、perm满了、system.gc()执行时

对整个jvm进行整理,包括young、old、perm

效率相对较低

回收区域

虚拟机栈、本地方法栈和程序计数器,这三个区域是线程私有的。比如栈帧的生命周期是和线程关联的,即随线程而生,随线程而死。

回收堆中的对象,方法中废弃的常量和无用的类

二、常用的垃圾回收器

垃圾回收器是垃圾回收算法的实现,在虚拟机规范中并没有定义要如何实现垃圾回收器,所以各大厂商对垃圾回收器的实现有很大差别,但都是在朝着一个方向努力:低延迟、高吞吐量。

上图中展示的就是目前主流的垃圾回收器,有连线的代表两者可以搭配使用,而打“X”的表示在JDK9中已经废弃的组合,另外从图中我们还可以发现除了G1,其它垃圾回收器都只能作用于新生代老年代中的其中一个区域,那么G1是不是表示废除了分代理论呢?下面来逐个介绍。

Serial/SerialOld

这两个是最早出现的垃圾回收器,如其名,它们都是单线程的垃圾回收器,只适合几十兆到一两百兆的堆空间的垃圾回收,如果用于更大的堆空间会导致系统停顿时间较长,想象一下系统每隔一段时间就要停止处理请求几分钟甚至更长时间,你能接受么?下图是他们的工作原理:
 


可以看到新生代或老年代在进行垃圾回收时都会暂停所有的用户线程,图中的SafePoint表示线程能够安全暂停的时机,即JVM要进行垃圾回收时,不可能随意暂停所有的线程,必须要确保线程处于安全点才能暂停它。这里先有这个概念,细节在下一篇进行阐述。
该组合可以通过-XX:+UseSerialGC参数开启。

ParNew

该收集器就是Serial的多线程版本,但在单核处理器环境中表现还不如Serial(涉及线程的切换)。它默认开启的收集线程数与处理器核心数量相同,在处理器核心非常多的环境中,可以使用-XX:ParallelGCThreads参数来限制垃圾收集的线程数。
 


另外需要注意的是它是除了Serial之外唯一可以与CMS配合的垃圾收集器,在激活CMS后(使用-XX:+UseConcMarkSweepGC选项)的默认新生代收集器,也可以使用-XX:+/-UseParNewGC选项来强制指定或者禁用它,在JDK9以后ParNew成为了CMS的一部分。

Parallel Scavenge/ParallelOld

Parallel Scavenge与其它垃圾收集器不同,其它的是追求尽可能小的GC停顿时间,而它主要关注吞吐量,所谓吞吐量就是代码运行时间/(代码运行时间 + 垃圾回收时间)。比如虚拟机运行100分钟,垃圾回收耗时1分钟,那么吞吐量就是99%。但是这款收集器在JDK1.6之前比较尴尬,没有与之对应的并行的老年代收集器,只能采用SerialOld老年代收集器,使得表现比不上PareNew+CMS的组合。直到ParallelOld出现后,Parallel Scavenge才能真正的展现它吞吐量的优势。
 


Parallel Scavenge有以下几个重要的参数:

  • -XX:MaxGCPauseMillis:该参数的值是一个大于0的毫秒数,收集器尽量保证GC停顿时间不超过该值,但是不要天真的认为该值越小越好。该值设置的太小会导致每次GC的回收率降低,垃圾堆积,GC发生的越来越频繁。比如原先需要100ms收集500M空间,现在设置为50ms,那么可能就只能回收300M或者更小的垃圾。
  • -XX:GCTimeRatio:控制垃圾回收时间比率。比如允许最大垃圾回收时间占总时间的5%,那么需要将该值设置为19(公式是1/(1 + 19))。
  • -XX:+UseAdaptiveSizePolicy:这个参数激活后,就不再需要我们手动设定新生代各区(Eden、from、to)的比例(-XX:SurvivorRatio),晋升老年代对象的大小(-XX:PretenureSizeThreshold),虚拟机会监控运行时的状态,进行动态的调整,这种方式称为垃圾收集的自适应调节策略(GC Ergonomics)。

CMS

CMS(Concurrent Mark Sweep)是第一款并发垃圾收集器,并发是指垃圾收集可以和用户线程同时进行。同时它也是唯一采用标记清除算法对老年代进行回收的垃圾回收器。它包含了以下几个阶段

  • 初始标记:STW,只标记与GC Roots直接关联的对象
  • 并发标记:和用户线程同时运行,进行可达性分析
  • 重新标记:STW,暂停用户线程,修正上一阶段变动的对象
  • 并发清除:最后是并发的清除掉垃圾


从上面我们可以发现CMS的整个过程中只有初始标记重新标记是需要暂停用户线程的,初始标记只是标记与GC Roots直接关联的对象,所以耗时只和GC Roots的数量有关,非常快;重新标记的耗时会比初始标记略长,但也远远比并发标记用时短,所以CMS就是通过细分GC的阶段来降低GC的停顿时间。
你可能会好奇为什么需要重新标记并且暂停所有用户线程,因为在与用户线程并发执行的同时肯定会存在引用变动的情况,而要处理这个问题,都是必须要暂停用户线程的,关于引用变动的处理在下一篇会详细分析
CMS可以说是一款跨时代的垃圾收集器,可以回收几个G到-20G左右的堆空间,但它存在以下几个明显的缺点:

  • CPU敏感:虽然并发标记并发标记是和用户线程并发执行的,但是也因此占用了系统的资源,导致应用程序忽然变慢,降低吞吐量。CMS默认启动的线程数是(处理器核心数+3)/4,因此当核心数量大于等于4时,GC占用资源不超过25%,但核心数小于4时,就会占用大量系统资源。
  • 大量的内存碎片:因为CMS是使用标记清除算法实现垃圾回收,所以会产生大量的内存碎片。为了避免这个问题,CMS采用了一个折中的办法,即提供一个-XX:+UseCMS-CompactAtFullCollection参数,该参数默认开启,控制CMS在进行FullGC的同时进行空间整理,但这样又会导致停顿时间加长,所以还提供了-XX:CMSFullGCsBefore-Compaction参数,控制CMS在进行了多少次不带整理的FullGC后进行一次带整理的FullGC,默认值是0,即每次FullGC都会整理,该参数JDK9后被废弃。
  • 浮动垃圾:因为最终清除的过程也是和用户线程并发执行的,因此这个过程中必然会产生新的垃圾,这一部分垃圾需要预留空间来存放,等待下一次GC的时候再清理,因此会浪费一部分空间。在JDK5的默认配置下,当老年代使用空间超过68%时就会进行GC,到JDK6时,这个阈值就提高到了92%,另外也可以通过-XX:CMSInitiatingOccu-pancyFraction参数控制。但该值越高,那么并发清理过程中可使用的内存就越小,当放不下时,就会出现一次Concurrent Mode Failure,这时候虚拟机就会冻结线程并采用SerialOld进行垃圾回收,导致停顿时间变得更长。

Garbage First

G1是目前最前沿且可商用的垃圾收集器,另外还有ZGC等更为前沿的垃圾收集器还处于试验阶段。它与其它垃圾收集器不同的是,他将堆空间化整为零,将内存区域划分为多个大小相等的独立区域(Region),使得它可以回收堆中的任何一个区域,而不是像其它的垃圾收集器要么只能回收新生代,要么只能回收老年代。但不是说G1就没有新生代和老年代了,它的每个Region都可以根据需要扮演Eden、Survivor或老年代,垃圾收集器也会针对不同角色的Region采用不同的策略去处理。
 


每个Region的大小可以通过-XX:G1HeapRegionSize设定,取值范围为1M~32M,且必须为2的N次幂。超过单个Region一半容量的对象即为大对象,而对于超过整个Region的对象将会使用多个连续的Humongous空间存放,G1大多数情况下都把Humongous作为老年代一部分看待。
 


G1的运行过程如上,它也包含了以下4个步骤:

  • 初始标记:STW,也是只标记GC Roots直接关联的对象,并修改TAMS的指针值(G1为每一个Region设计了两个名为TAMS(Top at Mark Start)的指针,把Region中的一部分空间划分出来用于并发回收过程中的新对象分配,并发回收时新分配的对象地址都必须要在这两个指针位置以上,垃圾回收时也不会回收这部分空间),这个过程耗时很短,而且是借用进行 Minor GC 的时候同步完成的,所以 G1 收集器在这个阶段实际并没有额外的停顿。
  • 并发标记:可达性分析找出要回收的对象,在对象扫描完成后,由于是与用户线程并发执行的,所以存在引用变动的对象,这部分对象会由SATB算法来解决(原始快照,下一篇详细分析)。
  • 最终标记:STW,处理并发阶段遗留的少量遗留的SATB记录。
  • 筛选回收:根据用户设定的-XX:MaxGCPauseMillis最大GC停顿时间对Region进行排序,并回收价值最大的Region,尽量保证满足参数设定的值(该值效果和Parallel Scavenge部分讲解的是一样的)。这里的回收算法就是讲存活的对象复制到空的Region中,即G1局部Region之间采用的是复制算法,而整体上采用的是标记整理算法

G1适合上百G的堆空间回收,与CMS的权衡在6~8G之间,较大的堆内存才能凸显G1的优势,可以通过-XX:+UseG1GC参数开启。

内存泄漏

一个不再被程序使用的对象或变量还在内存中占有存储空间。

    一次内存泄漏似乎不会有大的影响,但内存泄漏堆积后的后果就是内存溢出。

Java内存泄露引起原因

首先,什么是内存泄露?经常听人谈起内存泄露,但要问什么是内存泄露,没几个说得清楚。内存泄露是指无用对象(不再使用的对象)持续占有内存或无用对象的内存得不到及时释放,从而造成的内存空间的浪费称为内存泄露。内存泄露有时不严重且不易察觉,这样开发者就不知道存在内存泄露,但有时也会很严重,会提示你Out of memory。

那么,Java内存泄露根本原因是什么呢?长生命周期的对象持有短生命周期对象的引用就很可能发生内存泄露,尽管短生命周期对象已经不再需要,但是因为长生命周期对象持有它的引用而导致不能被回收,这就是java中内存泄露的发生场景

具体主要有如下几大类:

1、静态集合类引起内存泄露:

像HashMap、Vector等的使用最容易出现内存泄露,这些静态变量的生命周期和应用程序一致,他们所引用的所有的对象Object也不能被释放,因为他们也将一直被Vector等引用着。

例:

Static Vector v = new Vector(10);

for (int i = 1; i<100; i++)

{

Object o = new Object();

v.add(o);

o = null;

}//

在这个例子中,循环申请Object 对象,并将所申请的对象放入一个Vector 中,如果仅仅释放引用本身(o=null),那么Vector 仍然引用该对象,所以这个对象对GC 来说是不可回收的。因此,如果对象加入到Vector 后,还必须从Vector 中删除,最简单的方法就是将Vector对象设置为null。

2、当set集合里面的对象属性被修改后,再调用remove()方法时不起作用。

例:

public static void main(String[] args)

{

Set<Person> set = new HashSet<Person>();

Person p1 = new Person("唐僧","pwd1",25);

Person p2 = new Person("孙悟空","pwd2",26);

Person p3 = new Person("猪八戒","pwd3",27);

set.add(p1);

set.add(p2);

set.add(p3);

System.out.println("总共有:"+set.size()+" 个元素!"); //结果:总共有:3 个元素!

p3.setAge(2); //修改p3的年龄,此时p3元素对应的hashcode值发生改变

set.remove(p3); //此时remove不掉,造成内存泄漏

set.add(p3); //重新添加,居然添加成功

System.out.println("总共有:"+set.size()+" 个元素!"); //结果:总共有:4 个元素!

for (Person person : set)

{

System.out.println(person);

}

}

3、监听器

在java 编程中,我们都需要和监听器打交道,通常一个应用当中会用到很多监听器,我们会调用一个控件的诸如addXXXListener()等方法来增加监听器,但往往在释放对象的时候却没有记住去删除这些监听器,从而增加了内存泄漏的机会。

4、各种连接

比如数据库连接(dataSourse.getConnection()),网络连接(socket)和io连接,除非其显式的调用了其close()方法将其连接关闭,否则是不会自动被GC 回收的。对于Resultset 和Statement 对象可以不进行显式回收,但Connection 一定要显式回收,因为Connection 在任何时候都无法自动回收,而Connection一旦回收,Resultset 和Statement 对象就会立即为NULL。但是如果使用连接池,情况就不一样了,除了要显式地关闭连接,还必须显式地关闭Resultset Statement 对象(关闭其中一个,另外一个也会关闭),否则就会造成大量的Statement 对象无法释放,从而引起内存泄漏。这种情况下一般都会在try里面去的连接,在finally里面释放连接。

5、内部类和外部模块等的引用

内部类的引用是比较容易遗忘的一种,而且一旦没释放可能导致一系列的后继类对象没有释放。此外程序员还要小心外部模块不经意的引用,例如程序员A 负责A 模块,调用了B 模块的一个方法如:

public void registerMsg(Object b);

这种调用就要非常小心了,传入了一个对象,很可能模块B就保持了对该对象的引用,这时候就需要注意模块B 是否提供相应的操作去除引用。

6、单例模式

不正确使用单例模式是引起内存泄露的一个常见问题,单例对象在被初始化后将在JVM的整个生命周期中存在(以静态变量的方式),如果单例对象持有外部对象的引用,那么这个外部对象将不能被jvm正常回收,导致内存泄露,考虑下面的例子:

class A{

public A(){

B.getInstance().setA(this);

}

....

}

//B类采用单例模式

class B{

private A a;

private static B instance=new B();

public B(){}

public static B getInstance(){

return instance;

}

public void setA(A a){

this.a=a;

}

//getter...

}

显然B采用singleton模式,它持有一个A对象的引用,而这个A类的对象将不能被回收。想象下如果A是个比较复杂的对象或者集合类型会发生什么情况

总结

本篇是对常用垃圾收集器的实现原理的整体性分析比较,这一部分是必须掌握的,下一篇则是关于算法的实现细节,如三色标记是什么、并发标记过程中引用变动如何解决、跨代引用如何处理等等一系列问题。

  • 10
    点赞
  • 22
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值