java代码在jvm中的运行过程详解

168 阅读13分钟

本文已参与「新人创作礼」活动,一起开启掘金创作之路。

环境

jdk

jdk1.8(使用最广的版本,建议新项目选择jdk11,垃圾回收器更优秀)

一段简单代码

public class Person {
    public  int work()throws Exception{
        int x =1;
        int y =2;
        int z =(x+y)*10;
        return  z;
    }
    public static void main(String[] args) throws Exception{
        Person person = new Person();//person 栈中--、  new  Person  对象是在堆
        System.out.println(person.work());
    }
}

过程解释

类加载过程

加载--->连接(验证-->准备-->解析)-->初始化-->使用-->卸载

加载

我们都知道当我们编写一个类时就会生成一个.java文件,比如我们上面的代码在我机器上就有Person.java文件

image.png 点进去看看 从下图可以看见就是我们在idea编写的代码

image.png 由于jvm会识别class文件格式,因此我们常用的idea会帮我们自动编译好我们所写的类

image.png

连接

使用命令javap -verbose Person.class查询该文件的字节码

D:\BaiduNetdiskDownload\ref-jvm3\out\production\ref-jvm3\gcOom>javap -verbose Person.class
Classfile /D:/BaiduNetdiskDownload/ref-jvm3/out/production/ref-jvm3/gcOom/Person.class
  Last modified 202227日; size 745 bytes
  MD5 checksum b4981e183f37ec9bba52d52e4beae0ff
  Compiled from "Person.java"
public class gcOom.Person
  minor version: 0   // 1.小版本
  major version: 52  // 2.大版本
  flags: (0x0021) ACC_PUBLIC, ACC_SUPER // 3.访问标志
  this_class: #2                          // gcOom/Person
  super_class: #7                         // java/lang/Object
  interfaces: 0, fields: 0, methods: 3, attributes: 1
