JUC集合(一)

今天利用下班之后的一点时间来整理一下之前看过的有关JUC集合的东西吧。

目录:

1.List和Set
2.Map
3.延伸内容

在JUC List和Set集合中,比较常见的有三个

  • CopyOnWriteArrayList
  • ConcurrentSkipListSet
  • ConcurrentHashMap

CopyOnWriteArrayList

其中CopyOnWriteArrayList,简单看一下它的声明:

CopyOnWriteArrayList implements List<E>, RandomAccess, Cloneable, java.io.Serializable

再看一下它内部的两个重要成员变量:

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

一个是volatile类型的对象数组,一个是一个可重入锁ReentrantLock

我们知道,无论是ArrayList还是CopyOnWriteArrayList,其内部的实现原理都是基于一个对象数组,那么在ArrayList类中,其内部的对象数组并没有用volatile来修饰,那么CopyOnWriteArrayList内部的这个数组为什么要用该关键字来修饰呢?
这个时候就要想到这个关键字的作用了:为了保证每个线程在对该数组进行操作时,总能看到其它线程对该volatile变量最后的写入。这样一来,就能够完全保证每个线程读取到该数组的值都是修改之后,最新的。(但是它并没有使线程同步的功能)
另外多说一点个人理解,之前看过一些java内存模型,这个关键字之所以有上述的作用,其实是由于它能够插入一些内存屏障来阻止指令重排序,所以才能起到上述的作用。

那么ReentrantLock在这里的作用是什么呢?
既然它是锁嘛,所以起的肯定就是一个同步的作用。

那么将这两个属性放到一起来理解该CopyOnWriteArrayList是如何保证线程安全,做到同步的呢?
首先ReentrantLock排它锁保证每一次只能有一个线程对其内部的数据进行访问,这首先保证了互斥,保证 了临界资源的安全性。
接着某个线程对其进行了操作之后,由于在java内存模型中,每个线程都有自己独立的内存空间,也叫线程缓存。而所有线程共享访问的是主存区域的变量、资源。所以当某个线程对某个资源进行了操作之后,如果没有及时更新到主存中去,那么其他线程访问的这个资源则就是已过期的,无效的。这个时候关键字volatile通过在底层加入内存屏障,防止指令重排序,来保证每个线程在对该数组进行操作时,总能看到其它线程对该volatile变量最后的写入。(注:有关volatile关键字的具体原理大家可以去看一下java内存模型)
这样最终就做到了线程之间的同步以及资源的安全。

CopyOnWriteArrayList特点:
在进行(add,set,remove)操作时需要重新复制一份原集合数据(Arrays.copyOf),然后再此基础上再进行操作,最后再与原集合合并。

所以,基于上边的那个特点,可以推测出CopyOnWriteArrayList的应用场景如下:

  1. CopyOnWriteArrayList尽可能的用在读操作多于写操作的业务逻辑中
  2. 由于其内部有一个锁ReentrantLock,所以支持并发操作,能够保证线程安全。
  3. 其迭代器支持hasNext(), next()等不可变操作,但不支持可变 remove()等操作。
  4. 使用迭代器(在该类中,迭代器的类型为COWIterator)进行遍历的速度很快,并且不会与其他线程发生冲突。在构造迭代器时,迭代器依赖于不变的数组快照。

接着从add方法源码的角度来分析一下,顺便提高一下自己阅读代码的能力,啊哈哈:

public boolean add(E e) {
    final ReentrantLock lock = this.lock;
    // 获取“锁”
    lock.lock();
    try {
        // 获取原始”volatile数组“中的数据和数据长度。
        Object[] elements = getArray();
        int len = elements.length;
        // 新建一个数组newElements,并将原始数据拷贝到newElements中;
        // newElements数组的长度=“原始数组的长度”+1
        Object[] newElements = Arrays.copyOf(elements, len + 1);
        // 将“新增加的元素”保存到newElements中。
        newElements[len] = e;
        // 将newElements赋值给”volatile数组“。
        setArray(newElements);
        return true;
    } finally {
        // 释放“锁”
        lock.unlock();
    }
}

实现原理无非就以下几步:

  1. 首先获取一个Lock,然后进行加锁操作。
  2. 然后调用getArray()方法来获取原来的数据集合,
  3. 调用Arrays.copyOf()方法来复制一个新的数组,该数组长度+1
  4. 然后将新的元素放入到数组的最后一位
  5. 最后再调用setArray()方法来重新赋值给valatile数组

CopyOnWriteArraySet

首先来看一下这个类的声明:

CopyOnWriteArraySet extends AbstractSet<E>  implements java.io.Serializable

其内部的一个重要的成员属性:

final CopyOnWriteArrayList<E> al

