01 | Java代码是怎么运行的?

我们学院的一位教授之前去美国开会,入境的时候海关官员就问他:既然你会计算机,那你说说你用的都是什么语言吧?

教授随口就答了个Java。海关一看是懂行的,也就放行了,边敲章还边说他们上学那会学的是C+。我还特意去查了下,真有叫C+的语言,但是这里海关官员应该指的是C++。

事后教授告诉我们,他当时差点就问海关,是否知道Java和C++在运行方式上的区别。但是又担心海关官员拿他的问题来考别人,也就没问出口。那么,下次你去美国,不幸地被海关官员问这个问题,你懂得如何回答吗?

作为一名Java程序员,你应该知道,Java代码有很多种不同的运行方式。比如说可以在开发工具中运行,可以双击执行jar文件运行,也可以在命令行中运行,甚至可以在网页中运行。当然,这些执行方式都离不开JRE,也就是Java运行时环境。

实际上,JRE仅包含运行Java程序的必需组件,包括Java虚拟机以及Java核心类库等。我们Java程序员经常接触到的JDK(Java开发工具包)同样包含了JRE,并且还附带了一系列开发、诊断工具。

然而,运行C++代码则无需额外的运行时。我们往往把这些代码直接编译成CPU所能理解的代码格式,也就是机器码。

比如下图的中间列,就是用C语言写的Helloworld程序的编译结果。可以看到,C程序编译而成的机器码就是一个个的字节,它们是给机器读的。那么为了让开发人员也能够理解,我们可以用反汇编器将其转换成汇编代码(如下图的最右列所示)。

; 最左列是偏移;中间列是给机器读的机器码;最右列是给人读的汇编代码
0x00:  55                    push   rbp
0x01:  48 89 e5              mov    rbp,rsp
0x04:  48 83 ec 10           sub    rsp,0x10
0x08:  48 8d 3d 3b 00 00 00  lea    rdi,[rip+0x3b] 
                                    ; 加载"Hello, World!\n"
0x0f:  c7 45 fc 00 00 00 00  mov    DWORD PTR [rbp-0x4],0x0
0x16:  b0 00                 mov    al,0x0
0x18:  e8 0d 00 00 00        call   0x12
                                    ; 调用printf方法
0x1d:  31 c9                 xor    ecx,ecx
0x1f:  89 45 f8              mov    DWORD PTR [rbp-0x8],eax
0x22:  89 c8                 mov    eax,ecx
0x24:  48 83 c4 10           add    rsp,0x10
0x28:  5d                    pop    rbp
0x29:  c3                    ret

既然C++的运行方式如此成熟,那么你有没有想过,为什么Java要在虚拟机中运行呢,Java虚拟机具体又是怎样运行Java代码的呢,它的运行效率又如何呢?

今天我便从这几个问题入手,和你探讨一下,Java执行系统的主流实现以及设计决策。

为什么Java要在虚拟机里运行?

Java作为一门高级程序语言,它的语法非常复杂,抽象程度也很高。因此,直接在硬件上运行这种复杂的程序并不现实。所以呢,在运行Java程序之前,我们需要对其进行一番转换。

这个转换具体是怎么操作的呢?当前的主流思路是这样子的,设计一个面向Java语言特性的虚拟机,并通过编译器将Java程序转换成该虚拟机所能识别的指令序列,也称Java字节码。这里顺便说一句,之所以这么取名,是因为Java字节码指令的操作码(opcode)被固定为一个字节。

举例来说,下图的中间列,正是用Java写的Helloworld程序编译而成的字节码。可以看到,它与C版本的编译结果一样,都是由一个个字节组成的。

并且,我们同样可以将其反汇编为人类可读的代码格式(如下图的最右列所示)。不同的是,Java版本的编译结果相对精简一些。这是因为Java虚拟机相对于物理机而言,抽象程度更高。

# 最左列是偏移;中间列是给虚拟机读的机器码;最右列是给人读的代码
0x00:  b2 00 02         getstatic java.lang.System.out
0x03:  12 03            ldc "Hello, World!"
0x05:  b6 00 04         invokevirtual java.io.PrintStream.println
0x08:  b1               return

