美文网首页JVM
Java 代码编译和执行的整个过程

Java 代码编译和执行的整个过程

作者: 三也视界 | 来源:发表于2021-02-22 09:20 被阅读0次

    首先问一个问题,Java代码是如何运行的?

    • 写好一份.Java代码
    • 被打包成jar包或war包,打包过程中,被编译成了.class字节码文件
    • 使用命令”java -jar” 命令,运行这份java代码(或系统),此时就启动了一个JVM进程。

    所以,我们平时部署一个系统并运行的时候,其实就是启动了一个JVM,由JVM来运行这台机器上的这个系统。

    JVM要运行系统java代码,就需要类加载器将class文件中的类加载进JVM内存当中,由JVM自己的字节码执行引擎来执行加载进来的类文件,比如,找到并执行系统入口函数main()。

    那么,代码是如何被编译的呢?类加载器何时加载类呢?

    一个java代码文件,要想被执行,主要经过的步骤有:

    源代码(SourceCode)-》编译器(预处理器preprocessor->编译器compiler->汇编程序assembler->目标代码object code->链接器Linker->) -》可执行程序-》加载-》分配内存-》执行程序

    按照JVM工作流程划分其主要内容:

    • Java源码编译机制

    • 类加载机制

    • 类执行机制

    • 内存分配机制

    • 内存回收机制

    对于JVM的内存中包含以下两个机制,这里不展开,后面单独研究。

    image.png

    首先Java源代码文件(.java后缀)会被Java编译器编译为字节码文件(.class后缀),然后由JVM中的类加载器加载各个类的字节码文件,加载完毕之后,交由JVM执行引擎执行。在整个程序执行过程中,JVM会用一段空间来存储程序执行期间需要用到的数据和相关信息,这段空间一般被称作为Runtime Data Area(运行时数据区),也就是我们常说的JVM内存。

    1、Java代码编译

    代码编译由JAVA源码编译器来完成。主要是将源码编译成字节码文件(class文件);字节码文件格式主要分为两部分:常量池和方法字节码。

    Java代码编译是由Java源码编译器来完成,流程图如下所示:

    image

    具体步骤详见javac 编译与 JIT 编译

    Java 源码编译机制

    Java 源码编译由以下三个过程组成:

    1. 分析和输入到符号表
    2. 注解处理
    3. 语义分析和生成class文件

    流程图如下所示:

    image

    (javac–verbose 输出有关编译器正在执行的操作的消息)

    image

    最后生成的class文件由以下部分组成:

    结构信息:包括class文件格式、版本号、各部分的数量与大小的信息

    元数据:对应于Java源码中声明与常量的信息。包含类/继承的超类/实现的接口的声明信息、域与方法声明信息和常量池

    方法信息:对应Java源码中语句和表达式对应的信息。包含字节码、异常处理器表、求值栈与局部变量区大小、求值栈的类型记录、调试符号信息。

    最后生成的 class 文件由以下部分组成:

    • 结构信息。包括 class 文件格式版本号及各部分的数量与大小的信息。
    • 元数据。对应于 Java 源码中声明与常量的信息。包含类/继承的超类/实现的接口的声明信息、域与方法声明信息和常量池。
    • 方法信息。对应 Java 源码中语句和表达式对应的信息。包含字节码、异常处理器表、求值栈与局部变量区大小、求值栈的类型记录、调试符号信息。

    2、类加载机制

    2.1 类的生命周期

    类的生命周期由被加载到虚拟机内存中开始,到卸载出内存结束,共有七个阶段,其中到初始化之前的都是属于类加载的部分:

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

    系统可能在第一次使用某个类时加载该类,也可能采用预加载机制来加载某个类,当运行某个java程序时,会启动一个java虚拟机进程,两次运行的java程序处于两个不同的JVM进程中,两个jvm之间并不会共享数据。

    1、加载阶段

    这个流程中的加载是类加载机制中的一个阶段,段需要完成的事情有:

    1)通过一个类的全限定名来获取定义此类的二进制字节流。

    2)将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。

    3)在java堆中生成一个代表这个类的Class对象,作为访问方法区中这些数据的入口。

    由于第一点没有指明从哪里获取以及怎样获取类的二进制字节流,所以这一块区域留给我开发者很大的发挥空间。

    2、准备阶段

    这个阶段正式为类变量(被static修饰的变量)分配内存并设置类变量初始值,这个内存分配是发生在方法区中。

    1、注意这里并没有对实例变量进行内存分配,实例变量将会在对象实例化时随着对象一起分配在JAVA堆中。

    2、这里设置的初始值,通常是指数据类型的零值。

    private static int a = 3;
    

    这个类变量a在准备阶段后的值是0,将3赋值给变量a是发生在初始化阶段。

    3、初始化阶段

    初始化是类加载机制的最后一步,这个时候才正真开始执行类中定义的JAVA程序代码。在前面准备阶段,类变量已经赋过一次系统要求的初始值,在初始化阶段最重要的事情就是对类变量进行初始化,关注的重点是父子类之间各类资源初始化的顺序。

    java类中对类变量指定初始值有两种方式:

    1)声明类变量时指定初始值;

    2)使用静态初始化块为类变量指定初始值。

    初始化的时机

    1)创建类实例的时候,分别有:1、使用new关键字创建实例;2、通过反射创建实例;3、通过反序列化方式创建实例。

    new Test();Class.forName(“com.mengdd.Test”);
    

    2)调用某个类的类方法(静态方法) Test.doSomething();
    3)访问某个类或接口的类变量,或为该类变量赋值。 int b=Test.a; Test.a=b;

    4)初始化某个类的子类。当初始化子类的时候,该子类的所有父类都会被初始化。

    5)直接使用java.exe命令来运行某个主类。

    除了上面几种方式会自动初始化一个类,其他访问类的方式都称不会触发类的初始化,称为被动引用。

    被动引用的情况

    1、子类引用父类的静态变量,不会导致子类初始化。

    publicclass SupClass
    {
       public static int a = 123;   
       static
       {
           System.out.println("supclassinit");
       }
    }
    publicclass SubClass extends SupClass
    {
       static
       {
           System.out.println("subclassinit");
       }
    } 
    publicclass Test
    {
       public static void main(String[] args)
       {
           System.out.println(SubClass.a);
       }
    }
    

    执行结果:

    supclass init

    123

    2、引用常量时,不会触发该类的初始化

    用final修饰某个类变量时,它的值在编译时就已经确定好放入常量池了,所以在访问该类变量时,等于直接从常量池中获取,并没有初始化该类。

    初始化机制:

    1、如果该类还没有加载和连接,则程序先加载该类并连接。

    2、如果该类的直接父类没有加载,则先初始化其直接父类。

    3、如果类中有初始化语句,则系统依次执行这些初始化语句。

    ** 在第二个步骤中,如果直接父类又有直接父类,则系统会再次重复这三个步骤来初始化这个父类,依次类推,JVM最先初始化的总是java.lang.Object类。当程序主动使用任何一个类时,系统会保证该类以及所有的父类都会被初始化。**

    2.2 类加载机制

    类加载器结构关系

    JVM的类加载是通过ClassLoader及其子类来完成的,类的层次关系和加载顺序可以由下图来描述:

    image

    1)Bootstrap ClassLoader /启动类加载器

    是ClassLoader子类 ,自身也没有子类,并且不遵守classLoader加载机制;是JVM内核中的加载器,由C++实现;负责加载$JAVA_HOME中jre/lib/rt.jar里所有的class。

    2)Extension ClassLoader/扩展类加载器

    是用JAVA编写,且它的父加载器是Bootstrap,但是因为BootStrap是用C++写的,所以有时候也说ExtClassLoader没有父加载器,自身也是顶层父类,但是血统不纯,不全是JVM实现的。

    负责加载java平台中扩展功能的一些jar包,包括$JAVA_HOME中jre/lib/*.jar或-Djava.ext.dirs指定目录下的jar包

    通过程序来看下系统变量java.ext.dirs所指定的路径:

    public class Test{
      public static void main(String[] args)   {
          System.out.println(System.getProperty("java.ext.dirs"));
      }
    }
    

    执行结果:

    C:\Program Files(x86)\Java\jdk1.6.0_43\jre\lib\ext;C:\Windows\Sun\Java\lib\ext

    3)App ClassLoader/ 系统类加载器

    也称为应用程序类加载器,负责加载应用程序classpath目录下的所有jar和class文件。它的父加载器为Ext ClassLoader。

    4)Custom ClassLoader/用户自定义类加载器

    (java.lang.ClassLoader的子类)

    属于应用程序根据自身需要自定义的ClassLoader,如tomcat、jboss都会根据j2ee规范自行实现ClassLoader

    这几种类加载器的层次关系如下图所示:

    image

    类加载机制

    类加载机制的特点:

    全盘负责,当一个类加载器负责加载某个Class时,该Class所依赖的和引用的其他Class也将由该类加载器负责载入,除非显示使用另外一个类加载器来载入

    父类委托,先让父类加载器试图加载该类,只有在父类加载器无法加载该类时才尝试从自己的类路径中加载该类

    缓存机制,缓存机制将会保证所有加载过的Class都会被缓存,当程序中需要使用某个Class时,类加载器先从缓存区寻找该Class,只有缓存区不存在,系统才会读取该类对应的二进制数据,并将其转换成Class对象,存入缓存区。这就是为什么修改了Class后,必须重启JVM,程序的修改才会生效

    由上述分析可知,类的加载机制采用的是一种父类委托机制,也叫作双亲委派机制或者父优先等级加载机制

    如果一个类加载器收到了一个类加载请求,它不会自己去尝试加载这个类,而是首先会自下而上的检查该类是否已被加载,从Custom ClassLoader到BootStrap ClassLoader逐层检查,只要某个classloader已加载就视为已加载此类,并将结果逐层向下反馈;如果没有加载,则继续向上层检查,所有的类加载请求都应该传递到最顶层的启动类加载器中,只有到父类加载器反馈自己无法完成这个加载请求(在它的搜索范围没有找到这个类)时,子类加载器才会尝试自己去加载,这种委派机制的好处就是保证了一个类不被重复加载。

    所以说,类加载检查顺序是自下而上,而加载的顺序是自顶向下,也就是由上层来逐层尝试加载类。

    这种类加载机制的实现比较简单,源码如下:

    protectedsynchronized Class<?> loadClass(String paramString, boolean paramBoolean)
        throws ClassNotFoundException
      {
           //检查是否被加载过
        Class localClass =findLoadedClass(paramString);
           //如果没有加载,则调用父类加载器
        if (localClass == null) {
          try {
               //父类加载器不为空
            if (this.parent != null)
              localClass = this.parent.loadClass(paramString,false);
            else {
          //父类加载器为空,则使用启动类加载器,传统意义上启动类加载器没有父类加载器
              localClass =findBootstrapClass0(paramString);
            }
          }
          catch (ClassNotFoundExceptionlocalClassNotFoundException)
          {
               //如果父类加载失败,则使用自己的findClass方法进行加载
            localClass = findClass(paramString);
          }
        }
        if (paramBoolean) {
          resolveClass(localClass);
        }
        return localClass;
      }
    

    代码大意就是先检查是否已经被加载过,若没有加载则调用父类加载器的loadClass方法,若父类加载器不存在,则使用启动类加载器。如果父类加载器加载失败,则抛出异常之后看,再调用自己定义的的findClass方法进行加载。

    2.3 自定义类加载器

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

    下面我们通过一个示例来演示自定义类加载器的流程:

    public class MyClassLoader extendsClassLoader { 
       private String root; 
       protected Class<?> findClass(String name) throwsClassNotFoundException {
           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 加载,而不会通过我们自定义类加载器来加载。

    3 类执行机制

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

    image

    JVM是基于栈的体系结构来执行class字节码的。

    线程创建后,都会产生一个线程私有的程序计数器(PC寄存器)和栈(Stack)

    ** 程序计数器**存放程序正常执行时下一条要执行的指令在方法内的偏移量地址;

    ** 栈中存放一个个栈帧,各个方法每调用一次就会创建一个自己私有的栈帧,栈帧分为局部变量表、操作数栈、动态连接、方法返回地址和一些附加信息**:

    1) 局部变量区是一组变量值存储空间,用于存放方法中的参数、局部变量;

    局部变量表的容量以变量槽(slot)为最小单位,一个slot可以存放一个32位以内的数据类型,而Java中占32位以内的数据类型有boolean、byte、char、short、int、float、reference(也可以64位)和returnAddress八种类型

    Java语句中明确规定的64位的数据类型只有long和double两种(reference可能是32位,也可能是64位)故long和double不是原子操作,只是局部变量表建立在线程的堆栈上,是线程私有的数据,无论读写两个连续的slot是否是原子操作,都不会引起数据安全问题

    2) 操作数栈中用于存放方法执行过程中产生的中间结果。

    3) 动态连接

    符号引用一部分会在类加载阶段或第一次使用的时候转换成为直接引用,这种转换称为静态解析。另外一部分将在每一次的运行期间转换为直接引用,这部分称为动态引用

    4)方法返回地址

    当一个方法被执行后,有两种方式退出这个方法。

    第一种是执行引擎,遇到一个方法返回的字节码指令,这时可能会返回值传递给上层的方法调用者。这种退出方式为正常完成出口

    另一种是遇到异常并且没有在方法体内得到处理(throws不属于方法体内处理),这种退出方式是不会给它的上层调用者产生任何返回值的。

    一般来说,方法正常退出时,调用者的PC计数器的值就可以作为返回地址,栈帧中很可能会保存这个计数器值。而方法异常退出时,返回地址是要通过异常处理器表来确定的,栈帧中一般不会保存这部分信息。

    方法退出的实质

    实际上等同于把当前栈帧出栈,因此退出时可能执行的操作有:恢复上层方法的局部变量表盒操作数栈,把返回值(如果有的话)压入调用者栈帧的操作数栈中,调整PC计数器的值以指向方法调用指令后面的一条指令等

    JVM工作机制就是执行引擎的工作过程--执行引擎

    1、最简单的:一次性解释字节码。

    2、快,但消耗内存的:“即时编译器”,第一次被执行的字节码会被编译成机器代码,放入缓存,以后调用可以重用。

    3、自适应优化器,虚拟机开始的时候会解释字节码,但是会监视运行中程序的活动,并记录下使用最频繁的代码段。程序运行的时候,虚拟机只把使用最频繁的代码编译成本地代码,其他的代码由于使用的并不频繁,继续保留为字节码--由虚拟机继续解释他们。一般可以使java虚拟机80%90%的时间里执行被优化过的本地代码,只需要编译10%20%对性能优影响的代码。

    4、由硬件芯片组成,他用本地方法执行java字节码,这种执行引擎实际上是内嵌在芯片里的。

    JVM为何采用基于栈的结构设计

    基于栈的方式:所有的操作数必须先入栈,然后根据指令集中的操作码从栈顶弹出若干个元素后再将结果压入栈中。操作数入栈可以是直接常量入栈或本地变量集中的变量压入栈。

    JVM是基于栈的虚拟机,为每个新创建的线程都分配一个栈,也就是说一个Java程序来说,它的运行就是通过对栈的操作来完成的。栈以帧为单位保存线程的状态。JVM对栈只进行两种操作:以帧为单位的压栈出栈操作。

    某个线程正在执行的方法称为此线程的当前方法,当前方法使用的帧称为当前帧。当线程激活一个Java方法,JVM就会在线程的 Java堆栈里新压入一个帧。这个帧自然成为了当前帧.在此方法执行期间,这个帧将用来保存参数,局部变量,中间计算过程其他数据。这个帧在这里和编译原理中的活动纪录的概念是差不多的。

    从Java的这种分配机制来看,可以这样理解:栈(Stack)是操作系统在建立某个进程时或者线程(在支持多线程的操作系统中是线程)为这个线程建立的存储区域,该区域具有先进后出的特性。

    嵌套方法的出栈和入栈示意图:

    image

    上图中描述了嵌套方法时,stack的内存分配图,由上面可以知道,当嵌套方法调用时,嵌套越深,stack的内存就越晚才能释放,因此,在实际开发过程中,不推荐大家使用递归来进行方法的调用,递归很容易导致stack flow。

    非嵌套方法的出栈入栈过程

    image

    采用基于栈的结构设计原因:

    1)JVM要保证设计成的与平台无关。屏蔽平台的差异性,就要求保证在没有或者很少寄存器的机器上同样能够正确的执行Java代码。

    2)为了指令的紧凑性。为了让编译后的class文件更加紧凑,降低其大小,提高字节码在网络上传输的效率。

    3)及时释放内存,提高内存的利用率。

    相关文章

      网友评论

        本文标题:Java 代码编译和执行的整个过程

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