Java多线程&并发知识细节(二)

       在上一章节中,我们分析了线程和任务的基本概念,并总结了线程安全问题的三个特性已经相应的解决方案。针对线程安全问题,除了volatile和CAS之外,还可以通过加锁来解决。

一:synchronized

        这个关键字大家很熟悉,也是使用频率最高的同步锁。这是个Java关键字,使用的是Jvm的内置实现。synchronized依赖于某个对象,原理是在Jvm眼里,每个对象都可以理解为有一个相对应的monitor,获得某个对象的锁,本质上是获得它对应的monitor的所有权。如果这个所有权被其他的线程占据,那么当前的线程会阻塞,处于block状态,加入到同步队列中等待,其他那个线程释放了锁,然后在和其他的线程竞争。

        synchronized的使用这里就不讲了,大家都很熟悉。synchronized锁类似于一个门卫,守护部分代码,要想执行这段代码,就要想获得这个门卫的允许。synchronized在多个线程间是互斥的,多个线程同一个对象锁是互斥的,多个线程不同的对象锁不影响,但在同一个线程内部是重入的,也就是说,针对同一个对象锁,如果当前线程已经获得了锁,那么下次就可以直接获得了。同时这也是不可中断锁,在等待的过程中,如果其他的线程通过interrupt中断了当前的线程,这个等待的过程不会终止。获得锁的操作会继续进行下去,直到成功,但中断状态会被设置,代码中可以通过判断中断状态,来决定之后的逻辑。当代码执行完毕或者遇到了异常,相应的锁会被自动释放掉。

       以上就是synchronized相关的一些细节,并且synchronized可以解决线程安全问题。由于同一时间,只会有一个线程能获取锁,能执行对应的代码,所以就不存在有序性和原子性的问题。然后在释放锁的时候,相关的修改也会刷新到主内存了,所以通过synchronized可以解决线程安全问题。

        针对锁,除了synchronized之外,java中还有另外一个Lock的概念。之所以会有Lock,一方面是因为Lock提供了synchronized不具备的功能,比如公平锁,可中断获取锁等等;另一方面Lock的使用更灵活,在需要显示的获取或释放锁的时候,尤其涉及到了几个不同锁的获取/释放顺序的时候,synchronized就有点力不从心了,因为synchronized获取/释放锁的时机比较死板,Lock是更好的选择。

        Lock是一个接口,最常见的实现类ReentrantLock,所以对Lock的学习,可以通过源码来学习分析,能更清楚的看到锁的工作原理和步骤。

二:Lock

       Lock是一个接口,是一个和synchronized功能类似的锁,相比较synchronized,Lock存在的意义上面已经分析过了,这里不再赘述。直接来看Lock里面的函数:

       lock()/lockInterruptibly():这两个函数的作用都是获得锁,区别在于后者会受到interrupt的

                                                 影响。lock()和synchronized关键字的获取锁的过程一样,都是                                                   不可中断的。但lockInterruptibly()是可中断的获取锁,如果在调                                                   用函数之前,或者等待获取锁的过程中,当前线程被interrupt                                                      了,那么会抛出InteruptException。值得注意的是,根据源码                                                     注释的描述,当其他线程持有锁的时候,当前线程处于wait状                                                     态,而不是block状态,它的内部实现是通过LockSupport的                                                          park函数来实现的。

         trylock()/tryLock(long time, TimeUnit unit):这两个函数的意思是尝试获取锁,如果当前                                                     的锁可获取,那么就获得锁并返回true,否则返回false。后者                                                      相比较前者多了超时时间,如果超时时间到了还是没有获得到                                                    锁,那么也会返回false。同时,后者还会受到interrupt操作的                                                       影响。

             Lock还有两个函数,unLock()比较简单,就是释放锁的意思,而newCondition()这里先放一放,等到后面和Object的wait()函数一并分析。

        分析完了Lock接口里面的函数,接下来就以最常用的ReentrantLock为例,来分析一下Lock的实现。ReentrantLock内部是依赖于AbstractQueuedSynchronizer来实现的,所以我们先来分析一下AbstractQueuedSynchronizer。

         AbstractQueuedSynchronizer,简称AQS,是一个非常重要的类。它是Java提供的一个同步器,可以用来实现锁或者其他同步装置的基础框架。既支持共享模式,也支持互斥模式。其他的同步装置包括CountDownLatch,CyclicBarrier和Semaphore等等,这些类的实现都是直接或者间接依赖于AQS,相关的原理后面我们会分析到。

        AQS的工作原理是这样的:首先内部定义了一个int类型的state,用来代表某种状态,至于具体代表什么,由子类来指定。子类既可以对state进行任意的加减,同时也可以简单在0和1之间切换。针对这个state,AQS内部提供了相关原子操作的函数getState(),setState()和compareAndSetState():

