JVM再学习

剑指JVM:虚拟机实践与性能调优 - 尚硅谷教育 - 微信读书

测验与复习

第一次复习–测验生成

  1. JVM整体架构考察。整体架构分为哪三层。分别是什么?通过绘制架构图来作答。

  2. 前端编译器是什么,作用是什么。要进行那些步骤?

  3. 类加载构成几个步骤。并且详细作答每个步骤的工作。

  4. 准备阶段和初始化阶段的工作尤其重要,请详细解释这两个阶段的工作。

  5. 类加载的几个步骤,与类加载器源码之间的对应关系。

  6. 类加载器的几种类型,以及这些类型之间的实际关系。

  7. 类加载器和class实例的关系。

  8. 类加载的命名空间,以及类的命名空间。

  9. 类加载的三个基本特征。

  10. 获取对象的类加载器的常见方法。

  11. 数组类的加载是怎样的?

  12. ClassLoader的主要工作由那些方法构成?他们分别起到什么作用?

  13. 为什么自定义类加载器无法覆盖加载核心类库?

  14. 让类加载器加载类的某个类的方法?

  15. 所谓双亲委派机制是什么?他的作用和好处坏处是什么?

  16. 打破双亲委派机制的原因和方法。

  17. 运行时数据区的构成,共享以及线程私有部分的划分。

  18. 虚拟机栈的作用,以及栈帧的构成。

  19. 局部变量表,操作数栈非常重要,描述一下具体的作用机制。

  20. 栈帧中除了局部变量表,和操作数栈剩下的信息有什么作用。

  21. 动态链接,静态链接。在方法调用中的区别。

  22. 方法调用的几种字节码指令,他们分别代表什么方法的调用。

字节码的结构

魔数u4 cafe babe

版本u4 52 = java8

常量池计数器u2 从1开始,0索引留给不需要的情况

常量池 表 #1 -> #计数器-1

类标识符 u2 public final abstrat class annotion interface 之类

类索引u2 名字

父类索引u2 父类名字

接口计数器 u2 接口数组长度

接口集合 表 接口索引数组

字段计数器 u2

字段 表

​ -访问标识符 u2

​ -字段名索引 u2

​ -字段描述符索引 u2

​ -字段属性计数器 u2

​ -字段属性 表

方法计数器 u2

方法 表

​ -访问标识符 u2

​ -方法名索引 u2

​ -方法描述符索引 u2

​ -方法属性计数器 u2

​ -方法属性 表

JVM整体架构

可以分为三层:

1 JVM外部,从源文件到Class文件,再装载到JVM

2 JVM运行时数据区,相当于就是避风港、运行的大后方。给执行程序提供后勤。

3 执行引擎层。和运行时数据区交互,完成执行任务。

image-20240907121039353

前段编译器

将高级语言源文件编译成Class文件的过程就是前端编译的过程。这是java跨平台执行的关键。

前段编译器只负责将高级语言,编译成字节码,不负责具体的性能优化之类的,这些要在执行引擎中的JIT及时编译器中负责。

前段编译器编译的流程如下。

词法分析

检查关键字,引用等等有没有错误。

语法分析

根据具体的高级语言的语法分析,是否出现语法错误。

语义分析

根据逻辑关系,分析是否有可能出现的逻辑问题。比如未初始化啊,数组越界之类的。这部分的功能有限,只能发现较为明显的逻辑错误。

生成字节码

类加载过程

字节码文件并不存放在内存中,而是在内存外(可能是在磁盘中,也可能在网络中,也可能是动态生成的)。当需要用到的时候再由类加载去寻找并载入内存到运行时数据区才可以被JVM使用。

类加载的过程有7个阶段。

加载——验证——准备——解析——初始化——使用——卸载

image-20240907124254477

加载

1 通过某种方式找到对应的Class文件,获取到二进制数据流。

2 解析二进制数据流,并根据来建立对应方法区中的数据结构。

3 创建java.lang.Class类对象实例,用来作为方法区访问类数据的入口。(也就是给一个索引到方法区数据结构的对象)

image-20240907125431626

Class类的构造方法是私有的,只有JVM可以创建。然后这个Class实例对象,是元空间的入口,也是实现反射的关键数据。通过Class类提供的接口方法,可以获得这个描述类的种种信息。

