一、概览

jvm内部体系结构主要分为三个部分:类加载器子系统,运行时数据区和执行引擎
**

1.1-Java内存区域深究 - 图1

二、内存区域总览

Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域。这些区域都有各自的用途,以及创建和销毁的时间,有的区域随着虚拟机进程的启动而存在(程序计数器),有些区域则是依赖用户线程的启动和结束而建立和销毁。Java虚拟机所管理的内存化为如下的几个区域:

1.1-Java内存区域深究 - 图2

从宏观的角度看,Java的内存区域分为:线程共享的和线程私有的。

线程共享的包括:堆、常量池,方法区 线程私有的包括:虚拟机栈、本地方法栈、程序计数器

三、线程私-程序计数器

2.1 程序计数器

程序计数器是一块较小的内存空间,它的作用可以看作是当前线程所执行的字节码的行号指示器。在虚拟机的概念模型里字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。

    • 摘自《深入理解Java虚拟机》


特 点**

  1. 如果线程正在执行的是Java 方法,则这个计数器记录的是正在执行的虚拟机字节码指令地址
  2. 如果正在执行的是Native 方法,则这个技术器值为空(Undefined)
  3. 此内存区域是唯一一个在Java虚拟机规范中没有规定任何OutOfMemoryError情况的区域

2.2 程序计数器内容

先看一段简单的程序,然后分析一下程序计数器是如何在里面工作的。

  1. package JVM;
  2. public class ProgramCounterRegister {
  3. public int calc(){
  4. int a = 100;
  5. int b = 200;
  6. int c = 300;
  7. return ( a + b ) * c;
  8. }
  9. }

使用javap或使用jclasslib反编译为:

1.1-Java内存区域深究 - 图3

首先简单的分析一下这个汇编指令:

  1. 0 bipush 100 // 将单字节的常量值100(-128~127)推送至操作栈顶
  2. 2 istore_1 // 栈顶元素出栈,把栈顶的值存入到第1个(从0开始)局部变量表中
  3. 3 sipush 200 // 将一个短整型常量值200(-32768~32767)推送至操作数栈顶
  4. 6 istore_2 // 把栈顶的值存入到第2个(从0开始)局部变量表中
  5. 7 sipush 300 // 将一个短整型常量值300(-32768~32767)推送至操作数栈顶
  6. 10 istore_3 // 把栈顶的值存入到第2个(从0开始)局部变量表中
  7. 11 iload_1 // 将局部变量1(值100)加载到操纵栈的指令
  8. 12 iload_2 // 将局部变量2(值200)加载到操纵栈的指令
  9. 13 iadd // 将栈顶两个元素出栈,做加法,然后把结果再入栈(即a,b出栈,将a+b入栈)
  10. 14 iload_3 // 将局部变量3(值300)加载到操纵栈的指令
  11. 15 imul // 将栈顶两int型数值相乘并将结果压入栈顶
  12. 16 ireturn // 返回栈顶元素

看了上述的汇编指令,是不是有以下的几个疑问:

(1): 变量a是第一个局部变量,为什么在局部变量表中不是起始位置0,而是起始位置1呢?

java中非静态方法局部变量表中,0位置存放的是对象本身。 java中静态方法中局部变量表0处,存放的是第一个局部变量,如果此方法是静态的,那么此时的a对应局部变量表的0的位置。

(2):左边的字节码指令的数字是什么?

左边的数字对应的就是,字节码指令的偏移地址

2.3 详细的执行过程

现在回到正题,看这个方法是如何执行的,就会明白程序计数器
1.1-Java内存区域深究 - 图4
1.1-Java内存区域深究 - 图5
1.1-Java内存区域深究 - 图6
1.1-Java内存区域深究 - 图7

1.1-Java内存区域深究 - 图8
1.1-Java内存区域深究 - 图9
上面的指令执行过程只是一个概念模型,JVM会对过程做一些优化来提高性能,JVM在实际运行时可能执行过程差距比较大,并且不同虚拟机的执行也不尽相同。

四、线程私有-栈

《深入理解Java虚拟机》原文:Java虚拟机栈(Java Virtual Machine Stack)描述的是Java方法执行的内存模型:每个方法被执行的时候,Java虚拟机栈会同步创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态连接、方法出口等信息。

1.1-Java内存区域深究 - 图10

详细图:
1.1-Java内存区域深究 - 图11

栈内存的分配:

  • 栈内存可以在代码运行时通过一个虚拟机参数来指定其大小 -Xss size。不指定的话,除了windows系统,默认都是1M,windows系统是依据虚拟内存大小分配。
  • 栈内存分配的越大,只是能够进行更多次的方法递归调用,并不会增快运行的效率,反而会使得可执行的线程数变少(总内存不变,每个线程的栈内存变大,数量变少)

