类的加载机制和字节码执行引擎

类的加载机制和字节码执行引擎

文章发布于 2021-01-25 21:03:53,最后更新于 2021-01-26 22:14:02

JVM内存调优-类的加载机制和字节码执行引擎

src=http___img2.a0bi.com_upload_ttq_20150203_1422922431009.jpg&refer=http___img2.a0bi

前言

关于JVM的学习也已经做了两篇笔记了,堆JVM的内存结构、垃圾回收都有了了解,按照《深入理解JVM》这本书的排版,除去一些实战的章节,理论知识后面就剩类文件结构、JVM类的加载、字节码执行引擎等等。因为类文件结构是毕竟枯燥的一些概念,所以我打算把他放到后面再看,先继续学习类的加载机制和字节码执行引擎。

一、JVM虚拟机知识补充

微信截图_20210126220333
全图文件过大无法上传,获取吐血详细总结资料请关注程序员星宇公众号,或扫描文末二维码,回复关键词JVM,获取密码

翻看前面的比较,总感觉缺少了一些概念性的东西,一直都在谈JVM,但究竟什么是虚拟机,目前为止都有哪些虚拟机,这些知识有必要做个补充。

JAVA实现了“一次编写,到处运行”。

**虚拟机始祖:**Sun Classic/Exact VM,Sun Classic虚拟机技术是世界上第一款商用的JAVA虚拟机。

**武林盟主:**HotSpot VM,他是Sun/Oracle JDK和Open JDK中默认的JAVA虚拟机,也是目前使用最广泛的。

**小家碧玉:**Mobile/Embedded VM,是移动、嵌入式市场的虚拟机产品。

JAVA之所以可以"一次编译,多次运行"是因为有字节码的存在。JAVA虚拟机不与包括JAVA语言在内的任何程序语言绑定,只与"Class文件"这种特定的二进制文件格式关联。

image20210123211345019.png

二、虚拟机类加载机制

Class文件只是一个你所编写的程序信息的等价完备的存储,它是死的,若要让它活起来,就需要将Class文件加载到虚拟机中并运行。

Class文件的类加载过程是怎样?进入到虚拟机中的Class文件信息又会以什么样的形式存储?虚拟机如何找到应该执行的方法?虚拟机如何执行Class文件中的字节码?这些都是需要思考的问题。

什么是虚拟机类加载机制

JAVA虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终变成可以被虚拟机直接使用的JAVA类型的过程就是类加载机制。

JAVA的类的加载过程是在程序运行期间完成的,这种策略让JAVA语言进行提前编译会面临困难,也会让类加载增加性能开销,但是却给Java提供了极高的扩展性和灵活性。这也是多态实现的前提。

2.1 类的生命周期

类从加载到虚拟机的内存,到卸载出内存为止,整个生命周期会经历,加载验证准备解析初始化使用卸载

image20210123213330931.png

“加载”、“验证”、“准备”、“初始化”、“卸载”这五个阶段的开始顺序是确定的,而“解析”阶段既可以在初始化前开始,也可以在初始化后真正使用前开始。

初始化的时机
  • 遇到 new、getstatic、putstatic、invokestatic 这四条字节码指令时,若类没有进行过初始化,则需要先触发其初始化。
  • 使用java.lang.reflect包的方法对类进行反射调用时,若类没有进行过初始化,则需要先触发其初始化。
  • 当初始化一个类的时候,若发现其父类还没有进行过初始化,则需要先出发其父类的初始化。
  • 当虚拟机启动时,用户需要指定一个要执行的包含main方法的主类,虚拟机会先初始化这个主类。
  • 当使用JDK1.7的动态语言支持时,若一个java.lang.invoke.MedthodHandle实例最后的解析结果是REF_getStatic、REF_putStatic、REF_invokeStaic的方法句柄,并且这个方法句柄所对应的类没有进行过初始化,则需要先触发其初始化。

以上这五种场景的行为称为对一个类进行主动引用,除此以外所有引用类的方式都成为被动引用,不会触发初始化,例如

  • 通过子类引用父类的静态字段,不会导致子类初始化。
  • 通过数组定义来引用类,不会出发此类的初始化。
  • 常量在编译阶段会存入调用类的常量池中,本质上并没有直接引用到定义常量的类没,不会触发定义常量的类的初始化。

2.1.1 加载

