Blage's Coding Blage's Coding
Home
算法
  • 手写Spring
  • SSM
  • SpringBoot
  • JavaWeb
  • JAVA基础
  • 容器
  • Netty

    • IO模型
    • Netty初级
    • Netty原理
  • JVM
  • JUC
  • Redis基础
  • 源码分析
  • 实战应用
  • 单机缓存
  • MySQL

    • 基础部分
    • 实战与处理方案
    • 面试
  • ORM框架

    • Mybatis
    • Mybatis_Plus
  • SpringCloudAlibaba
  • MQ消息队列
  • Nginx
  • Elasticsearch
  • Gateway
  • Xxl-job
  • Feign
  • Eureka
  • 面试
  • 工具
  • 项目
  • 关于
🌏本站
🧸GitHub (opens new window)
Home
算法
  • 手写Spring
  • SSM
  • SpringBoot
  • JavaWeb
  • JAVA基础
  • 容器
  • Netty

    • IO模型
    • Netty初级
    • Netty原理
  • JVM
  • JUC
  • Redis基础
  • 源码分析
  • 实战应用
  • 单机缓存
  • MySQL

    • 基础部分
    • 实战与处理方案
    • 面试
  • ORM框架

    • Mybatis
    • Mybatis_Plus
  • SpringCloudAlibaba
  • MQ消息队列
  • Nginx
  • Elasticsearch
  • Gateway
  • Xxl-job
  • Feign
  • Eureka
  • 面试
  • 工具
  • 项目
  • 关于
🌏本站
🧸GitHub (opens new window)
  • JAVA基础

  • 集合容器

  • Netty

  • JVM

  • JUC

    • 并发机制初识
    • JMM语义与重排
    • 多线程通信与编程应用
    • Lock并发锁原理
      • Lock接口
      • 队列同步器AQS
        • 模板接口
        • 同步器AQS源码实现
        • 1.同步队列
        • 2.独占式同步状态获取与释放
        • 3.共享式同步状态获取与释放
        • 4.独占式超时获取同步状态
        • 案例——自定义同步组件
      • ReentrantLock重入锁
        • 1.重入性
        • 2.公平锁
        • 3.公平锁和非公平锁对比
      • ReentrantReadWriteLock读写锁
        • 接口示例
        • 源码分析
        • 1.读写同步状态划分
        • 2.写锁获取与释放
        • 3.读锁获取与释放
        • 4.锁降级
      • LockSupport工具
      • Condition接口
        • 接口示例
        • 源码分析
    • 并发容器与框架
    • 原子操作类
    • 并发工具类
    • 线程池
    • Executor框架
    • 并发编程实践
    • JUC面试
  • Java
  • JUC
phan
2024-04-01
目录

Lock并发锁原理

# Lock并发锁原理

本章主要介绍:

  • Java并发包中锁的源码实现
  • 并发锁API

# Lock接口

synchronized关键字 Lock接口
获取/释放锁 隐式 显示编程实现
可重入 支持 ReentrantLock显式重入
灵活性 差 支持中断获取锁,超时获取锁

Lock常用的接口包括:

  • void lock():获取锁。

  • boolean tryLock():尝试非阻塞获取锁,立即返回。

  • boolean tryLock(long time):超时阻塞时获取锁。仅当①超时间内获取到锁②超时时间内被中断③到达超时时间三种情况下会返回。

# 队列同步器AQS

# 模板接口

同步状态

同步状态可以理解为同步器的锁资源,多线程竞争Lock的锁资源实际上是在竞争AQS的“同步状态”。

用一个int整数存储表示,语义上可以理解为锁资源的大小、允许的并发度。

AbstractQueuedSynchronizer(AQS)同步器提供了三个对“同步状态”操作的原子方法和查看锁占有线程的API:

  • getState():获取当前同步状态
  • setState(int newState):设置当前同步状态
  • compareAndSetState(int current , int update):CAS更新当前状态。其中current表示当前getState结果。
  • getExclusiveOwnerThread:返回当前独占锁的线程。
  • setExclusiveOwnerThread(thread):设置当前占有锁的线程。

