JVM_01_JVM核心知识点图解

前言

最近准备好好总结下JVM相关的一系列知识点,本文先总结主要的核心知识点:

  • 运行时数据区
  • 对象探秘
  • 类加载
  • 垃圾回收

本文完整的图,请参见 processon:

JVM_01_JVM图解

推荐阅读

在这里记录下关于JVM的知识的来源:

一、JVM的运行时数据区

概览

JVM运行时数据区主要包括以下几个部分:程序计数器、虚拟机栈、本地方法栈、方法区、堆

JVM_01_JVM核心知识点图解

1.程序计数器

程序计数器可以看作是当前线程所执行的字节码的 行号指示器

  • 字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。

  • 如果线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址

2.虚拟机栈

JVM_01_JVM核心知识点图解

2.1 虚拟机栈

虚拟机栈描述的是 Java方法执行的内存模型

  • 每个方法在执行的同时都会创建一个栈帧,用于存储局部变量表、操作数栈、动态链接、方法出口等信息。
  • 每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。

栈帧(Stack Frame)是用于支持虚拟机进行方法调用和方法执行的数据结构,是虚拟机栈的栈元素。

  • 栈帧存储了方法的局部变量表、操作数栈、动态连接和方法返回地址等信息。
  • 每一个方法从调用开始至执行完成的过程,都对应着一个栈帧在虚拟机里从入栈到出栈的过程。

2.2 局部变量表

局部变量表是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量

(1)编译时就确定最大容量

在Java程序编译为Class文件时,就在Class文件的方法表的Code属性的 max_locals 数据项中确定了该方法所需要分配的局部变量表的最大容量。

(2)局部变量表的容量以变量槽(Slot)为最小单位

  • 一个Slot可以存放一个32位以内的数据类型,Java中占用32位以内的数据类型有8种: boolean 、byte、char、short、int、float、reference 和 returnAddress; 对于 64位的数据类型(long/double),虚拟机会以高位对齐的方式分配两个连续的Slot空间

(3)索引定位

  • 虚拟机通过索引定位的方式使用局部变量表,索引值范围是从0至最大Slot数量
  • 对32位数据类型的变量来说,索引n就代表了使用第n个Slot;而对64位数据类型的变量来说,则会同时使用n和n+1两个Slot;

(4)索引分配

  • 在方法执行时,虚拟机是使用局部变量表来完成参数值到参数变量列表的传递过程的
  • 如果执行的是实例方法,则局部变量表中第0位索引的Slot默认是用于传递方法所属对象实例的引用,在方法中可以通过关键字“this”来访问到这个隐含的参数;其余参数按照参数表顺序排列,占用从1开始的局部变量Slot;参数表分配完毕后,再根据方法体内部定义的变量顺序和作用域分配其余的Slot

(5)Slot重用

局部变量表中的Slot是可以重用的,如果当前字节码PC计数器的值,已经超过了某个变量的作用域,那这个变量对应的Slot就可以交给其他变量使用。

2.3 操作数栈

在方法的执行过程中,会有各种字节码指令往操作数栈中写入和提取内容,这就是入栈和出栈。

操作数栈是为字节码指令服务的:

例如,虚拟机执行字节码指令 iload ,会将一个int类型的局部变量从局部变量表加载到操作数栈,iadd会将操作栈顶的两个元素相加 ,并将相加后的结果入栈

2.4 动态连接

动态连接就是指向常量池中该栈帧所属方法的引用

每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接。

  • Class文件的常量池中存有大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用作为参数。

  • 这些符号引用一部分会在类加载阶段或者第一次使用的时候转化为直接引用,这种转化称为静态解析

  • 另一部分将在每一次运行期间转为直接引用,这部分称为动态连接

2.5 方法返回地址

一个方法的退出方式有两种:

  • 正常完成出口:执行引擎遇到任意一个方法返回的字节码指令
  • 异常完成出口:在方法执行过程中遇到了异常,并且这个异常没有在方法体内得到处理,也就是在本方法的异常表中没有搜索到匹配的异常处理器,这时就会导致方法异常退出。

方法返回地址:

  • 一般来说,方法正常退出时,调用者的PC计数器的值可以作为返回地址,栈帧中很可能会保存这个计数器值。
  • 而方法异常退出时,返回地址是要通过异常处理器表来确定的,栈帧中一般不会保存这部分信息。

方法退出的过程实际上就等同于把当前栈帧出栈:

