JVM 运行时数据区

知识框架:

  1. 线程私有

    1. 程序计数器 ~ 只存一个值 ~ 线程恢复、分支、异常、循环、跳转
    2. 虚拟机栈 ~ 局部变量表、操作数栈、动态连接、方法返回地址
    3. 本地方法栈 ~
  2. 线程共有

    1. 方法区 ~ 运行时常量池
    2. 堆 ~ TLAB

最经典的应该是这张运行时数据区图:

⚑ 方法区、堆、栈的关联


假设有一个类 Person,创建了一个对象 person,则:

Person 类的 class 文件放置在 方法区
person 变量引用放置在 栈的局部变量表
person 对象的实际数据放置在 中,该对象会有一个指针指向方法区中 Person 的 class 文件确认关系

1. 程序计数器(Program Counter Register)

:指示当前线程正在执行的虚拟字节码指令的地址(如果是 native 方法,则为空(undefined))

  1. 是一块很小的内存空间(几乎可以忽略不计),且是 线程私有的
  2. 分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成
  3. 是唯一一个没有规定 OOM 情况的区域,同时它也没有 GC

1.2. Java 虚拟机栈(Java Virtual Machine Stack)

:描述了 Java 方法执行的线程的内存模型

线程私有的,生命周期和线程相同

每个方法被执行的时候,Java 虚拟机都会同步创建一个 栈帧(Stack Frame)
栈帧中包含了:局部变量表、操作数栈、动态连接、方法返回地址和一些额外的附加信息

关键作用:方法调用


每次进行方法调用之前,JVM 都会为被调用的方法分配一个新的栈帧

  1. 分配完成后,如果被调用的方法有参数列表

    需要调用方将需要的参数依次压入到从它自己的 操作数栈 中(可以从局部变量表来,也可以是临时计算的直接就在操作数栈中)
    如果方法是实例方法或构造方法,则先将被调用方法的对象的引用放入局部变量表的第 0 个槽
    然后依次弹出需要的参数,放入被调用方法的局部变量表中,完成参数的传递

  2. 执行完毕后,如果被调用的方法有返回值

    则被调用方需要将该值放到调用方的操作数栈中,调用方可以将其存放到局部变量表,完成返回值的传递。

问题 1:如何确定被调用方法的地址?


JVM 有两种方式可以确定,编译期的静态绑定和运行期的动态绑定
两种方式各自对应了 2 个字节码指令:

  1. 静态绑定:invokestatic、invokespecial

    invokestatic 是调用静态方法
    invokespecial 是调用构造器方法、私有方法和父类方法

    这四种方法都是解析阶段就能确定唯一调用版本的,类加载阶段会直接解析成直接引用(指针 / 偏移量)

  2. 动态绑定:

    invokevirtual 调用除静态绑定涉及的方法之外的所有方法
    invokevirtual 调用接口方法,运行期才会确定实现这个接口的对象

    对于这些方法 JVM 会使用动态分派的方式来查找方法的地址

    ① 在方法调用指令之前,需要将对象的引用压入操作数栈
    ② 在执行方法调用时,找到操作数栈顶的第一个元素所指向的对象实际类型,记为 C
    ③ 在类型 C 中找到与常量池中的描述符和方法名称都相符的方法,并校验访问权限
    ④ 如果找到该方法并通过校验,则返回这个方法的引用
    ⑤ 否则,按照继承关系往上查找方法并校验访问权限
    ⑥ 如果始终没找到方法,则抛出 java.lang.AbstractMethodError 异常

    为了提高动态分派时方法的查找效率,JVM 为每个类都维护了一个虚函数表(方法区中)

    一个类的方法表包含类的所有方法入口地址,从父类继承的方法放在前面,接下来是接口方法和自定义的方法。如果某个方法在子类中没有被重写,那子类的虚方法表里面的地址入口和父类相同的方法的入口地址一致。如果子类重写了这个方法,子类方法表中的地址将会替换为指向子类实现版本的入口地址。

    使用 invokevirtual 调用的虚函数,JVM 在编译期就可以确定虚函数表的 offset,在首次调用就会将这个 offset 缓存起来,往后的调用就能够直接找到虚函数表获取方法地址了

    而使用 invokevirtual 则不行,因为每个类的虚函数表的 offset 可能不一样,所以每次 JVM 都会去搜寻一遍虚函数表,效率会比 invokevirtual 低一些