AQS抽象类主要面向锁的开发者,基于模板模式,所提供的方法可以分为两类:

  • 可重写方法:包括tryAcquire(),tryRelease()等。基于上述三个原子操作对锁资源进行操作。
  • 模板方法:同步器定义好可以直接拿来使用,包括acquire(),release()...模板方法会调用上面重写的方法。

实现的锁接口方法中,需要调用AQS模板方法实现同步。基于同步器框架可以实现同步组件,伪代码如下:

public class Mutex implements Lock {
    static class Sync extends 同步器 {
		//重写同步器方法
        public boolean tryAcquire(int acquires){}
    }
    private final Sync sync=new Sync();
    //外部暴露的锁方法。实际上都通过调用上述代理的同步器对象的方法实现
    public void lock() { sync.acquire(1); }
    public boolean tryLock() {return sync tryAcquire(1)}
    public void unlock() {}
}
1
2
3
4
5
6
7
8
9
10
11

显然用户在使用Lock锁对象时,并不会直接和同步器打交道。锁Lock负责与用户交互使用,而底层代码实际上是基于同步器框架实现的。

# 同步器AQS源码实现

在AQS不同方法中,所谓死循环“自旋”分成以下两种:

  • 模板方法:不断执行try方法尝试获取锁,根据try方法才能退出死循环
  • 重写的try方法:锁可以获取的情况下重复执行CAS方法,直到成功修改同步状态位。无锁状态则不需要自旋。

# 1.同步队列

内部依赖于自定义的Node数据结构,它是一个FIFO双向同步队列,保存的节点是获取同步状态失败的线程引用。

同步器内维护队列的头节点和尾节点。队首node节点成功获取到同步状态后出队,并更新同步器的头节点。

  • 队首元素更新不需要CAS,因为只会有一个元素能够获取同步状态。
  • 队尾元素插入更新需要拿到CAS,因为同时会有多个元素同时竞争获取同步状态。

# 2.独占式同步状态获取与释放

独占式:同一时刻只能有一个线程成功获取同步状态。通过同步器的acquire原生方法实现,主要逻辑如下:

  1. 执行重写的同步器方法tryAcquire(),若获取失败则执行下面步骤。
  2. 构造同步节点,并通过CAS加入同步队列的尾部。
  3. 节点调用acquireQueued,进入自旋状态,以死循环+阻塞的方式尝试获取同步状态。只有前驱是头节点的节点才会尝试获取同步状态,从而保证FIFO的队列特性。以下两种情况被唤醒:
    • 前驱节点出队列
    • 阻塞线程被中断

线程acquire拿到锁,并返回执行完相应逻辑后,需要释放同步状态并唤醒后继节点。

# 3.共享式同步状态获取与释放

共享式:同一时刻可以有多个线程同时获取到同步状态。通过同步器的acquireShared原生方法实现。

共享式和独占式一样,如果锁获取失败,都会死循环自旋不断尝试获取同步状态。区别在于,共享式退出自旋的条件变为p.prev==head&&tryAcquireShared()>=0,重写方法tryAc返回值大于等于0表示能够获取到同步状态。

“同步状态”释放时,会有多个线程同时释放锁,因此需要保证线程安全。(独占式每次只有一个线程释放,不需要考虑线程安全)

# 4.独占式超时获取同步状态

同步器原生方法doAcquireNanos:核心逻辑与独占式和共享式类似。区别在于在自旋状态中,每轮循环会计算一次经过的时间间隔,并从超时时间nanosTimeout里面扣减。如果当前超时时间被扣减到小于0,则说明当前超时,直接返回退出。

若时间nanosTimeout比较短,则进入无条件的快速自旋。

# 案例——自定义同步组件

功能:同一时刻,只允许至多两个线程同时访问。超过两个线程的访问将会被阻塞。

基于上述API和接口,采用共享模式实现如下:

