Java 多线程(八)- 其他常用并发容器

CopyOnWriteArrayList

CopyOnWriteArrayList 是写时复制的容器。通俗的理解是当我们要往容器中添加元素的时候,不直接往当前的数组天假,而是将当前数组进行 Copy,然后往新的数组中添加元素,添加完元素后,再将数组引用指向新的数组。

这样做的好处是可以并发的读,而不需要加锁同步,而同步锁只加在写线程上,也就说很可能读和写并不是同一个数组。

实现原理

简单起见,不妨看看 CopyOnWriteArrayList 的 get 和 add 接口是如何实现的:

final transient ReentrantLock lock = new ReentrantLock();

/** The array, accessed only via getArray/setArray. */
private transient volatile Object[] array;

private E get(Object[] a, int index) {
    return (E) a[index];
}

public E get(int index) {
    return get(getArray(), index);
}

public boolean add(E e) {
    final ReentrantLock lock = this.lock;
    lock.lock();
    try {
        Object[] elements = getArray();
        int len = elements.length;
        Object[] newElements = Arrays.copyOf(elements, len + 1);
        newElements[len] = e;
        setArray(newElements);
        return true;
    } finally {
        lock.unlock();
    }
}

可以看到调用 get 读数据的时候并没有加锁,多个线程是并发的读。但是调用 add 接口是会先获取锁,所有写线程串行访问。但是写线程和读线程是并发访问的,所以如果写线程还在写的时候,读线程还是有可能读取旧的数据。

CopyOnWriteArrayList 返回的迭代器不会抛出 ConcurrentModificationException 异常,并且返回的元素与迭代器创建时的元素完全一致,而不必考虑之后修改操作所带来的影响。

应用场景

每当修改时,CopyOnWriteArrayList 都会复制底层数组,这需要一定开销,特别是当容器规模非常大的。仅当迭代操作远远多于修改操作时,才应该考虑使用“写入时复制”容器。

尽量使用批量添加。因为每次添加,容器每次都会进行复制,所以减少添加次数,可以减少容器的复制次数。

缺点

CopyOnWriteArrayList 有很多优点,但是同时也存在两个问题,即内存占用问题数据一致性问题。所以在开发的时候需要注意一下:

  • 内存占用问题。因为写时复制机制,所以在进行写操作的时候,内存里会同时驻扎两个数组,如果数组内对象占用的内存比较,那么这个时候很有可能造成频繁的 GC。
  • 只能保证数据的最终一致性,不能保证数据的实时一致性。所以如果你希望写入的的数据,马上能读到,请不要使用写时复制容器。
CopyOnWriteArraySet

CopyOnWriteArraySet 实现 Set 接口,也属于写时复制容器,其实 CopyOnWriteArraySet 内部是用
CopyOnWriteArrayList 实现的,所以 CopyOnWriteArraySet 的特性几乎和 CopyOnWriteArrayList 一样,简单起见,可以看看以下关键代码:

private final CopyOnWriteArrayList al;

public boolean add(E e) {
    return al.addIfAbsent(e);
}

public Object[] toArray() {
    return al.toArray();
}

BlockingQueue

BlockingQueue 继承自 Queue,Queue 继承自 Collection,BlockingQueue 提供了可阻塞的 put 和 take 方法,以及支持定时的 offer 和 poll 方法。如果队列已经满了,那么 put 方法将阻塞直到有空间可用;如果队列为空,那么 take 方法将会阻塞知道有元素可用。队列可以是有界的也可以是无界的,无界队列永远不会充满,所以无界队列的 put 方法也永远不会阻塞。

接口

BlockingQueue 主要方法如下所示:

//添加元素,添加成功则返回 true,队列已满,则抛出 IllegalStateException 异常
boolean add(E e);

//添加元素,添加成功返回 true,添加失败 返回 false
boolean offer(E e); 

//添加元素,如果队列已满,则阻塞等待
void put(E e) throws InterruptedException;
    
//添加元素,如果队列已满,则阻塞等待一段时间,等待后还是插入失败,则返回 false
boolean offer(E e, long timeout, TimeUnit unit)
    throws InterruptedException;
    
//获取并删除队列第一个元素,如果队列为空,则阻塞等待
E take() throws InterruptedException;

//获取并删除队列第一个元素
//如果队列为空,则阻塞等待一段时间,等待后还是为空,则返回 null
E poll(long timeout, TimeUnit unit)
    throws InterruptedException;

//获取并删除队列第一个元素,如果队列为空,则抛出 NoSuchElementException 异常
E remove();

//获取并删除队列第一个元素,如果队列为空,则返回 null
E poll();

//获取队列第一个元素,但是并不出队,如果队列为空,抛出 NoSuchElementException 异常
E element();

//获取队列第一个元素,但是并不出队,如果队列为空,则返回 null
E peek()
使用场景

BlockingQueue 经常用于生产者-消费者设计模式中。该模式将“找出需要完成的工作”与“执行工作”这两个过程分离开来。生产者-消费者模式能够简化开发过程,因为它能够消除生产者类和消费者类之间的代码依赖性。此外,该模式还将生产数据的过程与使用数据的过程解耦以简化工作负载的管理,因为这两个过程在处理数据的速率上有所不同。

一种常见的生产者-消费者模式就是线程池与工作队列的组合,在 Executor 任务执行框架中就体现了这种模式。

