这个事情源自于最近跟朋友的聊天。在聊天的时候朋友说自己在出一套Java的试卷,问问我有没有什么灵感。
我问朋友要求是什么,朋友说要求就是两点,一个是够基础,一个是他希望这套题“反八股文”,让死背Java八股文的朋友能认识到光背八股文是不行的。
于是我就从八股文最爱问的HashMap问题下手,出了一个这样的题,大致内容是这样的:
public class DemoClass {
private int id;
private HashMap<String, String> data;
// 此处data属性不直接暴露getter和setter,下面是一些操作data属性的方法
}
小明定义了类
DemoClass
,类定义如上所示。现在有一此类的实例obj
存在被多个线程同时调用属性data
的问题,此时为了防止并发场景下可能出现的问题,对于此类的属性data
的类型,应当( )。
A. 须改为ConcurrentHashMap<String,String>
类型
B. 须维持现在的HashMap<String,String>
类型
C. 选用以上两种类型均可,此类型并非主要问题
D. 此处有必要自行实现Map
接口,定义适用于此场景的类型
这道题的答案是C
。
然后这套卷子的这个题被参加考试的朋友激烈的吐槽了(悲)。绝大多数的人看到选项中的“并发”,结合自己背的八股文,觉得应该是A
。也有一些朋友提出了一些不一样的看法。
那么以我所见的话,究竟应该怎么看待这里HashMap
和ConcurrentHashMap
的使用问题呢?
这篇文章仅仅是以使用的角度粗浅考量一下这两者的区别,不涉及原理部分。
你真的经常用ConcurrentHashMap
嘛?
按理说的话,在诸如Spring等框架里使用Map
的时候,我们难道不会遇到并发问题嘛?显然是不对的。但是问你一个问题,如果你实际开发过很多Java应用,写过很多Java代码的话,你真的会频繁使用ConcurrentHashMap
嘛?
我想答案是未必的。我觉得大部分情况下,开发的时候更倾向于选择使用HashMap
。
那为啥我一直用HashMap
,却感觉“没什么问题”呢?
如果想整明白这个问题,我觉得得回归到我们的日常使用中去。我觉得,大部分情况下纠结HashMap
的线程不安全问题是伪命题。
首先,我们究竟什么时候会用到HashMap
呢?
其实答案有很多很多,场景也很多,但是如果把大部分的情况归结一下的话,实际上HashMap实际使用的时候,绝大部分情况都是在方法体内部声明的。
如果是这样的情况,那么每个被调用的方法都属于一个单独的进程,这就根本不存在所谓的并发问题。
还有一个情况,我们不如思考一个简单的Spring应用的具体场景,假如你写了一个管理用户基本信息的应用,有若干个Bean负责处理这些对象,而这些Bean大部分情况下是单例的,假如Bean里存在一个Map
,它的线程安全与否应当如何断定?
首先我们要注意的是,Bean往往是只负责业务过程处理的,而不负责业务核心数据的存储。在这样的背景下,这应该根据具体的操作而看:
- 操作是对这个
Map
只读的:那不存在线程安全问题,没写操作,哪儿来的线程安全问题呢? - 这个操作对
Map
写了:那确实可能会存在所谓的安全问题,但我们通常在诸如Service之类的Bean里很少这样干,如果真的这么干了,即使把HashMap
改成ConcurrentHashMap
,也不能保证线程安全,线程安全还要取决于具体的操作逻辑,见下一节。
所以,我们其实大多数情况下讨论HashMap
的线程安全问题是“伪命题”,这些情况下HashMap
基本遇不到线程不安全的情况。
谁最终能够落实“线程安全”的指责
在上一节的描述里,提到了一个这样的场景:一个Bean里因为某些原因有一个Map
(其实这里抽象成任意一个大家都能写的属性也可以),这个Bean是一个“有状态”的Bean,那么我们如何讨论它的线程安全问题呢?
首先这里确实使用HashMap
不妥,因为这里归结到底,这个Map
对象是会被多个线程调用到的,我们把它改成ConcurrentHashMap
以后,真的能保证这里“线程安全”嘛?
要说明白这个问题,还得多说点题外话。
HashMap
什么时候会遇到线程不安全的问题?
我们刚才反思了一下,哦,原来我大部分时间都是在方法体内部声明并使用HashMap
的,那确实没线程安全问题。
但是这也太以偏概全了。
其实有的时候,我们还经常用static定义一个静态的HashMap
对象。
有的时候,一个静态的HashMap
对象,我们会把它在某些场景下当“临时存储表”、“小规模的数据池”或者“缓存”之类的角色来用。由于它是静态的,很容易就写出多个线程访问这个Map
对象的代码。
还有的时候,我们会因为某种原因,把某个HashMap
对象直接或间接的传递到多个线程上处理,造成这样情况的因素有很多,场景也同样有很多。
为了线程安全,此时我们不如改成ConcurrentHashMap
好了。
那么我们就迎来了第二个问题。
用了ConcurrentHashMap
,真的线程安全了嘛?
要回答这个问题,必须要死死抓住一件事:使用ConcurrentHashMap
,究竟维护了什么东西的线程安全。
这里不涉及这两种Map
的具体使用,答案是显而易见的:我们使用ConcurrentHashMap
,并不是维护我们的业务逻辑代码是线程安全的,而是维护了这个Map
对象对应的get
、put
等方法是线程安全的。
要知道,get
和put
并不是原子操作,里面其实也有对应的内部实现,这些内部实现的逻辑可能会因为多个线程的调用而出现问题,ConcurrentHashMap
通过某些手段正是解决了这样的问题。
但是这没办法阻止业务逻辑本身的疏漏,比如下面这个例子:
import java.util.concurrent.ConcurrentHashMap;
public class DemoClass {
public static ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>() {{
put("test", 0);
}};
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(new DemoRunnable()), t2 = new Thread(new DemoRunnable());
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(map.get("test"));
}
public static class DemoRunnable implements Runnable {
@Override
public void run() {
for (int i = 0; i < 100; i++) {
Integer num = map.get("test");
num = num + 1;
map.put("test", num);
}
}
}
}
两个线程都是循环了100次test+1
的操作,合起来输出应该是200
,实际是多少取决于具体的运行环境,比如我的运行结果是:
144
根本就不是200
,甚至我重新运行一次能随机刷新一个数字,显然这个逻辑存在线程安全问题。问题出在哪儿呢?出在具体的业务逻辑上:
Integer num = map.get("test");
num = num + 1;
map.put("test", num);
这三个语句的执行并不是一个整体,可能第一个线程执行到了第一句话,第二个线程快一步执行了第一、第二和第三句话,把test
已经设置为了1
,但是这里第一个线程仍然是按照0
处理的,又把test
设置为了1。
那么,为什么你用了ConcurrentHashMap
以后还是会有线程安全问题呢?
还是刚才那句话,ConcurrentHashMap
只能保证它自己的方法是线程安全的,具体业务逻辑能否保证线程安全完全取决于你是如何使用的,而不是你用没用ConcurrentHashMap
。
这个例子也充分的回答了这一节讨论的Bean的问题。如果这里存在线程安全问题,不仅仅要考虑把这里改成ConcurrentHashMap
,还得注意Bean里调用这个map
的具体业务代码有没有保证线程安全。
谁负责所谓的线程安全?
面向对象分三个基本的重要概念,封装、继承和多态。封装一般被我们放在第一个,可见封装的重要性。
为什么Java官方要定义ConcurrentHashMap
来解决线程安全问题呢?因为他们把get
和put
等基本方法封装了起来,我们作为使用者不能保证其内部逻辑的线程安全。这才是我们探索HashMap
线程安全问题的最关键的因素所在。
反观最开头提到的考题,现在定义了一个DemoClass
,实例化出来的对象里面的data
属性的线程安全该由谁维护?谁使用,谁维护。
试想这两个问题:
- 这里
data
属性不暴露getter
之类的方法,这个map
除了这个对象自己以外,没有其他对象能够拿得到,假如下面的调用方法保证了线程安全,这里还会出现线程安全问题吗?不会再出现了。 - 假如这个对象里面的一切东西都看起来保证了线程安全,使用这个
DemoClass
对象的业务逻辑能保证线程安全嘛?没办法保证。
那么线程安全落到实处的话,其实占据最大话语权的是具体的使用逻辑,而不是这里究竟是不是HashMap
。即使这里是存在线程安全问题的HashMap
,外部逻辑如果设计了相应的锁结构,使得对这个HashMap
的访问一次只保证了一个线程进行,那仍然是总体线程安全的。
如此看来,这里的主要矛盾根本不在于这里是什么类型,而在于具体的业务逻辑代码,因此这个题选C。
回过头来,再看看静态HashMap
的问题
实际上前面列举的“临时存储表”、“小规模的数据池”或者“缓存”之类的角色,在有一定小规模的项目里是伪需求,我们一般不用static定义HashMap
的方式来做。
如果是JVM内存缓存,我们一般会考虑用到caffeine这种缓存框架,其内部实现本质就是ConcurrentHashMap
做再次封装。
如果是更具规模的各种表、共享缓存等,那一般用到的是Redis。
可见这样的使用场景,随着框架和各种技术的应用、成熟和迭代,被我们感知到的场景也越来越少了。或许这也是我们“天天写HashMap
,怎么没见炸”的理由之一吧。