引言:
来源:知识结构来自河北王校长的JVM系列,博主基于视频自写笔记,并补充大量的延伸内容。
优点:笔记知识点关联性好,挖得深,延伸多。
缺点:缺少JVM与多线程结合的内容,以后会补充上;排版一般

1. class文件

class文件

class文件

https://blog.csdn.net/lioncatch/article/details/105919391

https://baijiahao.baidu.com/s?id=1717139924001321921&wfr=spider&for=pc

8字节 2进制 Java文件编译之后

  • 4字节魔数:标志文件类型;视频,照片也有魔数

  • 次版本号和主版本号:标志JDK版本;兼容低版本问题

  • 常量池计数器:记录后面常量池的常量(数据项)个数;※

  • 常量池:字面量+符号引用

    1.字面量:比较接近于Java语言层面的常量概念,如文本字符串、声明为final的常量值。
    2.符号引用
    package名字,权限命名,字段名称,方法名称,字段描述符,方法描述符,方法句柄,方法类型,动态调用点和动态常量(不了解)
    一句话:这部分就是常量池,装载了全部类的字段,方法,类名称的信息;

    符号引用

    符号引用

    每个常量项的第一个字节 对应上图中的tag标志 找到对应的结构

  1. 面试题
  • Q 哪些字面量会进入常量池中?

    1. 【final修饰】的8种基本类型的值会进入常量池。
    2. 【非final类型】(包括static的)的8种基本类型的值,只有【double、float、long】的值会进入常量池。
    3. 常量池中包含的字符串类型字面量(【双引号引起来的字符串值】)。

    4. final类型的8种基本类型的值会进常量池。

    5. final类型(包括static的)的8种基本类型的值,只有double、float、long的值会进常量池。

    6. 常量池中包含的字符串类型字量(双引号引起来的字符串值)。

  • java 字段名和方法名,有无长度限制?
    A: 有,CONSTANT_Utf8_info 存储字符串字面量, 最大长度是65535,大小限制在小于64kb

  • Q 字符串有长度限制吗?是多少?
    答:

    1. 运行期限制
      首先字符串的内容是由一个字符数组 char[] 来存储的,由于数组的长度及索引是整数,且String类中返回字符串长度的方法length() 的返回值也是int ,所以通过查看java源码中的类Integer我们可以看到Integer的最大范围是2^31 -1,
      由于数组是从0开始的,所以数组的最大长度可以使【0~2^31】通过计算是大概4GB。
    2. 编译时期限制
      通过翻阅java虚拟机手册对class文件格式的定义以及常量池中对String类型的结构体定义我们可以知道对于索引定义了u2,就是无符号占2个字节,2个字节可以表示的最大范围是2^16 -1 = 65535。但是由于JVM需要1个字节表示结束指令,所以这个范围就为65534了。超出这个范围在编译时期是会报错。
      原来是为了弥补早期设计时的一个bug,“长度刚好65535个字节,且以1个字节长度的指令结束,这条指令不能被异常处理器处理”,因此就将数组的最大长度限制到了65534了。跟程序计数器有关

运行时拼接或者赋值的话范围是在整形的最大范围。
https://blog.csdn.net/rd_w_csdn/article/details/110387250

错误回答:
CONSTANT_Utf8_info 中有 u2 length; 表明了该类型存储数据的长度。 u2 是无符号的 16 位整数,因此理论上允许的的最大长度是 2^16=65536。而 java class 文件是使用一种变体 UTF-8 格式来存放字符的,null 值使用两个 字节来表示,因此只剩下 65536- 2 = 65534 个字节。

Modified UTF-8 UTF-8 缩略编码是改进版的UTF-8编码,它和标准的UTF-8编码有下面三点区别:

  1. null空字符的编码从一个字节的'\u0000'改变为2个字节的形式,即11000000 10000000,因此在字符串的编码中不会出现嵌入的null字符;
  2. 只使用1~3个字节的格式;(即范围由\u0000-\uffff)
  3. 辅助字符以代理对的形式表示。 补充字符通常指大于u+ffff而小于等于u+10ffff范围内的字符
  • Q常量池计数器从0还是1开始?
    A: 从1开始,0索引留给无法指向的东西,即没有父类,没有类名的,比如匿名内部类,Object类

  • 2字节的访问标志:

    表明这个class有什么修饰符 只有两个字节

举例: 原来是00 20(看下表中ACC_SUPER描述,最低限度必须有此标志)
如果:类是public类型-的,则第一个ACC_PUBLIC为true 则加上00 01 ,结果为00 21
如果:类还是 final类型-的,则第二个为true,再加上00 10 ,结果为00 31

