转载

【JVM和性能优化3】JVM的执行子系统


在这里插入图片描述

Class 文件格式

一般情况下Java代码执行流程如下图:
在这里插入图片描述

字节码

字节码文件 .class文件的产生是最关键的,是Java语言跨平台的基础,.class文件跟不同的操作系统之间对接的差异性由JVM后台自动帮我们解决,我们只需要将代码编译成.class 字节码文件,

Class类的本质

任何一个Class文件都对应着唯一一个类或接口的定义信息,但反过来说,Class文件实际上它并不一定以磁盘文件的形式存在。Class 文件是一组以8位字节为基础单位的二进制流。

Class文件格式

数据项目严格按照固定顺序存储在Class文件中,数据之间无空隙。Class文件格式采用一种类似于的伪结构来C语言结构体存储数据,这种伪结构中只有两种数据类型:无符号数和表,整个Class文件本质上就是一张表。

  1. 无符号数属于基本的数据类型,以u1、u2、u4、u8来分别代表1个字节、2个字节、4个字节和8个字节的无符号数,无符号数可以用来描述数字、索引引用、数量值或者按照UTF-8编码构成字符串值。
  2. 表是由多个无符号数或者其他表作为数据项构成的复合数据类型,所有表都习惯性地以“_info”结尾。表用于描述有层次关系的复合结构的数据,

一个Class文件格式大致如下:

ClassFile { 
    u4 magic;  // 魔法数字,表明当前文件是.class文件,固定0xCAFEBABE
    u2 minor_version; // 分别为Class文件的副版本和主版本
    u2 major_version; 
    u2 constant_pool_count; // 常量池计数 常量池内容不一样的要计数
    cp_info constant_pool[constant_pool_count-1];  // 常量池内容
    u2 access_flags; // 类访问标识
    u2 this_class; // 当前类
    u2 super_class; // 父类
    u2 interfaces_count; // 实现的接口数
    u2 interfaces[interfaces_count]; // 实现接口信息
    u2 fields_count; // 字段数量
    field_info fields[fields_count]; // 包含的字段信息 
    u2 methods_count; // 方法数量
    method_info methods[methods_count]; // 包含的方法信息
    u2 attributes_count;  // 属性数量
    attribute_info attributes[attributes_count]; // 各种属性
}
  1. 魔数

大多数情况下,我们都是通过扩展名来识别一个文件的类型的,比如我们看到一个.txt类型的文件我们就知道他是一个纯文本文件。但是,扩展名是可以修改的,那一旦一个文件的扩展名被修改过,那么怎么识别一个文件的类型呢。这就用到了我们提到的“魔数”。在Java中我们用前四个字节来表明文件的格式Class文件格式必须为0xCAFEBABE

  1. 主次版本号

第5和第6个字节是次版本号(Minor Version),第7和第8个字节是主版本号(Major Version)。Java的版本号是从45开始的,JDK 1.1之后的每个JDK大版本发布主版本号向上加1高版本的JDK能向下兼容以前版本的Class文件,但不能运行以后版本的Class文件,即使文件格式并未发生任何变化,虚拟机也必须拒绝执行超过其版本号的Class文件。

具体的Class文件的数据格式就直接百度或者看附录的了。一般我们可以通过javap *.Class 文件实现字节码的查看,也可以通过JD-GUI进行反编译。

类加载机制

在这里插入图片描述
类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期包括:加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载(Unloading)7个阶段。其中验证、准备、解析3个部分统称为连接(Linking)。

在初始化阶段,虚拟机严格规定了有且只有5种情况必须立即对类进行初始化(而加载、验证、准备自然需要在此之前开始):

  1. 遇到new、getstatic、putstaticinvokestatic这4条字节码指令时,如果类没有进行过初始化,则需要先触发其初始化。生成这4条指令的最常见的Java代码场景是:使用new关键字实例化对象的时候、读取或设置一个类的静态字段(被final修饰、已在编译期把结果放入常量池的静态字段除外)的时候,以及调用一个类的静态方法的时候。
  2. 使用java.lang.reflect包的方法对类进行反射调用的时候,如果类没有进行过初始化,则需要先触发其初始化。
  3. 当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
  4. 当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类。
  5. 当使用JDK 1.7的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,并且这个方法句柄所对应的类没有进行过初始化,则需要先触发其初始化,跟1有点类似。

其余的引用方式是不会触发初始化的,demo如下:

class SuperClazz {

    static {
        System.out.println("SuperClass init!");
    }

    public static int value = 123; //0
    public static final String HELLOWORLD = "hello,sowhat";
    public static final int WHAT = value;
}

class SubClazz extends SuperClazz {
    static {
        System.out.println("Subclass init!");
    }
}

public class Main {

