暂无图片
暂无图片
暂无图片
暂无图片
暂无图片

Java从编译到执行发生了什么

星河之码 2023-03-07
527

「伸手摘星,即使一无所获,亦不致满手污泥」

「请关注公众号:星河之码」

我们在写java程序的时候,一般先在编译器中写.java文件,然后打包成jar或者war包,然后在java环境中运行起来,这里我们思考几个问题,

  • .java文件作为一个物理文件,它是怎么运行起来的呢?它经过了哪些过程?
  • 相同的一个.java文件为啥能够在不同的平台运行呢,java跨平台又是如何实现的呢?

带着这两个问题,我们来了解一下java的运行原理。

一、Java如何夸平台

Java是一门「跨平台」的语言,也就是我们常说的「一次编译,到处运行」,那么是怎么实现的呢,这是因为Java并不是直接运行在我们的系统平台上的,而是运行在JVM中,而我们的JVM可以在不同的平台(win、ios、linux),因此「java的跨平台其实是依赖JVM实现的」

那JVM又是如何实现跨平台的呢,其实很简单,我们需运行一个JAVA程序,必须要有JAVA环境,也就是必须要安装JDK,我们在「不同的操作系统」安装的JDK是不同的,「JDK是区分操作系统的,而JDK里包含了JVM的,因此Java就依赖JVM实现了『跨平台』」

二、Java的运行过程

了解了Java是如何实现跨平台的,接下来看看一个java程序运行在JVM中的经历的四个步骤:

  • 编译
  • 加载
  • 解释
  • 执行

经过以上四个步骤,「一个.java文件就可以转换成被计算机识别的指令,从而完成一个java程序的执行」,接下来分别看看这四个步骤都做了什么。

2.1 编译

一个java文件要被计算机识别,第一步编译:「要将java源码文件编程成JVM可以解释的class文件。在编译过程中程会对源代码程序做【词法分析】【语法分析】【语义分析】【编译优化】最后生成字节码文件」

2.1.1 词法分析

我们的.java文件本质上只是一段字符,要识别这串字符,就需要把字符进行读取处理,将这串字符里面的单词和标点符号等进行识别,「我们在写程序的时候会使用到关键字、标识符、字面量、操作符号等,这些在程序中被称之为token,将一串字符字符串转换为 Token 的过程,就叫做词法分析」

空白字符代表空格、tab、换行符。EOF是结束符

2.1.2 语法分析

当将一串字符解析出 Token后,就需要理解它的语法结构,「将其转换成成一个体现语法规则的、树状的数据结构」,这个数据结构叫做「抽象语法树」(AST,Abstract Syntax Tree)。

比如我们定义了一个函数,定义了函数的返回值类型,函数名称,参数以及函数体等,那么它通过语法分析得到的抽象语法树如下

可以看到这棵 AST 一共四层, 反映了函数的语法结构。分为四层,将函数体里面包含的多个语句,如变量声明语句、返回语句,拆分成函数体的子节点,直到不可拆分的叶子节点。「叶子节点就是词法分析阶段生成的 Token(带方框的部分),当对这棵 AST 做深度优先的遍历,就能依次得到所需要的 Token」

2.1.3 语义分析

负责检查抽象语法树的上下文相关属性

  • 变量使用前,需要事先定义
  • 变量运算时,类型需要匹配
  • 变量的作用域问题
  • 还有一些优化等等

2.1.4 编译优化

比如「对泛型的擦除」和经常使用的「Lombok的解析」等等。

通过上述四个步骤之后,我们的Java文件就会编译成一个class文件,提供给下一个阶段加载。

2.2 加载

加载阶段就是「将编译后的class文件加载到JVM中」。在加载阶段又可以细为【装载】【连接】【初始化】三个步骤。

2.2.3  装载

也就是我们常说的类加载,那么是什么是装载呢?

比如说:在程序执行过程中,「JVM在执行某段代码时,需要使用到class A,而此时在内存中没有找到class A的相关信息,于是JVM就会到相应的Class文件中查找class A的类信息,然后将其加载到内存中,这个过程就是类加载过程」

