摘要

ConcurrentHashMap 是 Java 面试里非常高频的并发集合类。很多人知道它线程安全,也知道它比 Hashtable 性能更好,但真正面试时容易答散。本文从 HashMap 为什么不安全、Hashtable 为什么性能差、ConcurrentHashMap 如何保证线程安全、JDK 1.7 和 JDK 1.8 的区别、put/get 流程、null 值限制和实际代码案例几个角度,梳理这个高频面试题。


前言

前面我们已经聊过 HashMap 的底层原理。

简单回顾一下:

HashMap 底层是数组 + 链表 + 红黑树,但它不是线程安全的。

那如果在多线程环境下,多个线程同时读写同一个 Map,该怎么办?

这时候就会引出一个非常经典的并发集合类:

ConcurrentHashMap

它是 Java 并发包里非常常用的线程安全 Map,也是面试里经常会被拿来和 HashMapHashtable 对比的集合类。

这篇还是按“少废话、直接抓重点”的方式来整理。


一、面试官一般怎么问?

关于 ConcurrentHashMap,常见问法有这些:

  • ConcurrentHashMap 为什么线程安全?
  • ConcurrentHashMapHashMap 有什么区别?
  • ConcurrentHashMapHashtable 有什么区别?
  • JDK 1.7 和 JDK 1.8 的 ConcurrentHashMap 有什么区别?
  • ConcurrentHashMap 的 put 流程大概是什么?
  • ConcurrentHashMap 的 get 操作需要加锁吗?
  • 为什么 ConcurrentHashMapHashtable 性能好?
  • ConcurrentHashMap 能不能存 null?
  • ConcurrentHashMap 一定没有并发问题吗?

二、先给结论

一句话先记住:

ConcurrentHashMap 是线程安全的 Map,它通过更细粒度的锁控制、CAS、volatile 等机制,保证并发读写安全,同时尽量减少锁竞争。

在 JDK 1.8 中,ConcurrentHashMap 的底层结构和 HashMap 类似:

数组 + 链表 + 红黑树

但是并发控制方式不一样。

JDK 1.8 中,ConcurrentHashMap 主要依赖:

CAS + synchronized + volatile

简单说:

  • 查询操作一般不加锁;
  • 插入时,如果桶为空,优先使用 CAS;
  • 如果桶不为空,对当前桶节点加锁;
  • 锁粒度不是整张表,而是尽量缩小到桶级别;
  • 扩容时支持多个线程协助迁移数据。

面试时可以先这样答:

ConcurrentHashMap 底层也是数组 + 链表 + 红黑树。

JDK 1.8 中,它主要通过 CAS + synchronized 保证并发写入安全。
如果桶为空,使用 CAS 插入;如果桶不为空,就对当前桶加 synchronized。
它不是锁整张表,而是尽量只锁当前桶,所以并发性能比 Hashtable 更好。

三、为什么 HashMap 不线程安全?

先看 HashMap

HashMap 本身没有任何并发控制。

如果多个线程同时修改同一个 HashMap,可能出现:

  • 数据覆盖;
  • 数据丢失;
  • 读取到不一致数据;
  • 扩容时结构异常;
  • 统计结果不准确。

下面写个简单例子。

import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.CountDownLatch;

public class HashMapUnsafeDemo {

    public static void main(String[] args) throws InterruptedException {
        Map<Integer, Integer> map = new HashMap<>();

        int threadCount = 10;
        int eachThreadCount = 10000;

        CountDownLatch latch = new CountDownLatch(threadCount);

        for (int i = 0; i < threadCount; i++) {
            int start = i * eachThreadCount;

            new Thread(() -> {
                for (int j = 0; j < eachThreadCount; j++) {
                    map.put(start + j, j);
                }
                latch.countDown();
            }).start();
        }

        latch.await();

        System.out.println("理论数量:" + threadCount * eachThreadCount);
        System.out.println("实际数量:" + map.size());
    }
}

多运行几次,可能会发现:

理论数量:100000
实际数量:99982

或者出现其他不稳定结果。

这就是因为多个线程同时修改 HashMap,没有任何线程安全保证。

所以:

多线程共享修改 Map 时,不建议使用 HashMap。


四、Hashtable 为什么不推荐?

既然 HashMap 不是线程安全的,那早期可以用 Hashtable

Hashtable 是线程安全的,因为它很多方法都加了 synchronized

类似这样:

public synchronized V put(K key, V value) {
    // ...
}

问题也在这里。

它锁的是整个对象。

也就是说,多个线程操作同一个 Hashtable 时,即使访问的是不同 key,也可能互相阻塞。

简单理解:

线程 A 操作 key1,要等锁
线程 B 操作 key2,也要等同一把锁
线程 C 操作 key3,还是要等同一把锁

所以 Hashtable 虽然线程安全,但锁粒度太粗,并发性能不好。

一句话总结:

Hashtable 线程安全,但锁的是整张表,性能较差,现在实际开发中基本不推荐使用。

五、ConcurrentHashMap 怎么解决问题?

ConcurrentHashMap 的核心思路是:

不要一上来锁整张表,能无锁就无锁,必须加锁时尽量缩小锁范围。

在 JDK 1.8 中,它主要通过下面几种方式保证线程安全:

  • CAS
  • synchronized
  • volatile
  • 桶级别加锁
  • 多线程协助扩容

简单理解:

读操作尽量不加锁
写操作尽量只锁当前桶
扩容时多个线程可以一起帮忙迁移数据

这就是它比 Hashtable 并发性能更好的核心原因。


六、JDK 1.7 和 JDK 1.8 有什么区别?

这个是面试重点。

1. JDK 1.7:Segment 分段锁

JDK 1.7 中,ConcurrentHashMap 使用的是:

Segment 分段锁

结构大概是:

ConcurrentHashMap
    ↓
Segment[]
    ↓
HashEntry[]

每个 Segment 可以理解成一个小的 HashMap。

不同线程访问不同 Segment 时,可以并发执行。

所以 JDK 1.7 的核心是:

分段锁

优点是:

  • 不锁整张表;
  • 不同 Segment 可以并发访问;
  • Hashtable 性能更好。

2. JDK 1.8:CAS + synchronized

JDK 1.8 取消了 Segment 分段锁。

底层结构变成:

数组 + 链表 + 红黑树

并发控制主要靠:

CAS + synchronized

锁粒度进一步缩小到桶级别。

也就是说:

JDK 1.8 不再锁一整个 Segment,而是尽量只锁当前桶。

面试时可以这样答:

JDK 1.7 的 ConcurrentHashMap 主要通过 Segment 分段锁实现线程安全。
JDK 1.8 取消了 Segment,底层结构变成数组 + 链表 + 红黑树,线程安全主要通过 CAS + synchronized 实现,锁粒度更细。

七、put 流程大概是什么?

不用死背源码,面试时能说清楚大概流程就行。

JDK 1.8 中,put 大概流程如下:

1. 判断 table 是否初始化
2. 根据 key 计算 hash
3. 根据 hash 定位数组下标
4. 如果桶为空,使用 CAS 放入节点
5. 如果桶不为空,对当前桶节点加 synchronized
6. 在链表或红黑树中插入或覆盖
7. 判断是否需要树化或扩容

简单版回答:

put 时先根据 key 计算 hash,然后定位到数组桶。
如果桶为空,就用 CAS 尝试插入。
如果桶不为空,说明发生冲突,就对当前桶加 synchronized,然后在链表或红黑树中插入。
插入完成后,再判断是否需要树化或扩容。

八、get 操作需要加锁吗?

一般不需要。

ConcurrentHashMap 的 get 操作通常是无锁的。

它主要依赖 volatile 保证可见性。

get 大概流程:

1. 根据 key 计算 hash
2. 定位桶位置
3. 如果第一个节点就是目标 key,直接返回
4. 否则在链表或红黑树中继续查找

面试时可以这样答:

ConcurrentHashMap 的 get 操作一般不加锁,主要依赖 volatile 保证可见性,所以读性能比较好。

这也是 ConcurrentHashMap 读性能比较高的一个原因。


九、ConcurrentHashMap 基本使用示例

最基础使用方式:

import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

public class ConcurrentHashMapBasicDemo {

    public static void main(String[] args) {
        Map<String, String> map = new ConcurrentHashMap<>();

        map.put("Java", "后端开发");
        map.put("Redis", "缓存");
        map.put("MySQL", "数据库");

        System.out.println(map.get("Java"));
        System.out.println(map.get("Redis"));
        System.out.println(map.get("MySQL"));
    }
}

输出:

后端开发
缓存
数据库

这种用法和普通 HashMap 很像。

区别是:

ConcurrentHashMap 更适合多线程共享读写场景。


十、多线程写入示例

ConcurrentHashMap 改造前面的例子。

import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch;

public class ConcurrentHashMapSafeDemo {