验证

属于链接中的第一步。

加载到内存之后,我们就可以快速的对字节码进行验证,保证字节码是合法,合规合理的。

字节码格式验证,字节码语义验证,字节码验证(逻辑),符号引用验证。

这和前端编译器,编译流程很像,词法分析对应格式验证,语法分析对应语义验证,语义分析对应字节码验证,以及符号引用验证。

准备阶段

这个时候,字节码通过验证了,那么可以开始完善类的静态部分了,这样一个Class对象算是可用。

也就是静态的成员变量,分配内存,初始化值。

1 分配内存,并进行初始化内存。

对于类的静态成员变量,都会先对内存空间初始化一个值,根据类型不同,初始化的也不同。

image-20240907223742392

java不支持boolean原生类型,内部实现实际上是通过int实现。默认int 0对应false。

这个初始化默认值对于static final修饰的基本数据类型无用,所以没有,因为其不需要多这么个初始为默认值的步骤,而可以直接确定最终值,在准备阶段直接进行显式赋值。(另外对于String类型在显式赋值中不涉及方法或构造器调用,其初始化是在链接阶段的准备环节进行)

对于实例变量,在准备阶段不会进行初始化,因为实例变量会随着实例对象分配到堆中,你不能提前知道要创建实例对象了并提前给创建好。

仅仅是初始化内存空间,相当于清理垃圾,并不会执行任何代码来赋值,这一步是后面初始化阶段做的事情)

解析阶段

将符号引用,转换为实际的直接引用。在实际的运行环境中,寻找符号引用的直接引用,替换到类对象中。

这个阶段有可能在初始化之后再进行,不确定。

初始化阶段

这是类装载的最后一个阶段。这个阶段,JVM才会执行初始化代码,根据类的初始化代码进行初始化类对象。

最重要的工作就是执行<cinit>()方法。(类的初始化方法),这个方法只能由java编译器生成,并只能被JVM调用(我们无法调用)。

()是根据类的静态成员赋值语句以及static代码块合并形成的。但是需要记住,在加载子类之前,JVM总会试图先加载其父类,所以父类的《cinit》一定先于子类的《cinit》执行。

如果没有静态赋值语句和静态代码块,那么编译器就不会产生《cinit》方法

cinit()方法只能在类加载的时候被调用一次,后续不能再调用,所以当JVM内部多个线程同时加载一个类的时候就需要保证线程安全,cinit方法自带同步锁,只有一个线程能执行cinit,其他线程都阻塞。等待执行完毕后会通知其他线程返回这个结果。

如果cinit中出现耗时长操作,会导致线程阻塞,难以排查。

初始化时机(cinit调用时机)

因为要执行代码,所以无疑初始化需要时间开销。所以并不是任何时候都能随时进行初始化的。初始化的时机就变得非常重要。

有两种使用类的方式,主动使用会导致初始化发生,被动使用不需要初始化。