区分“加载(Loading)”和“类加载(Class Loading)”:“加载”是“类加载”过程中的一个阶段,“类加载”过程包括“加载”、“验证”、“准备”、“解析”、“初始化”。

在加载阶段,虚拟机需要完成以下三件事情:

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

加载阶段结束后,JAVA虚拟机外部的二进制字节流就按照虚拟机所设定的格式存储在方法区中了。

2.1.2 验证

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

验证阶段大致上会完成四个阶段的校验动作:文件格式验证元数据验证字节码验证符号引用验证。当通过了文件格式验证后,字节流就会进入内存的方法区中进行存储,后面的三个阶段的验证全部都是基于方法区的存储结构进行的,不会再直接操作字节流。

文件格式验证:验证字节流是否符合Class文件格式规范,并且能被当前版本虚拟机执行。

元数据验证:对字节码描述信息进行语义分析,以保证描述的信息符合《Java语言规范要求》。

字节码验证:最复杂的阶段,通过数据流分析和控制流分析,确定程序语义是否合法、符合逻辑。

符号引用验证:可以看作是对类自身以外的各类信息进行匹配性校验,也就是判断该类是否缺少或者被禁止访问它依赖的某些外部资源。

2.1.3 准备

准备阶段是正式为类变量(即被static修饰的变量,不包括实例变量)分配内存并设置类变量初始值(通常情况下是数据类型的零值)的阶段。

这些变量所用的内存应该是在方法区分配,JDK7之前使用永久代实现方法区,JDK8及之后,类变量会随着Class对象一起放在堆内存中。

2.1.4 解析

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

  • 类或接口的解析

假设当前代码所处的类为D,如果要把一个从未解析过的符号引用N解析为一个类或接口C的直接引用,那么虚拟机将会把代表N的全限定名传递给D的类加载器去加载这个类C(类D和类C会被相同的类加载器所加载)。在加载类C的过程中,又可能触发其他的加载动作,比如加载这个类的父类或实现的接口。

  • 字段解析
  1. 首先会解析字段所属的类或接口的符号引用。
  2. 接着,若类本身就包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束。
  3. 否则,若在类中实现了接口,将会按照继承关系从下往上递归搜索各个接口和它的父接口。
  4. 否则,将会按照继承关系从下往上递归搜索其父类直至搜到java.lang.Object为止。
  • 类方法解析
  1. 首先会解析类方法所属的类的符号引用。
  2. 接着,若类本身就包含了简单名称和描述符都与目标相匹配的方法,则返回这个方法的直接引用,查找结束。
  3. 否则,将会按照继承关系从下往上递归搜索其父类直至搜到java.lang.Object为止。
  4. 否则,在类实现的接口列表及它们的父接口中递归查找,若找到了,则抛出 java.lang.AbstractMethodError 异常,若还是没找到,就抛出 java.lang.NoSuchMethodError 异常。

可以看出,在进行类方法解析时,优先搜索父类。若在所有父类中都没有找到,那么程序必然是有问题的必抛异常。

  • 接口方法解析
  1. 首先会解析接口方法所属的接口的符号引用。
  2. 接着,若接口本身就包含了简单名称和描述符都与目标相匹配的方法,则返回这个方法的直接引用,查找结束。
  3. 否则,在接口的父接口中递归查找,直到java.lang.Object为止。

2.1.5 初始化

在类加载过程中,除了在加载阶段用户应用程序可以通过自定义类加载器参与之外,其余动作完全由虚拟机主导和控制。在准备阶段,变量已经赋过一次系统要求的初始值,到了初始化阶段,才真正开始执行类中定义的程序代码。或者从另一个角度来说,初始化阶段是执行类构造器 () 方法的过程。

() 方法的几个要点:

  • () 方法是由编译器自动收集类中的所有类变量(不包括实例变量)的赋值动作和静态语句块(static{}块)中的语句合并产生的,并且编译器收集的顺序是由语句在源文件中出现的顺序决定的。
public class Test {
	static {
		i = 0; //给变量赋值可以正常编译通过
		System.out.print(i); //这句编译器会提示”非法向前引用“
	}
	static int i = 1;
}
//编译器收集的顺序是由语句在源文件中出现的顺序决定的,所以这里最终结果i=1
  • 在类的构造函数即() 方法中,需要显式地调用父类构造器,与之不同的是,虚拟机会保证在子类的() 方法执行之前,父类的() 方法已经执行完毕。这也就意味着父类中定义的静态语句块要优先于子类的类变量赋值操作。
  • 接口的() 方法有点不同,执行接口的() 方法不需要先执行父接口的() 方法,只有当父接口中定义的变量使用时,父接口才会初始化。
