目录
1. 锁策略
乐观锁 和 悲观锁
读写锁
重量级锁 和 轻量级锁
自旋锁 和 挂起等待锁
公平锁 和 非公平锁
可重入锁 和 不可重入锁
CAS
什么是CAS?
CAS 是怎么实现的
CAS 有哪些应用?
CAS 的 ABA 问题
ABA 问题引来的 BUG
Synchronized 原理
Synchronized的特点
Synchronized的加锁工作过程
其他的一些优化操作
锁消除
锁粗化
乐观锁:
假设数据一般情况下不会产生并发冲突,所以在数据进行提交更新的时候,才会正式对数据是否产生并 发冲突进行检测,如果发现并发冲突了,则让返回用户错误的信息,让用户决定如何去做
悲观锁:
总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这 样别人想拿这个数据就会阻塞直到它拿到锁
Synchronized初始使用乐观锁策略,当发现锁竞争比较频繁的时候,就会自动切换成悲观锁策略
乐观锁的一个重要功能就是要检测出数据是否发生访问冲突. 我们可以引入一个 "版本号" 来解决
假设我们需要多线程修改 "用户账户余额",设当前余额为 100,引入一个版本号 version,初始值为 1,并且我们规定 "提交版本必须大于记录 当前版本才能执行更新余额"
(1) 线程 A 此时准备将其读出( version=1,balance=100 ),线程 B 也读入此信息( version=1,balance=100 )
(2) 线程 A 操作的过程中并从其帐户余额中扣除 50( 100-50 ),线程 B 从其帐户余额中扣除 20 ( 100-20 );
(3)线程 A 完成修改工作,将数据版本号加1( version=2 ),连同帐户扣除后余额( balance=50 ),写回到内存中
(4)线程 B 完成了操作,也将版本号加1( version=2 )试图向内存中提交数据( balance=80 ),但此时比对版本发现,操作员 B 提交的数据版本号为2,数据库记录的当前版本也为2,不满足 “提交版本必须大于记录当前版本才能执行更新“ 的乐观锁策略。就认为这次操作失败
多线程之间,数据的读取方之间不会产生线程安全问题,但数据的写入方互相之间以及和读者之间都需要进行互斥。如果两种场景下都用同一个锁,就会产生极大的性能损耗。
所以读写锁因此而产生,读写锁在执行加锁操作时需要额外表明读写意图,复数读者之间并不互斥,而写者则要求与任何人互斥,读写锁特别适合于 "频繁读, 不频繁写" 的场景中
一个线程对于数据的访问, 主要存在两种操作:读数据 和 写数据
1. 两个线程都只是读一个数据,此时并没有线程安全问题,直接并发的读取即可
2. 两个线程都要写一个数据,有线程安全问题
3. 一个线程读另外一个线程写,也有线程安全问题
读写锁就是把读操作和写操作区分对待;Java 标准库提供了 ReentrantReadWriteLock 类,实现了读写锁
1. ReentrantReadWriteLock.ReadLock 类表示一个读锁,这个对象提供了 lock / unlock 方法进行 加锁解锁
2. ReentrantReadWriteLock.WriteLock 类表示一个写锁,这个对象也提供了 lock / unlock 方法进 行加锁解锁
注意:
1. 读加锁和读加锁之间,不互斥
2. 写加锁和写加锁之间,互斥
3. 读加锁和写加锁之间,互斥
只要是涉及到 "互斥",就会产生线程的挂起等待,一旦线程挂起,再次被唤醒就不知道隔了多久了,如果想要效率高,应当减少"互斥"的机会
Synchronized 不是读写锁
锁的核心特性 "原子性", 这样的机制追根溯源是 CPU 这样的硬件设备提供的
CPU 提供了 "原子操作指令"
操作系统基于 CPU 的原子指令, 实现了 mutex 互斥锁
JVM 基于操作系统提供的互斥锁, 实现了 synchronized 和 ReentrantLock 等关键字和类
重量级锁:加锁机制重度依赖了 OS 提供了 mutex
1.大量的内核态用户态切换
2. 很容易引发线程的调度
这两个操作,成本比较高. 一旦涉及到用户态和内核态的切换,成本一下子就非常高了
轻量级锁:加锁机制尽可能不使用 mutex,而是尽量在用户态代码完成,实在搞不定了,再使用 mutex
1. 少量的内核态用户态切换
2. 不太容易引发线程调度
什么是用户态和内核态?
想象去银行办业务,在窗口外,自己办理业务,这是用户态 用户态的时间成本是比较可控的
在窗口内,工作人员做,这是内核态 内核态的时间成本是不太可控的 如果办业务的时候反复和工作人员沟通,还需要重新排队,这时效率是很低的,而且时间成本也很高
synchronized 开始是一个轻量级锁;如果锁冲突比较严重,就会变成重量级锁
自旋锁:
按之前的方式,线程在抢锁失败后进入阻塞状态,放弃 CPU,需要过很久才能再次被调度;但实际大部分情况,虽然当前抢锁失败,但过不了很久,锁就会被释放;没必要就放弃 CPU~ 这个时候就可以使用自旋锁来处理这样的问题
自旋锁伪代码:
while (抢锁(lock) == 失败) {
}
如果获取锁失败,立即再尝试获取锁,无限循环,直到获取到锁为止,一旦锁被其他线程释放, 就能第一时间获取到锁
自旋锁是一种典型的 轻量级锁 的实现方式
优点: 没有放弃 CPU, 不涉及线程阻塞和调度,一旦锁被释放,就能第一时间获取到锁
缺点:如果锁被其他线程持有的时间比较久,那么就会持续的消耗 CPU 资源(而挂起等待的时候是 不消耗 CPU 的)
synchronized 中的轻量级锁策略大概率就是通过自旋锁的方式实现的
挂起等待锁:
相当于自旋锁的反面例子,锁冲突时线程会离开CPU进行阻塞等待,等到锁被其他线程释放了再进入CPU运行
假设有三个线程,A,B,C,A先尝试获取锁,获取成功,B,C再尝试获取锁的时候就失败了,都阻塞等待,那么,当A释放锁的时候,会发生什么呢
公平锁:遵守 "先来后到",B 比 C 先来的. 当 A 释放锁的之后, B 就能先于 C 获取到锁
非公平锁:不遵守 "先来后到",B 和 C 都有可能获取到锁
这就好比一群男生追同一个女神,当女神和前任分手之后,先来追女神的男生做她的男朋友,这就是公平锁;如果是女神不按先后顺序挑一个自己看的顺眼的,就是非公平锁
操作系统内部的线程调度就可以视为是随机的,如果不做任何额外的限制,锁就是非公平锁. 如果要想实现公平锁,就需要依赖额外的数据结构,来记录线程们的先后顺序
注意:synchronized 是非公平锁
可重入锁的字面意思是“可以重新进入的锁”,即允许同一个线程多次获取同一把锁
Java里只要以Reentrant开头命名的锁都是可重入锁,而且JDK提供的所有现成的Lock实现类,包括 synchronized关键字锁都是可重入的
而 Linux 系统提供的 mutex 是不可重入锁
"把自己锁死" :一个线程没有释放锁, 然后又尝试再次加锁
// 第一次加锁, 加锁成功 lock(); // 第二次加锁, 锁已经被占用, 阻塞等待. lock();
按照之前对于锁的设定,第二次加锁的时候,就会阻塞等待;直到第一次的锁被释放,才能获取到第二个锁,但是释放第一个锁也是由该线程来完成,这个线程不释放锁,造成了死锁
这样的锁称为 不可重入锁
synchronized 是可重入锁
CAS: 全称Compare and swap,字面意思:”比较并交换“,一个 CAS 涉及到以下操作
我们假设内存中的原数据V,旧的预期值A,需要修改的新值B
1. 比较 A 与 V 是否相等。(比较)
2. 如果比较相等,将 B 写入 V。(交换)
3. 返回操作是否成功
CAS 伪代码
boolean CAS(address, expectValue, swapValue) {
if (&address == expectedValue) {
&address = swapValue;
return true;
}
return false;
}
上面的代码不是原子的,真实的 CAS 是一个原子的硬件指令完成的;这个伪代码只是帮助我们能辅助理解 CAS 的工作流程
当多个线程同时对某个资源进行CAS操作,只能有一个线程操作成功,但是并不会阻塞其他线程,其他线程只会收到操作失败的信号。
CAS 可以视为是一种乐观锁
针对不同的操作系统,JVM 用到了不同的 CAS 实现原理,简单来讲:
1. java 的 CAS 利用的的是 unsafe 这个类提供的 CAS 操作
2. unsafe 的 CAS 依赖了的是 jvm 针对不同的操作系统实现的 Atomic::cmpxchg
3. Atomic::cmpxchg 的实现使用了汇编的 CAS 操作,并使用 cpu 硬件提供的 lock 机制保证其原子性
实现CAS是因为硬件予以了支持,软件层面才能做到
实现原子类
标准库中提供了 java.util.concurrent.atomic 包,里面的类都是基于这种方式来实现的
典型的就是 AtomicInteger 类,其中的 getAndIncrement 相当于 i++ 操作
AtomicInteger atomicInteger = new AtomicInteger(0);
atomicInteger.getAndIncrement();// 这一步操作相当于 i++
伪代码:
class AtomicInteger {
private int value;
public int getAndIncrement() {
int oldValue = value;
while ( CAS(value, oldValue, oldValue+1) != true) {
oldValue = value;
}
return oldValue;
}
}
假设两个线程同时调用 getAndIncrement
1. 两个线程都读取 value 的值到 oldValue 中 (oldValue 是一个局部变量,在栈上,每个线程有自己的栈)
2. 线程1 先执行 CAS 操作,由于 oldValue 和 value 的值相同,直接进行对 value 赋值
CAS 是直接读写内存的,而不是操作寄存器
CAS 的读内存,比较,写内存操作是一条硬件指令,是原子的
3. 线程2 再执行 CAS 操作,第一次 CAS 的时候发现 oldValue 和 value 不相等,不能进行赋值;因此需要进入循环:在循环里重新读取 value 的值赋给 oldValue
4. 线程2 接下来第二次执行 CAS,此时 oldValue 和 value 相同,于是直接执行赋值操作
5. 线程1 和 线程2 返回各自的 oldValue 的值即可
通过形如上述代码就可以实现一个原子类,可以高效的完成多线程的自增操作
ABA 的问题:
假设存在两个线程 t1 和 t2. 有一个共享变量 num,初始值为 A,接下来,线程 t1 想使用 CAS 把 num 值改成 Z,那么就需要
1. 先读取 num 的值, 记录到 oldNum 变量中
2. 使用 CAS 判定当前 num 的值是否为 A, 如果为 A, 就修改成 Z
但是,在 t1 执行这两个操作之间,t2 线程可能把 num 的值从 A 改成了 B,又从 B 改成了 A
到这里,t1 线程无法区分当前这个变量始终是 A,还是经历了一个变化过程
这就相当于,我们买一个手机,无法判定这个手机是刚出厂的新手机,还是别人用旧了,又翻新过的手机
大部分的情况下,t2 线程这样的一个反复横跳改动,对于 t1 是否修改 num 是没有影响的. 但是有一些特殊情况,会使结果截然不同
举个栗子:
假设 张三 有 100 存款. 滑稽想从 ATM 取 50 块钱. 取款机创建了两个线程,并发的来执行 -50 操作. 我们期望一个线程执行 -50 成功,另一个线程 -50 失败. 如果使用 CAS 的方式来完成这个扣款过程就可能出现问题
正常情况下
1. 存款 100 线程1 获取到当前存款值为 100,期望更新为 50;线程2 获取到当前存款值为 100, 期 望更新为 50
2. 线程1 执行扣款成功,存款被改成 50. 线程2 阻塞等待中
3. 轮到线程2 执行了,发现当前存款为 50,和之前读到的 100 不相同,执行失败
异常情况下
1. 存款 100. 线程1 获取到当前存款值为 100,期望更新为 50;线程2 获取到当前存款值为 100,期望更新为 50
2. 线程1 执行扣款成功,存款被改成 50. 线程2 阻塞等待中
3. 在线程2 执行之前, 张三的朋友正好给张三转账 50,账户余额变成 100
4. 轮到线程2 执行了,发现当前存款为 100,和之前读到的 100 相同,再次执行扣款操作
这个时候, 扣款操作被执行了两次~~
解决方案
给要修改的值,引入版本号. 在 CAS 比较数据当前值和旧值的同时,也要比较版本号是否符合预期
1. CAS 操作在读取旧值的同时, 也要读取版本号
2. 修改的时候如果当前版本号和读到的版本号相同, 则修改数据, 并把版本号 + 1;如果当前版本号高于读到的版本号. 就操作失败(认为数据已经被修改过了)
结合上面的锁策略, 我们就可以总结出, Synchronized 具有以下特性:
1. 开始时是乐观锁,如果锁冲突频繁,就转换为悲观锁
2. 开始是轻量级锁实现,如果锁被持有的时间较长,就转换成重量级锁
3. 实现轻量级锁的时候大概率用到的自旋锁策略
4. 是一种不公平锁
5. 是一种可重入锁
6. 不是读写锁
JVM 将 synchronized 锁分为 无锁、偏向锁、轻量级锁、重量级锁 状态。会根据情况,进行依次升级
1. 偏向锁
第一个尝试加锁的线程, 优先进入偏向锁状态
偏向锁不是真的 "加锁",只是给对象头中做一个 "偏向锁的标记",记录这个锁属于哪个线程. 如果后续没有其他线程来竞争该锁,那么就不用进行加锁解锁之类的操作了(避免了加锁解锁的开销)
如果后续有其他线程来竞争该锁(刚才已经在锁对象中记录了当前锁属于哪个线程了,很容易识别 当前申请锁的线程是不是之前记录的线程),那就取消原来的偏向锁状态,进入一般的轻量级锁状态
偏向锁本质上相当于 "延迟加锁" . 能不加锁就不加锁, 尽量来避免不必要的加锁开销
2. 轻量级锁
随着其他线程进入竞争,偏向锁状态被消除,进入轻量级锁状态(自适应的自旋锁)
此处的轻量级锁就是通过 CAS 来实现
通过 CAS 检查并更新一块内存 (比如 null => 该线程引用)
如果更新成功,则认为加锁成功
如果更新失败,则认为锁被占用,继续自旋式的等待(并不放弃 CPU)
自旋操作是一直让 CPU 空转,比较浪费 CPU 资源,因此此处的自旋不会一直持续进行,而是达到一定的时间/重试次数,就不再自旋了
3. 重量级锁
如果竞争进一步激烈,自旋不能快速获取到锁状态,就会膨胀为重量级锁
此处的重量级锁就是指用到内核提供的 mutex
1. 执行加锁操作,先进入内核态
2. 在内核态判定当前锁是否已经被占用
3. 如果该锁没有占用,则加锁成功,并切换回用户态
4. 如果该锁被占用,则加锁失败. 此时线程进入锁的等待队列,挂起. 等待被操作系统唤醒
5. 过了很久,当这个锁被其他线程释放了,操作系统也想起了这个挂起的线程,于是唤醒这个线程,尝试重新获取锁
编译器+JVM 判断锁是否可消除. 如果可以,就直接消除
什么是锁消除?
有些应用程序的代码中, 用到了 synchronized, 但其实没有在多线程环境下. (例如 StringBuffer)
StringBuffer sb = new StringBuffer();
sb.append("a");
sb.append("b");
sb.append("c");
sb.append("d");
此时每个 append 的调用都会涉及加锁和解锁. 但如果只是在单线程中执行这个代码,那么这些加 锁解锁操作是没有必要的,白白浪费了一些资源开销
一段逻辑中如果出现多次加锁解锁, 编译器 + JVM 会自动进行锁的粗化
锁的粒度: 粗和细
实际开发过程中,使用细粒度锁,是期望释放锁的时候其他线程能使用锁
但是实际上可能并没有其他线程来抢占这个锁. 这种情况 JVM 就会自动把锁粗化,避免频繁申请释放锁
谢谢大家的观看,喜欢就关注我吧,下节我会给大家带来一些关于多线程的面试题~~