在英雄联盟这款游戏的众多打野选手中,对于厂长(clearlove,明凯)这个人我是一种敬重,因为在这浮华的世界中还能保持初心,为了自己追逐自己的梦想,奉献自己的青春与热血。但要说最喜欢的那个打野肯定还是MLXG,虽已不在江湖,但江湖一直流传着你的传说,“绝食型打野”就是他的标签,敢秀敢操作,一个字就是帅。曾经那句:“红buff、蓝buff、大龙我全都要”,不仅显示出了他的自信和实力。
在Java并发编程里,同样有一个这么自信的关键字—synchronized,在上一篇文章中我们分析并证明了volatile关键字只保证可见性、有序性,基本不保证原子性,那么本文将要介绍的synchronized关键字就可能会说:“可见性、有序性,原子性我全都要”。 synchronized作为Java虚拟机提供的锁机制,如下表所示,在实现可见性与有序性原理上和volatile还是有些差异:
特性 | volatile | synchronized |
---|---|---|
可见性 | 通过MESI协议和内存屏障实现 | 规定在释放锁前必须将最新值同步回主内存,其实底层也是内存屏障 |
有序性 | 通过内存屏障禁止指令重排 | 不禁止指令重排,通过加锁保证持有相同锁的两个同步代码块只能串行执行 |
原子性 | 基本不保证原子性 | 通过加锁解锁的方式,保证synchronized代码块之间的原子性 |
synchronized的使用
synchronzied有三种使用场景:
- 修饰实例方法
- 修饰静态方法
- 修饰修饰代码块
如下面代码所示:
public class SynchronizedTest {
public static int race = 0;
public static int value = 0;
public static int block = 0;
private static final int THREAD_COUNT = 20;
public static synchronized void increaseStaticMethod() {
race++;
}
public synchronized void increaseMethod() {
value++;
}
public void increaseBlock() {
synchronized (SynchronizedTest.class) {
block++;
}
}
public static void main(String[] args) {
SynchronizedTest test = new SynchronizedTest();
Thread[] threads = new Thread[THREAD_COUNT];
for (int i = 0; i < THREAD_COUNT; i++) {
threads[i] = new Thread(() -> {
for (int j = 0; j < 10000; j++) {
increaseStaticMethod();
test.increaseMethod();
test.increaseBlock();
}
});
threads[i].start();
}
while (Thread.activeCount()> 1) {
Thread.yield();
}
System.out.println("race:" + race + " value:" + value + " block:" + block);
}
}
结果如下图所示:
三种使用场景下,程序均能并发正确执行。首先,我们要明确synchronized作为锁,它锁的是什么,在Java这样一门面向对象的编程语言中,没错它锁的是对象。对象分为类对象和实例对象,假设有类A及objA和objB两个对象:
-
修饰静态方法: 锁定的是类对象,其他线程可以访问这个类的所有非synchronized方法。假设有static synchronized methodA,则objA和objB不能同时访问methodA,因为objA和objB希望锁定的是类A 对象;
-
修饰实例方法: 锁定的是实例对象,其他线程可以访问该对象的其他非synchronized方法。假设有synchronized methodA,那么objA和objB访问methodA时不互斥,因为它们锁定的不是同一个对象;
-
修饰代码块: 既可能是类对象又可能是实例对象,具体场景下要根据代码中定义,在上面示例中修饰类对象。
synchronized原理分析
上文已经介绍了synchronized关键字的使用,接下来我们继续分析其底层原理。
字节码指令反汇编分析
通过javac SynchronizedTest.java
得到对应的字节码文件,再使用javap -v -c -s -l SynchronizedTest.class
得到字节码文件的反汇编指令,部分如下图所示:
从图中可以发现:
- 当synchronized修饰方法(静态方法和实例方法)时,都是在flags里通过ACC_SYNCHRONIZED来进行标识。当线程进入方法时,发现flags里有该标识则需要获得锁之后才能执行后续指令,当指令执行完毕或者指令执行异常时释放锁;
- 当synchronized修饰代码块时,则是通过一对monitorenter和monitorexit监视器指令来达到加锁的目的,monitorenter相当于加锁,monitorexit相当于释放锁。
前文我们曾经提到过,synchronized锁的是对象,同时通过底层的monitor监视器实现加锁,那么对象是怎么和monitor之间又是怎么关联起来的呢?
Java对象内存布局
了解Java虚拟机运行时数据区域的同学都知道,Java对象一般来说是分配在堆上(某些情况下分配在栈上),分配完以后,一个对象的内存布局一般来说包含两大部分:
- 对象头,其中包括:
MarkWord: 存放对象运行时数据,包括哈希值、对象分代年龄、锁标志、是否偏向等信息
Klass pointer:指向lock object类信息
数组长度(如果是数组的话还有数组长度)
- 对象数据:其中包括实例数据和填充数据
monitor监视器结构分析
当对象创建完毕后,Java虚拟机(JVM)会隐式的给每个对象创建一个对应的monitor对象,当一个线程调用一个对象的同步方法时,JVM会检查该对象的monitor对象是否被占用,如果monitor对象被其他线程占用,则该线程只能被阻塞,直到该monitor对象被释放;当线程退出同步方法也会释放锁。
从上面这段话要明白两个意思:
- monitor对同一线程具有可重入性,从而synchronized也是可重入锁;
- 对于一个java同步方法,monitor机制只能保证对于同一个对象在某一时刻只有一个线程在执行同步方法;对于不同的对象,同一个Java同步方法在某一时刻可以有多个线程在执行同步方法。
第二点有点绕,但也是理解synchronized锁对象的一个关键,大家多理解两遍。
monitor主要由以下结构组成:
- count: 计数器;
- CXQ竞争队列: 所有请求锁的线程首先会被放在这个队列中;
- EntryList: 等待获取锁的线程;
- owner: 拥有该monitor的线程;
- waitSet队列: owner线程被阻塞,会进入该队列。
加锁过程
讲完对象和monitor的结构,接下来它们是如何进行组合达到加锁的目的,如下图所示:
- 对象头通过锁标志位与monitor进行关联
- 当多个线程同时竞争同一monitor对象时,他们会进入CXQ竞争队列中,当owner线程释放锁时,根据monitor策略从CXQ队列或者entryList中唤醒一个线程,然后该线程通过CAS(Compare and swap)的方式拿锁;
- 当某一线程拿到锁之后,count + 1,然后将owner设为该线程;
- 当线程调用wait方法阻塞是,将owner置为null,count - 1,线程进入waitSet等待队列;当被再次唤(notify)时,重新进入CXQ等待队列或者entryList竞争锁;
- 当同步代码执行完毕,count-1,当count==0,owner置为null。
此处要说明三个事情:
1、使用过wait和notify/notifyAll的同学都知道,他们只能出现在相应的同步代码块或者同步方法中,其原因就是:
当owner线程被wait方法阻塞时进入waitSet队列,这个前提是要有monitor对象;同样当满足特定条件,比如从其他线程调用了notify/notifyAll时,从waitSet队列中唤醒进入CXQ竞争队列或者entryList的前提同样是要获取monitor对象。所以他们必须出现在同步代码块或者同步方法中;
2、唤醒阻塞的线程对象从waitSet中转移到CXQ竞争队列还是entryList中,是由monitor的策略决定,同时,monitor策略也决定了先来的线程不能保证就一定是先获取到锁,由此synchronized锁的非公平性也得到了体现;
3、线程的阻塞与唤醒是要通过操作系统来实现的,所以就必然涉及到了操作系统通过系统调用的方式用户态和内核态的切换,切换过程需要通过栈指针来保存线程相关信息,这里是严重影响性能的,由此synchronized也进行了锁优化。
synchronized锁优化
经过优化后的synchronized锁一共有4种状态:无锁、偏向锁、轻量级锁以及重量级锁,这4种状态在对象中通过对象头中的mark word进行标记。mark word结构如下所示:
无锁
锁对象最初的状态,没有发生线程需要执行同步方法
偏向锁
研究发现在实际生产中,大多数情况下,一个锁对象反复被同一线程持有,为了减少这种情况下反复获取锁带来的性能问题,才研究出了偏向锁。获取偏向锁的流程如下图所示:
线程1访问同步代码块希望加锁,这时候先看锁对象头中是否存储了线程1的线程id,如果存储了,说明锁已经是偏向锁了,所以不需要加锁直接就加锁成功;如果锁对象头中的线程id为null,则线程1通过CAS(compare and swap)将自己的线程id替换到锁对象头中,如果替换成功,则表示线程1加锁成功,然后更改对象头中的是否偏向标志位;
如果同时也有线程2也希望获取偏向锁,发现锁对象头中没有存储自己的线程id,通过CAS去替换所对象头中的线程id,结果失败(因为此时锁对象中线程id已经被线程1替换),此时就产生了锁竞争,线程2根据锁对象中存放的线程id去查看线程1是否还在继续持有锁,如果没有了,则将锁状态退回到无锁状态,然后重新进行偏向加锁;如果线程1还要继续持有锁,那么此时会在一个线程1的安全点暂停线程1,撤销线程1的偏向锁,将锁状态升级为轻量级锁。
由上可知:
- 偏向锁不会主动释放锁,直到发生锁竞争才会在安全点释放;
- 如果生产场景中,频繁有不同的线程去竞争同一个锁对象,显然偏向锁没有什么作用
安全点就是在某个时间点,工作线程全部停止执行代码指令,一般存在于方法调用、循环跳转等地方。
轻量级锁
轻量级锁产生的背景是,大量线程持有锁的时间很短,如果反复阻塞/唤醒线程得由操作系统频繁通过系统调用进行用户态到内核态的切换,导致严重影响性能。所以,轻量级锁采用一种乐观锁的思想,当发现锁竞争时,通过CAS自旋拿锁,当然也不会一直自旋下去,毕竟自旋也会消耗性能。
轻量级锁加锁/释放锁过程如下图所示:
加锁:
进入同步代码的线程会在栈帧中创建一个lock record;
将锁对象的mark word复制到lock record中;
采用CAS将锁对象mark word更为指向锁记录的指针,同时将锁lock record里的owner指针指向锁对象mark word,如果成功的话就表示加锁成功,更新锁标志位。如果失败,说明发生锁竞争,失败的线程会不断CAS自旋,如果在多个自旋后仍无法获取锁,则会膨胀为重量级锁,将自身线程阻塞,修改mark word的锁标志位。自旋的次数可以通过JVM参数设置
解锁过程:
如果在线程执行时,没有发生锁竞争,当线程执行完同步方法后,会通过CAS将栈帧中lock record里保存的mark word替换回锁对象的对象头中;
当存在锁竞争并且轻量级锁膨胀为重量级锁,会导致持有锁的线程CAS替换mark word失败,因为锁标志位已经被修改了,此时就会释放锁,并唤醒阻塞的线程。
重量级锁
轻量级锁膨胀为重量级锁,在底层是利用操作系统互斥(Mutex)机制。这里也就不再做过多赘述了。
经过优化以后的synchronized关键字在性能上得到的提升,熟悉ConcurrentHashMap的同学应该知道,在jdk1.8之前采用“分段锁”的思想,采用数组+链表的结构,每个桶(Segment)都是继承自ReentrantLock,来保证并发的安全,但是在jdk1.8中已经放弃原有的ReentrantLock,而是大量采用synchronized+cas来保证并发安全,由此也能说明他的性能提升得到了认可。
总结
本文主要介绍以下几个方面:
- 介绍了synchronized关键字的特征;
- synchronized关键字的使用;
- 通过反汇编揭露了加锁的原理;
- 分析对象头和monitor结构,指出了wait和notify必须在同步代码中出现的原因;
- 介绍synchronized锁升级的过程。
参考:
[1]: 中华石衫
更多精彩内容,请关注我的个人公众号,扫描下方二维码或者微信搜索:
肖说一下