3.垃圾回收器,JVM究竟要加载多少个类也需要在程

作者:美狮美高梅官方网站

Java 语言与其他编程语言有一个非常突出的特点,自动化内存管理机制。而这种机制离不开高效率的垃圾收集器(Garbage Collection)与合理的内存分配策略,这也是本篇文章将要描述的两个核心点。

概述

观察Java内存运行时区域的各个部分,其中程序计数器、Java虚拟机栈、本地方法栈3个区域随线程而生,随线程而灭;栈中的栈帧随着方法的进入和退出而有条不紊地执行着出栈和入栈操作。每一个栈帧中分配多少内存基本上是在类结构确定下来时就已知的,因此这几个区域的内存分配和回收都具备确定性,在这几个区域内就不需要过多考虑回收的问题,因为方法结束或者线程结束时,内存自然就跟随着回收了。然而,堆和方法区中的内存清理工作就没那么容易了。 堆和方法区所有线程共享,并且都在JVM启动时创建,一直得运行到JVM停止时。因此它们没办法根据线程的创建而创建、线程的结束而释放。
堆中存放JVM运行期间的所有对象,虽然每个对象的内存大小在加载该对象所属类的时候就确定了,但究竟创建多少个对象只有在程序运行期间才能确定。
方法区中存放类信息、静态成员变量、常量。类的加载是在程序运行过程中,当需要创建这个类的对象时才会加载这个类。因此,JVM究竟要加载多少个类也需要在程序运行期间确定。
这部分内存的分配和回收都是动态的,垃圾收集器所关注的是这部分内存,本文后续讨论中的“内存”分配与回收也仅指Java堆和方法区的内存

最近学习了周志明老师的《深入理解Java虚拟机》,收获颇多,留下一些学习笔记,供以后复习用。

引一句周志明老师对 Java 中的内存管理机制的描述:

对象已死吗?

在堆里面存放着Java世界中几乎所有的对象实例,垃圾收集器在对堆进行回收前,第一件事情就是要确定这些对象之中哪些还“存活”着,哪些已经“死去”(即不可能再被任何途径使用的对象)。
下面介绍两种判断对象是否存活的算法:

一.学习目标

Java 与 C++ 之间有一堵有内存动态分配和垃圾收集技术所围成的「高墙」,墙外面的人想进去,墙里面的人却想出来。

引用计数算法

给每个对象添加一个引用计数器,每当有一个地方引用它时,计数器值就加1;当引用失效时,计数器值就减1;任何时刻计数器为0的对象就是不可能再被使用的。
但主流的Java虚拟机里面没有选用引用计数算法来管理内存,因为这种算法存在一个缺陷,即它无法解决对象之间相互循环引用的问题:
举个简单的例子,对象objA和objB都有字段
instance,赋值令objA.instance = objB 及 objB.instance = objA,除此之外,这两个对象再无任何引用,实际上这两个对象已经不可能再被访问,但是它们因为互相引用着对方,导致它们的引用计数都不为0,于是引用计数算法无法通知GC收集器回收它们。

1.对象存活判断

各有各的优势,没有谁会替代谁,只是应用在不同的场合下,谁更适合而已。

可达性分析算法