    public static void main(String[] args) {
        //System.out.println(SubClazz.value); // 只会直接父类,子类的初始化不会发生
        //SuperClazz[] sca = new SuperClazz[10]; // 什么都不会触发初始化, 只是知道数组类型而已
        //System.out.println(SubClazz.HELLOWORLD); // 直接从常量池的数据使用,子类 父类都不会调用
        System.out.println(SubClazz.WHAT); // 这里是引用另外一个变量,会直接夫类初始化
    }
}

加载

要判断文件格式是否OK,是否可以找到文件。
虚拟机需要完成以下3件事情:

  1. 通过一个类的全限定名来获取定义此类的二进制字节流
  2. 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
  3. 堆内存中生成一个代表这个类的java.lang.Class对象(也就是反射),作为方法区这个类的各种数据的访问入口。
  4. 平常我们认识到的是一个class 然后new 出object。但是class 也是一个object。 这个object由JVM通过java.lang.Class来统一给我们生成。

验证

是连接阶段的第一步,这一阶段的目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。但从整体上看,验证阶段大致上会完成下面4个阶段的检验动作:文件格式验证、元数据验证、字节码验证、符号引用验证。大致的工作如下:

验证Java加载进内存的二进制文件是否符合JVM以及Java规范,并且不会危害虚拟机的自身安全。比如说符号引用中的类、字段、方法的访问性(private、protected、public、default)是否可被当前类访问、类中的字段、方法是否与父类产生矛盾……

准备

准备阶段是指准备要执行的制定的类,这包含了给这个类的静态变量数据分配内存空间,并分配初始值(仅仅是分配内存空间,具体初始化在最后一步)。
public static int age = 14这句代码在初始值设置之后为 0,因为这时候尚未开始执行任何 Java 方法。而把 age 赋值为 14 的 putstatic 指令是程序被编译后,存放于 clinit() 方法中,在初始化阶段才会对 value 进行赋值。但是如果添加了final就会在这个阶段直接赋值为14。

解析

这个阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。符号引用转换为直接引用就发生在解析阶段,解析阶段可能在初始化前,也可能在初始化之后。

为什么要用符号引用呢?这是因为类加载之前,javac会将源代码编译成.class文件,这个时候javac是不知道被编译的类中所引用的类、方法或者变量他们的引用地址在哪里,所以只能用符号引用来表示。在解析阶段又需要根据关联上数据。
1.符号引用

符号引用以一组符号来描述所引用的目标,符号可以使任何形式的字面量。

String str = "sowhat";
System.out.println("String" + str);
  1. 直接引用

直接引用可以使直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。直接引用和迅疾的内存布局实现有关

String str = "sowhat";
System.out.println("String" + "sowhat");

初始化

是类加载过程的最后一步,前面的类加载过程中,除了在加载阶段用户应用程序可以通过自定义类加载器参与之外,其余动作完全由虚拟机主导和控制。到了初始化阶段,才真正开始执行类中定义的Java程序代码在准备阶段,变量已经赋过一次系统要求的初始值,而在初始化阶段,则根据程序员通过程序制定的主观计划去初始化类变量和其他资源,或者可以从另外一个角度来表达:初始化阶段是执行类构造器<clinit>()方法的过程。<clinit>()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{}块)中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序所决定的。
<clinit>()方法对于类或接口来说并不是必需的,如果一个类中没有静态语句块,也没有对变量的赋值操作,那么编译器可以不为这个类生成<clinit>()方法。
虚拟机会保证一个类的<clinit>()方法在多线程环境中被正确地加锁、同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的<clinit>()方法,其他线程都需要阻塞等待,直到活动线程执行<clinit>()方法完毕。如果在一个类的<clinit>()方法中有耗时很长的操作,就可能造成多个进程阻塞。

例如,如果一个类中包含声明public static int age=14;那么变量age被赋值为14的过程将在初始化阶段进行,另外倘若静态变量并没有指定初值,那么JVM会自动给静态变量赋予一个初值,下表给出Java基本类型和引用变量的缺省值。
在这里插入图片描述

类加载器

前面我们说到了类加载分为7个部分,而在链接阶段我们一般是无法干预的,我们大部分干预的阶段类加载阶段(ClassLoder)。
对于任意一个类,都需要由加载它的类加载器和这个类本身一同确立其在Java虚拟机中的唯一性,每一个类加载器,都拥有一个独立的类名称空间。这句话可以表达得更通俗一些:比较两个类是否“相等”,只有在这两个类是由同一个类加载器加载的前提下才有意义,否则,即使这两个类来源于同一个Class文件,被同一个虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相等。
这里所指的相等,包括代表类的Class对象的 isAssignableFrom()方法,equals()方法、isInstance()方法的返回结果,也包括使用instanceof关键字做对象所属关系判定等情况。