可能抛出的异常:


Java 虚拟机栈可能抛出的异常有:StackOverflowError、OutOfMemoryError

  1. 如果不允许动态增加栈的大小,则当栈空间用完之后会抛出 SOF
  2. 如果 Java 虚拟机栈的容量允许动态扩展,但是在扩展时无法申请到足够的内存,就会抛出 OOM
    而 HotSpot 虚拟机虽然是不可以动态扩展的,但如果线程申请栈空间失败了还是会有 OOM

1.2.1. 局部变量表(Local Variables Table)

:存放了编译期可知的各种 Java 虚拟机 基本数据类型对象引用returnAddress 类型

  1. 也称为 本地变量表局部变量数组(实际上这个表是一维的)

  2. 它被定义为一个 一维数字数组,因为上边的三种值都可以使用数值来表示

  3. 这些数据在局部变量表中的存储空间以 **局部变量槽(Slot)**来表示

    变量槽的实际大小(32 bit 或 64 bit 或更多),完全由具体的虚拟机实现自行决定

  4. 编译期就可以确定该表的大小

    所以当进入一个方法时,这个方法需要在栈帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小(大小指的是变量槽的数量)

  5. 可存放的数据

    1. **基本数据类型:**int、byte、short、long、float、double、boolean、char

      其中 64 位的 long 和 double 会占用两个变量槽,其余的数据类型只会占用一个

    2. **对象引用:**reference

      可能是一个指向对象起始地址的引用指针、也可能指向一个代表对象的句柄或相关位置。分别对应了两种对象访问定位方式:⚑ 直接指针访问⚑ 句柄访问

    3. **returnAddress 类型:**指向了一条字节码指令的地址

      目前很少见了,是为字节码指令 jsr、jsr_w 和 ret 服务的,现在已经全部改为采用异常表来代替了

作用 1:完成 参数值 => 参数变量列表 的传递过程(即实参到形参)


  1. 如果执行的是实例方法(没有被 static 修饰的方法)或者构造方法中

    那局部变量表中第 0 位索引的变量槽默认是用于传递方法所属对象实例的引用(this)

  2. 其余参数按照参数表顺序排列,依次占用局部变量槽,完成值传递

问题 1:64 位数据非原子操作问题(并不会有线程安全问题)


对于 64 位的数据类型(long 和 double),Java 虚拟机会以高位对齐的方式为其分配两个连续的变量槽空间。同时允许把一次 long 和 double 数据类型读写分割为两次 32 位读写(非原子性)

但由于局部变量表是建立在线程堆栈中的,属于线程私有的数据,无论是否为原子操作都不会有线程安全问题

问题 2:局部变量槽可复用带来的问题


局部变量槽是 可复用 的,尽可能节省了栈帧耗用的内存空间

由于方法体中定义的变量,其作用域并不一定会覆盖整个方法体
所以当 PC 计数器的值已经超出了某个变量的作用域,那这个变量对应的变量槽就可以交给其他变量来重用

  • 但在某些时候这个机制会带来一些副作用:

    假设有一个很大的数组,占据了一个变量槽(指向了堆中的一大片区域)

    在 PC 离开该变量的作用域后,变量槽中的引用依旧指向堆中的那一大片内存,导致 GC 无法将这些内存进行回收,直到下一次操作变量表时将重新对该变量槽赋值了,那片内存才会因为没有指向而被回收

    所以有的时候,将变量手动赋值为 null 可以避免该副作用的出现(手动置空后内存没有被引用就能被回收了)
    但并不是说对于所有的引用类型每次都要手动置为 null

1.2.2. 操作数栈(Operand Stack)

