JVM类加载与GC垃圾回收机制

举报
未见花闻 发表于 2022/08/31 21:51:03 2022/08/31
【摘要】 本篇文章将介绍JVM运行时分区,类加载过程已经垃圾回收机制中的查找垃圾的算法和回收垃圾算法,了解常见的Java虚拟机。

⭐️前面的话⭐️

本篇文章将介绍JVM运行时分区,类加载过程已经垃圾回收机制中的查找垃圾的算法和回收垃圾算法,了解常见的Java虚拟机。

📒博客主页:未见花闻的博客主页
🎉欢迎关注🔎点赞👍收藏⭐️留言📝
📌本文由未见花闻原创!
📆首发时间:🌴2022年8月31日🌴
✉️坚持和努力一定能换来诗与远方!
💭参考书籍:📚《深入理解Java虚拟机》
💬参考在线编程网站:🌐牛客网🌐力扣
博主的码云gitee,平常博主写的程序代码都在里面。
博主的github,平常博主写的程序代码都在里面。
🍭作者水平很有限,如果发现错误,一定要及时告知作者哦!感谢感谢!

1.JVM区域划分

JVM区域包括以下几个:程序计数器,栈,堆,方法区,图中的元数据区可以理解为方法区,毕竟JVM有很多版本,不同版本的运行时区域划分也不一样。
运行时区域划分

程序计数器:内存最小的一块区域,保存了下一条要执行的指令地址在哪里,与书签类似。

栈:储存局部变量与方法调用信息,每一个线程有一个。

栈在JVM区域划分分为两种,一种是Java虚拟机栈,另外一种是本地方法栈,这两种栈功能非常类似,当方法被调用时,都会同步创建栈帧来存储局部变量表、操作数栈、动态连接、方法出口等信息。

只不过虚拟机栈是为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的本地(Native)方法服务。
栈

堆:储存对象以及对象的成员变量,一个进程只有一个,多个线程共用一个堆,内存中空间最大的区域,我们看到下图对堆做了细分,Java堆是垃圾收集器管理的内存区域,所以后介绍GC的时候我们细说。
堆

方法区:存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据,即就是储存“类对象”,被static修饰的变量或方法就成了类属性,.java文件会被编译成.class文件,.class会被加载到内存中,也就被JVM构造成类对象了,这个加载的过程叫做类加载,类对象描述了类的信息,如类名,类有哪些成员,每个成员叫什么名字,权限是什么,方法名等。

2.类加载

2.1类加载过程

1
这个图片所示的类加载过程来自官方文档,类加载包括三个步骤:LoadingLinkingInitialization
官方文档:https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-5.html
2
一个.class文件的格式如下:

3

通过观察.class文件结构,.class文件把.java文件的核心信息都保留了下来,只不过表现的形式发生了变化而已。

来自官方文档:https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-4.html#jvms-4.1

回到正题,类加载包括了三步:

第一步,进行Loading,通过Loading可以找到对应的.class文件,打开并读取文件,同时通过解析文件生成一个初步的类对象。

第二步,进行Linking,作用是建立多个实体之间的联系,该过程有包括三个小过程:

  • 校验,主要就是验证读取到的内容是不是和规范中规定的格式完全匹配,如果不匹配,就会类加载失败,并且会抛出异常。
  • 准备,给静态变量分配内存,并设置默认值。
  • 解析,.class文件中,常量是集中放置的(常量池),并且每一个常量都有一个编号,.class文件中的结构体初始情况下它只记录了常量的编号,解析过程简单来说就是根据编号将对应的常量填充到类对象中。

第三步,进行Initialization,这里是真正地对类对象进行初始化,特别是静态成员。

类加载过程是在执行某方法(如main方法)之前执行的,类加载的时候会进行静态代码块的执行,想要创建实例,必然先得类加载,静态代码块只会执行一次,构造方法与普通代码块每次实例对象都会执行,并且普通代码块比静态代码块先执行。

所以可以得到结论,静态的代码块,普通代码块,构造方法执行顺序为:静态的代码块->普通代码块->构造方法。

2.2双亲委派模型

双亲委派模型是类加载中的一个环节,属于Loading阶段,它是描述如何根据类的全限定名找到class文件的过程。

在JVM里面提供了一组专门的对象,用来进行类的加载,即类加载器,当然既然双亲委派模型是类加载中的一部分,所以其所描述找.class文件的过程也是类加载器来负责的。

但是想要找全class文件可不容易,毕竟.class文件可能在jdk目录里面,可能在项目的目录里面,还可能在其他特定的位置,因此JVM提供了多个类加载器,每一个类加载器负责在一个片区里面找,毕竟分工明确,才能事半功倍。

默认的类加载器主要有三个:

  • BootStrapClassLoader,负责加载标准库里面的类,如String,Random,Scanner等。
  • ExtensionClassLoader,负责加载JDK扩展的类,现在基本上很少使用了。
  • ApplicationClassLoader,负责加载当前项目目录中的类。

除了默认的几个类加载器,程序员还可以自定义类加载器,来加载其他目录的类,如Tomcat就自定义了类加载器,用来专门加载webapps目录中的.class文件,但是自定义的类加载器未必要遵守双亲委派模型,毕竟你在自己特定的目录下还没有找到对应的.class文件,再去标准库去找基本上也是未果,Tomcat中的自定义的类加载器就没有遵守双亲委派模型。

而双亲委派模型就描述了类加载过程中的找目录的环节,它的内容如下:

如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此。

因此所有的加载请求最终都应该传送到顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载(去自己的片区搜索)。

举两个栗子:第一个,我们要去找标准库里面的String.class文件,它的过程大致如下:

  • 首先ApplicationClassLoader类收到类加载请求,但是它先询问父类加载器是否加载过,即询问ExtensionClassLoader类是否加载过。
  • 如果ExtensionClassLoader类没有加载过,请求就会向上传递到ExtensionClassLoader类,然后同理,询问它的父加载器BootstrapClassLoader是否加载过。
  • 如果BootstrapClassLoader没有加载过,则加载请求就会到BootstrapClassLoader加载器这里,由于BootstrapClassLoader加载器是最顶层的加载器,它就会去标准库进行搜索,看是否有String类,我们知道String是在标准库中的,因此可以找到,请求的加载任务完成,这个过程也就结束了。

1
第二个栗子,我要加载搜索项目目录中的Test类,过程如下:

  • 首先ApplicationClassLoader类收到类加载请求,但是它先询问父类加载器是否加载过,即询问ExtensionClassLoader类是否加载过。
  • 如果ExtensionClassLoader类没有加载过,请求就会向上传递到ExtensionClassLoader类,然后同理,询问它的父加载器BootstrapClassLoader是否加载过。
  • 如果BootstrapClassLoader没有加载过,则加载请求就会到BootstrapClassLoader加载器这里,由于BootstrapClassLoader加载器是最顶层的加载器,它就会去标准库进行搜索,看是否有Test类,我们知道Test类不在标准库,所以会回到子加载器里面搜索。
  • 同理,ExtensionClassLoader加载器也没有Test类,会继续向下,到ApplicationClassLoader加载器中寻找,由于ApplicationClassLoader加载器搜索的就是项目目录,因此可以找到Test类,全过程结束。

当然,如果在ApplicationClassLoader还没有找到,就会抛出异常。
2
总的来说,双亲委派模型就是找class文件的过程,只是名字挺高大上的,其实也没啥。

1
双亲委派模型的优点:

  • 当自定义类与标准库中的类重名时,一定会加载标准库中的那个类,保证了Java的核心API不会被篡改。
  • 使得 Java 类随着它的类加载器一起具有一种带有优先级的层次关系,从而使得基础类得到统一。
  • 避免重复加载类:比如 A 类和 B 类都有一个父类 C 类,那么当 A类 启动时就会将 C 类加载起来,那么在 B 类进行加载时就不需要在重复加载 C 类了。

3.GC垃圾回收机制

