J.U.C同步框架

原文点击这里

摘要

J2SE1.5 java.util.concurrent包中大部分的同步工具(locks,barriers等等)都是基于AbstractQueuedSynchronizer类(下面简称AQS)构建的。这个类提供了通用的机制来“原子化”管理同步状态,阻塞和唤醒线程以及管理同步队列。本文介绍了该类的一些基本设计思路、实现、用法以及一些性能方面的考量。

1.简介

J2SE1.5 引入了J.U.C包,提供了一系列用来支持并发操作的"同步器"。
这些"同步器"都基于一个基础框架:1.包含一个同步状态;2.提供修改和查看同步状态的方法;3.提供阻塞当前线程和唤醒等待线程的方法;
这些"同步器"包括:mutex locks,read-write locks,semaphores,barriers,futures,event indicators,SynchronousQueue等.

几乎每种类型的"同步器"都可以用其他类型的"同步器"来实现,例如,semaphores可以实现reentrant locks,反之亦然。但是,这么做通常比较复杂,性能也比较差,不够灵活。所以JSR166提供了一个很小但是又很全面的基础类AbstractQueuedSynchronizer,提供了以上这些"同步器"需要的大部分机制的实现,"同步器"只需补充实现各自的特性即可。

本文剩下的部分讨论下AQS的功能需求,设计和实现的主要思路,一些简单的用法,还有一些测试用来展示它在性能方面的特征。

2.需求

2.1 功能

"同步器"提供了两类方法:1.至少一个acquire,这个方法获得锁,如果”锁“已被其他线程占用,则阻塞当前线程直到被释放锁的线程唤醒。2.至少一个release,这个方法释放锁,并且唤醒一个或多个等待的其他线程。

J.U.C包没有定义一个统一的API来规定这些"同步器"的方法和功能。所以它们有的是通过实现一个通用接口(如Lock),有些是直接实现了特定的功能。所以,acquirerelease在不同的"同步器"里可能会有不同的名字。例如,Lock.lock,Semaphore.acqure,CountDownLatch.await,和Future.get 都是与acquire类似的方法。

