AQS+锁技术



一、锁分类

1、公平锁与非公平锁

类型描述代表锁
公平锁公平锁就是,当线程执行到临界区时,竞争锁的线程直接进入同步队列进行排队,先来的线程可以先获得锁;待到释放锁时,唤醒队列的下一个线程去获取锁;ReentrantLock支持公平锁
非公平锁非公平锁就是,当线程执行到临界区时,竞争锁的线程直接CAS自旋尝试获取锁若能获得锁就直接持有,若自旋一定次数后依旧没有获得锁。则线程进入同步队列进行排队,队列中的线程依旧是按照先进先出的原则获取锁;synchronized锁、ReentrantLock支持非公平锁
  • 非公平锁省避免了线程切换的开销,效率比公平锁要高;

2、独占锁与共享锁

类型描述代表锁
独占锁锁一次只能被一个线程所持有synchronized锁、ReentrantLock锁支持独占模式
共享锁锁可同时被多个线程所持有ReentrantLock锁支持共享模式

3、可重入式锁与非可重入式锁

类型描述代表锁
不可重入式锁只判断这个锁有没有被锁上,只要被锁上,申请锁的线程都会被要求等待。继承AQS类,重写tryAcquire()-tryRelease()方法可以实现
可重入式锁不仅判断锁有没有被锁上,还会判断锁是哪个线程锁上的。如果是当前线程锁上的,那么它依旧可以再次访问临界资源,并把加锁次数加1。ReentrantLock默认就是可重入式锁

4、乐观锁与悲观锁

类型描述代表锁
乐观锁每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下,在此期间有没有别人去更新这个数据,可以使用版本号机制和CAS算法实现。CAS + 版本号机制,提高吞吐量
悲观锁每次访问临界区资源都会上锁synchronized、ReentrantLock都是悲观锁

5、分段锁

HashMap / HashTable / ConcurrentHashMap

数据结构是否线程安全
HashMap线程不安全
① put() 和 get()并发时,可能导致get为null;

② 多线程的put()操作可能会导致元素丢失;

③ JDK1.7出现hash冲突时,由于头插法,容易形成环形链表,导致死循环。JDK1.8后用尾插法解决了此问题;

④ 多线程扩容时,易出现死循环。
HashTable线程安全;HashTable对整个Hash表加了一把大锁,虽然线程安全,但是并发效率极差;
ConcurrentHashMap线程安全;

① JDK1.7时,底层基于是Segment(底层是volatile修饰的hashTable)分段锁机制 + 链表存储结构;

② JDK1.8时,若put()元素时,索引位置没有元素时,采用的是CAS的方式插入;若有元素,则对头节点加锁,然后再去put()元素。此外还有多线程扩容机制,确保不会出现线程安全问题;get()时,无需加锁。因为Node<k,v>的val和next指针都是被volatile修饰的,对其它线程可见;

ConcurrentHashMap底层原理⭐

//JDK1.7ConcurrentHashMap底层的数据结构
public class ConcurrentHashMap<K, V> extends AbstractMap<K, V>
                      implements ConcurrentMap<K, V>, Serializable {
    
    private static final long serialVersionUID = 7249069246763182397L;
    // 分段数组,每一段都是一个 hash 表
    final Segment<K,V>[] segments;

}
//分段Segment对象,继承了
static final class Segment<K,V> extends ReentrantLock implements Serializable {
    // 每段中的表
    transient volatile HashEntry<K,V>[] table;
}
  • Segment<K,V>对象: 继承了 ReentrantLock,能充当可重入式锁的角色;其实Segment就是hashtable;
  • HashEntry<K,V>对象: 被valatile修饰,用于存储键值对数据。

JDK1.7时的源码解析
JDK1.8时的源码解析

区别JDK1.7的ConcurrentHashMapJDK1.8的ConcurrentHashMap
数据结构Segmeng数组 + HashEntry数组 + 链表数组 + 链表 + 红黑树
底层原理1、调用concurrentHashMap.put(k,v) 方法:
① 当执行 put(k,v) 方法时,若value为null,则直接抛出空指针异常 (因为值为null会有歧义);

② 对传入的key值进行hash,并对Segment的数量取模,获得分段锁的下标;

③ 若Segment不存在,那么调用ensureSegment() 方法创建一个Segment;

④ 然后调用segment.put()方法,开始存放数据;

