volatile, synchronized, 读写屏障,CONCURRENTHASHMAP

Java 理论与实践: 构建一个更好的 HashMap

ConcurrentHashMap 如何在不损失线程安全的同时提供更高的并发性html

Brian Goetz, 首席顾问, Quiotix Corp

简介: ConcurrentHashMap 是 Doug Lea 的 util.concurrent 包的一部分,它提供比 Hashtable 或者 synchronizedMap 更高程度的并发性。并且,对于大多数成功的 get() 操做它会设法避免彻底锁定,其结果就是使得并发应用程序有着很是好的吞吐量。这个月,Brian Goetz 仔细分析了 ConcurrentHashMap 的代码,并探讨 Doug Lea 是如何在不损失线程安全的状况下取得这么骄人成绩的。请在 讨论论坛 上与做者及其余读者共享您对本文的一些想法(也能够在文章的顶部或底部点击 讨论 来访问论坛)。java

查看本系列更多内容node

发布日期: 2003 年 8 月 29 日
级别: 中级
访问状况 2997 次浏览
建议: 1 (查看或添加评论) 缓存

1 star 2 stars 3 stars 4 stars 5 stars 平均分 (共 3 个评分 )

在7月份的那期 Java理论与实践(“并发集合类”)中,咱们简单地回顾了可伸缩性的瓶颈,并讨论了怎么用共享数据结构的方法得到更高的并发性和吞吐量。有时候学习的最好方法是分析专家的成果,因此这个月咱们将分析 Doug Lea 的 util.concurrent 包中的 ConcurrentHashMap 的实现。JSR 133 将指定 ConcurrentHashMap 的一个版本,该版本针对 Java 内存模型(JMM)做了优化,它将包含在 JDK 1.5 的 java.util.concurrent 包中。util.concurrent 中的版本在老的和新的内存模型中都已经过线程安全审核。 安全

针对吞吐量进行优化数据结构

ConcurrentHashMap 使用了几个技巧来得到高程度的并发以及避免锁定,包括为不一样的 hash bucket(桶)使用多个写锁和使用 JMM 的不肯定性来最小化锁被保持的时间――或者根本避免获取锁。对于大多数通常用法来讲它是通过优化的,这些用法每每会检索一个极可能在 map 中已经存在的值。事实上,多数成功的 get() 操做根本不须要任何锁定就能运行。(警告:不要本身试图这样作!想比 JMM 聪明不像看上去的那么容易。util.concurrent 类是由并发专家编写的,而且在 JMM 安全性方面通过了严格的同行评审。)并发

多个写锁app

咱们能够回想一下,Hashtable(或者替代方案 Collections.synchronizedMap)的可伸缩性的主要障碍是它使用了一个 map 范围(map-wide)的锁,为了保证插入、删除或者检索操做的完整性必须保持这样一个锁,并且有时候甚至还要为了保证迭代遍历操做的完整性保持这样一个锁。这样一来,只要锁被保持,就从根本上阻止了其余线程访问 Map,即便处理器有空闲也不能访问,这样大大地限制了并发性。ide

ConcurrentHashMap 摒弃了单一的 map 范围的锁,取而代之的是由 32 个锁组成的集合,其中每一个锁负责保护 hash bucket 的一个子集。锁主要由变化性操做(put() remove())使用。具备 32 个独立的锁意味着最多能够有 32 个线程能够同时修改 map。这并不必定是说在并发地对 map 进行写操做的线程数少于 32 时,另外的写操做不会被阻塞――32 对于写线程来讲是理论上的并发限制数目,可是实际上可能达不到这个值。可是,32 依然比 1 要好得多,并且对于运行于目前这一代的计算机系统上的大多数应用程序来讲已经足够了。&#160

map 范围的操做

