深入理解JVM:GC(垃圾回收机制)

GC(垃圾回收机制)

GC是干啥的

简单来说,C语言的特点相当于是一个手动档的汽车,挂挡比较麻烦,但是能做到更加精确控制。而Java 语言的特点,相当于一个自动挡的汽车,开起来更加方便,但是不能像手动档那样精确控制。
在这里手动档和自动挡相当于内存管理,C 语言中的内存申请之后,需要手动释放,它的缺点是一旦忘记释放,就会容易导致“内存泄漏”;而Java 中的内存,申请之后不需要手动释放而是通过GC。

为啥要有GC

没有垃圾回收机制,就需要程序主动归还内存,有了垃圾回收,就相当于有了一个专门的人管理。指望程序员手动释放内存是不太靠谱的,容易出现错误,容易遗忘,内存的申请时机好确定但是施放时机是不太号确定的。 GC 相当于一个后勤主管。整体来看GC 还是很方便的,不仅仅是java ,大量的编程语言还是带GC 的,比如:python、PHP、Go、JS等 当然也有像C/C++不带GC 的。

C/ C++为什么不引入GC 呢?

1、引入 GC 会消耗更多的资源
2、GC 回收内存没有手动释放更加及时
3、GC 会影响到正常的程序工作(影响程序的执行效率),会出现STW (stop the world)问题

C++ 追求的是极致的运行性能,同时也尽可能的降低使用的资源。所以也就没有引入GC。

有没有一种办法,既能解决内存泄漏问题,同时尽可能的降低使用的资源??

Rust 没有GC ,但是也能很好的处理内存泄漏问题,它使用了一些奇葩的语法来进行内存使用的校验,在编译的过程中就能发现内存泄漏问题,从而及时报错。有兴趣大家可以去了解。

GC 要回收哪些内存

:主要回收的就是堆;
方法区:GC 需要回收方法区的内存!!但是方法区空间小,数据失去作用的概率低;
栈:不需要回收,因为栈上的内存何时释放的时机是明确的(线程结束栈上的内存就全部被释放了,某个栈帧销毁,也会导致对应的局部变量被释放)
程序计数器:只是存了地址,不需要回收。

回收的基本单位

内存的单位是字节,那么内存回收就是按照字节来回收的嘛?其实不是这样的,而是按照 对象的 方式来回收。每个对象,都持有一定的内存,释放对应的对象也就是回收了对应的内存。
在这里插入图片描述
GC主要回收的是“已经不用的” 对象,在这个区域中包含很多对象,这些对象曾经被用过,但是现在不再使用了。
当然也存在一种情况就是,有的对象介于“正在使用” 和 “已经不用的” ,在这里不做具体分析,对于这样的对象只认做是正在使用。

回收对象的基本思路

a)标记:找出这个对象到底是否需要回收(判断对象是否死亡)
b)回收:把死了的对象回收回去。

a)标记

a1)引用计数法(java中没有使用,而是在 Python、PHP…中有使用到)

引用计数描述的算法为:
给对象增加一个引用计数器,每当有一个地方引用它时,计数器就+1;当引用失效时,计数器就-1;任何时刻计数器为0的对象就是不能再被使用的,即对象已"死"。(在这里 +1 -1 都是GC机制自动完成的)
引用计数法实现简单,判定效率也比较高,在大部分情况下都是一个不错的算法。比如Python语言就采用引用计数法进行内存管理。
但是,在主流的JVM中没有选用引用计数法来管理内存,最主要的原因就是引用计数法无法解决对象的循环引用问题
我们来看这样一个代码:

class Test {
	Test t = null;
	}
Test a = new Test();
Test b = new Test();

a.t = b;
b.t = a;

a = null;
b = null;

在这里插入图片描述
很明显,对象1 和对象 2 的引用计数不为0 ,按照算法,不为0就不应该被回收。所以就已经没有办法使用对象1 和对象 2了,进入了死循环。

想使用对象1  => 找到对象1的引用 => 这个引用在对象2=> 有需要找到对象2的引用 => 又在对象1=> 先找到对象1 的引用

这样就陷入死循环,为了解决这个问题,还需要引入其他额外的成本,于是java 中并没有使用引用计数,而是采用可达性分析。

a2)可达性分析

此算法的核心思想为 : 通过一系列称为"GC Roots"的对象作为起始点,从这些节点开始向下搜索,搜索走过的路径称之为"引用链",当一个对象到GC Roots没有任何的引用链相连时(从GC Roots到这个对象不可达)时,证明此对象是不可用的。
以下图为例:
在这里插入图片描述
对象Object5-Object7之间虽然彼此还有关联,但是它们到GC Roots是不可达的,因此他们会被判定为可回收对象。
在Java语言中,可作为GC Roots的对象包含下面几种:

  1. 虚拟机栈(栈帧中的本地变量表)中引用的对象
  2. 方法区中类静态属性引用的对象
  3. 方法区中常量引用的对象
  4. 本地方法栈中JNI(Native方法)引用的对象