1 主动使用(核心就是涉及用到Class对象

也就是当我们需要用到类的静态变量了,那么一定会进行初始化。

  • new实例对象,或者反序列化得到实例对象
  • 反射获取到Class对象
  • 调用静态方法,静态方法可能涉及到静态变量的访问,所以可能静态成员变量需要初始化。
  • 访问未被初始化阶段赋值的静态成员变量(如果访问static final修饰的在准备阶段就赋值的静态变量,那不需要初始化就可以,但是是static final String NAME = new String(”123:“)这种就不可以)

对于接口来说,所有的静态字段都是static final修饰的,效果和类一样。

初始化子类时,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。JVM虚拟机初始化一个类时,要求它的所有父类都已经被初始化,但是这条规则并不适用于接口。在初始化一个类时,并不会先初始化它所实现的接口;在初始化一个接口时,并不会先初始化它的父接口。因此,一个父接口并不会因为它的子接口或者实现类的初始化而初始化。只有当程序首次使用特定接口的静态字段时,才会导致该接口的初始化

  • 如果一个接口定义了default方法,那么直接实现或者间接实现该接口的类在初始化之前需要实现接口的初始化

  • JVM启动时,用户需要指定一个要执行的主类[包含main()方法的那个类],JVM会先初始化这个主类。这个类在调用main()方法之前被链接和初始化,main()方法的执行将依次加载,链接和初始化后面需要使用到的类。

  • 初次创建MethodHandle实例时,初始化该MethodHandle实例时指向的方法所在的类

2 被动使用

并不是在代码中出现的类,就一定会被加载或者初始化

  • 当访问一个静态字段时,只有真正声明这个字段的类才会被初始化。当通过子类引用父类的静态变量,不会导致子类初始化,而如果访问子类的,就会导致父类进行初始化,以及子类的初始化
  • 通过数组定义类引用,不会触发此类的初始化。直到给具体的数组中的元素赋予对象才会。数组定义的引用,只是从编译阶段确定,所以并不会导致初始化。
  • 引用常量不会触发此类或接口的初始化,因为常量在链接阶段已经被显式赋值
  • 调用ClassLoader类的loadClass()方法加载一个类,并不是对类的主动使用,不会导致类的初始化。通过反射获取到Class对象,才会导致初始化,例如Class.forName()会导致初始化。

类的卸载

类的卸载,涉及到类加载,类Class对象,类实例之间的引用关系。

**类加载器和加载的类对象,相互关联。**类加载内部Java集合存放了加载过的类对象引用,而类对象也引用加载他的类加载器。

类实例总是引用代表这个类的Class对象getClass(),类中都有一个静态属性class(通过类名.class可获得),引用着这个类的Class对象。

所以条条大路通Class对象。

所以什么时候卸载类,要等到Class对象不在被引用的时候。

image-20240908091226234

所以类被卸载的三个条件,都是围绕Class对象被引用

  1. 所有类的实例对象都被回收
  2. Class对象没有直接被引用
  3. 类的加载器被GC回收(只有用户自定义加载器能够被回收)

这样图中Order.class实例的三个方向的引用都断了,那么可以卸载类了。

类加载器

实际上属于类加载过程的细节。类加载过程中第一个加载阶段就是类加载器负责的。

类加载器在整个装载阶段,只能影响到类的加载,而无法改变类的链接和初始化行为(存疑)

类加载器必要性

了解类加载器机制,可以解决以下问题。

(1)避免在开发中遇到java.lang.ClassNotFoundException异常或java.lang.NoClassDefFoundError异常时手足无措。

(2)只有了解类加载器的加载机制,才能够在出现异常的时候快速地根据错误异常日志定位并解决问题。

(3)需要支持类的动态加载或需要对编译后的class文件进行加解密操作时,就需要与类加载器打交道。

(4)开发人员可以在程序中编写自定义类加载器来重新定义类的加载规则,以便实现一些自定义的处理逻辑

也就是异常处理,动态或者自定义加载

类加载器的命名空间

每一个类是通过加载它的类加载器加上类本身的名字来确定其在JVM中的唯一性!而不仅仅是通过类的名字。

每个类加载器都有自己的命名空间,命名空间由该类加载器(实例)及所有的父类加载器组成,在同一命名空间中,不会出现类的完整名字(包括类的包名)相同的两个类;

这样就保证了类的唯一性。

在不同的命名空间中,有可能会出现类的完整名字(包括类的包名)相同的两个类;在大型应用中,我们往往借助这一特性,来运行同一个类的不同版本。

也就是通过不同的类加载器,加载一个类的不同版本。

类加载的基本特征

通常有三大特征:双亲委派,可见性,单一性

双亲委派实际上就是优先交给上一级加载器加载。这样是为了避免类在其他地方重复加载,第二个是恶意代码不能通过重复加载来替换核心类库。

可见性是下级加载器,可以访问上级加载器加载了哪些类型,反过来是不行的。也就是说高级加载器加载的类,只能看到自己和更高级的类的存在,如果和同级或者下级类进行交互,是ClassNotFound的。而下级加载器加载的类就可以看到上级加载器加载的类的存在。(就像是父类和子类的关系一样,但是只能是访问关系一样)

单一性,因为父加载器加载过的类型对于子加载器是可见的,所以父加载器加载的类型就不会在子加载器中重复加载。但是在同一级的加载器中(兄弟加载器)相互是不可见的,所以同一个类可以被同级别的加载器加载多次

类加载器的分类

实际上就是类加载器分级,是根据什么分级的,不同级别的类加载器有什么职责。

从最本质的来分,JVM有两种类加载器,启动类加载器和自定义类加载器。

自定义类加载器通常是指由开发人员自定义的一类类加载器,但是Java虚拟机规范中规定的更为广泛,凡是从抽象类ClassLoader派生而来的类加载器都是自定义类加载器。那么不是从ClassLoader派生而来的类加载器自然就是启动类加载器了。

无论类加载器的类型如何划分,在程序中我们最常见的类加载器结构如图20-1所示,其中扩展类加载器和应用程序类由抽象类ClassLoader派生而来

image-20240908104022594

实际上不同加载器之间是聚合关系,也就是下级加载器,有上级加载器的引用。而不是继承关系。

只是在ClassLoader这个抽象类中,有一个成员变量引用上一级的加载器,叫做parent。所以上级加载器才被叫做父加载器。

引导类加载器(启动类加载器)

这两种称呼,一种是基于职责负责引导程序运行,一种是指是启动程序的类加载器。

引导类加载器(BootstrapClassLoader,又称启动类加载器)使用C/C++语言实现,嵌套在JVM内部。

引导类加载器不继承java.lang.ClassLoader,没有父类加载器。出于安全考虑,引导类加载器主要用来加载Java的核心库,也就是“JAVA_HOME/jre/lib/rt.jar”或“sun.boot.class.path”路径下的内容,指定为扩展类和应用程序类加载器的父类加载器

所以引导类加载器作用有限,主要用来加载核心类库。

扩展类加载器

扩展类加载器(ExtensionClassLoader)由Java语言编写,间接继承与ClassLoader

扩展类加载器主要负责从java.ext.dirs系统属性所指定的目录或者JDK的安装目录的jre/lib/ext子目录下加载类库。如果用户创建的类放在上述目录下,也会自动由扩展类加载器加载。简言之扩展类加载器主要负责加载Java的扩展库。

应用程序类加载器

也叫做系统加载器,也是由Java语言编写,间接继承于ClassLoader类父类加载器为扩展类加载器。

负责加载环境变量classpath或系统属性java.class.path指定路径下的类库,应用程序中的类加载器默认是应用程序类加载器。(在IDE中,可以查看到JDK的CLASSPATH。在命令行执行的时候CLASSPATH就是当前路径)

它是用户自定义类加载器的默认父类加载器,通过ClassLoader的getSystemClassLoader()方法可以获取到该类加载器。

自定义加载器

自定义加载器有很多好处。

  1. 实现插件效果,即插即用。通过启用自定义加载器,加载额外功能。不需要的时候直接回收自定义加载器,就拔除功能。
  2. 隔离加载类。同级类加载器之间相互隔离。所以我们可以通过将不同的类簇通过不同的同级类加载器加载实现隔离。
  3. 修改类加载方式,除了启动类加载器之外,其他的类加载器并非一定引入。所以我们可以改变类加载器的加载。
  4. 扩展加载源。如果需要加载从咔咔郭郭来的类,可以通过自定义加载器载入。
  5. 提高程序的安全性。在一般情况下,使用不同的类加载器去加载不同的功能模块,会提高应用程序的安全性。但是,如果涉及Java类型转换,则加载器反而容易产生不美好的事情。在做Java类型转换时,只有两个类型都是由同一个加载器所加载,才能进行类型转换,否则转换时会发生异常。

获取常见类的加载器

方式 加载器类型
class对象.getClassLoader() 获取class对象的类加载器
Thread.currentThread().getContextClassLoader() 当前线程上下文的类加载器
ClassLoader.getSystemClassLoader() 获取系统类加载器
classLoader对象.getParent() 获取父类加载器

这些加载器,大多都是由应用类加载器来充当。

image-20240908120212235

image-20240908120221925

可以看到都是应用类加载器加载的。

引导类加载器结果为null,原因是引导类加载器是C++语言编写,并不是一个java对象,所以这里用null展示

数组类特殊

数组类的Class对象,不是由类加载器创建的,而是在Java运行期JVM根据需要自动创建的。数组类的类加载器可以通过Class.getClassLoader()方法返回,如果数组元素是引用数据类型,类加载器与数组当中元素类型相同,如果数组元素类型是基本数据类型,就没有类加载器

1
2
3
4
System.out.println(int[][].class.getClassLoader());
//输出null
System.out.println(ClassLoaderTest[][].class.getClassLoader());
//输出sun.misc.Launcher$AppClassLoader@18b4aac2

源码分析

有必要学习类加载器的源码。

image-20240908122240865

ClassLoader主要方法

抽象类ClassLoader的主要方法(内部没有抽象方法)如下。

1)public final ClassLoader getParent()该方法作用是返回该类加载器的父类加载器。