2、Segment.put()的具体逻辑如下:
⑤ 调用tryLock() 方法尝试获取锁,如果获取锁失败,则调用scanAndLockForPut() 方法获取锁;

⑥ scanAndLockForPut()方法内部死循环式的while(!tryLock())自旋获取锁,最大自旋次数64次,超过后会自动加互斥锁。
在获取锁过程中扫描key,若被其它线程修改过,则重新遍历,自旋次数-1;

⑦ 获取锁成功后,再根据hash值对HashEntry的数量进行取模运算,得到存放数据的HashEntry索引,取出头节点(JDK1.7底层是链表),开始真正的存放数据;

⑧ 遍历链表,若找到了相同的key,则更新值;若没找到相同的key,则使用 头插法 将value插入到链表头节点;

⑨ put() 完成后,若需要扩容则进行扩容,并且调用rehash();最后调用unLock()释放锁,完成put(k,v)操作;
1、调用concurrentHashMap.put(k,v) 方法:
① 当执行 put(k,v) 方法时,底层实际调用的方法是putVal(k,v)方法;

② 对传入的key值进行hash(对Node节点的数量进行取模),得到存放数据的节点位置;
注意: Node<k,v>就是一个Entry,Node为数组 / 链表头节点 / 红黑树根节点;

③ 如果底层数组结构还未初始化,则CAS的初始化;

④ 若hash计算得到的桶位置没有元素,利用CAS的方式插入元素;

⑤ 若hash计算得到的桶位置状态为moved,则表明正在扩容,则调用helpTransfer()方法参与扩容;

⑥ hash计算的桶位置元素不为空,且当前没有处于扩容操作,进行元素添加;具体的,先对当前桶加 synchronized锁,保证线程安全的前提下执行添加操作;

⑦ 判断当前桶的数据结构:
若是链表: 则使用 尾插法 将value插入到链表尾部;
若是红黑树: 那么则将value添加到红黑树;

⑧ 插入完成后,若结构为链表且元素数量 ≥ 8,那么就转为红黑树;

⑨ put() 完成后,判断是否需要扩容;执行完全部逻辑后,synchronized释放锁,完成put(k,v)操作;
扩容机制在每个Segment内部进行扩容,和HashMap的扩容逻辑类似;先生成新的数组,然后转移元素到新数组中;JDK1.8有两处扩容时机(底层时机调用的都是transfer()扩容方法):

① 插入元素前,hash到指定的Entry位置时,若Entry状态为Moved,则调用helpTransfer()方法多线程协助扩容,扩容后再添加元素;

② 插入元素后,若达到扩容阈值,则调用addCount()方法扩容;

二、Synchronized锁

关键描述
synchronized
锁类型
1、非公平锁: 当线程执行到临界区时,竞争锁的线程直接CAS自旋尝试获取锁。若能获得锁就直接持有。若自旋一定次数后依旧没有获得锁,则线程进入同步队列进行排队获取锁;

2、可重入锁: synchronized可重入特性依托于Monitor对象中的计数器,若该线程获取当前锁后,再一次请求当前锁时,允许获取,并且Monitor中的计数器count+1;
synchronized
底层原理
1、修饰代码块(显式同步): 对指定对象加锁; 底层通过MonitorEnter指令MonitorExit指令来管理Monitor对象的获取与释放;在JVM中,Monitor是由ObjectMonitor实现的;它的底层有两个队列,分别是两个双向链表EntryList和WaitSet,分别用于管理等待获取锁的线程与阻塞的线程;

① 当多个线程同时访问同步代码块时,由于是非公平锁,所有线程都先去尝试自旋获取锁;

② 达到最大自旋次数后,依然没有获得锁的线程会进入EntryList队列进行排队等待锁的释放;

③ 当持有锁的线程释放Monitor对象后,按照先进先出的原则,EntryList队首线程将获得锁并持有Monitor对象,进入owner区域,并把Monitor的owner属性设置为当前线程,并把计数器count+1;

④ 若线程调用了wait()方法,那么该线程将释放持有的Monitor对象,并将owner变量恢复为null,计数器count-1,同时该线程进入WaitSet队列(双向链表)等待被唤醒;

⑤ 待到当前线程执行完毕,则释放Monitor对象并重置计数器count,此时其它线程可继续抢锁;

