10、并发集合类

ArrayList、LinkedList等List类集合,以及HashMap、TreeMap、LinkedHashMap等Map类集合,都是线程不安全的,当作为共享变量时,在多线程并发环境下存在线程安全问题,为此,JAVA提供了线程安全的集合,List类集合有Vector、Collections.synchronizedList、CopyOnWriteArrayList,Map类集合有Hashtable、Collections.synchronizedMap、ConcurrentHashMap。

List类并发集合类

1、Vector

Vector是JDK1.0就存在的并发集合类,底层结构也是数组,通过对类中的所有操作进行加锁从而达到线程安全。

部分源码如下:

public class Vector<E>
    extends AbstractList<E>
    implements List<E>, RandomAccess, Cloneable, java.io.Serializable{
    // 存储元素的数组
    protected Object[] elementData;
    // 数组中的元素数量
    protected int elementCount;
    // 扩容时增长的容量
    protected int capacityIncrement;
    
    public Vector(int initialCapacity, int capacityIncrement) {
        super();
        if (initialCapacity < 0)
            throw new IllegalArgumentException("Illegal Capacity: "+
                                               initialCapacity);
        this.elementData = new Object[initialCapacity];
        this.capacityIncrement = capacityIncrement;
    }

    public Vector(int initialCapacity) {
        this(initialCapacity, 0);
    }

    public Vector() {
        this(10);
    }

    public Vector(Collection<? extends E> c) {
        elementData = c.toArray();
        elementCount = elementData.length;
        // c.toArray might (incorrectly) not return Object[] (see 6260652)
        if (elementData.getClass() != Object[].class)
            elementData = Arrays.copyOf(elementData, elementCount, Object[].class);
    }
    ...
}

由源码可以看出:

  1. Vector继承了AbstractList类实现了List接口,所以支持List的所有功能,比如迭代器;

  2. 实现了RandomAccess接口,所以支持根据索引快速随机访问;

  3. Vector的底层数据结构也是数组,使用数组存放数据;

  4. 如果初始化的时候不指定容量,数组的大小为默认大小10,并且是在初始化的时候就为数组分配空间,而不是像ArrayList一样在第一次执行add方法时才分配;

  5. 可以指定数组扩容时增长的大小capacityIncrement,如果没有指定capacityIncrement就等于原来数组的大小,如果想自己指定数组每次扩容时增长的大小,可以使用Vector(ArrayList每次扩容为原数组的1.5倍),扩容部分的源代码如下:

    private void grow(int minCapacity) {
      // 扩容前的数组大小
      int oldCapacity = elementData.length;
      // 新的数组的大小,如果capacityIncrement不大于0,就为扩容钱数组大小的两倍
      int newCapacity = oldCapacity + ((capacityIncrement > 0) ?
                                       capacityIncrement : oldCapacity);
      if (newCapacity - minCapacity < 0)
        newCapacity = minCapacity;
      if (newCapacity - MAX_ARRAY_SIZE > 0)
        newCapacity = hugeCapacity(minCapacity);
      // 数组拷贝,将旧数组中的数据拷贝到新数组,并让elementData指向新数组
      elementData = Arrays.copyOf(elementData, newCapacity);
    }
    

Vector的使用方式如下:

Vector<Integer> vector = new Vector<>(3, 2);
vector.add(1);
vector.add(2);
vector.add(3);
vector.add(4);
Iterator<Integer> iterator = vector.iterator();
while(iterator.hasNext()) {
  System.out.println(iterator.next());
}

2、Collections.synchronizedList

Collections.synchronizedList也是实现了List接口,并且也是通过synchronized关键字实现线程安全的,那么Collections.synchronizedList与Vector的区别是什么呢?

