Java 理论与实践: 流行的原子新原子类是 java.util.concurrent 的隐藏精华html ![]() |
![]() |
![]() |
级别: 初级编程 Brian Goetz (brian@quiotix.com), 首席顾问, Quiotix 2004 年 11 月 23 日数组 在 JDK 5.0 以前,若是不使用本机代码,就不能用 Java 语言编写无等待、无锁定的算法。在 java.util.concurrent 中添加原子变量类以后,这种状况发生了变化。请跟随并行专家 Brian Goetz 一块儿,了解这些新类如何使用 Java 语言开发高度可伸缩的无阻塞算法。您能够在本文的 论坛中与做者或其余读者共享您对本文的见解。(也能够经过单击文章顶部或者底部的 讨论连接来访问讨论。) 十五年前,多处理器系统是高度专用系统,要花费数十万美圆(大多数具备两个到四个处理器)。如今,多处理器系统很便宜,并且数量不少,几乎每一个主要微处理器都内置了多处理支持,其中许多系统支持数十个或数百个处理器。 安全 要使用多处理器系统的功能,一般须要使用多线程构造应用程序。可是正如任何编写并发应用程序的人能够告诉你的那样,要得到好的硬件利用率,只是简单地在多个线程中分割工做是不够的,还必须确保线程确实大部分时间都在工做,而不是在等待更多的工做,或等待锁定共享数据结构。 数据结构 问题:线程之间的协调多线程 若是线程之间 不须要协调,那么几乎没有任务能够真正地并行。以线程池为例,其中执行的任务一般相互独立。若是线程池利用公共工做队列,则从工做队列中删除元素或向工做队列添加元素的过程必须是线程安全的,而且这意味着要协调对头、尾或节点间连接指针所进行的访问。正是这种协调致使了全部问题。 并发 在 Java 语言中,协调对共享字段的访问的传统方法是使用同步,确保完成对共享字段的全部访问,同时具备适当的锁定。经过同步,能够肯定(假设类编写正确)具备保护一组给定变量的锁定的全部线程都将拥有对这些变量的独占访问权,而且之后其余线程得到该锁定时,将能够看到对这些变量进行的更改。弊端是若是锁定竞争太厉害(线程经常在其余线程具备锁定时要求得到该锁定),会损害吞吐量,由于竞争的同步很是昂贵。(Public Service Announcement:对于现代 JVM 而言,无竞争的同步如今很是便宜。 基于锁定的算法的另外一个问题是:若是延迟具备锁定的线程(由于页面错误、计划延迟或其余意料以外的延迟),则 没有要求得到该锁定的线程能够继续运行。 还可使用可变变量来以比同步更低的成本存储共享变量,但它们有局限性。虽然能够保证其余变量能够当即看到对可变变量的写入,但没法呈现原子操做的读-修改-写顺序,这意味着(好比说)可变变量没法用来可靠地实现互斥(互斥锁定)或计数器。 假如开发线程安全的计数器类,那么这将暴露 清单 1. 同步的计数器类
许多并发算法中都显示了原子的读-修改-写组合。清单 2 中的代码实现了简单的互斥, 清单 2. 同步的互斥类
清单 1 中的计数器类能够可靠地工做,在竞争很小或没有竞争时均可以很好地执行。然而,在竞争激烈时,这将大大损害性能,由于 JVM 用了更多的时间来调度线程,管理竞争和等待线程队列,而实际工做(如增长计数器)的时间却不多。您能够回想 上月专栏中的图,该图显示了一旦多个线程使用同步竞争一个内置监视器,吞吐量将如何大幅度降低。虽然该专栏说明了新的 使用锁定,若是一个线程试图获取其余线程已经具备的锁定,那么该线程将被阻塞,直到该锁定可用。此方法具备一些明显的缺点,其中包括当线程被阻塞来等待锁定时,它没法进行其余任何操做。若是阻塞的线程是高优先级的任务,那么该方案可能形成很是很差的结果(称为 优先级倒置的危险)。 使用锁定还有一些其余危险,如死锁(当以不一致的顺序得到多个锁定时会发生死锁)。甚至没有这种危险,锁定也仅是相对的粗粒度协调机制,一样很是适合管理简单操做,如增长计数器或更新互斥拥有者。若是有更细粒度的机制来可靠管理对单独变量的并发更新,则会更好一些;在大多数现代处理器都有这种机制。
如前所述,大多数现代处理器都包含对多处理的支持。固然这种支持包括多处理器能够共享外部设备和主内存,同时它一般还包括对指令系统的增长来支持多处理的特殊要求。特别是,几乎每一个现代处理器都有经过能够检测或阻止其余处理器的并发访问的方式来更新共享变量的指令。 支持并发的第一个处理器提供原子的测试并设置操做,一般在单位上运行这项操做。如今的处理器(包括 Intel 和 Sparc 处理器)使用的最通用的方法是实现名为 比较并转换或 CAS 的原语。(在 Intel 处理器中,比较并交换经过指令的 cmpxchg 系列实现。PowerPC 处理器有一对名为“加载并保留”和“条件存储”的指令,它们实现相同的目地;MIPS 与 PowerPC 处理器类似,除了第一个指令称为“加载连接”。) CAS 操做包含三个操做数 —— 内存位置(V)、预期原值(A)和新值(B)。若是内存位置的值与预期原值相匹配,那么处理器会自动将该位置值更新为新值。不然,处理器不作任何操做。不管哪一种状况,它都会在 CAS 指令以前返回该位置的值。(在 CAS 的一些特殊状况下将仅返回 CAS 是否成功,而不提取当前值。)CAS 有效地说明了“我认为位置 V 应该包含值 A;若是包含该值,则将 B 放到这个位置;不然,不要更改该位置,只告诉我这个位置如今的值便可。” 一般将 CAS 用于同步的方式是从地址 V 读取值 A,执行多步计算来得到新值 B,而后使用 CAS 将 V 的值从 A 改成 B。若是 V 处的值还没有同时更改,则 CAS 操做成功。 相似于 CAS 的指令容许算法执行读-修改-写操做,而无需惧怕其余线程同时修改变量,由于若是其余线程修改变量,那么 CAS 会检测它(并失败),算法能够对该操做从新计算。清单 3 说明了 CAS 操做的行为(而不是性能特征),可是 CAS 的价值是它能够在硬件中实现,而且是极轻量级的(在大多数处理器中): 清单 3. 说明比较并交换的行为(而不是性能)的代码
基于 CAS 的并发算法称为 无锁定算法,由于线程没必要再等待锁定(有时称为互斥或关键部分,这取决于线程平台的术语)。不管 CAS 操做成功仍是失败,在任何一种状况中,它都在可预知的时间内完成。若是 CAS 失败,调用者能够重试 CAS 操做或采起其余适合的操做。清单 4 显示了从新编写的计数器类来使用 CAS 替代锁定: 清单 4. 使用比较并交换实现计数器
若是每一个线程在其余线程任意延迟(或甚至失败)时都将持续进行操做,就能够说该算法是 无等待的。与此造成对比的是, 无锁定算法要求仅 某个线程老是执行操做。(无等待的另外一种定义是保证每一个线程在其有限的步骤中正确计算本身的操做,而无论其余线程的操做、计时、交叉或速度。这一限制能够是系统中线程数的函数;例如,若是有 10 个线程,每一个线程都执行一次 再过去的 15 年里,人们已经对无等待且无锁定算法(也称为 无阻塞算法)进行了大量研究,许多人通用数据结构已经发现了无阻塞算法。无阻塞算法被普遍用于操做系统和 JVM 级别,进行诸如线程和进程调度等任务。虽然它们的实现比较复杂,但相对于基于锁定的备选算法,它们有许多优势:能够避免优先级倒置和死锁等危险,竞争比较便宜,协调发生在更细的粒度级别,容许更高程度的并行机制等等。 在 JDK 5.0 以前,若是不使用本机代码,就不能用 Java 语言编写无等待、无锁定的算法。在 原子变量类能够认为是 虽然原子变量类表面看起来与清单 1 中的 调整具备竞争的并发应用程序的可伸缩性的通用技术是下降使用的锁定对象的粒度,但愿更多的锁定请求从竞争变为不竞争。从锁定转换为原子变量能够得到相同的结果,经过切换为更细粒度的协调机制,竞争的操做就更少,从而提升了吞吐量。
不管是直接的仍是间接的,几乎 若是没有 JDK 5.0 中的 JVM 改进,将没法构造这些类,这些改进暴露了(向类库,而不是用户类)接口来访问硬件级的同步原语。而后,java.util.concurrent 中的原子变量类和其余类向用户类公开这些功能。
上月,我介绍了 清单 5 显示了使用同步的 PRNG 实现和使用 CAS 备选实现。注意,要在循环中执行 CAS,由于它可能会失败一次或屡次才能得到成功,使用 CAS 的代码老是这样。 清单 5. 使用同步和原子变量实现线程安全 PRNG
下面图 1 和图 2 中的图与上月那些图类似,只是为基于原子的方法多添加了一行。这些图显示了在 8-way Ultrasparc3 和单处理器 Pentium 4 上使用不一样数量线程的随机发生的吞吐量(以每秒转数为单位)。测试中的线程数不是真实的;这些线程所表现的竞争比一般多得多,因此它们以比实际程序中低得多的线程数显示了 图 1. 8-way Ultrasparc3 中同步、ReentrantLock、公平 Lock 和 AtomicLong 的基准吞吐量 ![]() 图 2. 单处理器 Pentium 4 中的同步、ReentrantLock、公平 Lock 和 AtomicLong 的基准吞吐量 ![]() 大多数用户都不太可能使用原子变量本身开发无阻塞算法 — 他们更可能使用 开发人员能够直接将原子变量用做共享计数器、序号生成器和其余独立共享变量的高性能替代,不然必须经过同步保护这些变量。
JDK 5.0 是开发高性能并发类的巨大进步。经过内部公开新的低级协调原语,和提供一组公共原子变量类,如今用 Java 语言开发无等待、无锁定算法首次变为可行。而后,
|