时至今日,JVM的重要性已经不言而喻,但是JVM本身的复杂性使得了解其原理与运作方式是一件极其困难的事情,本文主要结合张龙老师的视频:深入理解JVM虚拟机open in new window与《深入理解Java虚拟机》,系统而全面的介绍Java虚拟机的方方面面。

Java 虚拟机总览

内存管理

Java与C++之间有一堵由内存动态分配和垃圾收集技术所围成的高墙,墙外面的人想进去,墙里面的人却想出来。

根据《Java虚拟机规范》的规定,Java虚拟机所管理的内存将会包括以下几个运行时数据区域:

image

主要部分的作用:

内存区域作用
程序计数器当前线程所执行的字节码的行号指示器
虚拟机栈Java方法执行的线程内存模型
本地方法栈本地方法(Native)服务
存放对象实例
方法区存储已被虚拟机加载的类型信息、常量、静态变量、即时编译后的代码缓存等

除此之外,还有直接内存,这部分并不属于运行时数据区的一部分,也不是《Java虚拟机规范》中定义的内存区域,但这部分内存也被频繁的使用。

垃圾回收

垃圾收集器主要完成三件事情:

  • 哪些内存需要回收?
  • 什么时候回收?
  • 如何回收?

在Java内存运行时区域的各个部分中,程序计数器、虚拟机栈、本地方法栈3个区域需要使用的内存区域绝大部分可以由类结构确定,因此,这几个区域不需要过多考虑如何回收的问题,当方法结束或者线程结束时,内存自然就跟随着回收了。

判断对象是否可以回收的方法主要有:

  • 引用计数算法
  • 可达性分析算法

垃圾回收的算法主要由:

  • 标记-清除算法
  • 标记-复制算法
  • 标记-整理算法

不同垃圾回收算法的比较:

算法速度空间开销移动对象
Mark-Sweep中等少(但会堆积碎片)
Mark-Compact最慢少(不堆积碎片)
Copying最快通常需要活对象的2倍大小(不堆积碎片)

经典的垃圾收集器及对比:

收集器串行、并行或并发新生代、老年代算法
Serial串行新生代复制算法
Serial Old串行老年代标记-整理
ParNew并行新生代复制算法
Parallel Scavenge并行新生代复制算法
Parallel Old并行老年代标记-整理
CMS并发老年代标记-清除
G1并发不区分标记-整理+复制算法

类文件结构

尽管不同版本的《Java虚拟机规范》对Class文件格式进行了多次更新,但基本上只是在原有结构基础上新增内容、扩充功能,并未对已定义的内容做出修改。

Class文件格式采用一种类似与C语言结构体的伪结构来存储数据,这种伪结构只有两种数据类型:“无符号数”和“表”。

  • 无符号数数据基本的数据类型,以u1、u2、u4、u8来分别代表1个字节、2个字节、4个字节和8个字节的无符号数,无符号数可以用来描述数字、索引应用、数量值或者按照UTF-8编码构成字符串值。
  • 表是由多个无符号数或者其他表作为数据项构成的复合数据类型,为了便于区分,所有表的命名都习惯性地以“_info”结尾。表用于描述有层次关系的复合结构的数据,整个Class文件本质上也可以视为是一张表,这张表由若干个数据项按严格顺序排列构成。
类型名称数量
u4magic(魔数)1
u2minor_version(主版本号)1
u2major_version(次版本号)1
u2constant_pool_count(常量池数)1
cp_infoconstant_pool(常量池)constant_pool_count - 1
u2access_flags(访问标志)1
u2this_class(类索引)1
u2super_class(父类索引)1
u2interfaces_count(接口数量)1
u2interfaces(接口类索引信息)interface_count
u2fields_count(字段数量)1
field_infofields(字段表索引信息)fields_count
u2methods_count(方法数量)1
method_infomethods(方法表索引信息)methods_count
u2attributes_count(属性表个数)1
attribute_infoattributes(属性表信息)attributes_count

类加载机制

Java虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终可以形成可以被虚拟机直接适用的Java类型,这个过程被称作虚拟机的类加载机制。与其他语言不同的是,Java语言中,类的加载、链接、初始化过程都是在程序运行期间完成的,这为Java应用提供了极高的扩展性和灵活性。

整个类加载的过程大致分为:

image-20210729173029886

JDK8以及之前的类加载器可以分为:

  • 启动类加载器
  • 扩展类加载器
  • 应用类加载器

内存管理

运行时数据区域

程序计数器

程序计数器(Program Counter),当前线程所执行的字节码的行号指示器,字节码解释器需要依赖程序计数器完成分支、循环、递归等基础功能。

如果线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指定的地址;如果正在整形的是本地(Native)方法,这个计数器值则应为空(Undefined)。此内存区域是唯一一个在《Java虚拟机规范》中没有规定任何OutOfMemoryError情况的区域。

程序计数器为线程私有。

虚拟机栈

Java虚拟机栈:存储对象的引用,属于线程私有的内存空间,伴随着线程的生命周期;局部变量表中可以存储基本数据类型以及引用类型,每个方法执行的时候都会创建一个栈帧,栈用于存放局部变量表,操作栈,动态链接,方法出口等信息,一个方法的执行过程,就是这个方法对于栈帧的入栈、出栈过程;

Java虚拟机栈的生命周期与线程相同。

本地方法栈

本地方法栈(Native Method Stack):主要用于处理本地方法,有的虚拟机实现会把本地方法栈和虚拟机栈合二为一,例如Hotspot虚拟机;

本地方法栈的生命周期与线程相同。

堆(Heap):存储真实的对象,JVM管理的最大的一块内存空间;堆空间的内存可以是连续的,也可以是不连续的,与堆相关的一个重要概念是垃圾收集器,现代几乎所有的垃圾收集器都是采用的分代收集算法,所以堆空间也基于这一点进行了相应的划分:新生代与老年代;Eden空间(80%),From Survivor空间(10%)与To Survivor空间(10%)。

方法区

方法区(Method Area):存储元信息。永久代(Permanent Generation),从JDK1.8开始,已经彻底废弃了永久代,使用元空间(meta space),方法区里面的元信息是很少会被回收的,实例数据和元数据,元数据(Class对象)位于方法区。

在HotSpot虚拟机中,方法区也被称为永久区。

运行时常量池

运行时常量池(Runtime Constant Pool)是方法区的一部分。Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项是常量池表(Constant Pool Table),用于存放编译器生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。

直接内存

直接内存(Direct Memory),并非由Java虚拟机管理的内存区域,与Java NIO密切相关,JVM是通过DirectByteBuffer来操作直接内存的。

总体而言,除了程序计数器之外都有可能发生OutOfMemoryError异常。

HotSpot虚拟机对象

对象的创建

Java通过new关键字创建对象的3个步骤:

  1. 在堆内存中创建出对象的实例
  2. 为对象的成员变量赋初值
  3. 将对象的引用返回

此时,从虚拟机的视角来看,创建对象的工作已经完成了,但是构造方法中的代码还没有执行,所有的字段均为默认值,new指令执行之后,接着执行<init>()方法,一个真正可用的对象才算创建成功。

在创建出对象的实例的之前,需要为对象分配内存,分配内存的策略却决于堆内存的实际情况,通常而言有两种策略:

  • 指针碰撞:如果堆内存是绝对规整的,那么通过一个指针对堆中的空间进行分割,一侧是已经被使用过的空间,另一侧是未被使用过的空间
  • 空闲列表:前提是堆内存空间中已使用与未被使用的空间是交织在一起的,这时,虚拟机就需要通过一个列表来记录哪些空间是可以使用的,哪些空间是未被使用的,接下来找出可以容纳下新创建对象的且未被使用的空间,在此空间存放该对象,同时还要修改列表上的记录

对象的内存布局

在HotSpot虚拟机中,对象在内存中的存储布局可以划分为三个部分:

  1. 对象头(Header)
  2. 实例数据(Instance Data)
  3. 对齐填充(Padding)

在对象头中,主要存储两类信息,第一类用于存储对象自身的运行时数据(Mark World),如哈希码,GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,具体如下表:

存储内容标志位状态
对象哈希码、对象分代年龄01未锁定
指向锁记录的指针00轻量级锁定
指向重量级锁的指针10重量级锁定
空,不需要记录信息11GC标记
偏向锁ID、偏向时间戳、对象分代年龄01可偏向

对象头中存储的第二类是类型指针,即对象指向它的元数据的指针,Java虚拟机通过这个指针来去顶该对象是那个类的实例,不过这并不是所有的虚拟机实现都必须在对象数据上保留类型指针。

此外,如果对象是一个Java数组,那在对象头中还必须有一块用于记录数组长度的数据,用于推断数组的大小。

接下来,在实例数据的区域中,无论是从父类继承下来的,还是在子类中定义的字段全部都会被记录起来。

对象的第三部分是对象填充,这部分是可选的,也没有特别的含义,仅仅起着占位符的作用,由于HotSpot虚拟机的自动内存管理系统要求对象起始地址必须是8字节的整数倍,如果对象实例数据部分没有对齐的话,就需要通过对齐填充来补全。

对象的访问定位

主要的访问方式:

  • 使用句柄的方式
  • 使用直接指针的方式

句柄是用来标识被应用程序锁创建或使用的对象的整数。其本质相当于带有引用计数的智能指针。句柄与普通指针的区别在于,指针包含的是引用对象的内存地址,而句柄则是由系统所管理的应用标识,该标识可以被系统重新定位到一个内存地址上。

如果使用句柄访问的话,Java堆中对象的内存布局就必须考虑如何放置访问类型数据的相关信息,reference中存储的直接就是对象的句柄地址,而句柄中包含了实例数据与类型数据各自具体的地址信息,其结构如下图:

image

如果使用直接指针访问的话,reference中存储的直接就是对象地址,如果只是访问对象本身的话,就不需要一次间接访问的开销。

image-20210729225747764

使用句柄的好处是,在对象被移动的时候只会改变句柄中实例数据的指针,而reference本身不需要被修改。

使用直接指针的好处是速度更快。

垃圾回收

判断对象已死

引用计数算法

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

引用计数法原理比较简单,判定效率也很高,但是需要考虑很多例外情况,必须要配合大量额外的处理才能保证正确地工作。譬如单纯的引用计数就很难解决对象之间相互循环引用的问题,因此未被主流的Java虚拟机采用。

对象循环引用:A引用B,B引用A,当A和B都是孤立的时候,这两个对象的计数器都是1,无法进行回收,但实际上已经没有作用了。

根搜索算法

根搜索算法(Root Tracing)又叫做可达性分析算法(Reachability Analysis),通过一系列的称为“GC Roots”的点作为起始进行向下搜索,当某个对象到GC Roots没有任何引用链(Reference Chain)相连,或者说从GC Roots到这个对象不可达时,则证明此对象是不可用的。

image-20210730101932370

在Java技术体系中,GC Roots主要包括:

  • 在虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等。
  • 在方法区中类静态属性引用的对象,譬如Java类的引用类型静态变量
  • 在方法区中常量引用的对象,譬如字符串常量池里的引用。
  • 在本地方法栈中JNI(即通常所说的Native方法)引用的对象
  • Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象(比如NullPointException),还有系统类加载器。
  • 所有被同步锁(synchronized关键字)持有的对象
  • 反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等

除了这些固定的GC Roots集合以外,根据用户所选用的拦击收集器以及当前回收的内存区域不同,还可以又其他对象“临时性”地加入,共同构成GC Roots集合。

对象的回收

方法区的垃圾收集器主要回收两部分的内容:废弃的常量和不再使用的类型。

判断常量需要被垃圾回收只要没有其他地方引用即可,但判断一个类型是否要被垃圾回收,需要同时满足下面三个条件:

  • 该类所有的实例都已经被GC,也就是JVM中不存在改Class的任何实例;
  • 加载该类的ClassLoader已经被GC;
  • 该类对应的java.lang.Class对象没有在任何地方被引用,如不能在任何地方通过反射访问该类的方法。