Java虚拟机可以由硬件实现[1],但更为常见的是在各个现有平台(如Windows_x64、Linux_aarch64)上提供软件实现。这么做的意义在于,一旦一个程序被转换成Java字节码,那么它便可以在不同平台上的虚拟机实现里运行。这也就是我们经常说的“一次编写,到处运行”。

虚拟机的另外一个好处是它带来了一个托管环境(Managed Runtime)。这个托管环境能够代替我们处理一些代码中冗长而且容易出错的部分。其中最广为人知的当属自动内存管理与垃圾回收,这部分内容甚至催生了一波垃圾回收调优的业务。

除此之外,托管环境还提供了诸如数组越界、动态类型、安全权限等等的动态检测,使我们免于书写这些无关业务逻辑的代码。

Java虚拟机具体是怎样运行Java字节码的?

下面我将以标准JDK中的HotSpot虚拟机为例,从虚拟机以及底层硬件两个角度,给你讲一讲Java虚拟机具体是怎么运行Java字节码的。

从虚拟机视角来看,执行Java代码首先需要将它编译而成的class文件加载到Java虚拟机中。加载后的Java类会被存放于方法区(Method Area)中。实际运行时,虚拟机会执行方法区内的代码。

如果你熟悉X86的话,你会发现这和段式内存管理中的代码段类似。而且,Java虚拟机同样也在内存中划分出堆和栈来存储运行时数据。

不同的是,Java虚拟机会将栈细分为面向Java方法的Java方法栈,面向本地方法(用C++写的native方法)的本地方法栈,以及存放各个线程执行位置的PC寄存器。

在运行过程中,每当调用进入一个Java方法,Java虚拟机会在当前线程的Java方法栈中生成一个栈帧,用以存放局部变量以及字节码的操作数。这个栈帧的大小是提前计算好的,而且Java虚拟机不要求栈帧在内存空间里连续分布。

当退出当前执行的方法时,不管是正常返回还是异常返回,Java虚拟机均会弹出当前线程的当前栈帧,并将之舍弃。

从硬件视角来看,Java字节码无法直接执行。因此,Java虚拟机需要将字节码翻译成机器码。

在HotSpot里面,上述翻译过程有两种形式:第一种是解释执行,即逐条将字节码翻译成机器码并执行;第二种是即时编译(Just-In-Time compilation,JIT),即将一个方法中包含的所有字节码编译成机器码后再执行。

前者的优势在于无需等待编译,而后者的优势在于实际运行速度更快。HotSpot默认采用混合模式,综合了解释执行和即时编译两者的优点。它会先解释执行字节码,而后将其中反复执行的热点代码,以方法为单位进行即时编译。

Java虚拟机的运行效率究竟是怎么样的?

HotSpot采用了多种技术来提升启动性能以及峰值性能,刚刚提到的即时编译便是其中最重要的技术之一。

即时编译建立在程序符合二八定律的假设上,也就是百分之二十的代码占据了百分之八十的计算资源。

对于占据大部分的不常用的代码,我们无需耗费时间将其编译成机器码,而是采取解释执行的方式运行;另一方面,对于仅占据小部分的热点代码,我们则可以将其编译成机器码,以达到理想的运行速度。

理论上讲,即时编译后的Java程序的执行效率,是可能超过C++程序的。这是因为与静态编译相比,即时编译拥有程序的运行时信息,并且能够根据这个信息做出相应的优化。

举个例子,我们知道虚方法是用来实现面向对象语言多态性的。对于一个虚方法调用,尽管它有很多个目标方法,但在实际运行过程中它可能只调用其中的一个。

这个信息便可以被即时编译器所利用,来规避虚方法调用的开销,从而达到比静态编译的C++程序更高的性能。

为了满足不同用户场景的需要,HotSpot内置了多个即时编译器:C1、C2和Graal。Graal是Java 10正式引入的实验性即时编译器,在专栏的第四部分我会详细介绍,这里暂不做讨论。

之所以引入多个即时编译器,是为了在编译时间和生成代码的执行效率之间进行取舍。C1又叫做Client编译器,面向的是对启动性能有要求的客户端GUI程序,采用的优化手段相对简单,因此编译时间较短。

C2又叫做Server编译器,面向的是对峰值性能有要求的服务器端程序,采用的优化手段相对复杂,因此编译时间较长,但同时生成代码的执行效率较高。

从Java 7开始,HotSpot默认采用分层编译的方式:热点方法首先会被C1编译,而后热点方法中的热点会进一步被C2编译。

为了不干扰应用的正常运行,HotSpot的即时编译是放在额外的编译线程中进行的。HotSpot会根据CPU的数量设置编译线程的数目,并且按1:2的比例配置给C1及C2编译器。

在计算资源充足的情况下,字节码的解释执行和即时编译可同时进行。编译完成后的机器码会在下次调用该方法时启用,以替换原本的解释执行。

总结与实践

今天我简单介绍了Java代码为何在虚拟机中运行,以及如何在虚拟机中运行。

之所以要在虚拟机中运行,是因为它提供了可移植性。一旦Java代码被编译为Java字节码,便可以在不同平台上的Java虚拟机实现上运行。此外,虚拟机还提供了一个代码托管的环境,代替我们处理部分冗长而且容易出错的事务,例如内存管理。

Java虚拟机将运行时内存区域划分为五个部分,分别为方法区、堆、PC寄存器、Java方法栈和本地方法栈。Java程序编译而成的class文件,需要先加载至方法区中,方能在Java虚拟机中运行。

为了提高运行效率,标准JDK中的HotSpot虚拟机采用的是一种混合执行的策略。

它会解释执行Java字节码,然后会将其中反复执行的热点代码,以方法为单位进行即时编译,翻译成机器码后直接运行在底层硬件之上。

HotSpot装载了多个不同的即时编译器,以便在编译时间和生成代码的执行效率之间做取舍。

下面我给你留一个小作业,通过观察两个条件判断语句的运行结果,来思考Java语言和Java虚拟机看待boolean类型的方式是否不同。

下载asmtools.jar [2] ,并在命令行中运行下述指令(不包含提示符$):

$ echo '
public class Foo {
 public static void main(String[] args) {
  boolean flag = true;
  if (flag) System.out.println("Hello, Java!");
  if (flag == true) System.out.println("Hello, JVM!");
 }
}' > Foo.java
$ javac Foo.java
$ java Foo
$ java -cp /path/to/asmtools.jar org.openjdk.asmtools.jdis.Main Foo.class > Foo.jasm.1
$ awk 'NR==1,/iconst_1/{sub(/iconst_1/, "iconst_2")} 1' Foo.jasm.1 > Foo.jasm
$ java -cp /path/to/asmtools.jar org.openjdk.asmtools.jasm.Main Foo.jasm
$ java Foo

[1] : https://en.wikipedia.org/wiki/Java_processor
[2]: https://wiki.openjdk.java.net/display/CodeTools/asmtools

