多线程进阶
乐观锁 vs 悲观锁
乐观锁与悲观锁描述的是两种不同的加锁的态度
锁冲突: 多个线程竞争同一把锁,就会发生锁冲突,就会发生阻塞等待
乐观锁: 预测锁冲突的概率不高,所以做的工作可以简单一点
悲观锁 : 预测锁冲突的概率比较高,所以做到工作要复杂一点
举一个例子: A和B合租,A洗澡的时候,不喜欢关门,他认为B来卫生间的概率比较低,所以A洗澡不锁卫生间门了,A就类似于乐观锁
B相反,B为了防止意外发生,每一次洗澡都会锁上卫生间的门,B就类似于悲观锁
读写锁 vs 普通互斥锁
普通的互斥锁,比如synchronized , 多个线程会竞争同一把锁,只有一个线程能获得这把锁,其他的线程只能等待
读写锁分为两种,加读锁 加写锁
度读与读之间不会产生锁竞争
读与写之间会有竞争
写与写之间会有竞争
在实际写代码的时候,读的场景很多,写的场景很少,所以读写锁相比于 普通互斥锁,就少了很多的锁竞争,优化了执行效率
重量级锁 vs 轻量级锁
重量级锁和轻量级锁也是一组锁策略
重量级锁 加锁解锁开销比较大 一个典型: 进入内核的加锁逻辑,开销比较大
轻量级锁 加锁解锁开销比较小 一个典型:纯用户态的加锁逻辑的开销比较小
重量级锁 轻量级锁 和 乐观锁 悲观锁的辨析
乐观锁 悲观锁是站在 加锁的过程的角度上看的, 加锁解锁过程中工作的多还是少
重量级锁 轻量级锁 是站在结果的角度上看的, 最终加锁解锁消耗的时间是多还是少
在通常的情况下, 干的工作多,消耗的时间也就多,所以一般情况下,乐观锁一般比较轻量, 悲观锁一般比较重量,但是,这不绝对 !
自旋锁(spin lock) vs 挂起等待锁
自旋锁是一种 轻量级锁[消耗的时间更短] 的典型实现
当某个线程没有没有申请到锁的时候,该线程不会被挂起, 会一直检测锁的情况, 一旦锁被释放就竞争锁,要是锁没有被释放, 每过一会在来检测
挂起等待锁是一种 重量级锁[消耗的时间更长]
当某个线程没有申请到锁, 该线程就会被挂起, 让出资源, 让给别的线程 , 直到锁 被释放了 ,该线程才开始重新竞争锁
举一个例子: 要是A约了B去打球,A已经到了B的楼下, B还没有下来 , 此时A就有两种选择
A每过一分钟,就给 B 打电话催他, 直到B下楼
A就在B的楼下开始看书,一直到B下楼
选择1 就是自旋锁 选择2 就是挂起等待锁
公平锁 和 不公平锁
t1 t2 t3线程竞争同一把锁, 谁先来的,谁就拿到锁,这就叫做公平, 要是三个线程随机拿到锁,有可能后来的线程拿到了锁,这就是不公平
操作系统默认的锁的调度是不公平的,要想实现公平锁,需要引入额外的数据结构,来记录线程加锁的顺序, 这需要一定的额外开销
可重入锁 vs 不可重入锁
可重入锁: 同一个线程对同一把锁,连续加锁两次,不会死锁
不可重入锁: 同一个线程对同一把锁, 连续加锁两次,会导致死锁
总结
一共有这些锁策略:
乐观锁 vs 悲观锁
读写锁 vs 普通互斥锁
轻量级锁 vs 重量级锁
自旋锁 vs 挂起等待锁
公平锁 vs 非公平锁
可重入锁 vs 不可重入锁
对于synchronized
既是乐观锁也是悲观锁
既是轻量级锁也是重量级锁
乐观锁的部分是基于自旋锁实现的, 悲观锁是基于挂起等待锁实现的
synchronized是自适应的 :
要是锁竞争不激烈,它就是乐观锁/轻量级锁/自旋锁
注意: 自旋锁是纯用户态实现的,相比于内核态,它的工作量是比较少的
要是锁竞争比较激烈, synchronized就会自动升级悲观锁/重量级锁/挂起等待锁
所以说synchronized是自适应的
- 不是读写锁,是普通互斥锁
- 是非公平锁
- 是可重入锁
CAS
CAS就是compare and swap 比较并交换
把内存中的某个值和CPU寄存器A中的值进行比较, 如果两个值相同, 就把另一个寄存器B中的值与内存的值进行交换(把内存中的值放到寄存器B中, 同时把寄存器B的值写给内存)
CAS主要关心的是内存中的值
CAS最厉害的就是它是通过一个CPU指令完成的, 原子的
所以使用CAS 是线程安全的,高效的,不加锁,也能保证线程安全
但是CAS只能在特定的场景中使用,加锁的适用性更好,而且,加锁的可读性更好
CAS主要是有两个使用场景:
实现原子类
要想实现多线程count++,就不安全,加上锁就能保证线程安全,但是效率就会大打折扣,此时基于CAS就能实现"原子"的++ , 既能保证线程安全, 也能保证高效
2.实现自旋锁
自旋锁是一种纯用户态的轻量级锁,当发现锁被其他的线程持有的时候,线程不会挂起等待,而是会一直询问,看当前的锁是否被释放,是为了抢先执行,节省了进入内核和系统调度的开销
自选锁属于耗CPU的资源换来的是第一时间获取到锁,它预期可以在短时间内获得锁,并且锁竞争并不激烈,所以自旋锁是一个轻量级锁也还是一个乐观锁
CAS的ABA问题
在CAS中, 进行值的比较的时候,发现寄存器A和内存M的值相同, 此时是无法判定M是始终没变还是变了,但是又变回来了
线程
1
抢先获得CPU时间片,而线程2
因为其他原因阻塞了,线程1
取值与期望的A值比较,发现相等然后将值更新为B,然后这个时候出现了线程3
,期望值为B,欲更新的值为A,线程3取值与期望的值B比较,发现相等则将值更新为A,此时线程2
从阻塞中恢复,并且获得了CPU时间片,这时候线程2
取值与期望的值A比较,发现相等则将值更新为B,虽然线程2
也完成了操作,但是线程2
并不知道值已经经过了A->B->A
的变化过程。
上面的概念还是有点抽象,接下来就举一个例子
小明在提款机,提取了50元,因为提款机问题,有两个线程,同时把余额从100变为50
线程1(提款机):获取当前值100,期望更新为50,
线程2(提款机):获取当前值100,期望更新为50,
线程1成功执行,线程2某种原因阻塞了,这时,某人给小明汇款50
线程3(默认):获取当前值50,期望更新为100,
这时候线程3成功执行,余额变为100,
线程2从Block中恢复,获取到的也是100,compare之后,继续更新余额为50! (造成错误 ! )
此时可以看到,实际余额应该为100,但是实际上变为了50(多扣了50块钱)这就是ABA问题带来的成功提交。
如何解决ABA问题?
在变量前面加上版本号,每次变量更新的时候,版本号+1 , 或者记录"上次修改的时间"通过这两种办法来解决ABA问题
synchronized原理
锁升级/锁膨胀
通过之前的学习,我们已经知道synchronized的作用是加锁 , 当两个线程对同一个对象加锁的时候,就会出现锁竞争 ,没有抢到锁的线程就会阻塞等待,一直等到 另一个线程释放锁
synchronized是自适应的,它既是轻量级锁也是重量级锁
要是当前场景是锁竞争不激烈, 就以轻量级锁状态来工作(自旋锁—第一时间拿到锁)
要是当前场景的锁竞争比较激烈,就以重量级锁状态来工作(挂起等待锁— 不能第一时间拿到锁,但是节省了CPU的开销)
synchronized是偏向锁
有时候加上synchronized也不一定是真的加上了锁,不一定会造成锁竞争
有时候两个线程的调度恰好错开了,此时这两个线程也就没有锁竞争,所以此时是没有进行加锁的
当涉及到锁竞争的时候,再进行加锁 (轻量级锁)
总结一下, synchronized是自适应的
无竞争 , 偏向锁
有竞争 , 轻量级锁
竞争激烈 , 重量级锁
以上的锁状态转变,就叫做锁升级 / 锁膨胀
锁消除
JVM会自动判定,要是发现有些代码不需要加锁,哪怕你写了synchronized,也不会真的加锁
注意: 锁消除是一种编译器的优化方法, 当编译器100%确定代码不需要加锁,才会去掉锁, 要是编译器不确定,那么编译器是不会贸然去掉锁的,所以锁消除是比较保守的
锁粗化
锁的粒度
锁的粒度是synchronized里面的代码包含多少代码
包含的代码少 就是粒度细
包含的代码多 就是粒度粗
锁粗化: 细粒度的加速–>出粒度的加锁
前提: 要是对同一个对象加锁才能粗化到一起
粗化一种编译器的优化,所以要首先保证程序是线程安全的,粗化只是锦上添花
粗化是锁的数目变少了,但是锁的代码变多了,也就完成了锁粗化
JUC
JUC是java.util.concurrent 关于并发的一个包 这个包里很多都是关于多线程的类 方法
Callable接口
Callable类似于Runnable, 但是Runnable描述任务,没有返回值
Callbale描述任务有返回值
创建一个线程来 计算1+2+3+…+1000的值
package Threading;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Future;
import java.util.concurrent.FutureTask;
public class Demo30 {
public static void main(String[] args) throws ExecutionException, InterruptedException {
Callable<Integer> callable = new Callable<Integer>() {
@Override
//call方法里面是要执行的任务
public Integer call() throws Exception {
int sum = 0;
for (int i = 1; i <= 1000 ; i++) {
sum += i;
}
return sum;
}
};
//callable不能直接传给t,所以要借助futureTask
//所以FutureTask存在的意义就就是接收Callable返回的结果,并把它传给线程
FutureTask futureTask = new FutureTask(callable);
Thread t = new Thread(futureTask);
t.start();
//获取返回值
System.out.println(futureTask.get());
}
}
创建线程的方式:
- 继承Thread (用不用匿名内部类都行)
- 实现 Runnable (用不用匿名内部类都行)
- 使用lambda
- 使用线程池
- 使用Callable
ReentrantLock
Reentrant [riːˈɛntrənt] 词根是enter, ReentrantaLock是可重入锁的意思
synchronized也是可重入锁,但是synchronized有些操作是做不到的, 所以ReentrantLock 算是对synchronized的补充
ReentrantLock的主要方法
- lock() 加锁
- unlock() 解锁
ReentrantLock的加锁和解锁是 要自己手动写上的,synchronized是出了范围就会自动解锁的,所以有时候可能会忘记解锁(缺点)
import java.util.concurrent.locks.ReentrantLock;
public class Demo31 {
public static void main(String[] args) {
ReentrantLock reentrantLock = new ReentrantLock();
//加锁
reentrantLock.lock();
//解锁
reentrantLock.unlock();
}
}
import java.util.concurrent.locks.ReentrantLock;
public class Demo31 {
public static void main(String[] args) {
ReentrantLock reentrantLock = new ReentrantLock();
try{
//加锁
reentrantLock.lock();
}finally{ ///使用finally就不会忘记解锁
//解锁
reentrantLock.unlock();
}
}
}
ReentrantLock有synchronized所没有的优势
1.tryLock 试试看能不能加上锁, 能锁成功就加锁成功,尝试失败就放弃加锁
tryLock还能指定加锁的等待超时时间(尝试失败了可以多等 s 时间,要是还是等不到锁就放弃 加锁)
2.ReentrantLock 可以实现公平锁, 默认是非公平锁, 构造的时候,传入一个参数就变成了公平锁 (多个线程按照申请锁的顺序去获得锁,线程会直接进入队列去排队,永远都是队列的第一位才能得到锁)
ReentrantLock reentrantLock = new ReentrantLock(true);
3.synchronized是搭配wait / notify实现等待通知机制的, 随机唤醒一个线程
ReentrantLock搭配Condition类实现, 能指定唤醒哪个等待线程
所以synchronized与ReentrantLock的区别是什么?
区别 =缺点 + 优点
原子类
在下面的类都是原子类的
package Threading;
import java.util.concurrent.atomic.AtomicInteger;
public class Demo32 {
public static void main(String[] args) throws InterruptedException {
AtomicInteger count = new AtomicInteger(0);
Thread t1 = new Thread(()->{
for (int i = 0; i < 5000; i++) {
//相当于是count++
count.getAndIncrement();
//count.incrementAndGet(); 相当于是 ++count
// count.getAndDecrement(); 相当于是count--
// count.decrementAndGet(); 相当于是--count
}
});
Thread t2 = new Thread(()->{
for (int i = 0; i < 5000; i++) {
//相当于是count++
count.getAndIncrement();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(count.get());
}
}
输出结果是10000 由于是原子类,所以已经是线程安全的了
信号量 Semaphore
Semaphore [ˈseməfɔː®]
信号量的基本操作有两个:
P操作,申请一个资源
V操作, 释放一个资源
信号量是一个计数器,表示可用资源的个数
P操作表示申请一个资源,可用资源就-1
V操作表示释放一个资源,可用资源就+1
当计数器为0的时候,要是继续进行P操作(申请资源),就会产生阻塞,阻塞等待到其他的线程V操作(释放资源)为止
这样子,锁就是一个特殊的信号量, 一个可用资源只有1 的信号量
java中提供了Semaphore类来给我们使用
public static void main(String[] args) throws InterruptedException {
Semaphore semaphore = new Semaphore(3);
semaphore.acquire();
System.out.println("申请一个资源");
semaphore.acquire();
System.out.println("申请一个资源");
semaphore.acquire();
System.out.println("申请一个资源");
semaphore.acquire();
System.out.println("申请一个资源");
}
只会输出三句话, 并且进程不会结束
一共就只有3个资源,到四个的时候,就不能在申请资源了, 就会阻塞等待, 需要等待资源释放
使用release() 来释放资源
public static void main(String[] args) throws InterruptedException {
Semaphore semaphore = new Semaphore(3);
semaphore.acquire();
System.out.println("申请一个资源");
semaphore.acquire();
System.out.println("申请一个资源");
semaphore.acquire();
System.out.println("申请一个资源");
//释放资源 计数器+1
semaphore.release();
semaphore.acquire();
System.out.println("申请一个资源");
}
此时就能输出四句话,并且进程能结束
P操作 acquire() 申请资源 计数器-1
V操作 release() 释放资源 计数器+1
一个冷知识: 这里的P操作 和 V操作是 荷兰语
CountDownLock
CountDownLock类似于考试计数 , 只要有一位考生交卷就记录一下, 直到最后一名考生交完卷之后,考试才算是真正结束, 当计数与实际的人数一致 的时候, 就结束
其实也类似 与IDM, 要下载一个很大的文件,IDM会将任务拆分成多个部分, 每个线程负责一个部分, 直至最后一个部分下载完成,整个文件才下载完成
package Threading;
import java.util.concurrent.CountDownLatch;
public class Demo33 {
public static void main(String[] args) throws InterruptedException {
//设定有10个考生
CountDownLatch countDownLatch = new CountDownLatch(10);
//创建10个线程
for (int i = 0; i < 10; i++) {
Thread t = new Thread(()->{
System.out.println("开始考试!" + Thread.currentThread().getName());
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("考试结束!"+ Thread.currentThread().getName());
//只要有一个线程结束就记录一次
countDownLatch.countDown();
});
t.start();
}
//await是阻塞等待,当所有人都交完卷子才算是考试真正结束
countDownLatch.await();
System.out.println("老师整理卷子,考试圆满结束!");
}
}
一个小技巧: IDEA全局搜索 Ctrl + Shift + R / F
ConcurrentHashMap
这是在多线程中很好用的HashMap,或者说在多线程下,使用ConcurrentHashMap是 很好的
ConcurrentHashMap做了很多的优化策略
1.锁粒度的控制
相比于HashTable是直接在方法上面加上使用synchronized,相当于是对this加锁, 也就是对哈希表对象加锁,也就是 说这个哈希表一共只有一个锁 ,所以HashTable很容易发生锁冲突,对性能的影响很大,所以连官方 都不推荐使用HashTable
相比之下, ConcurrentHashMap是每个哈希桶就有一个自己的锁, 大大降低了 锁冲突的概率,所以性能也就提升了
2.ConcurrentHashMap的加锁策略
ConcurrentHashMap只是给写操作加锁,读操作是不加锁的
两个线程同时读 ,不加锁
两个线程同时修改, 没有锁冲突
如果一个线程读,一个线程改, 也没有锁冲突 ----->要是一般情况下,这是可能出现线程不安全的, 主要担心的是读到的是一个修改了一半的数据
但是ConcurrentHashMap设计的时候考虑过 这一点,所以同时进行读和写是不会锁冲突的,并且ConcurrentHashMap广泛的使用了volatile来保证读到的数据是及时的
3 . 充分地利用了CAS的特性
在能使用CAS的地方尽量都使用了CAS,尽量避免使用synchronized, 减少加锁的数量 ,以此来提高代码的性能和效率
所以, ConcurrentHashMap的核心思路就是尽量降低 锁冲突的概率
4 . ConcurrentHashMap的扩容策略
当put元素的时候,发现当前的负载因子已经达到了阈值,就要开始扩容
HashTable的扩容策略是申请一个更大的数组,然后把旧的数据搬运过去, 但是这就会有一个很大的问题: 要是原本的元素很多,搬运的开销就会很大,就可能会导致请求超时
ConcurrentHashMap的扩容策略: 不是一次性搬运完,每次 进行哈希表的操作就往新的上插入一些数据, 要是删除元素,直接就删了不用搬运了,这样子积少成多 , 扩容 的效率就会变高
HashMap HashTable ConcurrentHashMap 的区别?
首先要知道,HashMap是线程不安全的,另外两个是线程安全的
ConcurrentHashMap相比于HashTable的优点:
- HashTable只会有一把大锁,容易锁冲突, ConcurrentHashMap在每个哈希桶里面都有锁,不容易发生锁冲突
- ConcurrentHashMap只是给写操作加锁,读操作是不加锁的
- ConcurrentHashMap使用了CAS
- ConcurrentHashMap的扩容策略是积少成多
死锁
所谓的死锁就是线程加上锁之后,解不开了,直接就僵住了
场景一: 一个线程,一把锁,连续加锁两次, 要是这个锁是不可重入锁, 就是死锁了
要是这个锁是可重入锁(比如synchronized) 就不会出现死锁
场景二: 两个线程, 两把锁, 相互申请,结果最后谁都解不了,僵住了,也变成了死锁
举一个具体的例子, 房间钥匙锁在车上了,车钥匙所在房间里了
一个相互加锁的死锁代码:
package Threading;
public class Demo34 {
public static void main(String[] args) {
Object locker1 = new Object();
Object locker2 = new Object();
Thread t1 = new Thread(() ->{
System.out.println("t1尝试获取locker1");
synchronized (locker1){
System.out.println("t1获取locker1成功");
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("t1尝试获取locker2");
synchronized (locker2){
System.out.println("两把锁获取成功");
}
}
});
Thread t2 = new Thread(() ->{
System.out.println("t2尝试获取locker2");
synchronized (locker2){
System.out.println("t2获取locker2成功");
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("t2尝试获取locker1");
synchronized (locker1){
System.out.println("两把锁获取成功");
}
}
});
t1.start();
t2.start();
}
}
注意: 在每个线程中的synchronized是嵌套的, 并且 两个线程最先加的锁是不一样的,这样子才会导致死锁
输出结果:
t1 t2最后都无法获取到对方的锁, 就变成了死锁
明确了死问题,该怎么解决?
死锁的四个必要条件:
1.互斥使用 锁A被线程1占用,线程2就使用不了
2.不可抢占 锁A被线程1占用, 线程2不能把锁抢过来, 一直要到线程1释放锁
3 . 请求和保持 有多把锁,线程1拿道锁A之后,不想释放锁A,还想要拿锁B,此时就要看获取锁B的时候是不是先释放了锁A
4 . 循环等待 线程1等待线程2释放锁,线程2要等待线程3释放锁,线程3要等待线程1释放锁(这样子就会导致死锁)
解决方法: 约定, 加多个锁的时候,必须先加编号小的, 后加编号大的
上面的代码进行修改:
package Threading;
public class Demo34 {
public static void main(String[] args) {
Object locker1 = new Object();
Object locker2 = new Object();
Thread t1 = new Thread(() ->{
System.out.println("t1尝试获取locker1");
synchronized (locker1){
System.out.println("t1获取locker1成功");
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("t1尝试获取locker2");
synchronized (locker2){
System.out.println("两把锁获取成功");
}
}
});
Thread t2 = new Thread(() ->{
System.out.println("t2尝试获取locker2");
synchronized (locker1){
System.out.println("t2获取locker2成功");
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("t2尝试获取locker1");
synchronized (locker2){
System.out.println("两把锁获取成功");
}
}
});
t1.start();
t2.start();
}
}
其实只是修改了,加锁的顺序,第二个线程也是现申请locker1再申请locker2
此时,当线程1抢到了locker1,线程2就会阻塞等待, 此时就不会出现死锁
到此为止,多线程的课时就结束了,多线程还是很难的,需要多看课和练习