JUC容器类

153 阅读8分钟

JUC容器类

Java的基础容器主要有List、Set、Queue、Map四个大类,但是大家熟知的基础容器类ArrayList、LinkedList、HashMap都是非线程安全的,在多个线程场景中使用这些基础容器会出现线程安全问题。

为了解决线程安全问题,Java使用内置锁提供了一套线程安全的同步容器类。虽然同步容器类解决了线程安全问题,不过性能却不高。正因为如此,JUC提供了一套高并发容器类。

Java高并发容器

JUC高并发容器是基于非阻塞算法(或者无锁编程算法)实现的容器类,无锁编程算法主要通过CAS(Compare And Swap)+Volatile组合实现,通过CAS保障操作的原子性,通过volatile保障变量内存的可见性。

JUC包中提供了List、Set、Queue、Map各种类型的高并发容器,如ConcurrentHashMap、ConcurrentSkipListMap、ConcurrentSkipListSet、CopyOnWriteArrayList和CopyOnWriteArraySet。在性能上,ConcurrentHashMap通常优于同步的HashMap,ConcurrentSkipListMap通常优于同步的TreeMap。当读取和遍历操作远远大于列表的更新操作时,CopyOnWriteArrayList优于同步的ArrayList。

  1. List

    CopyOnWriteArrayList相当于线程安全的ArrayList,它实现了List接口。在读多写少的场景中,其性能远远高于ArrayList的同步包装容器。

  2. Set

    CopyOnWriteArraySet继承自AbstractSet类,对应的基础容器为HashSet。其内部组合了一个CopyOnWriteArrayList对象,它的核心操作是基于CopyOnWriteArrayList实现的。

    ConcurrentSkipListSet是线程安全的有序集合,对应的基础容器为TreeSet。它继承自AbstractSet,并实现了NavigableSet接口。ConcurrentSkipListSet是通过ConcurrentSkipListMap实现的。

  3. Map

    ConcurrentHashMap对应的基础容器为HashMap。JDK 6中的ConcurrentHashMap采用一种更加细粒度的“分段锁”加锁机制,JDK 8中采用CAS无锁算法。

    ConcurrentSkipListMap对应的基础容器为TreeMap。其内部的Skip List(跳表)结构是一种可以代替平衡树的数据结构,默认是按照Key值升序的。

  4. Queue

    JUC包中的Queue的实现类包括三类:单向队列、双向队列和阻塞队列。

    ConcurrentLinkedQueue是基于列表实现的单向队列,按照FIFO(先进先出)原则对元素进行排序。新元素从队列尾部插入,而获取队列元素则需要从队列头部获取。

    ConcurrentLinkedDeque是基于链表的双向队列,但是该队列不允许null元素。作为双向队列,ConcurrentLinkedDeque可以当作“栈”来使用,并且高效地支持并发环境。

    阻塞队列:

    ArrayBlockingQueue:基于数组实现的可阻塞的FIFO队列。

    LinkedBlockingQueue:基于链表实现的可阻塞的FIFO队列。

    PriorityBlockingQueue:按优先级排序的队列。

    DelayQueue:按照元素的Delay时间进行排序的队列。

    SynchronousQueue:无缓冲等待队列。

CopyOnWriteArrayList

在很多应用场景中,读操作可能会远远大于写操作。由于读操作根本不会修改原有的数据,因此每次读取都进行加锁操作其实是一种资源浪费。我们应该允许多个线程同时访问List的内部数据,毕竟读操作是线程安全的。

写时复制(Copy On Write,COW)思想是计算机程序设计领域的一种优化策略。其核心思想是,如果有多个访问器(Accessor)访问一个资源(如内存或者磁盘上的数据存储),它们会共同获取相同的指针指向相同的资源,只要有一个修改器(Mutator)需要修改该资源,系统就会复制一份专用副本(Private Copy)给该修改器,而其他访问器所见到的最初资源仍然保持不变,修改的过程对其他访问器都是透明的(Transparently)。COW的主要优点是如果没有修改器去修改资源,就不会创建副本,因此多个访问器可以共享同一份资源。

使用

Collections可以将基础容器包装为线程安全的同步容器,但是这些同步容器包装类在进行元素迭代时并不能进行元素添加操作。

public class COWListTest {