    public static void main(String[] args) throws InterruptedException {
        Map<Integer, Integer> map = new ConcurrentHashMap<>();

        int threadCount = 10;
        int eachThreadCount = 10000;

        CountDownLatch latch = new CountDownLatch(threadCount);

        for (int i = 0; i < threadCount; i++) {
            int start = i * eachThreadCount;

            new Thread(() -> {
                for (int j = 0; j < eachThreadCount; j++) {
                    map.put(start + j, j);
                }
                latch.countDown();
            }).start();
        }

        latch.await();

        System.out.println("理论数量:" + threadCount * eachThreadCount);
        System.out.println("实际数量:" + map.size());
    }
}

输出一般是:

理论数量:100000
实际数量:100000

这说明在多线程并发写入时,ConcurrentHashMap 能保证单次 put 操作的线程安全。


十一、ConcurrentHashMap 能不能存 null?

不能。

ConcurrentHashMap 不允许:

  • null key;
  • null value。

示例:

import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

public class ConcurrentHashMapNullDemo {

    public static void main(String[] args) {
        Map<String, String> map = new ConcurrentHashMap<>();

        map.put(null, "Java");
    }
}

运行会报:

NullPointerException

再看 value 为 null:

import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

public class ConcurrentHashMapNullValueDemo {

    public static void main(String[] args) {
        Map<String, String> map = new ConcurrentHashMap<>();

        map.put("Java", null);
    }
}

也会报:

NullPointerException

为什么不允许 null?

主要是为了避免并发场景下产生歧义。

比如:

map.get("A");

如果返回 null,到底表示:

key 不存在?
还是 value 本身就是 null?

在并发环境下,这个判断会更复杂。

所以 ConcurrentHashMap 直接禁止 null key 和 null value。

面试回答:

ConcurrentHashMap 不允许 null key 和 null value。
主要是为了避免并发环境下 get 返回 null 时产生歧义,无法判断是 key 不存在,还是 value 本身就是 null。

十二、组合操作不一定线程安全

这个点很重要。

ConcurrentHashMap 能保证单次操作线程安全,比如:

map.put(key, value);
map.get(key);
map.remove(key);

但它不能保证你写的一组组合逻辑天然线程安全。

比如下面这种写法:

if (!map.containsKey("Java")) {
    map.put("Java", "后端开发");
}

这不是原子操作。

可能线程 A 和线程 B 都判断不存在,然后都执行 put。

看个例子。

import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch;

public class ContainsKeyAndPutDemo {

    public static void main(String[] args) throws InterruptedException {
        Map<String, Integer> map = new ConcurrentHashMap<>();

        int threadCount = 10;
        CountDownLatch latch = new CountDownLatch(threadCount);

        for (int i = 0; i < threadCount; i++) {
            int value = i;

            new Thread(() -> {
                if (!map.containsKey("count")) {
                    map.put("count", value);
                }
                latch.countDown();
            }).start();
        }

        latch.await();

        System.out.println(map);
    }
}

这段代码不一定能体现特别明显的问题,但逻辑上它不是原子的。

更推荐写法是:

map.putIfAbsent("count", 1);

完整示例:

import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

public class PutIfAbsentDemo {

    public static void main(String[] args) {
        Map<String, Integer> map = new ConcurrentHashMap<>();

        map.putIfAbsent("count", 1);
        map.putIfAbsent("count", 2);

        System.out.println(map.get("count"));
    }
}

输出:

1

因为第一次插入成功后,第二次发现 key 已存在,就不会覆盖。

所以面试时要补一句:

ConcurrentHashMap 保证的是单次操作线程安全。
如果是先判断再修改这种组合操作,要使用 putIfAbsent、computeIfAbsent 这类原子方法。

十三、computeIfAbsent 示例

computeIfAbsent 也是实际开发里很常用的方法。

它的作用是:

如果 key 不存在,就根据 key 计算一个 value 放进去;如果 key 已存在,就直接返回旧值。

示例:

import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

public class ComputeIfAbsentDemo {

    public static void main(String[] args) {
        Map<String, String> map = new ConcurrentHashMap<>();

        String value1 = map.computeIfAbsent("Java", key -> key + " 后端开发");
        String value2 = map.computeIfAbsent("Java", key -> key + " 新值");

        System.out.println(value1);
        System.out.println(value2);
        System.out.println(map);
    }
}

输出:

Java 后端开发
Java 后端开发
{Java=Java 后端开发}

第二次不会重新计算,因为 key 已经存在。

实际开发中,可以用它做缓存初始化。

import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

public class LocalCacheDemo {

    private static final Map<Long, String> userCache = new ConcurrentHashMap<>();

    public static void main(String[] args) {
        String userName = getUserName(1001L);
        System.out.println(userName);

        String userName2 = getUserName(1001L);
        System.out.println(userName2);
    }

    public static String getUserName(Long userId) {
        return userCache.computeIfAbsent(userId, id -> queryUserNameFromDb(id));
    }