static class Parent {
	public static int a = 1;
	static {
		a = 2;
	}
}
static class Sub extends Parent {
	public static int b = a;
}
//字段b的值将会是2
  • 虚拟机会保证一个类的() 方法在多线程环境中被正确地加锁。同步。

至此,一个类的所有前期准备工作都做好了,而且这些工作都是真真正正地在程序运行期完成的,下面就可以开始使用这个类了

2.2 类加载器

通过一个类的全限定名来获取描述该类的二进制字节流,实现这个动作的代码被称为“类加载器”(Class Loader)

类加载器虽然只用于实现类的加载动作,但他的作用远超于类加载阶段。比较两个类是否相等,只有在这两个类在同一个类加载器加载的前提下才有意义,否则就算是同一个Class文件,被同一个Java虚拟机加载,只要类加载器不同,两个类就必定不同。

2.2.1 各个类加载器之间的关系

image20210123223059602.png

Java中提供的这四种类型的加载器,是有各自的职责的:
  • Bootstrap ClassLoader:主要负责加载Java核心类库,%JRE_HOME%\lib下的rt.jar、resources.jar、charsets.jar和class等。
  • Extention ClassLoader:主要负责加载目录%JRE_HOME%\lib\ext目录下的jar包和class文件。
  • Application ClassLoader:主要负责加载当前应用的classpath下的所有类
  • User ClassLoader:用户自定义的类加载器,可加载指定路径的class文件

那么也就是说,一个用户自定义的类,如com.hollis.ClassHollis 是无论如何也不会被Bootstrap和Extention加载器加载的。

2.2.2 自定义类加载器

通常情况下,我们都是直接使用系统类加载器。但是,有的时候,我们也需要自定义类加载器。比如应用是通过网络来传输 Java 类的字节码,为保证安全性,这些字节码经过了加密处理,这时系统类加载器就无法对其进行加载,这样则需要自定义类加载器来实现。自定义类加载器一般都是继承自 ClassLoader 类,从上面对 loadClass 方法来分析来看,我们只需要重写 findClass 方法即可。下面我们通过一个示例来演示自定义类加载器的流程:

package com.neo.classloader;

import java.io.*;


public class MyClassLoader extends ClassLoader {

    private String root;

    protected Class<?> findClass(String name) throws ClassNotFoundException {
        byte[] classData = loadClassData(name);
        if (classData == null) {
            throw new ClassNotFoundException();
        } else {
            return defineClass(name, classData, 0, classData.length);
        }
    }
    private byte[] loadClassData(String className) {
        String fileName = root + File.separatorChar
                + className.replace('.', File.separatorChar) + ".class";
        try {
            InputStream ins = new FileInputStream(fileName);
            ByteArrayOutputStream baos = new ByteArrayOutputStream();
            int bufferSize = 1024;
            byte[] buffer = new byte[bufferSize];
            int length = 0;
            while ((length = ins.read(buffer)) != -1) {
                baos.write(buffer, 0, length);
            }
            return baos.toByteArray();
        } catch (IOException e) {
            e.printStackTrace();
        }
        return null;
    }

    public String getRoot() {
        return root;
    }

    public void setRoot(String root) {
        this.root = root;
    }

    public static void main(String[] args)  {
        MyClassLoader classLoader = new MyClassLoader();
        classLoader.setRoot("E:\\temp");
        Class<?> testClass = null;
        try {
            testClass = classLoader.loadClass("com.neo.classloader.Test2");
            Object object = testClass.newInstance();
            System.out.println(object.getClass().getClassLoader());
        } catch (ClassNotFoundException e) {
            e.printStackTrace();
        } catch (InstantiationException e) {
            e.printStackTrace();
        } catch (IllegalAccessException e) {
            e.printStackTrace();
        }
    }
}

自定义类加载器的核心在于对字节码文件的获取,如果是加密的字节码则需要在该类中对文件进行解密。由于这里只是演示,我并未对class文件进行加密,因此没有解密的过程。这里有几点需要注意:

1、这里传递的文件名需要是类的全限定性名称,即com.paddx.test.classloading.Test格式的,因为 defineClass 方法是按这种格式进行处理的。