对象的引用

在JDK1.2之前,一个对象只有“被引用”或者“未被引用”两种状态,但这种描述方式不能满足所有的场景,譬如我们希望描述一类对象:当内存空间还足够时,能保留在内存之中,如果内存空间在垃圾收集器后仍然非常警长,那就可以抛弃这些对象,这个时候就需要对引用的概念进行扩充。

引用类型定义特点
强引用(Strong Reference)通过new关键赋值的引用只要强引用关系还存在,垃圾收集器永远不会回收掉引用的对象
软引用(Soft Reference)还有用,但非必须的对象内存不够时一定会被GC,长期不用也会被GC
弱引用(Weak Reference)非必须对象被弱引用关联的对象只能生存到下一次垃圾收集发生为止。无论当前内存是否足够,都会回收
虚引用(Phantom Reference)“幽灵引用”或者“幻影引用”对象被垃圾收集器回收时收到一个系统通知

垃圾回收算法

分代收集理论

从如何判定对象消亡的角度触发,垃圾回收算法可以划分为“引用计数式垃圾收集器”和“追踪式垃圾收集器”两大类,这两类也常称位“直接垃圾收集器”和“间接垃圾收集器”,不过本文介绍的所有算法均属于追踪式垃圾收集的范畴。

分代收集理论指的是根据不同的存活周期将内存划分为几块,Java一般将堆分作新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法,譬如新生代每次GC都有大批对象死去,只有少量存活,那么就可以选用复制算法只需要付出少量存活对象的复制成本就可以完成收集,而老年代存放了经过一次或者多次GC还存活的对象,一般采用标记-清除和标记-整理算法完成收集。

当代商业虚拟机的垃圾收集器,大多数都遵循了“分代收集”的理论进行设计,分代收集理论实际上建立在三个分代假说之上:

  • 弱分代假说:绝大数对象都是朝生夕灭的
  • 强分代假说:熬过越多次数垃圾收集过程的对象就越难以消亡
  • 跨代引用假说:跨代引用相对于同代引用来说仅占极少数

根据跨代引用假说,在新生代上建立一个全局的数据结构(该结构被称为“记忆集”,Remembered Set),这个结构把老年代划分为若干小块,标识出老年代的哪一块内存会存在跨代引用。此后当发生Minor GC时,只有包含了跨代引用的小块内存里的对象才会被加入到GC Roots进行扫描。

分代收集理论有一些垃圾收集行为:

名称作用
部分收集(Partial GC)指目标不是完整收集整个Java堆的垃圾收集,又分为新生代、老年代、混合收集
新生代收集(Minor GC/Young GC)指目标只是新生代的垃圾收集
老年代收集(Major GC/Old GC)指目标只是老年代的垃圾收集,目前只有CMS收集器会有单独收集老年代的行为
混合收集(Mixed GC)指目标收集整个新生代以及部分老年代的垃圾收集。目前只有G1收集器会有这种行为
整堆收集(Full GC)收集整个Java堆和方法去区垃圾收集

标记-清除算法

标记-清除算法(Mark-Sweep),分为“标记”和“清除”两个阶段要回收的对象,然后回收所有需要回收的对象,执行过程如图所示:

image

缺点:

  • 效率问题,标记和清理两个过程效率都不高,需要扫描所有对象,堆越大,GC越慢
  • 空间问题,标记清理之后会产生大量不连续的内存碎片,空间碎片太多可能导致后续使用中无法找到足够的连续内存而提前触发另一次的垃圾搜集动作

标记-复制算法

标记-复制算法(Copying),将可用内存划分为两块,每次只使用其中的一块,当半区内存用完之后,仅将还存活的对象复制到另一块上面,然后就把原来整块内存空间一次性清理掉。

image

优点:

  • 这样使得每次内存回收都是对整个搬去的回收,内存分配时也就不用考虑内存碎片等复杂情况,只要移动过堆顶指针,按顺序分配内存就可以了,实现简单,运行高效。

缺点:

  • 这种算法的代价是将内存缩小为原来的一半,代价高昂

现在的虚拟机中都是采用此算法来回收新生代,不过新生代中的对象有98%熬不过第一轮收集。因此并不需要按照1:1的比例来划分新生代的内存空间。

通常采用复制算法的垃圾收集其,会将内存分为一块较大的eden空间和两块较少的survivor空间,每次使用eden和其中一块survivor,当回收的时候会将eden和survivor还存活的对象一次性拷贝到另一块survivor空间上,然后清理掉eden和用过的survivor,用过的survivor称为From survivor,没有用过的survivor称为To survivor。

HotSpot虚拟机默认的eden和survivor的大小比例是8:1,也就是每次只有10%的内存是“浪费”的。

如果不想浪费50%的空间,就需要有额外的空间进行分配担保用于应付半区内存中所有对象都100%存活的极端情况,所以在老年代一般不能直接选用这种算法。

总体来说,复制算法的特点如下:

  • 只需要扫描存活的对象,效率更高
  • 不会产生碎片
  • 需要浪费额外的内存作为复制区
  • 复制算法非常适合生命周期比较短的对象,因为每次GC总能回收大部分的对象,复制的开销比较小
  • 复制收集算法在对象存活率高的时候,效率有所下降

标记-整理算法

标记-整理算法(Mark-Compact)标记过程仍然一样,但后续步骤不是进行直接清理,而是令所有存活的对象一端移动,然后直接清理掉这端边界以外的内存。

image

特点:

  • 没有内存碎片,但是需要耗费更多的时间进行整理

经典垃圾收集器

HotSpot虚拟机的垃圾收集器:

image-20210730121739924

在垃圾收集器中,并发和并行的含义:

  • 并行(Parallel):指多个收集器的线程同时工作,但是用户线程处于等待状态

  • 并发(Concurrent):指收集器在工作的同时,可以允许用户线程工作。

并发不代表解决了GC停顿的问题,在关键的步骤还是要停顿。比如在收集器标记垃圾的时候要停顿,但在清除垃圾的时候,用户线程可以和GC线程并发执行。

Serial收集器

单线程收集器,收集时会暂停所有工作线程(Stop The World,简称STW),使用复制算法,虚拟机运行在客户端模式时的默认新生代收集器,运行示意图如下:

image-20210730115205713

特点:

  • 最早的收集器,单线程进行GC
  • New和Old Generation都可以使用
  • 在新生代,采用复制算法;在老年代,采用Mark-Compact算法
  • 因为是单线程GC,没有多线程切换的额外开销,简单使用。
  • Hotspot 客户端模式默认的垃圾收集器

ParNew收集器

ParNew收集器就是Serial的多线程版本,除了使用多个收集线程外,其余行为包括算法,STW,对象分配规则,回收策略等都与Serial收集器一摸一样。

image-20210730115727881

对应这种收集器是虚拟机运行在服务端模式的默认新生代收集器,在单CPU的环境中,ParNew收集器并不会比Serila收集器有更好的效果。

特点:

  • 使用复制算法
  • 只有在多CPU的环境下,效率才会比Serial收集器高
  • 可以通过-XX:ParallerlGCThreads来控制GC线程数的多少,需要结合具体的CPU的个数

Parallel Scavenge收集器

也是一个多线程收集器,也是使用复制算法,但它的对象分配规则与回收策略都ParNew收集器有所不同,它是以吞吐量最大化(即GC时间占总运行时间最小)为目标的收集器实现,它允许较长时间的STW换取总吞吐量最大化。

吞吐量=运行用户代码时间运行用户代码时间+运行垃圾收集时间 \text{吞吐量}=\frac{\text{运行用户代码时间}}{\text{运行用户代码时间} + \text{运行垃圾收集时间}}

由于吞吐量关系密切,Parallel Scavenge收集器也经常被称为“吞吐量优先收集器”。

Serial Old收集器

Serial Old收集器是单线程收集器,使用标记-整理算法,是老年代的收集器。

image-20210730121027807

Parallel Old收集器

Parallel Old收集器时Parallel Scavenge的老年代版本,使用多线程和标记-整理算法,从JDK1.6开始提供,在此之前,新生代使用了Parallel Scavenge收集器的话,老年代除了Serial Old别无选择,因为Parallel Scavenge无法与CMS收集器配合工作。

image-20210730182344292

特点:

  • Parallel Scavenge + Parallel Old = 高吞吐量,但GC停顿可能不理想

JDK1.8使用的默认垃圾收集器就是Parallel Scavenge + Parallel Old

CMS收集器

CMS简介

CMS收集器(Concurrent Mark Sweep)是一种以最短停顿时间为目标的收集器,使用CMS并不能达到GC效率最高(总体GC时间最小),但它能尽可能降低GC时服务的停顿时间,CMS收集器使用的是标记-清除算法。

image-20210730182623573

CMS(Concurrent Mark Sweep)收集器,以获取最短回收停顿时间(STW)为目标,多数应用于互联网站或者B/S系统的服务器端上。

CMS是基于“标记-清除“算法实现的,整个过程分为4个步骤:

  1. 初始标记(CMS initial mark)
  2. 并发标记(CMS concurrent mark)
  3. 重新标记(CMS remark)
  4. 并发清除(CMS concurrent sweep)

其中,初始标记和重新标记步骤仍然需要STW,初始标记只是标记一下GC Roots能直接关联到的对象,速度很快;

并发标记阶段就是进行GC Roots Tracing的过程;

重新标记阶段则是为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的挺短时间一般会比初始标记阶段稍长一些,但远比并发标记的时间短。

在整个过程中耗时最长的并发标记和并发清理的过程收集器线程都可以与用户线程一起工作,因此,从总体上看,CMS收集器的内存回收过程是与用户线程一起并发执行的。

CMS垃圾收集器的优点:

  • 并发收集、低停顿

缺点:

  • CMS收集器对CPU资源非常敏感
  • CMS收集器无法处理浮动垃圾(Floating Garbage),可能出现”Concurrent Mode Failure“失败而导致另一次Full GC的产生 。如果在应用中老年代增长不是太快,可以适当调高参数-XX:CMSInitiatingOccupanyFraction的值来提高触发百分比,以便降低内存回收次数从而获取更好的性能,要是CMS运行期间预留的内存无法满足程序需要时,虚拟机将启动后备预案:临时启动Serial Old收集器来重新进行老年代的来及收集,这样停顿时间就很长了。所以说参数-XX:CMSInitiatingOccupancyFraction设置得太高了很容易导致大量”Concurrent Mode Failure“失败。性能反而降低。
  • 收集结束时会有大量空间碎片产生,空间碎片过多时,将会给大对象分配带来很大麻烦,往往出现老年代还有很大空间剩余,但是无法找到足够大的连续空间来分配当前对象,不得不提前进行一个Full GC。CMS收集器提供了一个-XX:+UseCMSCompactAtFullCollection开关参数(默认开启),用于在CMS收集器顶不住要进行Full GC时开启内存碎片的合并整理过程,内存整理的过程是无法并发的,空间碎片问题没有了,但停顿时间不得不变长。

总的来看,CMS垃圾收集器的特点:

  • 只针对于追求最短停顿时间,非常适合web应用
  • 只针对老年区,一般结合ParNew使用
  • Concurrent,GC线程和用户线程并发工作(尽量并发)
  • 使用标记清除算法
  • 只有在多CPU环境下才有意义
  • 使用-XX:+UseConcMarkSweepGC打开
  • CMS以牺牲CPU资源的代价来减少用户线程的停顿。当CPU个数少于4的时候,有可能对吞吐量影响非常大
  • CMS在并发清理的过程中 ,用户线程还在跑,这时候需要预留一部分空间给用户线程
  • CMS用Mark-Sweep,会带来碎片问题。碎片过多的时候会容易频繁触发Full GC