在主流的商用程序语言(Java、C#、Lisp)的主流实现中,都是通过可达性分析(Reachability Analysis)来判定对象是否存活的。这个算法的基本思路就是通过一系列的称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连(用图论的话来说,就是从GC Roots到这个对象不可达)时,则证明此对象是不可用的。
如下图所示,对象object 5、object 6、object 7虽然互相有关联,但是它们到GC Roots是不可达的,所以它们将会被判定为是可回收的对象。

图片 1

可达性分析算法.png

在Java语言中,可作为GC Roots的对象包括下面几种:
1、虚拟机栈(栈帧中的本地变量表)中引用的对象。
2、本地方法栈中JNI(即一般说的Native方法)引用的对象。
3、方法区中类静态属性引用的对象。
4、方法区中常量引用的对象。

2.GC(garbage collection)算法学习

可达性分析算法

Java 中使用「可达性分析算法」来判定堆中的垃圾,但是很多其他的编程语言都采用「引用计数算法」判断对象是否依然存活。例如,Python,C++ 以及一些游戏脚本语言就采用的「引用计数算法」来判定对象的存活与否。

引用计数算法:给每一个引用对象增加一个计数器,每当有一个地方引用了该对象,就使该对象的计数器加一,每当一个引用失效时就使该计数器减一。当进行垃圾判定的时候,如果某个对象的计数器为零即说明了该对象无人引用,是垃圾。

这种算法设计简单,效率高,但 Java 里为什么没有采用呢?

主要是引用计数算法存在一个很致命的问题,循环引用。我们看一段代码:

public class A { private B bRef; public B getbRef() { return bRef; } public void setbRef { this.bRef = bRef; }}

public class B { private A aRef; public A getaRef() { return aRef; } public void setaRef { this.aRef = aRef; }}

产生循环引用:

public static void main(String[] args){ A obj1 = new A(); B obj2 = new B(); obj1.setbRef; obj2.setaRef; obj1 = null; obj2 = null;}

他们的内存布局如下:

图片 2image

依照引用计数算法,栈中 obj1 对堆中 A 的对象有一个引用,因此计数器增一,obj2 对堆中 B 的对象有一个引用,计数器增一。然后这两个对象中的字段又互相引用了,各自的计数器增一。

然后我们让 obj1 和 obj2 分别失去对堆中的引用,按照常理来说,堆中的这两个对象已经无用了,应该被回收内存。但是你会发现,采用引用计数算法的程序语言不会回收这两个对象的内存空间,因为它们内部互相引用,计数器都不为零。

这就是「循环引用」问题,引用计数算法是无法辨别堆中的这两个对象已经无用了,所以程序中如果大量互相引用的代码,收集器将无法回收这部分无用的垃圾,即产生内存泄露问题。

但是,如果上述逻辑由 Java 语言实现,运行结果会告诉你,GC 回收了这部分垃圾。看看 GC 日志:

图片 3image

粗糙点来说,原先堆中的两个对象加上堆中一些其他对象总共占用了 2302K 内存空间,经过 GC 后,显然这两个对象所占的内存空间被释放了。

既然如此,那么 Java 采用的「可达性分析算法」是如何避免这一类问题的呢?

可达性分析算法:从「GC Roots」为起始点,遍历引用链,所有能够直接或者间接被「GC Roots」引用的对象都判定为存活,其他所有对象都将在 GC 工作时被回收。

那么这些根结点的如何选择将直接决定了 GC 收集效率的高低。Java 中,规定以下的对象可以作为 GC Roots:

  • 虚拟机栈中引用的对象
  • 方法区中类属性引用的对象
  • 方法区常量引用的对象
  • 本地方法栈中 Native 方法引用的对象

整体上来看,这几种对象都是随时可能被使用的,不能轻易释放,或者说,这些对象的存活性极高,所以它们关联着的对象都不能被回收内存。

回收无效对象的过程

即使在可达性分析算法中不可达的对象,也并非是“非死不可”的,这时候它们暂时处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历两次标记过程:如果对象在进行可达
性分析后发现没有与GC Roots相连接的引用链,那它将会被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法。当对象没有覆盖finalize()方法,或
者finalize()方法已经被虚拟机调用过,虚拟机将这两种情况都视为“没有必要执行”。
具体步骤如下:

1)判断该对象是否覆盖了finalize()方法
若已覆盖该方法,并该对象的finalize()方法还没有被执行过,那么就会将对象放入F-Queue队列中;若未覆盖该方法,则直接释放对象内存。

2)执行F-Queue队列中的finalize()方法
虚拟机会以较低的优先级执行这些finalize()方法,所谓的“执行”是指虚拟机会触发这个方法,但不会确保所有的finalize()方法都会执行结束。一个对象在finalize()方法中执行缓慢,或者发生了死循环(更极端的情况),将很可能会导致F-Queue队列中其他对象永久处于等待,甚至导致整个内存回收系统崩溃,此时虚拟机就直接停止执行,将该对象清除。

3)对象重生或死亡
finalize()方法是对象逃脱死亡命运的最后一次机会,稍后GC将对F-Queue中的对象进行第二次小规模的标记,如果对象要在finalize()中成功拯救自己——只要重新与引用链上的任何一个对象建立关联即可,譬如把自己(this关键字)赋值给某个类变量或者对象的成员变量,那在第二次标记时它将被移除出“即将回收”的集合;如果对象这时候还没有逃脱,那基本上它就真的被回收了。