通过上面的过程我们可以总结出类加载过程

  • 「装载时机」

    「JVM不是一开始就把所有的类都加载进内存中」,而是只有第一次需要运行某个类时才会加载,且「只加载一次」

    比如new和反射的时候加载

  • 「装载过程」

    Java虚拟机通过【类加载器】将class文件装载到jvm中的,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机使用的Java类型。

  • 「装载方式」

    在装载过程中为了防止内存中重复加载,使用了双亲委派机制。

    JDK 中的本地方法类一般由启动类加载器(Bootstrp loader)装载

    JDK 中内部实现的扩展类一般由扩展加载器(ExtClassLoader )实现装载,

    程序中的类文件则由系统加载器(AppClassLoader )实现装载。

  • 「总结」

    「加载是一个读取calss文件,将其转化为某种静态数据结构存储在方法区内,并在JVM堆中生成一个便于用户调用的java.lang.Class类型的对象的过程」

    • 字节码来源:本地class文件、jar包class文件、远程网络、动态代理
    • 类加载器:启动类加载器、扩展类加载器、应用类加载器、自定义类加载器

2.2.2 连接

连接阶段主要是「对class的信息进行验证、为「类变量」分配内存空间并对其赋默认值,将类的字节码连接到JVM的运行状态之中」。这个过程一般又被分为【验证】【准备】【解析】。

  • 「验证」

    验证Class类是否符合 Java 规范和 JVM 规范,验证主要包括以下几个方面的验证:

    • 文件格式的验证,验证字节流是否符合Class文件的规范,是否能被当前版本的虚拟机处理

    • 元数据验证,对字节码描述的信息进行语义分析,确保符合java语言规范

    • 字节码验证,通过数据流和控制流分析,确定语义是合法的,符合逻辑的

    • 符号引用验证,这个校验在解析阶段发生,对class静态结构进行语法和语义上的分析,保证其不会产生危害jvm的行为。

  • 「准备」

    「为类的静态变量在方法区分配内存,初始化为【系统】的初始值」

    这里要注意,「在准备阶段的赋值不是我们代码中写的初始值,而是Java虚拟机根据不同变量类型的默认初始值」

    比如我们有如下代码,

    public static int a=7

    复制

    「这里在准备阶段过后a的初始值为0,而不是7」

  • 「解析」

    「将【常量池】中的符号引用替换为直接引用(内存地址)的过程(如物理内存地址指针),这个阶段有时候会在初始化之后执行」,分为两种情况:

    1. 「将符号引用替换为直接引用」

      有两个类A中引用了B,在编译阶段A是无法知道B有没有被编译的,此时A无法获取B准确的地址,如果A要找到B,就需要在A中「通过一个字符串来代替B的地址,这个字符串就是符号引用」

      当运行的时候A被加载了,到解析的时候发现被A引用的B还没有被加载,此时就会触发B的类加载,「当B被加载到JVM中后,此时A的符号引用就会被替换为B的内存地址,也就是直接引用」

    2. 「java多态,实现后期绑定」

      第一种方式是A引用B的时候,B是一个确定的实现类,是唯一的,这种方式称为静态解析。但是当A调用的B是一个抽象类或接口,它又多个实现的时候,怎么知道引用那个具体的实现呢?

      如果A引用的B是一个拥有多个实现类的抽象类或者接口,这种情况解析阶段并不知道应该引用B的那个实现类,此时就只能先不解析,「等到在运行过程中发生具体的调用的时候,java虚拟机栈得到具体的类信息,此时再进行解析,就可以明确A引用的B用那个实现类的地址了,这也是前面说的解析阶段有时候会在初始化之后执行的原因」

2.2.3 初始化

这个阶段就是「为类的静态变量赋予正确的初始值」

对class的成员变量、静态变量、静态代码块的赋值,如果有【实例化对象】,则会调用方法对实例变量进行初始化,并执行对应的构造方法内的代码。

比如上述的案例代码

public static int a=7

复制

在准备阶段,a被赋值了系统默认的初始值0,此时初始化阶段就会将其重新赋值为7。到这里就完成了加载阶段