CMS详细步骤

  1. 初始标记(CMS-initial-mark) ,会导致swt;
  2. 并发标记(CMS-concurrent-mark),与用户线程同时运行;
  3. 预清理(CMS-concurrent-preclean),与用户线程同时运行;
  4. 可被终止的预清理(CMS-concurrent-abortable-preclean) 与用户线程同时运行;
  5. 重新标记(CMS-final remark) ,会导致swt;
  6. 并发清除(CMS-concurrent-sweep),与用户线程同时运行;
  7. 并发重置状态等待下次CMS的触发(CMS-concurrent-reset),与用户线程同时运行;

初始标记

标记老年代中所有的GC Roots对象,如下图节点1;

标记年轻代中活着的对象引用到的老年代的对象(指的是年轻带中还存活的引用类型对象,引用指向老年代中的对象)如下图节点2、3;

并发标记

从“初始标记”阶段标记的对象开始找出所有存活的对象;

为是并发运行的,在运行期间会发生新生代的对象晋升到老年代、或者是直接在老年代分配对象、或者更新老年代对象的引用关系等等,对于这些对象,都是需要进行重新标记的,否则有些对象就会被遗漏,发生漏标的情况。为了提高重新标记的效率,该阶段会把上述对象所在的Card标识为Dirty,后续只需扫描这些Dirty Card的对象,避免扫描整个老年代;

并发标记阶段只负责将引用发生改变的Card标记为Dirty状态,不负责处理;

如下图所示,也就是节点1、2、3,最终找到了节点4和5。并发标记的特点是和应用程序线程同时运行。并不是老年代的所有存活对象都会被标记,因为标记的同时应用程序会改变一些对象的引用等。

预清理阶段

前一个阶段已经说明,不能标记出老年代全部的存活对象,是因为标记的同时应用程序会改变一些对象引用,这个阶段就是用来处理前一个阶段因为引用关系改变导致没有标记到的存活对象的,它会扫描所有标记为Direty的Card

如下图所示,在并发清理阶段,节点3的引用指向了6;则会把节点3的card标记为Dirty;

最后将6标记为存活,如下图所示:

可终止的预清理

这个阶段尝试着去承担下一个阶段Final Remark(STW)阶段足够多的工作。这个阶段持续的时间依赖好多的因素,由于这个阶段是重复的做相同的事情直到发生aboart的条件(比如:重复的次数、多少量的工作、持续的时间等等)之一才会停止。

此阶段最大持续时间为5秒,之所以可以持续5秒,另外一个原因也是为了期待这5秒内能够发生一次ygc,清理年轻代的引用,是的下个阶段的重新标记阶段,扫描年轻带指向老年代的引用的时间减少;

重新标记

这个阶段会导致第二次stop the word,该阶段的任务是完成标记整个年老代的所有的存活对象。

这个阶段,重新标记的内存范围是整个堆,包含_young_gen和_old_gen。为什么要扫描新生代呢,因为对于老年代中的对象,如果被新生代中的对象引用,那么就会被视为存活对象,即使新生代的对象已经不可达了,也会使用这些不可达的对象当做cms的“gc root”,来扫描老年代; 因此对于老年代来说,引用了老年代中对象的新生代的对象,也会被老年代视作“GC ROOTS”:当此阶段耗时较长的时候,可以加入参数-XX:+CMSScavengeBeforeRemark,在重新标记之前,先执行一次ygc,回收掉年轻带的对象无用的对象,并将对象放入幸存带或晋升到老年代,这样再进行年轻带扫描时,只需要扫描幸存区的对象即可,一般幸存带非常小,这大大减少了扫描时间

由于之前的预处理阶段是与用户线程并发执行的,这时候可能年轻带的对象对老年代的引用已经发生了很多改变,这个时候,remark阶段要花很多时间处理这些改变,会导致很长stop the word,所以通常CMS尽量运行Final Remark阶段在年轻代是足够干净的时候。

另外,还可以开启并行收集:-XX:+CMSParallelRemarkEnabled

并发清理

通过以上5个阶段的标记,老年代所有存活的对象已经被标记并且现在要通过Garbage Collector采用清扫的方式回收那些不能用的对象了。

这个阶段主要是清除那些没有标记的对象并且回收空间;

由于CMS并发清理阶段用户线程还在运行着,伴随程序运行自然就还会有新的垃圾不断产生,这一部分垃圾出现在标记过程之后,CMS无法在当次收集中处理掉它们,只好留待下一次GC时再清理掉。这一部分垃圾就称为“浮动垃圾”。

并发重置

这个阶段并发执行,重新设置CMS算法内部的数据结构,准备下一个CMS生命周期的使用。

CMS将大量工作分散到并发处理阶段来减少STW时间。

吞吐量关注的是,在一个指定的时间内,最大化一个应用的工作量。

衡量一个系统吞吐量的好坏:

  • 在一个小时内同一个事务(或者任务、请求)完成的次数(tps)
  • 数据库一小时可以完成多少次查询

对于关注吞吐量的系统,卡顿是可以接收的,因为这个系统关注长时间的大量任务的执行能力,单次快速的响应并不值得考虑。

响应能力指的是一个程序或者系统对请求是否能够及时响应,比如:

  • 一个桌面UI能多快地影响一个时间
  • 一个网站能够多块返回一个页面请求
  • 数据库能够多快返回查询的数据

对于这类对响应能力敏感的场景,长时间的停顿是无法接收的。

Garbage First收集器

G1简介

G1收集器是一个面向服务端的垃圾收集器,适用于多核处理器、大内存容量的服务端系统。满足短时间GC停顿的同时达到一个较高的吞吐量。G1最大的特点就是高效的执行回收,优先去执行那些大量对象可回收的区域。

G1收集器的设计目标

  • 与引用线程同时工作,几乎不需要STW
  • 整理剩余空间,不产生内存碎片(CMS只能在发生Full GC时,用STW整理内存碎片)
  • GC停顿更加可控
  • 不牺牲系统的吞吐量
  • GC不要求额外的内存空间(CMS需要预留空间存储浮动垃圾)

重要概念

Region

传统的垃圾收集器将连续的内存空间划分为新生代、老年代和永久代(JDK8去除了永久代,引入了元空间Metaspace),这种划分的特点是各代的存储地址是连续的。如下图所示:

image-20210804101940161

而G1的各代存储地址是不连续的,每一代都使用了n个不连续的大小相同的Region,每个Region占有一块连续的虚拟内存地址。如下图所示:

image-20210804102450160

可以看到,每个region都有一个分代的角色:eden、survivor、old,对每个角色的数量并没有强制的限定,也就是说对每种分代内存的大小,可以动态变化,每个Region默认按照512Kb划分成多个Card。

每个分区都可能是年轻代也可能是老年代,但是在同一时刻只能属于某个代。年轻代、幸存区、老年代这些概念还存在,成为逻辑上的概念,这样方便复用之前分代框架的逻辑。

此外,还有一些Region标明了H,它代表Humongous,这表示这些Region存储的是巨大对象(humongous object),即大小大于或等于Region一半的对象。巨型对象默认直接会被分配在老年代,如果一个H区装不下一个巨型对象,那么G1会寻找连续的H分区来存储。为了能找到连续的H区,有时候不得不启动Full GC。

H-obj有如下几个特征:

  • H-obj直接分配到了old gen,放置了反复拷贝移动
  • H-obj在global concurrent marking阶段的cleanup和full GC阶段回收

为了减少连续H-objs分配对GC的影响,需要把大对象变为普通的对象,建议增大Region size。

Humongous(巨大的):如果某一个对象的大小超过了region区域大小的50%,就被放置到 区域当中,humongous是eden、survivor、old generation其中一个,

在物理上不需要连续,则带来了额外的好处——有的分区内垃圾对象也别多,有的分区内垃圾对象很少,G1会优先回收垃圾对象特别多的分区,这样可以花费较少的时间来回收这些分区的垃圾,这也就是G1名字的由来,即首先收集垃圾最多的分区。

三色标记算法

三色标记算法是描述追踪式回收器的一种有效的方法,利用它可以推演回收器的正确性。

我们将对象分成三种类型:

  1. 黑色:根对象,或者该对象与它的子对象都被扫描过(对象被标记了,且它的所有的field也被标记完了)
  2. 灰色:对象本身被扫描,但还没扫描完该对象中的子对象(它的field还没有被标记或标记完)
  3. 白色:未被扫描对象,扫描完成所有对象之后,最终为白色的为不可达对象,即垃圾对象(对象没有被标记到)

遍历了所有可达的对象后,所有可达的对象都变成了黑色,不可达的对象即为白色,需要被清理。

如果在标记过程中,应用程序也在运行,那么对象的指针就有可能改变。这样的话,我们就会遇到一个问题:对象丢失问题

三色标记带来了两个问题,一个是新创建的对象的问题,另一个就是对象丢失的问题。

SATB算法

SATB(Snapshot-At-The-Begining),由字面意思理解就是GC开始时活着的对象的一个快照,是G1在并发标记阶段使用的增量式的标记算法。

SATB的步骤:

  1. 在开始标记的时候生成一个快照图,标记存活对象
  2. 在并发标记的时候所有被改变的对象入队(在write barrier里所有旧的引用所指向的对象都变成非白的)
  3. 可能存在浮动垃圾,将在下次被收集

解决三色标记算法的两个问题的方式:

1、每个Region记录着两个top-at-mark-start(TAMS)指针,分别为prevTAMS和nextTAMS。在TAMS以上的对象就是新分配的,因而被视为隐式marked。通常这种方式我们就找到了在GC过程中新分配的对象,并把这些对象认为是活的对象。

2、G1通过Write Barrier对引用字段进行赋值做了额外处理。通过Write Barrier就可以了解到那些引用对象发生了什么样的变化。

  • 对black新引用了一个white对象,然后又从gray对象中删除了对改white对象的引用,这样会造成了该white对象漏标记
  • 对black新引用了一个white对象,然后从gray对象删了一个引用该white对象的white对象,这样也会造成了该white对象漏标记
  • 对black新引用了一个刚new出来的white对象,没有其他gray对象引用该white对象,这样也会造成了该white对象漏标记

SATB在marking阶段中,对于从gray对象移除的目标引用对象标记为gray,对于black引用的新产生的对象标记为black,由于是在开始时进行的snapshot,因而可能存在浮动垃圾。

误标没什么关系,顶多造成浮动垃圾,在下次gc还是可以回收的,但是漏标的后果是致命的,把本应该存活的对象给回收了,从而影响程序的正确性。

漏标的情况只会发生在白色对象中,且满足以下任意一个条件:

  1. 并发标记时,应用线程给一个黑色对象的引用类型字段赋值了该白色对象
  2. 并发标记时,应用线程删除所有灰色对象到该白色对象的引用(可能存在黑色对象引用的情况)

解决方案:

  1. 利用post-write barrier记录所有新增的引用关系,然后根据这些引用关系为根重新扫描一遍
  2. 利用post-write barrier将所有即将被删除的引用关系的旧引用记录下来,最后以这些旧引用为根重新扫描一遍
RSet

已记忆集合(Remembered Set),是辅助GC过程的一种结构,典型的空间换时间工具,和Card Table有些类似。Rset记录了其他Region中的对象引用本Region中对象的关系,属于points-into结构(谁引用了我的对象),而Card Table则是一种points-out(我引用谁的对象)的结构,RSet的价值在于使得垃圾收集器不需要扫描整个堆到谁引用了当前分区中的对象,只需要扫描RSet即可。

RSet其实是一个hash table,key是别的Region的真实地址,value是一个集合,里面的元素是Card Table和index。举例来说,如果RegionA的RSet里面有一项的key是RegionB,value里有index为1234的card,它的意思就是region B的一个card里有引用指向region A。所以对RegionA来说,该RSet记录的是points-into的关系;而card table仍然记录了points-out的关系。

下图表示了RSet、Card和Region的关系:

image-20210804110522846
CSet

收集集合(Collection Set):GC要收集的Region的集合。在CSet中存活的数据会在GC过程中被移动到另一个可用分区,CSet中的分区可以来自eden空间、survivor空间或者老年代。

  • CSet里面总是具有所有年轻代里面的Region
  • CSet = 年轻代所有的Region + 全局并发阶段标记出来的收益高的老年代Region
