Java 并发 - 线程安全集合类

269

一、ArrayList

1.1 不安全用例

class ArrayListDemo {
    public static void main(String[] args) {
        List<String> arrayList = new ArrayList<>();
        for (int i = 0; i < 20; i++) {
            new Thread(()->{
                arrayList.add(UUID.randomUUID().toString().substring(0,8));
                System.out.println(arrayList);
            },String.valueOf(i)).start();
        }
    }
}

运行抛出 java.util.ConcurrentModificationException

1.2 导致原因

多线程并发对 arrayList 争夺写的权力,发生错误

1.3 解决方案

  • 使用 vector<>()
  • 使用 Collections.synchronizedList(new ArrayList<>());
  • 使用 CopyOnWriteArrayList<>()

1.4 CopyOnWriteArrayList

1.4.1 读写分离

  • 读不加锁,在原数组进行

  • 写要加锁,使用 ReentrantLock ,加锁后复制一份数组,添加完后,将新数组引用赋给旧的数组

1.4.2 写时复制

写时复制指的是该类型数组在写的时候会复制一份新的数组

源代码如下:

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

1.4.3 比较

对比 ArrayList,在看源代码的时候,发现每次赋值都是以旧长度加一作为新数组的长度,分析源代码可知:

  • 初始化容量

    • ArrayList 初始化一个空数组,写的时候会再扩容为 10 大小的数组,相当于默认大小 10
    • CopyOnWriteArrayList 初始化一个空数组,写的时候扩容为原数组容量加一大小的数组,默认大小为 0
  • 扩容

    • ArrayList 扩容时,扩容为原大小的 1.5 倍,写的时候只有容量不够才扩容
    • CopyOnWriteArrayList 扩容时,扩容为原大小加一,写的时候每次都扩容,容量每次加一
  • 删除

    • ArrayList 删除时,将 index + 1后面的元素都往前移动一个位置,覆盖到 index 位置上
    • CopyOnWriteArrayList 删除时,分别复制 index 前一截和后一截的元素,并将数组新引用赋值给旧的

    发现,删除都很耗费时间,都是花费了 O(n)

  • 适用场景

    • ArrayList 适合单线程的场景,性能比较高
    • CopyOnWriteArrayList 适合多线程条件,也适合读多写少的场景,但注意读的时候不一定是最新数据,也就是说它保证的是数据最终一致性而不是数据实时一致性;内存占用是 ArrayList 两倍,内存比较敏感的场景不适用

二、HashSet

2.1 不安全用例

class HashSetDemo {
    private static Set<String> hashSet = new HashSet<>();
    public static void main(String[] args) {
        for (int i = 0; i < 30; i++) {
            new Thread(() -> {
                hashSet.add(UUID.randomUUID().toString().substring(0, 8));
                System.out.println(hashSet);
            }, String.valueOf(i)).start();
        }
    }
}

运行抛出 java.util.ConcurrentModificationException

2.2 导致原因

多线程并发对 hashSet 争夺写的权力,发生错误

HashSet 底层是 HashMap 实现的,每次 add ,调用 hashmap 的 put ,将值作为 hashmap 键,但 hashmap 的值是一个常量,当前 new 出来的常量,所以 HashMap 不安全,直接调用 hashmap 的方法也直接报错

2.3 解决方案

  • 使用 Collections.synchronizedSet(new ArrayList<>());
  • 使用 CopyOnWriteSet

2.4 CopyOnWriteSet

底层使用 CopyOnWriteArrayList 实现,因此保证了线程安全

三、HashMap

3.1 不安全用例

class HashMapDemo {
    private static Map<String, String> hashMap = new HashMap<>();
    public static void main(String[] args) {
        for (int i = 1; i <= 30; i++) {
            new Thread(() -> {
                String name = Thread.currentThread().getName();
                String val = UUID.randomUUID().toString().substring(0, 8);
                hashMap.put(name, val);
                System.out.println(hashMap);
            }, String.valueOf(i)).start();
        }
    }
}

3.2 导致原因

多线程并发对 hashMap 争夺写的权力,发生错误

3.3 解决方案

  • 使用 Collections.synchronizedMap(new HashMap<>());
  • 使用 ConcurrentHashMap

3.4 ConcurrentHashMap

底层实现和 HashMap 类似,这里主要分析该容器并发原理

3.4.1 并发结构

1. JDK 1.7

  • Segment,分段锁,同时它继承 ReentrantLock,是一种可重入锁,基于数组和链表实现

  • HashEntry,桶,基于链表实现

2. JDK 1.8

  • Node,值的节点,基于链表实现的节点
  • TreeBin,值的节点,基于红黑树实现的节点

3.4.2 并发控制

1. JDK 1.7

ConcurrentHashMap = Segment 数组 + HashEntry 数组

一个 ConcurrentHashMap 里有一个 Segment 数组

Segment 数组里的每一个 Segment 包含一个 HashEntry 数组

HashEntry 数组里的每一个 HashEntry 都是一条链表

并发实现:

每一个 Segment 元素维护一个 HashEntry 数组,每次要对 HashEntry 数组某个元素修改,必须首先获得对应的 Segment 的锁

2. JDK 1.8

ConcurrentHashMap = Node 数组

一个 CocurrenHashMap 里有一个 Node 数组

Node 数组里的每一个 Node 都是一条链表

一条链表长度大于 8 的时候将 Node 转换为 TreeBin,即链表转为红黑树

并发实现:

首先采用 CAS 机制自旋操作,如果 CAS 操作失败的时,就使用 synchronized 加锁

synchronized 只锁定当前链表或红黑树的首节点,所以只要 hash 不冲突,就通过 CAS 方式写进去,就不会加锁