有 32 个独立的锁,其中每一个锁保护 hash bucket 的一个子集,这样须要独占访问 map 的操做就必须得到全部32个锁。一些 map 范围的操做,好比说size() isEmpty(),也许可以不用一次锁整个 map(经过适当地限定这些操做的语义),可是有些操做,好比 map 重排(扩大 hash bucket 的数量,随着 map 的增加从新分布元素),则必须保证独占访问。Java 语言不提供用于获取可变大小的锁集合的简便方法。必须这么作的状况不多见,一旦碰到这种状况,能够用递归方法来实现。


JMM概述

在进入 put()get()remove() 的实现以前,让咱们先简单地看一下 JMM。JMM 掌管着一个线程对内存的动做 (读和写)影响其余线程对内存的动做的方式。因为使用处理器寄存器和预处理 cache 来提升内存访问速度带来的性能提高,Java 语言规范(JLS)容许一些内存操做并不对于全部其余线程当即可见。有两种语言机制可用于保证跨线程内存操做的一致性――synchronizedvolatile。

按照 JLS 的说法,“在没有显式同步的状况下,一个实现能够自由地更新主存,更新时所采起的顺序多是出人意料的。”其意思是说,若是没有同步的话,在一个给定线程中某种顺序的写操做对于另一个不一样的线程来讲可能呈现出不一样的顺序, 而且对内存变量的更新从一个线程传播到另一个线程的时间是不可预测的。

虽然使用同步最多见的缘由是保证对代码关键部分的原子访问,但实际上同步提供三个独立的功能――原子性、可见性和顺序性。原子性很是简单――同步实施一个可重入的(reentrant)互斥,防止多于一个的线程同时执行由一个给定的监视器保护的代码块。不幸的是,多数文章都只关注原子性方面,而忽略了其余方面。可是同步在 JMM 中也扮演着很重要的角色,会引发 JVM 在得到和释放监视器的时候执行内存壁垒(memory barrier)。

一个线程在得到一个监视器以后,它执行一个读屏障(read barrier)――使得缓存在线程局部内存(好比说处理器缓存或者处理器寄存器)中的全部变量都失效,这样就会致使处理器从新从主存中读取同步代码块使用的变量。与此相似,在释放监视器时,线程会执行一个写屏障(write barrier)――将全部修改过的变量写回主存。互斥独占和内存壁垒结合使用意味着只要您在程序设计的时候遵循正确的同步法则(也就是说,每当写一个后面可能被其余线程访问的变量,或者读取一个可能最后被另外一个线程修改的变量时,都要使用同步),每一个线程都会获得它所使用的共享变量的正确的值。

若是在访问共享变量的时候没有同步的话,就会发生一些奇怪的事情。一些变化可能会经过线程当即反映出来,而其余的则须要一些时间(这由关联缓存的本质所致)。结果,若是没有同步您就不能保证内存内容一定一致(相关的变量相互间可能会不一致),或者不能获得当前的内存内容(一些值多是过期的)。避免这种危险状况的经常使用方法(也是推荐使用的方法)固然是正确地使用同步。然而在有些状况下,好比说在像 ConcurrentHashMap 之类的一些使用很是普遍的库类中,在开发过程中还须要一些额外的专业技能和努力(可能比通常的开发要多出不少倍)来得到较高的性能。


ConcurrentHashMap 实现

如前所述,ConcurrentHashMap 使用的数据结构与 HashtableHashMap 的实现相似,是 hash bucket 的一个可变数组,每一个 ConcurrentHashMap 都由一个 Map.Entry 元素链构成,如清单1所示。与 HashtableHashMap 不一样的是,ConcurrentHashMap 没有使用单一的集合锁(collection lock),而是使用了一个固定的锁池,这个锁池造成了bucket 集合的一个分区。


清单1. ConcurrentHashMap 使用的 Map.Entry 元素
protected static class Entry implements Map.Entry {
protected final Object key;
protected volatile Object value;
protected final int hash;
protected final Entry next;
...
}

不用锁定遍历数据结构

Hashtable 或者典型的锁池 Map 实现不一样,ConcurrentHashMap.get() 操做不必定须要获取与相关bucket 相关联的锁。若是不使用锁定,那么实现必须有能力处理它用到的全部变量的过期的或者不一致的值,好比说列表头指针和 Map.Entry 元素的域(包括组成每一个 hash bucket 条目的链表的连接指针)。

