Java并发_6.互斥之隐式锁和显式锁

189 阅读10分钟

java线程同步原理主要2个概念:互斥(mutual exclusion)和可见性。

其中互斥保证了在同一时刻只有一个线程可以访问临界区,可见性保证一个线程对共享变量的修改能够及时被其他线程看到。 第3节我们简单介绍了内存可见性相关内容,并在4、5分别介绍了因缓存一致性导致的伪共享问题和代码重排序。这一章节主要了解下互斥相关。 Java提供了2种锁机制来控制多个线程对共享资源的互斥访问。下文分别从可重入性、公平性了解这2种锁的实现方式。

    1. JVM实现的synchronized内置锁,在软件层面依赖JVM
    1. JDK实现的ReentrantLock显式锁,在硬件层面依赖特殊的CPU指令

互斥锁

锁的名字千千万,但首先锁分为内置锁/隐式锁/自动锁和显式锁。关于锁的共享与独占,是否可重入(避免死锁),公平非公平,可中断锁的概念会在之后进行介绍。本节主要了解说明内置锁和显式锁。

image.png

互斥锁关键属性

1. 锁标识

锁标识用于识别成功获得锁。

2. 锁等待队列

锁等待队列用于锁线程等待队列。

3. wait队列

wait队列用于wait()后的等待队列。

synchronized

以最简单的synchronized为例,synchronized(this)中的块,可以保证同时只有一个线程执行。

synchronized实现逻辑

synchronized重量级锁的逻辑在JVM的ObjectMonitor类,由c++实现。关键属性有:

  • _owner用来保存当前持有锁的线程

  • _recursions用于保存重入次数(支持可重入)每次重入+1,释放时-1

  • _cxq先进后出的队列和_EntryList都用于存放等待锁的线程,默认情况下一个线程抢锁失败就会进入_cxq

    两者的区别在持有锁的线程释放锁时,会唤醒等待队列中的线程。首先会看_EntryList中有没有元素,如果有唤醒_EntryList的头节点,如果为空则会把_cxq复制给_EntryList,从_cxq头部唤醒。

    用两个队列是因为线程获取和释放锁的时候,wait()notify()的时候都会涉及到出入_cxq的操作,如果使用一个队列会增加冲突的概率。加入_EntryList只有当前持有锁的线程操作,只要这个队列不为空,获取和释放锁没有竞争问题,提高锁的效率。

image.png

  • _WaitSet用于存放调用wait()方法的线程

    锁等待队列_cxq和wait队列_WaitSet实现上的不同。_cxq为双向链表,前入队,FILO_WaitSet为回环链表,后入队,FIFO。

ObjectMonitor() {
    // 多线程竞争锁进入时的单向链表
    ObjectWaiter * volatile _cxq;
    //处于等待锁block状态的线程,会被加入到该列表
    ObjectWaiter * volatile _EntryList;
    // _header是一个markOop类型,markOop就是对象头中的Mark Word
    volatile markOop _header;
    // 抢占该锁的线程数,约等于WaitSet.size + EntryList.size
    volatile intptr_t _count;
    // 等待线程数
  	volatile intptr_t _waiters;
    // 锁的重入次数
    volatile intptr_ _recursions;
    // 监视器锁寄生的对象,锁是寄托存储于对象中
    void* volatile  _object;
    // 指向持有ObjectMonitor对象的线程
    void* volatile _owner;
    // 处于wait状态的线程,会被加入到_WaitSet
    ObjectWaiter * volatile _WaitSet;
    // 操作WaitSet链表的锁
    volatile int _WaitSetLock;
    // 嵌套加锁次数,最外层锁的_recursions属性为0
    volatile intptr_t  _recursions;
}

synchronized加锁过程

  1. 一个线程尝试获取锁尝试通过CAS将_owner变成当前线程(null -> 当前thread),更新成功则抢锁成功。
  2. 更新失败,则会使用前入队方式在_cxq等待(后入队的在头部),队列中元素类型为ObjectWaiter。

因为步骤1中不管等待队列有没有在等待的元素,都会通过CAS来尝试获取锁,同时_cxq的先进后出逻辑后来的线程可能会先被唤醒,所以是非公平锁。

synchronized使用方法