在JDK1.2以前,Java中引用的定义很传统 : 如果引用类型的数据中存储的数值代表的是另一块内存的起始地址,就称这块内存代表着一个引用。这种定义有些狭隘,一个对象在这种定义下只有被引用或者没有被引用两种状态。我们希望能描述这一类对象 : 当内存空间还足够时,则能保存在内存中;如果内存空间在进行垃圾回收后还是非常紧张,则可以抛弃这些对象。很多系统中的缓存对象都符合这样的场景。
在JDK1.2之后,Java对引用的概念做了扩充,将引用分为**强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)和虚引用(Phantom Reference)**四种,这四种引用的强度依次递减。

  1. 强引用 : 强引用指的是在程序代码之中普遍存在的,类似于"Object obj = new Object()"这类的引用,只要强引用还存在,垃圾回收器永远不会回收掉被引用的对象实例。
  2. 软引用 : 软引用是用来描述一些还有用但是不是必须的对象。对于软引用关联着的对象,在系统将要发生内存溢出之前,会把这些对象列入回收范围之中进行第二次回收。如果这次回收还是没有足够的内存,才会抛出内存溢出异常。在JDK1.2之后,提供了SoftReference类来实现软引用。
  3. 弱引用 : 弱引用也是用来描述非必需对象的。但是它的强度要弱于软引用。被弱引用关联的对象只能生存到下一次垃圾回收发生之前。当垃圾回收器开始进行工作时,无论当前内容是否够用,都会回收掉只被弱引用关联的对象。在JDK1.2之后提供了WeakReference类来实现弱引用。
  4. 虚引用 : 虚引用也被称为幽灵引用或者幻影引用,它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。在JDK1.2之后,提供了PhantomReference类来实现虚引用。
生存还是死亡?

即使在可达性分析算法中不可达的对象,也并非"非死不可"的,这时候他们暂时处在"缓刑"阶段。要宣告一个对象的真正死亡,至少要经历两次标记过程 : 如果对象在进行可达性分析之后发现没有与GC Roots相连接的引用链,那它将会被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法。当对象没有覆盖finalize()方法或者finalize()方法已经被JVM调用过,虚拟机会将这两种情况都视为"没有必要执行",此时的对象才是真正"死"的对象。
如果这个对象被判定为有必要执行finalize()方法,那么这个对象将会被放置在一个叫做F-Queue的队列之中,并在稍后由一个虚拟机自动建立的、低优先级的Finalizer线程去执行它(这里所说的执行指的是虚拟机会触发finalize()方法)。finalize()方法是对象逃脱死亡的最后一次机会,稍后GC将对F-Queue中的对象进行第二次小规模标记,如果对象在finalize()中成功拯救自己(只需要重新与引用链上的任何一个对象建立起关联关系即可),那在第二次标记时它将会被移除出"即将回收"的集合;如果对象这时候还是没有逃脱,那基本上它就是真的被回收了。
任何一个对象的
finalize()方法都只会被系统自动调用一次,如果相同的对象在逃脱一次后又面临一次回收,它的finalize()方法不会被再次执行

a3)回收方法区类对象的规则

方法区(永久代)的垃圾回收主要收集两部分内容 : 废弃常量和无用的类。
回收废弃常量和回收Java堆中的对象十分类似。以常量池中字面量(直接量)的回收为例,假如一个字符串"abc"已经进入了常量池中,但是当前系统没有任何一个String对象引用常量池的"abc"常量,也没有在其他地方引用这个字面量,如果此时发生GC并且有必要的话,这个"abc"常量会被系统清理出常量池。常量池中的其他类(接口)、方法、字段的符号引用也与此类似。
判定一个类是否是"无用类"则相对复杂很多。类需要同时满足下面三个条件才会被算是"无用的类" :

  1. 该类所有实例都已经被回收(即在Java堆中不存在任何该类的实例)
  2. 加载该类的ClassLoader已经被回收
  3. 该类对应的Class对象没有在任何其他地方被引用,无法在任何地方通过反射访问该类的方法
    JVM可以对同时满足上述3个条件的无用类进行回收,也仅仅是"可以"而不是必然。在大量使用反射、动态代理等场景都需要JVM具备类卸载的功能来防止永久代的溢出。

b)回收

b1)标记清除

"标记-清除"算法是最基础的收集算法。算法分为"标记"和"清除"两个阶段 : 首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。
"标记-清除"算法的不足主要有两个 :

  1. 效率问题 : 标记和清除这两个过程的效率都不高
  2. 空间问题 : 标记清除后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行中需要分配较大对象时,无法找到足够连续内存而不得不提前触发另一次垃圾收集。

在这里插入图片描述

b2)标记复制(新生代回收算法)