:用来执行方法的字节码指令,包括算术运算、

  1. 每一个元素都可以是包括 long 和 double 在内的 任意数据类型

    一个方法刚开始时,它的操作数栈是空的,在进行算术运算或者调用其他方法获取返回值时,会使操作数栈增加或减少元素

  2. 和局部变量表一样,操作数栈的最大深度也在编译的时候被写入到 Code 属性的 max_stacks 数据项之中

  3. Java 虚拟机的解释执行引擎被称为“基于栈的执行引擎”,里面的“栈”就是操作数栈

    方法的参数和局部变量存储在局部变量表中,而字节码指令则在操作数栈上执行

作用 1:进行算数运算


通过将运算涉及的操作数压入栈顶,而后就可以调用运算指令来进行运算了

例如:在执行整数加法的字节码指令 iadd 时,会将栈顶的两个元素弹出(保证了此前已经有两个 int 值入栈了)并相加,然后将相加的结果重新入栈

作用 2:方法调用传参与接收返回值


在调用方法时,方法需要的参数会从局部变量表或操作数栈中弹出(这由它会暂存中间结果的功能支持),按照参数列表指定的顺序再依次压入操作数栈中。调用时再将参数弹出到对方的局部变量表中

调用的方法有返回值时,被调用的方法会将返回值压入当前方法栈帧的操作数栈中,调用方可以保存到局部变量表(有使用变量存储的话)

优化 1:共享区域


虽然在概念模型中,两个不同栈帧作为不同方法的虚拟机栈的元素,是完全相互独立的
但为了节约空间大多数虚拟机会让 两个栈帧出现一部分重叠(操作数栈和局部变量表)
这样在方法调用时可以直接共用一部分数据,无需进行额外的参数赋值传递了

优化 2:栈顶缓存技术(Tos,Top_of_Stack Cashing)


由于 HotSpot 是基于栈架构的虚拟机,所以每完成一项操作需要更多的入栈出栈操作,也就意味着内存读写的次数会很多,这样会影响到执行速度

为了解决这个问题就出现了栈顶缓存,指:将栈顶的 n 个元素缓存到物理 CPU 的寄存器中,以此来降低内存读写次数,提高执行引擎的效率(栈顶缓存了 n 元素,就成为 n-Tos)

但栈顶缓存只是优化措施而不是本质解决方法,所以在执行速度上,基于栈结构的指令集会慢一点

1.2.3. 动态连接(Dynamic Linking)

每个栈帧都包含一个指向 ((20220721191914-2mrk4q2 “运行时常量池”)) 中该栈帧所属方法的引用

在每一次运行期间会将指向的这些引用转化为直接引用,就是动态连接
~与之相对的是 静态解析,在类加载阶段或者第一次使用的时候就被转化成直接引用了~

1.2.4. 方法返回地址

一个方法只有两种方式能退出本方法

  1. 执行引擎遇到方法返回指令(正常调用完成)

    在有返回值时,会将返回值 通过操作数栈 传递给调用者
    这时调用者的 PC 计数器的值 可以作为返回地址,栈帧中会保留这个值

  2. 遇到了异常,没有搜索到匹配的异常处理器(异常调用完成)

    以这种方式退出方法,不会给调用者任何返回值
    这时返回地址要通过 异常处理器表 来确定,这时栈帧一般不会保存这个值

    异常处理器表中存放了:起始地址、终止地址、开始地址
    表示若在起始和终止地址之间出现异常,那么就跳到开始地址出继续执行(不是靠 PC 了)

1.3. 本地方法栈(Native Method Stacks)

与虚拟机栈所发挥的作用非常相似,区别如下:

Java 虚拟机栈为虚拟机执行 Java 方法(也就是字节码)服务
本地方法栈为虚拟机使用到的本地方法(Native)服务

可以通过 -Xss 参数来设置栈的大小

1.4. Java 堆(Java Heap)

:是 被所有线程共享 的一块内存区域(属于 JVM 进程,只有一份)

同时也是虚拟机所管理的内存中最大的一块,唯一目的就是存放对象实例
Java 中几乎所有对象实例都在这里分配

问题 1:对象都是分配在堆上的吗


只是几乎都在堆上分配,如果对象经过了逃逸分析,发现并没有逃逸除方法,那就可能被优化成栈上分配、标量替换,无需在堆上分配内存


逃逸分析:是指当一个对象被定义后,若其 实体 只在本方法内部使用,则认为没有发生逃逸,反之则是逃逸了

