首先针对垃圾收集提出的两个问题?
- 什么时候回收?
- 怎么回收?
针对问题1,为了回答什么时候回收这个问题,就需要清楚处于怎样状态下的对象才需要回收。
处于怎样状态下的对象才需要回收?在强引用下,只有当对象失去所有引用的时候,才要对其进行回收。
那么如何判断对象处于是否引用的状态呢?
目前有两种主流办法:
- 引用计数法
引用计数法是指每个对象都有一个引用计数器,每当该对象被引用,计数器加1,失去一个引用,计数器减1。如果该对象的计数器值为0时,则说明该对象无任何引用。
该方法的优势:判定效率高
劣势:解决不了"循环引用"问题
那么,何为循环引用呢?
举个例子:
//jack所引用的对象引用计数加1,reference = 1
Student jack = new Student();
//lucy所引用的对象引用计数加1,reference = 1
Student lucy = new Student();
//jack.goodFriend所引用的对象(即为jack所引用的对象)引用计数加1,reference = 2
jack.goodFriend = lucy;
//jack.goodFriend所引用的对象(即为lucy所引用的对象)引用计数加1,reference = 2
lucy.goodFriend = jack;
//jack.goodFriend所引用的对象(即为jack所引用的对象)引用计数减1,reference = 1
jack = null;
//jack.goodFriend所引用的对象(即为lucy所引用的对象)引用计数减1,reference = 1
lucy = null;
** 在该例子中,虽然两个对象还都有引用计数,但是经过 jack = null
和 lucy = null
之后,都无法再访问这两个对象了。所以由于这个劣势,在java中并没有使用到它,相反,使用的下面这种垃圾收集搜索算法 **
- 根搜索算法
根搜索算法是指:通过一系列名为"GC Roots"的对象作为起始点,从这些节点起开始向下搜索,搜索所走过的路径成为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连,即该对象到GC Roots不可达时,则此对象已经失去所有引用,已不可用了。
那么,哪些对象可以充当GC Roots对象呢?(为什么呢?)
- 虚拟机栈(栈帧中的本地变量表)中的引用的对象
- 方法区中的类静态属性引用的对象
- 方法区中的常量引用的对象
- 本地方法栈中JNI(Native方法)的引用的对象
扩展,"引用"这个词语,在Java里面的意思可有很多呢,光我知道就至少有四个,它们分别是:
- 强引用(Strong References)
对于强引用,则是我们经常在程序里面new一个对象,即 Boy boy = new Boy()
,只要boy不置为null,则我们new的这个对象就会一直存在,垃圾收集器这家伙就不敢拿它怎么办。
- 软引用(Soft References)
对于软引用的声明 SoftReference<T> softReference = new SoftReference<T>(Object obj);
具体例子: SoftReference<T> softReference = new SoftReference<T>(new Boy());
这个时候该Boy()实例就持有一个强引用boy和一个软引用softReference。
那么该软引用softReference有什么用呢?
当boy = null
的时候,此时可以通过softReference.get()方法来重新获得一个Boy的强引用。那么我就在想了,为什么要存在这个软引用呢?我再用一个强引用指向Boy不也可以吗?
先来看一下软引用的特点:
该软引用的确可以重新获得一个该对象的强引用,而且该软引用指向的对象也不会被垃圾收集器给收集,但是一旦JVM发现内存不够,那接下来就要对这些软引用开刀了---对它们所引用的对象进行收集,以获得所需要的内存。而一旦垃圾收集结束,该softReference.get()方法返回的便是null了。所以,软引用这种引用可以帮助我们再次获得强引用,但是它也有可能会被清理掉。
那么这种特点的意义何在?
软引用指向一个对象,一块内存,但是该对象我们有可能会使用到它,所以我们需要一个get()方法来立即获得该对象的一个强引用,但是也可能不会使用到它,可这样的话,这个对象又占着一块内存资源,所以我认为在这里JVM非常巧妙地采取了一种折中办法,在内存不够,OutOfMemory的时候,就要把这个对象给回收掉,空出多余的内存供系统正常使用。实在是妙呀!那么在什么应用场景下会使用到软引用呢?通过对软引用的了解,我认为在对数据、资源进行缓存的时候需要用到,有些非必须资源我们可以用一个软引用持有,当还没被回收掉的时候,可以提升应用程序的性能,而当内存不够,需要回收的时候,那就给回收掉,也没什么太大的损失。
- 弱引用(Weak References)
弱引用是什么?
声明一个弱引用WeakReference<T> weakWidget = new WeakReference<T>(Object obj)
举个例子:WeakReference<T> weakReference = new WeakReference<T>(new Boy());
这个时候weakReference就是作为一个指向Boy对象的弱引用。
那么这个弱引用有什么特点呢?
在JVM中,如果一个对象被一个弱引用所指向,那么该对象首先会在第一次垃圾收集周期被标记(没有任何条件,直接就会被标记),然后在第二次垃圾收集周期被回收掉。
不过在JAVA中提供了一个WeakHashMap()类,根据名字就可以得知这个类的一些基本用法了。WeakHashMap()类中的key为弱引用类型,value则为实例对象。当key所引用的对象被清理掉之后,该WeakHashMap()则会自动调用remove()方法来将对应的一组key-value给删除掉。
那么弱引用的这种特点有什么作用吗?
在学习这个弱引用的时候,查阅了许多的英文资料,不同的资料描述不一样,但是基本上说的还都是同一个东西。于此同时又对比了一下软引用,个人认为弱引用和它的功能比较类似,也是作为数据、资源缓存的一个很好的API,但之所以弱引用中有一个 "弱"字,就是因为该类型的引用不需要任何条件,直接就会被标记为垃圾,然后接下来一步就会被清理掉。所以在清理之前,我们可以通过 weakReference.get()来再次获得一个该对象的引用,等到清理掉之后,返回的就是null。
4.虚引用(Phantom References)
虚引用可以理解为该引用指向了一个已经调用过一次finalize()方法的对象,那么再下一次垃圾收集的时候,就果断将该对象给回收掉。虚引用是引用中最弱最弱的一种,以至于调用get()方法返回值始终都是null
。
虚引用的清除过程大概是怎样的呢?
Java提供了一个ReferenceQueue类,即为引用队列类,JVM会将该虚引用入队到该ReferenceQueue,等到出队的时候,也就是对象回收的时候,与此同时,也会给系统发送一个信号,表示该对象已被回收。
所以根据"对象被回收要接受到信号"这个特性,我们便先人一步知道了该对象被回收的时间,这个时候我们可以做一些后续的操作。
好了,总结完了对象何时会被回收之后,接下来要看看对象是如何被回收的。提到"如何"二字,如果用编程的思想来考虑的话,就是设计算法的问题了。
所以在"如何回收对象"这个问题上,JVM给我们提供了四种方法来解决。
- 标记-清除(Mark-Sweep)算法
简述一下该算法:该算法分为两个阶段:标记
和 清除
阶段。
在标记阶段,JVM所要做的事情有,给待回收的对象做上标记。
在清除阶段,JVM则会命令垃圾收集器在一次垃圾回收的时候对已经被标记的对象进行大清理。
但是,该算法存在怎样的问题呢?
会有内存碎片的产生
那么产生内存碎片有什么危害吗?
内存碎片一旦产生,就意味着我们的一部分内存就被分割成一块一块较小的内存了,这样每当有占有内存较大的对象要来分配的话,我们没有足够的内存来提供,但是这个对象又不可能不给人家分配内分对不对?所以JVM就不得不再次把垃圾收集器给叫过来,说:"看吧,都说了不建议你用这种 标记-清除方式 方法来干活,你就是不听,看看现在麻烦来了吧?你赶紧再去收集一次垃圾吧,抓紧腾出一块地方给刚刚那个新来的客人,人家是客,我们可惹不起"。垃圾收集器受到老大的这般训斥后,就赶紧屁颠屁颠地跑过去干活了。
- 标记-整理(Mark-Compact)算法
简述一下该算法:该算法与上一次算法的不同之处就在于,当每个待回收的对象被做上标记之后,垃圾收集器先不着急把它们一个个地给回收掉,而且先粗中有细地先把每个对象进行一个整理,怎么整理呢?将被标记的对象从第一个到最后一个依次有序地重新排列在内存的一端,然后再给一锅端了,这样做的好处与第一个算法相比,好处自然是大大的,为什么呢?因为不会产生内存碎片呀!
注意:该算法一般用在老年代内存区
- 复制(Copying)算法
简述一下该算法:"复制"二字,我们可以大概猜测这种算法可能是要复制一块内存吧?没错,准确地说,这种算法它会将内存分为均等的两份cake1和cake2,每份一模一样,不多也不少。然后在为对象分配内存的时候,会先在cake1上分配。最后当cake1上的内存被用光,要用到cake2内存的时候,就会先去cake1上检测哪些对象是可回收的,哪些是不可回收的。对于暂时还不可回收的对象,我们就直接将其依次有序地复制到cake2上,对于那些可回收的对象,就果断让垃圾收集器过来把它们统统给赶走。这样一来,我们的cake1就又完全变成一块崭新等待开发的内存了。这样每当再次需要为对象分配内存的时候,就在cake2上进行,接下来的过程就像第一次一样,循环交互,协同工作。
这种算法的优点:很明显,这种算法也不会产生内存碎片(其实只要不是随意地对对象进行回收,回收之前或者之后稍微做一些处理,都不会产生内存碎片的),而且实现简单,运行高效。
不过上面的那一种算法只是刚诞生时候的设计,它将内存按照1:1的比例来分配,这样有时候会造成50%的内存浪费,这对于程序员来说真得很让人痛心,那么这种算法有没有什么改进呢?
引用一段来自周志明先生所著的《深入理解Java虚拟机——JVM高级特性与最佳实践(第2版)》的原话:
IBM的专门研究表明,新生代中的对象98%是朝生夕死的,所以并不需要按照1:1的比例来划分内存空间。而是将内存分为一块较大的Eden空间和两块较小的Survivor空间,每次需要为对象分配内存的时候就现在Eden和其中的一块Survivor。当回收时,将Eden和Survivor中还存活着的对象一次性拷贝到另外一个Survivor空间上,最后清理掉Eden和刚才用过的Survivor的空间。HotSpot虚拟机默认Eden和Survivor的大小比例是8:1,也就是每次新生代中可用内存空间为整个新生代容量的90%,只有10%的内存是会被"浪费"的。
从这段话中我知道了原来该算法将内存划分比例从1:1调整到了8:1,其中划分了三个区域:Eden和两个Survivor,然后内存分配首先在Eden和一块Surivor上(也就是我的那个cake1),然后
当一次垃圾收集到来的时候,会根据上边复制算法描述的那样,该转移的转移,该清除的清除。不过转移的内存是第二块Survivor区域。
看完这本书里面的这段描述之后,觉得豁然开朗。但是随着思维的惯性又思考下去,发现遇到了一个问题:如果第二块Survivor的内存不够存储转移的对象了该怎么办?就不存储内存了吗?或者会发生内存溢出吗?又接着看下去发现原来书中对我这个疑惑给予了一定的解释,他是这样说的
当然,98%的对象可回收只是一般场景下的数据,我们没有办法保证每次回收都只有不多余10%的对象存活,当Survivor空间不够用时,需要依赖其他内存(这里指老年代)进行分配担保(Handle Promotion)。
有了这段话的解释,就多多少少解决了一些我的疑惑。不过又发现这两段引用中,有两个词不是太理解,一个叫"新生代",一个叫"老年代"。这两个**代指的又是什么呢?
4.分代收集(Generational Collection)算法
简述一下分代收集算法:"分代"是指根据对象的存活周期的不同把内存划分为几块,一般是把java堆分为新生代和老年代。哈哈,这里终于提到了"新生代"和"老年代"啦!那么,它俩具体指什么呢?
看一下这本书对其的简单介绍:
在新生代中,每次垃圾收集时都会有大批对象死去,只有少量存活。那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。而老年代中因为对象存活率高,没有额外空间对它进行分配担保,就必须使用"标记-清理"或"标记-整理"算法来进行回收。
真好,不光介绍了这两个代,而且把我刚刚学过的那些算法也用在相应了代上了。根据复制算法的特点我知道了何时选择它,当大部分对象处于"朝生夕死",使用次数不多的时候,这种算法就突显出了它的优势:内存浪费少,不会产生内存碎片,并且实现简单,运行高效。而当对象存活率高的时候,就用那两个标记算法。
垃圾收集器
前边学习了垃圾收集都有哪些算法,那么接下来就要来了解一下运行这些垃圾收集算法的东西,那就是垃圾收集器。
垃圾收集器的分类
HotSpot虚拟机的垃圾收集器Serial收集器
Serial收集器是最基本、历史最悠久的收集器,在JDK1.3.1之前是虚拟机新生代收集的唯一选择。
特点:单线程,简单高效(因为没有线程交互所带来的系统开销),进行垃圾收集时需要暂停其他所有线程(stop the world),所以就会有一定的卡顿现象。
Serial垃圾收集器应用:虚拟机在Clinet模式下的默认新生代收集器。
ParNew收集器
ParNew收集器是Serial收集器的多线程版本。
特点:多线程,速度相对较慢(因为有线程交互所带来的系统开销),进行垃圾收集时需要暂停其他所有进程
ParNew垃圾收集器应用:虚拟机在Server模式下首选的新生代收集器,不过为什么呢?目前除了Serial收集器外,目前只有它能与CMS收集器配合工作。
Parallel Scavenge收集器
Parallel Scavenge收集器是一个多线程的新生代收集器,使用复制算法。
特点:多线程,吞吐量优先
所谓吞吐量是指:CPU运行用户代码的时间与CPU总消耗时间的比值,即吞吐量 = 运行用户代码时间 / (运行用户代码时间 + 垃圾收集时间)
Serial Old收集器
Serial Old收集器是Serial收集器的单线程老年代版本,使用"标记-整理"算法
特点:适用于老年代,单线程
Serial Old垃圾收集器应用:
虚拟机在Client模式下使用该收集器;
在Server模式下,在JDK1.5及之前的版本中与Parallel Scavenge收集器搭配使用作为CMD收集器的后备预案,在并发收集发生Concurrent Mode Failure的时候使用。
Parallel Old收集器
Parallel Old收集器是Parallel Scavenge收集器的老年代版本,使用多线程和"标记-整理"算法。
特点:适用于老年代,多线程
Parallel Old垃圾收集器应用:在注重吞吐量及CPU资源敏感的场合,优先考虑Parallel Scavenge收集器和Parallel Old收集器
CMS收集器
CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器,基于"标记-清除"算法。
收集过程:
1.初始标记(CMS initial mark)
特点:单线程,stop the world
作用:仅仅是标记一下GC Roots能直接关联到的对象,速度很快
2.并发标记(CMS concurrent mark)
特点:单线程,与其他线程并发运行
3.重新标记(CMS remark)
特点:多线程,stop the world
作用:修正并发标记期间,因用户程序继续运行而导致标记产生变动的那一部分对象的标记记录。
4.并发清除(CMS concurrent sweep)
特点:单线程,与其他线程并发运行
CMS垃圾收集器应用:服务端
很明显的缺点:
1.对CPU资源敏感。CMS默认启动的回收线程(CPU数量 + 3) / 4 ,当CPU >= 4的时候,并发回收时垃圾收集线程最多占用不超过25%的CPU资源,但是当CPU < 4 的时候,CMS对用户程序的影响就可能变得很大。
2.CMS无法处理"浮动垃圾"(Floating Garbage),可能出现"Concurrent Mode Failure"失败而导致另一次Full GC的产生。 什么是"浮动垃圾"呢?在CMS结束标记之后,有一部分对象也成可以被清理的垃圾了,可CMS无法在本次的垃圾处理过程中回收掉它们,所以又动态产生的这部分垃圾叫做"浮动垃圾"。在CMS运行的同时,也有用户线程在运行,所以就需要预留够足够的内存空间给用户线程,而当CMS不能保证这一点的时候,就会出现"Concurrent Mode Failure"这种错误。
3.CMS基于的"标记-清除"算法会产生内存碎片。(不过CMS较好地解决了这种问题,解决的办法便是在经过一次的CMS垃圾处理过程服务之后,还会再送一个碎片整理服务)
G1收集器
G1收集器是当前收集器技术发展的最前沿成果,基于"标记-整理"算法,
特点:能够精确地控制停顿,可以实现在基本不牺牲吞吐量的前提下完成低停顿的内存回收。
那么,为什么有以上优点呢?引用一段来自《深入理解Java虚拟机-JVM高级特性与最佳实践》
G1收集器极力地避免全区域的垃圾收集,之前的收集器进行收集的范围都是整个新生代或老年代,而G1将整个JAVA堆(包括新生代、老年代)划分为多个大小固定的独立区域(Region),并且跟踪这些区域里面的垃圾堆积程度,在后台维护一个优先列表,每次根据允许的收集时间,优先回收垃圾最多的区域(Garbage First名字的由来)。这样一来,区域划分以及有优先级的区域回收,保证了G1收集器在有限的时间内可以获得最高的收集效率
内存分配与回收策略
1.对象优先在Eden分配
大多数情况下,对象在新生代Eden区中分配。当Eden区没有足够的空间进行分配时,虚拟机将发起一次Minor Gc(新生代垃圾收集,复制算法)。
2.大对象直接进入老年代
1.什么是大对象?
需要大量连续内存空间的Java对象(很长的字符串和数组)
那么,这样的大对象为什么要直接进入老年代呢?
因为经常出现大对象容易导致内存还有不少空间时就提前触发垃圾收集以获取足够的连续空间来"安置"它们。所以,虚拟机提供了一个-XX:PretenureSizeThreshold参数,如果所需内存值超过该参数值,就直接在老年代中分配,这样就直接避免了新生代区频繁地进行GC操作了。
3.长期存活的对象将进入老年代
如何衡量一个对象的存活时间呢?
JVM为每个对象设置了一个对象年龄计数器,每一次进行分代收集之后,如果位于新生代的对象还没有被收集的话,该年龄计数器加1,如果该值超过一个阀值(默认为15岁),则该对象会被调入到老年代中去享福咯!
4.动态对象年龄判定
这是另一种可以进入老年代的途径:如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,而无须等待到当初设定的那个阀值。
5.空间分配担保
JVM将内存分为新生代和老年代,在新生代又分为一个Eden区和两个Survivor区域,在进行垃圾收集的时候,第二块Survivor区域用于存储还存活的对象,但是有可能会存在所有存活对象所占内存过多,导致Survivor区域不够用,这个时候就要向老年代区域申请担保,把多余的对象放在老年区。不过此时需要有一个对老年区是否也能存放得下这些对象的一个评估,那就是根据之前每次晋升到老年代的平均大小是否大于老年代的剩余空间大小,
如果大于的话,就意味着此次的对象有很大的可能性是晋升不到老年代区的,意思就是老年代内存有很大可能是不够用的,那么该怎么做呢?只能把老年代区进行一次Full GC 来腾出一些空间了。
但是如果平均大小小于剩余空间的话,那就意味着有很大可能性是能够晋升的,那么就赶紧把这些对象给放进老年代区吗?等等!这里还有一个HandlePromotionFailure设置选项,该选项的意思是是否允许担保失败(这里是有可能失败的)。如果允许,一旦老年代区放不下,那就立马在新生代区执行MinorGC垃圾收集过程。如果不允许的话,一旦老年代放不下,那就要在老年代立马进行一次Full GC垃圾收集了。这样,无论哪种情况发生,我们要么在新生代进行MinorGC或者在老年代进行FullGC,这样总能尽最大可能来为对象分配内存空间。
参考资料
书籍:《深入理解Java虚拟机-JVM高级特性与最佳实践》周志强
网友评论