1 简述
Synchronized是Java的内置锁,可以保证方法或代码块在运行时,同一时刻只有一个线程可以进入到临界区(互斥性),同时它还保证了共享变量的内存可见性。
2 锁的是什么
- 方法
- 普通方法:锁的是当前实例对象
- 静态方法:锁当前类对象
- 代码块
- 锁传入的对象
3 CAS
谈Sync锁原理之前我们需要先了解什么是CAS操作。
CAS是一种比较交换,由硬件CPU实现,速度非常快,一般是纳秒级,比线程上下午切换快千倍不止。
// Unsafe.java
public final native boolean compareAndSwapInt(Object var1, long var2, int var4, int var5);
比如JDK中的Unsafe类就实现了很多CAS操作。
- var1 AtomicInteger对象本身。
- var2 该对象的引用地址。
- var4 需要变动的数量。
- var5 是用var1 var2找出的主内存中真实的值,用该对象当前的值与var5比较,如果相同,更新var5+var4并且返回true, 如果不同,继续取值然后再比较,直到更新完成。(自旋)
ABA问题
CAS解决并发问题虽然快,但是会有ABA问题。
什么是ASA?
打个比方,张三去平台租房,看到一套“未出租”状态的房子,但是钱不够,需要去凑点,所以并没有立即租房,只是记下了它的信息。
第二天,有个人租房,看中了这款,就租了,不过不满意又退了。房屋信息经历从“未出租”->”已出租“->“未出租”状态的修改,房屋里面也因为有人租过,发生了变化,比如,变脏了等等。
但是一周后,张三凑够钱来租房,看到房子还是未出租,以为还是一周前看房的样子,就把房子租了。租完后,才发现不符合预期。
这个故事就是类比的ABA。对一个对象做比较和交换,就很有可能发生,对象发生过修改,但是比较时引用和预期是一致的。
如何解决?
AtomicStampedReference
使用此类来保证对象,在修改一次后,会记录一次修改戳,这样就不仅可以比较值是否符合预期,还能知道值是否被修改过。
public boolean compareAndSet(V expectedReference,
V newReference,
int expectedStamp,
int newStamp) {
Pair<V> current = pair;
return
expectedReference == current.reference &&
expectedStamp == current.stamp &&
((newReference == current.reference &&
newStamp == current.stamp) ||
casPair(current, Pair.of(newReference, newStamp)));
}
自旋时间过长
使用CAS去抢占某个资源,很有可能出现抢不到的情况,发生长时间自旋,空耗CPU性能,所以我们需要控制自旋的次数。
4 锁升级过程
Sync锁在Java 1.6之前是通过线程阻塞,排队来保证并发安全,但是这种阻塞会带来比较大的时间等待。
所以在1.6以后,Sync锁采用锁升级的方式来提升效率。偏向锁 -> 轻量级锁 -> 重量级锁
偏向锁
在没有发生实际竞争的情况下,使用资源的其实一直是一个线程,这是JVM会使用偏向锁。只需要在Mark Word中CAS记录owner(本质上也是更新,但初始值为空),如果记录成功,则偏向锁获取成功,记录锁状态为偏向锁,以后当前线程等于owner就可以零成本的直接获得锁;否则,说明有其他线程竞争,膨胀为轻量级锁。
轻量级锁
轻量级锁就是线程在竞争的时候使用CAS自旋去抢占锁,默认10次自旋,如果10次后仍然得不到,就会进去线程挂起状态,进入阻塞队列,锁膨胀为重量级锁。
所以,轻量级锁使用与竞争不激烈的情况,也就是线程基本是交替执行的,就算发生资源竞争,时间也非常短。
重量级锁
线程进行CAS自旋次数过多就会膨胀为重量级锁,也被成为互斥锁、悲观锁。
这时发生竞争的线程都会进入阻塞队列等待,队列中会有一个线程不断去竞争资源。
那等待队列放在哪里?
在我们锁住的实例对象或类对象的对象头中,monitor指针指向的monitor对象。
//结构体如下
ObjectMonitor::ObjectMonitor() {
_header = NULL;
_count = 0;
_waiters = 0,
_recursions = 0; //线程的重入次数
_object = NULL;
_owner = NULL; //标识拥有该monitor的线程
_WaitSet = NULL; //等待线程组成的双向循环链表,_WaitSet是第一个节点
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ; //多线程竞争锁进入时的单向链表
FreeNext = NULL ;
_EntryList = NULL ; //_owner从该双向循环链表中唤醒线程结点,_EntryList是第一个节点
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
}
可以看到,有三个队列。
- _WaitSet:存放wait状态的线程
- _cxq:多线程竞争会放入此队列
- _EntryList:_WaitSet和_cxq中,有资格成为候选的线程会被移动到此队列中
_EntryList候选队列中的OnDeck线程会不断进行CAS尝试将_owner指向自己。成功就获取到了锁,执行同步代码块,失败继续自旋竞争,直到得到锁为止。
这样一个个线程 竞争 -> 获取锁 -> 执行 -> 释放锁 ,就解决了并发安全问题。
| bitfileds | 标志位 | |
|---|---|---|
| ptr to lock record | 00 | 轻量级锁 |
| hash | age | 0 | 01 | 无偏向 |
| thread ID | epoch | age | 1 | 01 | 偏向锁 |
| ptr to heavyweight monitor | 10 | 重量级锁 |
| - | 11 | 可GC |
5 与lock锁对比
前面谈到了CAS与锁升级,相比我们大致清楚了Sync锁处理过长。
但是,在实际Java开发中我们还会用到Lock锁(实现类ReentrantLock)。
这个锁的实现也是大量基于CAS,并且与Sync锁相比有以下区别:
- 需要手动的获取锁,释放锁(不合理使用,很可能造成死锁)
- 线程可以判断是否获取到了锁
- 可中断
- 公平或非公平,可选择
- 适合竞争非常激烈的情况(存在大量同步)
- 对于ReentrantLock而言,其是独享锁。但是对于另一个ReentrantReadWriteLock而言,其读锁是共享锁,其写锁是独享锁
6 总结
Synchronized是JVM的内置锁,保证了并发情况下的线程安全,且经过锁升级机制的优化后,也有了性能的提升。
不过,本质上还是悲观锁,在一定并发量上,会带来非常大的性能开销,是用Sync锁还是其他锁,需要具体情况具体分析。