Java并发(8)--JUC之同步队列器AQS原理、重入锁ReentrantLock、读写锁ReentrantReadWriteLock


本文第一章基于 Java并发之AQS详解对队列同步器AQS进行深入学习,是用来构建锁或者其他同步组件的基础框架,它使用了一个int成员变量表示同步状态,通过内置的FIFO队列来完成资源获取线程的排队工作。
第二章学习重入锁ReentrantLock
第三章介绍读写锁,读写锁在同一时刻可以允许多个读线程访问,但是在写线程访问时,所有的读线程和写线程均被阻塞。

一. AQS 原理

关于AQS 原理,这篇文章 Java并发之AQS详解讲的很透彻,我也是认真看了几遍,这节只对这篇博客做一些笔记。

AbstractQueuedSynchronizer(AQS)
抽象的队列式的同步器,AQS定义了一套多线程访问共享资源的同步器框架,许多同步类实现都依赖于它,如常用的ReentrantLock/Semaphore/CountDownLatch.等。

1.1 addWaiter()是如何保证多线程运行下入队操作的正确性?

对应博客中的3.1.2
addWaiter() 源码:

private Node addWaiter(Node mode) {
      //以给定模式构造结点。mode有两种:EXCLUSIVE(独占)和SHARED(共享)
    Node node = new Node(Thread.currentThread(), mode);
     
      //尝试快速方式直接放到队尾。
      Node pred = tail;
      if (pred != null) {
          node.prev = pred;
 			//将新建的node加入到队尾 
          if (compareAndSetTail(pred, node)) {
 				//调用CAS(CompareAndSet)重新设置tail 
             pred.next = node;
             return node;
         }
     }
     
     //上一步失败则通过enq入队。
     enq(node);
     return node;
 }

可以看出,实现正确性的关键在于原子性方法compareAndSetTail()

compareAndSetTail(pred, node)会比较pred和tail是否指向同一个节点,如果是,才将tail更新为node。

为何不是直接赋值,而要多做一步比较操作呢?那是因为虽然当前线程在声明pred时,为pred赋值了tail,但tail可能会被其他线程改变,而当前线程的本地变量pred是不会感知到这个改变的。
入队的同步关键在于原子性的compareAndSetTail()方法。它保证了每个线程能够完整的执行下面两个操作:

  • 设置prev,将自己链接到队尾;
  • 将tail更新为自己。

这使得队列中的tail和prev指针总是可靠的,用户在任何时候都可以使用tail和prev去访问队列。


1.2 enq(Node) 的CAS自旋volatile变量

此部分内容引自 AQS的原理浅析

enq(Node)源码:

  private Node enq(final Node node) {
      //CAS"自旋",直到成功加入队尾
      for (;;) {
          Node t = tail;
         if (t == null) {
		 // 队列为空,创建一个空的标志结点作为head结点,并将tail也指向它。
 		Node h = new Node();
				h.next = node;
				node.prev = h;
            if (compareAndSetHead(h))
                 tail = node;
 		   return h;
        } else {//正常流程,放入队尾
            node.prev = t;
             if (compareAndSetTail(t, node)) {
                 t.next = node;
                 return t;
             }
         }
     }
 }

首先这个是一个死循环,而且本身没有锁,因此可以有多个线程进来,假如某个线程进入方法,此时head、tail都是null,自然会进入if(t == null)所在的代码区域,这部分代码会创建一个Node出来名字叫h,这个Node没有像开始那样给予类型和线程,很明显是一个空的Node对象,而传入的Node对象首先被它的next引用所指向,此时传入的node和某一个线程创建的h对象如下图所示。
{% asset_img 1.jpg ConcurrentHashMap %}

刚才我们很理想的认为只有一个线程会出现这种情况,如果有多个线程并发进入这个if判定区域,可能就会同时存在多个这样的数据结构,在各自形成数据结构后,多个线程都会去做compareAndSetHead(h)的动作,也就是尝试将这个临时h节点设置为head,显然并发时只有一个线程会成功,因此成功的那个线程会执行tail = node的操作,整个AQS的链表就成为:
{% asset_img 2.jpg ConcurrentHashMap %}

