写点什么

JVM 虚拟机,基础原理总结

作者:知了一笑
  • 2022 年 4 月 15 日
  • 本文字数:5405 字

    阅读完需:约 18 分钟

JVM虚拟机,基础原理总结

一、虚拟机简介

1、虚拟机概念

虚拟机(Virtual Machine)指通过软件模拟的具有完整硬件系统功能的、运行在一个完全隔离环境中的完整计算机系统。在实体计算机中能够完成的工作在虚拟机中都能够实现。在计算机中创建虚拟机时,需要将实体机的部分硬盘和内存容量作为虚拟机的硬盘和内存容量。每个虚拟机都有独立的 CMOS、硬盘和操作系统,可以像使用实体机一样对虚拟机进行操作。

2、JVM 虚拟机

JVM 是 Java-Virtual-Machine 的缩写,即 Java 虚拟机,JVM 是一种用于计算设备的规范,它是一个虚构出来的计算机,是通过在实际的计算机上仿真模拟各种计算机功能来实现的。

3、JVM 特点


首先一次编译处处运行是学习 Java 语言都知道的事情,其实并不是 Java 语言跨平台,是 JVM 跨平台,Jvm 运行时并不是执行 Java 文件,而是执行编译后的.class 文件。


字节码


字节码文件即 JVM 可以识别并执行的二进制文件,不同的编程语言经过编译器编译处理之后,转换成统一的字节码规范文件,这样 JVM 就可以执行。


跨平台


跨平台的特性即 JVM 虚拟机可以运行在不同的计算机系统上,例如经常使用的 Linux 系统,MacOS 系统,Win 系统,一次编译,处处运行就是这样理解的。


跨语言


随着 JVM 的不断发展和优化,很多语言都借助 JVM 的能力,各种编程语言经过编译,转换为字节码文件,JVM 都可以识别,这也是现在 Java 体系下业务编程经常混语言的原因。


注意:现在和后续 Jvm 系列文章都是基于 HotSpot-VM 和 JDK1.8+版本的基础之上。

二、虚拟机结构

Jvm 的整体结构大致如下:


1、类加载器

类加载器用来加载 Java 类到 JVM 虚拟机中,源代码程序.java 文件在经过编译器编译之后就被转换成字节代码.class 文件,类加载器负责读取字节代码,并转换成 java.lang.Class 类的一个实例。

2、运行时数据区

元数据区


JDK1.8 开始的说法,之前称为方法区 Method-Area,存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。


堆区


所有线程共享的一块内存区域,虚拟机启动时被创建用来存放对象实例。


JVM 栈


可以参考了解栈的数据结构,存放 Java 方法执行的内存模型,在 Java 开发中,一个功能实现需要多个子程序方法配合,程序执行时跳往子程序前,会将下个指令的地址存到堆栈中,直到子程序执行完后再将地址取出,退回到原来的程序中。


本地方法栈


本地方法栈和虚拟机栈的功能类似,为 JVM 调用 native 方法时服务。


程序计数器


相对较小的一块内存空间,作用可以理解是当前线程所执行的字节码的行号指示器。

3、执行引擎

Java 虚拟机最核心的组成部分,输入的是字节码,处理过程是字节码解析,输出执行结果。

三、生命周期

这里说的 JVM 生命周期,指 JVM 执行 Java 程序时的周期:


启动初始化:启动时通过引导类加载器创建初始类完成;


程序执行:从 main 方法开始,执行 Java 程序,直到程序执行完结束;


虚拟机退出:程序正常执行结束,或者发生异常、错误等而造成终止,也可以调用 exit 退出方法;

四、HotSpot 虚拟机

HotSpot 是 Java 体系下使用最多的虚拟机,它结合了最新的内存模型,垃圾收集器和自适应优化器,为使用许多先进技术的 Java 应用程序提供了最佳性能。


主要原因:使用多,大部分的 Java 运行环境都依赖 HotSpot 虚拟机。

五、类加载简介