2、最好不要重写loadClass方法,因为这样容易破坏双亲委托模式。

3、这类Test 类本身可以被 AppClassLoader 类加载,因此我们不能把 com/paddx/test/classloading/Test.class 放在类路径下。否则,由于双亲委托机制的存在,会直接导致该类由 AppClassLoader 加载,而不会通过我们自定义类加载器来加载。

2.2.3 双亲委派模型的工作过程:

如果类加载器收到类加载请求,他首先不会自己去加载这个类,而是把这个请求委派给父类加载器去完成,每层加载器都是如此,因此所有的请求都会最终传给顶层的启动类加载器,只有父加载器反馈自己无法加载,子加载器才会尝试去加载。

2.2.4双亲委派模型的重要性:

如上面我们提到的,因为类加载器之间有严格的层次关系,那么也就使得Java类也随之具备了层次关系。

比如一个定义在java.lang包下的类,因为它被存放在rt.jar之中,所以在被加载过程汇总,会被一直委托到Bootstrap ClassLoader,最终由Bootstrap ClassLoader所加载。

而一个用户自定义的com.hollis.ClassHollis类,他也会被一直委托到Bootstrap ClassLoader,但是因为Bootstrap ClassLoader不负责加载该类,那么会在由Extention ClassLoader尝试加载,而Extention ClassLoader也不负责这个类的加载,最终才会被Application ClassLoader加载。

这种机制有几个好处。

  • 首先,通过委派的方式,可以避免类的重复加载,当父加载器已经加载过某一个类时,子加载器就不会再重新加载这个类。
  • 另外,通过双亲委派的方式,还保证了安全性。因为Bootstrap ClassLoader在加载的时候,只会加载JAVA_HOME中的jar包里面的类,如java.lang.Integer,那么这个类是不会被随意替换的,除非有人跑到你的机器上, 破坏你的JDK。
  • 那么,就可以避免有人自定义一个有破坏功能的java.lang.Integer被加载。这样可以有效的防止核心Java API被篡改。

2.2.5双亲委派模型的实现:

实现双亲委派的代码都集中在java.lang.ClassLoader的loadClass()方法之中:

protected Class<?> loadClass(String name, boolean resolve)
            throws ClassNotFoundException
        {
            synchronized (getClassLoadingLock(name)) {
                // First, check if the class has already been loaded
                Class<?> c = findLoadedClass(name);
                if (c == null) {
                    long t0 = System.nanoTime();
                    try {
                        if (parent != null) {
                            c = parent.loadClass(name, false);
                        } else {
                            c = findBootstrapClassOrNull(name);
                        }
                    } catch (ClassNotFoundException e) {
                        // ClassNotFoundException thrown if class not found
                        // from the non-null parent class loader
                    }
                    if (c == null) {
                        // If still not found, then invoke findClass in order
                        // to find the class.
                        long t1 = System.nanoTime();
                        c = findClass(name);
                        // this is the defining class loader; record the stats
                        sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
                        sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
                        sun.misc.PerfCounter.getFindClasses().increment();
                    }
                }
                if (resolve) {
                    resolveClass(c);
                }
                return c;
            }
        }

代码不难理解,主要就是以下几个步骤:

  1. 先检查类是否已经被加载过
  2. 若没有加载则调用父加载器的loadClass()方法进行加载
  3. 若父加载器为空则默认使用启动类加载器作为父加载器。
  4. 如果父类加载失败,抛出ClassNotFoundException异常后,再调用自己的findClass()方法进行加载。

2.2.6"父子加载器"之间的关系是继承吗?

双亲委派模型中,类加载器之间的父子关系一般不会以继承(Inheritance)的关系来实现,而是都使用组合(Composition)关系来复用父加载器的代码的。

public abstract class ClassLoader {

        // The parent class loader for delegation

        private final ClassLoader parent;

    }

2.2.7 如何主动破坏双亲委派机制?

因为他的双亲委派过程都是在loadClass方法中实现的,那么想要破坏这种机制,那么就自定义一个类加载器,重写其中的loadClass方法,使其不进行双亲委派即可。

  • 由于双亲委派模型是在JDK1.2之后才被引入的,而在这之前已经有用户自定义类加载器在用了。所以,这些是没有遵守双亲委派原则的。
  • JNDI、JDBC等需要加载SPI接口实现类的情况。
  • **为了实现热插拔热部署工具。**为了让代码动态生效而无需重启,实现方式时把模块连同类加载器一起换掉就实现了代码的热替换。
  • tomcat等web容器的出现。
  • OSGI、Jigsaw等模块化技术的应用。
