JVM基本原理

JVM基本原理

jvm系列文章

初识jvm



知识补充
java中的类型可以分为两大类:基本类型和引用类型。
基本类型是由java虚拟机预先定义好的。java引用了八个基本类型。
boolean、byte、short、char、int、long、float、double.
在 Java 中,正无穷和负无穷是有确切的值,在内存中分别等同于十六进制整数 0x7F800000 和 0xFF800000。
引用类型,细分为四类:类、接口、数组和泛型参数。数组类由jvm直接生成。

一、java代码是怎么运行的

首先,程序员编写java代码,通过前端编译器(javac)将后缀为.java的文件编译为后缀为.class的文件(.class文件中的内容就是java代码编译后的字节码)。将后缀为.class的文件加载到jvm中。通过后端编译器(jit/aot)将.class文件编译为可执行文件。

在HotSpot中,有两种解释执行的形式。第一种是解释执行,即逐条将字节码翻译成机器码并执行;第二种是即时编译,即将一个方法中包含的所有字节码编译成机器码后再执行。HotSpot采用混合模式,先解释执行代码,而后将其中反复执行的热点代码,以方法为单位进行即时编译。

补充:什么是前端编译器?什么是后端编译器?
这部分知识,设计到编译原理相关知识。在这就不展开讲解了,后面有时间详细讲解。
前端编译器:一般涉及 词法分析、语法分析、生成抽象语法树、类型检查和中间代码生成
后端编译器:则包含代码优化、目标代码生成和目标生成代码优化

1.java虚拟机是怎么运行java字节码的?

被加载到java虚拟机的java类会被存放在方法区(Method Area)中。实际运行时jvm会执行方法区内的代码。运行时,每当调用进入一个Java方法,JVM会在当前线程的Java方法栈中生成一个栈帧。退出执行的Java方法时,无论是不是正常返回,JVM都会弹出并舍弃掉当前线程的当前栈帧。

简单的流程理解图

二、java虚拟机是如何加载java类的

类从被加载到JVM中开始,到卸载为止,整个生命周期包括:加载、验证、准备、解析、初始化、使用和卸载七个阶段。

其中类加载过程包括加载、验证、准备、解析和初始化五个阶段。

1.加载

  通过全限定类名找到对应的二进制字节流到JVM内部转化为方法区的数据结构,并据此创建类的过程,这个Class对象在日后就会作为方法区中该类的各种数据的访问入口,对于数组类来说,它并没有对应的字节流,而是由Java虚拟机直接生成的。对于其他类型来说,java虚拟机则需要借助类加载器。

类加载器

基本的类加载器有三个分别是启动类加载器、扩展类加载器和应用类加载器。

    启动类加载器(Bootstrap ClassLoader):是由C++实现的,没有对应的Java对象,因此在Java中只能用null来指代。除了启动类加载器之外,其他的加载器都是java.lang.ClassLoader的子类。这些类加载器需要先由另一个类加载器,加载至Java虚拟机中,方能执行类加载。在 Java 9 之前,启动类加载器负责加载最为基础、最为重要的类,比如存放在 JRE 的 lib 目录下 jar 包中的类(以及由虚拟机参数 -Xbootclasspath 指定的类)

    扩展类加载器:扩展类加载器的父类加载器是启动类加载器。它负责加载相对次要、但又通用的类,采用java语言进行编写,主要负责加载“JAVA_HOME/lib/ext”扩展目录中的所有类型(以及由系统变量 java.ext.dirs 指定的类)。

    应用类加载器:同样采用java语言,应用类加载器的父类加载器则是扩展类加载器.主要负责加载ClassPath目录中的所有类型。(这里的应用程序路径,便是指虚拟机参数 -cp/-classpath、系统变量 java.class.path 或环境变量 CLASSPATH 所指定的路径。)默认情况下,应用程序中包含的类便是由应用类加载器加载的。

    自定义类加载器:如果被加载的类型并没有包含在ClassPath目录中时,程序最终就会抛出java.lang.ClassNotFoundException异常。为了满足这些特殊的场景,开发人员就需要在程序中编写自定义类加载器。首先继承ClassLoader并重写其findClass ()方法即可。当编写好自定义类加载器后,便可以在程序中调用loadClass()方法来实现类加载操作。

    Java 9 引入了模块系统,并且略微更改了上述的类加载器。扩展类加载器被改名为平台类加载器(platform class loader)。除了少数几个关键模块,比如说 java.base 是由启动类加载器加载之外,其他的模块均由平台类加载器所加载