00 21 = 00 20 + 00 01 ( 加了public)
表示为public 的类
设置的数值刚好不会出现相加和其他状态相等的情况,很巧妙
一句话 访问标识就是只用两个字节表示了这个类的修饰符有哪些

  • 类、父类(This class,Super class)

    如 00 02 和 00 003两个标识符也都是一个两字节的引用,我们class文件中,分别引向常量池中00 02的第二个常量项和00 03 第三个常量项
    CONSTANT_Class_info -> CONSTANT_Utf8_info -> 找到类名

    在这里插入图片描述

    在这里插入图片描述

  • 接口索引计数器

    类文件实现了接口的数量,没有就是00 00 ,有一个就是 00 01

  • 接口索引项

    指向常量池索引
    CONSTANT_Class_info -> CONSTANT_Utf8_info -> 找到接口名

  • 字段表:字段个数,字段1,字段2。。。。

    和上面差不多
    字段表,方法表,class类都有自己的属性表!
    `
    ※ 注意:ConstantValue属性:

    final static String = “hello”
    必须是String 类型,其值直接存储在字段表里面附带自己的属性表的ConstantValue属性指向的常量池里面,这样就可以类未加载直接用,效率提升
    `

ConstantValue属性
属性表中的一个属性
ConstantValue属性的作用是通知虚拟机自动为静态变量赋值,只有被static修饰的变量才可以使用这项属性。非static类型的变量的赋值是在实例构造器方法中进行的;static类型变量赋值分两种,在类构造其中赋值,或使用ConstantValue属性赋值。

在实际的程序中,只有同时被final和static修饰的字段才有ConstantValue属性,且限于基本类型和String。编译时Javac将会为该常量生成ConstantValue属性,在类加载的准备阶段虚拟机便会根据ConstantValue为常量设置相应的值,如果该变量没有被final修饰,或者并非基本类型及字符串,则选择在类构造器中进行初始化。

为什么ConstantValue的属性值只限于基本类型和string?

因为从常量池中只能引用到基本类型和String类型的字面量

final、static、static final修饰的字段赋值的区别

static修饰的字段在加载过程中准备阶段被初始化,但是这个阶段只会赋值一个默认的值(0或者null而并非定义变量设置的值)初始化阶段在类构造器中才会赋值为变量定义的值。

final修饰的字段在运行时被初始化,可以直接赋值,也可以在实例构造器中赋值,赋值后不可修改。

static
final修饰的字段在javac编译时生成comstantValue属性,在类加载的准备阶段直接把constantValue的值赋给该字段。

可以理解为在编译期即把结果放入了常量池中。

分析第一个 access_flags
—-其实跟之前我们讲访问标识的时候一样,不一样的是这次的访问标志修饰符是修饰字段的
所以在标志名称上有些不同
在这里插入图片描述

这里加一张图用另一种方式表示我们的修饰符

在这里插入图片描述

在这里插入图片描述

分析第四个 attributes_count
表示这个字段所拥有的attribute类型的个数,如果是00 01 就表示一个
分析第五个 attributes[attributes_count]
就是一个长度为 attributes_count,类型是:属性类型的数组
这个类型我们暂时不分析,到后面讲到 属性 时一起讲

一句话总结
就是一个装有我们定义的所有属性的数组,长度为字段个数,里面的每一个表信息都表示一个字段

  • 方法表: 方法个数,方法1,方法2。。。。

  • 字段表,方法表,class类都有自己的属性表!

    access_flags的修饰类型

※※ 方法的代码存储在: 方法表里面附带自己的属性表的CODE属性里面。
https://blog.csdn.net/weixin_29732737/article/details/113051809
max_stack
操作数栈的最大深度,说明方法在编译把方法的栈深度已经定好了。虚拟机运行的时候需要根据这个值来分配栈帧中的操作栈深度。
max_locals
局部变量表所需要的空间,长度不能变
单位是Slot,int、byte和returnAddress不超过32位的数据类型采用1个Slot来存储;
double和long等64位的数据类型采用两个Slot存储
其他(包括异常信息)
code_length存储了方法体中的字节码指令的长度
code就存储了具体的字节码指令
exception_table_length
显示异常(受检查的异常)中的个数(try-cacatch中的异常)
exception_info
这是受检查异常的具体信息

  • 属性表: 属性个数,属性1,属性2。。。。

    上面所说的字段方法的最后都有一项:属性项数组,里面装的就是这个类型了,比如ConstantValue和Code
    其实class文件还有一个属性表,就是上面那张图没有覆盖掉的最后一部分
    在Class文件、字段表、方法表都可以携带自己的属性表集合!!!
    属性表中不要求各个属性表具有严格的顺序,只要不与已有属性重名即可

2. JVM 类加载的整体流程

普通回答(过于绝对):
加载,连接,初始化
连接又分为验证,准备,解析

