并发编程学习8—AQS和ReentrantLock源码解析

J.U.C.并发工具包的使用

1. AQS原理

1.1 概述

全称是 AbstractQueuedSynchronizer,抽象同步队列,简称 AQS ,是Java并发包的根基,J.U.C.并发包中的锁(lock)就是基于AQS实现的。是阻塞式锁和相关的同步器工具的框架(其他工具都是它的子类)。AQS是一个接口,这个接口定义了一系列规则,而是否要设定公平锁与否由实现它的类来决定。

特点:

  1. state 属性来表示资源的状态(分独占模式和共享模式),子类需要定义如何维护这个状态,控制如何获取锁和释放锁。
    - getState - 获取 state 状态
    - setState - 设置 state 状态
    - compareAndSetState - cas 机制设置 state 状态
    - 独占模式是只有一个线程能够访问资源,而共享模式可以允许多个线程访问资源

  2. 提供了基于 FIFO 的等待队列,类似于 Monitor 的 EntryList。

  3. 条件变量来实现等待、唤醒机制,支持多个条件变量,类似于 Monitor 的 WaitSet。

通过子类继承AQS父类,子类主要实现这样一些方法(默认抛出 UnsupportedOperationException)
tryAcquire
tryRelease
tryAcquireShared
tryReleaseShared
isHeldExclusively
在这里插入图片描述

1.2 实现不可重入锁

【AQSTest3.java】用AQS自己实现一个独占锁

// 我的自定义锁 (不可重入锁)  
class myLock implements Lock{  
  
    // 【独占锁】的自定义 同步器类 (做给自定义锁调用)  
    // 锁要实现的大部分功能都是通过同步器类完成的,这边的自定义继承了AQS  
    class MySync extends AbstractQueuedSynchronizer {  
  
        @Override       // 尝试获得锁  
        protected boolean tryAcquire(int arg) {  
            // 用cas,是因为你尝试加锁时,可能别的线程也在尝试加锁,保证原子性  
            if(compareAndSetState(0, 1)) {  
                // 加上了锁(0 -> 1),并且设置Owner为当前线程  
                setExclusiveOwnerThread(Thread.currentThread());  
                return true;            }  
            return false;  
        }  
  
        @Override  
        protected boolean tryRelease(int arg) {  
            // 注意这两个方法顺序。 因为setState里面是volatile的  
            setExclusiveOwnerThread(null);  
            setState(0);  
            return true;        }  
  
        @Override       // 是不是【持有】独占锁  
        protected boolean isHeldExclusively() {  
            // state 为1时表示持有独占锁  
            return getState() == 1;  
        }  
  
        public Condition newCondition(){  
            // ConditionObject是上面继承的那个的内部类,可以直接使用  
            return new ConditionObject();  
        }  
    }  
  
    // 下面通过MySync实现下面锁的方法  
    private MySync sync = new MySync();  
  
    @Override       // 加锁 (如果不成功会放到队列中等待)  
    public void lock() {  
        sync.acquire(1);  
    }  
  
    @Override       // 加锁,但可打断 (以前syn不能打断,容易死锁)  
    public void lockInterruptibly() throws InterruptedException {  
        sync.acquireInterruptibly(1);  
    }  
  
    @Override       // 尝试加锁 (【只试一次】,失败返回false)  
    public boolean tryLock() {  
        return sync.tryAcquire(1);  
    }  
  
    @Override       // 尝试加锁,带超时时间  
    public boolean tryLock(long time, TimeUnit unit) throws InterruptedException {  
        return sync.tryAcquireNanos(1, unit.toNanos(time));  
    }  
  
    @Override  
    public void unlock() {  
        // 上面写的tryRelease不会去唤醒等待队列中阻塞的线程。  
        // 这边看源码,除了两个操作,还会去唤醒【正在阻塞的线程】  
        sync.release(1);  
    }  
  
    @Override       // 创建条件变量  
    public Condition newCondition() {  
        return sync.newCondition();  
    }  
}

2. ReentrantLock 原理

在这里插入图片描述

P238

2. 1 非公平锁实现原理

2. 1. 1 加锁解锁流程

Ctrl + F12 File structure
Ctrl + Alt + B 查找实现
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