2)public Class<?>loadClass(String name)throws ClassNotFoundException该方法作用是加载名称为name的类,返回结果为java.lang.Class类的实例。如果找不到类,则抛出“ClassNotFoundException”异常。

该方法中的逻辑就是双亲委派模型的实现

具体操作就是:

  1. sychronized保证同步没问题
  2. 查看自己是否加载过 通过findLoadedClass ,也就是protected final Class<?>findLoadedClass(String name) )
  3. 调用父加载器进行加载(如果父为空,调用findBootstrapClassOrNull加载)
  4. 父加载器失败,自己加载 通过findClass(name)
  5. 进行链接操作 resolveClass (也就是 验证,准备,解析 这三个操作)

这里的findClass方法就很重要了,最为加载器的兜底逻辑,负责查找二进制名为name的类,返回的是Class类实例。

protected Class<?>findClass(String name)throws ClassNotFoundException

在jdk1.2之后,官方已经不建议我们重写loadClass方法,因为loadClass方法中实现保证了双亲委派机制的逻辑。我们自己的逻辑建议写在findClass方法中。当loadClass()方法中父类加载器加载失败后,则会调用自己的findClass()方法来完成类加载,这样就可以保证自定义的类加载器也符合双亲委托模型。

在findClass()中,应该调用defineClass(),将找到的二进制流转换成Class对象。