注意:强烈不建议使用finalize()函数进行任何操作!如果需要释放资源,请使用try-finally。因为finalize()不确定性大,开销大,无法保证顺利执行。

3.垃圾回收器

HotSpot 中可达性算法的实现

可达性分析的第一步就是枚举出所有的根结点,然后才能去遍历标记所有不可达对象。而实际上,HotSpot 的实现并没有按序枚举所有的虚拟机栈,方法区等区域进行根结点查找,而是使用了 OopMap 这种数据结构来实现枚举操作的。

堆中的每个对象在自己的类型信息中都保存有一个 OopMap 结构,记录了对象内引用类型的偏移量,也就是说,通过该对象可以得到该对象内部引用的所有其他对象的引用。

对于虚拟机栈来说,编译器会在每个方法的某些特殊位置使用 OopMap 记录当前时刻栈中哪些位置存放有引用。

于是 GC 在进行可达性分析的时候,无需遍历所有的栈和方法区,只需要遍历一下各个线程当前的 OopMap 即可完成根结点枚举操作,接着递归标记可达对象就行了。

理解了 HotSpot 是如何枚举根结点的,那么对于安全点这个概念就很好理解了,所有生成 OopMap 更新的位置就叫做安全点。当系统发起 GC 请求的时候,需要中断所有线程的活动,而并不是线程的任何状态下都适合 GC 的,必须在停下来之前完成 OopMap 的更新,这样会方便 GC 枚举跟结点。

所以,我们说线程收到中断请求的时候,需要「跑」到最近的安全点才能停下,这是因为安全点的位置会完成 OopMap 的更新,以保证各个位置的对象引用关系不再改变。(你想啊,GC 根据 OopMap 进行根结点枚举,离上一次 OopMap 你已经做了一大堆事情了,改变了栈上很多对象的引用关系,难道你在停下来被 GC 之前不应该把你所做的这些操作记录下来吗?不然 GC 哪知道哪些对象已经不用了,哪些对象你又重新引用了?)

那安全区域又是一个什么样的概念呢?

安全区域是指,一段代码的执行不会更改引用关系,这段代码所处的范围可以理解为一个区域,某个线程在这个区域中执行的时候,只要标志自己进入了安全区域,就不用理会系统发起的 GC 请求而可以继续运行。

程序离开安全区域之前,会检查系统是否已经完成了 GC 过程,如果没有则等待,否则「走」出安全区域,继续执行后续指令。

安全区域实际上是安全点的一个扩展,安全区域中运行的线程可以与 GC 垃圾收集线程并发工作,这是它最大的一个特点。

Java引用种类

无论是通过引用计数算法判断对象的引用数量,还是通过可达性分析算法判断对象的引用链是否可达,判定对象是否存活都与“引用”有关。
在JDK 1.2以前,Java中的引用的定义很传统:如果reference类型的数据中存储的数值代表的是另外一块内存的起始地址,就称这块内存代表着一个引用。这种定义很纯粹,但是太过狭隘,一个对象在这种定义下只有被引用或者没有被引用两种状态,对于如何描述一些“食之无味,弃之可惜”的对象就显得无能为力。我们希望能描述这样一类对象:当内存空间还足够时,则能保留在内存之中;如果内存空间在进行垃圾收集后还是非常紧张,则可以抛弃这些对象。很多系统的缓存功能都符合这样的应用场景。
在JDK1.2之后,Java对引用的概念进行了扩充,将引用分为强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)、虚引用(Phantom Reference)4种,这4种引用强度依次逐渐减弱

4.内存分配与回收策略

四大引用

Java 里的引用本质上类似于 C 语言中的指针,变量中的值是内存中另一块的地址,而并非实际的数据。Java 中有四种引用,它们各自有不同的生命范围。

  • 强引用,类似于 String s = new String(); 这类引用,s 就是一种强引用,只要 s 通过这种方式强引用堆中对象,GC 永远都不能回收被引用的对象的内存
  • 软引用,用于描述某些还有用但并非必必需的对象,某次 GC 操作后,如果内存还是不足以用于当前分配,也就是即将发生内存溢出,那么将回收所有软引用所占用的内存空间
  • 弱引用,用于描述一些非必需的对象引用,当垃圾收集器工作时,不论当前内存空间是否充足,都会回收这一部分内存空间
  • 虚引用,又称幽灵引用,这是一种最弱的引用,即便 GC 没有工作,我也无法拿到这类引用指向的对象了