"标记复制"算法是为了解决"标记-清理"的效率问题。它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这块内存需要进行垃圾回收时,会将此区域还存活着的对象复制到另一块上面,然后再把已经使用过的内存区域一次清理掉。这样做的好处是每次都是对整个半区进行内存回收,内存分配时也就不需要考虑内存碎片等复杂情况,只需要移动堆顶指针,按顺序分配即可。此算法实现简单,运行高效。算法的执行流程如下图 :
在这里插入图片描述
优点:能过解决内存碎片问题,保证内存回收后,并不存在碎片;
缺点:需要一块额外的内存空间,如果生存的对象较多,此时他的效率就比较低。

现在的商用虚拟机(包括HotSpot都是采用这种收集算法来回收新生代)
新生代中98%的对象都是"朝生夕死"的,所以并不需要按照1 : 1的比例来划分内存空间,而是将内存(新生代内存)分为一块较大的Eden(伊甸园)空间和两块较小的Survivor(幸存者)空间,每次使用Eden和其中一块Survivor(两个Survivor区域一个称为From区,另一个称为To区域)。当回收时,将Eden和Survivor中还存活的对象一次性复制到另一块Survivor空间上,最后清理掉Eden和刚才用过的Survivor空间。当Survivor空间不够用时,需要依赖其他内存(老年代)进行分配担保。
HotSpot默认Eden与Survivor的大小比例是8 : 1,也就是说Eden:Survivor From : Survivor To = 8:1:1。所以每次新生代可用内存空间为整个新生代容量的90%,而剩下的10%用来存放回收后存活的对象。HotSpot实现的复制算法流程如下:

  1. 当Eden区满的时候,会触发第一次Minor gc,把还活着的对象拷贝到Survivor From区;当Eden区再次触发Minor gc的时候,会扫描Eden区和From区域,对两个区域进行垃圾回收,经过这次回收后还存活的对象,则直接复制到To区域,并将Eden和From区域清空。
  2. 当后续Eden又发生Minor gc的时候,会对Eden和To区域进行垃圾回收,存活的对象复制到From区域,并将Eden和To区域清空。
  3. 部分对象会在From和To区域中复制来复制去,如此交换15次(由JVM参数MaxTenuringThreshold决定,这个参数默认是15),最终如果还是存活,就存入到老年代。

在这里插入图片描述

b3)标记整理(老年代回收算法)

复制收集算法在对象存活率较高时会进行比较多的复制操作,效率会变低。因此在老年代一般不能使用复制算法。针对老年代的特点,提出了一种称之为"标记-整理算法"。标记过程仍与"标记-清除"过程一致,但后续步骤不是直接对可回收对象进行清理,而是让所有存活对象都向一端移动,然后直接清理掉端边界以外的内存。流程图如下:在这里插入图片描述
优点:不再像复制一样依赖更大的内存空间,也没有内存空间;
缺点:搬运的效率是相对较低的,不适合频繁的进行。

分代回收

当前JVM垃圾收集都采用的是"分代收集(Generational Collection)"算法,这个算法并没有新思想,只是根据对象存活周期的不同将内存划分为几块。
一般是把Java堆分为新生代和老年代。在新生代中,每次垃圾回收都有大批对象死去,只有少量存活,因此我们采用复制算法;而老年代中对象存活率高、没有额外空间对它进行分配担保,就必须采用"标记-清理"或者"标记-整理"算法。

一个“对象”的一生:
1、对象诞生于新生代伊甸区, 新对象的内存就是新生代中的内存
2、第一轮GC 扫描伊甸区之后,就会把大量的对象干掉(统计发现绝大多数对象都是“朝生夕死”)少数没有干掉的对象,就会被拷贝到生存区(标记复制算法)
3、进入生存区的对象,也会被GC 进行扫描,如果发现该对象已经不可达,也就要被销毁,没有被销毁的对象,就要通过复制算法,拷贝到另外一个生存区
4、对象在生存区中经历了若干次拷贝之后,也没被回收,此时说明,这个对象存活时间会比较久,就会被拷贝到老年代
5、老年代的对象也是要经历GC扫描,但是由于老年代的对象,存活时间比较长,所以扫描老年代的周期就比扫描新生代的周期长很多。

面试题 : 请问了解Minor GC和Full GC么,这两种GC有什么不一样吗
  1. Minor GC又称为新生代GC : 指的是发生在新生代的垃圾收集。因为Java对象大多都具备朝生夕灭的特性,因此Minor GC(采用复制算法)非常频繁,一般回收速度也比较快。
  2. Full GC 又称为 老年代GC或者Major GC : 指发生在老年代的垃圾收集。出现了Major GC,经常会伴随至少一次的Minor GC(并非绝对,在Parallel Scavenge收集器中就有直接进行Full GC的策略选择过程)。Major GC的速度一般会比Minor GC慢10倍以上。
  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值