当前位置: 首页 > news >正文

JVM 机制

目录

一、什么是 JVM:

二、JVM 的运行流程:

三、JVM 内存区域划分:

1、( 1 )  程序计数器:

1、( 2 )  元数据区:

1、( 3 )  栈:

1、( 4 )  堆:

四、类加载:

1、什么时候会触发类加载:

2、类加载过程:

2、( 1 )  加载:

2、( 2 )  验证:

2、( 3 )  准备:

2、( 4 )  解析:

2、( 5 )  初始化:

五、双亲委派模型:

六、垃圾回收机制:

1、判断 “垃圾” 的方式:

1、( 1 )  引用计数:

1、( 2 )  可达性分析:

2、如何回收 “ 垃圾 ” :

2、( 1 )  标记 - 清除算法:

2、( 2 )  复制算法:

2、( 3 )  标记 - 整理算法:

2、( 4 )  分代回收(综合方案):


一、什么是 JVM:

        “JVM ”全称 java 虚拟机 ,它提供了 java 代码程序运行的环境,有一个很重要的特性就是 “一次编写,到处运行” ,意思是,一段相同的 java 代码,不需要修改代码相关逻辑就可以跨平台运行。

        JVM 更准确的说,是 java 语言的“ 运行时环境” ,为什么这么说?因为 JVM 就是把 java 源代码,翻译成 cpu 能够识别的机器指令。而其他的一些编程语言(比如C++),源代码编译出来的就已经是标准的 cpu 能够执行的指令了,但这也给这些语言(比如C++)带来了一些问题,就是代码不能够跨平台。

        我们所说的跨平台,指的是同一个代码程序,可以直接放在不同的操作系统或者不同架构的 cpu 上运行。而类似 C++ 这些编程语言,不同的操作系统或者不同架构的 cpu,需要针对不同的平台单独编译生成对应的机器指令,因为不同的操作系统,提供的 api 是不同的;不同架构的 cpu,指令集是不兼容的。这些情况导致 C++ 编程语言同一段源代码无法跨平台运行。

二、JVM 的运行流程:

         java 这种编程语言,因为有 JVM 的存在,可以实现跨平台运行,大致流程:

        首先,开发人员编写的 java 代码文件( .java ),通过编译器(javac)转换成字节码文件( .class ),JVM 读取字节码文件,转换成机器指令(二进制指令)由 cpu 执行

        在此过程中,可以看到,JVM 相当于充当一个 “翻译官” 的作用,JVM 里有多个不同系统 api ,不同架构的 cpu 框架指令集,根据具体底层的平台,同一段java源代码可以翻译出对应的机器指令,使其得以运行。

        到这里,可以体现出来,java 代码虽然多了一次翻译,运行效率比C++低了,但是换来的是可以跨平台,这大大提高了开发效率,是值得的。

        

        

        

三、JVM 内存区域划分:

        java 程序跑起来,得到了 java 进程,这就需要从操作系统申请内存来运行进程了,为了提高 java 进程的运行效率和安全性,JVM 把这些内存区域划分为 程序计数器 ,元数据区 ,栈 ,堆 。

1、( 1 )  程序计数器:

         在操作系统中,线程是 cpu 的调度的基本执行单位;进程是系统资源分配的基本单位。而 程序计数器(分配给的内存空间比较小,仅用于存储当前线程正在执行的字节码指令地址),每个线程都会有一个

        在单核 cpu 的环境下,Thread创建的线程是并发执行的,也就是一个线程执行一段时间后,又切换到另一个线程执行,通过不断的切换,让我们感觉是 “同时进行”的。那么, 当切换回来的的线程,怎么知道这个执行线程上一次执行到哪个位置了?还有就是当遇到循环了,怎么程序怎么回到循环起始的位置?

        每段代码都有对应的字节码地址,上面我们说到,每个线程独立拥有一个程序计数器,程序计数器的一个作用就是记录当前线程正在执行的字节码指令地址。相当于 “保存上下文位置” 。 另一个作用就是跳转运行的指令地址,也就是当程序执行遇到 if ,while ,for ,方法调用,异常处理等这些情况,不再是默认的顺序执行了,而是跳转到对应的指令地址继续执行

        综上所述程序计数器记录的就是“程序下一步执行的位置”。