因此退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值压入调用者栈帧的操作数栈中,调整PC计数器的值以指向方法调用指令后面的一条指令等

3.堆

JVM_01_JVM核心知识点图解

Java堆在虚拟机启动时创建,唯一目的就是存放对象实例 以及 数组

(1)从内存回收的角度来看,Java堆是垃圾收集器管理的主要区域:

  • 由于目前的垃圾收集器都采用分代收集算法,因此Java堆中还可细分为:新生代和老年代,默认占比为Young:Old = 1:2
  • 同时新生代中采用复制算法,将新生代分为三个区域,默认占比为:Eden:from:to=8:1:1

(2)从内存分配的角度来看,线程共享的Java堆中可能划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer)

4.方法区

JVM_01_JVM核心知识点图解

方法区用于存储已被虚拟机加载的类信息(即Class对象)、常量(位于常量池)、静态变量、即时编译器编译后的代码等数据。

对于HotSpot虚拟机来说,也称为“永久代”(Permanent Generation)

方法区的变化:

  • jdk1.6及以前:有永久代,常量池在方法区中
  • jdk1.7:有永久代,但已逐步“去永久代”,常量池转移到堆中
  • jdk1.8及之后:无永久代,常量池在元空间中

二、HotSpot虚拟机对象探秘

1.对象的内存结构

JVM_01_JVM核心知识点图解

在HotSpot虚拟机中,对象的内存布局可分为:对象头、实例数据、对齐填充

对象头分为:

  • MarkWord:存储对象自身的运行时数据,如哈希码、GC分代年龄、锁状态标志、偏向线程ID等
  • 类型指针:即对象指向它的类元数据的指针,并不是所有的虚拟机实现都必须在对象数据上保留类型指针。
  • 数组长度:如果对象是数组,则对象头中还必须有一块用于记录数组长度的数据

实例数据,也即字段内容:

  • 存储顺序:存储顺序会受到虚拟机分配策略参数(FieldsAllocationStyle)和字段在Java源码中定义顺序的影响
    • 相同宽度的字段总是被分配在一起
    • 在父类中定义的变量会出现在子类之前

对齐填充

仅仅起到占位符的作用:由于HotSpot VM的自动内存管理系统要求对象起始地址必须是8字节的整数倍,也即对象的大小必须是8字节的整数倍。而对象头正好是8字节的整数倍(1倍或者2倍),因此对象实例数据部分没有对齐时,就需要通过对齐填充来补全。

2.对象的访问定位

Java 程序通过栈帧中局部变量表中的 reference 数据来操作堆上的具体对象。

由于reference类型在Java虚拟机规范中规定了一个指向对象的引用,并没有定义这个引用应该通过何种方式去定位、访问堆中的对象的具体位置,所以对象访问方式也是取决于虚拟机实现而定的

目前主流的访问方式有使用句柄和直接指针两种:

(1)直接指针

如果使用直接指针访问,那么Java堆对象的布局中就必须考虑如何放置类型数据的相关信息(对HotSpot虚拟机而言,也即对象头中需要存放类型指针),而reference中存储的直接就是对象地址。

JVM_01_JVM核心知识点图解

HotSpot虚拟机就是使用此种方式,使用直接指针访问方式的最大好处就是速更快。

(2)使用句柄

如果使用句柄访问,那么Java堆中将会划分一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息。
JVM_01_JVM核心知识点图解

3.对象的创建

JVM_01_JVM核心知识点图解

对象创建的流程如上图所示:

(1)new指令

虚拟机遇到一条new指令时,会进行类加载检查

(2)类加载检查

首先检查这个指令的参数能否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,那必须先执行相应的类加载过程。

(3)对象堆内存分配

在类加载检查通过后,接下来虚拟机将为新生对象分配内存;

对象所需内存的大小在类加载完成后便可确定,为对象分配空间的任务等同于把一块确定大小的内存从Java堆中划分出来。

  • 有两种内存分配方式:
    • 指针碰撞:要求堆规整,也即要求垃圾收集器带有压缩整理的功能,具体有 Serial、ParNew
    • 空闲列表:不要求堆规整,像CMS这种基于Mark-Sweep算法的收集器,通常采用空闲列表
  • 线程安全问题的两种解决方案:
    • CAS+失败重试:对分配内存空间的动作进行同步处理——实际上虚拟机采用CAS配上失败重试的方式保证更新操作的原子性
    • TLAB:将内存分配的动作放在线程本地空间中进行,即每个线程在Java堆中预先分配一小块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer)。哪个线程要分配内存,就在哪个线程的TLAB上分配,只有TLAB用完并分配新的TLAB时,才需要同步锁定。