停顿预测模型

G1是一个响应时间优先的垃圾收集器,它与CMS最大的不同是,用户可以设定整个GC过程的期望停顿时间,默认是200ms,不过它不是硬性条件,只是期望值,G1会根据停顿预测模型(Pause Prediction Model)统计计算出来历史数据来预测本次收集需要选择的Region数量,从而尽量满足用户设定的目标停顿时间,停顿预测模型是以衰减标准偏差理论基础实现的。

GC过程

回收算法

G1从多个region中复制存活的对象,然后集中放入一个region中,同时整理、清除内存(Copying收集算法)

特点:G1使用的copying算法不会造成内存碎片,并且只是针对特定的region进行整理,因此不会导致gc停顿的时间过长。

Mixed GC由一些参数控制,另外也控制着哪些老年代Region会被选入CSet(收集集合)

参数含义
G1HeapWastePercent在global concurrent marking结束之后,我们可以知道old gen regions中有多少空间要被回收,在每次YGC之后和再次发生Mixed GC之前,会检查垃圾占比是否达到此参数,只有达到了,下次才会发生Mixed GC。
CLiveThresholdPercentold genration region中存活对象的占比,只有在此参数之下,才会被选入CSet。
G1MixedGCCountTarget一次gloal concurrent marking之后,最多执行Mixed GC的次数
G1OldCSetRegionThresholdPercent一次Mixed GC中能被选入CSet的最多old generation region数量。

每次GC时,所有新生代都会被扫描,所以无需记录新生代之间的记录引用,只需要记录老年代到新生代之间的引用即可。

如果引用的对象很多,赋值器需要对每个引用做处理,赋值器开销会很大,为了解决赋值器开销这问题,在G1中又引入了另外一个概念,卡表(Card Table)。一个Card Table将一个分区在逻辑上划分为固定大小的连续区域,每个区域称之为卡。卡通常较小,介于128到512字节之间。Card Table通常为字节数组,由Card的索引(即数组小标来标识每个分区的空间地址)

收集过程

从图中不难看出,G1的收大致可以分为四个阶段:

  • Young GC(不同于CMS)
  • 并发标记阶段
  • Mixed GC
  • full GC

其中最主要的两个阶段:

  • Young GC:选定所有年轻代里的Region。通过控制年轻代的region个数,即年轻代内存大小,来控制young GC的时间开销。
  • Mixed GC:选定所有年轻代里的Region,外加根据global concurrent marking统计得出收集收益高的若干老年代Region。在用户指定的开销目标范围内尽可能选择收益高的老年代Region

Young GC和Mixed GC都需要STW。需要注意的是,Mixed GC不是Full GC,它只能回收部分老年代的Region,如果Mixed GC实在无法跟上程序分配内存的速度,导致老年代填满无法继续进行Mixed GC,就会使用serial old GC(Full GC)来收集整个GC heap。所以本质上,G1是不提供Full GC的。

Young GC

G1 YGC在Eden充满时触发,在回收之后所有之前属于Eden的区块全部变成空白,即不属于任何一个分区(Eden、Survivor、Old)。

Young GC的处理过程共分为以下五个阶段

  • 根扫描:静态和本地对象被扫描
  • 更新RS:处理dirty card队列更新RS
  • 处理RS:检测从年轻代指向老年代的对象
  • 对象拷贝:拷贝存活的对象到survivor/old区域
  • 处理引用队列:软引用、弱引用、虚引用处理
Mixed GC
  • 全局并发标记(global concurrent marking)
  • 拷贝存活对象(evacuation)
global concurrent marking

global concurrent marking的执行过程类似CMS,但是不同的是,在G1 GC中,它主要是为Mixed GC提供标记服务的,并不是一次GC过程的一个必须环节

global concurrent marking共分为如下四个步骤:

image-20210730190157084

  • 初始标记(initial mark,STW):它标记了从GC Root开始直接可达的对象
  • 并发标记(Concuurent Marking):这个阶段从GC Root开始对heap中的对象进行标记,标记线程与应用程序线程并发执行,并且收集各个Region的存活对象信息
  • 重新标记:(Remark,STW):标记那些在并发标记阶段发生变化的对象,将被回收
  • 清理(Cleanup):清除空Region(没有存活对象的),加入到free list。

第一阶段initial mark是共用了Young GC的暂停,这是因为他们可以复用root scan操作,所以可以说global concurrent marking是伴随Young GC而发生的,第四阶段Cleanup只是回收了没有存活对象的Region,所以它并不需要STW。

G1特点

G1并非一个实时的收集器,与PS一样,对gc停顿时间的设置并不是绝对生效,只是G1有较高的几率保证不超过设定的gc停顿时间。与之前的gc收集器对比,G1会根据用户设定的gc停顿时间,只能评估哪几个region需要被回收可以满足用户的设定。

依然是在新生代满了的时候,对整个新生代进行回收——整个新生代中的对象,要么被回收,要么晋升,至于新生代也采取分区机制的原因,则是因为这样跟老年代的策略统一,方便调整代的大小。

G1还是一种带压缩的收集器,在回收老年代的分区时,是将存活的对象从一个分区拷贝到另一个可用分区,这个拷贝的过程就实现了局部的压缩。

停顿时间不是越短越好,设置的时间越短意味着每次收集的CSet就越小,导致垃圾逐步积累变多,最终不得不退化程Serial GC,停顿时间设置的过长那么会导致每次都会产生长时间的停顿,影响了程序对外的响应时间。

当Mixed GC赶不上对象产生的速度的时候就退化成Full GC,这一点是需要重点调优的地方。

G1会在Young GC和Mixed GC之间不断地切换,同时定期地做全局并发标记,在实在赶不上对象创建速度的情况下使用Full GC(Serial GC)。

G1收集器在运行的时候会自动调整新生代和老年代的大小。通过改变代的大小来调整对象晋升的速度以及晋升年龄,从而达到我们为收集器设置的暂停时间的目标值。

设置了新生代大小相当于放弃了G1为我们做的自动调优,我们只需要设置整个堆内存大小即可。

Evacuation Failure:类似CMS晋升失败,堆空间的垃圾太多导致无法完成Region之间的拷贝,于是不得不退化成Full GC来做一次全局范围内的垃圾收集。

参数设置

$ -verbose:gc -Xms10m -Xmx10m -XX:+UseG1GC   // 使用GC垃圾收集器 
$ -XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:MaxGCPauseMillis=200m  // 最大停顿时间
$ -XX:MaxGCPauseMillis=x    // 设置启动应用程序暂停时间,一般情况下是100ms-200ms 

垃圾收集器参数总结

$ -verbose:gc   // 表示输出详细的垃圾回收的日志
$ -Xms20M       // 堆内存初始大小
$ -Xmx20M       // 堆内存最大值
$ -Xmn10M       // 新生代大小是10m
$ -XX:+PrintGCDetails     // 打印GC详细信息 
$ -XX:SurvivorRatio=8    // 8:1
$ -XX:PretenureSizeThreshold=4194304   // 新生代创建对象的阈值大小,单位为字节,只有串行收集器才会生效
$ -XX:+UseSerialGC -XX:MaxTenuringThreshold  // 在可以自动调节对象晋升(Promote)到老年代阈值的GC中,设置该阈值的最大值
$ -XX:PrintTenuringDistribution // 每次新生代GC时,打印出幸存区中对象的年龄分布

查看java虚拟机启动参数:

java -XX:+PrintCommandLineFlags -version 

执行结果:

image-20210730185108404

其中含义:

  • PretenureSizeThreshold:设置对象超过多大的时候直接在老年代进行分配

  • MaxTenuringThreshold:CMS中默认值为6,G1中默认值为15(该数值是由4个bit来表示的,所以最大值1111,即15)

HotSpot的算法实现细节

经历了多次GC后,存活的对象会在From Survivor与To Survivor之间来回存放,而这里面的一个前提则是这两个空间由足够的大小来存放这些数据,在GC算法中,会计算每个对象年龄的大小,如果达到某个年龄后发现总大小已经大于了Survivor空间的50%,那么这时候就需要调整阈值,不能再继续等到默认的15次GC后才开始晋升,因为这样会导致Survivor空间不足,所以需要调整阈值,让这些存活对象尽快完成晋升。

根节点枚举

当执行系统停顿下来后,并不需要一个不露地检查完所有执行上下文和全局的引用位置,虚拟机应该当是有办法直接得知哪些地方存放着对象引用。再HotSpot的实现中,是使用一组称为OopMap的数据结构来达到这个目的的。

安全点

在OopMap的协助下,HotSpot可以快速且准确地完成GC Roots枚举,但一个很现实的问题随之而来:可能导致引用关系变化,或者说OopMap内容变化的指令非常多,如果为每一条指令都生成对应的OopMap,那将会需要大量的额外空间,这样GC的空间成本将会变得更高;

实际上,HotSpot并没有为每条指令都生成OopMap,而只是在“特定的位置”记录了这些信息,这些位置称为安全点,即程序执行时并非在所有地方都能停顿下来开始GC,只有在达到安全点时才能暂停;

安全点的选定既不能太少以至于让GC等待时间太长,也不能过于频繁以至于过分增大运行时的负载,所以,安全点的选定基本上是以“是否具有让程序长时间执行的特征”为标准进行选定的——因为每条指令执行的时间非常短暂,程序不太可能因为指令流长度太长这个原因而过长时间运行,“长时间执行”的最明显的特征就是指令序列复用,例如方法调用、循环跳转、异常跳转等,所以具有这些功能的指令才会产生安全点;

对于安全点,另一个需要考虑的问题就是如何让在GC发生时让所有线程(这里不包括执行JNI调用的线程)都“跑”到最近的安全点上再停顿下来:抢占式中断(Preemptive Suspension)和主动式中断(Voluntary Suspension)。

抢占式中断:它不需要线程的执行代码主动去配合,在GC发生时,首先把所有线程全部中断,如果有线程中断的地方不在安全点上,就恢复线程,让它执行到安全点上,再进行中断。

主动式中断:当GC需要中断线程的时候,不直接对线程操作,仅仅简单的设置一个标志,各个线程执行时主动去轮询这个标志,发现中断标志为真时就自己中断挂起。轮询标志的地方和安全点是重合的,另外再加上创建对象需要分配内存的地方。

现在几乎没有虚拟机采用抢占式中断来暂停线程从而相应GC事件。

安全区域

在使用安全点似乎已经完美解决了如何进入GC的问题,但实际情况却并不一定,安全点机制保证了程序执行时,在不太长的时间内就会遇到可进入GC的安全点,但如果程序在“不执行”的时候呢?所谓程序不执行就是没有分配CPU时间,典型的例子就是处于Sleep状态或者Blocked状态,这时候线程无法响应JVM的中断请求,JVM也显然不太可能等待线程重新弄分配CPU时间,对于这种情况,就需要安全区域(Safe Region)来解决了。

在线程执行到安全区域中的代码时,首先标识自己已经进入了安全区域,那样,当在这段时间里JVM要发去GC时,就不用管标识自己为安全区域状态的线程了。在线程要离开安全区域时,它要检查系统是否已经完成了根节点枚举(或者是整个GC过程),如果完成了,那线程就继续执行,否则它就必须等待知道收到可以安全离开安全区域的信号为止。

安全区域是指能够确保某一段代码片段之中,引用关系不会发生变化,因此,在这个区域中任意地方开始垃圾收集都是安全的。我们也可以把安全区域看作被扩展拉伸了的安全点。

当用户线程执行到安全区域里面的代码时,首先会标识自己已经进入了安全区域,那样当这段时间里虚拟机要发起垃圾收集时就不必去管这些已声明自己在安全区域内的线程了。当线程要离开安全区域时,它要检查虚拟机是否已经完成了根节点枚举(或者垃圾收集过程中其他需要暂停用户线程的阶段),如果完成了,那线程就当做没事发生过,继续执行;否则它就必须一直等待,直到收到可以离开安全区域的信号为止。

记忆集与卡表

