业务开发问题之ConcurrentHashMap

JDK 1.5 后推出的 ConcurrentHashMap,是一个高性能的线程安全的哈希表容器。“线程安全”这四个字特别容易让人误解,因为 ConcurrentHashMap 只能保证提供的原子性读写操作是线程安全的。

在相当多的业务代码中看到过这个误区,比如下面这个场景。有一个含 900 个元素的 Map,现在再补充 100 个元素进去,这个补充操作由 10 个线程并发进行。开发人员误以为使用了 ConcurrentHashMap 就不会有线程安全问题,于是不加思索地写出了下面的代码:在每一个线程的代码逻辑中先通过 size 方法拿到当前元素数量,计算 ConcurrentHashMap 目前还需要补充多少元素,并在日志中输出了这个值,然后通过 putAll 方法把缺少的元素添加进去。

为方便观察问题,我们输出了这个 Map 一开始和最后的元素个数。

//线程个数
private static int THREAD_COUNT = 10;
//总元素数量
private static int ITEM_COUNT = 1000;
//帮助方法,用来获得一个指定元素数量模拟数据的
ConcurrentHashMapprivate ConcurrentHashMap<String, Long> getData(int count) {
    return LongStream.rangeClosed(1, count)
            .boxed()
            .collect(Collectors.toConcurrentMap(i -> UUID.randomUUID().toString(), Function.identity(),
                    (o1, o2) -> o1, ConcurrentHashMap::new));}@GetMapping("wrong")public String wrong() throws InterruptedException {
    ConcurrentHashMap<String, Long> concurrentHashMap = getData(ITEM_COUNT - 100);
    //初始900个元素
    log.info("init size:{}", concurrentHashMap.size());
    ForkJoinPool forkJoinPool = new ForkJoinPool(THREAD_COUNT);
    //使用线程池并发处理逻辑
    forkJoinPool.execute(() -> IntStream.rangeClosed(1, 10).parallel().forEach(i -> {
        //查询还需要补充多少个元素
        int gap = ITEM_COUNT - concurrentHashMap.size();
        log.info("gap size:{}", gap);
        //补充元素
        concurrentHashMap.putAll(getData(gap));
    }));
    //等待所有任务完成
    forkJoinPool.shutdown();
    forkJoinPool.awaitTermination(1, TimeUnit.HOURS);
    //最后元素个数会是1000吗?
    log.info("finish size:{}", concurrentHashMap.size());
    return "OK";
}

访问接口后程序输出的日志内容如下:

从日志中可以看到:

1、初始大小 900 符合预期,还需要填充 100 个元素。

2、worker1 线程查询到当前需要填充的元素为 36,竟然还不是 100 的倍数。

3、worker13 线程查询到需要填充的元素数是负的,显然已经过度填充了。

4、最后 HashMap 的总项目数是 1536,显然不符合填充满 1000 的预期。

针对这个场景,我们可以举一个形象的例子。ConcurrentHashMap 就像是一个大篮子,现在这个篮子里有 900 个桔子,我们期望把这个篮子装满 1000 个桔子,也就是再装 100 个桔子。有 10 个工人来干这件事儿,大家先后到岗后会计算还需要补多少个桔子进去,最后把桔子装入篮子。

ConcurrentHashMap 这个篮子本身,可以确保多个工人在装东西进去时,不会相互影响干扰,但无法确保工人 A 看到还需要装 100 个桔子但是还未装的时候,工人 B 就看不到篮子中的桔子数量。更值得注意的是,你往这个篮子装 100 个桔子的操作不是原子性的,在别人看来可能会有一个瞬间篮子里有 964 个桔子,还需要补 36 个桔子。

回到 ConcurrentHashMap,我们需要注意 ConcurrentHashMap 对外提供的方法或能力的限制:

1、使用了 ConcurrentHashMap,不代表对它的多个操作之间的状态是一致的,是没有其他线程在操作它的,如果需要确保需要手动加锁。

2、诸如 size、isEmpty 和 containsValue 等聚合方法,在并发情况下可能会反映 ConcurrentHashMap 的中间状态。因此在并发情况下,这些方法的返回值只能用作参考,而不能用于流程控制。显然,利用 size 方法计算差异值,是一个流程控制。

