最近在使用memcache客户端的时候,发现一个可能是多线程的问题,客户端的实现是NIO+JUC,由于出现频率很低,场景没有办法复原,一直没有找到问题的真正原因,通过代码走查也没有发现任何问题,于是决定回顾一下JUC的东西,看看是不是可以受到启发,于是决定先看一下大牛Doug Lea的论文,顺便翻译一下。由于英文水平很挫,又是第一次,希望不要误导了大家。废话不表。
JAVA.util.concurrent 同步框架
Doug Lea SUNY Oswego Oswego NY 13126 dl@cs.oswego.edu
摘要
J2SE1.5的java.util.concurrent包中大多数同步器(锁,壁垒(barrier)等)都是基于一个轻量级框架建立起来的,这个框架的基础类就是AbstractQueuedSynchronizer类。
这个框架为同步状态的原子性操作、阻塞和唤醒线程和排队提供了通用的机制。本文将介绍这个框架的基本原理、设计、实现、使用和性能。
分类和主题
D.1.3 [编程技术]:并发编程 – 并行编程
通用术语
算法、测量(measurement)、性能、设计。
keywords
同步、Java
1、引言
通过Java社区进程(JCP)的Java规范请求(JSR)166,java 1.5引入了java.util.concurrent包,它提供了一系列中等水平的并发支持类,这些组件是一系列的同步器,即一个维护了内部同步状态的抽象数据类型(ADT)(例如:表示一个锁是锁定还是解锁)。可以对这个状态的更新和检查,并且必须至少提供一个方法,调用它可以阻塞线程。并且当其它线程改变这个状态的时候,允许该线程唤醒。例如:各种形式的互斥锁、读写锁、信号量(semaphores)、 壁垒(barrier)、futures、 事件信号(event indicators)、 和交替队列(handoff queues)。
众所周知(见[2])几乎任何同步器都可用于实现其他的同步器,例如:可以通过可重入锁(reentrant locks)构建信号量(semaphores ),反之亦然;然而,这样的实现往往都非常复杂,过度的设计和僵化的实现,充其量只能是备选方案;此外在概念上也不具有吸引力,如果这些结构与其他没有本质上的区别,开发人员不应该被强迫选择任意其中之一作为建设其他的基础;相反,JSR166建立一个以类AbstractQueuedSynchronizer为中心的轻量级框架,它提供了一个公共的机制,包里面提供的同步器都是基于它,当然用户也可以定义自己的类;
本文的其余部分讨论了这个框架的需求,设计、实现和用法示例,以及一些其性能特点;
2、需求
2.1、功能需求
同步器具有两种类型的方法[7]:至少有一个获取锁操作(acquire ),它阻塞线程直到同步状态允许继续执行,另外需要一个释放操作(release),它改变同步的状态,以允许一个或多个阻塞的线程解锁。java.util.concurrent包中没有为同步器定义一个统一的API。一些定义是通过通用的接口(如锁),另外一些只是包含在特定的版本中;因此,获取和释放操作,在不同的类中的名称和形式不一样。例如,Lock.lock Semaphore.acquire,CountDownLatch.await和FutureTask.get方法都是获取操作。然而,不同的类也保持一致的约定,以支持一系列常见的用法。在有意义的时候,每个同步支持:
1、非阻塞同步(例如的tryLock)以及阻断版本;
2、可选的超时,这样应用程序可以放弃等待;
3、通过中断(interruption)实现可取消,通常提供一个可中断的和不可中断的获取操作(acquire )
根据他们是否只维护一个互斥量(exclusive),同步器可能会有所不同;互斥量(exclusive)表示在这个可能的阻塞点一次只有一个线程可以执行;对应还有的阻塞点可以允许一次至少一个线程允许。他们叫共享量(shared);通常的锁都是独占的(拥有一个互斥量),但是像计数器。可以允许多个允许计数的线程同时获取;要想广泛使用,该框架必须支持两种操作模式。
java.util.concurrent包中还定义了Condition接口,提供监视器风格的阻塞(await)和唤醒(signal)操作,可以在独占类型的锁里使用,它的实现本质上是依赖于他关联的锁;
2.2 性能需求
Java内置锁(使用synchronized方法或者synchronized块)开发者长期以来一直担心它的性能,关于他的研究文献也相当可观([1], [3]),然而,这些工作的主要重点是在单处理器单线程的上下文中使用时最大限度地减少空间上的开销(因为任何Java对象可以作为一个锁)和最大限度地减少时间开销;但是这些都不是同步器应该关心的重点:1、程序员只在需要时构建同步器,所以没有必要压缩空间;2、 可以预料同步器几乎全部用在多线程设计(多处理器的场景也越来越多)。通常JVM的优化,也只是针对零竞争的场景,其他的场景是很难预见和处理的; "slow paths" [12] is not the right tactic for typical multithreaded server applications that rely heavily on java.util.concurrent.(暂时没想到好的翻译)
相反,这里的主要目标是可扩展性:特别是当使用同步器是有争议的时候可以预测维护效率。理想的情况下,不管多少线程同步,在一个同步点上的开销应该是恒定的。其中的主要目标是,以尽量减少总时间,在此期间,一个线程允许通过一个同步点,但其他将会阻塞。当然,这必须兼顾对其他资源的考虑,比如:总CPU时间,内存开销,和线程调度的开销。举例来说,自旋锁(spinlocks)通常比阻塞锁提供更短的获取时间,但是通常因为空循环和内存争用而不经常使用。
这些目标覆盖了两种使用风格,大多数应用程序追求最大化的总吞吐量,容忍饥饿的出现,然而另外一些应用,如资源控制,对他们来说更为重要的是保持线程的公平性,允许低的总吞吐量。 框架不可以代替用户决定这些相互冲突的目标,而是必须实现不同的公平策略。
不管如何精心设计的,对于某些应用,同步器将出现性能瓶颈;因此,框架必须提供可监测和检查的基本操作,让用户及时发现和缓解瓶颈;最基本的功能(也是最有用)需要提供一种方法来确定有多少线程被阻塞;
3、设计和实现
一个同步器背后的基本理念是非常简单。一个获取操作(acquire)的处理如下:
while (同步状态不能获取(acquire)) {
当前线程如没有排队等待,则排队
可能阻塞当前线程;
}
如果当前线程排队,则出队列;
一个释放(release)操作的处理流程如下:
更新同步器状态
if(同步状态允许一个阻塞的线程获取(acquire))
唤醒一个或者多个线程
支持这些操作需要下面三个基本组成部分的协调:
同步状态的原子管理
线程阻塞和解除阻塞
维护一个队列
虽然可以建立一个框架,使这三件独立变化;然而,这既不是非常有效的,也是不可用的。例如,存放在队列节点的信息必须与需要释放的线程关联,并且暴露的方法签名必须依赖于同步状态的特性。同步框架的核心设计决策是为这三个组成部分选择一个具体实现,同时仍保持一个灵活的扩展;虽然限制了适用范围,但提供了高效率的支持,几乎没有任何理由不使用它,而去从头开始构建同步器。
3.1 同步状态
类AbstractQueuedSynchronizer使用一个(32位)的整数维护同步状态,并且提供getState,setState和compareAndSetState方法访问和更新状态;反过来,
java.util.concurrent.atomic的这些方法支持JSR133(Java内存模型)定义的读取和写入操作的可见性(volatile)语义;并且通过一个native的
compare-and-swap 和 loadlinked/ store-conditional 来实现 compare- AndSetState方法;只有当它拥有的值和给定的预期相同,才会更新为一个新的值,整个操作保持原子性。
以一个32位的int维护同步状态,是一个务实的决定。虽然JSR166还提供了64位长的原子操作,但是这些仍然必须在适当的平台上使用,用来模拟内部锁。否则同步器可能不会工作的很好,在将来,很可能添加第二个基类提供一个专门的64位状态(即 long 型);但是,现在没有一个令人信服的理由,将它包含在包里;目前,32位满足大多数应用,
java.util.concurrent中只有一个CyclicBarrier同步类,维护更多的位来保持状态,代替使用锁(它像大多数更高级别的工具包)。
基于类AbstractQueuedSynchronizer具体实现必须定义tryAcquire和tryRelease方法,用这些对外暴露的方法控制获取和释放操作。如果tryAcquire方法获取(acquire)成功必须返回true,如果新的同步状态允许新的线程获取,tryRelease方法必须返回true,这些方法只接受一个int参数,用于各自状态的沟通,例如, 可重入锁( reentrant lock),当等待条件返回后重新获得锁需要重新建立递归计数。许多同步器并不需要这个参数,可以直接忽略它。
3.2 阻塞:
JSR166之前,没有Java API可以阻塞和唤醒线程,用于构建一个不基于内在的监视器(monitors.)的同步机制。唯一可以使用的是Thread.suspend 和Thread.resume,
但是由于他们有一个无法解决的竞争问题,也难以使用,即:如果一个解除阻塞的线程在阻塞线程被暂停(suspend)之前执行恢复操作(resume),恢复操作(resume)不会有任何效果。java.util.concurrent.locks包里的LockSupport类提供了解决这个问题的方案。方法LockSupport.park阻塞当前线程,直到LockSupport.unpark被调用,(假唤醒也是允许的)unpark方法的调用不会计数,所以在一个park方法前多个unpark,也只唤醒一个park方法阻塞的线程。此外,这适用于每个线程,而不是每同步。这意味着一个线程在一个新的同步器上调用了park方法,可能会立即返回,因为有之前剩余的unpark,然而,在一个unpark的情况下,它的下一次调用将被阻塞。虽然有可能显式地清除这种状态,这是不值得这样做。这使得当需要多次调用park的时候更有效。
同样的机制也一定程度上被Solaris9线程库[11]、 win32的“事件消费机制”、和Linux的NPTL线程库等使用。每一个对应到在最常见的Java平台上运行也是有效的。
(目前Sun HotSpot JVM的实现参考了Solaris和Linux上实际使用的一个pthread condvar机制来兼容现有的设计。)park方法同样支持一个可选项,比如一个相对或则绝对的超时时间(timeout),并且集成了JVM 中Thread.interrupt支持 - 线程unparks的时候可以中断它。
原文见附件
下一篇:http://caoyaojun1988-163-com.iteye.com/admin/blogs/1290759