一个奇形怪状的面试题:Bean中的CHM要不要加volatile?

你好呀,我是歪歪。

事情是这样的,前几天有一个读者给我发消息,说他面试的时候遇到一个奇形怪状的面试题。

歪师傅纵横面试界多年,最喜欢的是奇形怪状的面试题。

可以说是见过大场面的人,所以让他描述一下具体啥问题。

据他的描述,这道面试题是这样的:

在多线程环境下使用 ConcurrentHashMap 时,是否需要将其声明为 volatile 以确保线程安全?

呃...

这个题...

有点意思...

简单盘一盘

这个题听起来确实有点奇奇怪怪的,多线程、ConcurrentHashMap(后续文中用 CHM 代替)、volatile、线程安全...

乍一听有一种全都是我熟悉的技术点,但是组合在一起,突然有点不认识了的陌生感。

但是如果你真的对上面这几个技术点达到了熟悉的程度,那么简单梳理一下之后,你又会觉得线索太多,甚至有点不知道从何说起。

先梳理清楚两个关键点:

  • CHM 是干啥的?
  • volatile 又是干啥的?

首先,CHM 是八股中老大哥了,一般它和 HashMap 会在面试环节成对出现。

比如这样式儿的:HashMap 不是线程安全的,那我们应该怎么办呢?

然后 CHM 就噼里啪啦一大堆开始背诵起来了。

但是在这篇文章中,关于 CHM 我们需要注意的就一个点:

CHM 是线程安全的,但是它的线程安全仅限于方法内部的操作。

然后,volatile 是干啥的?

这种老八股应该是张口就来:

volatile 可以保证变量的可见性,即一个线程修改了被 volatile 修饰的变量,其他线程能立即看到新值。

这里画个重点:变量。

如果要把 CHM 和 volatile 牵扯到一起,那么他们就需要对齐一下颗粒度:CHM 需要是一个变量。

当 CHM 在程序的引用中会发生变化时,讨论 volatile 才有意义。

所以,这个面试题的答案也就呼之欲出了。

答案就是:得结合代码,看具体场景,分两种情况去讨论。

第一种情况是 CHM 的引用不会发生变化,就不需要加 volatile。

比如下面这种写法:

private static final ConcurrentHashMap<String, String> chm = new ConcurrentHashMap<>();

这里的 final 已经保证引用不可变,无论多少个线程在同时操作这个 CHM,都能确保看到的始终是同一个对象。

至于线程安全问题,CHM 内部的方法自会处理好并发问题。

第二种情况是 CHM 的引用会变,比如这样的代码逻辑:

private volatile ConcurrentHashMap<String, String> cache = new ConcurrentHashMap<>();

public void updateCache() {
    ConcurrentHashMap<String, String> newCache = new ConcurrentHashMap<>();
    // 填充新数据...
    cache = newCache; 
}

因为我们程序中有把 newCache 赋值给 cache 的操作,而这个 cache 可能又不只是一个线程在操作。

所以这个场景下,就需要使用 volatile,保证当前线程对 cache 操作之后,其他线程能立刻看到新引用。

从而保证了线程安全。

现在,我们再回过头去看这个问题,应该就清晰很多了:

在多线程环境下使用 ConcurrentHashMap 时,是否需要将其声明为 volatile 以确保线程安全?

这个问题确实是有点陷阱的,不能直接回答需要或者不需要。

需要面试者结合自己的理解,去分析出不同的场景,得到上面的回答。

想到另一个题

在分析上面这个问题的时候,我又联想到了另外一个经典的面试题。

问:Spring 的 Bean 是否是线程安全的?

我个人认为这两个题的相似程度算是非常高的。

为啥这样说呢?

我们一起分析一波。

首先,我们搞个示意代码:

@Controller
public class TestController {

    private int num = 0;

    @RequestMapping("/test")
    public void test() {
        System.out.println(++num);
    }

    @RequestMapping("/test1")
    public void test1() {
        System.out.println(++num);
    }
}

TestController 就是在 Spring 中托管的一个 Bean。

这个程序跑起来,我们先访问 test,得到的答案是 1,然后再访问 test1,得到的答案是 2。

按照常规的理解,两个不同的请求,它们之间应该相互独立才对。

现在的现象是第二个线程的运行结果,受到了前一个线程的影响。

但是,你能说这是线程不安全的吗?

不能。

只能说因为 Bean 里面包含了可变的成员变量 num,有 ++num 这种代码,所以多个线程并发修改时会导致数据不一致,这里需要我们在开发的时候自行通过加锁或者使用原子类来保证同步。

而这个 Bean,我们一般叫它有状态的 Bean。

对应的,如果 Bean 里面没有成员变量,或者所有的变量都是只读的,那我们就能说它是线程安全的......吗?

先把这个问题按下不表,我们先看看 Bean 的作用域。

还是上面这个例子,如果我在 TestController 类上加一个注解 @Scope("prototype")。

其他都不变,程序跑起来,我们先访问 test,得到的答案是 1,然后再访问 test1,得到的答案还是 1。

这样看起来就是线程安全的了。

@Scope("prototype"),翻译过来是说这个 Bean 是原型作用域的 Bean。

其特性是每次请求 Bean 时,Spring 都会创建一个新实例。

由于每个线程操作独立的 Bean 实例,所以天然线程安全。

而我们在没加 @Scope("prototype") 之前,Bean 的默认作用域是 Singleton,即单例。

其特性是整个 Spring 容器中仅有这一个实例,所有的线程都共享此实例。

所以,由于共享,才出现了前面的线程不安全现象。

