[笔记] 深入了解 JVM 底层原理

Java Virtual Machine ( JVM ) - How does JVM work?

对应视频地址:

(上集):https://www.bilibili.com/video/BV1BT4y1G73q

(下集):https://www.bilibili.com/video/BV13Z4y147mt

笔记MarkDown版下载地址:https://wwa.lanzous.com/i7TxGj2mhrc

1. 操作数栈(OS)和本地变量表(LVA)

  1. 本地变量表 (LVA)
    • 本地变量表是一个以0为起始下标的字数组,它包含了所有参数和局部变量, 每一个slot都是4个字节(Byte)大小
    • int、float和reference类型的值在数组中占据1个slot,即4个字节。
    • double和long的值在数组中占据2个连续的slot,即总共8个字节
    • Byte、short和char的值在存储前会被转换为int类型,占据1个slot,即4个字节。
    • 但是不同的 JVM 对 Boolean 的存储方式是不同的。但大多数JVM在局部变量数组中给Boolean提供了1个slot。
    • 参数会按照声明的顺序放入局部变量数组中。
  2. 操作数栈 (OS)
    • JVM使用操作数栈作为工作空间,是用来存储中间计算的结果的。
    • 操作数栈的组织形式是像本地变量表 (LVA) 一样的字数组,每一个slot占用4个字节(Byte)。 但它不像本地变量数组那样使用索引来访问,而是通过一些指令来访问 (OS是一个Stack模型,可以进行push或者pop) 这些指令可以将值推送到操作数栈(如:push),一些指令(如:pop)可以从操作数栈中弹出值 还有一些指令可以执行必要的操作。
    例:50 + 20 = ?JVM内运行环境的OSLVA,以及其对应的字节码的操作
Working of LVA and OS
Assembly Code Instruction for Operand Stack
  • iload_0 把一个int值从本地变量表中index为0的地方压入操作数栈
  • iload_1 把一个int值从本地变量表中index为1的地方压入操作数栈
  • 将栈顶两int型数值相减并将结果压入栈顶
  • istore_2 讲一个int值从操作数栈中弹出,并存储到本地变量表index为2的地方

(资料来源:Java Virtual Machine (JVM) Stack Area - GeeksforGeeks)


2. this指针是何时赋值的

JVM字节码:

image-20201201210744549

对应的Java代码

image-20201201211723791

这段代码的0~7是对一个Test4对象进行new

在执行invokespecial指令的时候进行的赋值。执行完毕invokespecial之后,才回去执行 <com/qimingnan/jvm/Test4.<init>>调用Test4的构造器

invokespecial:调用超类构建方法, 实例初始化方法, 私有方法。

那么为什么要在invokespecial的时候进行赋值呢?

因为我们在构造方法中,需要使用this指针。所以我们要在调用Test4的构造器之前进行this指针的赋值

image-20201201211500089

所有的非静态方法中的局部变量表中index0的位置,存放的都是this指针


2.1 new一个对象是否是线程安全的?

new是非线程安全的**,因为一句

Test4 demo = new Test4

就有4句JVM字节码来对应实现。

0 new #2 <com/qimingnan/jvm/Test4>
3 dup
4 invokespecial #3 <com/qimingnan/jvm/Test4.<init>>
7 astore_1

这里7为什么是astore_1,而不是astore_0

因为astore_0存储的是this指针,所以demo对象只能放在index1的位置

image-20201201212121943

在main()方法中,index0存放的是args参数

image-20201201212244361

3. 栈和栈帧的区别:

  • 栈:每一个线程,都会有一个独特自己的虚拟机栈(Stack),所以在栈里的数据是不会进行线程互斥,线程安全问题的。
  • 栈帧:每调用一个方法,就会产生一个栈帧。

4. return的时候都做了些什么?

image-20201201215559081

如:ireturn

ireturn:从当前方法返回int

不仅仅是这样,当JVM执行ireturn的时候,做了四件事:

  1. 将局部变量表指针恢复成上一帧的指针
  2. 将操作数栈的指针恢复成上一帧的指针
  3. 将运行时数据区内的程序计数器改成调用invokevirtual <com/qimingnan/jvm/Test4.add>的下一个字节码指令序号 (如图所示,即:改成15)