protected final Class<?>defineClass(String name,byte [] b,int off,int len)

通过这个方法不仅能够通过class文件实例化Class实例对象,也可以通过其他方式实例化Class对象,如通过网络接收一个类的字节码,然后转换为byte字节流创建对应的Class对象

protected final void resolveClass(Class<?>c)

使用该方法可以使用类的Class对象创建完成的同时也被解析。前面我们说链接阶段主要是对字节码进行验证,为类变量分配内存并设置初始值同时将class文件中的符号引用转换为直接引用

也就是

SecureClassLoader与URLClassLoader

ClassLoader中有很多方法没有实现。SecureClassLoader新增了对Class源的验证,权限之类的方法。

他的子类URLClassLoader给ClassLoader众多没实现的方法提供了实现。例如findClass(),findResouce()方法。新增了通过URLClassPath类来协助获取Class字节码流的功能。

我们可以通过继承自URLClassLoader来避免实现过于复杂的findClass()和字节码获取代码。

ExtClassLoader与AppClassLoader

这两个类加载器都继承自URLClassLoader。是sun.misc.lanucher的内部静态类。sun.misc.lanucher主要用来启动主应用程序。

类加载调用方法

一般通过Class.forName(全限定名),或者classLoader实例.loadClass(全限定名)加载一个类。

不同的是Class方法会在加载到内存同时进行初始化。

而classLoader实例方法,只会加载到内存,不会触发实例化。

让自定义类加载器加载类的办法

如果在findClass中不做什么改变,那么类一般都会让父加载器也就是AppClassLoader给加载了。所以我们需要在loaderClass的时候传入类名,而不是全限定名,这样父加载器就找不到类,会交给自定义加载器加载。在findClass中我们再拼接出类的路径,找到类文件,然后进行加载。

双亲委派模型的改变

双亲委派机制并不是必须的,而是java设计者推荐的一种类加载器实现机制。

他的好处是:

  1. 避免重复加载
  2. 保护程序安全

打破双亲委派机制也无法破坏核心类库的唯一加载

在ClassLoader中的final defineClass方法中,为核心类库提供了一层保护机制。无论是什么类加载器,最终都会调用defineClass这个方法,这个方法final不能重写,在其内部调用preDefineClass方法,对核心类库进行保护。

他的劣势是:

​ 因为我们只设计了父级类加载器的成员变量引用,那么我们的类去寻找其他的类的时候,只能先获取自己的ClassLoader,然后从ClassLoader中获取findLoadedClass(),找不到只能向上找loadedClass。所以无法访问下级类加载器加载的类。

破坏双亲委派机制的三种

为了兼容性

jdk1.2之前,并没有引入双亲委派机制,所以自定义类加载器很多都是通过重写loadClass实现的。在引入之后,为了兼容这些代码,就没有以技术手段防止loadClass被重写。创建了一个prorected findClass来代替重写loadClass。

SPI场景(不同的类加载器加载的类之间的交互)

简单来说就是接口定义在了启动类加载器中,而实现类定义在了其他类加载器中,当启动类加载器需要加载其他子类加载器路径中的类时,需要使用线程上下文类加载器(默认是应用程序类加载器),这样以上下文加载器为中介,使得启动类加载器中的代码也可以访问应用类加载器中的类。

线程上下文类加载器,实际上是一个帽子,主要看我们让哪一个类加载器来带上这个帽子。带上什么帽子起什么作用。原来的findLoadedClass是一条只能向上的线,通过线程上下文类加载器,这样就可以形成一个环。我们通常让APPClassLoader来充当线程上下文类加载器,这样AppClassloader,ExtratClassLoader,BootstrapClassLoader形成了一个环,那么这三个类加载器中加载的类就都相互可查询可见了。

1

为了热部署

追求程序的动态性,代码热部署,模块热替换等。

java并不天生支持热替换。热替换是指在程序的运行过程中,不停止服务,只通过替换程序文件来修改程序的行为。热替换的关键需求在于服务不能中断,修改必须立即表现正在运行的系统之中。

如果一个类已经加载到系统中,通过修改类文件,并无法让系统再来加载并重定义这个类。

但是如果我们就是想要重新加载并定义这个类呢?为了不影响程序的运行,我们不能卸载这个类,但是我们可以通过加载一个同名类来替换。要做到这件事,首先我们需要防止触发重复加载,我们需要换一个类加载器,这样JVM会认为是不同的类,然后加载进去,不能调用loadClass这样会交由AppClassLoader加载,就是会认为加载过了。

运行时数据区

终于到了这里,从前段编译器开始,到类加载初始化终于是通过了JVM的第一层。现在是JVM的第二层,也是主体部分。

所谓运行时数据区,实际上是JVM管理内存的一种划分。

JVM对内存的管理,可以从生命周期的角度进行划分。一种内存是伴随着JVM启动开始,直到JVM退出结束的内存,这种就是共享内存。另一种是随着线程创建时创建,线程销毁时释放的内存,这种就是线程私有内存。

共享内存比较简单,所以我们这里从线程角度出发,了解JVM的内存管理。

image-20240908162858897

共享区域

包括方法区,堆区

线程私有

每个线程都有自己的

  1. 程序计数器(用来记录现在执行到的代码位置)
  2. 虚拟机栈(存储栈帧,栈帧里面存储线程执行过程中的方法调用数据,局部变量,等等)
  3. 本地方法栈,存储本地方法的栈

在HotSpot虚拟机中,常见的守护线程主要包括以下3种。

(1)垃圾回收线程:这种线程对在JVM里不同种类的垃圾收集行为提供了支持。

(2)编译线程:这种线程在运行时会将字节码编译成本地代码。

(3)手动创建守护线程:在调用start()方法前调用setDaemon(true)可以将线程标记为守护线程。

程序计数器

实际上记录的是当前线程执行代码所在的行数位置。字节码解释器就是通过改变这个计数器的值来选取下一条指令来执行。它是程序控制流的指示器。

分支、循环、跳转、异常处理、线程恢复等基础功能,都需要依赖这个计数器来完成。

如果线程执行的是一个Java方法,那么程序计数器指令的地址就是下一次执行的代码。如果执行的是本地方法,那么程序计数器中的值是空(undefined)

程序计数器既没有垃圾回收也没有内存移除。因为程序计数器就是很简单的一个存放数值的区域。

程序计数器和执行引擎直接交互。

程序计数器存储的数据结构就是偏移地址,执行引擎根据得到的指令地址,然后操作JVM局部变量表,操作数栈,进行操作。

