Java并发之同步容器与并发容器

799 阅读13分钟

引 言

容器是Java基础类库中使用频率最高的一部分,Java集合包中提供了大量的容器类来帮组我们简化开发,最简单的就是通过synchronized关键字将所有使用到非线程安全的容器代码全部同步执行。这种方式虽然可以达到线程安全的目的,但存在几个明显的问题:首先是在编码上存在一定的复杂性;再者就是在竞争激烈的环境下,性能可能达不到我们的预期。在JDK1.5中为我们提供了一系列的并发容器,集中在java.util.concurrent包下,用来解决这两个问题。
首先,我们先来看一下Java中的同步容器。

同步容器

同步容器可以理解为通过synchronized来实现同步的容器,如果有多个线程调用同步容器的方法,它们将会串行执行。同步容器包括Vector、Hashtable、Collections.synchronizedXXX(List/Set/Map)等工厂方法创建的类。
Vector和HashTable这两个容器的实现和早期的ArrayList和HashMap代码实现基本一样,不同在于Vector和HashTable在每个方法上都添加了synchronized关键字来保证同一个实例同时只有一个线程能访问,部分源码如下:

Vector
public synchronized boolean add(E e) { }  
public synchronized E remove(int index) { }
public synchronized E get(int index) { }
HashTable
public synchronized int size() { }
public synchronized boolean contains(Object value) { }
public synchronized V put(K key, V value) { }

接下来我们通过几个简单的例子来对比Vector与ArrayList,HashTable与HashMap在线程安全性上的差别。

  1. Vector与ArrayList
    先看下ArrayList:(ps:下面代码演示的例子是5000个线程去累计计数的)
@Slf4j
public class ArrayListTest {

    // 请求总数
    public static int clientTotal = 5000;

    // 同时并发执行的线程数
    public static int threadTotal = 200;
    // ArraryList
    private static List<Integer> list = new ArrayList<>();

    public static void main(String[] args) throws InterruptedException {
        ExecutorService exe = Executors.newCachedThreadPool();
        final Semaphore semaphore = new Semaphore(threadTotal);
        final CountDownLatch countDownLatch = new CountDownLatch(clientTotal);
        for (int i = 0; i < clientTotal; i++) {
            final int count = i;
            exe.execute(() -> {
                try {
                    semaphore.acquire();
                    update(count);
                    semaphore.release();
                } catch (Exception e) {
                    log.error("execption", e);
                }
                countDownLatch.countDown();
            });
        }
        countDownLatch.await();
        exe.shutdown();
        log.info("size:{}", list.size());
    }

    private static void update(int i) {
        list.add(i);
    }
}

测试结果:size:4936
再运行几次,size还是达不到我们的期望值——5000 (ps:我们如果想到达我们的期望值,可以在update()方法加上synchronized关键字)
我们再演示一下Vector,还是同样的例子,稍稍修改一下上面的代码:

  // 把ArrayList改成Vector 
  private static Vector<Integer> vector = new Vector<>();

再运行几次,我们看下测试结果:size:5000
从上面的测试来看,我们可以得出这样一个结论:在没有手动做同步处理的情况下,ArrayList是线程不安全的,Vector是线程安全的

  1. HashTable与HashMap
public class HashMapTest {

    // 请求总数
    public static int clientTotal = 5000;

    // 同时并发执行的线程数
    public static int threadTotal = 200;
    // HashMap
    private static Map<Integer,Integer> map = new HashMap<>();

    public static void main(String[] args) throws InterruptedException {
        ExecutorService exe = Executors.newCachedThreadPool();
        final Semaphore semaphore = new Semaphore(threadTotal);
        final CountDownLatch countDownLatch = new CountDownLatch(clientTotal);
        for (int i = 0; i < clientTotal; i++) {
            final int count = i;
            exe.execute(() -> {

                try {
                    semaphore.acquire();//是否允许被执行
                    update(count);
                    semaphore.release();
                } catch (Exception e) {
                    log.error("execption", e);
                }
                countDownLatch.countDown();
            });
        }
        countDownLatch.await();
        exe.shutdown();
        log.info("size:{}", map.size());
    }

    private static void update(int i) {
        map.put(i,i);
    }
}