※ 从细节回答:
真正是分为7到8步

  1. 加载
    静态加载,java编译为class文件二进制字节流,获得的是class文件二进制字节流
  2. 验证
    进入连接阶段的验证:
    第一步,文件格式验证: 魔数和主次版本号
  3. 加载
    验证成功后,把class文件二进制字节流 加载到 方法区
    作用: 结构调整,把静态存储文件转换为运行时数据结构
    (思考: class常量池和方法区的运行时常量池的区别和联系)
  4. 加载
    堆内存生成当前类class对象 作为方法区中这个类的各种访问入口,比如:Object类,java.lang.Object 一定先加载,放入堆,提供给其他类访问
  5. 验证
    跳到连接阶段的验证:
    第二步,元数据验证,字节码描述的信息进行语义分析
  6. 验证
    跳到连接阶段的验证:
    第三步,字节码验证,通过数据流和控制流分析
    https://blog.csdn.net/weixin_38608626/article/details/88173916
  7. 准备
    0值的初始转化,含了给这个类的静态变量static和其他数据分配内存空间(仅仅是分配内存空间,具体初始化在最后一步)。
    static int a=123; 这一阶段a会变成0
  8. 初始化
    (解析: 第四步,符号验证,常量池内符号引用转直接引用,确定解析一定初始化之前,不确定在何时)【直接引用与虚拟机有关,不同的虚拟机翻译出来一般不会相同】
    JVM用赋值或者缺省值将静态变量进行初始化,并执行静态初始化程序(static块中的代码),初始化发生在执行main方法之前,但在指定的类初始化之前他的父类必须先初始化,若其父类仍然存在父类,那也需递归的初始化。

真正执行java代码,会调用<clinit>()方法 :
在编译生成class文件时,会自动产生两个方法,一个是类的初始化方法<clinit>, 另一个是实例的初始化方法<init>。
只有被static修饰并且赋值的静态属性才会产生<clinit>()方法,不管是直接赋值还是在静态块中赋值,最后都会被合并成一个<clinit>()方法,并按照代码中的顺序依次赋值。

  • 在初始化一个类时,并不会先初始化它所实现的接口。
  • 在初始化一个接口时,并不会先初始化它的父接口。

(面试题) 什么情况下立即对类初始化?

1 遇到四条字节码指令会立即初始化:

  • 创建普通对象的指令:new
  • 访问类字段(static 字段,或者称为类变量)的指令:getstatic, putstatic
  • invokestatic 指令:用于调用类方法(static方法)。
    (new引用类会立即初始化, new数组不会初始化: 原因:创建数组得到指令:newarray , anewarray , multianewarray,不是new指令)

2 反射直接初始化
3 父类未初始化先初始化
4 有main函数
5 java.lang.invoke.MethodHandle 实例最后解析结构结果为REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,并且这个方法句柄对应类没有初始化时,必须触发其初始化。
6 jdk1.8 接口default 方法

引申知识:

  • 加载:
    双亲委派模型:
    1启动类加载器(lib目录,根加载器),
    2拓展类加载器(lib->ext目录),
    3系统类加载器
    (又叫应用程序加载器,加载用户类路径(classpath)上的指定类库,可以直接使用这个类加载器。一般情况,如果我们没有自定义类加载器默认就是用这个加载器)
    4当然,可以通过继承Classloader实现自定义加载器
    如果一个类加载器收到类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器完成。每个类加载器都是如此,只有当父加载器在自己的搜索范围内找不到指定的类时(即ClassNotFoundException),子加载器才会尝试自己去加载。

    • (面试) 双亲委派好处?
      1.顺序加载。父类优先加载,避免重复加载
      2.安全性和唯一性。避免核心类篡改

      利用 沙箱安全机制。 比如:加载器发现这个名字的类,发现该类已被加载,并不会重新加载网络传递过来(或自己写)的java.lang.Integer
      tips: instance of 是根据类名称+类加载器 定位

    • JVM加载数组?
      数组这个类加载,不是类加载器加载,其内存区域是运行中内存动态构造的。
      类型的类在new时,才由加载器加载。
      如何标记出唯一数组?
    1. 当引用类被加载器加载时,数组标志在加载器的命名空间上。
    2. 基础类型数组类型直接标记在启动类加载器命名空间上。

命名空间
https://blog.csdn.net/chuifuhuo6864/article/details/100887587

由不同的类装载器装载的类将被放在虚拟机内部的不同命名空间。命名空间由一系列唯一的名称组成,每一个被装载的类有一个名字。JAVA虚拟机为每一个类装载器维护一个名字空间。例如,一旦JAVA虚拟机将一个名为Volcano的类装入一个特定的命名空间,它就不能再装载名为Valcano的其他类到相同的命名空间了。可以把多个Valcano类装入一个JAVA虚拟机中,因为可以通过创建多个类装载器从而在一个JAVA应用程序中创建多个命名空间。
初始类装载器/ 定义类装载器

命名空间有助于安全的实现,因为你可以有效地在装入了不同命名空间的类之间设置一个防护罩。在JAVA虚拟机中,在同一个命名空间内的类可以直接进行交互,而不同的命名空间中的类甚至不能觉察彼此的存在,除非显示地提供了允许它们进行交互的机制,如获取Class对象的引用后使用反射来访问。

如果要求某个类装载器去装载一个类型,但是却返回了其他类装载器装载的类型,这种装载器被称为是那个类型的初始类装载器
;而实际装载那个类型的类装载器被称为该类型的定义类装载器
。任何被要求装载类型,并且能够返回Class实例的引用代表这个类型的类装载器,都是这个类型的初始类装载器。

