Java基础之刨根问底第1集——JVM的结构

本文详细介绍了JVM的结构,包括程序计数器、栈区和堆区的组成,以及方法区、年轻代和老年代的内存分布。通过示例代码解析了字节码指令如何执行,展示了数据在JVM各区域的存储位置。同时,强调了运行时常量池的作用和局部变量的存储规则。
摘要由CSDN通过智能技术生成

原文转自我自己的个人公众号:Java基础之刨根问底第1集——JVM的结构

断更好久了,终于有时间写新的了,希望可以把《刨根问底》做成一个系列。

原本第一集我是想写数据类型的,在整理原始数据类型的相关资料的时候,感觉JVM的结构是基础中的基础,如果不讲清楚JVM的结构,其他的知识也很难讲透彻,因此第一集就讲JVM的结构吧。

    网上讲JVM的结构的资料还是比较多的,排除掉各种复制粘贴后,总感觉知识相对零散,总有一种雾里看花的感觉,于是我根据Oracle官方发布的《Java虚拟机规范》进行了比较全面的整理。

    JVM的结构的全貌如下图所示:

图片

    注意:图中不含native stack;young和tenured的结构是默认分代的排布,不包括并行收集器和G1收集器。

    下面我就图中的元素进行逐一的介绍:

    从大块上来看,我将其分为三个部分:

  • PC Register:程序计数器注册机,每个线程一个。其中保存了returnAddress类型的数据,用于指向当前被执行的JVM指令的位置。

  • Stacks:栈区。每个线程创建的时候,都会在栈区为它创建一个专属的栈(Stack)。

  • Heap:堆区。所有线程共享的区域,其中的内存可以被垃圾回收器(GC)进行回收,虚拟机启动的时候就会创建。主要用于存储类的实例和数组。

    下面重点介绍Stack和Heap,首先是Stack。

    Stack中存储的是局部变量和部分结果,在方法的调用和返回中起到作用。每个Stack对应了一个线程,其中包含了若干个Frame(帧)。当程序执行方法的时候,会给该方法创建一个Frame,当方法调用结束,对应的Frame就会被销毁。由于一个线程统一时刻只能执行一个方法,因此一个Stack中只会有一个Frame是激活状态的(联想下Debug时候的调用栈信息可以更好的理解这一点)。

    Frame主要用于存储方法内的数据、部分结果、动态连接、方法返回值和分派异常。一个Frame内包括以下两个区域:

  • LocalVariableTable:局部变量表。实际上是一个数组,用于保存局部变量的值。0号位置是this,局部变量从1开始存储。每一个boolean、byte、char、short、int、float、引用和returnAddress占用一个位置,每个long和double占用2个位置。

  • Operand Stack:操作数栈,是一个后进先出的结构。大部分JVM的指令在取操作数的时候都不会直接操作局部变量,而是从操作数栈中弹出。因为这个机制,操作数栈和局部变量表会频繁的相互交换数据。操作数栈创建时是空的,当需要数据时从局部变量或者运行时常量池中加载,运算结果会再放回操作数栈,需要的话再存入局部变量表中供后续使用。

    下来再介绍下Heap。Heap区是所有线程共享的,主要存储实例和数组,虚拟机启动的时候就会创建,Heap区域的内存可以被垃圾回收。Heap内包括以下子区域:

  • Method Area:方法区。在逻辑上属于Heap,但规范中并没有强制要求,具体的JVM实现可以选择是否对该区域进行GC,也可以选择是否将该区域放在Heap中。该区域在JVM启动的时候就会创建。内部存储Run-time Constant Pool、编译后的代码、字段和方法信息等。

  • Young:年轻代。新创建的对象实例和数组存放的区域,包含Eden区和2个Survivor区。两个Survivor统一时刻只会有一个有数据(年轻代和老年代的详情我计划用另一篇介绍垃圾回收的文章进行介绍)。

  • Tenured:老年代。对象存活足够多次后就会放入老年代。

    在进一步来看一下Method Area中的Run-time Constant Pool,即:运行时常量池。当每个类或接口创建的时候就会创建一个专属的运行时常量池,用来存储编译时已知的值和需要在运行时解析的方法和字段的引用。

    下面用思维导图再总结一下:

图片

    下面我们用一个示例程序来看看代码中的数据都存在了哪里,也顺带介绍下JVM是怎么在字节码中通过指令集来执行我们的程序的。

    程序是这样的:

图片

    大家可以先猜一下这段代码中的属性和变量都存在JVM中的哪个区中。

    为了验证猜想,需要将这个类编译后的class文件反编译成Java汇编指令。JDK中提供了javap工具,可以通过“javap -v class文件名”的方式来反编译(是对class文件使用而不是对java文件)。

    结果是这样的:

