java知识系列之并发集合

JDK1.5中为我们提供了一系列的并发容器,集中在java.util.concurrent包下,先从同步容器说起。

同步容器Vector和HashTable

这两个是非常古老的集合了,可以追溯到JDK1.0。

为了简化代码开发的过程,早期的JDK在java.util包中提供了Vector和HashTable两个同步容器,这两个容器的实现和早期的ArrayList和HashMap代码实现基本一样,不同在于Vector和HashTable在每个方法上都添加了synchronized关键字来保证同一个实例同时只有一个线程能访问,部分源码如下:

//Vector
public synchronized int size() {};
public synchronized E get(int index) {};

//HashTable 
public synchronized V put(K key, V value) {};
public synchronized V remove(Object key) {};

通过对每个方法添加synchronized,保证了多次操作的串行。这种方式虽然使用起来方便了,但并没有解决高并发下的性能问题,与手动锁住ArrayList和HashMap并没有什么区别,不论读还是写都会锁住整个容器。其次这种方式存在另一个问题:当多个线程进行复合操作时,是线程不安全的

public static void deleteVector(){
    int index = vectors.size() - 1;
    vectors.remove(index);
}
代码中对Vector进行了两步操作,首先获取size,然后移除最后一个元素。
多线程情况下如果两个线程交叉执行,A线程调用size后,
B线程移除最后一个元素,这时A线程继续remove将会抛出索引超出的错误。

那么怎么解决这个问题呢?最直接的修改方案就是对代码块加锁来防止多线程同时执行:

public static void deleteVector(){
    synchronized (vectors) {
        int index = vectors.size() - 1;
        vectors.remove(index);
    }
}

这里的处理方式是对整个对象进行加锁,在高并发下读写操作的性能将会是个很大的问题。

那么有没有什么方式能够很好的对容器的迭代操作和修改操作进行分离,在修改时不影响容器的迭代操作呢?这就需要java.util.concurrent包中的各种并发容器了出场了。

并发容器CopyOnWrite

CopyOnWrite–写时复制容器是一种常用的并发容器,它通过多线程下读写分离来达到提高并发性能的目的,任何时候都可以进行读操作,写操作则需要加锁。不同的是,在CopyOnWrite中,对容器的修改操作加锁后,通过copy一个新的容器来进行修改,修改完毕后将容器替换为新的容器即可。

这种方式的好处显而易见:通过copy一个新的容器来进行修改,这样读操作就不需要加锁,可以并发读,因为在读的过程中是采用的旧的容器,即使新容器做了修改对旧容器也没有影响,同时也很好的解决了迭代过程中其他线程修改导致的并发问题。

JDK中提供的并发容器包括CopyOnWriteArrayList和CopyOnWriteArraySet,下面通过CopyOnWriteArrayList的部分源码来理解这种思想:

//添加元素
public boolean add(E e) {
    //独占锁
    final ReentrantLock lock = this.lock;
    lock.lock();
    try {
        Object[] elements = getArray();
        int len = elements.length;
        //复制一个新的数组newElements
        Object[] newElements = Arrays.copyOf(elements, len + 1);
        newElements[len] = e;
        //修改后指向新的数组
        setArray(newElements);
        return true;
    } finally {
        lock.unlock();
    }
}

public E get(int index) {
    //未加锁,直接获取
    return get(getArray(), index);
}
final Object[] getArray() {
        return elements;
}

代码很简单,在add操作中通过一个共享的ReentrantLock来获取锁,这样可以防止多线程下多个线程同时修改容器内容。获取锁后通过Arrays.copyOf复制了一个新的容器,然后对新的容器进行了修改,最后直接通过setArray将原数组引用指向了新的数组,避免了在修改过程中迭代数据出现错误。get操作由于是读操作,未加锁,直接读取就行。CopyOnWriteArraySet类似,这里不做过多讲解。

但是这里也会有两个问题。

一个是频繁写的时候,每次都会Arrays.copyOf申请更大的内存空间,然后替换。一定程度上,这就会造成频繁的GC。

另一个问题是数据一致性问题。由于在修改中copy了新的数组进行替换,同时旧数组如果还在被使用,那么新的数据就不能被及时读取到,这样就造成了数据不一致,如果需要强数据一致性,CopyOnWrite容器也不太适合。

并发容器ConcurrentHashMap

ConcurrentHashMap容器相较于CopyOnWrite容器在并发加锁粒度上有了更大一步的优化,它通过修改对单个hash桶元素加锁的达到了更细粒度的并发控制。在了解ConcurrentHashMap容器之前,推荐大家先阅读HashMap源码分析的文章。因为在底层数据结构上,ConcurrentHashMap和HashMap都使用了数组+链表+红黑树的方式,只是在HashMap的基础上添加了并发相关的一些控制,所以这里只对ConcurrentHashMap中并发相关代码做一些分析。

在这里插入图片描述
先从ConcurrentHashMap的写操作开始,这里就是put方法:

final V putVal(K key, V value, boolean onlyIfAbsent) {
    if (key == null || value == null) throw new NullPointerException();
    int hash = spread(key.hashCode()); //计算桶的hash值
    int binCount = 0;
    //循环插入元素,避免并发插入失败
    for (Node<K,V>[] tab = table;;) {
        Node<K,V> f; int n, i, fh;
        if (tab == null || (n = tab.length) == 0)
            tab = initTable();
        else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
            //如果当前桶无元素,则通过cas操作插入新节点
            if (casTabAt(tab, i, null,
                            new Node<K,V>(hash, key, value, null)))
                break;                   
        }
        //如果当前桶正在扩容,则协助扩容
        else if ((fh = f.hash) == MOVED)
            tab = helpTransfer(tab, f);
        else {
            V oldVal = null;
            //hash冲突时锁住当前需要添加节点的头元素,可能是链表头节点或者红黑树的根节点
            synchronized (f) { 
                if (tabAt(tab, i) == f) {
                    if (fh >= 0) {
                        binCount = 1;
                        for (Node<K,V> e = f;; ++binCount) {
                            K ek;
                            if (e.hash == hash &&
                                ((ek = e.key) == key ||
                                    (ek != null && key.equals(ek)))) {
                                oldVal = e.val;
                                if (!onlyIfAbsent)
                                    e.val = value;
                                break;
                            }
                            Node<K,V> pred = e;
                            if ((e = e.next) == null) {
                                pred.next = new Node<K,V>(hash, key,
                                                            value, null);
                                break;
                            }
                        }
                    }
                    else if (f instanceof TreeBin) {
                        Node<K,V> p;
                        binCount = 2;
                        if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key,
                                                        value)) != null) {
                            oldVal = p.val;
                            if (!onlyIfAbsent)
                                p.val = value;
                        }
                    }
                }
            }
            if (binCount != 0) {
                if (binCount >= TREEIFY_THRESHOLD)
                    treeifyBin(tab, i);
                if (oldVal != null)
                    return oldVal;
                break;
            }
        }
    }
    addCount(1L, binCount);
    return null;
}

在put元素的过程中,有几个并发处理的关键点:

  • 如果当前桶对应的节点还没有元素插入,通过典型的无锁cas操作尝试插入新节点,减少加锁的概率,并发情况下如果插入不成功,很容易想到自旋,也就是for (Node<K,V>[] tab = table;😉。
  • 如果当前桶正在扩容,则协助扩容((fh = f.hash) == MOVED)。这里是一个重点,ConcurrentHashMap的扩容和HashMap不一样,它在多线程情况下或使用多个线程同时扩容,每个线程扩容指定的一部分hash桶,当前线程扩容完指定桶之后会继续获取下一个扩容任务,直到扩容全部完成。扩容的大小和HashMap一样,都是翻倍,这样可以有效减少移动的元素数量,也就是使用2的幂次方的原因,在HashMap中也一样。
  • 在发生hash冲突时仅仅只锁住当前需要添加节点的头元素即可,可能是链表头节点或者红黑树的根节点,其他桶节点都不需要加锁,大大减小了锁粒度。

通过ConcurrentHashMap添加元素的过程,知道了ConcurrentHashMap容器是通过CAS + synchronized一起来实现并发控制的。这里有个额外的问题:为什么使用synchronized而不使用ReentrantLock?在这里我的理解是synchronized在后期优化空间上比ReentrantLock更大。

阻塞队列

阻塞队列统一实现了BlockingQueue接口,BlockingQueue接口在java.util包Queue接口的基础上提供了put(e)以及take()两个阻塞方法。他的主要使用场景就是多线程下的生产者消费者模式,生产者线程通过put(e)方法将生产元素,消费者线程通过take()消费元素。除了阻塞功能,BlockingQueue接口还定义了定时的offer以及poll,以及一次性移除方法drainTo。


boolean add(E e);//插入元素,队列满后会抛出异常

E remove();//移除元素,队列为空时会抛出异常


boolean offer(E e);//插入元素,成功反会true

E poll();//移除元素


void put(E e) throws InterruptedException;//插入元素,队列满后会阻塞

E take() throws InterruptedException;//移除元素,队列空后会阻塞

int drainTo(Collection<? super E> c);//获取所有元素到Collection中

JDK1.8中的阻塞队列实现共有7个,分别是ArrayBlockingQueue、LinkedBlockingQueue、PriorityBlockingQueue、DelayQueue、SynchronousQueue、LinkedTransferQueue以及LinkedBlockingDeque,下面就来一一对他们进行一个简单的分析

ArrayBlockingQueue

ArrayBlockingQueue是一个底层用数组实现的有界阻塞队列,有界是指他的容量大小是固定的,不能扩充容量,在初始化时就必须确定队列大小。它通过可重入的独占锁ReentrantLock来控制并发,Condition来实现阻塞。

//通过数组来存储队列中的元素
final Object[] items;

//初始化一个固定的数组大小,默认使用非公平锁来控制并发
public ArrayBlockingQueue(int capacity) {
    this(capacity, false);
}

//初始化固定的items数组大小,初始化notEmpty以及notFull两个Condition来控制生产消费
public ArrayBlockingQueue(int capacity, boolean fair) {
    if (capacity <= 0)
        throw new IllegalArgumentException();
    this.items = new Object[capacity];
    lock = new ReentrantLock(fair);//通过ReentrantLock来控制并发
    notEmpty = lock.newCondition();
    notFull =  lock.newCondition();
}
复制代码可以看到ArrayBlockingQueue初始化了一个ReentrantLock以及两个Condition,用来控制并发下队列的生产消费。这里重点看下阻塞的put以及take方法:
//插入元素到队列中
public void put(E e) throws InterruptedException {
    checkNotNull(e);
    final ReentrantLock lock = this.lock;
    lock.lockInterruptibly(); //获取独占锁
    try {
        while (count == items.length) //如果队列已满则通过await阻塞put方法
            notFull.await();
        enqueue(e); //插入元素
    } finally {
        lock.unlock();
    }
}

private void enqueue(E x) {
    // assert lock.getHoldCount() == 1;
    // assert items[putIndex] == null;
    final Object[] items = this.items;
    items[putIndex] = x;
    if (++putIndex == items.length) //插入元素后将putIndex+1,当队列使用完后重置为0
        putIndex = 0;
    count++;
    notEmpty.signal(); //队列添加元素后唤醒因notEmpty等待的消费线程
}

//移除队列中的元素
public E take() throws InterruptedException {
    final ReentrantLock lock = this.lock;
    lock.lockInterruptibly(); //获取独占锁
    try {
        while (count == 0) //如果队列已空则通过await阻塞take方法
            notEmpty.await(); 
        return dequeue(); //移除元素
    } finally {
        lock.unlock();
    }
}

private E dequeue() {
    // assert lock.getHoldCount() == 1;
    // assert items[takeIndex] != null;
    final Object[] items = this.items;
    @SuppressWarnings("unchecked")
    E x = (E) items[takeIndex];
    items[takeIndex] = null;
    if (++takeIndex == items.length) //移除元素后将takeIndex+1,当队列使用完后重置为0
        takeIndex = 0;
    count--;
    if (itrs != null)
        itrs.elementDequeued();
    notFull.signal(); //队列消费元素后唤醒因notFull等待的消费线程
    return x;
}

复制代码在队列添加和移除元素的过程中使用putIndex、takeIndex以及count三个变量来控制生产消费元素的过程,putIndex负责记录下一个可添加元素的下标,takeIndex负责记录下一个可移除元素的下标,count记录了队列中的元素总量。队列满后通过notFull.await()来阻塞生产者线程,消费元素后通过notFull.signal()来唤醒阻塞的生产者线程。队列为空后通过notEmpty.await()来阻塞消费者线程,生产元素后通过notEmpty.signal()唤醒阻塞的消费者线程。
限时插入以及移除方法在ArrayBlockingQueue中通过awaitNanos来实现,在给定的时间过后如果线程未被唤醒则直接返回。

public boolean offer(E e, long timeout, TimeUnit unit)
    throws InterruptedException {
    checkNotNull(e);
    long nanos = unit.toNanos(timeout); //获取定时时长
    final ReentrantLock lock = this.lock;
    lock.lockInterruptibly();
    try {
        while (count == items.length) {
            if (nanos <= 0) //指定时长过后,线程仍然未被唤醒则返回false
                return false;
            nanos = notFull.awaitNanos(nanos); //指定时长内阻塞线程
        }
        enqueue(e);
        return true;
    } finally {
        lock.unlock();
    }
}

复制代码还有一个比较重要的方法:drainTo,drainTo方法可以一次性获取队列中所有的元素,它减少了锁定队列的次数,使用得当在某些场景下对性能有不错的提升。

public int drainTo(Collection<? super E> c, int maxElements) {
    checkNotNull(c);
    if (c == this)
        throw new IllegalArgumentException();
    if (maxElements <= 0)
        return 0;
    final Object[] items = this.items;
    final ReentrantLock lock = this.lock; //仅获取一次锁
    lock.lock();
    try {
        int n = Math.min(maxElements, count); //获取队列中所有元素
        int take = takeIndex;
        int i = 0;
        try {
            while (i < n) {
                @SuppressWarnings("unchecked")
                E x = (E) items[take];
                c.add(x); //循环插入元素
                items[take] = null;
                if (++take == items.length)
                    take = 0;
                i++;
            }
            return n;
        } finally {
            // Restore invariants even if c.add() threw
            if (i > 0) {
                count -= i;
                takeIndex = take;
                if (itrs != null) {
                    if (count == 0)
                        itrs.queueIsEmpty();
                    else if (i > take)
                        itrs.takeIndexWrapped();
                }
                for (; i > 0 && lock.hasWaiters(notFull); i--)
                    notFull.signal(); //唤醒等待的生产者线程
            }
        }
    } finally {
        lock.unlock();
    }
}

LinkedBlockingQueue

LinkedBlockingQueue是一个底层用单向链表实现的有界阻塞队列,和ArrayBlockingQueue一样,采用ReentrantLock来控制并发,不同的是它使用了两个独占锁来控制消费和生产。put以及take方法源码如下:

public void put(E e) throws InterruptedException {
    int c = -1;
    Node<E> node = new Node<E>(e);
    final ReentrantLock putLock = this.putLock;
    //因为使用了双锁,需要使用AtomicInteger计算元素总量,避免并发计算不准确
    final AtomicInteger count = this.count; 
    putLock.lockInterruptibly();
    try {
        while (count.get() == capacity) {
            notFull.await(); //队列已满,阻塞生产线程
        }
        enqueue(node); //插入元素到队列尾部
        c = count.getAndIncrement(); //count + 1
        if (c + 1 < capacity) //如果+1后队列还未满,通过其他生产线程继续生产
            notFull.signal();
    } finally {
        putLock.unlock();
    }
    if (c == 0) //只有当之前是空时,消费队列才会阻塞,否则是不需要通知的
        signalNotEmpty(); 
}

private void enqueue(Node<E> node) {
    //将新元素添加到链表末尾,然后将last指向尾部元素
    last = last.next = node;
}

public E take() throws InterruptedException {
    E x;
    int c = -1;
    final AtomicInteger count = this.count;
    final ReentrantLock takeLock = this.takeLock;
    takeLock.lockInterruptibly();
    try {
        while (count.get() == 0) {
            notEmpty.await(); //队列为空,阻塞消费线程
        }
        x = dequeue(); //消费一个元素
        c = count.getAndDecrement(); //count - 1
        if (c > 1) // 通知其他等待的消费线程继续消费
            notEmpty.signal();
    } finally {
        takeLock.unlock();
    }
    if (c == capacity) //只有当之前是满的,生产队列才会阻塞,否则是不需要通知的
        signalNotFull();
    return x;
}

//消费队列头部的下一个元素,同时将新头部置空
private E dequeue() {
    Node<E> h = head;
    Node<E> first = h.next;
    h.next = h; // help GC
    head = first;
    E x = first.item;
    first.item = null;
    return x;
}

可以看到LinkedBlockingQueue通过takeLock和putLock两个锁来控制生产和消费,互不干扰,只要队列未满,生产线程可以一直生产,只要队列不为空,消费线程可以一直消费,不会相互因为独占锁而阻塞。
看过了LinkedBlockingQueue以及ArrayBlockingQueue的底层实现,会发现一个问题,正常来说消费者和生产者可以并发执行对队列的吞吐量会有比较大的提升,那么为什么ArrayBlockingQueue中不使用双锁来实现队列的生产和消费呢?我的理解是ArrayBlockingQueue也能使用双锁来实现功能,但由于它底层使用了数组这种简单结构,相当于一个共享变量,如果通过两个锁,需要更加精确的锁控制,这也是为什么JDK1.7中的ConcurrentHashMap使用了分段锁来实现,将一个数组分为多个数组来提高并发量。LinkedBlockingQueue不存在这个问题,链表这种数据结构头尾节点都相对独立,存储上也不连续,双锁控制不存在复杂性。这是我的理解,如果你有更好的结论,请留言探讨。

LinkedBlockingDeque

LinkedBlockingDeque是一个有界的双端队列,底层采用一个双向的链表来实现,在LinkedBlockingQeque的Node实现多了指向前一个节点的变量prev。并发控制上和ArrayBlockingQueue类似,采用单个ReentrantLock来控制并发,这里是因为双端队列头尾都可以消费和生产,所以使用了一个共享锁。它实现了BlockingDeque接口,继承自BlockingQueue接口,多了addFirst,addLast,offerFirst,offerLast,peekFirst,peekLast等方法,用来头尾生产和消费。LinkedBlockingDeque的实现代码比较简单,基本就是综合了LinkedBlockingQeque和ArrayBlockingQueue的代码逻辑,这里就不做分析了。

有界阻塞队列包括:ArrayBlockingQueue、LinkedBlockingQueue以及LinkedBlockingDeque三种,LinkedBlockingDeque应用场景很少,一般用在“工作窃取”模式下。ArrayBlockingQueue和LinkedBlockingQueue基本就是数组和链表的区别。无界队列包括PriorityBlockingQueue、DelayQueue和LinkedTransferQueue。PriorityBlockingQueue用在需要排序的队列中。DelayQueue可以用来做一些定时任务或者缓存过期的场景。LinkedTransferQueue则相比较其他队列多了transfer功能。最后剩下一个不存储元素的队列SynchronousQueue,用来处理一些高效的传递性场景。

小结

  1. ConcurrentHashMap

    • 线程安全的HashMap的实现
    • 数据结构:一个指定个数的Segment数组,数组中的每一个元素Segment相当于一个HashTable(一个HashEntry[])
    • 扩容的话,只需要扩自己的Segment而非整个table扩容
    • key与value均不可以为null,而hashMap可以
    • 添加元素:
      • 根据key获取key.hashCode的hash值 hashVal_0
      • 根据 hashVal_0 计算出将要插入的Segment
      • 根据 hashVal_0 与(Segment中的HashEntry的容量-1) 按位与 ,计算出将要插入的HashEntry的index
        • 若HashEntry[index]中的HashEntry链表有与插入元素相同的key和hash值,根据onlyIfAbsent决定是否替换旧值
        • 若没有相同的key和hash,直接返回将新节点插入到链头,原来的头结点设置为新节点next(采用的方式与HashMap一致,都是HashEntry替换的方法)
    • ConcurrentHashMap基于concurrencyLevel划分出多个Segment来存储key-value,这样的话put的时候只锁住当前的Segment,可以避免put的时候锁住整个map,从而减少了并发时的阻塞现象
    • 获取元素:
      • 根据key获取key.hashCode的hash值
      • 根据hash值与找到相应的Segment
      • 根据hash值与Segment中的HashEntry的容量-1按位与获取HashEntry的index
      • 遍历整个HashEntry[index]链表,找出hash和key与给定参数相等的HashEntry,例如e
        • 如没找到e,返回null
        • 如找到e,获取e.value
          • 如果e.value!=null,直接返回
          • 如果e.value==null,则先加锁,等并发的put操作将value设置成功后,再返回value值
      • 对于get操作而言,基本没有锁,只有当找到了e且e.value等于null,有可能是当下的这个HashEntry刚刚被创建,value属性还没有设置成功,这时候我们读到是该HashEntry的value的默认值null,所以这里加锁,等待put结束后,返回value值
    • 加锁情况(分段锁)
      • put:
      • get:找到了hash与key都与指定参数相同的HashEntry,但是value==null的情况
      • remove:
      • size():三次尝试后,还未成功,遍历所有Segment,分别加锁(即建立全局锁)
  2. CopyOnWriteArrayList

    • 线程安全且在读操作时无锁的ArrayList
    • 采用的模式就是"CopyOnWrite"(即写操作–>包括增加、删除,使用复制完成)
    • 底层数据结构是一个Object[],初始容量为0,之后每增加一个元素,容量+1,数组复制一遍
    • 遍历的只是全局数组的一个副本,即使全局数组发生了增删改变化,副本也不会变化,所以不会发生并发异常。但是,可能在遍历的过程中读到一些刚刚被删除的对象
    • 增删改上锁、读不上锁
    • 读多写少且脏数据影响不大的并发情况下,选择CopyOnWriteArrayList
  3. CopyOnWriteArraySet

    • 基于CopyOnWriteArrayList,不添加重复元素
  4. ArrayBlockingQueue 阻塞队列

    • 基于数组、先进先出、线程安全,可实现指定时间的阻塞读写,并且容量可以限制
    • 组成:一个对象数组 + 1把锁ReentrantLock + 2个条件Condition
    • 三种加入队列
      • offer(E e) :【不阻塞】如果队列没有满,则立即返回true; 如果队列满了,则立即返回false
      • put(E e):【阻塞】如果队列满了,一直阻塞,直到数组不满了或者线程被中断–>阻塞
      • offer(E e, long timeout, TimeUnit unit):在队尾插入一个元素,,如果数组已满,则进入等待,直到出现以下三种情况:–>【阻塞】
        • 被唤醒
        • 等待时间超时
        • 当前线程被中断
    • 三种出队列
      • poll():【不阻塞】如果没有元素,直接返回null;如果有元素队首,出队
      • take():【阻塞】如果队列空了,则一直阻塞,知道数组不为空或者线程被中断
      • poll(long timeout, TimeUnit unit):如果数组不空,出队;如果数组已空且已经超时,返回null;如果数组已空且时间未超时,则进入等待,直到出现以下三种情况:
        • 被唤醒
        • 等待时间超时
        • 当前线程被中断
    • 需要注意的是,数组是一个必须指定长度的数组,在整个过程中,数组的长度不变,队头随着出入队操作一直循环后移
    • 锁的形式有公平与非公平两种
    • 在只有入队高并发或出队高并发的情况下,因为操作数组,且不需要扩容,性能很高
  5. LinkedBlockingQueue

    • 基于链表实现,读写各用一把锁,在高并发读写操作都多的情况下,性能优于ArrayBlockingQueue
    • 组成 一个链表 + 两把锁 + 两个条件
    • 默认容量为整数最大值,可以看做没有容量限制
    • 三种入队与三种出队与上边完全一样,只是由于LinkedBlockingQueue的的容量无限,在入队过程中,没有阻塞等待

参考链接:https://juejin.im/post/5bd1e4d7e51d4566a17a0f74#heading-7

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值