主要区别如下:

  1. Vector中synchronized是放在方法上的,是对方法加锁,锁定的是this对象;而synchronizedList中synchronized是放在方法中的,是对代码块进行加锁,锁定的是mutex对象,一般来说锁的范围大小和性能是成反比的,而且synchronizedList中加锁的对象mutex可以是我们自己指定的对象,所以synchronizedList在加锁的对象上更灵活一些;

  2. 虽然Vector是在方法上加锁,而synchronizedList是在代码块上加锁,但是实际上两者的性能差别不大,比如add()方法,两者的源码对比如下:

    // Vector的add()方法
    public synchronized boolean add(E e) {
      modCount++;
      ensureCapacityHelper(elementCount + 1);
      elementData[elementCount++] = e;
      return true;
    }
    
    // synchronizedList的add()方法
    public void add(int index, E element) {
      synchronized (mutex) {list.add(index, element);}
    }
    

    synchronizedList其实并不会比Vector的性能更高。

    有一点需要特别注意,synchronizedList的迭代器在遍历时没有加锁,所以使用迭代器遍历时需要我们自己手动加锁,如果在遍历过程中,集合中的数据被修改了,会抛ConcurrentModificationException异常;而Vector是对迭代器加锁了的,可以直接使用,不会有线程安全问题。

  3. synchronizedList支持ArrayList和LinkedList两种List,创建synchronizedList时参数如果是ArrayList时,底层的数据结构也是数组,在数组扩容时跟Vector是由区别的,Vector可以自己指定扩容时数组增长的大小,默认为原数组的大小,而ArrayList扩容时增长的大小为原数组的1/2;

  4. SynchronizedList有很好的扩展和兼容功能,可以将所有的List的子类转成线程安全的类,而Vector是java.util包中的一个类,这也是SynchronizedList与Vector在使用场景下的重要区别;

Collections.synchronizedListde使用方式如下:

ArrayList<Integer> arrayList = new ArrayList<>();
for(int i = 1; i <= 10; ++i) {
  arrayList.add(i);
}
// synchronizedList()方法的参数可以是所有的List的子类
List<Integer> synchronizedList = Collections.synchronizedList(arrayList);
Iterator<Integer> iterator = synchronizedList.iterator();
while(iterator.hasNext()) {
  System.out.println(iterator.next());
}

3、CopyOnWriteArrayList

CopyOnWriteArrayList具有以下特性:

  1. 线程安全的,多线程环境下可以直接使用,无需加锁;
  2. 通过锁+数组拷贝+volatile关键字保证了线程安全;
  3. 每次操作数组,都会把数组拷贝一份出来,在新数组上进行操作,操作成功之后再赋值回去;
  4. 迭代过程中,即使数组被修改了,也不会抛出异常;
整体架构

从整体架构上来说,CopyOnWriteArrayList 数据结构和 ArrayList 是一致的,底层是个数组,只不过 CopyOnWriteArrayList 在对数组进行操作的时候,基本会分为四步:

  1. 加锁;
  2. 从原数组中拷贝出新数组;
  3. 在新数组上进行操作,并把新数组赋值给数组容器;
  4. 解锁;

除了加锁之外,CopyOnWriteArrayList 的底层数组还被 volatile 关键字修饰,意思是一旦数组被修改,其它线程立马能够感知到,注意,被volatile修饰的数组,如果只是改变数组中的值,是无法通知到其他CPU缓存的,必须改变数组引用的地址。

新增数据

新增有很多种情况,比如说:新增到数组尾部、新增到数组某一个索引位置、批量新增等等,操作的思路还是开头说的四步,拿新增到数组尾部的方法举例,来看看底层源码的实现:

// 添加元素到数组尾部
public boolean add(E e) {
    // 获取锁,所有方法获取的都是同一个锁
    final ReentrantLock lock = this.lock;
    // 加锁
    lock.lock();
    try {
        // 获取原数组
        Object[] elements = getArray();
        int len = elements.length;
        // 拷贝到新数组里面,新数组的长度是 + 1 的,因为会新增一个元素
        Object[] newElements = Arrays.copyOf(elements, len + 1);
        // 在新数组中进行赋值,新元素直接放在数组的尾部
        newElements[len] = e;
        // 替换掉原来的数组
        setArray(newElements);
        return true;
    // finally 里面释放锁,保证即使 try 发生了异常,仍然能够释放锁   
    } finally {
        lock.unlock();
    }
}

