JUC 并发容器

798 阅读11分钟

Java 容器在我们平常项目中使用实在是太频繁了,比如 ArrayList、HashMap、HashSet 这三个,但有一点需要我们注意,这三个容器在并发环境下是线程不安全的。

所以我们在并发环境下如果要使用容器的话,java.util.concurrent 包下为我们提供了一些线程安全的容器,并且还能在保证线程安全的条件下性能也不差。如 ConcurrentHashMap、CopyOnWriteArrayList、CopyOnWriteArraySet 等。

1、HahMap 线程不安全

解决方法:
(1)使用 Hashtable 替代 HashMap
(2)Collections.synchronizedMap()
(3)使用 ConcurrentHashMap 代替 HashMap(推荐使用)

数据丢失问题

先看源码:

image-20220211142330569

假如两个线程同时进入 if ((p = tab[i = (n - 1) & hash]) == null) 这一行,假设对应的位置为 null

这也就意味着两个线程可以同时进入 if 包含的代码块(线程上下文切换),恰好两个 put 的位置是一样的(如 key 相等或者哈希冲突,哈希冲突正常情况下应该用拉链法解决)。结果就是后一个线程的数据会覆盖前一个线程的数据。

也就是数据丢失问题

可见性问题

可见性:当一个线程操作这个容器的时候,该操作需要对另外的线程都可见,也就是其他线程都能感知到本次操作。

HashMap 是无法做到的。

死链问题

JDK 1.7 的时候是采用链表头插法的,这种情况下扩容的时候会造成链表死循环。

JDK 1.7 源码如下:

void transfer(Entry[] newTable, boolean rehash) {
    int newCapacity = newTable.length;
    for (Entry<K,V> e : table) {
        while(null != e) {
            Entry<K,V> next = e.next;
            if (rehash) {
                e.hash = null == e.key ? 0 : hash(e.key);
            }
            int i = indexFor(e.hash, newCapacity);
            e.next = newTable[i];
            newTable[i] = e;
            e = next;
        }
    }
}
  • e 和 next 都是局部变量,用来指向当前节点和下一个节点
  • 线程 1(绿色)的临时变量 e 和 next 刚引用了这俩节点,还未来得及移动节点,发生了线程切换,由线程 2(蓝色)完成扩容和迁移

image-20211011212510270

线程 2 扩容完成,由于头插法,链表顺序颠倒。但线程 1 的临时变量 e 和 next 还引用了这俩节点,还要再来一遍迁移

image-20211011212535708

第一次循环

  • 循环接着线程切换前运行,注意此时 e 指向的是节点 a,next 指向的是节点 b
  • e 头插 a 节点,注意图中画了两份 a 节点,但事实上只有一个(为了不让箭头特别乱画了两份)
  • 当循环结束是 e 会指向 next 也就是 b 节点

image-20211011212550607

第二次循环

  • next 指向了节点 a
  • e 头插节点 b
  • 当循环结束时,e 指向 next 也就是节点 a

image-20211011212610592

第三次循环

  • next 指向了 null
  • e 头插节点 a,a 的 next 指向了 b(之前 a.next 一直是 null),b 的 next 指向 a,死链已成
  • 当循环结束时,e 指向 next 也就是 null,因此第四次循环时会正常退出

image-20211011212628152

2、ConcurrentHashMap 分析

面试题:ConcurrentHashMapJDK 1.7JDK 1.8 中的结构分别是什么?它们有什么相同点和不同点?

JDK 1.7

整体结构图:

image-20220211145255706

可以看出,在 ConcurrentHashMap 内部进行了 Segment 分段,Segment 继承了 ReentrantLock,可以理解为一把锁,各个 Segment 之间都是相互独立上锁的,互不影响。

相比于 Hashtable 每次操作都需要把整个对象锁住而言,大大提高了并发效率。因为它的锁与锁之间是独立的,而不是整个对象只有一把锁。

每个 Segment 的底层数据结构与 HashMap 类似,仍然是数组和链表组成的拉链法结构。默认有 16 个 Segment,所以最多可以同时支持 16 个线程并发操作(操作分别分布在不同的 Segment 上)。16 这个默认值可以在初始化的时候设置为其他值,但是一旦确认初始化以后,是不可以扩容的。

JDK 1.8

整体结构图:

image-20220211145704580

图中的节点有三种类型:

  • 第一种是最简单的,空着的位置代表当前还没有元素来填充。
  • 第二种就是和 HashMap 非常类似的拉链法结构,在每一个槽中会首先填入第一个节点,但是后续如果计算出相同的 Hash 值,就用链表的形式往后进行延伸。
  • 第三种结构就是红黑树结构,这是 JDK 1.7ConcurrentHashMap 中所没有的结构,了解过 HashMap 的话应该知道这个。

当链表长度大于 8,数组长度 >= 64 时,链表会转化为红黑树。

红黑树特点

