java 中线程安全的容器类

2016-08-03 12:08:40   最后更新: 2016-08-03 12:08:40   访问数量:799




java 在类库设计的过程中,对线程安全做了额外的考虑,因此诞生了丰富的线程安全容器类以及用于协调多个相互写作的线程控制流的同步工具类,特别是在 java5 和 java6 中引入了一些新模块,用来构造并发应用程序的一些常用模式

 

众所周知,HashMap 和 ArrayList 等常用的容器类并不是线程安全的,但在单线程模型下,他们有着很好的执行效率

早期,java 通过加锁的方式实现了两个线程安全的同步容器类:Vector 和 Hashtable

我们也可以使用 java 类库中提供的 Collections 类的 synchronizedXxxx 方法来创建非线程安全容器的同步容器类

如:

List<Widget> widgetList = Collections.synchronizedList(new ArrayList<Widget>());

 

 

同步容器类的问题

同步容器类是线程安全的,但是有些情况下需要客户端加锁来保护复合操作

常见的复合操作包括:

  1. 迭代(遍历容器)
  2. 跳转(找到下一个元素)
  3. 条件运算

虽然同步容器类的所有操作都是线程安全的,但是当其他线程修改容器时,上面列出的复合操作就会表现出意料之外的行为

 

public static Object getLast(Vector list) { int lastIndex = list.size() - 1; return list.get(lastIndex); }

 

上面的代码非常典型,如果虽然 size 操作和 get 操作都是线程安全的,但在他们之间,可能有另一个线程删除了 list 中的一个元素,那么,在最后就会抛出 ArrayIndexOutOfBoundsException 异常

所以这种情况下,客户端必须对整个表进行加锁,以保护复合操作

 

迭代器与 ConcurrentModificationException

无论是直接使用迭代器进行迭代操作还是使用 for-each 循环语法,他们都是使用 Iterator 对象来实现迭代操作的

如果有其他线程并发的修改容器,那么使用迭代器就必须在迭代期间对容器加锁,然而 jdk 的设计中并没有考虑到这个问题,在实际环境中,如果容器在迭代过程中被其他线程修改,那么就会抛出一个 ConcurrentModificationException 异常

解决这个问题,最直观的方法就是加锁,但是加锁意味着多个线程的竞争,可能会极大地降低吞吐量和 CPU 利用率,另一种替代的方法是克隆容器,在副本上进行迭代,这样就可以保证其他线程不会去对其进行修改,他的主要开销在于创建克隆容器的阶段

 

同步容器在执行每个操作期间都必须持有锁,即便是最基本的 get 操作,对于 HashMap 来说,都必须在许多元素上调用 equals 方法,在一段时间内持有锁,这会让其他同步的线程阻塞,造成效率的明显下降

ConcurrentHashMap 也是一个基于三列的 Map 容器,它实现了一种完全不同的加锁策略来提供更高的并发性,它使用分段所的加锁机制,这样的机制下,任意数量的读取线程可以并发的访问 Map,执行读取操作的线程和写入操作的线程可以并发执行

ConcurrentHashMap 提供的迭代器不会抛出 ConcurrentModificationException 异常,但 ConcurrentHashMap 返回的迭代器是弱一致的,他可以容忍并发的修改,因此,他也可能会返回过期数据,size、isEmpty 等方法不再是可靠的,但他保证 get、put、containsKey、remove 等并发环境下常用操作返回的正确性

 

ConcurrentHashMap 还提供了额外的原子操作:

  1. V putIfAbsent(K key, V value); -- 只有 K 没有相应映射时才插入
  2. boolean remove(K key, V value); -- 只有 K 的值为 value 时才移除
  3. boolean replace(K key, V oldValue, V newValue); -- 如果 K 的值是 oldValue,则更新为 newValue
  4. V replace(K key, V newValue); -- 如果 key 存在,则更新为 newValue

 

CopyOnWriteArrayList 用来替代同步 List,以便让他提供更好地并发性能

CopyOnWriteArrayList 顾名思义,他的基础数组是只读的,一旦需要修改等任何写操作,他都会通过复制创建一个新的数组,这样的好处是随机访问、迭代等读操作无需加锁,但他不应该被用于频繁修改数据的场景下,那样会造成大量的额外开销

 

BlockingQueue 简化了生产者-消费者模式的实现过程,它支持任意数量的生产者、消费者并发地对队列进行操作

阻塞队列提供了可阻塞的 put 和 take 方法,以及支持定时的 offer 和 poll 方法,如果队列已满,那么 pull 方法将阻塞直到有空间可用,如果队列为空,那么 take 方法也将阻塞,直到有元素可用,offer 方法尝试将元素放入队列,如果队列已满,则立即返回 false,他有一个传入时间参数的重载方法,支持最多等待一段时间,poll 方法是与 offer 类似的取出元素的操作

BlockingQueue 可以通过构造方法声明其容量大小,从而构造一个有界队列,如果没有指定,他就将会是一个无界队列,无界队列永远不会充满

 

BlockingQueue 派生出了下列常见的阻塞队列:

  1. ArrayBlockingQueue -- 通过定长数组实现的阻塞队列,采用分离锁实现更好地并发性能
  2. LinkedBlokingQueue -- 通过链表实现的阻塞队列,与 ArrayBlockingQueue 类似
  3. DelayQueue -- 只有指定的延时时间到了,才能够从队列中获取数据的无界阻塞队列
  4. PriorityBlockingQueue -- 具有优先级的无界阻塞队列
  5. SynchronousQueue -- 无缓冲的阻塞队列,如果没有空闲的消费者,put 操作则会一直阻塞,如果没有就绪的数据,take 操作则会一直阻塞

这些队列都包含了足够的内部同步机制,从而可以安全的将数据从生产者线程交付给消费者线程

 

Java6 提供了两个双端队列 -- Deque 和 BlockingDeque,他们分别扩展了 Queue 和 BlockingQueue,具体的实现有 ArrayDeque 和 LinkedBlockingDeque

双端队列用来实现工作密取模式,即消费者在完成自己双端队列中获取到的全部工作后,他可以去其他消费者的双端队列中秘密地获取工作,这样相比于传统的生产者-消费者模式,它具有了更好地伸缩性

密取模式有一个最常见的应用场景,就是一个线程既是生产者又是消费者,比如在 web-spider 工作的过程中,消费者在抓取网页并进行分析后,会得到更多的任务 url,需要重新写回到队列中去

 






读书笔记      技术帖      龙潭书斋      sync      线程      thread      array      list      并发      队列      queue      synchronized      java并发编程实战      同步      blockingqueue      deque     


京ICP备15018585号