双亲委派模型

    在jvm类加载器存在着特定的规则,我们通常把这种规则称为双亲委派模型。
每当一个类加载器接收到加载请求时,它会先将请求转发给父类加载器。在父类加载器没有找到所请求的类的情况下,该类加载器才会尝试去加载。使用双亲委托机制的好处是:能够有效确保一个类的全局唯一性,当程序中出现多个限定名相同的类时,类加载器在执行加载时,始终只会加载其中的某一个类。

类的全局唯一性: 类加载器名称+类全限定类名称

2.链接(验证、准备、解析)

链接是指将创建成的类合并至 Java 虚拟机中,使之能够执行的过程。经由验证、准备和解析三个阶段。在class文件被加载到java虚拟机之前,这个类无法知道其他类及本身的相关信息。因此,每当需要引用这些成员的时候,java编译器会生成一个符号引用。对于一个方法调用,编译器会生成一个包含目标方法所在类的名字、目标方法的名字、接收参数类型以及返回值类型的符号引用,来指代所要调用的方法

验证阶段的目的,在于确保被加载类能够满足 Java 虚拟机的约束条件。

准备阶段的目的,则是为被加载类的静态字段分配内存。

解析阶段的目的,将符号引用解析成为实际引用。如果符号引用指向一个未被加载的类,或者未被加载类的字段或方法,那么解析将触发这个类的加载

如果某些字节码使用了符号引用,那么在执行这些字节码之前,需要完成对这些符号引用的解析。

3.初始化

初始化主要工作是为静态变量赋程序设定的初值。将一个类中所有被static关键字标识的代码统一执行一遍,如果执行的是静态变量,那么就会使用用户指定的值覆盖之前在准备阶段设置的初始值;如果执行的是static代码块,那么在初始化阶段,JVM就会执行static代码块中定义的所有操作。类初始化过程是线程安全的,并且只能被初始化一次。jvm会通过加锁来保证方法仅被执行一次。

类的初始化何时被触发?
 1.当虚拟机启动时,初始化用户指定的主类;
 2. 当遇到用以新建目标类实例的 new 指令时,初始化 new 指令的目标类;
 3. 当遇到调用静态方法的指令时,初始化该静态方法所在的类;
 4. 当遇到访问静态字段的指令时,初始化该静态字段所在的类;
 5. 子类的初始化会触发父类的初始化;
 6. 如果一个接口定义了 default 方法,那么直接实现或者间接实现该接口的类的初始化,会触发该接口的初始化;
 7. 使用反射 API 对某个类进行反射调用时,初始化这个类;
 8. 当初次调用 MethodHandle 实例时,初始化该 MethodHandle 指向的方法所在的类。

三、JVM是如何执行方法调用的?

Java 虚拟机识别方法的关键在于类名、方法名以及方法描述符(method descriptor)。上面提到过在编译过程中,无法确定目标方法的具体内存地址。java编译器会暂时用符号引用来表示该目标方法。符号引用存储在class文件的常量池之中。根据目标方法是否为接口方法,这些引用可分为接口符号引用和非接口符号引用。在执行使用了符号引用的字节码前,Java 虚拟机需要解析这些符号引用,并替换为实际引用。

非接口符号引用查找流程:

1.在符号引用所指向的类中查找符合名字及描述符的方法。
2.如果没有找到,在符号引用所指向的类的父类中继续搜索,直至Object类
3.如果没有找到,在符号引用所指向的类所直接实现或间接实现的接口中搜索,搜索的目标方法必须是非私有、非静态的。如果目标方法在间接实现的接口中,则需满足c与该接口的之间没有符合条件的目标方法。如果有多个符合条件的目标方法,则任意返回其中一个。

接口符号引用查找流程:

执行方法调用的字节码指令有:
1.在符号引用所指向的接口中查找符合名字及描述符的方法。
2.如果没有找到,在 Object 类中的公有实例方法中搜索。
3.如果没有找到,则在 符号引用所指向的接口的超接口中搜索。这一步的搜索结果的要求与非接口符号引用步骤 3 的要求一致。