3、诸如 putAll 这样的聚合方法也不能确保原子性,在 putAll 的过程中去获取数据可能会获取到部分数据。

代码的修改方案很简单,整段逻辑加锁即可:

@GetMapping("right")
public String right() throws InterruptedException {
    ConcurrentHashMap<String, Long> concurrentHashMap = getData(ITEM_COUNT - 100);
    log.info("init size:{}", concurrentHashMap.size());
    ForkJoinPool forkJoinPool = new ForkJoinPool(THREAD_COUNT);
    forkJoinPool.execute(() -> IntStream.rangeClosed(1, 10).parallel().forEach(i -> {
        //下面的这段复合逻辑需要锁一下这个ConcurrentHashMap
        synchronized (concurrentHashMap) { 
           int gap = ITEM_COUNT - concurrentHashMap.size();
            log.info("gap size:{}", gap);
            concurrentHashMap.putAll(getData(gap));
        }    }));
    forkJoinPool.shutdown(); 
   forkJoinPool.awaitTermination(1, TimeUnit.HOURS);
    log.info("finish size:{}", concurrentHashMap.size()); 
   return "OK";
}

 

重新调用接口,程序的日志输出结果符合预期:

可以看到,只有一个线程查询到了需要补 100 个元素,其他 9 个线程查询到不需要补元素,最后 Map 大小为 1000。到了这里,你可能又要问了,使用 ConcurrentHashMap 全程加锁,还不如使用普通的 HashMap 呢。其实不完全是这样。

ConcurrentHashMap 提供了一些原子性的简单复合逻辑方法,用好这些方法就可以发挥其威力。这就引申出代码中常见的另一个问题:在使用一些类库提供的高级工具类时,开发人员可能还是按照旧的方式去使用这些新类,因为没有使用其特性,所以无法发挥其威力。

我们来看一个使用 Map 来统计 Key 出现次数的场景吧,这个逻辑在业务代码中非常常见。

1、使用 ConcurrentHashMap 来统计,Key 的范围是 10。

2、使用最多 10 个并发,循环操作 1000 万次,每次操作累加随机的 Key。

3、如果 Key 不存在的话,首次设置值为 1。

代码如下:

 

//循环次数
private static int LOOP_COUNT = 10000000;
//线程数量
private static int THREAD_COUNT = 10;
//元素数量
private static int ITEM_COUNT = 1000;
private Map<String, Long> normaluse() throws InterruptedException {
    ConcurrentHashMap<String, Long> freqs = new ConcurrentHashMap<>(ITEM_COUNT);
    ForkJoinPool forkJoinPool = new ForkJoinPool(THREAD_COUNT);
    forkJoinPool.execute(() -> IntStream.rangeClosed(1, LOOP_COUNT).parallel().forEach(i -> {
        //获得一个随机的Key
        String key = "item" + ThreadLocalRandom.current().nextInt(ITEM_COUNT); 
               synchronized (freqs) {
                          if (freqs.containsKey(key)) {
                        //Key存在则+1
                        freqs.put(key, freqs.get(key) + 1);
                    } else {
                        //Key不存在则初始化为1
                        freqs.put(key, 1L);
                    }
                }
            }
    ));
    forkJoinPool.shutdown();
    forkJoinPool.awaitTermination(1, TimeUnit.HOURS);
    return freqs;
}

我们吸取之前的教训,直接通过锁的方式锁住 Map,然后做判断、读取现在的累计值、加 1、保存累加后值的逻辑。这段代码在功能上没有问题,但无法充分发挥 ConcurrentHashMap 的威力,改进后的代码如下:

private Map<String, Long> gooduse() throws InterruptedException {
    ConcurrentHashMap<String, LongAdder> freqs = new ConcurrentHashMap<>(ITEM_COUNT);
    ForkJoinPool forkJoinPool = new ForkJoinPool(THREAD_COUNT);
    forkJoinPool.execute(() -> IntStream.rangeClosed(1, LOOP_COUNT).parallel().forEach(i -> {
        String key = "item" + ThreadLocalRandom.current().nextInt(ITEM_COUNT);
                //利用computeIfAbsent()方法来实例化LongAdder,然后利用LongAdder来进行线程安全计数
                freqs.computeIfAbsent(key, k -> new LongAdder()).increment(); 
           }    ));
    forkJoinPool.shutdown();
    forkJoinPool.awaitTermination(1, TimeUnit.HOURS);
    //因为我们的Value是LongAdder而不是Long,所以需要做一次转换才能返回
    return freqs.entrySet().stream()
            .collect(Collectors.toMap(
                    e -> e.getKey(),
                    e -> e.getValue().longValue())
            );
}

在这段改进后的代码中,我们巧妙利用了下面两点:

1、使用 ConcurrentHashMap 的原子性方法 computeIfAbsent 来做复合逻辑操作,判断 Key 是否存在 Value,如果不存在则把 Lambda 表达式运行后的结果放入 Map 作为 Value,也就是新创建一个 LongAdder 对象,最后返回 Value。

2、由于 computeIfAbsent 方法返回的 Value 是 LongAdder,是一个线程安全的累加器,因此可以直接调用其 increment 方法进行累加。

这样在确保线程安全的情况下达到极致性能,把之前 7 行代码替换为了 1 行。

我们通过一个简单的测试比较一下修改前后两段代码的性能:

@GetMapping("good")
public String good() throws InterruptedException {
    StopWatch stopWatch = new StopWatch();
    stopWatch.start("normaluse");
    Map<String, Long> normaluse = normaluse();
    stopWatch.stop();
    //校验元素数量
    Assert.isTrue(normaluse.size() == ITEM_COUNT, "normaluse size error");
    //校验累计总数
        Assert.isTrue(normaluse.entrySet().stream()
                    .mapToLong(item -> item.getValue()).reduce(0, Long::sum) == LOOP_COUNT
            , "normaluse count error");
    stopWatch.start("gooduse");
    Map<String, Long> gooduse = gooduse();
    stopWatch.stop();
    Assert.isTrue(gooduse.size() == ITEM_COUNT, "gooduse size error");
    Assert.isTrue(gooduse.entrySet().stream()
                    .mapToLong(item -> item.getValue())
                    .reduce(0, Long::sum) == LOOP_COUNT
            , "gooduse count error");
    log.info(stopWatch.prettyPrint());
    return "OK";
}

这段测试代码并无特殊之处,使用 StopWatch 来测试两段代码的性能,最后跟了一个断言判断 Map 中元素的个数以及所有 Value 的和,是否符合预期来校验代码的正确性。测试结果如下:

可以看到,优化后的代码,相比使用锁来操作 ConcurrentHashMap 的方式,性能提升了 10 倍。你可能会问,computeIfAbsent 为什么如此高效呢?答案就在源码最核心的部分,也就是 Java 自带的 Unsafe 实现的 CAS。它在虚拟机层面确保了写入数据的原子性,比加锁的效率高得多: 

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

像 ConcurrentHashMap 这样的高级并发工具的确提供了一些高级 API,只有充分了解其特性才能最大化其威力,而不能因为其足够高级、酷炫盲目使用。

利涉の博客

利涉の博客

