美文网首页
Java虚拟机

Java虚拟机

作者: 9283856ddec1 | 来源:发表于2020-03-29 13:53 被阅读0次

    1 虚拟机概述

    1.1 虚拟机产生原由

    JVM是Java Virtual Machine(Java虚拟机)的缩写,JVM是一种用于计算设备的规范,是一台虚拟机,即虚构的计算机。

    JVM屏蔽了与具体操作系统平台相关的信息,使Java程序只需生成在Java虚拟机上运行的目标代码(字节码),就可以在多种平台上不加修改地运行。JVM在执行字节码时,实际上最终还是把字节码解释成具体平台上的机器指令执行。

    1.2 JRE/JDK/JVM概念

    • JRE
      JRE(Java Runtime Environment,Java运行环境),也就是Java平台。所有的Java程序都要在JRE下才能运行。

    • JDK
      JDK(Java Development Kit,Java开发工具包),是程序开发者用来编译、调试java程序的开发工具包。JDK的工具也是java程序,需要JRE环境的支持才能运行。

    • JVM
      JVM(Java Virtual Machine,Java虚拟机),是JRE的一部分。它是一个虚构出来的计算机,在实际的计算机上模拟各种计算机的功能,其设计时有运行时字节码指令集,运行时数据区域,执行引擎,以及本地库接口。

    1.3 整体框架结构

    JVM框架结构.png
    • 类装载器(Class Loader):用来装载.class文件;
    • 运行时数据区域:方法区,堆,虚拟机栈,程序计数器,本地方法栈;
    • 执行引擎:执行字节码或本地方法;

    2 Java代码编译

    Java源代码是不能被机器识别的,需要先经过编译器编译成JVM可以执行的.class字节码文件,再由解释器解释运行。即:Java源文件(.java) -- Java编译器 --> Java字节码文件 (.class) -- Java解释器 --> 执行。流程图如下:


    Java编译过程.png

    2.1 词法分析器

    将源码转化为Token流。读取源代码,从源文件的第一个字符开始,按照java语法规范依次找出package,import,类定义,属性,方法定义等,最后构建出一个抽象语法树。

    package compile;
    
    /**
     * 词法解析器
     */
     public class Cifa{
         int a;
         int c = a + 1;
     }
    

    转化为Token流:


    Token流.png

    2.2 语法分析器

    语法解析器要将Token流组建成更加结构化的语法树,也就是将这些Token流中的单词装成一句话,完整的语句。

    package compile;
    
    /**
     * 语法
     */
    public class Yufa {
        int a;
        private int c = a + 1;
        
        //getter
        public int getC() {
            return c;
        }
        //setter
        public void setC(int c) {
            this.c = c;
        }
    }
    
    语法树.png

    2.3 语义分析器

    将语法树转化为注解语法树,即在这颗语法树上做一些处理。
    步骤

    • 给类添加默认构造函数(由com.sun.tools.javac.comp.Enter类完成)
    • 处理注解(由com.sun.tools.javac.processing.JavacProcessingEnvironment类完成)
    • 检查语义的合法性并进行逻辑判断(由com.sun.tools.javac.comp.Attr完成)
      ① 变量的类型是否匹配;
      ② 变量在使用前是否初始化;
      ③ 能够推导出泛型方法的参数类型;
      ④ 字符串常量合并;
    • 数据流分析(由com.sun.tools.javac.comp.Flow类完成)
      ① 检验变量是否被正确赋值(eg.有返回值的方法必须确定有返回值);
      ② 保证final变量不会被重复修饰;
      ③ 确定方法的返回值类型;
      ④ 所有的检查型异常是否抛出或捕获;
      ⑤ 所有的语句都要被执行到(return后边的语句就不会被执行到,除了finally块儿);
    • 对语法树进行语义分析(由com.sun.tools.javac.comp.Flow执行)
      ① 去掉无用的代码,如只有永假的if代码块;
      ② 变量的自动转换,如将int自动包装为Integer类型;
      ③ 去除语法糖,将foreach的形式转化为更简单的for循环;

    2.4 代码生成器

    生成语法树后,接下来Javac会调用com.sun.tools.javac.jvm.Gen类遍历语法树,生成Java字节码。
    步骤

    • 将java方法中代码块转化为符合JVM语法的命令形式;
    • 按照JVM的文件组织格式将字节码输出到以class为拓展名的文件中;

    3 字节码

    3.1 字节码指令集

    机器指令的格式.png

    3.2 .class文件结构

    Java编译器编译成后缀为.class的文件,该类型的文件是由字节组成的文件,又叫字节码文件。那么,class字节码文件里面到底是有什么呢?它又是怎样组织的呢?让我们先来大概了解一下他的组成结构吧。


    class字节码结构示意图.png
    ClassFile {
    u4              magic;
    u2              minor_version;
    u2              major_version;
    u2              constant_pool_count;
    cp_info         constant_pool[constant_pool_count-1]
    u2              access_flages;
    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];
    }
    

    4 类加载

    4.1 类加载过程

    类加载过程.png
    4.1.1 加载(Loading)
    • 何时预加载
      预加载:在虚拟机启动的时候加载,加载的是JAVA_HOME/lib/下的rt下的.class文件,是java程序运行时经常要用到的一些类,比如java.lang.⁎以及 java.util.⁎等。
      运行时加载:虚拟机在用到一个.class文件时,首先会去内存中查找这个.class文件有没有被加载,没有被加载会根据这个类的全限定名去加载。

    • 加载阶段主要做如下事项:
      ① 通过一个类的全限定名来获取定义此类的二进制字节流。 不一定要从本地的Class文件获取,可以从jar包,网络,甚至十六进制编辑器弄出来的。开发人员可以重写类加载器的loadClass()方法。
      ② 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构;
      ③ 在内存中生成一个唯一代表此类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口(一般这个class对象会存储在堆中,不过HotSpot虚拟机比较特殊,这个Class对象是放在方法区中的);

    说明:类的数据流来源于
    1> 从zip包中获取,如jar,ear,war格式等;
    2> 从网络上获取,典型应用就是Applet;
    3> 运行时计算生成,典型应用就是动态代理技术;
    4> 由其他文件生成,典型应用就是JSP,即由JSP生成对应的.class文件;

    4.1.2 认证(Verifying)

    这一阶段目的为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。
    文件格式验证,其实就是验证字节流是否符合Class文件规范,符合规范通过验证才能保证输入的字节流能正确的被解析并存储到方法区。如魔数(0xCAFEBABE)开头、主次版本号是否在当前虚拟机处理范围之内等。

    元数据验证,此阶段开始就不是直接操作字节流,而是读取方法区里的信息,元数据验证大概就是验证是否符合Java语言规范;

    字节码验证,是整个验证过程中最复杂的一个阶段,主要目的是通过数据流和控制流分析,确定程序语义是否合法,符合逻辑。JDK6之后做了优化,不再验证,可以通过-XX:-UseSplitVerifier关闭优化。

    符号引用验证,此阶段可以看做是类自己身意外的信息进行匹配性校验。如:符号引用中通过字符串描述的全限定名是否能找到对应的类;符号引用中的类、字段、方法的访问性(private、protected、public、default)是否可被当前类访问。

    4.1.3 准备(Preparing)

    为类变量分配内存并赋初值的阶段。注意这里仅包括类变量(被static修饰的变量),而不是包括实例变量。
    以下是数据类型的零值:

    数据类型 零值
    int 0
    long 0L
    short (short)0
    char 'u0000'
    byte (byte)0
    boolean false
    float 0.0f
    double 0.0d
    reference null
    // 举个例子:
    public static int value = 123;
    

    在这个阶段中,value的值是0而不是123,给value赋值为123的动作将在初始化阶段进行。

    4.1.4 解析(Resolving)

    解析是虚拟机将常量池中的符号引用转换为直接引用的过程。
    符号引用:属于编译原理方面的概念,符号引用包括了下面三类常量:① 类和接口的全限定名; ② 字段的名称和描述符; ③ 方法的名称和描述符;
    直接引用:直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。直接引用是和虚拟机实现的内存布局相关的,同一个符号引用在不同的虚拟机示例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标必定已经存在在内存中了。

    4.1.5 初始化(Initializing)

    在前面的类加载过程中,除了在加载阶段,用户应用程序可以通过自己定义类加载参与之外,其余动作完全由虚拟机主导和控制。到了这个初始化阶段,才真正开始执行类中定义的Java程序代码(或者说是字节码)。

    初始化过程就是执行类构造器<clinit>()的过程,就是将类变量赋予用户指定的值。
    虚拟机规范定义了“有且仅有”5种会触发初始化的场景:
    ① 遇到new、getstatic、putstatic或invokestatic这四条字节码指令时,如果类没有进行初始化,则要先触发初始化;
    ② 使用java.lang.reflect包中的方法对类进行反射调用的时候;
    ③ 初始化类时,若发现其父类还没有初始化,则先触发父类的初始化;
    ④ 虚拟机启动的时候,虚拟机会先初始化用户指定的包含main()方法的那个类;
    ⑤ 当使用JDK 1.7的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,并且这个方法句柄所对应的类没有进行过初始化,则需要先触发其初始化。

    4.2 类加载器

    JVM的类加载通过ClassLoader及其子类来完成的,类的层次关系和加载顺序如下图所示:


    类加载过程.png
    4.2.1 类加载器种类
    加载器种类.png
    1. 引导类加载器(Bootstrap ClassLoader)
      JVM的根ClassLoader,由C++实现,JVM启动时即初始化此ClassLoader,用以加载JVM运行时所需要的系统类,这些系统类在{JRE_HOME}/lib目录下。由于类加载器是使用平台相关的底层C/C++语言实现的, 所以该加载器不能被Java代码访问到。但是,我们可以查询某个类是否被引导类加载器加载过。

    一般而言,{JRE_HOME}/lib下存放着JVM正常工作所需要的系统类,如下表所示:

    文件名 描述
    rt.jar 运行环境包,rt即runtime,J2SE 的类定义都在这个包内
    charsets.jar 字符集支持包
    jce.jar 是一组包,它们提供用于加密、密钥生成和协商以及 Message Authentication Code(MAC)算法的框架和实现
    jsse.jar 安全套接字拓展包Java(TM) Secure Socket Extension
    classlist 该文件内表示是引导类加载器应该加载的类的清单
    net.properties JVM 网络配置信息

    引导类加载器(Bootstrap ClassLoader) 加载系统类后,JVM内存会呈现如下格局:


    引导类加载器过程.png
    • 引导类加载器将类信息加载到方法区中,以特定方式组织,对于某一个特定的类而言,在方法区中它应该有 运行时常量池、类型信息、字段信息、方法信息、类加载器的引用,对应class实例的引用等信息。
    • 类加载器的引用,由于这些类是由引导类加载器(Bootstrap Classloader)进行加载的,而 引导类加载器是有C++语言实现的,所以是无法访问的,故而该引用为NULL。
    • 对应class实例的引用, 类加载器在加载类信息放到方法区中后,会创建一个对应的Class 类型的实例放到堆(Heap)中, 作为开发人员访问方法区中类定义的入口和切入点。
    1. 扩展类加载器(Extension ClassLoader)
      该加载器是用于加载 java 的拓展类 ,拓展类一般会放在 {JRE_HOME}/lib/ext/ 目录下,或java.ext.dirs系统变量指定的路径中的所有类库,用来提供除了系统类之外的额外功能。

    拓展类加载器是是整个JVM加载器的Java代码可以访问到的类加载器的最顶端,即是超级父加载器,拓展类加载器是没有父类加载器的。

    1. 应用程序类加载器(Application ClassLoader)
      该类加载器是用于加载用户代码,是用户代码的入口。一般情况,如果我们没有自定义类加载器默认就是用这个加载器。

    应用类加载器将拓展类加载器当成自己的父类加载器,当其尝试加载类的时候,首先尝试让其父加载器-拓展类加载器加载;如果拓展类加载器加载成功,则直接返回加载结果Class<T> instance。加载失败,则会询问是否引导类加载器已经加载了该类;只有没有加载的时候,应用类加载器才会尝试自己加载。

    1. 自定义类加载器(User ClassLoader)
      应用程序自定义的加载器,如tomcat,jboss都会实现自己的ClassLoader;
    4.2.2 双亲委派

    JVM在加载类时默认采用的是双亲委派机制。通俗的讲,如果一个类加载器收到了类加载的请求,它首先不会自己去加载这个类,而是把这个请求委派给父类加载器去完成,每一层的类加载器都是如此,这样所有的加载请求都会被传送到顶层的启动类加载器中。如果父类加载器可以完成类加载任务,就成功返回;如果父类加载器无法完成此加载任务时,才自己去加载。
    ① 委托父类加载器帮忙加载;
    ② 父类加载器加载不了,则查询引导类加载器有没有加载过该类;
    ③ 如果引导类加载器没有加载过该类,则当前的类加载器应该自己加载该类;
    ④ 若加载成功,返回 对应的Class<T> 对象;若失败,抛出异常“ClassNotFoundException”。
    一般情况下,双亲加载模型如下所示:


    双亲加载模型.png

    注意:双亲委派模型中的"双亲"并不是指它有两个父类加载器的意思,一个类加载器只应该有一个父加载器。上面的步骤中,有两个角色:1)父类加载器(parent classloader):它可以替子加载器尝试加载类;2)引导类加载器(bootstrap classloader): 子类加载器只能判断某个类是否被引导类加载器加载过,而不能委托它加载某个类;换句话说,就是子类加载器不能接触到引导类加载器,引导类加载器对其他类加载器而言是透明的。

    JDK源码看java.lang.ClassLoader的核心方法 loadClass()的实现:

    //提供class类的二进制名称表示,加载对应class,加载成功,则返回表示该类对应的Class<T> instance 实例
    public Class<?> loadClass(String name) throws ClassNotFoundException {
        return loadClass(name, false);
    }
    
    
    protected Class<?> loadClass(String name, boolean resolve)
        throws ClassNotFoundException
    {
        synchronized (getClassLoadingLock(name)) {
            // 首先,检查是否已经被当前的类加载器记载过了,如果已经被加载,直接返回对应的Class<T>实例
            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;
        }
    }
    

    相对应地,我们可以整理出双亲模型的工作流程图:

    4.2.3 举例说明

    5 内存模型

    运行时数据区.png

    5.1 Stack区

    5.1.1 程序计数器(Program Counter)

    线程私有,指示当前线程执行到的字节码行数。JVM多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的。在一个确定的时刻,一个处理器(或者说多核处理器的一个内核)只会执行一条线程中的命令。因此,为了正常的切换线程,每个线程都会有一个独立的PC,各线程的PC不会互相影响。

    如果线程在执行的是Java方法,那么PC记录的是正在执行的虚拟机字节码指令的地址。如果正在执行的不是Java方法即Native方法,那么PC的值为undefined。

    PC的内存区域是唯一的没有规定任何OutOfMemoryError的Java虚拟机规范中的区域。

    5.1.2 Java虚拟机栈(Java VM Stack)

    线程私有,生命周期跟线程相同。虚拟机栈描述Java方法执行的内存模型,每个方法被执行时都会创建一个栈帧(Stack Frame),栈帧会利用局部变量数组存储局部变量(Local Variables),操作栈(Operand Stack),方法出口(Return Value),动态连接(Current Class Constant Pool Reference)等信息。

    5.1.2.1 栈帧
    栈帧.png
    • 局部变量数组
      存储了编译可知的八个基本类型(int, boolean, char, short, byte, long, float, double),对象引用(根据不同的虚拟机实现可能是引用地址的指针或者一个handle),returnAddress类型。64位的long和double会占用两个Slot,其余类型会占用一个Slot。在编译期间,局部变量所需的空间就会完成分配,动态运行期间不会改变所需的空间。

    • 操作栈
      在执行字节码指令时会被用到,这种方式类似于原生的CPU寄存器,大部分JVM把时间花费在操作栈的花费上,操作栈和局部变量数组会频繁的交换数据。

    • 动态连接
      控制着运行时常量池和栈帧的连接。所有方法和类的引用都会被当作符号的引用存在常量池中。符号引用是实际上并不指向物理内存地址的逻辑引用。
      JVM 可以选择符号引用解析的时机,一种是当类文件加载并校验通过后,这种解析方式被称为饥饿方式。另外一种是符号引用在第一次使用的时候被解析,这种解析方式称为惰性方式。无论如何 ,JVM 必须要在第一次使用符号引用时完成解析并抛出可能发生的解析错误。
      绑定是将对象域、方法、类的符号引用替换为直接引用的过程。绑定只会发生一次,一旦绑定,符号引用会被完全替换。如果一个类的符号引用还没有被解析,那么就会载入这个类。每个直接引用都被存储为相对于存储结构(与运行时变量或方法的位置相关联的)偏移量。

    对Java虚拟机栈这个区域,Java虚拟机规范规定了两种异常:1)线程请求的栈深度大于虚拟机所允许的深度,抛出StackOverFlow异常。2)对于支持动态扩展的虚拟机,当扩展无法申请到足够的内存时会抛出OutOfMemory异常。

    5.1.3 本地方法栈(Native Method Stack)

    主要为Native方法服务,在JVM规范中,没有对它的实现做具体规定。

    5. 2 Non Heap区

    5.2.1 代码缓存(Code Cache)

    用于编译和存储那些被 JIT 编译器编译成原生代码的方法。

    5.2.2 方法区(Method Area)
    方法区内容.png

    线程间共享,用于存储已经被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。它属于非堆区(Non Heap),和Java堆区分开。

    对于存在永久代(Permanent)概念的虚拟机(HotSpot)而言,方法区处于永久代。Java虚拟机规范对方法区的规定很宽松,甚至可以不实现GC。不过并非进入方法区的数据就会永久存在了,这块区域的内存回收主要为常量池的回收和类型的卸载。这个区域的回收处理不善也会导致严重的内存泄漏。当方法区无法满足内存分配需求时也会抛出OutOfMemoryError。

    注:运行时常量池用于存放编译期生成的各种字面量与符号引用,如String类型常量就存放在常量池。

    5.2.2.1 类信息(Class Data)

    类信息存储在方法区,其主要构成为运行时常量池(Run-Time Constant Pool)和方法(Method Code)。
    一个编译后的类文件包括以下结构:

    结构 解释
    magic, minor_version, major_version 类文件的版本信息和用于编译这个类的 JDK 版本。
    constant_pool 类似于符号表,尽管它包含更多数据。下面有更多的详细描述。
    access_flags 提供这个类的描述符列表。
    this_class 提供这个类全名的常量池(constant_pool)索引,比如org/jamesdbloom/foo/Bar。
    super_class 提供这个类的父类符号引用的常量池索引。
    interfaces 指向常量池的索引数组,提供那些被实现的接口的符号引用。
    fields 提供每个字段完整描述的常量池索引数组。
    methods 指向constant_pool的索引数组,用于表示每个方法签名的完整描述。如果这个方法不是抽象方法也不是 native 方法,那么就会显示这个函数的字节码。
    attributes 不同值的数组,表示这个类的附加信息,包括 RetentionPolicy.CLASS 和 RetentionPolicy.RUNTIME 注解。
    • 运行时常量池(Run-Time Constant Pool)
      Class文件中有类的版本,字段,方法,接口等描述信息和用于存放编译期生成的各种字面量和符号引用。这部分内容将在类加载后存放到方法区的运行时常量池中。Java虚拟机规范对Class的细节有着严苛的要求而对运行时常量池的实现不做要求。一般来说除了翻译的Class,翻译出来的直接引用也会存在运行时常量池中。

    运行时常量池具备动态性,即运行时也可将新的常量放入池中,比如String类的intern()方法。常量池无法申请到足够的内存分配时也会抛出OutOfMemoryError。

    5.3 Heap区

    5.3.1 堆(Heap)

    线程间共享,用于存储对象实例,可以通过-Xmx和-Xms控制堆的大小。如下图所示:


    GC堆.png

    Java堆是垃圾收集器管理的主要区域,因而也被称为GC堆。收集器采用分代回收法,GC堆可以分为新生代(Yong Generation)和老生代(Old Generation)。新生代包括Eden Space和Survivor Space。

    • 新生代(Young)
      新生代主要存放新创建的对象,通常可以划分为Eden Space和Survivor Space。Eden空间不足时会把存活的对象转移到Survivor,可用-XX:SurvivorRatio控制Eden和Survivor的比例。
    • 旧生代(Tenured)
      存放经过多次垃圾回收后仍旧存活的对象;

    根据Java虚拟机规范,堆所在的物理内存区间可以是不连续的,只要逻辑连续就可以。实现时既可以是固定大小,也可以是可扩展的。如果堆无法扩展时,就会抛出OutOfMemoryError。

    6 执行引擎机制

    Java字节码是由执行引擎来完成,流程图如下所示:


    执行引擎.png

    主要的执行技术:解释,即时编译,自适应优化、芯片级直接执行。

    • 解释:属于第一代JVM;
    • 即时编译:属于第二代JVM;
    • 自适应优化:吸取第一代和第二代的经验,采用两者结合的方式;

    7 垃圾回收

    将内存中不再被使用的对象进行回收,GC中用于回收的方法称为收集器,由于GC需要消耗一些资源和时间,Java在对对象的生命周期特征进行分析后,按照新生代、旧生代的方式来进行对象的收集,以尽可能的缩短GC对应用造成的暂停。

    • Minor GC:对新生代的对象收集;
    • Full GC:对旧生代的对象收集;

    不同的对象引用类型, GC会采用不同的方法进行回收,JVM对象的引用分为了四种类型:

    • 强引用:默认情况下,对象采用的均为强引用(这个对象的实例没有其他对象引用,GC时才会被回收)。
    • 软引用:软引用是Java中提供的一种比较适合于缓存场景的应用(只有在内存不够用的情况下才会被GC)。
    • 弱引用:在GC时一定会被GC回收。
    • 虚引用:由于虚引用只是用来得知对象是否被GC。

    参考资料

    [1] 详细介绍Java虚拟机(JVM)
    [2] Java基础:Java虚拟机(JVM)
    [3] Java 虚拟机底层原理知识总结
    [4] 深入分析 Javac 编译原理
    [5] 《Java虚拟机原理图解》 1.1、class文件基本组织结构 ★★
    [6] JVM 类加载机制及双亲委派模型
    [7] 虚拟机类加载机制
    [8] 理解虚拟机jvm的工作原理

    相关文章

      网友评论

          本文标题:Java虚拟机

          本文链接:https://www.haomeiwen.com/subject/vvxluhtx.html