ClassLoader 里面有三个重要的方法 loadClass()findClass()defineClass(),平常用到的主要函数如下:

  1. loadClass() 方法是加载目标类的入口,它首先会查找当前 ClassLoader 以及它的双亲里面是否已经加载了目标类,找到直接返回。
  2. 如果没有找到就会让双亲尝试加载,如果双亲都加载不了,就会调用 findClass() 让自定义加载器自己来加载目标类。ClassLoader 的 findClass() 方法是需要子类来覆盖的,不同的加载器将使用不同的逻辑来获取目标类的字节码。拿到这个字节码之后再调用 defineClass() 方法将字节码转换成 Class 对象。
  1. getParent() 返回该类加载器的父类加载器。
  2. loadClass(String name) 加载名称为 name的类,返回的结果是 java.lang.Class类的实例。
  1. 此方法负责加载指定名字的类,首先会从已加载的类中去寻找,如果没有找到;从parent ClassLoader[ExtClassLoader]中加载;如果没有加载到,则从Bootstrap ClassLoader中尝试加载(findBootstrapClassOrNull方法), 如果还是加载失败,则自己加载。如果还不能加载,则抛出异常ClassNotFoundException。
  2. 如果要改变类的加载顺序可以覆盖此方法;
  1. findClass(String name) 查找名称为 name的类,返回的结果是 java.lang.Class类的实例。
  2. findLoadedClass(String name) 查找名称为 name的已经被加载过的类,返回的结果是 java.lang.Class类的实例。
  3. defineClass(String name, byte[] b, int off, int len) 把字节数组 b 中的内容转换成 Java 类,返回的结果是 java.lang.Class类的实例。这个方法被声明为 final的。
  4. resolveClass(Class<?> c) 链接指定的 Java 类。

双亲委派机制

在这里插入图片描述

定义:当某个类加载器需要加载某个.class文件时,它首先把这个任务委托给他的上级类加载器,递归这个操作,如果上级的类加载器没有加载,自己才会去加载这个类。
作用:

  1. 防止重复加载同一个.class。通过委托去向上面问一问,加载过了,就不用再加载一遍。保证数据安全。
  2. 保证核心.class不能被篡改。通过委托方式,不会去篡改核心.clas,即使篡改也不会去加载(自己写个java.lang.String试试),即使加载也不会是同一个.class对象了。不同的加载器加载同一个.class也不是同一个Class对象。这样保证了Class执行安全。
  1. BootstrapClassLoader(启动类加载器)

c++编写,加载java核心库 java.*,构造ExtClassLoader和AppClassLoader。由于引导类加载器涉及到虚拟机本地实现细节,开发者无法直接获取到启动类加载器的引用,所以不允许直接通过引用进行操作

  1. ExtClassLoader (标准扩展类加载器)

java编写,加载扩展库,如classpath中的jre ,javax.*或者
java.ext.dir 指定位置中的类,开发者可以直接使用标准扩展类加载器。

  1. AppClassLoader(系统类加载器)

java编写,加载程序所在的目录,如user.dir所在的位置的class

  1. CustomClassLoader(用户自定义类加载器)

java编写,用户自定义的类加载器,可加载指定路径的class文件

总结: Java的双亲委派机制类加载只是一直Java类加载的一种模式。但是当我们使用一些第三方框架的时候比如JDBC跟具体实现的时候,反而会引发错误,因为JDK自带的JDBC接口由启动类加载,而第三方实现接口由应用类加载。这样相互之间是不认识的,因此JDK引入了线程上下文加载器来实现用同一个加载器加载。

Tomcat:服务器类加载器也使用代理模式,不同的是它总是先尝试去加载某个类,如果找不到再用上一级的加载器,这跟一般类加载器顺序正好相反。

栈桢

在这里插入图片描述
JVM中除了一些native方法是基于本地方法栈实现的,所有的Java方法几乎都是通Java虚拟机栈来实现方法的调用和执行过程(当然,需要程序计数器、堆、方法区的配合),所以Java虚拟机栈是虚拟机执行引擎的核心之一。而Java虚拟机栈中出栈入栈的元素就称为栈帧。

栈帧(Stack Frame):

用于支持虚拟机进行方法调用和方法执行的数据结构。栈帧存储了方法的局部变量表、操作数栈、动态连接和方法返回地址等信息。每一个方法从调用至执行完成的过程,都对应着一个栈帧在虚拟机栈里从入栈到出栈的过程。

  1. 局部变量表
    局部变量表(Local Variable Table)是一组变量值存储空间,用于存放方法参数和方法内定义的局部变量。局部变量表的容量以变量槽(Variable Slot)为最小单位,Java虚拟机规范并没有定义一个槽所应该占用内存空间的大小,但是规定了一个槽应该可以存放一个32位以内的数据类型。

  2. 操作数栈
    操作数栈(Operand Stack)也常称为操作栈,它是一个后入先出栈(LIFO)。同局部变量表一样,操作数栈的最大深度也在编译的时候写入到方法的Code属性的max_stacks数据项中。

  3. 动态连接
    在一个class文件中,一个方法要调用其他方法,需要将这些方法的符号引用转化为其在内存地址中的直接引用,而符号引用存在于方法区中的运行时常量池。
    Java虚拟机栈中,每个栈帧都包含一个指向运行时常量池中该栈所属方法的符号引用,持有这个引用的目的是为了支持方法调用过程中的动态连接(Dynamic Linking)。
    这些符号引用一部分会在类加载阶段或者第一次使用时就直接转化为直接引用,这类转化称为静态解析。另一部分将在每次运行期间转化为直接引用,这类转化称为动态连接。
    4.方法返回
    当一个方法开始执行时,可能有两种方式退出该方法:

正常完成出口
异常完成出口

简单的一个加减法:

public class ShowByteCode {
    private String xx;
    private static final int TEST = 1;

    public ShowByteCode() {
    }

    public int calc() {
        int a = 100;
        int b = 200;
        int c = 300;
        return (a + b) * c;
    }
}

直接看JVM底层栈执行过程如下:

javap -c  *.class

在这里插入图片描述

JVM方法调用详解

方法解析

调用目标方法在程序代码写好、编译器进行编译时就必须确定下来。这类方法的调用称为解析。
在Java语言中符合“编译期可知,运行期不可变”这个要求的方法,主要包括静态方法私有方法两大类,前者与类型直接关联,后者在外部不可被访问,这两种方法各自的特点决定了它们都不可能通过继承或别的方式重写其他版本,因此它们都适合在类加载阶段进行解析。

静态分派

载(Overload):这个是静态分配,编译时候就确定下来调用函数了。函数名跟参数构成方法签名,然后调用的时候根据方法数据的静态类型进行显示切记。

Human称为变量的静态类型(Static Type),或者叫做的外观类型(Apparent Type),后面的Man则称为变量的实际类型(Actual Type),静态类型和实际类型在程序中都可以发生一些变化,区别是静态类型的变化仅仅在使用时发生,变量本身的静态类型不会被改变,并且最终的静态类型是在编译期可知的;而实际类型变化的结果在运行期才可确定,编译器在编译程序的时候并不知道一个对象的实际类型是什么。

如下代码中定义了两个静态类型相同但实际类型不同的变量,但虚拟机(准确地说是编译器)在重载时是通过参数的静态类型而不是实际类型作为判定依据的。并且静态类型是编译期可知的,因此,在编译阶段,Javac编译器会根据参数的静态类型决定使用哪个重载版本,所以选择了sayHello(Human)作为调用目标。所有依赖静态类型来定位方法执行版本的分派动作称为静态分派。静态分派的典型应用是方法重载。静态分派发生在编译阶段,因此确定静态分派的动作实际上不是由虚拟机来执行的。

public class  StaticDispatch{
	
	static abstract class Human{}
	static class Man extends Human{	}
	static class Woman extends Human{}
	
	public void sayHello(Human guy){
		System.out.println("hello,human!");//1
	}
	public void sayHello(Man guy){
		System.out.println("hello,man!");//2
	}
	public void sayHello(Woman guy){
		System.out.println("hello,woman!");//3
	}
	
	public static void main(String[]args){
		Human h1 = new Man();
		Human h2 = new Woman();
		StaticDispatch sr = new StaticDispatch();
		sr.sayHello(h1); //human
		sr.sayHello(h2); //human
	}
}

动态分派

表现形式为我们熟知的多态,只有在运行时候才知道调用的具体方法。多态的实现机制就是父类跟子类各自有一个方法表(一个类有一个方法表,由虚拟机维护,维护着各个方法实际入口<方法区实际地址>),如果没重写那么他们公用一个方法,如果方法重写了那么 Father跟Son会各自指向实际的方法。然后在对象调用的时候就直接调用真实的方法。
在这里插入图片描述

public class DynamicDispatch {
	static abstract class Human{
		protected abstract void sayHello();
	}
	static class Man extends Human{
		@Override
		protected void sayHello() {
			System.out.println("hello,gentleman!");
		}	
	}
	static class Woman extends Human{

		@Override
		protected void sayHello() {
			System.out.println("hello,lady!");
		}
	}
	
	public static void main(String[]args){
		Human h1 = new Man();
		Human h2 = new Woman();
		h1.sayHello(); //hello gentleman
		h2.sayHello(); // hello lady
	}
}

PS:

  1. 基于寄存器的字节指令一般由于硬件不同会有些许差异不过因为直接跟硬件打交到因此速度更快些。
  2. 基于栈的字节码解释执行指令,速度慢点但是可移植。

参考

Class文件格式
栈桢了解

正文到此结束