ArrayBlockingQueue

它是一个由数组支持的有界阻塞队列。它的容纳大小是固定的。此队列按 FIFO(先进先出)原则对元素进行排序。数组容量在构造函数里传入。以下是它的核心成员变量:

/** 数组作为队列*/
final Object[] items;
/** 队列头部坐标 */
int takeIndex;
/** 队尾坐标 **/
int putIndex;
/** 队列中元素个数 **/
int count;
/** 独占锁,对队列的访问需要获取该锁,保证线程访问同步 **/
final ReentrantLock lock;
/** 非空等待条件变量,用于阻塞/通知队列为空时的 take 线程 */
private final Condition notEmpty;
/** 未满等待条件队列,用于阻塞/通知队列充满时的 put 线程  **/
private final Condition notFull;

ArrayBlockingQueue 有以下特点:

  1. 有界数组,队列容量在构造时传入;
  2. 通过 takeIndex / putIndex 设计,数组是 循环数组
  3. lock 保护临界区,notEmpty 和 notFull 用来阻塞和通知等待线程;
  4. 不接受 null 元素;
  5. 构造函数中可以指定公平性,公平性是通过 lock 实现的。
LinkedBlockingQueue

它是一个基于已链接节点的,可以无界,也可以有界的阻塞队列。此队列按 FIFO(先进先出)原则对元素进行排序。以下是它的核心成员变量:

 /** 队列容量,默认为 Integer.MAX_VALUE */
private final int capacity;
/** 当前元素数量 */
private final AtomicInteger count = new AtomicInteger();
/** 队列头部 */
transient Node head;
/** 队列尾部 */
private transient Node last;
/** 从队列取数据时的独占锁 */
private final ReentrantLock takeLock = new ReentrantLock();
/** 条件变量,用于阻塞/唤醒队列为空时的 take 线程 */
private final Condition notEmpty = takeLock.newCondition();
/** 向队列加入数据时的独占锁 */
private final ReentrantLock putLock = new ReentrantLock();
/** 条件变量,用于阻塞/唤醒队列充满时的 put 线程 */
private final Condition notFull = putLock.newCondition();

LinkedBlockingQueue 有以下特点:

  1. 内部链表结构,默认无界;
  2. 初始化时,head 和 last 指向同一个占位 node,该 node 的元素为 null;
  3. takeLock 保护多线程同步访问 head,取数据;
  4. putLock 保护多线程同步访问 last,往队列加数据;
  5. 读线程和写线程同步分离,提高队列的并发行,同时可以有一个读线程和写线程访问队列;
  6. 没有公平性设计。
PriorityBlockingQueue

它是一个基于数组的无界数组。它使用与类PriorityQueue相同的顺序规则,并且提供了阻塞检索的操作。以下是它的核心成员变量:

/** 元素数组,数组结构,但是是用极小堆来处理的 */
private transient Object[] queue;
/** 队列当前大小 */
    private transient int size;
    /**
      * 排序使用的对比式,如果没有设置,则需要对象实现 Comparable 接口 
      */
private transient Comparator comparator;
    /**
      * public 接口都要获取该锁,用于保护临界区
       */
 private final ReentrantLock lock;
 /**
       * 当取数据时,队列为空,线程等待的条件变量
      */
 private final Condition notEmpty;
    /**
      * 数组扩容时的自旋锁.
     */
    private transient volatile int allocationSpinLock;

PriorityBlockingQueue 有以下特点:

  1. 内部数组存储数据,读和写需要同一个锁进行同步保护;
  2. 虽然是数组存储,但是使用极小堆进行排序;
  3. 初始化可以指定 comparator,如果没有指定,则需要对象实现 Comparable 接口,调用它的 compareTo;
  4. 不接受 null
  5. 虽然是数组,但是必要时回扩容,所以无界,只需要 notEmpty 条件变量
SynchronousQueue

它是一个没有数据缓冲的 BlockingQueue,生产者线程对其的插入操作 put 必须等待消费者的移除操作take,反过来也一样。

不像 ArrayBlockingQueue 或 LinkedListBlockingQueue,SynchronousQueue 内部并没有数据缓存空间,你不能调用peek()方法来看队列中是否有数据元素,因为数据元素只有当你试着取走的时候才可能存在,不取走而只想偷窥一下是不行的,当然遍历这个队列的操作也是不允许的。队列头元素是第一个排队要插入数据的线程,而不是要交换的数据。数据是在配对的生产者和消费者线程之间直接传递的,并不会将数据缓冲数据到队列中。可以这样来理解:生产者和消费者互相等待对方,握手,然后一起离开。

SynchronousQueue 有以下特点:

  1. 没有数据缓冲,写线程和读线程一一对应;
  2. 排队等候的线程,而非数据;
  3. 支持 FIFO 或 LIFO,公平模式下,使用 TransferQueue 支持 FIFO,非公平模式下,使用 TransferStack 支持 LIFO;
  4. 内部用链表存储等候线程;
  5. 实现时,使用轮询 CAS 来实现无锁化处理;

内容来源

Java 并发编程实战

http://ifeve.com/java-copy-on-write/

http://wsmajunfeng.iteye.com/blog/1629354

http://jiangzhengjun.iteye.com/blog/683593

http://ifeve.com/java-synchronousqueue/

你可能感兴趣的:(Java 多线程(八)- 其他常用并发容器)