Java是如何做到一次编译到处运行的?

前言

Java 一直以来备受青睐,是“互联网大厂”企业级后端服务偏爱的语言。我们通过 TIOBE 编程排行榜也可一览其平均江湖地位和受欢迎程度。

 

近两年随着人工智能的兴起(风头正盛),Python赶超跃居榜首,Java看似不再佔據前两位。查看2023年8月份的榜单时,甚至已被挤出前三!难道Java已经凉凉?已跌落神坛?实际上,Java的需求依然是很大,Java在后端语言的领导地位仍无法撼动「手动狗头」(但不可否认的,很卷、市场饱和、门槛已经大幅度提升...)。

 

这主要还得归因于它很多优化点和特性。如跨平台、面向对象编程、强大的开源生态和社区、稳定性和可靠性、安全性等。

跨平台 这个特性,绝对是Java的巨大优势。是实现号称“一次编译到处运行”的关键。而实现这一目标的重要环节就是类加载。

一、程序的执行过程

要理解类的加载,那先了解在java中一个类的执行过程。
废话不多说了,上代码:

arduino复制代码package com.jvm.test;

public class Book {

    public static void main(String[] args) {
        String name = "《三体》";
        System.out.printf("一本你不看,都不知道何为“惊艳”二字的书:" + name);
    }
}

简单的代码,那具体的执行过程是怎样的呢?

 

如上图,java源文件通过编译器编译为.class字节码文件,字节码文件通过JVM虚拟机,先进行字节码校验,然后再通过解释器 或 JIT编译器 翻译为特定平台的本地机器码,最终实现在不同的操作系统上运行。
其中,关于字节码翻译为机器码,JVM并不一定总是先将字节码翻译成本地机器码。JVM有两种执行策略:

  • 解释执行:JVM会逐条解释执行字节码指令,将其翻译成本地机器码后直接执行。一句话,就是边翻译边执行。
  • JIT编译:JIT编译器会将经常执行的热点代码翻译成机器码,缓存起来以供后续使用,以提高性能。并且通常只对热点代码进行一次编译。然后在后续执行中直接使用已翻译好的本地机器码。
    而如何判断是否为热点代码,会使用到一种叫“热点探测”的技术。这个不是本章的重点,了解下即可。

Java编译器将源代码编译为字节码,这是一种与具体平台无关的中间表示。然后,JVM负责将字节码翻译成适用于特定操作系统和硬件平台的机器码。从软件层面屏蔽了不同操作系统在底层硬件与指令的区别。使得Java应用程序可以在不同的操作系统上运行,从而实现“一次编译到处运行“的跨平台目标。

二、类的加载过程

当我们通过开发工具(IDEA、Eclipse等)运行这个类的main方法来启动程序时,实际上也是在后台调用java命令来执行该类的.class字节码文件。‘

通过Java命令执行代码后,其底层大体流程如下:

 

再看一个类在JVM中的生命周期:

 

而类的加载过程需要经过前五个步骤,即:加载 >> 验证 >> 准备 >> 解析 >> 初始化。

  • 加载(Loading):类加载过程的第一步,类加载器会根据类的全限定名查找并加载类的字节码。将字节码从磁盘上查找并加载到内存,并创建一个java.lang.Class对象表示这个类,作为方法区这个类的各种数据的访问入口。
    需要注意的是,是使用到这个类才会加载,例如调用类的main()方法、new对象等。
  • 链接(Linking): 链接阶段分为三个子阶段:
    • 验证(Verification): 验证被加载类的字节码,确保它符合Java虚拟机规范,不会引发安全问题。
    • 准备(Preparation): 为类的静态变量分配内存,并赋默认的初始值。
      例如下面的代码,在准备阶段,只会为price属性分配内存和赋初始值0,而不会为name属性分配内存。这里的初始值,一般是根据数据类型来决定的,比如数值类型会设置为0, 引用类型会设置为null。至于用户希望的最终值的赋予,将在初始化阶段进行。例如下面代码,在这个阶段赋予price属性的初始值是0,而不是45。
    • ini复制代码
    • public static int price = 45; public String name = "《三体》";
    • 但如果一个变量是常量(被static final修饰)的话,那么在这个阶段,属性将会被赋予用户希望的值。例如下面的代码,在这个阶段,num的值将直接是99,而不是0。
    • arduino复制代码
    • public static final int num = 99;
    • 为何static final变量会直接赋予用户希望的值,而static变量会被赋予零值?稍微想一下也能明白了。
      final关键字,在Java中代表不可变的意思。既然一旦赋值就不会再变,那就在准备阶段直接赋予用户希望的值。而没有被final修饰的静态变量,而没有被filal修饰的静态变量,可能在初始化阶段或运行阶段还会发生变化,所以没必要在准备阶段赋予最终值。
  • 解析(Resolution): 将符号引用(例如类、方法、字段的引用)解析为直接引用(内存地址)。
  • 初始化(Initialization): 在这一阶段,类的静态初始化代码块(static块)会被执行,静态变量会被赋予初始值。
    例如下面的代码,在初始化阶段,将会触发静态变量book对象的实例化、静态代码块将会被执行、静态变量price将会被赋予用户希望的值45。
  • csharp复制代码
  • static TestDynamicLoadBook book = new TestDynamicLoadBook(); static { System.out.println("书的静态代码块"); } public static int price = 45;
  • 使用(Using): 当JVM完成初始化后,JVM便开始从入口方法执行程序代码。
  • 卸载(Uninstall): 当程序执行完毕,JVM便开始销毁创建的Class对象,最后负责运行的JVM也退出内存。