从源码中,可以发现整个 add 过程都是在持有锁的状态下进行的,通过加锁,来保证同一时刻只能有一个线程能够对同一个数组进行 add 操作。

除了加锁之外,还会从老数组中创建出一个新数组,然后把老数组的值拷贝到新数组上,这时候就有一个问题:都已经加锁了,为什么需要拷贝数组,而不是在原来数组上面进行操作呢,原因主要为:

  1. volatile 关键字修饰的是数组,如果简单的在原来数组上修改其中某几个元素的值,是无法触发可见性的,必须通过修改数组的内存地址,对数组进行重新赋值才行;
  2. 对老数组进行拷贝,然后在新数组上进行操作,在这个过程中老数组的数据是不变的,从而实现了迭代过程中,即使数组被修改了,也不会抛出异常。因为迭代的过程中迭代器中直指向的是老数组的地址,是对老数组中的数据进行遍历,而老数组中的数据是没有被改变的,因此当前线程迭代的数据可能不是最新的,CopyOnWriteArrayList 只能保证数据的最终一致性而不能保证数据的强一致性。

简单 add 操作是直接添加到数组的尾部,接着我们来看下指定位置添加元素的关键源码(部分源码):

// len:数组的长度、index:插入的位置、numMoved:需要往后移动的数组长度
int numMoved = len - index;
// 如果要插入的位置正好等于数组的末尾,直接拷贝数组即可
if (numMoved == 0)
    newElements = Arrays.copyOf(elements, len + 1);
else {
// 如果要插入的位置在数组的中间,就需要拷贝 2 次
// 第一次从 0 拷贝到 index。
// 第二次从 index+1 拷贝到末尾。
    newElements = new Object[len + 1];
    System.arraycopy(elements, 0, newElements, 0, index);
    System.arraycopy(elements, index, newElements, index + 1,
         numMoved);
}
// index 索引位置的值是空的,直接赋值即可。
newElements[index] = element;
// 把新数组的值赋值给数组的容器中
setArray(newElements);

从源码中可以看到,当插入的位置正好处于末尾时,只需要拷贝一次;当插入的位置处于中间时,并不是先把原数组完全拷贝然后再把新数组的数据往后移动,而把原数组一分为二,进行两次拷贝操作,设计的很巧妙,减少了拷贝的数据量。

从 add 系列方法可以看出,CopyOnWriteArrayList 通过加锁 + 数组拷贝+ volatile 来保证了线程安全,每一个要素都有着其独特的含义:

  1. 加锁:保证同一时刻只能有一个线程对数组进行操作;
  2. 数组拷贝:保证数组的内存地址被修改,修改后触发 volatile 的可见性,其它线程可以立马知道数组已经被修改;
  3. volatile:值被修改后,其它线程能够立马感知最新值;
删除数据

单个删除的逻辑跟新增的逻辑差不多,主要看下批量删除,源码如下:

// 批量删除包含在 c 中的元素
public boolean removeAll(Collection<?> c) {
    if (c == null) throw new NullPointerException();
    final ReentrantLock lock = this.lock;
    lock.lock();
    try {
        Object[] elements = getArray();
        int len = elements.length;
        // 说明数组有值,数组无值直接返回 false
        if (len != 0) {
            // newlen 表示新数组的索引位置,新数组中存在不包含在 c 中的元素
            int newlen = 0;
            // 创建临时数组
            Object[] temp = new Object[len];
            // 循环,把不包含在 c 里面的元素,放到临时数组中
            for (int i = 0; i < len; ++i) {
                Object element = elements[i];
                // 不包含在 c 中的元素,从 0 开始放到临时数组中
                if (!c.contains(element))
                    temp[newlen++] = element;
            }
            // 拷贝临时数组,变相的删除了不包含在 c 中的元素
            if (newlen != len) {
                // 拷贝临时数组中不为null的数据到新数组
                setArray(Arrays.copyOf(temp, newlen));
                return true;
            }
        }
        return false;
    } finally {
        lock.unlock();
    }
}