「也就是说在初始化过程中,jvm才真正开始执行类中定义的java代码。」

  • 初始化阶段主要是执行类构造器clinit()方法的过程。类构造器clinit()方法是由编译器自动收集类中的所有类变量的赋值动作和静态代码块中的语句合并产生的。
  • 当初始化一个类的时候,如果发现其父类还没有进行过初始化、则需要先触发其父类的初始化。
  • 虚拟机会保证一个类的clinit()方法在多线程环境中被正确加锁和同步。

2.3 解释

「解释阶段:把字节码转换为操作系统识别的指令」

java文件通过j「avac」编译成class文件,这种中间码被称为字节码。然后由JVM加载字节码,

初始化完成之后,当我们尝试执行一个类的方法时,会找到对应方法的字节码的信息,然后解释器会把字节码信息解释成一行行系统能识别的指令码来执行。

这个过程「会通过【字节码解释器】【即时编译器(JIT)】相互配合,使java程序几乎能达到和编译型语言一样的执行效率」

  • 「字节码解释器」

    通俗讲:就是将字节码拿过来,翻译成指令码,给系统执行。就跟中英文翻译官一样。

  • 「即时编译器(JIT)」

    通俗讲:其实跟解释器做的事情差不多。也是将字节码拿过来,翻译成指令码,只不过会将指令码保存起来,下次不用翻译了,直接用就行。


在程序运行时,「当JVM发现某个方法或代码块的运行特别频繁的时候,就会把将其认定为【热点代码】,即时编译器(JIT)会将【热点代码】的字节码编译成指令码并保存起来,下次执行的时候就无需重复的进行解释,直接执行缓存的机器语言」

那么问题来了,怎么界定是不是热点代码呢???

在JVM中有个【热点探测】的机制,就是用来检查是否为热点代码的。主要有两种方式:

  • 「基于采样的热点探测」

    「采用采样探测的虚拟机,会周期性的检查每个线程的虚拟机栈栈顶,记录每个方法出现在栈顶的次数,达到阈值后就会认为是热点方法」

    这种方式「简单高效,但是不准确」,因为可能由于线程阻塞而导致某个方法一直在栈顶

  • 「基于计数器的热点探测」

    「采用计数器探测的虚拟机,会为每个方法或者代码块建立一个计数器,统计方法的执行次数。达到阈值后就会认为是热点方法」

    这种方式要维护计数器「,实现相对采用复杂一些,但是结果相对精准」

到这里我们就知道了,上述两种探测方式都有一个阈值,「当到达阈值后就会认为是热点代码,从而触发JIT编译,将编译后的指令码保存起来,下次直接无需解释,直接使用」

2.4 执行

执行阶段就没啥特殊的了,主要就是「操作系统把解释器解析出来的指令码,调用系统的硬件执行最终的程序指令」

2.5 总结

以上介绍了一个Java程序从.Java文件被加载到jvm中,直到被编译成指令码被系统执行的全过程,这里我画了一个图作为整个过程的总结。

三、clinit 与 init

前面在说初始化过程的时候,提到了「初始化阶段主要是执行类构造器clinit()方法的过程」,这里我们要跟我们常说的初始化init方法区分一下

「在编译生成class文件时,编译器会产生两个方法加于class文件中,一个是类的初始化方法clinit, 另一个是实例的初始化方法init」

3.1 类的初始化方法clinit

  • 「如果类中没有静态变量或静态代码块,则clinit方法将不会被生成」

  • 在执行clinit方法时,必须先执行父类的clinit方法。

  • clinit方法只执行一次。

  • static变量的赋值操作和静态代码块的合并顺序由源文件中出现的顺序决定

3.2 实例的初始化方法init

「init指的是实例构造器,主要作用是在类实例化过程中执行,执行内容包括非静态成员变量初始化和代码块的执行」

  • 「如果类中没有成员变量和代码块,那么init方法将不会被生成」

  • 在执行init方法时,必须先执行父类的init方法。

  • init方法每实例化一次就会执行一次。

  • init方法先为实例变量分配内存空间,再执行赋默认值,然后根据源码中的顺序执行赋初值或代码块。


文章转载自星河之码,如果涉嫌侵权,请发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

评论