Java并发面经(二)

22.ConcurrentHashMap的底层实现

JDK7 用Segment 分段锁 + 数组 + 链表 ,把整个 Map 拆成多个 Segment,每个 Segment 是一把 ReentrantLock,锁粒度粗;JDK8 改成数组 + 链表 / 红黑树 ,用synchronized+CAS 来锁数组里的单个桶节点,锁粒度更细,链表过长会转红黑树提升查询效率,去掉了 Segment 层。

23.ConcurrentHashMap为什么是线程安全的

JDK7 :核心是分段锁机制 。操作数据时,先定位到对应的 Segment,然后对该 Segment 加锁(ReentrantLock),只阻塞操作同一 Segment 的线程,不影响其他 Segment 的读写,既保证了线程安全,又避免了全表锁的低效。

JDK8 :通过 CAS + synchronized + volatile 组合实现。读操作利用 volatile 修饰的 Node 节点保证可见性,无需加锁;写操作时,先通过 CAS 尝试无锁更新,若失败(存在并发冲突)则对当前桶节点加 synchronized 锁,只锁当前桶,锁粒度极细,并发效率大幅提升。

24.ConcurrentHashMap的key和value为什么不能为null

一是为了避免歧义:如果 get 返回 null,分不清是 key 不存在还是 value 本身就是 null;二是并发场景下,null 值会导致判断逻辑混乱,比如containsKey(key)get(key)结果不一致,所以设计上直接禁止存 null。

25.ConcurrentHashMap的put方法如何保证数组元素的可见性

JDK7 :通过 UNSAFE.getObjectVolatile()putObjectVolatile() 读写 Segment 内的 HashEntry 节点,利用 volatile 的内存语义,保证一个线程对节点的修改能被其他线程立刻看到。

JDK8 :数组 Node 节点被 volatile 修饰,put 操作时,要么通过 CAS 进行 volatile 写,要么在 synchronized 锁内修改节点,锁释放后会刷新缓存,保证其他线程能读取到最新的数组元素。

26.ConcurrentHashMap扩容时怎么保证数组安全

扩容前会对当前桶节点加 synchronized 锁,防止迁移过程中其他线程修改该桶数据。

迁移时,旧桶数据会被标记为 "正在迁移",读请求会优先读取旧桶数据,写请求会等待迁移完成。

迁移完成后,旧桶节点会被指向新数组的对应位置,通过 volatile 保证更新可见性,确保后续读写操作能访问到新数据,不会出现数据丢失或错乱。

27.HashMap、Hashtable、ConcurrentHashMap的核心区别

HashMap 是线程不安全的,无锁设计让它单线程性能很高,允许存储一个 null 键和多个 null 值,底层是数组 + 链表 + 红黑树,采用单线程扩容,JDK1.7 版本还存在链表头插法导致的死循环风险;Hashtable 是线程安全的,但用全局 synchronized 锁让所有操作排队执行,并发性能极低,不支持 null 键值,底层只有数组 + 链表,同样是单线程扩容;ConcurrentHashMap(JDK8)通过 CAS + synchronized 锁单个桶节点实现细粒度线程安全,既保证了高并发读写效率,又支持多线程并发扩容,不支持 null 键值,底层结构和 JDK8 的 HashMap 一致,是数组 + 链表 + 红黑树。

28.ConcurrentHashMap的put方法

put 方法会先根据 key 计算出对应的数组桶下标,然后尝试用 CAS 无锁方式更新节点,如果更新失败(说明有并发修改),就会对当前桶节点加 synchronized 锁,保证同一时间只有一个线程能修改这个桶的数据;接着会遍历链表或红黑树,找到对应 key 就更新值,没找到就新增节点,最后判断是否需要扩容,整个过程只锁当前桶,不会影响其他桶的操作,既保证了线程安全又提升了并发效率。

29.ConcurrentHashMap的get方法

get 方法是完全无锁的,它会先根据 key 计算出桶下标,然后直接读取被 volatile 修饰的桶节点,保证能看到最新的节点数据;接着遍历链表或红黑树,通过 equals 匹配 key,找到就返回对应 value,没找到就返回 null,因为读操作不会修改数据结构,所以不需要加锁,靠 volatile 就能保证可见性,实现了高并发读取。

30.ConcurrentHashMap的size方法

size 方法用来统计 Map 中所有元素的总数,它会先遍历所有桶,累加每个桶的元素数量并记录修改次数(modCount),如果两次遍历的 modCount 一致,说明遍历期间没有发生修改,就直接返回累加结果;如果 modCount 不一致,说明有并发修改,会再重试一次,如果还是不一致就会对所有桶加锁,最后再遍历一次统计总数,所以 size 方法在并发场景下效率不高,尽量少用。

31.ConcurrentHashMap扩容的触发条件

ConcurrentHashMap 的扩容触发条件和 HashMap 基本一致:当一次 put 操作完成后,若当前数组中已使用的节点数量达到「数组长度 × 负载因子(默认 0.75)」,并且本次插入的是链表节点(非红黑树节点)时,就会触发扩容;数组默认长度为 16,每次扩容后长度都会变为原来的 2 倍(始终保持 2 的幂),这样能保证 hash 计算时的下标定位逻辑依然准确。

32.ConcurrentHashMap并发扩容的核心逻辑