但是,J.U.C包也保持了一些一致的惯例。每种"同步器"必须支持:

  • 既支持阻塞也支持非阻塞(例如: lock & tryLock
  • 提供超时机制:以便应用可以放弃等待
  • 响应线程中断:通常提供两个类似的acquire方法,一个支持中断一个不支持

"同步器"既可以是独占锁,也可以是共享锁。例如,ReentrantLock是独占锁,而Semaphore是共享锁。为了适用性更广,AQS必须同时支持这两种模式的"同步器"。

J.U.C包还定义了一个接口Condition,配合ReentrantLock实现"monitor"风格(即Java中的Synchronize关键字)的"await/signal"操作。

2.2 性能

Java内建的synchronize关键字一直存在性能方面的问题,有很多文献曾经分析过这些问题。但是这些研究主要集中在单核环境下的绝大多数时候都是单线程情形的最小化内存占用和cpu时间占用。而实际上这些问题都不算问题:1.程序员们只有在需要使用的时候才会使用锁,所以锁的内存开销相对于整个应用的内存开销占比非常小,压缩内存意并不大;2.越来越多的程序是运行在多核多线程的环境下,在这种环境下资源竞争是不可避免的。但是过去的JVM对于锁优化的策略主要基于没有资源争用(单核)的情形,而对其他的情形都使用难以预测的“slow path"(通过阻塞和唤醒线程的方式进行同步),这对于严重依赖JUC的典型多线程服务来说并不是一种正确的策略。

(译者注:在linux内核中,当一个进程尝试获取一个mutex时,它可能会走三种paths:

  • fastpath: 无资源争用(zero-contention),直接获取到锁
  • midpath: 有少量资源争用,采取optimistic spinning
  • slowpath: 有大量资源争用,采取block/wakeup

JVM的优化只针对了“fastpath”,而且JVM的path只有两种:fastpath和slowpath,没有midpath。其实不难理解,因为midpath只有在多核(是多核而非多线程)环境下才有效,单核环境下的spinning没有意义,而JVM主要针对单核优化的。Java 1.5引入了JUC,所以这篇文章所描述的是1.5之前的monitor的性能问题,而Java 1.6之后monitor已经不存在这些性能问题了)

我们这里的主要性能目标是可扩展性:即使在资源竞争激烈的情况下,也要可预测的平滑性能。最理想的情形是无论有多少线程请求这个"锁",它们的处理时间都是常数级的。在这些目标中有一个是要尽量减少那些可以获得锁但是还未完成加锁过程的时间的总和(降低获取锁过程中的开销,减少锁的总空闲时间)。当然,这里也需要在总的CPU耗时,内存负载以及线程调度的耗费之间作出一些权衡。例如,自旋锁通常可以缩短获取锁的时间,但是会让CPU空跑,也会产生一些内存争用,所以应用得不多。

另外,我们需要考虑两种不同类型的使用场景。第一种是大部分应用的需求,需要最大化吞吐量,而对于可能产生的"线程饥饿"可以容忍。另一种是对于那些资源控制类型的应用,需要保证每个线程获取资源的公平性。没有一种策略可以同时满足这俩种互相冲突的目标,所以AQS需要提供这两种不同的公平性策略。

无论框架设计得多么好,在一些实际的应用中,一定会存在性能瓶颈。因此,框架需要提供方法来监控和检查一些基本操作,让使用者可以发现并且缓解性能瓶颈。例如,至少得提供个方法让用户可以知道有多少线程在阻塞。

设计与实现

"同步器"的基本思路非常简单。
有一个acquire

while (synchronization state does not allow acquire) 
{ 
    enqueue current thread if not already queued; 
    possibly block current thread;
}
dequeue current thread if it was queued;

有一个release:

update synchronization state;
if (state may permit a blocked thread to acquire)
    unblock one or more queued threads;

想要支持这两个操作需要三个基本组件的配合:

  • 原子地更新同步状态
  • 阻塞和唤醒线程
  • 管理线程队列

也许可以为这三个组件各创建一个框架,允许它们可以相互独立,但这种方案效率不高,也缺乏可行性。例如,唤醒某个线程依赖于队列中该线程所在节点的状态,而开放出来的方法(public或protected)也依赖于同步状态。

同步框架的核心设计是为这三个组件每个选择一个具体的实现,同时仍允许在如何使用它们上有广泛的选项。这种设计可能牺牲了一些适用性,但是能保证足够的效率,所以当实际使用的时候,如果场景合适,几乎没有理由不用它而选择自己实现一个。

3.1 同步状态

AQS用一个int值来表示同步状态,并且开放了getStatesetStatecompareAndSetState方法来获取和更新它。这个状态值是volatile的,符合JSR133中volatile的语义。并且compareAndSetState基于CAS指令实现了该状态的原子更新。

将状态限定为int是个务实的决定。JSR166也提供了对于long类型的原子操作类,但是在很多平台上这些操作在JVM内部实现的时候有一些lock,性能会差一些。未来,有可能会提供另一个基于long型状态值的"AQS"。但是,目前还没有足够的必要性去添加这个特性,在绝大部分应用场景下,int已经足够满足需求----只有一个CyclicBarrier需要更多的字节去表示状态,所以我们直接使用了其他的”锁”来实现(用其他"锁"来管理状态,而不是直接实现或使用AQS,J.U.C中大部分的高级API都是如此)。

基于AQS的同步器实现类必须实现两个方法tryAcquiretryRelease,通过使用开放出来的状态检查和更新方法来实现acquirerelease操作。如果获取"锁"成功,tryAcquire方法必须返回true;如果成功"解锁",release方法也必须返回true。这些方法都接收一个int类型参数,用来沟通需要的状态。例如,在Reentrant Lock中,如果某个线程acquire成功,则它在持有"锁"的过程中可以利用此参数一次性acquire多个或多次acquire,也可以利用此参数一次性release多个或多次release。很多"同步器"实现不需要这个参数,直接忽略即可。

3.2 阻塞和唤醒线程

直到JSR166,还没有可用的Java API用来为同步器阻塞和唤醒线程。唯一的候选者是Thread.suspendThread.resume,但是他们不能解决一个问题:如果一个活动线程在suspend之前调用resume,则这次resume操作没有任何作用。

J.U.C包引入了一个LockSupport类来解决这个问题。LockSupport.park方法可以阻塞当前线程直到LockSupport.unpark方法来唤醒它(包括虚假唤醒)。unpark不会被"计数",所以在park之前无论调用多少次unpark只能唤醒一次park(译者注:但是有unpark状态,也正是这一点解决了suspendresume的问题)。另外,这俩方法都是针对线程而不是"同步器"的。一个在新的"同步器"上调用了park的线程可能会立即返回(因为有可能之前还有个"剩余"的unpark),它的下一次park才会阻塞。虽然可以显式地清除unpark的状态,但是更优雅的方法是多次调用park方法。

这种机制类似于Solaris-9的线程库,win32的“可消费事件”和linux NPTL的线程库,所以它能很好地映射到这些平台库上,提供良好的运行性能(但是,目前的Sun HotSpot JVM在Solaris和Linux上的实现实际上使用了一个pthread条件变量,因为需要适配现存的运行时设计)。park也支持可选的相对或者绝对的超时时间,并且响应JVM的Thread.interrupt

3.3 同步队列

AQS的核心是对同步队列的管理,且队列已被限定为先入先出队列。所以,AQS不支持基于优先级的同步。

毫无争议,最适合用来做同步队列的数据结构是那些不需要依赖更低级的"同步器"的。所以,现在有两个主要的候选者:MCS 和CLH。历史上,CLH只被用于过自旋锁。但是,它看起来比MCS要更经得起考验,因为它可以很轻松地处理取消和超时,所以我们选择CLH作为同步队列的基础。

CLH队列不是一个"单纯"的队列,因为它的出队和入队操作都与它的作为"同步器"的功能紧密联系。它是一个linked队列,包含两个原子更新的属性headtail,它们一开始都指向一个无意义的节点。

20181016041652.png

新的节点可以通过原子操作入队:

do { 
  pred = tail;
} while(!tail.compareAndSet(pred, node));

当前节点的release状态由它的前置节点管理,所以,"锁"的自旋如下:

while (pred.status != RELEASED) ; // spin

在自旋之后的出队操作就是简单地把head设置成当前获得"锁"的节点即可:

head = node;

CLH最主要的优势在于出队和入队非常快,而且没有任何锁的开销,也不需要阻塞线程(即时是在有竞争的情况下,因为总会有一个线程插入成功);检查有没有线程在等待也很简单(只需要判断headtail是否是一样的即可);而且release状态是非中心化的,避免了内存争用。

在原始版本的CLH中,节点之间甚至没有连接。在自旋锁中,前置节点被作为一个局部变量。但是,Scott和Schere发现通过显式地将前置节点与当前节点连接,CLH可以处理超时或者其他形式的取消操作。因为如果一个节点的前置节点被取消了,这个节点可以继续找到"前置节点"的"前置节点"的状态。

AQS对于CLH最主要的改动在于提供了一个有效的方法可以使一个节点找到它的后继节点。在自旋锁中,一个节点只要修改了它的状态,就会被它的后继节点在下一次循环时注意到,所以不需要将当前节点与它的后继节点连接起来;但是在阻塞的"同步器"中,一个节点必须显式地唤醒(unpark)它的后继节点。

AQS的节点有一个next属性关联到它的后继节点。但是没有什么技术可以"无锁地""原子地"向双向队列中插入一个节点,所以这个属性没有作为原子性插入节点的一部分(译者注:prednext只需要保证一个原子更新成功即可,另一个直接赋值);只需要在节点插入之后简单地赋值即可:

pred.next = node;

next连接通常只被当做一种优化的访问路径。如果一个节点的后继节点不存在了(可能被取消了),可以通过从tail遍历每个节点的pred属性找到一个实际存在的节点。

第二个改动是在每个节点中使用一个status属性来控制线程的阻塞和唤醒,而不是一直自旋。在AQS中,队列中的节点只有在tryAcquire返回trueacquire成功才能返回,所以仅仅只有一个release位是不够的(release状态只能支持acquire操作,tryAcquire需要依赖其他状态)。而且需要保证一个活动的线程只有当它在队首的时候才可以tryAcquire,也可以通过检查当前节点的前置节点是否是head来确定。

    final boolean acquireQueued(final Node node, int arg) {
        boolean failed = true;
        try {
            boolean interrupted = false;
            for (;;) {
                final Node p = node.predecessor();
                if (p == head && tryAcquire(arg)) {
                    setHead(node);
                    p.next = null; // help GC
                    failed = false;
                    return interrupted;
                }
                if (shouldParkAfterFailedAcquire(p, node) &&
                    parkAndCheckInterrupt())
                    interrupted = true;
            }
        } finally {
            if (failed)
                cancelAcquire(node);
        }
    }

与自旋锁相比,这样减少了读取head的内存争用(只读取一次,而自旋锁在一直循环读取)。但是,取消的状态还是需要在节点中维护。

队列中节点的状态还被用来避免对parkunpark不必要的调用。虽然这些方法跟线程的阻塞原语(操作系统级别)性能相当,但还是不可避免地有一些横亘在Java代码和JVM运行时之间的耗费。所以在调用park之前,线程先设置一个signal me状态位,然后检查一次同步状态和节点状态,最后再parkrelease操作会清除这个状态位(所以,如果某个线程在设置signal mepark之间,持有锁的线程调用了release,并且正好unpark了这个线程,则节省了一次线程阻塞和唤醒的消耗)。这种做法可以避免很多不必要的阻塞,尤其是对于那些耗费了很多时间在等待下一个合适的线程获得锁的"同步器"的实现中(对于这类"同步器",阻塞应该尽量避免,因为阻塞了之后,等待下个线程来唤醒的话可能会比较耗时)。这样还可以避免一些release操作,因为release需要节点设置了signal状态,所以我们可以跳过那些cancelled的节点,除非signal和cancel同时发生了。

也许AQS与其他语言的CLH变种之间的最大区别是依赖垃圾回收器管理节点的内存回收,这避免了一些复杂性和性能消耗。然而,虽然有GC,但是还是需要将确定不再用到的属性置为null。这个在节点出队的时候显然肯定会做到,但是,对于那些已经取消的节点却仍然可以被访问到,导致他们不能被回收(需要手动置为null,help gc)。

还有一些更进一步的小优化,包括将headtail的初始化延迟到第一次有线程等待时。

忽略这些细节,最终对于基本的acquire(排他,不响应中断,不超时)的大致实现:

if (!tryAcquire(arg)) {
    node = create and enqueue new node;
    pred = node's effective predecessor;
    while (pred is not head node || !tryAcquire(arg)) {
        if (pred's signal bit is set) 
             park();
        else
             compareAndSet pred's signal bit to true;
             pred = node's effective predecessor; 
     }
     head = node;
}

release的大致实现:

if (tryRelease(arg) && head node's signal bit is set) { 
    compareAndSet head's signal bit to false;
    unpark head's successor, if one exists
}

虽然acquiretryAcquire可能会循环多次,但是,如果不考虑被取消的节点和每个平台对于park的不同实现的话,acquirerelease中的单个操作分摊到每个线程中都是常量时间O(1)

对于取消的支持主要是在park的时候需要响应中断和检查超时。一个由于超时或中断被取消的线程会设置节点的状态,并且唤醒它的后继线程,所以它有可能会重置这些连接。因为有被取消节点的存在,找到有效的前置节点或者后继节点并修改状态可能会花费O(n)次的遍历(n是队列长度)。如果某个操作因为超时或中断被取消了,通常直接返回false或者抛异常,不会再去relock,所以节点的连接和状态属性可以很快重建好。

3.4 条件队列

AQS提供了一个ConditionObject类来实现排它锁,并且遵循Lock接口的API。一个锁可以绑定任意多的条件对象(condition object),可以提供monitor风格的awaitsignalsignalAll操作,包括这些操作的超时版本和一些监控和检查的方法(详见J.U.C的Lock接口)。

ConditionObject类可以用来与其他同步器配合,修复一些设计缺陷。这个类支持monitor风格的访问,当且仅当当前线程拥有锁的时候。将ConditionObjectReentrantLock结合起来可以完成与Java内建monitor相同的操作(像Object.wait等),只是在名称上略有不同,而且还有个额外的功能:用户可以对一个锁声明多个条件对象(内建只有一个)。

ConditionObject使用和"锁"相同的队列节点类型,但是使用了单独的条件队列。signal操作就是将条件队列中的节点转移到"锁"的同步队列中,而不是唤醒等待线程(这里不就是类似公平锁了吗?性能上应该会差很多吧?不知道为什么这么做,求指教)。

基本的await操作:

create and add new node to condition queue; release lock;
block until node is on lock queue;
re-acquire lock;

signal操作:

transfer the first node from condition queue to lock queue;

因为这些操作只在当前线程持有"锁"时才会发生,所以不需要考虑线程同步的问题,只需要按顺序将每个节点连接起来即可(在节点中用一个nextWaiter属性)。transfer操作只需要将条件队列中的第一个节点转移到它所关联的锁的同步队列尾部即可。

实现这些操作最复杂的部分是处理超时和中断引起的等待取消。一个取消操作如果与signal操作发生的时间非常接近的话,可能会产生竞争,它们的竞争结果符合Java内建monitor的规范。JSR133修订版中规定,如果中断发生在signal之前,那么await方法必须在被唤醒时抛出InterruptedException;如果中断发生在signal之后,那么await必须无异常地返回,但是需要设置它的线程中断状态。

为了保证合适的处理顺序,节点有一个”transferred“状态(或者正在被transfer)(waitStatus里没有详细指出来,看代码应该是直接用初始状态0代替了)。signalcancel都会去CAS这个状态。如果signal失败了(cancel先发生),则它会继续signal下一个节点(如果有的话);如果cancel失败(signal先发生),则放弃这次cancel,重新去获取锁(如果是超时引起的cancel,显然,因为signal在超时之前,所以超时不成立;如果是中断的话,中断状态被设置就可以了(更底层的代码已经处理过了),如上节所述)。后者可能会自旋很久:只有在signal过程完成之后,"cancelled"的节点才可以去重新请求锁。这种情况很少,而且使用了Thread.yield让出取消节点的线程的CPU时间片,最理想的情况是正好被signal的节点的线程轮转到,那么显然自旋很快就会结束。虽然可以实现一些策略来帮助解决取消节点的自旋,但是这种情形太少见了,没有多大意义,反而会增加些额外的消耗。在其他任何情形下,都不会有自旋或yield,所以在单核的情况下能够保证不错的性能。

(这里看了下代码,详细解释一下。首先对比一下lock和condition的实现。lock对应acquirerelease,condition对应awaitsignalacquireawait都有不响应中断(准确地说只是不抛异常,还是会唤醒并设置中断状态)和超时、只响应中断(抛异常,下同)、同时响应中断和超时三种版本,功能也基本相似。但是release的实现与signal的实现有所不同,release直接唤醒等待线程,如果将要唤醒的节点因为超时或中断取消了或正在取消,其实线程已经是运行状态了,可以忽略这次release;而signal有个transfer阶段,将需要signal的节点转移到Lock的同步队列中,而await可能会发生的cancel(超时和中断)也有一个transfer阶段(也是转移节点到同步队列),这两个transfer显然会产生竞争(如上节所述),所以不能像release那样直接忽略就可以了(ParkSupport.unpark一个活动的线程会立即返回,但是可能会导致下次的park被抵消掉,所以park需要自旋(见3.2))。

4. 使用

AQS类将上述的功能结合在一起并用"模板模式"作为所有"同步器"的基础类。子类只需要实现状态的检查和更新操作来控制acquirerelease即可。但是,这些子类不会直接继承AQS,因为AQS类有一些公开的方法,这些方法用来控制acquirerelease的策略,不能直接暴露给普通用户。所以J.U.C中的"同步器"都声明了一个继承AQS的内部类,"同步器"的功能用这个内部类来代理。这样不仅对用户屏蔽掉了复杂的实现细节,而且还可以根据需要定义任意名称、任意功能的方法。

例如,下面是个最小的Mutex类,用状态是否为0来控制锁的状态。

class Mutex { 
class Sync extends AbstractQueuedSynchronizer { 

    public boolean tryAcquire(int ignore) {
        return compareAndSetState(0, 1);
    }
    public boolean tryRelease(int ignore) {
        setState(0); 
        return true; 
    }
}
 private final Sync sync = new Sync();
 public void lock() { sync.acquire(0); }
 public void unlock() { sync.release(0); }

完整版本的Mutex例子和用法可以在J2SE文档中找到,当然可能会有一些变化。例如,tryAcquire可能会使用"test-and-test-and-set"来更新状态。

你可能会有些惊讶一个性能敏感的"同步器"竟然会用"代理"和"虚方法"的组合方式来实现(代码比较繁琐)。但实际上,这些都是现代动态编译器长时间聚焦的面向对象编程的方法。他们擅长于优化并消弭掉这些性能损耗,尤其在这些被频繁使用的"同步器"上。

AQS类还提供了很多方法可以帮助各种"同步器"控制同步策略。例如,它同时支持了不响应中断(准确地说只是不抛异常,还是会唤醒并设置中断状态)和超时、只响应中断(抛异常,下同)、同时响应中断和超时三种版本的acquire方法。还有,虽然讨论到现在的一直是排他锁,但是AQS同样包含了一系列支持共享锁的方法(如acquireSharedreleaseShared),它们使得可以允许多个线程acquire,并且可以级联地唤醒多个等待的线程。

虽然通常不会直接序列化一个"同步器",但是在配合构成其他类例如线程安全的集合类的时候,序列化是很有必要的。AQS类和ConditionObject类提供了一些方法序列化"同步器"的状态,但是没有序列化阻塞的线程或者其他的一些瞬时状态。实际上,大部分的"同步器"反序列化的时候都是重置状态到初始值,这与内建monitor的反序列化策略一致:总是反序列化到未锁的状态。

4.1 公平性

尽管AQS是基于先进先出队列的,但并不一定是公平的。在acquire入同步队列之前会先tryAcquire。所以如果恰好在解锁的过程中有一个线程tryAcquire,则会“窃取”这次加锁的机会。

这种策略叫做"barging FIFO",通常可以提供更高的吞吐量。它主要利用了"解锁过程"这一段时间,在这期间允许其他线程"窃取"锁,无需入队出队,从而减少了整体的入队出队时间。这种策略在每个线程只需要短暂地持有"锁"的应用中尤其有效,相反地,如果每个线程需要持有很长时间的"锁",这种策略则会造成很多"解锁"失败的情况,可能会起负面作用。一个简单的衡量方法是看平均"解锁时间"与平均"持锁时间"的比例,如果大于1的话这种策略效果显著。不过这种策略也会产生一个问题,如果新线程进入的频率太快,则可能会造成同步队列的head节点陷入"unpark-acquire failed-park"循环中,永远拿不到锁,造成“饥饿”。在多核环境下,这种"短暂持有锁"的场景中,解锁期间出现多次"窃取"和release是很平常的。而即使是在那些非常耗时的IO操作的"锁"中,实际上也没有办法完全避免"窃取"和"饥饿"。(但是,在多核环境下,"barging"的策略显然可以更高效地利用CPU,不至于让一个核在解锁的过程中,其他核只能干等待(假设没有其他应用的情况下))

20181016041723.png

当需要严格控制公平性的时候,处理起来反而简单一些。程序员可以定义tryAcquire方法,如果当前线程恰好是head节点中的线程才可以acquire,这样就避免了其他线程来"窃取"锁。

另一种更快的、更宽松的变种是当队列(暂时)是空的时候tryAcquire也可以成功。这种情况下,很多线程会同时遇到空队列,并且去同时acquire,通常它们中至少有一个不用出队入队。J.U.C中的所有"同步器"在公平模式下都是采用这种策略。

虽然"公平锁"在实践中看起来很有用,但其实也没有办法彻底保证公平,因为JLS并没有提供线程调度层面的保证。例如,尽管使用一个严格公平的”锁“,但是如果没有互相阻塞的话,JVM也可以简单地顺序执行一个线程集合。(译者注:我猜作者想说的是:如果没有阻塞的话,请求公平锁的线程的运行顺序其实是由JVM决定的(因为没有入等待队列),而JVM执行顺序是不确定的,有可能后请求的线程反而先执行了,从这个角度看并不“公平”)
在实践中,单核环境下,这些线程都按时间片执行,如果某个线程持有一个排它锁,它将会被暂时切换回来,只是为了释放锁并且这个时候才知道是否有其他线程在请求锁,因此线程调度实际上进一步增加了锁的”空闲“时间。(译者注:类似之前讨论的”解锁“时间,这里是调度时间,当其他线程去请求锁的时候,实际上可能持有锁的线程已经完成所需的操作,不再需要持有锁,但是因为没有调度到它,就使得其他线程还需要去阻塞)
锁的公平性设置在多核的环境下影响更大,因为多核之间的交互比较多,从而更容易“barging”,而公平锁禁用了“barging”,性能影响更大。

尽管在"短暂持锁"和竞争激烈的场景中,公平锁的性能可能比较差,但是在其他场景下,表现得都不错。例如,在"长期持锁"的场景中,"barging FIFO"几乎没有性能优势,反而会增加"饥饿"的风险,而公平锁则无此虞。用户可以根据具体场景选择适合的"同步器"类型。

4.2 锁

这里是J.U.C中所有"同步器"的概览。

ReentrantLock类使用同步状态记录lock次数(递归)。当锁被acquired,它也会记录当前持有锁的线程的id,用来检查递归调用,或者请求解锁的线程是否与当前线程一致。这个类还提供了ConditionObject类和一些检查和更新锁状态的方法。这个类也提供两种类似的锁--公平和非公平的。

ReentrantReadWriteLock类使用同步状态的16位记录读锁的状态,剩下的16位记录写锁的状态。它的实现与ReentrantLock类基本一致。ReadLock类使用acquireShared方法支持多个读者。

Semaphore类使用同步状态记录一个计数。它定义了acquireShared方法去减少这个计数,或者当计数非正时,阻塞当前线程;tryRelease方法增加计数,如果计数为正,则可能解锁当前线程。

CountDownLatch类使用同步状态记录一个计数,当计数为0时所有等待线程acquire成功。

FutureTask类使用同步状态表示Future的运行状态。用release设置或者取消future,用acquire唤醒等待future值的线程。

SynchronousQueue类使用内建的"等待节点"匹配生产者和消费者。它使用同步状态允许一个生产者在一个消费者消费了一个item之后继续生产下一个item,反之亦然。

用户当然也可以根据J.U.C的类实现自定义的锁。例如,很多曾经被考虑过但是没有被采纳进J.U.C的类,它们提供了与 WIN32 events, binary latches, centrally managed locks, and tree-based barriers 类似的语义和功能。

5.性能

虽然除排他锁之外,J.U.C也提供了很多其他风格的"同步器",但是"排他锁"的性能是最容易衡量和对比的。有很多方法都可以衡量"排他锁"的性能。下面的实验揭示了"排他锁"的耗时和吞吐量。

在每个测试中,每个线程都重复地去更新一个nextRandom(int seed)产生的伪随机数:

int t = (seed % 127773) * 16807 – (seed / 127773) * 2836;
return (t > 0)? t : t + 0x7fffffff;

在线程的每个更新循环中,线程有S的概率去更新一个加了排它锁的共享的值,或者更新一个无锁的局部变量(1-S)。这样会使得需要加锁的代码块很小,尽量减少无关的影响(譬如线程正持有锁,但是被其他线程抢占了CPU)。这个随机数方法提供了两个功能:1.可以用来决定是否需要加锁;2.可以使得循环的代码不会被JVM优化。

该实验会比较四种锁:内建monitor(用synchronized关键字);Mutex(使用简单的Mutex类,就像前面提到的那样);Reentrant(使用ReentrantLock类);和Fair(使用ReentrantLock类的公平模式)。所有的测试基于J2SE 1.5 JDK 的 build46 版本(与beta2版本几乎完全相同)的"server"模式。测试程序会在正式收集数据前零竞争地运行20次,消除"热身"的影响。除了公平模式只会循环一百万次以外,其他测试中每个线程都会循环一千万次。

测试程序跑在四个x86的机器和四个UltraSparc的机器上。所有x86机器都运行基于RedHat NPTL-based 2.4内核的Linux系统。所有的UltraSparc机器都运行Solaris-9系统。所有系统在测试时都没有其他负载。"4P"的意思是支持超频,可以将双核的机器表现得像四核一样。这里没有试图去统一这些机器配置。因为主要看的是相对耗时。


20181016041745.png

5.1 耗时

零竞争的耗时只用一个线程去测量(避免任何竞争),用S1(S=1,下同)的耗时减去S0的耗时。表2展示了这些数据。Mutex类的S0与S1的差距是最小的。ReentrantLoc的额外耗时在于需要记录当前线程和错误检查,公平模式的额外耗时在于检查队列是否为空。

表2也展示了tryAcquire和内建monitor的"fast-path"之间的耗时对比。这里的区别主要反映了不同机器上不同锁使用的原子操作指令和内存屏障的耗时。在多核环境下,这些指令(的耗时)会显著多于其他指令。内建monitor与其他锁之间的区别主要是内建monitor在加锁和解锁的时候都使用CAS,而基于AQS的其他锁只在加锁时CAS,而在release时使用volatile来保证状态的可见性即可。它们的相对和绝对耗时在不同的机器上可能会有不同。

20181016041811.png

表3展示了在另一个极端(S1,256个同步线程)的情况下,每次加锁的时间。在这种充分竞争的情况下,"barging-FIFO"比内建monitor大约少了一个数量级的耗时(而且吞吐量也更高)比公平锁少了两个数量级的耗时。这显示了在竞争激烈的环境下"barging-FIFO"仍然可以提供相对高效的处理性能。


20181016041829.png

表3还显示了虽然线程切换只需要很少的内部耗时,但是在公平锁中却几乎完全决定了锁的性能。列出的(公平锁的)耗时与不同平台上的阻塞线程和唤醒线程耗时成正比。而且,后续的实验(只在4P上做的)还发现在"短暂持锁"的情况下,公平锁的整体耗时波动性很小。每个线程的运行时间被记录下来作为一个粗略的衡量波动性的变量。在"4P"上公平锁的标准差与平均值的比值为0.7%,而ReentrantLock是6.0%。作为对比,模拟下"长期持锁"的情况,每个线程在持锁过程中生成16k个随机数。总的来说运行时间没什么差别(公平模式9.79s,非公平模式9.72s)。公平模式的波动性还是很小,标准差与平均值的比值为0.1%,而非公平模式是29.5%。

5.2 吞吐量

大部分的"同步器"使用场景都是在零竞争与饱和竞争之间变化的。可以在两个维度上去测试:1.保持线程集合不变,调整线程的同步概率S;2.保持同步概率不变,调整线程集合。为了量化这些影响,我们用ReentrantLock做了很多不同同步概率S或不同线程集合的测试。下面的图表用了一个slowdown指标:

20181016041800.png

t是总的执行时间,b是没有竞争或者同步的基线,n是线程数。p是核数,S是同步概率。slowdown值是观测到的执行时间与用Amdahl定律计算出的对于执行串行和并行组合任务的理想执行时间的比值(没有同步耗费,线程阻塞等)。但是,在非常低的竞争下,有一些任务结果表现得比最理想的状态还要好一点,可能是因为贯穿基线与测试的一些优化手段,管道等。

这些图取的是原始值的对数值(以2为底)。使用的同步概率从1/128(0.008)到1,线程数从1到1024。

通常来说,在单核环境下,性能随着竞争的加剧而不是线程数的增加而下降。多核环境下,性能随竞争加剧下降得会更快。多核环境下的性能图表显示,性能会很快到达一个顶峰,虽然只有很少的线程在竞争。这反映了一个过渡的性能区域,在这个区域内,"barging"和"signal"有相同的机会获取到锁,因此频繁地互相阻塞。在大部分情况下,这之后是个比较平滑的曲线,因为"锁"基本上都不可用了,造成类似于单核的串行运行,在多核的机器上到这一节点要晚一点。还可以看到,例子中竞争饱和的性能图表显示出在更少的处理器的环境下相对更低的slowdown值。

从实验结果可以看出,如果可以优化park/unpark操作,使耗费在线程切换上的时间减少的话,应该可以提高框架的性能。而且,如果可以优化多核环境中"短暂持锁"且竞争激烈场景下的行为,尽可能避免失败的话,也能提供一定的性能提升。虽然适配不同场景下的"自旋"是非常困难的,但是用户可以根据不同场景利用J.U.C来构建自定义的锁,从而解决特定的问题。

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

推荐阅读更多精彩内容

  • 本文是我自己在秋招复习时的读书笔记,整理的知识点,也是为了防止忘记,尊重劳动成果,转载注明出处哦!如果你也喜欢,那...
    波波波先森阅读 11,249评论 4 56
  • ReentrantLock 介绍 一个可重入的互斥锁,它具有与使用{synchronized}方法和语句访问的隐式...
    tomas家的小拨浪鼓阅读 4,047评论 1 4
  • 1. 概述 在jdk1.4以前,java的内置锁(使用synchronized的方法或代码块)性能问题一直都在被人...
    _呆瓜_阅读 2,082评论 1 9
  • 阿雪是一只有些孤僻的兔子。她喜欢一个人坐在自己的房间里发呆,最害怕跟人交流,,最最害怕朋友们和家人们来看望自己。 ...
    coo哭哭同学阅读 833评论 0 1
  • 生成性教学不能失去学科文化规范 在生成性教学中,我们强调将生活世界纳入教学之中,要求我们的教学贴近学生的生活实际,...
    宜都黎明阅读 394评论 0 0