image-20240909094330081

程序计数器的设计使得多线程执行中,CPU知道当前线程之前执行到那条指令了。

线程私有,使得每个线程之间相互不影响,可以有条不紊的并发执行。

虚拟机栈

栈由栈帧组成,每个栈帧又包括局部变量表、操作数栈、动态链接、方法返回地址和一些附加信息。

因为java语言要实现跨平台性,因为不同平台的CPU架构不同,所以JVM不能基于寄存器,是基于栈架构(操作数栈)设计的Java指令。这样设计的优点是可以跨平台,指令集小,编译器容易实现。但是实现相同功能所需要更多的指令。

虚拟机栈,早起也叫做Java栈,每个线程创建的时候都会创建一个虚拟机栈,虚拟机栈中存放栈帧。

每个栈帧代表一个方法的调用,存放方法中的局部变量、操作数栈(没错栈中栈)、动态链接、方法出口等信息。

栈帧入栈和出栈的过程就是一个方法被调用到执行完毕的过程。

虚拟机栈解决的事程序运行的问题,每个方法之间的调用顺序…..

特点

  • 快速有效,速度仅次于程序计数器
  • 不存在垃圾回收,但是内存存在溢出(栈溢出)
  • 栈的操作先进先出。方法调用入栈,方法结束出栈。

Java虚拟机规范中允许栈是可动态大小或者固定的。但是现在HotSpot虚拟不支持栈动态扩展。

虚拟机栈可能出现的异常有两个1 栈溢出异常stackoverflowerror 2 outOfMemoryERROR 无法申请到内存

可以通过虚拟机启动参数 -Xss (也就是 stacksize缩写) 来设置最大栈空间

栈帧

栈帧是Java方法的运行环境,栈帧是一个内存区块,也是一个数据集,维系着方法执行中的各种数据信息。

局部变量表(Local Variables)方法中的局部变量内存分配就在栈帧中

操作数栈,每个栈帧有自己的操作数栈(或者是表达式栈)

动态链接(指向运行时常量池的方法引用,代码所在地)

方法返回地址(方法正常返回的定义和异常的定义)

一些附加信息。

image-20240910112159800

局部变量表

也叫做局部变量数组,或者本地变量表。只有基本数据类型的数据,会创建在局部变量表中,引用数据类型通过new分配内存都是在堆中。

局部变量实际上定义为一个数字数组,用于存储方法的参数,和方法內的局部变量。对于基本数据类型,则直接存储其值,对于引用类型的存储指向对象的引用。returnAddress也是一样的。

局部变量表是线程私有的,所以没有数据安全问题。

局部变量表的大小是在编译期间就定下来的,保存在方法的code属性表中的maxinum local variables数据项中。

局部变量表影响栈帧的大小,栈帧的大小影响虚拟机栈能够容纳栈帧的次数,也就是影响栈能够的方法嵌套数。

局部变量表中的变量只在房钱方法中有效,在方法执行结束后,局部变量表就会随这栈帧被销毁。

局部变量表中的基本的存储单位是Slot(变量槽),局部变量表中存放8中基本数据类型,引用类型,returnAddress类型。

32位及其以下的数据类型,占用一个slot(byte,short,boolean,char,包括引用类型,returnAddress都转换成int进行存储),而double,long这些都是占据2个slot。

每个slot会分配一个索引,通过索引号即可访问到局部变量表中的数据。

slot可以被重复利用,当slot中的局部变量过了作用域,那么就可以将slot重复利用

1
2
3
4
5
6
7
8
public void test(){
{
int a = 1;
System.out.println(a);
}
//当a变量的作用域结束,b会重用a的slot
int b =1;
}

局部变量没有系统初始化过程,这意味着我们必须要手动赋值才能使用,否则会报错。

局部变量表中的returnAddress完成方法的传递,局部变量表还是垃圾回收的根节点,众多栈中的众多栈帧中的局部变量表中直接或者间接引用的对象都不会被回收。

操作数栈

栈帧中还有一个栈,就是操作数栈,也叫做表达式栈。是用来正确解析表达式执行顺序的一个重要结构。

主要用于保存计算过程的中间结果,同时作为计算过程中的变量临时存储的空间

