GC 相关知识点
- 一、垃圾收集器
- 二、 java 中的引用
- 三、 怎么判断对象是否可以被回收?
- 四、 Java对象在虚拟机中的生命周期
- 五、垃圾收集算法
- 标记-清除算法
- 复制算法
- 补充知识点
- 深拷贝和浅拷贝
- 标记-压缩算法(Mark-Compact)
- 分代收集算法
- Java堆的分区
- 六、内存分配策略
一、垃圾收集器
垃圾收集器(Garbage Collection),通常被称作GC。提到GC,很多人认为它是伴随Java而出现的,其实GC出现的时间要比Java早太多了,它是1960年诞生于MIT的Lisp。GC主要做了两个工作,一个是内存的划分和分配,另一个是对垃圾进行回收
关于内存的划分和分配,目前Java虚拟机内存的划分是依赖于GC设计的,比如现在GC都是采用了分代收集算法来回收垃圾的,Java堆作为GC主要管理的区域,被细分为新生代和老年代,再细致一点新生代又可以划分为Eden空间、FromSurvivor空间、To Survivor空间等,这样划分是为了更快地进行内存分配和回收。空间划分后,GC就可以为新对象分配内存空间。关于对垃圾进行回收,被引用的对象是存活的对象,而不被引用的对象是死亡的对象(也就是垃圾),GC要区分出存活的对象和死亡的对象(也就是垃圾标记),并对垃圾进行回收。在对垃圾进行回收前,GC要先标记出垃圾,那么如何标记呢?目前有两种垃圾标记算法,分别是引用计数算法和根搜索算法,这两个算法都和引用有些关联,因此讲垃圾标记算法前,我们先回顾一下引用的知识点。
二、 java 中的引用
在JDK1.2之后,Java将引用分为强引用、软引用、弱引用和虚引用。
- 强引用: 当我们新建一个对象时就创建了一个具有强引用的对象,如果一个对象具有强引用,垃圾收集器就绝不会回收它。Java虚拟机宁愿抛出OutOfMemoryError异常,使程序异常终止,也不会回收具有强引用的对象来解决内存不足的问题。
- 软引用: 如果一个对象只具有软应用,当内存不够时,会回收这些对象的内存,回收后如果还是没有足够的内存,就会抛出OutOfMemoryError异常。Java 提供了SoftReference 类来实现软引用。
- 弱引用: 弱引用比起软引用具有更短的生命周期,垃圾收集器一旦发现了只具有弱应用的对象,不管当前内存是否足够,都会收回它的内存。Java 提供了WeakReference 类来实现弱引用。
- **虚引用:**虚引用并不会决定对象的生命周期,如果一个对象仅持有虚引用,这就和没有任何应用一样,在任何时候都可能被垃圾收集器回收。一个只具有虚引用的对象,被垃圾收集器回收时会收到一个系统通知,这也是虚引用的主要作用。Java 提供了PhantomReference 类来实现虚引用。
三、 怎么判断对象是否可以被回收?
垃圾收集器在做垃圾回收的时候,首先需要判断的就是哪些内存是需要被回收的,哪些对象是「存活」的,是不可以被回收的;哪些对象已经「死掉」了,需要被回收。
一般有两种方法来判断:
-
引用计数器法:为每个对象创建一个引用计数,有对象引用时计数器加1,引用被释放时计数减1.当计数器为0时,则该对象就不能被使用,变成了垃圾。
举个例子,在下面代码的注释1和注释2处,d1和d2相互引用,除此之外这两个对象无任何其他引用,实际上这两个对象已经死亡,应该作为垃圾被回收,但是由于这两个对象互相引用,引用计数就不会为0,如果Java虚拟机采用了引用计数算法,垃圾收集器就无法回收它们。
-
根搜索算法(可达性分析算法):这个算法的基本思想就是选定一些对象作为GC Roots ,并组成跟对象集合,然后以这些GC Roots 的对象作为起始点,向下搜索,如果目标对象到GC Roots 是连着的,我们则称该目标对象是可达的(搜索所走过的路径称为引用链),如果目标对象不可达则说明对象是可以被回收的对象,如下图
可以看出,Obj5、Obj6和Obj7都是不可达的对象,其中Obj5和Obj6虽然互相引用,但是因为它们到GC Roots是不可达的,所以它们仍旧被判定为可回收的对象,这样根搜索算法就解决了引用计数算法无法解决的问题:已经死亡的对象因为相互引用而不能被回收。在Java中,可以作为GC Roots的对象主要有以下几种: -
Java 栈中引用的对象
-
本地方法栈中JNI 引用的对象
-
方法区中运行时常量池引用的对象
-
方法区中静态属性引用的对象
-
运行中的线程
-
由引导类加载器加载的对象
-
GC 控制的对象
问题 :被标记为不可达的对象会立即被垃圾收集器回收吗?
要回答这个问题我们首先要了解Java对象在虚拟机中的生命周期,如下
四、 Java对象在虚拟机中的生命周期
在Java 对象被类加载器加载到虚拟机中后,Java 对象在Java 虚拟机中有7个阶段
-
创建阶段(Created)
创建阶段的具体步骤为:
(1)为对象分配存储空间
(2)构造对象
(3)从超类到子类对static 成员进行初始化
(4)递归调用超累的构造方法
(5)调用子类的构造方法 -
应用阶段(In Use)
当对象被创建,并分配给变量赋值时,状态就切换到了应用阶段。这一阶段的对象至少要具有一个强引用,或者显示的使用软引用、弱引用或者虚引用。 -
不可见阶段(Invisible)
在程序中找不到对象的任何强引用,比如程序的执行已经超出了该对象的作用域。在不可见阶段,对象仍可能被特殊的强引用GC Roots持有着,比如对象被本地方法栈中JNI 应用或被运行中的线程应用等 -
不可达阶段(Unreachable)
在程序中找不到对象的任何强引用,并且垃圾收集器发现对象不可达。 -
收集阶段(Collected)
垃圾收集器已经发现对象不可达,并且垃圾收集器已经准备好要对该对象的内存空间重新进行分配,这个时候如果该对象重写了finalize 方法,则会调用该方法(finalize在java 9y以后被废弃。应调用时机不确定,不建议重写该方法) -
终结阶段(Finalized)
在对象执行完finalize 方法后仍然处于不可达状态时,或者对象没有重写finalize 方法,则该对象进入终结阶段,并等待垃圾收集器回收该对象空间 -
对象空间重新分配阶段(Deallocated)
当垃圾收集器对对象的内存空间进行回收或者在分配时,这个对象就会彻底消失
很显然是不会的,被标记为不可达的对象会进入收集阶段,这时会执行该对象重写的finalize方法,如果没有重写finalize方法或者finalize方法中没有重新与一个可达的对象进行关联才会进入终结阶段,并最终被回收
五、垃圾收集算法
标记-清除算法
标记—清除算法(Mark-Sweep)是一种常见的基础垃圾收集算法,它将垃圾收集分为两个阶段。
- 标记阶段:标记出可以回收的对象
- 清除阶段:回收被标记的对象所占用的空间
标记-清除算法之所以是基础的,是因为后面讲到的垃圾收集算法都是在此算法的基础上进行改进的。
优点:实现简单,不需要对象进行移动
缺点:标记和清除过程效率低,产生大量不连续的内存碎片,碎片太多可能会导致后续没有足够的连续内存分配给较大的对象,从而提前触发新的一次垃圾收集动作,提高了垃圾回收的频率。
复制算法
为了解决标记-清除算法的效率不高的问题,产生了复制算法。它把内存空间划分为两个相等的区域,每次只使用其中一个区域。垃圾收集时,遍历当前使用的区域,把存活对象复制到另外一个区域中,最后将当前使用的区域的可回收的对象进行回收。
优点:按顺序分配内存即可,这种算法每次都对整个半区进行内存回收,不需要考虑内存碎片的问题。实现简单、运行高效。
缺点:可用的内存大小缩小为原来的一半。复制算法的效率与存活对象的数目多少有很大的关系,如果存活对象很少,复制算法的效率就会很高。由于绝大多数对象的生命周期很短,并且这些生命周期很短的对象都存于新生代中,所以复制算法被广泛用于新生代中。
补充知识点
深拷贝和浅拷贝
- 浅拷贝(shallowCopy)只是增加了一个指针指向已存在的内存地址
- 深拷贝(deepCopy)是增加了一个指针并且申请了一个新的内存,使这个增加的指针指向这个新的内存。使用深拷贝的情况下,释放内存的时候不会因为出现浅拷贝时释放同一个内存的错误
区别
浅拷贝:仅仅是指向被复制的内存地址,如果原地址发生改变,那么浅拷贝出来的对象也会相应的改变
深复制:在计算机中开辟一块新的内存地址用于存放复制的对象
标记-压缩算法(Mark-Compact)
在新生代中可以使用复制算法,但是在老年代就不能选择复制算法了,因为老年代的对象存活率会较高,这样会有较多的复制操作,导致效率变低。标记—清除算法可以应用在老年代中,但是它效率不高,在内存回收后容易产生大量内存碎片。因此就出现了一种标记—压缩(Mark-Compact)算法,与标记—清除算法不同的是,在标记可回收的对象后将所有存活的对象压缩到内存的一端,使它们紧凑地排列在一起,然后对边界以外的内存进行回收,回收后,已用和未用的内存都各自一边。
优点:解决了标记-清理算法存在的内存碎片问题。
缺点:仍需要进行局部对象移动,一定程度上降低了效率。
分代收集算法
分代收集算法会结合不同的收集算法来处理不同的空间,因此在学习分代收集算法之前我们首先需要了解Java堆区的空间划分。Java 堆区的划分在Java 虚拟机中,各种对象的生命周期会有着较大的差别,大部分对象的生命周期很短暂,少部分对象的生命周期很长。有的甚至与应用程序以及Java虚拟机的运行周期一样长。因此,应该对不同生命周期的对象采取不同的收集策略,根据生命周期长短将他们分别放到不同的区域,并在不同的区域采用不同的手机算法,这就是分代的概念。
Java堆的分区
Java 堆区基于分代的概念,分为新生代(Young Generation) 和老年代(Tenured Generation)
新生代再细分为 :
- Eden空间
- From Survivor空间
- To Survivor 空间
因为Eden空间中的大多数对象生命周期很短,所以新生代的空间划分并不是均分的,HotSpot虚拟机默认Eden 空间和两个 Survior 空间的比例是 8:1:1。
在接收分代收集的执行流程前,我们先明确几个分代收集的概念
- Minor GC: 是指发生在新生代的GC ,因为Java对象大多都是朝生夕死,所以Minor GC 非常频繁,一般回收速度也非常快
- Major GC:是指发生在老年代的GC ,出现了Major GC 通常都会伴随至少一次Minor GC。 Major GC 的速度通常会比Minor GC 慢上10倍以上
- Full GC:是清理整个堆空间—包括年轻代和老年代
当Eden区满的时候,JVM会触发MinorGC,如下图
当发生一次Minor GC的时候,它的执行流程如下:
- 把Eden 空间的存活对象复制到To Survivor空间。并把经过一次Minor GC 并在Form Survivor 空间存活的任然年轻的对象也复制到 ToSurvivor 空间
- 清空Eden 空间和From Survivor 空间
- From Survivor 和 To Survivor 空间交换,From Survivor 变 To
Survivor,To Survivor 变 From Survivor
每次在From Survivor 到To Survivor 空间移动时都存活的对象,年龄加1,当年龄到达所指定的阈值(默认是15)时,升级为老年代。大对象也会直接进入老年代。如下图
六、内存分配策略
所谓自动内存管理,最终要解决的也就是内存分配和内存回收两个问题。前面我们介绍了内存回收,这里我们再来聊聊内存分配。对象的内存分配通常是在 Java 堆上分配(随着虚拟机优化技术的诞生,某些场景下也会在栈上分配,后面会详细介绍),对象主要分配在新生代的 Eden 区,如果启动了本地线程缓冲,将按照线程优先在 TLAB 上分配。少数情况下也会直接在老年代上分配。总的来说分配规则不是百分百固定的,其细节取决于哪一种垃圾收集器组合以及虚拟机相关参数有关,但是虚拟机对于内存的分配还是会遵循以下几种「普世」规则:
- 对象优先在Eden区分配:多数情况,对象都在新生代 Eden 区分配。当 Eden 区分配没有足够的空间进行分配时,虚拟机将会发起一次 Minor GC。如果本次 GC 后还是没有足够的空间,则将启用分配担保机制在老年代中分配内存。
- 大对象直接进入老年代:所谓大对象是指需要大量连续内存空间的对象,频繁出现大对象是致命的,会导致在内存还有不少空间的情况下提前触发 GC 以获取足够的连续空间来安置新对象。前面我们介绍过新生代使用的是标记-清除算法来处理垃圾回收的,如果大对象直接在新生代分配就会导致 Eden 区和两个 Survivor 区之间发生大量的内存复制。因此对于大对象都会直接在老年代进行分配。
- 长期存活对象将进入老年代:虚拟机采用分代收集的思想来管理内存,那么内存回收时就必须判断哪些对象应该放在新生代,哪些对象应该放在老年代。因此虚拟机给每个对象定义了一个对象年龄的计数器,如果对象在 Eden 区出生,并且能够被Survivor 容纳,将被移动到 Survivor 空间中,这时设置对象年龄为 1。对象在 Survivor 区中每「熬过」一次 Minor GC 年龄就加 1,当年龄达到一定程度(默认 15) 就会被晋升到老年代。