2、修饰方法(隐式同步): synchronized修饰方法时,底层并不会通过MonitorEnter指令与MontiorExit指令来进行Monitor对象的管理,而是会给该方法标记 acc_synchronized 字段,表明这是一个同步方法。当方法调用时,JVM会检查方法表结构中的「acc_synchronized」字段是否被设置。若被设置了,则执行线程会先去获取Monitor对象,然后再执行方法。待到方法执行完成后,释放Monitor对象。

修饰实例方法时,对当前实例对象加锁;方法加标识后获取当前实例对象的Monitor;
修饰静态方法时,对当前类对象加锁;方法加标识后获取当前类对象的Monitor;
synchronized
锁升级
无锁 → 偏向锁 → 轻量级锁 → 重量级锁;

① 偏向锁: 不存在多线程竞争,锁总是被同一个线程获得;主要是根据对象头MarkWord字段中记录的偏向线程ID实现的;

② 轻量级锁: 存在第二个线程申请锁,但不存在竞争。交替持有锁时,锁升级为轻量级锁,此时「Mark Word」字段指向持有锁的线程的「LockRecord」;在轻量级锁下,其它线程将会基于「自旋」的形式尝试获取锁,而「不会阻塞」;

③ 重量级锁: 存在多线程竞争锁,则升级为重量级锁;此时Mark Word字段记录持有对象监视器Monitor的线程;在重量级锁下,其它线程将会进入等待队列EntryList,等待锁的释放;
synchronized
锁优化
① 锁升级: 无锁 → 偏向锁 → 轻量级锁 → 重量级锁;

② 锁消除: JIT编译优化,自动去掉那些不可能存在竞争的锁;

③ 锁粗化: JIT编译优化,避免重复性的加锁与释放锁,比如for循环内部加锁,可以移到for循环外部;

④ 自旋机制: 基于CAS的自旋锁与自适应锁;

1、对象的内存布局

  • Monitor对象的位置对象的对象头区域中的Mark Word字段里,在这里插入图片描述

2、Monitor对象详解

在这里插入图片描述

ObjectMonitor对象结构

//结构体如下
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 ;  
}  

三、AQS锁框架

AQS源码解析

关键描述
核心思想总的来说: AQS就是基于底层的同步等待队列,获取volatile修饰的共享变量state,线程通过CAS自旋的改变状态符,若改变成功则获取锁成功,失败则进入同步等待队列进行排队与释放;

AQS全称为AbstractQueuedSynchronizer(抽象队列同步器),它是基于模板方法的锁同步框架;

② AQS的底层维护了一个用volatile修饰的int类型的state状态字段,用于表示当前共享资源的状态;获取共享资源时是基于CAS的方式进行的;

③ 它支持独占式共享式两种获取资源的方式,并基于此开放了4个模板方法与1个获取资源状态的方法,用于资源的获取与释放;AQS可以通过重写模板方法中对共享资源的获取与释放逻辑,来实现公平锁可重入锁

④ 对于获取不到共享资源的线程,AQS提供了一个双向链表结构的同步队列进行管理;对于调用了await()方法而阻塞的线程,AQS提供了一个单向链表结构的条件队列进行管理;
资源共享方式AQS定义了两种资源共享方式:独占式、共享式

1、独占式: 同一时刻只能有一个线程能够获取共享资源,比如ReentrantLock锁。独占式锁又可以分为公平锁 / 非公平锁。对应模板方法为: tryAcquire()tryRelease()

① 公平锁: 线程进入同步队列排序,按先后顺序获取锁;

② 非公平锁: 线程无视同步队列顺序,直接自旋抢锁。达到最大自旋次数后,进入同步等待队列按顺序获取锁。

2、共享式: 同一时刻可以有多个线程获取共享资源,比如信号量 (Semaphore) 和 循环栅栏 (CountDownLatch) 等;对应模板方法为: tryAcquireShared()tryReleaseShared()
同步队列1、进入同步队列时机: 当共享资源被某个线程占有后,其它请求该资源的线程会被阻塞,进入同步队列排队等待。AQS的底层维护了一个双向链表结构的同步队列,它是一个虚拟队列。不存在队列实例,只存在节点之间的关联关系。

2、Node节点: AQS将每个请求共享资源的线程信息封装成一个Node节点来实现锁的分配与释放。Node节点有四种状态: 只有状态码小于0的节点才需要被唤醒。

① Canceled = 1 (取消): 表示当前节点从同步队列中取消;自旋获取锁出现异常时改为此状态;

Signal = -1 表示后继节点的线程处于等待状态,如果当前节点释放同步状态则会通知后继节点,使得后继节点能够运行;

