04-内存分配与垃圾回收

概述

问:为什么需要 GC?

  • 回收内存,防止内存消耗完
  • 进行碎片整理,防止因内存碎片太多造成无法分配大的连续空间

问:什么是内存泄漏?

答:在 C 语言中,开辟了一片内存空间,并通过指针指向该地址。 如果在后面使用完后,将该指针指向了其它地方,且没有事先释放这片内存空间,则会造成这片内存空间不再被使用,但是又不能被系统回收再利用, 这种情况称为内存泄漏。

问:有了自动内存管理和 GC,Java 中会发生内存泄漏吗?

答:在有 GC 的编程语言中,所谓的内存泄漏是指对象的声明周期被意外地延长。例如,流使用完毕后没有及时关闭。

问:内存动态分配和内存回收技术已经相当成熟,一切似乎进入到“自动化”时代,为什么需要了解垃圾收集和内存分配技术?

  • 当需要排查各种内存溢出,内存泄漏问题时;
  • 当垃圾收集成为系统达到更高并发量的瓶颈时,我们需要对这些自动化技术进行监控和调节

分代收集理论(经验法则)

  • 弱分代假说:绝大多数对象的生命周期都是短暂的

  • 强分代假说:经历越多次垃圾收集存活下来的对象越难以消亡

垃圾收集器的设计原则基于分代收集理论:收集器应该将 Java 堆划分出不同的区域,然后将回收对象根据其年龄分配到不同的区域之中进行存储。

垃圾收集 GC 的三个步骤

  1. 定义垃圾内存(What)

    哪些内存需要回收,或者说,什么是垃圾内存?

  2. 回收策略(How)

    使用什么回收算法来进行回收?

  3. 回收时机(When)

    什么时候回收?

垃圾对象标记

什么是垃圾? 或哪些内存需要回收?

生命周期结束的对象需要回收,即已死亡的对象需要回收。

具体来说,只有方法区和堆中需要进行GC,并且方法区中还可以不进行GC。 所以说,GC的主要工作是回收堆内存。

引用计数算法(Reference Counting)

引用类型

引用类型 含义
强引用 传统认为的引用,只有当引用计数器为 0 时才会被回收
软引用 内存空间不够的时候会被回收,如果回收完还是不够才发生内存溢出异常
弱引用 发生GC的时候回收
虚引用 虚引用和引用的关系不大,唯一目的是在这个对象被收集器回收时收到一个系统通知

算法流程

  1. 为对象添加一个引用计数器,初始值为1
  2. 每当有一个地方引用时,计数器加1; 每当引用失效时,计数器减1
  3. 任何时刻计数器为0时,说明该对象不可再被使用

缺点与措施

缺点:当存在循环引用,即有向图中存在环的时候,无法回收环中的对象。

解决办法:

  • 手动解除

    在合适的时机,手动设置引用计数为0

  • 使用弱引用(weakref)

    弱引用只要发生 GC 就会被回收,不用考虑弱引用的引用计数。 有点类似用户线程计数为 0,则不用考虑守护线程就可以退出。

可达性分析算法

从一系列被称为 GC Root 的根对象出发,根据引用关系构成一张有向图(若 A 引用 B,则有 A $\to$ B),通过在图上遍历,遍历完成后,未访过的对象被认为是垃圾对象。

GC Root对象(一定不可以被垃圾回收的对象)包括:

  • 在虚拟机栈中引用的对象:例如各个线程被调用的方法堆栈中使用到的参数,局部变量,临时变量等
  • 在方法区中常量引用的对象:例如字符串常量池(String Table)中的引用
  • 在本地方法栈中JNI引用的对象
  • Java虚拟机内部的引用:例如基本数据类型对应的 Class 对象,一些常驻的异常对象(NullPointException等),系统类加载器
  • 所有被同步锁持有的对象
  • 反映Java虚拟机内部情况的JMXBean,JVMTI中注册的回调,本地代码缓存

确定 GC Root 的方法步骤:

  1. 通过 jmap 获取快照
  2. 使用 MemoryAnalyzerTool 进行分析
  3. 扫描堆空间,确定与 GC Root 相连通的对象

测试案例

验证 Java 并不是使用 Reference Counting 算法

垃圾回收策略

假定:

  • 新生代总是会有大量对象死亡(对新生代进行垃圾收集的效率最高)
  • 新生代中的对象可能被老年代中的对象指向引用(老年代中的对象可能需要放入到 GC Roots 中进行遍历)
  • 跨代引用(老年代引用新生代)的占比极低(对老年代进行进一步划分,可能有跨代引用的对象放入专门的区域,这个区域是什么?)

