乐观锁与悲观锁
乐观锁和悲观锁都是用于解决并发场景下的数据竞争问题,但是却是两种完全不同的思想。它们的使用非常广泛,也不局限于某种编程语言或数据库。
乐观锁的概念
所谓的乐观锁,指的是在操作数据的时候非常乐观,乐观地认为别人不会同时修改数据,因此乐观锁不会上锁,只有在执行更新的时候才会去判断在此期间别人是否修改了数据,如果别人修改了数据则放弃操作,否则执行操作。
乐观锁执行过程
悲观锁的概念
所谓的悲观锁,指的是在操作数据的时候比较悲观,悲观地认为别人一定会同时修改数据,因此悲观锁在操作数据时是直接把数据上锁,直到操作完成之后才会释放锁,在上锁期间其他人不能操作数据。
悲观锁的执行过程乐观锁的实现方式
乐观锁的实现方式主要有两种,一种是CAS(Compare and Swap,比较并交换)机制,一种是版本号机制。
CAS机制
CAS操作包括了三个操作数,分别是需要读取的内存位置(V)、进行比较的预期值(A)和拟写入的新值(B),操作逻辑是,如果内存位置V的值等于预期值A,则将该位置更新为新值B,否则不进行操作。另外,许多CAS操作都是自旋的,意思就是,如果操作不成功,就会一直重试,直到操作成功为止。
版本号机制
版本号机制的基本思路,是在数据中增加一个version字段用来表示该数据的版本号,每当数据被修改版本号就会加1。当某个线程查询数据的时候,会将该数据的版本号一起读取出来,之后在该线程需要更新该数据的时候,就将之前读取的版本号与当前版本号进行比较,如果一致,则执行操作,如果不一致,则放弃操作。
悲观锁的实现方式
悲观锁的实现方式也就是加锁,加锁既可以在代码层面(比如Java中的synchronized
关键字),也可以在数据库层面(比如MySQL中的排他锁)。
乐观锁与悲观锁的优缺点和使用场景
乐观锁和悲观锁并没有优劣之分,它们有各自适合的场景。
功能限制
乐观锁与悲观锁相比,适用的场景受到了更多的限制,无论是CAS机制还是版本号机制。
比如,CAS机制只能保证单个变量操作的原子性,当涉及到多个变量的时候,CAS机制是无能为力的,而synchronized却可以通过对整个代码块进行加锁处理;再比如,版本号机制如果在查询数据的时候是针对表1,而更新数据的时候是针对表2,也很难通过简单的版本号来实现乐观锁。
竞争激烈程度
在竞争不激烈(出现并发冲突的概率比较小)的场景中,乐观锁更有优势。因为悲观锁会锁住代码块或数据,其他的线程无法同时访问,必须等待上一个线程释放锁才能进入操作,会影响并发的响应速度。另外,加锁和释放锁都需要消耗额外的系统资源,也会影响并发的处理速度。
在竞争激烈(出现并发冲突的概率较大)的场景中,悲观锁则更有优势。因为乐观锁在执行更新的时候,可能会因为数据被反复修改而更新失败,进而不断重试,造成CPU资源的浪费。
乐观锁是否会加锁
乐观锁本身是不加锁的,只有在更新的时候才会去判断数据是否被其他线程更新了,比如AtomicInteger便是一个例子。但是有时候乐观锁可能会与加锁操作合作,比如MySQL在执行更新数据操作的时候会加上排他锁。因此可以理解为乐观锁本身是不加锁的,只有在更新数据的时候才有可能会加锁。
CAS的缺点
CAS的缺点主要有ABA问题、高竞争下的开销问题和本身的功能限制。
ABA问题
所谓的ABA问题,指的就是一个线程在操作数据的时候,有别的线程对数据进行了一系列操作,但是在该线程重新读取该数据的时候,被修改过的数据却和该线程一开始读取的数据一致,该线程不会知道该数据已经被修改过了,然后CAS操作就被判断是成功了。
ABA问题在一些场景下可能不会造成什么危害,但是在某些场景中却可能会造成隐患。比如CAS操作的是栈顶的数据,栈顶的数据虽然经过两次(或多次)变化后又恢复了原值,但是栈却可能是发生了变化,栈中数据的变化就可能会引发一些问题。
对于ABA问题,比较有效的方案是引入版本号。只要内存中的值发生变化,版本号就加1,在进行CAS操作的时候不仅比较内存中的值,也比较版本号,只有当二者都没有变化的时候,CAS操作才能执行成功。Java中的AtomicStampedReference类便是适用版本号来解决ABA问题的。
高竞争下的开销问题
在并发冲突的概率较大的高竞争场景下,如果CAS操作一直失败,就会一直重试,造成CPU开销大的问题。针对这个问题,一个简单的思路是引入退出机制,如果重试次数超过一定阈值,就强制失败退出。当然了,最好是避免在高竞争的场景下使用乐观锁。
自身的功能限制
CAS的功能是比较受限的,比如CAS只能保证单个变量(或者说单个内存值)操作的原子性。这就意味着原子性不一定能保证线程安全,当涉及到多个变量(或者说多个内存值),CAS也是无能为力。除此之外,CAS的实现需要硬件层面处理器的支持,在Java中普通的用户是无法直接使用的,只有借助atomic包下的原子类才能使用,灵活性有限。
公平锁与非公平锁
公平锁的概念
公平锁 是指多个线程按照申请锁的顺序来获取锁,类似排队打饭,先来后到。
非公平锁的概念
非公平锁 是指多个线程获取锁的顺序并不是按照申请锁的顺序,有可能后申请的线程比先申请的线程优先获取锁,在高并发的情况,有可能会造成优先级 反转 或 饥饿现象。
两者区别
公平锁 / 非公平锁
并发包中ReentrantLock的创建可以指定构造函数的boolean
类型来得到公平锁或非公平锁,默认是非公平锁。
关于两者的区别
公平锁:Threads acquire a fair lock in the order which they requested it.
公平锁,就是很公平,在并发环境中,每个线程在获取锁时会先查看此锁维护的等待队列,如果为空,或者当前线程是等待队列的第一个,就占有锁,否则就会加入到等待队列中,以后按照FIFO的规则从队列中取到自己。
非公平锁:a nonfair lock permits barging: treads requesting a lock can jump ahead of the queue of waiting threads if the lock happens to be available when it is requested.
非公平锁,毕竟粗鲁,上来就直接尝试占有锁,如果尝试失败,就再采用类似公平锁的那种方式。
题外话
Java ReentrantLock而言
通过构造函数指定该锁是否是公平锁,默认是非公平锁。非公平锁的优点在于吞吐量比公平锁大。
对于Synchronized而言,也是一种非公平锁。
自旋锁与非自旋锁
自旋锁的概念
自旋锁(spinlock
),是指尝试获取锁的线程不会立即阻塞,而是采用循环的方式去尝试获取锁,这样的好处是减少线程上下文切换的消耗,缺点是循环会消耗CPU。
自旋锁的优点
自旋锁不会使线程状态发生切换,一直处于用户态,即线程一直都是active的;不会使线程进入阻塞状态,减少了不必要的上下文切换,执行速度快
非自旋锁在获取不到锁的时候会进入阻塞状态,从而进入内核态,当获取到锁的时候需要从内核态恢复,需要线程上下文切换。 (线程被阻塞后便进入内核(Linux)调度状态,这个会导致系统在用户态与内核态之间来回切换,严重影响锁的性能)
// unsafe.getAndAddInt
public final int getAndAddInt(Object var1, long var2, int var4) {
int var5;
do {
var5 = this.getIntVolatile(var1, var2);
} while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));
return var5;
}
Demo1# 实现一个自旋锁
package com.nuih.lock;
import sun.misc.Unsafe;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
/**
* 题目:实现一个自旋锁
* 自旋锁好处:循环比较获取直到成功为止,没有类似wait的阻塞
*
* 通过cas操作完成自旋锁,A线程先进来调用myLock方法自己持有锁5秒钟,
* B随后进来后发现当前又线程持有锁,不是null,所以只能通过自旋等待,直到A释放锁后B随后抢到
*
* @author: hejianhui
* @create: 2020-06-21 15:05
* @see SpinLockDemo
* @since JDK1.8
*/
public class SpinLockDemo {
// 原子引用
AtomicReference<Thread> atomicReference = new AtomicReference<>();
public void myLock(){
Thread thread = Thread.currentThread();
System.out.println(thread.getName() + "\t come in 😊");
while (!atomicReference.compareAndSet(null,thread)){
}
}
public void myUnLock(){
Thread thread = Thread.currentThread();
atomicReference.compareAndSet(thread,null);
System.out.println(thread.getName() + "\t invoked myUnLock");
}
public static void main(String[] args) throws InterruptedException {
SpinLockDemo spinLockDemo = new SpinLockDemo();
new Thread(() -> {
spinLockDemo.myLock();
try {
TimeUnit.SECONDS.sleep(5);
} catch (InterruptedException e) {
e.printStackTrace();
}
spinLockDemo.myUnLock();
},"A").start();
TimeUnit.SECONDS.sleep(1);
new Thread(() -> {
spinLockDemo.myLock();
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
spinLockDemo.myUnLock();
},"B").start();
}
}
可重入锁(又名递归锁)
可重入锁的概念
可重入锁(也叫做递归锁)
指的是同一线程外层函数获得锁之后,内层递归函数仍然能获取该锁的代码,在同一个线程在外层方法获取锁的时候,在进入内层方法会自动获取锁。也就是说,线程可以进入任何一个它已经拥有的锁所同步着的代码快。
ReentrantLock / Synchronized 就是一个典型的可重入锁
可重入锁的最大作用是避免死锁
Demo2# 可重入锁
package com.nuih.lock;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
class MyPhone implements Runnable {
public synchronized void sendMsg(){
System.out.println(Thread.currentThread().getName() + "\t invoked sendMsg");
sendEmail();
}
public synchronized void sendEmail(){
System.out.println(Thread.currentThread().getName() + "\t##### invoked sendEmail");
}
Lock lock = new ReentrantLock();
@Override
public void run() {
get();
}
public void get(){
lock.lock();
try{
System.out.println(Thread.currentThread().getName() + "\t invoked get");
set();
}finally {
lock.unlock();
}
}
public void set(){
lock.lock();
try{
System.out.println(Thread.currentThread().getName() + "\t###### invoked set");
}finally {
lock.unlock();
}
}
}
/**
* 可重入锁(也叫做递归锁)
*
* 指的是同一线程外层函数获得锁之后,内层递归函数仍然能获取该锁的代码,
* 在同一个线程在外层方法获取锁的时候,在进入内层方法会自动获取锁。
*
* 也就是说,线程可以进入任何一个它已经拥有的锁所同步着的代码快。
*
* t1 invoked sendMsg() t1线程在外层方法获取锁的时候
* t1 ##### invoked sendEmail t1在进入内层方法会自动获取锁
*
* t2 invoked sendMsg()
* t2 ##### invoked sendEmail
*
* @author: hejianhui
* @create: 2020-06-21 14:10
* @see ReentrantLockDemo
* @since JDK1.8
*/
public class ReentrantLockDemo {
public static void main(String[] args) throws InterruptedException {
MyPhone myPhone = new MyPhone();
new Thread(() -> {
myPhone.sendMsg();
},"t1").start();
new Thread(() -> {
myPhone.sendMsg();
},"t2").start();
TimeUnit.SECONDS.sleep(1);
System.out.println("\n\n\n\n");
Thread t3 = new Thread(myPhone,"t3");
t3.start();
Thread t4 = new Thread(myPhone,"t4");
t4.start();
}
}
独占锁(写锁)/共享锁(写锁)/互斥锁
是什么?
独占锁:指该锁一次只能被一个线程所持有。对ReentrantLock和Synchronized而言都是独占锁
共享锁:指该锁可被多个线程所持有。
对ReentrantReadWriteLock其读锁是共享锁,其写锁是独占锁。
读锁的共享锁可保证并发读是非常高效的,读写、写读、写写的过程是互斥的。
Demo#3 独占锁/共享锁
package com.nuih.lock;
import com.nuih.map.HashMap;
import com.nuih.map.Map;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
class MyCache{
private volatile Map<String,Object> map = new HashMap<>();
final ReadWriteLock readWriteLock = new ReentrantReadWriteLock();
public void put(String key,Object value){
readWriteLock.writeLock().lock();
try{
System.out.println(Thread.currentThread().getName() + "\t 开始写入:" + key);
map.put(key,value);
System.out.println(Thread.currentThread().getName() + "\t 写入完成");
}finally {
readWriteLock.writeLock().unlock();
}
}
public void get(String key){
readWriteLock.readLock().lock();
try {
System.out.println(Thread.currentThread().getName() + "\t 开始读取");
Object result = map.get(key);
System.out.println(Thread.currentThread().getName() + "\t 读物完成:" + result);
}finally {
readWriteLock.readLock().unlock();
}
}
}
/**
* 多个线程同时读一个资源类没有任何问题,所以为了满足并发量,读取共享资源应该可以同时进行
* 但是
* 如果又一个线程想去写共享资源了,就不应该再有其它线程可以对该资源进行读或写
* 小总结:
* 读-读可以共存
* 读-写不能共存
* 写-写不能共存
*
* 写操作:原子+独占,中间过程必须一个完整的统一体,中间不许被分割
*
* @author: hejianhui
* @create: 2020-06-21 15:39
* @see ReadWriteLockDemo
* @since JDK1.8
*/
public class ReadWriteLockDemo {
public static void main(String[] args) {
MyCache myCache = new MyCache();
for(int i=0;i<5;i++){
int finalI = i;
new Thread(() -> {
myCache.put(finalI+"",finalI);
},"A"+String.valueOf(i)).start();
}
for(int i=0;i<5;i++){
int finalI = i;
new Thread(() -> {
myCache.get(finalI+"");
},"B"+String.valueOf(i)).start();
}
}
}
网友评论