Java 并发 - 线程安全容器的能力

1062

一、前言

今天想到一个有意思的事儿:互联网关于 Java Map 容器有无数讨论,其中 99.9% 的文章在讨论其源码以及设计,但让这些人写出一段代码证明 HashMap 为何线程不安全是不是就当场愣住了?

本文我们讨论一个问题:HashMap 在 Jdk 1.8 描述为线程不安全,ConcurrentHashMap 描述为线程安全,如何证明?

二、线程安全容器的能力

2.1 不具备的能力

线程不安全问题是一个大范畴问题,原因可能多种,我们日常开发中一定要判断好导致「线程不安全」的具体原因是什么,使用容器一定要心里有底,了解容器具备的能力和不具备的能力是哪些

举个例子,以下代码就会发生线程不安全问题,此时不管你用的是 ConcurrentHashMap、HashMap 还是 HashTable 都是一样的,都会有并发问题,因为这里的问题根本原因在于临界资源被多个线程并发读写,没做并发控制

这里的临界资源就是 key 为 1 的资源,在读和写的时候并发进行,要解决这个问题需要对临界资源的操作进行乐观锁或者悲观锁的手段控制。例如,常见的悲观锁可以使用可重入锁 & Condition,信号量(Semaphore) 等

@RepeatedTest(100)
void run4HashMap() {
    Map<Integer, Integer> map = new ConcurrentHashMap<>(2);
    List<Integer> results = new ArrayList<>(2);
    ExecutorService executors = new ThreadPoolExecutor(
            2,
            2,
            1L, TimeUnit.MINUTES,
            new LinkedBlockingQueue<>(2)
    );

    executors.execute(() -> map.put(1, 1));
    executors.execute(() -> results.add(map.get(1)));
    executors.execute(() -> map.put(1, 2));
    executors.execute(() -> results.add(map.get(1)));
    executors.shutdown();
    int sum = results.stream().mapToInt(i -> i).sum();
    if (sum != 3) {
        System.out.println(sum);
    }
    Assertions.assertEquals(sum, 3);
}

从上可知,线程安全的容器不具备解决业务代码临界资源竞争问题的能力!

2.2 具备的能力

从 2.1 节我们了解到线程不安全的原因是我们没有对临界资源做好并发控制,OK,我们保证不会有临界资源竞争问题再继续

如下代码,十个线程,每个线程往一个 Map 中放 1000 对 KV,总共 10 * 1000 对 KV,它们的 K 都是不相等的,也就是说这个过程中 10 个线程在并发地写不同的临界资源,因此从业务逻辑上来看,最后 Map 预期是具有 10 * 1000 个元素

单元测试跑了 1000 次,发现只有 1 个用例通过,999 个都不满足断言

@RepeatedTest(1000)
void run4HashMap() throws InterruptedException {
    Map<String, String> map = new HashMap<>();
    CountDownLatch latch = new CountDownLatch(10 * 1000);
    for (int i = 0; i < 10; i++) {
        new Thread() {
            @Override
            public void run() {
                for (int j = 0; j < 1000; j++) {
                    map.put(this.hashCode() + j + "", this.hashCode() + j + "");
                    latch.countDown();
                }
            }
        }.start();
    }

    // 等待 10 个线程执行完
    latch.await(5, TimeUnit.SECONDS);
    Assertions.assertEquals(map.size(), 10 * 1000);
}

这正是令人费解的地方,从业务逻辑上来看,应该没有并发问题啊?的确,业务应用层上来看没有什么问题,这里问题的根本原因在于 HashMap 的源码层在实现时没有做并发控制

详细分析,这里参考掘金一个老哥的描述:

HashMap 在多线程 put() 的时候,当产生 hash 碰撞的时候,会导致丢失数据。因为要put() 的两个值 hash 相同,如果这个值对于 hash 桶的位置个数小于 8,那么应该是以链表的形式存储,由于没有做并发控制,后面 put() 的元素可能会直接覆盖之前那个线程 put() 的数据,这样就导致了数据丢失(参考作者:JasonGaoH;链接:https://juejin.cn/post/6844903951385493518)

虽然业务逻辑代码做了并发控制,但是 HashMap 的代码逻辑没有做并发控制,因此最后还是导致出现了并发问题,你可以决定自己的代码,但是你无法决定 HashMap 的源码怎么做

那怎么办?换一个容器!HashTable,Collections.synchronizedMap(),ConcurrentHashMap 都是解决方案,换上后再跑一 1000 次,用例全部通过

你做好并发控制,容器自己的实现也做好并发控制,最后就不会出现并发问题了

从上可知,线程安全的容器具备在并发场景并且业务代码无临界资源竞争问题的前提下,保证数据读写一致性的能力

三、总结

从 2.1 和 2.2 的结论,我们可以知道线程安全的容器能力是有限的,并发场景下一定要使用线程安全的容器,另一方面无论是哪种容器,业务代码层的临界资源竞争问题是躲不掉的!