多线程—锁策略
上篇文章:
多线程—应用案例https://blog.csdn.net/sniper_fandc/article/details/146456564?fromshare=blogdetail&sharetype=blogdetail&sharerId=146456564&sharerefer=PC&sharesource=sniper_fandc&sharefrom=from_link
目录
1 乐观锁VS悲观锁
2 互斥锁VS读写锁
3 重量级锁VS轻量级锁
4 自旋锁VS挂起等待锁
5 公平锁VS非公平锁
6 可重入锁VS不可重入锁
7 CAS
7.1 CAS的概念与理解
7.2 CAS应用
7.2.1 实现原子类
7.2.2 实现自旋锁
7.3 CAS的ABA问题
1 乐观锁VS悲观锁
乐观锁:认为发生并发冲突的概率不大,只有提交数据修改时才会检测是否发生并发冲突,发生并发就返回错误信息给用户处理。往往是纯用户态执行。
悲观锁:认为发生并发冲突的概率很大,每次取数据时都会加锁。往往是需要进入内核态对当前线程挂起等待。
synchronized是一个自适应锁,即初始是乐观锁策略,当锁竞争激烈时就切换为悲观锁。
2 互斥锁VS读写锁
互斥锁:任意两个加锁操作互斥。
读写锁:细化加锁操作,分为加读锁和加写锁。读锁与读锁不互斥(线程安全),读锁与写锁互斥(线程不安全),写锁与写锁互斥(线程不安全)。读写锁常用于“频繁读,不频繁写”的场景。
Java 标准库提供了ReentrantReadWriteLock类实现了读写锁,使用lock/unlock方法进行加锁/解锁。ReentrantReadWriteLock.ReadLoc类表示读锁,ReentrantReadWriteLock.WriteLock类表示一个写锁。
synchronized是互斥锁,不是读写锁。
3 重量级锁VS轻量级锁
从CPU到Java代码,各级向上提供的API是不同的,而ReentrantLock和synchronized是对操作系统API的封装,并在内部做了许多其他工作:
重量级锁:加锁使用mutex互斥锁,涉及到用户态和内核态切换,容易引起线程调度,开销大。
轻量级锁:加锁尽可能不使用mutex互斥锁,尽量在用户态完成加锁工作,开销小。
synchronized是一个自适应锁,即初始是轻量级锁策略,当锁竞争激烈时就切换为重量级锁。
4 自旋锁VS挂起等待锁
这组锁是轻量级锁和重量级锁的具体实现。
自旋锁:当线程获取锁失败,不会被阻塞,会立即再次尝试获取锁,直到获取锁成功。因此自旋锁更轻量(尽力避免线程调度,在用户态完成工作),自旋锁是轻量级锁,也是乐观锁。但是缺点就是如果锁一直不释放,就会消耗大量CPU资源。伪代码:
while (抢锁(lock) == 失败) {}
挂起等待锁:当前程获取锁是被,会被挂起等待,放弃CPU资源。因此挂起等待锁更重量(挂起等待涉及到线程调度,用户态和内核态的切换),挂起等待锁是重量级锁,也是悲观锁。但是缺点就是锁被释放时,线程无法第一时间获取到锁。
synchronized采用轻量级锁策略时,内部是自旋锁的实现;采用重量级锁策略时,内部是挂起等待锁的实现。
5 公平锁VS非公平锁
公平锁:符合“先来后到”原则,即线程A持有锁,B比C先到,A释放锁后B会先得到锁。
非公平锁:符合“机会平等”原则,即线程A释放锁后,无论BC谁先到,都会一起竞争锁。
操作系统内部的挂起等待锁就是非公平锁,要想实现公平锁,必须使用额外的数据结构来实现(比如队列),synchronized也是非公平锁。
6 可重入锁VS不可重入锁
可重入锁:一个线程可以重复对同一个锁对象加锁。比如某一个线程先进入一段synchronized 代码块对一个锁对象加锁,但是在这个代码块中还需要对该锁对象加锁,按照互斥的特性,这个锁对象未被释放时线程无法再次加锁,出现线程阻塞等待自己未释放的锁(自己等自己,死锁),对应代码如下:
class Counter {
public int count = 0;
synchronized void increase1() {
count++;
}
synchronized void increase2() {
increase1();
}
}
这段代码可以正常运行,因为synchronized是可重入锁。在可重入锁内部包含“线程持有者”和“计数器”两个属性,线程持有者指明锁持有的线程,同一个线程重复加锁时,判断持有的线程还是自己,就同意加锁,计数器+1;计数器的值减为0时,锁才被释放。Java里只要以Reentrant开头命名的锁都是可重入锁,而且JDK提供的所有现成的Lock实现类。
不可重入锁:一个线程不能重复对同一个锁对象加锁。Linux提供的mutex互斥锁是不可重入锁。
7 CAS
7.1 CAS的概念与理解
CAS是CPU提供的原子性的硬件指令,全称是Compare And Swap,比较并交换,可以
具体而言:假设某个变量存在寄存器A、寄存器B和内存共3个版本,B存的是最新修改后的值(newValue),A存的是旧值(oldValue),内存存的是value,CAS(value,oldValue,newValue)要做的事是比较value与oldValue,如果相等,就把newValue写入value的值;如果不相等,就什么都不做。
当多个线程同时对某个资源进行CAS操作,只能有一个线程操作成功,但是并不会阻塞其他线程,其他线程只会收到操作失败的信号。
如何理解CAS:当value与oldValue相等,说明在此时多线程环境下,内存数据和寄存器的副本保持了数据一致性,此时没有线程安全问题,直接执行写操作即可。而当value与oldValue不相等,说明此时寄存器的副本和内存数据没有保存数据一致性,发生了多个线程同时读写数据的情况,且该线程的寄存器中的数据已经不是最新的了,此时如果再执行就会导致线程不安全,因此就不执行写操作(也就是什么都不做)。因此CAS也是乐观锁的一种实现方式。
7.2 CAS应用
7.2.1 实现原子类
在java.util.concurrent.atomic包中的类都是原子类,基于CAS实现,比如AtomicInteger类,使用AtomicInteger类代替Integer修饰变量,就会保证读写操作的原子性,进而避免使用重量级锁(通过阻塞来引起大开销的操作)。
AtomicInteger有getAndIncrement()和incrementAndGet()方法,分别对应i++和++i操作,但是i++和++i操作不是原子性的,而getAndIncrement()和incrementAndGet()方法使用CAS机制保证了原子性。内部实现的伪代码:
public int getAndIncrement() {
int oldValue = value;
while ( CAS(value, oldValue, oldValue+1) != true) {
oldValue = value;
}
return oldValue;
}
举一个例子理解CAS保证i++操作原子性的例子,假设变量i初始值为0,两个线程并发执行一次对i的自增操作,执行序列依次是线程1先执行int oldValue = value(操作1),线程2再执行int oldValue = value(操作2),然后线程1执行while(操作3),最后线程2执行while(操作4),流程如下:
操作1和操作2两个线程都会读取value=0的值到自己的工作内存(即oldValue=0);操作3线程1判断value和oldValue相同,因此执行交换,value=oldValue+1,同时退出循环,此时value=1;操作4线程2判断value和oldValue不相同,因此不做任何操作,此时while条件成立,执行循环体一次,因此执行oldValue = value,此时线程2的oldValue值和value相同,再次执行条件判断CAS,会执行交换value=oldValue+1,因此value=2。即两次多线程并发的自增操作并没有引起线程不安全问题。
7.2.2 实现自旋锁
public class SpinLock {
private Thread owner = null;
public void lock(){
while(!CAS(this.owner, null, Thread.currentThread())){
}
}
public void unlock (){
this.owner = null;
}
}
自旋锁使用CAS实现和原子类类似,owner表示当前锁的持有线程是谁,null表示锁没有任何线程持有。通过 CAS 指令判断当前锁是否被某个线程持有,如果这个锁已经被别的线程持有(this.owner==null),那么就继续一次循环。如果这个锁没有被别的线程持有,那么就把owner设为当前尝试加锁的线程(owner=Thread.currentThread())。
7.3 CAS的ABA问题
CAS的ABA问题是指:多线程环境下,一个线程读取到变量的值为A,在还未执行修改操作前,由于并发执行的CPU调度,导致其他线程修改变量的值由A变为B再变回A,导致原线程无法判断这个变量的值一直是A还是经过变化后是A,从而容易引发一些问题。
比如银行取钱:用户希望取100,但是不小心连续按了两个取100的按键,启动两个线程并发执行取钱程序,第一个线程先成功取100,第二个线程后判断余额比原来读取的少了100,因此不执行交换操作,也就是取钱失败,最终账户只扣了100。这属于正常情况,符合用户期望。但是如果在第二个线程比较前有其他线程进行了转账业务,转入100,此时第二个线程比较余额就会和原来读取的一样,因此就会执行交换操作,余额又扣了100,因此这就引发了bug。
如何解决:引入版本号,每一个数据都有对应的版本号,每一次修改版本号只会增加,CAS进行比较时如果发现数据一致,就比较版本号,只有版本号和数据都一样是才进行交换操作,否则就不进行任何操作(操作失败)。
Java提供AtomicStampedReference<E>类来包装某个类,进行版本管理工作。
下篇文章: