文章目录
- 一、常见的锁策略
- 乐观锁 vs 悲观锁
- 轻量级锁 vs 重量级锁
- 自旋锁 vs 挂起等待锁
- 互斥锁 vs 读写锁
- 公平锁 vs 非公平锁
- 可重入锁 vs 不可重入锁
- 二、CAS
- 原子类
- 实现自旋锁
- ABA问题
一、常见的锁策略
我们这里所介绍到的锁策略,不仅仅是java中的,任何涉及到锁的地方,比如数据库,操作系统等等,都会设计到以下特性
乐观锁 vs 悲观锁
乐观锁: 认为锁竞争不是很激烈(做的工作更少,开销更小)
悲观锁: 认为锁锁竞争比较激烈(做的工作更多,开销更大)
举个例子: 你想要去你朋友家找你朋友玩。
有两种做法: 1. 你认为你朋友是比较忙的,不一定在家。所以你会先向你朋友发消息确认一下你朋友在家吗,我来找你玩?(相当于加锁),如果在家就去找,如果不在家就等一段时间,在向朋友确定时间,这是悲观锁
2. 你认为你朋友是比较闲的,大概率是在家的,所以你会直接去你朋友家(没有加锁,直接访问资源),如果确实在家,目的就达成了,如果不在家,下次再来(尽管没加锁,但能识别数据访问冲突),这是乐观锁。
这两种锁策略,不能去分个高度,只能说不同的场景,有不同的用处。
我们的Synchronized初始使用乐观锁,当发现锁竞争比较频繁的时候,就会自动切换为悲观锁。
我们乐观锁有一个很重要的功能,就是要检测数据访问是否冲突,我们这里引入一个: "版本号"来解决。
比如两个线程去操作一个共享数据A初始为50,版本号为001,规定版本号的提交版本必须大于记录版本号才能更新操作。
比如我们的线程一将A修改为100,然后提交版本号为002,当我们的线程二将A修改为80时,修改版本号为002提交时,我们发现版本号并没有大于记录版本号,所以我们就认为此次的操作失败。
轻量级锁 vs 重量级锁
我们锁的核心特性: “原子性”,这样的机制追溯到根源是CPU硬件设备提供的
1.CPU提供了"原子操作指令”
2.操作系统基于CPU原子指令,实现了mutex互斥锁
3.JVM基于操作系统提供的互斥锁,实现了synchronized和reentrantLock等关键字和类
轻量级锁: 轻量级锁的加锁解锁的开销比较小(加锁尽量不使用mutex,而是尽量在用户态(我们人为可控制的状态)代码完成,实在完成不了在使用mutex)
重量级锁: 重量级锁的加锁解锁的开销比较大(加锁严重依赖操作系统的mutex,大量的内核态用户态的切换,线程调度的开销)
大多数情况下,乐观锁也是一个轻量级锁。
大多数情况下,悲观锁也是一个重量级锁。
自旋锁 vs 挂起等待锁
自旋锁(Spin Lock):在线程抢锁失败后,不是阻塞等待,而是快速的再循环一次,一旦锁被其他线程释放,就能第一时间获取到锁
挂起等待锁: 往往是内核实现,线程在抢锁失败之后进入阻塞状态,不占用CPU,直到操作系统调度之后被唤醒。
//自旋锁的伪代码
while(获取锁() == 失败) {
}
逻辑比较简单,先去获取锁,如果获取失败,就无限循环,直到获取到锁为止,一旦其他线程释放锁,会第一时间获取到锁。
自旋锁是一种典型的轻量级锁的实现方式:
优点: 没有放弃CPU,不涉及线程阻塞和调度,锁一旦被释放,就能第一时间获取到锁
缺点: 如果锁被占用的时间较长,那么自旋锁就会持续占有消耗CPU资源(我们挂起等待的状态是不消耗CPU资源的。
synchronized中的轻量级锁策略就很有可能是通过自旋锁方式实现的。
互斥锁 vs 读写锁
互斥锁: 我们之间用到过的synchronized这样的锁,如果一个线程获取到了锁,其他线程也尝试加锁的话,就会阻塞等待。
读写锁: 我们在并发编程的时候,如果仅仅是多个线程去读一个共享变量是不会产生线程安全问题的,但写和读之间或者写和写之间是互斥的。如果我们所有场景都使用同一种锁,就会产生很大的性能消耗,因此我们需要把读写锁分开。
读写锁就是把读操作和写操作分别加锁,我们java标准库提供了ReetrantReadWriterLock类,分别实现了读写锁。
ReentrantReadWriteLock.ReadLock 类表示一个读锁. 这个对象提供了 lock / unlock 方法进行加锁解锁.
ReentrantReadWriteLock.WriteLock 类表示一个写锁. 这个对象也提供了 lock / unlock 方法进行加锁解锁
我们所有的锁策略并没有优劣之分,我们的synchronized是互斥锁,我们的读写锁适合于"高频读,低频写"这样的场景。
公平锁 vs 非公平锁
我们来举例说明下公平锁和非公平锁。
我们有三个人在图书馆等开门。
我们的公平锁就是,最先来的先进,按顺序进。
非公平锁,就是三个人抢着给进冲,谁先进到图书馆,充满不确定性。
我们操作系统内部的线程调度可以认为是随机的,如果没有任何干涉,就是非公平锁。如果想要实现公平锁,需要加额外的数据结构(比如队列),来记录线程的先后顺序。
synchronized是非公平锁
可重入锁 vs 不可重入锁
可重入锁: 一个线程针对同一把锁连续加锁两次,不会出现死锁
不可重入锁: 一个线程针对同一把锁连续加锁两次,会出现死锁
按照我们之前对于锁的设定,当我们第二次加锁的时候,就会阻塞等待,直到第一次的锁被释放,才能获取到第一把锁,但是我们的第一个锁的释放也是由该线程完成,但是该线程正在尝试加锁,也就无法进行解锁操作,就会产生死锁。我们synchronized内部做了处理,判断时候第一次加锁和第二次加锁是同一个线程就不会产生死锁。
我们java当中synchronized是可重入的,以及Reentrant开头命名的锁都是可重入的,但是我们操作系统提供的mutex是不可重入锁。
二、CAS
CAS(Compare and swap):比较并交换,简单的来说就是CAS通过一条CPU指令(原子操作)在一定程度上可以回避线程安全问题,这是加锁之外,另一种解决线程安全的思路.
我们的CAS大致思路:
这里的old是我们内存的原数据,new是我们旧的预期值,expect是我们需要将旧的值改为的值。
1.先比较 old 和 new是否相等(比较)
2.如果相等,将expect赋给old,否则什么都不发生(交换)
3.返回操作是否成功
//CAS伪代码,并非真实
boolean CAS(address, expectValue, swapValue) {
if (&address == expectedValue) {
&address = swapValue;
return true;
}
return false;
}
我们上述代码只是CAS大致思路,并不是原子的,真正的CAS是通过CPU指令完成的,硬件给予了支持,我们的软件层面才达到了此行为。
原子类
我们的JUC包下为我们提供了一些原子类,就是通过CAS的方式实现的。
方法 | 作用 |
---|---|
addAndGet(int delta) | i += delta |
decrementAndGet() | –i |
getAndDecrement() | i– |
incrementAndGet() | ++i |
getAndIncrement() | i++ |
我们举例说明是如何保障线程安全的,我们这里以AtomicInteger的getAndIncrement()方法为例:
class AtomicInteger {
private int value;
public int getAndIncrement() {
int oldValue = value;
while ( CAS(value, oldValue, oldValue+1) != true) {
oldValue = value;
}
return oldValue;
}
}
我们的线程一,线程二根据同一个AtomicInteger对象A进行getAndIncrement()操作。
我们线程一和线程二对A进行操作,线程一对A进行了CAS操作,先比较是否与内存的值是否相等,我们发现是相等的,于是进行getAndIncrement操作。
当我们线程二进行getAndIncrement操作时,进行比较时发现与内存的值并不相同,于是它就会从新加载一份内存的值到自己的工作内存,然后在进行比较,比较相等在进行getAndIncremenet操作,如果不相等在加载,比较,一直循环到操作完成为止。
我们可以发现通过CAS的方式就可以实现一个原子类,而且不用使用重量级锁,就可以实现多线程对于共享变量的操作。
实现自旋锁
我们在上述已经给大家已经介绍了自旋锁的情况,基于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;
}
}
简单的来说我们的自旋锁需要判断锁是否空闲,如果空闲就获取,否则继续判断,直到获取为止,这和我们的CAS的比较,赋值干的活是一致的,所以我们的判断锁是否空闲,并且获取锁这一操作就可以基于CAS的操作来实现。
ABA问题
我们的CAS操作,大致流程就是判断old和new的值是否一致,如果一致,就认为这个变量是没有进行过操作,于是之间赋值。
ABA这个问题,就相当于我们买苹果手机,确实我们是拆了新机,但我们拆的这个手机,可能是翻新机,也可能是新机,对于我们正常使用来讲是无法区分的。
但是我们这里有一个非常极端的情况。
我们的狗头老铁去银行取钱,卡里总共有两百,想取100去网吧冲冲浪,于是他在自动取款机上按下了取100的操作,然后自动取款机卡死了,已经扣费了100,但是屏幕没有任何显示,也没有出钱,与此同时狗头老铁的好朋友给他还钱转了100,然后等了一会,然后自动取款机恢复了,狗头老铁发现余额是200,然后又输了取款100的操作,这次取款成功了。
尽管这种情况的发生的概率是很低的,但是我们仍然需要防患于未然,针对这里的操作我们引入一个版本号的概念,比如我们进行CAS操作的时候,初始的版本号是001,每次发生变化是我们的版本号都加1,进行CAS操作时,去判断版本号,如果版本号没变,证明我们的变量是没有变化的,如果版本号变化了,证明我们的变量进行过修改。