为什么JNDI、JDBC要破坏双亲委派模型?

我们日常开发中,大多数时候会通过API的方式调用Java提供的那些基础类,这些基础类时被Bootstrap加载的。

但是,调用方式除了API之外,还有一种SPI的方式。

如典型的JDBC服务,我们通常通过以下方式创建数据库连接:

Connection conn = DriverManager.getConnection("jdbc:mysql://localhost:3306/mysql", "root", "1234");

在以上代码执行之前,DriverManager会先被类加载器加载,因为java.sql.DriverManager类是位于rt.jar下面的 ,所以他会被根加载器加载。

类加载时,会执行该类的静态方法。其中有一段关键的代码是:

ServiceLoader<Driver> loadedDrivers = ServiceLoader.load(Driver.class);

这段代码,会尝试加载classpath下面的所有实现了Driver接口的实现类。

那么,问题就来了。

DriverManager是被根加载器加载的,那么在加载时遇到以上代码,会尝试加载所有Driver的实现类,但是这些实现类基本都是第三方提供的,根据双亲委派原则,第三方的类不能被根加载器加载。

那么,怎么解决这个问题呢?

于是,就在JDBC中通过引入ThreadContextClassLoader(线程上下文加载器,默认情况下是AppClassLoader)的方式破坏了双亲委派原则。

我们深入到ServiceLoader.load方法就可以看到:

public static <S> ServiceLoader<S> load(Class<S> service) {

        ClassLoader cl = Thread.currentThread().getContextClassLoader();

        return ServiceLoader.load(service, cl);

    }
为什么Tomcat要破坏双亲委派?

我们知道,Tomcat是web容器,那么一个web容器可能需要部署多个应用程序。

不同的应用程序可能会依赖同一个第三方类库的不同版本,但是不同版本的类库中某一个类的全路径名可能是一样的。

如多个应用都要依赖hollis.jar,但是A应用需要依赖1.0.0版本,但是B应用需要依赖1.0.1版本。这两个版本中都有一个类是com.hollis.Test.class。

如果采用默认的双亲委派类加载机制,那么是无法加载多个相同的类。

所以,Tomcat破坏双亲委派原则,提供隔离的机制,为每个web容器单独提供一个WebAppClassLoader加载器。

Tomcat的类加载机制:为了实现隔离性,优先加载 Web 应用自己定义的类,所以没有遵照双亲委派的约定,每一个应用自己的类加载器——WebAppClassLoader负责加载本身的目录下的class文件,加载不到时再交给CommonClassLoader加载,这和双亲委派刚好相反。

2.2.8    loadClass()、findClass()、defineClass()区别?

ClassLoader中和类加载有关的方法有很多,前面提到了loadClass,除此之外,还有findClass和defineClass等,那么这几个方法有什么区别呢?

  • loadClass() 就是主要进行类加载的方法,默认的双亲委派机制就实现在这个方法中。
  • findClass() 根据名称或位置加载.class字节码
  • definclass() 把字节码转化为Class

这里面需要展开讲一下loadClass和findClass,我们前面说过,当我们想要自定义一个类加载器的时候,并且像破坏双亲委派原则时,我们会重写loadClass方法。

那么,如果我们想定义一个类加载器,但是不想破坏双亲委派模型的时候呢?

这时候,就可以继承ClassLoader,并且重写findClass方法。findClass()方法是JDK1.2之后的ClassLoader新添加的一个方法。

/**
     * @since  1.2
     */
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        throw new ClassNotFoundException(name);
    }

这个方法只抛出了一个异常,没有默认实现。

JDK1.2之后已不再提倡用户直接覆盖loadClass()方法,而是建议把自己的类加载逻辑实现到findClass()方法中。

因为在loadClass()方法的逻辑里,如果父类加载器加载失败,则会调用自己的findClass()方法来完成加载。

所以,如果你想定义一个自己的类加载器,并且要遵守双亲委派模型,那么可以继承ClassLoader,并且在findClass中实现你自己的加载逻辑即可。

2.2.9 模块化技术与类加载机制

近几年模块化技术已经很成熟了,在JDK 9中已经应用了模块化的技术。

