目录
1.常见的锁策略
1.1乐观锁 vs 悲观锁
悲观锁:
总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁。
乐观锁:
假设数据一般情况下并不会并发冲突,所以在数据进行提交更新的时候,才会正式对数据是否产生并发冲突进行检测,如果发现并发冲突了,则让返回用户错误的信息,让用户决定如何去做。
举个例子:同学A和同学B想请教老师一个问题。
同学A认为:“老师是比较忙的,我来问问题,老师不一定有空回答”。因此同学A会先给老师发信息:“老师你忙嘛?我下午两点能来找你问个问题嘛?”(相当于加锁操作)得到肯定的答复之后,才会真的来问问题。如果得到了否定的答复,那就等一段时间,下次再来和老师确定时间。这是个悲观锁。
同学B认为:“老师是比较闲的,我来问问题,老师大概率是有空解答的”。因此同学B直接就来找老师。(没加锁,直接访问资源)如果老师确定比较闲,那么直接问题就要解决了。如果老师这会确定很忙,那么同学B也不会打扰老师,就下次再来(虽然没加锁,但是能识别出数据访问冲突)。这个是乐观锁。
Synchronized初识使用乐观锁策略。当发现锁竞争比较频繁的时候,就会自动切换成悲观锁策略
1.2重量级锁 vs 轻量级锁
锁的核心特性“原子性”,这样的机制追根溯源是CPU这样的硬件设备提供的。
- CPU提供了“原子操作指令”。
- 操作系统基于CPU的原子指令,实现了mutex互斥锁。
- JVM基于操作系统提供的互斥锁,实现了synchronized 和ReetrantLock 等关键字和类。
注意:synchronized 并不仅仅是对mutex进行封装,在synchronized 内部还做了很多其他的工作
重量级锁:加锁机制重度依赖了OS提供了mutex
- 大量的内核态用户态切换
- 很容易引发线程的调度
这两个操作,成本比较高,一旦涉及到用户态和内核态的切换,就意味着“沧海桑田”。
轻量级锁:加锁机制尽可能不适用mutex,而是尽量在用户态代码完成,是在搞不定了,再使用mutex。
- 少量的内核态用户态切换。
- 不太容易引发线程调度。
理解用户态 vs 内核态
想象去银行办业务。
在窗口外,自己做,这是用户态,用户态的时间成本是比较可控的。
在窗口内,工作人员做,这是内核态,内核态的时间成本是不太可控的。、
如果办业务的时候反复和工作人员沟通,还需要重新排队,这时效率是很低的。
synchronized 开始是一个轻量级锁,如果锁冲突比较严重,就会变成重量级锁。
1.3自旋锁(Spin Lock)
按之前的方式,线程在抢锁失败后进入阻塞状态,放弃CPU,需要过很久才能再次被调度。
但实际上,大部分情况下,虽然当前抢锁失败,但过不了很久,锁就会被释放。没必要就放弃CPU。这个时候可以使用自旋锁来处理这样的问题。
自旋锁伪代码:
while (抢锁(lock) == 失败) {}
如果获取锁失败,立即在尝试获取锁,无限循环,直到获取到锁为止,第一次获取锁失败,第二次的尝试会在极短的时间内到来。
一旦锁被其他线程释放,就能第一时间获取到锁。
自旋锁是一种典型的 轻量级锁 的实现方式。
- 优点:没有放弃CPU,不涉及线程阻塞和调度,一旦被释放,就能第一时间获取到锁。
- 缺点:如果锁被其他线程持有的时间比较久,那么就会持续的消耗CPU资源(而挂起等待的时候是不消耗CPU的)。
synchronized中的轻量级锁策略大概率是通过自旋锁的方式实现的。
1.4公平锁 vs 非公平锁
假设三个线程ABC,A先尝试获取锁,获取成功。然后B再尝试获取锁,获取失败,阻塞等待;然后C也尝试获取锁,C也会获取失败,也阻塞等待。
当线程A释放锁的时候,会发生什么呢?
公平锁:遵守“先来后到”,B比C先来的,当A释放锁之后,B就能先于C获取到锁。
非公平锁:不遵守“先来后到”,B和C都有可能获取到锁。
注意:
- 操作系统内部的线程调度就可以视为随机的,如果不做任何额外的限制,锁就是非公平锁,如果要实现公平锁,就需要依赖额外的数据结构,来记录他们的先后顺序。
- 公平锁和非公平锁没有好坏之分,关键还是看使用场景。
synchronized是非公平锁
1.5可重入锁和不可重入锁
可重入锁的字面意思是“可以重新进入的锁”,即允许同一个线程多次获取同一把锁。
比如一个递归函数里面有加锁操作,递归过程中这个锁会阻塞自己吗?如果不会,那么这个锁就是可重入锁(因为这个原因可重入锁也叫做递归所)。
Java里只要以Reentrant开头命名的锁都是可重入的,而且JDK提供的所有现成的Lock实现类,包括synchronized关键字锁都是可重入的。
而Linux系统提供的mutex是不可重入锁。
// 第⼀次加锁, 加锁成功
lock();
// 第⼆次加锁, 锁已经被占⽤, 阻塞等待.
lock();
synchronized是可重入锁。
1.6读写锁
多线程之间,数据的读取方之间不会产生线程安全问题,但数据的写入方互相之间以及和读者之间都需要进行互斥。如果两种场景下都用一个锁,就会产生极大的性能损耗。所以读写锁因此而产生。
读写锁,在执行加锁操作时需要额外表明读写意图,复数读者之间并不互斥,而写者则要求任何人互斥。
一个线程对于数据的访问,主要存在两种操作:读数据和写数据。
- 两个线程都只是读一个数据,此时并没有线程安全问题,直接并发的读取即可。
- 两个线程都要写一个数据,有线程安全问题。
- 一个线程读另外一个线程写,也有线程安全问题。
读写锁就是把读操作和写操作区分对待,Java标准库提供了ReentrantReadWriteLock类,实现了读写锁。
- ReetrantReadWriteLock.ReadLock类表示一个读锁。这个对象提供了lock/unlock方法进行加锁解锁。
- ReentrantReadWriteLock.WriteLock类表示一个写锁。这个对象也提供了lock/unlock方法进行加锁解锁。
其中,
- 读加锁和读加锁之间,不互斥。
- 写加锁和写加锁之间,互斥。
- 读加锁和写加锁之间,互斥。
注意,只要是涉及到“互斥”,就会产生线程的挂起等待。一旦线程挂起,再次被唤醒就不知道隔了多久了。
因此尽可能减少“互斥”的机会,就是提高效率的重要途径。
读写锁特别适合于“频繁读,不频繁写”的场景中。
Synchronized不是读写锁。
2.CAS
2.1什么事CAS
CAS:全称Compare and swap,字面意思:“比较并交换”,一个CAS涉及到以下操作:
我们假设内存中的原数据V,旧的预期值A,需要修改的新值B。
- 比较A与V是否相等。(比较)
- 如果比较相等,将B写入V。(交换)
- 返回操作是否成功。
CAS伪代码
boolean CAS(address, expectValue, swapValue) {
if (&address == expectedValue) {
&address = swapValue;
return true;
}
return false;
}
两种典型的不是“原子性”的代码
当多个线程同时对某个资源进行CAS操作,只能有一个线程操作成功,但是并不会阻塞其他线程,其他线程只会收到操作失败的信号。
CAS可以视为是一种乐观锁。
2.2CAS是怎么实现的
针对不同的操作系统,JVM用到了不同的CAS实现原理,简单原理:
- java的CAS利用的是unsafe这个类提供的CAS操作。
- unsafe的CAS依赖了的事jvm针对不同的操作系统实现的Atomic::cmpxchg;
- Atomic::cmpxchgd的实现使用了汇编的CAS操作,并使用cpu硬件提供的Lock机制保证其原子性。
简而言之,是因为硬件给予了支持,软件层面才能做到。
2.3CAS有哪些应用
2.3.1实现原子类
标准库中提供了java.util.concurrent.atomic包,里面的类都是基于这种方式来实现的。
典型的就是AtomicInteger类。其中的getAndINcrement相当于i++操作。
AtomicInteger atomicInteger = new AtomicInteger(0);
// 相当于 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;
}
}
假设两个线程同时调用getAndIncrement
- 两个线程都读取value的值到oldValue中。(oldValue是一个局部变量,在栈上。每个线程有自己的栈)。
- 线程1先执行CAS操作,由于oldValue和value的值相同,直接进行对value赋值。
注意:
- CAS是直接读写内存的,而不是操作寄存器。
- CAS的读内存,比较,写内存操作是一条硬件指令,是原子的。
3.线程2执行CAS操作,第一次CAS的时候发现oldValue和Value不相等,不能进行赋值,因此需要进入循环。
4.线程2接下来第二次执行CAS,此时oldValue和value相同,于是直接执行赋值操作。
5.线程1和线程2返回各自的oldValue的值即可。
通过形如上述代码就可以实现一个原子类,不需要使用重量级锁,就可以高效的完成多线程的自增操作。
本来check and set这样的操作在代码角度不是原子的。但是在硬件层面上可以让一条指令完成这个操作,也就是变成原子的了。
2.3.2实现自旋锁
基于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;
}
}
2.4CAS的ABA问题
2.4.1什么是ABA问题
ABA的问题:
假设存在两个线程t1和t2,有一个共享变量num,初始值A。
接下来,线程t1想使用CAS把num值改成Z,那么就需要
- 先读取num的值,记录到oldNum变量中。
- 使用CAS判定当前num的值是否为A,如果为A,就修改成Z。
但是,在t1执行这两个之间,t2线程可能吧num的值从A改成了B,又从B改成了A