从源码中可以看到,并不会直接对数组中的元素进行挨个删除,而是先对数组中的值进行循环判断,把不需要删除的数据放到临时数组中,最后临时数组中的数据就是不需要删除的数据。

ArrayList 的批量删除的思想也是和这个类似的,所以在需要删除多个元素的时候,最好都使用这种批量删除的思想,而不是采用在 for 循环中使用单个删除的方法,单个删除的话,在每次删除的时候都会进行一次数组拷贝(删除最后一个元素时不会拷贝),很消耗性能,也耗时,会导致加锁时间太长,并发大的情况下,会造成大量请求在等待锁,这也会占用一定的内存。

迭代器

在 CopyOnWriteArrayList 类注释中,明确说明了,在其迭代过程中,即使数组的原值被改变,也不会抛出 ConcurrentModificationException 异常,其根源在于数组的每次变动,都会生成新的数组,不会影响老数组,这样的话,迭代过程中,根本就不会发生迭代数组的变动。

测试代码:

CopyOnWriteArrayList<Integer> list = new CopyOnWriteArrayList<>();
for(int i=0; i<5; ++i) {
  list.add(i);
}
Iterator<Integer> iterator = list.iterator();
while(iterator.hasNext()) {
  list.add(10);
  System.out.println(iterator.next());
}
System.out.println(JSON.toJSONString(list));

/**
* 运行结果:
* 0
* 1
* 2
* 3
* 4
* [0,1,2,3,4,10,10,10,10,10]
*/

4、Collections.synchronizedList与CopyOnWriteArrayList的对比

CopyOnWriteArrayList和Collections.synchronizedList是实现线程安全的列表的两种方式。两种实现方式分别针对不同情况有不同的性能表现,其中CopyOnWriteArrayList的写操作性能较差,读操作性能较好;而Collections.synchronizedList的写操作性能比CopyOnWriteArrayList在多线程操作的情况下要好很多,而读操作因为是采用了synchronized关键字的方式,其读操作性能并不如CopyOnWriteArrayList。因此在不同的应用场景下,应该选择不同的多线程安全实现类。

Map类并发集合类

1、Hashtable

Hashtable从Java JDK1.0开始就存在了,由于性能较低,现在用的很少,一般直接使用ConcurrentHashMap,下面简单说下Hashtable与HashMap的区别:

  1. Hashtable是线程安全的,通过在方法上加synchronized实现,HashMap是线程不安全的;
  2. 计算数组下标的方式不一样,Hashtable是直接使用的key的hashcode对数组长度取余,HashMap的计算方式是(n-1) & ((h=hashcode)^h),HashMap由于是直接使用的二进制进行计算,所以速度更快;
  3. 数组的长度计算方式不同,Hashtable支持初始化时指定容量,如果指定了容量,数组的大小就等于指定的容量,默认是11,每次扩容后为(2n+1),所以Hashtable的数组容量是奇数;HashMap也支持初始化时指定容量,但是实际容量为大于等于该容量且最接近的2的n次幂,比如初始化容量为17,实际数组容量为32,并且HashMap每次扩容后的容量为2n。之所以Hashtable与HashMap的数组容量不一样是由于其hash算法造成的,Hashtable是直接计算的key的hashcode,然后对数组长度取模,当数组长度为奇数时,计算出来的数组下标分布的更均匀;而HashMap是用二进制计算的,只有当数组长度为2的幂次方时算法才成立;
  4. hash算法不一样,由第三点可知;
  5. 性能不一样,Hashtable的性能比HashMap要低很多,因为Hashtable的所有方法都加了synchronized,同一时刻只支持一个线程操作;

2、Collections.synchronizedMap

Collections.synchronizedMap与Hashtable的区别跟Collections.synchronizedMap与Vector的区别一样,同样为以下几点:

  1. Hashtable是方法锁,synchronizedMap是代码块锁;
  2. synchronizedMap有很好的扩展和兼容功能,可以将所有的Map的子类转成线程安全的类,而Hashtable是java.util包中的一个类,这也是synchronizedMap与Hashtable在使用场景下的重要区别;