类的加载机制是指把编译后的.class 类文件的二进制数据读取到内存中,并为之创建一个 java.lang.Class 对象,用来封装类在元数据空间的数据结构。



类在 JVM 中的生命周期为:加载,连接,初始化,使用,卸载。不过这里只重点描述加载,连接,初始化这三个过程。

六、加载过程

基于一张图看类加载子系统的细节流程:


1、加载阶段

过程描述


加载阶段需要完成以下三个过程:


  • 通过类的全限定名来获取其定义的二进制字节流;

  • 将字节流所代表的静态存储结构转化为云数据空间的运行时数据结构;

  • 在堆 Heap 中生成一个代表这个类的 java.lang.Class 对象,作为对元数据空间中这些数据的访问入口;


类加载器


  • 引导类加载器


Bootstrap-ClassLoader 基于 C/C++实现,负责加载 Java 的核心类库 JAVA_HOME\jre\lib\rt.jar,该加载器不继承自 ClassLoader 抽象类,并且只加载包名为 java、javax、sun 等开头类,一次保证对核心源码的保护。


  • 扩展类加载器


Extension-ClassLoader,基于 Java 语言,由 sun.misc.Launcher$ExtClassLoader 实现,派生于 ClassLoader 抽象类,从 java.ext.dirs 系统变量指定的路径中的加载类库,或者 JDK 安装目录 jre\lib\ext 目录下加载。


  • 系统类加载器


Application-ClassLoader,基于 Java 语言,由 sun.misc.Launcher$ExtClassLoader 实现,它负责加载环境变量 ClassPath 指定的类库,如果在应用程序中没有自定义类加载器,一般情况下作为程序中默认的类加载器。

2、连接阶段

验证


目的在于确保 Class 文件的字节流中包含的信息符合当前虚拟机的要求,保证加载类的正确性,不会危害虚拟机自身的安全,主要包括四种检验动作:


  • 文件格式验证:验证字节流是否符合 Class 文件格式的规范;

  • 元数据验证:确保其描述的信息符合 Java 语言规范的要求;

  • 字节码验证:确定程序语义是符合逻辑的;

  • 符号引用验证:确保解析动作能正确执行。


准备


为类的静态变量分配内存,并初始化为默认值,这时候进行内存分配的仅包括类变量(static)修饰,不包括(final-static)修饰的,这里也不会为实例变量分配初始化,实例变量会随着对象一块分配到 Java 堆中。


解析


将常量池中的符号引用转换为直接引用的过程,直接引用就是直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄。解析主要针对类或接口、字段、类方法、接口方法、方法类型等,解析的动作实际是会随着 JVM 在执行完初始化之后再执行的。

3、初始化阶段

执行类构造器 clinit()方法的过程,该方法不需要自定义,是 javac 编译器自动收集类中的所有类变量的赋值动作和静态代码块中的语句合并而来,Jvm 要保证 clinit()方法在多线程访问下的安全性。

七、机制策略

1、双亲委派模式


类加载器收到了类加载的请求时,不会自己先去尝试加载这个类,而是把请求委托给父加载器去执行;


如果父加载器还存在父类加载器,则依次向上委托,因此类加载请求最终都应该被传递到顶层的启动类加载器中;


如果父类加载器可以完成类加载请求,就直接成功返回,只有当父加载器在无法完成该加载,子加载器才会尝试自己去加载该类;

2、沙箱安全机制

假设自定义一个类名为 String 且所在包为 java.lang,在使用引导类加载器加载时会先加载 JDK 中的 String 类,因为这个类本来是属于 jdk 的,后面再次出现 String 类就会报错,以此保证源代码不被恶意篡改,这就是沙箱安全机制。

八、内存与线程

1、内存结构

内存是计算机的重要部件之一,它是外存与 CPU 进行沟通的桥梁,计算机中所有程序的运行都在内存中进行,内存性能的强弱影响计算机整体发挥的水平。JVM 的内存结构规定 Java 程序在执行时内存的申请、划分、使用、回收的管理策略,通说来说 JVM 的内存管理指运行时数据区这一大块的管理。