经过上述的解析步骤之后,符号引用会被解析成实际引用。对于可以静态绑定的方法调用而言,实际引用是一个指向方法的指针。对于需要动态绑定的方法调用而言,实际引用则是一个方法表的索引。

Java 字节码中与调用相关的指令共有五种。
    1.invokestatic: 调用静态方法
    2.invokespecial: 用于调用私有实例方法、构造器,以及使用 super 关键字调用父类的实例方法或构造器,和所实现接口的默认方法。
    3.invokevirtual: 用于调用非私有实例方法。
    4.invokeinterface: 调用接口方法,会在运行时再确定一个实现次接口的对象
    5.invokedynamic: 在运行时才能确定调用的具体方法,由调用点限定符确定

静态绑定和动态绑定
Java 虚拟机中的静态绑定指的是在解析时便能够直接识别目标方法的情况
动态绑定则指的是需要在运行过程中根据调用者的动态类型来识别目标方法的情况。
Java 虚拟机中采取了一种用空间换取时间的策略来实现动态绑定。它为每个类生成一张方法表,用以快速定位目标方法。

方法表

方法表本质上是一个数组,每个数组元素指向一个当前类及其祖先类中非私有的实例方法。这些方法可能是具体的、可执行的方法,也可能是没有相应字节码的抽象方法。

方法表满足两个特质:
    其一,子类方法表中包含父类方法表中的所有方法;
    其二,子类方法在方法表中的索引值,与它所重写的父类方法的索引值相同。

    方法调用指令中的符号引用会在执行之前解析成实际引用。对于静态绑定的方法调用而言,实际引用将指向具体的目标方法。对于动态绑定的方法调用而言,实际引用则是方法表的索引值(实际上并不仅是索引值)。在执行过程中,Java 虚拟机将获取调用者的实际类型,并在该实际类型的方法表中,根据索引值获得目标方法。这个过程便是动态绑定。

内联缓存

    内联缓存是一种加快动态绑定的优化技术。它能够缓存虚方法调用中调用者的动态类型,以及该类型所对应的目标方法。在之后的执行过程中,如果碰到已缓存的类型,内联缓存便会直接调用该类型所对应的目标方法。如果没有碰到已缓存的类型,内联缓存则会退化至使用基于方法表的动态绑定。

四、JVM是如何处理异常的?

在java中,所有的异常都是Throwable类或其子类。
    在构造异常实例时,Java 虚拟机便需要生成该异常的栈轨迹(stack trace)。该操作会逐一访问当前线程的 Java 栈帧,并且记录下各种调试信息,包括栈帧所指向方法的名字,方法所在的类名、文件名,以及在代码中的第几行触发该异常。当然,在生成栈轨迹时,Java 虚拟机会忽略掉异常构造器以及填充栈帧的 Java 方法(Throwable.fillInStackTrace),直接从新建异常位置开始算起。

java虚拟机是如何捕获异常的

    在编译生成的字节码中,每个方法都附带一个异常表。异常表中的每一个条目代表一个异常处理器。并且由 from 指针(起点)、to 指针(终点)、target 指针(跳转的pc偏移位置)以及所捕获的异常类型构成(该异常类所在常量池中的索引)。其中,from 指针和 to 指针标示了该异常处理器所监控的范围,例如 try 代码块所覆盖的范围。target 指针则指向异常处理器的起始位置,例如 catch 代码块的起始位置。

五、JVM是如何实现反射的?

    委派给 MethodAccessor 来处理。MethodAccessor 是一个接口,它有两个已有的具体实现:一个通过本地方法来实现反射调用,另一个则使用了委派模式。为了方便记忆,我便用“本地实现”和“委派实现”来指代这两者。每个 Method 实例的第一次反射调用都会生成一个委派实现,它所委派的具体实现便是一个本地实现。本地实现非常容易理解。当进入了 Java 虚拟机内部之后,我们便拥有了 Method 实例所指向方法的具体地址。这时候,反射调用无非就是将传入的参数准备好,然后调用进入目标方法。

    Java的反射机制还设立了另一种动态生成字节码的实现,简称动态实现,并且委派实现的意义就是在于,可以在本地实现和动态实现中切换。

    在这里我说一下,动态实现的总体速度是比本地实现快上几十倍的,但是问题在于,生成字节码然后解码的过程倒是很浪费资源,所以,如果你就调用一次方法去反射,得不偿失啊。

    所以JVM就规定了反射次数的一个规范:当调用invoke方法<15次,就本地实现,当≥15次,就用动态生成字节码的方法反射。