JDK1.8 的 ConcurrentHashMap 支持多线程并发扩容 ,核心是「分段迁移」思想来避免单线程扩容耗时过长:触发扩容后先创建一个长度为原数组 2 倍的新 Node 数组,通过 volatile 修饰的 transferIndex 变量标记当前待迁移的桶范围,多个线程通过 CAS 竞争获取迁移任务(每个线程负责迁移一部分桶);迁移时会对原桶加锁,将链表或红黑树节点按新 hash 值定位到新数组的对应桶中,完成后把原桶头节点设为 ForwardingNode 扩容标记,引导后续读写直接访问新数组,等所有桶迁移完毕后用新数组替换旧数组,扩容结束;和 HashMap 相比,它支持多线程并行迁移,不会出现 JDK1.7 HashMap 那样的死循环问题,并发扩容更高效安全。

33.ConcurrentHashMap 与 HashMap 扩容区别

ConcurrentHashMap支持多线程并发扩容,不会出现死循环;HashMap单线程扩容,JDK1.7还会因链表头插法导致多线程扩容时出现环形链表死循环问题,发场景下扩容安全性和效率都远不如 ConcurrentHashMap。

34. JDK1.8 为什么取消分段锁?

JDK1.8 把分段锁换掉,核心就是想让并发更快、代码更简单:

以前的分段锁,锁的范围还是太大了,只要多个线程碰同一个 Segment,就得排队等,性能上不去;后来 CAS 技术成熟了,配合 synchronized 只锁数组里的一个桶节点,锁的粒度细了很多,很多时候甚至不用加锁就能完成操作,并发效率比分段锁高一大截;而且去掉 Segment 这一层后,整个结构更清爽,代码更好维护,查询和扩容的速度也更快了。

35.MESI一致性协议

MESI 是用来保证多个 CPU 核心缓存数据一致的协议,它给每个缓存行标了四种状态:修改、独占、共享、失效。只有一个核心读数据时,缓存是独占状态;别的核心也来读,就都变成共享状态;要是有核心要改数据,会先通知其他核心把对应缓存标记为失效,自己改完后标记为修改状态,等其他核心再读时,会从这个修改过的核心同步最新值,重新回到共享状态,这样多核心下数据就不会乱了。(虽然能保证读到的数据都是一致的,但是失去了原子性)

36.happens-before原则

happens-before 是 Java 内存模型(JMM)向程序员提供的执行顺序与可见性保证,如果操作 A happens-before 操作 B,JVM 就会保证 A 的结果 B 一定能看到、逻辑上 A 也一定在 B 前面运行,但底层编译器和 CPU 为了跑得快,可以随便重排指令,只要最终结果和按顺序跑一样就行,它只保结果正确、不保底层真的按顺序执行。

37.并发三大特性

可见性、原子性、有序性

38.什么是原子操作

指的是一个指令或一系列指令,要么完全执行完毕,要不完全不执行,中间不会被任何其他操作打断。也不允许其他cpu内核看到执行的一个中间状态

39.缓存的写策略

写策略分为写直达和写回策略,写直达就是每次修改缓存数据时,必须立刻同步更新到内存 ,保证缓存和内存里的数据永远一致,但因为频繁写内存,速度会慢一些,好处是数据安全、不容易丢。写回策略则是先只改缓存里的数据,给它打个 "脏" 标记,暂时不碰内存,等这块缓存要被新数据挤出去(替换)时,再一次性把修改后的数据写回内存,这样能减少对内存的访问次数,速度更快,但要多一步判断 "脏不脏" 的逻辑,万一断电可能丢数据。

40.JMM中的工作内存和主存

主存是所有线程共享的内存区域,存储所有共享变量。每个线程都有独立的工作内存,其中保存了该线程所需共享变量的副本。线程对变量的所有读写操作都必须在工作内存中完成,不能直接读写主存。不同线程之间无法直接访问对方的工作内存,线程间共享变量的传递必须通过主存完成。

41.多线程下变量不可见性的原因

多线程下变量修改不可见,核心原因是线程操作的是共享变量在自己工作内存里的副本,而非直接操作主内存:一个线程修改了自己工作内存中的变量副本后,不会立刻把新值刷新到主内存;而其他线程仍在读取自己工作内存里的旧副本,也不会主动去主内存加载最新值,所以一方的修改对另一方就不可见了。

42.volatile关键字的核心作用

volatile 有两个核心作用:一是保证可见性 ,当一个变量被 volatile 修饰后,线程修改它时会立刻把新值刷到主内存,同时让其他线程里这个变量的副本全部失效;其他线程再读这个变量时,发现自己的副本已经没用了,就必须去主内存重新读取最新值,这样就能保证一个线程的修改对其他线程立刻可见。二是禁止指令重排序 ,编译器和 CPU 不能随意打乱被 volatile 修饰变量的读写顺序,避免多线程下因指令乱序导致的逻辑错误,比如防止对象创建时半初始化的问题。

相关推荐
小雷君2 小时前
SpringBoot 接口开发5个高频踩坑总结
java·spring boot·后端·面试
aloha_7892 小时前
软考高项-第二章-信息技术发展
java·人工智能·python·学习
寒秋花开曾相惜2 小时前
(学习笔记)3.8 指针运算(3.8.5 变长数组)
java·c语言·开发语言·笔记·学习
南境十里·墨染春水2 小时前
C++笔记 构造函数 析构函数 及二者关系(面向对象)
开发语言·c++·笔记·ecmascript
Dxy12393102162 小时前
Python如何删除文件到回收站
开发语言·python
斌味代码2 小时前
RAG 实战:用 LangChain + DeepSeek 搭建企业私有知识库问答系统
开发语言·langchain·c#
途经六月的绽放2 小时前
常见设计模式及其应用示例
java·设计模式
REI-2 小时前
黑马点评项目启动
java·后端
AlunYegeer3 小时前
【JAVA】网关的管理原理和微服务的Interceptor区分
java·服务器·前端