有一个线程会成功修改head和tail的值,其它的线程会继续循环,再次循环就不会进入if (t == null)的逻辑了,而会进入else语句的逻辑中。

else逻辑和 addWaiter() 是一样的。


1.3 acquire(int)方法总结

此方法是独占模式下线程获取共享资源的顶层入口。如果获取到资源,线程直接返回,否则进入等待队列,直到获取到资源为止,且整个过程忽略中断的影响。这也正是lock()的语义,当然不仅仅只限于lock()。获取到资源后,线程就可以去执行其临界区代码了。下面是acquire()的源码:

public final void acquire(int arg) {
     if (!tryAcquire(arg) &&
         acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
         selfInterrupt();
 }

流程:

  1. 调用自定义同步器的tryAcquire()尝试直接去获取资源,如果成功则直接返回;
  2. 没成功,则addWaiter()将该线程加入等待队列的尾部,并标记为独占模式;
  3. acquireQueued()使线程在等待队列中休息,有机会时(轮到自己,会被unpark())会去尝试获取资源。获取到资源后才返回。如果在整个等待过程中被中断过,则返回true,否则返回false。
  4. 如果线程在等待过程中被中断过,它是不响应的。只是获取资源后才再进行自我中断selfInterrupt(),将中断补上。
    {% asset_img 3.png ConcurrentHashMap %}

1.4 release(int) 方法总结

上一小节已经把acquire()说完了,这一小节就来讲讲它的反操作release()吧。此方法是独占模式下线程释放共享资源的顶层入口。它会释放指定量的资源,如果彻底释放了(即state=0),它会唤醒等待队列里的其他线程来获取资源。这也正是unlock()的语义,当然不仅仅只限于unlock()。下面是release()的源码:

1 public final boolean release(int arg) {
2     if (tryRelease(arg)) {
3         Node h = head;//找到头结点
4         if (h != null && h.waitStatus != 0)
5             unparkSuccessor(h);//唤醒等待队列里的下一个线程
6         return true;
7     }
8     return false;
9 }

1.5 AQS应用注意点

不同的自定义同步器争用共享资源的方式也不同。自定义同步器在实现时只需要实现共享资源state的获取与释放方式即可,至于具体线程等待队列的维护(如获取资源失败入队/唤醒出队等),AQS已经在顶层实现好了。自定义同步器实现时主要实现以下几种方法:

isHeldExclusively():该线程是否正在独占资源。只有用到condition才需要去实现它。
tryAcquire(int):独占方式。尝试获取资源,成功则返回true,失败则返回false。
tryRelease(int):独占方式。尝试释放资源,成功则返回true,失败则返回false。
tryAcquireShared(int):共享方式。尝试获取资源。负数表示失败;0表示成功,但没有剩余可用资源;正数表示成功,且有剩余资源。
tryReleaseShared(int):共享方式。尝试释放资源,如果释放后允许唤醒后续等待结点返回true,否则返回false。

同步类在实现时一般都将自定义同步器(sync)定义为内部类,供自己使用;而同步类自己(Mutex)则实现某个接口,对外服务。当然,接口的实现要直接依赖sync,它们在语义上也存在某种对应关系!!而sync只用实现资源state的获取-释放方式tryAcquire-tryRelelase,至于线程的排队、等待、唤醒等,上层的AQS都已经实现好了,我们不用关心。


二. 重入锁 ReentrantLock

ReentrantLock 支持线程对资源的重复加锁,同时,该锁还支持获取锁的公平性和非公平性选择。(公平锁即等待时间最长的线程优先获得锁)

2.1 互斥锁 Mutex

Java并发之AQS详解的文末,介绍了互斥锁Mutex的源码,当一个线程调用lock()方法获取锁之后,如果在调用lock()方法,将会使得线程阻塞,这是因为第二次lock()方法在调用tryAcquire()时,会返回false,导致线程阻塞。

class Mutex implements Lock, java.io.Serializable {
    // 自定义同步器
    private static class Sync extends AbstractQueuedSynchronizer {
        // 判断是否锁定状态
        protected boolean isHeldExclusively() {
            return getState() == 1;
        }

        // 尝试获取资源,立即返回。成功则返回true,否则false。
        public boolean tryAcquire(int acquires) {
            assert acquires == 1; // 这里限定只能为1个量
            if (compareAndSetState(0, 1)) {//state为0才设置为1,不可重入!
			   //设置为当前线程独占资源
                setExclusiveOwnerThread(Thread.currentThread());
                return true;
            }
            return false;
        }

        // 尝试释放资源,立即返回。成功则为true,否则false。
        protected boolean tryRelease(int releases) {
            assert releases == 1; // 限定为1个量
			//既然来释放,那肯定就是已占有状态了。只是为了保险,多层判断!
            if (getState() == 0)
                throw new IllegalMonitorStateException();
            setExclusiveOwnerThread(null);
            setState(0);//释放资源,放弃占有状态
            return true;
        }
    }

    // 真正同步类的实现都依赖继承于AQS的自定义同步器!
    private final Sync sync = new Sync();

    //lock<-->acquire。两者语义一样:获取资源,即便等待,直到成功才返回。
    public void lock() {
        sync.acquire(1);
    }

    //tryLock<-->tryAcquire。两者语义一样:尝试获取资源,要求立即返回。成功则为true,失败则为false。
    public boolean tryLock() {
        return sync.tryAcquire(1);
    }

    //unlock<-->release。两者语文一样:释放资源。
    public void unlock() {
        sync.release(1);
    }

    //锁是否占有状态
    public boolean isLocked() {
        return sync.isHeldExclusively();
    }
}

2.2 ReentrantLock 重进入的源码分析(非公平性)

通过组合自定义同步容器实现锁的获取与释放。

  1. 尝试获取资源的 nonfairTryAcquire()
final boolean nonfairTryAcquire(int acquires) {  //acquires 其实就是1
    final Thread current = Thread.currentThread();
    int c = getState(); // 同步状态值
    if (c == 0) {  // 当c=0 时,表示当前线程第一次获取锁,0表示资源未被锁定
        if (compareAndSetState(0, acquires)) { 
		//当 c= state =0 时,才表示当前线程可以占用该资源
            setExclusiveOwnerThread(current);   //设置资源的持有者为该线程
            return true;
        }
    }
	//当获取锁的线程再次请求时,将同步状态值进行增加并返回true,表示同步状态成功
    else if (current == getExclusiveOwnerThread()) {
        int nextc = c + acquires;
        if (nextc < 0) // overflow
            throw new Error("Maximum lock count exceeded");
        setState(nextc);
        return true;
    }
    return false;
}

该方法增加了再次获取同步状态的处理逻辑:通过判断当前线程是否为获取锁的线程来
决定获取操作是否成功,如果是获取锁的线程再次请求,则将同步状态值进行增加并返回
true,表示获取同步状态成功。

  1. 释放资源的 tryRelease()
protected final boolean tryRelease(int releases) {
    int c = getState() - releases;
    if (Thread.currentThread() != getExclusiveOwnerThread())
        throw new IllegalMonitorStateException();
    boolean free = false;
    if (c == 0) {
        free = true;
        setExclusiveOwnerThread(null);
    }
    setState(c);
    return free;
}

如果该锁被获取了n次,那么前(n-1)次tryRelease(int releases)方法必须返回false,而只有同
步状态完全释放了,才能返回true。可以看到,该方法将同步状态是否为0作为最终释放的条
件,当同步状态为0时,将占有线程设置为null,并返回true,表示释放成功。


2.3 公平与非公平获取锁的源码分析

如果一个锁是公平的,那么锁的获取顺序就应该符合请求的绝对时间顺序,即FIFO(这种公平性通常效率不高)

  1. 公平性的尝试获取资源的 tryAcquire()
final boolean nonfairTryAcquire(int acquires) { 
    final Thread current = Thread.currentThread();
    int c = getState(); 
    if (c == 0) { 
        if (hasQueuedPredecessors() && compareAndSetState(0, acquires)) {  
            setExclusiveOwnerThread(current);  
            return true;
        }
    }
	
    else if (current == getExclusiveOwnerThread()) {
        int nextc = c + acquires;
        if (nextc < 0) // overflow
            throw new Error("Maximum lock count exceeded");
        setState(nextc);
        return true;
    }
    return false;
}

该方法与nonfairTryAcquire(int acquires)比较,唯一不同的位置为判断条件多了hasQueuedPredecessors()方法,即加入了同步队列中当前节点是否有前驱节点的判断,如果该方法返回true,则表示有线程比当前线程更早地请求获取锁,因此需要等待前驱线程获取并释放锁之后才能继续获取锁。


2.4 重入锁的相关性质与应用

  1. 与synchronized的区别

    可重入性:
    两者的锁都是可重入的,差别不大,有线程进入锁,计数器自增1,等下降为0时才可以释放锁
    锁的实现
    synchronized是基于JVM实现的(用户很难见到,无法了解其实现),ReentrantLock是JDK实现的。
    性能区别:
    在最初的时候,二者的性能差别差很多,当synchronized引入了偏向锁、轻量级锁(自选锁)后,二者的性能差别不大,官方推荐synchronized(写法更容易、在优化时其实是借用了ReentrantLock的CAS技术,试图在用户态就把问题解决,避免进入内核态造成线程阻塞)
    功能区别:
    (1)便利性:synchronized更便利,它是由编译器保证加锁与释放。ReentrantLock是需要手动释放锁,所以为了避免忘记手工释放锁造成死锁,所以最好在finally中声明释放锁。
    (2)锁的细粒度和灵活度,ReentrantLock优于synchronized

  2. 重入锁独有的功能

  • 可以指定是公平锁还是非公平锁,sync只能是非公平锁。(所谓公平锁就是先等待的线程先获得锁)
  • 提供了一个Condition类,可以分组唤醒需要唤醒的线程。不像是synchronized要么随机唤醒一个线程,要么全部唤醒
  • 提供能够中断等待锁的线程的机制,通过lock.lockInterruptibly()实现,这种机制 ReentrantLock是一种自选锁,通过循环调用CAS操作来实现加锁。性能比较好的原因是避免了进入内核态的阻塞状态。

  1. 使用ReentrantLock
//创建锁:使用Lock对象声明,使用ReentrantLock接口创建
private final static Lock lock = new ReentrantLock();
//使用锁:在需要被加锁的方法中使用
private static void add() {
    lock.lock();
    try {
        count++;
    } finally {
        lock.unlock();
    }
}

2.5 重入锁的使用之 condition

以下内容引自慕课网实战·高并发探索(十二):并发容器J.U.C – AQS组件 锁:ReentrantLock、ReentrantReadWriteLock、StempedLock

Condition可以非常灵活的操作线程的唤醒,下面是一个线程等待与唤醒的例子,其中用1234序号标出了日志输出顺序

public static void main(String[] args) {
    ReentrantLock reentrantLock = new ReentrantLock();
    Condition condition = reentrantLock.newCondition();//创建condition
    //线程1
    new Thread(() -> {
        try {
            reentrantLock.lock();
            log.info("wait signal"); // 1
			//使获取锁的线程进入等待队列,必须先执行lock()方法获得锁
            condition.await();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        log.info("get signal"); // 4
        reentrantLock.unlock();
    }).start();
    //线程2
    new Thread(() -> {
        reentrantLock.lock();
        log.info("get lock"); // 2
        try {
            Thread.sleep(3000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
		//唤醒所有等待的线程,但是由于还未释放锁,其他线程还不能得到资源。
        condition.signalAll();//发送信号
        log.info("send signal"); // 3
        reentrantLock.unlock();
    }).start();
}

输出过程讲解:

1、线程1调用了reentrantLock.lock(),线程进入AQS等待队列,输出1号log
2、接着调用了awiat方法,线程从AQS队列中移除,锁释放,直接加入condition的等待队列中
3、线程2因为线程1释放了锁,拿到了锁,输出2号log 4、线程2执行condition.signalAll()发送信号,输出3号log
5、condition队列中线程1的节点接收到信号,从condition队列中拿出来放入到了AQS的等待队列,这时线程1并没有被唤醒。
6、线程2调用unlock释放锁,因为AQS队列中只有线程1,因此AQS释放锁按照从头到尾的顺序,唤醒线程1
7、线程1继续执行,输出4号log,并进行unlock操作。


三. 读写锁

之前提到的锁(Mutex和 ReentrantLock)都是排他锁,这些锁在同一时刻只允许一个线程进行访问。而读写锁在同一时刻可以允许多个读线程访问,但是在写线程访问时,所有的读线程和写线程均被阻塞。

“读取锁” 用于只读操作,它是“共享锁”,能同时被多个线程获取。
“写入锁”用于写入操作,它是“独占锁”,写入锁只能被一个线程锁获取。
注意:不能同时存在读取锁和写入锁! #E91E63
ReadWriteLock是一个接口。ReentrantReadWriteLock是它的实现类,ReentrantReadWriteLock包括子类ReadLock和WriteLock。

备注:在没有任何读写锁的时候才可以取得写入锁(悲观读取,容易写线程饥饿),也就是说如果一直存在读操作,那么写锁一直在等待没有读的情况出现,这样我的写锁就永远也获取不到,就会造成等待获取写锁的线程饥饿。平时使用的场景并不多。

3.1 读写锁的接口与示例

以下部分内容引自 《java并发编程的艺术》

  1. ReadWriteLock接口简单说明
    ReadWriteLock接口只定义了两个方法:
// 返回用于读取操作的锁。
Lock readLock()
// 返回用于写入操作的锁。
Lock writeLock()

通过调用相应方法获取读锁或写锁,获取的读锁及写锁都是Lock接口的实现,可以如同使用Lock接口一样使用。

  1. 读写锁的示例

public class Cache {
    private static final Map<String, Object>    map = new HashMap<String, Object>();
    private static final ReentrantReadWriteLock rwl = new ReentrantReadWriteLock(); // 读写锁
    private static final Lock                   r   = rwl.readLock(); //读锁
    private static final Lock                   w   = rwl.writeLock(); //写锁

	// 读操作 用读锁
    public static final Object get(String key) {
        r.lock();
        try {
            return map.get(key);
        } finally {
            r.unlock();
        }
    }

	// 写操作,用写锁,在获取写锁后,所有的读线程和其他的写线程均被阻塞,防止读入脏数据
    public static final Object put(String key, Object value) {
        w.lock();
        try {
            return map.put(key, value);
        } finally {
            w.unlock();
        }
    }
	
	// clear 操作同时更新 HashMap ,也需要获取写锁
    public static final void clear() {
        w.lock();
        try {
            map.clear();
        } finally {
            w.unlock();
        }
    }
}

上述示例中,Cache组合一个非线程安全的HashMap作为缓存的实现,同时使用读写锁的读锁和写锁来保证Cache是线程安全的。在读操作get(String key)方法中,需要获取读锁,这使得并发访问该方法时不会被阻塞。写操作put(String key,Object value)方法和clear()方法,在更新HashMap时必须提前获取写锁,当获取写锁后,其他线程对于读锁和写锁的获取均被阻塞,而只有写锁被释放之后,其他读写操作才能继续。


参考:

  1. Java并发之AQS详解
  2. Java AbstractQueuedSynchronizer源码阅读2-addWaiter()
  3. 慕课网实战·高并发探索(十二):并发容器J.U.C – AQS组件 锁:ReentrantLock、ReentrantReadWriteLock、StempedLock
  4. 《java并发编程的艺术》
  5. java多线程系列(四)—ReentrantLock的使用
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值