并发的可达性分析

空间分配担保

在发生Minor GC之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那么Minor GC可以确保是安全的。当大量对象在Minor GC后仍然存活,就需要老年代进行空间分配担保,把Survivor无法容纳的对象直接进入老年代。如果老年代判断到剩余空间不足(根据以往每一次回收晋升到老年代对象容量的平均值作为经验值),则进行一次Full GC。

内存屏障

来源

每个CPU都会有自己的缓存(有的甚至L1、L2、L3),缓存的目的就是为了提高性能,避免每次都要想内存存取。但这样做的弊端也很明显:不能实时的和内存发生信息交换,分在不同CPU执行的不同线程对同一个变量的缓存之不同。

使用volatile关键字修饰变量可以解决上述问题,那么volatile是如何做到这一点的呢?那就是内存屏障,内存屏障是硬件层的概念,不同的硬件平台实现内存屏障的手段并不是一样的,java通过屏蔽这些差异,统一由jvm来生成内存屏障指令。

类型

硬件层的内存屏障分为两种:Load Barrier和Store Barrier即读屏障和写屏障。

内存屏障主要由两个作用:

  • 阻止屏障两侧的指令重排序
  • 强制把缓冲区/告诉缓存中的脏数据等写回主内存,让缓存中相应的数据失效

对于Load Barrier来说,在指令前插入Load Barrier,可以让高速缓存中的数据失效,强制从新主内存加载数据;对于Store Barrier来说,在指令后插入Store Barrier,能让写入缓存中的最新数据更新写入主内存,让其他线程可见。

Java内存屏障

Java的内存屏障通常所谓的四种即LoadLoad,StoreStore,LoadStore,StoreLoad实际上也是上述两种的组合,完成一系列的屏障和数据同步功能。

  • LoadLoad屏障:对于这样的语句Load1;LoadLoad;Load2,在Load2以及后续读取操要读取的数据被访问之前,保证Load1要读取的数据被读取完毕
  • StoreStore屏障:对于这样的语句Store1;StoreStore;Store2,在Store2及后续写入操作执行前,保证Store1的写入操作对其它线程处理器可见
  • LoadStore屏障:对于这样的语句Load1;LoadStore;Store2,在Store2及后续写入操作被刷出前,保证Store1的写入操作对其它处理器可见
  • StoreLoad屏障:对于这样的语句Store1;StoreLoad;Load2,在Load2及后续所有读操作执行前,保证Store1的写入对所有处理器可见。它的开销是四种屏障中最大的。在大多数的处理器的实现中,这个屏障是个万能屏障,兼具其它三种内存屏障的功能

volatile语义中的内存屏障

volatile的内存屏障策略非常严格保守,非常悲观且毫无安全感的心态:

  • 在每个volatile写操作前插入StoreStore屏障,在写操作后插入StoreLoad屏障
  • 在每个volatile读操作前插入LoadLoad屏障,在读操作后插入LoadStore屏障

正是由于内存屏障的作用,避免了volatile变量个其它指令重排序、线程之间实现了通信,是的volatile表现除了锁的特性。

final语义中的内存屏障

  • 新建对象过程中,构造体中对final域的初始化写入和这个对象赋值给其他引用变量,这两个操作不能重排序;
  • 初次包含final域的对象引用和读取这个final域,这两个操作不能重排序(先赋值引用,再调用final的值);

总之,必须要保证一个对象的所有final域被写入完毕后才能引用和读取,这也是内存屏障起的作用。

性能监控、故障处理工具

Java内存泄漏的经典原因:

  1. 对象定义在错误的范围(Wrong Scope)
  2. 异常(Exception)处理不当
  3. 集合数据管理不当(1、当使用基于数组的数据结构的时候,使用合适的初始值,减少resize的过程 2、如果List只需要顺序访问,不需要随机访问的话,使用LinkedList代替ArrayList)

基础故障处理工具

-Xms5m -Xmx5m -xx:+HeapDumpOnOutOfMemoryError

元空间大小设置:

-XX:MaxMetaspaceSize=10m -XX:+TraceClassLoading

获取java进程:

$ ps -ef | grep java jps -mlvV jps -l

打印类加载器数据命令:

$ jmap -clstats PID  // cl表示classloader 

打印堆内存命令:

$ jmap -heap PID  

打印元空间的信息:

$ jstat -gc LVMID 

jcmd是从jdk1.7开始增加的命令,可以用来查看进程的JVM启动参数:

$ jcmd PID VM.flags 

当前运行java进程可以执行的操作列表:

$ jcmd PID help

查看具体命令的选项:

$ jcmd PID help JFR.dump 

查看JVM性能相关的参数:

$ jcmd PID PerfCounter.print 

查看JVM启动的时长:

$ jcmd PID VM.uptime 

查看系统中的类的统计信息:

$ jcmd PID GC.class_histogram 

查看当前线程的堆栈信息:

$ jcmd PID Thread.print 

导出Head dump 文件,导出的文件可以通过jvisualvm查看:

$ jcmd PID GC.head_dump file_name 

查看JVM的属性信息:

$ jcmd PID VM.system_properties 

查看JVM启动命令行的参数:

$ jcmd PID VM.command_line 

jclasslib查看工具

$ javap -verbose -p ...

除此之外,还有一些其他的工具:

  • jstack:可以查看或是导出Java应用程序中的堆栈信息
  • jmc:Java Mission Control
  • jfr:Java Flight Recorder 实时到统计数据
  • OQL:JVM 对象查询语言
  • jhat file_name

可视化故障处理工具

jvisualvm

jconsole

类文件结构

Class类文件的结构

《Java虚拟机规范》中要求在Class文件必须应用许多强制性的语法和结构化约束,但是图灵完备的字节码格式,保证了任意一门功能性语言都可以表示一个能被虚拟机所接收的有效的Class文件,具体如下图所示:

image-20210730193726096

在可计算性理论,如果一系列操作数据的规则(如指令集、编程语言、细胞自动机)可以用来模拟任何图灵机,那么它是图灵完备的。这意味着这个系统也可以识别其他数据处理规则集,图灵完备性被用作表达这种数据处理规则集的一种属性

使用javap -verbose命令分析一个字节码文件时,将会分析该字节码文件的魔数、版本号、常量池、类信息、类的构造方法,类中的方法信息、类变量与成员变量等信息。

<init>表示实例构造方法,<clint>表示静态构造方法。

整个Class文件可以使用如下结构描述:

image-20210802220940252

Class字节码中有两种数据类型:

  • 字节数据直接量:这是基本数据类型。共细分为u1、u2、u4、u8四种,分别代表连续的1个字节、2个字节、4个字节、8个字节组成的整体数据。
  • 表(数组):表是由多个基本数据或其他表,按照既定顺序组成的大的数据集合。表是有结构的,它的结构体现在:组成表的成分所在的位置和顺序都是已经严格定义好的。

魔数与Class文件的版本

所有的.class字节码文件前4个字节都是魔数,魔数值为固定值:oxCAFEBABE。

魔数之后的4个字节为版本信息,前两个字节表示minor version版本,后两个字节表表示major version。主版本号为52表示该文件的版本号为1.8.0。可以通过java -version命令来验证这一点。

常量池

常量池(constant pool):紧接着主版本号之后的就是常量池入口。一个Java类中定义的很多信息都是由常量池来维护和描述的。可以将常量池看作是class文件的资源仓库,比如Java类中定义的方法与变量信息,都是存储在常量池中。

常量池中主要存储两类常量:字面量与符号引用,字面量如文本字符串,Java中声明为final的常量值等,而符号引用主要包括下面几类变量:

  • 被模块导出或者开放的包
  • 类和接口的全限定名
  • 字段的名称和描述符
  • 方法的名称和描述符
  • 方法句柄和方法类型
  • 动态调用点和动态常量

Class文件中不会保存各个方法、字段最终在内存中的布局信息,这些字段、方法的符号引用不经过虚拟机在运行期转换的话无法得到真正的内存入口地址,也就无法被虚拟机直接使用。当虚拟机进行类加载的时候,将会从常量池获得对应的符号引用,再在类创建时或运行时解析、翻译到具体的内存地址之中。

常量池的总体结构:Java类所对应的常量池主要由常量池数量与常量池数组这两部分共同构成。常量池数量紧跟在主版本号后面,占据2个字节,常量池数组紧跟在常量池数量之后。常量池数组与一般的数组不同的是,常量池数组中不同的元素的类型、结构都是不同的,长度当然也就不同,但是每一种元素的第一个数据都是一个u1类型,该字节是个标志位,占据1个字节,JVM在解析常量池时,会根据这个u1类型来获取元素的具体类型。

值得注意的是,常量池数组中元素的个数 = 常量池数-1(其中0暂时不使用),目的是为了满足某些常量池索引值在特定情况下需要表达不引用任何一个常量池的含义;根本原因在于,索引为0也是一个常量(保留常量),只不过它不位于常量表中,这个常量就对应null值,所以,常量池的索引从1而非0开始。

访问标志

Access_Flag访问标志信息包括,该Class文件是类还是接口,是否被定位成public,是否是abstract,如果是类,是否被声明为final。

访问标志以及标志的含义如表:

image-20210801115314201

访问标志是0x0021的含义:0x0020和0x0001的并集,表示ACC_PUBLIC与ACC_SUPER的并集。

类索引、父类索引与接口索引集合

通过类索引可以得到类的全限定类名,通过父类索引可以得到父类的全限定类名,由于Java支持多接口,因此这里设计成了接口计数器和接口索引集合来实现,通过这三项就可以确定这个类的继承关系。

字段表集合

字段表用于描述类和接口中声明的变量。这里的字段包含了类级别变量以及实例变量,但是不包括方法内部声明的局部变量。

image-20210802215541429

字段表集合中不会列出从父类或者父接口中继承而来的字段。

方法表集合

在JVM规范中,每个变量/字段都有描述信息,描述信息主要的作用是描述字段的数据类型、方法的参数列表(包括数量、类型与顺序)与返回值。根据描述符规则,基本数据类型和代表无返回值的void类型都用一个大写字符来表示,对象类型则使用字符L加对象的全限定名称来表示。为了压缩字节码文件的体积,对于基本数据类型,JVM都只使用一个大写字母来表示,如下所示:B-byte,C-char,D-double,F-float,I-int,J-long,S-short,Z-boolean,V-void,L-对象类型,如Ljava/lang/String;

