简单琢磨一下Java里的HashMap和ConcurrentHashMap的使用问题

这个事情源自于最近跟朋友的聊天。在聊天的时候朋友说自己在出一套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。也有一些朋友提出了一些不一样的看法。

那么以我所见的话,究竟应该怎么看待这里HashMapConcurrentHashMap的使用问题呢?
这篇文章仅仅是以使用的角度粗浅考量一下这两者的区别,不涉及原理部分。

你真的经常用ConcurrentHashMap嘛?

按理说的话,在诸如Spring等框架里使用Map的时候,我们难道不会遇到并发问题嘛?显然是不对的。但是问你一个问题,如果你实际开发过很多Java应用,写过很多Java代码的话,你真的会频繁使用ConcurrentHashMap嘛?
我想答案是未必的。我觉得大部分情况下,开发的时候更倾向于选择使用HashMap

那为啥我一直用HashMap,却感觉“没什么问题”呢?
如果想整明白这个问题,我觉得得回归到我们的日常使用中去。我觉得,大部分情况下纠结HashMap的线程不安全问题是伪命题。

首先,我们究竟什么时候会用到HashMap呢?
其实答案有很多很多,场景也很多,但是如果把大部分的情况归结一下的话,实际上HashMap实际使用的时候,绝大部分情况都是在方法体内部声明的。
如果是这样的情况,那么每个被调用的方法都属于一个单独的进程,这就根本不存在所谓的并发问题。

还有一个情况,我们不如思考一个简单的Spring应用的具体场景,假如你写了一个管理用户基本信息的应用,有若干个Bean负责处理这些对象,而这些Bean大部分情况下是单例的,假如Bean里存在一个Map,它的线程安全与否应当如何断定?
首先我们要注意的是,Bean往往是只负责业务过程处理的,而不负责业务核心数据的存储。在这样的背景下,这应该根据具体的操作而看:

  1. 操作是对这个Map只读的:那不存在线程安全问题,没写操作,哪儿来的线程安全问题呢?
  2. 这个操作对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对象对应的getput等方法是线程安全的。
要知道,getput并不是原子操作,里面其实也有对应的内部实现,这些内部实现的逻辑可能会因为多个线程的调用而出现问题,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来解决线程安全问题呢?因为他们把getput等基本方法封装了起来,我们作为使用者不能保证其内部逻辑的线程安全。这才是我们探索HashMap线程安全问题的最关键的因素所在

反观最开头提到的考题,现在定义了一个DemoClass,实例化出来的对象里面的data属性的线程安全该由谁维护?谁使用,谁维护

试想这两个问题:

  1. 这里data属性不暴露getter之类的方法,这个map除了这个对象自己以外,没有其他对象能够拿得到,假如下面的调用方法保证了线程安全,这里还会出现线程安全问题吗?不会再出现了。
  2. 假如这个对象里面的一切东西都看起来保证了线程安全,使用这个DemoClass对象的业务逻辑能保证线程安全嘛?没办法保证。

那么线程安全落到实处的话,其实占据最大话语权的是具体的使用逻辑,而不是这里究竟是不是HashMap。即使这里是存在线程安全问题的HashMap,外部逻辑如果设计了相应的锁结构,使得对这个HashMap的访问一次只保证了一个线程进行,那仍然是总体线程安全的。
如此看来,这里的主要矛盾根本不在于这里是什么类型,而在于具体的业务逻辑代码,因此这个题选C。

回过头来,再看看静态HashMap的问题

实际上前面列举的“临时存储表”、“小规模的数据池”或者“缓存”之类的角色,在有一定小规模的项目里是伪需求,我们一般不用static定义HashMap的方式来做。

如果是JVM内存缓存,我们一般会考虑用到caffeine这种缓存框架,其内部实现本质就是ConcurrentHashMap做再次封装。
如果是更具规模的各种表、共享缓存等,那一般用到的是Redis。

可见这样的使用场景,随着框架和各种技术的应用、成熟和迭代,被我们感知到的场景也越来越少了。或许这也是我们“天天写HashMap,怎么没见炸”的理由之一吧。

上一篇
下一篇