state相关原子操作

     由于state是基本类型int,所以对它直接的读取和赋值都是原子操作,所以getState和setState没问题。如果对state的修改要依赖当前值,那么就通过CAS来实现。

        AQS的一般使用方式是定义为自定义锁或者同步装置的内部类,对AQS里面的部分函数进行重写。能够重写的函数非常固定,因为AQS大部分的函数都是final修饰,我们没法改动。至于其他的线程阻塞和队列的维护工作,AQS帮我们完成了,我们不需要关心。AQS是个抽象类,它继承自AbstractOwnableSynchronizer,AbstractOwnableSynchronizer这个类的功能很简单,只是简单的明确了锁和线程的对应关系。内部exclusiveOwnerThread字段来对应所属的线程,并提供了相应的get/set函数:

AbstractOwnableSynchronizer

针对AQS的使用,这里先来看一个下例子,是源码注释里的:

Mutex-1
Mutex-2

       由于电脑屏幕较小,所以就截了两次图。Mutex是一个非重入的互斥锁,非重入的意思是,即便当前的线程已经获得了该锁,等下次需要的时候,不能直接获得,需要等待。

       Mutex的实现原理是这样的,内部依赖AQS,0代表的是空闲状态,意味这该锁可以被任意线程获得,1代表的是被占用状态,正在被某个线程占据。Mutex它实现了Lock接口,所以我们就从Lock函数的角度,一点点的剖析它的实现原理。

      首先来看一下lock()函数,它直接调用了AQS的acquire()函数,这个函数被final修饰,相关逻辑在AQS中已经定死了,子类不能重写:

accquire()

        accquire()函数用于互斥模式的获取,并且忽略interrupt。它内部先调用tryAcquire()函数,而这个函数是在Mutex的内部类Sync中被重写的:

tryAcquire()

        tryAcquire()的实现很简单,就是在state==0的前提下,把它设置为1,并设置所属线程为当前的线程,CAS操作确保了不会因为其他线程的干扰导致逻辑的混乱。由于是非重入的,所以不会在锁被占据的前提下,来判断占有线程是不是当前的线程。如果锁被其他线程占据,那么tryAcquire()就会返回false,那么就会调用addWaiter()函数,以当前线程建立节点Node,把它加入到链表中。AQS通过链表来实现等待队列,针对链表的操作,由于需要考虑锁和Contidion的等待队列两种情况,这里就不细细展开了,直接抛出一些影响流程的结论:

addWaiter()

        addWaiter()是把当前线程加入到链表的尾部,如果CAS操作失败,就会调用enq()函数继续这一过程:

enq()

      而enq()函数内部就是for循环和CAS的典型实现,添加进链表了之后,会继续调用acquireQueued()函数:

acquireQueued

        acquireQueued()函数是针对已经在链表中的线程,在互斥并且不受interrupt的前提下,不断的获取。如果当前的节点是Head的下一个节点,并且在tryAccquire()成功的前提下,那么就会成功获得锁。Head是链表的头节点,但它是一个虚拟节点,没有实际意义。通过acquireQueued()和addWaiter()两个函数结合分析,可以看出AQS默认获得锁是有顺序的,是先到先得的,新来一个线程从tail方向添加,获得锁的时候从head方向获取,也就是说AQS默认是一个公平锁的模式。在上一节点不是head或者尝试获取锁失败的前提下,会继续调用shouldParkAfterFailedAcquire(),这个函数笔者没有仔细分析,它的意思是在尝试获取锁失败的前提下,要不要阻塞当前的线程,阻塞的话返回的就是true,会通过parkAndCheckInterrupt()函数来阻塞当前线程:

parkAndCheckInterrupt()

       这个地方又用到了LockSupport,LockSupport是一个较为底层,原始的类,主要用于在锁或其他的同步装置中,用来实现线程的阻塞。它的原理是这样,给每个使用LockSupport的线程维护一个permit,这个perit默认不可用,而且不可计数,最多为一。当线程调用LockSupport的park()函数的时候,如果permit可用,函数就直接使用,并且消费掉。如果不可用的话,那么就会阻塞当前线程,让当前线程处于wait状态。当线程被interrupt,或者通过LockSupport的unpark()函数让permit可用了之后,那么park()函数就会返回,程序可以继续执行下去。

         简单来说,用park()阻塞线程,用unpark()来唤醒线程。park()也不一定会阻塞线程,因为会出现先调用了unPark()的情况。同时,park()调用了之后,permit也就被消费了,如果再次调用park(),那么还是会被阻塞。LockSupport里面的函数,unpark()比较简单,但park()却有很多版本。包括park(),带超时的park()和带截止时间的park(),这三类函数还各自另外有一个带Object参数的版本,这个参数的目的是在线程等待的时候,用来进行监测或者分析线程阻塞原因的。在实现各种锁的时候,这个参数一般指向的就是锁自己,而且源码里推荐我们使用这种方式。不过这里笔者也不是特别理解,并没有深刻认识到这个参数的意思,这里暂时就先了解一下。LockSupport的函数实现,依赖的也是UnSafe,这里以park()函数为例:

LockSupport$park()

       通过UNSAFE的park函数实现线程的阻塞,并且在线程被唤醒了之后,也就把blocker设置为null了。