2、线程运行

JVM 中一个应用是可以有多个线程并行执行,线程被一对一映射为服务所在操作系统线程,调度在可用的 CPU 上执行,启动时会创建一个操作系统线程;当该线程终止时,这个操作系统线程也会被回收。



在虚拟机启动运行时,会创建多个线程,数据区中有的模块是线程共享的,有的是线程私有的:



线程共享:元数据区、堆 Heap;


线程私有:虚拟机栈、本地方法栈、程序计数器;


单个 CPU 在特定时刻只能执行一个线程,所以多线程通过几块空间的使用,然后不断的争抢 CPU 的执行时间段。

九、元数据空间

基本描述


方法元空间(方法区)在 JVM 启动的时候被创建,是被各个线程共享的内存空间,用于存放类和方法的元数据以及常量池,比如 Class 和 Method。


在实际的开发中,经常因为加载的类太多,进而导致内存溢出问题,这样可以对元空间的大小进行扩展。


与堆的关系



元空间存放加载的类信息,当类被实例化时,堆中存储实例化的对象信息,并且通过对象类型数据的指针找到类。

十、堆空间

基本描述


JVM 启动时创建堆区,是内存管理的核心区,通常情况下也是最大的内存空间,是被所有线程共享的,几乎所有的对象实例都要在堆中分配内存,所以这里也是垃圾回收的重点空间。


堆栈关系



栈是 JVM 运行时的单位,堆是存储单位,当栈中方法结束,相关对象失去所有引用后,不会马上被移除堆空间,要等到垃圾收集器运行的时候。

十一、虚拟机栈

虚拟机栈(Java 栈)在每个线程创建时都会生成一个虚拟机栈,栈的内部是一个个栈帧单元,对应 Java 方法的调用,其生命周期和线程周期保持一致。用来存储方法的局部遍历,部分执行结果,方法的调用和返回。



栈帧是方法执行的数据集,维持执行过程中的各种数据信息,执行的方法依次入栈,栈顶存放当前要执行的方法,执行结束后出栈,对于栈没有垃圾回收问题。

十二、程序计数器

基本描述


JVM 中程序计数寄存器用来存储下一条将要执行指令的地址,执行引擎获取到指令后进行执行,是线程私有的。它可以看作是当前线程所执行的字节码的行号指示器。



前后关系


线程在获取 CPU 的时间段内执行代码,但是线程随时可能没有执行完就被挂起,等到线程 A 再次获取 CPU 执行时,CPU 得知道执行到线程 A 的哪一个指令,程序计数器会存储该动作。

十三、本地方法栈

本地方法栈与虚拟机栈所起到的作用是类似的,虚拟机栈为虚拟机执行 Java 方法,本地方法栈管理虚拟机使用到的 本地方法,在虚拟机规范中对本地方法栈中方法使用的语言、使用方式与数据结构并没有强制规定,因此具体的虚拟机可以自由实现它。HotSpot 虚拟机直接就把本地方法栈和虚拟机栈合二为一。

十四、执行引擎

应用程序经过编译,转换为字节码文件,字节码加载到内存空间并不能直接在操作系统上执行,执行引擎作为 Java 虚拟机核心的组成部分,作用就是将字节码指令解释/编译为对应系统平台上的本地机器指令。



解释器:虚拟机启动时会根据预定义对字节码采用逐行解释的方式执行,将每条字节码文件中的内容解释为对应系统平台的本地机器指令执行;


JIT 编译器:虚拟机将源代码编译成本地机器平台相关的机器语言,并且寻找热点高频执行的代码将其放入元空间中,即元空间中存放的 JIT 缓存代码;


垃圾回收:对于没有任何引用的对象标记为垃圾,会被回收释放内存空间。

十五、垃圾对象标记

1、引用计数法

每个对象保存一个整型引用计数器,用来记录对象被引用的次数,当该对象被一个对象引用时,计数器加 1,当失去一个引用时,计数器减 1;引用计数算法就是通过判断对象的引用数量来决定对象是否可以被当做垃圾对象回收掉。


