初识并发编程【3】之锁Lock

140 阅读11分钟

本文源自Recently祝祝,创自Recently祝祝。转载请标注出处。

此解决方式在企业中有所应用,适合Java初级开发学习,参考。

本文字数与行数,耐心阅读必有收获。

image.png

1.java锁的分类

上锁方式的不同

显示锁:JUC包中提供的锁,加锁和解锁都是看得见的,在代码中显示的

隐式锁:synchronized(同一时刻最多一个线程执行加锁代码),并不需要显示加锁解锁的过程

锁特性的不同

乐观锁、悲观锁:按照线程时候需要锁住共享资源

乐观锁:无论什么时候都保持乐观,觉得不会有别的线程修改数据,获取数据的时候不上锁,若在获取数据时存在其他线程更新,则报错或者自动重试等操作。CAS算法、关系型数据库的版本号机制

悲观锁:任何时候都要加锁,保证数据不被修改。JUC的锁、Synchronized

可重入锁、不可重入锁:是否能重复上锁同一个线程资源

可重入锁:重复加锁,同样的资源重复锁定 ,锁次数增加1.释放资源则减1。可重入锁的一个优点是可一定程度避免死锁。不用等待锁释放,可以直接获取锁。ReentrantLock、synchronized

不可重入锁:不能对同样的资源重复加锁,重复获取锁则发生死锁。

公平锁、非公平锁:多线程竞争同一锁资源的时候需不需排队

公平锁:线程需要按照申请锁的顺序获取锁

非公平锁:不需要按照申请锁的顺序获取锁。

独享锁、共享锁:多个线程是否能共享同一把锁

独享锁(排他锁,写锁):同一时间只能有一个线程获取锁,其他线程将被阻塞,等待锁释放之后才能获取锁

共享锁(读锁):多个线程可以同时获取同一把锁,无需等待。

其他锁

自旋锁:

不加锁,通过指令,实现原子性可见性,多个线程操作,先停一会给其他线程操作,其他线程操作完了,再去获取资源执行。不断循环尝试获取锁,直到获取锁成功。

分段锁:

锁不是锁住整个资源,而是锁住其中的一部分,多线程访问容器里不同数据段的数据,线程间不存在锁竞争,提高并发访问效率

无锁(不加锁)、偏量锁(只有一个线程)、轻量级锁(两个线程就是用CAS)、重量级锁(多个线程资源复杂的时候使用):

四种状态会随着竞争的情况逐渐升级,而且是不可逆的过程,即不可降级。

分析以上锁特征可以知道synchronized分别是:隐式锁、可重入锁、非公平锁、独享锁、排他锁、悲观锁。

2.重点说明:重入锁:ReentrantLock

可重入锁,重入锁,分布式锁、手写锁跟重入锁逻辑相似,重入锁可以是公平的也可以是非公平的,核心就是加锁和解锁,

核心方式:

类图:

源码图:

NonfairSync和FairSync都继承自抽象类Sync,而Sync类继承自抽象类AbstractQueuedSynchronizer(简称AQS)。在ReentrantLock中有非公平锁NonfairSync和公平锁FairSync的实现

定义重入锁可以设置公平还是不公平,默认公平。

第一点:加锁,线程抢夺锁的实现;第二点:解锁,线程被唤醒实现

private Lock lock = new ReentrantLock();

1)ReentrantLock源码分析--锁获取

AQS实现源码