2. 常量池

1.常量池常量池,也叫Class常量池(常量池==Class常量池)。Java文件被编译成Class文件,Class文件中除了包含类的版本、字段、方法、接口等描述信息外,还有一项就是常量池,常量池是当Class文件被Java虚拟机加载进来后存放在方法区各种字面量(Literal)和符号引用。在Class文件结构中,最头的4个字节用于存储魔数(MagicNumber),用于确定一个文件是否能被JVM接受,再接着4个字节用于存储版本号,前2个字节存储次版本号,后2个存储主版本号,再接着是用于存放常量的常量池常量池主要用于存放两大类常量:字面量和符号引用量,字面量相当于Java语言层面常量的概念,如文本字符串,声明为final的常量值等,符号引用则属于编译原理方面的概念。如下

2.运行时常量池

2.1运行时常量池的简介运行时常量池是方法区的一部分。运行时常量池是当Class文件被加载到内存后,Java虚拟机会将Class文件常量池里的内容转移到运行时常量池里(运行时常量池也是每个类都有一个)。运行时常量池相对于Class文件常量池的另外一个重要特征是具备动态性,Java语言并不要求常量一定只有编译期才能产生,也就是并非预置入Class文件中常量池的内容才能进入方法区运行时常量池,运行期间也可能将新的常量放入池中2.2方法区的Class文件信息,Class常量池和运行时常量池的三者关系字符串常量池

3.1字符串常量池的简介字符串常量池又称为:字符串池,全局字符串池,英文也叫StringPo。在工作中,String类是我们使用频率非常高的一种对象类型。JVM为了提升性能和减少内存开销,避免字符串的重复创建,其维护了一块特殊的内存空间,这就是我们今天要讨论的核心:字符串常量池。字符串常量池由String类私有的维护。

3. 运行时数据区

  1. 线程共享
    方法区,堆
  2. 线程私有
    虚拟机栈,本地方法栈,程序计数器

程序计数器

  1. 小内存,几乎无OOM,控制代码执行位置,比如循环,线程block恢复,wait恢复

方法区(永久代)

  1. 存储类信息 (第四步加载过程)
  2. 常量,静态变量
  3. 即时编译后的代码缓存数据 【Class方法表中Code属性,保存执行指令字节,将其缓存在方法区】

元空间:能不限制使用系统内存资源

运行时常量池

  1. 存放编译期生成的字面量+符号引用
  2. 具备动态性
    不要求常量一定只有编译期才能产生
    String.intern() 是一个Native方法。调用intern()方法后,JVM 就会在当前类的运行时常量池中查找是否存在与str等值的String,若存在则直接返回运行时常量池中相应Strnig的引用;若不存在,则会在运行时常量池中创建一个等值的String,然后返回这个String在运行时常量池中的引用。

  1. 对象头(Header): (Mark Word+ class指针+ 数组长度)
    hashcode,GC分代年龄,锁标志状态,当前锁,偏向锁ID,偏向时间戳
  2. 实例数据( Instance)
  3. 对齐填充(Padding): 不满8字节的倍数就对齐填充
    字段内存对齐的其中一个原因,是让字段只出现在同一CPU的缓存行中。 如果字段不是对齐的,那么就有可能出现跨缓存行的字段。也就是说,该字段的读取可能需要替换两个缓存行,而该字段的存储也会同时污染两个缓存行。这两种情况对程序的执行效率而言都是不利的。其实对其填充的最终目的是为了计算机高效寻址。

(面试题) 64位JVM 中 new Object() 在Java中占多少内存?
16字节。
Mark Word (8字节)+ class指针(指针压缩4字节,否则8字节)+实例数据(0字节)+对齐填充(4字节或0字节)

虚拟机栈

java栈是针对每一个线程的,每一个线程都会有自己的栈,维护在其内部的引用就是本地变量表。

通过线程想起栈,提问,对象是如何被线程访问定位的呀?问的非常底层。
https://blog.csdn.net/y471519146/article/details/104638340

  1. 使用句柄。句柄是一个对象的唯一标示。中间有一个句柄池,存在于java堆的内存区域,线程指向句柄池,句柄池reference链接到对象地址
  2. 使用直接指针。线程对象指向另一对象。
    优劣:直接指针访问快,但gc清理将对象位置移动,每个线程的指针就要改变,消耗大。
    句柄优势:稳定,句柄池位置不变,当gc清理将对象位置移动,只需改变句柄池1个链接。

栈帧※

  1. 1个方法创建1个栈帧:存储方法的局部变量表,操作数栈,动态链接,返回地址等。【class方法表中附带属性表中code属性有局部变量所需要的空间】
  2. 开始调用到完成过程就是一个栈帧的入栈出栈
    局部变量表
    存储方法里的局部变量(包括方法参数),编译完成存储【class方法表中附带属性表中code属性】
    操作数栈
    方法调用的最大深度【code属性的max_stack】,比如递归,调用数超出,则StackOverflowError。
    动态链接
    方法调用过程中,链接其他方法或其他类
    返回地址
    谁调用返回给谁。分两点
  3. 正常退出。
  4. 异常退出。抛错给调用方。