可以修饰在不同层级:修饰实例方法、修饰静态方法、修饰代码块。通过在对象->对象头->mark word标记字段中修改锁状态标志,

  1. 修饰实例方法,常量池多了ACC_SYNCHRONIZED标示符,根据标示符实现方法同步。调用指令时,会检查标示是否存在,如果设置执行线程就先获取monitor,获取后才能执行方法体,执行完后释放monitor。
public synchronized void synchronisedCalculate() {
    setSum(getSum() + 1);
}
  1. 修饰静态方法
public static synchronized void syncStaticCalculate() {
    staticSum = staticSum + 1;
}
  1. 修饰代码块,修饰代码块时会通过monitorenter和monitorexit指令获取Monitor所有权和退出Monitor。
public void performSynchronisedTask() {
    synchronized (this) {
        setCount(getCount()+1);
    }
}

锁升级

除了无锁状态,Synchronized还有偏向锁、轻量级锁、重量级锁概念。

image.png

Lock

Lock提供了一种无条件的、可轮询的、定时的以及可中断的锁获取操作,所有加锁和解锁的方法都是显式的。在 Lock的实现中必须提供与内部锁相同的内存可见性语义,但在加锁语义、调度算法、顺序保证以及性能特性等方面可以有所不同。以最简单的Lock为例,lock()加锁unlock()释放锁。Lock中while(isLocked)为自旋锁,

public class Counter{

  private Lock lock = new Lock();
  private int count = 0;

  public int inc(){
    lock.lock();
    int newCount = ++count;
    lock.unlock();
    return newCount;
  }
}

public class Lock{

  private boolean isLocked = false;

  public synchronized void lock()
  throws InterruptedException{
    while(isLocked){
      wait();
    }
    isLocked = true;
  }

  public synchronized void unlock(){
    isLocked = false;
    notify();
  }
}

Lock的实现逻辑

上面提到要实现互斥锁需要锁标识和锁等待队列。在AQS中分别对应state整数和exclusiveOwnerThread,存储当前持有锁的线程。使用一个双向链表存储当前等待锁的队列。双向链表的head节点是一个空节点,FIFO新加入的节点会加入队列尾部,唤醒时会唤醒头部的节点。

等待队列的节点封装在Node类中

    /**
     * The synchronization state.
     * AbstractQueuedSynchronizer
     */
    private volatile int state;
    
     /**
     * AbstractOwnableSynchronizer
     */
    private transient Thread exclusiveOwnerThread;
    
    /**
         * Status field, taking on only the values:
         *   SIGNAL:     The successor of this node is (or will soon be)
         *               blocked (via park), so the current node must
         *               unpark its successor when it releases or
         *               cancels. To avoid races, acquire methods must
         *               first indicate they need a signal,
         *               then retry the atomic acquire, and then,
         *               on failure, block.
         *   CANCELLED:  This node is cancelled due to timeout or interrupt.
         *               Nodes never leave this state. In particular,
         *               a thread with cancelled node never again blocks.
         *   CONDITION:  This node is currently on a condition queue.
         *               It will not be used as a sync queue node
         *               until transferred, at which time the status
         *               will be set to 0. (Use of this value here has
         *               nothing to do with the other uses of the
         *               field, but simplifies mechanics.)
         *   PROPAGATE:  A releaseShared should be propagated to other
         *               nodes. This is set (for head node only) in
         *               doReleaseShared to ensure propagation
         *               continues, even if other operations have
         *               since intervened.
         *   0:          None of the above
         *
         * The values are arranged numerically to simplify use.
         * Non-negative values mean that a node doesn't need to
         * signal. So, most code doesn't need to check for particular
         * values, just for sign.
         *
         * The field is initialized to 0 for normal sync nodes, and
         * CONDITION for condition nodes.  It is modified using CAS
         * (or when possible, unconditional volatile writes).
         */
        volatile int waitStatus;

Lock加锁过程