测试结果:size:4857
再运行几次,size还是达不到我们的期望值——5000
我们把HashMap换成HashTable测试一下

// 把HashMap换成HashTable
private static Map<Integer,Integer> table = new Hashtable<>();

再运行几次,我们看下测试结果:size:5000
由此可见:HashMap是线程不安全的,HashTable是线程安全的
原因我们已经说过了:Vector和HashTable在每个方法上都添加了synchronized关键字来保证同一个实例同时只有一个线程能访问
3. Collections.SynchronizedXXX(List/Set/Map)
我们再来看一下Collections.SynchronizedXXX等通过工厂方法创建的类,首先给出结论:这种方式创建的类是线程安全的
还是上面累计计数的例子,我们稍作一些修改: 分别把 ArrayList换成Collections.SynchronizedList/Collections.SynchronizedSet/Collections.SynchronizedMap

   // 由工厂方法创建的List
   private static List<Integer> list = Collections.synchronizedList(Lists.newArrayList());  
  //由工厂方法创建的Set  
   private static Set<Integer> set = Collections.synchronizedSet(Sets.newHashSet());
 // 由工厂方法创建的Map
  private static Map<Integer,Integer> map = Collections.synchronizedMap(Maps.newHashMap());

分别对上面的例子多运行几次。测试结果总能达到我们的预期:size:5000
所以说:这种方式创建的类是线程安全的
我们以Collections.synchronizedMap为例,看下部分源码实现

 SynchronizedMap(Map<K,V> m) {
            this.m = Objects.requireNonNull(m);
            mutex = this;
        }

        SynchronizedMap(Map<K,V> m, Object mutex) {
            this.m = m;
            this.mutex = mutex;
        }

        public int size() {
            synchronized (mutex) {
            return m.size();
            }
        }
         public V put(K key, V value) {
            synchronized (mutex) {
            return m.put(key, value);
            }
        }

看到内部方法实现都带有了synchronized (mutex),此时就明白了这种工厂方法创建的类是线程安全的吧。
同步容器虽然能够保证线程的安全性,但是如果在操作频繁、数据量极大、资源竞争激烈的环境下,就很难保证性能。所以,并发容器就出场了,接下来,我们来看下并发容器。

并发容器

常见的并发容器主要有:CopyOnWriteArrayList、CopyOnWriteArraySet、ConcurrentSkipListSet、ConcurrentHashMap、ConcurrentSkipListMap.
CopyOnWrite:写时复制容器是一种常用的并发容器,它通过多线程下读写分离来达到提高并发性能的目的,这种方式的好处显而易见:通过copy一个新的容器来进行修改,这样读操作就不需要加锁,可以并发读,因为在读的过程中是采用的旧的容器,即使新容器做了修改对旧容器也没有影响,同时也很好的解决了迭代过程中其他线程修改导致的并发问题。
以CopyOnWriteArrayList为例,看其内部源码实现:读操作是不需要加锁的,写操作是利用了ReentrantLock。

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

// set()方法
 public E set(int index, E element) {
        final ReentrantLock lock = this.lock;
        lock.lock();
        try {
            Object[] elements = getArray();
            E oldValue = get(elements, index);

            if (oldValue != element) {
                int len = elements.length;
                Object[] newElements = Arrays.copyOf(elements, len);
                newElements[index] = element;
                setArray(newElements);
            } else {
                // Not quite a no-op; ensures volatile write semantics
                setArray(elements);
            }
            return oldValue;
        } finally {
            lock.unlock();
        }
    }

还是上面累计计数的例子,我们稍作一下修改:

  // new CopyOnWriteArrayList()
  private static List<Integer> list = new CopyOnWriteArrayList<>();

多运行几次,测试结果:size:5000
再修改成CopyOnWriteArraySet来测试一下:

// new CopyOnWriteArraySet()
private static Set<Integer> list = new CopyOnWriteArraySet<>();

测试结果也是一致的:size:5000
我们接下来看一下ConcurrentSkipListMap
ConcurrentSkipListMap是线程安全的有序的哈希表,适用于高并发的场景。 ConcurrentSkipListMap和TreeMap,它们虽然都是有序的哈希表,但是也有很大的区别,一是它们的线程安全机制不同,TreeMap是非线程安全的,而ConcurrentSkipListMap是线程安全的;二是ConcurrentSkipListMap是通过跳表实现的,而TreeMap是通过红黑树实现的。
什么是跳表呢?请看下面这张图:

跳表分为许多层(level),每一层都可以看作是数据的索引,这些索引的意义就是加快跳表查找数据速度。每一层的数据都是有序的,上一层数据是下一层数据的子集,并且第一层(level1)包含了全部的数据;层次越高,跳跃性越大,包含的数据越少。跳表包含一个表头,它查找数据时,是从上往下,从左往右进行查找。
在4线程1.6万数据的条件下,ConcurrentHashMap 存取速度是ConcurrentSkipListMap 的4倍左右。 但ConcurrentSkipListMap有几个ConcurrentHashMap 不能比拟的优点:

  1. ConcurrentSkipListMap 的key是有序的。
  2. ConcurrentSkipListMap 支持更高的并发。ConcurrentSkipListMap的存取时间是log(N),和线程数几乎无关。也就是说在数据量一定的情况下,并发的线程越多,ConcurrentSkipListMap越能体现出他的优势。

在非多线程的情况下,应当尽量使用TreeMap。此外对于并发性相对较低的并行程序可以使用Collections.synchronizedSortedMap将TreeMap进行包装,也可以提供较好的效率。对于高并发程序,应当使用ConcurrentSkipListMap,能够提供更高的并发度。 所以在多线程程序中,如果需要对Map的键值进行排序时,请尽量使用ConcurrentSkipListMap,可能得到更好的并发度。
ConcurrentSkipListMap主要用到了Node和Index两种节点的存储方式,通过volatile关键字实现了并发的操作,源码如下:

// Node是最底层的链表的节点,包括键值对和指向下一个节点的指针
static final class Node<K,V> {
        final K key;
        volatile Object value;
        volatile Node<K,V> next;

        Node(K key, Object value, Node<K,V> next) {
            this.key = key;
            this.value = value;
            this.next = next;
        }