加锁流程总结:

  1. 默认非公平锁,加锁时用compareAndSetState去改变状态变成1,修改成功,Owner线程就会改成当前线程。
  2. 第二个线程来,出现竞争,CAS失败进去else的acquire(1),去做tryAcquire,相当于再试一次,还是不行就构造Node双向队列(懒惰)。这个等待队列再第一次创建节点时,会创两个,第一个是哨兵节点,不关联线程,第二个会让当前线程进入acquireQueue。
  3. 这个逻辑是这样,它会在一个死循环里不断尝试获得锁(最后一试),失败的话进入park阻塞。如果这个时候自己紧邻着head节点(排第二),还能再试一次,去tryAcquire。如果这个时候再失败,进入shouldParkAfterFailedAcquire,就会把前驱节点的waitStatus状态改成-1(初始0),-1意味着有责任唤醒它的后继节点(也就是当前线程不能获得锁了,得去阻塞,但得给一个节点将来唤醒它),这次返回false。再把acquireQueue的流程再走一遍,就会返回true。
  4. true了就会进入parkAndCheckInterrupt,就会让当前线程阻塞住。

解锁流程总结:

  1. 如果无竞争,从unlock调用同步器的release方法,进入tryRelease,成功就设置state为0,Owner为null。
  2. 判断当前队列不为null,且head的waitStatus为-1,就要去唤醒后继节点,去unpark恢复运行,回到这个线程(T1)的acquireQueue过程的parkAndCheckInterrupt(之前阻塞着),这时被唤醒,又进入循环(在for(;😉),就tryAcquire,改变state和Owner。
  3. 把原来T1的节点清空设置为头结点,原来头结点断开连接(可被GC)。
  4. 因为非公平锁,如果有竞争,新线程不在Node队列,也想获得锁,就会和刚才T1竞争,如果新线程赢了,新线程被设置为Owner,state为1。T1再次进入acquireQueue过程,获取锁失败,重新park阻塞。

2. 2 可重入原理

在这里插入图片描述

如果state是0,就CAS改成1,获得锁。如果等于1,再判断当前线程是不是Owner线程,是就是发生锁重入,就让state加1,相当于锁进入2次。
释放锁的时候,就state-1,当state为1时free为false,不解锁。如果1的时候再想-1,free才会变成true,把Owner改成null。

2. 3 可打断原理

2. 3. 1 不可打断模式(默认)

在此模式下,即使它被打断,被唤醒了,但他仍然在循环里,去尝试获得锁,失败了再进入阻塞队列(一直在AQS队列里),一直要等到获得锁后方能得知自己被其他线程打断了。

2. 3. 2 可打断模式

在这里插入图片描述

不一样的是从parkAndCheckInterrupt被唤醒后,会抛出异常。不进入for(;😉,不在AQS队列里再等了,就可以停止去等待锁。

2. 4 公平锁实现原理

2. 4. 1 非公平:

![[uTools_1689491940081.png]]

2. 4. 2 公平:

CAS前多做一个事,检查AQS队列中是否有前驱节点,没有才去竞争。
在这里插入图片描述
在这里插入图片描述

2. 5. 条件变量实现原理

每个条件变量其实就对应着一个等待队列,其实现类是 ConditionObject。
类似于synchronized的wait-notify。

2. 5. 1 await流程

![[uTools_1689071066344.png]]
![[uTools_1689071149967.png]]
总结

  1. 如果T1线程持有锁,调用await,就进入ConditionObject的addConditionWaiter流程。
  2. 就是把线程加到条件变量的双向链表里,把新的Node状态设为-2(等待状态)。
  3. 下面进入fullyRelease流程,释放同步器(节点)的所有锁,锁重入的话全去掉。
  4. unpark AQS队列的下一个等待的节点,去竞争锁。
  5. 成功了,就会park阻塞T1。

2. 5. 2 signal 流程

![[uTools_1689071173998.png]]
![[uTools_1689071189243.png]]
总结(T1唤醒T0)

1.先检查是否是锁的持有者,是才能唤醒别的线程。
2. 进入ConditionObject的doSignal流程,去取条件变量等待队列的第一个Node,把它从条件变量的链表中断开,加到竞争锁的AQS队列的尾部。
3. 把原来的最后一个的WaitStatus改成-1,加进去的T0对应的改成0。

3. 读写锁

3. 1 ReentrantReadWriteLock使用

读操作远远高于写操作时,这时候使用 读写锁 让 读-读 可以并发,提高性能(一个优化,读不修改数据)。 类似于数据库中的 select …from … lock in share mode(类似于共享锁)

提供一个 数据容器类 内部分别使用读锁保护数据的 read() 方法,写锁保护数据的 write() 方法。

3. 1. 1 注意事项

  1. 读锁不支持条件变量。
  2. 重入时升级不支持:即持有读锁的情况下去获取写锁,会导致获取写锁永久等待。
  3. 重入时降级支持:即持有写锁的情况下去获取读锁。
    ![[uTools_1689071805862.png]]

3. 2 StampedLock

为了进一步优化读性能,它的特点是在使用读锁、写锁时都必须配合【戳】使用。
乐观读,StampedLock 支持 tryOptimisticRead() 方法(乐观读),读取完毕后需要做一次 戳校验 如果校验通过,表示这期间确实没有写操作,数据可以安全使用,如果校验没通过,需要重新获取读锁,保证数据安全

Semaphore:信号量,用来限制能同时访问共享资源的线程上限。

3. 2. 1 基本使用

ReentrantLock是独占的,而信号量应用是,现在有多个共享资源,也允许多个线程使用,只是想限制能同时访问共享资源的线程上限。(停车 管理车位上限)

public static void main(String[] args) {
	// 1. 创建 semaphore 对象
	Semaphore semaphore = new Semaphore(3);
	// 2. 10个线程同时运行
	for (int i = 0; i < 10; i++) {
		new Thread(() -> {
			// 3. 获取许可, 此信号量
			try {
				semaphore.acquire();
			} catch (InterruptedException e) {
				e.printStackTrace();
			}
			try {
				log.debug("running...");
				sleep(1);
				log.debug("end...");
			} finally {
			// 4. 释放许可
				semaphore.release();
			}
		}).start();
	}
}

3. 2. 2 应用

使用 Semaphore 限流,在访问高峰期时,让请求线程阻塞,高峰期过去再释放许可。
只适合限制单机线程数量,并且仅是限制线程数,而不是限制资源数(例如连接数,请对比 Tomcat LimitLatch 的实现)。

适用于一个线程对应一个资源。 比如一个线程是对应一个数据库连接的(new Semaphore(poolSize),让许可数与资源数一致)。
![[uTools_1689072687060.png]]
Semaphore优化:
![[uTools_1689072756406.png]]

用 Semaphore 实现简单连接池,对比『享元模式』下的实现(用wait notify),性能和可读性显然更好,注意下面的实现中线程数和数据库连接数是相等的。

3. 2. 3 原理 △△

Semaphore 有点像一个停车场,permits 就好像停车位数量,当线程获得了 permits 就像是获得了停车位,然后停车场显示空余车位减一。

  1. 刚开始,permits(state)为 3,这时 5 个线程来获取资源。
    ![[uTools_1689072927014.png]]
  2. 假设其中 Thread-1,Thread-2,Thread-4 cas 竞争成功,而 Thread-0 和 Thread-3 竞争失败,进入 AQS 队列park 阻塞。
    ![[uTools_1689072964509 1.png]]
  3. 接下来 Thread-0 竞争成功,permits 再次设置为 0,设置自己为 head 节点,断开原来的 head 节点,unpark 接下来的 Thread-3 节点,但由于 permits 是 0,因此 Thread-3 在尝试不成功后再次进入 park 状态。
    在这里插入图片描述

4. CountdownLatch

倒计时锁,用来进行线程同步协作,等待所有线程完成倒计时。

4. 1 基本使用

其中构造参数用来初始化等待计数值,await() (一般是main线程)用来等待计数归零,countDown() 用来让计数减一
![[uTools_1689073210074 1.png]]
主线程要等三个线程里把计数减到0,才会await被唤醒,去运行。

改进:

上面调用join(),也可以实现,但join比较底层,使用繁琐,这边属于高级API。以后不自己创建线程,而是用线程池来创建并获取线程达到线程的重用。此时,会发现里面的线程一直在运行,并不会轻易结束。

CyclicBarrier

循环栅栏,用来进行线程协作,等待线程满足某个计数。构造时设置计数个数,每个线程执行到某个需要“同步”的时刻调用 await() 方法进行等待,当等待的线程数满足计数个数时,继续执行。 起到一个同步的作用。

CyclicBarrier 与 CountDownLatch 的主要区别在于==CyclicBarrier 是可以重用的 CyclicBarrier 可以被比喻为『人满发车』 ==。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值