大多并发类使用同步来保证独占式访问一个数据结构(以及保持数据结构的一致性)。ConcurrentHashMap 没有采用独占性和一致性,它使用的链表是通过精心设计的,因此其实现能够检测到它的列表是否一致或者已通过时。若是它检测到它的列表出现不一致或者过期,或者干脆就找不到它要找的条目,它就会对适当的 bucket 锁进行同步并再次搜索整个链。这样作在通常的状况下能够优化查找,所谓的通常状况是指大多数检索操做是成功的而且检索的次数多于插入和删除的次数。

使用不变性

不一致性的一个重要来源是能够避省得,其方法是使 Entry 元素接近不变性――除了值字段(它们是易变的)以外,全部字段都是 final 的。这就意味着不能将元素添加到 hash 链的中间或末尾,或者从 hash 链的中间或末尾删除元素――而只能从 hash 链的开头添加元素,而且删除操做包括克隆整个链或链的一部分并更新列表的头指针。因此说只要有对某个 hash 链的一个引用,即便可能不知道有没有对列表头节点的引用,您也能够知道列表的其他部分的结构不会改变。并且,由于值字段是易变的,因此可以当即看到对值字段的更新,从而大大简化了编写可以处理内存潜在过期的 Map 的实现。

新的 JMM 为 final 型变量提供初始化安全,而老的 JMM 不提供,这意味着另外一个线程看到的多是 final 字段的默认值,而不是对象的构造方法提供的值。实现必须可以同时检测到这一点,这是经过保证 Entry中每一个字段的默认值不是有效值来实现的。这样构造好列表以后,若是任何一个 Entry 字段有其默认值(零或空),搜索就会失败,提示同步 get() 并再次遍历链。

检索操做

检索操做首先为目标 bucket 查找头指针(是在不锁定的状况下完成的,因此说多是过期的),而后在不获取 bucket 锁的状况下遍历 bucket 链。若是它不能发现要查找的值,就会同步并试图再次查找条目,如清单2 所示:


清单2. ConcurrentHashMap.get() 实现
public Object get(Object key) {
int hash = hash(key); // throws null pointer exception if key is null

// Try first without locking...
Entry[] tab = table;
int index = hash & (tab.length - 1);
Entry first = tab[index];
Entry e;

for (e = first; e != null; e = e.next) {
  if (e.hash == hash && eq(key, e.key)) {
Object value = e.value;
// null values means that the element has been removed
if (value != null) 
  return value;
else
  break;
  }
}

// Recheck under synch if key apparently not there or interference
Segment seg = segments[hash & SEGMENT_MASK];
synchronized(seg) { 
  tab = table;
  index = hash & (tab.length - 1);
  Entry newFirst = tab[index];
  if (e != null || first != newFirst) {
for (e = newFirst; e != null; e = e.next) {
  if (e.hash == hash && eq(key, e.key)) 
return e.value;
}
  }
  return null;
}
  }

删除操做

由于一个线程可能看到 hash 链中连接指针的过期的值,简单地从链中删除一个元素不足以保证其余线程在进行查找的时候不继续看到被删除的值。相反,从清单3咱们能够看到,删除操做分两个过程――首先找到适当的 Entry 对象并把其值字段设为 null,而后对链中从头元素到要删除的元素的部分进行克隆,再链接到要删除的元素以后的部分。由于值字段是易变的,若是另一个线程正在过期的链中查找那个被删除的元素,它会当即看到一个空值,并知道使用同步从新进行检索。最终,原始 hash 链中被删除的元素将会被垃圾收集。


