文章目录
- 悲观锁和乐观锁
- Java中的悲观锁和乐观锁
- 乐观锁常见的两种实现方式
- 版本号机制
- CAS(compare and swap) 算法
- 乐观锁的缺点
- 轻量级锁和重量级锁
- 自旋锁 VS 互斥锁
- 公平锁 VS 非公平锁
- 读写锁
- 读写锁的插队策略
- 读写锁的升级策略
- 可重入锁 VS 不可重入锁
悲观锁和乐观锁
所谓悲观和乐观
从人的精神层面来讲,悲观就是:在生活中,人思考问题时,总向着坏的方向思考,乐观就是:在思考问题时,总向着好的方向考
Java中的悲观锁和乐观锁
Java中的悲观锁和乐观锁是一种锁的思想,并不是一种具体的锁,所以以下的内容,都是在讲解锁的思想,请读者不要和具体的锁产生混乱;
- 悲观锁:总是假设是最坏的情况,当一个线程获取数据时,都会有其他的线程同时来修改这个数据,所以,当一个线程拿数据时,总会去加锁,这样,当别的线程想要对这个数据进行操作时,就会阻塞等待,换句话讲,就是共享资源在一个时刻间只能一个线程获取,其他线程阻塞等待,直到这个线程释放锁之后,其他线程才能够在尝试获取资源,所以,悲观锁更适合于多个线程对同一个资源进行修改的情况
- 乐观锁:总是假设是最好的情况,当一个线程获取数据的同时,认为其他的线程不会来对这个数据进行修改,所以,就没必要进行加锁,所以,正因为这个原因,乐观锁更适合于多个线程读取同一个资源的情况。
乐观锁常见的两种实现方式
1️⃣版本号机制
2️⃣CAS 算法
版本号机制
版本号机制是要引入一个版本号属性version,来记录数据被修改的次数,当数据被修改时,version+1,比如,线程A要更新数据的场景时,在获取这个这个数据的同时,会把version也获取到,当线程A对数据修改了以后,也会将version+1,然后,在提交这个更新后的数据时,如果刚才已经修改后的version值大于当前内存中的version值,更新数据,否则,重试更新操作,直到更新成功。
举个例子:假设有这样一种场景:当前,钱包中有100余额,线程A减了50,在线程A进行减50的过程中,线程B进行了减20的操作,请看下图:
CAS(compare and swap) 算法
CAS 算法正如它的名字一样,比较和交换,它有三个操作数CAS(V,A,B),算法如下:
V是需要读写的内存值
A是要和V进行比较的值
B是要写入的新值
当 V 和 A相同时,CAS 算法就认为此时V没有被修改过,就会将 B 赋值给 V,否则,不会进行任何的操作,需要注意的是:这个比较和交换的操作,它是一个原子性的操作,也就是由一条 CPU 指令完成的,所以,CAS 算法也是一种无锁编程,即在不使用锁的情况下实现多线程之间的变量同步,在一般情况下,它是一个自旋的操作,也就是不断的重试。
乐观锁的缺点
1️⃣.ABA问题
ABA 问题是 CAS 操作中的一个大问题,如果一个变量 V 初次读取的时候是 A 值,在准备赋值的时候,检查到它仍然是 A 值,那我们就能说明这个值就没被其他线程改过吗?,答案是:不能,因为,在这段时间内,可能被其他线程改过了,但是又改了过来,那 CAS 操作就会认为它从来没有被修改过。
2️⃣.循环时间长,开销大
因为,在 CAS 操作下,它是一种自旋操作,以及在引入版本号的情况下,它也是一种循环重试的操作,如果长时间不成功,那么就会一直循环重试,进入一种“忙等”的状态,对 CPU 的开销比较大
本篇文章中有些内容借鉴于:https://zhuanlan.zhihu.com/p/40211594
轻量级锁和重量级锁
轻量级锁,锁的开销比较小;
重量级锁,锁的开销比较大;
轻量级锁和重量级锁也是和上面的乐观和悲观有关联的,因为,乐观锁做的工作比较少,所以就会比较轻量,而悲观锁,做的工作比较多,所以就会很重量。
它们只是站在了不同的角度来衡量的,一个是预测锁冲突的概率,一个是实际消耗的开销
所以乐观锁通常就是轻量级锁,悲观锁通常是重量级锁
自旋锁 VS 互斥锁
自旋锁 就属于轻量级锁的典型表现
互斥锁 就属于重量级锁的一种典型表现
对于互斥锁而言,当某一个线程获取锁后,其他线程再尝试获取锁时,就会进行阻塞等待,就暂时不参与 CPU 的调度,暂时就不参与 CPU 的运算了,直到锁释放以后,
互斥锁要借助系统 api 来实现,如果出现锁竞争,就会在内核中触发一系列的动作,比如,让线程进入阻塞状态,暂时不参与cpu的调度,直到锁被释放以后,才参与CPU的调度,这里就涉及了内核态和用户态切换操作,所以开销就比较大,就比较重量
自旋锁 往往是在纯用户态实现,比如使用一个while循环来不停的判定当前锁是否被释放,如果没释放,就继续循环,如果释放了,就获取倒锁,从而结束循环,它就不涉及到阻塞,会一直在CPU上运行,通过“忙等”的方式消耗cpu,换来更快的响应。
公平锁 VS 非公平锁
假设,现在有三个线程 A,B,C 轮流尝试获取同一把锁,此时,线程A获取到锁后,线程B 和 线程C依次阻塞等待,当线程A释放锁后,线程B获取锁,之后 线程C 再获取锁,这样按照“先来后到”的方式,来加锁,此时就是公平锁,反之,线程A释放锁喉,线程B 和 线程C 都有可能获取到锁,此时就是非公平锁
公平锁:按照“先来后到”的方式加锁,此时就是公平锁
非公平锁:不按照“先来后到”的方式,按照“抢占式”的方式,此时就是非公平锁。例如,synchronized 就是非公平锁
读写锁
在多线程下,进行读操作时,是不会产生线程安全问题的,在写操作时,非常容易出现线程安全问题,所以,就可以使用加锁产生互斥效果来解决线程安全问题,而在多个线程进行读操作时,既然不会产生线程安全问题,那么也就不用再进行互斥操作了因为只要涉及到互斥操作,就要阻塞等待,阻塞等待后,就不知道什么时候能够被唤醒了,而且,阻塞等待是内核态+用户态完成的,所以,效率就比较低,而为了提高效率,减少互斥就是一种重要的手段,所以直接并发读就可以了,只有在写操作时,进行互斥操作,所以,就有了读写锁策略。
读写锁的特性:
- 读加锁 和 读加锁 之间不互斥
- 写加锁 和 读加锁 之间互斥
- 写加锁 和 写加锁 之间互斥
在 Java 标准库中,提供了 ReentrantReadWriteLock 类,该类是基于读写锁实现的;
在这个类中,又实现了两个内部类,分别表示 读锁 和 写锁:
- ReentrantReadWriteLock.ReadLock 类表示读锁,这个类提供了 lock() 方法进行加锁 和 unlock() 方法进行解锁
- ReentrantReadWriteLock.WriteLock 类表示写锁,,这个类提供了 lock() 方法进行加锁 和 unlock() 方法进行解锁
代码示例:
示例一:两个线程都进行读操作。执行结果:可以同时获取锁
public class Main {
//创建 ReentrantReadWriteLock实例,用于创建读锁和写锁实例
private static ReentrantReadWriteLock reentrantReadWriteLock = new ReentrantReadWriteLock();
//创建读锁实例
private static ReentrantReadWriteLock.ReadLock readLock = reentrantReadWriteLock.readLock();
//创建写锁实例
private static ReentrantReadWriteLock.WriteLock writeLock = reentrantReadWriteLock.writeLock();
//创建线程池
private static ExecutorService threadPool = Executors.newCachedThreadPool();
//获取的读锁方法
public static void read() {
//线程获取到读锁
readLock.lock();
System.out.println(Thread.currentThread().getName() + "获取读锁,开始执行");
try {
//睡眠3秒
Thread.sleep(3000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}finally {
//释放读锁
readLock.unlock();
System.out.println(Thread.currentThread().getName() + "释放读锁,执行完成");
}
}
//获取写锁的方法
public static void write() {
writeLock.lock();
System.out.println(Thread.currentThread().getName() + "获取写锁,开始执行");
try {
//睡眠3秒
Thread.sleep(3000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}finally {
//释放读锁
writeLock.unlock();
System.out.println(Thread.currentThread().getName() + "释放写锁,执行完成");
}
}
public static void main(String[] args) {
threadPool.submit(new Runnable() {
@Override
public void run() {
read();
}
});
threadPool.submit(new Runnable() {
@Override
public void run() {
read();
}
});
}
}
结论:由结果可以看到,多个线程在获取读锁时不会产生阻塞等待
示例二:一个线程进行读操作,一个线程进行写操作。执行结果:一个可以获取到锁,一个阻塞
public class Main {
//创建 ReentrantReadWriteLock实例,用于创建读锁和写锁实例
private static ReentrantReadWriteLock reentrantReadWriteLock = new ReentrantReadWriteLock();
//创建读锁实例
private static ReentrantReadWriteLock.ReadLock readLock = reentrantReadWriteLock.readLock();
//创建写锁实例
private static ReentrantReadWriteLock.WriteLock writeLock = reentrantReadWriteLock.writeLock();
//创建线程池
private static ExecutorService threadPool = Executors.newCachedThreadPool();
//获取的读锁方法
public static void read() {
//线程获取到读锁
readLock.lock();
System.out.println(Thread.currentThread().getName() + "获取读锁,开始执行");
try {
//睡眠3秒
Thread.sleep(3000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}finally {
//释放读锁
readLock.unlock();
System.out.println(Thread.currentThread().getName() + "释放读锁,执行完成");
}
}
//获取写锁的方法
public static void write() {
writeLock.lock();
System.out.println(Thread.currentThread().getName() + "获取写锁,开始执行");
try {
//睡眠3秒
Thread.sleep(3000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}finally {
//释放读锁
writeLock.unlock();
System.out.println(Thread.currentThread().getName() + "释放写锁,执行完成");
}
}
public static void main(String[] args) {
threadPool.submit(new Runnable() {
@Override
public void run() {
read();
}
});
threadPool.submit(new Runnable() {
@Override
public void run() {
write();
}
});
}
}
结果:可以看出,获取读锁后时,写锁无法进行加锁,必须等读锁释放后才可以获取写锁
示例三:两个线程都进行写操作。执行结果:一个可以获取到锁,一个阻塞
public class Main {
//创建 ReentrantReadWriteLock实例,用于创建读锁和写锁实例
private static ReentrantReadWriteLock reentrantReadWriteLock = new ReentrantReadWriteLock();
//创建读锁实例
private static ReentrantReadWriteLock.ReadLock readLock = reentrantReadWriteLock.readLock();
//创建写锁实例
private static ReentrantReadWriteLock.WriteLock writeLock = reentrantReadWriteLock.writeLock();
//创建线程池
private static ExecutorService threadPool = Executors.newCachedThreadPool();
//获取的读锁方法
public static void read() {
//线程获取到读锁
readLock.lock();
System.out.println(Thread.currentThread().getName() + "获取读锁,开始执行");
try {
//睡眠3秒
Thread.sleep(3000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}finally {
//释放读锁
readLock.unlock();
System.out.println(Thread.currentThread().getName() + "释放读锁,执行完成");
}
}
//获取写锁的方法
public static void write() {
writeLock.lock();
System.out.println(Thread.currentThread().getName() + "获取写锁,开始执行");
try {
//睡眠3秒
Thread.sleep(3000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}finally {
//释放读锁
writeLock.unlock();
System.out.println(Thread.currentThread().getName() + "释放写锁,执行完成");
}
}
public static void main(String[] args) {
threadPool.submit(new Runnable() {
@Override
public void run() {
write();
}
});
threadPool.submit(new Runnable() {
@Override
public void run() {
write();
}
});
}
}
结论:由执行结果可以看出,无法同时获取写锁
读写锁的插队策略
插队策略:为了防止“线程饥饿”,读锁不能插队
举个例子:
假设在非公平的ReentrantReadWriteLock场景下:有4个线程,线程1 和 线程2 是同时读取,所以可以同时获取到锁,线程3 想要写入,此时就会阻塞等待(读加锁和写加锁互斥),进入等待队列,此时,线程4没有在队列中,但是,线程4想要进行读取操作,线程4能否有先有线程3执行呢?
针对上述场景,就有两种策略:
- 策略一:允许线程4优先于线程3执行,因为,线程3是写锁,线程4是读锁,让线程4先读取,是不会对线程3的写操作有任何影响的,也可以提高一定的效率,但是,这个策略有一个弊端:如果在线程4之后又有 n 个线程也进行读操作,都进行插队的话,就会造成“线程饥饿”;
- 策略二:不允许插队,就是,线程4的读操作必须放在线程3的写操作之后,放入队列中,排在线程3的后面,这样就能避免线程饥饿;
而事实上,ReentrantReadWriteLock 在非公平情况下,采用的是策略2,允许写锁插队,也允许读锁插队,但是,读锁插队的请提示,队列的第一个元素不能是想获取写锁的线程。
读写锁的升级策略
读锁 变成 写锁为升级策略
写锁 变成 读锁为降级策略
代码示例:
public class Main3{
//创建 ReentrantReadWriteLock实例,用于创建读锁和写锁实例
private static ReentrantReadWriteLock reentrantReadWriteLock = new ReentrantReadWriteLock();
//创建读锁实例
private static ReentrantReadWriteLock.ReadLock readLock = reentrantReadWriteLock.readLock();
//创建写锁实例
private static ReentrantReadWriteLock.WriteLock writeLock = reentrantReadWriteLock.writeLock();
//创建线程池
private static ExecutorService threadPool = Executors.newCachedThreadPool();
//获取的读锁方法
public static void read() {
try {
//线程获取到读锁
readLock.lock();
System.out.println(Thread.currentThread().getName() + "获取读锁,开始执行");
System.out.println(Thread.currentThread().getName() + "尝试将读锁升级成写锁");
writeLock.lock();//升级失败,不会执行到下面的代码
System.out.println(Thread.currentThread().getName() + "读锁升级成写锁成功");
//睡眠3秒
Thread.sleep(2000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
//释放读锁
readLock.unlock();
System.out.println(Thread.currentThread().getName() + "释放锁,执行完成");
}
}
//获取写锁的方法
public static void write() {
try {
//线程获取到写锁
writeLock.lock();
System.out.println(Thread.currentThread().getName() + "获取写锁,开始执行");
System.out.println(Thread.currentThread().getName() + "尝试将写锁降级为读锁");
readLock.lock();
System.out.println(Thread.currentThread().getName() + "写锁降级成功");
//睡眠3秒
Thread.sleep(2000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
//释放读锁
writeLock.unlock();
System.out.println(Thread.currentThread().getName() + "释锁,执行完成");
}
}
public static void main(String[] args) {
//读锁升级成写锁失败
/* threadPool.submit(new Runnable() {
@Override
public void run() {
read();
}
});*/
//写锁降级成读锁成功
threadPool.submit(new Runnable() {
@Override
public void run() {
write();
}
});
}
}
ReentrantReadWriteLock 不支持升级为写锁是因为:为了避免死锁,如果多个线程同时进行升级的话,就会造成死锁,比如,假设线程A和线程B都是读锁,如果两个线程都想升级,那么,线程A升级时,就要等线程B释放了锁,而线程B想要升级时,就要等才能成A释放了锁,此时,就会互相等待,构成死锁。
使用场合:读写锁(ReentrantReadWriteLock)适合于读多写少的场合,可以提高并发效率
可重入锁 VS 不可重入锁
可重入锁:一个线程针对同一把锁连续加锁多次,如果不会死锁,就时可重入锁,反之就是不可重入锁。例如,synchronized就是可重入锁