并发编程:线程安全工具类的正确使用与优化

在多线程编程的广袤天地里,并发工具类犹如一把把锋利的双刃剑,运用得当,可大幅提升程序的性能与效率,助力我们在复杂的业务场景中披荆斩棘;但倘若使用不当,不仅无法发挥其优势,反而会埋下诸多隐患,导致程序出现难以排查的错误和性能瓶颈。从线程安全工具类的微妙陷阱,到并发工具类特性的深度挖掘,再到使用场景的精准匹配,每一个环节都至关重要。接下来,让我们一同深入探讨并发工具类在使用过程中的要点与注意事项,为构建稳健、高效的多线程应用筑牢根基 。

严谨使用线程安全的工具类

使用线程安全的工具类并不等同于解决了所有线程安全问题。以ConcurrentHashMap为例,虽然它能够保证原子性的读写操作是线程安全的,但在某些场景下,仍然可能出现线程安全问题。

场景示例:
假设有一个包含800个元素的Map,现在需要再添加200个元素,这个添加操作由10个线程并发执行,每次线程的逻辑如下:

使用了ConcurrenntHashMap,先通过size方法获取到当前元素数量,计算出还需要添加多少个元素,然后通过putAll方法把缺少的元素添加进去。

在这种情况下,最终添加的元素数量可能会远大于200个。这是因为多个线程在读取map.size()时可能会同时得到相同的值,从而导致多个线程都认为还有空间添加元素。

因此

  • 使用了ConcurrenntHashMap,不代表对它的多个操作之间的状态是一致的,此时是没有其他线程操作它的,需要手动加锁。
  • 例如size,isEmpty,containsValue等聚合方法,在并发情况下可能会反映ConcurrenntHashMap的中间状态,因此在并发情况下,这些方法的值只能作为参考值,而不能用于流程控制。
  • putAll这样的聚合方法也不能确保原子性,在putAll的过程中获取数据也可能会获取到部分数据。

充分了解并发工具类的特性

还是拿ConcurrenntHashMap举例,假如现有如下逻辑:

  • 使用ConcurrenntHashMap来统计,key的大小范围是10。
  • 使用最多10个并发,循环操作1000万次,每次操作累加随机的key。
  • 如果key不存在,设置首次值为1.

并有如下代码:

public class Main {
    private static int LOOP_COUNT = 1000000;
    private static int THREAD_COUNT = 10;
    private static int ITEN_COUNT = 10;
    public static void main(String[] args) throws InterruptedException {
        ConcurrentHashMap map = new ConcurrentHashMap<>(ITEN_COUNT);
        ForkJoinPool forkJoinPool = new ForkJoinPool(THREAD_COUNT);
        forkJoinPool.execute(() -> IntStream.rangeClosed(1, LOOP_COUNT).parallel().forEach(i -> {
            String key = "item" + ThreadLocalRandom.current().nextInt(ITEN_COUNT);
            synchronized (map) {
                if (map.contains(key)) {
                    map.put(key, map.get(key) + 1);
                } else {
                    map.put(key, 1L);
                }
            }
        }));
        forkJoinPool.shutdown();
        forkJoinPool.awaitTermination(1, TimeUnit.HOURS);
    }
}

这样的作法实现的效果显然是没有问题,但是这并没有完全发挥ConcurrenntHashMap的全部威力,性能不佳。

为此我们可以做出如下优化:

public class Main {
    private static int LOOP_COUNT = 1000000;
    private static int THREAD_COUNT = 10;
    private static int ITEN_COUNT = 10;
    public static void main(String[] args) throws InterruptedException {
        ConcurrentHashMap map = new ConcurrentHashMap<>(ITEN_COUNT);
        ForkJoinPool forkJoinPool = new ForkJoinPool(THREAD_COUNT);
        forkJoinPool.execute(() -> IntStream.rangeClosed(1, LOOP_COUNT).parallel().forEach(i -> {
            String key = "item" + ThreadLocalRandom.current().nextInt(ITEN_COUNT);
            map.computeIfAbsent(key, k -> new LongAdder()).increment();
        }));
        forkJoinPool.shutdown();
        forkJoinPool.awaitTermination(1, TimeUnit.HOURS);
    }
}

在ConcurrenntHashMap中,使用computelfAbsent来做复合逻辑操作,判断key是否存在value,如果不存在则把Lambda表达式运行的结果放入map作为value,也就是创建了一个LongAdder对象,最后返回Value。

computelfAbsent方法返回的value是LongAdder,是一个线程安全的累加器,因此可以直接调用increment方法进行累加。

这样在确保线程安全的情况下,把性能也发挥到了极致,还减少了代码行数,唯一的额外操作就是还需要把LongAdder转化为Long类型。

认清并发工具的使用场景

例如现在需要来缓存大量的数据,并且数据变化比较频繁。

而你使用了CopyOnWriteArrayList来实现这个操作,这样就会造成严重的性能浪费问题。

CopyOnWriteArrayList的核心实现原理是写时复制(Copy-On-Write) 。当执行添加、删除、修改等写操作时,它并不会直接在原数组上进行修改,而是先复制出一个新的数组,然后在新数组上完成相应的操作,最后将原数组的引用指向新数组。这种机制在一定程度上保证了读操作的高效性和线程安全性,因为读操作始终是在原数组上进行,无需加锁,避免了锁竞争带来的性能开销。

然而,在数据频繁变化的场景下,这种实现方式的弊端就会暴露无遗。每次写操作都要复制整个数组,这意味着大量的内存资源会被消耗在数组的复制上。随着写操作次数的增加,内存的占用会急剧上升,甚至可能导致内存溢出错误。

此外,频繁的数组复制操作还会占用大量的 CPU 时间,使得系统的整体性能大幅下降。比如,在一个实时数据处理系统中,需要不断地更新缓存中的数据以反映最新的业务状态。如果使用CopyOnWriteArrayList作为缓存容器,随着数据更新频率的增加,系统的响应速度会越来越慢,无法满足实时性的要求。

因此,CopyOnWriteArrayList显然并不适合数据频繁变动的场景。相反,它更适合应用于读操作远远多于写操作,或者期望在无锁状态下进行高效读操作的场景中。例如,在一些日志记录系统中,日志数据通常是被频繁读取用于分析和查询,而写入操作相对较少,此时使用CopyOnWriteArrayList就能够充分发挥其读操作高效的优势。又比如,在一些配置信息的缓存场景中,配置信息在系统运行过程中很少发生变化,但会被多个线程频繁读取,CopyOnWriteArrayList也能很好地满足这种需求。

综上所述,在使用并发工具类时,我们必须深入了解其底层的实现原理,只有这样,才能在众多的并发工具中做出正确的选择,将其应用于最适合的业务场景,从而实现高效、稳定的多线程编程。

你可能感兴趣的:(并发编程,java,并发编程)