####

堆 展开知识 (第二期)

Q:新生代 eden s0 s1 是根据什么思想 8:1:1分区 ? 回收器都是这样吗?
基于标记复制算法思想; 而基于标记清理算法就不是。

第一次minorgc: eden 空,s0 年龄1
第二次minorgc:eden空,s0空,s1 年龄有1和2
此时 s0 和 s1 角色互换
第三次minorgc:eden 1岁,s0空,s1 年龄有2和3
eden 空,s0空年龄有1,、2和3 ,s1空
此时 s0 和 s1 角色互换…

为什么需要s0和s1?
需要空闲的缓冲内存,存储清理后的存活对象,避免s0区空间碎片化。

老年代

  1. 年龄15岁以上
  2. 超大对象,超过阈值设置 max tenuring thread hold
  3. 相同年龄所有对象大小总和大于一个幸存者空间的一半,取年龄大于等于的对象,
    所以年轻代空间利用,只有 90%

    (一旦老年代要满就引发fullgc)

※ 空间分配担保策略
minorGC发生之前,第一步先检查老年代最大可用连续空间是否大于新生代所有对象的总空间,如果条件成立确保安全,如果条件不成立,比如空间剩余500M;
就会第二步检查虚拟机HandlePromotionFailure参数是否true,是否允许担保失败;
如果允许担保失败,jvm再次第三步检查老年代最大可用连续空间是否大于历次晋升到老年代对象的平均大小;

  • 如果大于,比如平均之前300M,冒险尝试一次minorGC, 存在风险:minorGC后存活对象还是大于500M,超出最大可用连续空间,说明老年代要满了就引发fullGC。
    • 如果小于 或 HandlePromotionFailure参数false,就直接fullGC。

(面试)如何判断对象是否存活? gc过程是什么?
可达性分析算法,通过一系列称为“GC Roots”根节点,开始引用链的搜索,如果对象搜索不到,证明对象不可达,标记白色,最终被垃圾回收。
引申:引用计数器算法,每个对象有引用计数器,Redis使用
5个问题:

1.gcroots 是什么?

是可达性分析的起点,是两个栈的栈帧的本地变量表中引用的对象,方法区中静态属性引用的对象,方法区中常量引用对象(两个栈两个方法区),其实还有,jvm内部也有引用,锁的获取和释放。

2.引用链是什么?

强:常见,永远不被垃圾回收
软:非必须,内存溢出OOM之前,列入回收范围进行第二次回收,还溢出就抛错
弱:生存到下一次垃圾收集之前
虚:幽(或幻)灵引用,主要用于监测对象是否已经从内存中删除,堆外内存回收。

软引用尝试保留其引用对象,弱引用不会试图保留其引用对象,虚引用所引用对象不会被被释放直到所有指向该对象的虚引用被清除。

  • 虚引用必须和引用队列关联使用, 当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会把这个虚引用加入到与之 关联的引用队列中

  • 程序可以通过判断引用队列中是否已经加入了虚引用,来了解被引用的对象是否将要被垃圾回收。如果程序发现某个虚引用已经被加入到引用队列,那么就可以在所引用的对象的内存被回收之前采取必要的行动

3.对象不可达意味着什么?一定被回收吗?

意味对象即将被垃圾回收,当然,不会立即回收。
※ 对象会放入f-q 的队列,会启动一条低优先级别的线程,读取对象,一个个调用对象的finalize方法,如果方法被覆盖并且被调用过,jvm会对该对象置为非必要垃圾回收,它逃过垃圾回收

4.三色标记法是什么?

白: 对象不可达
黑: 已被访问,所有关联对象也扫描过
灰:未被扫描 (重新标记有关)

  • img

    img

假设现在有白、灰、黑三个集合(表示当前对象的颜色),其遍历访问过程为:

  1. 初始时,所有对象都在【白色集合】中;

  2. 将 GC Roots 直接引用到的对象挪到 【灰色集合】中;

  3. 从灰色集合中获取对象:
    3.1. 将本对象引用到的其他对象全部挪到 【灰色集合】中;
    3.2. 将本对象挪到【黑色集合】里面。

  4. 重复步骤3,直至【灰色集合】为空时结束。

  5. 结束后,仍在【白色集合】的对象即为 GC Roots 不可达,可以进行回收。

    注:如果标记结束后对象仍为白色,意味着已经“找不到”该对象在哪了,不可能会再被重新引用。

当 Stop The World (以下简称 STW)时,对象间的引用是不会发生变化的,可以轻松完成标记。

而当需要支持并发标记时,即标记期间应用线程还在继续跑,对象间的引用可能发生变化,多标和漏标的情况就有可能发生。

多标-浮动垃圾

