前提概念
如何判断那些对象可以回收?
垃圾收集算法
垃圾收集器的介绍(基于JDK 1.7)
内存分配与回收策略
在Java中,我们通常所说的垃圾回收,大部分说的就是对Java堆和方法区中的无用的内存进行回收的行为策略。因为Java不像C系语言,需要自行管理内存。而是将内存交给虚拟机来管理。
我们知道运行时数据区有五大块,程序计数器,虚拟机栈,本地方法栈,堆和方法区。
而程序计数器,虚拟机栈,本地方法栈都是线程私有的,生命周期跟随线程,当线程结束的时候,它们的内存就会被自然释放。且栈中的栈帧随着方法的进入和退出而有条无紊的执行着入栈和出栈操作。每一个栈帧中分配多少内存基本上是类结构确定下来时就已知的,所以大体可以认为程序计数器,虚拟机栈,本地方法栈三个区域的内存分配和回收都具有确定性,所以这几个区域就不需要过多的考虑回收的问题。
而堆和方法区是线程共享的,不跟随线程的生命周期,且只有程序在运行期间,我们才会知道创建了什么对象,加载了什么类,所以关于堆和方法区的内存分配和回收都是动态性的,是不具有确定性的。所以垃圾回收主要关注的也是这个两个部分。
所以后文所说的垃圾回收是针对堆和方法区而言的,也可以说主要是针对堆而言的,因为方法区在某些虚拟机的实现是永久代,一般不怎么回收。
在Java虚拟机中,什么时候回收的策略是交给虚拟机自行去判断的,是无法用代码是显式执行的。
通过各种垃圾收集器去回收不需要再用到的内存。
在堆里存放这Java世界中几乎所有的对象实例,垃圾收集器在进行回收之前,第一件事情就是要确定这些对象中,那些是可以回收的,那些是不能回收的。既那些对象已死,那些还活着?
引用计数算法就是:在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值+1,当引用失效,则计数器-1。当计数器为0时,则代表该对象已死,没有被任何别的对象引用,既不会再被使用。
虽然引用计数算法有简单高效的优点,但是主流虚拟机都没有采用这种算法,因为这种算法存在很大的缺陷
可达性分析算法是主流虚拟机所采用的一种算法。基本思想就是通过一系列的称为“GC Roots”的对象作为起点,从这些节点开始向下搜索,搜索走过的路径称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连的时候,则证明该对象是不可用的,已死,可回收。
如上图,A, B之间虽然相互引用,但是从GC Roots开始走,却没有路径到达,这所说A,B对象已死,可被回收。用图论的话说,就是从GC Roots到A,B对象不可达。
GC Roots就是可达性分析时作为查找对象引用的起点
总结来说,能做GC Roots都是全局性引用和执行上下文,既静态变量、常量、局部变量所指向的在堆中的对象 ;
对象的成员变量是不能做为GCRoots的:
- 因为我们要考察的成员就是对象,而成员变量是属于对象的一部分,如果一个对象A有一个成员变量指向对象A本身,但除了自己引用了自己外,就没有任何东西指向了对象A,这就会导致对象A永远都不会被回收
- 又或者说对象A因为不可达,要被回收了,但它的成员属性所指向的对象B却引用了很多的对象,如果成员变量可以作为GC Roots ,那成员属性对象B所引用的对象们到底要不要被回收呢?这就是一个矛盾
强引用: 普遍存在的,如Object obj = new Object这类引用(obj变量引用堆中的Object对象),只要强引用在,垃圾收集器就永远不会回收被引用的对象。
软引用: 有用但并非必须的对象,内存不够,将发生内存溢出,将软引用关联着的对象进行第二次回收,如果这次回收还有没足够的内存,才会抛出内存溢出异常。既第一次回收内存任然不够,就第二次回收,将软引用的对象回收。
弱引用: 同样是非必须对象,强度比软引用更弱,弱引用对象只能存活到垃圾回收之前,既垃圾回收器工作时,弱引用必然被回收,不管内存是否足够
虚引用: 幽灵引用,幻影引用,是最弱的引用关系。无法通过虚引用取得一个对象实例,设置虚引用的目的仅仅是当这个对象被回收时收到一个系统通知。
当一个对象被可达性分析判断为不可达对象,不代表这个对象非死不可,只是暂时判了缓刑死刑。这个对象还有最有一次救赎的机会。那就是实现finalize()方法,这个方法是在GC时被自动调用的方法。只要你在这个方法里实现了重新引用,那么该对象就可以的到复活的机会。如果在finalize()方法中,你任然没有获得复活的机会,那这个对象就死定了。
通俗点讲就是:可达性分析发现对象不可达,此时将继续判断对象是否覆盖finalize()方法,如果覆盖了finalize()并且是第一次被调用,如果在finalize()方法中重新与任何一个对象建立了连接,对象就仍然可以存活。
因为方法区在Java8以前的HotSpot虚拟机中实现为永久代,所以很多人认为是没有垃圾收集的。Java虚拟机规范也说过,可以不要求在方法区实现垃圾收集。而且通常在方法区回收的效率和性价比也很低。
回收废弃常量和回收堆中的对象类似,比如说如果字符串常量池中有一个“abc”
的对象,而当前系统没有一个字符串对象为"abc"
,既没有任何一个String对象引用了常量池中的"abc"
对象且没有其他地方引用了这个"abc"
字面量。如果方法区发生了GC,那么这个"abc"
字符串对象就会被回收`
回收无用类的条件就很苛刻了,至少要满足下面三个条件
虚拟机要满足上面三个条件,才允许可以被回收。
标记 - 清除算法是最基础的垃圾收集算法,其他的算法都是在他的基础上改进的
分为标记和清理两个步骤,首先标记所有要回收的对象,在标记完成后统一回收所有被标记的对象
(图片来源于网上)首先标记出要回收的对象,最后统一回收,从图中,我们就可以看到,垃圾回收过后,存活对象和未使用的空间的凌乱的,当空间碎片太多时,可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续空间而不得不提前触发另一次GC
为了解决标记 - 清除算法的效率问题,所以复制算法诞生了
现在的商业虚拟机都采用复制算法来回收新生代。IBM公司专门研究表明新生代中98%的对象就是朝生夕死,所以不需要按1:1的空间来划分内存空间。
HotSpot虚拟机默认Eden和Survivor的大小比例为8:1
,所以新生代内存空间只有百分之10被"浪费"。当然也有意外,当survivor空间不够放存活对象时怎么办?这时候就需要依赖其他内存进行分配担保(如老年代)
复制算法在对象存活率较高的情况下就需要进行较多的复制操作,这样效率比较低,且有空间浪费和需要其他内存分配担保。所以在老年代一般不会选用复制算法,而是交给一种改进型算法 “标记- 整理算法” ,该算法根据老年代的特点(对象存活率高)进行了算法的优化和修改。
分为三个部分: 标记,整理,清理
根据老年代对象存活率高的问题进行了优化和改进,并且整理了空间,减少了空间碎片
多了整理的步骤,相对来说,效率降低
当前的商业虚拟机都是采用分代收集算法来进行垃圾收集的,这起也不是一个什么算法,说白了就是采用了策略模式,根据不同的需要采用不同的算法去收集。
新生代对象存活率低,所以我们就使用可以使用复制算法。老年代对象存活率高,我们就可以采用标记 - 整理算法或标记 - 清理算法
这里要介绍的垃圾收集器有7种,如图:
- Serial收集器
- ParNew收集器
- Parallel Scavenge收集器
- Serial Old收集器
- Parallel Old收集器
- CMS收集器
- G1收集器
从图上,我们可以获得一些信息:
Serial
,ParNew
,Parallel Scavenge
,老年代收集器有CMS
,Serial Old
,Parallel Old
。还有一个新老都可以的G1
收集器并行: 指多条垃圾收集线程并行工作,但此时的用户线程处于等待状态
并发: 指用户线程和垃圾收集线程同时执行(不一定是并行,可能是交替执行),如用户程序在继续运行,而垃圾收集程序运行在另一个CPU上
吞吐量: CPU用于运行用户代码的时间和CPU总消耗时间的比值。既吞吐量 = 运行用户代码时间/(运行用户代码时间 + 垃圾收集时间) ,如虚拟机运行了100分钟,垃圾收集用了1分钟,那么吞吐量就是99%
停顿时间: 垃圾回收线程运行时,让其他线程停止等待的时间
两者的关系:
可能你会很矛盾,同样的GC量,我只是停顿时间小了,分批处理,要花的时间应该跟一次性GC(停顿时间大)用的一样吧,为什么我的吞吐量就小了呢?毕竟吞吐量小了就代表我停顿的时间更多了,难道是我理解错了吗?
不不不,其实这里有一个误区,实际上追求停顿时间小和追求吞吐量大的确是两个对立关系,通常情况下,同样的GC量,追求停顿时间小的策略花费的GC时间要比吞吐量优先策略花费的更多
我个人的猜测是,停顿时间小,代表此次GC能回收的东西就少了,同时每次GC可能需要做许多的判断,比如判断那些对象才能被GC,而每次判断都需要耗费一些时间;这样在效率上不如停顿时间长,GC次数少的高吞吐量优先策略;从这篇博客上GC对吞吐量的影响 - @作者:deepinmind看,的确最短回收停顿时间策略和高吞吐优先策略的GC次数和GC总时间相差还是很大的
Serial收集器/Serial Old收集器可以当做单线程的垃圾收集器组合来搭配使用,分别负责新生代和老生代的垃圾收集~
在JDK1.5时期,HotSpot推出了划时代意义的垃圾收集器,因为CMS是一个真正意义上的并发收集器
G1(Garbage-First)收集器是现今收集器技术的最新成果之一,之前一直处于实验阶段,直到jdk7u4之后,才正式作为商用的收集器。G1收集器是面向服务端应用的垃圾收集器,希望他可以在未来替换掉1.5发布的CMS收集器
使用G1收集器,Java堆的内存布局就不太一样了,虽然也分新生代和老年代,但是它们之间不再由物理的隔离。而是被分成多个大小相等的独立区域(Region)
所以我们知道了通常情况下,垃圾收集器通常有自己的目标,追求低停顿时间和和追求高吞吐量,二选一,鱼和熊掌不可兼得。
通常情况下,同样的GC量,追求低停顿时间的收集器耗费的时间要比追求高吞吐量的收集器多;所以虚拟机的根据具体需求的人为调优有时候也是很必要的;选择正确的收集器组合也是非常必要的;
如果你是响应时间要很高要求的情况下,建议使用追求低停顿时间的垃圾收集器,当然相应的可能性能上会有所降低;如果你追求的就是高吞吐量,以性能优先,对客户的短时间中断没有什么影响的情况下就可以选择高吞吐量优先的垃圾收集器
堆内存的划分根据不同的参数,不同的垃圾收集器会有些不太一样,所以我这里说一下通常的情况:
借R大的话说,Java虚拟机发展了这么多年,名词早就混乱了,按照《深入理解Java虚拟机》概念,概念如下:
Minor GC
Major GC/Full GC
但是书本上的知识也并非是完全正确的,书本上将Major GC和Full GC等价化,在实现中,我们常常并不一定会如此描述。
我们这里引入R大的概念,针对HotSpot VM的实现,它里面的GC其实准确分类只有两大种:
Partial GC
:并不收集整个GC堆的模式
Full GC
:收集整个堆,包括young gen、old gen、perm gen(如果存在的话)等所有部分的模式。我更愿意使用R大的概念,而非原书籍的概念,我们可以简单的把Minor GC等于Young GC, Major GC等同Old GC, Full GC代表整个堆的GC
什么是TLAB?
-XX:+/-UseTLAB
参数来设定。这么做的目的之一,也是为了并发创建一个对象时,保证创建对象的线程安全性。TLAB比较小,直接在TLAB上分配内存的方式称为快速分配方式,而TLAB大小不够,导致内存被分配在Eden区的内存分配方式称为慢速分配方式。新生对象优先分配Eden区
大对象直接进入老年代Old Generation
长期存活的对象将进入老年代Old Generation
虚拟机给每个对象定义了一个对象年龄(Age)计数器,如果对象在Eden出生并经过了第一次Minor Gc任然存活,并且被Survivor容纳,那将移入Survivor区,并且对象年龄设置为1。对象在Survivor每熬过一次Minor Gc,年龄就加1。当年龄到达了一定的程度(默认15岁),就会被晋升到老年代中。
当然还有一种情况不一定要到达年龄阈值才能晋升到老年代,比如Survivor的相同年龄的所有对象大小总和大于Survivor空间的一半,那么年龄大于或等待该年龄的对象就直接进入老年代了。
其实我们从jstat -hea
Young GC
Old GC
Full GC