从ConcurrentHashMap谈谈一致性

你要是觉得我是要讲ConcurrentHashMap源码分析、segment,rehash之类的事情,就可以不用往下看了。

考虑以下场景:

在Spring Framework实现的服务中做一个计数器,要求对任意请求的到达计数。比如,实现了这处理这几个path的controller: /path1, /path2, /path3,……

很自然,基本的设计是做一个Service Bean,内部封装一个KV形式的方式来统计,像这样:

/path1 --> [count of path1]
/path2 --> [count of path2]
/path3 --> [count of path3]
...

在java里就是个map。而用得最多的就是HashMap了。那么基本的逻辑大概就是这样

@Service
public class CountService {
  Map counterMap = new HashMap();
  public void countPath(String path) {
    Integer count = counterMap.get(path);
    if (count == null) {
      counterMap.put(path, 1);
    } else {
      counterMap.put(path, count + 1);
    }
  }
}

懂得稍微多一点点的同学就会说,这种服务系统都是多线程的。确切的说,是Web Server内维持一个线程池,每个请求都会从线程池取一个线程出来。在同一个时刻,如果有多个Controller都在响应请求,这些Controller就在不同的线程中并发的执行。

这样的话,用HashMap就会有问题。HashMap不是线程安全的,在并发更改下它会报ConcurrentModificationException

那么有几个选择:

  • 自己包一个HashMap,然后让所有的get、put等方法都标记synchronized关键字强行加锁。
  • 使用Collections.synchronizedMap包装的HashMap
  • 使用ConcurrentHashMap

前两种本质上差不多。第三个听起来高大上,而且根据之前查阅的文章资料模模糊糊的印象表示ConcurrentHashMap可以有效减少加锁的几率,提高性能。另外,JDK8的ConcurrentHashMap完全用CAS避免了锁。太赞了,就用它呗。

成,试试就试试。

然后就会发现并不work。计数的结果,比实际发生的调用要少。

为什么咧????

这就是本文想说明的一个基本观点:一致性无法依靠单一组件解决;一致性要依靠正确的处理“需要同步的区域“才能解决

怎么理解?

要解决多线程一致性问题,第一步要做的是识别发生竞争的代码,并将其设计为同步的。在本例中,这段区域在下面这个过程中:

从counterMap中取出计数,+1,然后塞回counterMap

为了一致性,你必须把这一段同步化。你可以给这段逻辑起个名字叫做"getAndIncrAndPut",并且加锁。这时你会惊喜地发现,高大上的ConcurrentHashMap根本帮不上你的忙,因为它是一个通用的数据结构类,并没假设你会这么使用。ConcurrentHashMap只对基本的Map的方法put、get等提供同步支持,但不会把这段"getAndIncrAndPut"逻辑也给同步了。

这样的话只能自己封装了。当然,因为ConcurrentHashMap帮不上你的忙,你也用不着它了。

@Service
public class CountService {  
  Map counterMap = new HashMap(); 
  public synchronized void countPath(String path) {    
    Integer count = counterMap.get(path);   
    if (count == null) {      
      counterMap.put(path, 1);   
    } else {     
      counterMap.put(path, count + 1);    
    }  
  }
  public synchronized int getCountOfPath(String path) {
    Integer count = counterMap.get(path);   
    return count == null ? 0 : count;
  }
}

嗯,对,它加锁了,效率会有影响。如果觉得确实影响你的业务,可以借鉴ConcurrentHashMap的思路也对path做做分组,比如你可以弄2^N个counterMap,然后先对path做一次hash,选择对应的counterMap,就像ConcurrentHashMap的segment拆分逻辑那样。但是你必须在加锁的外围才能实现这一点。

简单来说,如果需要同步的区域恰好可以被封装到一个组件里(class,lib……),那么恭喜你,直接用就行了。但如果你的业务逻辑需要这个区域会跨多个组件,那么就只能对这个区域加锁。不管这个锁会让代码多难看,会让模块切分多不舒服,都必须得做。如果你的功力足够强,你可以自己封装一个符合你需求的组件来代替通用组件。