假设已经遍历到 E(变为灰色了),此时应用执行了 objD.fieldE = null (D > E 的引用断开):

img

img

此刻之后,对象 E/F/G 是“应该”被回收的。然而因为 E 已经变为灰色了,其仍会被当作存活对象继续遍历下去。最终的结果是:这部分对象仍会被标记为存活,即本轮 GC 不会回收这部分内存。

这部分本应该回收 但是没有回收到的内存,被称之为“浮动垃圾”。浮动垃圾并不会影响应用程序的正确性,只是需要等到下一轮垃圾回收中才被清除。

另外,针对并发标记开始后的新对象,通常的做法是直接全部当成黑色,本轮不会进行清除。这部分对象期间可能会变为垃圾,这也算是浮动垃圾的一部分。

漏标-读写屏障

假设 GC 线程已经遍历到 E(变为灰色了),此时应用线程先执行了:

1
2
3
var G = objE.fieldG;
objE.fieldG = null; // 灰色E 断开引用 白色G
objD.fieldG = G; // 黑色D 引用 白色G
img

img

此时切回 GC 线程继续跑,因为 E 已经没有对 G 的引用了,所以不会将 G 放到灰色集合;尽管因为 D 重新引用了 G,但因为 D 已经是黑色了,不会再重新做遍历处理。

最终导致的结果是:G 会一直停留在白色集合中,最后被当作垃圾进行清除。这直接影响到了应用程序的正确性,是不可接受的。

不难分析,漏标只有同时满足以下两个条件时才会发生:

  1. 灰色对象断开了白色对象的引用(直接或间接的引用);即灰色对象原来成员变量的引用发生了变化。
  2. 黑色对象重新引用了该白色对象;即黑色对象成员变量增加了新的引用。

从代码的角度看:

1
2
3
var G = objE.fieldG; // 1.读
objE.fieldG = null; // 2.写
objD.fieldG = G; // 3.写
  1. 读取对象 E 的成员变量 fieldG 的引用值,即对象 G;
  2. 对象 E 往其成员变量 fieldG,写入 null值。
  3. 对象 D 往其成员变量 fieldG,写入对象 G ;

我们只要在上面这三步中的任意一步中做一些“手脚”,将对象 G 记录起来,然后作为灰色对象再进行遍历即可。比如放到一个特定的集合,等初始的 GC Roots 遍历完(并发标记),该集合的对象遍历即可(重新标记)。

重新标记是需要 STW 的,因为应用程序一直在跑的话,该集合可能会一直增加新的对象,导致永远都跑不完。当然,并发标记期间也可以将该集合中的大部分先跑了,从而缩短重新标记 STW 的时间,这个是优化问题了。

写屏障用于拦截第二和第三步;而读屏障则是拦截第一步。
它们的拦截的目的很简单:就是在读写前后,将对象 G 给记录下来。

写屏障(仅拓展)

  • CMS:写屏障 + 增量更新

    当有新引用插入进来时,记录下新的引用对象。

    思路:不要求保留原始快照,而是针对新增的引用,将其记录下来等待遍历,即增量更新(Incremental Update)。

  • G1:写屏障 + SATB

    当原来成员变量的引用发生变化之前,记录下原来的引用对象。

    思路:尝试保留开始时的对象图,即原始快照(Snapshot At The Beginning,SATB),当某个时刻 的 GC Roots 确定后,当时的对象图就已经确定了

    比如 当时 D 是引用着 G 的,那后续的标记也应该是按照这个时刻的对象图走(D 引用着 G)。如果期间发生变化,则可以记录起来,保证标记依然按照原本的视图来。

  • ZGC:读屏障

    当读取成员变量时,一律记录下来

5.有没有跨代引用的问题?※

minorGC时候,如果当前年轻对象被老年代的对象引用,那么就需要遍历老年代对象里面的引用,如果有引用就不删除年轻代对象。 遍历过多,这消耗过大。

空间换时间,引入记忆集RememberSet数据结构),
(卡表CardTable是具体实现类似数组的一种实现)

用于记录从非收集区域指向收集区域的一个指针集合的抽象数据结构。

它存放在收集区域,比如在新生代里面存放着老年代对新生代对象的每一个引用。这样在收集新生代的时候,我们就可以根据记忆集知道哪些对象被老年代对象所引用,不能回收,这就解决了跨代引用的问题。
https://baijiahao.baidu.com/s?id=1663322935066223947&wfr=spider&for=pc

记忆集根据记录的精度分三类:
字长精度:记录的是老年代指向新生代地址。
对象精度:记录的是老年代引用的新生代对象。
卡精度:记录的是新生代一段地址是否存在被老年代引用的记录。

把地址的值右移9位相当于除于512就是卡表索引,每字节512为一组对应卡表同一个元素,一组就是一个卡页,
如果这个卡页中只要有一个对象被其他区域对象所引用,对应卡表元素的值就变成1,也就是所谓的元素变脏。

在垃圾回收时,只要筛选出卡表中变脏的元素,就能轻易得出哪些卡页对应的内存包含跨代指针,把他们加入GC Roots中一并扫描。