图片

    从图中可以看到,最上方显示了Constant pool信息,这个就是运行时常量池,它随着类的创建而被创建,所以是类中全局的。中间的DemoClass()是自动生成的构造方法,由于我们本次的目标是分析demoMethod中的数据存放位置,因此构造方法先不看。最下面就是重点要分析的demoMethod方法。可以看到,DemoClass()和demoMethod()都有自己专属的本地变量表LocalVariableTable。

    首先,从最上方的运行时常量池中可以看到,我在类中定义的三个属性field、demoClass和fieldArray都存在了heap中Method Area中的运行时常量池中。在介绍运行时常量池时,介绍过这里就是存储编译时已知的值和类的类属性的引用,因此不但field、demoClass和fieldArray作为属性引用出现在常量池中,而且localArg、localVar、localClass和localArray也在常量池中。在常量池中还看到了方法和类自身的一些信息的引用。这里只是存放引用的,具体的值存放在heap的年轻代或老年代中。

    下面重点看一下demoMethod方法,我从Code中的0:开始介绍。

    0:iconst_0

    1:istore_2

    这两行指令对应了int localVar = 0;这行代码。

    iconst指令用于把程序中的常量push到操作数栈中,iconst是一个家族,iconst_0是其中的一个,表示存放的操作数的值是0,这样就不用把值放到heap中用的时候再去取了,效率会高一些,也省内存。

    istore指令用于从操作数栈顶弹出一个操作数存入localVariable中,下划线后面表示存入本地变量表中的位置,因此可以在最下方的本地变量列表中看到Slot为2的那一行显示了localVar。这里补充一下,可以看到Slot是1的行显示localArg,这个是方法的变量,而0号位则是this,就是我们经常在程序中用的那个this。也就是说,this永远都是本地变量表中的0号位,然后再按顺序排列参数列表和方法中的变量。

    2:new   #4

    5:dup

    6:invokespecial   #5

    9:astore_3

    这四行对应了程序中的DemoClass localClass = new DemoClass();这行代码。

    new指令用于创建一个对象,#后的数字是在常量池中的索引,从最上方类的常量池列表中可以看到#4对应的就是一个DemoClass的实例,实例会在新生代创建,然后对象的引用会push到操作数栈上。

    dup指令用于将操作数栈顶的值拷贝一份再push到栈中,这样栈顶的2个位置都是这个值。用于后续的处理。

    invokespecial指令用于调用实例的方法,#后面表示常量池中的位置。通过看常量池中的#5,这一步是调用了类的构造方法。

    astore指令用于从操作数栈中弹出一个引用然后存入本地变量中,下划线后面的数字是本地变量表的位置,可以看到3号位正是localClass。

    bipush 10

    newarray int

    astore_4

    这3行对应了程序中的int[] localArray = new int[10];这一行。

    bipush指令用于将一个字节push到操作数栈中,后面跟的是这一个字节的数据。一个字节能表示的整数范围是-128到127,10在这个范围内。这里的10是因为程序中声明了大小为10的数组。

    newarray指令用于创建数组,后面跟的是类型。该指令会从操作数栈中弹出刚刚push进去的数组长度,用于创建数组,数组会被创建到新生代中,数组引用被push到操作数栈顶。

    astore_4这个已经解释过了,将栈顶刚刚创建的数组引用存入本地变量表的第四个位置。

    iload_1

    iload_2

    iadd

    这三行对应了程序中的localArg + localVar。

    iload指令用于从本地变量表中加载数据push到操作数栈中,下划线后面的数字是本地变量表的位置。也就是从本地变量表1、2两个位置向操作数栈push数据,这两个位置放的就是localArg和localVar的值。

    iadd指令则是从操作数栈中弹出2个int值然后相加,将结果再push到操作数栈顶。

    ireturn

    这条指令对应了程序中的return,表示方法返回一个int型数据,即:弹出操作数栈顶,然后销毁该方法的操作数栈。

    以上就是这段程序内各个属性和变量存放位置的解释,可能有人会得出这样一个结论:类的属性都会以引用的方式存放在运行时常量池中,方法中包括参数在内的局部变量只要是对象和数组就会存到heap中,像int就会直接存在栈中。然而,并不是所有的int都会存放到栈中

    在demoMehod方法中有2个int值,一个是0,另一个是10。0被存入栈是因为有专门为零准备的iconst_0指令,10被存入的原因是因为它可以用一个字节表示,因此用了bipush指令直接压入操作数栈。可能有人想到,0也可以用一个字节表示,为什么没有用bipush呢?实际上,用bipush也可以,对于字节码来说,iconst_0指令后面不需要跟值,而bipush后面需要跟一个字节的值,因此会比前者大一个字节。

    如果数据的值比一个字节大,或者是不常见的double型数据时,就不会直接存入stack中了,而是会存入heap中。java8官方jvm规范中,举了下面这个例子:

图片

    javap的结果是这样的:

图片

    可以看到,超过一个字节的1000000、0xffffffff和不是常见数字的double都使用了ldc指令,该指令会根据索引从运行时常量池中获取值,然后将得到的值push到操作数栈中。也就是说,这些数字都是存在heap中的。

    到此,第1集JVM的结构就介绍完了。

更多内容请关注我的公众号:

 

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值