并发编程之并发容器

本篇文章介绍并发容器和阻塞队列,部分内容总结摘抄自《Java并发编程的艺术》和《Java并发编程实战》,仅作笔记。

我们熟知的ArrayList、HashMap等都是非线程安全的,即多个线程访问的时候会出现未知的问题,例如在多线程环境中使用HashMap可能会导致程序死循环(JDK1.8不会了)。为了解决这个问题,Java提供了Vector、Hashtable等同步容器,这些同步容器的实现即是在对应非线程安全类的方法上增加了synchronized关键字。

但随之而来的问题就是同步容器的性能问题,在多个线程竞争容器级别的锁时,有一个线程获取了容器的锁,其他所有竞争的线程就会被阻塞,吞吐量就会降低。例如HashMap.get或List.contains()操作中,可能包含大量的工作:当遍历散列桶或链表来查找某个特定的对象时,必须在许多元素上调用equals()。在基于散列的容器中,如果hashCode不能很均匀的分布散列值,那么容器中的元素就不会均匀的分布在整个容器中。当遍历很长的链表并且在某些或全部元素上调用equals()方法时,会发费很长的时间,其他线程在这段时间或者阻塞或者轮询。

为了解决同步容器的性能问题,Java.util.concurrent包下提供了多个并发容器。下面对ConcurrentHashMap、CopyOnWriteArrayList和ConcurrentLinkedQueue详细介绍,对其他并发容器简单介绍。

ConcurrentHashMap

与HashMap一样,ConcurrentHashMap也是一个基于散列的Map,但它使用了一种完全不同的加锁策略来提供更高的并发性和伸缩性。在JDK1.7中,ConcurrentHashMap并不是将每个方法都在同一个锁上同步并使得每次只能有一个线程访问容器,而是使用一种粒度更细的加锁机制来实现更大程度的共享,这种机制称为分段锁(Lock Striping)。关于分段锁的概念在这篇文章中详细介绍,此处不再赘述。在JDK1.8中则是使用CAS和synchronized保证并发。

先介绍JDK1.7中的ConcurrentHashMap的结构和实现。

在分段锁机制中,任意数量的读取现场可以并发地访问Map,执行读取操作的线程和执行写入操作的线程可以并发地访问Map,并且一定数量的写入线程可以并发地修改Map。简单来说,HashTable容器效率低的原因是在并发环境中所有访问HashTable容器的线程都必须竞争同一把锁,一个线程获取锁之后,其他线程都要堵塞或者轮询。ConcurrentHashMap的锁分段技术将数据分成一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据的时候,其他段的数据也能被其他线程访问。

ConcurrentHashMap时由Segment数组结构和HashEntry数组结构组成。Segment是ConcurrentHashMap的内部类,继承了ReentrantLock,在ConcurrentHashMap中扮演可重入锁的角色;HashEntry则用于存储键值对数据。一个ConcurrentHashMap中包含一个Segment数组。Segment的结构和HashMap类似,是一种数组和链表结构。一个Segment里包含一个HashEntry数组,每个HashEntry时一个链表结构的元素,每个Segment守护着一个HashEntry数组里的元素,当对HashEntry数组的数据进行修改时,必须首先获得与它对应的Segment锁。如下图所示。

下面简单介绍一下ConcurrentHashMap的get、put和size操作。

get操作

Segment的get操作实现非常简单和高效。实现代码如下:

public V get(Object key) {
    Segment<K,V> s; // manually integrate access methods to reduce overhead
    HashEntry<K,V>[] tab;
    int h = hash(key);
    long u = (((h >>> segmentShift) & segmentMask) << SSHIFT) + SBASE;
    if ((s = (Segment<K,V>)UNSAFE.getObjectVolatile(segments, u)) != null && (tab = s.table) != null) {
        for (HashEntry<K,V> e = (HashEntry<K,V>) UNSAFE.getObjectVolatile(tab, ((long)(((tab.length - 1) & h)) << TSHIFT) + TBASE);e != null; e = e.next) {
            K k;
            if ((k = e.key) == key || (e.hash == h && key.equals(k)))
                return e.value;
        }
    }
    return null;
}

上述代码中,先经过一次散列,然后使用散列值通过散列运算定位到Segment,再遍历Segment的HashEntry数组取得value值。get操作的高效之处在于整个get方法不需要加锁。get()方法中使用的共享变量都定义成了volatile类型,保证了这些共享变量在线程之间的可见性。

put操作

由于put操作需要对共享变量进行写入操作,即便共享变量使用volatile修饰了也只能保证其可见性而不能保证其原子性,因此在操作共享变量时需要加锁。put方法首先定位到Segment,然后尝试获取锁,获取锁失败则自旋获取直到获取成功。获取锁成功后,通过key的hashcode定位到HashEntry。插入元素后判断数组数量是否超过阀值,如果超过则对数组进行扩容:首先创建一个容量是原来两倍的数组,然后将原数组里的元素进行再散列后插入到新的书阻力。为了高效,ConcurrentHashMap不会对整个容器进行扩容,而是只对某个Segment扩容。

size操作

要统计整个ConcurrentHashMap里元素的数量,就必须统计所有Segment里元素的数量后求和。做法是遍历加锁全部Segment,进行累加count操作,然后再遍历解锁全部Segment。

对于hash表来说,散列是否均匀分布尤其重要,但影响散列是否均匀分布的元素有很多,大多数情况都没有我们想像的那么理想。在ConcurrentHashMap中如果出现队列较长的情况,查询遍历的效率是很低的。因此在JDK1.8中,在HashMap和ConcurrentHashMap结构中加入了红黑树来优化查询遍历性能,并且使用CAS和synchronized替代了分段锁保证并发更新。

在对ConcurrentHashMap容器做更新操作时,如果没有hash冲突,则使用CAS操作更新数据;如果有hash冲突,则使用synchronized关键字为链表或红黑树的首节点加锁。这种方式相比于分段锁,粒度更细且更大程度的减少了线程冲突。

//TODO 以后有时间补充更详细的JDK1.8的Concurrent介绍

ConcurrentLinkedQueue

ConcurrentLinkedQueue即非阻塞队列,这位大佬写的太详细了,我暂时引用一下,有时间补充源码详解。

https://www.jianshu.com/p/231caf90f30b

CopyOnWriteArrayList

CopyOnWriteArrayList即线程安全的ArrayList,也是一个可变数组。CopyOnWrite的意思是写时复制,在做更新操作时先创建一个新的数组,然后将原始数组的数据拷贝到新的数组中,在新的数组中更新(add或delete),最后将新的数组引用赋值给CopyOnWriteArrayList维护的一个valotile修饰的Object数组引用。因此,CopyOnWriteArrayList的更新操作效率很低。

CopyOnWriteArrayList有如下两个成员变量:

final transient ReentrantLock lock = new ReentrantLock();
private transient volatile Object[] array;

CopyOnWriteArrayList本质即是通过维护array数组实现的,上面提到的更新操作后将新数组的引用赋值给的也是此array引用。每个CopyOnWriteArrayList与一个互斥锁ReentrantLock绑定来实现互斥访问。

下面详细介绍get、add、remove操作。

get操作

由于CopyOnWriteArrayList在更新时是创建了一个新的数组,并且array被volatile修饰保证了其可见性,因此get()方法中不需要加锁。

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

add操作

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

在add()方法中,首先,获取锁并加锁,新建一个Object数组并将原始数组的数据拷贝进去,新数组的长度=原始数组的长度+1。然后,将新增的元素添加到新数组的末尾。最后,将新创建的数组引用赋值给array。需要注意的是,add()方法的返回值永远是true。

remove操作

public E remove(int index) {
    final ReentrantLock lock = this.lock;
    lock.lock();
    try {
        Object[] elements = getArray();
        int len = elements.length;
        E oldValue = get(elements, index);
        int numMoved = len - index - 1;
        if (numMoved == 0)
            setArray(Arrays.copyOf(elements, len - 1));
        else {
            Object[] newElements = new Object[len - 1];
            System.arraycopy(elements, 0, newElements, 0, index);
            System.arraycopy(elements, index + 1, newElements, index,numMoved);
            setArray(newElements);
        }
        return oldValue;
    } finally {
        lock.unlock();
    }
}

在remove()方法中,首先获取锁并加锁,然后获取要删除的指定索引的旧值用来返回。判断要删除的值是否是数组的最后一个元素,如果是,则直接创建新数组并将原始数组的值拷贝到新数组,并将新数组的引用赋值给array;如果不是,则创建新数组,新数组的长度=原始数组的长度-1,将原始数组的小于待删除索引的数据全部拷贝到新数组中,然后将原始数组的大于待删除索引的数组全部拷贝到新数组中(其实就是将原始数组除了要删除的索引之外的所有其他值拷贝到新数组中,也就相当于删除操作),最后将新数组的引用赋值给array。

还有一些其他的并发容器,此处简单介绍,笔者现在面临找工作面试,以后查漏补缺时会结合源代码补充。

CopyOnWriteArraySet

CopyOnWriteArraySet即线程安全的HashSet,基于CopyOnWriteArrayList实现,唯一的区别在于add()方法中,如果待添加的元素已经存在于CopyOnWriteArraySet中,则直接返回false,否则调用CopyOnWriteArrayList的add()方法。

ConcurrentSkipListMap

ConcurrentSkipListMap即线程安全的TreeMap,使用了跳表这个数据结构,用于存储有序的数据,顺序按照key值升序。

ConcurrentSkipListSet

ConcurrentSkipListSet级线程安全的TreeSet,基于ConcurrentSkipListMap实现。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值