快速入门Java虚拟机

560 阅读15分钟

JVM介绍

JVM :即Java Virtual Machine,用来执行一系列虚拟计算机的指令

我们编写好的Java文件的运行流程:

image.png

JVM的作用就是将 .class文件 转换为计算机可以直接识别的 机器码

Java虚拟机从软件层面屏蔽了不同操作系统在底层硬件与指令上的区别

这就实现了Java的跨平台特性

那具体JVM是如何处理的 .class 文件呢?

JVM 想要执行这个  .class 文件,我们需要将其装进一个 类装载子系统 中

它就像一个搬运工一样,会把所有的  .class 文件全部搬进JVM里面来

接着数据进入到运行时数据区

image.png

最后字节码执行引擎会执行class文件,将字节码转换为机器可以直接识别的机器码

虚拟机类加载机制

从类被加载到虚拟机内存中开始,到释放内存总共有7个步骤:加载,验证,准备,解析,初始化,使用,卸载。其中验证,准备,解析三个部分统称为连接

image.png

加载:

在加载阶段,Java虚拟机主要完成三件事情

  1. 通过一个类的全限定名来获取定义此类的二进制字节流
  2. 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
  3. 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据结构的访问入口

连接:

  1. 验证:确保加载的类符合 JVM 规范和安全,保证被校验类的方法在运行时不会做出危害虚拟机的事件
  2. 准备:为static变量在方法区中分配内存空间,设置变量的初始值
  3. 解析:虚拟机将常量池内的符号引用替换为直接引用的过程

验证是连接阶段的第一步,这一阶段的目的是为了确保 Class 文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。

从整体上来看,验证阶段大致会完成下面四个阶段的检验动作

  1. 文件格式验证:保证输入的字节流能正确地解析并存储于方法区之内

  2. 元数据验证:保证不存在不符合 Java 语言规范的元数据信息

  3. 字节码验证:通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的

  4. 符号引用验证:保证可以将符号引用转化为直接引用

准备阶段是类变量分配内存并设置类变量初始值的阶段

解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程

初始化 直到这个阶段,Java虚拟机才开始真正执行类中编写的Java文件

初始化阶段就是执行类构造器<clinit>()方法的过程,这个方法是Javac编译器的自动生成物

我们需要了解<clinit>()方法可能会影响程序运行行为的细节

<clinit>() 方法是由编译器依次自动收集类中的所有类变量的赋值动作和静态代码块中的语句合并产生的。

静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值,但是不能访问,编译器会提示非法向前引用

image.png

<clinit>() 方法对于类或接口来说并不是必需的,如果一个类中没有静态语句块,也没有对变量的赋值操作,那么编译器可以不为这个类生成 <clinit>() 方法

卸载: GC将无用对象从内存中卸载

类加载器

加载一个Class类的顺序也是有优先级的,类加载器从最底层开始往上的顺序是这样的

  1. BootStrap ClassLoader:启动类加载器,加载\lib目录下的符合特定名字的jar包
  2. Extention ClassLoader: 扩展类加载器,加载\lib\ext目录下扩展的jar包
  3. Application ClassLoader:应用程序类加载器,加载指定的classpath下面的jar包
  4. Custom ClassLoader:自定义的类加载器

image.png

各种加载器之间的层次关系被称为类加载器的双亲委派模型

双亲委派模型要求除了顶层的启动类加载器外,其余的加载器都要有自己的父类加载器

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

这样做的好处是,加载java.lang.Object类时不管是哪个加载器加载,最终都会委托到BootStrap ClassLoader进行加载,这样保证了使用不同的类加载器得到的都是同一个结果

比较两个类是否相同,只有这两个类是由同一个类加载器加载的前提下才有意义,否则就算是同一个class文件,被同一个虚拟机加载,只要类加载器不同,那么这两个类必定不相同

破坏双亲委派模型:

双亲委派模型主要出现过 3 较大规模的“被破坏”情况

  1. 双亲委派模型在 JDK 1.2 之后才被引入,而类加载器和抽象类 java.lang.ClassLoader 则在 JDK 1.0 时代就已经存在,JDK 1.2之后,其添加了一个新的 protected 方法 findClass(),在此之前,用户去继承 ClassLoader 类的唯一目的就是为了重写 loadClass() 方法,而双亲委派的具体逻辑就实现在这个方法之中,JDK 1.2 之后已不提倡用户再去覆盖 loadClass() 方法,而应当把自己的类加载逻辑写到 findClass() 方法中,这样就可以保证新写出来的类加载器是符合双亲委派规则的。
  2. 基础类无法调用类加载器加载用户提供的代码(越基础的类越由上层的加载器进行加载,用户提供的代码放在classpath下),为此 Java 引入了线程上下文类加载器(Thread Context ClassLoader)。这个类加载器可以通过 setContextClassLoaser() 方法进行设置,如果创建线程时还未设置,它将会从父线程中继承一个,如果在应用程序的全局范围内都没有设置过的话,那这个类加载器默认就是应用程序类加载器。如此,父类加载器请求子类加载器去完成类加载的动作,这种行为实际上就是打通了双亲委派模型的层次结构来逆向使用类加载器

为什么要使用线程上下文加载器,明明使用 ClassLoader.getSystemClassLoader() 方法也可以获取到 Application 类加载器,那是因为并非上下文加载器都是Application类加载器,有时候是自定义类加载器

image.png

  1. 代码热替换(HotSwap)、模块热部署(Hot Deployment)等,OSGi 实现模块化热部署的关键则是它自定义的类加载器机制的实现。每一个程序模块(Bundle)都有一个自己的类加载器,当需要更换一个 Bundle 时,就把 Bundle 连同类加载器一起换掉以实现代码的热替换。在 OSGi 环境下,类加载器不再是双亲委派模型中的树状结构,而是进一步发展为更加复杂的网状结构

运行时数据区

Java虚拟机在执行Java程序的过程中会把它管理的内存分为若干个不同的数据区域

image.png

程序计数器

可以看做当前线程所执行的字节码的行号指示器,如果线程正在执行一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址

在虚拟机概念模型里,字节码执行引擎工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支,循环,跳转,异常处理,线程恢复等基础功能都需要依赖这个计数器来完成

由于jvm的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器都只会执行一条线程中的指令。因此未来线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间计数器互不影响,独立存储

虚拟机栈

线程私有,生命周期与线程相同

虚拟机栈描述的是Java方法执行的内存模型,每个方法在执行的同时都会创建一个栈帧  用于存储局部变量表,操作数栈,动态链接,方法出口等信息。每一个方法从调用直至完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程

比如执行这个线程

image.png

每个线程都会有一个私有的栈空间,当main方法执行的时候,同时创建一个栈帧,将这个栈帧放到栈空间的底部,main方法调用了compute方法,这时候创建compute方法的栈帧

image.png

compute方法执行完之后 从栈顶出栈

先调用的方法先分配内存,后调用的方法先结束 ,满足了栈先进后出的原则

这个内存区域有两种异常情况:

  1. 如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常
  2. 如果Java虚拟机栈容量扩展时无法申请到足够的内存会抛出OutOfMemoryError异常

局部变量表:用于存放方法参数和方法内部定义的局部变量

操作数栈:一个方法刚开始执行的时候,该方法的操作数栈是空的,在方法的执行过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是入栈和出栈操作

动态链接:每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,只有这个引用是为了支持方法调用过程中的动态链接(Dynamic Linking)。Class文件的常量池中存有大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用作为参数。这些符号引用一部分会在类加载阶段或者第一次使用的时候就转化为直接引用,这种转化称为静态链接。另外一部分将在每一次运行期间转化为直接引用,这部分称为动态链接

  当前线程的栈帧通过获取方法的直接引用,指向着常量池对应方法的字节码,就可以利用常量池、操作数栈执行方法

方法出口:当一个方法开始执行后,只有两种方式可以退出这个方法。

  1. 执行引擎遇到任意一个方法返回的字节码指令return,会有返回值传递给上层的方法调用者,简称正常完成出口

  2. 在方法的执行过程中遇到了异常Exception,并且议程没有在方法体中处理,简称异常完成出口

无论采用何种退出方式,在方法退出后都需要返回到方法被调用的位置,程序才能继续执行,方法返回时可能需要在栈帧中保存一些信息,用来恢复它的上层方法的执行状态。一般来说,方法正常退出时,调用者的 PC 计数器的值可以作为返回地址,栈帧中很可能会保存这个计数器值。而方法异常退出时,返回地址是要通过异常处理器表来确定的,栈帧中一般不会保存这部分信息

