当前位置: 首页 > news >正文

java锁机制(CAS和synchronize)的实现原理和使用方法

一、 CAS(Compare And Swap)实现原理和用法

CAS基本概念:

CAS 是一种无锁并发技术,用于在多线程环境下实现原子操作。核心思想是:先比较内存中的值是否与预期相同,若相同则更新为新值,否则不操作

CAS实现机制:
  1. 操作步骤
    预期值(Expected):读取当前内存值作为预期。
    新值(New):准备写入的新值。
    比较与交换:原子性地检查内存值是否等于预期值。若相等,则更新为新值;否则放弃操作。

  2. 硬件支持
    • 依赖 CPU 指令(如 x86 的 CMPXCHG 指令)实现原子性操作,确保比较和交换过程不可中断。

  3. 代码示例(伪代码):

    public boolean cas(int expected, int newValue) {if (currentValue == expected) { // 比较当前值是否为预期值currentValue = newValue;     // 如果是,更新为新值return true;}return false;
    }
    
  4. 优缺点
    优点:无锁,避免线程阻塞,适合低竞争场景。
    缺点
    ABA 问题:值从 A → B → A,CAS 无法感知中间变化。解决方法:使用版本号(如 AtomicStampedReference)。
    高竞争下自旋开销大:线程可能长时间循环尝试 CAS,浪费 CPU。

好的!我用一个实际的转账例子来解释这三个值,保证你彻底明白。


CAS场景模拟:银行账户转账

假设你有一个银行账户,余额是 100元。现在有两个操作同时发生:

  1. 想转账 50元(余额变为 100 - 50 = 50元)。
  2. 同时,银行系统自动给你账户 充值30元(余额变为 100 + 30 = 130元)。

我们用 CAS 来保证转账和充值的原子性(避免数据错乱)。

第一步:你发起转账(操作1)
  1. 读取当前余额(预期值 Expected)
    Expected = 100元
    (你转账前看到的余额是100元)

  2. 计算新值(New)
    New = Expected - 50元 = 50元
    (转账后你预期余额变成50元)

  3. 执行 CAS 操作
    • 检查当前内存中的余额是否还是 100元(和预期值一致)。
    如果一致:说明这段时间没有人修改余额,可以安全更新为 50元
    如果不一致:说明有其他操作(比如充值)修改了余额,转账失败,需要重试。

第二步:银行系统同时充值(操作2)
  1. 读取当前余额(预期值 Expected)
    Expected = 100元
    (充值前看到的余额也是100元)

  2. 计算新值(New)
    New = Expected + 30元 = 130元
    (充值后余额变成130元)

  3. 执行 CAS 操作
    • 检查当前内存中的余额是否还是 100元
    如果一致:更新为 130元
    如果不一致:充值失败,需要重试。

关键点:CAS 的原子性

两个操作同时发生,但 CAS 保证只有一个能成功,具体流程如下:

  1. 假设转账操作(操作1)先执行
    • 检查余额是 100元(和预期一致)→ 更新为 50元
    此时充值操作(操作2)的 CAS 会失败,因为当前余额已经是 50元(不再是它预期的 100元)。
    • 充值操作需要重新读取当前余额(50元),重新计算新值(50 + 30 = 80元),再次尝试 CAS。

  2. 假设充值操作(操作2)先执行
    • 检查余额是 100元 → 更新为 130元
    转账操作(操作1)的 CAS 会失败,因为当前余额是 130元(不再是预期的 100元)。
    • 转账操作需要重新读取余额(130元),重新计算新值(130 - 50 = 80元),再次尝试 CAS。

实际代码演示(Java)

AtomicInteger 模拟账户余额:

AtomicInteger balance = new AtomicInteger(100); // 初始余额100元// 操作1:转账50元(线程1)
boolean transferSuccess = balance.compareAndSet(100, // 预期值 Expected = 10050   // 新值 New = 50
);// 操作2:充值30元(线程2)
boolean rechargeSuccess = balance.compareAndSet(100, // 预期值 Expected = 100130  // 新值 New = 130
);// 最终只有一个操作成功!
System.out.println("转账是否成功:" + transferSuccess);   // 可能输出 true 或 false
System.out.println("充值是否成功:" + rechargeSuccess);   // 另一个输出 false
System.out.println("最终余额:" + balance.get());         // 最终余额可能是50或130,之后需要重试失败的操作
总结三个值的作用
  1. 预期值(Expected):你操作前看到的原始值,用来判断是否有人中途修改过数据。
    • 比如转账前看到的 100元

  2. 新值(New):你希望更新后的值。
    • 比如转账后的 50元 或充值后的 130元

  3. 比较与交换:原子性地检查数据是否未被改动,只有未被改动才更新。
    • 如果数据被改动,说明有其他操作介入,你的操作需要 重试或放弃

想象你和朋友同时修改一份在线文档:
预期值:你打开文档时看到的内容。
新值:你修改后的内容。
CAS:提交修改时,系统会检查文档是否还是你打开时的版本。如果是,更新成功;如果已经被别人修改过,系统会提示你“版本冲突,请重新编辑”。

二、 synchronized 实现原理和用法

synchronized基本概念

假设有一个公共厕所(共享资源),多个用户(线程)要使用它。为了安全,厕所门上有锁(synchronized)。

  1. 无锁状态
    • 厕所门开着,没人使用(对象未被任何线程锁定)。

  2. 偏向锁
    • 老王第一个进去,发现厕所没人,直接在门上贴了自己的名字(线程ID)。
    • 以后老王再来时,直接推门进去(无需额外操作)。

  3. 轻量级锁
    • 老王在里面时,小李来敲门(竞争发生)。
    • 老王说:“稍等,我马上出来!”(自旋等待)。
    • 老王出来后,小李进去(通过 CAS 更新门锁信息)。

  4. 重量级锁
    • 如果厕所门口排了很多人(高竞争),大家只能排队等待。
    • 管理员(操作系统)给每个人发号牌,叫号进入(线程阻塞并唤醒)。


锁对象:每个 Java 对象都有一个“内置锁”(类比厕所门锁)。
Monitor 机制:JVM 通过 ObjectMonitor(C++实现)管理锁的获取和释放。
锁升级:根据竞争强度,从无锁 → 偏向锁 → 轻量级锁 → 重量级锁逐步升级。


synchronized 场景示例:多线程售票系统

• 假设一个电影院有 10 张票,由 3 个售票窗口(线程)同时售卖。
• 必须保证每张票只能被卖出一次(线程安全)。

错误写法(未加锁)

public class TicketSeller {private int tickets = 10;public void sell() {if (tickets > 0) {System.out.println(Thread.currentThread().getName() + " 卖出第 " + tickets + " 张票");tickets--;}}public static void main(String[] args) {TicketSeller seller = new TicketSeller();// 启动 3 个线程卖票new Thread(() -> { while (seller.tickets > 0) seller.sell(); }).start();new Thread(() -> { while (seller.tickets > 0) seller.sell(); }).start();new Thread(() -> { while (seller.tickets > 0) seller.sell(); }).start();}
}

问题
多个线程可能同时判断 tickets > 0,导致卖出同一张票,甚至出现负数!

正确写法(使用 synchronized

public class TicketSeller {private int tickets = 10;private final Object lock = new Object(); // 锁对象public void sell() {synchronized (lock) { // 对 lock 对象加锁if (tickets > 0) {System.out.println(Thread.currentThread().getName() + " 卖出第 " + tickets + " 张票");tickets--;}}}public static void main(String[] args) {TicketSeller seller = new TicketSeller();new Thread(() -> { while (seller.tickets > 0) seller.sell(); }).start();new Thread(() -> { while (seller.tickets > 0) seller.sell(); }).start();new Thread(() -> { while (seller.tickets > 0) seller.sell(); }).start();}
}

关键点
synchronized (lock) 表示线程必须获取 lock 对象的锁,才能执行代码块。
• 同一时间只有一个线程能持有锁,其他线程在锁外等待。


synchronized 的三种用法

1. 同步代码块(手动指定锁对象)

synchronized (任何对象) { // 需要同步的代码
}

2. 同步实例方法(锁对象是当前实例 this

public synchronized void method() {// 代码
}

3. 同步静态方法(锁对象是类的 Class 对象)

public static synchronized void method() {// 代码(锁的是 TicketSeller.class)
}

synchronized底层实现原理(结合售票系统)

1. 锁对象的内存布局
• 每个对象头中的 Mark Word 存储锁状态(如偏向锁、轻量级锁、重量级锁)。
• 当线程进入 synchronized 代码块时:
• 如果无竞争,JVM 使用偏向锁(记录线程ID)。
• 若有其他线程竞争,升级为轻量级锁(CAS自旋)。
• 自旋失败后,升级为重量级锁(线程阻塞,由操作系统调度)。

2. 售票系统的锁流程

  1. 线程A 进入 sell() 方法,检查 lock 对象的锁状态。
  2. 如果锁空闲,线程A获取锁,修改 Mark Word 为偏向锁(记录线程A的ID)。
  3. 线程B 尝试进入:
    • 发现锁被线程A持有,升级为轻量级锁(自旋等待)。
    • 若自旋超过阈值(如 10 次),升级为重量级锁,线程B进入阻塞队列。
  4. 线程A释放锁后,唤醒线程B继续执行。

常见错误和注意事项

错误1:锁对象不一致

// 错误!每个线程用不同的锁对象,无法同步
public void sell() {synchronized (new Object()) { // 代码}
}

错误2:锁范围过大或过小

// 错误!锁范围太小,可能重复卖票
public void sell() {if (tickets > 0) {synchronized (lock) {tickets--;}}
}

核心思想:通过对象内置锁(Monitor)保证同一时间只有一个线程访问共享资源。
锁升级:根据竞争动态调整锁策略,平衡性能和安全性。
最佳实践
• 锁对象尽量用 private final 对象(如 private final Object lock = new Object())。
• 同步代码块的范围要精确覆盖共享资源的读写操作。


特性CASsynchronized
实现方式硬件指令(如 CMPXCHG对象头、Monitor、锁升级机制
锁类型无锁偏向锁 → 轻量级锁 → 重量级锁
适用场景低竞争,简单原子操作高竞争,复杂同步代码块
性能开销自旋消耗 CPU(高竞争下差)重量级锁涉及内核切换(高竞争下较好)
ABA 问题需要额外处理(如版本号)
编程复杂度需手动处理失败重试自动加锁/释放,简单易用

三、自旋锁的概念

1. 什么是自旋?

自旋(Spinning)是一种线程等待锁的策略,当线程尝试获取锁失败时,不会立即放弃 CPU(不进入阻塞状态),而是进入一个循环,不断检查锁是否被释放,直到成功获取锁或超过一定时间。

类比
想象你在超市的储物柜前存包:
自旋等待:你站在柜子前,反复尝试开锁按钮(不断检查),直到柜门打开。
阻塞等待:你发现柜子被占用后,先去逛超市,每隔几分钟回来检查一次(线程挂起,让出 CPU)。


2. 自旋的优缺点

优点
低竞争场景(锁很快被释放),自旋避免线程挂起和唤醒的开销(上下文切换),性能更高。
缺点
高竞争场景(锁长时间被占用),自旋会空耗 CPU 资源(循环什么也不做)。


3. 自旋的实际例子

假设有一个共享计数器,两个线程同时尝试通过自旋更新它:

public class SpinExample {private volatile int count = 0;  // 使用 volatile 保证可见性public void increment() {// 自旋:循环尝试直到成功while (true) {int current = count;int newValue = current + 1;if (compareAndSet(current, newValue)) {  // 伪代码:CAS操作break;  // 成功则退出循环}// 失败则继续循环(自旋)}}private boolean compareAndSet(int expected, int newValue) {// 原子性操作:如果当前值等于 expected,则更新为 newValue// (此处简化逻辑,实际使用 AtomicInteger 的 CAS)if (count == expected) {count = newValue;return true;}return false;}
}

流程解析

  1. 线程A 读取 count = 0,尝试通过 CAS 将其更新为 1,成功并退出。
  2. 线程B 同时读取 count = 0,也尝试 CAS 更新为 1,但发现当前值已被线程A改为 1,CAS 失败。
  3. 线程B 自旋:重新读取 count = 1,计算新值 2,再次尝试 CAS,直到成功。

4. 自旋在 Java 中的应用
场景1:轻量级锁(synchronized 的锁升级)

自旋阶段:当线程竞争锁时,JVM 会让线程自旋几次(比如 10 次),尝试获取锁。
成功:获取锁,继续执行。
失败:锁升级为重量级锁,线程进入阻塞状态。

场景2:CAS 操作(如 AtomicInteger)

自旋重试:CAS 失败时,通常会配合自旋重试(比如 AtomicIntegerincrementAndGet())。


5. 自旋与阻塞的对比
行为自旋(Spinning)阻塞(Blocking)
线程状态保持运行状态(Running)挂起(Waiting)
CPU 占用占用 CPU(空循环)不占用 CPU(让给其他线程)
适用场景锁很快被释放(低竞争)锁长时间被占用(高竞争)
开销无上下文切换开销有上下文切换开销

6. 自旋的优化:适应性自旋

现代 JVM 会根据历史自旋成功率动态调整自旋次数:
成功率高 → 增加自旋次数。
成功率低 → 减少自旋次数或直接阻塞。


7. 自旋锁总结

自旋的本质:通过循环不断尝试获取锁,避免线程挂起。
适用场景:适合锁竞争不激烈、锁持有时间短的场景(如简单的计数器更新)。
注意事项:高竞争场景需谨慎使用,避免浪费 CPU。

四、 ConcurrentHashMap 的实现原理(JDK 1.8)


1. 核心思想:用最小的锁实现线程安全

想象一个储物柜大厅,多个用户(线程)要存取物品。ConcurrentHashMap 的设计就像:
每个储物柜(桶)独立上锁:你操作 1 号柜子时,不影响别人操作 2 号柜子。
无锁化尝试:如果柜子没人用,直接用指纹(CAS)开锁;如果冲突了,才用钥匙(synchronized)。


2. 底层结构:数组 + 链表/红黑树
// 类似这样的结构(简化版)
Node[] table = new Node[16]; // 初始桶数组

• 每个桶(table[i])可能存三种数据:
:直接通过 CAS 插入新节点。
链表:节点数量 < 8,用链表存储。
红黑树:节点数量 ≥ 8,转成树(防止链表过长,查询变慢)。


3. PUT 操作流程(重点!)

以两个线程同时存数据为例:

场景:线程 A 存 Key="apple", 线程 B 存 Key="banana",两个 Key 哈希到同一个桶。
步骤 1:计算哈希,定位桶

• 两个线程都计算哈希值,发现要操作的是桶 table[3]

步骤 2:无锁尝试(CAS)

• 线程 A 发现桶 table[3] 为空,尝试用 CAS 写入新节点(Node("apple"))。

if (CAS(table[3], null, new Node("apple"))) {// 写入成功!
}

如果成功:直接返回,无需加锁。
如果失败:说明其他线程已修改该桶(比如线程 B 抢先写入),进入步骤 3。

步骤 3:加锁操作(synchronized)

• 线程 A 对桶 table[3] 的头节点加锁(synchronized (头节点))。

synchronized (headNode) {// 遍历链表或树,插入或更新节点
}

• 此时线程 B 如果也想操作该桶,必须等待线程 A 释放锁。

步骤 4:处理链表/树

• 插入新节点或更新值。
• 如果链表长度超过 8,转换为红黑树。


4. 线程安全的 Size 计算

直接全局加锁统计大小会非常慢,ConcurrentHashMap 用了分段计数:
baseCount:基础计数器,通过 CAS 更新。
CounterCell[]:当多个线程同时修改时,每个线程更新自己的 CounterCell,减少竞争。

// 最终 size = baseCount + CounterCell[0] + CounterCell[1] + ... 
类比

• 超市收银台统计营业额:
baseCount:主收银台的总金额。
CounterCell[]:高峰时段开放多个临时收银台,各自统计后再汇总。


5. 为什么用 synchronized 而不是 ReentrantLock?

性能优化:JDK1.8 的 synchronized 经过锁升级优化(偏向锁 → 轻量级锁 → 重量级锁),性能接近 ReentrantLock。
代码简洁:synchronized 由 JVM 直接支持,无需手动释放锁。


http://www.mrgr.cn/news/98383.html

相关文章:

  • Domain Adaptation领域自适应
  • 科目四 学习笔记
  • 智能云图库-1-项目初始化
  • 祁连山国家公园shp格式数据
  • Python 机器学习实战 第6章 机器学习的通用工作流程实例
  • 大数据面试问答-Spark
  • 嵌入式程序设计英语
  • Spring Security6 从源码慢速开始
  • HarmonyOS:使用Refresh组件实现页面下拉刷新上拉加载更多
  • PVE 8.4.1 安装 KDE Plasma 桌面环境 和 PVE换源
  • linux中查看.ypc二进制文件
  • Linux服务之网络共享
  • Melos 发布pub.dev
  • 30学Java第十天——类加载的过程
  • 【动手学强化学习】番外7-MAPPO应用框架2学习与复现
  • AWS Redshift的使用场景及一些常见问题
  • 绿算轻舟系列FPGA加速卡:驱动数字化转型的核心动力
  • electron-builder参数详解
  • ukui-greeter编译与安装
  • C/C++的数据类型