以ReentrantLock非公平锁为例。

  1. 修改锁标识,看state属性是不是为0,如果是0则使用CAS将state更新为1。更新成功1则表示获取锁成功。将当前线程设置为exclusiveOwnerThread。
  2. 如果state不为0,根据exclusiveOwnerThread判断是否可重入锁,如果锁重入,则将state值直接加为1,返回成功。
  3. 如果不是exclusiveOwnerThread不是当前线程,其他线程持有锁,则需要进入锁等待队列中。
  4. 入队检查head节点是否存在,如果为空说明等待队列还没有初始化,就会初始化一个dummy空节点,初始化好后,head跟tail都会指向这个节点。队列初始化结束。
  5. 初始化后,节点就可以入队了,入队的线程将自己的prev指针指向tail节点,通过CAS将tail更新为新入队的节点。
  6. 新入队的线程会再检查一次,是不是队列中的第一个可用节点,如果是则重新尝试获取一次锁,防止自己入队过程中,持有锁的线程已经把锁释放掉了。
  7. 入队之后,把前置节点的waitStatus属性设置为SIGNAL。告诉前置节点当前节点先park一下,等轮到当前节点了再unpark。这样就不用轮训等待。

释放锁过程

  1. 检查state是否大于1,如果大于1锁重入,那么state-1,返回成功。
  2. 如果state等于1,那么将exclusiveOwnerThread设置为空,state设置为0,释放成功,但还要检查等待队列中有没有线程需要被唤醒。
  3. 如果等待队列中有节点需要释放,那么从等待队列后面的节点开始查找,找到第一个waitStatue等于SIGNAL的节点,会唤醒第一个waitStatue等于SIGNAL节点的下一个节点,将对应的节点unpark一下。这样释放锁就结束了。
  4. 被唤醒的线程开始抢锁,如果抢锁成功,则开始出队。
  5. 出队时,head节点指向自己,把自己的Thread属性设置为空,最后断开与原head的双向指针。

Lock相关一些问题

  1. 锁等待队列什么时候初始化?

    发生第一次锁竞争的时候,第一个线程要入队的时候才会初始化等待队列。

  2. 公平锁和非公平锁的区别是什么?

    非公平锁调用lock()的时候会先尝试抢锁。如果这个时候刚好有持有锁的线程释放就抢到了。公平锁看到等待队列中已经有线程在等待了,那么就会入队。

  3. 为什么等待队列中的第一个节点是空dummy节点?

    因为释放锁的线程在唤醒等待线程时,通过前一个线程的waitStatus字段来判断后续节点是否可以被唤醒。如果没有空dummy节点,那么第一个节点就没有前置状态可以判断了。

Synchronized和Lock区别

无法中断、无法实现非阻塞这些都是不满足于使用隐式锁synchronized的原因。Lock在加锁和内存上提供的语义与与内置锁相同,此外它还提供了一些其他功能,包括定时的锁等待、可中断的锁等待、公平性,以及实现非块结构的加锁。

  1. Lock加解锁的顺序灵活
  2. Lock可通过lockInterruptibly中断,Synchronized不可中断
  3. Lock可以被多个线程持有例如读写锁ReadWriteLock中读锁/StampedLock实现乐观读(多个线程并发读,一个线程写),Synchronized没有并发读
  4. Lock可设置为公平锁或非公平锁
  5. Lock底层原理是AQS和CAS,Synchronized底层是互斥Mutex Lock
  6. Lock的等待和唤醒通过Condition的await和signal实现,Synchronized的等待和唤醒通过wait和notify实现。都为同步队列和等待队列的切换。
  7. Lock有和Synchronized都只有一个同步队列,但是Lock有多个等待队列,Synchronized只有一个等待队列。
  8. Lock的tryLock可以实现非阻塞,Synchronized只有阻塞
  9. Lock支持定时的锁等待,可以通过tryLock设置超时机制,synchronized无超时机制
  10. Lock可定时可轮训

相关概念

  • 线程同步

同步是在互斥的基础上,通过其他机制实现访问者对资源的有序访问。

  • 同步队列

等待竞争锁的队列

  • 等待队列

等待被唤醒后进入同步队列以获取竞争锁的队列

参考:

  1. jenkov.com/tutorials/j…
  2. cloud.tencent.com/developer/a…
  3. www.cnblogs.com/CarpenterLe…
  4. www.baeldung.com/java-synchr…
  5. segmentfault.com/a/119000004…
  6. segmentfault.com/a/119000002…
  7. 《Java并发编程实战》第13章
  8. xiaomi-info.github.io/2020/03/24/…