目录
一、乐观锁VS悲观锁
Java中的乐观锁和悲观锁是两种并发控制的锁策略,用于解决多线程访问共享资源时可能出现的竞争和冲突问题。
1.1 悲观锁
悲观锁的思想是,每次访问共享的资源时都认为其它线程可能会访问该资源,因此会对该资源进行加锁保护。就是总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁。
2.2 乐观锁
乐观锁的思想是,每次访问共享的资源时都假定其它线程不会同时访问该资源,因子不会对该资源进行加锁保护,而是通过版本号和时间戳等方式来检测数据是否被其它线程修改过,如果检测数据已经被修改过,则会进行回滚或重试等操作。乐观锁的优点是避免了频繁地加锁和释放锁,从而提高了并发性能。
对比来看:这两种锁策略不能说谁优谁劣,而是看当前的场景是否合适,悲观锁适用于并发写入比较多的场景,可以有效的保证数据的一致性,但是在高并发的情况下可能会影响性能;乐观锁适用于并发读取比较多的场景,可以提高并发性能,但是在并发修改多的情况下可能会导致数据一致性的问题。
二、重量级锁VS轻量级锁
2.1 轻量级锁
轻量级锁是一种基于对象头的锁实现方式,主要用于低竞争情况下的锁性能。当一个线程尝试获取对象的锁时,如果该对象没有被其它线程加锁,则该线程将对象头中的标志位改为轻量级标志位,并将对象头中存储的线程ID设置为当前线程的ID。如果该对象已经被其它线程加锁,则该线程会自旋等待锁的释放,在自旋的过程中,如果其它线程已经释放了锁,则当前线程可以直接获取锁,否则就会变成重量级锁。
2.2 重量级锁
重量级锁是基于操作系统互斥量的锁实现方式,主要用于高竞争情况下的锁性能。当一个线程尝试获取对象的锁时,如果该对象已经被其他线程锁定,则该线程会进入阻塞状态,直到其他线程释放了锁,该线程才能继续执行。重量级锁的实现涉及到操作系统内核的系统调用,因此在高并发情况下会产生较大的系统开销和资源消耗。
对比来看:轻量级锁适用于低竞争情况下的并发访问,可以有效的提高锁的性能,但是在锁冲突比较严重的情况下就会变成重量级锁;重量级锁适用于高竞争情况下的并发访问,可以保证数据的正确性,但是会带来比较大的系统开销和资源消耗。
三、自旋锁VS挂起等待锁
3.1 自旋锁
自旋锁是一种基于忙等待的锁实现方式,当一个线程尝试获取锁时,如果其它线程已经加锁,则该线程会不断地循环检测锁是否被释放,直到获取锁为止。自旋锁的优先可以避免线程的阻塞和切换,因此对于锁的竞争不是非常激烈的情况下,自旋锁可以提供良好的性能表现,但是在锁竞争比较激烈的时,自旋锁就会忙等造成资源的浪费,导致性能下降。
3.2 挂起等待锁
挂起等待锁是一种基于线程挂起的锁实现方式,当一个线程尝试获取锁时,如果该锁已经被其他线程占用,则该线程会被挂起等待锁的释放。挂起等待锁的优点是可以避免线程的忙等待,节省CPU资源,同时也可以防止锁的竞争过于激烈,从而保证程序的稳定性。但是,挂起等待锁的缺点是在线程挂起和恢复的过程中,需要进行线程的切换和上下文切换,这些操作会带来一定的系统开销和性能下降。
对比来看:自旋锁适用于锁竞争不是很激烈的情况下,这样可以提高性能;挂起等待锁适用于锁竞争比较激烈的情况,可以保证程序的稳定性。
四、公平锁VS非公平锁
4.1 公平锁
公平锁是指多个线程按照申请锁的顺序来获取锁。在实际编程中,如果一个锁是公平的,那么线程的调度顺序就会按照线程获取锁的顺序来执行(先来后到的原则),这样避免了“饥饿现象”,也就是在优先级较高的线程也不能优先执行,必须按照获取锁的顺序来执行。
4.2 非公平锁
非公平锁是在锁被释放时,不一定按照申请锁的顺序来获取锁,而是随机,这样就会产生“饥饿现象”,可能极端情况下某个线程永远都获取不到锁。
对比来看:公平锁看起来更加公平,所有线程都有平等的机会获取到锁,但是公平锁的实现往往会随着系统性能的显著下降而下降;非公平锁虽然会造成线程饥饿,但是在高并发、吞吐量大的系统中,会优先选择非公平锁。
五、可重入锁VS不可重入锁
5.1 可重入锁
可重入锁是指同一线程可以多次持有同一把锁而不会死锁。可重复锁通常是通过一个计数器来记录锁的持有次数,每次加锁计数器都会加1,解锁计数器减1,当计数器为0时,锁就会完全释放。
5.2 不可重入锁
不可重入锁是指同一个线程不能重复获取已经持有的锁,否则会死锁。
六、互斥锁VS读写锁
6.1 互斥锁
当一个线程获取到互斥锁时,其它线程就无法再获取到该锁,直到该线程释放锁。互斥锁的优点是能够保证数据的一致性,但缺点会带来较大的性能开销,因此在每次获取锁时,需要进行线程的阻塞和上下文切换。
6.2 读写锁
读写锁是一种针对读写操作加锁的实现方式,相比于互斥锁,读写锁可以实现更细粒度的并发控制。读写锁允许多个线程同时进行读操作,但只允许一个线程进行写操作。
读写锁就是把读操作和写操作区分对待. Java 标准库提供了 ReentrantReadWriteLock 类, 实现了读写 锁.
ReentrantReadWriteLock.ReadLock 类表示一个读锁. 这个对象提供了 lock / unlock 方法进行加锁操作
ReentrantReadWriteLock.WriteLock 类表示一个写锁. 这个对象也提供了 lock / unlock 方法进 行加锁解锁.
这两个是操作系统内核提供的API在Java里进行封装的,系统API再底层的实现就是CPU指令级别了
其中,
读加锁和读加锁之间, 不互斥.
写加锁和写加锁之间, 互斥.
读加锁和写加锁之间, 互斥.
七、CAS
CAS(Compare and Swap,比较并交换)是一种基于原子操作内存并发控制方式,是实现乐观锁的一种方式。
CAS机制包括三个参数:内存中的原数据V,旧的期望值A,需要修改的新值B。当且仅当V的值等于A时,CAS操作才会通过原子方式将V的值修改为B;如果V的值不等于A,那么CAS操作将不会执行任何操作,并且会返回V的当前值。
7.1 CAS实现原子类
在Java中,CAS操作主要由java.util.concurrent.atomic包提供的原子类来实现。这些原子类提供了一系列基于CAS机制的线程安全的原子操作,包括原子加、原子减、原子更新等操作。这些原子类通过使用CAS机制,可以避免锁机制的使用,从而提高并发性能。
CAS的优点:无锁化的实现方式,避免了锁机制的使用,可以避免由于锁竞争导致线程阻塞等待,从而提高系统的并发性能,而且CAS操作不会阻塞其它线程的访问,可以提高线程的响应速度。
CAS的缺点:CAS机制需要在循环中不断地进行CAS操作,知道成功为止,但是这可能引起ABA问题,其次,CAS机制只能针对一个变量进行原子操作,如果需要对多个变量进行原子操作,就需要使用锁机制来保证操作的原子性。最后,CAS机制的实现依赖于CPU硬件支持,不同CPU对于CAS操作的支持程度不同,可能会影响CAS机制的效率。
典型的就是 AtomicInteger 类.,下面代码演示AtomicInteger的使用:
import java.util.concurrent.atomic.AtomicInteger;
public class AtomicIntegerDemo {
private static int value = 0;
private static AtomicInteger atomicInteger = new AtomicInteger(0);
public static void main(String[] args) throws InterruptedException {
Thread thread1 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
value++;
//相当于后置++操作
atomicInteger.getAndIncrement();
}
});
thread1.start();
Thread thread2 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
value++;
atomicInteger.getAndIncrement();
}
});
thread2.start();
thread1.join();
thread2.join();
System.out.println("number: " + value);
System.out.println("atomicInteger: " + atomicInteger);
}
}
运行结果:
7.2 CAS实现自旋锁
自旋锁是一种高效的锁机制,基于忙等策略,避免了线程的上下文切换所带来的性能损失,从而优化了系统的并发性能。其核心思想是,当一个线程试图获取锁,但是锁已经被其它线程占用时,该线程会持续检查锁状态,直到锁被释放为止。
1、自旋锁通常使用一个标志位来表示锁的状态,这个标志位被设定为volatile的变量,以确保所有线程都能看到这个变量的最新状态,当锁的状态为0(未被占用)时,线程尝试通过CAS机制将标志位设置成1(已被占用),以此来获取锁。
2、CAS操作会比较当前锁状态(oldValue)和预期状态(value)。只有在两者相等时,CAS操作才会将锁状态更新为新的状态(通常为1,表示锁已被占用)。如果CAS操作成功,那么线程就获取到锁,并可以执行临界区代码。如果CAS操作失败,这意味着锁已经被其它线程占用,此时线程并不会立即放弃锁,而是继续执行CAS操作尝试获取锁,也就是自旋等待。
3、当线程完成临界区代码执行并准备释放锁时,它将通过CAS操作将锁状态重新设置成0(未被占用)。这个过程也是原子的,保证了锁状态的安全性。
八、CAS的ABA问题解决方案
8.1 什么是ABA问题
ABA问题是指在使用ABA操作进行比较-交换时,如果变量在此期间被修改了两次及以上,那么CAS操作可能会出现误判。
举一个栗子:假设存在两个线程t1和t2,有一个共享变量num,其初始值为A,step1:接下来线程t1想使用CAS把num值改为Z,那么需要的操作是先读取num的值,记录到oldNum变量中,step2:然后再使用CAS判断当前num的值是否为A,如果为A,就修改为Z,但是,在t1执行这两个操作之间,t2线程可能会把num的值从A改成了B,又从B改成了A,以至于t1线程无法区分当前这个变量A中途是否修改过。
8.2 ABA问题引来的BUG
大部分的情况下,t2线程这样反复横跳改动,对于t1是否修改num是没有影响的,但是有特殊的情况,举一个例子:
假设我的银行卡上有100元,我想取走50元,手机银行创建了两个线程,并发的执行-50操作,我们期望一个线程执行-50成功,另一个线程-50失败,但是如果使用CAS的方式来完成这个扣款过程就可能出现问题。
异常的过程:
1、存款为100,线程1获取当前的存款值为100,期望更新为50;线程2获取到当前存款值为100,期望更新为50;
2、线程1扣款成功,存款被改成50;
3、线程2执行之前,我一个朋友给我转账50,余额就变成了100;
4、到线程2执行了,发现存款为100,就会进行扣款操作
这样就导致了进行了两次扣款,就是ABA问题搞的鬼!!!
8.3 解决方案
给要修改的值,引入一个版本号,在CAS比较数据当前值和旧值的同时,也要比较版本号是否符合预期。
1、CAS操作在读取旧值的同时,也要读取版本号
2、真正修改的时候:·如果当前版本号和读取的版本号相同,则修改数据,并将版本号+1。
·如果当前版本号高于读取的版本号,就操作失败,认为数据已经被修 改。
示例代码:
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicStampedReference;
public class CASDemo {
private static final AtomicStampedReference<Integer> count =
new AtomicStampedReference<>(1,1);
public static void main(String[] args) {
Thread thread1 = new Thread(() -> {
//获取初始版本号
int stamp = count.getStamp();
//获取初始值
int oldValue = count.getReference();
try {
//等待1s是为了让线程1拿到版本号和初始值
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("线程1的初始值:" + oldValue + "初始版本号:" + stamp);
//模拟线程执行过程中被其它线程干扰,使得oldValue被修改
count.compareAndSet(oldValue, 2, stamp, stamp + 1);
System.out.println("线程1:新值是 " + count.getReference() + ",版本号是:" + count.getStamp());
//模拟操作完成之后,变量值又修改为1
count.compareAndSet(2,1, count.getStamp(), count.getStamp() + 1);
System.out.println("线程1最新值:" + count.getReference() + ",最新版本号:" + count.getStamp());
});
Thread thread2 = new Thread(() -> {
int stamp = count.getStamp(); // 获取初始版本号
int oldValue = count.getReference(); // 获取初始值
System.out.println("线程2: 初始值是 " + oldValue + ", 初始版本号是 " + stamp);
try {
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 模拟操作过程中,变量的值被其他线程改为了2
boolean flag = count.compareAndSet(oldValue, 3, stamp, stamp + 1);// 将变量的值由1改为3,此时线程1已经将变量的值由1改为2
System.out.println("线程2是否修改成功: " + flag);
System.out.println("线程2: 新值是 " + count.getReference() + ", 新版本号是 " + count.getStamp() );
});
thread1.start();
thread2.start();
}
}