【学习笔记】深入理解JVM之垃圾回收机制
更多文章首发地址:地址
参考:
- 《深入理解JAVA虚拟机》第三版 第三章
- 尚硅谷 第134 - 203 集
- 参考文章:https://blog.csdn.net/qq_48435252/article/details/123697193
1、概念
🌻 首先我们要知道在程序中
垃圾
是怎么样的一个定义?
在现实中,我们知道 垃圾
是我们人类丢弃的废弃物品,而在程序中也是相对如此 垃圾
是指在运行程序中没有任何指针指向的对象,这个对象就是需要被回收的垃圾。 如果不及时对内存中的垃圾进行清理,那么,这些垃圾对象所占的内存空 间会一直保留到应用程序结束,被保留的空间无法被其他对象使用。甚至可能导致内存溢出。
🌻 为什么需要垃圾回收机制呢?
面对这个问题,我们也可以想一想我们人类自己的生存问题,如果我们一直不解决 垃圾
的存储问题,则可能会导致我们生存的地方被 垃圾
所侵占,从而导致没有生存空间。
- 对于高级语言来说,一个基本认知是,如果不进行
垃圾回收
,内存迟早都会被消耗完,因为不断地分配内存空间而不进行回收,就好像不停地生产生活垃圾
而从来不打扫一样。 - 除了释放没用的对象,
垃圾回收
也可以清除内存里的记录碎片。碎片整理将所占用的堆内存移到堆的一端,以便JVM
将整理出的内存分配给新的对象。
2、常见的垃圾回收算法
概念补充:
对象已死
在堆里面存放着Java世界中几乎所有的对象实例,垃圾收集器在对堆进行回收前,第一件事情就 是要确定这些对象之中哪些还“存活”着,哪些已经“死去”(“死去”即不可能再被任何途径使用的对 象)了。
- 判断对象存活一般有两种方式:
引用计数算法
和可达性分析算法
。
我们比较常见的 垃圾回收算法
有以下几种:
引用计数法
可达性分析算法
标记清除算法
复制算法
标记压缩算法
2.1 引用计数法
概念: 引用计数算法(Reference Counting)
比较简单,对每个对象保存一个整型 的引用计数器属性。用于记录对象被引用的情况。
对于一个对象 A,只要有任何一个对象引用了 A,则 A 的引用计数器就加1;当引用失效时,引用计数器就减1。只要对象 A 的引用计数器的值为0,即表示对象A不可能再被使用,可进行回收。
优点:
- 实现简单方便,回收效率高
缺点:
- 需要单独的 字段存储计数器 ,这样的做法增加了存储空间的开销。
- 每次赋值都需要更新计数器,伴随着加法和减法操作,这增加了时间开销。
- 引用计数器有一个严重的问题,即无法处理循环引用的情况。这是一 条致命缺陷,导致 在Java的垃圾回收器中没有使用这类算法(如下图)
2.2 可达性分析算法
这个算法的基本思路 就是通过 一系列称为 “GC Roots”
的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过 程所走过的路径称为 “引用链”(Reference Chain)
,如果某个对象到 GC Roots
间没有任何引用链相连, 或者用图论的话来说就是从 GC Roots
到这个对象不可达时,则证明此对象是不可能再被使用的。
在 Java
技术体系里面,固定可作为 GC Roots
的对象包括以下几种:
-
栈帧中的本地变量表中引用的对象。
-
静态变量。
-
字符串常量池里的引用。
-
在本地方法栈中
JNI(即通常所说的Native方法)
引用的对象。 -
Java
虚拟机内部的引用,如基本数据类型对应的Class对象
,一些常驻的异常对象(比如NullPointExcepiton、OutOfMemoryError
)等,还有系统类加载器。 -
所有被同步锁(synchronized关键字)持有的对象.
2.3 对象的finalization机制
- Java语言提供了对象终止
(finalization)
机制来允许开发人员提供对象被销毁之前的自定义处理逻辑。 - 当垃圾回收器发现没有引用指向一个对象,即:垃圾回收此对象之前,总会先调用这个对象的
finalize()
方法。 finalize()
方法允许在子类中被重写,用于在对象被回收时进行资源释放。通常在这个方法中进行一些资源释放和清理的工作,比如关闭文件、套接字和数据库连接等。- 永远不要主动调用某个对象的
finalize ()
方法,应该交给垃圾回收机制调用。理由包括下面三点:- ➢在
finalize()
时可能会导致对象复活。 - ➢
finalize()
方法的执行时间是没有保障的,它完全由GC线程决定,极端情况下,若不发生GC,则finalize()
方法将没有执行机会。 - ➢一个糟糕的
finalize ()
会严重影响GC的性能。
- ➢在
- 从功能上来说,finalize()方法与C++ 中的析构函数比较相似,但是Java采用的是基于垃圾回收器的自动内存管理机制,所以finalize()方法在本质,上不同于C++ 中的析构函数。
2.4 判断对象的死亡
即使在可达性分析算法中判定为不可达的对象,也不是 “非死不可”
的,这时候它们暂时还处于 “缓刑”
阶段,要真正宣告一个对象死亡,至少要经历两次标记过程:如果对象在进行可达性分析后发现没 有与 GC Roots
相连接的引用链,那它将会被第一次标记,随后进行一次筛选,筛选的条件是此对象是 否有必要执行 finalize()
方法。假如对象没有覆盖 finalize()
方法,或者 finalize()
方法已经被虚拟机调用过,那么虚拟机将这两种情况都视为 “没有必要执行”
。
如果对象重写了 finalize()
方法,且还没有执行该方法,则会把该对象插入到 F一Queue
队列中,由一个虚拟机自动创建的、低优先级的 Finalizer
线程触发其 finalize()
方法执行。
2.5 标记清除算法
概念:
如它的名字一样,算法分为 “标记”
和 “清除”
两个阶段:首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来,标记存活的对象,统一回收所有未被标记的对象。
如图:
优点:
- 比较简单且经常使用。
缺点:
- 第一个是执行效率不稳定,如果
Java
堆中包含大量对象,而且其中大部分是需要被回收的,这时必须进行大量标记和清除的动作,导致标记和清除两个过程的执行效率都随对象数量增长而降低; - 第二个是内存空间的碎片化问题,标记、清除之后会产生大 量不连续的内存碎片,空间碎片太多可能会导致当以后在程序运行过程中需要分配较大对象时无法找 到足够的连续内存而不得不提前触发另一次垃圾收集动作。
2.6 复制算法
为了解决 标记-清除算法
面对大量可回收对象时执行效率低的问题,1969年Fenichel提出了一种称为 “半区复制”(Semispace Copying)
的垃圾收集算法,它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。
核心思想
将活着的内存空间分为两块,每次使用一块,进行垃圾回收的时候,将存活对象复制到另一块未使用的区域,然后将源区域清空,然后交换两个内存的角色
- 优点:
- 没有标记和清除过程,实现简单,运行高效
- 复制过去以后保证空间连续性,不会出现
“碎片”
问题。
- 缺点:
- 此算法的缺点也是很明显的,就是需要两倍的内存空间。
- 对于
G1
这种分拆成为大量region
的GC
,复制而不是移动,意味着GC
需要维护region
之间对象引用关系,不管是内存占用或者时间开销也不小。 - 特别的如果系统中的可用对象很多,复制算法不会很理想,因为要复制大量的对象。
在新生代,对常规应用的垃圾回收,一次通常可以回收70%一 99%的内存空间。回收性价比很高。所以现在的商业虚拟机都是用这种收集算法回收新生代。
2.7 标记压缩算法
背景:
复制算法的高效性是建立在存活对象少、垃圾对象多的前提下的。这种情况在新生代经常发生,但是在老年代,更常见的情况是大部分对象都是存活对象。如果依然使用复制算法,由于存活对象较多,复制的成本也将很高。因此,基于老年代垃圾回收的特性,需要使用其他的算法。 标记一清除算法的确可以应用在老年代中,但是该算法不仅执行效率低下,而且在执行完内存回收后还会产生内存碎片,所以JVM的设计者需要在此基础之上进行改进。标记一压缩(Mark一Compact) 算法由此诞生。 1970年前后,G. L. Steele 、C. J. Chene和D.S. Wise 等研究者发布标记一压缩算法。在许多现代的垃圾收集器中,人们都使用了标记一压缩算法或其改进版本。
核心思想:
- 第一阶段和标记一清除算法一样,从根节点开始标记所有被引用对象.
- 第二阶段将所有的存活对象压缩到内存的一端,按顺序排放。
- 之后,清理边界外所有的空间。
-
标记一压缩算法
的最终效果等同于标记一清除算法执行完成后,再进行一次内存碎片整理,因此,也可以把它称为标记一清除一压缩(Mark一 Sweep一Compact)算法。 -
二者的本质差异在于
标记清除算法
是一种 非移动式 的回收算法,标记压缩
是移动式的。是否移动回收后的存活对象是一项优缺点并存的风险决策。
清除算法的对比:
属性\算法 | 标记清除算法 | 复制算法 | 标记压缩算法 |
---|---|---|---|
时间复杂度 | 中 | 快 | 满 |
空间复杂度 | 少 | 占用2倍 | 少 |
内存碎片 | 有 | 无 | 无 |
移动对象 | 否 | 是 | 是 |
2.8 分代收集算法
前面所有这些算法中,并没有一种算法可以完全替代其他算法,它们都具有自己独特的优势和特点。分代收集算法应运而生。
分代收集算法,是基于这样一个事实:不同的对象的生命周期是不一样的。因此,不同生命周期的对象可以采取不同的收集方式,以便提高回收效率。
一般是把 Java
堆分为 新生代
和 老年代
,这样就可以根据各个年代的特点使用不同的回收算法,以提高垃圾回收的效率。
目前几乎所有的GC都是采用分代收集(Generational Collecting) 算法执行垃圾回收的。
在HotSpot中,基于分代的概念,GC所使用的内存回收算法必须结合年轻代和老年代各自的特点。
- 年轻代(Young Gen)
- 年轻代特点:区域相对老年代较小,对象生命周期短、存活率低,回收频繁。
- 这种情况复制算法的回收整理,速度是最快的。复制算法的效率只和当前存活对象大小有关,因此很适用于年轻代的回收。而复制算法内存利用率不高的问题,通过
hotspot
中的两个survivor
的设计得到缓解。
- 老年代(Tenured Gen)
- 老年代特点:区域较大,对象生命周期长、存活率高,回收不及年轻代频繁。
- 这种情况存在大量存活率高的对象,复制算法明显变得不合适。一般是由标记清除或者是标记整理的混合实现。
- ➢标记阶段的开销与存活对象的数量成正比。
- ➢清除阶段的开销与所管理区域的大小成正相关。
- ➢压缩阶段的开销与存活对象的数据成正比。
3、内存溢出与内存泄露
3.1 内存溢出
内存溢出
内存溢出
相对于内存泄漏
来说,尽管更容易被理解,但是同样的,内存溢出
也是引发程序崩溃的罪魁祸首之一。- 由于
GC
一直在发展,所有一般情况下,除非应用程序占用的内存增长速度非常快,造成垃圾回收已经跟不上内存消耗的速度,否则不太容易出现OOM
的情况。 - 大多数情况下,
GC
会进行各种年龄段的垃圾回收,实在不行了就放大招,来一次独占式的Full GC
操作,这时候会回收大量的内存,供应用程序继续使用。 javadoc
中对OutOfMemoryError
的解释是,没有空闲内存,并且垃圾收集器也无法提供更多内存。
首先说没有空闲内存的情况:说明Java虚拟机的堆内存不够,原因有二:
- (1)
Java
虚拟机的堆内存设置不够。 比如:可能存在内存泄漏
问题;也很有可能就是堆的大小不合理,比如我们要处理比较可观的数据量,但是没有显式指定JVM
堆大小或者指定数值偏小。我们可以通过参数一Xms、一Xmx
来调整。 - 2)代码中创建了大量大对象,并且长时间不能被垃圾收集器收集(存在被引用)对于老版本的
Oracle JDK
,因为永久代的大小是有限的,并且JVM
对永久代垃圾回收(如,常量池回收、卸载不再需要的类型)非常不积极,所以当我们不断添加新类型的时候,永久代出现OutOfMemoryError
也非常多见,尤其是在运行时存在大量动态类型生成的场合;类似intern
字符串缓存占用太多空间,也会导致OOM
问题。对应的异常信息,会标记出来和永久代相关:“java. lang. OutOfMemoryError: PermGen space”
。 随着元数据区的引入,方法区内存已经不再那么窘迫,所以相应的OOM有所改观,出现OOM,异常信息则变成了:“java. lang. OutOfMemoryError: Metaspace"
。 直接内存不足,也会导致OOM
。
这里面隐含着一层意思是,在抛出 OutOfMemoryError
之 前,通常垃圾收集器会被触发,尽其所能去清理出空间。
- ➢例如:在引用机制分析中,涉及到JVM会去尝试回收软引用指向的对象等。
- ➢在
java.nio.BIts.reserveMemory()
方法中,我们能清楚的看到,System.gc()
会被调用,以清理空间。
当然,也不是在任何情况下垃圾收集器都会被触发的。 - 比如,我们去分配一一个超大对象,类似一个超大数组超过堆的最大值,
JVM
可以判断出垃圾收集并不能解决这个问题,所以直接拋出OutOfMemoryError
3.2 内存泄漏(Memory Leak)
- 也称作
“存储渗漏”
。严格来说,只有对象不会再被程序用到了,但是GC
又不能回收他们的情况,才叫内存泄漏
。 - 但实际情况很多时候一些不太好的实践(或疏忽)会导致对象的生命周期变得很长甚至导致内存溢出
OOM
,也可以叫做宽泛意义上的内存泄漏
. - 尽管内存泄漏并不会立刻引起程序崩溃,但是一旦发生内存泄漏,程序中的可用内存就会被逐步蚕食,直至耗尽所有内存,最终出现
0utOfMemory
异常,导致程序崩溃。
注意,这里的存储空间并不是指物理内存,而是指虚拟内存大小,这个虚拟内存大小取决于磁盘交换区设定的大小。
举例
- 1、单例模式 单例的生命周期和应用程序是一样长的,所以单例程序中,如果持有对外部对象的引用的话,那么这个外部对象是不能被回收的,则会导致内存泄漏的产生。
- 2、一些提供
close
的资源未关闭导致内存泄漏 数据库连接( dataSourse. getConnection()),网络连接(socket)和io连接必须手动close,否则是不能被回收的。
4、垃圾回收器
- 1.优先调整堆的大小让JVM自适应完成。
- 2.如果内存小于100M,使用串行收集器
- 3.如果是单核、单机程序,并且没有停顿时间的要求,串行收集器
- 4.如果是多CPU、需要高吞吐量、允许停顿时间超过1秒,选择并行或者JVM自己选择
- 5.如果是多CPU、追求低停顿时间,需快速响应(比如延迟不能超过1秒,如互联网应用),使用并发收集器
- 官方推荐G1,性能高。现在互联网的项目,基本都是使用G1。