(4)初始化为零值

将对象实例数据初始化为零值

(5)对象头设置

设置对象头,例如如何才能找到类的元数据信息(类型指针)、对象的哈希码(MarkWord)、对象的分代GC年龄(MarkWord)、是否启用偏向锁(MarkWord)等

(6)执行<init>方法

即执行对象的构造方法

三、类加载机制

虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这就是虚拟机的类加载机制

1.类的生命周期

JVM_01_JVM核心知识点图解

类的整个生命周期包括7个阶段:加载、验证、准备、解析、初始化、使用和卸载,其中验证、准备、解析3个部分统称为连接。

2.类加载的时机

虚拟机规范严格规定了有且只有5种情况必须立即对类进行“初始化“(而加载、验证、准备自然需要在此之前开始):

(1)遇到 newgetstaticputstaticinvokestatic 这4条字节码指令时,如果类没有进行过初始化,则需要先触发其初始化

(2)使用 java.lang.reflect 包的方法对类进行反射调用的时候,如果类没有进行过初始化,则需要先触发其初始化。

(3)当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。

(4)当虚拟机启动时,用户需要指定一个要执行的主类(包含main() 方法的那个类),虚拟机会先初始化这个主类。

(5)当使用JDK 1.7的动态语言支持时,如果一个 java.lang.invoke.MethodHandle 实例最后的解析结果 REF_getStaticREF_putStaticREF_invokeStatic的方法句柄,并且这个方法句柄所对应的类没有进行过初始化,则需要先触发其初始化。

3.类加载过程

3.1 加载

在加载阶段,虚拟机需要完成以下3件事情:

1)通过一个类的全限定名来获取定义此类的二进制字节流。
2)将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
3)在内存中生成一个代表这个类的 java.lang.Class对象(存在方法区),作为方法区这个类的各种数据的访问入口。

加载与连接阶段的部分内容是交叉进行的

这两个阶段的开始时间保持固定的先后顺序

3.2 验证

验证的目的是:为了确保 Class 文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全

主要进行如下验证动作:文件格式验证、元数据验证、字节码验证、符号引用验证

3.3 准备

在方法区中,为类变量分配内存,并设置类变量初始零值

3.4 解析

解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程,

符号引用:如全类名、字段描述符、方法描述符

直接引用:直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄

解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符7类符号引用进

3.5 初始化

初始化阶段是执行类构造器<clinit>()方法的过程

  • <clinit>()方法是由编译器自动收集类中的所有 类变量的赋值动作静态语句块static{})中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序所决定的,静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值,但是不能访问
  • 虚拟机会保证在子类的 <clinit>() 方法执行之前,父类的<clinit>() 方法已经执行完毕

4.类加载器的双亲委派模型

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Ha9L6HNm-1572329243092)(images/1571906504950.png)]

双亲委派模型的工作过程是:

如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载。

四、垃圾回收机制

1.对象存活判定算法

1.1 引用计数法

给对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加1;当引用失效时,计数器值就减1;任何时刻计数器为0的对象就是不可能再被使用

引用计数法判定效率高,但很难解决对象之间相互循环引用的问题。

1.2 可达性分析法

JVM_01_JVM核心知识点图解

通过一系列的称为GC Roots的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到 GC Roots没有任何引用链相连时,则证明此对象是不可用的

引用:

强引用:不回收

软引用:内存溢出前回收

弱引用:下一次垃圾收集时回收

2.垃圾收集算法

2.1 标记-清除

JVM_01_JVM核心知识点图解

标记:标记出所有不可达的对象

清除:在标记完成后统一回收所有被标记的对象

不足:(1)效率低 (2)标记清除后产生大量空间碎片

2.2 复制

JVM_01_JVM核心知识点图解

将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上
面,然后再把已使用过的内存空间一次清理掉

2.3 标记整理算法

JVM_01_JVM核心知识点图解

标记:标记出所有不可达的对象

整理:让所有存活对象向一端移动,然后直接清除掉端边界以外的内存

2.4 分代收集算法

根据对象存活周期的不同将内存划分为几块。一般是把Java堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。

  • 新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。
  • 老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用**“标记—清理”或者“标记—整理”**算法来进行回收。

