言简意赅JVM核心


1. JVM 运行流程

JVM 是 Java 运行的基础,也是实现一次编译到处执行的关键
那么 JVM 是如何执行的呢?

JVM 执行流程

程序在执行之前先要把java代码转换成字节码(class文件),JVM 首先需要把字节码通过一定的方式类加载器(ClassLoader) 把文件加载到内存中 运行时数据区(Runtime Data Area) ,而字节码文件是 JVM 的一套指令集规范,并不能直接交个底层操作系统去执行,因此需要特定的命令解析器 执行引擎(Execution Engine)将字节码翻译成底层系统指令再交由CPU去执行,而这个过程中需要调用其他语言的接口 本地库接口(Native Interface) 来实现整个程序的功能,这就是这4个主要组成部分的职责与功能。

在这里插入图片描述
总结:JVM 主要通过分为以下 4 个部分,来执行 Java 程序。

  1. 类加载器(ClassLoader)
  2. 运行时数据区(Runtime Data Area)
  3. 执行引擎(Execution Engine)
  4. 本地库接口(Native Interface)

2.JVM 运行时数据区

什么是运行时数据区(就是我们java运行时的东西是放在那里的)

JVM 运行时数据区域也叫内存布局,但需要注意的是它和 Java 内存模型((Java Memory Model,简称 JMM)完全不同,属于完全不同的两个概念,它由以下 5 大部分组成:
在这里插入图片描述

2.1 堆(线程共享)

堆的作用:程序中创建的所有对象都在保存在堆中。 在虚拟机启动时创建。

我们常见的 JVM 参数设置 -Xms10m 最小启动内存是针对堆的,-Xmx10m 最大运行内存也是针对堆的。

ms 是 memory start 简称,mx 是 memory max 的简称。

堆里面分为两个区域:新生代和老生代,新生代放新建的对象,当经过一定 GC 次数之后还存活的对象会放入老生代。新生代还有 3 个区域:一个 Endn + 两个 Survivor(S0/S1)。

垃圾回收的时候会将 Endn 中存活的对象放到一个未使用的 Survivor 中,并把当前的 Endn 和正在使用 的 Survivor 清除掉。(后续会详细介绍)

局部变量 栈上
成员变量 堆上
静态变量 方法区

2.2 Java虚拟机栈(线程私有)

什么是线程私有?

由于JVM的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现,因此在任何一个确定的时刻,一个处理器(多核处理器则指的是一个内核)都只会执行一条线程中的指令。因此为了切换线程后能恢复到正确的执行位置,每条线程都需要独立的程序计数器,各条线程之间计数器互不影响,独立存储。我们就把类似这类区域称之为"线程私有"的内存。

Java 虚拟机栈的作用:

  1. Java 虚拟机栈的生命周期和线程相同
  2. Java 虚拟机栈描述的是 Java 方法执行的内存模型:每个方法在执行的同时都会创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法出口等信息。

咱们常说的堆内存、栈内存中,栈内存指的就是虚拟机栈。

Java 虚拟机栈中包含了以下 4 部分:
在这里插入图片描述

栈帧: 每虚拟机栈中是有单位的,单位就是栈帧,一个方法一个栈帧。一个栈帧中他又要存储局部变量表,操作数栈,动态链接,出口等。

局部变量表:是存放了编译器可知的各种基本数据类型(8大基本数据类型)、对象引用。局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在帧中分配多大的局部变量空间是完全确定的,在执行期间不会改变局部变量表大小。简单来说就是存放方法参数和局部变量。
操作数栈:每个方法会生成一个先进后出的操作栈。操作数栈就是用来存储操作数的,例如代码中有个x=6*6,先读取代码,进行计算后放入局部变量表中。
动态链接:指向运行时常量池的方法引用。
方法返回地址:PC 寄存器的地址。

思考

一个方法调用另一个方法,会创建栈帧吗?
:会创建。如果一个栈中有动态链接调用别的方法,就会去创建新的栈帧,栈中是由顺序的,一个栈帧调用另一个栈帧,另一个栈帧就会排在调用者下面

栈指向堆是什么意思?
:栈指向堆是什么意思,就是栈中要使用成员变量怎么办,栈中不会存储成员变量,只会存储一个应用地址指向堆中数据。
递归的调用自己会创建很多栈帧吗?
:递归的话也会创建多个栈帧,递归太深会出现堆栈溢出。

递归函数调用的太深,需要太多的内存,递归里用到的局部变量存储在堆栈中,堆栈的访问效率高,速度快,但空间有限,递归太多变量需要一直入栈而不出栈,导致需要的内存空间大于堆栈的空间。

2.3 本地方法栈(线程私有)

本地方法栈和虚拟机栈类似,只不过 Java 虚拟机栈是给 JVM 使用的,而本地方法栈是给本地方法使用的。

只不过方法上带了 native 关键字的栈。

native关键字的方法是看不到的,必须要去oracle官网去下载才可以看的到,而且native关键字修饰的大部分源码都是C和C++的代码。同理可得,本地方法栈中就是C和C++的代码。

2.4 程序计数器(线程私有)

程序计数器的作用:保存当前线程所正在执行的字节码指令的地址(行号)

程序计数器内存区域是虚拟机中唯一没有规定OutOfMemoryError情况的区域。

  • 如果当前线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;
  • 如果正在执行的是一个Native方法,这个计数器值为空。

在java中最小的执行单位是线程,线程是要执行指令的,执行的指令最终操作的就是我们的电脑,就是 CPU。在CPU上面去运行,有个非常不稳定的因素,叫做调度策略,这个调度策略是时基于时间片的,也就是当前的这一纳秒是分配给那个指令的。

线程是最小的执行单位,他不具备记忆功能,他只负责去干,那这个记忆就由程序计数器来记录,继续执行上一次执行的地方。

2.5 方法区(线程共享)

方法区的作用:用来存储被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据的。

JDK 8 中将字符串常量池移动到了堆中。

在《Java虚拟机规范中》把此区域称之为“方法区”,而在 HotSpot 虚拟机的实现中,在 JDK 7 时此区域
叫做永久代(PermGen),JDK 8 中叫做元空间(Metaspace)。

运行时常量池

运行时常量池是方法区的一部分,存放字面量与符号引用
字面量 : 字符串(JDK 8 移动到堆中) 、final常量、基本数据类型的值
符号引用 : 类和结构的完全限定名、字段的名称和描述符、方法的名称和描述符

小结

在这里插入图片描述

3. 类加载

3.1 类加载是什么

Java程序在运行之前,需要先编译
.java => .class文件(二进制字节码文件)
运行的时候,java进程(JVM)就会读取对应的 .class 文件,并且解析保存,在内存中构造出类对象并进行初始化。

类从文件加载到内存中

反射,jackson,synchronized都用到类对象
类对象描述了这个类的信息,也是创建实例的具体依据
有哪些属性(属性名字,类型,private/public)
有哪些方法(方法名称,参数个数,参数类型,返回值类型,private/public)
继承自哪个父类,实现哪些接口

3.2 类加载大体过程

对于一个类来说,它的生命周期是这样的
在这里插入图片描述

术语都是出自java官方文档(Java语言规范&JVM规范)
Java® 虚拟机规范 Java SE 8 版

  1. 加载
    找到 .class 文件,读取文件内容,并且按照 .class 规范的格式来解析
    在加载 Loading 阶段,Java虚拟机需要完成以下三件事情:
    1)通过一个类的全限定名来获取定义此类的二进制字节流。
    2)将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
    3)在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。
  2. 连接
  • 验证:检查当前 .class里的内容格式是否符合要求

