JVM-垃圾回收
1. 概述
1.1 什么是垃圾
垃圾
是指 在运行程序中没有任何指针指向的对象,这个对象就是需要被回收的垃圾。
如果不及时对内存中的垃圾进行清理,那么,这些垃圾对象所占的内存空间会一直保留到应用程序结束,被保留的空间无法被其他对象使用。甚至可能 导致内存溢出。
1.2 为什么需要垃圾回收
-
对于高级语言来说,一个基本认知是如果不进行垃圾回收,内存迟早都会被消耗完,因为不断地分配内存空间而不进行回收,就好像不停地生产生活垃圾而从来不打扫一样。
-
除了释放没用的对象,垃圾回收也可以清除内存里的记录碎片。碎片整理将所占用的堆内存移到堆的一端,以便 JVM 将整理出的内存分配给新的对象。
-
随着应用程序所应付的业务越来越庞大、复杂,用户越来越多,没有GC就不能保证应用程序的正常进行。而经常造成
STW
的GC
又跟不上实际的需求,所以才会不断地尝试对 GC 进行优化。
另一方面:
-
对于 Java 开发人员而言,自动内存管理就像是一个黑匣子,如果过度依赖于“自动”,那么这将会是一场灾难,最严重的就会弱化 Java 开发人员在程序出现内存溢出时定位问题和解决问题的能力。
-
此时,了解
JVM
的自动内存分配和内存回收原理就显得非常重要,只有在真正了解JVM
是如何管理内存后,我们才能够在遇见OutOfMemoryError
时,快速地根据错误异常日志定位问题和解决问题。 -
当需要排查各种内存溢出、内存泄漏问题时,当垃圾收集成为系统达到更高并发量的瓶颈时,我们就必须对这些“自动化”的技术实施必要的监控和调节。
1.3 Java 中垃圾回收的重点区域是什么
垃圾回收器可以对年轻代回收,也可以对老年代回收,甚至是全堆和方法区的回收。
其中,Java 中堆是垃圾收集器的工作重点。
2. 垃圾回收算法
2.1 垃圾判断
- 引用计数法
原理:
对于一个对象 A,只要有任何一个对象引用了 A ,则 A 的引用计数器就加1,当引用失效时,引用计数器就减 1。只要对象 A 的引用计数器的值为 0,即表示对象A不可能再被使用,可进行回收。
优点:实现简单,垃圾对象便于辨识;判定效率高,回收没有延迟性。
缺点:
缺点1:它需要单独的字段存储计数器,这样的做法增加了存储空间的开销。
缺点2:每次赋值都需要更新计数器,伴随着加法和减法操作,这增加了时间开销。
缺点3:引用计数器有一个严重的问题,即无法处理循环引用的情况。这是一条致命缺陷,导致在 Java 的垃圾回收器中没有使用这类算法。
- 可达性分析法
原理:
其原理简单来说,就是将对象及其引用关系看作一个图,选定活动的对象作为 GC Roots,然后跟踪引用链条,如果一个对象和 GC Roots
之间不可达,也就是不存在引用链条,那么即可认为是可回收对象。
基本思路:
- 可达性分析算法是以根对象集合(GC Roots)为起始点,按照从上至下的方式搜索被根对象集合所连接的目标对象是否可达。
- 使用可达性分析算法后,内存中的存活对象都会被根对象集合直接或间接连接着,搜索所走过的路径称为引用链(Reference Chain)
- 如果目标对象没有任何引用链相连,则是不可达的,就意味着该对象己经死亡,可以标记为垃圾对象。
- 在可达性分析算法中,只有能够被根对象集合直接或者间接连接的对象才是存活对象。
优点:
实现简单,执行高效 ,有效的解决循环引用的问题,防止内存泄漏。
GC Roots
在 Java 语言中, GC Roots
包括以下几类元素:
- 虚拟机栈中引用的对象
- 比如:各个线程被调用的方法中使用到的参数、局部变量等。
- 本地方法栈内JNI(通常说的本地方法)引用的对象
- 类静态属性引用的对象
- 比如:Java类的引用类型静态变量
- 方法区中常量引用的对象
- 比如:字符串常量池(String Table)里的引用
- 所有被同步锁 synchronized 持有的对象
- Java虚拟机内部的引用。
- 基本数据类型对应的 Class 对象,一些常驻的异常对象(如:NullPointerException、OutOfMemoryError),系统类加载器。
- 反映 java虚拟机 内部情况的 JMXBean、JVMTI中注册的回调、本地代码缓存等。
小技巧:
由于Root 采用栈方式存放变量和指针,所以如果一个指针,它保存了堆内存里面的对象,但是自己又不存放在堆内存里面,那它就是一个Root 。
注意点:
-
如果要使用可达性分析算法来判断内存是否可回收,那么分析工作必须在一个能保障一致性的快照中进行。这点不满足的话分析结果的准确性就无法保证。
-
这点也是导致GC 进行时必须“Stop The World”的一个重要原因。即使是号称(几乎)不会发生停顿的 CMS 收集器中,枚举根节点时也是必须要停顿的。
2.2 垃圾清除阶段算法
2.2.1 标记-清除算法
执行过程:
当堆中的有效内存空间(available memory)被耗尽的时候,就会停止整个程序(也被称为stop the world),然后进行两项工作,第一项则是标记,第二项则是清除。
标记:Collector从引用根节点开始遍历,标记所有被引用的对象。一般是在对象的Header中记录为可达对象。
清除:Collector对堆内存从头到尾进行线性的遍历,如果发现某个对象在其Header中没有标记为可达对象,则将其回收。
标记-清除的缺点:
1、效率比较低:递归与全堆对象遍历两次。
2、在进行GC的时候,需要停止整个应用程序,导致用户体验差。
3、这种方式清理出来的空闲内存是不连续的,产生内存碎片。
2.2.2 复制算法
核心思想:
将活着的内存空间分为两块,每次只使用其中一块,在垃圾回收时将正在使用的内存中的存活对象复制到未被使用的内存块中,之后清除正在使用的内存块中的所有对象,交换两个内存的角色,最后完成垃圾回收。
优点:
没有标记和清除过程,实现简单,运行高效
复制过去以后保证空间的连续性,不会出现“碎片”问题。
缺点:
此算法的缺点也是很明显的,就是需要两倍的内存空间。
对于G1这种分拆成为大量region的GC,复制而不是移动,意味着GC需要维护region之间对象引用关系,不管是内存占用或者时间开销也不小。
特别的:
如果系统中的存活对象很多,复制算法不会很理想。因为复制算法需要复制的存活对象数量并不会太大,或者说非常低才行。
应用场景:
在新生代,对常规应用的垃圾回收,一次通常可以回收70%-99%的内存空间。回收性价比很高。所以现在的商业虚拟机都是用这种收集算法回收新生代。
比如:IBM 公司的专门研究表明,新生代中 80% 的对象都是“朝生夕死”的。
2.2.3 标记-压缩算法
背景:
复制算法的高效性是建立在存活对象少、垃圾对象多的前提下的。这种情况在新生代经常发生,但是在老年代,更常见的情况是大部分对象都是存活对象。如果依然使用复制算法,由于存活对象较多,复制的成本也将很高。因此,基于老年代垃圾回收的特性,不能使用复制算法, 而需要使用其他的算法。
标记-清除算法的确可以应用在老年代中,但是该算法不仅执行效率低下,而且在执行完内存回收后还会产生内存碎片,所以JVM 的设计者需要在此基础之上进行改进。标记 - 压缩(Mark - Compact)算法由此诞生。
执行过程:
第一阶段和标记-清除算法一样,从根节点开始标记所有被引用对象
第二阶段将所有的存活对象压缩到内存的一端,按顺序排放。
之后, 清理边界外所有的空间。
标记-压缩算法的最终效果等同于标记-清除算法执行完成后,再进行一次内存碎片整理,因此,也可以把它称为 标记-清除-压缩(Mark-Sweep-Compact)算法。
二者的本质差异在于标记-清除算法是一种非移动式的回收算法,标记-压缩是移动式的。是否移动回收后的存活对象是一项优缺点并存的风险决策。
可以看到,标记的存活对象将会被整理,按照内存地址依次排列,而未被标记的内存会被清理掉。如此一来,当我们需要给新对象分配内存时,JVM 只需要持有一个内存的起始地址即可,这比维护一个空闲列表显然少了许多开销。
指针碰撞(Bump the Pointer)
如果内存空间以规整和有序的方式分布,即已用和未用的内存都各自一边,彼此之间维系着一个记录下一次分配起始点的标记指针,当为新对象分配内存时,只需要通过修改指针的偏移量将新对象分配在第一个空闲内存位置上,这种分配方式就叫做指针碰撞(Bump the Pointer)。
优点:(此算法消除了“标记-清除”和“复制”两个算法的弊端。)
消除了标记/清除算法当中,内存区域分散的缺点,我们需要给新对象分配内存时,JVM只需要持有一个内存的起始地址即可。
消除了复制算法当中,内存减半的高额代价。
缺点:
从效率上来说,标记-压缩算法要低于复制算法。
效率不高,不仅要标记所有存活对象,还要整理所有存活对象的引用地址。
对于老年代每次都有大量对象存活的区域来说,极为负重。
移动对象的同时,如果对象被其他对象引用,则还需要调整引用的地址。
移动过程中,需要全程暂停用户应用程序。即:STW