目录
一、锁策略
1. 乐观锁和悲观锁
2. 读写锁
3. 重量级锁和轻量级锁
4. 自旋锁
5. 公平锁和非公平锁
6.可重入锁 vs 不可重入锁
二、CAS
1. CAS 是怎么实现的
1) 实现原子类
2) 实现自旋锁
3. CAS 的 ABA 问题
三、Synchronized 原理
1.Synchronized 加锁工作过程
3.1 偏向锁
3.2 轻量级锁
3.3 重量级锁
2. Synchronized优化操作
悲观锁:(预测接下来冲突概率比较大)
总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁。乐观锁:(预测接下来冲突概率不大)
假设数据一般情况下不会产生并发冲突,所以在数据进行提交更新的时候,才会正式对数据是否产生并发冲突进行检测,如果发现并发冲突了,则让返回用户错误的信息,让用户决定如何去做
举个栗子:A 和 B 想去KTV唱歌.
A 认为 "这个是时间,KTV很多人,有可能没有房间". 因此A 会先给KTV前台打电话: "你好,现在还有空余的房间没有,我准备一会过去" (相当于加锁操作) 得到肯定的答复之后, 才会真的来KTV. 如果得到了否定的答复, 那就等一段时间, 一会再来和前台确定时间. 这个是悲观锁.
B 认为 "这么大个KTV,肯定大概率是有房间的,". 因此 B 直接就来KTV.(相当于没加锁, 直接访问资源) 如果KTV确实比较闲, 那么直接问题就解决了. 如果KTV这会确实很忙, 那么 B , 就下次再来(虽然没加锁, 但是能识别出数据访问冲突). 这个是乐观锁.
这两种思路不能说谁优谁劣, 而是看当前的场景是否合适.
如果当前KTV确实比较忙, 那么使用悲观锁的策略更合适, 使用乐观锁会导致 "白跑很多趟", 耗费额外的资源.
如果当前KTV确实比较闲, 那么使用乐观锁的策略更合适, 使用悲观锁会让效率比较低.
多线程之间,数据的读取方之间不会产生线程安全问题,但数据的写入方互相之间以及和读者之间都需要进行互斥。如果两种场景下都用同一个锁,就会产生极大的性能损耗。所以读写锁因此而产生。
一个线程对于数据的访问, 主要存在两种操作: 读数据 和 写数据.
读写锁就是把读操作和写操作区分对待. Java 标准库提供了 ReentrantReadWriteLock 类, 实现了读写 锁.
- ReentrantReadWriteLock.ReadLock 类表示一个读锁. 这个对象提供了 lock / unlock 方法进行加锁解锁
- ReentrantReadWriteLock.WriteLock 类表示一个写锁. 这个对象提供了 lock / unlock 方法进行加锁解锁.
读写锁特别适合于 "频繁读, 不频繁写" 的场景中. (这样的场景其实也是非常广泛存在的)
重量级锁:主要依赖了操作系统提供的锁,使用这种锁,就容易产生阻塞等待
轻量级锁:主要尽量的避免使用操作系统提供的锁,而是尽量在用户态来完成功能,尽量避免用户态和内核态的切换,尽量避免挂起等待
synchronized是自适锁,既是轻量级锁,又是重量级锁,是根据锁冲突的情况来的,冲突的不高就是轻量级,冲突的很高就是重量级
锁的核心特性 "原子性", 这样的机制追根溯源是 CPU 这样的硬件设备提供的。CPU 提供了 "原子操作指令";操作系统基于 CPU 的原子指令, 实现了 mutex 互斥锁;JVM 基于操作系统提供的互斥锁, 实现了 synchronized 和 ReentrantLock 等关键字和类。
重量级锁:
轻量级锁:
自旋锁是轻量级锁的具体实现.(自旋锁是轻量级锁,也是乐观锁)
挂起等待锁是重量级锁的具体实现(挂起等待锁是重量级锁,也是悲观锁)
自旋锁:当发现锁冲突时,不会挂起等待,会迅速再来尝试看这个锁能不能获取到
- 一旦锁被释放,就可以第一时间获取到
- 如果锁一直不释放,就会立即尝试获取锁,无限循环,直到获取到锁为止,就会消耗大量CPU
挂起等待锁:发现锁冲突,就挂起等待
- 一旦锁被释放,不能第一时间获取到
- 在锁被其他线程占用时,会放弃CPU资源
自旋锁是一种典型的 轻量级锁 的实现方式.
优点: 没有放弃 CPU, 不涉及线程阻塞和调度, 一旦锁被释放, 就能第一时间获取到锁.
缺点: 如果锁被其他线程持有的时间比较久, 那么就会持续的消耗 CPU 资源. (而挂起等待的时候是 不消耗 CPU 的).
公平锁: 遵守 "先来后到". 这样的规则就是公平的,”机会均等“的竞争反而是不公平的
非公平锁: 不遵守 "先来后到". 就是非公平的
synchronized是非公平锁
可重入锁是指可以重新进入的锁,一个线程针对一把锁,连续两次加锁不会出现死锁,这种就是可重入锁 synchronized就属于可重入锁
CAS 可以视为是一种乐观锁.
全称 Compare and swap, 即 "比较并交换". 相当于通过一个原子的操作, 同时完成 "读取内存, 比
较是否相等, 修改内存" 这三个步骤. 本质上需要 CPU 指令的支撑.
我们假设内存中的原数据V,旧的预期值A,需要修改的新值B。
比较 A 与 V 是否相等。(比较)
如果比较相等,将 B 写入 V。(交换)
返回操作是否成功。
- java 的 CAS 利用的的是 unsafe 这个类提供的 CAS 操作;
- unsafe 的 CAS 依赖了的是 jvm 针对不同的操作系统实现的 Atomic::cmpxchg;
- Atomic::cmpxchg 的实现使用了汇编的 CAS 操作,并使用 cpu 硬件提供的 lock 机制保证其原子性。
标准库中提供了 java.util.concurrent.atomic 包, 里面的类都是基于这种方式来实现的.
典型的就是 AtomicInteger 类. 其中的 getAndIncrement 相当于 i++ 操作.
AtomicInteger atomicInteger = new AtomicInteger(0);
// 相当于 i++
atomicInteger.getAndIncrement();
基于 CAS 实现更灵活的锁, 获取到更多的控制权
伪代码实现:
public class SpinLock {
private Thread owner = null;
public void lock(){
// 通过 CAS 看当前锁是否被某个线程持有.
// 如果这个锁已经被别的线程持有, 那么就自旋等待.
// 如果这个锁没有被别的线程持有, 那么就把 owner 设为当前尝试加锁的线程.
while(!CAS(this.owner, null, Thread.currentThread())){
}
}
public void unlock (){
this.owner = null;
}
}
ABA问题是在分布式系统中,由于网络分区或节点故障导致的数据一致性问题。在CAS(Compare and Swap)操作中,ABA问题指的是,在执行CAS操作期间,数据从A变为B又变回A,导致CAS操作成功,但实际上数据并没有发生变化。
什么是ABA 的问题:
假设存在两个线程 t1 和 t2. 有一个共享变量 num, 初始值为 A.
接下来, 线程 t1 想使用 CAS 把 num 值改成 Z, 那么就需要
先读取 num 的值, 记录到 oldNum 变量中.
使用 CAS 判定当前 num 的值是否为 A, 如果为 A, 就修改成 Z.
但是, 在 t1 执行这两个操作之间, t2 线程可能把 num 的值从 A 改成了 B, 又从 B 改成了 A
解决方案:
给要修改的值, 引入版本号. 在 CAS 比较数据当前值和旧值的同时, 也要比较版本号是否符合预期. CAS 操作在读取旧值的同时, 也要读取版本号.
真正修改的时候,如果当前版本号和读到的版本号相同, 则修改数据, 并把版本号 + 1.
如果当前版本号高于读到的版本号. 就操作失败(认为数据已经被修改过了).
3.1 偏向锁
第一个尝试加锁的线程, 优先进入偏向锁状态.偏向锁不是真的 “加锁”, 只是给对象头中做一个 “偏向锁的标记”, 记录这个锁属于哪个线程.如果后续没有其他线程来竞争该锁, 那么就不用进行其他同步操作了(避免了加锁解锁的开销)
如果后续有其他线程来竞争该锁(刚才已经在锁对象中记录了当前锁属于哪个线程了, 很容易识别 当前申请锁的线程是不是之前记录的线程), 那就取消原来的偏向锁状态, 进入一般的轻量级锁状态.偏向锁本质上相当于 “延迟加锁” . 能不加锁就不加锁, 尽量来避免不必要的加锁开销.'
3.2 轻量级锁随着其他线程进入竞争, 偏向锁状态被消除, 进入轻量级锁状态(自适应的自旋锁).此处的轻量级锁就是通过 CAS 来实现.
通过 CAS 检查并更新一块内存 (比如 null => 该线程引用)如果更新成功, 则认为加锁成功如果更新失败, 则认为锁被占用, 继续自旋式的等待(并不放弃 CPU).自旋操作是一直让 CPU 空转, 比较浪费 CPU 资源.因此此处的自旋不会一直持续进行, 而是达到一定的时间/重试次数, 就不再自旋了.也就是所谓的 “自适应”
3.3 重量级锁
如果竞争进一步激烈, 自旋不能快速获取到锁状态, 就会膨胀为重量级锁 执行加锁操作, 先进入内核态在内核态判定当前锁是否已经被占用 如果该锁没有占用, 则加锁成功, 并切换回用户态.如果该锁被占用, 则加锁失败. 此时线程进入锁的等待队列, 挂起. 等待被操作系统唤醒.经历了一系列的沧海桑田, 这个锁被其他线程释放了, 操作系统也想起了这个挂起的线程, 于是唤醒 这个线程, 尝试重新获取锁
我们可以总结出, Synchronized 具有以下特性(只考虑 JDK 1.8):
- 开始时是乐观锁, 如果锁冲突频繁, 就转换为悲观锁.
- 开始是轻量级锁实现, 如果锁被持有的时间较长, 就转换成重量级锁.
- 实现轻量级锁的时候大概率用到的自旋锁策略
- 是一种不公平锁
- 是一种可重入锁
- 不是读写锁
锁消除
锁消除,编译器自动判定,如果认为这个代码没必要加锁,就不加了.
这个操作不是所有情况下都会触发,在大部分情况下不能触发;而偏向锁,每一次加锁都会先进入偏向锁
举个例子,有些应用程序的代码中, 用到了 synchronized, 但其实没有在多线程环境下. (例如 StringBuffer)
StringBuffer tmp = new StringBuffer();
tmp.append("ab");
tmp.append("bc");
tmp.append("cd");
tmp.append("de");
此时每个 append 的调用都会涉及加锁和解锁. 但如果只是在单线程中执行这个代码, 那么这些加锁解锁操作是没有必要的, 白白浪费了一些资源开销。这就来到了锁粗化。
锁粗化
一段逻辑中如果出现多次加锁解锁, 编译器 + JVM 会自动进行锁的粗化.
锁的粒度:表示synchronized包含的代码范围是大还是小,范围越大,粒度越粗;范围越小,粒度越细
锁的粒度细了,能够更好的提高线程的并发,但是也会增加“加锁解锁”的次数
for(int i = 0;i < 10000;i++) {
synchronized (synchronized线程安全.class) {
count++;
}
}
synchronized (synchronized线程安全.class) { //加锁
for(int i = 0;i < 10000;i++) {
count++;
}
}// 释放锁
public static void main(String[] args) {
StringBuffer tmp = new StringBuffer();
tmp.append("ab");
tmp.append("bc");
tmp.append("cd");
//其中连续三次同时append,加锁---释放锁。
System.out.println(sb.toString());
}