根据分代收集理论,新生代中总是会有大量对象死亡,因此新生代的垃圾收集效率最高。 而对于新生代中经历 GC 之后存活的对象会逐渐晋升到老年代中。 对于新生代的垃圾收集,由于使用可达性分析算法,而新生代中的对象可能被老年代中的对象引用,因此需要将老年代中的对象添加到GC Roots中进行遍历。 然而将整个老年代中的对象全部添加到GC Roots中代价太高,并且跨代引用(老年代引用新生代)的占比极低,但是又不能不对跨代引用进行处理,因此进一步对老年代进行划分,有可能进行跨代引用的老年代对象专门放在一个区域,同样由于跨代引用占比低的事实,添加到 GC Roots 中的老年代对象也会少得多,从而提高了可达性分析的效率。

收集范围

类型 GC 范围
部分收集(Partial GC) 新生代收集(Minor GC) 只收集新生代中的死亡对象
老年代收集(Marjor GC) 只收集老年代中的死亡对象
混合收集(Mixed GC) 收集新生代和部分老年代中的死亡对象
整堆收集(Full GC) 整堆收集(Full GC) 收集整个Java堆和方法区

收集算法

标记-清除算法

垃圾收集过程中,存活对象不移动,可能存在大量内存碎片

标记-复制算法

以空间换时间的做法,将内存区域一分为二,每次轮流使用其中一块,当发生垃圾收集时只需要通过两个指针完成从一个区域到另一个区域的复制,同时也处理了内存碎片的问题。

缺点:

  • 复制的开销大,因此只适用于对象存活率低的新生代
  • 内存利用率低,只有50%。为此HotSpot虚拟机进一步对新生代的内存布局进行优化,按8:1:1的比例分配一片Eden区域和两片Survivor区域,每次可以使用Eden和一片Survivor,当发生垃圾收集时将存活对象复制到另一片Survivor中。另外在特殊情况下,可能一片Survivor的空间不足以保存所有的存活对象,此时一般将剩余对象保存到老年代中。

标记-复制算法一般用于新生代的垃圾收集,不用于老年代的垃圾收集。因为该算法复制存活的对象,新生代中只有少部分存活的对象,而老年代中可能有着 100% 的存活率。

标记-整理算法

垃圾收集过程中,存活对象移动整理,清除了内存碎片,但是相较于标记-清除算法更加复杂和耗时。 但是由于程序不仅仅考虑到收集过程,还有内存的分配和访问过程,每次垃圾收集都进行内存的整理能够简化内存分配和访问。 而内存分配和访问的频率是远远大于垃圾回收的频率的,因此HotSpot选择在老年代中使用标记-整理算法。

标记-整理算法在移动对象的期间会使得应用程序不可访问,这种现象被最初的虚拟机设计者称为“Stop The World”,目前 ZGC 收集器使用读屏障技术实现了整理过程与用户线程的并发执行

标记-清除算法和标记-整理算法的比较:

标记-整理算法可以算是标记-清除算法的一种变体,二者从不同的角度出发:

  • 标记-清除算法简化垃圾收集过程,不为其添加额外的负担,但是这会造成内存分配和访问时的消耗;
  • 标记-整理算法则是在垃圾收集过程中进行额外的整理移动过程,这个移动过程会造成 stop the world(应用程序在这段时间内被暂停),但是这让更频繁的内存分配和访问过程受益,因此还是值得的。

另外,CMS 垃圾收集器使用一种折中的方法,在大多数时候使用标记-清除算法,容忍内存碎片的存在,

对象的finalization机制

面试题

image-20230207163638846

image-20230207163945620

   转载规则


《04-内存分配与垃圾回收》 熊水斌 采用 知识共享署名 4.0 国际许可协议 进行许可。
 上一篇
Volatile关键字 Volatile关键字
Volatile关键字Java 多线程需要解决三个问题: 原子性:保证指令不会受到线程上下文切换的影响 可见性:保证指令不会受到 CPU 缓存的影响 有序性:保证指令不会受到 CPU 指令重排序和 JIT 即时编译器的指令重排序的影响
2023-03-12
下一篇 
03-深入理解StringTable 03-深入理解StringTable
测试题public static void main(String[] args) { String s1 = "a"; String s2 = "b"; String s3 = "a" + "b"; // ab
2023-03-12
  目录