除了强引用,其他的三类引用实际中很少使用,关于它们的测试代码,将随着本篇文章一起脱管在我的 GitHub 上,感兴趣的可以去 fork 回去运行一下,此处不再赘述。

强引用

我们平时所使用的引用就是强引用。 A a = new A(); 也就是通过关键字new创建的对象所关联的引用就是强引用。 只要强引用存在,该对象永远也不会被回收。

对于GC学习,我们主要考虑三个问题:哪些内存需要回收 -> 什么时候回收 -> 如何回收

垃圾收集算法

垃圾收集算法的实现是很复杂的,并且不同平台的虚拟机也有着不同的实现,但是单看收集算法本身而言,还是相对容易理解的。

标记-清除算法

标记清除算法实现思路包含两个阶段,第一个阶段,根据可达性分析算法标记所有不可达的「垃圾」,第二阶段,直接释放这些对象所占用的内存空间。

图片 4image

但是,它的缺点也很明显,做一次清除操作至少要遍历两次堆,一次用于标记,一次用于清除。并且整个堆内存会存在大量的内存碎片,一旦遇到大对象,将无法提供连续的内存空间而不得不提前触发一次 Full GC。

复制算法

复制算法将内存划分为两份大小相等的块,每次只使用其中的一块,当系统发起 GC 收集动作时,将当前块中依然存活的对象全部复制到另一块中,并整块的释放当前块所占内存空间。

图片 5image

这种算法不需要挨个去遍历清除,整体上释放内存,相对而言,效率是提高了,但是需要浪费一半的内存空间,有点浪费。

根据 IBM 公司的研究表明,「新生代」中的对象往往都是「朝生夕死」的,也就是说,我们完全没有必要舍掉一半的内存用于转移 GC 后存活的对象,因为活着的对象很少。

主流的商业虚拟机都采用复制算法对新生代进行垃圾收集,但是却将内存划分三个块,一块较大的 Eden 区和两块较小的 Survivor 区。

图片 6image

Eden 和 From 区域用于分配新生代对象的内存空间,当发生 Minor GC 的时候,虚拟机会将 Eden 和 From 中所有存活的对象全部移动到 To 区域并释放 Eden 和 From 的内存空间。

这样不仅解决了效率问题,也解决了空间浪费的问题,但是存在的问题是,如果不巧,某次 Minor GC 后,活着的对象很多,To 区放不下怎么办?

虚拟机的做法是,将这些对象往老年代晋升,具体的后文详细介绍。

标记-整理算法

标记整理算法一般用在老年代,它在标记清除算法的基础上,增加了一个步骤用于对将所有存活着的对象往一端移动以解决内存碎片问题。这种算法适用于老年代的垃圾回收,因为老年代的对象存活性高,每次只需要移动很少的次数即能完成垃圾的清理。

图片 7image

软引用

软引用是用来描述一些还有用但并非必需的对象。对于软引用关联着的对象,在系统将要发生OutOfMemeryError之前,将会把这些对象列进回收范围之中进行第二次回收。如果这次回收还没有足够的内存,才会抛出OutOfMemeryError。软引用的生命周期比强引用短一些。

二.那些内存需要回收

垃圾收集器

从可达性分析算法判定哪些对象不可达,标记为「垃圾」,到回收算法实现内存的释放操作,这些都是理论,而垃圾收集器才是这些算法的实际实现。虚拟机中使用不同的垃圾收集器收集不同分代中的「垃圾」,每种垃圾收集器都具有各自的特点,也适用于不同的场合,需要适时组合使用。但并不是任意的两个收集器都能组合工作的:

图片 8image

可以看到,新生代主要有三款收集器,老年代也有三款收集器,G1(Garbage First)是一款号称能一统所有分代的收集器,当然还不成熟。