在方法的实行过程中,字节码指令会将数据写入操作数栈,或从操作数栈中取出,使用后再把结果压入栈。

例如执行2+3的时候,会现将2,3压入栈,然后弹栈,得出结果5再压入栈

每个操作数栈深度是从编译阶段就确定好的,定义在方法的code属性表中的maxinum stack size 数据项中。

栈中的任何一个元素都可以是任意的Java数据类型。32位的类型占用一个栈单位深度,64位的类型占用两个栈单位深度。这个和局部变量表都有点像了。

方法在返回值的时候就是将返回值,压入操作数栈(按理说这个时候操作数栈之中只会出现一个数据)

JVM的解释引擎是基于栈的执行引擎,其中的栈指的就是操作数栈

栈顶缓存技术

也就是因为操作数栈的访问太过频繁,为了提升效率,将操作数栈栈顶附近的几个操作数放入寄存器。因为寄存器的空间有限,所以只能作为几个操作数的缓存。这个缓存技术和各种缓存中间件有同工之处。

动态链接

每个栈帧中都会存储一个在运行时常量池中,该栈帧所属方法的引用。(简单提一下,运行时常量池是在方法区中的,方法区中的各种信息,通过运行时常量池来寻址)

包含这个引用的目的就是为了支持当前方法的代码能够实现动态链接(Dynamic Linking)。

Current Class Constant Pool Reference区域为动态链接,method references区域代表着方法的引用地址,即直接引用。

image-20240910212618910

在运行时,将方法的符号引用,替换成了直接引用。

方法的调用

之前在虚拟机栈,中栈帧保存的动态链接就是方法调用的一种。

动态链接实际上就是将符号引用转换成调用方法的直接引用。在JVM中,除了动态链接,还有一种叫做静态链接。

JVM中,将符号引用转换为调用方法的直接引用与方法的绑定机制相关,方法的绑定机制有两种,分别是静态链接和动态链接。

封装、继承和多态等面向对象特性,既然编程语言具备多态特性,那么自然也就具备静态链接和动态链接两种绑定方式。

静态链接

在字节码载入JVM之后,如果一个方法在编译阶段可知,运行时不变(也就是方法所要执行的代码是已知且不变的),这种情况下,调用方法的符号引用转换成为直接引用的过程叫做静态链接。

动态链接

执行的具体代码无法确定下来,只有在程序运行时将方法的符号引用转换成直接引用,这种引用转换具有动态性。

区分

静态链接和动态链接一般还会被称为早期绑定(Early Binding)和晚期绑定(Late Binding)。绑定的意思就是一个字段、方法或者类的符号引用被转换为直接引用的过程,这仅仅发生一次。

静态链接是指方法在编译期就确定了具体的调用版本,这个版本在运行时是不可变的,一般称这样的方法为非虚方法。除去非虚方法的都叫作虚方法。

实际上和C++中的虚方法类似,虚方法就是可以被重写的方法。这类方法在调用的时候并不知道调用的具体会是虚方法的哪个重写版本。只有在运行时才能确定。而final修饰的方法不能再被重写,所以不算是虚方法了。静态方法也没有重写。

有些时候如果不能很好的区分虚方法和非虚方法,可以通过字节码文件的指令来区分。

虚拟机中提供了以下5条方法调用指令。

(1)invokestatic:调用静态方法,解析阶段确定唯一方法版本。

(2)invokespecial:调用方法、私有及父类方法,解析阶段确定唯一方法版本。

(3)invokevirtual:调用所有虚方法。

(4)invokeinterface:调用接口方法。

(5)invokedynamic:动态解析出需要调用的方法,然后执行。

这里有另一种解释

  1. invokestatic:用于调用静态方法。
  2. invokespecial:用于调用私有实例方法、构造器,以及使用 super 关键字调用父类的实例方法或构造器,和所实现接口的默认方法。
  3. invokevirtual:用于调用非私有实例方法。
  4. invokeinterface:用于调用接口方法。
  5. invokedynamic:用于调用动态方法。

https://blog.csdn.net/Herishwater/article/details/123389961

https://blog.csdn.net/Herishwater/article/details/123366249


JVM再学习
https://wainyz.online/wainyz/2024/09/07/JVM再学习/
作者
wainyz
发布于
2024年9月7日
许可协议