虽然引用计数法效率高,但是当两个对象互相引用时会导致这两个对象一直不会被回收,这是一个致命的缺陷。所以 JVM 并没有采用该标记算法。

2、可达性分析算法

可达性分析算法是基于对象到根对象的引用链是否可达来判断对象是否可以被回收;



运行程序把所有的引用关系链看作一张图,通过 GC-Roots 根对象对象集合作为起始点,从每个根节点向下不断搜索被根对象集合所连接的对象是否可达,搜索路径称为引用链(Reference-Chain),如果对象到 GC-Roots 没有任何引用链存在,则说明此对象是不可用的,


  • 虚拟机栈中引用的对象;

  • 元空间中类静态属性引用的对象;

  • 元空间中常量引用的对象;

  • 本地方法栈中 Native 方法引用的对象;


相对于引用计数法算法,可达性分析算法则避免了循环引用导致的问题,同样具备执行高效的特点,也是 JVM 采用的标记算法。

十六、垃圾回收机制

1、标记清除算法

标记-清除算法分为标记和清除两个阶段:


标记阶段:从根对象集合进行扫描,对存活的对象对象标记;清除阶段:再次扫描发现未被标记的对象并进行回收;



该算法效率不高,进行垃圾回收需要暂停应用程序,同时会产生大量内存碎片,后续程序运行过程中分配内存占用较大的对象时,会有连续内存不够情况,容易触发再一次垃圾收集动作。

2、标记整理算法

标记整理算法的标记过程类似标记清除算法,第一阶段:标记出垃圾对象;第二阶段:让所有存活的对象都向内存区一端移动;第三阶段:直接清理掉边界端以外的内存,类似于磁盘整理的过程;



该垃圾回收算法效率不高,对象移动过程需要暂停应用程序,适用于对象存活率高的场景(老年代)。

3、复制算法

复制算法将内存按容量划分为大小相等的两块,每次只使用其中的一块,当使用的这块的内存用完,就将还存活着的对象复制到另外一块空闲内存上,然后使用过的内存空间一次清理。



该算法实现简单,运行效率高,但是内存空间严重浪费,适用于对象存活率低的场景,比如新生代。

4、分代收集算法

当前市场上几乎所有的虚拟机都采用该回收算法,分代收集算法根据年轻代和老年代的各自特点采用不同的算法机制,不同内存区域中对象生命周期也不同,因此对堆内存不同区域采用不同的回收策略可以提高垃圾回收执行效率。通常情况新生代对象存活率低,回收频繁,就采用复制算法;老年代存对象生命周期长,活率高,就用标记清除算法或者标记整理算法。


Java 堆内存一般可以分为新生代、老年代和永久代三个模块,如下图所示:



新生代


通常情况下,新创建的对象实例首先都是放在新生代空间中,所以追求快速的回收掉垃圾对象,一般情况下,新生代内存按照 8:1:1 的比例分为一个 eden 区和两个 survivor(survivor0,survivor1)区,对象实例大部分在 Eden 区中生成;


垃圾回收时先把 eden 区存活对象复制到 S0 区,然后清空 eden 区,当 S0 区也满时,再将 eden 区和 S0 区存活对象复制到 S1 区,然后清空 eden 和 S0 区,之后交换 S0 区和 S1 区的角色,当 S1 区无法存放 eden 区和 S0 区的存活对象时,就将存活对象直接存移到老年代区,当老年代区也满了,触发一次 FullGC,即新生代、老年代都进行回收。


老年代


老年代区存放一些生命周期较长的对象,对象实例在新生代中经历了多次垃圾回收仍然存活的对象,会被移动到老年代区中。



发布于: 刚刚阅读数: 2
用户头像

知了一笑

关注

公众号:知了一笑 2020.04.08 加入

源码仓库:https://gitee.com/cicadasmile

评论

发布
暂无评论
JVM虚拟机,基础原理总结_Java_知了一笑_InfoQ写作平台