4. 将add()方法占用的栈帧内存全部回收

4.1 额外思考:

如图所示,

image-20201201211723791
image-20201201215559081

为什么add方法中的赋值操作,需要先将10push到操作数栈(OS),在istore到本地变量表(LVA)中呢?

而不是直接对本地变量表进行操作呢?

由于视频中老师没有进行具体的解答,所以我在SOF进行了提问,发现有人提出过类似的问题

我发的帖子下面有人进行的回复截图:

image-20201205135837110

我发现别人提问的帖子地址:What is the role of operand stack in JVM? (stackoverflow.com)

有兴趣的同学可以自行阅览。


5. 运行时数据区内几个区域之间的关系

image-20201201220414805

5.1 虚拟机栈 与 堆

如在一个方法中,有这么一句代码:

public void test{
    Test demo = new Test();
}

则,在test()方法的本地变量表中,index1的位置,会有一个名为demo的对象,其引用内容为堆中的对象内容


6. 堆(Heap)

6.1 堆的默认大小:

  • 最小:1/64的物理内存
  • 最大:1/4的物理内存

6.2 堆为什么要分新生代和老年代:

6.2.1 老年代存储的内容

  1. 大对象。什么是大对象?超过eden区,就是大对象
  2. GC年龄超过15。经历过15次GC
  3. 空间分配担保 一句话解释JVM中空间分配担保的问题先解释YGC(轻GC):当对象生成在EDEN区失败时,出发一次YGC,先扫描EDEN区中的存活对象,进入S0区,S0放不下的进入OLD区,再扫描S1区,若存活次数超过阀值则进入OLD区,其它进入S0区,然后S0和S1交换一次。那么当发生YGC时,JVM会首先检查老年代最大的可用连续空间是否大于新生代所有对象的总和,如果大于,那么这次YGC是安全的,如果不大于的话,JVM就需要判断HandlePromotionFailure是否允许空间分配担保。允许分配担保:JVM继续检查老年代最大的可用连续空间是否大于历次晋升到老年代的对象的平均大小,如果大于,则正常进行一次YGC,尽管有风险(因为判断的是平均大小,有可能这次的晋升对象比平均值大很多);如果小于,或者HandlePromotionFailure设置不允许空间分配担保,这时要进行一次FGC。新生代采用的是复制收集算法,S0和S1始终只是用其中一块内存区,当出现YGC后大部分对象仍然存活的话,就需要老年代进行分配担保,把survior区无法容纳的对象直接晋升到老年代。那么这种空间分配担保的前提是老年代还有容纳的空间,一共有多少对象会活下来,在实际完成内存回收之前是无法明确知道的,所以只好取之前每次回收晋升到老年代对象容量的平均值大小作为经验值,与老年代的剩余空间比较,决定是否进行FGC(重GC)来让老年代腾出更多空间。

6.3 新生代中各大分区占比比例的由来

对大量程序经过统计之后,发现Eden区的对象只有5%~10%的对象会存活,所以在新生代中,各大区域分布比例为8:1:1


7. 对象大小的计算方式

7.1 Class文件

Class文件开始的两个字(Word=2Byte)的大小的数据为cafe babe(咖啡Baby)

image-20201204171235117

7.2 对象(Object)结构

image-20201204171622471
  • Mrk Word:用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等等。 Mark Word在32位JVM中的长度是32bit,在64位JVM中长度是64bit。32位MarkWord数据结构【共4B】:
  • 类型指针:指向Class对象(即这个对象(Object)的模板)
  • 数组长度:若这个对象是数组,则此处存放数组长度=====以上为头部(Head)区域=====
  • 实例数据:类中定义的一些属性等
  • 对齐填充:在JDK里,所有数据都是8Byte对齐。若一个对象数据为12Byte,则其后面必须填充4Byte,即12+4=16Byte 为8Byte的整数倍则只需要设置指针每次移动9Byte,就可正确读取内存中的数据同理,Windows内核中也是以4K对齐,称之为页(Page),以4M为大页(Huge Page)为什么JVM要对齐填充?为了后面的指针压缩

7.3 指针压缩算法

