常见的锁策略
乐观锁和悲观锁(以锁冲突的概率区分)
- 乐观锁:预测接下来锁的冲突概率不高
具体体现:只有在数据提交更新的时候才会检测有没有发生锁冲突,如果冲突就返回错误信息,让用户决定怎么做 - 悲观锁:预测接下来锁的冲突概率较高
具体体现:每次去拿数据的时候都会给数据上锁,阻止别人在自己拿数据的时候进行修改
重量级锁和轻量级锁(以加锁开销的大小区分)
- 重量级锁:加锁开销比较大的
- 轻量级锁:加锁开销比较小的
大部分情况下,我们可以简单的将重量级锁约等于悲观锁;轻量级锁约等于乐观锁
挂起等待锁和自旋锁(以获取锁的方式区分)
- 挂起等待锁:重量级锁的典型实现方式.当出现锁冲突的时候,让线程进入阻塞状态(由于该线程进入了阻塞状态,放出了cpu资源,故消耗的时间更多)
- 自旋锁:轻量级锁的典型实现方式.当出现锁冲突的时候,通过忙等来等待锁释放(通过忙等能第一时间拿到释放的锁,会消耗cpu资源,但消耗的时间更短)
忙等是这个线程会一直不断的尝试拿到锁,这个尝试过程会一直消耗cpu
公平锁和非公平锁(以获取锁的优先级来区分)
在计算机中.公平的定义是先来后到 .也就是说对于一个公平锁,先尝试获取锁的线程会优先的到锁:对于非公平锁,则不论顺序,每个尝试获取锁的线程都有均等概率[1](#1)来获得锁
- 公平锁:需要创建一个额外的队列来维护多个线程的加锁先后顺序
ReentrantLock就实现了公平锁
- 非公平锁:每个线程都均等概率获取锁
大部分情况使用非公平锁足以.synchronized就是非公平锁
可重入锁和不可重入锁(以连续加锁多次是否会死锁来区分)
- 可重入锁:一个线程对同一把锁进行连续加锁不会死锁
- 不可重入锁:一个线程对同一把锁进行连续加锁会死锁
互斥锁和读写锁(以锁定的内容来区分)
- 互斥锁:多个线程不能对同一个变量进行读/写操作
- 读写锁:多个线程能对同一个变量进行读/写操作
读写锁的核心操作中分为了三个锁:- 读锁:只影响读的内容
- 写锁:只影响写的内容
- 解锁
写锁和写锁会竞争
读锁和写锁不会竞争
读锁和读锁会竞争
解锁会和读/写锁竞争
Synchronized 原理
synchronized的特性
synchronized 是一个特殊的锁.
- 既是悲观锁,又是乐观锁
开始是乐观锁,但若锁冲突频繁,就会转换成悲观锁 - 既是重量级锁,又是轻量级锁
开始是轻量级锁,如果锁被持有的时间长,就转换为悲观锁 - 既是挂起等待锁,又是自旋锁
当实现轻量级锁的时候大概率是使用的自旋锁策略 - 是可重入锁
- 非公平锁
- 普通的互斥锁
锁升级/膨胀
synchronized
进入synchronized代码块
发生锁冲突时
多次发生锁冲突时
无锁
偏向锁
自旋锁
重量级锁
- 升级到偏向锁
当一个线程进入 synchronized 标记的代码块时,这个线程就从无锁过度到偏向锁.偏向锁不是一进入{...}代码块中就进行一个加锁操作(性能损耗大).而是先进行"标记"(标记的过程很轻量快速,性能损耗小).
如果一直运行到离开代码块时也没有其它线程竞争锁,这个锁就会一直处于偏向锁状态,解锁也只需要取消"标记"即可 - 升级到自旋锁
但若有其他线程来竞争这个锁时,这个处于偏向锁的进程会先一步把偏向锁升级为自旋锁.竞争的线程就只能忙等 - 升级到重量级锁
每一次遇到锁竞争时,synchronized都会统计锁冲突的频率,如果频率较高,就会升级为重量级锁(竞争频率高说明其它线程容易忙等,故升级为重量级锁,避免浪费系统资源)
对于当前的JVM,锁升级后是不支持降级的
锁消除
锁消除的作用就和名字一样,消除加锁代码.
但此机制只会在编译器有十足把握确定你的加锁是无意义的,编译器才会把锁去掉(很多时候虽然加锁是无意义的,但编译器也不一定能够消除掉,所以不能完全指望编译器帮助你消除无用锁)
锁粗化
- 先解释一下锁的粒度:
锁的粒度就是指加锁和解锁之间包含代码的多少
代码越多,锁的粒度越粗
代码越少,锁的粒度越细
锁粗化是指把一些细粒度的锁合并成一个更粗粒度的锁.由于加锁解锁会造成性能损耗,故通过锁粗化机制会优化性能 - 常见的锁粗化场景
对于下段代码
java
for (int i = 0; i < 10000; i++) {
synchronized (lock) {
// 执行同步操作
}
}
编译器大抵会优化成这样
java
synchronized (lock) {
for (int i = 0; i < 10000; i++) {
// 执行同步操作
}
}
或者对于下段代码
java
public void doSomething() {
synchronized (lock) {
// 任务 A
}
synchronized (lock) {
// 任务 B
}
synchronized (lock) {
// 任务 C
}
}
编译器会将这三个细粒度锁优化为一个粗粒度锁
java
public void doSomething() {
synchronized (lock) {
// 任务 A
// 任务 B
// 任务 C
}
}
CAS
CAS全称是 Compare And Swap 即比较和交换
我们可以看一段模拟CAS实现的伪代码
java
boolean CAS(address, expectValue, swapValue) {
if(&address == expectValue) {
&address = SwapValue;
return true;
}
return false;
}
- 其中:
address是一个内存地址
expectValue和swapValue都是寄存器的值
观看上面的代码我们能很容易看出它的逻辑 - 如果address这个内存地址的值等于expectValue,那么就让内存中的值更新为swapValue
- 如果address这个内存地址的值不等于expectValue,那么不更新这个内存中的值
虽然我们为了理解CAS写出了一段逻辑代码,但实际在电脑中运行的CAS不是一个简单的函数,而是一条CPU指令.也就是说CAS是具有原子性的.即不存在线程安全问题
在java中一般不会使用原生的CAS,而是使用基于CAS封装好的类
ABA问题
CAS是原子性的,是线程安全的.但是它也会存在一个类似于线程安全的问题,即ABA问题
在代码调用compareAndSet()方法时
- 第一步(普通get):它会先获取当前变量的值(比如oldValue).这只是一个普通的get操作,不具备原子性
- 第二步(原子CAS):把获取到的oldValue交给CPU进行比较和交换,这个过程才具有原子性
因此会有一种可能:
第一个线程使用compareAndSet()方法时获取到的值是A,第一个线程还没来得及进行CAS时,第二个线程就抢先一步获取到值是A并且修改为了B再又把B改回了A.此时第一个线程再拿到的值仍然是A,于是就将这个值修改为了C.以上过程便是ABA问题 - 如何解决ABA问题?
只需要加上一个版本号即可
还是接着上一个例子,只不过这次修改为了带版本号的CAS
第一个线程使用AtomicStampedReference类的的 compareAndSet() 方法时时获取到的值是A(版本号是1),第一个线程还没来得及进行CAS时,第二个线程就抢先一步获取到值是A并且修改为了B(版本号变成了2)再又把B改回了A(版本号变成了3).此时第一个线程再拿到的值虽然仍是A,但此时CPU发现现在的A版本号为3,而不是最开始获取到的版本号1.于是CAS交换失败.以上过程便是带版本号的ABA问题
JUC的常见类
JUC是java.util.concurrent类.这个类中是Java并发编程工具包.在此我只列举几个常见的类
Semaphore 信号量
对于synchronized.它一次性只能控制一个线程的访问.semaphore的作用与它类似,但是它内部维护了一个最大线程数.同一时间只允许一定的线程数访问该资源
简单来说:信号量本质就是一个计数器,用来描述"可用资源"的个数
它的常用API有以下几个
| API | 作用 |
|---|---|
| new Semaphore(int count) | 构造方法,指定count总可用资源数 |
| acquire() | 获取一个资源数,如果拿不到就阻塞 |
| tryACquire() | 尝试获取一个资源数,如果拿不到就立刻返回false |
| release() | 释放一个资源数.如果此时有阻塞的acquire()就会唤醒该方法 |
- 代码示例1:
java
import java.util.concurrent.Semaphore;
public class Main {
public static void main(String[] args) throws InterruptedException {
Semaphore semaphore = new Semaphore(2);
semaphore.acquire();
System.out.println("拿到一个资源");
semaphore.acquire();
System.out.println("拿到一个资源");
System.out.println("尝试拿到一个资源:" + semaphore.tryAcquire());
semaphore.release();
System.out.println("释放一个资源");
}
}
运行结果:
拿到一个资源
拿到一个资源
尝试拿到一个资源false
释放一个资源
*代码示例2-利用semaphore实现线程安全:
java
public class Main {
private static int count = 0;
public static void main(String[] args) throws InterruptedException {
Semaphore semaphore = new Semaphore(1);
Thread t1 = new Thread(() -> {
for (int i = 0; i < 5000; i++) {
try {
semaphore.acquire();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
count++;
semaphore.release();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 5000; i++) {
try {
semaphore.acquire();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
count++;
semaphore.release();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(count);
}
}
运行结果:
10000
- semaphore在实现线程安全上和synchronized差异点
- semaphore任何线程都能执行释放锁操作,而synchronized必须由加锁线程来释放
- semaphore不支持重入锁
- semaphore支持阻塞中断
CountDownLatch
作用:等待所有线程执行完毕才能进入下一步
类似于王者荣耀的加载界面
一局10个人都必须等到所有人加载完毕才能进入对局.
API|作用
new CountDownLatch(int count)|初始化一个CountDownLatch并设定所有线程数count
countDown()|每调用一次计数器就减一
await()|使当前线程挂起等待,直到count减到0才能继续执行
- 代码示例:
java
public static void main(String[] args) throws InterruptedException {
CountDownLatch countDownLatch = new CountDownLatch(3);
System.out.println("模拟系统启动");
Thread thread1 = new Thread(() -> {
try {
System.out.println("正在启动磁盘检查");
Thread.sleep(2000);
System.out.println("磁盘检查完毕");
countDownLatch.countDown();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
});
Thread thread2 = new Thread(() -> {
try {
System.out.println("正在启动内存检查");
Thread.sleep(3000);
System.out.println("内存检查完毕");
countDownLatch.countDown();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
});
Thread thread3 = new Thread(() -> {
try {
System.out.println("正在启动主板检查");
Thread.sleep(1000);
System.out.println("主板检查完毕");
countDownLatch.countDown();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
});
thread1.start();
thread2.start();
thread3.start();
System.out.println("等待系统自检完毕");
countDownLatch.await();
System.out.println("所有自检完毕,进入系统");
}
输入内容:
模拟系统启动
等待系统自检完毕
正在启动磁盘检查
正在启动内存检查
正在启动主板检查
主板检查完毕
磁盘检查完毕
内存检查完毕
所有自检完毕,进入系统
- CountDownLatch是一次性的.它的count计数器是无法重置的
- 在操作系统中由于线程的调度是随机的,所以即便每个线程都有均等概率来获取锁,但也不是数学上的严格均等 ↩︎