//AQS内部维护这一个双向链表,AQS主要属性
public abstract class AbstractQueuedSynchronizer
    extends AbstractOwnableSynchronizer
    implements java.io.Serializable {
        private transient volatile Node head;//头节点指针
        private transient volatile Node tail;//尾节点指针
        private volatile int state;//同步状态,0无锁;大于0,有锁,state的值代表重
        入次数。
        //AQS链表节点结构
        static final class Node {
            static final Node SHARED = new Node();//共享模式
            static final Node EXCLUSIVE = null;//独占模式
            /**
*等待状态:取消。表明线程已取消争抢锁并从队列中删除。
*取消动作:获取锁超时或者被其他线程中断。
*/
            static final int CANCELLED = 1;
            /**
*等待状态:通知。表明线程为竞争锁的候选者。
*只要持有锁的线程释放锁,会通知该线程。
*/
            static final int SIGNAL = -1;
            /**
*等待状态:条件等待
*表明线程当前线程在condition队列中。
*/
            static final int CONDITION = -2;
            /**
*等待状态:传播。
*用于将唤醒后继线程传递下去,这个状态的引入是为了完善和增强共享锁的唤醒机
制。
*在一个节点成为头节点之前,是不会跃迁为此状态的
*/
            static final int PROPAGATE = -3;
            volatile int waitStatus;
            volatile Node prev;//直接前驱节点指针
            volatile Node next;//直接后继节点指针
            volatile Thread thread;//线程
            Node nextWaiter;//condition队列中的后继节点
            final boolean isShared() {//是否是共享
                return nextWaiter == SHARED;
            }
            final Node predecessor() throws NullPointerException {
                Node p = prev;
                if (p == null)
                    throw new NullPointerException();
                else
                    return p;
            }
            Node() {//默认构造器
            }
            //在重入锁中用于addWaiter方法中,用于将阻塞的线程封装成一个Node
            Node(Thread thread, Node mode) {
                this.nextWaiter = mode;
                this.thread = thread;
            }
            Node(Thread thread, int waitStatus) {//用于Condition中
                this.waitStatus = waitStatus;
                this.thread = thread;
            }
        }
    }

场景01-线程抢夺锁失败时,AQS队列的变化【加锁】

① AQS的head、tail分别代表同步队列头节点和尾节点指针默认为null

② 当第一个线程抢夺锁失败,同步队列会先初始化,随后线程会被封装成Node节点追加到AQS队列中。

➢ 假设:当前独占锁的的线程为ThreadA,抢占锁失败的线程为ThreadB。

➢ 2.1 同步队列初始化,首先在队列中添加Node,thread=null

➢ 2.2 将ThreadB封装成为Node,追加到AQS队列

③ 当下一个线程抢夺锁失败时,继续重复上面步骤。假设:ThreadC抢占线程失败

源码解析

可以设置重入锁公平或者不公平

默认公平锁

ReentrantLock.lock()

sync是Sync类的一个实例,Sync类实际上是ReentrantLock的抽象静态内部类,它集成了AQS来实现重入锁的具体业务逻辑。AQS是一个同步队列,实现了线程的阻塞和唤醒,没有实现具体的业务功能。在 不同的同步场景中,需要用户继承AQS来实现对应的功能。

第一个公平锁

线程进入AQS中的acquire方法,arg=1。

这个方法逻辑:先尝试抢占锁,抢占成功,直接返回;

抢占失败,将线程封装成Node节点追加到AQS队列中并使线程阻塞等待。

1)首先会执行tryAcquire(1)尝试抢占锁,成功返回true,失败返回false。抢占成功了,就不会执行下面的代码了

(2)抢占锁失败后,执行addWaiter(Node.EXCLUSIVE)将x线程封装成Node节点追加到AQS队列。

(3)然后调用acquireQueued将线程阻塞,线程阻塞。 线程阻塞后,接下来就只需等待其他线程唤醒它,线程被唤醒后会重新竞争锁的使用。

FairSync.tryAcquire(1)

尝试获取锁:若获取锁成功,返回true;获取锁失败,返回false。

这个方法逻辑:获取当前的锁状态,如果为无锁状态,当前线程会执行CAS操作尝试获取锁;若当前线程是重入获取锁,只需增加锁的重入次数即可。

点击应用

AQS.addWaiter(Node.EXCLUSIVE)

线程抢占锁失败后,执行addWaiter(Node.EXCLUSIVE)将线程封装成Node节点追加到AQS队列。

addWaiter(Node mode)的mode表示节点的类型,Node.EXCLUSIVE表示是独占排他锁,也就是说重入锁是独占锁,用到了AQS的独占模式。

Node定义了两种节点类型:

  • 共享模式:Node.SHARED。共享锁,可以被多个线程同时持有,如读写锁的读锁。
  • 独占模式:Node.EXCLUSIVE。独占很好理解,是自己独占资源,独占排他锁同时只能由一个线程持有。

AQS.acquireQueued(newNode,1)

这个方法的主要作用就是将线程阻塞。

  1. 若同步队列中,若当前节点为队列第一个线程,则有资格竞争锁,再次尝试获得锁。
  • 尝试获得锁成功,移除链表head节点,并将当前线程节点设置为head节点。
  • 尝试获得锁失败,判断是否需要阻塞当前线程。
  1. 若发生异常,取消当前线程获得锁的资格。

AQS.shouldParkAfterFailedAcquire