开启指针压缩的JVM参数:-XX:+UseCompressedOops

64位地址分为堆的基地址+偏移量,当堆内存<32GB时候,在压缩过程中,把偏移量/8后保存到32位地址。在解压再把32位地址放大8倍,所以启用CompressedOops的条件是堆内存要在4GB*8=32GB以内。

开启后,线性地址为4Byte,若不开,则为8Byte

指针压缩在JDK6以后就是默认开启的!若要关闭,则使用-XX:-UseCompressedOopsJVM参数


7.4 空对象大小

空对象是什么?空对象是没有任何普通(静态)属性的对象,而并非指 指针为Null的对象。

class Test01{
    int a = 10;//int占4Byte,以及末尾的对齐,所以并非空对象
}

public class Test{
    public static void main(String[] args){
        Test01 demo01 = new Test01();
        Test01 demo02 = null;
    }
}

demo01和demo02并非空对象

public class Test02{
    
}

Test02为空对象

  • 空对象占多少字节?
    • 开启指针压缩时:16Byte16B = 8B【Mark Word】 + 4B【类型指针(Klass Pointer) 经过指针压缩优化后变成4B】 + 0B【数组长度】+ 0B【实际数据】 + 4B【对齐】调优参数(开启指针压缩参数):-XX:+UseCompressedOops
  • 关闭指针压缩时:16Byte16B = 8B【Mark Word】 + 8B【类型指针(Klass Pointer)】 + 0B【数组长度】+ 0B【实际数据】

7.5 普通对象大小

image-20201204204421731
  • 开启指针压缩时:32Byte32B= 8B【Mark Word】 + 4B【类型指针(Klass Pointer)】 + 0B【数组长度】+ (4B【int大小】+4B【int大小】+8B【double大小】)【实际数据】 + 4B【对齐】
  • 关闭指针压缩时:32Byte32B= 8B【Mark Word】 + 8B【类型指针(Klass Pointer)】 + 0B【数组长度】+ (4B【int大小】+4B【int大小】+8B【double大小】)【实际数据】

7.6 数组对象大小

image-20201204210556899
image-20201204204710706

关闭指针压缩的图所示,在没有开启指针压缩的数组对象的对象头中其实是存在着对齐填充的。对齐大小仍为8B的整数倍。

image-20201204210357211

8. 对象指针(OOP)

image-20201204215313434

8.1 压缩指针是怎么实现的

前提假设:对象内存从0x00000开始,且连续,中间无其他数据

JVM的实现方式是 因为对象大小都是8B的倍数,所以存储的内存地址都是以000结尾的,压缩时,可以将末尾的000去掉

如:0x10000 压缩后——> 0x10

解压的时候,直接在末尾添加三个0即可

8.2 如何扩容OOP?

咋不关闭指针压缩的情况下,如何扩容OOP?

可以可以在地址末尾补0,则OOP的位从35位变成36位,支持最大的类空间则变成2^36

为什么JVM不扩容?因为最后一位补0会浪费空间。

8.3 哪些信息会被压缩?

1.对象的全局静态变量(即类属性) 2.对象头信息:64位平台下,原生对象头大小为16字节,压缩后为12字节 3.对象的引用类型:64位平台下,引用类型本身大小为8字节,压缩后为4字节 4.对象数组类型:64位平台下,数组类型本身大小为24字节,压缩后16字节

8.4 哪些信息不会被压缩?

1.指向非Heap的对象指针 2.局部变量、传参、返回值、NULL指针


9. 虚拟机栈溢出

9.1 导致栈溢出的原因有哪些?

  1. 调用链过长
  2. 死循环
  3. 无限递归

JDK默认栈大小是多少?1M

演示栈溢出代码:(已手动设置JVM栈大小为160K【因为JVM最小就是160K,不能少于160K】)

image-20201205134933194
image-20201205134556102

若将虚拟机大小设置为160k -Xss160k,经过测试后,调用深度为772

则可计算出,栈帧大小为:160*1024 / 772 = 212

9.2 如何避免栈溢出?

在写递归/死循环的时候,一定要给递归/死循环一个出口。

标签:

发表回复

您的电子邮箱地址不会被公开。 必填项已用*标注

Captcha Code