铁文整理
14.7 线程安全的集合
如果多线程要并发地修改一个数据结构,例如散列表,那么很容易会破坏这个数据结构(有关散列表的详细信息见第13章)。例如,一个线程可能要开始向表中插入一个新元素。假定在调整散列表各个桶之间的链接关系的过程中,被剥夺了控制权。如果另一个线程也开始遍历同一个链表,可能使用无效的链接并造成混乱,会抛出异常或者陷入死循环。
可以通过提供锁来保护共享数据结构,但是选择线程安全的实现作为替代可能更容易些。当然,前一节讨论的阻塞队列就是线程安全的集合。在下面各小节中,将讨论Java类库提供的另一种线程安全的集合。
14.7.1 高效的映像、集合和队列
java.util.concurrent包提供了映像、有序集和队列的高效实现:ConcurrentHashMap、ConcurrentSkipListMap、ConcurrentSkipListSet和ConcurrentLinkedQueue。
这些集合使用复杂的算法,通过允许并发地访问数据结构的不同部分来使竞争极小化。
与大多数集合不同,size方法不必在常量时间内操作。确定这样的集合当前的大小通常需要遍历。
集合返回弱一致性的迭代器,这意味着迭代器不一定能反映出它们被构造之后的所有的修改,但是,它们不会将同一个值返回两次,也不会抛出CocurrentModificationException异常。
注释:与之形成对照的是,集合如果在迭代器构建之后发生改变,java.util包中的迭代器将抛出一个ConcurrentModificationException异常。
并发的散列映像表,可高效地支持大量的读者和一定数量的写者。默认情况下,假定可以有多达16个写者线程同时执行。可以有更多的写者线程,但是,如果同一时间多于16个,其他线程将暂时被阻塞。可以指定更大数目的构造器,然而,恐怕没有这种必要。
ConcurrentHashMap和ConcurrentSkipListMap类有相应的方法用于原子性的关联插入以及关联删除。putIfAbsent方法自动地添加新的关联,前提是原来没有这一关联。对于多线程访问的缓存来说这是很有用的,确保只有一个线程向缓存添加项:
cache.putAbsent(key, value);
相反的操作是删除(或许应该叫作removeIfPresend)。调用
cache.remove(key, value)
将原子性地删除键值对,如果它们在映像表中出现的话。最后,
cache.replace(key, oldValue, newValue)
原子性地用新值替换旧值,假定旧值与指定的键值关联。
API:java.util.concurrent.ConcurrentLinkedQueue<E> 5.0
-
ConcurrentLinkedQueue<E>():构造一个可以被多线程安全访问的无边界非阻塞的队列。
API:java.util.concurrent.ConcurrentLinkedQueue<E> 6
-
ConcurrentLinkedQueue<E>()
-
ConcurrentLinkedQueue<E>(Comparator<? super E> comp):构造一个可以被多线程安全访问的有序集。第一个构造器要求元素实现Comparable接口。
API:java.util.concurrent.ConcurrentHashMap<K, V> 5.0
API:java.util.concurrent.ConcurrentSkipListMap<K, V> 6
-
ConcurrentHashMap<K, V>()
-
ConcurrentHashMap<K, V>(int initialCapacity)
-
ConcurrentHashMap<K, V>(int initialCapacity, float loadFactor, int concurrencyLevel):构造一个可以被多线程安全访问的散列映像表。参数:initialCapacity:集合的初始容量,默认值为16。loadFactor:控制调整:如果每一个桶的平均负载超过这个因子,表的大小会被重新调整。默认值为0.75。concurrencyLevel:并发写者线程的估计数目。
-
ConcurrentSkipListMap<K, V>()
-
ConcurrentSkipListSet<K, V>(Comparator<? super K> comp):构造一个可以被多线程安全访问的有序的映像表。第一个构造器要求键实现Comparable接口。
-
V putIfAbsent(K key, V value):如果该键没有在映像表中出现,则将给定的值同给定的键关联起来,并返回null。否则返回与该键关联的现有值。
-
boolean remove(K key, V value):如果给定的键与给定的值关联,删除给定的键与值并返回真。否则,返回false。
-
boolean replace(K key, V oldValue, V newValue):如果给定的键当前与oldValue相关联,用它与newValue关联。否则,返回false。
14.7.2 写数组的拷贝
CopyOnWriteArrayList和CopyOnWriteArraySet是线程安全的集合,其中所有的修改线程对底层数组进行复制。如果在集合上进行迭代的线程数超过修改线程数,这样的安排是很有用的。当构建一个迭代器的时候,它包含一个对当前数组的引用。如果数组后来被修改了,迭代器仍然引用旧数组,但是,集合的数组已经被替换了。因而。旧的迭代器拥有一致的(可能过时的)视图,访问它无须任何同步开销。
14.7.3 旧的线程安全的集合
从Java的初始版本开始,Vector和Hashtable类就提供了线程安全的动态数组和散列表的实现。在Java SE 1.2中,这些类被弃用了,取而代之的是ArrayList和HashMap类。这些类不是线程安全的,而集合库中提供了不同的机制。任何集合类通过使用同步包装器变成线程安全的:
List<E> synchArrayList = Collections.synchronizedList(new ArrayList<E>());
Map<K, V> synchHashMap = Collections.synchronizedMap(new HashMap<K, V>());
结果集合的方法使用锁加以保护,提供了线程的安全访问。
应该确保没有任何线程通过原始的非同步方法访问数据结构。最便利的方法是确保不保存任何指向原始对象的引用,简单地构造一个集合并立即传递给包装器。像我们的例子中所做的那样。
如果在另一个线程可能进行修改时要对集合进行迭代,仍然需要使用“客户端”封锁:
synchronized (synchHashMap) {
Iterator<K> iter = synchHashMap.keySet().iterator();
while (iter.hasHext()) ...;
}
如果使用“for each”循环必须使用同样的代码,因为循环使用了迭代器。注意:如果在迭代过程中,别的线程修改集合,迭代器会失效,抛出ConcurrentModifucationException异常。同步仍然是需要的,因此并发的修改可以被可靠地检测出来。
最好使用java.util.concurrent包中定义的集合,不使用同步包装器中的。特别是,假如它们访问的是不同的桶,由于ConcurrentHashMap已经精心地实现了,多线程可以访问它而且不会彼此阻塞。有一个例外是经常被修改的数组列表。在那种情况下,同步的ArrayList可以胜过CopyOnWriteArrayList。
API:java.util.Collections 1.2
-
static <E> Collection<E> synchronizedCollection(Collection<E> c)
-
static <E> List synchronizedList(List<E> c)
-
static <E> Set synchronizedSet(Set<E> c)
-
static <E> SortedSet synchronizedSortedSet(SortedSet<E> c)
-
static <Ky V> Map<K, V> synchronizedMap(Map<K, V> c)
-
static <K, V> SortedMap<K, V> synchronizedSortedMap(SortedMap<K, V> c):构建集合视图,该集合的方法是同步的。