再看看我们刚刚按下不表的问题:如果 Bean 里面没有成员变量,或者所有的变量都是只读的,那我们就能说它是线程安全的......吗?

是的,它就是线程安全的。

不管作用域是 prototype 还是 Singleton。

我这样写,也只是为了模拟面试的时候,面试官故意通过反问的方式,来检验你对于知识点的掌握程度。

好,现在回到最开始的这个问题上:Spring 的 Bean 是否是线程安全的?

经过前面的分析,我们知道这个问题确实也是有点陷阱的,不能直接回答是或者不是。

和"CHM 需要将其声明为 volatile"一样,需要面试者结合自己的理解,去分析出不同的场景,得到下面的回答。

首先,Spring 本身并不自动保证 Bean 的线程安全。

在 Spring 框架中,Bean 的线程安全性取决于其作用域和具体实现方式。

当作用域是 Singleton 是,如果 Bean 是有状态的 Bean,即 Bean 中包含可变的成员变量,那就是线程不安全的,需要开发者执行保证。

如果是无状态的 Bean,则是线程安全的。

而当作用域是 Prototype 时,由于每次请求 Bean 时,Spring 都会创建一个新实例。所以每个线程操作的 Bean 实例都是独立的,天然线程安全。

那你可能在想,Spring 的 Bean 我天天都在用,也一直用的是默认的 Singleton 模式,为什么我用的时候没遇到过线程安全的问题呢?

那你就去仔细想想,翻一翻代码,我们用的绝大部分 Bean 是不是都是无状态的设计?

如果,你盘出来发现在项目中有几个有状态的 Bean,访问对应的变量时,你也没有做相应的加锁之类的处理,那恭喜你,找到一个潜伏工作做的不错的 BUG。

结合一下

好,如果前面写的你都理解到了,那现在我们把前面两个题结合一下。

比如我给你这样一份代码:

@RestController
public class TestController {

    private ConcurrentHashMap chm = new ConcurrentHashMap();

    @RequestMapping("/test")
    public void test() {
        chm.put("1", "1");
    }

    @RequestMapping("/test1")
    public void test1() {
        chm.put("2", "2");
    }
}

你说一说它的问题是什么?

或者你说说它有没有出现线程安全问题的风险?

如果回答不出来说明你看的时候根本就没用心去理解,只是在用眼睛看,没有往脑子里面记。

说明你正在看文章的此刻,不是学习的时候。你就先放到收藏夹里面,退出去得了,等有时间的时候再慢慢看。

首先,由于我们使用的是 CHM,所以即使 1000 个线程同时调用 test 方法,最终结果也正确。

这个方法的线程安全性是由 CHM 来保证的。

其次,由于 TestController 是单例的,所有的请求共享同一个 TestController 实例,因此共享同一个 CHM。

但是这个 CHM 没有被 volatile 修饰。

如果以后新增代码,逻辑中修改了 chm 的引用,比如这样:

在 test2 方法中对 chm 进行了重新赋值的操作,因为没有使用 volatile 修饰 chm,所以可能导致其他线程看到的不是最新的 chm。

这就是风险点。

而这个风险点的化解方式之一是给 chm 加上 volatile。

化解方式之二是给 chm 加上 final,确保不能对 chm 进行重新赋值。

化解方式之三是把 Bean 的作用域修改为 prototype,让每个请求操作的 Bean 实例都是独立的。

具体采用哪个方案,就得结合你的应用场景来看了。

再延伸一下

关于 CHM,再做一个小小的延伸,是我多年前看到的一段源码了,印象深刻,和文章内容也比较匹配,分享一下。

Spring 的 SimpleAliasRegistry 类中有一个 CHM 类型的 aliasMap 变量。

但是在操作这个变量之前都是用 synchronized 把 aliasMap 锁住了:

请问,为什么我们操作 ConcurrentHashMap 的时候还要加锁呢?

看一下 registerAlias 方法中的这部分代码:

aliasMap 的 get 和 put 方法都是线程安全的,但是先 get,再检查是否存在,然后再 put,这几步操作组合在一起的时候,其他的线程能在 get 和 put 之间插入数据。

这个类是个别名管理器,具体来说就是可能导致重复别名。

即使你使用了 CHM,它也只能保证自身方法的原子性,无法保证外部复合操作的原子性。

因此,在这个场景下,用 synchronized 包裹住了整个复合操作。

如果你觉得不太好理解的话我再举一个 Redis 的例子。

Redis 的 get、set 方法都是线程安全的吧。

但是你如果先 get 再 set,那么在多线程的情况下还是会因为操作非原子性导致竞态条件,比如下面这种:

value = redis.get("counter")  # 步骤1
value += 1
redis.set("counter", value)

两个线程可能同时执行步骤 1,读到相同的 value,导致最终结果少加 1。

因为这两个操作不是原子性的。所以 incr 就应运而生了。

我举这个例子的是想说线程安全与否不是绝对的,要看场景。给你一个线程安全的容器,你使用不当还是会有线程安全的问题。

再比如,HashMap 一定是线程不安全的吗?

朋友,说不能说的这么死吧?

它是一个线程不安全的容器。

但是如果我的使用场景是只读呢?

在这个只读的场景下,它就是线程安全的。

总之,看场景,不要脱离场景讨论问题。

道理,就是这么一个道理。

最后,记住歪师傅下面说的这句话,面试的时候可能用的上:

线程安全问题是一个全局问题,不能试图依赖单个组件的特性来解决这个全局的问题。

好,行文至此,暂驻笔锋。

诸君,可以鼓掌了。

最后,欢迎关注公众号"why技术",全网首发平台,还有技术之外的东西哦。