一、垃圾收集器相关
1、HotSpot的算法细节实现
其实下面讲到的技术本质上是为了解决垃圾回收过程中的具体问题而采用的解决方案,所以我会先说明遇到的问题再讲解HotSpot给出相对应的解决方案
1)、OopMap
在判断对象是否存活的时候,采用的是可达性分析算法,也就是从GC Roots集合找引用链这样的一个过程。虽然固定可以作为GC Roots的节点主要在全局性的引用(例如常量或类静态属性)与执行上下文(例如栈帧中的本地变量表)中,但如果要逐个检查以这里为起源的引用要消耗不少的时间。而且为了保证分析结果的准确性,需要避免分析过程中根节点集合的对象引用关系还在不断变化的情况,所以现在所有收集器在根节点枚举这一步骤时都是必须暂停用户线程的(Stop The World)。所以,每次直接遍历整个引用链肯定是不现实的
HotSpot使用OopMap这种数据结构让虚拟机直接得到哪些地方存放着对象引用(空间换时间)。一旦类加载动作完成的时候,HotSpot就会把对象内什么偏移量上是什么类型的数据计算出来,在即时编译过程中,也会在特定的位置记录下栈里和寄存器里哪些位置是引用。在OopMap的协助下,HotSpot可以快速准确地完成GC Roots枚举
2)、安全点
可能导致引用关系变化或者说导致OopMap内容变化的指令非常多,如果为每一条指令都生成对应的OopMap,将会需要大量的额外存储空间
HotSpot只是在特定的位置生成OopMap用来记录引用关系,这些位置被称为安全点(Safepoint)
HotSpot中的Stop The World是通过安全点机制来实现的。当HotSpot收到Stop The World请求,它便会等待所有的线程都到达安全点,才允许请求Stop The World的线程进行独占的工作
安全点的初始目的并不是让其他线程停下,而是找到一个稳定的执行状态。在这个执行状态下,HotSpot的堆栈不会发生变化。这么一来,垃圾回收器便能够安全地执行可达性分析
一般安全点设置在以下位置:
- 方法调用
- 循环跳转
- 异常跳转
如何在垃圾收集发生时让所有线程都跑到最近的安全点,然后停顿下来。有两种方案:抢占式中断和主动式中断
- 抢占式中断不需要线程的执行代码主动去配合,在垃圾收集发生时,系统首先把所有用户线程全部中断,如果发现用户线程中断的地方不在安全点上,就恢复这条线程执行,让它一会再重新中断,直到跑到安全点上。现在几乎没有虚拟机实现采用抢占式中断来暂停线程响应GC事件
- 主动式中断是当垃圾收集需要中断线程的时候,不直接对线程操作,仅仅简单地设置一个标志位,各个线程执行过程时会不停地主动去轮询这个标志,一旦发现中断标志为真时就自己在最近的安全点上主动中断挂起。轮询标志的额地方和安全点是重合的,另外还要加上所有创建对象和其他需要Java堆上分配内存的地方,这是为了检查是否即将要发生垃圾收集,避免没有足够内存分配新对象
3)、安全区域
安全点机制保证了程序执行时,在不长的时间内就会遇到可进入垃圾收集过程的安全点。但是,程序不执行的时候呢?比如用户线程处于Sleep状态或者额Blocked状态,这时候线程无法响应虚拟机的中断请求,不能再走到安全的地方去中断挂起自己,对于这种情况,就必须引入安全区域来解决
安全区域(Safe Region)是指能够确保在某一段代码片段之中,引用关系不会发生变化,因此,在这个区域中任意地方开始垃圾收集都是安全的
当用户线程执行到安全区域里面的代码时,首先会标识自己已经进入了安全区域,这样当这段时间里虚拟机要发起垃圾收集时就不必在去管这些已声明自己在安全区域内的线程了。当线程要离开安全区域时,它要检查虚拟机是否已经完成了根节点枚举,如果完成了,那线程就当作没事发生过,继续执行;否则它就必须一直饿等待,直到收到可以离开安全区域的信号为止
4)、记忆集与卡表
Minor GC是不用对整个堆进行垃圾回收,但是老年代的对象可能引用新生代的对象。也就是说,在标记存活对象的时候,需要扫描老年代中的对象。如果该对象拥有对新生代对象的引用,那么这个引用也会被作为GC Roots。这样一来,岂不是又做了一次全堆扫描呢?
垃圾收集器在新生代中建立了名为记忆集(Remembered Set)的数据结构,用以避免把整个老年代加进GC Roots扫描范围。记忆集是一种用于记录从非收集区域指向收集区域的指针集合的抽象数据结构
卡表(Card Table)是记忆集的一种具体实现,它定义了记忆集的记录精度、与堆内存的映射关系等。卡表是一个字节数组,每一个元素都对应着其标识的额内存区域中一块特定大小等内存块,这个内存块被称为卡页(Card Page)。HotSpot中使用的卡页都是512字节的
CARD_TABLE [this address >> 9] = 0;
一个卡页的内存中通常包含不止一个对象,只要卡页内有一个或更多对象的字段存在着跨代指针,那就将对应卡表的数组元素的值标识为1,称为这个元素变脏,没有则标识为0。在垃圾收集发生时,只要筛选出卡表中变脏的元素,就能轻易得出哪些卡页内存块中包含跨代指针,把它们加入GC Roots中一起扫描
5)、写屏障
在HotSpot虚拟机里是通过写屏障(Write Barrier)技术维护卡表状态的。写屏障可以看作是在虚拟机层面对引用类型字段赋值这个动作对AOP切面,在引用对象赋值时会产生一个环形通知,供程序执行额外的动作,也就是说赋值的前后都在写屏障的覆盖范畴内。在赋值前的部分的写屏障叫做写前屏障,在赋值后的则叫做写后屏障
应用写屏障后,虚拟机就会为所有赋值操作生成相应的指令,一旦收集器在写屏障中增加了更新卡表操作,无论更新的是不是老年代对新生代对象的引用,每次只要对引用进行更新,就会产生额外的开销,不过这个开销与Mionr GC时扫描整个老年代的代价相比还是低很多的
除了写屏障的开销之外,卡表在高并发场景下还面临着伪共享问题。现代中央处理器的缓存系统中是以缓存行为单位存储的,当多线程修改互相独立的变量时,如果这些变量恰好共享同一个缓存行,就会彼此影响(写回、无效化或者同步)而导致性能降低,这就是伪共享问题
假设处理器的缓存行大小为64字节,由于一个卡表元素占1个字节,64个卡表元素将共享同一个缓存行。这64个卡表元素对应的卡页总的内存为32KB( 64 ∗ 512 64*512 64∗512字节),也就是说如果不同线程更新的对象正好处于这32KB的内存区域内,就会导致更新卡表时正好写入同一个缓存行而影响性能。为了避免伪共享问题,可以先检查卡表标记,只有当该卡表元素未被标记过时才将其标记为变脏,即将卡表更新的逻辑变为一下代码所示:
if (CARD_TABLE [this address >> 9] != 0)
CARD_TABLE [this address >> 9] = 0;
2、垃圾收集器CMS
CMS收集器是一种以获取最短回收停顿时间为目标的收集器,基于标记-清除算法,针对的是老年代,只能和新生代的垃圾收集器Serial和ParNew配合工作
整个过程分为4个步骤:
- 初始标记
- 并发标记
- 重新标记
- 并发清除
初始标记、重新标记这两个步骤仍然需要Stop The World
初始标记仅仅只是标记一下GC Roots能直接关联到的对象,速度很快
并发标记阶段就是从GC Roots的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长但不需要停顿用户线程,可以与垃圾收集线程一起并发运行
而重新标记阶段则是为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段稍长一些,但远比并发标记的时间短
由于整个过程中耗时最长的并发标记和并发清除过程收集器线程都可以与用户线程一起工作,所以,从总体上来说,CMS收集器的内存回收过程是与用户线程一起工作
CMS的缺点:
- 在并发阶段,CMS收集器虽然不会导致用户线程停顿,但是会因为占用了一部分线程(或者说CPU资源)而导致应用程序变慢,总吞吐量会降低。CMS默认启动的回收线程数是
(CPU数量+3)/4
- CMS收集器无法处理浮动垃圾,可能出现Concurrent Mode Failure失败而导致另一次Full GC的产生。由于CMS并发清理阶段用户线程还在运行着,伴随程序运行自然就还会有新的垃圾不断产生,这一部分垃圾出现在标记过程之后,CMS无法在当次收集中处理掉它们,只好留到下一次GC时再清理掉。这一部分垃圾就称为浮动垃圾。由于在垃圾收集阶段用户线程还需要运行,那也就还需要预留有足够的内存空间给用户线程使用,因此CMS收集器不能像其他收集器那样等到老年代几乎完全被填满了再进行收集,需要预留一部分空间提供并发收集时的程序运作使用
- CMS是一款基于标记-清除算法实现的收集器,收集结束时会有大量空间碎片产生
3、垃圾收集器G1
垃圾收集器发展历程:
最开始的Serial在GC的时候Stop The World使用单线程进行回收;后来采用并行的垃圾收集器Parallel使用多个线程进行回收;再后来采用CMS可以让用户线程和垃圾回收的线程一起执行以用来降低延时,在此之前垃圾收集的目标范围要么是整个新生代(Minor GC),要么是整个老年代(Major GC),再要么是整个Java堆(Full GC)。直到G1的出现抛弃了原来固有的设计思路,G1可以面向堆内存任何部分来组成回收集(Collection Set)进行回收,衡量标准不再是它属于哪个分代,而是哪块内存中存放的垃圾数量最多,回收收益最大,这就是G1收集器的Mixed GC模式
G1把连续的Java堆划分为多个大小相等的独立区域(Region),每一个Region都可以根据需要,扮演新生代的Eden空间、Survivor空间或者老年代空间
还有一类特殊的Humongous区域,专门用来存储大对象。G1认为只要大小超过了一个Region容量一半的对象即可判定为大对象。每个Region的大小可以通过参数-XX:G1HeapRegionSize
设定,取值范围为1MB~32MB,且应为2的N次幂。而对于那些超过了整个Region容量的超级大对象,将会被存放在N个连续的Humongous Region之中,G1的大多数行为都把Humongous Region作为老年代的一部分来进行看待
G1将Region作为单次回收的最小单元,即每次收集到的内存空间都是Region大小的整数倍,这样可以有计划地避免在整个Java堆中进行全区域的垃圾收集。而且G1会去跟踪各个Region里面的垃圾堆积的价值大小,价值即回收所获得的空间大小以及回收所需时间的经验值,然后在后台维护一个优先级列表,每次根据用户设定允许的收集停顿时间(使用-XX:MaxGCPauseMillis
指定,默认值是200毫秒)优先处理回收价值收益最大的那些Region
Minor GC:
Minor GC回收的是所有年轻代的Region。当Eden区不能再分配新的对象时就会触发。Eden区的对象会移动到Survivor区,当Survivor区空间不够的时候,Eden区的对象会直接晋升到Old区,同时Survivor区的数据移动到新的Survivor区,如果Survivor区的部分对象到达一定年龄,会晋升到Old区
Mixed GC分为4个步骤:
- 初始标记:它标记了从GC Roots开始直接可达的对象。初始标记阶段借用Minor GC的暂停,因而没有额外的、单独的暂停阶段
- 并发标记:这个阶段从GC Roots开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户线程并发执行
- 最终标记:标记那些在并发标记阶段发生变化的对象,将被回收
- 筛选回收:负责更新Region的统计数据,对各个Region的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个Region构成回收集,然后把决定回收的那一部分Region的存活对象复制到空的Region中,再清理掉整个旧Region的全部空间。这里的操作涉及存活对象的移动,是必须暂停用户线程,由多条收集器线程并行完成的
Full GC:
G1的垃圾回收过程是和应用程序并发执行的,当Mixed GC的速度赶不上应用程序申请内存的速度的时候,Mixed GC就会降级到Full GC,使用的是Serial GC。Full GC会导致长时间的Stop The World,应该要尽量避免
导致G1 Full GC的原因可能有两个:
- 拷贝存活对象的时候没有足够的空间来存放晋升的对象
- 并发处理过程完成之前空间耗尽
推荐学习资料:
https://www.bilibili.com/video/av89885794?p=1
《深入理解Java虚拟机》 第三版
二、常见JVM参数
参数 | 含义 |
---|---|
-Xms20M | 初始堆大小 |
-Xmx20M | 最大堆大小 |
-Xmn10M | 新生代大小 |
-XX:NewRatio=2 | 表示新生代与老年代的比值,新生代是老年代的二分之一 |
-XX:SurvivorRatio=8 | 新生代中Eden区与一个Survivor区的空间比例是8:1 |
三、AQS
抽象队列同步器AbstractQueuedSynchronizer是用来构建锁或者其他同步组件的基本框架,AQS使用一个volatile修饰的int类型的成员变量state来表示同步状态,通过内置的FIFO队列来完成资源获取的排队工作,通过CAS完成对state值的修改
同步器的设计是基于模板方法模式的,使用者需要继承同步器并重写指定的方法,随后将同步器组合在自定义同步组件的实现中,并调用同步器提供的模板方法,而这些模板方法将会调用使用者重写的方法
同步器提供的功能可以分为独占功能和共享功能两类
通过ReentrantLock的非公平实现为例,来看下它与AQS之间方法的关联之处:
1)、加锁过程
public void lock() {
sync.lock();
}
这里的lock()
是个抽象方法,来看下它的非公平实现NonfairSync
static final class NonfairSync extends Sync {
private static final long serialVersionUID = 7316153563782823691L;
final void lock() {
//调用AQS的CAS方法把同步状态state从0变成1
if (compareAndSetState(0, 1))
//设置同步状态成功,设置当前拥有独占访问权限的线程
setExclusiveOwnerThread(Thread.currentThread());
//CAS操作失败调用AQS的acquire方法
else
acquire(1);
}
protected final boolean tryAcquire(int acquires) {
return nonfairTryAcquire(acquires);
}
}
public final void acquire(int arg) {
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
1)先来看下tryAcquire方法,tryAcquire翻译为尝试获取,其实这个方法还是在尝试获取锁,还是看下它的非公平实现NonfairSync
static final class NonfairSync extends Sync {
private static final long serialVersionUID = 7316153563782823691L;
final void lock() {
if (compareAndSetState(0, 1))
setExclusiveOwnerThread(Thread.currentThread());
else
acquire(1);
}
protected final boolean tryAcquire(int acquires) {
//调用父类Sync中的nonfairTryAcquire方法
return nonfairTryAcquire(acquires);
}
}
final boolean nonfairTryAcquire(int acquires) {
final Thread current = Thread.currentThread();
//获取同步状态state
int c = getState();
//如果同步状态为0,尝试使用CAS操作锁
if (c == 0) {
if (compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
//判断当前线程和占有锁的线程是否是同一个,如果是同一个同步状态state+1
else if (current == getExclusiveOwnerThread()) {
int nextc = c + acquires;
if (nextc < 0) // overflow
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
return false;
}
这里可以看到在更新同步状态state的时候,有时候调用AQS的compareAndSetState()
方法,有时候调用AQS的setState()
方法。这是因为如果同步状态为0此时可能存在多个线程竞争,使用CAS操作保证原子性(volatile关键字修饰state保证可见性);而如果当前线程和占有锁的线程是否是同一个,此时不会有多线程竞争的问题存在,就是单线程执行直接修改状态即可
2)如果还是获取锁失败,执行AQS的addWaiter和acquireQueued方法
addWaiter()
方法将当前线程以及等待状态等信息构造成为一个节点并将其加入同步队列,使用compareAndSetTail()
方法设置尾节点
private Node addWaiter(Node mode) {
Node node = new Node(Thread.currentThread(), mode);
Node pred = tail;
if (pred != null) {
node.prev = pred;
//快速尝试在尾部添加
if (compareAndSetTail(pred, node)) {
pred.next = node;
return node;
}
}
enq(node);
return node;
}
private Node enq(final Node node) {
//在死循环中只有通过CAS将节点设置成为尾节点之后,当前线程才能从该方法返回,否则,当前线程不断地尝试设置
for (;;) {
Node t = tail;
if (t == null) {
if (compareAndSetHead(new Node()))
tail = head;
} else {
node.prev = t;
if (compareAndSetTail(t, node)) {
t.next = node;
return t;
}
}
}
}
在acquireQueued()
方法中,当前线程在死循环中尝试获取同步状态,而只有前驱节点是头节点才能够尝试获取同步状态
final boolean acquireQueued(final Node node, int arg) {
boolean failed = true;
try {
boolean interrupted = false;
for (;;) {
final Node p = node.predecessor();
if (p == head && tryAcquire(arg)) {
setHead(node);
p.next = null; // help GC
failed = false;
return interrupted;
}
if (shouldParkAfterFailedAcquire(p, node) &&
//使用LockSupport阻塞当前线程并检测线程的中断状态
parkAndCheckInterrupt())
interrupted = true;
}
} finally {
if (failed)
cancelAcquire(node);
}
}
- 头节点是成功获取到同步状态的节点,而头节点的线程释放了同步状态之后,将会唤醒其后继节点,后继节点的线程被唤醒后需要检查自己的前驱节点是否是头节点
- 维护同步队列的FIFO原则。该方法中,节点自旋获取同步状态的行为如下图所示:
由于非首节点线程前驱节点出队或者被中断而从等待状态返回,随后检查自己的前驱是否是头节点,如果是则尝试获取同步状态。可以看到节点和节点之间在循环检查的过程中基本不相互通信,而是简单地判断自己的前驱是否为头节点,这样就使得节点的释放规则符合FIFO,并且也便于对过早通知的处理(过早通知是指前驱节点不是头节点的线程由于中断而被唤醒)
2)、解锁过程
public void unlock() {
//调用AQS的release方法
sync.release(1);
}
public final boolean release(int arg) {
//调用子类实现的tryRelease方法
if (tryRelease(arg)) {
Node h = head;
if (h != null && h.waitStatus != 0)
//使用LockSupport来唤醒处于等待状态的线程
unparkSuccessor(h);
return true;
}
return false;
}
protected final boolean tryRelease(int releases) {
int c = getState() - releases;
if (Thread.currentThread() != getExclusiveOwnerThread())
throw new IllegalMonitorStateException();
boolean free = false;
if (c == 0) {
free = true;
setExclusiveOwnerThread(null);
}
setState(c);
return free;
}
3)、独占锁ReentrantLock实现流程
四、LockSupport工具类
LockSupport定义了一组的公共静态方法,这些方法提供了最基本的线程阻塞和唤醒功能
LockSupport提供的阻塞和唤醒方法:
方法名称 | 描述 |
---|---|
park() | 阻塞当前线程,如果调用unpark(Thread thread) 方法或者当前线程被中断,才能从park() 方法返回 |
parkNanos(long nanos) | 阻塞当前线程,最长不超过nanos纳秒,返回条件在park() 的基础上增加了超时返回 |
parkUntil(long deadline) | 阻塞当前线程,直到deadline时间(从1970年开始到deadline时间的毫秒数) |
unpark(Thread thread) | 唤醒处于阻塞状态的线程thread |