垃圾回收的3种算法

  • 标记-清除算法
    标记后统一回收
    空间问题: 他只有一块内存空间,会产生空间碎片,导致没有太多的可用的比较大的连续空间,导致对象创建找不到容纳空间,直接进入老年代。
    优点:快,找到并马上删除

  • 标记-复制算法
    起初思想是将内存分半,内存1:1,一部分存储新对象,一部分负责我们的存活对象。实现的时候,eden,s0,s1,幸存者区1个缓冲
    缺点:至少10%空间浪费,相对慢
    优点:针对创建对象,有很大的可用连续空间,GC后,eden区域清空,都是连续空空间,没有空间碎片

  • 标记-整理算法
    优化了标记复制算法和标志清除算法的改进,
  1. 标记并清除
  2. 存活对象整理到1边
    删除快,没有空间碎片

垃圾收集器

  1. 新生代:serial 单线程
    单核服务器最快最理想,有stw暂停和安全点线程挂起的问题。
    Q:会不会有CPU时间切片轮询?为什么单核快呢?
    其他线程必须挂起Stop the world,cpu时间片都要落到垃圾收集的线程里面,其他线程不会有cpu时间片轮询,即便有,cpu时间片也是0。其他线程处于内核态,只有垃圾收集器处于用户态。

为什么Stop the world呢?

  1. 新生代:并发的parNew 。 serial 的多线程版本
    标记复制算法

  2. 新生代:parallel Scavenge。
    能多线程,又能关注和控制吞吐量。
    吞吐量=运行用户代码时间/(运行用户代码时间+运行垃圾收集时间)
    不与CMS合作

两个控制参数:
最大垃圾收集停顿时间
直接设置吞吐量大小
如何控制吞吐量?
首先,运行用户代码不会变。
那么,通过多运行几回垃圾收集,来提高一次虚拟机运行的吞吐量。
比如: 注意吞吐量参数是(0,100)

  1. 老年代:Serial Old 收集器
    单线程,标记整理算法

  2. 老年代:Pallel Old 收集器
    标记整理算法,jdk1.6才开始,吞吐量优先。

  3. 老年代:CMS 收集器
    Concurent Mark Sweep 多线程标记清除,以最短回收停顿时间为目标的收集器。
    记忆:
    三个mark一清除,初重stw

CMS步骤过程:
初始标记 stw,标记gcroots 直接关联对象
并发标记 不停顿,扫描old区对象。

刚才产生的集合中标记出存活对象;
应用程序也在运行;
并不能保证可以标记出所有的存活对象;

重新标记 stw,修正
并发清除

stop the world 其他线程被挂起,处于内核态。

※※
CMS 缺点:
首先承认,G1 出来之前,jdk9之前, CMS是靠谱的。

  1. 对资源处理非常敏感。
    在并发标记时,占用CPU线程数,影响项目中运行所用线程数。因为占用资源,可能导致资源不足。默认启动(核数n+3)/4线程,这样,核数多防止占用。
  2. 无法处理浮动垃圾。
    并发清除,程序运行依然运行,有新垃圾出现,本次收集无法处理,留到下一次GC才清理,这就是“浮动垃圾”。如果对象被疯狂访问,大量对象堆积,可能出现“Concurrent Mode Failure”失败而导致另一次Full GC的产生。

  3. “标记清除算法”虽然快,会产生大量空间碎片。

stop the word ※※

引入概念:根节点枚举。
在开始标记收集的时候,不管单线程还是并发,不管是对象还是线程,一定在一致性的快照里,才能进行。
如果不知道当前的状态的话,没办法进行后续的标记。
一致性快照的分析工作就是根节点枚举期间:所有线程停顿。

OopMap 特定的位置记录下栈和寄存器中的哪些位置是引用

OopMap 记录了栈上本地变量到堆上对象的引用关系。其作用是:垃圾收集时,收集线程会对栈上的内存进行扫描,看看哪些位置存储了 Reference 类型。如果发现某个位置确实存的是 Reference 类型,就意味着它所引用的对象这一次不能被回收。但问题是,栈上的本地变量表里面只有一部分数据是 Reference 类型的(它们是我们所需要的),那些非 Reference 类型的数据对我们而言毫无用处,但我们还是不得不对整个栈全部扫描一遍,这是对时间和资源的一种浪费。
一个很自然的想法是,能不能用空间换时间,在某个时候把栈上代表引用的位置全部记录下来,这样到真正 gc 的时候就可以直接读取,而不用再一点一点的扫描了。事实上,大部分主流的虚拟机也正是这么做的,比如 HotSpot ,它使用一种叫做 OopMap 的数据结构来记录这类信息。
我们知道,一个线程意味着一个栈,一个栈由多个栈帧组成,一个栈帧对应着一个方法,一个方法里面可能有多个安全点。 gc 发生时,程序首先运行到最近的一个安全点停下来,然后更新自己的 OopMap ,记下栈上哪些位置代表着引用。枚举根节点时,递归遍历每个栈帧的 OopMap ,通过栈中记录的被引用对象的内存地址,即可找到这些对象( GC Roots )。
可以把oopMap简单理解成是调试信息。在源代码里面每个变量都是有类型的,但是编译之后的代码就只有变量在栈上的位置了。oopMap就是一个附加的信息,告诉你栈上哪个位置本来是个什么东西。 这个信息是在JIT编译时跟机器码一起产生的。因为只有编译器知道源代码跟产生的代码的对应关系。 每个方法可能会有好几个oopMap,就是根据safepoint把一个方法的代码分成几段,每一段代码一个oopMap,作用域自然也仅限于这一段代码。 循环中引用多个对象,肯定会有多个变量,编译后占据栈上的多个位置。那这段代码的oopMap就会包含多条记录。
通过上面的解释,我们可以很清楚的看到使用 OopMap 可以避免全栈扫描,加快枚举根节点的速度。但这并不是它的全部用意。它的另外一个更根本的作用是,可以帮助 HotSpot 实现准确式 GC 。

