【操作系统】JVM详细介绍
五大组成部分
程序计数器
经过编译的.java文件就变为了.class文件.也就是变成了字节码的形式.
当程序要运行的时候,就将字节码指令加载到内存中,为了让CPU可以正常的运行每一个指令,就需要有一个程序计数器,来进行表示要执行的指令,当前指令执行完毕之后,程序计数器自动加1,CPU就按照这里面的值执行这个指令,并且可以通过这个计数器来实现分支,循环,跳转等功能.
另外,因为CPU是多线程并发执行的,所以对于每一个进程都会有一个程序计数器来标识当前执行到那里了,所以是线程独立的,不会互相干扰.它的生命周期随着进程的消失而消失
java虚拟机栈
对java来说,一个类里面,要么是成员变量,要不是成员方法.成员变量主要保存在堆中,而成员方法的一些信息都是保存在栈中的.
每次调用这个方法的时候,就会形成一个栈帧保存在java的虚拟机栈中.
这个栈帧中又保存下面四种信息:
**局部变量表:**保存这个方法中出现的所有的局部变量
操作数栈:计算的临时变量和中间变量
动态连接:用于当前方法调用其他方法的时候
方法返回地址:也是PC寄存器里面保存的地址
因为这个也是属于每一个单独的线程的**,所以这个java虚拟机栈也是线程私有的**
而且,有一个要注意的点就是这个栈是有大小的.如果出现递归死循环的话,就会栈溢出,也就是StackOverflow
本地方法栈
和java虚拟机一样,都是保存方法的栈,只不过java虚拟机栈保存的是本地的native方法,也是线程私有的,每一个线程都有一个
上面的三个都是属于线程私有的,每一个线程都分配给它们这三个部分:
堆
堆是线程共有的,堆是虚拟机中最大的部分,保存的是new出来的对象和成员变量
方法区
方法区里面保存的是类对象.
.class文件被加载到内存内之后,就被jvm加载为类对象(这个过程也叫做类加载)
这个对象详细描述了这个类的各种信息:
类信息:名字
字段信息,方法信息
常量,静态变量.其中静态变量被称为类变量
类加载过程
类加载主要分为三步:加载(loading) 连接(linking) 初始化(initialing)
其中连接又分为三步:验证,准备,解析
加载(loading)
加载主要做下面几个动作:
通过全类名获取定义此类的二进制字节流
就是通过类名找到指定的.class文件
将字节流所代表的静态存储结构转换为方法区的运行时数据结构
在内存中生成一个代表该类的
Class
对象,作为方法区这些数据的访问入口
这个加载的过程就是通过类名获取到.class文件,然后根据.class文件构造出一个class对象的过程
读取class文件中的内容,并将这些内容赋值给类对象
连接(linking)
验证,准备,解析
验证
在类加载的时候,我们读取classFile,这个时候为了保证我们读取的内容是正确的,就使用验证来证明.
如果读到的数据的格式不符合规划,就会类加载失败,抛出异常
准备
给静态变量分配内存,注意只是开辟内存,没有初始化,只是将它的内容设为0
解析
.class文件中保存的类,类方法,类接口等都是符号引用,没有真正存放它们真正的值,而是存的它们的符号.
解析的过程就是通过常量池,将符号引用都替换为直接引用
初始化(initializing)
将静态变量进行初始化,真正的进行赋值
上面只是类加载的过程,也就是对每一个类来说,要先进行类加载,然后才可以执行里面的函数
而且类加载是最先执行的,而且只会加载一次
面试题
class A {
public A() {
System . out . println("A的构造方法");
}
{
System . out . println("A的构造代码块”);
}
static {
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();
}
- 首先会先加载main函数所在的类也就是Test类,而且这个类加载只会加载一次
- 因为Test类使用到了B类(实例化,调用方法,继承),所以就还有加载B类,B类继承了A类,所以就要加载A类
- 在A的类加载阶段,执行static静态代码,进行打印
- 在B的类加载阶段,执行static的静态代码,进行打印
- 所有的类加载完之后,进入main函数中,执行new Test()
- 要想要构造Test类,就要构造B类,要构造B类,就要构造A类.
- 构造A类的时候,先构造代码块,在构造构造方法,构造B类的时候同理
- 第二次new Test()的时候,还会执行一次构造的过程,
A的静态代码块
B的静态代码块----------类加载过程
A的构造代码块
A的构造方法
B的构造代码块
B的构造方法----------第一次new Test()
A的构造代码块
A的构造方法
B的构造代码块B
的构造方法-----------第二次new Test()
双亲委派模型
这个主要是用于类加载的加载过程(loading),就是找到.class文件然后加载的详细过程,就是由双亲委派模型.
这个模型中主要有三个类加载器:
BootStrapClassLoader 主要加载java的标准库中的类
ExtensionClassLoader 主要加载JDK扩展的类,不常用
ApplicationClassLoader 主要加载自己实现的类
用户自己也可以自定义类
当一个类进入类加载过程时:先判断它是否被加载过,如果已经被加载过了,就直接返回,不然的话:
先到ApplicationClassLoader类,然后由它的父亲类ExtensionClassLoader交给最上层的BootStrapClassLoader类.
然后从这个最长层的BootStrapClassLoader判断这个类是不是标准类,如果是的话,就直接加载,如果不是,交给它的子类ExtensionClassLoader进行加载,如果这个类是扩展类的话,就直接加载,如果不是还是交给子类ApplicationClassLoader,由它进行加载
这个过程就是先从底到顶先提交,然后再从顶到下进行处理
作用:
如果我们设计了一个类,这个类名和标准库相同的类名相同,那么这个类不会被成功的加载,因为上先进行Bootstrap类加载,然后再进行application类加载,还没到这一步呢,就已经别最长面的那一层当中标准库的类加载了.
垃圾回收机制
垃圾回收就是回收那些已经不再使用的对象,但是它也是由缺点的:
1.jvm会消耗较多的资源
2.在程序的运行过程中会可能出现STW的问题
主要是对堆中进行垃圾回收
怎么找到要回收的垃圾
引用计数
第一种方法就是对每一个开辟的对象,都存一个计数器,来记录一下当前有哪几个引用它对对象
void fun(){
Test t1=new A();
Test t2=t1;
}
当执行这个fun函数的时候,有2个引用指向这个新创建的A对象.
但是如果fun函数执行完毕,就它的指向就是0了,这样我们就可以将它视为垃圾,可以被回收了
缺点:
计数器会占用较多的资源,这个计数器一般占用4个字节,但是有的类还不到4个字节呢,所以会占用内存中较多的资源
会存在循环引用的情况
class Test{ Test test=null; } Test t1=new Test(); Test t2=new Test();
上面很好理解新new出来的两个对象,各自都是只有一个引用
t1.t=t2 t2.t=t1
现在是双方互指先对方,所以现在都是2个引用了
t1=null; t2=null;
现在t1和t2都不指向这两个对象了,但是它们互相指向,这样引用计数的值就一直不为0,虽然它已经没有任何价值了,不可以被任何人访问到,应该被回收.但是在计数值不是0,所以这样就造成了内存泄露的情况
可达性分析
这个就是java使用的机制
通过额外的线程,定期的对所有的对象进行扫描,从一个Gcroots开始遍历,将所有的变量都标记一遍,不可到达的就不标记
GCRoots:
栈的局部变量
常量池中引用指向的对象
方法区中静态变量指向的对象
方法区中常量引用的对象
这样,那些没有被标记的就是不可以到达的,就是垃圾
优点:
- 解决了空间消耗过大的问题
- 没有循环引用的问题
缺点:
时间复杂度大,浪费较多的时间去判断
删除垃圾
标记-删除
直接将没有标记的删除,就是直接释放内存.
缺点:
因为要回收的对象不是均匀分布的,所以删除的也是分散的,这样会造成大量的内存碎片.
但是如果要开辟内存的话,都是连续开辟的,这样就会浪费大量的内存
复制算法
将内存分为两部分,一个是用来开辟内存的,一个是用来复制不删除的对象的.
对于要删除的对象,将所有的未标记的对象复制到复制区域,然后将开辟内存的所有内存都删掉
缺点:
- 内存的使用率低,因为被开辟成开辟区域,一个是复制区域
- 如果要删除的对象少,保留的多,那么会增加时间复杂度
标记-整理
标记要删除的对象后,针对那些对象,就像删除数组中的元素一样,
1 2 3 4 5
. .
1 3 5
就像删除数组中的元素一样
缺点:
时间复杂度高
优点:
空间利用率高
无内存碎片
分代回收
- 堆内存为两个区:新生代和老年代;
- 新生代默认占堆内存的 1/3,老年代默认占堆内存的 2/3;
- 新生代又分为Eden区、Survivor From区、Survivor To区默认比例是 8:1:1;
- 工作过程:
- 所有新创建的对象都在Eden区,当Eden区内存满后将Eden区+Survivor From区存活的对象复制到Survivor To区;
- 清空Eden区与Survivor From区;
- 同时Survivor From与Survivor To分区进行交换;
- 每次Minor GC存活对象年龄加1,当年龄达到15(默认值)岁时,被移到老年代;
- 当Eden的空间无法容纳新创建的对象时,这些对象直接被移至老年代;
- 当老年代空间占用达到阈值时,触发Major GC;
- 以上流程循环执行。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-BbWkFxNo-1657706506448)(C:\Users\19625\AppData\Roaming\Typora\typora-user-images\image-20220713165626111.png)]
通常情况来说,不是单一使用上面的一个方法,而是使用多个方法一起.
最新开辟的对象都是放在伊甸区
经过一轮GC之后,还存留的经过复制算法到幸存区.
有人可能会疑问?伊甸区那么大,幸存区那么小,怎么复制下呢?
其实,大部分对象经过一轮的GC就已经被删除了,剩余存活的经过复制算法到达幸存区
当eden区和第一个幸存区全部被清空之后,幸存区1和幸存区2进行交换,保证幸存区2一直是空的
4.当它们的年龄**(被GC的次数)大于一定的值之后**,就会进入老年区,这个区里面的GC次数比较少,因为可以进入这个区域的存活率比较高,另外内存占比比较大的对象也放在老年区.因为放在前面复制的话时间复杂度比较大.另外eden区如果放不下,也会进入老年区.引入放在前面复制的话时间复杂度比较大.这个区域采用标记和整理的方法
垃圾回收器
上面的都是理论,垃圾回收器是应用.
为什么有这么多的垃圾回收器
因为 不同的场景应用不同的垃圾回收器,
Serial 收集器 复制算法
Serial Old收集器 整理标记
串型收集,回收的时候,业务代码也要停止
ParNew收集器 --并发收集,多线程版本 复制算法
Parallel scavenge收集器 -多线程,高吞吐量 复制算法
Parallel Old收集器 老年代 整理标记
并发收集,多线程版本,可以同时进行业务代码
CMS
标记清除
尽量让STW的时间比较少
1 初始标记,只是为了找到GCRoots,只会短暂的引起STW
2 并发标记 虽然速度是慢的,但是和业务代码是并发的,不会引起STW
3 重新标记 因为第二步进行了业务代码,所以对标记进行调整
4 回收内存 和业务代码并发,不会引起STW
G1
全区域的收集器,对每一个区域进行不同的扫描,一次扫描多个区域,从Java11之后
GC的版本
部分收集 (Partial GC):
- 新生代收集(Minor GC / Young GC):只对新生代进行垃圾收集;
- 老年代收集(Major GC / Old GC):只对老年代进行垃圾收集。需要注意的是 Major GC 在有的语境中也用于指代整堆收集;
- 混合收集(Mixed GC):对整个新生代和部分老年代进行垃圾收集。