了解了LockSupport之后,回来继续来看parkAndCheckInterrupt()函数:

parkAndCheckInterrupt()

        通过LockSupport的park函数来阻塞当前的线程,当调用了unPark,线程被interrupt或者虚假的唤醒之后,线程就可以从park函数中返回,然后返回当前线程的中断状态,并把中断状态重置。继续看acquireQueued():

acquireQueued()

       当parkAndCheckInterrupt()函数返回了,那么可能是unPark,可能是被interrupt,也可能是虚假的唤醒,如果被interrupt了,就把interrupted设置为true,而interrupted也是acquireQueued函数的返回值。但不管那种情况,获得锁的过程还会继续,因为这是不可中断的获取锁。当成功获得到锁之后,把对应的节点清理了之后,acquireQueued函数也就返回了,继续回到acquire()函数中:

acquire()

       如果在获取锁的过程中,线程被interrupt了,那么就会通过selfInterrupt函数来再次中断线程,并设置中断状态。所以实际开发中,可以在acquire()函数返回后第一时间来检查线程的中断状态,在决定之后的业务逻辑。

        以上就是Lock的lock函数的实现,是个不可中断的获取锁的功能。在看lockInterruptibly():

lockInterruptibly()

lockInterruptibly()内部调用的是AQS的acquireInterruptibly()函数:

acquireInterruptibly()

       acquireInterruptibly()这个函数会考虑interrupt的影响,所以先判断线程的中断状态,被中断的话,直接抛出异常。然后也是调用tryAcquire来尝试获取锁,失败的话,就调用doAcquireInterruptibly()函数:

doAcquireInterruptibly()

        doAcquireInterruptibly()函数里面的逻辑和刚才的acquireQueued()基本一样,不一样的地方在于当等待的时候,线程被interrupt了,那么就会直接抛出异常。

       分析完了两种lock操作之后,接下来再来看一下带有超时时间的tryLock,它直接调用了AQS的tryAcquireNanos():

tryAcquireNanos()

它里面的逻辑很多也很类似,直接来看doAcquireNanos()函数:

doAcquireNanos()

        doAcquireNanos里面的逻辑也很类似,不一样的地方只在于阻塞线程使用的是带有超时时间的park()函数。还有一点需要注意的是,各种版本的尝试获得锁,内部都会依赖相应的tryAcquire()函数。

最后来看一下unLock()函数,它内部依赖的是AQS的release()函数:

release()

首先调用的是tryRelease(),而这个函数是在Mutex中被重写的:

tryRelease()

        代码简单,这里就不解释了。如果tryRelease返回true,意味着锁释放成功了,那么下一个问题,就要从等待的线程中选取一个进行唤醒,从而让它获得锁,继续执行。这部分逻辑在unParkProcessor()中:

unparkSuccessor()

        里面具体的细节就不分析了,结论就是按照从head开始的顺序,依次唤醒线程,也就是公平锁的逻辑。

        至此,我们以Mutex这个简单的例子,将AQS作为基础框架来实现锁的过程逐步分析完毕。接下来看最常用的ReentrantLock,也就是重入锁。ReentrantLock里面的核心逻辑和刚才Mutex很类似,所以这里只分析两者之间不一样的地方。

        首先ReentrantLock,可以实现公平锁。synchronized是非公平锁,非公平锁容易出现饥饿现象,就是当多个线程并行是,容易造成某个优先级较高的线程持续获得CPU执行权的现象,而公平锁就没有这个问题,它总是按照等待的顺序来一次获得锁。公平锁虽然不会有饥饿现象,但是它的吞吐量不如非公平锁,所以开发的时候具体情况具体分析。

ReentrantLock的公平和不公平通过构造参数来指定:

ReentrantLock

        从代码可以看出来,ReentrantLock内部有两个AQS的子类,一个是公平锁的实现,另一个是非公平锁的实现,那他们分别是怎么实现的呢,可以通过lock()函数来对比:

FariSync$lock()

       公平锁的实现很简单,直接调用了acquire()函数。前面我们也分析过了,AQS的默认实现就是公平锁。再来看非公平锁:

NonfairSync$lock()

         通过代码来看,非公平锁的实现也很简单,就是在公平锁的基础上,多了一个插队的操作,如果插队失败,那么就退回到公平锁模式。再来看一下两种锁的tryAcquire()实现的区别:

FairSync$acquire()

       这是公平锁的实现,如果当前锁可以被获取,并且在没有其他线程已经等待的前提下,那么当前线程会获得锁。如果当前锁已经处于锁定状态,但是获得锁的线程正是当前线程,那么也可以获得成功,因为这是重入锁。所以公平锁的获得方式会考虑链表里是否已经有了其他的线程在等待,有的话,就会获取失败,然后把线程加入链表尾部等待。再来看一下非公平锁的实现:

NoFairSync

        相比公平锁,非公平锁的实现不会考虑链表里是否以线程在等待,而是直接插队,直接和等待的线程来争夺锁。所以非公平锁的实现就是在公平锁的基础上多了一个插队的操作,如果插队失败了,就会和公平锁一样了。笔者刚开始的时候,犯了一个想当然概念的错误,以为非公平锁应该是随机的,是从等待队列里随机选择线程,现在才发现,非公平锁并不是随机,只是多了一个插队的操作。还有一个细节就是,公平锁对于tryLock函数没有影响,tryLock函数就是非公平的,它内部直接调用的就是nonfairTryAcquire()函数:

nonfairTryAcquire()

         但是带有超时时间的tryLock是会受到公平锁的影响,因为它依赖的是相应的sync的tryAcquire(),会受到公平/不公平的影响。

       ReentrantLock内部也是依赖于AQS,但是针对state的使用上,和前面的Mutex不太一样。Mutex相当于给state定义了两个值:0和1,分别对应两种不同的状态,state的值也只是翻来覆去的在0和1之间切换,但ReentrantLock不一样,它是对state进行加1或者减1。state==0的时候代表锁被释放了,每调用一次lock()函数,state就会+1。如果当前线程已经获得了锁,再调用lock,函数会马上返回,同时state+1。每调用一次unlock,state就会减1,当state减少为0了,就意味着锁被释放了。同时,针对每个线程的调用次数,有个2147483647的上限,超过了就会抛出Error。由于这是一个互斥锁,同一时间只会有一个线程获得锁,只会有一个线程可以操作state,所以state就代表当前线程获得锁的数量,可以通过调用getHoldCount()函数来获得:

getHoldCount()
getHoldCount()

从函数的角度来讲,lock依赖于tryAcquire,每次tryAcquire的参数都是1,这里以公平锁为例:

tryAcquire()

如果锁可用,就把state设置为1,如果是重复获得,那么就加1。相应的unLock就是减一:

tryRelease

       tryRelease是被隐含在unLock函数中,会先判断锁有没有被当前线程获取,如果没有,就会抛出IllegalMonitorStateException。然后让state-1,只有在state==0的情况下,锁才算完全释放。所以unLock函数并不一定会把锁释放掉,因为lock函数可能会被调用多次,只有调用和lock()函数调用次数相同的unLock,锁才会被释放掉。

       以上就是ReentrantLock的实现过程,这里重点分析了和Mutex不同的地方,剩余的逻辑和Mutex一样,这里就不再赘述了。

       前面提到了,AQS不仅可以实现锁,还可以实现其他的装置,主要有CountDownLatch,CyclicBarrier和Semaphore,接下来就简单的分析一下这几个类的实现。

三:CountDownLatch

      CountDonwLatch,一般翻译为闭锁。它的主要作用是用来线程之间的协同,尤其是存在依赖关系的线程,一个线程必须等待另一个或另外多个线程的逻辑完成了之后才可以继续执行,这种情形就可以通过CountDownLatch来实现。

     具体的实现方式为构造CountDownLatch的时候,会指定一个int值,这就是一个最大值。某个线程调用await的时候,如果这个int不等于0,那么线程就会阻塞,直到int值等于0为止。其他的线程可以通过调用countDown来对state减一,所以CountDownLatch就是一个通过向下计数的方式实现的闭锁。

        CountDownLatch的值的设置可以根据依赖线程数量来指定,多个线程依赖一个线程,那么就可以设置为1,一个线程依赖多个线程,就可以设置为线程数量。第二种情况也可以理解为任务的拆解,将一个任务拆分,交给多个线程来执行,等他们全部执行完毕了之后,在执行之后的逻辑。值得注意的是,CountDownLatch的向下计数过程是一次性的,不可以重复设置。

        接下来,我们来分析CountDownLatch重要函数的实现原理。因为CountDownLatch适用于线程的协同,可以被多个线程访问,并且任意线程都可以对state进行修改,并没有规定互斥,所以它内部使用的是AQS的共享模式。

      首先,在构造CountDownLatch的对象的时候,指定一个int参数,随后把这个参数设置给了内部以来的AQS的state,并且这是个最大值。

CountDownLatch

       CountDownLatch有await()函数,还有一个带有超时时间的await()函数,功能类似,这里就只分析await()了。

await()

它内部调用的是AQS的acquireSharedInterruptibly()函数:

acquireSharedInterruptibly()

       这个函数和互斥模式的acquire()非常类似,也会考虑interrupt的影响,一些雷同的地方就略过了。它是先调用tryAcquireShared(),这是共享模式下的tryAcquire(),在共享模式下尝试获得。并且会返回一个int值,如果小于0,意味着获取失败了,如果==0,意味着获取成功,但是之后的获取会失败;如果>0,意味着获取成功了,后续的获取可能会成功,后续调用需先检查。这里来看一下CountDownLatch的内部实现:

tryAcquireShared()

        这里的实现很简单,和CountDownLatch 的设定保持了一致。如果当前state==0,那么返回1,意味着获取成功;否则返回-1,代表获取失败。在尝试获取失败了之后,acquireSharedInterruptibly会继续调用doAcquireSharedInterruptibly()函数,这个函数代码大家也会很熟悉:

doAcquireSharedInterruptibly

         也是会频繁的调用tryAcquireShared,在返回>=0的前提下,意味着获取成功,然后doAcquireSharedInterruptibly()函数返回。所以CountDownLatch的工作原理很简单,依赖AQS,在state==0的情况下,代表获取成功,然后线程可以继续执行,否则就阻塞线程。在这个过程中,不会更改state。

        会更改state的是countDown函数,每调用一次,对state减1。countDown()函数内部会调用releaseShared()函数,

releaseShared()

它里面的逻辑大部分和前面的release()雷同,这里只分析一下tryReleaseShared()的实现:

tryReleaseShared

       这里的代码就很熟悉了,又是for循环加上CAS操作,避免多个线程同时执行countDown()函数的时候可能造成的错误。这个函数的实现相对来说比较简单,也不会抛出IllegalMonitorStateException。因为在CountDownLatch眼里,应该是没有所属线程的概念,任意的线程都可以调用countDown()函数来更改相应的state。

         以上就是CountDownLatch的知识细节,它主要就是用于线程之间的协同,也可以说是线程间的通信。类似能实现线程通信的是Object的wait,只不过这种方式,在多个线程被wait的时候就不是很方便了,因为线程即便被notify了,还要重新争夺同步锁,而锁还是互斥的,导致还是只有一个线程可以继续执行。当CountDownLatch的state初始值==1的时候,功能确实和Object里面的wait()类似,只不过CountDownLatch方便的一点在于,不必像wait()函数那样必须拿到对应的对象锁。

四:CyclicBarrier

         CyclicBarrier,翻译过来时循环障碍。针对固定数量的线程(比如为了完成某个业务逻辑而创建了几个线程),给这些线程设置一个共同的障碍,然后让这些线程彼此之间相互等待,等所有线程都到达了这个障碍了之后,所有线程的代码才可以继续执行。并且还可以指定一个Runnable,在最后一个到达的线程里执行,在所有线程可以继续执行之前执行。      

          它的工作原理是内部使用了一个ReentrantLock,并派生了一个Condition。通过这个Lock,保护了一个int值。这个int值在对象构造的时候被创建,数值和线程数量一样。每当一个线程需要在障碍这等待的时候,就会调用await。CyclicBarrier内部有两个int类型的字段,parties代表线程的数量,count代表接下来要在障碍处等待的线程的数量。count初始值是parties,每当一个线程调用了await之后,count就会减1。await还会有一个返回值,代表的是线程到达的顺序。第一个到达的线程返回的是parties-1,最后一个返回的是0。刚开始的线程,由于int值还没有等于0,就会通过Condition的wait函数来等待。等到最后一个线程到达的时候,一方面执行指定的Runnable,另一方面唤醒其他的线程,从而让其他线程能够继续进行下去。

        接下来来分析一下相关函数的实现,这里就只分析一些重要的函数了。CyclicBarrier里面的两个版本的await函数,内部都是调用的dowait()函数:

dowait()-1

       CyclicBarrier内部定义了一个内部类Generation,用于标记当前障碍的状态,主要是为了障碍被破坏的时候。一旦障碍被破坏了,它的broken字段就会被设置为true,默认是false。当doWait()被调用的时候,先检查障碍有没有被破坏,如果破坏了,抛出BrokenBarrierException。这个异常就是每个线程在将要等待或者等待的过程中,如果障碍被破坏了,就会抛出这个异常。接下来再检查当前线程有没有被interrupt,如果有的话,抛出InterruptedException。

       由于当前线程马上就要到障碍了,所以把count--。如果减一之后的count==0,就意味着当前线程是最后一个抵达的线程。那么就会执行指定的Runnable,如果执行顺利的话,就会调用nextGeneration函数:

nextGeneration()

        通过nextGeneration()函数,将generation和count复原,然后调用Condition的sigalAll函数唤醒其他线程,然后让doWait()函数返回0。如果Runnable执行失败的话,就会在finally里面调用breakBarrier()函数:

breakBarrier()

        通过breakBarrier()将当前的线程标记为被破坏,当前线程抛出Runnable抛出的异常,其他的线程会抛出BrokenBarrierException。如果减1之后的count不等于0,就意味着当前线程不是最后一个线程,还有其他的线程没到,

dowait()-2

        通过需不需要超时,来调用Condition的await()和awaitNanos()函数,如果在等待的过程中,被interrupt了,先调用breakBarrier()来破坏障碍,从而让其他的线程抛出BrokenBarrierException,然后抛出InterruptedException。当从wait中被唤醒了之后,如果障碍已经被破坏了,也就是当前线程是通过breakBarrier()来唤醒的,那么就会抛出BrokenBarrierException()。如果设置了超时,但是超时时间小于等于0,那么先调用breakBarrier(),在抛出TimeoutException()。正常情况下,是最后一个线程到了,然后通过nextGeneration()唤醒了当前线程,那么也就是g != generation,返回对应的index就可以了。

         除此之外,CyclicBarrier内部还有isBroken()和reset()等函数,逻辑简单,这里就不分析了。通过上面分析,我们知道CyclicBarrier的原理简单来说,就是通过ReentrantLock维护了一个和线程数量相同的int值,每当一个线程到达,就把它减1,知道最后为0为止,只不过CyclicBarrier帮我们做了封装,一些锁啊,线程等待唤醒这些细节不需要我们操心,然后直接使用即可。

       最后,再来对比一下CyclicBarrier和CountDowLatch的区别。两个类都是随着对象的创建初始化一个int值,而且都是最大值,后续只会减少,只不过CyclicBarrier可以重复使用,而CountDownLatch是一次性的。从概念的角度来讲,CountDownLatch侧重于有依赖关系的线程之间的协同。比如有个线程A,还有一组完成某项业务逻辑的线程组B。线程A和线程组B之间的协同恭喜可以通过CountDownLatch来实现。而如果线程组B内的线程,彼此之间需要设置一个障碍,然后相互等待,那么这个时候CountDownLatch就没法实现了,CyclicBarrier是更好的选择。CountDownLatch的控制权掌握在依赖的线程手里,而CyclicBarrier设置了一个障碍,控制权在自己手里。

       对于CyclicBarrier和CountDownLatch,他们之间的区别,由于笔者工作中并发经验并不是很多,所以这个地方只能从概念的角度,从自己理解的角度来进行区分,还做不到结合具体的业务场景来分析,以后有机会在补充吧。

五:Semaphore

        Semaphore,翻译过来是信号量。这个类型内部会维护一定数量的permit,在对象构造的时候会被指定。主要用于为了保护某个共享资源,从而限制这个共享资源被同时访问的线程数量或者次数。每个线程访问的时候,先需要调用acquire(),来申请一个或多个permit,如果想要数量的permit可用,那么会消费,然后这个函数返回,否则就会阻塞线程。同时,其他的线程可以通过release来释放一个或多个permit,并把相应阻塞的线程唤醒。当Semaphore如果只声明了一个permit,其实这个时候就和锁类似了,只不过和锁不一样的是,它没有所有权的概念,任何一个线程都可以释放。同时,它和ReentrantLock一样,也有类似于公平锁的概念,这个原理大同小异,使用方式也类似,所以就不再赘述了。值得注意的一点事,Semaphore在实际开发中可能还得需要和锁一起配合使用。因为可能刚开始配置的permit比较多,然后这个时候来了几个线程想访问共享资源,那么都可以成功申请到permit,都可以对资源进行访问,甚至修改,可能会有安全问题的风险,所以还需要和锁配合在一起使用。

        接下来针对Semaphore函数的具体实现,内部也是依赖于AQS,很多地方都是雷同类似的,所以这次只会大概的讲述一下。首先,构造函数会指定permit的初始数量:

构造函数

        这个参数只是一个初始值,而不是最大值,既可以通过acquire来减少,也可以通过release来增加。甚至还可以是个负数,只不过这时候就需要先调用release,在调用acquire。Semaphore中acquire()的实现和CountDownLatch类似,也是基于共享模式的获取,所以关键还是在tryAcquireShared()函数中。同时,它的公平与不公平,和ReentrantLock里面是一样的,就不再赘述了。就简单看一下公平模式下的tryAcquireShared()的实现逻辑:

tryAcquireShared()

        虽然Semaphore维护的是permit,但内部还是通过state来代表。state就是permit的可用数量。每次调用tryAcquireShared的时候,就意味着申请一个或多个permit。那么就对state进行相应的减法运算,看剩余的permit 的数量,是否满足这次的请求,满足的话就消费,更改state,不满足的话就会加入链表,然后阻塞线程等待。那另一方面,就是release也是调用共享模式的release,核心也是在tryReleaseShared()函数上:

tryReleaseShared()

        由于Release的本质是释放相应数量的permit,也就是对permit进行增加,又没有其他的要求,所以这个地方逻辑也很简单,就是for循环和CAS操作,增加相应数量的permit,并通过doReleaseShared()函数来唤醒等待线程。

          至此,Semaphore的原理我们分析完了。简单来说,Semaphore就是一个用state来代表permit的数量,然后通过acquire()和release()对state进行加减,从而限制访问共享资源的线程数量和次数的同步装置。

