垃圾收集机制与内存分配策略

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

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

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

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

可达性分析算法

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

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

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

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

public class A {
    private B bRef;

    public B getbRef() {
        return bRef;
    }

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

    public A getaRef() {
        return aRef;
    }

    public void setaRef(A aRef) {
        this.aRef = aRef;
    }
}

产生循环引用:

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

    obj1 = null;
    obj2 = null;
}

他们的内存布局如下:

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

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

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

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

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

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

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

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

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

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

HotSpot 中可达性算法的实现

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

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

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

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

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

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

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

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

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

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

四大引用

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

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

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

垃圾收集算法

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

标记-清除算法

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

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

复制算法

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

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

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

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

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

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

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

标记-整理算法

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

垃圾收集器

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

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

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

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

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

内存分配策略

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

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

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

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

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

虚拟机首先会检查一下新生代还能不能再分出一兆的内存空间出来,发现不能,于是发起 MinorGC 回收新生代堆空间,并将依然存活的对象复制到另一块 Survivor 空间(to),发现 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];
//此时新生代所剩下的空间大约 512K
byte[] buffer2 = new byte[1 * 1024 * 1024];

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

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

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

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

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

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



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

(https://github.com/SingleYam/overview_java)

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

原文地址:https://www.cnblogs.com/yangming1996/p/8715885.html

时间: 2024-11-08 17:21:06

垃圾收集机制与内存分配策略的相关文章

垃圾收集器以及内存分配策略

垃圾回收 垃圾回收的三个问题: 哪些内存需要回收? 什么时候回收? 如何回收? 1.哪些对象需要回收? 判断对象是否存活的办法: 引用计数算法:给对象中添加一个引用计数器,有一个地方引用就+1,引用失效就-1.只要计数器为0则对象已死. 优点:简单易实现: 缺点:无法解决对象之间相互引用的问题.(JVM也因为此种原因没有使用它) 根搜索算法: 通过选取出一个GC Roots对象,已它作为起始点,如果对象不可达,则对象已死. GC Roots对象: 虚拟机栈中引用的对象 方法区中类静态属性引用的对

第三章 垃圾收集器和内存分配策略

第三章 垃圾收集器和内存分配策略 对象已死吗 引用计算方法 可达性分析算法 通过一些列的GC roots 对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径成为引用链,当一个对象到GC roots 没有任何引用链的则证明对象不可用的 虚拟机栈中的引用的对象 方法区中类静态属性引用的对象 方法去区中常量引用的对象 本地方法栈中JNI引用的对象 生存还是死亡 一次筛选,筛选是否有必要执行 finalize()方法 没有覆盖或者finalize()已经被调用过  视为没必要执行 放入一个F-Qu

深入理解java虚拟机----->垃圾收集器与内存分配策略(下)

1.  前言 内存分配与回收策略 JVM堆的结构分析(新生代.老年代.永久代) 对象优先在Eden分配 大对象直接进入老年代 长期存活的对象将进入老年代 动态对象年龄判定 空间分配担保  2.  垃圾收集器与内存分配策略 Java技术体系中所提倡的自动内存管理最终可以归结为自动化地解决两个问题: 给对象分配内存; 回收分配给对象的内存. 对象的内存分配,往大方向上讲就是在堆上的分配,对象主要分配在新生代的Eden区上.少数也可能分配在老年代,取决于哪一种垃圾收集器组合,还有虚拟机中的相关内存的参

Java虚拟机垃圾收集器与内存分配策略

Java虚拟机垃圾收集器与内存分配策略 概述 那些内存需要回收,什么时候回收,如何回收是GC需要完成的3件事情. 程序计数器,虚拟机栈与本地方法栈这三个区域都是线程私有的,内存的分配与回收都具有确定性,内存随着方法结束或者线程结束就回收了. java堆与方法区在运行期才知道创建那些对象,这部分内存分配是动态的,本章笔记中分配与回收的内存指的就是:java堆与方法区. 判断对象已经死了 引用计数算法:给对象添加一个引用计数器,每当有一个地方引用它,计数器+1;引用失败,计数器-1.计数器为0则改判

垃圾收集器与内存分配策略(二)

垃圾收集算法简介 1.标记-清除算法       标记-清除算法主要分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后统一进行回收.对象的标记过程在垃圾收集器与内存分配策略(一)中已经介绍过. 存在的问题:一是效率问题,标记和清除的效率都不高:二是空间问题,标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行过程中需要分配较大对象时无法找到足够的内存而不得不提前触发另一次垃圾收集动作. 2.复制算法       复制算法:它将内存按照容量划分为大小

垃圾收集器与内存分配策略(四)之垃圾收集器

垃圾收集器与内存分配策略(四)--垃圾收集器 收集算法是内存回收的方法论,垃圾收集器则是内存回收的具体实现. 垃圾收集器介绍 在垃圾收集器的层面上对并行与并发的解释: 并行(Parallel):指多条垃圾收集线程并行工作,但此时用户现场仍处于等待状态. 并发(Concurrent):指用户线程与垃圾收集线程同时执行(但并不一定是并行的,可能会交替执行),用户程序仍在继续执行,而垃圾收集程序运行于另一个CPU上. 对于不同的厂商,不同的版本的虚拟机都可能有很大的差别.此处讨论的是jdk1.7之后的

垃圾收集器与内存分配策略(三)之HotSpot的算法实现

垃圾收集器与内存分配策略(三)--HotSpot的算法实现 Java JVM 垃圾回收 在HotSpot虚拟机上实现这些算法时,必须对算法的执行效率有着严格的考量,才能保证虚拟机高效地运行. 1. 枚举根节点 采用可达性分析从GC Roots节点中找引用链为例 存在的缺点: 1.在前面找出还存活对象时,采用可达性分析从GC Roots节点中找引用链时,可作为GC Roots的节点主要在全局性的引用(方法区的常量或类静态属性引用)与执行上下文(虚拟机栈栈帧中的本地变量表或本地方法栈中的Native

垃圾收集器与内存分配策略(六)之内存分配与回收策略

垃圾收集器与内存分配策略(六)--内存分配与回收策略 对象的内存分配,一般来说就是在堆上的分配(但也可能经过JIT编译后被拆散为标量类型并间接地栈上分配),对象分配的细节取决于当前使用的是哪一种垃圾收集器组合,还有虚拟机中与内存相关的参数设置. 区分Minor GC与 Full GC: 新生代GC(Minor GC):指发生在新生代的的垃圾收集动作,因为Java对象大多具有朝生夕灭的特性,所以Minor GC非常频繁,一般回收速度也比较快. 老年代GC(Full GC / Major GC):老

垃圾收集器与内存分配策略(五)之垃圾日志与常见参数

垃圾收集器与内存分配策略(五)--垃圾日志与常见参数 理解GC日志 每个收集器的日志格式都可以不一样,但各个每个收集器的日志都维持一定的共性.如下面二段日志: 33.125: [GC [DefNew: 3324K->152K(3712K), 0.0025925 secs] 3324K->152K(11904K), 0.0031680 secs] 100.667: [Full GC [Tenured: 0K->210K(10240K), 0.0149142 secs] 4603K->