    private static String queryUserNameFromDb(Long userId) {
        System.out.println("查询数据库,userId=" + userId);
        return "用户" + userId;
    }
}

输出:

查询数据库,userId=1001
用户1001
用户1001

可以看到,同一个 userId 第二次不会再查数据库。

当然,真实项目里如果做本地缓存,还要考虑:

  • 数据过期;
  • 数据一致性;
  • 内存占用;
  • 是否需要 Caffeine、Redis 这类缓存组件。

这里主要是演示 computeIfAbsent 的用法。


十四、并发计数不要直接 get 后 put

有些人会这样写计数逻辑:

Integer count = map.get("success");
if (count == null) {
    map.put("success", 1);
} else {
    map.put("success", count + 1);
}

这在并发场景下是不安全的。

因为多个线程可能同时读到同一个旧值,然后覆盖写入。

错误示例:

import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch;

public class WrongCounterDemo {

    public static void main(String[] args) throws InterruptedException {
        Map<String, Integer> map = new ConcurrentHashMap<>();

        int threadCount = 10;
        int eachThreadCount = 10000;

        CountDownLatch latch = new CountDownLatch(threadCount);

        map.put("success", 0);

        for (int i = 0; i < threadCount; i++) {
            new Thread(() -> {
                for (int j = 0; j < eachThreadCount; j++) {
                    Integer count = map.get("success");
                    map.put("success", count + 1);
                }
                latch.countDown();
            }).start();
        }

        latch.await();

        System.out.println("理论数量:" + threadCount * eachThreadCount);
        System.out.println("实际数量:" + map.get("success"));
    }
}

可能输出:

理论数量:100000
实际数量:42631

原因是:

get 和 put 分开执行,这组操作不是原子的。

更推荐的写法是使用 AtomicIntegerLongAdder

例如:

import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.atomic.LongAdder;

public class RightCounterDemo {

    public static void main(String[] args) throws InterruptedException {
        Map<String, LongAdder> map = new ConcurrentHashMap<>();

        int threadCount = 10;
        int eachThreadCount = 10000;

        CountDownLatch latch = new CountDownLatch(threadCount);

        map.put("success", new LongAdder());

        for (int i = 0; i < threadCount; i++) {
            new Thread(() -> {
                for (int j = 0; j < eachThreadCount; j++) {
                    map.get("success").increment();
                }
                latch.countDown();
            }).start();
        }

        latch.await();

        System.out.println("理论数量:" + threadCount * eachThreadCount);
        System.out.println("实际数量:" + map.get("success").sum());
    }
}

输出:

理论数量:100000
实际数量:100000

也可以结合 computeIfAbsent

import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.atomic.LongAdder;

public class CounterWithComputeIfAbsentDemo {

    public static void main(String[] args) throws InterruptedException {
        Map<String, LongAdder> map = new ConcurrentHashMap<>();

        int threadCount = 10;
        int eachThreadCount = 10000;

        CountDownLatch latch = new CountDownLatch(threadCount);

        for (int i = 0; i < threadCount; i++) {
            new Thread(() -> {
                for (int j = 0; j < eachThreadCount; j++) {
                    map.computeIfAbsent("success", key -> new LongAdder()).increment();
                }
                latch.countDown();
            }).start();
        }

        latch.await();

        System.out.println("理论数量:" + threadCount * eachThreadCount);
        System.out.println("实际数量:" + map.get("success").sum());
    }
}

这也是实际项目里比较常见的写法。


十五、ConcurrentHashMap 和 HashMap 的区别

简单对比:

对比项

HashMap

ConcurrentHashMap

线程安全

不安全

安全

并发场景

不适合

适合

null key

允许一个 null key

不允许

null value

允许 null value

不允许

底层结构

数组 + 链表 + 红黑树

数组 + 链表 + 红黑树

典型用途

普通 Map

并发共享 Map

一句话:

HashMap 适合单线程或局部变量场景;ConcurrentHashMap 适合多线程共享读写场景。

十六、ConcurrentHashMap 和 Hashtable 的区别

对比项

Hashtable

ConcurrentHashMap

线程安全

安全

安全

锁粒度

整表锁

桶级别锁

性能

较差

更好

null key/value

不允许

不允许

推荐程度

不推荐

推荐

一句话:

Hashtable 是早期线程安全 Map,方法级 synchronized 锁粒度太粗;
ConcurrentHashMap 锁粒度更细,并发性能更好,实际开发中更推荐。

十七、实际开发中怎么用?

1. 多线程任务结果记录

import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

public class TaskResultDemo {

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