六:生产者-消费者模式

       生产者消费者模式大家都很熟悉,耳熟能详,是并发中非常普遍的一个模式。我们这里就不对这个模式展开过多的细节,重点来分析一下其中线程通信的细节。在生产者消费者模式中,根据实际需要,需要平衡生产者生产数据和消费者消费数据的能力。最简单的一种情况就是一生产者一消费者,生产一个,消费一个,这就需要线程之间进行通信。

       所谓的线程通信,并不是将某个数据类型的对象在线程里传递,而是线程之间传递信号,在Java中,通过等待唤醒来实现,在Android中,还可以通过Handler的方式。

       针对线程的等待唤醒,有两种实现方式,一种是定义在Object里面的wait()和notify()函数,另一种就是Condition的await()和signal()。我们先来看一下wait/notify的方式,这两个函数是定义在Object里面的,所以每个数据类型的对象都可以使用。

        先来看一下wait()函数,代码就不贴了,这里直接说总结的结论。wait是一个native函数,通过任意类型的对象的wait()函数,可以让当前的线程休眠,等待,处于wait状态。当线程被interrupt,其他线程调用了该对象的notify()/notifyAll()之后,线程就会醒来。除此之外,线程也可能会没有任何原因的醒来,这种情况被称为虚假唤醒。为了规避虚假唤醒的情况,最好把wait放在循环里。当从wait函数返回的时候,再次对让线程进入等待状态的条件进行检查,如果还不满足,那么继续等待。调用函数一定先拥有对应对象的锁,否则的话会抛出IllegalMonitorStateException。这个地方是源码中的描述,但笔者实际代码测试的时候代码会走到catch代码块里,但抛出的异常却是null,具体原因还没有找到。拥有对象锁的方式,也就是锁的使用方式,大家都很熟悉。当wait()函数一调用,持有的该对象的锁会释放掉,如果还持有其他的对象锁,这些锁不会被释放。当发生了刚才的几种情况之一,导致线程醒来,但线程不会马上从wait()函数中返回,他还需要重新获得失去的锁,一旦获得成功,才可以从wait中返回。同时,wait()函数还会受到interrupt的影响,当调用这个函数之前,或者在等待的过程中,如果线程被interrupt了,那么就会抛出异常。值得注意的是,如果是在等待的时候被interrupt,那么也是先重新获得锁之后,在抛出异常。在异常抛出的同时,持有的对象锁也就自动被释放了。如果已经被唤醒了,但在和其他线程重新争夺锁的过程中,线程被interrupt了,这种情况下不会抛出异常,但线程的中断状态会被设置。

       根据上面的分析,我们了解到虽然wait()只是一行简单的代码,但背后却包含这很多逻辑,很多种情况。由于它是native函数,所以看不到具体的代码实现。等我们接下来学习Condition的时候,可以具体分析一下实现代码。同时,Object内部还有一个带有超时时间的wati()函数,只是多了一个超时时间的因素,其他的完全一致,这里就不多讲了。

        分析完了wait,再来看一下notify。它也是一个native函数,有notify和notifyAll两个版本,两个版本的区别在于前者是从等待线程中选择一个来唤醒,具体选择哪个,应该是jvm的实现;后者是把所有的线程全部唤醒。根据wait的分析,即便线程被唤醒了,仍然需要重新获得锁,才可以从wait函数中返回。这两个函数调用的时候,同样也需要先持有对象锁,否则也会抛出异常。

       分析完了wait和nofity两个函数的原理,那么基于这种方式的线程等待唤醒的过程我们也就清楚了。然后就可以通过这个,来平衡生产者和消费者之间的性能差异。但参考文章中提到了一个假死的问题,造成这个问题的原因是因为唤醒的时候使用的是notify,就有可能在某个时刻,出现唤醒的是同类线程的情况。生产者唤醒的是生产者线程,消费者唤醒的事消费者线程,从而最后让所有的线程全部进入等待状态,从而假死。文章中给的解决方式使用notifyAll来替换notify的使用,这样就可以避免唤醒的只是同类线程的情况。但这种方案不够彻底,我们可以分析一下,在生产者消费者模式这个场景下,相当于按照职责把线程分为了两类,负责生产数据的是生产者线程,负责消费数据的是消费者线程,那么最好的解决方案是等待和唤醒的时候可以指定相应的线程,比如只唤醒消费者线程,只让生产者线程等待。这个需求使用Object的wait/notify就没有办法实现了,因为一个Object对象是一把锁,一把锁只会有一个版本的wait/notify函数,不可能做到对指定类型线程的等待唤醒。针对上述方案的不足,所以才有了Condition。

         Condition,翻译过来是条件,更本质的理解是对线程的分类,将线程按照职责进行分类,从而对不同类型的线程进行指定的等待和唤醒。Condition内部也定义了和Object的wait/notify功能类似的函数await/singal,用来实现线程的等待唤醒,基本的用法也完全一致。不一样的地方在于,使用Object的wait和notify函数时,它是和synchronized对象对象锁绑定在一起的。针对同一个对象锁,它只会有一个等待条件。而Condition是和Lock绑定在一起,针对同一个Lock,可以通过newCondition()函数创造出任意个等待条件。所以,可以理解为Condition是针对Object中的wait/notify函数的拆分,分解。

        接下来再来看一下Condition相关函数的实现,Condition是一个接口,它的实现类是定义在AQS里面的ConditionObject,一般像ReentrantLock等锁都是直接拿ConditionObject来用。

ConditionObject

        ConditionObject是AQS的内部类,所以它的等待队列是直接使用AQS里面的。Condition的函数分为两类,分别负责等待和唤醒。等待相关的函数有4个:await(),带有超时时间的,带有截止时间的和不支持interrupt的。这里以await为例分析就好:

await()

      ConditionObject的await()的实现过程,几乎和Object的wait()原理是一样的,所以在分析await的原理的过程中,顺便加深一下对wait()函数的理解。首先检查当前线程的状态,如果被中断,直接抛出异常。换句话说,如果在调用await()之前,当前线程被interrupt了,那么就会直接抛出异常。值得注意的是,这个时候当前线程还是持有对应的Lock的,持有的Lock还没有被释放,在Object中,synchronized会保证在抛出异常的时候,自动释放锁。在Lock,中就需要我们显示的释放锁,这也是一般都是在finally里面释放锁的原因。排除了interrupt的情况之后,接下来就会释放Lock,还会有判断,如果当前线程没有持有这个Lock,难么就会抛出IllegalMonitorStateException。这部分的逻辑被定义在了fullyRelease()中:

fullyRelease()

        这个地方,笔者刚开始也犯了一个想当然的错误。想当然的以为要先判断持有Lock的线程是不是当前的线程,如果不是的话,就抛出异常。其实完全没必要做,因为Lock是个互斥锁,同一时间只会被一个线程持有。所以只需要执行释放Lock的逻辑就可以了,如果释放失败,也就意味着Lock没有被当前线程持有,根本不需要额外的判断线程。fullyRelease()里面的逻辑大家一看就懂,就不多说了。那么await()函数到此,就已经释放了锁持有的锁了,下一步就是等待了。而阻塞线程的实现,就是前面介绍到的LockSupport的park函数来实现的。

        在阻塞线程之前,先要通过isOnSyncQueue()函数来确保,当前线程不在同步队列中。这里解释一下,针对Lock也好,synchronized也好,可以理解为它内部有两个队列。一个里面存放的是等待锁的线程,理解为同步队列。另一个是因为wait陷入等待的线程,理解为等待队列。如果一个线程从wait中醒来,然后和其他线程争夺锁的时候,就会从等待队列来到同步队列。

       虽然说是有两个队列,但是AQS 里面的实现就是同一个链表,然后通过Node的字段来进行区分,这个地方暂时先不分析了,后续再补充吧。通过isOnSyncQueue()函数,确保线程还在等待队列的前提下,就通过LockSupport来阻塞当前的线程。LockSupport的park函数会放在循环中,每一次循环都会对线程不再同步队列里这个条件进行检查,从而避免了虚假唤醒的情况,如果是正常唤醒,也就在同步队列里了,循环也可以退出。

       除了虚假的唤醒,还需要考虑interrupt的情况。既要考虑在等待的时候被interrupt的情况,还要考虑从等待状态被唤醒了之后被interrupt的情况。这两种情况通过checkInterruptWhileWaiting来检查,返回一个int值。如果等待中被interrupt,返回-1,如果被唤醒了之后才被interrupt的,那么返回1,正常情况返回0。但不管哪种情况,接下来都会通过acquireQueued来获得锁,同时在等待获取锁的时候,也有可能会被interrupt。int返回值由局部变量interruptMode来代表,如果在获取锁的过程中或者在LockSupport.park()正常返回之后,获取锁之前被interrupt的,那么interruptMode会被设置为1。在LockSupport.park()期间被interrupt的,那么interruptMode会被设置为-1。针对不同的interruptMode,通过reportInterruptAfterWait来分情况处理:

reportInterruptAfterWait()

      在==1的情况下,只是中断线程,在==-1的情况下,就会抛出InterruptedExceptionl了。总结来说,针对interrupt的处理,Object中的wait和Condition中的await()是完全一样的。如果是在wait的过程中被interrupt了,那么会在重新获得锁之后,抛出InterruptedExceptionl。如果在wait之后,在争夺锁的过程中被interrupt了,那么就不会抛出异常,但是中断状态会被设置。不管怎样,锁肯定会重新获取到。

       分析完了await相关,下一个就是signal相关的。signalAll是唤醒所有的线程,唤醒的方式就是LockSupport的unPark,而signal是从队列中选择一个来唤醒。选择的逻辑也是先进先出的,因为通过addConditionWaiter向尾部添加的,然后唤醒的时候是从头部开始唤醒的。这里就只简单的分析一下signalAll()就好了:

signalAll()

      首先会判断当前线程有没有持有锁,没有的话就会抛出异常,接下来就会从头开始,调用doSignalAll()函数:

doSignalAll()

       这个函数的核心逻辑都在transferForSignal()函数里,代码就不贴了。这个函数一方面通过LockSupport的unPark()函数来唤醒所有的线程,另外一方面将这些线程从等待队列里添加到同步队列里。这样就正好的和await()函数里面的逻辑形成了配合,在await()里这些线程会争夺锁的所有权。

     至此,关于ConditionObject的内部原理我们分析完了,通过上面的分析,一一解开了线程等待唤醒的面纱,它的内部并不神秘。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,015评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,262评论 1 292
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,727评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,986评论 0 205
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,363评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,610评论 1 219
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,871评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,582评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,297评论 1 242
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,551评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,053评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,385评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,035评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,079评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,841评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,648评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,550评论 2 270

推荐阅读更多精彩内容