其实早在JDK 9之前,OSGI这种框架已经是模块化的了,而OSGI之所以能够实现模块热插拔和模块内部可见性的精准控制都归结于其特殊的类加载机制,加载器之间的关系不再是双亲委派模型的树状结构,而是发展成复杂的网状结构。

image20210123231659556.png

在JDK中,双亲委派也不是绝对的了。

在JDK9之前,JVM的基础类以前都是在rt.jar这个包里,这个包也是JRE运行的基石。

这不仅是违反了单一职责原则,同样程序在编译的时候会将很多无用的类也一并打包,造成臃肿。

在JDK9中,整个JDK都基于模块化进行构建,以前的rt.jar, tool.jar被拆分成数十个模块,编译的时候只编译实际用到的模块,同时各个类加载器各司其职,只加载自己负责的模块。

image20210124140747792.png

Class<?> c = findLoadedClass(cn);
    if (c == null) {
        // 找到当前类属于哪个模块
        LoadedModule loadedModule = findLoadedModule(cn);
        if (loadedModule != null) {
            //获取当前模块的类加载器
            BuiltinClassLoader loader = loadedModule.loader();
            //进行类加载
            c = findClassInModuleOrNull(loadedModule, cn);
         } else {
              // 找不到模块信息才会进行双亲委派
                if (parent != null) {
                  c = parent.loadClassOrNull(cn);
                }
          }
    }

2.3 小结

学习了类加载过程的加载验证准备解析初始化这五个阶段虚拟机都进行了哪些动作,也学习了类加载器的工作原理和对虚拟机的意义,下面就开始探索Java虚拟机的执行引擎,学习一下虚拟机如何执行定义的Class文件里的字节码。

三、虚拟机字节码执行引擎

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

JAVA到底是编译执行还是解释执行?

在不同的虚拟机实现中,执行引擎在执行字节码的时候,通常是解释执行(通过解释器)和编译执行(即时编译器产生本地代码执行)相辅相成。

所有的JAVA虚拟机的执行引擎输入和输出都是一致的:输入字节码二进制流,输出执行结果。

3.1 运行时栈帧结构

Java虚拟机以方法作为最基本的执行单元。栈帧(stack Frame)是用于支持虚拟机进行方法调用和方法执行背后的数据结构。他是虚拟机运行时数据区中的虚拟机栈的栈元素。方法的调用到执行结束就是一个栈帧在虚拟机栈里入栈和出栈的过程。

每一个栈帧都包括了局部变量表、操作数栈、动态连接、方法返回地址和一些额外的附加信息。

一个线程中的方法调用链可能会很长,很多方法都同时处于执行状态。在活动线程中,只有位于栈顶的栈帧才是有效的,成为当前栈帧(Current Stack Frame)。执行引擎运行的所有字节码指令都只针对当前栈帧进行操作。
image20210124150819331.png

3.1.1 局部变量表

作用:是一组变量值的存储空间,用于存放方法参数和方法内部定义的局部变量。

基本单位:以变量槽(Variable Slot)为最小单位。

安全性:因为是线程私有的所有不存在安全性问题。

分配时期:在Java程序被编译成Class文件时,就在方法的Code属性的max_locals数据项中确定了该方法所需要分配的局部变量表的最大容量。

大小:虚拟机规范中没有明确指明一个变量槽占用的内存空间大小,允许变量槽长度随着处理器、操作系统或虚拟机的不同而发生变化。

  • 对于 32 位以内的数据类型(booleanbytecharshortintfloatreferencereturnAddress ),虚拟机会为其分配一个变量槽空间。
  • 对于 64 位的数据类型(longdouble ),虚拟机会以高位对齐的方式为其分配两个连续的变量槽空间。

特点:可重用。为了尽可能节省栈帧空间,若当前字节码 PC 计数器的值已超出了某个变量的作用域,则该变量对应的变量槽可交给其他变量使用

访问方式:通过索引定位。索引值的范围是从 0 开始至局部变量表最大的变量槽数量。

image20210124161010449.png

测试代码:

//--------------------------测试1---------------------------//
public static void main(String[] args){
        byte[] placeholder = new byte[64*1000*1000];
        System.gc();
}
//查看日志,并未回收
[GC (System.gc())  69437K->63438K(251392K), 0.0012879 secs]
[Full GC (System.gc())  63438K->63277K(251392K), 0.0058505 secs]
//------------------------测试2-----------------------------//
public static void main(String[] args) {
        {
            byte[] placeholder = new byte[64 * 1000 * 1000];
        }
        System.gc();
}
//查看日志,并未回收
[GC (System.gc())  69437K->63420K(251392K), 0.0011785 secs]
[Full GC (System.gc())  63420K->63277K(251392K), 0.0058676 secs]
//------------------------测试3-----------------------------//
public static void main(String[] args) {
        {
            byte[] placeholder = new byte[64 * 1000 * 1000];
        }
        int a = 0;
        System.gc();
}
//查看日志,回收了
[GC (System.gc())  69437K->63454K(251392K), 0.0011921 secs]
[Full GC (System.gc())  63454K->777K(251392K), 0.0056915 secs]

我们知道类的字段变量有两次赋值的机会,一次是连接阶段的准备阶段,会赋予系统的初始值,另一次是在初始化阶段,赋予程序员定义的值。因此如果初始化阶段不给类变量赋值也没关系,变量依然会有一个系统默认值,但是如果一个局部变量没有初始值,那会导致字节码校验出问题,报错。

3.1.2 操作数栈

操作数栈是一个后入先出栈

作用:在方法执行过程中,写入(进栈)和提取(出栈)各种字节码指令

分配时期:同上,在编译时会在方法的 Code 属性的 max_stacks 数据项中确定操作数栈的最大深度。

栈容量:操作数栈的每一个元素可以是任意的 Java 数据类型 ——32 位数据类型所占的栈容量为 164 位数据类型所占的栈容量为 2。

注意:操作数栈中元素的数据类型必须与字节码指令的序列严格匹配,在编译时编译器需要验证一次、在类校验阶段的数据流分析中还要再次验证

当一个方法开始执行的时候,他的操作数栈是空的,方法执行的过程中,会有各种字节码指令往操作数栈中写入和提取内容。

在概念模型中,两个帧栈作为虚拟机栈的元素是完全独立的,但是在大多数虚拟机实现中都会做优化,将两个帧栈出现一部分重叠:让下面帧栈的部分操作栈与上面帧栈的部分局部变量表重叠,以便在方法调用时共用一部分数据,避免不必要的参数复制传递。

image20210124160915334.png

3.1.3 动态连接

定义:每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接

静态解析和动态连接区别