红黑树是每个节点都带有颜色属性的二叉查找树,颜色为红色或黑色,红黑树的本质是对二叉查找树 BST 的一种平衡策略,我们可以理解为是一种平衡二叉查找树,查找效率高,会自动平衡,防止极端不平衡从而影响查找效率的情况发生。

查找时间复杂度为:O(logN),因为如果链表在某些极端情况下太长的话,查找时间复杂度为 O(N),这也就是引入红黑树的原因,主要是为了性能。

节点颜色规定:

  • 每个节点要么是红色,要么是黑色,但根节点永远是黑色的。
  • 红色节点不能连续,也就是说,红色节点的子和父都不能是红色的。
  • 从任一节点到其每个叶子节点的路径都包含相同数量的黑色节点。

JDK 1.8 源码分析

Node 节点

(1)ConcurrentHashMapNode 节点源码:

static class Node<K,V> implements Map.Entry<K,V> {
    final int hash;
    final K key;
    volatile V val;
    volatile Node<K,V> next;
}

(2)HashMapNode 节点源码:

static class Node<K,V> implements Map.Entry<K,V> {
    final int hash;
    final K key;
    V value;
    Node<K,V> next;
}

很明显,在 valuenext 属性上加了 volatile 关键字。为了保证可见性

put 方法

源码如下:

// 实际上是调用了 putVal 方法
public V put(K key, V value) {
    return putVal(key, value, false);
}

// 真正的添加元素逻辑实现
final V putVal(K key, V value, boolean onlyIfAbsent) {
    if (key == null || value == null) throw new NullPointerException();
    // 计算 hash 值,方法源码写在下面了,和 HashMap 有点不一样
    int hash = spread(key.hashCode());
    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;                   // no lock when adding to empty bin
        }
        // hash 值等于 MOVED,代表在扩容
        else if ((fh = f.hash) == MOVED)
            tab = helpTransfer(tab, f);
        // 对应哈希桶下标有值的情况
        else {
            V oldVal = null;
            // 用 synchronized 锁住当前位置,保证并发安全,synchronized 在 JDK 1.6 后做了优化
            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;
                        // 调用 putTreeVal 方法往红黑树里增加数据
                        if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key,
                                                       value)) != null) {
                            oldVal = p.val;
                            if (!onlyIfAbsent)
                                p.val = value;
                        }
                    }
                }
            }
            if (binCount != 0) {
                // 检查是否满足条件并把链表转换为红黑树的形式,默认的 TREEIFY_THRESHOLD 阈值是 8
                if (binCount >= TREEIFY_THRESHOLD)
                    treeifyBin(tab, i);
                // putVal 的返回是添加前的旧值,所以返回 oldVal
                if (oldVal != null)
                    return oldVal;
                break;
            }
        }
    }
    addCount(1L, binCount);
    return null;
}

static final int spread(int h) {
    return (h ^ (h >>> 16)) & HASH_BITS;
}

static final <K,V> boolean casTabAt(Node<K,V>[] tab, int i,
                                    Node<K,V> c, Node<K,V> v) {
    return U.compareAndSwapObject(tab, ((long)i << ASHIFT) + ABASE, c, v);
}

get 方法

public V get(Object key) {
    Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
    int h = spread(key.hashCode());
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (e = tabAt(tab, (n - 1) & h)) != null) {
        if ((eh = e.hash) == h) {
            if ((ek = e.key) == key || (ek != null && key.equals(ek)))
                return e.val;
        }
        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;
}

JDK 1.7 和 JDK 1.8 对比

数据结构

JDK 1.7Segment 分段锁

JDK 1.8:数组 + 链表 + 红黑树

并发度

JDK 1.7:每个 Segment 独立加锁,最大并发个数就是 Segment 的个数,默认是 16。

JDK 1.8:锁粒度更细,理想情况下 table 数组元素的个数(也就是数组长度)就是其支持并发的最大个数,并发度比之前有提高。

保证并发安全

JDK 1.7:采用 Segment 分段锁来保证安全,而 Segment 是继承自 ReentrantLock

JDK 1.8:采用 Node + CAS + synchronized 保证线程安全。

遇到 Hash 碰撞

JDK 1.7:在 Hash 冲突时,会使用拉链法,也就是链表的形式。

JDK 1.8:先使用拉链法,当链表长度大于 8,数组长度 >= 64 时,链表会转化为红黑树,来提高查找效率。

查询时间复杂度

JDK 1.7:遍历链表的时间复杂度是 O(N),n 为链表长度。

JDK 1.8:如果变成遍历红黑树,那么时间复杂度降低为 O(logN),n 为树的节点个数。

与 Hashtable 的区别

实现线程安全的方式不同

Hashtable 是使用 synchronized 来保证线程安全的。

ConcurrentHashMap 是使用 CAS + synchronized + volatile 来保证线程安全的。

性能不同

