在上一篇中,介绍了JVM组件中的运行时数据区域,这一篇主要介绍垃圾回收器
JVM架构图:
1、垃圾回收概述
在第一篇中介绍JVM特点时,有提到过内存管理,即Java语言相对于C,C++进行的优化,可以在适当的时机自动进行垃圾回收,而不需要手动的编写回收逻辑。
而JVM中的垃圾回收,主要体现在堆和方法区上。栈不存在垃圾回收的问题,因为栈帧之间的线程独立,并且方法在执行完后会弹出栈并且自动释放其中的内存。
1.1、方法区的垃圾回收
一般来说,方法区的垃圾回收体现在以下几点:
-
废弃常量的回收: 方法区中存储的常量包括字符串常量、类的静态常量等。当这些常量没有被引用时,它们就成为了废弃常量。垃圾回收器可以通过检查废弃常量并回收它们占用的内存空间。
-
无效的类的回收: 在JVM中加载的类信息存储在方法区中。当一个类不再被使用,例如类的实例已经全部被回收,那么该类就成为无效的类。垃圾回收器可以通过检查无效的类并回收相关的内存空间。
-
而判定一个类是否能被卸载,需要满足以下条件:
-
实例是否可达: 如果该类的所有实例都不再被任何活跃的引用链所持有,即没有任何途径可以从根对象(如静态变量、方法区中的常量等)到达该类的实例,那么该类的实例就成为了孤岛对象,可以被回收。
-
静态变量和静态方法是否引用: 如果该类的静态变量或静态方法仍然被其他地方引用,那么该类本身的类信息也会保持可达状态,无法被回收。因此,如果某个类的静态资源仍然被引用,即使该类的实例全部不可达,该类也无法被回收。
public class GcDemo3 {
public static void main(String[] args) {
Student.study();
}
}
class Student{
public static void study(){
System.out.println("我是一个学习java时长达到两年半的练习生");
}
}
没有unload:
-
类加载器的生命周期: 在Java中,类的卸载与类加载器密切相关。当一个类加载器不再需要加载某个类时,可以触发该类的卸载。只有当某个类的所有实例都不可达,并且对应的类加载器也不再存活时,才能够导致该类的卸载和回收。
案例:
public class ClassUnload {
public static void main(String[] args) throws InterruptedException {
try {
ArrayList<Class<?>> classes = new ArrayList<>();
ArrayList<URLClassLoader> loaders = new ArrayList<>();
ArrayList<Object> objs = new ArrayList<>();
for (int i = 0; i < 5; i++) {
URLClassLoader loader = new URLClassLoader(
new URL[]{new URL("file:D:\\Idea_workspace\\2024\\")});
Class<?> clazz = loader.loadClass("com.itheima.my.A");
Object o = clazz.newInstance();
// objs.add(o);
// loader = null;
// classes.add(clazz);
// loaders.add(loader);
System.gc();
}
} catch (Exception e) {
e.printStackTrace();
}
}
}
加入了两个JVM参数:
-XX:+TraceClassLoading: 打印加载信息
-XX:+TraceClassUnloading: 打印卸载信息
每次循环都会触发回收:
loader,class,a对应的实例都是强引用,按道理强引用是宁愿OOM也不会垃圾回收,案例中为什么每次循环都会触发回收?
虽然每次循环中新创建了URLClassLoader对象、加载了类、实例化了对象,但是在循环结束后,这些对象都会丢失引用。因为这些对象都是在循环内部被创建的局部变量,一旦循环结束,它们就会超出作用域而无法再被访问到。
把案例中的代码注释打开,则每次循环都将产生的对象放在对应的集合中,产生了引用,所以不会导致回收:
1.2、堆区的垃圾回收
堆的垃圾回收,取决于对象是否被引用:
例如我现在有两个类,A中引用了B,B中引用了A
public class A {
B b;
}
public class B {
A a;
}
public class Demo1 {
public static void main(String[] args) {
A a = new A();
B b = new B();
a.b = b;
b.a = a;
a = null;
b = null;
}
}
在JVM中是这样的结构:
那么将a和b指向堆的地址设置成为null,能否触发垃圾回收呢?取决于不同的分析方法:
1.2.1、引用计数法
每个对象都有一个与之关联的引用计数器,当有新的引用指向该对象时,计数器加1;当引用不再指向该对象时,计数器减1。当一个对象的引用计数器为0时,即没有任何引用指向它,可以判定该对象为垃圾,可以被回收。
在上面的案例中,a指向A的实例时,引用计数器就+1,A的实例又指向B实例中的a时,计数再次+1,b同理。
如果将a和b指向堆的地址设置成为null,仍然保留了A的实例指向B中的a,B的实例指向A中的b的引用,即这种循环引用的情况下引用计数器无法归零,这也是引用计数法的缺陷之一。
引用计数法的缺陷:
无法处理循环引用: 如果存在循环引用的情况,即一组对象相互引用形成了一个环,那么这些对象的引用计数器就永远不会为0,导致它们无法被回收,从而引发内存泄漏。
计数器更新开销较大: 每次引用发生变化时,需要对相关对象的引用计数器进行更新,这会增加额外的开销,并且会在多线程环境下引入并发访问的问题。
无法处理跨代引用: 在分代垃圾回收算法中,对象通常被分为不同的代,而引用计数法无法处理跨代引用的情况,可能导致一些对象被错误地回收或长时间无法回收。
1.2.2、可达性分析
该算法通过从一组根对象(如线程栈、静态变量等)出发,递归地遍历所有可访问的对象,标记它们为可达对象,而未被标记的对象则被认为是不可达的,可以被回收。
可达性分析主要包括以下几个步骤:
-
确定根对象: 首先确定一组根对象,通常包括活跃线程的栈帧、静态变量以及常量池中的对象等。这些根对象是程序中已知的起始点,是可以直接或间接访问到的对象。
-
遍历可达对象: 从根对象开始,对每一个对象进行遍历,找出其引用的对象,并标记这些对象为可达对象。然后对这些被标记的对象再次进行遍历,重复这个过程,直到无法找到新的可达对象为止。
-
标记未被访问到的对象: 在完成可达对象的遍历后,所有未被标记的对象都可以被认为是不可达的,即无法从根对象出发访问到的对象。
-
回收不可达对象: 将所有不可达对象进行回收,释放它们占用的内存空间。
图中栈中的a,b即是根对象,当将a和b指向堆的地址设置成为null,A和B的实例就已经被标记为不可达,尽管内部依旧存在循环引用,但是依旧会被回收。
2、四种引用
2.1、强引用
强引用是最常见的引用类型,如果一个对象有强引用存在,那么它就不会被垃圾回收器回收。即使出现内存不足的情况,JVM也不会回收被强引用指向的对象。
什么时候一个对象会存在强引用?
- 对象被赋值给一个变量:例如,通过Object obj = new Object();将一个对象赋值给一个变量 obj ,此时obj持有该对象的强引用。
- 对象作为参数传递给方法:例如,调用方法时,将对象作为参数传递给方法,方法内部持有该对象的强引用。
- 对象作为实例变量存储在其他对象中:例如,一个类中的实例变量引用了一个对象,那么该对象就具有强引用,只要该类的实例还存在。
- 对象被设置为数组元素:例如,通过byte[] bytes1 = new byte[1024 * 1024 * 100];将一个对象存储在数组中的某个元素位置,该对象就具有强引用。
案例:
public class Demo1 {
public static void main(String[] args) {
byte[] bytes1 = new byte[1024 * 1024 * 100];
byte[] bytes2 = new byte[1024 * 1024 * 100];
}
}
将最大堆内存设置成200M,上面的代码执行,如果过程中没有发生GC,就会内存溢出。
可以看到,强引用即使是内存不足的情况下,也不会进行回收:
2.2、软引用
软引用是一种相对强引用弱化了一些的引用类型。当系统内存不足时,JVM会尝试回收被软引用指向的对象,但并不是必然回收,在这样的情况下,只有当对象已经没有强引用指向它时才会被回收。使用软引用可以有效地实现缓存功能。
同样将最大堆大小设置为200M。
byte数组对象被包装成了软引用(这里为什么要在包装成软引用后把bytes设置为null呢?可以思考一下)。
public class SoftReferenceDemo2 {
public static void main(String[] args) throws IOException {
byte[] bytes = new byte[1024 * 1024 * 100];
SoftReference<byte[]> softReference = new SoftReference<byte[]>(bytes);
bytes = null;
System.out.println(softReference.get());
byte[] bytes2 = new byte[1024 * 1024 * 100];
System.out.println(softReference.get());
}
}
当创建第二个byte数组时,由于内存空间不足,对被包装成软引用的byte数组对象进行了回收。
如果不将bytes设置为null,则不会对被包装成软引用的byte数组对象进行了回收,因为bytes有强引用指向它。
当被软引用SoftReference包装的实例对象被回收后,其容器SoftReference对象也要被回收,在SoftReference的构造方法中,提供了一个ReferenceQueue队列。
当被软引用SoftReference包装的实例对象被回收后,SoftReference对象会放入ReferenceQueue队列中
2.3、弱引用
弱引用比软引用的生命周期更短暂,一旦GC运行无论内存是否充足,被弱引用指向的对象就会被回收(如被包装成弱引用的原有对象具有强引用,依旧无法回收)。弱引用通常用于实现规范映射或者监听器模式。
public class Demo2 {
public static void main(String[] args) {
WeakReference<Object> weakReference = new WeakReference<>(new Object());
System.out.println(weakReference.get());
System.gc();
System.out.println(weakReference.get());
}
}
2.4、虚引用
虚引用是最弱的一种引用类型,它几乎没有实际作用,主要用于在对象被垃圾回收时收到一个系统通知。虚引用必须和引用队列(ReferenceQueue)一起使用。
PhantomReference<Object> phantomRef = new PhantomReference<>(new Object(), referenceQueue);
此外还有一个finalize,它代表的是终结器引用。当对象被回收时,会将对象放入Finalizer类中的引用队列,并且稍后由FinalizerThread线程从队列中获取对象,执行finalize()方法,用于对象在被垃圾回收之前进行清理和释放资源操作。但是,具体的调用时间是不确定的,而且垃圾回收器也不保证一定会调用每个对象的finalize()方法。
finalize()方法是Object类中的,如果需要自定义逻辑只需要重写该方法。
案例:
可以在重写的finalize()方法中,再次将对象关联成强引用。
public class FinalizeReferenceDemo {
public static FinalizeReferenceDemo reference = null;
public void alive() {
System.out.println("当前对象还存活");
}
@Override
protected void finalize() throws Throwable {
try {
System.out.println("finalize()执行了...");
//设置强引用自救
reference = this;
} finally {
super.finalize();
}
}
public static void main(String[] args) throws Throwable {
reference = new FinalizeReferenceDemo();
test();
test();
}
private static void test() throws InterruptedException {
reference = null;
//回收对象
System.gc();
//执行finalize方法的优先级比较低,休眠500ms等待一下
Thread.sleep(500);
if (reference != null) {
reference.alive();
} else {
System.out.println("对象已被回收");
}
}
}
由FinalizerThread线程从队列中获取对象:
小结:
日常程序开发中,创建出的对象绝大部分都是强引用,即使出现内存溢出,也不会对被强引用的对象进行垃圾回收。弱引用和软引用,区别在于前者是垃圾回收器只要发现了就会回收,后者是内存不足时才会被优先回收,但是共同点是,如果被包装成软/弱引用的原目标对象被强引用,则不会被回收。
3、垃圾回收算法
JVM常见的垃圾回收算法有:标记-清除算法,标记-整理算法,复制算法,分代回收算法。
无论哪一种算法,其核心思想是,先找到内存中存活的对象,然后清除其他的对象并释放内存,使得内存空间得以复用。
Java的垃圾回收是由单独负责GC的线程去执行的,但是在执行的过程中,都需要暂时停止用户线程(STW):
而评价一个垃圾回收算法的优劣,通常会关注以下几点:
-
内存利用效率:垃圾回收算法应该尽可能地高效利用内存,避免内存碎片化和内存泄漏问题。
-
垃圾回收的延迟:垃圾回收算法执行时,会导致程序停顿或延迟,这会影响系统的响应速度。因此,垃圾回收算法的评价中会考虑其对程序执行的影响,包括停顿时间的长短、频率等。
-
垃圾回收的吞吐量:垃圾回收算法应该尽可能地减少对程序的干扰,以提高程序的整体吞吐量。(垃圾回收的吞吐量,指用户执行代码的时间/GC时间)
-
碎片化处理能力:垃圾回收算法应该有效地处理内存碎片化问题,避免内存分配失败或者性能下降。
3.1、标记-清除算法
其核心思想在于,利用可达性分析,标记所有仍在使用中的对象,然后清除所有不可达的孤岛对象。(标记使用中的,清除未使用的)。
如图所示,C对象为不可达,会在下一次GC中被清除。
它的弊端在于,在清理过程中可能会存在内存碎片。
首先明白一点,内存是连续的,而未被标记即将被清理的对象,不会恰好都在一片连续的内存上,如图所示,红色部分代表被清理的对象。
由于内存碎片的存在,JVM同时会维护一个空余内存的链表,如果我要添加一个3字节的对象,可能会遍历到链表的尾部才能实现,如图所示,这样的效率就会较低。
3.2、标记-整理算法
为了解决上述内存碎片导致的相关问题,引入了标记-整理算法,相当于对标记-清除算法的一种改进。
其核心思想在于:通过可达性分析,将所有使用中的对象进行标记,然后对其进行整理,使其存放在一块连续的内存上,然后对所有未标记的对象进行清理:
标记阶段:
整理阶段:
但是依旧存在一些缺陷:
-
执行时间较长:标记-整理算法在整理阶段需要移动存活对象,这可能导致执行时间较长,尤其在内存使用较大时,整理操作对程序的停顿时间会更加明显。
-
需要额外空间:标记-整理算法通常需要额外的空间来进行对象的移动操作,这可能会增加算法的空间复杂度,特别是当内存中存在大量存活对象时。
-
不适用于并发环境:标记-整理算法通常需要在整理阶段移动对象,这可能导致并发环境下的一些问题,比如需要暂停所有线程的执行,以保证整理操作的正确性。
3.3、复制算法
复制算法是另一种针对标记-清除算法的增强,其核心思想在于,将堆分为两个区域,分别是From和To,新创建的对象都在From中:
当需要GC时,将所有可达的对象放入To区域:
然后清理From区域:
最后将名称互换:
可达的对象在GC时都是在To空间。
复制算法的主要弊端在于:空间开销大,复制算法需要额外的一块同等大小的内存空间来进行存活对象的复制,这可能导致整体的内存利用率下降,特别是在内存空间受限的情况下。
3.4、分代回收算法
其核心思想是,将内存按代划分成新生代和老年代,其中新生代又由伊甸园区和From,To组成(复制算法):
新创建的对象会被分配在伊甸园区,当伊甸园区空间充满后,会触发一次Minor GC回收掉不可达的对象,然后将剩下的对象放入To区,并且将From和To的名称互换。
我们可以通过arthas工具查看一下这个过程:(JDK1.8版本需要添加JVM参数-XX:+UseSerialGC 表示使用Serial 和 SerialOld垃圾回收器,后面会说明)
public class GcDemo0 {
public static void main(String[] args) throws IOException {
List<Object> list = new ArrayList<>();
int count = 0;
while (true){
System.in.read();
System.out.println(++count);
//每次添加1m的数据
list.add(new byte[1024 * 1024 * 1]);
}
}
}
然后通过arthas的memory命令查看(从上到下分别是伊甸园区,幸存者区,老年区):
同时我们也可以通过JVM命令自主设置这些区的大小:
我们进行如下设置:
-XX:+UseSerialGC -Xms60m -Xmn20m -Xmx60m -XX:SurvivorRatio=3 -XX:+PrintGCDetail
初始状态下,伊甸园区使用了8M
当我们添加了三次数据后触发了Minor GC:
[GC (Allocation Failure) [DefNew: 15819K->4096K(16384K), 0.0084006 secs] 26354K->20135K(57344K), 0.0084792 secs] [Times: user=0.00 sys=0.00, real=0.01 secs]:
- [GC (Allocation Failure) 表示这次垃圾回收是由于内存分配失败而触发的。
- [DefNew: 15819K->4096K(16384K)表示新生代(Young Generation)的垃圾回收情况。15819K是垃圾回收前新生代已使用的内存大小,4096K是垃圾回收后新生代已使用的内存大小,16384K是新生代总内存大小。
- 26354K->20135K(57344K)表示整个堆内存的垃圾回收情况。26354K是垃圾回收前堆内存已使用的大小,20135K是垃圾回收后堆内存已使用的大小,57344K是堆内存总大小。
- 0.0084006 secs:表示这次垃圾回收的实际耗时。
- [Times: user=0.00 sys=0.00, real=0.01 secs]:表示垃圾回收过程中消耗的CPU时间和实际时间。
垃圾回收前后的内存情况:
当某个对象在新生代存活过了最多15次垃圾回收(存在一种特殊情况:如果新生代的内存已满,即使年龄没有到达15,依旧会被放入老年代),JVM会认为它很难被回收,就会放入老年区中。 当老年代空间不足时,会先触发一次Minor GC,如果内存依旧不足,则会触发一次Full GC对新生代和老年代所有的垃圾进行回收,若Full GC后依旧无法回收老年代的垃圾,就会触发OOM错误。
老年代空间不足,并且触发了Minor GC依旧内存不足:
触发Full GC:
由于案例中的对象都是强引用,所以无法回收,再次向老年代放入则会触发OOM:
4、垃圾回收器
垃圾回收器是负责管理堆内存中对象的回收和内存释放的组件。JVM的垃圾回收器有多种不同的实现,通常新生代的垃圾回收器需要和老年代的垃圾回收器配合使用:
4.1、Serial&Serial Old
第一种组合方式是Serial(新生代)和Serial Old(老年代):
Serial、Serial Old是一个单线程的收集器,有一个专门用于GC的线程进行垃圾回收,过程中会暂停所有用户线程来进行垃圾回收。
区别在于,Serial使用的是复制算法,而Serial Old使用的是标记-整理算法。
如果需要使用该垃圾回收器,需要配合JVM参数:-XX:+UseSerialGC
可以通过arthas的dashboard命令查看:
4.2、ParNew&CMS(Serial Old)
第二种组合方式是ParNew(新生代)配合CMS(老年代)或Serial Old(老年代)
ParNew&Serial Old的组合,实际上是针对Serial&Serial Old的一种优化,使用多线程进行垃圾回收,ParNew使用的依旧是复制算法。
可通过JVM参数-XX:+UseParNewGC 使用:
CMS垃圾回收器在尽量减少停顿时间的同时进行垃圾回收。适用于对响应时间要求较高的应用。允许用户线程和垃圾回收线程在某些时刻同时运行。 使用的是标记-清除算法
执行主要分为四个阶段:
- 初始标记阶段:CMS会暂停所有应用线程来标记根对象。
- 并发标记阶段:CMS收集器会和应用程序线程并发地标记整个堆内存中的对象。
- 重新标记阶段:由于并发标记阶段没有暂停应用线程,某些对象可能发生改变,所以需要二次标记。(暂停所有应用线程)
- 并发清理阶段:与应用程序并发的执行,清理所有未被标记的对象。
它存在的缺陷:
- 因为使用的是标记-清除算法,所以会产生内存碎片。
- 在并发清理阶段,因为应用程序没有暂停,所以在清理的过程中也可能会存在新的垃圾。
- 如果老年代内存不足无法分配对象,CMS会退化为Serial Old单线程回收老年代。
4.3、Paraller Scavenge&Paraller Old
最后一种组合是Paraller Scavenge(新生代)配合Paraller Old(老年代)
Paraller Scavenge&Paraller Old组合,通过多线程并行地进行垃圾收集,将应用程序的暂停时间降到最低,以此来提高系统的吞吐量。Paraller Scavenge使用的是复制算法,Paraller Old使用的是标记-整理算法 ,在GC期间会暂停所有其他线程的执行。
在JDK1.8中,默认就是使用该组合进行垃圾回收,无需额外设置JVM参数。
同时这个组合允许自定义最大暂停时间、吞吐量以及自动调整内存大小:
- -XX:MaxGCPauseMillis= 设置最大暂停时间
- -XX:GCTimeRatio = 设置吞吐量
- -XX:+UseAdaptiveSizePolic 自动调整内存大小
4.4、G1垃圾回收
G1垃圾回收器是在JDK 7中引入的一种全新的垃圾回收器,并在JDK9中成为默认的垃圾回收器。
它具有以下的特点:
- 区域化内存管理:G1将堆内存划分为多个大小相等的区域,每个区域既可以用作新生代也可以用作老年代,这样就消除了新生代和老年代之间的严格划分。
- 并发和并行:G1垃圾回收器在标记、整理和清理阶段都可以利用并发和并行的方式进行垃圾回收,以减少暂停时间并提高吞吐量。
- 持续性垃圾回收:G1会根据应用程序的动态情况来选择哪些区域进行垃圾回收,以尽可能地减少垃圾回收的暂停时间。
- 可预测的暂停:G1通过一种叫做“垃圾优先(Garbage-First)”的算法来选择优先回收哪些区域,从而实现可预测的垃圾回收暂停时间。
在分代回收算法中,划分的新生代和老年代的内存空间是连续的:
而在G1垃圾回收器中,新生代与新生代,老年代与老年代之间并非一定是一块完整的区域:
而是将堆划分为大小相同的区域,每个区域的大小可以通过堆大小/2048算得,也可以自己通过JVM参数进行设置:-XX:G1HeapRegionSize= ,但是参数的值必须是2的指数幂,并且最大为32。
在垃圾回收时,分为新生代回收和混合回收:
4.4.1、新生代回收
当新生代的空间达到60%时,会触发一次回收,使用的是复制算法 (标记伊甸园区和幸存者区,放入另一个幸存者区),但是为了兼顾最大暂停时间,通常不会对所有的垃圾进行回收。当某个对象的年龄到达15时会被放入老年代。
如果某个对象的大小大于该区域的1/2,例如某个区域分配的大小为4M,某个对象的大小为3M,这些对象会被集中存放入Homongous区中。
4.4.2、混合回收
当多次新生代回收后,总堆占有率达到阈值时,会触发混合回收,使用的是复制算法 ,
混合回收的阶段:
- 初始标记阶段:会暂停用户线程,标记所有GC Root引用的对象(不包含该对象的引用链)。
- 并发标记阶段:会和用户线程并发执行,将第一步中对象的引用链一并标记为存活。
- 最终标记阶段:会暂停用户线程,主要是标记第二步中发生引用改变漏标的对象。
- 并发清理阶段:会和用户线程并发执行,将存活的对象通过复制算法 复制到其他区域。
和CMS四个阶段的区别:
CMS | 混合回收 | |
初始标记阶段 | 标记所有GC Root引用的对象 | 标记所有GC Root引用的对象 |
并发标记阶段 | 标记所有的对象 | 标记第一步中对象的引用链 |
最终(重新)标记阶段 | 标记第二步中错标,漏标的对象 | 标记第二步中发生引用改变漏标的对象,不管新创建和不再关联的对象 |
并发清理阶段 | 使用的是标记-清除算法 | 将存活的对象通过复制算法 复制到其他区域。 |
G1进行老年代回收时,也会优先清理存活度最低的区域,但是在清理的过程中,如果没有足够的区域去转移对象,就会触发Full GC,这时使用的是标记-整理算法。
总结
JVM基础篇完结