如:对象作为返回值返回了,变成成员属性了,或则赋值为成员属性了,就认为是逃逸了


栈上分配:如果对象经过逃逸分析后,没有发生逃逸,那么就会进行栈上分配。当线程结束或者栈空间被回收,该局部变量对象也就被回收了,无需 GC

注意:但在后期的 HtoSpot 中,并没有使用栈上分配的方法,因为会增加维护和优化的难度,所以 HotSpot 采用了 ”标量替换“ 作为折中


标量替换:标量(Scalar)是指无法在被继续分解的数据(如 Java 的原始数据类型),相反会有聚合量(Aggregate)指还可以继续分解的(如 Java 的对象)

如果经过逃逸分析发现对象没有逃逸,那么经过 JIT 优化,就会将这个对象拆解成若干个其中包含的成员变量来代替

1.4.1. 堆内存

:一共划分了两个代:新生代(Eden + 2*Survivor)、老年代
JDK8 之前还有永久代,但在之后改为元空间,不在堆中而是在方法区了

堆内存的大小可以被实现为 固定大小的 或者 可扩展的,且不需要连续的内存(但 逻辑上需要是连续的)。一般都是都是可扩展的,可以通过 -Xms(初始大小)和 -Xmx(最大大小) 来设置堆的大小

没有使用参数设置时,假设物理电脑内存大小为 M,则默认的 Xms 为 M/64,默认的 Xmx 为 M/4

堆中大部分空间是共享的,但又一小部分空间是 TLAB,是线程私有的,为了提高并发效率
在方法结束后,堆中的内存不会马上被回收,而是等待 GC 来回收

常用的 JVM 参数:


  1. 设置占用内存空间大小

    1. -Xms(等价于 -XX:InitialHeapSize):用于设置堆的起始内存大小(默认为 物理电脑内存 / 64)
    2. -Xmx(等价于 -XX:MaxHeapSize):用于设置堆的最大内存大小(默认为 物理电脑内存 / 4),一旦超过这个值,就会报 OOM 异常。通常会将 -Xms 和 -Xmx 设置成一样的,减少由于动态扩容和缩容带来的性能损耗
    3. -Xmn:用于设置新生代的大小(直接指定固定的值)
  2. 设置各区域占用内存比例

    1. -XX:NewRatio:用于设置新生代和老年代在堆中的内存占比(如值为 2,那么新生代占 1,老年代占 2)
    2. -XX:SurvivorRatio:用于设置 Eden 区和 Survivor 区(其中一个)的内存占比(如值为 8,则 Eden : S0 : S1 = 8 : 1 : 1)
  3. 设置 TLAB

    1. -XX:UseTLAB:表示是否开启 UseTLAB 空间
    2. -XX:TLABWasteTargetPercent:设置 TLAB 的大小(单位为占据 Eden 区的百分比)

1.4.2. TLAB(Thread Local Allocation Buffer)

:全称为 本地线程分配缓冲,解决了创建对象的并发问题

由于堆区是进程级别的,是共享区域,任何线程都可以访问堆中的共享数据.这就导致多个线程操作同一地址时,需要使用加锁等机制,影响分配进度

由于对堆空间的使用是在 Eden 区,所以主要就是堆 Eden 区进行优化。
TLAB 就是将 Eden 区划分为一块一块的 TLAB(大概占 Eden 的 1%),每个线程持有一份私有的缓存区域。当多个线程同时分配内存时,由于时私有的所以不会有安全问题

TLAB 的大小可以通过选项 -XX:TLABWasteTargetPercent 来设置,值为百分比大小
一旦对象在 TLAB 空间分配内存失败时(如自己的 TLAB 用完了),JVM就会尝试着通过 使用加锁机制确保数据操作的原子性 ,从而直接在 Eden 空间 中分配内存

TLAB 主要解决的是创建对象的并发问题,私有空间只是针对 创建 而言,当其他线程要访问另一个线程的 TLAB 也是可以的(堆内存是共享的),只不过不能在别的线程的 TLAB 中创建对象

1.5. 方法区(Method Area)

