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

相关文章

来自专栏扎心了老铁

分布式锁的实现(redis)

1、单机锁 考虑在并发场景并且存在竞态的状况下,我们就要实现同步机制了,最简单的同步机制就是加锁。 加锁可以帮我们锁住资源,如内存中的变量,或者锁住临界区(线程...

3516
来自专栏技术博客

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

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

792
来自专栏java一日一条

Java线程面试题 Top 50

不管你是新程序员还是老手,你一定在面试中遇到过有关线程的问题。Java语言一个重要的特点就是内置了对并发的支持,让Java大受企业和程序员的欢迎。大多数待遇丰厚...

632
来自专栏Java帮帮-微信公众号-技术文章全总结

回顾Java 8 9 10的新特性,展望即将来临的11和明年的12【大牛经验】

1997年4月2日,JavaOne会议召开,参与者逾一万人,创当时全球同类会议纪录;

5332
来自专栏Golang语言社区

Go语言并发编程总结

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

3449
来自专栏AI派

真是绝了!史上最详细的Jupyter Notebook入门教程

Jupyter Notebook 是一个在浏览器中使用的交互式的笔记本,可以实现将代码、文字完美结合起来,它的受众群体大多数是一些从事数据科学领域相关(机器学习...

5268
来自专栏Golang语言社区

Go语言并发编程总结

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

2947
来自专栏Java3y

Activiti就是这么简单

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

5328
来自专栏Linyb极客之路

工作流引擎之activiti入门

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

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

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

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

683

扫码关注云+社区