类被加载到方法区后,主要包含 运行时常量池类型信息字段信息方法信息类加载器的引用对应class实例的引用等信息。

类加载器的引用:这个类到类加载器实例的引用。
对应class实例的引用:类加载器把类信息加载放到方法区后,会创建一个对应的java.lang.Class对象实例放到堆(Heap)中,作为开发人员访问方法区中类定义的入口。

另外,主类在运行过程中如果使用到其它类,会逐步加载这些类。jar包 或 war包的类也不是一次性加载,是使用到才加载。

三、牛刀小试

理解了类的加载过程,那我们来看一个栗子。
看看输出结果是依次是什么。

csharp复制代码package com.jvm;

public class TestDynamicLoadBook {
    public static void main(String[] args)
    {
        staticFunc();
        System.out.printf("price="+price);
    }

    static TestDynamicLoadBook book = new TestDynamicLoadBook();

    static
    {
        System.out.println("书的静态代码块");
    }

    {
        System.out.println("书的普通代码块");
    }

    TestDynamicLoadBook()
    {
        System.out.println("书的构造方法");
        System.out.println("name=" + name +",author=" + author +",price=" + price);
    }

    public static void staticFunc(){
        System.out.println("书的静态方法");

    }

    public static int price = 45;
    public static final String author  = "刘慈欣";
    public String name = "《三体》";
}

输出结果是:

ini复制代码书的普通代码块
书的构造方法
name=《三体》,author=刘慈欣,price=0
书的静态代码块
书的静态方法
price=45

下面来分析一下代码的整个执行过程。
首先,再回顾类加载过程的几个步骤:加载、链接(验证、准备、解析)、初始化。因为准备 和 初始化 是影响我们这个示例输出结果的两个重要阶段。

  • 准备阶段:为静态变量分配内存并设置初始值。
  • 初始化阶段:静态变量被赋予用户期望的值,静态代码块会被执行。

代码示例分析:

  • 首先,在准备阶段,静态变量book被分配内存,初始值为null; 静态变量price也被分配内存,初始值为0; 常量author被分配内存并赋值为“刘慈欣”;
  • java复制代码
  • static TestDynamicLoadBook book = new TestDynamicLoadBook(); public static int price = 45; public static final String author = "刘慈欣";
  • 然后,进入初始化阶段:
    a. Book类被触发实例化,也就是创建book对象。先执行对象的实例初始化块,也就是普通代码块,因此输出:「书的普通代码块」;
  • 在Java中,无论使用哪种构造器构造对象,首先都会运行初始化块(实例初始化块),然后才会运行构造器的主体部分。
  • 然后执行构造方法,输出:「书的构造方法」、「name=《三体》,author=刘慈欣,price=0」。
  • 这个结果,关于 name=《三体》和 price=0 可能有点小疑问。
  • 先解释price: 这里的price为0,是因为在Java中,代码的执行是按代码在类文件中的顺序依次执行的。在我们的示例中, public static int price = 45;语句在 static TestDynamicLoadBook book = new TestDynamicLoadBook();语句之后,初始化阶段,此时还才执行到book对象的实例化的构造方法的调用,静态变量price的初始化还未被执行到,因此尚未被赋值。
  • 解释name=《三体》:name是Book类的成员变量,在执行 static TestDynamicLoadBook book = new TestDynamicLoadBook(); 语句后进行对象的实例化,构造器会被调用,成员变量就会被赋予预期的值。
  • b. 执行静态代码块,输出:「书的静态代码块」
  • 最后,在main方法中:
    a. 静态方法staticFunc被调用,输出:「书的静态方法」。b. 打印静态变量price的值,此时price的值已经在初始化阶段赋值为45,所以输出: 「price=45」。

写到最后

今天,我们介绍Java类加载的全过程,还深入研究了类加载的几个很重要阶段。希望能够为你对类加载的理解带来些许启发和帮助。

写文章的的初衷,主要是为了加深对知识的理解、强化记忆、构建更完整的知识体系、同时锻炼自己的表达能力。

如果你觉得文章还行,不妨点个赞、留个言,让我知道你来过,对我将是一种极大的正反馈。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值