4.1 局部变量表(LocalVariableTable)

存放八大基本数据类型 对象引用指针(指向对象在堆内存中的起始地址) 最后返回的returnAddress类型。returnAddress就是指向下一条应该执行的字节码指令

  • 局部变量表的容量以变量槽(Variable Slot)为最小单位,虚拟机规范中并没有明确指明一个Slot暂用的内存空间大小,只是很有“导向性”地说明每个Slot都应该能存放 boolean、byte、char、short、int、float、refrence、returnAddress 类型的数据

  • Java 虚拟机通过索引定位的方式使用局部变量表,索引值的范围是从0开始到局部变量表最大的 Slot 数量。如果是32位数据类型的数据,索引 n 就表示使用第 n 个 Slot,如果是64位数据类型的变量,则说明要使用第 n 和第 n+1 两个 Slot。

  • 一个Slot可以存放一个32位以内的数据类型,Java中用32位以内的数据类型有:boolean、 byte、 char、 short int、 float、reference、returnAddress八种类型。reference是对象的引用。虚拟机规范即没有说明它的长度,也没有明确指出这个引用应由怎样的结构,一般来说,虚拟机实现至少都应当能从此引用中直接或间接的查找到对象在Java堆中得起始地址索引和方法区中得对象类型数据。


  • 对于64位的数据类型,虚拟机会以高位在前的方式为其分配两个连续的Slot空间。Java语言中明确规定的64位的数据类型只有long和double数据类型分割存储的做法与”long和double的非原子性协定” 中把一次long 和double 数据类型读写分割为两次32位读写的做法类似,在阅读JAVA内存模型时对比下。不过,由于局部变量表建在线程的堆栈上,是线程私有的数据,无论读写两个连续的Slot是否是原子操作,都不会引起数据安全问题。

  • 在一个线程里面,方法调用的调用链可能会很长,很多方法可能都同时处于执行的状态,对于执行引擎而言,在活动线程之中,只有位于 Java 虚拟机栈栈顶的那个栈帧才是有效的。这个栈帧被成为当前栈帧,与这个栈帧相关联的方法被成为当前方法。虚拟机执行引擎中所有执行的字节码指令都针对当前栈帧和当前方法进行操作。

关于局部变量表中的基本数据类型和reference数据类型
**

这里的引用指的是局部变量的对象引用,而不是成员变量的引用。成员变量的对象引用是存储在 Java 堆(Heap)中,这个地方是需要特别注意的点

image.png

ReturnAddress: 指向了一条字节码指令的地址

虚拟机数据类型,returnAddress 类型的值就是指向特定指令内存地址的指针,JVM支持多线程,每个线程有自己的程序计数器(pc register),而 pc 中的值就是当前指令所在的内存地址,即 returnAddress 类型的数据(也就是pc register中的值)

这里是R神的回答:https://www.zhihu.com/question/53822079/answer/136699108

returnAddress类型并不是用来存储Java方法调用意义上的“返回地址”,而是纯粹用来存储指向Java字节码指令地址的类型。这个类型用于实现jsr(jump-to-subroutine)及其对应的ret指令,这俩指令(外加宽版的jsr_w)在JDK1.4.2之前用于实现finally块,而从Java SE 7开始就被禁用了。 (留意到Java方法的调用与返回用的是invoke-*指令和return指令,特别是这个return指令跟ret是不一样的)

看一段Java代码:

  1. static int giveMeThatOldFashionedBoolean(boolean bVal) {
  2. try {
  3. if (bVal) {
  4. return 1;
  5. }
  6. return 0;
  7. }
  8. finally {
  9. System.out.println("Got old fashioned.");
  10. }
  11. }