1、( 2 )  元数据区:

        元数据区主要的作用是存储类的结构信息和类的静态具体内容(静态变量、静态常量(final),静态方法、静态代码块类的结构信息比如继承关系(这个类继承了哪个类),实现的接口(这个类实现的接口),类的属性(属性的名字,类型......),类实现的方法(方法名字,参数列表,返回值...)。

        这里的信息和内容要注意理解,内容是整个,信息是描述。

1、( 3 )  栈:

         栈这个内存区域保存了方法的调用关系,这里的栈类似于“数据结构的栈”,也就是元素先进后出的特性。且每个线程都会有一个独立的栈

        

        在 jvm 中所说的栈,把这里栈每个元素称为 “栈帧” ,每个栈帧表示一次方法调用,比如最底下的A栈帧调用了栈帧B,也就是我们常说的方法A里面调用了方法B,每个栈帧包含的数据有方法的参数,方法的局部变量,方法执行结束后要返回的结果,方法结束后要跳转回的地址。

        当栈帧执行完(方法结束 / return)了,对应的栈帧就会销毁。所以,当我们写出死递归的代码的时候,就会出现栈溢出的情况,就是栈帧满了。

1、( 4 )  堆:

       通过 new 关键字创建出来的对象就在堆里。包含了其对象的实例,成员属性及其对应的值。

        一张图理解对应关系:

        

总结(整体占内存空间大小关系 ):       

四、类加载:

        类加载,就是把字节码文件(最初存放在硬盘的 .class 文件)加载到内存中变成能使用的 Class 对象类

        但是,不是程序开始运行就加载全部的 .class ,而是要用上了对应的类,才会开始加载。且一个类只会加载一次,下面具体分析。

1、什么时候会触发类加载:

当:

        构造某个类的实例。

        调用类的静态方法(类似比如 main)或静态成员变量。

        当创建子类的实例时,如果父类尚未被加载,则会先触发父类的加载。

2、类加载过程:

类加载分为五个时机,下面具体分析:

2、( 1 )  加载:

        根据代码中编写的 “全限定类名” ,找到对应的 .class 文件(找的过程也叫“双亲委派模型”)。这里的全限定类名,就是包名 + 类名,比如我的代码中有个类是 Test,这个类在 Demo 包里,那么全限定类名是Demo.Test 。

        再打开文件,把读取的 .class(二进制数据)解析成对应的类的具体内容到内存中。由于 .class 二进制数据是严格按照规范排列的,包含多个部分,每个部分对应着类的具体内容(类的名字,类的字段,类继承的哪个类,实现的接口......),jvm会按顺序解析其中的每个部分,并将其转换成内存的类结构。

// 伪代码:解析类结构
class ClassFile {int magic;                 // 魔数(前 4 字节是0xCAFEBABE)int version;               // 版本号(决定了 JVM 能否处理该文件)ConstantPool constantPool; // 常量池(存储类中使用的所有常量,包括类名、方法名、字段名等。)int accessFlags;           // 访问标志(如public、final)int thisClass;             // 类索引int superClass;            // 父类索引// ... 字段、方法、属性表 ...
}

        这里的魔数,指的是二进制文件的前四个字节,这四个字节作是标识当前的二进制文件是什么后缀的(常见的有.exe / .png / .class / .mp4 / .mp3/......)。

2、( 2 )  验证:

        上述的 .class 文件解析完后,再去验证是否合法如果在验证的过程中,发现某个格式存在问题(魔数错误,jvm版本不兼容......),就会抛出异常,并提醒告知开发人员。

2、( 3 )  准备:

         验证完后,就可以给类对象结构信息和类中静态成员变量分配空间,并把静态成员变量初始化为 0 (放在元数据区)。此处申请的内存空间,是一个 “未初始化” 的内存空间(内存上每个字节都是 “0”)。

2、( 4 )  解析:

        准备完之后,将代码中的 “名字”(符号引用)翻译成 “内存地址”(直接引用),也就是对代码中的常量进行赋值,放到内存中。

2、( 5 )  初始化:

        解析完之后,就进入真正开始执行类中编写的 java 程序代码,将主导权交给应用程序,负责执行静态代码块,将静态成员变量初始化实际值(赋值)。

        如果当前的类继承的父类 / 接口还没有被加载,会重复这五个步骤初始化父类 / 接口。

五、双亲委派模型:

        上面类加载讲到,双亲委派模型在第一个阶段(加载)的时候出现,即根据类的全限定类名找到对应的 .class 文件的过程。

        JVM 中进行类加载的时候,需要依赖内部的模块,即 “类加载器” 。而 JVM 中自带了三种类加载器。        

        以上的三个类加载器之间,存在逻辑上的 “ 父子关系 ” ,而不是真正意义上的父子 / 继承关系,注意区别。

三个加载器之间有这么一个关系:

绿色代表他们的关系,从上到下可以把他们看作 “爷爷” ,“爸爸” ,“儿子” 。

        当一个类要加载的时候,首先进入 “儿子 ”这个加载器,加载器会自身检查这个类有没有加载过,如果这个类已经被加载过了,直接返回如果这个类还没有被加载,则 “ 儿子” 这个加载器会委托给 “父亲” 这个加载器,再检查,再委托“爷爷” 这个加载器,后面再自上往下搜索对应目录库尝试加载。(结合上图理解)

        值得注意的是,java 自带的类(比如String这个类,他所在的包名是 java.lang,全限定类名是java.lang.String)是在“爷爷” 这个加载器完成的;而我们自己写的类,一般是在 “儿子” 这个加载器完成的。

六、垃圾回收机制:

        垃圾回收(gc)是一种自动内存管理机制,其核心作用是自动识别并回收不再使用的对象所占用的内存空间。要注意的是,触发 gc 的时候,JVM 会暂停所有线程的执行,仅让 GC 线程运行,可以类似理解为其他线程为 gc 这个线程让步而停止工作。、

        那么 gc 回收的内存区域主要是哪里?主要是回收 这个内存区域,因为堆是保存对象实例的区域,当一个对象之后的代码不再涉及了,就会被回收,并且是以对象为维度的回收。

        那么,上诉的对象被判定为 “垃圾” ,通俗来说是之后的代码不再涉及了,更准确的说应该是对象是否可达性(判断是否有引用指向这个对象)。下面讲述两种方式。但 java 只涉及一种方式。

1、判断 “垃圾” 的方式:

1、( 1 )  引用计数:

        先说明,这个方式 java 并没有采用,但是其他的计算机语言(python...)涉及到。主要理解其中的设计思想。

        这个引用计数的方式,就是给每个对象实例包含一个计数器(需要占用内存),当有新引用指向这个对象时,计数器 +1 ,引用失效(把当前引用置为 null )计数器 -1,当计数器为 0 时,对象就立即被回收。

        上述的方式,会存在两个问题,第一个问题就是计数器本身占用内存空间,当如果创建的对象很小的时候,计数器会占用其相当一部分空间。第二个问题就是,会出现循环引用的问题。

class Test {Test t;
}Test a = new Test( );
Test b = new Test( );a.t = b;
b.t = a;a = null;
b= null;

        比如上面的这段代码,当创建出来的的两个实例,每个实例都有对应的计数器 ,栈上也保存了分别指向他们的引用,计数器当前都是 1 ,执行当执行 a.t = b 和 b.t = a 后,两个实例中的 t 字段也分别保存了对方的引用,此时双方的计数器都变成了 2 ,当执行 a = null 和 b = null ,此时栈区指向他们的引用都没了,此时双方的计数器都变成了 1。到这里,此时外部已经没有可以使用的引用执行双方了,此时应该被销毁才对,但是销毁不了,因为计数器还是 1 。

        上图可以看到,双方的对象实例里有指向对方的引用,但是使用不了。此时就是构成了循环引用问题。

1、( 2 )  可达性分析:

这种方案,就是 java 使用的方案 。

        这种方案的核心思想是,通过确立根对象作为起点,遍历所有的引用访问对应的对象,访问到的对象被标记为 “存活” ,未被访问到对象被视为 “垃圾” 。

        上述的根对象,可以是栈上的局部变量(引用类型);常量池里指向的对象(final 修饰的引用类型);元数据区(静态成员的引用类型)。 JVM 就会以他们作为根开始访问。

        举两个例子,比如这个代码:

class Test {Test t;
}Test a = new Test( );
Test b = new Test( );a.t = b;
b.t = a;a = null;
b= null;

        之前我们讲到,这个代码使用引用计数的方式会出现循环引用的问题。但是,在可达性分析的方法里,尽管两个对象有字段互相引用对方的实例,但是由于他们栈中的 a 和 b 引用(作为根)变量断开。此时这两个实例就访问不到,就会被回收。

        另一个例子,类似于二叉树,根节点作为根对象,当某一个节点被置为 null 了,那么即使通过根节点依次遍历,这个节点的所有子节点都无法被访问到,就会被回收。

2、如何回收 “ 垃圾 ” :

通过上面的可达性分析识别出垃圾后,就可以进行回收操作了,有下面四种回收垃圾算法

2、( 1 )  标记 - 清除算法:

        标记,也就是上面的可达性分析找到 “ 垃圾 ” 的过程,清除,也就是直接释放这部分“垃圾”的内存空间。但是,这种方式会存在 内存碎片 的问题。

        上图中,每个方块代表 1MB 的内存空间,红色代表被释放的内存空间,而白色的代表还正在使用的内存空间,此时,当我们想要申请 2 MB 的内存就会申请失败,因为申请内存时都是需要一段连续的内存空间的。

2、( 2 )  复制算法:

        这个方式,将内存区域划分为两个大小相等区域,首先,使用的时候,一边是正在使用的内存,另一边的内存是空闲的,通过一次 gc 后,正在使用的内存的存活的对象会被全部复制到另一边空闲的内存而这一边就全部释放。

        这种方式,很好的解决了内存碎片的问题,但是,有两个很大的弊端,那就是可使用的内存只有一半,并且如果存活的对象比较多 / 比较大,复制的开销是很大的。

2、( 3 )  标记 - 整理算法:

        当标记出 “垃圾” 后,通过类似于顺序表删除元素的操作把后面存活的对象,往前搬到前面释放出来的空间(往前靠)

        这种方式,可以解决内存碎片的问题,也可以避免复制算法带来内存浪费,但是,这种方式也存在一个明显的弊端,那就是移动对象需额外计算位置的开销。

2、( 4 )  分代回收(综合方案):

这个方案,是把前面的几个方案,扬长避短,综合起来的方案。

        这个方案的核心思想,是把内存区域划分为新生代老年代,这两个区域的 gc 频率是不一样的,新生代区域的频率较高老年代的 gc 频率较低

        当一些对象刚开始创建出来,存放在伊甸区,通过一次 gc 后,通过复制算法,存活的对象进入其中一个幸存区。原来的伊甸区清空。

        期间可能有新创建的对象进入了伊甸区,后面继续 gc ,再通过复制算法,伊甸区存活的对象和幸存区存活的对象进入另一个幸存区原来的幸存区和伊甸区清空。

         当 gc 到一定程度后(15次 gc )依旧存活的对象,通过复制算法,进入老年代区,此时老年代区的对象经过这么多次 gc 都依旧存活,我们就认为其生命周期以后大概率也会很长,所以 gc 的频率变低。或者如果创建的这个对象很大,就不太适合复制算法了,直接进入老年代区。

        其中,新生代区采用的是复制算法老年代区采用的是标记 - 清理 标记 - 整理算法

相关文章:

  • 【论文阅读】人脸修复(face restoration ) 不同先验代表算法整理
  • Adobe Illustrator学习备忘
  • 单细胞转录组(4)Cell Ranger
  • 项目管理学习-CSPM-4考试总结
  • vscode用python开发maya联动调试设置
  • Redis 数据类型与操作完全指南
  • 开源语音-文本基础模型和全双工语音对话框架 Moshi 介绍
  • 【Redis】List 列表
  • 谈谈未来iOS越狱或巨魔是否会消失
  • Redis的Hot Key自动发现与处理方案?Redis大Key(Big Key)的优化策略?Redis内存碎片率高的原因及解决方案?
  • 计算机网络(1)——概述
  • Redis——缓存雪崩、击穿、穿透
  • WSL 安装 Debian 12 后,如何安装图形界面 X11 ?
  • 手撕四种常用设计模式(工厂,策略,代理,单例)
  • sudo apt update是什么意思呢?
  • STM32F10xx 参考手册
  • 从零开始理解Jetty:轻量级Java服务器的入门指南
  • JavaScript入门【2】语法基础
  • MATLAB学习笔记(六):MATLAB数学建模
  • Redis Sentinel如何实现高可用?
  • 雅安市纪委监委回应黄杨钿甜耳环事件:相关政府部门正在处理
  • 东部沿海大省浙江,为何盯上内河航运?
  • 多个“首次”!上市公司重大资产重组新规落地
  • 福州一宋代古墓被指沦为露天厕所,仓山区博物馆:已设置围挡
  • 竞彩湃|欧联杯决赛前,曼联、热刺继续划水?
  • 美国关税压力下,日本经济一年来首次萎缩