由此可以推断,CopyOnWriteArraySet是由内部的CopyOnWriteArrayList实现的。其实事实也是如此。

所以CopyOnWriteArraySet所具有的特点其实就是CopyOnWriteArrayList的那几个特点:宜读不宜写,线程安全,迭代速度快,无remove方法。

由于CopyOnWriteArraySet不允许有重复的元素,那么来看一下其内部是如何实现的:

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

很明显,该方法其实是CopyOnWriteArrayList的addIfAbsent的方法实现,那么来看一下addIfAbsent的方法实现吧:

    public boolean addIfAbsent(E e) {
        Object[] snapshot = getArray();
        return indexOf(e, snapshot, 0, snapshot.length) >= 0 ? false :  addIfAbsent(e, snapshot);
    }

实现原理简单概括一下就两步:

  1. 首先获取一个原数组的快照,然后调用indexOf()方法来判断原数组中是否有E对象,
  2. 如果有,则返回false,否则添加到数组中。

ConcurrentHashMap

首先看一下ConcurrentHashMap类的声明:

ConcurrentHashMap extends AbstractMap<K,V>  implements ConcurrentMap<K,V>, Serializable

实现原理:锁分段
ConcurrentHashMap将哈希表分成许多片段(Segment),每一个片段除了保存哈希表之外,本质上也是一个“可重入的互斥锁”(ReentrantLock)。多线程对同一个片段的访问,是互斥的;但是,对于不同片段的访问,却是可以同步进行的。

在jdk1.7中,ConcurrentHashMap大致是这样被实现的:
一个ConcurrentHashMap对应着n个Segment片段,然后每个Segment片段又对应着n个HashEntry<K,V>。以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;
    // 获取key对应的Segment片段。
    // 如果Segment片段不为null,则在“Segment片段的HashEntry数组中”中找到key所对应的HashEntry列表;
    // 接着遍历该HashEntry链表,找到于key-value键值对对应的HashEntry节点。
    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;
}

实现过程概括为两步:

  1. 首先将Key值hash,然后获取key所对应的Segment片段。
  2. 如果Segment片段不为null,则在Segment片段的HashEntry数组中找到key所对应的HashEntry列表;遍历该HashEntry链表,找到于key-value键值对对应的HashEntry节点。

增加方法put:

public V put(K key, V value) {
    Segment<K,V> s;
    if (value == null)
        throw new NullPointerException();
    // 获取key对应的哈希值
    int hash = hash(key);
    int j = (hash >>> segmentShift) & segmentMask;
    // 如果找不到该Segment,则新建一个。
    if ((s = (Segment<K,V>)UNSAFE.getObject          // nonvolatile; recheck
         (segments, (j << SSHIFT) + SBASE)) == null) // in ensureSegment
        s = ensureSegment(j);
    return s.put(key, hash, value, false);
}

实现过程大致如下:

  1. put()根据key获取对应的哈希值,再根据哈希值找到对应的Segment片段。如果Segment片段不存在,则新增一个Segment。
  2. 将key-value键值对添加到Segment片段中。

** 该类解决hash冲突的办法为:拉链法 **

通过上边的两个实现,再结合jdk1.8对其的优化,可以想到,利用segment片段来进行分段加锁访问,其实这种控制粒度相对来说还比较大,如果能够精确控制到某一个HashEntry上,那么这种效率就更高了。其实jdk1.8就是这样做的。

jdk1.8的优化有两方面的内容:
1.去掉了segment,直接采用Node<K,V> table来保存数据,用table数组元素作为锁,从而实现了对每一行数据进行加锁,进一步减少并发冲突的概率。
2.将原先table数组+单向链表的数据结构,变更为table数组+单向链表+红黑树的结构。

相关知识

1.解决冲突的其他方法:

  1. 开放地址法:如果冲突,则向后查找,直接有一个空位置
  2. 建立公共溢出区:多分配一块内存用以存放冲突的对象
  3. 再哈希法

2.红黑书参考资料:

  1. 史上最清晰的红黑树讲解(上)
  2. 深入理解红黑树及TreeMap源码实现

3.ConcurrentHashMap学习资料:

  1. 深入浅出ConcurrentHashMap(1.8)
  2. Java并发编程总结4——ConcurrentHashMap在jdk1.8中的改进

后记

并发集合框架是自己平时空闲时间里面边看博客边看代码学习的,今天稍作整理,来记录一下自己的学习过程和成果。不过还有许多并发类,比如ConcurrentSkipListMap没来得及看。抽空多复习一下自己总结的,同时也要保持一颗好奇心多去了解一些新奇的东西。我觉得我会做的越来越好的!

推荐阅读更多精彩内容