Class 文件的常量池中存有大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用作为参数,这些符号引用:

  • 一部分会在类加载阶段或者第一次使用的时候就转化为直接引用(静态解析
  • 另一部分会在每一次运行期间转化为直接引用(动态连接

3.1.3 方法返回地址

  • 方法退出的两种方式:
  • 正常退出:执行中遇到任意一个方法返回的字节码指令
  • 异常退出:执行中遇到异常、且在本方法的异常表中没有搜索到匹配的异常处理器区处理
  • 作用:在方法返回时都可能在栈帧中保存一些信息,用于恢复上层方法调用者的执行状态
  • 正常退出时,调用者的 PC 计数器的值可以作为返回地址
  • 异常退出时,通过异常处理器表来确定返回地址
  • 方法退出的执行操作:
  • 恢复上层方法的局部变量表和操作数栈
  • 若有返回值把它压入调用者栈帧的操作数栈中
  • 调整 PC 计数器的值以指向方法调用指令后面的一条指令等

在实际开发中,一般会把动态连接、方法返回地址与其他附加信息全部一起称为栈帧信息

3.2 方法调用

方法调用不等同于方法执行,方法调用阶段唯一的作用就是确定被调用方法的版本(就是调用哪个方法),暂时没涉及方法内部的具体运行。

方法调用是最普遍且频繁的操作

3.2.1 解析调用

所有方法调用的目标方法在Class文件里都是一个常量池中的符号引用,在类解析的过程中,会将一部分的符合引用转换为直接引用。

特点

  1. 是静态过程
  2. 在编译期间就完全确定,在类装载解析阶段就会把涉及的符号引用全部转变为可确定的直接引用,而不会延迟到运行期再去完成,即编译期可知、运行期不变

适用对象private 修饰的私有方法,类静态方法,类实例构造器父类方法

3.2.1 分派调用

什么是静态类型?什么是实际类型?

//父类
public class Human {
}
//子类
public class Man extends Human {
}
public class Main {
    public static void main(String[] args) {
        //这里的 Human 是静态类型,Man 是实际类型
        Human man=new Man();
    }
}

1.静态分派

  • 依赖静态类型来定位方法的执行版本
  • 典型应用是方法重载
  • 发生在编译阶段,不由 JVM 来执行

代码演示

public class Father {
}
public class Son extends Father {
}
public class Daughter extends Father {
}
public class Hello {
    public void sayHello(Father father){
        System.out.println("hello , i am the father");
    }
    public void sayHello(Daughter daughter){
        System.out.println("hello i am the daughter");
    }
    public void sayHello(Son son){
        System.out.println("hello i am the son");
    }
}
public static void main(String[] args){
    Father son = new Son();
    Father daughter = new Daughter();
    Hello hello = new Hello();
    hello.sayHello(son);
    hello.sayHello(daughter);
}

输出结果如下:

hello , i am the father

hello , i am the father

分析:

代码中定义了两个静态类型相同,而实际类型不同的两个变量,虚拟机在重载的时候是通过参数静态类型来判断的而不是实际类型。因为静态类型在编译期可知,所有Javac编译器就根据参数的静态类型决定使用哪个重载版本。

2.动态分派

  • 依赖动态类型来定位方法的执行版本
  • 典型应用是方法重写
  • 发生在运行阶段,由 JVM 来执行

代码演示:

public class Father {
    public void sayHello(){
        System.out.println("hello world ---- father");
    }
}

//继承 + 方法重写
public class Son extends Father {
    @Override
    public void sayHello(){
        System.out.println("hello world ---- son");
    }
}
public static void main(String[] args){
    Father son = new Son();
    son.sayHello();
}

输出结果如下:

hello world ---- son

当我们将要调用某个类型实例的具体方法时,会首先将当前实例压入操作数栈,然后我们的 invokevirtual 指令需要完成以下几个步骤才能实现对一个方法的调用:

image20210124231149138.png

3.单分派和多分派

方法的接收者和方法的参数统称为方法的宗量。单分派是根据一个宗量对目标方法进行选择,多分派是根据多于一个宗量对目标方法进行选择。

静态分配属于多分派;

动态分配属于单分派;

测试代码:


package test;
 
public class Dispatch {
 
	static class Ipad{}
	static class Iphone{}
	public static class Father{
		public void hardChoice(Ipad arg){
			System.out.println("Father choice Ipad!!!");
		}
		public void hardChoice(Iphone arg){
			System.out.println("Father choice Iphone!!!");
		}
	}
	public static class Son extends Father{
		public void hardChoice(Ipad arg){
			System.out.println("Son choice Ipad!!!");
		}
		public void hardChoice(Iphone arg){
			System.out.println("Son choice Iphone!!!");
		}
	}
	public static void main(String[] args) {
		// TODO Auto-generated method stub
       Father father = new Father();
       Father son = new Son();
       
       father.hardChoice(new Ipad());
       son.hardChoice(new Iphone());
	}
 

输出:

Father choice Ipad!!!
Son choice Iphone!!!

这个时候,选择目标方法依据两点:

一个是静态类型Father和Son,

二是方法参数Ipad和Iphone。

这次选择的结果的最终产物是产生了两条invokevirtual指令,两条指令的参数分别为常量池中指向Father.hardChoice(ipad)

及Father.hardChoice(iphone)方法的符号引用。因为是根据两个宗量进行选择,所以Java语言的静态分派属于多分派类型。

再看看运行阶段虚拟机的选择,也就是动态分派的过程。

在执行son.hardChoice(new Ipad())这句代码时,由于编译期已经决定目标方法的签名必须为hardChoice(Ipad),虚拟机不会关心传递过来的参数到底是什么,因为这时参数的静态类型、实际类型都对方法的选择不会构成任何影响,唯一可以影响虚拟机选择的因素只有此方法的接受者的实际类型到底是Father还是Son。因为只有一个宗量作为选择一句,所以Java语言的动态分派属于单分派类型。

结束语

你知道的越多,你不知道的越多

最后老话,欢迎有问题,有建议,有好资料的哥们和我分享,当然我也很乐意分享我的资料。只要你主动我们就有故事!!!

微信截图_20210122212100

我是星宇,一个满头黑发,渴望秃头的开发,我们下期见!

Copyright: 采用 知识共享署名4.0 国际许可协议进行许可

Links: https://mxyblogs.club/archives/jvm内存调优-类的加载机制和字节码执行引擎

Buy me a cup of coffee ☕.