JDK 7之后的代码,对应的汇编指令是如何的的实现try catch代码的:

  1. ### if True的分支 ####
  2. 0 iload_0 // 将局部变量bVal(值100)加载到操纵栈的指令
  3. 1 ifeq 16 (+15) // 指的是如果当前操作数栈顶的值是0(false)的话,就跳转到位于偏移量16的字节码指令
  4. 4 iconst_1 // 将int型(1)推送至栈顶
  5. 5 istore_1 // 栈顶元素出栈,把栈顶的值存入到第1个(从0开始)局部变量表中
  6. 6 getstatic #2 <java/lang/System.out> // 获取类的静态字段,这个参数2是Class文件里的常量池的下标。
  7. 9 ldc #3 <Got old fashioned.> // 从常量池中把字符串压如到栈中
  8. 11 invokevirtual #4 <java/io/PrintStream.println> // 调用println方法
  9. 14 iload_1 // 第1个变量压入操作数栈
  10. 15 ireturn // 返回 1
  11. ### if false的分支 ######
  12. 16 iconst_0 // 将int型(0)推送至栈顶
  13. 17 istore_1 // 栈顶元素出栈,把栈顶的值存入到局部变量表索引为1的slot中
  14. 18 getstatic #2 <java/lang/System.out> //根据偏移从static_fields数组中加载相应的静态属性的值
  15. 21 ldc #3 <Got old fashioned.> // 常量从常量池推送至栈顶
  16. 23 invokevirtual #4 <java/io/PrintStream.println // invokevirtual:调度对象的实例方法
  17. 26 iload_1 // 第1个变量压入操作数栈
  18. 27 ireturn // 返回 0
  19. #### 异常分支 #####
  20. 28 astore_2 // 将引用类型存储在局部变量表的索引2的位置
  21. 29 getstatic #2 <java/lang/System.out> //根据偏移从static_fields数组中加载相应的静态属性的值
  22. 32 ldc #3 <Got old fashioned.> // 常量从常量池推送至栈顶
  23. 34 invokevirtual #4 <java/io/PrintStream.println> // invokevirtual:调度对象的实例方法
  24. 37 aload_2 // 第2个变量压入操作数栈
  25. 38 athrow // 拿到栈顶的异常对象,从本帧开始,一个个去找帧对应方法的异常处理表

彩蛋1:**关于try catch finally的执行顺序:

从第一段if中是不是可以看出,即使是return了,finally中的指令都是会执行的,在return 的时候先把 操作数栈中的1 存放到了局部变量表,在finally中的语句执行了之后,就会再次把局部变量表中指定位置的值压入到栈中,返回出去。——从JVM的源码层面解释了try finally的执行流程

彩蛋2:**关于astore_2:

在pc count第28行,把栈顶的异常对象的引用存在局部变量表索引为2的位置,但是如何知道这个引用指向堆内存中的具体的异常对象,(在编译期间,怎么知道是哪个异常呢?)。这个问题我已经发布在StackOverFlow上了,具体的地址可以参考里面的FaceBook的一位工程师的回答:https://stackoverflow.com/questions/61583835/when-an-exception-occurs-in-a-java-program-what-is-stored-in-the-local-variable?noredirect=1#comment108938415_61583835

彩蛋3:**关于returnAddress和程序计数器的区别我的理解:

程序计数器中存的是正在执行的字节码指令地址,而returnAddress中保存的是return后要执行的字节码的指令地址。一般ret指令都是从returnAddress中取到保存的字节码指令地址,然后跳转到该地址继续执行。

4.2 动态连接(Dynamic Linking)

**

  • 每一个栈帧内部除了包含局部变量表和操作数栈之外,还包含一个指向运行时常量池中该栈帧所属方法的引用,包含这个引用的目的就是为了支持当前方法的代码能够实现动态链接(Dynamic Linking)。在一个字节码文件中,描述一个方法调用了另外的其他方法时,就是通过常量池中指向方法的符号引用(Symbolic Reference)来表示的,那么动态链接的作用就是为了将这些符号引用转换为调用方法的直接引用。


  • 在JVM中,将符号引用转换为调用方法的直接引用与方法的绑定机制相关。当一个字节码文件被装载进JVM内部时,如果被调用的目标方法在编译期可知,且运行期保持不变时,那么在这种情况下将调用方法的符号引用转换为直接引用的过程称之为静态链接。相反如果被调用的方法在编译期无法被确定下来,也就是说,只能够在程序运行期将调用方法的符号引用转换为直接引用,由于这种引用转换过程具备动态性,因此也就被称之为动态链接。

早期绑定:早期绑定就是指被调用的目标方法如果在编译器可知,且运行期保持不变时,即可将这个方法与所属的类型进行绑定,这样一来,由于明确了被调用的目标方法究竟是哪一个,因此也就可以使用静态链接的方式将符号引用转换为直接引用。 晚期绑定:相反如果被调用的方法在编译期无法被确定下来,只能够在程序运行期根据实际的类型绑定相关的方法,这种绑定方式也就被称之为晚期绑定。

  1. <br /> 在Java中,开发人员并不需要在程序中显式指定某一个方法需要在运行期支持晚期绑定,因为除了final方法外,几乎所有的方法都是默认基于晚期绑定的。

五、线程公有-方法区

参考

《深入理解Java虚拟机》 超详细文字配上详图带你解析Java虚拟机各大运行时数据区域 https://juejin.im/post/5e8c58556fb9a03c3e3f526d#heading-1 JVM 程序计数器

jvm:内存模型、内存分配及GC垃圾回收机制 Class字节码指令解释执行 浅谈JVM - 内存结构(二)- 虚拟机栈|凡酷 手写JVM系列 https://www.sudo.ren/article/101

ePUBw.COM+-+深入理解Java虚拟机:JVM高级特性与最佳实践.pdf