    @Test
    public void test1() {
        List<Integer> list = Collections.synchronizedList(Lists.newArrayList(1, 2, 3));
        for (Integer i : list) {
            list.add(i * 2);
        }
        System.out.println(list);
    }

}

运行结果:

java.util.ConcurrentModificationException
	at java.util.ArrayList$Itr.checkForComodification(ArrayList.java:911)
	at java.util.ArrayList$Itr.next(ArrayList.java:861)
	at org.gjy.m8.thread.COWListTest.test1(COWListTest.java:19)

使用CopyOnWriteArrayList优化

public class COWListTest {
    @Test
    public void test2() {
        CopyOnWriteArrayList<Integer> list = new CopyOnWriteArrayList<>(Lists.newArrayList(1, 2, 3));
        for (Integer i : list) {
            list.add(i * 2);
        }
        System.out.println(list);
    }

}

运行结果:

[1, 2, 3, 2, 4, 6]

原理

CopyOnWrite(写时复制)就是在修改器对一块内存进行修改时,不直接在原有内存块上进行写操作,而是将内存复制一份,在新的内存中进行写操作,写完之后,再将原来的指针(或者引用)指向新的内存,原来的内存被回收。

CopyOnWriteArrayList是写时复制思想的一种典型实现,其含有一个指向操作内存的内部指针array,而可变操作(add、set等)是在array数组的副本上进行的。当元素需要被修改或者增加时,并不直接在array指向的原有数组上操作,而是首先对array进行一次复制,将修改的内容写入复制的副本中。写完之后,再将内部指针array指向新的副本,这样就可以确保修改操作不会影响访问器的读取操作。

CopyOnWriteArrayList是一个满足CopyOnWrite思想并使用Array数组存储数据的线程安全List。CopyOnWriteArrayList的核心成员如下,具体的部分方法可看下文。

public class CopyOnWriteArrayList<E>
    implements List<E>, RandomAccess, Cloneable, java.io.Serializable {
    
    final transient ReentrantLock lock = new ReentrantLock();

    private transient volatile Object[] array;
    
}

写入

写入操作时加了独占锁,确保只有一个线程进行写入操作,避免多个线程写的时候出现多个副本。

增加元素底层使用的是Arrays.copyOf复制一个新数组,将要添加的值设置在新数组的最后一位,然后通过setArray将新数组执行array变量。

**注意:**因为每次都是重新复制一个新的数组,会导致开销比较大,在实际的使用场景中,尽量避免频繁的插入数据。

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();
    }
}

写入操作还有其他的方式,这里一一介绍了,感兴趣可以看一下CopyOnWriteArrayList的源码。

读取

访问器的读取操作没有任何同步控制和锁操作,理由是内部数组array不会发生修改,只会被另一个array替换,因此可以保证数据安全。

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

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

final Object[] getArray() {
    return array;
}

迭代器

CopyOnWriteArray有自己的迭代器,该迭代器不会检查修改状态,也无须检查状态。为什么呢?因为被迭代的array数组可以说是只读的,不会有其他线程能够修改它。

public Iterator<E> iterator() {
    return new COWIterator<E>(getArray(), 0);
}
static final class COWIterator<E> implements ListIterator<E> {
    /** Snapshot of the array */
    private final Object[] snapshot;
    /** Index of element to be returned by subsequent call to next.  */
    private int cursor;

    private COWIterator(Object[] elements, int initialCursor) {
        cursor = initialCursor;
        snapshot = elements;
    }

    public boolean hasNext() {
        return cursor < snapshot.length;
    }

    public E next() {
        if (! hasNext())
            throw new NoSuchElementException();
        return (E) snapshot[cursor++];
    }

}

总结

CopyOnWriteArrayList有一个显著的优点,那就是读取、遍历操作不需要同步,速度会非常快。所以,CopyOnWriteArrayList适用于读操作多、写操作相对较少的场景(读多写少),比如可以在进行“黑名单”拦截时使用CopyOnWriteArrayList。

CopyOnWriteArrayList和ReentrantReadWriteLock读写锁的思想非常类似,即读读共享、写写互斥、读写互斥、写读互斥。但是前者相比后者的更进一步:为了将读取的性能发挥到极致,CopyOnWriteArrayList读取是完全不用加锁的,而且写入也不会阻塞读取操作,只有写入和写入之间需要进行同步等待,读操作的性能得到大幅度提升。

BlockingQueue