    public static void main(String[] args) {
        taskResultMap.put("task_001", "SUCCESS");
        taskResultMap.put("task_002", "FAIL");

        System.out.println(taskResultMap.get("task_001"));
    }
}

2. 本地缓存

import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

public class UserCacheDemo {

    private static final Map<Long, UserInfo> userCache = new ConcurrentHashMap<>();

    public static void main(String[] args) {
        UserInfo user = getUserInfo(1001L);
        System.out.println(user);
    }

    public static UserInfo getUserInfo(Long userId) {
        return userCache.computeIfAbsent(userId, UserCacheDemo::queryUserFromDb);
    }

    private static UserInfo queryUserFromDb(Long userId) {
        System.out.println("模拟查询数据库,userId=" + userId);
        return new UserInfo(userId, "用户" + userId);
    }

    static class UserInfo {
        private Long userId;
        private String userName;

        public UserInfo(Long userId, String userName) {
            this.userId = userId;
            this.userName = userName;
        }

        @Override
        public String toString() {
            return "UserInfo{" +
                    "userId=" + userId +
                    ", userName='" + userName + '\'' +
                    '}';
        }
    }
}

3. 批处理分组统计

import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.LongAdder;

public class GroupCountDemo {

    public static void main(String[] args) {
        List<String> statusList = Arrays.asList(
                "SUCCESS", "FAIL", "SUCCESS", "PROCESSING",
                "SUCCESS", "FAIL", "SUCCESS"
        );

        Map<String, LongAdder> countMap = new ConcurrentHashMap<>();

        statusList.parallelStream().forEach(status -> {
            countMap.computeIfAbsent(status, key -> new LongAdder()).increment();
        });

        countMap.forEach((key, value) -> {
            System.out.println(key + " = " + value.sum());
        });
    }
}

输出类似:

PROCESSING = 1
SUCCESS = 4
FAIL = 2

这个例子里:

  • ConcurrentHashMap 保证并发访问安全;
  • LongAdder 适合高并发计数;
  • computeIfAbsent 保证初始化逻辑更简洁。

十八、ConcurrentHashMap 一定不会有并发问题吗?

不是。

这一点面试里很加分。

ConcurrentHashMap 只能保证 Map 自身提供的单个操作是线程安全的。

但业务层面的组合逻辑,不一定线程安全。

例如:

if (!map.containsKey(key)) {
    map.put(key, value);
}

这就是典型的组合操作,不是原子的。

更推荐:

map.putIfAbsent(key, value);

或者:

map.computeIfAbsent(key, k -> value);

再比如计数:

错误写法:

map.put(key, map.get(key) + 1);

推荐:

map.computeIfAbsent(key, k -> new LongAdder()).increment();

所以面试可以补一句:

ConcurrentHashMap 不是万能的。
它保证的是容器内部操作的线程安全,但如果业务代码由多个操作组合而成,仍然要考虑原子性。

十九、面试回答模板

如果面试官问:

ConcurrentHashMap 为什么线程安全?

可以这样回答:

ConcurrentHashMap 是线程安全的 Map,适合多线程并发读写场景。

JDK 1.7 中主要通过 Segment 分段锁实现线程安全,不同 Segment 可以并发访问,避免像 Hashtable 一样锁整张表。

JDK 1.8 中取消了 Segment,底层结构变成数组 + 链表 + 红黑树,线程安全主要通过 CAS + synchronized 实现。put 时,如果桶为空,会使用 CAS 插入;如果桶不为空,会对当前桶节点加 synchronized,然后在链表或红黑树中完成插入或更新。

它的 get 操作一般不加锁,主要依赖 volatile 保证可见性,所以读性能比较好。

相比 Hashtable 直接锁整个方法,ConcurrentHashMap 锁粒度更细,并发性能更好。

不过 ConcurrentHashMap 只能保证单次操作线程安全,如果是 containsKey 再 put 这种组合操作,还是要用 putIfAbsent、computeIfAbsent 这类原子方法。

这段回答基本就够用了。


二十、一句话总结

ConcurrentHashMap 的核心不是简单“加锁”。

它真正的重点是:

CAS + synchronized + 更细粒度锁控制

JDK 1.7 靠分段锁。

JDK 1.8 靠 CAS + synchronized,锁粒度缩小到桶级别。

面试时只要把下面几个点讲清楚:

  • 为什么 HashMap 不安全;
  • Hashtable 为什么性能差;
  • JDK 1.7 和 JDK 1.8 的区别;
  • put 和 get 的大概流程;
  • 为什么不能存 null;
  • 组合操作为什么仍然要注意原子性;

基本就不会乱。

大家加油:)


Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