wangjie_fourth

may the force be with you

0%

JVM内部是如何运行的

在此写一下我理解中的JVM是啥样子的,一是为了更好理解jvm的执行过程,二是为了以后可能会被问到问题做准备。博客内容大致分为:

  • 背景提要
  • JVM的基本结构
  • 字节码执行引擎的执行过程
    • class文件结构
  • 类加载与双亲委派模型

背景提要

在开始具体写之前,先明确一下我以前长时间被迷惑的概念。
(1)虚拟机规范名称与实际实现名称可能不同
这里主要说的是堆的元空间、永久代、方法区。要明白方法区是虚拟机规范中的概念,而元空间、永久代是不同虚拟机对其实现的名称。所以网上会出现这三个混着表述,要明白这其实说的是同一个东西。

JVM的基本结构

以下内容是参考java虚拟机规范的2.5、2.6节和网上其他信息汇总。

栈区

栈区是每个线程都有独立的方法栈,它是负责执行具体方法。比如:

  • 如果是普通指令,字节码执行引擎就是顺序执行
  • 如果是新方法调用,则创建一个新的栈帧
  • 如果调用结束后(正常或异常),就弹出栈帧

组成结构

1、PC Register
PC 寄存器,这里是指字节码执行引擎的,用于记录当前线程中字节码指令的执行位置。比如:

  • 线程执行新的方法代码时,PC Register会指向新方法内部的字节码指令位置
  • 等新方法执行完成后,栈帧销毁,PC Register就会重新指回原来执行字节码指令的位置

2、Stack Frame
栈帧。是线程每次进入一个方法后,都创建的一个对象。其内部组成部分有:

  • 局部变量表:存储方法用需要用到的局部变量信息,比如:this
  • 操作数栈:用于存储后面计算所需要的变量和符号

3、Native Method Stack
指的是一些Native方法栈。完全不懂,找时间看看内部实现,不过估计得学一学C++了。(又是一个不知道什么时候能完成的坑)

实际展示

启动一个Java程序后,执行jps,jstack。线程中死锁的一些信息怎么判断出来?

script
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
"main" #1 prio=5 os_prio=31 cpu=319.81ms elapsed=757.78s tid=0x00007f9a0d00f000 nid=0x1703 in Object.wait()  [0x000070000b725000]
java.lang.Thread.State: TIMED_WAITING (on object monitor)
at java.lang.Object.wait(java.base@17.0.1/Native Method)
- waiting on <0x00000007d014b000> (a java.lang.Object)
at com.intellij.execution.rmi.RemoteServer.spinUntilWatchdogAlive(RemoteServer.java:124)
- locked <0x00000007d014b000> (a java.lang.Object)
at com.intellij.execution.rmi.RemoteServer.start(RemoteServer.java:110)
at org.jetbrains.idea.maven.server.RemoteMavenServer36.main(RemoteMavenServer36.java:23)

"Reference Handler" #2 daemon prio=10 os_prio=31 cpu=0.74ms elapsed=757.66s tid=0x00007f9a0d808600 nid=0x3a03 waiting on condition [0x000070000be3a000]
java.lang.Thread.State: RUNNABLE
at java.lang.ref.Reference.waitForReferencePendingList(java.base@17.0.1/Native Method)
at java.lang.ref.Reference.processPendingReferences(java.base@17.0.1/Reference.java:253)
at java.lang.ref.Reference$ReferenceHandler.run(java.base@17.0.1/Reference.java:215)

// 表示native的方法栈
"GC Thread#0" os_prio=31 cpu=14.03ms elapsed=757.74s tid=0x00007f9a0b106ee0 nid=0x4d03 runnable

堆区