本地方法栈

本地方法栈与虚拟机栈所发挥的作用非常相似,他们之间的区别不过是虚拟机栈为虚拟机执行Java方法(字节码)服务,而本地方法栈则为虚拟机中使用到的native方法服务

Java堆是被所有线程共享,虚拟机启动时创建,此内存区域唯一的目的就是存放对象实例,几乎所有的对象实例都在这里分配内存

堆也是垃圾收集器管理的主要区域,因此很多时候也被称为“GC堆”。Java堆还可以细分为:新生代和老年代;再细致一点的有Eden空间,From Survivor空间,To Survivor空间等

image.png

当我们new一个对象后,会先放到Eden划分出来的一块作为存储空间的内存 ,当Eden空间满了之后,会触发Minor GC操作,存活下来的对象移动到Survivor from区。Survivor from区满后触发 Minor GC,就会将存活对象移动到Survivor to区,此时还会把from和to两个指针交换,这样保证了一段时间内总有一个survivor区为空。经过多次的 Minor GC后仍然存活的对象会移动到老年代

老年代是存储长期存活的对象的,占满时就会触发我们最常听说的Full GC,期间会停止所有线程等待GC的完成。所以对于响应要求高的应用应该尽量去减少发生Full GC从而避免响应超时的问题

Minor GC/Young GC:指发生在新生代的垃圾收集动作

Major GC/Full GC:一般回收老年代,年轻代,方法区的垃圾

程序计数器、虚拟机栈、本地方法栈,3个区域随着线程的生存而生存的。内存分配和回收都是确定的。随着线程的结束内存自然就被回收了,因此不需要考虑垃圾回收的问题

而Java堆和方法区则不一样,各线程共享,内存的分配和回收都是动态的。因此垃圾收集器所关注的都是堆和方法区这部分内存

判断对象是否还存活的方法:

  1. 引用计数器计算:给对象添加一个引用计数器,每次引用这个对象时计数器加一,引用失效时减一,计数器等于0时判定对象死亡(存在循环引用问题 两个对象互相引用则无法被回收)

  2. 可达性分析计算:将一系列的GC root作为起始的存活对象集,从这个节点往下搜索,直接或间接可达的对象,即为存活

垃圾回收算法

  1. 标记清除算法:标记清除算法就是分为“标记”和“清除”两个阶段。标记出所有需要回收的对象,标记结束后统一回收,不足的方面就是标记和清除的效率比较低下。且这种做法会产生内存碎片

  2. 标记整理算法:标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉边界以外的内存

  3. 复制算法:按内存容量将内存划分为等大小的两块。每次只使用其中一块,当这一块内存满后将尚存活的对象复制到另一块上去,把已使用的内存清掉

  4. 分代收集算法:核心思想是根据对象存活的不同生命周期将内存划分为不同的域,一般情况下将 GC 堆划分为老生代和新生代。老生代的特点是每次垃圾回收时只有少量对象需要被回收,新生代的特点是每次垃圾回收时都有大量垃圾需要被回收,因此可以根据不同区域选择不同的算法。新生代使用复制算法,老年代使用标记整理算法

方法区(元空间)

和堆一样所有线程共享,主要用于存储已被jvm加载的类信息、常量、静态变量、即时编译器编译后的代码等数据

字节码执行引擎

执行引擎是Java虚拟机核心的组成部分之一,「虚拟机」是相对于「物理机」的概念,这两种机器都有代码执行的能力,区别是物理机的执行引擎是直接建立在处理器、硬件、指令集和操作系统层面上的,而虚拟机执行引擎是由自己实现的,因此可以自行制定指令集与执行引擎的结构体系,并且能够执行那些不被硬件直接支持的指令集格式。

栈帧(Stack Frame)是用于支持虚拟机进行方法调用和方法执行的数据结构,它是虚拟机运行时数据区中的虚拟机栈的栈元素。栈帧存储了方法的局部变量、操作数栈、动态链接和方法返回地址等信息。每一个方法从调用开始到执行完成的过程,都对应着一个栈帧在虚拟机栈里从入栈到出栈的过程。

字节码引擎需要了解的伙伴可以看看《深入理解Java虚拟机》