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 问题

  1. 第一步:自己主动读一次内存(get ()),存到线程本地变量
  2. 第二步:调用 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
2
3
4
5
6
7
class ObjectMonitor {
void* _owner; // 持有锁的线程
WaitSet _WaitSet; // 等待池(管理调用 wait() 方法的线程)
EntryList _EntryList; // 锁池(管理因竞争锁失败而阻塞的线程)
int _recursions // 记录锁的重入次数
// 其他不重要的字段 ...
};
  • 每个对象在 JVM 里对应一个 ObjectMonitor(监视器对象),里面有锁池(Entry Set)、等待池(Wait Set)、计数器等等
  • synchronized 编译成字节码后是 monitorenter / monitorexit 两条指令:进入时执行 monitorenter 尝试获取锁,退出时执行 monitorexit 释放锁。
  • 获取锁:把 monitor 的持有者标记为当前线程,计数器 +1。
  • 可重入:同一个线程再次进入,计数器再 +1(这也是为什么 synchronized 是可重入的);退出时 -1,减到 0 才真正释放,别的线程才能拿到。

AQS

AQS 是什么:JDK 提供的一个并发同步器基类

AQS 的两个核心成员:

  1. volatile int state:同步状态位。0 表示锁空闲,>0 表示被占用(可重入时累加)。具体含义由子类定义。
  2. CLH 双向队列:抢不到锁的线程包装成节点(Node)排进队尾,阻塞等待;锁释放后唤醒队头节点。

工作流程

  1. 线程尝试用 CAS 把 state 从 0 改成 1;成功 → 拿到锁,标记持有者为当前线程。
  2. 失败 → 把当前线程包装成 Node 入队(CLH 队尾)。
  3. 入队后自旋 / 阻塞,检查前驱节点;前驱是头节点且抢锁成功 → 出队执行;否则挂起(park)。
  4. 释放锁 → 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() 流程

  1. 计算 Hash: 扰动函数计算 key 的 hash 值。
  2. 数组初始化: 采用懒加载,第一次 put 时才通过 CAS 初始化数组(默认长度 16)。
  3. 寻找槽位:
    • 如果该位置为空,CAS 尝试插入。成功则结束,失败则进入下一次自旋。
    • 如果该位置节点的 hash == -1(即 MOVED 状态),说明当前 ConcurrentHashMap 正在扩容,当前线程会调用 helpTransfer() 协助扩容(这是 1.8 的一大亮点,多线程协同搬运数据)。
  4. 加锁写入: 如果该位置有数据,利用 synchronized 锁住头节点,遍历链表或红黑树进行节点的插入或更新。
  5. 树化判断: 插入完成后,如果链表长度 链表长度 ≥ 8 且数组长度 ≥ 64,将其转化为红黑树。
  6. 统计数量: 调用 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,线程执行完任务后不会被销毁而是被复用,这就引发了著名的内存泄漏问题:

  1. 根源:当外界不再使用某个 ThreadLocal 对象,且没有强引用指向它时,垃圾回收器(GC)会顺理成章地回收这个弱引用 Key。此时,ThreadLocalMap 中就会出现 Key 为 null 的 Entry。
  2. 泄漏:由于 Key 是 null,我们再也无法通过代码访问到这个 Entry 的 Value。但因为当前线程依然存活(被线程池保留),线程对象持有着 ThreadLocalMap 的强引用,进而导致这些 Value 对象永远无法被垃圾回收,造成内存泄漏。
  3. 解决办法:虽然 ThreadLocal 内部在执行 get()、set() 时会启发式地清理掉一些 Key 为 null 的废弃数据,但这并不可靠。绝对的安全规范是:每次使用完 ThreadLocal 后,必须在 finally 块中显式调用 remove() 方法清理数据。