public class MyLock implements Lock {
    private static class Sync extends AbstractQueuedSynchronizer {
        public Sync(int count) {
            if (count < 0) {
                throw new IllegalArgumentException("count must larger than zero");
            }
            setState(count);
        }
        //返回更新后的同步状态值,小于0则表示获取失败
        public int tryAcquireShared(int reduceCount){
            while (true) {
                int current = getState();
                int newState = current - reduceCount;
                //扣减失败,返回小于0的同步状态结果
                if (newState < 0 ) {
                    return newState;
                }
                //大于0,则执行CAS成功后才进行扣减
                else{
                    if(compareAndSetState(current, newState))
                    return newState;
                }
            }
        }
        public boolean tryReleaseShared(int returnCount){
            while (true) {
                int current = getState();
                int newState = current + returnCount;
                if (compareAndSetState(current, newState)) {
                    return true;
                }
            }
        }
    }
    private static Sync sync = new Sync(2);
    
    @Override
    public void lock() {
        sync.acquireShared(1);
    }
    @Override
    public void unlock() {
        sync.releaseShared(1);
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45

# ReentrantLock重入锁

ReentrantLock锁主要包含如下特性:

  • ReentrantLock是一个独占锁(排他锁),仅允许一个线程同时持有锁。

  • ReentrantLock的同步状态state语义上相当于一个计数器,代表当前占有锁的线程重入的次数。

    • state=0:代表当前锁没有线程占用,任何线程可以获取锁,需要CAS抢占锁。
    • state=n:代表锁被某个线程重入了n次。
  • 重入性:占有锁的线程支持再次调用lock方法,重新获取锁进入同步块。

  • 公平性:先申请获取锁的请求优先被满足,获取锁的过程是顺序的。

# 1.重入性

重入性实现核心是通过getExclusiveOwnerThread判断当前线程是否为占有锁的线程,分为获取和释放两部分:

  • 获取锁逻辑:首先判断当前同步状态,如果为0则直接抢占锁;若大于零,则判断线程是否为占用锁的线程,如果是同一个线程,累加同步状态计数器。
  • 释放锁逻辑:同步状态计数器扣减当前释放线程数,当且仅当同步状态减到0才算真正的释放完毕,将锁占有线程置为空并返回true。

# 2.公平锁

ReentrantLock默认实现是非公平锁。公平锁能够实现按照“请求顺序”获取锁,与AQS内部队列顺序保持一致。

公平锁请求顺序

“请求顺序”是指多线程调用tryAcquire的顺序,它不等于AQS内部等待队列的顺序,也不等于代码执行顺序。

无论是公平锁还是非公平锁,如果所有线程都进入了等待队列,那么都必须按照AQS内部等待队列的顺序执行。

当前可以获取锁时,CAS操作多加hasQueuedPredecessors()判断条件,如果当前节点有前驱节点返回true,保证每个能够获取锁的一定是头结点,没有插队的线程。

if(!hasQueuedPredecessors() && compareAndSetState() )
1

而非公平锁,某一个时刻还没加入等待队列时tryacquire,如果当前正好锁释放了可以获取同步状态,那么当前线程无需加入等待队列阻塞,直接拿锁返回,CPU无需唤醒其它线程。

# 3.公平锁和非公平锁对比

💡对比实验需要死循环调用获取锁。若所有线程都进入同步队列,那么结果毫无意义,无法区分是否公平。

  • 线程饥饿现象

非公平锁中,刚释放完锁的线程即使处于队列尾部,也可能会立刻重新获取到锁,从而导致其它线程一直不能调度,出现”饥饿“的情况。

而在公平锁中,刚释放的锁排在队尾,必定不能立刻重新获取锁。

  • 上下文开销

虽然非公平锁不能调度,但连续两次线程获取到线程,不需要进行上下文切换,减少系统开销,吞吐和效率更高

而反过来,公平锁实现FIFO的代价就是,进行大量上下文切换,开销大。

# ReentrantReadWriteLock读写锁

读锁多个线程同时访问,写锁则只允许一个线程。两种实现方式:

  • 基于wait/notify的等待通知机制,但是代码编程复杂
  • ReentrantReadWriteLock读锁和写锁,简单明了。

# 接口示例

ReadWriteLock提供的API主要分为两类:

  • 获取读写锁:readLock(),writeLock(),返回Lock接口的引用,通过调用读写锁的lock,unlock方法实现读写并发控制。
  • 监控内部读写锁状态的方法:包括getReadLockCount(),isWriteLocked()...获取读锁次数,写锁是否获取。

# 源码分析

# 1.读写同步状态划分

将同步状态按位切割,分别存储“读锁”的同步状态,“写锁”的同步状态:

  • 写锁同步状态:state=0代表当前未获取,state>0代表线程重入的次数。
  • 读锁同步状态:state作为计数器记录当前获取读锁的线程数。

getState>0,写锁同步状态为0,那么可以推出一定有读锁同步状态大于0,当前处于读状态。

# 2.写锁获取与释放

写锁抢占获取逻辑如下:

  • 如果读写同步状态大于0,分别判断读同步状态和写同步状态:
    • readState>0,则获取写锁失败。
    • writeState>0,则进行重入性判断,当前线程不是重入线程则失败。否则获取写锁成功。
  • 同步状态等于0,则需要CAS占用锁。

写锁释放逻辑与ReentrantLock锁释放逻辑相似。

# 3.读锁获取与释放

读锁获取逻辑:只要写状态为0,读状态大于0,则读锁总会被成功获取。另外如果当前线程获取了写锁,那么当他尝试获取读锁时,也能成功获取(降级)。

读同步状态指的是,所有读线程重入读锁的次数之和。每个线程单独维护一个ThreadLocal对象,保存当前线程重入的次数之和。

读锁释放与ReentrantLock释放类似,需要CAS控制并发。

# 4.锁降级

锁降级指的是并发情况一种数据可见性控制策略,适用于特定的场景。

  1. 获取持有写锁
  2. 尝试获取读锁
  3. 释放写锁

其中中间这一步获取读锁,目的是为了保证数据的可见性。当前线程释放写锁后还持有读锁,这使得其它阻塞在写锁的线程仍然处于被阻塞的状态。相当于延长了当前线程对数据一致性的控制周期。

# LockSupport工具

定义了一组公共静态方法,用于控制线程阻塞和唤醒:

方法名称 描述
park() 阻塞当前线程
unpark(thread) 唤醒处于阻塞状态的thread线程
parkNanos(blocker,nanos) blocker标识当前线程阻塞对象

# Condition接口

监视器方法用于在锁同步块内实现线程的等待通知机制,目前有两种实现方式:

  • Object对象的监视器方法:wait,notify, notifyAll。与synchronized配合
  • Condition接口监视器方法:await。与Lock.lock配合

# 接口示例

Condition对象在await()阻塞和signal()通知线程时,必须先获取与其绑定的Lock锁对象。

static Lock lock = new ReentrantLock();
static Condition condition = lock.newCondition();
public void conditionAwaitOrSignal() throws InterruptedException {
    lock.lock();
    try {
        condition.await();//阻塞当前线程
        //condition.signal(); 其它线程调用唤醒当前线程
    } finally {
        lock.unlock();
    }
}
1
2
3
4
5
6
7
8
9
10
11

和先前等待通知机制的三段式相同,阻塞代码需要使用while而不是if,防止过早或者意外通知。

while (count==queue.length) addCondition.await();
1

# 源码分析

ConditionObject是AQS同步器的内部类,每个Condition对象都维护一个FIFO“等待队列”,每个节点都是在该Condition对象上等待的线程。

在AQS同步器+Condition模型的监视器模型中包含:

  • 一个AQS同步队列
  • 多个Condition等待队列

底层无论是await方法,还是signal方法,都用到了LockSupport进行阻塞和唤醒节点线程:

  1. await():线程被Condition.await方法阻塞时,相当于将当前线程从AQS同步队列的头结点,移动到了Condition等待队列当中。
  2. signal():从Condition等待队列头节点移除,加入到同步队列中。再使用LockSupport唤醒(跳出await方法的while循环),继续进行锁的竞争。
  3. signalAll():所有等待队列的节点都执行一次signal,移动到同步队列。
编辑 (opens new window)
#JUC
上次更新: 2024/04/08, 16:52:36
多线程通信与编程应用
并发容器与框架

← 多线程通信与编程应用 并发容器与框架→

Theme by Vdoing | Copyright © 2023-2024 blageCoder
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式