前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >从ConcurrentHashMap谈谈一致性

从ConcurrentHashMap谈谈一致性

作者头像
大宽宽
发布2018-05-14 11:33:26
1.6K0
发布2018-05-14 11:33:26
举报

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

考虑以下场景:

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

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

代码语言:javascript
复制
/path1 --> [count of path1]
/path2 --> [count of path2]
/path3 --> [count of path3]
...

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

代码语言:javascript
复制
@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。计数的结果,比实际发生的调用要少。

为什么咧????

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

怎么理解?

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

代码语言:javascript
复制
从counterMap中取出计数,+1,然后塞回counterMap

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

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

代码语言:javascript
复制
@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)。希望这个词能帮你在面试里唬住人。

本文参与 腾讯云自媒体同步曝光计划,分享自作者个人站点/博客。
原始发表:2017.12.12 ,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档