六、 JVM是怎么实现Invokedynamic的?

    Java 7 引入了一条新的指令 invokedynamic。该指令的调用机制抽象出调用点这一个概念,并允许应用程序将调用点链接至任意符合条件的方法上。 invokedynamic 底层机制的基石:方法句柄。方法句柄是一个强类型的、能够被直接执行的引用。它仅关心所指向方法的参数类型以及返回类型,而不关心方法所在的类以及方法名。方法句柄的权限检查发生在创建过程中,相较于反射调用节省了调用时反复权限检查的开销。方法句柄可以通过 invokeExact 以及 invoke 来调用。其中,invokeExact 要求传入的参数和所指向方法的描述符严格匹配。方法句柄还支持增删改参数的操作,这些操作是通过生成另一个充当适配器的方法句柄来实现的。方法句柄的调用和反射调用一样,都是间接调用,同样会面临无法内联的问题。

七、JVM是如何实现synchronized的?

    当声明synchronized代码块时,编译而成的字节码将包含 monitorenter 和 monitorexit 指令。这两种指令均会消耗操作数栈上的一个引用类型元素(也就是synchronized关键字括号里的引用),作为要加锁解锁的锁对象。
    monitorenter 和 monitorexit 操作所对应的锁对象是隐式的。对于实例方法来说,这两个操作对应的锁对象是 this;对于静态方法来说,这两个操作对应的锁对象则是所在类的 Class 实例

    当执行 monitorenter 时,如果目标锁对象的计数器为 0,那么说明它没有被其他线程所持有。在这个情况下,Java 虚拟机会将该锁对象的持有线程设置为当前线程,并且将其计数器加 1。在目标锁对象的计数器不为 0 的情况下,如果锁对象的持有线程是当前线程,那么 Java 虚拟机可以将其计数器加 1,否则需要等待,直至持有线程释放该锁。

    当执行 monitorexit 时,Java 虚拟机则需将锁对象的计数器减 1。当计数器减为 0 时,那便代表该锁已经被释放掉了。之所以采用这种计数器的方式,是为了允许同一个线程重复获取同一把锁。
下面给大家介绍jvm中几种最常见的锁实现。
jvm通过对象头中的标记字段(mark word)中最后两位用来表示该对象的锁状态。其中00表示轻量级锁,01代表无锁(或偏向锁),10代表重量级锁,11代表GC标记。

对象状态对象头(64bit)
MarkWordClassMetadata
25 bit4 bit1 bit2 bit对象类型指针(32bits)
23 bit2 bit是否可偏向标志位
无锁对象hashCode对象分代年龄001指向元数据对象的指针
偏向锁线程IDEpoch101指向元数据对象的指针
轻量级锁指向栈中锁记录的指针00指向元数据对象的指针
重量级锁指向互斥量(重量级锁)的指针10指向元数据对象的指针
标记为GC11指向元数据对象的指针

重量级锁

重量级锁状态下,jvm会阻塞加锁失败的线程,并且在目标锁释放的时候,唤醒这些线程。线程的唤醒和阻塞,都是依靠操作系统来完成的。这些操作涉及用户态到内核态的切换,其开销非常之大。为了避免这些操作,jvm会在线程进入阻塞状态之前,以及被唤醒后竞争不到锁的情况下,进入自旋状态,在处理器上空跑并且轮询锁是否被释放。如果此时锁恰好被释放了,那么当前线程无须进入阻塞状态,而是直接获得这把锁。与线程阻塞相比,自旋状态可能会浪费大量的处理器资源。

Java 虚拟机给出的方案是自适应自旋,根据以往自旋等待时是否能够获得锁,来动态调整自旋的时间(循环数目)。