堆区是存放对象的区域,其内部会根据垃圾回收算法分为不同代,这里留到后面写GC算法的时候,再具体写一写。堆区里有一个比较核心的问题,就是对象内存是如何分配的?
1、对象内存分配
在一般的正常情况下,创建对象后,都会到堆里去申请内存空间来存放新的对象。
(1)TLAB
TLAB:Thread Local Allocate Buffer,它的出现是为了解决多线程并发在堆中申请内存而导致的锁浪费。其实现就是为每个线程都分配一块内存,这样每个线程都在自己的TLAB范围内申请内存,就不会有多线程并发申请内存而导致的锁浪费。
(2)栈上分配
这也是一种JVM内存分配优化,通过逃逸分析来判断方法内的对象会不会逃逸出去,如果不会,那么这个对象就直接在当前栈帧上创建。这样的好处有:减轻GC压力,这种对象在方法调用结束后,就会被直接销毁。

太卷了。以上都是些概念,如果要深入了解其实现的话,待我学完C++,有生之年系列。

方法区

方法区是用于存储描述类的元数据,比如:运行时常量池、字段、方法数据等等。这个区域是被所有线程共享的。
1、方法区算不算堆上的一部分?
在虚拟机规范上,是属于堆的一部分,但在java8的实现上,将其与堆区分开了。主要是因为java应用程序越来越大、动态字节码技术的滥用,方法区就变得越来越大,挤占了堆的大小。

2、方法区、永久代、元空间的关系
这三个在某种程度上都是指的一个东西。

  • 方法区是java虚拟机规范的描述
  • 永久代是Java7之前实现方法区的名称,且其是作为堆的一部分
  • 元空间是Java8之后实现方法区的名称,是作为堆外内存的一部分

字节码执行引擎的执行过程

在说具体字节码执行引擎的工作流程之前,先说说java代码是如何变成字节码的。在你写完java代码,点击构建的时候,javac命令会把每个java文件中每个class转换成一个符合当前版本语法要求的BST,如果转换失败,那就会提示编译失败。如果编译成功,就会遍历这颗BST,然后按照规则写.class文件。至此,字节码文件就生成好了。
然后在jvm运行时,发现一个class还没有被加载,此时就会触发类加载流程。jvm会在cp里面不断遍历的去找全限定名称一致的class文件,找到后就会通过classLoader将class文件转换字节码存储到方法区,字节码执行引擎需要执行该class的字节码指令时,就会去方法区找到该字节码来解释执行,生成符合当前平台要求的机器码,最后cpu才会按照这些机器码执行指令。

至此,就是大概的java代码编写到实际运行的过程。接下来主要是看class文件存储的内容是啥,一个class是如何被加载到jvm中,然后再紧接看一个例子。

class文件结构

从编译器的角度来看,一个类就是一个编译单元,就比如说:一个java文件有多个类,而这些类会各自生成一个class文件,就会各自对应一个编译单元。
1、文件结构

  • magic:一般特殊文件都有这个标识
  • minor_version:当前jdk的小版本
  • major_version:当前jdk的大版本
  • constant_pool_count:常量池个数
  • constant_pool:一般用来减少class存储空间,后续的字节码指令后面就可以直接用这个常量池的下标
    • 可以存代码中方法的执行信息
  • access_flags:当前类的访问标记
  • this_class:当前类的全限定类名
  • super_class:当前类的父类全限定类名
  • interfaces_count:
  • interfaces:接口
  • fields_count
  • fields:属性
  • methods_count
  • methods:方法
  • attributes_count
  • attributes:

执行字节码的示例

1、常见字节码指令

  • invokevirtual:一般实例例⽅方法,有多态;
  • invokeinterface:接⼝口⽅方法,有多态
  • invokestatic:静态⽅方法,⽆无多态
  • invokespecial:特殊⽅方法,⽆无多态;特殊方法:private方法、类的构造方法、static静态代码快等等
  • invokedynamic:动态调⽤用,JDK 7 新增,⽅方法⽆无需在编译时确定

多态的实现是方法栈帧的局部变量表的this实现的,实例会现在这个this中寻找,找不到再去父类中寻找。而在静态方法、static静态代码块中方法的局部变量表无this,也就没法实现多态

