本文内容
1.锁策略:乐观/悲观,轻量/重量,自旋/挂起等待,读写,公平/非公平,可重入/不可重入,其他锁策略,Callable
2.CAS:原理,应用(原子类,自旋锁),ABA
3.JUC(java.util.concurrent) 的常见类:ReentrantLock,原子类,Semaphore,CountDownLatch
4.线程安全的集合类:多线程环境使用 ArrayList/队列/哈希表
一.锁策略
1.1各种锁策略介绍
乐观锁&悲观锁
1.1.1乐观锁&悲观锁
乐观锁和悲观锁只是锁的一种策略,并不是具体实现
乐观锁:
假设冲突概率低,先操作,更新时检查数据有没有被修改过,比如用版本号机制。更新数据时带上版本号,如果版本号不匹配,说明数据被修改过,这时候需要处理冲突,可以重试或者报错
悲观锁:
总是假设最坏的情况,每次在处理数据的时候都认为别人会修改,所以每次处理数据之前都会上锁,防止干扰
synchronized初始使用乐观锁策略,当发现锁竞争比较频繁的时候,,就会自动切换成悲观锁策略
1.1.2轻量级锁&重量级锁
锁的核心特性 “原子性”,这样的机制追根溯源是CPU这样的硬件设备提供的
重量级锁:严重依赖操作系统内核提供的互斥机制(mutex)
轻量级锁:尽量不依赖mutex,能在用户态解决就不切换内核态
当线程遇到轻量级锁时,会使用CAS指令快速获取锁,当获取成功后,该线程继续执行,如果获取失败,线程不会直接陷入阻塞,而是进入自旋状态,持续探测锁是否被释放了。但不会无限制的自旋下去,达到一定自旋次数时,停止自旋,进入阻塞状态。所以,轻量级锁和重量级锁是可以相互切换的。
重量级锁开销大的原因
上下文切换开销:线程被挂起和唤醒时,需要进行上下文切换,这涉及到保存和恢复线程的寄存器状态,程序计数器等,开销较大
操作系统内核介入:重量级锁的实现依赖于操作系统的内核函数调用,这会增加系统的调用开销。
1.1.3自旋锁&挂起等待锁
自旋锁是轻量级锁的具体实现,挂起等待锁是重量级锁的具体实现
自旋锁:自旋锁是一种忙等待的锁,当某线程尝试获取自旋锁时,如果该锁已经被其他线程持有,该线程不会陷入阻塞,而是会在一个循环中不断地检查锁是否被释放,这个过程不涉及内核态和用户态的切换,是轻量级锁的实现
挂起等待锁(也叫阻塞等待锁):当一个线程尝试获取一个已经被其他线程持有的锁时,该线程会被挂起,这个操作在内核态进行。这个过程涉及用户态和内核态的切换,线程的阻塞和唤醒,并且要保存该线程的上下文信息,会消耗性能,所以挂起等待锁是重量级锁的实现
1.1.4读写锁
多线程之间,数据的读取方之间不会产生线程安全问题,但数据的写入方互相之间以及和读者之间都需要进行互斥。如果两种场景下都用同一个锁,就会产生极大的性能损耗,所以读写锁因此而产生
读写锁就是把读操作和写操作区分对待. Java 标准库提供了ReentrantReadWriteLock 类, 实现了读写锁.
ReentrantReadWriteLock.ReadLock类表示一个读锁,这个对象提供了 lock / unlock 方法进行加锁解锁
ReentrantReadWriteLock.WriteLock 类表示一个写锁. 这个对象也提供了 lock /unlock 方法进行加锁解锁
其中:
读加锁和读加锁之间, 不互斥
写加锁和写加锁之间, 互斥
读加锁和写加锁之间, 互斥
1.1.5公平锁/非公平锁
公平锁: 线程陷入阻塞后进入阻塞队列(这需要额外的数据结构来实现,比如记录线程阻塞的时间,放进优先级队列中),在锁释放后按照先来后到的顺序获取锁
非公平锁:锁释放后操作系统会唤醒一个或者多个线程,这些线程同时竞争锁,不关心是哪个线程能获取锁
1.1.6可重入锁/不可重入锁
可重入锁:线程已经持有某个对象的锁,那么它可以再次获取该对象的锁,不会被阻塞。可重入锁通常会维护一个计数器,记录当前线程获取锁的次数。每次获取锁时,计数器加一;释放锁时,计数器减一。当计数器为零时,锁才真正被释放
不可重入锁:线程已经持有某个对象的锁,那么它可以再次获取该对象的锁,会被阻塞
1.2其他锁策略
锁消除
编译器+JVM 判断锁是否可消除.。如果可以,,就直接消除
什么是 “锁消除”
StringBuffer的代码中, 用到了 synchronized, 但单线程环境下不存在锁竞争,JVM就会自动消除synchronized
锁粗化
一段逻辑中如果出现多次加锁解锁,编译器 + JVM 会自动进行锁的粗化,例如
1.3Callable
Callable 是一个interface,相当于把线程封装了一个 “返回值”,方便程序员借助多线程的方式计算结果
public class Main {
public static void main(String[] args) throws ExecutionException, InterruptedException {
Callable<Integer> callable = () -> {
int sum = 0;
for (int i = 1; i <= 1000; i++) {
sum += i;
}
return sum;
};
//FutureTask中的run方法会调用Callable中的call方法
FutureTask<Integer> futureTask = new FutureTask<>(callable);
Thread thread = new Thread(futureTask);
thread.start();
//将call()方法的返回值保存起来,以便后续通过Future接口的get()方法获取
System.out.println(futureTask.get());//futureTask.get() = 500500
}
}
二.CAS
2.1什么是CAS?
CAS是Compare And Swap的缩写,意思是比较并交换。
CAS操作通过比较内存中的值(address)与预期值(expectedValue)是否相同,如果相同,则将内存中的值更新为新值;否则,不进行更新
下面是CAS的伪代码
boolean CAS(address, expectValue, swapValue) {
if (address == expectedValue) {
address = swapValue;
return true;
}
return false;
}
当多个线程同时对某个资源进行CAS操作,只能有一个线程操作成功,但是并不会阻塞其他线程,其他线
程只会收到操作失败的信号
2.2CAS应用
2.2.1实现原子类
标准库中提供了java.util.concurrent.atomic 包,里面的类都是基于这种方式来实现的
典型的就是 AtomicInteger 类,其中的 getAndIncrement相当于 i++ 操作
AtomicInteger atomicInteger = new AtomicInteger(0);
// 相当于 i++
atomicInteger.getAndIncrement();
伪代码实现原子类
class AtomicInteger {
//内存中的值
private int value;
public int getAndIncrement() {
int oldValue = value;
//当value==oldValue时,oldValue+1并且结束循环
//当value!=oldValue时,将value的值赋给oldValue,再次判断value是否等于oldValue
while ( CAS(value, oldValue, oldValue+1) != true) {
oldValue = value;
}
return oldValue;
}
}
2.2.2实现自旋锁
public class SpinLock {
private Thread owner = null;
public void lock(){
// 通过 CAS 看当前锁是否被某个线程持有
// 如果这个锁已经被别的线程持有,那么就自旋等待
// 如果这个锁没有被别的线程持有,那么就把 owner 设为当前尝试加锁的线程
while(!CAS(this.owner, null, Thread.currentThread())){
}
}
public void unlock (){
this.owner = null;
}
}
2.3ABA问题
它指的是在多线程环境下,一个变量在某个时间点被修改为另一个值,然后又修改回原来的值。尽管变量的值最终没有变化,但这个过程中变量的状态可能已经发生了实质性的改变,而CAS操作无法检测到这种变化。
例如:
假设滑稽老哥有100存款,滑稽想从ATM取50 块钱,取款机创建了两个线程,并发的来执行-50
操作
我们期望一个线程执行-50成功,另一个线程-50失败
如果使用CAS的方式来完成这个扣款过程就可能出现问题
取款过程如下:
存款100:线程1获取到当前存款值为100, 期望更新为50;线程2获取到当前存款值为100,期望更新为 50
线程1执行扣款成功,存款被改成50;线程2阻塞等待中
在线程2执行之前,滑稽的朋友给他转账50,余额又变回100
轮到线程2执行了,发现当前存款为100,和之前读到的100相同,再次执行扣款操作
解决方案:引入版本号
版本1:
存款100:线程1获取到当前存款值为100, 期望更新为50;线程2获取到当前存款值为100,期望更新为 50
版本2:
线程1执行扣款成功,存款被改成50;线程2阻塞等待中
版本3:
在线程2执行之前,滑稽的朋友给他转账50,余额又变回100
轮到线程2 执行了, 发现当前存款为100,和之前读到的 100 相同。但是当前版本号为3, 之前读
到的版本号为1,版本小于当前版本,认为操作失败