清单3. ConcurrentHashMap.remove() 实现
protected Object remove(Object key, Object value) {
/*
  Find the entry, then 
1. Set value field to null, to force get() to retry
2. Rebuild the list without this entry.
   All entries following removed node can stay in list, but
   all preceding ones need to be cloned.  Traversals rely
   on this strategy to ensure that elements will not be
  repeated during iteration.
*/

int hash = hash(key);
Segment seg = segments[hash & SEGMENT_MASK];

synchronized(seg) {
  Entry[] tab = table;
  int index = hash & (tab.length-1);
  Entry first = tab[index];
  Entry e = first;

  for (;;) {
if (e == null)
  return null;
if (e.hash == hash && eq(key, e.key)) 
  break;
e = e.next;
  }

  Object oldValue = e.value;
  if (value != null && !value.equals(oldValue))
return null;
 
  e.value = null;

  Entry head = e.next;
  for (Entry p = first; p != e; p = p.next) 
head = new Entry(p.hash, p.key, p.value, head);
  tab[index] = head;
  seg.count--;
  return oldValue;
}
  }

图1为删除一个元素以前的 hash 链:


图1. Hash链
Figure 1. Hash chain

图2为删除元素3以后的链:


图2. 一个元素的删除过程
Figure 2. Removal of an element

插入和更新操做

put() 的实现很简单。像 remove() 同样,put() 会在执行期间保持 bucket 锁,可是因为 put() 并非都须要获取锁,因此这并不必定会阻塞其余读线程的执行(也不会阻塞其余写线程访问别的 bucket)。它首先会在适当的 hash 链中搜索须要的键值。若是可以找到,value字段(易变的)就直接被更新。若是没有找到,新会建立一个用于描述新 map 的新 Entry 对象,而后插入到 bucket 列表的头部。

弱一致的迭代器

ConcurrentHashMap 返回的迭代器的语义又不一样于 ava.util 集合中的迭代器;并且它又是 弱一致的(weakly consistent) 而非 fail-fast 的(所谓 fail-fast 是指,当正在使用一个迭代器的时候,如何底层的集合被修改,就会抛出一个异常)。当一个用户调用 keySet().iterator() 去迭代器中检索一组 hash 键的时候,实现就简单地使用同步来保证每一个链的头指针是当前值。next()hasNext() 操做以一种明显的方式定义,即遍历每一个链而后转到下一个链直到全部的链都被遍历。弱一致迭代器可能会也可能不会反映迭代器迭代过程当中的插入操做,可是必定会反映迭代器尚未到达的键的更新或删除操做,而且对任何值最多返回一次。ConcurrentHashMap 返回的迭代器不会抛出 ConcurrentModificationException 异常。

动态调整大小

随着 map 中元素数目的增加,hash 链将会变长,所以检索时间也会增长。从某种意义上说,增长 bucket 的数目和重排其中的值是很是重要的。在有些像 Hashtable 之类的类中,这很简单,由于保持一个应用到整个 map 的独占锁是可能的。在 ConcurrentHashMap 中,每次一个条目插入的时候,若是链的长度超过了某个阈值,链就被标记为须要调整大小。当有足够多的链被标记为须要调整大小之后,ConcurrentHashMap 就使用递归获取每一个 bucket 上的锁并重排每一个 bucket 中的元素到一个新的、更大的 hash 表中。多数状况下,这是自动发生的,而且对调用者透明。

不锁定?

要说不用锁定就能够成功地完成 get() 操做彷佛有点言过其实,由于 Entryvalue 字段是易变的,这是用来检测更新和删除的。在机器级,易变的和同步的内容一般在最后会被翻译成相同的缓存一致原语,因此这里会有 一些 锁定,虽然只是细粒度的而且没有调度,或者没有获取和释放监视器的 JVM 开销。可是,除语义以外,在不少通用的状况下,检索的次数大于插入和删除的次数,因此说由 ConcurrentHashMap 取得的并发性是至关高的。


结束语

ConcurrentHashMap 对于不少并发应用程序来讲是一个很是有用的类,并且对于理解 JMM 何以取得较高性能的微妙细节是一个很好的例子。ConcurrentHashMap是编码的经典,须要深入理解并发和 JMM 才可以写得出。使用它,从中学到东西,享受其中的乐趣――可是除非您是 Java 并发方面的专家,不然的话您本身不该该这样试。

相关文章
相关标签/搜索