验证是连接阶段的第一步,这一阶段的目的是确保Class文件的字节 流中包含的信息符合《Java虚拟机规范》的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全。

在这里插入图片描述

描述了.class二进制格式是什么
这个东西可以理解为C的结构体(JVM是基于C++实现的)使用结构体方式来标识是常见的作法。
u4 四个字节的无符号整数 u2 两个字节的无符号整数
magic 二进制文件必备属性,魔幻数字(魔数)
magic number 存在的目的是为了区分当前二进制文件是哪种格式.(后缀名不能作为参考)
当前Java已经有很多版本了,最新的已经是Java19 这些版本的.class文件格式是略有差别的
在Java19上编译得到的 .class 放到 Java 8上是运行不了的,不兼容了

  • 准备:给类中的静态变量分配内存空间并设置类变量初始值的阶段,这些内存都将在方法区中进行分配。

static int a = 123;
准备阶段就是给a分配内存空间(4个字节)同时这些内存空间初始情况是全0了

  • 解析:初始化字符串常量,把符号引用替换成直接引用。

符号引用 :占位符 直接引用 : 内存地址
.class 文件里会包含很多字符串常量(代码中也会有很多地方用到字符串常量)
String s = "hello";
在类加载之前,”hello“这个字符串常量是没有分配内存空间的(等到类加载完之后才有内存空间)
没有内存空间,s里也就无法保存字符串常量的真实地址,只能先使用应该占位符标记一下 ,等到真正给”hello"分配内存空间之后,就用这个真正地址代替占位符

  1. 初始化:针对类进行初始化,初始化静态成员,执行静态代码块,并且加载父类。Java 虚拟机真正开始执行类中编写的 Java 程序代码,将主导权移交给应用程序。