Constant pool:   // 4.类常量池
   #1 = Methodref          #7.#30         // java/lang/Object."<init>":()V
   #2 = Class              #31            // gcOom/Person
   #3 = Methodref          #2.#30         // gcOom/Person."<init>":()V
   #4 = Fieldref           #32.#33        // java/lang/System.out:Ljava/io/PrintStream;
   #5 = Methodref          #2.#34         // gcOom/Person.work:()I
   #6 = Methodref          #35.#36        // java/io/PrintStream.println:(I)V
   #7 = Class              #37            // java/lang/Object
   #8 = Utf8               <init>
   #9 = Utf8               ()V
  #10 = Utf8               Code
  #11 = Utf8               LineNumberTable
  #12 = Utf8               LocalVariableTable
  #13 = Utf8               this
  #14 = Utf8               LgcOom/Person;
  #15 = Utf8               work
  #16 = Utf8               ()I
  #17 = Utf8               x
  #18 = Utf8               I
  #19 = Utf8               y
  #20 = Utf8               z
  #21 = Utf8               Exceptions
  #22 = Class              #38            // java/lang/Exception
  #23 = Utf8               main
  #24 = Utf8               ([Ljava/lang/String;)V
  #25 = Utf8               args
  #26 = Utf8               [Ljava/lang/String;
  #27 = Utf8               person
  #28 = Utf8               SourceFile
  #29 = Utf8               Person.java
  #30 = NameAndType        #8:#9          // "<init>":()V
  #31 = Utf8               gcOom/Person
  #32 = Class              #39            // java/lang/System
  #33 = NameAndType        #40:#41        // out:Ljava/io/PrintStream;
  #34 = NameAndType        #15:#16        // work:()I
  #35 = Class              #42            // java/io/PrintStream
  #36 = NameAndType        #43:#44        // println:(I)V
  #37 = Utf8               java/lang/Object
  #38 = Utf8               java/lang/Exception
  #39 = Utf8               java/lang/System
  #40 = Utf8               out
  #41 = Utf8               Ljava/io/PrintStream;
  #42 = Utf8               java/io/PrintStream
  #43 = Utf8               println
  #44 = Utf8               (I)V
{
  public gcOom.Person();
    descriptor: ()V
    flags: (0x0001) ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: return
      LineNumberTable:
        line 8: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       5     0  this   LgcOom/Person;

  public int work() throws java.lang.Exception;
    descriptor: ()I
    flags: (0x0001) ACC_PUBLIC
    Code:
      stack=2, locals=4, args_size=1
         0: iconst_1
         1: istore_1
         2: iconst_2
         3: istore_2
         4: iload_1
         5: iload_2
         6: iadd
         7: bipush        10
         9: imul
        10: istore_3
        11: iload_3
        12: ireturn
      LineNumberTable:
        line 10: 0
        line 11: 2
        line 12: 4
        line 13: 11
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      13     0  this   LgcOom/Person;
            2      11     1     x   I
            4       9     2     y   I
           11       2     3     z   I
    Exceptions:
      throws java.lang.Exception

  public static void main(java.lang.String[]) throws java.lang.Exception;
    descriptor: ([Ljava/lang/String;)V
    flags: (0x0009) ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=2, args_size=1
         0: new           #2                  // class gcOom/Person
         3: dup
         4: invokespecial #3                  // Method "<init>":()V
         7: astore_1
         8: getstatic     #4                  // Field java/lang/System.out:Ljava/io/PrintStream;
        11: aload_1
        12: invokevirtual #5                  // Method work:()I
        15: invokevirtual #6                  // Method java/io/PrintStream.println:(I)V
        18: return
      LineNumberTable:
        line 16: 0
        line 17: 8
        line 18: 18
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      19     0  args   [Ljava/lang/String;
            8      11     1 person   LgcOom/Person;
    Exceptions:
      throws java.lang.Exception
}
SourceFile: "Person.java"

Class文件结构

从上面加上了解能知道class文件结构大致分为魔术(一个特殊标志,表示是jvm能识别的文件类似于图片.jpg一样) 这里不知道读者有没有疑问,为什么class文件不像其他文件一样用后缀来标识呢?因为后缀容易被改变,所以java将标识设计在内部,更安全。下面引用《深入理解java虚拟机》来进一步解释

魔数的使用主要是基于安全考虑,文件扩展名可以随意更改。魔数可以由文件格式制定者自由选择,只要没被使用。java的魔数是0xCAFEBABE。

紧接着魔数的 4 个字节存储的是 Class 文件的版本号:第 5 和第 6 个字节是次版本号(MinorVersion),第 7 和第 8 个字节是主版本号(MajorVersion)。版本的用处就是标识当前class能否在当前jvm中运行,高本版向下兼容。

接着就是常量池,访问标志,类索引、父类索引与接口索引集合,字段表集合,方法表集合,属性表集合。

类加载器

我们已经知道类最终变成class字节码,可以由jvm识别和执行,那么类在加载的时候,是否都用一个类加载器,如果用同一个类加载器,又怎么能保证java的核心类不会被篡改覆盖导致出现安全问题,那么这个时候聪明的先辈们就提出了双亲委派模型。

双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应当有自己的父类加载器。这里类加载器之间的父子关系一般不会以继承(Inheritance)的关系来实现,而是都使用组合(Composition)关系来复用父加载器的代码。

jdk提供了三层类加载器,BootstrapClassLoader底层由C实现(加载java的核心类),ExtentionClassLoader(加载lib/ext目录下的jar包和.class文件),ApplicationClassLoader(加载我们编写的java类),CustomClassLoader(自定义加载器)

image.png

java内存模型

从上面我们已经知道一个类怎么被加载,那么被加载的这个类里面的数据信息又放到哪里的呢?肯定大家都知道在内存中,但如果在jvm里内存就是一整块,那么怎么使用以及回收呢?有些数据是线程独享的,有些是线程共享的那么应该如何划分呢?这个时候就引出了运行时数据区域这个概念,运行时数据区中的线程共享和线程独享区域再次详细划分。

image.png

详情点击《java内存模型》

这个时候是否有疑问为啥要这么分,首先分这几块是jvm虚拟机规范中明确规定的,这个时候大家思考一下为啥堆和方法区是线程共享的,其他的是线程私有的?要回答这个问题需要弄清楚每块存放什么数据。

比如在方法区存放的就是:静态变量,常量池,运行时常量池,类信息,即时编译器编译后的代码缓存。

:绝大多数对象的分配都在上面,是运行时数据区占比最大的一块。

虚拟机栈和本地方法栈:存放方法中的局部变量表,操作数栈,动态连接,返回地址。

程序计数器:记录当前线程执行到当前方法哪一行了,当再次切换到执行权时从当前下一行开始执行。

从上面我们是不是就知道栈是来承载方法的,而方法的执行必须跟线程的生命周期一样,而程序计数器又是来记录线程的执行位置,因此这三个必须是线程私有的,也没必须做成共享的。

从上面我们就知道了我们最开始的那段代码,也就是Person类最终将它的信息保存到了方法区中。 new Person()新建的对象保存到堆中。

对象在堆上分配

堆是否是一整块呢?如果了解过分代回收理论就知道对于堆有根据分代理论实现的,也有不根据分代理论实现的,具体要根据实现的垃圾回收器来进行描述,现在主要按照分代理论来说。

弱分代假说:绝大多数对象都是朝生夕死的。

强分代假说:熬过越多次垃圾回收的对象就越难消亡。

跨代引用假说:跨代引用相对于同代引用来说仅占极少数

根据这三个假说的理论基础,又将堆划分为新生代以及老年代,老年代存放那些难以消亡的对象,新生代存放绝大多数新创建的对象,如果是大对象会直接存放到老年代,新生代再根据需要划分为eden区和两个survivor区。虽然跨代引用比较少,但是还是存在因此在HostSport通过卡表来进行解决这个问题。

思考为什么新生代还要分成eden区和两个survivor区?

那是因为新生代分配的对象要想进入老年代必须要达到一定的标准(年龄达到一定数值,动态年龄调整,空间分配担保),加上在垃圾回收上面采用的是复制算法,如果不这样分那么将浪费一般的空间。而这三者默认比例是8:1:1,那么就只会浪费1/10的空间。

对象的分配

对象的分配方式有两种:指针碰撞,空闲列表

指针碰撞:适应于空间规整,在空闲和使用之间存在一个指针,当需要分配时只需要移动指针。

空闲列表:适用于不规整的空间中对象分配,使用一个列表来记录那块区域未分配。

分配安全的保证

cas+重试本地线程分配缓冲

cas+重试:优点使用的时候才分配,不占空间,缺点大量重试导致性能下降。

TLAB(本地线程分配缓冲):在线程创建时就开辟一个空间用于当前线程进行对象的分配,优点提前分配不会存在冲突,简单高效。缺点提前分配浪空间。默认是此方式。

思考:分配方式二者如和选择?

主要根据当前垃圾回收机制是否能将空间变得规整,比如标记压缩和复制算法就可以选择指针碰撞,而标记清除就可以选择空闲列表。

对象的访问

现在我们根据上面一步一步,已经将对象分配在堆上了,那么在使用时就需要进行访问,对象的访问方式主要有两种,一种是直接指针和另一种句柄

image.png

二者的对比

句柄

需要额外在堆上开辟一块空间作为句柄池,reference指向句柄地址,句柄地址又会存类型数据地址以及实例数据地址,一次查找需要两次定位。

优点:移动对象的时候不需要改变refrence的指针地址。

缺点:需要两次定位,在大量操作时还是会存在性能问题。

直接指针

reference中记录的是实例数据的地址,实例数据需要在对象头中记录类型数据的地址,通过一次访问就能获取到实例数据。HotSport默认使用此方式进行对象访问,因为在java中,对象的创建和访问是十分频繁的。

优点:访问速度快。

缺点:移动对象时需要改变reference的指针地址,增加额外开销。

对象的内存布局

对象头(mark word,klasspoint,数组长度),实例数据,对齐填充。

对象的回收

为什么要对对象进行回收?回收的意义?以及如何确定对象能被回收?如何回收? 对象回收的意义当然是保持程序正常运行,垃圾对象被清除掉用于新对象的分配,因此回收垃圾对象是十分有意义的。

垃圾对象的确定

确定对象是否是垃圾主要有两种算法。

名称描述优点缺点
引用计数法对象每被引用一次就改变对象中维护一个引用计数器的值加一,每失去一次引用就减一直到为0就认为对象,不再被任何对象引用就是垃圾,可以被回收。实现简单,能快速高效统计和分辨对象是否为垃圾,次数为0就是垃圾。为了引用计数的正确性需要额外大量开销来维护(内部结构问题,通过引入外部结构来解决就会增加额外的维护成本),该算法不能解决对象之间循环引用对象的清除。
可达分析法指从GC ROOT 出发能到达的对象就是存活的对象,不能到达的就会被标记可以被清除。注意就算被标记为可清除,并不一定会清除,还要看对象是否可以被拯救。查找准确度高,不依赖外部结构遍历效率是GC的最大瓶颈

垃圾回收算法

名称描述优点缺点
标记-清除主要分为两个步骤,第一步根据gcroot标记还存活的对象,第二步将未被标记的对象也就是垃圾直接进行清除。是最先提出的算法,为后续算法做出铺垫。实现简单回收效率不确定,当有大量对象回收时,停顿时间会增加,会产生大量空间碎片
复制-算法将内存划分成相等的两部分,每次使用只用其中一部分,在回收的时候将存活的对象复制到空闲的那一部分,然后直接释放掉之前的那一部分。如果有大量存活的对象需要复制,效率会降低。实现简单会浪费一半的空间
标记-整理大致的执行步骤跟标记清除差不多,唯一不同的是标记后的下一个阶段并不是直接清除,而是将存活的对象,朝着一端移动,然后释放掉存活的边界之外的内存。空间利用率高,空间规整回收速度慢,stw时间会增加。

垃圾回收处理器

我们已经知道如何确定对象是否该被清除,以及清除的算法,那么就可以根据这些来设计垃圾回收处理器来实现垃圾的回收,当然前辈们已经开发出许多经典的垃圾回收处理器了。

image.png

完整过程视频演示

字节码执行过程视频

总结

本文从一段代码引出在jvm中是如何执行的,存放到哪里,如何进行对象的分配以及对象的访问,再到垃圾的回收,由于篇幅有限,还有很多细节并没有描述出来,比如HostSport中的一些实现细节三色标记,读写屏障,卡表,以及根节点枚举,安全点以及安全区域等。最后推荐大家一定多看看这本神书《深入理解java虚拟机》。

点击更多关于jvm

v2-096fb6b22311971ec42f4859a57f1de9_b.gif