安全点Safepoint

垃圾收集过程,用户线程到达特定位置,这位置不会产生新对象,也不会影响项目的执行,只能让垃圾收集非常安全的进行下去。
位置:
1、循环的末尾
2、方法临返回前 / 调用方法的call指令后
3、可能抛异常的位置

OopMap线程找对象
回顾句柄池,直接指针。

如何线程如何找Class文件?
找常量池里的Constant_class_info指向Constant_utf8_info,找到权限命名,再去方法区。(类加载过程中,对象加载到方法区,再在堆生成class对象,作为访问类的入口)在堆中定位到Class。

想继续找方法?
方法表->属性表->code->max stack,max local,方法入口-> 代码

G1垃圾收集器※※

  1. 分代理论垃圾收集,只不过是增加一个拓展。
  2. 把内存区域分成多个大小相等的内存区域块Region。
  3. jdk9发布,把吞吐量组合取代了,成为服务端默认。如果换成CMS,那么JDK参数配置开启CMS,不过提示Warnings:CMS以后会被废弃。
  4. 内存分块会按照“意愿”进行局部收集。
  5. Region堆内存布局的原理:
    不再坚持固定大小的分代理论,把内存区域分成多个大小相等的内存区域块Region,每个Region会根据需要扮演新生代eden,survival),老年代的角色。
    如果有大对象?
    G1会分出一部分区域作为特殊区域专门存储大对象,如果通过G1检测,只要大小超过超过1.5个region,就可以判断大对象,就放到H。Region可以通过JVM参数设置,1-32GB之间。如果超过16GB,肯定放在大对象区域。
    大对象区域称为:humongous,全称:attempt_allocation_humongous。
    对于超大对象,比如32GB,会分配两个连续的Region空间。

  6. 标榜:可预测停顿时间的模型。
    关注每个模块里回收价值的大小,通过设置一个很低的最大允许GC时间ms,,-XX:MaxGCPauseMillis =200,根据200ms去找它能够进行收集的Region,比如:收10个Region需要花300ms,由于超过200ms,按比例降低先收集6-7Region达到而200ms要求。

    • 由于分区的原因,G1可以只选取部分区域进行内存回收,这样缩小了回收的范围,因此对于全局停顿情况的发生也能得到较好的控制
  • G1 跟踪各个 Region 里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region。保证了G1收集器在有限的时间内可以获取尽可能高的收集效率

Q:如何解决跨代Region引用问题?
A:使用记忆集,结构相对复杂,是个双向卡表。

步骤:
1.初始标记:stw
GCRoots根节点枚举,在安全点进行
2.并发标记
3.最终标记:stw
4.筛选回收
根据MaxGCPauseMillis最大允许GC时间,优先回收价值最大的Region, 保证可预测停顿时间。

JVM调优经验 ※※

根据用户访问量,在不同时刻导致JVM回收
在上线前,做压测,监督内存空间的使用,以及发生GC回收的频率和停顿时间。

  1. 大访问的压力,一边回收一边对象持续创建,minorGC会比较频繁,那么,一:调大年轻代的大小。二:如果发生时间不长,很快回收,且回收率高,不会导致老年代短时间内有对象填充,那么就,进行持续压测,观察老年代的上限。

  2. 大对象创建的频繁且常访问,导致FullGC比较频繁,(对于G1会分配到humongous区)。一:压测观察大对象的大小,过大可以把对象拆分,维持在JVM参数下;二:适当调大判断为大对象的参数,防止它直接进入老年代;三:发生OOM异常,如何排查呢?压测过程,一定先dump出来堆内存的heap文件,导入visual VM工具,主要查看堆栈信息。找内存占用高的对象,Reference指向堆栈信息,最终定位代码。springboot的Context上下文是单例,很难发生大对象频繁创建。四: 线程无法到达安全点,请求第三方等待中,比如60s超时时间,其他线程的已经在安全点STW,这里细说:用户态和内核态。