具体的底层区别暂不讨论。

3、ConcurrentHashMap

ConcurrentHashMap是Map最重要的并发集合类,我们重点学习下。

在类注释上可以得知以下信息:

  1. 所有的操作都是线程安全的,在使用时,无需再加锁;
  2. 多个线程同时进行put、remove等操作时并不会阻塞,可以同时进行,与Hashtable不同,Hashtable在进行操作时会锁住整个Map;
  3. 迭代过程中,即使Map结构被修改,也不会抛出ConcurrentModificationException 异常;
  4. 除了数组+链表+红黑树的基本结构外,新增加了转移节点,是为了保证扩容时线程安全的节点;
  5. 提供了很多Stream流式方法,如forEach、search、reduce等;
ConcurrentHashMap与HashMap的区别与联系:

相同点:

  1. 数组、链表结构几乎相同,所以底层对数据结构的操作思路是相同的(只是思路相同,底层实现不同);
  2. 都实现了Map接口,继承了AbstractMap 抽象类,所以大多数的方法也是相同的,HashMap有的方法ConcurrentHashMap几乎都有,所以当需要从HashMap切换到ConcurrentHashMap时,无需关心两者的兼
    容性问题;

不同点:

  1. 红黑树结构略有不同,HashMap的红黑树的节点叫TreeNode,TreeNode不仅仅有属性,还维护着或红黑树的结构,比如查找、新增等;ConcurrentHashMap中红黑树被拆分成了两块,TreeNode仅仅维护着属性和查询功能,新增了TreeBin来维护红黑树结构,并负责红黑树根节点的加锁解锁;
  2. 新增ForwardingNode(转移)节点,扩容时会用到,通过使用该节点,来保证扩容时的线程安全;
Put操作

ConcurrentHashMap在Put方法的整体思路上和HashMap相同,但在线程安全方面写了很多保障性代码,先看下整体思路:

  1. 如果数组为空,初始化,初始化完成之后走2;
  2. 计算槽点的下标,检查槽点有没有值,如果没有值,cas创建节点,失败继续自旋(for死循环),直到成功,槽点有值走3;
  3. 如果槽点是转移节点(正在扩容),就会一直自旋等待扩容完成之后再新增或一起扩容,不是转移节点走4;
  4. 先锁住当前槽点,保证其余线程不能操作,如果是链表,新增值到链表尾部,如果是红黑树,使用红黑树新增的方法新增;
  5. 新增完成之后检查是否需要扩容;

CAS,compare and swap的缩写,中文翻译成比较并交换。CAS 操作包含三个操作数 —— 内存位置(V)、预期原值(A)和新值(B)。 如果内存位置的值与预期原值相匹配,那么处理器会自动将该位置值更新为新值 ;否则,处理器不做任何操作。CAS 有效地说明了“我认为位置 V 应该包含值 A;如果包含该值,则将 B 放到这个位置;否则,不要更改该位置,只告诉我这个位置现在的值即可。”

put操作的源码如下:

final V putVal(K key, V value, boolean onlyIfAbsent) {
    if (key == null || value == null) throw new NullPointerException();
    //计算hash
    int hash = spread(key.hashCode());
    int binCount = 0;
    // for死循环进行自旋
    for (Node<K,V>[] tab = table;;) {
        Node<K,V> f; int n, i, fh;
        //table是空的,进行初始化
        if (tab == null || (n = tab.length) == 0)
            tab = initTable();
        //如果当前索引位置没有值,直接创建
        else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
            //cas 在 i 位置创建新的元素,当 i 位置是空时,即能创建成功,结束for自循,否则继续自旋
            if (casTabAt(tab, i, null,
                         new Node<K,V>(hash, key, value, null)))
                break;                   // no lock when adding to empty bin
        }
        //如果当前槽点是转移节点,表示该槽点正在扩容,就会一直等待扩容或一起扩容
        //转移节点的 hash 值是固定的,都是 MOVED
        else if ((fh = f.hash) == MOVED)
            tab = helpTransfer(tab, f);
        //槽点上有值的
        else {
            V oldVal = null;
            //锁定当前槽点,其余线程不能操作,保证了安全
            synchronized (f) {
                //这里再次判断 i 索引位置的数据没有被修改
                //binCount 被赋值的话,说明走到了修改表的过程里面
                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;
                            }
                        }
                    }
                    //红黑树,这里没有使用 TreeNode,使用的是 TreeBin,TreeNode 只是红黑树的一个节点
                    //TreeBin 持有红黑树的引用,并且会对其加锁,保证其操作的线程安全
                    else if (f instanceof TreeBin) {
                        Node<K,V> p;
                        binCount = 2;
                        //满足if的话,把老的值给oldVal
                        //在putTreeVal方法里面,在给红黑树重新着色旋转的时候
                        //会锁住红黑树的根节点
                        if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key,
                                                       value)) != null) {
                            oldVal = p.val;
                            if (!onlyIfAbsent)
                                p.val = value;
                        }
                    }
                }
            }
            //binCount不为空,并且 oldVal 有值的情况,说明已经新增成功了
            if (binCount != 0) {
                // 链表是否需要转化成红黑树
                if (binCount >= TREEIFY_THRESHOLD)
                    treeifyBin(tab, i);
                if (oldVal != null)
                    return oldVal;
                //这一步几乎走不到。槽点已经上锁,只有在红黑树或者链表新增失败的时候
                //才会走到这里,这两者新增都是自旋的,几乎不会失败
                break;
            }
        }
    }
    //check 容器是否需要扩容,如果需要去扩容,调用 transfer 方法去扩容
    //如果已经在扩容中了,check有无完成
    addCount(1L, binCount);
    return null;
}

ConcurrentHashMap 在 put 过程中,采用了以下手段来保证线程安全:

数组初始化时的线程安全

数组初始化时,首先通过自旋来保证一定可以初始化成功,然后通过CAS设置SIZECTL的值,来保证同一时刻只能有一个线程对数组进行初始化,当CAS成功之后,还会再次判断数组是否已初始化,如果已经初始化,就不会再初始化,通过自旋+CAS+双重校验的方式保证了数组初始化时的线程安全,源码如下:

//初始化 table,通过对 sizeCtl 的变量赋值来保证数组只能被初始化一次
private final Node<K,V>[] initTable() {
    Node<K,V>[] tab; int sc;
    //通过自旋保证初始化成功
    while ((tab = table) == null || tab.length == 0) {
        // 小于 0 代表有线程正在初始化,释放当前 CPU 的调度权,重新发起锁的竞争
        if ((sc = sizeCtl) < 0)
            Thread.yield(); // lost initialization race; just spin
        // 当内存中的SIZECTL的值等于sc的值时,将SIZECTL的值设置为-1
        // CAS 赋值保证当前只有一个线程在初始化,-1 代表当前只有一个线程能初始化
        // 保证了数组的初始化的安全性
        else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
            try {
                // 很有可能执行到这里的时候,table 已经不为空了,这里是双重 check
                if ((tab = table) == null || tab.length == 0) {
                    // 进行初始化
                    int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
                    @SuppressWarnings("unchecked")
                    Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
                    table = tab = nt;
                    sc = n - (n >>> 2);
                }
            } finally {
                // 设置sizeCtl的值为下一次扩容时的阈值,作用相当于HashMap中的threshold
                sizeCtl = sc;
            }
            break;
        }
    }
    return tab;
}

#####新增槽点值时的线程安全

