常见锁策略
乐观锁和悲观锁
悲观锁
总是假设最坏的情况, 每次去拿数据的时候都会认为会被别人修改, 因此会上锁, 防止数据在使用过程中被别的线程修改,
乐观锁
假设数据一般情况下不会产生并发冲突,因此在拿数据,操作数据的过程中不加锁, 而在数据进行提交更新的时候, 才会正式对数据是否产生并发冲突进行检测 .
(如何对数据是否产生并发冲突进行检测? 引入版本号)
如果发生并发冲突了, 则返回错误数据给用户,让用户决定如何去解决 .
synchronized 初始使用乐观锁策略, 当发生锁竞争比较频繁的时候, 会自动切换成悲观锁策略
读写锁
多线程之间, 读和读不会产生线程安全问题, 读和写, 写和写之间, 会产生线程安全问题.
如果两种情况下都使用同一种锁, 会产生极大的性能消耗 (得解决线程安全问题吧?), 读写锁就是解决这个问题, 应运而生.
读写锁(readers-writer lock), 把读操作和写操作区分对待
要求在执行加锁操作时, 需要额外表明读写意图
- 读加锁和读加锁之间不互斥
- 读加锁和写加锁之间互斥
- 写加锁和写加锁之间互斥 .
synchronized 不是读写锁
重量级锁和轻量级锁
前置知识
锁的核心特性 “原子性”, 这样的机制追根溯源是 CPU 这样的硬件提供的
- CPU 提供了 “原子操作指令”
- OS 基于 CPU 的原子指令, 实现了 mutex 互斥锁
- JVM 基于 OS 提供的互斥锁, 实现了 synchronized 和 ReentrantLock 等关键字和类
重量级锁 : 加锁机制重度依赖 OS 提供的 mutex
- 大量的内核态用户切换
- 很容易引发线程的较低
轻量级锁 : 加锁机制尽可能不用 mutex , 而是尽量在 用户态 代码完成, 实在搞不定, 在使用 mutex
- 少量的内核态用户切换
- 不太容易引发线程调度
attention : 如果内核态和用户态进行频繁切换, 效率和资源的消耗就会非常大!
synchronized 是一个轻量级锁, 如果锁冲突比较严重, 就会变成重量级锁. (自动转换)
自旋锁 (Spin Lock)
自旋锁可以解决的情况 :
一般情况下, 抢锁失败后, 进入阻塞状态, 放弃 CPU , 然后进行一堆线程之间的锁竞争(时间可能会很漫长 …)
当抢锁失败后, 由于锁一般会被很快释放, 因此没必要放弃 CPU.(即一旦锁被其他线程释放, 能第一时间获取到锁)
可以通过一个伪代码来大概看出自旋锁如何实现的:
while( 抢锁(Lock) == 失败 ) {} //一直死循环跑, 直到获取到锁
自旋锁 和 挂起等待锁 是相对的
自旋锁是一种典型的 轻量级锁 的实现方式
- 优点 : 没有放弃 CPU , 不涉及线程阻塞和调度 (因此轻量), 一旦锁被释放, 能第一时间获取到锁 .
- 缺点 : 都 while 循环了, 那么如果其他线程长时间持有锁, 那么 CPU 消耗的资源会很多 (挂起等待是不消耗 CPU 的)
synchronized 中的轻量级锁策略, 大概率是通过自旋锁的方式实现的 .
公平锁 VS 非公平锁
公平锁 : 遵循先来后到, 先来的一定会比后到的先执行 .
非公平锁 : 不遵循先来后到, 当锁被释放后, 阻塞队列中的所有线程, 一起竞争锁, 谁能抢到谁先用 .
attertion :
- OS 内部的线程进度可以视为是随机的, 如果要实现公平锁, 就需要依赖额外的数据结构, 来记录线程间的先后顺序
- 公平锁和非公平锁之间无好坏之分, 只有适用不适用
synchronized 是非公平锁
可重入锁(递归锁) VS 不可重入锁
可重入锁 : 允许同一个线程多次获取同一把锁 (如果一个线程内, 两次对同一个对象加锁, 如果中间没有锁释放, 可重入锁是可以进入的, 不可重入锁就不能进入, 会进行阻塞)
- Java 中只要以 Reentrant 开头命名的锁, 都是可重入锁, 且 JDK 提供的所有线程的 Lock 实现类, 包括 synchronized 都是可重入锁 .
- Linux 提供的 mutex 是不可重入锁
CAS
什么是 CAS?
CAS (Compare and swap) : “比较与交换”, 是一种实现并发算法时常用到的技术
相当于通过一个原子的操作, 同时完成 “读取内存, 比较是否相等, 修改内存” 这三个步骤. 本质上需要 CPU 指令的支撑
CAS 算法 :
有三个操作数 : 内存中的原数据 (V) , 旧的预期值 (A), 修改后的值 (B)
- 比较 A 与 V 是否相等
- 如果 A == V , 那么将 B 写入 V
- 返回替换操作, 是否成功 (true or false)
伪代码如下 (真实的 CAS 是一个原子操作)
boolean CAS (address, expectValue, swapValue) { if(&address == expectValue) { &address = swapValue; return true; } return false; }
attention : CAS 是乐观锁的一种实现方式, 因此该操作不会阻塞其他线程
CAS 是如何实现的?
硬件给予了支持, 软件方面才能对 CAS 进行了实现
- JVM 中的 CAS : 通过 UnSafe 类来调用, OS 底层的CAS指令实现。
CAS 的应用
- 实现原子类
- 实现自旋锁
CAS 的 ABA 问题
什么是 ABA 问题?
假设有两个线程 t1 & t2, 有一个共享变量 num, 初始值为 A
当 t1 想使用 CAS 把 num 的值修改的时候, 会对 num 进行判定
- 先读取 num 的值, 存储到 oldNum 中
- 使用 CAS 判定当前 num 的值是否为 A , 如果为 A, 就修改
但是如果在判定过程中 ( if( num == oldNum )
), t2 线程对 num 进行了多次操作, 而且其最后 num 的值最终变成了 原值, 即 A -> B -> … ->A, 那这个时候, t1 进行的判定, 是符合的 (非原子操作), 但是理论上, 他们并不相等 (用一个不太精巧的说法, 值相等, 址不相等)
解决方法
引入版本号, 在 CAS 进行数据比对的时候, 也比较版本号是否符合预期
// 伪代码 if(版本号(num) == 版本号(oldNum) && num == oldNum ) { 版本号(num)++; num = newNum; }
synchronized 原理
基本特点 (JDK 1.8 版本下)
- 开始是乐观锁, 如果锁冲突频繁, 会自动转换成悲观锁
- 开始是轻量级锁实现, 如果锁被长时间持有, 会转换成重量级锁
- 实现轻量级锁的时候大概率用到自旋锁策略
- 不公平锁
- 可重入锁
- 不是读写锁
加锁工作过程
JVM 将 synchronized 锁分为 无锁, 偏向锁, 轻量级锁, 重量级锁 状态, 会根据情况, 依次升级
偏向锁
偏向锁不是真的加锁, 只是说给一个 “偏向锁标记”, 记录该锁属于哪个线程
如果后续没有其他线程来竞争该锁, 那就不用加锁 (避免加锁解锁的开销)
如果后续有其他线程来竞争该锁, 那么偏向锁就代表我已经被其他线程加锁了, 此时偏向锁取消, 进入轻量锁状态
轻量级锁
轻量锁通过 CAS 实现
- 通过 CAS 检查并更新一块内存 (eg: NULL -> 该线程引用)
- 如果更新成功, 就认为加锁成功
- 如果更新失败, 则认为该锁被占用, 继续自旋式等待 (并不放弃 CPU , 才是轻量级, 放弃了, 就是重量级了)
重量级锁
重量级锁是指用到内核提供的 mutex
- 执行加锁操作, 先进入内核态
- 在内核态中判定当前锁是否已被占用
- 如果该锁没有占用, 则加锁成功, 并切回用户态
- 如果该锁被占用, 则线程进入该锁的等待队列, 挂起等待
多线程 (下) - 学习笔记2:https://developer.aliyun.com/article/1518510