【读书会第12期】对于jvm运行时数据区域,我做了一些更深层次的解读和理解
【读书会第十二期】
假期借着华为云读书会的活动,重读了一遍《深入理解java虚拟机》, 发现第一遍读“运行时数据区”相关内容的时候,只关注了最简单的概念部分,对于其中的细节部分没有深入探究,觉得那些东西太底层了,没啥用。
其实他们背后的原理,和我们平时运行进程时的各种报错息息相关。
另外如果能理解运行时数据区,也能够对“代码究竟是如何运行的”有更深的理解。
看来经典书籍要多读多总结,是有道理的。
于是在阅读这个章节时,针对每个结构,思考了非常多的问题,提出了很多QA,方便进行深度的思考和学习。
欢迎关注一下我的华为云社区账号或者社区读书会活动。
欢迎点击该链接报名参加读书会,一起成长学习和交流!
报名链接
jvm全局结构
首先是一张经典的jvm运行时内存区域划分的图,我自己画了一张:
Q: 存在多个线程时,刚才提到的5个区域是怎么分布的?
A:
每个线程,都有自己独立的虚拟机栈、独立的程序计数器PC。
而方法区和堆是线程们共用的。
java堆
java堆的内容比较多,这里不探究对象分配的原理,后面会补充新的文章。这里只讨论堆的一些其他细节问题。
Q: 堆是线程之间共用的,但这样会导致频繁发生冲突,是否要考虑并发问题?怎么办?
A:
线程分配堆空间时,会先根据TLAB进行独立分配。
TLAB ——Thread Local Allocation Buffer, 中文名为线程本地分配缓冲区。
启用了 TLAB 之后(-XX:+UseTLAB, 默认是开启的),JVM 会针对每一个线程在 Java 堆中预留一个内存区域。
一旦某个区域确定划分给某个线程,之后该线程需要分配内存的时候,会优先在这片区域中申请。这个区域针对分配内存这个动作而言是该线程私有的,因此在分配的时候不用进行加锁等保护性的操作
Q: 但是如果恰巧多个线程在试图竞争同一个TLAB预留空间时(即都在试图扩容),发生冲突怎么办?
A:
在预留这个动作发生的时候,需要进行加锁或者采用** CAS(compareAndSet) **等操作进行保护,避免多个线程预留同一个区域
Q: 分配的时候,在TLAB区域里,怎么知道放在哪个位置呢?
A:
具体的分配内存有两种情况(和垃圾回收机制有关)
- 第一种情况是内存空间绝对规整。(对应使用回收-整理/复制算法的垃圾回收区)
- 第二种情况是内存空间是不连续的。(对应使用回收-清除算法的垃圾回收区)
对于内存绝对规整的情况相对简单一些,虚拟机只需要在被占用的内存和可用空间之间移动指针即可,这种方式被称为指针碰撞。
对于内存不规整的情况稍微复杂一点,这时候虚拟机需要维护一个列表,来记录哪些内存是可用的。分配内存的时候需要找到一个可用的内存空间,然后在列表上记录下已被分配,这种方式成为空闲列表。
程序计数器
Q: PC计数器是整个jvm共有的吗?
A:
不是的,是每个线程各自有一个, 而且是java自己定义的线程PC, 和CPU里的PC寄存器不同。
Q: PC计数器有啥用? 那如果没有PC寄存器呢? 我不是也能一条条执行,遇到return指令,返回对应地址即可,需要PC寄存器做啥?
A: PC寄存器的作用在于多线程切换的时候,能找到每个线程执行的位置,所以它是线程私有的一个寄存器,知道当前运行到哪了。如果没有,一旦随机切换就不知道咋办了。你总需要一个地方存储这个线程当前执行情况,但又要保持独立性,所以不可能存到其他线程的空间里。
Q: 为什么native方法的程序计数器为0(undefine)?如果发生线程切换,怎么办?
A:
注意,jvm内存结构里的PC计数器是jvm自己定义的“字节码指令”执行寄存器。
对于native方法,并不在字节码的范围,不指向方法区里的任何指令位置。
因此native方法其实不是由jvm管理的,如果线程切换,他执行到哪边,取决于OS的底层机器码计数器实现。
以HotSpot VM的实现为例,它目前在大多数平台上都使用1:1模型,也就是每个Java线程都直接映射到一个OS线程上执行。此时,native方法就由原生平台直接执行,并不需要理会抽象的JVM层面上的“pc寄存器”概念——原生的CPU上真正的PC寄存器是怎样就是怎样。就像一个用C或C++写的多线程程序,它在线程切换的时候是怎样的,Java的native方法也就是怎样的。
Q: PC计数器里存的到底是啥?是指令地址吗?
A:
错误! 存的不是地址,而是这个方法的字节码偏移。例如0、1、5、6这种。
Q: 那么怎么知道实际的字节码位置?
A: 这个就要结合下文提到的栈帧中的动态链接,来联合计算实际字节码位置了。
虚拟机栈区域
Q: 什么是栈帧?
A: 每个线程有一个自己的栈帧,然后运行到每个方法时,每个方法中都会可以理解为是摄影里的一帧。
Q: 栈帧里包含什么?
A:
- 局部变量表
- 操作数栈
- 动态链接
- 方法返回地址
其实与上面这4样配合的,还有个上文提到的“程序计数器”,才共同实现了jvm指令的执行。
操作数栈
Q: 什么是操作数栈
A:
可以理解为jvm做计算时,需要一个临时的寄存器,把需要计算的数据或者传方法的参数放到栈中,然后做计算。
Q: 为什么一定要有操作数栈?如果要做a+b,我直接从变量表上取a的值和b的值,加起来不就好了?
A:
那我如果是 a + bc呢, 这个bc的值放哪里?
如果是a+b*(c+d)呢?
这时候如果你学习过数据结构里栈的应用 ,就会知道 模拟一个计算器,往往需要一个栈。
而操作数栈就是这个作用。
当你学习jvm指令时,就会看到有专门的指令就是取栈顶或者把值推送到栈顶的指令。
这样做加法的时候,也就不用关心变量的地址了,只要你把栈顶的值存好,我直接拿去加就行。
栈帧
Q: 栈帧的大小什么时候确定?
A:
在编译程序代码的时候.
注意, 图例提到的栈大小,并不是指线程堆栈的最大深度,
而是指“操作数栈”的最大深度。(注意这个深度存在类文件字节码中对应方法的属性表中)
即jvm能够通过分析代码中可能存在多少个变量以及计算空间,来确定局部变量表和最大操作数栈的一个深度。
局部变量表
Q: 什么是局部变量表?
A:
每个线程所在栈帧都会有一个自己的局部变量表,里面存储方法中使用到的局部变量。
Q: 局部变量的槽又是什么?
A:
- returnAddress类型是为字节码指令jsr、jsr_w和ret服务的,它指向了一条字节码指令的地址。
- 局部变量表的容量以变量槽(Slot)为最小单位,32位虚拟机中一个Slot可以存放一个32位以内的数据类型(boolean、byte、char、short、int、float、reference和returnAddress八种)
- slot的长度可以随着处理器、操作系统的不同而变化, 不是绝对的32位。
jvm概念中说的是”slot一定能存放下1个boolean\byte\int\引用地址\返回地址returnAddress“等不包括long在内的内容。 - 如果要访问long,需要做2次局部变量slot的读取,读取n和n+1,不允许单独访问,如果有问题会在字节码校验中报错。
Q: 局部变量表里的returnAddress和栈帧里的返回地址returnAddress有啥区别?
A:
局部变量表里的的returnAddress,是老版本jvm用于处理异常跳转的(jsr\jsr_w\ret指令,新版本基本都用code里的异常表来代替),而栈帧里的返回地址,是返回到上一层栈帧的代码调用位置,更新PC计数器用的。
Q: 局部变量表的slot可以被覆盖吗?这个设计有什么好处
A:
- 可以减少局部变量表的空间,通过分析每个局部变量的使用生命周期,在某变量不再被使用后,让其他变量可以覆盖这个槽的位置。
- 另一方面,覆盖的机制,可以将一些局部变量上已经不使用的大对象解除引用,例如对一些大的变量做=null的操作,那么可以尽早进行垃圾回收(因为栈帧的局部变量表里的每个slot都是一个gcRoot)
Q: 设置null值,就一定会覆盖slot吗?
A:
不一定,有时候JIT编译优化,可能会处理掉这个无用的=null的操作,且能正确处理slot中已经不被使用的变量。
按照书里的说法,正好有大对象,然后还停留在局部变量表里的概率是比较低的。不建议那么做了
Q:为什么java中局部变量没有默认初始?
A:
我的理解,局部变量在局部变量表中,而局部变量表是运行时生成的, 而非在堆上生成,因此不会有堆对象创建时的那个默认值赋值操作。 即jvm定义上, 就是局部变量没有初始化前的’准备‘这个阶段的,也就不存在默认赋值的指令行为。
如果硬要说为什么,如果每个局部变量都复制,肯定会影响执行效率,因此不如不赋值。,所以必须通过赋值指令在运行时给他赋值。(没找到很好的解释,有更好理解的可以帮忙回答一下,其实就是)
另外如果每个局部变量都有,那可能指令数量就会变多,因为你需要放入很多赋值指令?
阅读JMM内存模型时的另一个解释:
对于未同步或未正确同步的多线程程序,JMM只提供最小安全性.
线程执行时读取到的值,要么是之前某个线程写入的值,要么是默认值(0,Null,False),JMM保证线程读操作读取到的值不会无中生有(Out Of Thin Air)的冒出来
对于全局变量(类对象成员),必须有默认初始化,为了满足多线程环境下的最小安全性。
但对于局部变量,不存在被多线程使用,因此一定后面可以拼接一个指令,所以不需要默认初始化的动作。
Q: 执行多次方法,一个栈上有多个栈帧,每个栈帧都有各自的局部变量表和操作数栈,上下的栈帧之间可能存在共享的情况吗?
A:
可能存在。即上下两个栈帧之间, 可能有操作数栈可以直接操作另一个栈帧局部变量的情况。这样可以避免额外的参数复制传递。
什么时候触发?不清楚
动态链接
Q : 栈帧里的动态链接又是啥?
A:
首先明确一点, 每一个栈帧,不一定是”动态”链接,但一定会有一个指向常量池中方法的引用。
为什么栈帧里需要存这个指向方法的引用?
首先,当你进入一个方法,准备生成一个栈帧,放到线程上时,你需要知道你这个代码执行的是什么代码,才能进行后面的操作。
如果是构造方法、final方法,则会编译器进行静态链接。
如果是虚方法,则会进行动态链接,运行期只是从类对象中,拿到了一个符号引用,
但是这个引用指向哪个方法?则通过下面的过程进行定位和寻找,把符号引用转成实际方法的直接引用。
因此要提供一个引用,指向常量池里的方法。指向后,就能知道程序位置。
然后字节码实际引用位置 + PC计数器偏移,就能知道当前线程执行到哪个方法的哪一步指令上了。
(关于动态链接这个名称的由来,是因为“动态分派”的存在,你这个方法位置是不确定的,和实际对象+方法名有关, 所以称为动态链接。)
返回地址
Q: 既然有PC寄存器,栈帧里的返回地址的作用是什么?
A:
方法A调用方法B的时候,PC寄存器会跟着移动到B方法去。当B执行完后,要能返回A继续执行,就需要A当时执行到的那条指令的地址。所以,在B的栈帧中保存A当时的指令地址(当时PC寄存器的值),当B执行完后,根据此返回地址跳回A。通过返回地址,从而知道当前线程的上一级应该从PC的第几行偏移开始。
另外除了正常通过ret指令退出,还可能是出现异常时,如果没有在异常表里被捕捉并处理,也会通过异常完成出口, 使用返回地址返回到上一层。
Q: 栈帧中的方法退出时,会触发哪些动作?
A:
- 当前栈帧出栈
- 恢复上层方法的局部变量表和操作数栈
- 如果有返回值,把返回值压入操作数栈的栈顶(因为马上就要被调用了)
- 调整这个线程栈的PC计数器,改成returnAddress对应的那个指令位置地址,然后继续往下调用执行。
Q: 栈帧除了上面提到的几个,还有其他的信息吗?
A:
有些支持调试的虚拟机,可能会补充很多调试相关的信息。
方法区
Q: 方法区里存的是class字节码吗?
A:
不是。经过类的加载、链接、初始化之后, class字节码对于进程来说就没用了。
存了以下内容:
- 每个类的类型信息:类名、父类类名、修饰符、接口
- 字段信息field(域信息):字段名、字段类型、字段修饰符
- 方法信息,包括方法名、类型、参数、修饰符、字节码、一场表
如下: - 类的静态变量
- 常量池,存储常量
注意,符号引用、类引用、实际类名等信息等都是放在常量池中的。
Q: 元空间与永久代到底是怎么回事?
A:
方法区和“PermGen space”又有着本质的区别。
前者是 JVM 的规范,而后者则是 JVM 规范的一种实现
并且只有 HotSpot 才有 “PermGen space”,而对于其他类型的虚拟机,如 JRockit(Oracle)、J9(IBM) 并没有“PermGen space”。
元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。因此,默认情况下,元空间的大小仅受本地内存限制,但可以通过以下参数来指定元空间的大小
-XX:MetaspaceSize和-XX:MaxMetaspaceSize
Q: 为什么要替换永久代
A:
替换永久代的其他原因:
- 字符串存在永久代中,容易出现性能问题和内存溢出。
- 类及方法的信息等比较难确定其大小,因此对于永久代的大小指定比较困难,太小容易出现永久代溢出,太大则容易导致老年代溢出。
- 永久代会为 GC 带来不必要的复杂度,并且回收效率偏低。
最后的感想
好累,终于写完了,感觉能看到最后的人不会太多,但一通详细地分析和解决中间发现的问题,还是收获了不少。
关于jvm运行时数据区,最重要的不是去死记硬背,而是试图在脑中构建一个指令运行的逻辑流程。
且对于很多没有学习过计算机底层原理(例如CSAPP这本书) 的人来说, 是很难接触到计算机是如何执行机器码指令的。
而java虚拟机栈可以更好理解 指令是如何运行的(虽然这不是机器码,而是jvm字节码)。
但是通过运行时数据区的各种行为和概念, 我们可以快速对应到java中常见的各种操作。
这对于很多入门时直奔删减改查的同学来说, 是不可多得的学习底层的机会。
图解笔记系列也会持续更新下去,争取做全网最细又最大的java分享文章。如果感觉不错,欢迎扫描文末的二维码,参加社区的活动并抽奖!
- 点赞
- 收藏
- 关注作者
评论(0)