此外,还有其他几个变通的办法。

  • 串行化。把并行的请求变成一个序列依次执行。比如上面的例子,你可以用一个ConcurrentLinkedQueue收集所有的计数请求,然后在另一个计数线程消费这些请求。如果你用的是spring,可以考虑以下@Async,可以令你的代码简单不少。
  • 彻底单线程化。如果你用过nodejs就知道,根本就不会遇到这个问题,因为整个nodejs是一个单线程的系统。而且nodejs不一定比多线程的、静态编译的Java慢(why?另外撰文详细讨论)。如果多线程无可避免,可以把计数的工作交给redis,它也是个单线程的服务,而且提供了很赞的incr命令。
  • 简化问题。上面的分析会发现竞争的代码有两个地方。一个是对map的get和put操作。另外一个是对计数加1。对于前者,如果业务需求允许,可以考虑在初始化阶段将所有可能的path全部填入,之后就再也不需要put操作了(顺便用Collections.unmodifableMap包一下)。而对于计数,可以考虑用AtomicLong或者JDK8的LongAdder。他们都用CAS实现了高效的、原子的计数。这样一来,无需加锁,代码也简洁了许多。

也许你已经有点明白了,一致性问题之所以难,是因为无法通过抽象、组件化的方式掩盖这个问题。一旦出现,就必须得从头开始思考,并识别需要同步的区域。如果业务逻辑复杂到跨越多个Service,并且多个需要同步的区域相互重叠,那么代码就会相当难写。如果遇到了,希望你可以找到办法避开。

顺便提一下,还有一类问题也是这样难,无法通过单一组件解决,必须贯穿整个业务流。这个问题就是安全,也会在后面的文章中讲解。

那么ConcurrentHashMap到底解决了什么问题呢?—— 它成功的让Map这个接口的各个方法在并发情况下能使用,并且效率还不错;但除非是运气好,它不会解决你的业务问题

最后说一句,那个需要同步的区域术语一般叫做临界区(Critical Section)。希望这个词能帮你在面试里唬住人。

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

发表于

我来说两句

0 条评论
登录 后参与评论

相关文章

来自专栏杨建荣的学习笔记

一个MySQL死锁问题的反思

很早之前我写过几篇关于MySQL死锁的分析,比如 换个角度看待MySQL死锁的一点简单认识 MySQL死锁的两个小案例 MySQL在RR隔离级别下的unique...

3398
来自专栏cloudskyme

JAVA高性能I/O设计模式

同步阻塞模式。在JDK1.4以前,使用Java建立网络连接时,只能采用BIO方式,在服务器端启动一个ServerSocket,然后使用accept等待客户端请求...

952
来自专栏zhisheng

Java研发方向如何准备BAT技术面试

背景: 最近BAT等各大互联网巨头们的校招陆陆续续都准备开始了,可能对于在校的大多数学生来说,不知道如何正确衡量自己掌握的技术,更不知道BAT这...

1.4K4
来自专栏服务端技术杂谈

Motan源码阅读--ShutDownHook使用

任何一个中间件系统,都需要有个“平滑部署,平滑下线”的功能。 如果基于Java开发,往往采用ShutDownHook去做这件事情。 比如我们在tomcat关闭时...

682
来自专栏chenssy

【死磕【Sharding-jdbc】---EventBus-轻量级进程内事件分发组件

翻译:将事件分派给监听器,并为监听器提供注册自己的方法。EventBus允许组件之间的发布 - 订阅式通信,而不需要组件彼此明确注册(并且因此彼此意识到)。 它...

532
来自专栏京东技术

飞哥教你使用异步编程提升服务性能

1323
来自专栏逸鹏说道

NetCore并发编程

示例代码:https://github.com/lotapp/BaseCode/tree/master/netcore/4_Concurrency

914
来自专栏jessetalks

异步编程 In .NET

概述   在之前写的一篇关于async和await的前世今生的文章之后,大家似乎在async和await提高网站处理能力方面还有一些疑问,博客园本身也做了不少的...

2996
来自专栏技术之路

Go 单例模式[个人翻译]

  原文地址:http://marcio.io/2015/07/singleton-pattern-in-go/   最近几年go语言的增长速度非常惊人,吸引...

2299
来自专栏阿杜的世界

Spring实战6-利用Spring和JDBC访问数据库主要内容

写在前面:经过上一篇文章的学习,我们掌握了如何写web应用的控制器层,不过由于只定义了SpitterRepository和SpittleRepository接口...

561

扫码关注云+社区