在多线程环境中,通过BlockingQueue(阻塞队列)可以很容易地实现多线程之间的数据共享和通信,比如在经典的“生产者”和“消费者”模型中,通过BlockingQueue可以完成一个高性能的实现版本。

阻塞队列与普通队列(ArrayDeque等)之间的最大不同点在于阻塞队列提供了阻塞式的添加和删除方法。

(1)阻塞添加阻塞添加是指当阻塞队列元素已满时,队列会阻塞添加元素的线程,直到队列元素不满时,才重新唤醒线程执行元素添加操作。

(2)阻塞删除阻塞删除是指在队列元素为空时,删除队列元素的线程将被阻塞,直到队列不为空时,才重新唤醒删除线程,再执行删除操作。

常用方法

  1. 添加方法

(1)add(E e):添加成功则返回true,失败就抛出IllegalStateException异常。

(2)offer(E e):成功则返回true,如果此队列已满,就返回false。

(3)put(E e):将元素添加至此队列的尾部,如果该队列已满,就一直阻塞。

  1. 删除类方法

(1)poll():获取并移除此队列的头元素,若队列为空,则返回null。

(2)take():获取并移除此队列的头元素,若没有元素,则一直阻塞。

(3)remove(Object o):移除指定元素,成功则返回true,失败则返回false。

  1. 获取元素类方法

(1)element():获取但不移除此队列的头元素,没有元素则抛出异常。

(2)peek():获取但不移除此队列的头元素,若队列为空,则返回null。

抛出异常特殊值(true/false)阻塞限时阻塞
insertadd(e)offer(e)put(e)offer(e,time,unit)
removeremove()poll()take()poll(time, unit)
examineelement()peek()not applicablenot applicable

常见实现类

BlockingQueue的实现类有ArrayBlockingQueue、DelayQueue、LinkedBlockingDeque、PriorityBlockingQueue、SynchronousQueue等。

  1. ArrayBlockingQueue

    基于数组实现的,其内部使用一个定长数组来存储元素。除了一个定长数组外,ArrayBlockingQueue内部还保存着两个整型变量,分别标识着队列的头部和尾部在数组中的位置。

    ArrayBlockingQueue的添加和删除操作共用同一个锁对象,由此意味着添加和删除无法并行运行,这点不同于LinkedBlockingQueue

    在长时间、高并发处理大批量数据的场景中,LinkedBlockingQueue产生的额外Node实例会加大系统的GC压力。

  2. LinkedBlockingQueue

    基于链表的阻塞队列,其内部也维持着一个数据缓冲队列(该队列由一个链表构成)。LinkedBlockingQueue对于添加和删除元素分别采用了独立的锁来控制数据同步,这也意味着在高并发的情况下,生产者和消费者可以并行地操作队列中的数据,以此来提高整个队列的并发性能。

    需要注意的是,在新建一个LinkedBlockingQueue对象时,若没有指定其容量大小,则LinkedBlockingQueue会默认一个类似无限大小的容量(Integer.MAX_VALUE),这样的话,生产者的速度一旦大于消费者的速度,也许还没有等到队列满阻塞产生,系统内存就已经被消耗殆尽了。

  3. DelayQueue

    DelayQueue中的元素只有当其指定的延迟时间到了,才能够从队列中获取该元素。DelayQueue是一个没有大小限制的队列,因此往队列中添加数据的操作(生产者)永远不会被阻塞,而只有获取数据的操作(消费者)才会被阻塞。

    DelayQueue的使用场景较少,但是相当巧妙,常见的例子是使用DelayQueue来管理一个超时未响应的连接队列。

  4. PriorityBlockingQueue

    基于优先级的阻塞队列和DelayQueue类似,PriorityBlockingQueue并不会阻塞数据生产者,而只会在没有可消费的数据时,阻塞数据的消费者。在使用的时候要特别注意,生产者生产数据的速度绝对不能快于消费者消费数据的速度,否则时间一长,会最终耗尽所有的可用堆内存空间。

  5. SynchronousQueue

    一种无缓冲的等待队列,类似于无中介的直接交易,有点像原始社会中的生产者和消费者,生产者拿着产品去集市销售给产品的最终消费者,而消费者必须亲自去集市找到所要商品的直接生产者,如果一方没有找到合适的目标,那么大家都在集市等待。

ArrayBlockingQueue

下面通过ArrayBlockingQueue队列实现一个生产者-消费者的案例,通过该案例简单了解其使用方式和方法。具体的代码在前面的生产者和消费者实现基础上进行迭代——Consumer(消费者)和Producer(生产者)通过ArrayBlockingQueue队列获取和添加元素。其中,消费者调用take()方法获取元素,当队列没有元素时就阻塞;生产者调用put()方法添加元素,当队列满时就阻塞。通过这种方式便可以实现生产者-消费者模式,比直接使用等待唤醒机制或者Condition条件队列更加简单。

模拟生产者,消费者

package org.gjy.m8.thread;

import org.junit.Test;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.time.LocalDateTime;
import java.util.UUID;
import java.util.concurrent.*;
import java.util.concurrent.atomic.LongAdder;

public class BlockingQueueTest {

    private static final Logger log = LoggerFactory.getLogger(BlockingQueueTest.class);

    @Test
    public void test1() throws InterruptedException {
        int threads = 20;
        DataBuffer<String> data = new DataBuffer<>(100);
        CountDownLatch p = new CountDownLatch(threads);
        CountDownLatch c = new CountDownLatch(threads);
        LongAdder producerAdder = new LongAdder();
        LongAdder consumerAdder = new LongAdder();

        Callable<String> producer = () -> {
            String s = UUID.randomUUID().toString();
            data.add(s);
            log.info("producer: {}, {}", s, LocalDateTime.now());
            p.countDown();
            producerAdder.increment();
            return s;
        };

        Callable<String> consumer = () -> {
            String s = data.fetch();
            log.info("consumer: {}, {}", s, LocalDateTime.now());
            c.countDown();
            consumerAdder.increment();
            return s;
        };

        ExecutorService pool = Executors.newFixedThreadPool(threads);
        for (int i = 0; i < threads; i++) {
            pool.submit(producer);
        }
        for (int i = 0; i < threads; i++) {
            pool.submit(consumer);
        }

        p.await();
        c.await();

        log.info("producerAdder {}", producerAdder);
        log.info("consumerAdder {}", consumerAdder);
    }

    private static class DataBuffer<T> {
        private final BlockingQueue<T> queue;

        public DataBuffer(Integer max) {
            queue = new ArrayBlockingQueue<>(max);
        }

        public void add(T e) {
            queue.add(e);
        }

        public T fetch() throws Exception {
            return queue.take();
        }
    }

}

构造器和成员

ArrayBlockingQueue中的元素访问存在公平访问与非公平访问两种方式,所以ArrayBlockingQueue可以分别作为公平队列和非公平队列使用:

(1)对于公平队列,被阻塞的线程可以按照阻塞的先后顺序访问队列,即先阻塞的线程先访问队列。

(2)对于非公平队列,当队列可用时,阻塞的线程将进入争夺访问资源的竞争中,也就是说谁先抢到谁就执行,没有固定的先后顺序。

构造器源码

ArrayBlockingQueue内部的阻塞队列是通过重入锁ReenterLock和Condition条件队列实现的。

public ArrayBlockingQueue(int capacity) {
    this(capacity, false);
}

public ArrayBlockingQueue(int capacity, boolean fair) {
    if (capacity <= 0)
        throw new IllegalArgumentException();
    this.items = new Object[capacity];
    lock = new ReentrantLock(fair);
    notEmpty = lock.newCondition();
    notFull =  lock.newCondition();
}

成员变量

public class ArrayBlockingQueue<E> extends AbstractQueue<E>
        implements BlockingQueue<E>, java.io.Serializable {
    final Object[] items;
    int takeIndex;
    int putIndex;
    int count;
    final ReentrantLock lock;
    private final Condition notEmpty;
    private final Condition notFull;
    transient Itrs itrs = null;
}

ArrayBlockingQueue内部是通过数组对象items来存储所有的数据的,通过ReentrantLock类型的成员lock控制添加线程与删除线程的并发访问。

ArrayBlockingQueue使用等待条件对象notEmpty成员来存放或唤醒被阻塞的消费(take)线程,当数组对象items有元素时,告诉take线程可以执行删除操作。

同理,ArrayBlockingQueue使用等待条件对象notFull成员来存放或唤醒被阻塞的生产(put)线程,当队列未满时,告诉put线程可以执行添加元素的操作。

takeIndex成员为消费(或删除元素)的索引,标识的是下一个方法(take、poll、peek、remove)被调用时获取数组元素的位置。

