从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 条评论
登录 后参与评论

相关文章

来自专栏Golang语言社区

Go语言并发编程总结

Golang :不要通过共享内存来通信,而应该通过通信来共享内存。这句风靡在Go社区的话,说的就是 goroutine中的 channel ....... 他在...

36590
来自专栏听雨堂

VB下中文URL编码问题的解决

        因为编码涉及的内容既多且烦,特别是vb的编码,又有诸多限制,所以在解决URL中文编码的时候走了很久的弯路。 问题:         我的VB...

67560
来自专栏Linyb极客之路

工作流引擎之activiti入门

在解释activiti之前我们看一下什么是工作流。 工作流(Workflow),就是“业务过程的部分或整体在计算机应用环境下的自动化”,它主要解决的是“使在多个...

2.6K40
来自专栏Java3y

Activiti就是这么简单

Activiti介绍 什么是Activiti? Activiti5是由Alfresco软件在2010年5月17日发布的业务流程管理(BPM)框架,它是覆盖了业务...

80880
来自专栏Golang语言社区

Go语言并发编程总结

Golang :不要通过共享内存来通信,而应该通过通信来共享内存。这句风靡在Go社区的话,说的就是 goroutine中的 channel …….

14740
来自专栏Golang语言社区

Go语言并发编程总结

Golang :不要通过共享内存来通信,而应该通过通信来共享内存。这句风靡在Go社区的话,说的就是 goroutine中的 channel ....... 他在...

31770
来自专栏程序员的知识天地

这4个Python实战项目,让你瞬间读懂Python!

Python当下真的很火。Python实战项目,也一直尤为关注,接下来,和大家介绍下十个Python练手的实战项目

17330
来自专栏技术博客

编写高质量代码改善C#程序的157个建议[用抛异常替代返回错误、不要在不恰当的场合下引发异常、重新引发异常时使用inner Exception]

  自从.NET出现后,关于CLR异常机制的讨论就几乎从未停止过。迄今为止,CLR异常机制让人关注最多的一点就是“效率”问题。其实,这里存在认识上的误区,因为正...

13220
来自专栏Golang语言社区

Go语言并发编程总结

Golang :不要通过共享内存来通信,而应该通过通信来共享内存。这句风靡在Go社区的话,说的就是 goroutine中的 channel ....... 他在...

28390
来自专栏腾讯移动品质中心TMQ的专栏

内存泄漏漫谈

对于C/C++来说,内存泄漏问题一直是个很让人头痛的问题,因为对于没有GC的语言,内存泄漏的概率要比有GC的语言大得多,同时,一旦发生问题,也严重的多,而且,内...

33570

扫码关注云+社区

领取腾讯云代金券