3.内存分配与回收策略

3.1 内存分配与回收策略

对象的内存分配,往大方向讲,就是在堆上分配(例外:逃逸分析)。

同时还需记住一点:分配的规则并不是百分之百固定的,其细节取决于当前使用的是哪一种垃圾收集器组合,还有虚拟机中与内存相关的参数的设置。

(1)对象优先在 TLAB 上分配

如果启动了本地线程分配缓冲,将按线程优先在 TLAB 上分配

(2)对象优先在Eden分配

大多数情况下,对象在新生代Eden区中分配。当Eden区没有足够空间进行分配时,虚拟机将发起一次MinorGC。

(3)大对象直接进入老年代

虚拟机提供了一个-XX:PretenureSizeThreshold参数,令大于这个设置值的对象直接在老年代分配。这样做的目的是避免在Eden区及两个Survivor区之间发生大量的内存复制

(4)长期存活的对象进入老年代

  • 年龄计数:虚拟机给每个对象定义了一个对象年龄(Age)计数器,对象每经历一次Minor GC ,年龄就加1,当它的年龄增加到一定程度(默认为15岁),就将会被晋升到老年代中。
  • 年龄阈值:对象晋升老年代的年龄阈值,可以通过参数-XX:MaxTenuringThreshold设置。
  • 动态对象年龄判定:如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到MaxTenuringThreshold中要求的年龄。

(5)空间分配担保

Minor GC在发之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间

  • 如果大于,那么Minor GC 可以确保是安全的。
  • 如果不成立,则虚拟机会 查看 HandlePromotionFailure 设置值是否允许担保失败
    • 如果允许,那么会继续检查 老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行一次Minor GC,尽管这次Minor GC是有风险的;
    • 如果小于,或者HandlePromotionFailure设置不允许冒险,那这时也要改为进行一次Full GC

也就是说,如果Minor GC不安全,虚拟机就进行 Full GC

3.2 Minor GC 与 Full GC 触发条件

  • 新生代GC(Minor GC):发生在新生代的垃圾收集动作。因为Java对象大多都具备朝生夕灭的特性,所以Minor GC非常频繁,一般回收速度也比较快。
  • 老年代GC(Major GC/Full GC):指发生在老年代的GC,出现了Major GC,经常会伴随至少一次的Minor GC(但非绝对的,在Parallel Scavenge收集器的收集策略里就有直接进行Major GC的策略选择过程)。Major GC的速度一般会比Minor GC慢10倍以上。

参考:Minor GC ,Full GC 触发条件是什么?

Minor GC触发条件:

当Eden区满时,触发Minor GC

Full GC触发条件:

(1)System.gc()方法的调用

(2)老年代空间不足

(3)方法区空间不足

(4)通过Minor GC后进入老年代的平均大小大于老年代的可用内存

(5)由Eden区、From Space区向To Space区复制时,对象大小大于To Space可用内存,则把该对象转存到老年代,且老年代的可用内存小于该对象大小

易混点

1.TLAB 的精确位置

引用 https://dzone.com/articles/thread-local-allocation-buffers 文中一句:

TLAB stands for Thread Local Allocation Buffer and it is a region inside Eden, which is exclusively assigned to a thread

因此是在新生代 Eden区

2.Class对象就是类元数据吗

群友说就是类元数据,

3. major gc 和 full gc有什么区别?

https://plumbr.io/handbook/garbage-collection-in-java#metaspace

我们应该关注于当前GC是否 stop the world,而并不是字面意思

参考资料

画图参考

  1. https://www.processon.com/view/5c749debe4b0f9fba6921d15
  2. https://www.processon.com/view/5c8860fbe4b02ce2e88bc422
  3. https://www.processon.com/view/562eed2fe4b0f7ab0e7f7b4b
  4. https://www.processon.com/view/5d28ab5ce4b0878e40b01dc5
  5. https://www.processon.com/view/5d88eef8e4b00c785f348288
  6. https://www.processon.com/view/57220a6de4b08d6d334dec42
  7. https://www.processon.com/view/5be0fed2e4b0aadfb77c369c
  8. https://www.processon.com/view/5d7eebade4b01cbe9933fd86
  9. https://www.processon.com/view/5b7d123de4b015327abb63d9

类加载:

https://www.processon.com/view/5ac2343fe4b0cf24e96521c0
https://www.processon.com/view/5793117ee4b0d617ce2f8410

待读

护士神推荐阅读: