JVM【类加载与GC垃圾回收机制】
JVM【类加载与GC垃圾回收机制】
🍒1.1JVM简介
JVM 是 Java Virtual Machine 的简称,意为 Java虚拟机
虚拟机是指通过软件模拟的具有完整硬件功能的、运行在一个完全隔离的环境中的完整计算机系统
常见的虚拟机:JVM、VMwave、Virtual Box
JVM 和其他两个虚拟机的区别:
- VMwave与VirtualBox是通过软件模拟物理CPU的指令集,物理系统中会有很多的寄存器
- JVM则是通过软件模拟Java字节码的指令集,JVM中只是主要保留了PC寄存器,其他的寄存器都进行了裁剪
JVM 是一台被定制过的现实当中不存在的计算机
🍒1.2JVM执行流程
程序在执行之前先要把java代码转换成字节码(class文件)
,JVM 首先需要把字节码通过一定的方式类加载器(ClassLoader)
把文件加载到内存中 运行时数据区(Runtime Data Area)
,而字节码文件是 JVM 的一套指令集规范,并不能直接交个底层操作系统去执行,因此需要特定的命令解析器 执行引擎(Execution Engine)
将字节码翻译成底层系统指令再交由CPU去执行,而这个过程中需要调用其他语言的接口 本地库接口(Native Interface)
来实现整个程序的功能,这就是这4个主要组成部分的职责与功能
🍒2.1 程序计数器(线程私有)
程序计数器的作用:用来记录当前线程执行的行号的,用来存储下一条指令的地址
程序计数器是一块比较小的内存空间,可以看做是当前线程所执行的字节码的行号指示器。如果当前线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是一个Native方法,这个计数器值为空
总结:
程序计数器:内存最小的一块区域,保存了下一条要执行的指令地址在哪里,与书签类似
什么是线程私有?
由于JVM的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现,因此在任何一个确定的时刻,一个处理器(多核处理器则指的是一个内核)都只会执行一条线程中的指令。因此为了切换线程后能恢复到正确的执行位置,每条线程都需要独立的程序计数器
,各条线程之间计数器互不影响,独立存储,我们就把类似这类区域称之为"线程私有"的内存
🍒2.2 栈(线程私有)
● Java虚拟机栈(线程私有)
Java 虚拟机栈的作用:Java 虚拟机栈的生命周期和线程相同,Java 虚拟机栈描述的是 Java 方法执行的
内存模型:每个方法在执行的同时都会创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法出口等信息,咱们常说的堆内存、栈内存中,栈内存指的就是虚拟机栈
Java 虚拟机栈中包含了以下 4 部分:
- 局部变量表: 存放了编译器可知的各种基本数据类型(8大基本数据类型)、对象引用。局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在帧中分配多大的局部变量空间是完全确定的,在执行期间不会改变局部变量表大小。简单来说就是
存放方法参数和局部变量
- 操作栈:每个方法会生成一个先进后出的操作栈。
- 动态链接:指向运行时常量池的方法引用。
- 方法返回地址:PC 寄存器的地址
关于虚拟机栈会产生的两种异常:
● 如果线程请求的栈深度大于虚拟机所允许的最大深度,会抛出StackOverFlow异常
● 如果虚拟机在拓展栈时无法申请到足够的内存空间,则会抛出OOM异常
出现StackOverflowError异常时有错误堆栈可以阅读,比较好找到问题所在。如果使用虚拟机默认参数,栈深度在多多数情况下达到1000-2000完全没问题,对于正常的方法调用(包括递归),完全够用
如果是因为多线程导致的内存溢出问题,在不能减少线程数的情况下,只能减少最大堆和减少栈容量的方式来换取更多线程
●本地方法栈
本地方法栈和虚拟机栈类似,只不过 Java 虚拟机栈是给 JVM 使用的,而本地方法栈是给本地方法使用的
🍒2.3 堆(线程共享)
堆:储存对象以及对象的成员变量,一个进程只有一个,多个线程共用一个堆,内存中空间最大的区域,我们看到下图对堆做了细分,Java堆是垃圾收集器管理的内存区域,所以后介绍GC的时候我们细说
🍒2.4 方法区(线程共享)
方法区:存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据
,即就是储存“类对象”,被static修饰的变量或方法就成了类属性,.java文件会被编译成.class文件,.class会被加载到内存中,也就被JVM构造成类对象了,这个加载的过程叫做类加载,类对象描述了类的信息,如类名,类有哪些成员,每个成员叫什么名字,权限是什么,方法名等
所以可以得到结论,静态的代码块,普通代码块,构造方法执行顺序为:静态的代码块->普通代码块->构造方法
🍒3.1类加载过程
对于一个类来说,它的生命周期是这样的:
其中前 5 步是固定的顺序并且也是类加载的过程,其中中间的 3 步我们都属于连接,所以对于类加载来
说总共分为以下几个步骤:
1. 加载(Loading)2. 连接(Linking) .验证 .准备 .解析3. 初始化(Initialization)
下面我们分别来看每个步骤的具体执行内容
(1) 加载(Loading)
“加载”(Loading)阶段是整个“类加载”(Class Loading)过程中的一个阶段,它和类加载 ClassLoading 是不同的,一个是加载 Loading 另一个是类加载 Class Loading,所以不要把二者搞混了。在加载 Loading 阶段,Java虚拟机需要完成以下三件事情:
● 通过一个类的全限定名来获取定义此类的二进制字节流。
● 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
● 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口
(2) 验证
主要就是验证读取到的内容是不是和规范中规定的格式完全匹配,如果不匹配,就会类加载失败,并且会抛出异常验证是连接阶段的第一步,这一阶段的目的是确保Class文件的字节 流中包含的信息符合《Java虚拟机规范》的全部约束要求,保证这些信 息被当作代码运行后不会危害虚拟机自身的安全验证选项:文件格式验证字节码验证符号引用验证...
(3) 准备
准备阶段是正式为类中定义的变量(即静态变量,被static修饰的变量)分配内存并设置类变量初始值的阶段。比如此时有这样一行代码:public static int value = 123;它是初始化 value 的 int 值为 0,而非 123。
(4) 解析
.class文件中,常量是集中放置的(常量池),并且每一个常量都有一个编号,.class文件中的结构体初始情况下它只记录了常量的编号,解析过程简单来说就是根据编号将对应的常量填充到类对象中
(5) 初始化(Initialization)
这里是真正地对类对象进行初始化,特别是静态成员类加载过程是在执行某方法(如main方法)之前执行的,类加载的时候会进行静态代码块的执行,想要创建实例,必然先得类加载,静态代码块只会执行一次,构造方法与普通代码块每次实例对象都会执行,并且普通代码块比静态代码块先执行
常见笔试题
所以可以得到结论,静态的代码块,普通代码块,构造方法执行顺序为:静态的代码块->普通代码块->构造方法
class A{ public A(){ System.out.println("这是A的构造方法"); } { System.out.println("这是A的代码块"); } static { System.out.println("这是A的静态代码块"); } public void fun(){ System.out.println("方法A"); }}class B extends A { public B() { System.out.println("这是B的构造方法"); } { System.out.println("这是B的代码块"); } static { System.out.println("这是B的静态代码块"); }}public class Test extends B{ public static void main(String[] args) { new Test(); new Test(); int s = 10; System.out.println(s); }}
🍒3.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是在标准库中的,因此可以找到,请求的加载任务完成,这个过程也就结束了
第二个例子,我要加载搜索项目目录中的Test类,过程如下:
首先ApplicationClassLoader
类收到类加载请求,但是它先询问父类加载器是否加载过,即询问ExtensionClassLoader
类是否加载过。
如果ExtensionClassLoader
类没有加载过,请求就会向上传递到BootStrapClassLoader
类,然后同理,如果BootstrapClassLoader
没有加载过,则加载请求就会到BootstrapClassLoader
加载器这里,由于BootstrapClassLoader加载器是最顶层的加载器,它就会去标准库进行搜索,看是否有Test类,我们知道Test类不在标准库,所以会回到子加载器里面搜索。
同理,ExtensionClassLoader加载器也没有Test类,会继续向下,到ApplicationClassLoader加载器中寻找,由于ApplicationClassLoader加载器搜索的就是项目目录,因此可以找到Test类,全过程结束。
当然,如果在ApplicationClassLoader还没有找到,就会抛出异常
双亲委派模型的优点:
● 当自定义类与标准库中的类重名时,一定会加载标准库中的那个类,保证了Java的核心API不会被篡改
● 使得 Java 类随着它的类加载器一起具有一种带有优先级的层次关系,从而使得基础类得到统一
● 避免重复加载类:比如 A 类和 B 类都有一个父类 C 类,那么当 A类 启动时就会将 C 类加载起来,那么在 B 类进行加载时就不需要在重复加载 C 类了
上面讲了Java运行时内存的各个区域。对于程序计数器、虚拟机栈、本地方法栈这三部分区域而言,其生命周期与相关线程有关,随线程而生,随线程而灭
。并且这三个区域的内存分配与回收具有确定性,因为当方法结束或者线程结束时,内存就自然跟着线程回收了。因此我们本节课所讲的有关内存分配和回收关注的为Java堆与方法区这两个区域
Java堆中存放着几乎所有的对象实例,垃圾回收器在对堆进行垃圾回收前,首先要判断这些对象哪些还存活,哪些已经"死去"。判断对象是否已"死"有如下几种算法
● 引用计数算法
● 可达性分析算法
🍒4.1 死亡对象的判断方法
在堆空间,内存的分布有三种,一是正在使用的内存,二是未使用且未回收的内存,三是未分配的内存,那内存中的对象,也有三种情况,对象内存全部在使用(相当于对象整体全部在使用),对象的内存部分在使用(相当于对象的一部分在使用),对象的内存不使用(对象也就使用完毕了),对于这三类对象,前两类不需要回收,最后一类需要回收
🍉4.1.1引用计数算法(python采取该方案)
引用计数描述的算法为:
给对象增加一个引用计数器,每当有一个地方引用它时,计数器就+1;当引用失效时,计数器就-1;任何时刻计数器为0的对象就是不能再被使用的,即对象已"死"。
引用计数法实现简单,判定效率也比较高,在大部分情况下都是一个不错的算法。比如Python语言就采用引用计数法进行内存管理
但是,在主流的JVM中没有选用引用计数法来管理内存,最主要的原因就是引用计数法无法解决对象的循环引用问题会导致最终内存泄漏
class D{ D d = null;}public class Test1 { public static void main(String[] args) { D a = new D(); D b = new D(); a = b.d; b = a.d; a = null; b = null; }}
两个对象的属性相互指向另一个对象,使得计数器的值都为1,由于对象外界没有指向这两个对象的引用,于是这两个对象处于既不被使用,也不被释放的尴尬场景当中,这就是循环引用问题
🍉4.1.1可达性分析算法(Java采取该方案)
在上面我们讲了,Java并不采用引用计数法来判断对象是否已"死",而采用"可达性分析"来判断对象是否存活(同样采用此法的还有C#、Lisp-最早的一门采用动态内存分配的语言)。
此算法的核心思想为 : 通过一系列称为"GC Roots"的对象作为起始点,从这些节点开始向下搜索,搜索走过的路径称之为"引用链",当一个对象到GC Roots没有任何的引用链相连时(从GC Roots到这个对象不可达)时,证明此对象是不可用的
在Java语言中,可作为GC Roots的对象包含下面几种:
- 虚拟机栈(栈帧中的本地变量表)中引用的对象;
- 方法区中类静态属性引用的对象;
- 方法区中常量引用的对象;
- 本地方法栈中 JNI(Native方法)引用的对象
从上面我们可以看出“引用”的功能,除了最早我们使用它(引用)来查找对象,现在我们还可以使用“引用”来判断死亡对象了。所以在 JDK1.2 时,Java 对引用的概念做了扩充,将引用分为强引用(StrongReference)、软引用(Soft Reference)、弱引用(Weak Reference)和虚引用(Phantom Reference)四种,这四种引用的强度依次递减
🍒4.2垃圾回收相关算法
垃圾回收的算法最常见的有以下几种:
● 标记-清除算法
● 标记-复制算法
● 标记-整理算法
● 分代回收算法(本质就是综合上述算法,在堆的不同区采取不同的策略)
🍉4.2.1 标记-清除算法
标记其实就是可达性分析的过程,在可达性分析的过程中,会标记可达的对象,其他 不可达的对象,都会被视为垃圾进行回收
标记-清除算法的不足主要有两个 :
- 效率问题 : 标记和清除这两个过程的效率都不高
- 空间问题 : 标记清除后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行中
需要分配较大对象时,无法找到足够连续内存而不得不提前触发另一次垃圾收集,因为在我们申请内存的时候时需要申请连续的内存,这时使用标记清除算法就会导致在回收的内存都是"内存碎片",
比如经过一轮标记后,标记状态如图:
🍉4.2.2 标记-复制算法(幸存区)
为了解决标记-清除算法所带来的内存碎片化的问题,引入了复制算法。
它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。如果内存中多数对象都是存活的,这种算法将会产生大量的内存间复制的开销,但对于多数对象都是可回收的情况,算法需要复制的就是占少数的存活对象,而且每次都是针对整个半区进行内存回收,分配内存时也就不用考虑有空间碎片的复杂情况。
复制算法的第一步还是要通过可达性分析进行标记,得到那一部分需要进行回收,那一部分需要保留,不能回收。
标记完成后,会将还在使用的内存连续复制到另外一块等大的内存上,这样得到的未分配内存一直都是连续的,而不是碎片化的
但是,复制算法也有缺陷:
● 空间利用率低。
● 如果可回收的内存少,需保留的内存大,复制的开销也大
🍉4.2.3 标记-整理算法(老年区)
复制收集算法在对象存活率较高时会进行比较多的复制操作,效率会变低。因此在老年代一般不能使用复制算法。
针对老年代的特点,提出了一种称之为"标记-整理算法"。标记过程仍与"标记-清除"过程一致,但后续步骤不是直接对可回收对象进行清理,而是让所有存活对象都向一端移动,然后直接清理掉端边界以外的内存。
解决了标记-复制算法空间利用率低的问题,但是复制的开销问题并没有得到解决
流程图如下
🍉4.2.4 分代回收算法
上述的回收算法都有缺陷,分代回收就是将上述三种算法结合起来分区使用,分代回收会针对对象进行分类,以熬过的GC扫描轮数作为“年龄”,然后针对不同年龄采取不同的方案
在新生代,包括一个伊甸区与两个幸存区,伊甸区存储的是未经受GC扫描的对象,也就是刚刚创建的对象。
幸存区存储了经过若干轮存储的对象,通过实际经验得出,新生代的对象具有“朝生夕灭”的特点,也就是说只有少部分的伊甸区对象才能熬过第一轮的GC扫描,所以到幸存区的对象相比于伊甸区少的多,正因为大部分新生代的对象熬不过JVM第一轮扫描,所以伊甸区与幸存区的分配比例并不是1:1的关系,HotSpot虚拟机默认一个Eden和一个Survivor的大小比例是8∶1,正因为新生代的存活率较小,所以新生代使用的垃圾回收算法为标记-复制算法最优,毕竟存活率越小,对于标记-复制算法,复制的开销也就很小。
不妨我们将第一个Survivor称为活动空间,第二个Survivor称为空闲空间,一旦发生GC,将10%的活动区间与另外80%中存活的对象复制到10%的空闲空间,接下来,将之前90%的内存全部释放,以此类推。
在后续几轮GC中,幸存区的对象在两个Survivor中进行标记-复制算法。
在继续持续若干轮GC后,幸存区的对象就会被转移到老年代,老年代中都是年龄较老的对象,根据经验,一个对象越老,继续存活的可能性就越大,因此老年代的GC扫描频率远低于新生代,所以老年代采用标记-整理的算法进行内存回收,毕竟老年代存活率高,对于标记-整理算法,复制转移的开销很低
🍒常见垃圾收集器
这一部分,我们了解即可,首先有请历史最悠久的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造成)
来源地址:https://blog.csdn.net/m0_59735420/article/details/127902353
免责声明:
① 本站未注明“稿件来源”的信息均来自网络整理。其文字、图片和音视频稿件的所属权归原作者所有。本站收集整理出于非商业性的教育和科研之目的,并不意味着本站赞同其观点或证实其内容的真实性。仅作为临时的测试数据,供内部测试之用。本站并未授权任何人以任何方式主动获取本站任何信息。
② 本站未注明“稿件来源”的临时测试数据将在测试完成后最终做删除处理。有问题或投稿请发送至: 邮箱/279061341@qq.com QQ/279061341