2、方法执行过程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
public class Base {
public void testOverride() {
System.out.println("Base!");
}
}

final class Sub extends Base {

@Override
public void testOverride() {
System.out.println("Sub!");
}

public boolean testCodeExecFlow(int a) {
try {
a++;
System.out.println(a);
testOverride();
return true;
} catch (IllegalArgumentException e) {
e.printStackTrace();
} catch (IllegalStateException e) {
e.printStackTrace();
} finally {
System.out.println("end");
}
return false;
}

public static void main(String[] args) {
int a = 10;
boolean result = new Sub().testCodeExecFlow(a);
System.out.println(result);
}
}

(1)在main方法开始执行时,遇到调用方法的字节码指令时,该线程的方法栈会新增一个栈帧,并将一些参数的地址拷贝赋值到新栈帧的局部变量表中。如果是普通实例方法,局部变量表的第一个参数就是this,然后根据this的地址到堆中寻找该变量,然后现在this找对应的方法,如果没有就去父类找,这就是多态的内部实现。后续会在这新的栈帧开始执行字节码。
(2)当新的方法执行完后,会将当前栈帧的操作数栈的值放置调用者栈帧中的操作数栈。如果方法内部有finally块,当前栈帧会接着执行该代码块的字节码。
(3)如果一个方法有捕获异常时,会在方法的字节码中指定它的处理流程,比如:

script
1
2
3
4
5
6
7
8
9
10
11
public testCodeExecFlow(I)Z
TRYCATCHBLOCK L0 L1 L2 java/lang/IllegalArgumentException
TRYCATCHBLOCK L0 L1 L3 java/lang/IllegalStateException

...

L11
LOCALVARIABLE e Ljava/lang/IllegalArgumentException; L7 L8 2
LOCALVARIABLE e Ljava/lang/IllegalStateException; L10 L9 2

...

如果方法中没有指定异常的处理流程时,就会用线程最后兜底的异常处理方法:比如Thread的uncaughtExceptionHandler。

类加载与双亲委派模型

在上面说到jvm中字节码执行引擎的工作流程,其中最开始的部分,即如何将字节码文件输入到jvm内部,是没有说的。这里就需要看一下jvm是如何把class文件转换成jvm内部的Class对象。

何时触发类加载

从字节码的角度上来看,都是将符号引用转换成对象时,才会触发类加载,具体有俩种情况:
(1)获取一个Class对象
比如说Class clz = MyTest.class。注意:在这种情况下只会执行加载、验证、准备步骤,不会对这个类进行解析,也就是不会对这个类的静态方法进行初始化。
(2)触发new、getstatic等指令
比如说MyTest test = new MyTest();。此时,会执行加载、验证、准备、解析操作。

初始化类加载器与定义类加载器

当jvm在执行类加载的过程中,会涉及到俩种类加载器:初始化类加载器、定义类加载器。

  • 初始化类加载器:执行类加载代码类的定义类加载器
  • 定义类加载器:最终实际调用defineClass方法将字节码转换成Class对象

比如说:下面的代码

1
2
3
4
5
6
7
public class Demo {
public static void main(String[] args) {
MyTest myTest = new MyTest();
}
}
// 对应的字节码大致如下
NEW com/github/MyTest

当JVM需要加载MyTest类时,会调用当前类的定义类加载器的loadClass方法,产生新的栈帧,也就是Demo类的定义类加载器就是MyTest的初始化加载器。根据双亲委派加载模型,最终执行defineClass方法的ClassLoader才是MyTest的定义类加载器。需要注意的是:

  • Class.getClassLoader()获取的是定义类加载器
  • 只有类名+定义类加载器完全一样,才是同一个类的实例
  • 由于双亲委派模型的存在,初始化类加载器和定义类加载器可能不是同一个,初始化类加载器仅仅负责触发类加载的过程,最终不一定是它将字节码数组转换类对象

双亲委派加载模型

