文章目录
Java中的悲观锁和乐观锁
Java中的悲观锁和乐观锁是一种锁的思想,并不是一种具体的锁,所以以下的内容,都是在讲解锁的思想,请读者不要和具体的锁产生混乱;
- 悲观锁:总会认为冲突的概率会很大,当一个线程获取数据时,都会有其他的线程同时来修改这个数据,所以,当一个线程拿数据时,总会去加锁,这样,当别的线程想要对这个数据进行操作时,就会阻塞等待,换句话讲,就是共享资源在一个时刻间只能一个线程获取,其他线程阻塞等待,直到这个线程释放锁之后,其他线程才能够在尝试获取资源,所以,悲观锁更适合于多个线程对同一个资源进行修改的情况
- 乐观锁:总会认为冲突的概率会很小,当一个线程获取数据的同时,认为其他的线程不会来对这个数据进行修改,所以,就没必要进行加锁,所以,正因为这个原因,乐观锁更适合于多个线程读取同一个资源的情况。虽然乐观锁去除了加锁的操作,但是,一旦发生冲突,重试的概率就会很大,所以,在冲突概率非常小,且加锁成本非常高时,才考虑使用乐观锁。
乐观锁常见的两种实现方式
- 版本号机制
- 基于时间戳
- CAS 算法
版本号机制是要引入一个版本号属性version,来记录数据被修改的次数,
例如:
① 在数据库中,为每一条记录添加一个版本号字段version
② 当一个事务开始时,会先读取记录中当前的版本号,然后再执行对应的修改命令
③ 在事务提交更新之前,检查一下记录中的版本号是否还和当初读出的版本号一样,如果一样,则表示其他事没有进行修改,则更新成功,并将版本号+1
④ 如果版本号已经被修改了,则更新失败,事务进行回滚或者重试。
版本号机制
版本号机制是要引入一个版本号属性version,来记录数据被修改的次数,当数据被修改时,version+1,比如,线程A要更新数据的场景时,在获取这个这个数据的同时,会把version也获取到,当线程A对数据修改了以后,也会将version+1,然后,在提交这个更新后的数据时,如果刚才已经修改后的version值大于当前内存中的version值,更新数据,否则,重试更新操作,直到更新成功。
举个例子:假设有这样一种场景:当前,钱包中有100余额,线程A减了50,在线程A进行减50的过程中,线程B进行了减20的操作,请看下图:
基于时间戳
类似于版本号机制,但是用时间戳字段来跟踪记录的修改时间。
比如:
事务在读取记录时,同时获取到时间戳字段,并在提交事务时,检查记录的时间戳是是否与最初读取时的时间戳一致,如果一致,则更新成功,如果不同,则更新失败
CAS(compare and swap) 算法
比如,现在有一个内存M,有两个寄存器 A,B,假设CAS 相当于是一个函数,M,A,B是函数的三个参数,CAS(M,A,B),如果 M 和 A 相同,就将 B 和 M 进行交换,说是交换,其实也就是一种赋值效果,因为,主要关心的是内存中 M 的值,而没人会关心 B 的值,同时整个操作返回 true,如果 M 和 A 不同,就什么事也不做,同时整个操作返回 false,
以上只是 CAS 的一个逻辑关系,CAS 并不是一个方法,一个CPU 指令完成了比较交换逻辑,这也就说明了CAS它是原子的,用来进一步替代了加锁操作。
所以,基于 CAS 实现线程安全的方式,也成为 “无锁编程”。
所以,CAS 的优点:避免了阻塞,降低了开销。缺点:只能使用于特定场景,不如加锁灵活。
同时呢,因为 CAS 本质上是一个 cpu 指令,后来被操作系统封装,提供成API,然后又被 JVM 封装,也提供成 API,供程序员们使用,如下图:
场景:两个线程对同一个变量count进行++:
public class Main {
public static void main(String[] args) throws InterruptedException {
AtomicInteger count = new AtomicInteger();
Thread thread1 = new Thread(() -> {
for(int i = 0; i < 100; i++) {
count.getAndIncrement(); //count++;
//count.incrementAndGet(); ++count
//count.getAndDecrement(); count--
//count.decrementAndGet(); --count;
}
});
Thread thread2 = new Thread(() -> {
for(int i = 0; i < 100; i++) {
count.getAndIncrement();
}
});
thread1.start();
thread2.start();
thread1.join();
thread2.join();
System.out.println(count.get());
}
}
CAS 适用场景
CAS 的适用场景
public class SpinLock {
public AtomicInteger lock;
public SpinLock() {
lock = new AtomicInteger(0);
}
public void lock() {
while(true) {
int curValue = lock.get();//获取当前锁的值,也就相当于CAS(M, A, B) 中M的值
if(curValue == 0) {// 表示此时锁还没有被获取
if(lock.compareAndSet(0, 1)) {//尝试将锁的值从0原子的设置成1,也就是判断M和A得值是否相同
return; //成功获取到锁
}
}
}
}
public void unlock() {
lock.compareAndSet(1, 0);
}
}
ABA 问题
CAS 进行操作的关键是通过值“没有发生变化”来作为“没有其他线程穿插执行”的判定依据。也就是 CAS(M, A, B),判断M有没有发生变化。但是,会出现 ABA 问题。
下面来演示一个例子解释ABA问题:
- 初始状态:有一个变量
V
,其初始值为A
。 - 第一个线程(Thread A) 读取
V
的值(A
),并准备将其更新为B
。 - 第二个线程(Thread B) 在 Thread A 读取
V
之后,但在更新之前,读取了V
的值(A
),并将其更新为C
。 - 第三个线程(Thread C) 在 Thread B 更新
V
之后,读取了V
的值(C
),并将其更新回A
。 - Thread A 继续执行,使用 CAS 操作试图将
V
的值从A
更新为B
。由于V
的值尚未被 Thread B 修改(仍为A
),CAS 操作成功执行。
在这个过程中,尽管 V
的值在 Thread A 首次读取和最终更新之间经历了变化(从 A
变为 C
再变回 A
),CAS操作却未能检测到这一变化,因为在 CAS 操作执行时,V
的值看起来没有被修改。这就是 ABA 问题。这就好像我们买手机,买到的是一个二手的翻新机,看起来好像是一个新的,但是实际上是个二手的。
为了解决 ABA 问题,可以采用几种策略,最常见的是使用版本号或时间戳。在每次更新操作时,版本号递增,CAS 操作除了比较值外,还要比较版本号。只有当值和版本号都匹配时,更新才能成功。这样即使值被恢复到原始状态,由于版本号已经改变,CAS 操作能够识别出实际的变化
自旋锁 VS 互斥锁
互斥锁是一种独占锁,比如当线程A成功加锁后,此时,互斥锁就被线程A独占了,只要线程A没有释放手中的锁,线程B就会加锁失败,于是就会释放CPU给其他线程,既然线程B释放掉了CPU,自然线程B的加锁代码就会阻塞。
对于互斥锁这种加锁失败而阻塞的现象,是由操作系统内核实现的,当加锁失败后,这个线程就会进入睡眠状态,等到锁被释放以后,内核会在合适的时机唤醒线程,当这个线程成功获取到锁时,就可以继续执行。
所以,当加锁失败时,会从用户态变成内核态操作,让内核帮我们切换线程,此时就会产生一定的性能上的开销。
当两个线程属于同一个进程时,因为虚拟内存是共享的,所以在切换时,虚拟内存这些资源就保持不动,只需要切换线程的私有数据和寄存器状态,线程的上下文等不共享的数据。
但是如果能够确定被锁住的代码很短,那么很可能上下文切换的时间比锁住的代码执行的时间还长,就不要用互斥锁了,应该使用自旋锁。
自旋锁是用户态完成加锁和解锁的操作,不会主动产生线程上下文切换,相比互斥锁来说,会更快一点,开销会小一点。
比如,在代码中使用while循环:
import java.util.concurrent.atomic.AtomicInteger;
public class SpinLock {
private AtomicInteger lock = new AtomicInteger(0);
public void lock() {
int expectedValue = 0;
while (!lock.compareAndSet(expectedValue, 1)) {
expectedValue = 0; // 重置expectedValue,以准备下一次尝试
}
}
public void unlock() {
lock.set(0); // 释放锁,设置lock为0
}
public static void main(String[] args) {
SpinLock spinLock = new SpinLock();
Thread thread1 = new Thread(() -> {
spinLock.lock();
// 执行临界区代码
System.out.println("Thread 1 is inside critical section.");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Thread 1 is leaving critical section.");
spinLock.unlock();
});
Thread thread2 = new Thread(() -> {
spinLock.lock();
// 执行临界区代码
System.out.println("Thread 2 is inside critical section.");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Thread 2 is leaving critical section.");
spinLock.unlock();
});
thread1.start();
thread2.start();
}
}
读写锁
读写锁是由 读锁 和 写锁 两部分构成,如果只是读取共享资源的话,使用 读锁加锁,如果要修改共享资源的话,则用写锁加锁。
读写锁的工作原理就是:
当写锁没有被线程持有时,多个线程能够同时并发的持有读锁,因为读锁是用于读取共享资源的场景,所以,多个线程同时持有读锁也不会破坏共享资源的数据,这也就大大提高了共享资源的访问效率。
当写锁被线程持有后,那么,读操作的线程获取读锁时,就会被阻塞,写操作的线程获取写锁时也会被阻塞。
所以,写锁就是一种独占锁,任何时刻只能被一个线程拥有,读锁就是一种共享锁,可以让多个线程同时拥有。
读写锁的特性:
- 读加锁 和 读加锁 之间不互斥
- 写加锁 和 读加锁 之间互斥
- 写加锁 和 写加锁 之间互斥
所以,读写锁在读操作多,写操作少的场景下,能发会出优势。
而且,读写锁也可以分为 读优先锁 和 写优先锁
读优先锁优先服务读线程,它的工作方式就是:比如,有一个 读线程 A 获取了读锁,写线程 B 在获取写锁时,就会进行阻塞,之后又有一个读线程 C 来获取读锁,此时读线程 C 能够成功的加锁,等到线程A和线程C都释放锁后,线程B才能够再获取到写锁。
但是,如果一直有读线程来获取读锁,那么,写线程就会一直进行阻塞,就造成了写线程饥饿的现象。
写优先锁就是优先服务写线程,比如,有一个读线程 A 获取了读锁,那么,写线程B在获取写锁时就会阻塞,之后又有一个读线程C来获取读锁,此时,线程C就会阻塞,等到 线程A释放锁后,线程B就可以获取 到写锁,而不是 线程C获取到读锁。
但是,写优先锁也会出现线程饥饿的情况,如果一直有写线程加锁,那么读线程就会被饿死。
在 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();
}
});
}
}
结论:由执行结果可以看出,无法同时获取写锁
公平锁 VS 非公平锁
假设,现在有三个线程 A,B,C 轮流尝试获取同一把锁,此时,线程A获取到锁后,线程B 和 线程C依次阻塞等待,当线程A释放锁后,线程B获取锁,之后 线程C 再获取锁,这样按照“先来后到”的方式,来加锁,此时就是公平锁,反之,线程A释放锁喉,线程B 和 线程C 都有可能获取到锁,此时就是非公平锁
公平锁:按照“先来后到”的方式加锁,此时就是公平锁
非公平锁:不按照“先来后到”的方式,按照“抢占式”的方式,此时就是非公平锁。例如,synchronized 就是非公平锁