并发
CAS
CAS (Compare And Swap)是一种乐观锁,它默认没有其他线程来竞争,直到真正更新数据的那一刻才去验证。
CAS 操作需要三个核心操作数:
- V (Value offset):内存中实际的当前值。
- A (Expected value):你“认为”现在的旧值是多少。
- B (New value):你想要更新成的新值。
执行逻辑: 只有当内存里的真实值 V 等于你预期的旧值 A 时,才把 V 更新为 B。如果不等于,说明在此期间有别的线程插队改了这个值,更新失败。
CAS是如何实现原子性的?
要保证“先比较,再交换”的原子性。
Java 本身无法直接操作内存,但通过 Unsafe 类,可以调用 C++ 编写的 native 方法。CAS 的底层最终会映射到 CPU 的一条硬件指令 cmpxchg。因为它是硬件级别的指令,所以 CPU 会在指令级别保证这一个动作的绝对原子性,中途绝不会发生线程上下文切换。
CAS 真的无锁吗?
Java API 层面是无锁(乐观锁),硬件 CPU 层面不是完全没有锁,有lock总线锁 / 缓存锁。
cmpxchg在硬件层面还是通过总线锁或者缓存锁来实现。
自旋
在实际应用中,CAS 通常会搭配自旋(一个 do-while 循环)来使用。
如果发现 V 不等于 A,说明被别人抢先了。这时候线程不会阻塞挂起,而是立刻重新去读取最新的 V,把它作为新的 A,再次尝试计算和 CAS 操作,直到成功为止。这就是所谓的“自旋锁”。
ABA 问题
- 第一步:自己主动读一次内存(get ()),存到线程本地变量
- 第二步:调用 CAS(compareAndSet),CAS 指令内部又再读一次内存
场景描述: 假设内存里的值原来是 A。线程 1 读到了 A,正准备把它改成 C。但就在线程 1 准备执行 CAS 之前,线程 2 飞速地把 A 改成了 B,然后又改回了 A。
后果: 此时线程 1 执行 CAS,发现内存里的值“依然”是 A,于是更新成功了。但实际上,这个 A 已经不是原来那个纯洁的 A 了(经历了 A -> B -> A 的变化)。在普通数字递增的场景下没问题,但在链表节点替换或带有状态流转的复杂业务中,会引发严重错误。
解决方法: 引入版本号机制。每次修改不仅更新值,还给版本号加 1(比如 1A -> 2B -> 3A)。Java 提供了 AtomicStampedReference 类,内部维护了一个带时间戳(或版本号)的引用,只有当“值”和“版本号”同时匹配时,CAS 才算成功。
synchronized
| 修饰位置 | 示例代码 | 锁定的实际对象 | 锁的颗粒度 | 阻塞范围与影响 |
|---|---|---|---|---|
| 普通实例方法 | public synchronized void doX() |
当前实例对象 (this) |
较大 | 仅阻塞竞争同一个实例对象的其他线程。不同实例对象之间互不干扰。 |
| 静态方法 | public static synchronized void doY() |
当前类的 Class 对象 (如 MyClass.class) |
最大 | 阻塞竞争该类所有实例的静态同步方法的线程。注意:它与普通实例方法抢的是两把不同的锁,互不干扰。 |
| 同步代码块 | synchronized(lockObj) { ... } |
括号中显式传入的具体对象 | 最小 | 仅阻塞尝试获取同一个指定对象的线程。极度推荐,可缩小同步范围,或利用不同对象实现锁分离。 |
锁是存在对象的对象头的 Mark Word里的
锁升级流程
在 JDK 1.6 之前,synchronized 是纯粹的“重量级锁”,只要使用它,就会直接向底层操作系统申请 Mutex Lock(互斥量),导致线程在用户态和内核态之间频繁切换,性能开销极大。
无锁 → 偏向锁 → 轻量级锁 → 重量级锁(锁只能升级,不能降级)
无锁
对象刚被 new 出来时,没有任何线程来竞争。此时 Mark Word 里主要记录的是对象的 HashCode 和分代年龄。
偏向锁
当第一个线程(假如是 Thread A)来获取锁时,JVM 发现现在是无锁状态,于是通过刚才讲过的 CAS 操作,把 Thread A 的线程 ID 记录到 Mark Word 中。
- 核心思想: 锁“偏心”于第一个拿到它的线程。
- 优势: 如果之后一直是 Thread A 来要这把锁,它连 CAS 都不用做,只要检查一下 Mark Word 里的线程 ID 是不是自己,就能直接长驱直入,开销接近于 0。
轻量级锁
假设此时 Thread B 来了,它看到 Mark Word 里写着 Thread A 的名字,发现锁被占了。这就是**“锁竞争”**的开始。 此时,偏向锁被撤销,升级为轻量级锁。
- 核心思想: Thread B 觉得 Thread A 应该很快就会用完锁,没必要去阻塞挂起,不如在原地等一会儿。
- 底层机制: Thread B 开始执行 自旋 (也就是一个 while 循环),不断地用 CAS 尝试去把 Mark Word 更新为指向自己的锁记录。
- 优势: 避免了线程被操作系统挂起和唤醒的巨大开销。
重量级锁
自旋是非常消耗 CPU 资源的(相当于 CPU 在空转)。如果 Thread B 自旋了很久(默认 10 次或者自适应自旋),Thread A 还是没有释放锁;或者在这个期间,Thread C、Thread D 也跑来抢锁了。 JVM 发现局面控制不住了,就会把锁膨胀为重量级锁。
- 核心思想: 放弃无谓的 CPU 空转,把所有没抢到锁的线程全部丢进等待队列,让操作系统去管理它们的休眠和唤醒。
- 代价: Mark Word 此时指向了重量级锁的指针(ObjectMonitor)。线程的阻塞和唤醒需要跨越到内核态,性能大打折扣。
monitor(监视器锁)
1 | class ObjectMonitor { |
- 每个对象在 JVM 里对应一个
ObjectMonitor(监视器对象),里面有锁池(Entry Set)、等待池(Wait Set)、计数器等等 - synchronized 编译成字节码后是
monitorenter/monitorexit两条指令:进入时执行 monitorenter 尝试获取锁,退出时执行 monitorexit 释放锁。 - 获取锁:把 monitor 的持有者标记为当前线程,计数器 +1。
- 可重入:同一个线程再次进入,计数器再 +1(这也是为什么 synchronized 是可重入的);退出时 -1,减到 0 才真正释放,别的线程才能拿到。
AQS
AQS 是什么:JDK 提供的一个并发同步器基类
AQS 的两个核心成员:
volatile int state:同步状态位。0 表示锁空闲,>0 表示被占用(可重入时累加)。具体含义由子类定义。- CLH 双向队列:抢不到锁的线程包装成节点(Node)排进队尾,阻塞等待;锁释放后唤醒队头节点。
工作流程
- 线程尝试用 CAS 把
state从 0 改成 1;成功 → 拿到锁,标记持有者为当前线程。 - 失败 → 把当前线程包装成 Node 入队(CLH 队尾)。
- 入队后自旋 / 阻塞,检查前驱节点;前驱是头节点且抢锁成功 → 出队执行;否则挂起(park)。
- 释放锁 →
state减回 0 → 唤醒后继节点(unpark)。
公平锁 vs 非公平锁
- 非公平锁(默认):新线程来了先插队 CAS 抢一次,抢不到才入队排队。优点:吞吐高(减少线程切换);缺点:可能造成队头饥饿。
- 公平锁:先来先服务,严格按入队顺序拿锁。优点:公平;缺点:吞吐略低,且频繁让线程在阻塞 / 唤醒间切换。
ReentrantLock
可重入的意思是:如果一个线程已经拿到了这把锁,当它再次进入需要这把锁的代码块时,不会被自己阻塞,而是可以畅通无阻地继续执行,只是把“加锁次数”加 1。
与synchronized区别:
| 维度 | synchronized | ReentrantLock |
|---|---|---|
| 实现层次 | JVM 层面 (内置关键字,基于 C++ 实现的 ObjectMonitor) | API 层面 (JDK 类,基于 AQS 和 CAS 实现) |
| 加解锁方式 | 自动释放 (进入/退出同步代码块,发生异常也会自动释放,不会死锁) | 手动控制 (必须手动调用 lock(),并在 finally 块中 unlock()) |
| 响应中断与超时 | 死等 (不可中断,拿不到锁就一直阻塞) | 灵活 (支持 lockInterruptibly() 响应中断,支持 tryLock() 超时返回) |
| 公平性 | 只能是非公平锁 | 默认非公平锁,但可在构造函数传 true 实现公平锁 |
| 条件队列 | 只有一个等待队列 (wait()/notify()) |
支持绑定多个 Condition 对象,可实现多路、精准唤醒 |
Atomic 原子类家族
| 分类 | 代表 | 场景 |
|---|---|---|
| 基本类型 | AtomicInteger/AtomicLong/AtomicBoolean |
计数器、标志位 |
| 数组 | AtomicIntegerArray 等 |
数组元素原子更新 |
| 引用 | AtomicReference、AtomicStampedReference(带版本)、AtomicMarkableReference(带标记) |
原子替换对象,ABA 解决 |
| 累加器(JDK8) | LongAdder、LongAccumulator |
高并发计数 |
AtomicLong 和 LongAdder 有什么区别?
AtomicLong 在高并发下所有线程都抢同一个 value,CAS 冲突大、自旋多、性能差。
LongAdder(分段更新 / 空间换时间):
- 核心思想类似
ConcurrentHashMap的分段锁。它内部维护了一个基础值base和一个Cell数组。 - 低并发时: 直接使用 CAS 更新
base的值(表现和AtomicLong一样)。 - 高并发时(出现 CAS 冲突): 它不再让所有线程去死磕
base,而是把线程哈希(Hash)映射到Cell数组的不同位置,让线程去 CAS 更新各自对应的Cell里的值。
ConcurrentHashMap
HashMap 并发问题
针对 HashMap 并发问题,准确的底层表现是:当两个线程同时判断某个数组下标为空并准备放入新节点时,由于没有加锁,后执行内存写入的线程会直接覆盖前一个线程初始化的 Node,造成数据彻底丢失
ConcurrentHashMap不像Hashtable 给整表加锁,而是用细粒度锁,并发度高。
JDK 1.7 结构(分段锁 Segment)
- 一个 Map 分成多个 Segment(默认 16),每个 Segment 内部是个小 HashTable。
- 读写只锁命中的那个 Segment,其他 Segment 可并发 → 并发度 = Segment 数量。
- 缺点:结构复杂、分段数固定、扩容麻烦。
JDK 1.8 结构(CAS + synchronized + 红黑树)
- 放弃分段,改回数组 + 链表 / 红黑树(和 HashMap 1.8 类似)。
- 并发控制:空桶用 CAS 直接放;非空桶用 synchronized 锁住桶头节点。
- 链表长度 ≥ 8 且数组长度 ≥ 64 → 转红黑树;红黑树节点 ≤ 6 → 转回链表。
- 锁粒度更细(只锁一个桶),性能更好。
与普通的 HashMap 不同,ConcurrentHashMap 不允许 key 或 value 为 null。传入 null 会抛出空指针异常。
注:官方在 1.8 弃用 ReentrantLock 改用 synchronized,是因为 JVM 对 synchronized 做了大量锁升级优化(偏向锁 -> 轻量级锁 -> 重量级锁),且如果是每个节点都配备一把 ReentrantLock,内存开销会非常大。
核心 put() 流程
- 计算 Hash: 扰动函数计算 key 的 hash 值。
- 数组初始化: 采用懒加载,第一次 put 时才通过 CAS 初始化数组(默认长度 16)。
- 寻找槽位:
- 如果该位置为空,CAS 尝试插入。成功则结束,失败则进入下一次自旋。
- 如果该位置节点的
hash == -1(即MOVED状态),说明当前 ConcurrentHashMap 正在扩容,当前线程会调用helpTransfer()协助扩容(这是 1.8 的一大亮点,多线程协同搬运数据)。
- 加锁写入: 如果该位置有数据,利用
synchronized锁住头节点,遍历链表或红黑树进行节点的插入或更新。 - 树化判断: 插入完成后,如果链表长度 链表长度 ≥ 8 且数组长度 ≥ 64,将其转化为红黑树。
- 统计数量: 调用
addCount()增加元素个数,并判断是否需要触发扩容。
统计 size 的设计
普通 HashMap 只有一个 size 变量,而在高并发下,所有线程用 CAS 修改同一个 size 会导致严重冲突。 ConcurrentHashMap 借鉴了无锁模块中 LongAdder 的思想:
- 内部维护了一个
baseCount和一个CounterCell[]数组。 - 并发量低时,直接 CAS 更新
baseCount。 - 并发量高时,将数量累加分散到不同的
CounterCell槽中。 - 最终调用
size()时,结果是baseCount加上所有CounterCell的值。
ThreadLocal 原理
不是 ThreadLocal 对象里存着所有线程的数据,而是每个 Thread 对象里有自己的 ThreadLocalMap
ThreadLocalMap
每个线程对象内部都有一个名为 ThreadLocalMap 的属性,这就是存储数据的真正容器。
这个 Map 内部维护了一个 Entry 数组,每个 Entry 存一组 key、value:
key:对 ThreadLocal 对象的弱引用
value:当前线程要保存的资源,是强引用
每次调用 get 或 set 方法时,都是先获取当前访问的线程,再从中获取该线程对应的 ThreadLocalMap 来进行操作。
一般把ThreadLocal设为static让所有对象共享,然后每个对象有自己的ThreadLocalMap
为什么key要弱引用,value要强引用?
Value 是强引用(私人物品): 存进柜子里的东西(比如用户信息)绝对不能无故消失。如果设计成弱引用,系统一回收垃圾,你的数据就没了,等要用的时候拿不到,程序就会直接报错(空指针异常)。所以必须死死绑定,保证数据安全。
Key 是弱引用: **如果 key 也是强引用,只要线程不死亡(线程池复用),那么即使用户在外部断开了 ThreadLocal 的引用,由于 Map 里还有强引用,**会导致 Key 和 Value 一起内存泄漏。设计成弱引用,至少能保证 Key 会被回收,并且能在后续调用 set/get 时通过探测 null Key 来触发 Value 的被动清理,这是 JDK 在设计上的一种权衡与妥协。最后还是得靠程序员手动 remove。
内存泄漏问题
由于 ThreadLocalMap 的生命周期与 Thread 一样长,如果在线程池中使用 ThreadLocal,线程执行完任务后不会被销毁而是被复用,这就引发了著名的内存泄漏问题:
- 根源:当外界不再使用某个
ThreadLocal对象,且没有强引用指向它时,垃圾回收器(GC)会顺理成章地回收这个弱引用 Key。此时,ThreadLocalMap中就会出现 Key 为null的Entry。 - 泄漏:由于 Key 是
null,我们再也无法通过代码访问到这个Entry的 Value。但因为当前线程依然存活(被线程池保留),线程对象持有着ThreadLocalMap的强引用,进而导致这些 Value 对象永远无法被垃圾回收,造成内存泄漏。 - 解决办法:虽然
ThreadLocal内部在执行get()、set()时会启发式地清理掉一些 Key 为null的废弃数据,但这并不可靠。绝对的安全规范是:每次使用完ThreadLocal后,必须在finally块中显式调用remove()方法清理数据。