3.3 何时触发类加载

使用到一个类的时候,就会触发加载

类并不是程序一启动就加载,第一次使用才加载(类似于懒汉模式)

  1. 创建这个类的实例
  2. 使用类的静态方法/静态属性
  3. 使用类的子类(加载子类会触发加载父类)

3.4 类加载器的介绍

JVM加载类是由类加载器(class loader)模块负责的。JVM自带了多个类加载器(包括程序猿也可以自实现,暂时不考虑)

主要包括这三个类加载器,各自负责一个各自的片区(负责各自的一组目录)

  1. Bootstrap ClassLoader 负责加载标准库中的类
  2. Extension ClassLoader 负责加载JVM扩展的库中的类
  3. Application ClassLoader 负责加载自己项目里的自定义类
    在这里插入图片描述

3.5 双亲委派模型

双亲委派模型本身是类加载中不太重要的环节

类加载重要的环节:
解析 .class
校验.class
构造.class对象

什么是双亲委派模型?

如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到最顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去完成加载。

描述下述类加载器相互配合的工作过程就是双亲委派模型
在这里插入图片描述在这里插入图片描述

  1. 上述三个类加载器存在父子关系
  2. 进行类加载的时候,输入的内容 全限定类名,如java.lang.Thread
  3. 加载的时候,从Application ClassLoader开始
  4. 某个类加载器开始加载的时候,不会立即扫描自己负责的路径,而是先把任务委派给 父 类加载器
  5. 找到最上面的Bootstrap ClassLoader再往上就没有 父 类加载器了,就只能自己加载了
  6. 如果父亲没有找到类,就交给自己的儿子继续加载
  7. 如果一直找到最下面的Application ClassLoader也没有找到类,就会抛出一个异常,类没找到 java.lang.ClassNotFoundException

优点:

  1. 按这个顺序加载的最大好处在于如果程序员写了一个类,正好全限定类名和标准库中的类名冲突了,此时仍然保证类加载可以加载到标准库的类,防止代码加载错,带来问题,保证了 Java 的核心 API 不被篡改。

比如自己写了个类叫做java.lang.Thread

  1. 避免重复加载类:比如 A 类和 B 类都有一个父类 C 类,那么当 A 启动时就会将 C 类加载起来,那么在 B 类进行加载时就不需要在重复加载 C 类了。

双亲???
双亲=父亲+母亲
这里的双亲委派模型是典型的机翻,英文术语parent,翻译成双亲
更好的翻译是”单亲委派模型“,”父类委派模型“

4. GC 垃圾回收机制

学C语言的时候,创建内存有两种方式:

  1. 直接定义变量,变量就对应内存空间,内存释放的时机是确定的,出了作用域就释放了。
  2. malloc申请内存(动态内存申请),务必需要通过free来进行释放。

手动释放的最大问题在于容易忘了,导致内存泄漏

GC 垃圾回收机制,程序员只需要负责申请内存,释放内存的工作,交给JVM来完成。

使用GC最大的问题在于引入额外的开销(时间+空间)
空间:消耗额外的CPU/内存等资源
时间:GC中最大的问题,STW问题(Stop The World)

STW问题好比,有一天你在玩游戏,马上胜利了,妈妈过来打扫卫生,让你”起来,抬脚“。在你起来的时候,就无法操作游戏,妈妈慢吞吞扫完后,继续操作,结果发现可能也就Game Over了。
反应在用户这里,就会出现明显卡顿的现象。

开发效率 > 运行效率

4.1 GC回收哪部分内容

JVM中主要内存分成堆,方法区,栈,程序计数器。
栈的释放时机确定,不必回收;
程序计数器的内存空间固定,不必回收;
方法区中的类对象加载之后也不太会卸载。

所以,GC主要就是针对堆来回收
在这里插入图片描述

GC中回收内存,是以对象为单位回收,不是以字节为单位。不存在一个对象回收一半内存。

4.2 具体怎么回收

  1. 先找出垃圾
  2. 再回收垃圾(释放内存)

这里的怎么找和怎么回收,还要一系列比较复杂的策略。

4.3 怎么找垃圾(如何判定某个对象是否是垃圾)

如果一个对象再也不用了,就说明是垃圾。

通过引用来判断当前对象是否还能被使用,没有引用指向就视为是无法被使用。

在Java中,对象的使用需要凭借引用。假设如果有一个对象,已经没有任何引用能指向它,这个对象自然就无法再被使用了。

两种典型判断对象是否存在引用的办法:

引用计数(不是JVM采取的办法)

Python,PHP用这个

给每个对象都加上个计数器,这个计数器表示当前对象有几个引用。