Hashtable 把整个对象给锁了,而 ConcurrentHashMap 只锁单个节点,所以并发性能比 Hashtable 好。

迭代时修改的不同

Hashtable(包括 HashMap)不允许在迭代期间修改内容,否则会抛出 ConcurrentModificationException 异常,其原理是检测 modCount 变量,Hashtablenext() 方法源码如下:

public T next() {
    if (modCount != expectedModCount)
        throw new ConcurrentModificationException();
    return nextElement();
}

ConcurrentHashMap 即便在迭代期间修改内容,也不会抛出 ConcurrentModificationException

3、CopyOnWriteArrayList 分析

JDK 1.5 开始,Java 并发包里提供了使用 CopyOnWrite 机制实现的并发容器 CopyOnWriteArrayList 作为主要的并发 ListCopyOnWrite 的并发集合还包括 CopyOnWriteArraySet,其底层正是利用 CopyOnWriteArrayList 实现的。

核心就是读写分离

适用场景

  • 读操作可以尽可能的快,而写即使慢一些也没关系

比如,有些系统级别的信息,往往只需要加载或者修改很少的次数,但是会被系统内所有模块频繁的访问。

  • 读多写少

读写规则

  • 读写锁的规则

读写锁的思想是:读读共享、其他都互斥(写写互斥、读写互斥、写读互斥)。

  • 对读写锁规则的升级

CopyOnWriteArrayList 的思想比读写锁的思想又更进一步。为了将读取的性能发挥到极致,CopyOnWriteArrayList 读取是完全不用加锁的,更厉害的是,写入也不会阻塞读取操作,也就是说你可以在写入的同时进行读取,只有写入和写入之间需要进行同步,也就是不允许多个写入同时发生,但是在写入发生时允许读取同时发生。这样一来,读操作的性能就会大幅度提升。

特点

  • CopyOnWrite的含义

CopyOnWrite 的意思是说,当容器需要被修改的时候,不直接修改当前容器,而是先将当前容器进行 Copy,复制出一个新的容器,然后修改新的容器,完成修改之后,再将原容器的引用指向新的容器。这样就完成了整个修改过程。

CopyOnWriteArrayList 利用了不变性原理,因为容器每次修改都是创建新副本,所以对于旧容器来说,其实是不可变的,也是线程安全的,无需进一步的同步操作。所以可以对 CopyOnWrite 容器进行并发的读,而不需要加锁,因为当前容器不会添加任何元素,也不会有修改。

核心思想就是读写分离

  • 迭代期间允许修改集合内容

ArrayList 在迭代期间如果修改集合的内容,会抛出 ConcurrentModificationException 异常。根据 modCount 的值是否改变来判断。

CopyOnWriteArrayList 的迭代器一旦被建立之后,如果往之前的 CopyOnWriteArrayList 对象中去新增元素,在迭代器中既不会显示出元素的变更情况,同时也不会报错。

缺点

  • 内存占用问题

因为 CopyOnWrite 的写时复制机制,所以在进行写操作的时候,内存里会同时驻扎两个对象的内存,这一点会占用额外的内存空间。

  • 在元素较多或者复杂的情况下,复制的开销很大

复制过程不仅会占用双倍内存,还需要消耗 CPU 等资源,会降低整体性能。

  • 数据一致性问题

由于 CopyOnWrite 容器的修改是先修改副本,所以这次修改对于其他线程来说,并不是实时能看到的,只有在修改完之后才能体现出来。如果你希望写入的的数据马上能被其他线程看到,CopyOnWrite 容器是不适用的。

源码分析

数据结构

// 非公平锁
final transient ReentrantLock lock = new ReentrantLock();
// 底层就是一个数组,加了 volatile 关键字来保证可见性
private transient volatile Object[] array;

// 获取数组
final Object[] getArray() {
    return array;
}

// 设置数组
final void setArray(Object[] a) {
    array = a;
}

// 空构造方法
public CopyOnWriteArrayList() {
    setArray(new Object[0]);
}

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;
        // 设置新数组为 array 的引用
        setArray(newElements);
        return true;
    } finally {
        lock.unlock();
    }
}

CopyOnWrite 的思想:写操作是在原来容器的拷贝上进行的,并且在读取数据的时候不会锁住 list。而且可以看到,如果对容器拷贝操作·的过程中有新的读线程进来,那么读到的还是旧的数据,因为在那个时候对象的引用还没有被更改。

get 方法

private E get(Object[] a, int index) {
    return (E) a[index];
}

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

可以发现,get 操作并没有上锁。

迭代器 COWIterator 类

private COWIterator(Object[] elements, int initialCursor) {
    // 迭代器的游标
    cursor = initialCursor;
    // 数组的快照
    snapshot = elements;
}

之后的迭代器所有的操作都基于 snapshot 数组进行的,比如:

public E next() {
    if (! hasNext())
        throw new NoSuchElementException();
    return (E) snapshot[cursor++];
}