代码在执行的过程中,经常需要申请内存,比如new对象,创建变量,加载类等,既然有内存的申请,那自然也应该有内存的回收,在Java中GC垃圾收集器捡起来了这一任务,下面我们来试着来了解GC,内存回收任务有包括但不限于以下几个难点:

  • 哪些内存需要回收?
  • 什么时候回收?
  • 如何回收?

我们将从这三个维度来认识GC垃圾回收机制。

3.1哪些内存需要回收?

3.1.1明白哪些内存需要回收

我们知道我们运行时,内存分为四个区,分别是程序计数器,栈,堆,方法区。
对于程序计数器,它占据固定大小的内存,不涉及释放,那么也就用不到GC;对于栈空间,函数执行完毕,对应的栈帧自动释放了,也不需要GC;对于方法区,主要进行类加载,虽然需要进行"类卸载",此时需要释放内存,但是这个操作的频率是非常低的;最后对于堆空间,经常需要释放内存,是最需要进行GC的。

在堆空间,内存的分布有三种,一是正在使用的内存,二是未使用且未回收的内存,三是未分配的内存,那内存中的对象,也有三种情况,对象内存全部在使用(相当于对象整体全部在使用),对象的内存部分在使用(相当于对象的一部分在使用),对象的内存不使用(对象也就使用完毕了),对于这三类对象,前两类不需要回收,最后一类需要回收。
谁需要回收
所以,垃圾回收的基本单位是对象,而不是字节,对于如何找到垃圾,常用有引用计数法与可达性分析法两种方式。

3.1.2基于引用计数找垃圾(Java不采取该方案)

所谓基于引用计数判断垃圾,就是给每一个对象带上一个计数器,来记录该对象被多少个引用变量所指,如果这个计数器的值为0则表示该对象需要回收,比如有一个Test对象,它被两个引用所指,所以这个Test对象所带计数器的值就是2

//伪代码:
Test t1 = new Test();
Test t2 = t1;

基于计数器

如果上述的伪代码是在一个方法中,待方法执行完毕,方法中的局部引用变量被销毁,那么Test对象的引用计数变为0,此时就会被回收。

由此可见,基于引用计数的方案非常简单高效并且可靠,但是它拥有两个致命缺陷:

  • 当对象的内存大小较小时,由于每个对象都需要一个计数器,因此空间利用率较低。
  • 存在循环引用的问题,会出现对象既不使用也不释放的情况。

第一点好理解,就是当计数器与对象所需内存差不多的情况下,空间利用率只有50%,并不高。
第二点,难理解一点,下面我们来举例子进行分析。

有以下一段伪代码:

class Test {
	Test t = null;
}

//main方法中:
Test t1 = new Test();
Test t2 = new Test();
t1.t = t2;
t2.t = t1;

执行上述伪代码,运行时内存图如下:

1

然后,我们把变量t1t2置为null,伪代码如下:

//伪代码:
t1 = null;
t2 = null;

执行完上面伪代码,运行时内存图如下:
2
根据图可知,两个对象的属性相互指向另一个对象,使得计数器的值都为1,由于对象外界没有指向这两个对象的引用,于是这两个对象处于既不被使用,也不被释放的尴尬场景当中,这就是循环引用问题。

3.1.3基于可达性分析找垃圾(Java采取方案)

所谓可达性分析就是通过额外的线程,对整个内存空间的对象进行扫描。但扫描不是盲扫,先会找到一些起始位置GCRoots,从该位置开始,以类似深度优先搜索的方式去找对象,它会对所有可以找到的对象进行标记,没有找到的对象自然也就没有标记,那这部分有标记的对象是可达的,未被标记的对象是不可达的,未被标记的对象就会被JVM视为垃圾进行回收。
1

对于这个GCRoots,它来自:

  • 在虚拟机栈(栈帧中的本地变量表)中引用的对象,例如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等。
  • 在本地方法栈中JNI(即通常所说的Native方法)引用的对象。
  • 常量池中引用所指向的对象。
  • 方法区中静态成员所指向的对象。
  • 所有被同步锁(synchronized关键字)持有的对象。

