并发之Synchronized锁分析

241 阅读5分钟

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 record00轻量级锁
hash | age | 001无偏向
thread ID | epoch | age | 101偏向锁
ptr to heavyweight monitor10重量级锁
-11可GC

5 与lock锁对比

前面谈到了CAS与锁升级,相比我们大致清楚了Sync锁处理过长。
但是,在实际Java开发中我们还会用到Lock锁(实现类ReentrantLock)。
这个锁的实现也是大量基于CAS,并且与Sync锁相比有以下区别:

  • 需要手动的获取锁,释放锁(不合理使用,很可能造成死锁)
  • 线程可以判断是否获取到了锁
  • 可中断
  • 公平或非公平,可选择
  • 适合竞争非常激烈的情况(存在大量同步)
  • 对于ReentrantLock而言,其是独享锁。但是对于另一个ReentrantReadWriteLock而言,其读锁是共享锁,其写锁是独享锁

6 总结

Synchronized是JVM的内置锁,保证了并发情况下的线程安全,且经过锁升级机制的优化后,也有了性能的提升。
不过,本质上还是悲观锁,在一定并发量上,会带来非常大的性能开销,是用Sync锁还是其他锁,需要具体情况具体分析。