精选留言

  • jiaobuchongจุ๊บ

    2018-10-14 09:31:52

    对老师写的那段 awk 不懂得可参考:
    https://blog.csdn.net/jiaobuchong/article/details/83037467
  • 小名叫大明

    2018-07-26 18:46:11

    受益匪浅,多谢老师。

    请教老师一个问题,网上我没有搜到。

    服务器线程数爆满,使用jstack打印线程堆栈信息,想知道是哪类线程数太多,但是堆栈里全是一样的信息且没有任何关键信息,是哪个方法创建的,以及哪个线程池的都看不到。

    如何更改打印线程堆栈信息的代码(动态)让其打印线程池信息呢?
  • 曲东方

    2018-07-20 14:46:56

    jvm把boolean当做int来处理

    flag = iconst_1 = true

    awk把stackframe中的flag改为iconst_2

    if(flag)比较时ifeq指令做是否为零判断,常数2仍为true,打印输出

    if(true == flag)比较时if_cmpne做整数比较,iconst_1是否等于flag,比较失败,不再打印输出


    作者回复

    字节码高手!

    2018-07-20 14:58:19

  • novembersky

    2018-07-20 09:11:49

    文中提到虚拟机会把部分热点代码编译成机器码,我有个疑问,为什么不把java代码全部编译成机器码?很多服务端应用发布频率不会太频繁,但是对运行时的性能和吞吐量要求较高。如果发布或启动时多花点时间编译,能够带来运行时的持久性能收益,不是很合适么?
    作者回复

    问得好!事实上JVM确实有考虑做AOT (ahead of time compilation) 这种事情。AOT能够在线下将Java字节码编译成机器码,主要是用来解决启动性能不好的问题。

    对于这种发布频率不频繁(也就是长时间运行吧?)的程序,其实选择线下编译和即时编译都一样,因为至多一两个小时后该即时编译的都已经编译完成了。另外,即时编译器因为有程序的运行时信息,优化效果更好,也就是说峰值性能更好。

    2018-07-20 13:47:06

  • 醉人

    2018-07-20 13:47:25

    解释执行 执行时才翻译成机器指令,无需保存不占内存。但即时编译类似预编译,编译之后的指令需要保存在内存中,这种方式吃内存,按照二八原则这种混合模式最恰当的,热点代码编译之后放入内存避免重复编译,而其他运行次数较少代码则解释执行,避免占用过多内存
  • かっこいすぎる郑一凡

    2018-07-20 21:52:29

    我想问下,JVM是怎么区别出热点代码和非热点代码的?
  • MARK

    2018-07-25 22:46:18

    作业终于做出来~\(≧▽≦)/~喜大普奔
    asmtools下载地址:
    https://adopt-openjdk.ci.cloudbees.com/view/OpenJDK/job/asmtools/lastSuccessfulBuild/artifact/asmtools-6.0.tar.gz;
    先是在window环境里,awk不能使用,看https://zh.wikipedia.org/wiki/Awk,AWK是一种优良的文本处理工具,Linux及Unix环境中现有的功能最强大的数据处理引擎之一,于是转战Linux,
    [root@localhost cqq]# javac Foo.java
    [root@localhost cqq]# java Foo
    Hello,Java
    Hello,JVM
    [root@localhost cqq]# java -cp /cqq/asmtools.jar org.openjdk.asmtools.jdis.Main Foo.class>Foo.jasm.1
    [root@localhost cqq]# ls
    asmtools.jar Foo.class Foo.jasm.1 Foo.java
    [root@localhost cqq]# vi Foo.jasm.1
    [root@localhost cqq]# awk 'NR==1,/iconst_1/{sub(/iconst_1/,"iconst_2")} 1' Foo.jasm.1>Foo.jasm
    [root@localhost cqq]# java -cp /cqq/asmtools.jar org.openjdk.asmtools.jasm.Main Foo.jasm
    [root@localhost cqq]# java Foo
    Hello,Java
    结果为啥是这个看点赞第一的高手;
    另外asmtools使用方式还可以这样子:
    java -jar asmtools.jar jdis Foo.class>Foo.jasm.1
    java -jar asmtools.jar jasm Foo.jasm
  • 之外^Excepts

    2018-07-21 14:55:27

    我只想问,你就没有教授的担忧?万一我拿今天的知识点去问面试者,答不上来咋办?
  • J

    2018-12-25 14:56:44

    在Windows使用不了awk工具(貌似有代替方案),所以结合其他小伙伴的答案和自己的思考,答案整理如下:
    小作业的过程是:
    1、写Java代码,生成java文件
    2、将java文件编译成class文件(字节码)
    3、执行字节码,输出两个Hello,world!
    4、使用asmtool工具将class文件生成jasm文件
    5、使用awt工具将jasm文件stackframe的flag改为iconst_2
    6、再次使用asmtool工具将jasm文件恢复成class文件
    7、执行字节码,输出一个Hello, world!
    由于Java虚拟机将boolean类型看成0或者1,在步骤5中将源代码中的flag修改成2,于是在步骤7中的运行过程中,if(2),true,执行输出;if(2 == 1),结果为false,不执行输出。
  • 2018-07-24 08:53:05

    1:为什么使用JVM?
    1-1:可以轻松实现Java代码的跨平台执行
    1-2:JVM提供了一个托管平台,提供内存管理、垃圾回收、编译时动态校验等功能
    1-3:使用JVM能够让我们的编程工作更轻松、高效节省公司成本,提示社会化的整体快发效率,我们只关注和业务相关的程序逻辑的编写,其他业务无关但对于编程同样重要的事情交给JVM来处理

    2:听完此节的课程的疑惑(之前就没太明白,原期待听完后不再疑惑的)
    2-1:Java源代码怎么就经过编译变成了Java字节码?
    2-2:JVM怎么就把Java字节码加载进JVM内了?先加载那个类的字节码?它怎么定位的?拿到后怎么解析的?不会整个文件放到一个地方吧?使用的时候又是怎么找到的呢?这些感觉还是黑盒
    2-3:JVM将内存区域分成堆和栈,然后又将栈分成pc寄存器、本地方法栈、Java方法栈,有些内存空间是线程可共享的,有些是线程私有的。现在也了解不同的内存区块有不同的用处,不过他们是怎么被划分的哪?为什么是他们,不能再多几种或少几种了吗?共享的内存区和私有的又是怎么控制的哪?
    作者回复

    总结得非常细致!
    2-1 其实是这样的,JVM接收字节码,要运行在JVM上只能选择转化为字节码。要是不想在JVM上跑,可以选择直接转化为机器码。

    2-2 类加载会在第三篇详细介绍。

    2-3 具体的划分都是实现细节。你也可以选择全部冗杂在一起。但是这样子做性能较高,因为线程私有的可以不用同步。

    2018-07-24 15:44:33

  • Ryan-Hou

    2018-07-20 10:49:05

    在为什么Java要在虚拟机里执行这一节您提到,java语法复杂,抽象度高,直接通过硬件来执行不现实,但是同样作为高级语言为什么C++就可以呢?这个理由作为引入虚拟机这个中间层的原因不是很充分吧
    作者回复

    多谢指出!这里的直接运行指的是不经过任何转换(编译),直接在硬件上跑。即便是C++,也不可以直接运行。

    C++的策略是直接编译成目标架构的机器码,Java的策略是编译成一个虚拟架构的机器码。这个虚拟架构可以有物理实现(可以搜Java processor),也可以是软件实现,也就是我们经常接触到的JRE。

    2018-07-20 13:54:57

  • 周仕林

    2018-07-23 09:09:46

    看到有人说热点代码的区别,在git里面涉及到的热点代码有两种算法,基于采样的热点探测和基于计数器的热点探测。一般采用的都是基于计数器的热点探测,两者的优缺点百度一下就知道了。基于计数器的热点探测又有两个计数器,方法调用计数器,回边计数器,他们在C1和C2又有不同的阈值。😂😂
    作者回复

    谢谢!

    2018-07-24 15:33:43

  • kernel

    2018-08-13 23:56:03

    评论比文章精彩,学习到更多
  • mj4ever

    2018-10-05 17:29:37

    Java语言规范中,boolean类型的只有两种 true和false(false是默认值);
    Java虚拟机规范中,boolean类型要转换为int类型,true => 1,false => 0;
    在编译而成的class文件中,除了字段和传入参数外,基本看不出boolean类型:
    比如,第一次时为:
    public class Foo {
    public Foo() {
    }
    public static void main(String[] var0) {
    byte flag = 1;
    if (flag != 0) {
    System.out.println("Hello, Java!");
    }
    if (flag == 1) {
    System.out.println("Hello, JVM!");
    }
    }
    }
    第2次执行时,正则表达式将flag替换为2:
    public class Foo {
    public Foo() {
    }
    public static void main(String[] var0) {
    byte flag = 2;
    if (flag != 0) {
    System.out.println("Hello, Java!");
    }
    if (flag == 1) {
    System.out.println("Hello, JVM!");
    }
    }
    }
  • 尔东

    2018-12-30 11:04:00

    1.Java代码首先编译为class文件,然后通过java虚拟机加载到方法区。Java虚拟机是一个独立的进程,执行方法区的代码。
    2.Java虚拟机把内存分为堆栈两种形式来存储运行时数据,包括线程共有的方法区和堆,以及线程私有的pc计数器,方法栈,naive方法栈
    3.Java虚拟机将字节码翻译成机器码执行的方法有两种,一种是解释执行,即逐条将字节码翻译成机器码并执行;第二种是即时编译,即将一个方法包含的所有字节码编译成机器码后再执行
    4.解释执行的好处是无需等待编译,即时编译的好处是速度更快。这里编译的概念并不是代码编译为字节码,而是字节码编译为机器码,字节码编译为机器码是由java虚拟机在运行程序的时候才会去做的,所以是运行时的开销。热点代码会通过即时编译来执行。
    5.HotSpot内置了多个即时编译器,包括C1、C2和Graal。
    6.Asmtools.jar下载地址https://ci.adoptopenjdk.net/view/Dependencies/job/asmtools/lastSuccessfulBuild/
    7.JVM将Boolean类型看作是int类型,true就是1,false就是0,flag如果改成2第二个判1等式就不成立,所以只有第一个判0等式通过。
  • 笨笨蛋

    2018-07-24 07:34:06

    什么时候使用C1,什么时候使用C2,他是怎么区分热点方法的呢?
    作者回复

    刚刚看到一个同学总结了。JVM会统计每个方法被调用了多少次,超过多少次,那就是热点方法。(还有个循环回边计数器,用来编译热循环的。)

    默认的分层编译应该是达到两千调C1,达到一万五调C2。

    2018-07-24 16:01:53

  • ace

    2018-08-05 12:40:07

    最后awk那段分析了下。希望分析正确 也对大家有帮助
    1、NR==1,/iconst_1/ 是用于匹配行 匹配第一行到第一个出现iconst_1的行
    2、{}进行脚本执行。针对第一步中匹配的行执行内置的字符串函数sub 做替换
    3、1 都会被执行。在awk 1被计算为true,表现为默认操作 print $0 也就是打印整行
    整体效果是打印所以文本行,但第一个出现iconst_1的做替换。
  • 那我懂你意思了

    2018-07-20 09:14:03

    老师,那个pc寄存器,本地方法栈,以及方法栈,java方法栈这三个组成的就是我们常统称的栈吧,然后也叫栈帧?
    作者回复

    JVM里的栈指的应该是Java方法栈和本地方法栈。每个方法调用会在栈上划出一块作为栈帧(stack frame)。栈是由多个栈帧构成的,就好比电影是由一个个帧构成的。

    2018-07-20 14:16:16

  • 踏雪无痕

    2018-07-20 10:20:26

    您好,我现在所在的项目经常堆外内存占用非常多,超过总内存的70%,请问一下有没有什么方法能观察一下堆外内存有什么内容?
    作者回复

    堆外内存的话,就把JVM当成普通进程来查找内存泄漏。可以看下Google Performance Tools相关资料

    2018-07-20 14:54:57

  • Phoenix

    2018-09-16 23:31:44

    解释执行是将字节码翻译为机器码,JIT也是将字节码翻译为机器码,为什么JIT就比解释执行要快这么多?
    如果说JIT检测到是热点代码并且进行优化,那么为什么解释执行不直接就用这种优化去解释字节码?
    一些比较浅的问题,希望老师能指点一二
    作者回复

    1. 就单条加法字节码而言,解释执行器需要识别字节码,然后将两个操作数从Java方法栈上读取出来并相加,最后将结果存入Java方法栈中。而JIT生成的机器码就只是一个CPU加法指令。

    2. 因为JIT比较费时。如果字节码需要JIT后才跑,那么启动性能会很糟糕

    2018-09-17 17:17:24