3.2什么时候回收?

对于内存回收的时机必须恰到好处,早了不行,打个比方,想象一个情景,今天是KFC的疯狂星期四,你灰常开心,想去KFC吃大餐,于是你去到KFC点了最便宜的套餐,吃到一半,你发现自己肚子不舒服,于是你就跑去卫生间拉稀去了,拉完你神清气爽,当你再次回到你的餐位时,发现你的餐已经被服务员收到垃圾桶了,你非常生气,然后…

在这个场景中你买的套餐就好比你申请了一块内存,但是你还没有使用完就被GC收集器(服务员)给回收了。

那内存回收晚了行不行?我再打一个比方,最典型的栗子就是图书馆占座,你说你去图书馆,发现每一个位置都有书,但就是没有人,你说气不气?

所以内存回收的时机灰常重要,不能早也不能晚,垃圾回收的时机就是通过找垃圾的算法,确定一块内存是垃圾后,就可以回收了。

3.3如何回收?

垃圾回收的算法最常见的有以下几种:

  • 标记-清除算法
  • 标记-复制算法
  • 标记-整理算法
  • 分代回收算法(本质就是综合上述算法,在堆的不同区采取不同的策略)

3.3.1标记-清除算法

标记其实就是可达性分析的过程,在可达性分析的过程中,会标记可达的对象,其他 不可达的对象,都会被视为垃圾进行回收。

比如经过一轮标记后,标记状态如图:
回收前
回收后,内存状态如图:

回收后
我们发现,内存是释放了,但是回收后,未分配的内存空间有点“散”啊,我们知道申请内存的时候得到的内存是连续的,所以说,由于未分配的内存是碎片化的,假设你的主机有1GB为分配的内存,但是这些内存是碎片形式存在的,当申请500MB内存的时候,可能会申请失败,毕竟不能保证有一块大于500MB的连续内存空间,这也是标记-清除算法的缺陷。

3.3.2标记-复制算法

为了解决标记-清除算法所带来的内存碎片化的问题,引入了复制算法。

它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。如果内存中多数对象都是存活的,这种算法将会产生大量的内存间复制的开销,但对于多数对象都是可回收的情况,算法需要复制的就是占少数的存活对象,而且每次都是针对整个半区进行内存回收,分配内存时也就不用考虑有空间碎片的复杂情况。

复制算法的第一步还是要通过可达性分析进行标记,得到那一部分需要进行回收,那一部分需要保留,不能回收。

标记完成后,会将还在使用的内存连续复制到另外一块等大的内存上,这样得到的未分配内存一直都是连续的,而不是碎片化的。

复制回收前
回收后,复制算法得到的未分配空间是连续的,完美地弥补了清除算法的缺陷。
复制回收后
但是,复制算法也有缺陷:

  • 空间利用率低。
  • 如果可回收的内存少,需保留的内存大,复制的开销也大。

3.3.3标记-整理算法

标记-整理算法针对复制算法做出进一步改进。其中的标记过程仍然与“标记-清除”算法一致,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存。

标记回收前的内存图:
整理回收前
然后,将存活对象按照某一顺序(比如从左到右,从上到下的顺序)拷贝到非存活对象的内存区域,得到了以下回收后的内存分布图:
整理回收后
解决了标记-复制算法空间利用率低的问题,但是复制的开销问题并没有得到解决。

3.3.4分代回收

上述的回收算法都有缺陷,分代回收就是将上述三种算法结合起来分区使用,分代回收会针对对象进行分类,以熬过的GC扫描轮数作为“年龄”,然后针对不同年龄采取不同的方案。