每次多一个引用指向该对象,计数器+1
每次少一个引用指向该对象,计数器-1
当引用计数器数值为0的时候,就说明当前这个对象已经无人能够使用了,此时就可以进行释放了。

Test a = new Test();
Test b = a;
在这里插入图片描述
优点:简单,容易实现;执行效率比较高
缺点:空间利用率比较低,尤其是小对象;可能会出现循环引用的情况。
例如:
在这里插入图片描述
此时虽然这两个对象的引用计数为1,但是实际上是两个对象相互引用。此时外界的代码仍然是无法访问和使用对象的,但是由于引用计数没有成为0,这两个对象是无法进行释放的。

可达性分析(JVM采取的方法)

同样采用此法的还有C#、Lisp-最早的一门采用动态内存分配的语言

核心思想:约定一些特定的变量,成为"GC roots”。每隔一段时间,从GC roots出发,进行遍历,看看当前变量是否能够被访问到。能被访问到的变量就称为“可达”,否则就是“不可达”。

GC roots:
虚拟机栈(栈帧中的本地变量表)中引用的对象
常量值引用的对象
方法区中引用类型的静态变量
本地方法栈中 JNI(Native方法)引用的对象

每一组都有一些变量,每个变量都视为起点,从这些起点出发,尽可能遍历,就能够找到所有访问到的对象了。

例如
在这里插入图片描述
root.right.right=null;
一旦断开,F就不可达,F就是个垃圾了。
root.right=null;
此时C不可达,C就是个垃圾了。同时,F是通过C来访问的,C不可达,F也就不可达了,F也是个垃圾了。

在这里插入图片描述

4.4 垃圾回收算法

  1. 标记清除
  2. 复制算法
  3. 标记整理
  4. 分代回收

标记清除算法

标记出垃圾后,直接把对象对应的内存空间进行释放。
在这里插入图片描述
"标记-清除"算法的问题主要有两个 :

  1. 效率问题 : 标记和清除这两个过程的效率都不高
  2. 空间问题 : 标记清除后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行中 需要分配较大对象时,无法找到足够连续内存而不得不提前触发另一次垃圾收集。

复制算法

针对内存碎片问题,引入的办法

将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这块内存需要进行垃圾回收时,会将此区域还存活着的对象复制到另一块上面,然后再把已经使用过的内存区域一次清理掉。
在这里插入图片描述

"复制"算法的问题主要有两个 :

  1. 空间利用率更低(用一半,丢一半)
  2. 如果一轮GC下来,大部分对象要保留,只有少数对象要回收,这个时候复制的开销就很大。

标记整理算法

复制算法在对象存活率较高时会进行比较多的复制操作,效率会变低。因此在老年代一般不能使用复制算法。

针对老年代的特点,提出了一种称之为"标记-整理算法"。标记过程仍与"标记-清除"过程一致,但后续步骤不是直接对可回收对象进行清理,而是让所有存活对象都向一端移动,然后直接清理掉端边界以外的内存。

类似于顺序表删除元素,搬运操作。

流程图如下:
在这里插入图片描述
"标记整理"算法的问题主要是搬运操作比较耗时

分代算法

上述三个方法都不是尽善尽美,就需要根据实际的场景,因地制宜的解决问题。“分代回收"策略把上述办法综合了一下,根据对象不同特点,采取不同的回收方法。

分代算法是通过区域划分,实现不同区域和不同的垃圾回收策略。从而实现更好的垃圾回收。这就好比中国的一国两制方针一样,对于不同的情况和地域设置更符合当地的规则,从而实现更好的管理,这就时分代算法的设计思想。

当前 JVM 垃圾收集都采用的是"分代收集(Generational Collection)"算法,这个算法并没有新思想,只是根据对象存活周期的不同将内存划分为几块。一般是把Java堆分为新生代和老年代。在新生代中,每次垃圾回收都有大批对象死去,只有少量存活,因此我们采用复制算法;而老年代中对象存活率高、没有额外空间对它进行分配担保,就必须采用"标记-清理"或者"标记-整理"算法。

根据GC轮次来计算。有一组线程,周期性的扫描代码里所有的对象。如果一个对象经历了一次GC没有被回收,就认为年龄+1

一个基本的经验规律如果一个对象寿命比较长,大概率还会存活的更久。

  • 新生代:一般创建的对象都会进入新生代;
  • 老年代:大对象和经历了 N 次垃圾回收依然存活下来的对象会从新生代移动到老年代。

在这里插入图片描述
上述规则还有一个特殊情况。如果对象是一个非常大的对象,则直接进入老年代。这是因为大对象进行复制算法开销太大。

总结

在这里插入图片描述

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值