putIndex成员为生产(或添加元素)的索引,代表下一种方法(put、offer、add)被调用时元素添加到数组中的位置。

非阻塞添加add offer

在队列满而不能添加元素时,非阻塞式添加元素的方法会立即返回,所以其执行线程不会被阻塞。非阻塞式添加元素的方法有add()方法和offer()方法。

从源码可以看出,add()方法间接调用了offer()方法,如果offer()方法添加失败,那么add()将抛出IllegalStateException异常,如果offer()方法添加成功,那么add()返回true。

offer()方法根据数组是否满了,分两种场景进行操作:(1)如果数组满了,就直接释放锁,然后返回false。(2)如果数组没满,就将元素入队(加入数组),然后返回true。offer调用了enqueue方法

public boolean add(E e) { // 特别标明,这是父类AbstractQueue中的方法
    if (offer(e))
        return true;
    else
        throw new IllegalStateException("Queue full");
}

public boolean offer(E e) {
    checkNotNull(e);
    final ReentrantLock lock = this.lock;
    lock.lock();
    try {
        if (count == items.length)
            return false;
        else {
            enqueue(e);
            return true;
        }
    } finally {
        lock.unlock();
    }
}

private void enqueue(E x) {
    final Object[] items = this.items;
    items[putIndex] = x;
    if (++putIndex == items.length)
        putIndex = 0;
    count++;
    notEmpty.signal();
}

阻塞添加 put

在队列满而不能添加元素时,执行添加元素的线程会被阻塞。当前线程会被加入notFull条件对象的等待队列中,直到队列有空位置才会被唤醒执行添加操作。但如果队列没有满,就直接调用enqueue(e)方法将元素加入数组队列中。

public void put(E e) throws InterruptedException {
    checkNotNull(e);
    final ReentrantLock lock = this.lock;
    lock.lockInterruptibly();
    try {
        while (count == items.length)
            notFull.await();
        enqueue(e);
    } finally {
        lock.unlock();
    }
}

非阻塞删除 remove poll

在队列空而不能删除元素时,非阻塞式删除元素的方法会立即返回,所以其执行线程不会被阻塞。非阻塞式删除元素的方法有poll()方法。

public boolean remove(Object o) {
    if (o == null) return false;
    final Object[] items = this.items;
    final ReentrantLock lock = this.lock;
    lock.lock();
    try {
        if (count > 0) {
            final int putIndex = this.putIndex;
            int i = takeIndex;
            do {
                if (o.equals(items[i])) {
                    removeAt(i);
                    return true;
                }
                if (++i == items.length)
                    i = 0;
            } while (i != putIndex);
        }
        return false;
    } finally {
        lock.unlock();
    }
}

public E poll() {
    final ReentrantLock lock = this.lock;
    lock.lock();
    try {
        return (count == 0) ? null : dequeue();
    } finally {
        lock.unlock();
    }
}

private E dequeue() {
    final Object[] items = this.items;
    E x = (E) items[takeIndex];
    items[takeIndex] = null;
    if (++takeIndex == items.length)
        takeIndex = 0;
    count--;
    if (itrs != null)
        itrs.elementDequeued();
    notFull.signal();
    return x;
}

阻塞删除 take

(1)如果队列没有数据,就将线程加入notEmpty等待队列并阻塞线程,一直到有生产者插入数据后通过notEmpty发出一个消息,notEmpty将从其等待队列唤醒一个消费(或者删除)节点,同时启动该消费线程。

(2)如果队列有数据,就通过dequeue()执行元素的删除(或消费)操作。

public E take() throws InterruptedException {
    final ReentrantLock lock = this.lock;
    lock.lockInterruptibly();
    try {
        while (count == 0)
            notEmpty.await();
        return dequeue();
    } finally {
        lock.unlock();
    }
}

返回头元素 element peek

peek()方法从takeIndex(头部位置)直接就可以获取最早被添加的元素,所以效率是比较高的,如果不存在就返回null。

public E element() { // 特别标注,这是父类AbstractQueue的方法
    E x = peek();
    if (x != null)
        return x;
    else
        throw new NoSuchElementException();
}

public E peek() {
    final ReentrantLock lock = this.lock;
    lock.lock();
    try {
        return itemAt(takeIndex); // null when queue is empty
    } finally {
        lock.unlock();
    }
}

final E itemAt(int i) {
    return (E) items[i];
}