收集器很多,本文限于篇幅不可能每一个都详细的介绍,只能简单的描述一下各个收集器的特点和优劣之处。

  • Serial:新生代的单线程垃圾收集器,适用于单 CPU,待收集内存不大的场景下,速度快高效率,是客户端模式下虚拟机首选的新生代收集器
  • ParNew:是 Serial 收集器的多线程版本,适用于多 CPU 多线程下的垃圾收集,是服务端虚拟机的首选收集器
  • Parallel Sacenge:类似于 ParNew,但却是一个注重吞吐量的收集器,可以显式指定收集器达到什么层次的吞吐量
  • Serial Old:Serial 的老年代版本,采用的标记整理算法收集垃圾
  • Parallel Old:Parallel 的老年代版本
  • CMS:这是一款基于标记清除算法收集新生代的收集器,主要特点是,低停顿时间,容易产生浮动垃圾

关于垃圾收集器的细节内容,很多,文章中不可能描述清楚,大家可以参阅相关书籍及论文进行学习。

弱引用

弱引用也是用来描述非必需对象的,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生之前。当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。

内存运行时,程序计数器、本地方法栈和虚拟机栈是随着线程的产生而产生,随着线程的消亡而消亡的,这几部分的内存分配和回收是确定好了的,随方法结束或线程结束时,内存就紧跟着回收了。而Java堆和方法区不一样。一个接口中多个实现类需要的内存可能不一样,一个方法中的多个分支需要的内存也可能不一样,我们只有在运行期间才知道会创建哪些对象,故内存回收与分配重点关注的是堆内存方法区内存

内存分配策略

Java 对象的内存都分配在堆中,准确来说,新生的对象都分配在新生代的 Eden 区中,如果 Eden 区域不足以存放一些对象的时候,系统将发起一次 Minor GC 清除并复制依然存活的对象到 Survivor 区,一旦 Survivor 区域不够存放,将通过内存担保机制将这些对象移入老年代。下面我们用代码具体看一看:

//VM:-XX:+PrintGCDetails -Xms10M -Xmx10M -Xmn5M//限制了 10M 的堆内存,其中新生代和老年代分别占 5Mbyte[] buffer = new byte[2 * 1024 * 1024];

图片 9image

新生代收集器默认 Eden 与 Survivor 的比例为是 8:1。这里我们看到新生代已使用空间 4032K,其中一部分是我们两兆的字节数组,其余的是一些系统的对象内存分配。

如果我们还要再分配一兆大小的内存空间呢?

//VM:-XX:+PrintGCDetails -Xms10M -Xmx10M -Xmn5Mbyte[] buffer = new byte[2 * 1024 * 1024];byte[] buffer1 = new byte[1 * 1024 * 1024];

图片 10image

虚拟机首先会检查一下新生代还能不能再分出一兆的内存空间出来,发现不能,于是发起 MinorGC 回收新生代堆空间,并将依然存活的对象复制到另一块 Survivor 空间,发现 512K 根本放不下 buffer,于是通过担保机制将 buffer 送入老年代,接着为 buffer1 分配一兆的内存空间。

接着,我们来看看这个担保机制是怎样的?

当实际发生 MinorGC 之前,虚拟机会查看老年代最大可用的连续空间是否能容纳新生代当前所有对象,因为它假设此次 MinorGC 后,新生代所有对象都能够存活下来。

如果条件能够成立,虚拟机认为此次 GC 毫无风险,将直接进行 MinorGC 对新生代进行垃圾回收,否则虚拟机会去查看 HandlePromotionFailure 参数设置的值是否允许「担保失败」。

如果允许,那么虚拟机将继续判断老年代最大可用连续空间是否大于历届晋升过来的新生代对象的平均大小

如果大于,那么虚拟机将冒着风险去进行 MinorGC 操作,否则将改为一次 FullGC。

取平均值的这种概率方法能大概率的保证安全担保,但也不乏担保失败的情况出现,一旦担保失败,虚拟机将发起 FullGC 对整个堆进行扫描回收。看一段代码:

//VM:-XX:+PrintGCDetails -Xms10M -Xmx10M -Xmn5M//系统对象占用大约 2M 堆空间byte[] buffer = new byte[1 * 1024 * 1024];byte[] buffer1 = new byte[1 * 1024 * 1024];//此时新生代所剩下的空间大约 512Kbyte[] buffer2 = new byte[1 * 1024 * 1024];