        Node(Node<K,V> next) {
            this.key = null;
            this.value = this;
            this.next = next;
        }
        
// 索引节点结构,存储了两个指针,分别指向右边和下边的节点
// 索引节点的value为链表节点
 static class Index<K,V> {
        final Node<K,V> node;
        final Index<K,V> down;
        volatile Index<K,V> right;
        Index(Node<K,V> node, Index<K,V> down, Index<K,V> right) {
            this.node = node;
            this.down = down;
            this.right = right;
        }

// 索引层的头节点结构
// 在索引节点的基础上添加了表示层数的level变量
static final class HeadIndex<K,V> extends Index<K,V> {
        final int level;
        HeadIndex(Node<K,V> node, Index<K,V> down, Index<K,V> right, int level) {
            super(node, down, right);
            this.level = level;
        }
    }

ConcurrentSkipListMap的put()方法是通过调用doPut()方法实现的,源码如下:

// put()方法
 public V put(K key, V value) {
        if (value == null)
            throw new NullPointerException();
        return doPut(key, value, false);
    }
//doPut()方法
 private V doPut(K key, V value, boolean onlyIfAbsent) {
    Node<K,V> z;             // added node
    if (key == null)
        throw new NullPointerException();
    Comparator<? super K> cmp = comparator;
    outer: for (;;) {
        for (Node<K,V> b = findPredecessor(key, cmp), n = b.next;;) { //查找前继节点
            if (n != null) { //查找到前继节点
                Object v; int c;
                Node<K,V> f = n.next; //获取后继节点的后继节点
                if (n != b.next)  //发生竞争,两次节点获取不一致,并发导致
                    break;
                if ((v = n.value) == null) {  // 节点已经被删除
                    n.helpDelete(b, f);
                    break;
                }
                if (b.value == null || v == n) 
                    break;
                if ((c = cpr(cmp, key, n.key)) > 0) { //进行下一轮查找,比当前key大
                    b = n;
                    n = f;
                    continue;
                }
                if (c == 0) { //相等时直接cas修改值
                    if (onlyIfAbsent || n.casValue(v, value)) {
                        @SuppressWarnings("unchecked") V vv = (V)v;
                        return vv;
                    }
                    break; // restart if lost race to replace value
                }
                // else c < 0; fall through
            }

            z = new Node<K,V>(key, value, n); //9. n.key > key > b.key
            if (!b.casNext(n, z)) //cas修改值 
                break;         // restart if lost race to append to b
            break outer;
        }
    }

    int rnd = ThreadLocalRandom.nextSecondarySeed(); //获取随机数
    if ((rnd & 0x80000001) == 0) { // test highest and lowest bits
        int level = 1, max;
        while (((rnd >>>= 1) & 1) != 0) // 获取跳表层级
            ++level;
        Index<K,V> idx = null;
        HeadIndex<K,V> h = head;
        if (level <= (max = h.level)) { //如果获取的调表层级小于等于当前最大层级,则直接添加,并将它们组成一个上下的链表
            for (int i = 1; i <= level; ++i)
                idx = new Index<K,V>(z, idx, null);
        }
        else { // try to grow by one level //否则增加一层level,在这里体现为Index<K,V>数组
            level = max + 1; // hold in array and later pick the one to use
            @SuppressWarnings("unchecked")Index<K,V>[] idxs =
                (Index<K,V>[])new Index<?,?>[level+1];
            for (int i = 1; i <= level; ++i)
                idxs[i] = idx = new Index<K,V>(z, idx, null);
            for (;;) {
                h = head;
                int oldLevel = h.level;
                if (level <= oldLevel) // lost race to add level
                    break;
                HeadIndex<K,V> newh = h;
                Node<K,V> oldbase = h.node;
                for (int j = oldLevel+1; j <= level; ++j) //新添加的level层的具体数据
                    newh = new HeadIndex<K,V>(oldbase, newh, idxs[j], j);
                if (casHead(h, newh)) {
                    h = newh;
                    idx = idxs[level = oldLevel];
                    break;
                }
            }
        }
        // 逐层插入数据过程
        splice: for (int insertionLevel = level;;) {
            int j = h.level;
            for (Index<K,V> q = h, r = q.right, t = idx;;) {
                if (q == null || t == null)
                    break splice;
                if (r != null) {
                    Node<K,V> n = r.node;
                    // compare before deletion check avoids needing recheck
                    int c = cpr(cmp, key, n.key);
                    if (n.value == null) {
                        if (!q.unlink(r))
                            break;
                        r = q.right;
                        continue;
                    }
                    if (c > 0) {
                        q = r;
                        r = r.right;
                        continue;
                    }
                }

                if (j == insertionLevel) {
                    if (!q.link(r, t))
                        break; // restart
                    if (t.node.value == null) {
                        findNode(key);
                        break splice;
                    }
                    if (--insertionLevel == 0)
                        break splice;
                }

                if (--j >= insertionLevel && j < level)
                    t = t.down;
                q = q.down;
                r = q.right;
            }
        }
    }
    return null;
}

跳表生产索引的源码可能比较晦涩难懂,我们可以这样简单理解(笔者的水平有限,大家也可以去google一下相关资料再去理解一下)

  1. 获取前继节点后通过CAS来插入节点
  2. 对level层数进行判断,如果大于最大层数,则插入一层
  3. 第三步插入对应层的数据

回归我们本文最开始的那个小例子,修改成ConcurrentSkipListMap

//  new ConcurrentSkipListMap<>();
 private static Map<Integer,Integer> map = new ConcurrentSkipListMap<>();

测试结果: size:5000
这是在插入过程全部通过CAS自旋的方式保证并发情况下的数据正确性
ConcurrentSkipListSet是通过ConcurrentSkipListMap实现的,它的接口基本上都是通过调用ConcurrentSkipListMap接口来实现的,所以我们这里就不再对它的源码进行分析了!
最后我们再来看一下ConcurrentHashMap
在底层数据结构上,ConcurrentHashMap和HashMap都使用了数组+链表+红黑树的方式,只是在HashMap的基础上添加了并发相关的一些控制,所以这里只对ConcurrentHashMap中并发相关代码做一些分析,直接上源码:

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元素的过程中,有几个并发处理的关键点:

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

测试结果: size:5000
通过ConcurrentHashMap添加元素的过程,W我们知道了ConcurrentHashMap容器是通过CAS + synchronized一起来实现并发控制的