③ Condition = -2: 表示当前节点的线程调用了await()方法,正在条件队列中等待被signal()唤醒,重新变成Signal状态;

④ Propagate = -3: 表示下一次共享式同步状态的获取将会无条件传播下去;

1、独占式-获取与释放锁的逻辑

关键描述
独占式获取锁逻辑
tryAcquire()
Step1: 当某个线程共享资源时,首先CAS抢锁,抢锁失败后调用AQS的模板方法acquire()方法,底层调用的tryAcquire()方法,该方法会判断当前的state字段是否为0;若为0则说明没有线程持有锁,此时直接CAS直接拿到锁,执行同步代码块;

Step2: 若CAS获取锁失败,则首先判断当前线程是否持有锁;若持有锁,则根据可重入性更新state字段的值。然后获取到锁,执行同步代码块。若持有锁的不是当前线程,则调用addWaiter()方法将当前线程封装成一个Node节点,方法内部以自旋的形式保证Node节点能够加入同步队列尾部成功;

Step3: 入队操作成功后,执行acquireQueued()方法;

 ① 若自旋过程发现当前节点的前驱节点是头节点,则此时自旋式的调用tryAcquired()方法,获取锁成功后,将当前线程Node设置为头节点,表示拿到了锁,此时能够访问共享资源;

 ② 若自旋获取锁的过程中出现异常,则调用cancelAcquire()方法,将当前线程Node节点的状态改为Canceled;

 ③ 若自旋的过程获取同步状态 (锁) 失败,则从当前线程Node节点开始,向前寻找第一个不为Conceled状态的节点作为前驱节点,并将其的节点状态设置为Signal。然后执行park()操作,阻塞当前线程Node,等待前驱节点为头节点时,才唤醒它调用 tryAcquired() 方法开始抢锁;

Step4: 由于AQS的acquire()方法对线程中断不敏感,因此在抢锁等待锁的过程中,如果线程被中断,同步队列不会移除它。因此,AQS底层设置了一个标识位,用于记录线程在同步队列等待过程中是否被中断过。因此,在acquireQueued()执行结束后,若发现这个标识位为true,则表示线程中断过,此时要调用selfInterrupt()方法使得线程恢复中断状态;

至此,获取锁的acquire()逻辑结束。
独占式释放锁逻辑
tryRelease()
Step1: 当某个线程释放锁时,首先会调用release()方法,底层调用AQS中的tryRelease()方法,逻辑很简单,就是将CAS的将state字段恢复为0。

Step2: 锁释放成功后,找到当前节点后第一个状态小于0的节点,调用unparkSuccessor()方法唤醒该节点对应的线程,通知它去自旋的获取锁。

2、共享式-获取与释放锁的逻辑

关键描述
共享式获取锁
逻辑
acquireShared()
Step1: 当锁的类型是共享式时,首先会调用的acquireShared()方法,AQS底层调用的时tryAcquireShared()方法,若state字段的值大于0,则获取锁成功;

Step2: 若获取锁失败,则调用doAcquireShared()方法。该方法会将当前线程信息封装成一个Node节点,调用addWaiter()方法将Node节点添加到同步队列的队尾;

Step3: 入队成功后,在doAcquireShared()方法内部自旋式的判断其当前节点的前驱节点的状态,如果前驱节点的状态为头节点,那么就调用tryAcquireShared()方法尝试获取锁。

① 若获取锁成功,就将当前节点设置为头节点,同时可以访问共享资源。

② 若自旋获取锁的过程中出现异常,则调用cancelAcquire()方法,将当前线程Node节点的状态改为Canceled;

③ 若自旋获取同步状态 (锁) 失败,那么就向前寻找第一个状态不为Conceled的节点作为前驱节点,将其状态设置为Signal,然后调用park()方法阻塞当前线程,等待前驱节点为头节点时,才唤醒它调用 tryAcquireShared()方法开始抢锁;

至此,获取锁的acquireShared()逻辑结束。
共享式获取锁
逻辑
releaseShared()
Step1: 当某个线程释放锁时,首先会调用releaseShared()方法,底层调用AQS中的tryReleaseShared()方法,逻辑很简单,就是将CAS的将state字段恢复初值。

Step2: 锁释放成功后,调用doReleaseShared()方法,找到当前节点后第一个状态小于0的节点,调用unparkSuccessor()方法唤醒该节点对应的线程,通知它去自旋的获取锁。
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值