此时为了保证线程安全,做了四处优化:

  1. 通过自旋死循环保证一定可以新增成功;
  2. 当前槽点值如果为空,通过CAS设置槽点值。这里写的非常严谨,没有在判断槽点为空的情况下直接赋值,因为在判断槽点为空和赋值的瞬间,很有可能槽点已被其他线程赋值了,采用CAS的方式,能够保证槽点值为空的情况下赋值成功,如果恰好槽点已被其他线程赋值,当前CAS操作失败,会再次执行for自旋,再走槽点有值的put流程,这里就是使用的自旋+CAS的结合;
  3. 当前槽点有值,锁住当前槽点。put时,如果当前槽点有值,就是key的hash冲突的情况,此时槽点上可能是链表或红黑树,通过锁住当前槽点,保证同一时刻只会有一个线程对该槽点进行修改,使用的是synchronized锁
  4. 如果是红黑树,红黑树旋转时,锁住红黑树的根节点,保证同一时刻,当前红黑树只能被一个线程旋转;
扩容时的线程安全

ConcurrentHashMap 的扩容时机和 HashMap 相同,都是在 put 方法的最后一步检查是否需要扩容,如果需要则进行扩容,但两者扩容的过程完全不同,ConcurrentHashMap 扩容的方法叫做 transfer,从 put 方法的 addCount 方法进去,就能找到 transfer 方法,transfer 方法的主要思路是:

  1. 首先需要把老数组的数据都拷贝到扩容后的新数组上,从数组的队尾开始拷贝;
  2. 拷贝数组的槽点时,会把原数组正在拷贝的槽点锁住,保证原数组正在拷贝的槽点不能操作,成功拷贝的新数组后,把原数组槽点赋值为转移节点;
  3. 这时如果有新数据正好需要put到此槽点,发现槽点为转移节点,就会等待或者一起拷贝没有被锁住且不为转移节点的槽点数据到新数组,直到扩容成功之后,才能继续 put。所以在扩容期间,原数组中为转移节点的槽点是不能被操作的,不为转移节点的槽点是可以put数据的,这里体现出了ConcurrentHashMap使用的是分段锁,而不是直接对整个Map加锁
  4. 从数组的尾部拷贝到头部,每拷贝成功一次,就把原数组的槽点设置成转移节点;
  5. 直到所有数组数据都拷贝到新数组时,直接把新数组整个赋值给数组容器,拷贝完成;

源码如下:

// 扩容主要分 2 步,第一新建新的空数组,第二移动拷贝每个元素到新数组中去
// tab:原数组,nextTab:新数组
private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
    // 老数组的长度
    int n = tab.length, stride;
    if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)
        stride = MIN_TRANSFER_STRIDE; // subdivide range
    // 如果新数组为空,初始化,大小为原数组的两倍,n << 1
    if (nextTab == null) {            // initiating
        try {
            @SuppressWarnings("unchecked")
            Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n << 1];
            nextTab = nt;
        } catch (Throwable ex) {      // try to cope with OOME
            sizeCtl = Integer.MAX_VALUE;
            return;
        }
        nextTable = nextTab;
        transferIndex = n;
    }
    // 新数组的长度
    int nextn = nextTab.length;
    // 代表转移节点,如果原数组上是转移节点,说明该节点正在被扩容
    ForwardingNode<K,V> fwd = new ForwardingNode<K,V>(nextTab);
    boolean advance = true;
    boolean finishing = false; // to ensure sweep before committing nextTab
    // 无限自旋,i 的值会从原数组的最大值开始,慢慢递减到 0
    for (int i = 0, bound = 0;;) {
        Node<K,V> f; int fh;
        while (advance) {
            int nextIndex, nextBound;
            // 结束循环的标志
            if (--i >= bound || finishing)
                advance = false;
            // 已经拷贝完成
            else if ((nextIndex = transferIndex) <= 0) {
                i = -1;
                advance = false;
            }
            // 每次减少 i 的值
            else if (U.compareAndSwapInt
                     (this, TRANSFERINDEX, nextIndex,
                      nextBound = (nextIndex > stride ?
                                   nextIndex - stride : 0))) {
                bound = nextBound;
                i = nextIndex - 1;
                advance = false;
            }
        }
        // if 任意条件满足说明拷贝结束了
        if (i < 0 || i >= n || i + n >= nextn) {
            int sc;
            // 拷贝结束,直接赋值,因为每次拷贝完一个节点,都在原数组上放转移节点,所以拷贝完成的节点的数据一定不会再发生变化。
            // 原数组发现是转移节点,是不会操作的,会一直等待转移节点消失之后在进行操作。
            // 也就是说数组节点一旦被标记为转移节点,是不会再发生任何变动的,所以不会有任何线程安全的问题
            // 所以此处直接赋值,没有任何问题。
            if (finishing) {
                nextTable = null;
                table = nextTab;
                sizeCtl = (n << 1) - (n >>> 1);
                return;
            }
            if (U.compareAndSwapInt(this, SIZECTL, sc = sizeCtl, sc - 1)) {
                if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT)
                    return;
                finishing = advance = true;
                i = n; // recheck before commit
            }
        }
        else if ((f = tabAt(tab, i)) == null)
            advance = casTabAt(tab, i, null, fwd);
        else if ((fh = f.hash) == MOVED)
            advance = true; // already processed
        else {
            synchronized (f) {
                // 进行节点的拷贝
                if (tabAt(tab, i) == f) {
                    Node<K,V> ln, hn;
                    if (fh >= 0) {
                        int runBit = fh & n;
                        Node<K,V> lastRun = f;
                        for (Node<K,V> p = f.next; p != null; p = p.next) {
                            int b = p.hash & n;
                            if (b != runBit) {
                                runBit = b;
                                lastRun = p;
                            }
                        }
                        if (runBit == 0) {
                            ln = lastRun;
                            hn = null;
                        }
                        else {
                            hn = lastRun;
                            ln = null;
                        }
                        // 如果节点只有单个数据,直接拷贝,如果是链表,循环多次组成链表拷贝
                        for (Node<K,V> p = f; p != lastRun; p = p.next) {
                            int ph = p.hash; K pk = p.key; V pv = p.val;
                            if ((ph & n) == 0)
                                ln = new Node<K,V>(ph, pk, pv, ln);
                            else
                                hn = new Node<K,V>(ph, pk, pv, hn);
                        }
                        // 在新数组位置上放置拷贝的值
                        setTabAt(nextTab, i, ln);
                        setTabAt(nextTab, i + n, hn);
                        // 在老数组位置上放上 ForwardingNode 节点
                        // put 时,发现是 ForwardingNode 节点,就不会再动这个节点的数据了
                        setTabAt(tab, i, fwd);
                        advance = true;
                    }
                    // 红黑树的拷贝
                    else if (f instanceof TreeBin) {
                        // 红黑树的拷贝工作,同 HashMap 的内容,代码忽略
                        …………
                        // 在老数组位置上放上 ForwardingNode 节点
                        setTabAt(tab, i, fwd);
                        advance = true;
                    }
                }
            }
        }
    }
}
Get操作

ConcurrentHashMap 读的话,就比较简单,先获取数组的下标,然后通过判断数组下标的 key 是否和我们的 key 相等,相等的话直接返回,如果下标的槽点是链表或红黑树的话,分别调用相应的查找数据的方法,整体思路和 HashMap 很像,源码如下:

public V get(Object key) {
    Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
    //计算hashcode
    int h = spread(key.hashCode());
    //不是空的数组 && 并且当前索引的槽点数据不是空的
    //否则该key对应的值不存在,返回null
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (e = tabAt(tab, (n - 1) & h)) != null) {
        //槽点第一个值和key相等,直接返回
        if ((eh = e.hash) == h) {
            if ((ek = e.key) == key || (ek != null && key.equals(ek)))
                return e.val;
        }
        //如果是红黑树或者转移节点,使用对应的find方法
        else if (eh < 0)
            return (p = e.find(h, key)) != null ? p.val : null;
        //如果是链表,遍历查找
        while ((e = e.next) != null) {
            if (e.hash == h &&
                ((ek = e.key) == key || (ek != null && key.equals(ek))))
                return e.val;
        }
    }
    return null;
}
5 总结
  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值