定义

双亲委派加载模型是一种约定,它规则当一个类加载器在加载某个类之前,需要先向其父类加载器请求,只有父类加载器都没有加载过这个类时,才允许当前类加载器去加载这个类,否则直接用父类加载器加载好的类。

目的

双亲委派加载模型是为了解决安全问题,比如说恶意、重复加载某个类。比如说:一些JDK自带的类可能被其他恶意jar所替换,比如说HashMap,如果自定义类加载器加载外部的java.util.HashMap类,那么整个JVM本身就会存在俩个HashMap,就有可能带来安全问题。

具体介绍

JVM的类加载器从加载类的路径上分为三种:
(1)Bootstrap ClassLoader
启动类加载器是用于加载核心类库rt.jar(runtime.jar),它是用C++编写的,所以在java程序中表现为null。
(2)Extension ClassLoader
扩展类加载器用于加载ext目录下的jar包。
(3)Application ClassLoader
应用类加载器是用于加载JVM启用时,cp参数传进来的jar包。

不遵守双亲委派加载模型的例子

背景

双亲委派加载模型是JDK1.2引入的,而ClassLoader在JDK1.0就有了,所以SPI(Service Provider Interface)和JDBC机制都无法满足该要求。
比如说:SPI机制的java.util.ServiceLoader在使用load方法加载某个实现类时,因为此时方法已经进入java.util.ServiceLoader,此时新类的初始化类加载器就是java.util.ServiceLoader的定义类加载器也就是Bootstrap ClassLoader,而它只能去加载rt.jar,但实现类肯定是通过cp传入的,也就是Bootstrap ClassLoader是没有办法去加载实现类。

如何解决

在线程中设置上下文类加载器。此时java.util.ServiceLoader再去加载某个类时,就可以使用上下文类加载器去加载,而不是当前类的定义类加载器去加载。

类加载具体过程

jvm虚拟机规范中,将类加载过程分为3部分:Loading、Linking、Initializing。

(1)Loading
加载,这个过程就是将字节码文件转换成字节数组,在这过程中会做基本校验工作,比如说检验这个文件是否为字节码文件,其版本是否支持等等。
(2)Linking
链接,这个过程就是将字节码文件中符号引用与真实具体的对象引用链接起来。具体过程分为3部分:

  • Verification:验证字节码文件的正确性
  • Preparation:为static成员赋默认的初始值
  • Resolution:解析当前字节码里包含的其他符号引用

(3)Initializing
初始化,这个过程就是执行类的初始化方法。

源码分析

ClassLoader与URLClassLoader。其中ClassLoader的三个常见方法:

  • loadClass:加载Class对象
  • findClass:当前父亲ClassLoader没有加载制定Class时,当前ClassLoader加载
  • defineClass:将字节数组转换成Class对象

URLClassLoader的ucp字段存储了它搜索的class目录信息

一堆疑问

1、用双引号包含的字符串是放在字符串常量池的,而字符串常量池是每个class单独维护的,那为什么我在俩个class定义相同的字符串,他们的引用却相同?
这里要梳理清楚问题,用双引号包含的字符串的确是放在字符串常量池的,而字符串常量池是由每个class单独维护是不正确的。这里涉及到俩个概念:运行时常量池、字符串常量池。

  • 运行时常量池是jvm虚拟机规范的概念,它的确是由每个class单独维护的;
  • 字符串常量池并没有在jvm虚拟机规范明确规定,属于逻辑上的一个概念,它是jvm中单独存在,用于减少重复创建字符串;

在jvm运行时,会将每个class的运行时常量池的字符串放到字符串常量池中,但不仅仅只放运行时常量池的字符串。A.class的运行时常量池有”xxx”,jvm在加载A.class,会往字符串常量池新建”xxx”对象;当jvm去加载B.class时,发现已经有”xxx”,就会直接拿原来的字符串用。所以,在俩个class定义相同的字符串,他们的引用就相同。