当我们的 buffer 和 buffer1 分配进 Eden 区之后,新生代剩下不足一兆的内存空间,但是当我们分配一个一兆的字节数组时,系统查看老年代空间为 5M 能够容纳新生代所有存活对象,于是直接发起 MinorGC,回收了新生代中部分对象并尝试着将活着的对象复制到 to 区块中。

显然,to 区域不能容纳这么多对象,于是全部晋升进入老年代。

接着为 buffer2 分配 1M 内存空间在 Eden 区,GC 日志如下:

图片 11image

可以看到,buffer 和 buffer1 已经被担保进入老年代了,而 buffer2 则被分配在了新生代中。MinorGC 之前,新生代中大约 4M 的对象在 MinorGC 后只剩下 504K 了,其中 2M 左右的对象被担保进入了老年代,还有一部分则被回收了内存。

总结一下,本篇文章介绍了虚拟机判定垃圾的「可达性分析算法」,几种垃圾回收算法,还简单的描述不同垃圾收集器各自的特点及应用场景。最后我们通过一些代码了解了虚拟机是如何分配内存给新生对象的。

总的来说,这只能算做一篇科普类文章,帮助你了解相关概念,其他的相关深入细节之处,还有待深入学习。

文章中的所有代码、图片、文件都云存储在我的 GitHub 上:

()

欢迎关注微信公众号:扑在代码上的高尔基,所有文章都将同步在公众号上。

图片 12image

虚引用

虚引用也称为幽灵引用或者幻影引用,它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知

三.什么时候回收

方法区的内存回收

很多人认为方法区(或者HotSpot虚拟机中的永久代)是没有垃圾收集的,Java虚拟机规范中确实说过可以不要求虚拟机在方法区实现垃圾收集,而且在方法区中进行垃圾收集的“性价比”一般比较低:在堆中,尤其是在新生代中,常规应用进行一次垃圾收集一般可以回收70%~95%的空间,而永久代的垃圾收集效率远低于此。
永久代的垃圾收集主要回收两部分内容:废弃常量和无用的类

对于方法区,永久代的垃圾收集主要回收两部分内容:废弃常量和无用的类。

如何判定废弃常量?

清除废弃的常量和清除对象类似,只要常量池中的常量不被任何变量或对象引用,那么这些常量就会被清除掉。

对于堆,其中存放的是对象实例,对于对象实例的回收,我们首先要判断哪些对象是“存活的”,对于那部分“死亡的”对象,就是我们要回收的。判断对象的存活有两种方法:

如何判定废弃的类?

清除废弃类的条件较为苛刻:
1、该类所有的实例都已经被回收,也就是Java堆中不存在该类的任何实例。
2、加载该类的ClassLoader已经被回收。
3、该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

  • 引用计数算法
  • 可达性分析算法

垃圾收集算法

现在我们知道了判定一个对象是无效对象、判定一个类是废弃类、判定一个常量是废弃常量的方法,也就是知道了垃圾收集器会清除哪些数据,那么接下来介绍如何清除这些数据。

引用计数算法:给对象添加一个引用计数器, 每当有一个地方引用它时, 计数器值+1, 引用失效, -1, 为0的对象不能被使用。

标记-清除算法

最基础的收集算法是“标记-清除”(Mark-Sweep)算法,如同它的名字一样,算法分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象
之所以说它是最基础的收集算法,是因为后续的收集算法都是基于这种思路并对其不足进行改进而得到的。它的主要不足有两个:一个是效率问题,标记和清除两个过程的效率都不高;另一个是空间问题,标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。标记—清除算法的执行过程如下图所示:

图片 13

“标记-清除”算法示意图.png

  • 优势:实现简单,效率高。
  • 缺点:无法解决对象相互引用的问题——会导致对象的引用虽然存在,但是已经不可能再被使用,却无法被回收。

复制算法

为了解决效率问题,一种称为“复制”(Copying)的收集算法出现了,它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。这样使得每次都是对整个半区进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。这种算法避免了碎片空间,但内存被缩小了一半。 而且每次都需要将有用的数据全部复制到另一片内存上去,效率不高。复制算法的执行过程如下图所示:

图片 14

复制算法示意图.png

注意:在复制的时候会将存活对象复制到一片连续的空间上,因为复制算法的内存分配是通过“指针碰撞”方式实现的。

可达性分析算法:通过一系列的称为”GC Roots”的对象作为起始点, 从这些节点开始向下搜索, 搜索走过的路径称为引用链(Reference Chain), 当一个对象到GC Roots不可达(也就是不存在引用链)的时候, 证明对象是不可用的。如下图: Object5、6、7 虽然互有关联, 但它们到GC Roots是不可达的, 因此也会被判定为可回收的对象。(注意:不可达的对象, VM也并不是马上对其回收, 因为要真正宣告一个对象死亡, 至少要经历两次标记)

解决空间利用率问题

现在的商业虚拟机都采用这种收集算法来回收新生代。在新生代中,由于大量的对象都是“朝生夕死”,也就是一次垃圾收集后只有少量对象存活,所以并不需要按照1:1的比例来划分内存空间,我们将新生代内存划分成三块:Eden、Survior1、Survior2,内存大小分别是8:1:1,每次分配内存时,只使用Eden和其中一块Survivor。比如先使用Eden+Survior1,当发现Eden+Survior1的内存即将满时,JVM会发起一次Minor GC,清除掉废弃的对象,并将所有存活下来的对象复制到另一块Survior2中。那么,接下来就使用Survior2+Eden进行内存分配。也就是每次新生代中可用内存空间为整个新生代容量的90%(80%+10%),只有10%的内存会被“浪费”。
通过这种方式,只需要浪费10%的内存空间即可实现带有压缩功能的垃圾收集方法,避免了内存碎片的问题。
但是,当一个对象要申请内存空间时,发现Eden+Survior中剩下的空间无法放置该对象,此时需要进行Minor GC,如果MinorGC过后空闲出来的内存空间仍然无法放置该对象,那么此时就需要将对象转移到老年代中,这种方式叫做“分配担保”。

图片 15

什么是分配担保?

当JVM准备为一个对象分配内存空间时,发现此时Eden+Survior中空闲的区域无法装下该对象,那么就会触发Minor GC,对该区域的废弃对象进行回收。但如果Minor GC过后只有少量对象被回收,仍然无法装下新对象,那么此时需要将Eden+Survior中的所有对象都转移到老年代中,然后再将新对象存入Eden区。这个过程就是“分配担保”。

在Java, 可作为GC Roots的对象包括:

标记-整理算法

标记-整理算法是一种老年代的垃圾收集算法。老年代中的对象一般寿命比较长,因此每次垃圾回收会有大量对象存活,因此如果选用“复制”算法,每次需要复制大量存活的对象,会导致效率很低。而且,在新生代中使用“复制”算法,当Eden+Survior中都装不下某个对象时,可以使用老年代的内存进行“分配担保”,而如果在老年代使用该算法,那么在老年代中如果出现Eden+Survior装不下某个对象时,没有其他区域给他作分配担保。因此,老年代中一般使用“标记-整理”算法。
根据老年代的特点,有人提出了另外一种“标记-整理”(Mark-Compact)算法,标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存,“标记-整理”算法的示意图如下图所示:

图片 16

“标记-整理”算法示意图.png

  • 方法区: 类静态属性引用的对象;
  • 方法区: 常量引用的对象;
  • 虚拟机栈中引用的对象.
  • 本地方法栈JNI中引用的对象。

分代收集算法

当前商业虚拟机的垃圾收集都采用“分代收集”(Generational Collection)算法,这种算法并不是一种具体的方法,而是一种思想,即根据对象存活周期的不同将内存划分为几块。一般是把Java堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。而老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用“标记—清理”或者“标记—整理”算法来进行回收。

四.如何回收

内存分配与回收策略

垃圾收集算法

对象优先在Eden分配

