文章目录
本文设计知识点如下:
1. 不是所有的 Map 都能包含 null
有这样一段业务代码,功能很简单,从XML
中读取相关配置,存入 Map
中。
代码示例如下:
那时候正好有个小需求,需要改动一下这段业务代码。改动的过程中,突然想到HashMap
并发过程可能导致死锁的问题。
于是改动了一下这段代码,将 HashMap
修改成了 ConcurrentHashMap
。
美滋滋提交了代码,然后当天上线的时候,就发现炸了。。。
应用启动过程发生NPE
(空指针)问题,导致应用启动失败。
根据异常日志,很快就定位到了问题原因。由于XML
某一项配置问题,导致读取元素为null
,然后元素置入到ConcurrentHashMap
中,抛出了空指针异常。
这不科学啊!之前HashMap
都没问题,都可以存在null
,为什么它老弟ConcurrentHashMap
就不可以?
翻阅了一下 ConcurrentHashMap#put
方法的源码,开头就看到了对KV
的判空校验。
看到这里,不知道你有没有疑惑,为什么ConcurrentHashMap
与 HashMap
设计的判断逻辑不一样?
求助了下万能的Google
,找到 Doug Lea 老爷子的回答:
总结一下:
-
null
会引起歧义,如果value
为null
,我们无法得知是值为null
,还是key
未映射具体值? - Doug Lea 并不喜欢
null
,认为null
就是个隐藏的炸弹。
上面提到 Josh Bloch 正是HashMap
作者,他与 Doug Lea 在null
问题意见并不一致。
也许正是因为这些原因,从而导致ConcurrentHashMap
与HashMap
对于null
处理并不一样。
最后贴一下常用Map
子类集合对于 null
存储情况:
上面的实现类约束,都太不一样,有点不好记忆。其实只要我们在加入元素之前,主动去做空指针判断,不要在 Map 中存入 null,就可以从容避免上面问题。
2. 自定义对象为 key
先来看个简单的例子,我们自定义一个Goods
商品类,将其作为 Key
存在Map
中。
示例代码如下:
上面代码中,第二次我们加入一个相同的商品,原本我们期望新加入的值将会替换原来旧值。但是实际上这里并没有替换成功,反而又加入一对键值。
翻看一下 HashMap#put
的源码:
以下代码基于 JDK1.7
这里首先判断hashCode
计算产生的hash
,如果相等,再判断 equals
的结果。但是由于Goods
对象未重写的hashCode
与equals
方法,默认情况下hashCode
将会使用父类对象Object
方法逻辑。
而Object#hashCode
是一个native
方法,默认将会为每一个对象生成不同hashcode
(与内存地址有关),这就导致上面的情况。
所以如果需要使用自定义对象做为Map
集合的key
,那么一定记得重写hashCode
与 equals
方法。
然后当你为自定义对象重写上面两个方法,接下去又可能踩坑另外一个坑。
使用
lombok
的EqualsAndHashCode
自动重写hashCode
与equals
方法。
上面的代码中,当Map
中置入自定义对象后,接着修改了商品金额。然后当我们想根据同一个对象取出Map
中存的值时,却发现取不出来了。
上面的问题主要是因为get
方法是根据对象 的 hashcode
计算产生的 hash
值取定位内部存储位置。
当我们修改了金额字段后,导致Goods
对象hashcode
产生的了变化,从而导致get
方法无法获取到值。
通过上面两种情况,可以看到使用自定义对象作为Map
集合key
,还是挺容易踩坑的。
所以尽量避免使用自定义对象作为Map
集合key
,如果一定要使用,记得重写 hashCode
与equals
方法。另外还要保证这是一个不可变对象,即对象创建之后,无法再修改里面字段值。
3. 错用 ConcurrentHashMap 导致线程不安全
我们都知道HashMap
其实是一个线程不安全的容器,多线程环境为了线程安全,我们需要使用ConcurrentHashMap
代替。
但是不要认为使用了ConcurrentHashMap
一定就能保证线程安全,在某些错误的使用场景下,依然会造成线程不安全。
上面示例代码,我们原本期望输出1001
,但是运行几次,得到结果都是小于1001
。
深入分析这个问题原因,实际上是因为第一步与第二步是一个组合逻辑,不是一个原子操作。
ConcurrentHashMap
只能保证这两步单的操作是个原子操作,线程安全。但是并不能保证两个组合逻辑线程安全,很有可能A
线程刚通过get
方法取到值,还未来得及加1
,线程发生了切换,B
线程也进来取到同样的值。
这个问题同样也发生在其他线程安全的容器,比如Vector
等。
上面的问题解决办法也很简单,加锁就可以解决,不过这样就会使性能大打折扣,所以不太推荐。
我们可以使用AtomicInteger
解决以上的问题。
4. List 集合这些坑,Map 中也有
List 踩坑那篇文章中我们提过,Arrays#asList
与 List#subList
返回List
将会与原集合互相影响,且可能并不支持add
等方法。同样的,这些坑爹的特性在Map
中也存在,一不小心,将会再次掉坑。Map
接口除了支持增删改查功能以外,还有三个特有的方法,能返回所有key
,返回所有的value
,返回所有kv
键值对。
// 返回 key 的 set 视图
Set<K> keySet();
// 返回所有 value Collection 视图
Collection<V> values();
// 返回 key-value 的 set 视图
Set<Map.Entry<K, V>> entrySet();
这三个方法创建返回新集合,底层其实都依赖的原有Map
中数据,所以一旦Map
中元素变动,就会同步影响返回的集合。
另外这三个方法返回新集合,是不支持的新增以及修改操作的,但是却支持clear、remove
等操作。
示例代码如下:
所以如果需要对外返回Map
这三个方法产生的集合,建议再来个套娃。
new ArrayList<>(map.values());
最后再简单提一下,使用foreach
方式遍历新增/删除Map
中元素,也将会和List
集合一样,抛出ConcurrentModificationException
。
5. 总结
首先,从上面文章可以看到不管是 List
提供的方法返回集合,还是Map
中方法返回集合,底层实际还是使用原有集合的元素,这就导致两者将会被互相影响。所以如果需要对外返回,请使用套娃大法,这样让别人用的也安心。
第二,Map
各个实现类对于null
的约束都不太一样,这里建议在Map
中加入元素之前,主动进行空指针判断,提前发现问题。
第三,慎用自定义对象作为Map
中的key
,如果需要使用,一定要重写hashCode
与 equals
方法,并且还要保证这是个不可变对象。
第四,ConcurrentHashMap
是线程安全的容器,但是不要思维定势,不要片面认为使用ConcurrentHashMap
就会线程安全。