我们知道GC主要是回收堆上的无用内存,我们先来了解一下堆的划分,堆包括新生代(Young)、老年代(Old),注意这个名称不是固定的,在IBM J9虚拟机中对应称为婴儿区(Nursery)和长存区(Tenured),名字不同但其含义是一样的。而新生代包括一个伊甸区(Eden)与两个幸存区(Survivor,分代回收算法就会根据不同的代去采取不同的标记-xx算法。

堆
在新生代,包括一个伊甸区与两个幸存区,伊甸区存储的是未经受GC扫描的对象,也就是刚刚创建的对象。

幸存区存储了经过若干轮存储的对象,通过实际经验得出,新生代的对象具有“朝生夕灭”的特点,也就是说只有少部分的伊甸区对象才能熬过第一轮的GC扫描,所以到幸存区的对象相比于伊甸区少的多,正因为大部分新生代的对象熬不过JVM第一轮扫描,所以伊甸区与幸存区的分配比例并不是1:1的关系,HotSpot虚拟机默认一个Eden和一个Survivor的大小比例是8∶1,正因为新生代的存活率较小,所以新生代使用的垃圾回收算法为标记-复制算法最优,毕竟存活率越小,对于标记-复制算法,复制的开销也就很小。

不妨我们将第一个Survivor称为活动空间,第二个Survivor称为空闲空间,一旦发生GC,将10%的活动区间与另外80%中存活的对象复制到10%的空闲空间,接下来,将之前90%的内存全部释放,以此类推。

在后续几轮GC中,幸存区的对象在两个Survivor中进行标记-复制算法。

在继续持续若干轮GC后,幸存区的对象就会被转移到老年代,老年代中都是年龄较老的对象,根据经验,一个对象越老,继续存活的可能性就越大,因此老年代的GC扫描频率远低于新生代,所以老年代采用标记-整理的算法进行内存回收,毕竟老年代存活率高,对于标记-整理算法,复制转移的开销很低。

3.4常见的垃圾收集器

这一部分,我们了解即可,首先有请历史最悠久的Serial收集器(新生代收集器,串行GC)与 Serial Old收集器(老年代收集器,串行GC)登场,这两类收集器前者是新生代收集器,后者是老年代收集器,采用串行GC的方式进行垃圾收集,由于串行GC开销较大,会产生较严重的STW。

STW是什么?
Stop The World (STW),你可以理解为你打游戏的时候,你的xxx来干xxx,使得你不得不中断游戏,这段中断的时间就相当于STW,或者你理解为由于设备原因使得你打游戏很卡,这些卡顿的时间就是STW。

然后就是 ParNew收集器(新生代收集器,并行GC),Parallel Scavenge收集器(新生代收集器,并行GC),Parallel Old收集器(老年代收集器,并行GC),前两个是新生代的收集器,最后一个是老年代的收集器,这组收集器引入了多线程,并发情况下,GC处理效率相比于前一组更高,但是如果在单线程情况下,可能不会比Serial收集器要好,此外,Parallel Scavenge收集器相比于 ParNew收集器只是多了些参数而已。

CMS收集器,该收集器设计的初衷是尽量使得STW时间尽量地短, 特点:

  • 初始标记,过程速度很快,只是找到GCRoots,只会引起短暂的STW。
  • 并发标记,虽然速度很慢,但是它可以和业务线程并发执行,不会产生STW。
  • 重新标记,在并发标记过程中,业务代码可能会改变标记的结果,需要进行一次微调,由于是微调,引起的STW很短。
  • 回收内存,也是和业务代码并发。

前三部分的标记过程就是将可达性分析给拆开了,回收内存主要用于标记-整理,老年代专属。

G1收集器,它把内存分为分成了很多的小区域(Region),并且给这些Region做了标记,有些Region放新生代对象,有些Region放老年代对象。

GC扫描的时候,只扫描一部分Region,不追求一次扫描完,分多次来扫描,这样对业务代码执行影响更小。

G1收集器可以优化STW的时间小于1ms。本质上CMS与G1都是化整为零的思想。

最后,做个小总结,垃圾回收本质靠运行时环境,来帮助程序员完成内存释放的工作,但是它有以下缺点:

  • 产生额外的开销。
  • 可能会影响程序的流畅运行(STW造成)。
【版权声明】本文为华为云社区用户原创内容,转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息, 否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。