目前主流的垃圾收集器都会采用分代回收算法,因此需要将堆内存分为新生代和老年代。
在新生代中为了防止内存碎片问题,因此垃圾收集器一般都选用“复制”算法。因此,堆内存的新生代被进一步分为:Eden区+Survior1区+Survior2区。
每次创建对象时,首先会在Eden区中分配。若Eden区已满,则在Survior1区中分配。若Eden区+Survior1区剩余内存太少,导致对象无法放入该区域时,则会触发Minor GC,对该区域的废弃对象进行回收。但如果Minor GC过后只有少量对象被回收,仍然无法装下新对象,就会启用“分配担保”,将当前Eden区+Survior1区中的对象转移到老年代中,然后再将新对象存入Eden区。
新生代GC(Minor GC):指发生在新生代的垃圾收集动作,因为Java对象大多都具备朝生夕灭的特性,所以Minor GC非常频繁,一般回收速度也比较快。
Minor GC触发条件:当Eden区满时,触发Minor GC。
老年代GC(Major GC/Full GC):指发生在老年代的GC,出现了Major GC,经常会伴随至少一次的Minor GC(但非绝对的,在Parallel Scavenge收集器的收集策略里就有直接进行Major GC的策略选择过程)。Major GC的速度一般会比Minor GC慢10倍以上。
Full GC触发条件:
1)调用System.gc()时,系统建议执行Full GC,但是不必然执行
2)老年代空间不足
3)方法区空间不足
4)通过Minor GC后进入老年代的平均大小大于老年代的可用内存
5)由Eden区、Survivor1区向Survivor2区复制时,对象大小大于Survivor2可用内存,则把该对象转存到老年代,且老年代的可用内存小于该对象大小

1.标记清除算法

大对象直接进入老年代

所谓的大对象是指,需要大量连续内存空间的Java对象,最典型的大对象就是那种很长字符串以及数组。
当发现一个大对象在Eden区+Survior1区中存不下的时候就需要分配担保机制把当前Eden区+Survior1区的所有对象都复制到老年代中去。 我们知道,一个大对象能够存入Eden区+Survior1区的概率比较小,发生分配担保的概率比较大,而分配担保需要涉及到大量的复制,就会造成效率低下。 因此,对于大对象我们直接把他放到老年代中去,从而就能避免大量的复制操作。 那么,什么样的对象才是“大对象”呢?
通过-XX:PretrnureSizeThreshold参数设置大对象
该参数用于设置大小超过该参数的对象被认为是“大对象”,直接进入老年代。
注意:该参数只对Serial和ParNew收集器有效。

分为标记和清除两个阶段,先标记出需要回收的对象(可达性分析算法或者引用计数算法),在标记完成后统一回收所有被标记的对象。

生命周期较长的对象进入老年代

老年代用于存储生命周期较长的对象,那么我们如何判断一个对象的年龄呢?
新生代中的每个对象都有一个年龄计数器,当新生代发生一次Minor GC后,存活下来的对象的年龄就加一,当年龄超过一定值时,就将超过该值的所有对象转移到老年代中去。
使用-XXMaxTenuringThreshold设置新生代的最大年龄
设置该参数后,只要超过该参数的新生代对象都会被转移到老年代中去。

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

动态对象年龄判定

如果当前新生代的Survior中,年龄相同的对象的内存空间总和超过了Survior内存空间的一半,那么所有年龄相同的对象和超过该年龄的对象都被转移到老年代中去。无需等到对象的年龄超过MaxTenuringThreshold才被转移到老年代中去。

图片 17

“分配担保”策略详解

当垃圾收集器准备要在新生代发起一次Minor GC时,首先会检查老年代中最大的连续空闲区域的大小是否大于新生代中所有对象的大小,也就是老年代中目前能够将新生代中所有对象全部装下。
若老年代能够装下新生代中所有的对象,那么此时进行Minor GC没有任何风险,然后就进行Minor GC。
若老年代无法装下新生代中所有的对象,那么此时进行Minor GC是有风险的,垃圾收集器会进行一次预测:根据以往MinorGC过后存活对象的平均数来预测这次MinorGC后存活对象的平均数。
如果以往存活对象的平均数小于当前老年代最大的连续空闲空间,那么就进行MinorGC,虽然此次MinorGC是有风险的。
如果以往存活对象的平均数大于当前老年代最大的连续空闲空间,那么就对老年代进行一次Full GC,通过清除老年代中废弃数据来扩大老年代空闲空间,以便给新生代作担保。
这个过程就是分配担保

2.复制算法

本文由美狮美高梅官方网站发布,转载请注明来源

关键词: