垃圾回收机制
垃圾回收机制
垃圾回收,不是Java语言的伴生产物。早在1960年,第一门开始使用内存动态分配和垃圾收集技术的Lisp语言诞生。
关于垃圾回收有三个问题:
- 什么是垃圾
- 什么时候回收
- 如何回收
垃圾回收机制是Java的招牌。极大地提高了开发效率。
如今,垃圾回收几乎成为了现代语言的标配,即使经过如此长时间的发展,Java的垃圾收集机制仍然在不断的演进,不同大小的设备、不同的特征的应用场景,对垃圾回收提出了新的挑战。
1、 垃圾回收概述
1、什么是垃圾(Garbage)
垃圾是指 在运行程序中没有任何指向的对象。这个对象就是需要被回收的垃圾。
如果不及时对内存中的垃圾进行清理,那么,这些垃圾对象所占的内存空间会一直被保留到应用程序结束,被保留的空间无法被其他对象使用。甚至可能导致内存溢出。
2、为什么需要垃圾回收
- 对于高级语言来说,一个基本认知是如果不进行垃圾回收,内存迟早都会被消耗完,因为不断地分配内存空间而不进行回收。就好像不停地生产生活垃圾而从来不打扫一样。
- 除了释放没用的对象,垃圾回收也可以消除内存里的记录碎片。碎片整理将所占用的堆内存移到堆的一端。以便JVM将整理出的内存分配给新的对象。
- 随着应用程序所应付的业务越来越庞大、复杂,用户越来越多,没有GC就不能保证应用程序的正常进行。而经常造成STW(Stop The World)的GC又跟不上实际的需求,没有不断地尝试对GC进行优化。
3、早期的垃圾回收机制
在早期的C/C++时代,垃圾回收基本是手工进行的。开发人员使用new 关键字进行内存申请,并使用delete关键字进行内存释放。
比如下面的代码:C/C++
1 |
|
这种方式可以灵活控制内存释放的时间,但是会给开发人员带来频繁申请和释放内存的管理负担。倘若有一处内存区间由于程序员编码的问题忘记被回收,那么就会产生内存泄漏,垃圾对象永远无法被清楚,随着系统运行时间不断增长,垃圾对象所耗内存可能持续上升,直到出现内存溢出并造成应用程序崩溃。
在有了垃圾回收机制后,上述代码块就可能变成这样:
1 |
|
到如今,除了Java以外,C#、Python、Ruby等语言都使用了自动垃圾回收机制的思想,这也是未来发展的趋势。
4、Java垃圾回收机制
自动内存管理,无需开发人员手动参与内存的分配与回收,这样降低内存泄漏和内存溢出的风险。
如果没有垃圾回收器,Java也会和cpp一样,有各种的野指针,悬垂指针。各种泄漏问题。
自动内存管理,将程序员从繁重的内存管理中释放出来,可以更专心于业务开发。
对于Java开发人员而言,自动内存管理像是一个黑匣子。如果过度依赖于“自动”,那么这将会是一场灾难,最严重就会弱化Java开发人员在程序出现内存溢出时定位问题和解决问题的能力。
此时,了解JVM的自动内存分配和内存回收原理就显得非常重要,只有在真正了解JVM是如何管理内存后,我们才能够在遇见OOM时,快速地根据错误异常日志定位问题和解决问题。
当需要排查各种内存溢出、内存泄漏问题时,当垃圾回收成为系统达到更高并发量的瓶颈时,我们就必须对这些“自动化”的技术实施必要的监控和调节。
==Java内存中的哪些区域需要回收==:方法区和堆区
Java的堆是垃圾回收器的重点。
频繁收集新生代
较少收集老年代
基本不动元空间
2、垃圾回收的相关概念
1、System.gc()
在默认的情况下,通过System.gc()或者Runtime.getRuntime().gc()的调用。会触发Full GC,同时对老年代和新生代进行垃圾回收,尝试释放被丢弃对象占用的内存。
然而System.gc()调用附带一个免责声明,无法保证对垃圾收集器的调用。
JVM实现者可以通过System.gc()调用来决定JVM的GC行为。而一般情况下,垃圾回收应该是自动进行的,无须手动触发,否则就太过麻烦了。在一些特殊情况下,如我们正在编写一个性能基准,我们可以在运行之间调用System.gc()。
1 |
|
调用了两次finalize()方法,将两个对象全部回收。
只调用System.gc()方法垃圾回收器可能不会进行Full GC,但可以通过System.runFinalization()强制执行
1 |
|
2、内存溢出和内存泄漏
==内存溢出(OOM)==
内存溢出相对于内存泄漏来说,尽管更容易被理解,但是同样的,内存溢出也是引发程序崩溃的罪魁祸首之一。
由于GC一直在发展,所有一般情况下,除非应用程序占用的内存增长速度非常快,造成垃圾回收已经跟不上内存的消耗的速度。否则不大容易出现OOM的情况。
大多数情况下,GC会进行各种年龄段的垃圾回收,实在不行,就会来一次Full GC操作。这时候会回收大量的内存,供应用程序继续使用。
OutOfMemoryError:没有空闲的内存,并且垃圾回收也无法提供更多的内存。
在抛出OutOfMemoryError之前,通常垃圾回收期会被触发,尽其所能去清理空间。如果分配一个超大对象,需要的内存直接超过了堆空间的最大值。那么者直接抛出OOM异常。
堆内存空间不够的原因
Java虚拟机的堆内存设置不够
比如:我们需要处理大量的数据,设置的数值偏小或者没有显示指定虚拟机内存大小。可以通过参数-Xms和-Xmx来调整。
也有可能是内存泄漏,造成内存不够,最后处理数据时内存溢出。
代码中创建了大量大对象,并且长时间不能被垃圾回收期收集。
在项目的框架中,我们会创建大量的对象,这些对象占用的内存非常大,而且随着项目的运行会一直存在虚拟机中,并且存放在老年代中。
==内存泄漏==
对于Java来说:只有对象不会在被程序用到了,但是GC又不能回收它们的情况,叫做内存泄漏。
尽管内存泄漏并不会立刻导致程序崩溃,但是一旦发生内存泄漏,程序中的可用内存就会被逐步蚕食,直至耗尽所有内存,最终出现OOM异常,导致程序崩溃。
这里的存储空间不是物理内存,而是指虚拟内存大小,这个虚拟内存大小取决于磁盘交换区设定的大小。
举例:
单例模式
单例的生命周期和应用程序是一样长的,所以单例程序中,如果持有对外部对象的引用的话,那么这个外部对象是不能被回收的,则会导致内存泄漏的问题。
一些提供close的资源未关闭导致内存泄漏
数据库连接(dataSourse.getConnection),网络连接(socket),io操作等必须手动close,否则是不能被回收的。
3、Stop The World(STW)
在GC事件发生的过程中,会产生应用程序(用户线程)的停顿。停顿产生时整个应用程序线程都会被暂停,没有任何响应,这个停顿称为STW。
比如:
在可达性分析算法中,我们要找出所有的GC Roots,这时候就需要STW。
因为:分析工作必须在一个能确保一致性的快照中进行。一致性指整个分析期间整个执行系统看起来像被冻结在某个时间点上。如果时间在变化,那么引用对象也在变化,那么就无法保证找到正确的GC Roots。
STW和采用哪款GC无关,所有的GC都有这个事件。只能说优秀的垃圾回收器会尽可能的缩短暂停时间。
在开发中,不要使用System.gc(),这样会导致STW的发生。
4、垃圾回收的并行与并发
==程序中的并行与并发==:
并发:在一段时间段中有几个程序都处于已启动到运行完毕之间,且这几个程序都是在同一处理器上运行。
并发不是真正意义上的“同时进行”,只是CPU把一个时间段划分为几个时间片段(时间区间),然后在这几个时间区间来回切换,由于CPU处理的速度非常快,只要时间间隔处理得当,即可让用户感觉是多个应用程序同时在进行。
并行:当系统有一个以上CPU时,当一个CPU执行一个进程时,另一个CPU可以执行另一个进程,两个进程互不抢占CPU资源,可以同时进行。
==垃圾回收中的并发和并行==
不同的垃圾回收器执行情况是不一样的。
并行垃圾回收器:指多条垃圾回收线程并行工作,但此时用户线程仍处于等待状态。
比如:PawNew、Parallel Scavenge、Parallel Old。
并发垃圾回收器:指用户线程与垃圾回收线程同时执行,但不一定是并行的,可能会交替执行,垃圾回收线程在执行时不会停顿用户线程的运行。
比如:CMS、G1
5、安全点和安全区域
==安全点==
在进行STW时,程序并非在所有地方都能停顿下来开始GC,只有特定的位置才能停顿下来开始GC,这些位置称为安全点(Safe Point)。
Safe Point的选择是很重要的,如果太少可能会导致GC等待的时间太长,如果太频繁可能会导致运行时的性能问题。我们通常会选择一些执行时间较长的指令作为Safe Point,比如方法调用、循环跳转和异常跳转。
如何在GC发生时,检查所有的线程都到达了最近的安全点停顿下来:
抢先式中断:(目前没有虚拟机采用了)
首先中断所有线程。如果还有线程不在安全点,就恢复线程,让线程跑到安全点。
主动式中断
设置一个中断标志,各个线程运行到Safe Point 的时候主动轮询这个标志,如果中断标志为真,则将自己进行中断挂起。
==安全区域==
SafePoint机制保证了程序执行时,在不太长的时间内就会遇到可进入GC的SafePoint。但是,程序不执行的时候,比如线程处于Sleep状态或者Blocked状态。这时候线程无法响应JVM的中断请求,无法到达安全点。对于这种情况,就需要安全区域来解决。
安全区域是指在一段代码片段中,对象的引用关系不会发生变化,在这个区域中的任何位置开始GC都是安全的。我们可以把安全区域看做是被扩展的安全点。
6、引用
我们希望能描述这样一类对象:当内存空间还足够时,则能保留在内存中。如果内存空间在进行垃圾回收后还是很紧张,则可以抛弃这些对象。
1、强引用:不回收
最传统的“引用”的定义,是指在程序代码之中普遍存在的引用赋值,即类似“Object obj = new Object()”。这种引用关系。无论任何情况下,只要强引用关系还存在,垃圾回收期永远不会回收被引用的对象。
在Java程序中,最常见的就是强引用 ,也是默认的引用类型。
强引用时造成Java内存泄漏的主要原因之一。
2、软引用:内存不足回收
在系统将要发生内存溢出之前,将会把这些弱引用对象列入回收范围之中进行第二次回收。如果这次回收之后还没有足够的内存,才会抛出OOM异常。
软引用通常用来实现内存敏感的缓存。比如:高速缓存就有用到软引用。如果还有空间内存,就可以暂时保留缓存,当内存不足时清理掉,这样就保证了使用缓存的同时,不会耗尽内存。
程序:
我们设置堆内存为10M
1 |
|
1 |
|
可以看到,在第一个强制进行GC之后,软引用依旧存在,之后我们定义大量的数据,内存OOM异常。此时软引用会被回收。
3、弱引用:内存足够回收
被弱引用关联的对象只能生存到下一次垃圾回收之前。当垃圾收集器工作时,无论空间是否足够,都会回收被弱引用关联的对象。
1 |
|
WeakHashMap
Map本身生命周期很长,需要长期贮留内存中,但Map中的Entry可以删除,使用时可以从其它地方再次取得。
4、虚引用:对象回收跟踪
一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来获得一个对象的实例。
如果一个对象仅持有虚引用,那么就和没有引用是一样。随时都可能被垃圾回收器回收。
他不能单独的使用,也无法通过虚引用来获取被引用的对象。当通过虚引用的get()方法取得对象时,总是返回null。
为一个对象设置虚引用关联的唯一目的就是在这个对象被收集器回收时收到一个系统通知。
虚引用必须和引用队列一起使用。虚引用在创建时必须提供一个引用队列作为参数。当垃圾回收一个对象时,如果发现它还有虚引用,就会在回收对象后,将这个虚引用加入引用队列,已通知引用程序对象的回收情况。
1 |
|
3、垃圾回收的算法
1、 标记阶段:对象存活判断
在堆里存放着几乎所有的Java对象实例,在GC执行垃圾回收之前,首先需要区分出内存中哪些是存活对象,哪些是已经是死亡的对象,GC才会在执行垃圾回收时,释放掉其所占用的内存空间,因此这个过程我们可以称为垃圾标记阶段。
那么在JVM中究竟是如何标记一个死亡对象呢?简单来说,当一个对象已经不再被任何的存活对象继续引用时,就可以宣判为已经死亡。
判断对象存活一般有两种方式:引用计数算法和可达性分析算法
1.1、引用计数算法
引用计数算法(Reference Counting)比较简单,对每个对象保存一个整性的引用计数器属性。用于记录队对象被引用的情况。
对于一个对象A,只要有任何一个对象引用了A,则A的引用计数器就加1;当引用失效时,引用计数器就减1。只要对象的引用计数器的值为0。即表示对象A不可能再被使用,可进行回收。
引用计数算法的优点:
- 实现简单,垃圾对象便于辨认。
- 判定效率高,回收没有延迟性。(与可达性分析算法对比)
引用计数算法的缺点:
- 它需要单独的字段存储计数器,增加了存储空间的开销
- 每次赋值都需要更新计数器,伴随着加法和减法操作。增加了时间的开销
- 引用计数器有一个重要的问题,即无法处理循环引用的问题。这是引用计数的致命缺陷。
下面有三个对象互相指向。其中rc就是引用计数。
当我们将p指向第一个对象的指针断开。其他三个对象的rc都变为了1,但此时这三个对象应该是垃圾,但此时rc不为0,永远不能被收集。因此出现了内存泄漏。
引用计数算法,是很多语言的资源回收选择,例如Python,他更多同时支持引用计数和垃圾回收机制。
Python如何解决循环引用问题:
- 手动解除:手动解除对象之间的引用关系。
- 使用弱引用weakref,它是python提供的标准库,旨在解决循环引用。
1.2、可达性分析算法(根搜索算法、追踪性垃圾收集)
相对于引用计数算法而言,可达性分析算法不仅同样具备实现简单和执行高效等特点,更重要的是该算法可以有效地解决循环引用问题,防止内存泄漏问题。
相较于引用计数算法,这里的可达性分析就是Java、C#选择的。这种类型的垃圾回收通常也叫做追踪性垃圾回收(Tracing Garbage Collection)。
==GC Roots 是一组必须活跃的对象集合==
在Java中,GC Roots包含以下几类元素:
虚拟机栈中引用的对象
比如:各种线程被调用的方法使用到的参数、局部变量
本地方法栈内JNI(通常说的本地方法)引用的对象
方法区中类静态属性引用的对象
比如:Java类的引用类型静态变量
方法区中常量引用的对象
比如:字符串常量池(String Table)中的引用。
所有被同步锁synchronized持有的对象
Java虚拟机内部的引用
基本数据类型对应的Class对象,一些常驻的异常对象(NullPointerException、OutOfMemoryError),系统类加载器。
反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。
可达性分析算法的基本思路:
- 可达性分析算法是以跟对象集合(GC Roots)为起始点,按照从上至下的方式搜索被跟对象集合所连接的目标对象是否可达。
- 使用可达性分析算法后,内存中的存活对象都会被跟对象集合直接或间接连接着,搜索所走过的路径称为引用链(Reference Chain)
- 如果目标对象没有任何引用链相连,则是不可达的,就意味着该对象已经死亡,可以标记为垃圾对象。
- 在可达性分析算法中,只有能够被跟对象集合直接或间接连接的对象才是存活对象
注意:
如果要使用可达性分析算法来判断内存是否可回收,那么分析工作必须在一个能保障一致性的快照中进行。这点不满足的话,分析结果的准确性就无法保证了。
因此GC进行时有Stop The World
1.3、对象的finalization机制
Java语言提供了对象终止(finalization)机制来允许开发人员提供对象被销毁之前的自定义处理逻辑。
当垃圾回收器发现一个没有引用指向的一个对象,即垃圾回收此对象之前,总会先调用这个对象的finalize()方法
finalize()方法允许在子类中被重写,用于在对象被回收时进行资源释放,通常在这个方法中进行一些资源释放和清理工作,比如关闭文件,套接字和数据库连接等。这个方法只能被调用一次。
从功能上来说,finalize()方法和C++中的析构函数比较相似。但是Java采用的是基于垃圾回收期的自动内存管理机制,所以finalize()方法在本质上不同于C++中的析构函数。
永远不要主动调用finalize()方法:
- 在finalize()时可能会导致对象复活
- finalize()方法的执行时间是没有保障的,他完全由GC线程决定,极端情况下,若不发生GC,则finalize()方法将没有执行机会。
- 一个糟糕的finalize()会严重影响GC的性能。
如果从所有的根基欸但都无法访问到某个对象,说明对象已经不再使用了。一般来说,此对象需要被回收。但事实上,也并非是“非死不可”,这个时候对象暂时处于缓刑的阶段。一个无法触及的对象可能在某一个条件下复活自己。
因此,虚拟机中的对象一般处于三种可能的状态:
可触及的
:从根节点开始,可以到达这个对象。(活)可复活的
:对象的所有引用都被释放,但是对象有可能在finalize()方法中复活。(复活)不可触及的
:对象的finalize()被调用,并且没有复活,那么就会进入不可触及状态。不可触及的对象不可能被复活,因为finalize()只能被调用一次。(死)
1.4、判断一个对象是否被回收过程
判定一个对象Obj是否被回收,至少要经历两次标记过程:
- 如果对象Obj到GC Roots没有引用链,则进行第一次标记。
- 进行筛选,判断此对象是否有必要执行finalize()方法。
- 如果对象Obj没有重写finalize()方法,或者finalize()方法已经被虚拟机调用过了,则虚拟机视为没有必要执行此方法。Obj被判定为不可触及的对象。
- 如果Obj对象重写了finalize()方法,且还未执行过,那么Obj会被插入到F-Queue队列中,有一个虚拟机自动创建的、低优先级的Funalizer线程触发其finalize()方法执行。
- finalize()方法是对象逃脱死亡的最后机会,稍后GC会对F-Queue队列中的对象进行第二次标记。如果Obj在finalize()方法中与引用链上的任何一个对象建立了联系,那么在第二次标记时,Obj会被移出“即将回收” 的集合(可复活对象)。之后,对象会再次出现没有引用存在的情况。在这个情况下,finalize()不会在调用,对象会直接变成不可触及的状态,也就是说,一个对象的finalize()方法只会被调用一次。
2、清除阶段:垃圾清除
当成功区分出内存中存活对象和死亡对象后,GC接下来的任务就是执行垃圾回收,释放掉无用对象所占用的内存空间,以便有足够的可用内存空间为新对象分配内存。
目前在JVM中比较常见的三种垃圾收集算法是:标记-清除算法(Mark-Sweep)、复制算法(Copying)、标记-压缩算法(Mark-Compact)
什么是清除:
这里的所说的清除并不是真的置空,而是把需要清除的对象地址保存在空闲的地址列表里。下次有新对象需要加载时,判断垃圾的位置空间是否够用,如果够用,就存放。
2.1、标记-清除算法(Mark-Sweep)
标记-清除算法(Mark-Sweep)是一种非常基础和常见的垃圾收集算法。该算法被J.McCarthy等人在1960年提出并应用于Lisp语言。
算法执行过程:
当堆中的有效空间被耗尽的时候,就会停止整个程序(Stop The World),然后进行两项工作,第一项是标记,第二项是清除。
- 标记:Collector从引用根节点开始遍历,标记所有被引用的对象。一般在对象的Header中记录为可达对象
- 清除:Collector对堆内存从头到尾的线性遍历,如果发现某个对象在其Header中没有标记为可达对象,则将其回收。
标记-清除算法缺点:
效率不高
在进行GC的时候,需要停止整个应用程序,导致用户体验差
这种方式清理出来的空闲内存是不连续的,产生内存碎片。需要维护一个空闲列表。
2.2、复制算法(Copying)
为了解决标记-清除算法在垃圾回收效率方面的缺陷,M.L.Minsky在论文中提出双存储区的垃圾回收算法。
复制算法的核心思想:
==将活着的内存空间分成两块,每次只使用其中一块,在垃圾回收时将正在使用的内存中的存活对象复制到未被使用的内存块中。之后清除正在使用的内存块中的所有对象,交换两个内存的角色。最后完成垃圾回收。==
我们找到A区所有的存活对象,并将这个对象以到B区,最后将A区中的对象全部清除。
复制算法的优点:
- 没有标记和清除过程,实现简单,运行高效
- 复制过去以后保证空间的连续性,不会出现“碎片”问题
复制算法的缺点:
- 需要两倍的内存空间
- 对于G1这种分拆称为大量region的GC,复制而不是移动,意味着GC需要维护region之间对象引用关系,不管是内存占用或者时间开销也不小。
如果系统需要的复制的对象太多,这种算法的效率会非常低。
在堆的新生代中,大多数对象都是朝生夕死的,因此在新生代中使用复制算法是非常高效的。
2.3、标记-压缩算法(Mark-Compact)
复制算法的高效性是建立在存活对象少、垃圾对象多的前提下。这种情况下在新生代经常发生。但是在老年代,更常见的情况是大部分对象都是存活对象。如果依然使用复制算法,由于存活的对象较多,复制的成本也将很高。因此,基于老年代的垃圾回收的特性,需要使用其他的算法。
标记-清除算法可以应用在老年代中,但是该算法不仅执行效率底下,而且清除之后还存在内存碎片,因此标记-压缩算法由此诞生。
标记-压缩算法的执行过程:
第一阶段:从根节点开始标记所有被引用对象
第二阶段:将所有的存活对象压缩到内存的一端,按顺序排放。之后,清理边界外所有空间。
标记-清除算法和标记-压缩算法的区别:
标记-压缩算法的最终效果等同于标记-消除执行完成之后,再进行一次内存碎片整理。因此也可以把它称为标记-清除-压缩算法(Mark-Sweep-Comoact)。二者的本质差异在于标记-清除算法是一种非移动的回收算法。标记-压缩算法是移动的。
是否移动回收后的存活对象是一项优缺点并存的风险决策。
- 标记的存活对象将会被整理,按照内存地址依次排列,这样在回收阶段效率会降低。
- 我们在为新对象分配内存时,JVM只需要持有一个内存的起始地址即可,这比维护一个空闲列表显然少了许多的开销。
标记-整理算法优点和缺点
优点:
- 消除了标记-清除算法当中,内存区域分散的缺点。
- 消除了复制算法当中,内存减半的高额代价
缺点
- 从效率上来说,标记-整理算法要低于复制算法。
- 移动对象的同时,如果对象被其他对象引用,则还需要调整引用的地址
- 移动过程中,需要全程暂停用户的应用程序。(STW)
2.4、三种算法的对比
垃圾回收算法的三个指标:
从效率上来说,复制算法是当之无愧的最高效。但是浪费了太多的内存。
而为了尽量兼顾上面提高的三个指标,标记-整理算法相对来说更平滑。但是效率太差。它比复制算法多一个标记阶段。比标记-清除多一个整理内存的阶段。
3、分代收集算法
前面所有这些的算法中,并没有一种算法可以完全替代其他算法,它们都具有自己独特的优势和特点。分代收集算法应运而生。
分代收集算法,是基于这个一个事实:不同的对象的生命周期是不一样的。
因此不同的生命周期的对象可以采取不同的收集方式,以便提高回收效率。一般来说,我们把Java堆分成新生代和老年代。这样就可以根据各个年代的特点使用不同的回收算法,以提高垃圾回收的效率。
在Java程序运行的过程中,会产生大量的对象,其中有些对象是与业务信息相关的。比如:Http请求中的session对象、线程、Socket连接,这些对象跟业务直接挂钩,因此生命周期比较长。但是还有一些对象,主要程序运行过程中生成的临时变量,这些对象生命周期会比较短。比如:String对象,由于其不变类的特性,系统会产生大量的这些对象,有些对象甚至只用一次即可回收。
目前几乎所有的GC都是采用分代收集(Generational Collection)算法执行垃圾回收。
在HotSopt中,基于分代的概念,GC所使用的内存回收算法必须结合年轻代和老年代各自的特点。
- 年轻代
年轻代的特点:区域相对老年代较小,对象生命周期短,存活率低,回收频繁。
这种情况复制算法的回收整理,速度是最快的。复制算法的效率只和当前存活对象大小有关,因此很适用于年轻代的回收。而复制算法内存利用率不高的问题。通过hotspot中的两个survivor的设计得到缓解。
- 老年代
老年代的特点:区域较大,对象生命周期长,存活率高,回收不及年轻代频繁。
这种情况存在大量存活率高的对象,复制算法明显变得不合适。一般是由标记-清除算法或者标记-清除与标记-整理的混合实现。
Mark阶段的开销与存活对象的数量成正比
Sweep阶段的开销与所管理区域的大小成正比
Compact阶段的开销与存活对象的数据成正比
4、增量收集算法
上述现有的算法,在垃圾回收过程中,应用软件将处于一种Stop The World 的状态。在STW状态下,应用程序中的所有线程都是挂起的。暂停一切正常的工作,等待垃圾回收的完成。如果垃圾回收时间过长,应用程序会被挂起很久。将严重影响用户体验或者系统的稳定性。
为了解决这种问题,即对实时垃圾收集算法的研究直接导致了增量收集算法的诞生
基本思想:
如果一次性将所有垃圾进行处理,需要造成系统长时间的停顿,那么就可以让垃圾收集线程和应用程序线程交替执行。每次,垃圾收集线程只收集一小片区域的内存空间,接着切换到应用程序线程。依次反复,直到垃圾收集完成。
增量收集算法的基础仍是传统的标记-清除和复制算法。
增量收集算法通过线程间的冲突的妥善处理,允许垃圾收集线程以分阶段的方式完成标记、清理或复制工作。
缺点:
线程切换和上下文转换的消耗,造成系统的吞吐量下降。
5、分区算法
一般来说:在相同条件下,堆越大,一次GC是所需要的时间就越长。有关GC产生的停顿也就越长。为了更好地控制GC产生的停顿时间,将一块大的区域分割成多个小块。根据目标的停顿时间,每次合理地回收若干个小区间(region)。而不是整个堆空间。从而减少一次GC所产生的停顿。
分代算法将按照对象的生命周期长短划分为两个部分,分区将整个堆空间划分为连续的不同的小区间。
每一个小区间都独立使用,独立回收。这种算法的好处是可以控制一次回收多少个区间。
4、垃圾回收器
垃圾回收器没有在规范中进行过多的规定,可以有不同的厂商、不同的版本的JVM来实现。
由于JDK的版本处于高速迭代过程中,因此Java发展至今已经衍生了众多的GC版本。
1、 垃圾回收器的分类和性能指标
分类:
==按线程数来分==:可以分为串行垃圾回收器和并行垃圾回收器。
串行回收指的是在同一时间段内只允许有一个CPU用于垃圾回收操作。此时工作线程被暂停,直至垃圾回收工作结束。
并行回收可以运行多个CPU同时执行垃圾回收,因此提升了应用的吞吐量,不过并行回收仍然与串行回收一样,采用独占式,使用了“Stop-the-world”机制。
==按工作模式来分==:可以分为并发式垃圾回收器和独占式垃圾回收器
并行式垃圾回收器与应用程序线程交替工作,已尽可能减少应用程序的停顿时间。
独占式垃圾回收器(STW)一旦运行,就停止应用程序中的所有用户线程。直至垃圾回收过程完全结束。
==按碎片处理方式==:可分为压缩式垃圾回收器和非压缩式垃圾回收器
压缩式垃圾回收器会在回收完成后,对存活对象进行压缩整理,清除回收后的碎片。再分配对象空间使用:指针碰撞
非压缩式的垃圾回收器不进行这步操作,再分配对象空间使用:空闲列表
==按工作的内存区间==:又可分成年轻代垃圾回收器和老年代垃圾回收器。
性能指标:
==吞吐量==:运行用户代码的时间占总运行时间的比例。
(总运行时间 = 程序的运行时间 + 内存回收的时间)
垃圾收集开销:吞吐量的补数,垃圾收集所用时间与总运行时间的比例。
==暂停时间==:执行垃圾回收时,程序的工作线程被暂停时间。
收集频率:相对于应用程序的执行,回收操作发生的频率。
==内存占用==:Java堆区所占的内存大小
快速:一个对象从诞生被回收所经历的时间
其中我们主要关注的是:吞吐量、暂停时间
吞吐量:就是CPU用于运行用户代码的时间与CPU总消耗时间的比值。
吞吐量 = 运行用户代码时间 / (运行用户代码时间 + 垃圾回收时间)
比如:虚拟机总共运行100分钟,其中垃圾收集花掉1分钟,那吞吐量就是99%。
暂停时间:是指一个时间段内应用程序暂停,让GC线程执行的状态。
比如:GC期间100毫秒的暂停时间意味着在这100毫秒期间内没有应用程序线程是活动。
吞吐量和暂停时间的对比:
高吞吐量较高因为这会让应用程序的最终用户感觉只有应用程序在做“生产性”工作。不会感觉到GC的存在。
低暂停时间(低延迟)较好因为从最终用户的角度来所,不管是CG还是用户进程被挂起始终是不好。可能会打断终端用户的体验。特别是交互式的应用程序。
如果以高吞吐量优先:必然会降低内存回收的执行频率
如果以低延迟优先:只能频率地执行内存回收。
2、垃圾回收器概述
1. 7中经典的垃圾回收器
串行回收器:Serial、Serial Old
并行回收器:ParNew、Parallel Scavenge
并发回收器:CMS、G1
2. 垃圾回收器和垃圾分代之间的关系:
新生代回收器:Serial、ParNew、Paraller Scavenge
老年代回收器:Serial Old、Parallel Old、CMS
整堆收集器:G1
3. 垃圾回收器的组合关系:
4. 为什么要有这么多的收集器
因为Java的使用场景很多,移动端,服务端等。所以就需要针对不同的场景,提供不同的垃圾收集器。提高垃圾回收的性能。我们对各个收集器比较,并非挑选一个最好的收集器,而是针对具体的应用挑选一个最合适的收集器。
5. 如何查看默认的垃圾收集
在Java1.8中,查看默认的垃圾收集器。
加上参数:
1 |
|
打印出:
1 |
|
最后一个-XX:+UseParallelGC
:代表新生代使用的是:Parallel GC
。那么老年代会自动选择Parallel Old GC
。
3、Serial回收器:串行回收
Serial收集器是最基本、历史最悠久的垃圾收集器。在JDK1.3之前是回收新生代唯一的选择。
Serail收集器采用复制算法、串行回收和“Stop-The-World”机制的方式执行内存回收。
除了年轻代之外,Serail收集器还提供用于执行老年代垃圾回收器的Serial Old
收集器。Serial Old收集器同样采用了串行回收和“Stop - The - World”机制。只不过内存回收算法使用的是压缩算法。
Serail收集器作为HotSpot中Client模式下的默认新生代垃圾收集器,Serila Old是默认的老年代收集器。
在Server端,Serial Old主要有两个用途:
- 与新生代Parallel Scavenge配合使用
- 作为老年代CMS收集器的后备方案
Serial是一个单线程的收集器,单线程并不仅仅说明它只会使用一个CPU或一条收集线程去完成垃圾收集工作,更重要的是在它进行垃圾回收时,必须暂停其他所有的工作线程。直到它收集结束。
Serial的优势
简单高效:以其他收集器的单线程相比。Serial收集器没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率。
使用SerialGC:
虚拟机参数
1 |
|
新生代使用Serail GC 且老年代使用Serial Old GC
==总结:==
现在的计算机都是多核,使用Serial效率低。
对于交互强的应用而言,这种垃圾收集器是不能接受的。
4、ParNew回收器:并行回收
Serial GC是年轻代中的单线程垃圾收集器,而ParNew回收器则是Serail收集器的多线程版本。
ParNew收集器除了采用并行回收的方式执行内存回收外,两款垃圾收集器几乎没有任何区别。ParNew收集器在年轻代中同样也是采用复制算法、“Stop-The-World”机制。
ParNew是很多JVM运行在Srever模式下新生代的默认的垃圾收集器。
上图中,在新生代中,我们使用ParNew收集器,在老年代中,使用Serial Old收集器。
因为新生代回收次数频繁,使用并行方式高效。对于老年代,回收次数少,并行会造成上下文切换,使用串行方式节省资源。
由于ParNew收集器是基于并发回收,那么是否可以断定ParNew一定比Serial高效吗?
ParNew收集器运行在多CPU的环境下,由于可以充分利用多CPU、多核心等物理硬件资源优势,可以更快地完成垃圾收集。提升程序的吞吐量。
但在单个CPU环境中,ParNew不比Serial更高效。
在程序中,开发人员可以通过参数-XX:+UserParNewGC
手动指定使用ParNew收集器执行内存回收任务。但它仅仅表示在年轻代中使用ParNew,不会影响老年代。
我们还可以通过参数-XX:ParallelGCThreads
限定线程数量。默认开启和CPU数据相同的线程数。
5、Parallel回收器:吞吐量优先
高吞吐量可以高效率地利用CPU时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。因此,在常见的服务器环境中使用。例如:执行批量处理,订单处理,工资支付,科学计算的应用程序。
Parallel优势年轻代中基于并行回收的回收器。
Parallel Scavenge收集器同样使用复制算法、并行回收和“Stop-The-World”机制
与ParNew回收器有什么不同?
Parallel Scavenge收集器的目标是达到可控制的吞吐量。他也被成为吞吐量优先的垃圾收集器。
自适应调节策略也是Parallel Scavenge与ParNew一个重要区别。自适应调节是指:动态调整内存的分配
Parallel Old
收集器是JDK1.6时提供用于执行老年代收集器,用来代替Serial Old
收集器。
Parallel Old
收集器采用了标记-压缩算法,但同样也是基于并行回收和“Stop-The-World”机制。
在程序吞吐量优先的应用场景中,Parallel 收集器和Parallel Old收集器的组合,在server模式下的内存回收性能很不错。Java8默认的垃圾收集器。
==Parallel Scavenge回收器的参数==:
1 |
|
1 |
|
1 |
|
1 |
|
1 |
|
1 |
|
6、CMS回收器:低延迟
目前很大一部分的Java应用集中在互联网站或B/S系统的服务器上,这类应用尤其重视服务的响应速度。希望系统停顿时间最短。已给用户带来较好的体验。CMS收集器就非常符和这类应用的需求。
CMS(Concurrent-Mark-Sweep)收集器是一款老年代的收集器。但无法与新生代收集器Parallel Scavenge配合工作。
CMS这款收集器是HotSpot虚拟机中第一款真正意义上的并发收集器,它第一次实现了让垃圾收集器线程与用户线程同时工作。
CMS收集器的关注点是尽可能缩短垃圾收集时用户线程的停顿时间。停顿时间越短(低延迟)就越适合与用户交互的程序,良好的响应速度能提升用户体验。
CMS的垃圾收集算法采用的是标记-清除算法。并且也会”Stop - The - World”。
==CMS整个过程分成四个阶段==:
初始标记、并发标记、重新标记和并发清除。
初始标记(Initial - Mark)阶段:在这个阶段,程序中所有的工作线程都将会因为”Stop - The - World”机制而出现短暂的暂停这个阶段的主要任务仅仅是标记出GC Roots能直接关联到的对象。一旦标记完成之后就会恢复之前被暂停的所有应用线程。由于直接关联对象比较小,所以这里的速度非常快。
并发标记(Concurrent-Mark)阶段:从GC Roots的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长但是不需要停顿用户线程。可以与垃圾收集线程一起并发运行。
重新标记(ReMark)阶段:由于并发标记阶段中,程序的工作线程会和垃圾收集线程同时运行,由于为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录。这个阶段的停顿时间通常会比初始标记阶段稍长一点,但也远比并发标记阶段的时间短。
并发清除(Concurrent-Sweep)阶段:此阶段清理删除标记阶段判断的已经死亡的对象,释放内存空间。由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发执行。
==CMS的特点==
尽管CMS收集器采用的是并发回收(非独占式),但是在初始阶段和重新标记阶段都需要执行STW机制来暂停程序的工作线程。不过暂停时间的不会太长。
目前的垃圾收集器都做不到完全不需要STW,只能尽可能缩短暂停时间。
由于最耗费时间的并发标记与并发清除阶段都不需要暂停工作,所以成体的回收是低停顿的。
由于垃圾收集阶段用户线程没有中断,所以在CMS回收过程中,还应该确保应用程序用户线程有足够的内存可用。因此,CMS收集器不能像其他收集器那样等到老年代几乎完全填满再进行收集。而是当堆内存使用率达到了某一阈值时,便开始进行回收,以确保应用程序再CMS工作过程中依旧有足够的空间支持应用程序运行。要是CMS运行期间预留的内存无法满足程序需要。就会出现**”Concurrent Mode Failure”**失败,这是虚拟机将启动后备预案:临时Serial Old收集器来重新进行老年代的垃圾收集。这样停顿时间就很长了。
CMS收集器的垃圾回收算法采用的是“标记-清除算法”,这意味着每次执行内存回收后,由于被执行内存回收的无用对象所占用的内存空间极有可能是不连续的内存块。不可避免的产生一些内存碎片。那么CMS在为新对象分配内存空间时,只能选择空闲列表。执行内存分配。
为什么使用标记-压缩算法:
因为CMS是并发收集器。并发清理阶段是和用户线程一起执行的。我们不能去改变线程的地址。
CMS的弊端:
会产生内存碎片:
导致并发清除后,用户线程可用的空间不足。在无法分配大对象的情况下,不得不提前触发Full GC
CMS收集器堆CPU资源非常敏感
在并发阶段。它虽然不会导致用户停顿,但是会因为占用了一部分线程而导致应用程序变慢。总吞吐量会降低。
CMS无法处理浮动的垃圾
可能出现“Concurrent Mode Failure”失败而导致另一次Fill GC的产生。在并发标记阶段由于程序的工作线程和垃圾是收集线程是同时运行或者交叉运行的。那么在并发标记阶段如果产生新的垃圾对象。CMS将无法对这些垃圾对象进行标记,最终会导致这些新产生垃圾对象没有被即使回收,从而只能在下一次执行GC时释放这些之前未被会回收的内存空间。
CMS收集器设置参数:
1 |
|
1 |
|
1 |
|
1 |
|
1 |
|
7、G1收集器:区域化分代式
为什么还有发布G1
业务越来越庞大、复杂、用户越来越多。
为了适应不断扩大的内存和不断增加的处理器数量
官方给G1设定的目标是在延迟可控的情况下,获得尽可能高的吞吐量。所以G1担当起“全功能收集器”的重任和期望。
为什么名字叫做Garbage First(G1)?
因为G1是一个并行回收器,它把堆内存分割为很多不相关的区域(Region)(物理上不连续的)。使用不同的Region来表示Eden、幸存区0、幸存区1、老年代等。
G1 GC有计划地避免在整个Java堆中进行全区域的垃圾收集。G1跟踪各个Region里面的垃圾堆积的价值大小(回收所获得空间大小记忆回收所需要的时间的经验值)。在后台维护一个优先列表。每次根据允许的手机时间,优先回收价值最大的Region。
由于这种方式的侧重点在于回收垃圾最大量的区间(region),所以我们为G1去一个名字:
垃圾优先(Garbage First)。
G1(Garbage - Frist)是一款面向服务端应用的垃圾收集器,主要针对配备多核CPU及大容量内存的机器,以极高概率满足GC停顿时间的同时,还要兼备高吞吐量的性能特征。
在JDK1.7盘本正式启用,移除了Experumental的标识,是JDK 9以后默认的垃圾回收器。被Oracle官方称为“最全功能的垃圾收集器”。与此同时,CMS已经被标记为废弃。
G1回收器的优势:
并行与并发
并行性:G1在回收期间,可以在多个GC线程同时工作,有效利用多核计算能力。此时用户线程STW。
并发性:G1拥有与应用程序交替执行的能力,部分工作可以和应用程序同时执行,因此,一般来说,不会在整个回收阶段发生完全阻塞应用程序的情况。
分代收集
从分代上看,G1依然属于分代型垃圾回收器,它会区分年轻代和老年代,年轻代依然有Eden区和Survivor区。但从堆的结构上看,他不要求整个Eden区,年轻代或老年代都是连续的。也不再坚持固定大小和固定数量。
将堆空间分成若干个区域(Region),这些区域中包含了逻辑上的年轻代和老年代。
和之前的各类回收器不同,他同时兼顾年轻代和老年代。
空间整合
G1将内存划分为一个个的region。内存的回收是以region作为基本单位。Region之间是复制算法,但整体上实际可看做是标记-压缩算法。这两种都可以避免内存碎片。
这种特性有利于程序长时间运行,分配大对象是不会因为无法找到连续内存空间而提前触及下一次GC。
可预测的停顿时间模型
这是G1相对于CMS的另一大优势,G1除了追求低停顿之外,还能建立可预测的停顿时间模型。能让使用者明确指定在一个长度为M毫秒的时间片段内。消耗在垃圾回收上的时间不得超过N毫秒。
G1跟踪各个Region里面的垃圾堆积的价值大小。(回收所获得空间大小以及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region。保证了G1收集器在有限的时间内可以获取尽可能高的收集效率。
G1回收器的缺点:
相较于CMS,G1还不具备全方位、压倒性优势。比如在用户程序运行过程中,G1无论是为了垃圾收集产生的内存占用还是程序运行时的额外执行负载都要比CMS高。
从经验上说,在小内存应用上CMS的表现大概率会优于G1,而G1在大内存应用上则发挥其优势。平衡点在6-8GB之间。
G1回收器的参数:
1 |
|
1 |
|
1 |
|
1 |
|
1 |
|
1 |
|
关于Region:划整为零
使用G1收集器时,他将整个Java堆划分成大约2048个大小相同的独立Region块。每个Region块的大小根据堆空间的实际大小而定。所有的Region大小相同。且在JVM生命周期内不会被改变。
虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔离的了。它们都是一部分Region的集合。通过Region的动态分配方式实现逻辑上的连续。
一个region有可能属于Eden、Survivor或者Old/Tenured内存区域。但是一个region只能属于一个区域。
G1垃圾收集器还增加了一种新的内存区域,叫做Humongous内存区域。主要用于存储大对象,如果超过1.5个region,就放在H中。