自旋状态还带来另外一个副作用,那便是不公平的锁机制。处于阻塞状态的线程,并没有办法立刻竞争被释放的锁。然而,处于自旋状态的线程,则很有可能优先获得这把锁。

轻量级锁

多个线程在不同的时间段请求同一把锁,也就是说没有锁竞争。
当进行加锁操作时,Java 虚拟机会判断是否已经是重量级锁。如果不是,它会在当前线程的当前栈桢中划出一块空间,作为该锁的锁记录,并且将锁对象的标记字段复制到该锁记录中。然后,Java 虚拟机会尝试用 CAS(compare-and-swap)操作替换锁对象的标记字段。CAS 是一个原子操作,它会比较目标地址的值是否和期望值相等,如果相等,则替换为一个新的值。如果是,则替换为刚才分配的锁记录的地址。由于内存对齐的缘故,它的最后两位为 00。此时,该线程已成功获得这把锁,可以继续执行了。如果不是那么有两种可能。

第一,该线程重复获取同一把锁。此时,Java 虚拟机会将锁记录清零,以代表该锁被重复获取。
第二,其他线程持有该锁。此时,Java 虚拟机会将这把锁膨胀为重量级锁,并且阻塞当前线程。
当进行解锁操作时,如果当前锁记录(你可以将一个线程的所有锁记录想象成一个栈结构,每次加锁压入一条锁记录,解锁弹出一条锁记录,当前锁记录指的便是栈顶的锁记录)的值为 0,则代表重复进入同一把锁,直接返回即可。否则,Java 虚拟机会尝试用 CAS 操作,比较锁对象的标记字段的值是否为当前锁记录的地址。如果是,则替换为锁记录中的值,也就是锁对象原本的标记字段。此时,该线程已经成功释放这把锁。如果不是,则意味着这把锁已经被膨胀为重量级锁。此时,Java 虚拟机会进入重量级锁的释放过程,唤醒因竞争该锁而被阻塞了的线程。

偏向锁

从始至终只有一个线程请求某一把锁。
在线程进行加锁时,如果该锁对象支持偏向锁,那么 Java 虚拟机会通过 CAS 操作,将当前线程的地址记录在锁对象的标记字段之中,并且将标记字段的最后三位设置为 101。
在接下来的运行过程中,每当有线程请求这把锁,Java 虚拟机只需判断锁对象标记字段中:最后三位是否为 101,是否包含当前线程的地址,以及 epoch 值是否和锁对象的类的 epoch 值相同。如果都满足,那么当前线程持有该偏向锁,可以直接返回。

    这里的 epoch 值是一个什么概念呢?先从偏向锁的撤销讲起。当请求加锁的线程和锁对象标记字段保持的线程地址不匹配时(而且 epoch 值相等,如若不等,那么当前线程可以将该锁重偏向至自己),Java 虚拟机需要撤销该偏向锁。这个撤销过程非常麻烦,它要求持有偏向锁的线程到达安全点,再将偏向锁替换成轻量级锁。如果某一类锁对象的总撤销数超过了一个阈值(对应 Java 虚拟机参数 -XX:BiasedLockingBulkRebiasThreshold,默认为 20),那么 Java 虚拟机会宣布这个类的偏向锁失效。具体的做法便是在每个类中维护一个 epoch 值,你可以理解为第几代偏向锁。当设置偏向锁时,Java 虚拟机需要将该 epoch 值复制到锁对象的标记字段中。在宣布某个类的偏向锁失效时,Java 虚拟机实则将该类的 epoch 值加 1,表示之前那一代的偏向锁已经失效。而新设置的偏向锁则需要复制新的 epoch 值。为了保证当前持有偏向锁并且已加锁的线程不至于因此丢锁,Java 虚拟机需要遍历所有线程的 Java 栈,找出该类已加锁的实例,并且将它们标记字段中的 epoch 值加 1。该操作需要所有线程处于安全点状态。如果总撤销数超过另一个阈值(对应 Java 虚拟机参数 -XX:BiasedLockingBulkRevokeThreshold,默认值为 40),那么 Java 虚拟机会认为这个类已经不再适合偏向锁。此时,Java 虚拟机会撤销该类实例的偏向锁,并且在之后的加锁过程中直接为该类实例设置轻量级锁。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值