<think>好的,我现在需要帮助用户找到关于Java业务开发的手册或指南,特别是最佳实践方面的内容。用户已经提供了两个引用,分别是阿里巴巴的Java开发手册和关于空指针处理的例子。首先,我需要确认用户的需求:他们可能在进行Java业务开发时,希望遵循一些行业内的最佳实践,提高代码质量和可维护性,同时避免常见的错误,比如空指针异常。 首先,我应该回顾用户提供的引用内容。引用1提到阿里巴巴的Java开发手册,涵盖了命名规则、格式设置、注释标准、控制语句、并发处理、日志管理以及SQL优化等内容。这是一个非常全面的指南,适用于Java开发的多个方面,符合用户的需求。引用2则具体讨论了如何处理空指针异常,给出了一个判空的例子,这属于代码健壮性的一部分,也是业务开发中常见的问题。 接下来,我需要思考用户可能需要的其他资源或手册。除了阿里巴巴的手册之外,是否还有其他知名的Java开发指南?比如Google的Java编程风格指南,或者Effective Java这类书籍中的最佳实践。不过用户提到的是手册或指南,可能更偏向于公司或组织发布的规范文档。 然后,用户的问题是关于“业务开发”的,可能需要关注业务逻辑处理、分层架构、事务管理、异常处理、日志记录、性能优化等方面。阿里巴巴的手册已经覆盖了很多基础规范,但可能需要补充更多业务相关的实践,比如如何设计服务层,如何进行参数校验,如何保证事务一致性,如何处理分布式系统中的问题等。 另外,用户提供的引用2中提到了空指针的最佳实践,可以联想到使用Optional类、Objects工具类等方法,或者通过注解如@NonNull进行静态检查,这些都是避免NPE的常见方法。此外,可能还需要讨论在业务逻辑中如何合理处理null值,比如返回空集合而不是null,使用空对象模式等。 接下来,我需要确保回答结构清晰,分点列出推荐的资源,并简要说明每个资源的价值。同时,需要生成相关问题,这些问题应该围绕用户可能进一步想了解的内容,比如具体的命名规范、异常处理策略、分层架构设计、性能优化方法等。 最后,要检查是否符合用户的要求:使用中文,LaTeX格式正确,引用标识自然添加。确保没有使用Markdown格式,代码块用正确的格式展示,如用户提供的示例中的Python代码那样,Java代码也应该用类似的方式呈现。 总结步骤: 1. 推荐阿里巴巴Java开发手册,详细说明其内容。 2. 提及其他可能的资源,如《Effective Java》和Spring框架的最佳实践。 3. 分点讨论业务开发中的关键点,如分层架构、参数校验、事务管理等。 4. 生成相关问题,引导用户深入探讨各个最佳实践。 5. 确保引用正确,格式符合要求。</think>在Java业务开发中,遵循规范手册和最佳实践能显著提升代码质量和可维护性。以下是关键资源与建议: --- ### 1. **《阿里巴巴Java开发手册》核心内容** - **命名规范**:类名使用大驼峰(如`UserService`),包名全小写(如`com.alibaba.util`),常量全大写加下划线(如`MAX_COUNT`)[^1]。 - **异常处理**:禁止捕获异常后忽略(如空`catch`块),推荐通过预检查避免`NullPointerException`(NPE),例如: ```java public void processOrder(Order order) { if (order == null) { throw new IllegalArgumentException("Order cannot be null"); } // 业务逻辑 } ``` - **并发安全**:使用线程安全的集合类(如`ConcurrentHashMap`),避免在业务代码中直接操作共享资源。 - **日志规范**:区分日志级别(`DEBUG`/`INFO`/`ERROR`),使用占位符代替字符串拼接(如`log.info("Order id: {}", orderId)`)[^1]。 --- ### 2. **业务开发最佳实践** - **分层架构**:遵循`Controller-Service-DAO`模式,例如: ```java @RestController public class UserController { @Autowired private UserService userService; @PostMapping("/user") public Response createUser(@RequestBody UserDTO userDTO) { return userService.createUser(userDTO); } } ``` - **参数校验**:结合`Hibernate Validator`注解简化校验: ```java public class UserDTO { @NotBlank(message = "用户名不能为空") private String username; @Min(value = 18, message = "年龄必须大于18岁") private Integer age; } ``` - **事务管理**:使用`@Transactional`声明式事务,注意配置传播机制和回滚条件: ```java @Transactional(rollbackFor = Exception.class) public void placeOrder(Order order) { inventoryService.deductStock(order); // 扣减库存 orderDao.save(order); // 保存订单 } ``` - **空指针防护**:优先使用`Optional`和工具类(如`Objects.requireNonNull`): ```java public void processData(Data data) { Optional.ofNullable(data) .map(Data::getDetails) .ifPresent(details -> saveDetails(details)); } ``` --- ### 3. **其他推荐资源** - **《Effective Java》**:涵盖泛型、并发编程等高级技巧,例如“避免创建不必要的对象”[^3]。 - **Spring官方文档**:提供依赖注入、AOP等框架级最佳实践。 - **企业级架构指南**:如《领域驱动设计(DDD)实战》中聚合根设计、领域事件等模式。 ---
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值