这个方法的主要作用是:线程竞争锁失败以后,通过Node的前驱节点的waitStatus状态来判断, 线程是否需要被阻塞。

  1. 如果前驱节点状态为 SIGNAL,当前线程可以被放心的阻塞,返回true。
  2. 若前驱节点状态为CANCELLED,向前扫描链表把 CANCELLED 状态的节点从同步队列中移除,返回false。
  3. 若前驱节点状态为默认状态或PROPAGATE,修改前驱节点的状态为 SIGNAL,返回 false。
  4. 若返回false,会退回到acquireQueued方法,重新执行自旋操作。自旋会重复执行
    acquireQueued和shouldParkAfterFailedAcquire,会有两个结果:
    (1)线程尝试获得锁成功或者线程异常,退出acquireQueued,直接返回。
    (2)执行shouldParkAfterFailedAcquire成功,当前线程可以被阻塞。
  5. 若返回true,调用parkAndCheckInterrupt阻塞当前线程。
    Node 有 5 种状态,分别是:
  • 0:默认状态。
  • 1:CANCELLED,取消/结束状态。表明线程已取消争抢锁。线程等待超时或者被中断,节点的waitStatus为CANCELLED,线程取消获取锁请求。需要从同步队列中删除该节点
  • -1:SIGNAL,通知。状态为SIGNAL节点中的线程释放锁时,就会通知后续节点的线程。
  • -2:CONDITION,条件等待。表明节点当前线程在condition队列中。
  • -3:PROPAGATE,传播。在一个节点成为头节点之前,是不会跃迁为PROPAGATE状态的。用于将唤醒后继线程传递下去,这个状态的引入是为了完善和增强共享锁的唤醒机制。

获取锁成功跳出循环返回,获取锁失败,就阻塞等待,等待上一个节点释放锁

AQS.parkAndCheckInterrupt

将当前线程阻塞挂起。 LockSupport.park(this)会阻塞当前线程,会使当前线程(如ThreadB)处于等待状态,不再往下执行。

2)ReentrantLock源码分析--锁释放

场景02-线程被唤醒时,AQS队列的变化【解锁】

① ReentrantLock唤醒阻塞线程时,会按照FIFO的原则从AQS中head头部开始唤醒首个节点中线程。

② head节点表示当前获取锁成功的线程ThreadA节点。

③ 当ThreadA释放锁时,它会唤醒后继节点线程ThreadB,ThreadB开始尝试获得锁,如果ThreadB获得锁成功,会将自

己设置为AQS的头节点。ThreadB获取锁成功后,AQS变化如下:

源码解析:

公平不公平释放锁都是同一个tryRelese()

ReentrantLock.unlock

释放锁时,需调用ReentrantLock的unlock方法。这个方法内部,会调sync.release(1),release方法为AQS类的final方法。

AQS.release(1)

首先执行方法tryRelease(1),tryRelease方法为ReentrantLock中Sync类的final方法,用于释放锁。

  1. 释放锁。若释放后锁状态为无锁状态,需唤醒后继线程
  2. 同步队列头节点
  3. 若head不为null,说明链表中有节点。其状态不为0,唤醒后继线程。

第三步:Sync.tryRelease(1)

  1. 判断当前线程是否为锁持有者,若不是持有者,不能释放锁,直接抛出异常。
  2. 若当前线程是锁的持有者,将重入次数减1,并判断当前线程是否完全释放了锁。
    1. 若重入次数为0,则当前新线程完全释放了锁,将锁拥有线程设置为null,并将锁状态置为无锁状态(state=0),返回true。
    2. 若重入次数>0,则当前新线程仍然持有锁,设置重入次数=重入次数-1,返回false。
  1. 返回true说明,当前锁被释放,需要唤醒同步队列中的一个线程,执行unparkSuccessor唤醒同步队列中节点线程。

AQS.unparkSuccessor

  1. 唤醒后继线程
  2. 头节点waitStatus状态 SIGNAL或PROPAGATE
  3. 查找需要唤醒的节点:正常情况下,它应该是下一个节点。但是如果下一个节点为null或者它的 waitStatus为取消时,则需要从同步队列tail节点向前遍历,查找到队列中首个不是取消状态的节点。
  4. 将下一个节点中的线程unpark唤醒

LockSupport.unpark(s.thread)

会唤醒挂起的线程,使被阻塞的线程继续执行。unpark相当于notify();