对于数组类型来说,每一个维度使用一个前置的[来表示,如int[]被记录为[I,String[][],被记录为[[Ljava/lang/String;

用描述符描述方法时,按照先参数列表,后返回值的顺序来描述。参数列表按照参数的严格顺序放在一组()之内,如方法:String getRealnamebyIdaAndNickname(int id,String name)的描述符为:(I,Ljava/lang/String;)Ljava/langString;

方法表结构如下:

image-20210802215205812

如果父类方法在子类中没有被重写,方法表集合就不会出现父类的方法。

属性表集合

属性表的结构如下:

image-20210802220825871

属性表的类型较多,这里以字段表属性为例,Code attribute的作用是保存该方法的结构:

image-20210802222155363

具体含义如下:

  • attribute_length表示attribute所包含的字节数,不包含attribute_name_index和attribute_length字段;
  • max_stack表示这个方法进行的任何时刻所能达到的操作数栈的最大深度;
  • max_locals表示方法执行期间创建的局部变量的数目,包含用来表示传入的参数的局部变量;
  • code_length表示该方法所包含的字节码的字节数及具体的指令码;
  • code表示具体字节码,即是该方法被调用时,虚拟机所执行的字节码;
  • exception_table,这里存放的是处理异常的信息;

除了方法属性表之外,还有异常属性表,在java字节码对于异常的处理方式:

  • 统一采用异常表的方式来对异常进行处理
  • 在jdk1.4.2之前的版本中,并不是使用异常表的方式来对异常进行处理的,而是采用特定的指令的方式
  • 当异常处理存在finally语句块时,现在化的JVM采取处理方式时将finally语句块的字节码拼接到每一个catch块后面。换句话说,程序中存在多少个catch块后面重复多少个finally语句块的字节码。

除了上述提到的方法属性表、异常属性表之外,实际上Java虚拟机中预定义的属性有20多个,除了JVM预定义的属性,编译器还自己可以实现自己的属性写入class文件里,供运行时使用,不同的属性通过attribute_name_index来区分。

字节码指令

字节码与数据类型

JVM助记符:

  • invokeinterface:调用接口中的方法,实际上是在运行期决定的,决定到底调用实现该接口中的哪个对象的特定的方法;

  • invokestatic:调用静态方法;

  • invokespecial:调用自己的私有方法、构造方法(<init>)以及父类的方法;

  • invokevirtual:调用虚方法,运行期间动态查找的过程;

  • invokedynamic:动态调用方法。

静态解析的四种情形:

1、静态方法

2、父类方法

3、构造方法

4、私有方法(无法被重写)

以上四类方法称为非虚方法,他们是在类加载阶段就可以将符号引用换转为直接直接引用,这种情形就被称之为静态解析。

方法的静态分派:

Grandpa g1 = new Father(); 

以上代码,g1的静态类型是Grandpa,而g1的实际类型(真正指向的类型)是Father。

结论:变量的静态类型是不会发生变化的,而变量的实际类型则是可以发生变化的(多态的一种体现),实际类型是在运行期可确定。

方法重载对于JVM来讲是一种静态的行为,因此调用重载方法,变量类型是根据传入变量的静态类型来确定的。方法重载在编译期就可以完全确定的。

方法的动态分派:方法接收者指的是这个方法到底是由哪一个对象来的调用的,invokevirtual字节码指令的多态查找流程,在运行期间,首先找到操作数栈顶的第一个元素所指向的真正的类型,然后在实际的类型当中寻找到特定的方法,如果能找到,那就将符号引用转换为直接引用,如果找不到,就开始按照继承体系,在父类里面开始从下网上开始寻找,可以找到就转换为直接引用,如果没有找到,则抛出异常。

方法重载和方法重写的结论:方法重载是静态的,是编译期行为;方法重写是动态的,是运行期行为。

针对于方法调用的动态分派的过程,虚拟机在类的方法区简历一个虚方法表的数据结构(virtual method table,vtable)

针对于invokeinterface指令来说,虚拟机会建立一个接口方法表的数据结构(interface method table,itable)

编译执行,解释执行

现在JVM在执行java代码的时候,通常都会将解释执行与编译执行二者结合来进行。

所谓解释执行,就是通过解释器来读取字节码,遇到相应的指令就去执行该指令;

所谓编译执行,是通过即时编译器(Just In Time,JIT)将字节码转换为本地机器码来执行,现在JVM会根据代码热点来生成响应的本地机器码。

基于栈的指令集(内存当中执行),基于寄存器的指令集(CPU当中执行)的关系:

1、JVM执行指令时所采取的方式是基于栈的指令集;

2、基于栈的指令集主要的操作有入栈与出栈两种;

3、基于栈的指令集的优势在于它可以在不同平台之间一直,而基于寄存器的指令集是与硬件架构关联的,无法做到可移植;

4、基于栈的指令集的缺点在于完成相同的操作,指令数量通常要比基于寄存器的指令集数量要多;基于栈的指令集是在内存中完成操作的,而基于寄存器的指令集是直接由CPU来执行的,它是在高速缓冲区中进行执行的,速度要快很多。虽然虚拟机以采用一些优化手段,但总体来说,基于栈的指令集的执行速度要慢一些。

类加载机制

类加载过程

加载:查找并加载类的二进制数据

连接:

  • 验证:确保被加载的类的正确性
  • 准备:为类的静态资源变量分配内存,并将其初始化为默认值
  • 解析:把类中的符号引用转换为直接引用

初始化:为类的静态变量赋予正确的初始值

加载

在类的加载阶段,Java虚拟机需要完成以下三件事情:

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

加载.class文件的方式:

  • 从本地系统中直接加载
  • 通过网络下载.class文件
  • 从zip,jar等归档文件中加载.class文件
  • 从专有数据库中提取.class文件
  • 将Java源文件动态编译为.class文件

类加载器并不需要某个类被“首次主动使用”时再去加载它,JVM规范允许类加载器在预料某个类将要被使用时就预先加载它,如果在预先加载的过程中遇到了.class文件缺失或存在错误,类加载器必须在程序首次主动使用该类时才报告错误。

当一个类在初始化时,要求其父类全部都初始化完毕,当一个接口在初始化时,并不要求其父接口都完成了初始化。

验证

验证是连接阶段的第一步,这一阶段的目的是确保Class文件的字节流中包含的信息复合《Java虚拟机规范》的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全。

验证阶段大致会完成下面四个阶段的检验动作:

  • 文本格式验证

    主要验证字节流是否符合Class文件格式的规范,并且能被当前版本的虚拟机处理

  • 元数据验证

    对字节码描述的信息进行语义分析,以保证其描述的信息符合《Java语言规范》的要求

  • 字节码验证

    通过数据流分析和控制流分析,确定程序语义是合法的、符合逻辑的。

  • 符号引用验证

    确保符号引用转化为直接引用的时候可以正常执行。

准备

准备阶段是正式为类中定义的变量(即静态变量,被static变量)分配内存并设置类变量初始值的阶段,这里所说的初始值“通常情况”下是数据类型的零值。

不同数据类型的零值如下:

image-20210802181522346

解析

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

初始化

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

Java程序对类的使用方式可分为两种:主动使用、被动使用,所有的Java虚拟机实现必须在每个类或者接口被Java程序“首次主动使用”时才初始化他们,主动使用的情况有以下七种:

  1. 创建类的实例
  2. 访问某个类或接口的静态变量,或者对该静态变量赋值
  3. 调用类的静态方法
  4. 反射
  5. 初始化一个类的子类
  6. Java虚拟机启动时被标明为启动类的类(Java Test)
  7. JDK1.7开始提供的动态语言支持:Java.lang.invoke.MethodHandle实例的解析结果REF_getStatic,REF_putStatic,REF_invokeStatic句柄对应的类没有初始化则初始化

除了以上其中情况,其他使用Java类的方式都被看作时对类的被动使用,都不会导致类的初始化。

类加载器

类与类加载器

自底向上检查类是否已经加载,自项向下尝试加载类。

启动类加载器:$JAVA_HOME中jre/lib/rt.jar里所有的class,由c++实现,不是classloader的子类;

扩展类加载器:加载java平台中扩展功能的一些jar包,包括$JAVA_HOME中jre/lib/*.jar或-Djava.ext.dirs指定目录下的jar包;

系统类加载器:负责加载classpath中指定的jar包及目录中class;

若有一个类加载器能够成功加载Test类,那么这个类加载器被称为定义类加载器,所有能成功返回Class对象引用的类加载器(包括定义类加载器)都成为初始类加载器。

加载器之间的父子关系实际上是包装关系。

双亲委派模型

站在Java虚拟机的角度看,只存在两种不同的类加载器:一种是启动类加载器(Bootstrap ClassLoader),另一种是其他的类加载器,这些类都由Java语言实现,并且继承自抽象类java.lang.ClassLoader。

站在开发人员的角度来看,类加载会被划分的更加细致一些,不同类加载器之间的作用如下表:

类加载器作用
启动类加载器加载<JAVA_HOME>\lib目录,或者被-Xbootclasspath参数所执行的路径中存放的,并且能够被Java虚拟机能够识别的类
扩展类加载器加载<JAVA_HOME>\lib\ext目录中,或者被java.ext.dirs系统变量所执行的路径中所有的类库
应用类加载器加载用户类路径上所有的类库

在双亲委派机制中,各个加载器按照父子关系形成了树形结构,除了根类加载器之外,其余的类加载器都有且只有一个父加载器。

image-20210731161405825

双亲委派机制的有点是能够提高软件系统的安全性。因为在此机制下,用户自定义的类加载器不可能加载应该由父加载器加载的可靠类,从而防止不可靠甚至恶意的代码代替由父加载器的可靠代码。

每个类加载器都有自己的命名空间,命名空间由该加载器及所有父加载器所加载的类组成。

在同一个命名空间中,不会出现类的完整名字(包括类的包名)相同的两个类,在不同的命名空间中,有可能会出出现类的完整名字(包括类的包名)相同的两个类

同一个命名空间内的类是相互可见的。

子加载器的命名空间包含所有父加载器的命名空间。因此由子加载器加载的类能看见父加载器加载的类,由父加载器加载的类不能看见子加载器加载的类

如果两个加载器之间没有直接或间接的父子关系,那么它们各自加载的类相互不可见。

一个类何时结束生命周期,取决于代表它的Class对象何时结束生命周期

由JAVA虚拟机自带的类加载器所加载的类,在虚拟机的生命周期中,始终不会被卸载。

破坏双亲委派模型

在Java的世界中,大部分的类加载器都遵循双亲委派模型,但也有例外情况:

  • 双亲委托模型之前的自定义类加载器:java.lang.ClassLoader#findClass

  • SPI场景:

    • java.lang.Thread#setContextClassLoader
    • java.util.ServiceLoader以及META-INF/services
  • 模块化

SPI机制

简介

SPI全称为Service Provider Interface,是一种服务发现机制。SPI的本质是将接口实现类的全限定名配置在文件中,并由服务加载器读取文件,加载实现类。这样可以在运行时,动态为接口替换实现类。正因此特性,我们可以很容易的通过SPI机制为我们的程序提供扩展功能。

为了理解SPI的使用场景,这里假设我们设计了一款全新的日志框架,默认以XML文件作为这款日志框架的配置文件,并且设计了一个配置文件解析的接口:

package com.github.kongwu.spisamples;

public interface SuperLoggerConfiguration {
    void configure(String configFile);
}

默认是XML的实现:

package com.github.kongwu.spisamples;

public class XMLConfiguration implements SuperLoggerConfiguration{
    public void configure(String configFile){
        ......
    }
}

那么在初始化解析配置的时候,只需要调用XMLConfiguration这个来解析XML配置文件即可:

package com.github.kongwu.spisamples;

public class LoggerFactory {
    static {
        SuperLoggerConfiguration configuration = new XMLConfiguration();
        configuration.configure(configFile);
    }
    
    public static getLogger(Class clazz){
        ......
    }
}

假设用户/使用方想增加一个yml文件的方式,作为日志配置文件,使用上述方式,在注入的时候,LoggerFactory就无法加载新的实现类YAMLConfiguration,这个时候就需要借助SPI机制完成。

JDK SPI

JDK提供了一个SPI功能,核心类是java.util.ServiceLoader。其作用就是,可以通过类名获取在META-INF/service下的多个配置实现文件。

为了解决上面的扩展问题,现在在META-INF/service目录下创建一个com.github.kongwu.spisamples.SuperLoggerConfiguration文件(无后缀名):

META-INF/services/com.github.kongwu.spisamples.SuperLoggerConfiguration:

com.github.kongwu.spisamples.XMLConfiguration

然后通过ServiceLoader 获取我们的 SPI 机制配置的实现类:

ServiceLoader<SuperLoggerConfiguration> serviceLoader = ServiceLoader.load(SuperLoggerConfiguration.class);
Iterator<SuperLoggerConfiguration> iterator = serviceLoader.iterator();
SuperLoggerConfiguration configuration;

while(iterator.hasNext()) {
    //加载并初始化实现类
    configuration = iterator.next();
}

//对最后一个configuration类调用configure方法
configuration.configure(configFile);

最后在调整LoggerFactory中初始化配置的方式为现在的SPI方式:

package com.github.kongwu.spisamples;

public class LoggerFactory {
    static {
        ServiceLoader<SuperLoggerConfiguration> serviceLoader = ServiceLoader.load(SuperLoggerConfiguration.class);
        Iterator<SuperLoggerConfiguration> iterator = serviceLoader.iterator();
        SuperLoggerConfiguration configuration;

        while(iterator.hasNext()) {
            configuration = iterator.next();//加载并初始化实现类
        }
        configuration.configure(configFile);
    }
    
    public static getLogger(Class clazz){
        ......
    }
}

为了支持 YAML 配置,现在在使用方/用户的代码里,增加一个YAMLConfiguration的 SPI 配置:

META-INF/services/com.github.kongwu.spisamples.SuperLoggerConfiguration:

com.github.kongwu.spisamples.ext.YAMLConfiguration

此时通过iterator方法,就会获取到默认的XMLConfiguration和我们扩展的这个YAMLConfiguration两个配置实现类了。

在上面那段加载的代码里,我们遍历iterator,遍历到最后,我们**使用最后一个实现配置作为最终的实例,但是使用方/用户自定义的YAMLConfiguration是否是最后一个,这取决于运行时的ClassPath 配置,这就是JDK的SPI机制的一个明显的劣势,无法确认具体加载哪一个实现,也无法加载某个指定的实现,仅靠ClassPath的顺序是一个非常不严谨的方式。

Spring SPI

Spring的SPI配置文件是一个固定的文件 - META-INF/spring.factories,功能上和JDK的类似,每个接口可以有多个扩展实现,使用起来非常简单:

//获取所有factories文件中配置的LoggingSystemFactory
List<LoggingSystemFactory>> factories = 
    SpringFactoriesLoader.loadFactories(LoggingSystemFactory.class, classLoader);

SpringBoot中spring.factories示例:

# Logging Systems
org.springframework.boot.logging.LoggingSystemFactory=\
org.springframework.boot.logging.logback.LogbackLoggingSystem.Factory,\
org.springframework.boot.logging.log4j2.Log4J2LoggingSystem.Factory,\
org.springframework.boot.logging.java.JavaLoggingSystem.Factory

# PropertySource Loaders
org.springframework.boot.env.PropertySourceLoader=\
org.springframework.boot.env.PropertiesPropertySourceLoader,\
org.springframework.boot.env.YamlPropertySourceLoader

# ConfigData Location Resolvers
org.springframework.boot.context.config.ConfigDataLocationResolver=\
org.springframework.boot.context.config.ConfigTreeConfigDataLocationResolver,\
org.springframework.boot.context.config.StandardConfigDataLocationResolver

......

Spring将所有的配置都放到了一个固定的文件中,并且支持Classpath中存在多个spring.factories文件的,加载时会按照classpath的顺序依次加载这些spring.factories文件,添加到一个ArrayList中。由于没有别名,所以也没有去重的概念,有多少就添加多少。

SpringBoot中的ClassLoader会优先加载项目中的文件,而不是依赖包中的文件。所以在项目中定义spring.factories文件,那么这个文件会被第一个加载,得到的Factories中,项目中spring.factories里配置的那个实现类也会排在第一个。

如果我们要扩展某个接口的话,只需要在项目里新建一个META-INF/spring.factories文件,只添加新的配置,不需要完整的复制一遍Spring Boot的spring.factories文件然后修改。

例如添加一个LoggingSystemFactory 实现,只需要新建META-INF/spring.factories文件:

org.springframework.boot.logging.LoggingSystemFactory=\
com.example.log4j2demo.Log4J2LoggingSystem.Factory

虚拟机字节码执行引擎

运行时栈帧结构

Java虚拟机方法作为最基本的执行单元,“栈帧”则是用于支持虚拟机进行方法调用和方法执行背后的数据结构,它也是虚拟机运行时数据区中的虚拟机栈中的栈元素。栈帧存储了方法的局部变量表、操作数栈、动态连接和方法返回地址等信息。

一个线程中的方法调用链可能会很长,以Java程序的角度来看,同一时刻、同一条线程里面,在调用堆栈方法的所有方法都同时处于执行状态。而对于执行引擎来讲,在活动线程中,只有位于栈顶的方法才是运行的,只有位于栈顶的栈帧才是生效的,其被称为“当前栈帧”,与这个栈帧所关联的方法被称为“当前方法”。执行引擎所运行的所有字节码指令都针对当前栈帧进行操作,典型的栈帧结构表示如下图:

栈帧的概念结构

局部变量表

局部变量表是一组变量值的存储空间,用于存放方法参数和方法内部定义的局部变量。在Java程序被编译为Class文件时,就在方法的Code属性的max_locals数据项中确定了该方法所需分配的局部变量表的最大容量。

局部变量表的容量以变量槽为最小单位,一个变量槽可以存放一个32以内的数据类型,Java中占用不超过32位存储空间的数据类型有boolean、byte、char、short、int、float、reference和returnAddress。其中,第7中reference类型标识对一个对象实例的引用,通过这个引用,虚拟机可以:

  • 根据引用直接或间接地查找到对象在Java堆中的数据存放的起始地址或索引
  • 根据引用直接或间接地查找到对象所属数据类型在方法区中地存储地类型信息

第8中returnAddress在某些古老的Java虚拟机曾经用来实现异常处理时的跳转。

对于64位的数据类型,Java虚拟机会以高位对齐的方式为其分配两个连续的变量的槽空间。Java语言中明确64位的数据类型只有long和double两种。

Java虚拟机通过索引定位的方式使用局部变量表,索引值的范围是从0开始至局部变量表最大的变量槽数量。如果访问的是32位数据类型的变量,索引N就代表了使用第N个变量槽,如果访问的是64位数据类型的变量,则说明会同时使用第N和N+1两个变量槽。对于两个相邻的共同存放一个64位数据的两个变量槽,虚拟机不允许采用任何方式单独访问其中一个。

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

为了尽可能节省栈帧耗用的内存空间,局部变量表中的变量槽是可以重用的,方法体中定义的变量,其作用域并不一定会覆盖整个方法体,如果当前字节码PC计数器的值已经超出了某个变量的作用域,那这个变量对应的变量槽就可以交给其他变量来重用。不过,这样的设计除了节省栈帧空间意外,还会伴随有少量额外的副作用。

操作数栈

操作数栈也常被称为操作数栈,它是一个后入先出的数据结构,同局部变量表一样,操作数栈的最大深度也在编译的时候被写入到Code属性的max_stacks数据项之中。操作数栈的每一个元素都可以是包括long和double在内的任意Java数据类型。32位数据类型所占的栈容量为1,64位数据类型所栈的栈容量为2。

当一个方法刚刚开始执行的时候,这个方法的操作数栈式空的,在方法的执行过程中,会有各种字节码执行往操作数栈中写入和提取内容,也就是出栈和入栈操作。

举例来说,在JVM中 执行 a = b + c 的字节码执行过程中操作数栈以及局部变量表的变化如下图所示。

局部变量表中存储着a、b、c 三个局部变量,首先将b和c分别入栈:

image-20210805170308818

将栈顶的两个数出栈执行加法操作,并将结果保存至栈顶,之后将栈顶的数出栈赋值给a:

image-20210805170345449

操作数栈中元素的数据类型必须与字节码指令的序列严格匹配,在编译程序代码的时候,编译器必须要严格保证这一点,在类校验阶段的数据流分析中还要再次验证这一点。

另外在概念模型中,两个不同栈帧作为不同方法的虚拟机栈的元素,是完全相互独立的。但是在大多虚拟机的实现里都会进行一些优化处理,令两个栈帧出现一部分重叠。让下面栈帧的部分操作数栈与上面栈帧的部分局部变量表重叠在一起,这样做不仅节约了一些空间,更重要的是在进行方法调用时就可以直接共用一部分数据,无须进行额外的参数复制传递了,重叠的过程如图:

image-20210805170917346

动态连接

每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接。Class文件的常量池中存在大量的符号引用,字节码中的方法调用指令就以常量池里指向方法的符合引用作为参数。这些符号一部分在类加载阶段或者第一次使用的时候就被转化为直接引用,这种转化被称为静态解析。另一部分将在每一次运行期间都转化为直接引用,这部分称为动态连接。

动态连接的例子:

Animal a = new Cat(); 
a.sleep; 
a = new Dog();
a.sleep;
a = new Tiger();
a.sleep;

在程序编译期间,实际上调用的是Animal的sleep方法,执行期间会通过invokevirtual来检查真正的方法的指向。

方法返回地址

当一个方法开始执行后,只有两种方式退出这个方法。第一种方式是执行引擎遇到任意一个方法返回的字节码指令,这时候可能会返回值传递给上层的方法调用者(调用当前方法的方法称为调用者或者主调方法),方法是否有返回值以及返回值的类型根据遇到何种方法返回指令来决定,这种退出方法的方式称为“正常调用完成”。

另一种退出方式是在方法执行的过程中遇到了异常,并且这个异常没有在方法体没得到妥善处理。这种退出方法的方式称为“异常调用完成”。一个方法使用异常完成出口的方式退出,是不会给它的上层调用者提供任何返回值的。

无论采用何种退出方式,在方法退出之后,都必须返回到最初方法被调用时的为止,程序才能继续执行,方法返回时可能需要在栈帧中保存一些信息,用来帮助恢复它的上层主调方法的执行状态。一般来说,方法正常退出时,主调方法的PC计数器的值就可以作为返回地址,栈帧中很可能会保存这个计数器值。而方法异常退出时,返回地址是要通过异常处理器表来确定的,栈帧中一般不会保存这部分信息。

方法退出的过程实际上等同于把当前栈帧出战,因此退出时可能执行的操作有:

  • 恢复上层方法的局部变量表和操作数栈
  • 将返回值(如果有的话)压入调用者栈帧的操作数栈中
  • 调整PC计数器的值以指向方法调用指令后面的一条指令

可能还有一些其他操作,这取决于具体的虚拟机的实现。

附加信息

《Java虚拟机规范》允许虚拟机实现增加一些规范里没有描述的信息到栈帧之中,例如与调用、性能收集相关的信息,这部分信息完全取决于具体的虚拟机实现。

一般会把动态连接、方法返回地址与其他附件信息全部归为一类,称为栈帧信息。

方法调用

方法调用并不等同于方法中的代码被执行,方法调用阶段唯一的任务就是确定调用哪一个方法,暂时还未涉及方法内部的具体的运行过程。一切方法调用在Class文件里面存储的都只是符号引用,而不是方法在实际运行时内存布局中的入口地址(也就是直接引用)。这个特性给Java带来了更强大的动态扩展能力,但也使得Java方法调用过程变的相对复杂,某些调用需要在类加载期间,甚至知道运行期间才能确定目标方法的直接引用。

解析

所有方法调用的目标方法在Class文件里面都是一个常量池中的符号引用,在类加载的解析阶段,会将其中的一部分符号引用转化为直接引用,这种解析能够成立的前题是:方法在程序真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期是不可改变的。换句话说,调用目标在程序代码写好、编译器进行编译那一刻就已经确定下来。这类方法的调用被称为解析。

符合这种要求的方法主要有静态方法和私有方法两大类,前者与类型直接关联,后者在外部不可被访问,这两种方法各自的特点决定了它们不可能通过继承或别的方式重写出其他版本,因此它们都适合在类加载阶段进行解析。

调用不同类型的方法,字节码指令集里设计了不同的指令:

指令含义
invokestatic用于调用静态方法
invokespecial用于调用实例构造器<init>()方法、私有方法和父类中的方法
invokevirtual用于调用所有的虚方法
invokeinterface用于调用接口方法,会在运行时再确定一个实现该接口的对象
invokedynamic先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法

可以在类加载的时候就把符号引用解析为直接引用的方法类型:

  • 静态方法
  • 私有方法
  • 实例构造器
  • 父类方法
  • final修饰的方法

这些方法统称为“非虚方法”,除了这些方法外的其他方法就被称为“虚方法”。

分派

解析调用一定是个静态的过程,在编译期间就完全确定,在类加载的解析阶段就会把设计的符号引用全部变为明确的直接引用,不必延迟到运行期再去完成。除了解析调用之外,还有分派,它可能是静态的也可能是动态的,根据分派依据的宗量数可分为单分派和多分派。这两类分两两组合就构成了静态单分派、静态多分派、动态单分派、动态多分派4中分派组合情况。

不同分派之间的对比:

分派特点
静态分派重载
动态分派重写
单分派根据一个宗量对目标方法进行选择,即为单分派
动态分派根据多于一个宗量对目标方法进行选择,即为多分派

方法的接收者与方法的参数统称为方法的宗量。

Java内存模型与线程

Java内存模型

主内存与工作内存

Java内存模型的主要目的是定义程序中各种变量的访问规则,即关注在虚拟机中把变量值存储到内存和从内存中取出变量值这样的底层细节。

此处的变量包括了实例字段、静态字段和构成数组对象的元素,但是不包括局部变量与方法参数,因为后者是线程私有的,不会被共享,自然就不会存在竞争问题。

Java内存模型规定了所有的变量都存储在主内存中,每条线程还有子集的工作内存,线程的工作内存中保存了该线程使用的变量的主内存副本,线程堆变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的数据。不同线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成,线程、主内存、工作内存三者的交互关系如图:

image-20210805230911338

volatile型变量

当一个变量被定义成volatile之后,它将具备两项特性:

  • 保证此变量对所有线程的可见性
  • 禁止指令重排序优化

Java与线程

线程的实现

实现线程主要有三种方式:

  • 使用内核线程实现(1:1实现)
  • 使用用户线程实现(1:N实现)
  • 使用用户线程加轻量级进程混合实现(N:M实现)

Java线程调度

线程调度是指系统为线程分配处理器使用权的过程,调度方式主要有两种:

  • 协同式线程调度
  • 抢占式线程调度

Java线程调度是系统自动完成的,一共设置了10个级别的线程优先级,线程优先级并不是一项稳定的调节手段,例如windows中只有七种优先级,这意味着有几个线程的优先级会对应到同一个操作系统优先级。

线程状态

线程的不同状态可以转化,具体关系如下图:

image-20210805233636533

线程安全与锁优化

线程安全

当多个线程同时访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方法进行任何其他的协同操作,调用这个对象的行为都可以获得正确的结果,那就称这个对象是线程安全的。

线程安全的实现方式:

  • 互斥同步
  • 非阻塞同步
  • 无同步方案

互斥同步

互斥同步是指多个线程并发访问共享数据时,保证共享数据在同一时刻只被一个线程使用。同步的实现方式是通过互斥来实现的,常见的互斥方式有:

  • 临界区(critical selection)
  • 互斥量(mutex)
  • 信号量(semaphore)

java中最基本的互斥手段就是synchronized关键字,synchronized经过编译之后,会在同步块前后分别形成monitorenter和monitorexit两个字节码命令,这两个字节码指令都需要一个reference类型的参数来指明需要锁定和解锁的对象。如果synchronized明确指出了对象参数,那就是这个对象的引用,如果没有,那就根据所修饰的是实例方法还是类方法,去获取相应的对象实例或者Class对象作为锁对象。

synchronized对同一线程是可重入的,不会出现自己把自己锁死的情况。同步块在执行结束前,会阻塞其它线程的进入。由于java的线程是要映射到操作系统的原生线程的,如果阻塞或者一个线程,都需要操作系统来帮助完成,这就需要从用户态转换为内核态,这个转换需要消耗大量的CPU时间,对于代码简单的同步块,可能这个时间要大于执行时间,因此说,synchronized是一个重量级操作,一般只在确实必要的情况下使用。

除了使用synchronized,还可以使用ReentrantLock来实现同步。相比synchronized,ReentrantLock提供了一些比较灵活高级的功能,主要有:

  • 等待可中断:持有锁的线程长期不释放锁的时候,等待的线程可以选择放弃等待
  • 公平锁:多个线程获取锁的时候,必须按照申请锁的时间来依次获取锁
  • 绑定多个条件:一个ReentrantLock对象可以绑定多个Condition对象,而synchronized中,锁对象的wait()跟notify()可以实现一个隐含的条件,如果要和多余一个的条件关联时,就不得不额外加锁,而ReentrantLock只需要多次的new Condition就可以了

性能上,1.5的时候ReentrantLock略好,JDK1.6以后两者持平,而且虚拟机在优化上偏向于synchronized,因此性能不再是两者的考虑因素。都可以实现的情况下,优先synchronized。

非阻塞同步

互斥同步最主要的问题就是进行线程阻塞和唤醒所带来的性能问题,因此这种实现方式也叫阻塞同步。从处理问题的方式上来说,互斥同步属于一种悲观的并发策略。

随着硬件指令集的发展,我们有了另外一个选择:基于冲突检测的乐观并发策略。也就是先进行操作,如果没有其他线程使用共享数据,那就操作成功,如果有,那就再采取补偿措施。这种方式不需要把线程挂起,因此称为非阻塞同步。

这里所谓的“硬件指令集”,就是指现代指令集中新增的CAS指令(Compare-And-Swap,比较并交换)。不过这种操作无法涵盖互斥同步的所有使用场景,并且CAS从语义上来说并不是完美的,因为存在一个逻辑漏洞,那就是ABA问题。

如果变量V初次读取的时候值是a,并且在准备赋值的时候还是a,其实这个时候无法判断变量V没有被其它线程修改过,如果在这个有线程把V修改为了b,又重新修改为了a,CAS就会认为没有被修改过。这个漏洞就是“ABA”问题,解决ABA问题可以为变量增加版本号或者修改的时间戳来解决。

无同步方案

要保证线程安全,并不一定要进行同步。两者之间没有因果关系。同步只是一种保证共享数据争用时正确性的手段而已。有些代码是天生线程安全的,比如:

  • 可重入代码:这种代码也叫纯代码。可以在代码执行的任何时间中断它,转而执行另一端代码。而在控制权返回后,原程序不会有任何错误。可重入代码都有一些共同特征,比如:不依赖存储在对上的数据和公用的系统资源、用到的状态量都由参数传入、不调用非可重入的方法等
  • 线程本地存储:如果一段代码中所需要的数据完全包含同一个线程中,如果能保证这一点,那就不会因为跟其他线程争抢修改资源而导致数据不一致,也就没有线程风险,是线程安全的。我们平常web开发中基本不考虑多线程干扰,就是因为web交互模型中的“一个请求对应一个服务器线程”的处理方式

锁优化

自旋锁与自适应自旋

如果物理及其有一个以上的处理器或者处理器核心,能让两个或以上的线程同时并发执行,我们就可以让后面请求锁的那个线程“稍等一会”,但不放弃处理器的执行时间,看看持有锁的线程是否很快就会释放释放锁。为了让线程等待,我们只须让线程执行一个忙循环(自旋),这项计数就是所谓的自旋锁。

锁消除

锁消除是指虚拟机即时编译器在运行时,对一些代码要求同步,但是对被检测到不可能存在共享数据竞争的锁进行消除。锁消除的主要判断依据来源于逃逸分析的数据支持,如果判断到一段代码中,在堆上的所有数据都不会逃逸出去被其他线程访问到,那就可以把它们当作操作数栈上数据对待,认为它们是线程私有的,同步加锁自然就无须再进行。

锁粗化

原则上,我们在编写代码的时候,总是推荐将同步块的作用范围限制得尽量小——只在共享数据得实际作用域中才进行同步,这样是为了使得需要同步得操作数量尽可能变少,即使存在锁竞争,等待锁的线程也能尽可能快地拿到锁。

大多数情况下,上面的原则都是正确的,但是如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁操作是出现在循环体之中的,那即使没有线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗。

锁的粗化就是指如果虚拟机探测到有一串零碎的操作都对同一个对象加锁,就会把加锁同步的范围扩展(粗化)到整个操作序列的外部,这样只需要加锁一次就可以了。

轻量级锁

“轻量级”是相对于使用操作系统互斥量来实现的传统锁而言的,因此传统的锁的机制就被称为“重量级”锁。

HotSpot虚拟机对象头Mark Word示意图:

image-20210806104948711

在代码即将进入同步块的时候,如果此同步对象没有被锁定(锁标志位“01”状态),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储对象目前的Mark Word的拷贝(官方称为Displaced Mark Word),这时候线程堆栈与对象头的状态如图:

image-20210806105450428

然后,虚拟机将使用CAS操作尝试把对象的Mark Word更新位为指向Lock Record的指针。如果这个更新动作成功了,即代表该线程拥有了这个对象的锁,并且对象Mark Word的锁标志位(Mark Word的最后两个比特)将转变为“00”,表示此对象处于轻量级锁定状态。这时候线程堆栈与对象头的状态如下图:

image-20210806105747996

如果这个更新操作失败了,那就意味着至少存在一个线程与当前线程竞争获取该对象的锁。虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是,说明当前线程已经拥有了这个对象的锁,那直接进入同步块继续执行就可以了,否则就说明这个锁对象已经被其他线程抢占了。如果出现两条以上的线程争用同一个锁的情况,那轻量级锁就不再有效,必须要膨胀为重量级锁,锁标志的状态值变为“10”,此时Mark Word中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也必须进入阻塞状态。

上述描述的是轻量级锁的加锁过程,解锁过程也同样是通过CAS操作来进行的,如果对象的Mark Word仍然指向线程的锁记录,那就用CAS操作把对象当前的Mark Word和线程中复制的Displaced Mark Word替换回来。假设能够成功替换,那整个同步过程就顺利完成了;如果替换失败,则说明有其他线程尝试过获取该锁,就要在释放锁的同时,唤醒被挂起的线程。

轻量级锁能提升程序同步性能的依据是“对于绝大部分的锁,在整个同步周期内都是不存在竞争的”这一经验法则。如果没有竞争,轻量级锁便通过CAS操作成功避免了使用互斥量的开销;但如果确实存在锁竞争,除了互斥量的本身开销外,还额外发生了CAS操作的开销。因此在有竞争的情况下,轻量级锁反而会比传统的重量级锁更慢。

偏向锁

偏向锁的目的是消除数据在无竞争情况下的同步原语,进一步提高程序的运行性能。如果说轻量级锁是在无竞争的情况下使用CAS操作去消除同步使用的互斥量,那偏向锁就是在无竞争的情况下把整个同步都消除掉,连CAS操作都不去做了。

偏向锁中的“偏”,就是偏心的“偏”,偏袒的“偏”,它的意思是这个锁会偏向于第一个获取它的线程,如果在接下来的执行过程中,该锁一直没有被其他的线程获取,则持有偏向锁的线程永远不需要再进行同步。

假设虚拟机启用了偏向锁,那么当锁对象第一次被线程获取的时候,虚拟机将会把对象头中的标志位设置为“01”、把偏向模式设置为“1”,标识进入偏向模式。同时使用CAS操作把获取到这个锁的线程ID记录再对象的Mark World之中。如果CAS操作成功,持有偏向锁的线程以后每次进入这个锁相关的同步块是,虚拟机都可以不再进行任何同步操作。

一旦出现另一个线程去尝试获取这个锁的情况,偏向模式就马上宣告结束。根据锁对象目前是否处于被锁定的状态决定是否撤销偏向(偏向模式设置为“0”),撤销后标志位恢复到未锁定(标志位为“01”)或轻量级锁定(标志位为“00”)的状态,后续的同步操作就按照轻量级锁那样去执行。偏向锁、轻量级锁的状态转化及对象Mark Word的关系如图:

image-20210806102556856

参考文献

[1] 圣思源张龙的JVM学习笔记open in new window

[2] 使用java实现jvmopen in new window

[3] 通过字节码分析JDK8中Lambda表达式编译及执行机制open in new window

[4] 深入理解Java虚拟机open in new window

[5] JVM调优总结open in new window

[6] RSetopen in new window

[7] JDK网页open in new window

[8] G1官方文档open in new window

[9] Metaspaceopen in new window

[10] 新一代垃圾回收器ZGC的探索与实践open in new window

[11] Java永久代去哪儿了open in new window

[12] 深入理解Java字节码结构open in new window

[13] CMS垃圾回收器详解open in new window

[14] 栈帧open in new window