方法区在逻辑上是属于堆的一部分,在 HotSpot 中方法区的别名为 non-heap(非堆)用来和堆区分开来

它是各个线程共享的内存区域,它用于存储已被虚拟机加载的类型信息(域信息、方法信息等)、常量、即时编译器编译后的代码缓存等数据

方法区除了和 Java 堆一样不需要连续的内存和可以选择固定大小或者可扩展外,甚至还可以选择不实现垃圾收集,该区的垃圾回收目标主要是针对常量池的回收和对类型的卸载、但回收效果比较差(因为对类型的回收条件很苛刻)

方法区的演进:


方法区是一个规范,落实到不同的虚拟机可以有不同的实现,以 HotSpot 为例:

只有 HotSpot 才有永久代(其他虚拟机没有,如 JRockit、IBM J9)
在 JDK8 之前,方法区的实现是 永久代,JDK8 及以后,方法区的实现是 元空间(Metaspace)

在 JDK7 时,字符串常量池和静态变量移出,保存在堆中了
在 JDK8 之后没有永久代了,类型信息、字段、方法、常量保存在本地内存的元空间,但字符串常量池和静态变量依旧保存在堆

**▲ 注意:**对象本身都是存放在堆中的,无论是 JDK 哪个版本,只不过在 JDK8 之后,静态变量的引用存放在堆中,而 JDK8 以前静态变量的引用是在永久区中


为什么 HotSpot(和其他虚拟机)不设置永久代?

  1. 永久代需要设置的空间大小很难确定
  2. 永久代进行调优的难度很高

为什么要将字符串常量池(StringTable)移出到堆中?

因为方法区中进行垃圾回收的频率很低,导致堆字符串常量池的回收效率不高
而开发中又经常会有大量的字符串被创建,所以放到堆中能保证内存的及时回收

1.5.1. 运行时常量池

:是 方法区的一部分,在将类和接口加载到虚拟机之后,就会创建对应的运行时常量池

JVM 为 每一个已加载的类型(类或接口)都维护了一个常量池,池中的数据项是通过索引访问的,此时如果常量池需要的内存空间超过了方法区的可用空间,就会抛出 OOM

运行时常量池中的常量来源于:编译器已经明确的数值字面量(常量池表)、运行期解析后动态产生的常量

当类加载到内存后,JVM 会将 class 文件 常量池 中的内容放到运行时常量池中。经过解析(resolve)之后,将符号引用替换为直接引用,解析的过程需要查询全局字符串池(也就是字符串常量表 StringTable),来保证运行时常量池锁引用的字符串域全局字符串引用的一致

运行时常量池和常量池的区别


出现的位置不同 使用的阶段不同
运行时常量池 方法区 虚拟机栈的动态连接阶段
常量池 字节码文件 类加载-连接的解析阶段

常量池在类加载之后也会成为运行时常量池的一部分哦,它是 字节码文件 的一部分,用于存放编译期生成的各种 字面量符号引用


为什么需要 常量池

Java 字节码文件需要很多数据支持,把这些数据都放到每一个字节码文件中会导致大量冗余,字节码文件过大。所以就可以存放到常量池,供各个字节码文件引用,达到复用的效果,在动态链接阶段时再将这些引用拿到加载为正确的类

同时也避免了频繁的创建和销毁对象对系统性能造成的影响,其实现了对象的共享

1.6. 直接内存

并不是虚拟机运行时数据区的一部分,也不是《Java虚拟机规范》中定义的内存区域
但是这部分内存也被频繁地使用,而且也可能导致 OOM(但不会受限于 -Xmx 指定的大小,因为在堆外)

在 JDK1.4 中新加入了 NIO,可以使用 Native 函数直接分配堆外内存,通过一个存储在 Java 堆中的 DirectByteBuffer 对象作为这块内存的引用操作,访问内存的速度提高,在读写频繁的地方性能会显著提高(因为避免了在 Java 堆和 Native 堆中来回复制数据)

缺点:分配回收成本较高、不受 JVM 内存回收管理(同时监控工具也监控不到,如果发现 OOM 后 dump 文件较小,那就有可能是直接内存直接把系统内存撑爆了)

对比图:

未使用直接内存 VS 使用直接内存