多线程进阶篇
文章目录
- 多线程进阶篇
- 1、常见的锁策略
- 1) 乐观锁 vs 悲观锁
- 2) 重量级锁 vs 轻量级锁
- 3) 自旋锁 vs 挂起等待锁
- 4) 读写锁 vs 互斥锁
- 5) 公平锁 vs 非公平锁
- 6) 可重入锁 vs 不可重入锁
- 2、死锁
- 1) 死锁的三种典型情况:
- 2) 如何解决死锁问题
- 3) 死锁产生的必要条件
- 3、Synchronized 采用策略
- 1) 偏向锁
- 2) 轻量级锁
- 3) 其他优化操作
- 1.锁消除
- 2.锁粗化
- 4、CAS
- 1) CAS的应用
- 1. 实现原子类
- 2. 实现自旋锁
- 2) CAS 的 ABA 问题
- 3) 解决方案
- 5、Callable 接口
- 6、JUC
- 1) ReentrantLock 可重入锁
- 2) 原子类的应用场景
- 3) 信号量 Semaphore
- 4) CountDownLatch
- 7、集合类
- 1) 多线程环境使用 ArrayList
- 2) 多线程环境使用队列
- 3) 多线程环境使用哈希表
1、常见的锁策略
这里讨论的锁策略不仅仅局限于 Java,此篇幅主要是认识几种常见的锁策略,能够知道概念。
接下来提及到的都不是某个具体的锁,而是抽象的概念。
描述的是锁的特性,描述的是“一类锁”。
1) 乐观锁 vs 悲观锁
二者都是对后续场景中的锁冲突现象进行一个预估。
乐观锁:预测后续的场景中,不会出现很多锁冲突的现象。(后续的工作会更少)
悲观锁:预测后续的场景中,很容易出现锁冲突的现象。(后续会做出更多的工作来保证线程安全)
锁冲突:两个线程尝试获取同一把锁,一个线程能获取成功,另一个线程阻塞等待。
锁冲突的概率大还是小,对后续的工作,是有一定影响的。
2) 重量级锁 vs 轻量级锁
重量级锁:加锁的开销是比较大的(花的时间多,占用系统资源多)
轻量级锁:加锁开销比较小的(花的时间少,占用系统资源少)
乐观悲观锁 vs 重量轻量锁
乐观悲观锁,是在加锁之前,对锁冲突概率的预测,决定工作的多少。
重量轻量,是在加锁之后,考量实际的锁的开销。
正是因为这样的概念存在重合,针对一个具体的锁,可能把它叫做乐观锁,也可能叫做轻量锁。但是此观点是不绝对的,反之也成立。
3) 自旋锁 vs 挂起等待锁
自旋锁:是轻量级锁的一种典型实现。
在用户态下,通过自旋的方式(while…循环),实现类似于加锁的效果。
这种锁,会消耗一定 cpu 资源,但是可以做到最快速度拿到锁。
等待挂起锁:是重量级锁的一种典型实现。
通过内核态,借助系统提供的锁机制,当出现锁冲突的时候,会牵扯到内核对于线程的调度,是冲突的线程出现挂起(阻塞等待)
这种方式,消耗 cpu 资源更少.也就无法保证第一时间拿到锁。
4) 读写锁 vs 互斥锁
互斥锁:就是简单的加锁(synchronized)解锁
读写锁:把读操作枷锁和写操作加锁分开了。
读锁:是在读的时候加锁。 写锁:是在写的时候加锁。
如果两个线程,都对读加锁加锁,则不会产生锁竞争。(多线程并发执行的效率就更高)
如果两个线程,一个线程写加锁,一个线程也是写加锁,则会产生锁竞争。
如果两个线程,一个线程写加锁,两一个线程读加锁,则也会产生锁竞争。
实际开发中,读操作的频率,往往比读操作,高更多。
java标准库中也提供了现成的读写锁。
5) 公平锁 vs 非公平锁
公平锁:遵循先来后到。(通过一定的数据结构去实现)
非公平锁:一拥而上,抢占式。(原生)
操作系统自带的锁(pthread_mutex)属于是非公平锁。
6) 可重入锁 vs 不可重入锁
一个线程,针对同一把锁,连续加锁多次。如果产生了死锁,则是不可重入锁,如果没有产生死锁,就是可重入锁。
可以按照字面意思来理解,可重入锁的字面意思是“可以重新进入的锁”,即允许同一个线程多次获取同一把锁。
观察下面这串伪代码:
public synchronized void increase() {
synchronized (this) {
count ++;
}
}
1.调用方法,先针对this加锁。 此时假设加锁成功了。
⒉接下来往下执行到代码块中的 synchronized ,此时,还是针对this来进行加锁。
在不可重入锁中:
第二次 this 上的锁,得在 increase 方法执行完毕之后,才能释放。要想让代码继续往下执行,就需要把第二次加锁获取到,也就是把第一次加锁释放。要想把第一次加锁释放,又需要保证代码先继续执行。这就陷入了一个死锁状态,程序无法执行。(这个状态是非常不合理的,第二次尝试加锁的时候,该线程已经有了这个锁的权限了,这个时候不应该加锁失败,不应该阻塞等待)
不可重入锁:如果是一个不可重入锁。这把锁不会保存,是哪个线程对它加的锁,只要它当前处于加锁状态之后,收到了"加锁”这样的请求,就会拒绝当前加锁。而不管当下的线程是哪个。就会产生死锁。
可重入锁:一把可重入锁,是会让这个锁保存,是哪个线程加上的锁。后续收到加锁请求之后,就会先对比一下,看看加锁的线程是不是当前持有自己这把锁的线程,这个时候就可以灵活判定了。
注:synchronized 实际上是一个可重入锁。
可重入锁,是让锁记录了当前是哪个线程持有了锁,观察下面伪代码。
synchronized (this) { //这个是真正加了锁,下面的锁只是虚晃一枪。
synchronized (this) { //判定了一下持有线程就是当前线程
synchronized (this) { //同上
…………
} //执行到这个代码,出了代码块的时候,刚才加上的锁是否要释放?? 答案是:不释放。
} //如果在里层就释放了锁,意味着最外面的 synchronized 和次外层的代码,就没有处于锁的保护之中了
}
问题:如果加了 N 层锁,在遇到大括号时,JVM 咋知道当前这个大括号是最后一个(最外层的)呢??
答:让锁这里持有一个“计数器”就行了。让锁对象不光要记录是哪个线程持有的锁,同时再通过一个整形变量记录当前这个线程加了几次锁!!
2、死锁
什么是死锁??
死锁是一种严重的 BUG!!导致一个程序的线程 “卡死”, 无法正常工作!
死锁是这样一种情形:多个线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放。由于线程被无限期地阻塞,因此程序不可能正常终止。
1) 死锁的三种典型情况:
1. 一个线程,一把锁,但是是不可重入锁。该线程针对这个锁连续加锁两次,就会出现死锁。
public synchronized void increase() {
synchronized (this) {
count ++;
}
}
2. 两个线程,两把锁。这两个线程先分别获取到一把锁,然后再同时尝试获取对方的锁。
public class Demo1 {
private static Object locker1 = new Object(); //第一把锁
private static Object locker2 = new Object(); //第二把锁
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
synchronized (locker1) { //获取第一把锁,成功获取。
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (locker2) { //获取第二把锁,由于 locker2 被占用,获取失败。(死锁)
System.out.println("t1 两把锁加锁成功!");
}
}
},"t1");
Thread t2 = new Thread(() -> {
synchronized (locker2) { //获取第二把锁,成功获取。
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (locker1) { //获取第一把锁,由于 locker1 被占用,获取失败。(死锁)
System.out.println("t2 两把锁加锁成功!");
}
}
},"t2");
t1.start();
t2.start();
}
}
从 jconsole 中可以看到程序中两个线程中(t1、t2)的死锁。死锁的线程就僵住了,无法正常工作,会对程序造成严重的影响。
3. N个线程M把锁,哲学家就餐问题。
可以通过一个抽象的图来进行理解。有五个哲学家(五个线程),五根筷子(五把锁)。
每个哲学家,主要要做两件事:
- 思考人生。(此时会放下筷子)
- 吃面,会拿起左手和右手的筷子,再去夹面条吃。(拿起筷子)
其他设定:
- 每个哲学家,啥时候思考人生,啥时候吃面条,都很随机。
- 每个哲学家一旦想吃面条了,就会非常固执的完成吃面条的操作。如果此时,他的筷子被别人使用了,就会阻塞等待,而且等待过程中不会放下手里已经拿着的筷子。
基于上述的模型设定,绝大部分情况下,这些哲学家都是可以很好的工作。
但是,如果出现了极端情况,就会出现死锁。
比如,同一时刻,五个哲学家都想吃面,并且同时伸出左手拿起左边的筷子。再尝试伸右手拿右边的筷子。
2) 如何解决死锁问题
解决方法:针对锁进行编号,并且规定加锁的顺序。每个线程如果要获取多把锁,必须先获取编号小的锁,后获取编号大的锁。
利用上述办法,1 2 3 4 号哲学家分别获取到 1 2 3 4 号筷子。当 5 号哲学家开始获取筷子时,只能去获取 4 号筷子,但是 4 号筷子已经被 4 号哲学家获取到了,因此只能阻塞等待 4 号哲学家用完后释放,才能获取到。当 1 号哲学家用完 1 5 两根筷子时,1 5 均被释放,2 号就可以获取到 1 号筷子……以此类推,当 4 号哲学家释放 4 号筷子时,5号哲学家才能开始动筷。
3) 死锁产生的必要条件
- 互斥使用:当一个线程获取到一把锁后,别的线程不能获取到着吧锁。(锁的基本特性)
- 不可抢占:锁只能是被持有者主动释放,而不能是被其他线程直接抢走。(锁的基本特性)
- 请求和保持:这一个线程去尝试获取多把锁,在获取第二把锁的过程中,会保持对第一把锁的获取状态。
- 循环等待:t1 尝试获取 locker2,需要 t2 执行完,释放 locker2; t2尝试获取 locker1,需要 t1 执行完,释放 locker1。
3、Synchronized 采用策略
synchronized 加锁过程:代码中写了一个 synchronized 之后,这里可能会产生一系列的"自适应的过程",锁升级(锁膨胀)
无锁→偏向锁→轻量级锁→重量级锁
1) 偏向锁
偏向锁,不是真的加锁,而只是做了一个"标记"。如果有别的线程来竞争锁了,才会真的加锁。如果没有别的线程竞争,就自始至终都不会真的加锁了。
加锁本身,有一定开销。能不加,就不加。非得是有人来竞争了,才会真的加锁~
偏向锁在没有其他人竞争的时候,就仅仅是一个简单的标记(非常轻量)。一旦有别的线程尝试进行加锁,就会立即把偏向锁,升级成真正加锁的状态,让别人只能阻塞等待。
2) 轻量级锁
synchronized 通过自旋锁的方式来实现轻量级锁。
当一个线程把锁占用时,其它线程就会按照自旋的方式,来反复查询当前的锁的状态是不是被释放了。
但是,后续如果竞争这把锁的线程越来越多了(锁冲突更激烈了),从轻量锁升级成重量级锁。
3) 其他优化操作
1.锁消除
编译器,会智能的判定当前代码是否有必要加锁。
如果你写了加锁,但是实际上没有必要加锁,就会把加锁操作自动删除掉。
有些应用程序的代码中, 用到了 synchronized, 但其实没有在多线程环境下. (例如 StringBuffer)
StringBuffer sb = new StringBuffer(); sb.append("a"); sb.append("b"); sb.append("c"); sb.append("d");
此时每个 append 的调用都会涉及加锁和解锁.。但如果只是在单线程中执行这个代码,那么这些加锁解锁操作是没有必要的,白白浪费了一些资源开销。
2.锁粗化
关于"锁的粒度",如果加锁操作里包含的实际要执行的代码越多,就认为锁的粒度越大。
有的时候,希望锁的粒度小比较好,并发程度更高。
有的时候,也希望锁的粒度大比较好 (因为加锁解锁本身也有开销).
4、CAS
CAS: 全称Compare and swap,字面意思:”比较并交换“。
能够比较和交换某个寄存器中的值和内存中的值,看是否相等。如果相等,则把另外一个寄存器中的值和内存进行交换。
我们假设内存中的原数据V,旧的预期值A,需要修改的新值B。
- 比较 address 与 expectValue 是否相等。(比较)
- 如果比较相等,将 swapValue写入 address。(交换)
- 返回操作是否成功。
CAS 伪代码
boolean CAS(address, expectValue, swapValue) { //判断 address 与 expectValue 是否相等,若相等则将 swapValue 写入 address
if (&address == expectedValue) {
&address = swapValue;
return true;
}
return false;
}
1) CAS的应用
1. 实现原子类
比如,多线程针对一个 count 变量进行 ++。在 java 标准库中,已经提供了一组原子类。
基于CAS又能衍生出一套"无锁编程",进一步提高代码运行效率。
这里面提供了 自增/自减/自增任意值/自减任意值,这些操作,就可以基于 CAS 无锁编程的方式来实现。
上述的原子类,就是基于 CAS 来实现的。
//伪代码实现
class AtomicInteger {
private int value;
public int getAndIncrement() {
int oldValue = value;
while ( CAS(value, oldValue, oldValue+1) != true) {
oldValue = value;
}
return oldValue;
}
}
假设两个线程同时调用 getAndIncrement ,同时假设 value 是 0.
在 CAS 中比较 value 和 oldValue 是否相等时,其实就是在检查当前 value 的值是不是变了。是不是被别的线程穿插进来做出修改了!!进一步就发现了当前的 ++ 操作不是一气呵成的原子操作了,一旦发现出现其他线程穿插的情况,立即重新读取内存的值准备下一次尝试~~
当两个线程并发的去执行++操作的时候,如果不加任何限制,就意味着,有时候,这俩++是串行的,能计算正确的。有的时候这俩++操作是穿插的,这个时候是会出现问题的。可以通过加锁保证线程安全:通过锁,强制避免出现穿插~~
原子类/CAS保证线程安全:借助CAS来识别当前是否出现“穿插"的情况,如果没穿插,此时直接修改,就是安全的。如果出现穿插了,就重新读取内存中的最新的值,再次尝试修改。
2. 实现自旋锁
基于 CAS 实现更灵活的锁, 获取到更多的控制权.
public class SpinLock {
private Thread owner = null; //用owner表示当前线程持有的锁,null为解锁状态。
public void lock(){
// 通过 CAS 看当前锁是否被某个线程持有.
// 如果这个锁已经被别的线程持有, 那么就自旋等待.
// 如果这个锁没有被别的线程持有, 那么就把 owner 设为当前尝试加锁的线程.
while(!CAS(this.owner, null, Thread.currentThread())){
//获取当前线程引用.哪个线程调用lock,这里得到的结果就是哪个线程的引用!
}
}
public void unlock (){
//当该锁已经处于加锁状态,这里就会返回false, cas不会进行实际的交换操作.接下来循环条件成立,继续进入下一轮循环.
this.owner = null;
}
}
2) CAS 的 ABA 问题
上面讲到了,CAS 的关键要点,是比较 寄存器1 和 内存 中的值,通过这里的是否相等,来判定内存的值是否发生变化。
如果内存的值发生变化,则存在其他线程进行了修改。如果内存的值没有发生变化,则没有别的线程修改,接下来进行的修改就是安全的。
但是我们要想到一个问题,如果这里的值没变,就一定没有别的线程进行修改吗?
ABA 问题就描述了另一个线程,把变量的值从A->B,又从B->A。此时本线程区分不了,这个值是始终没变,还是出现变化又回来了的情况。
大部分情况下,就算是出现 ABA 问题,也没啥太大影响。但是在一些比较极端情况下,还是会出现问题。
虽然上述操作,概率比较小,也需要去考虑。
ABA 问题,CAS 基本的思路是 ok 的,但是主要是修改操作能够进行反复横跳,就容易让咱们 CAS 的判定失效。
3) 解决方案
我们也有相应的解决办法,可以给上述案例中的账户余额安排一个隔壁邻居 ——— 版本号。
给要修改的值,引入版本号。 在 CAS 比较数据当前值和旧值的同时, 也要比较版本号是否符合预期。
- CAS 操作在读取旧值的同时, 也要读取版本号.
- 真正修改时
- 如果当前版本号和读到的版本号相同,则修改数据,并把版本号 + 1。
- 如果当前版本号高于读到的版本号。就操作失败(认为数据已经被修改过了)。
在 Java 标准库中提供了 AtomicStampedReference<E>
类。这个类可以对某个类进行包装,在内部就提供了上面描述的版本管理功能。
5、Callable 接口
Callable 是一个 interface。相当于把线程封装了一个 “返回值”。方便程序猿借助多线程的方式计算结果。
Callable interface 也是创建线程的一种方式。
如果进行多线程操作,如果你只是关心多线程执行的过程,使用 Runnable 即可。(只关心过程)
如果是关心多线程的计算结果,使用Callable更合适。(比如说通过多线程,计算一个公式,返回结果)
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;
public class Demo1 {
public static void main(String[] args) throws ExecutionException, InterruptedException {
Callable<Integer> callable = new Callable<Integer>() {
@Override
public Integer call() throws Exception {
int sum = 0;
for (int i = 1; i <= 1000; i++) {
sum += i;
}
return sum;
}
};
FutureTask<Integer> futureTask = new FutureTask<>(callable);
Thread t = new Thread(futureTask);
t.start();
Integer result = futureTask.get();
System.out.println(result);
}
}
使用 Callable 不能直接作为 Thread 的构造方法参数。而是需要用到 FutureTask 。
上面 Callable 线程结果啥时候能算出来??这是最关心的一点。使用 futureTask 就可以帮助咱们解决这个问题。
获取 call 方法的返回结果。get 类似于 join 一样,如果 call 方法没执行完,会阻塞等待。
6、JUC
Juc (java.util.concurrent) 的常见类也是并发编程。
1) ReentrantLock 可重入锁
这个锁,没有 synchronized 那么常用,但是也是一个可选的加锁的组件。
ReentrantLock 具有一些特点,是 synchronized 不具备的功能。
- 提供了一个tryLock方法进行加锁。对于lock操作,如果加锁不成功,就会阻塞等待(死等)。对于tryLock,如果加锁失败,直接返回false/也可以设定等待时间。tryLock给加锁操作提供了更多的可操作空间~~
- ReentrantLock有两种模式。可以工作在公平锁状态下,也可以工作在非公平锁的状态下。构造方法中通过参数设定的公平/非公平模式
- ReentrantLock 也有等待通知机制。搭配Condition 这样的类来完成。这里的等待通知要比 wait notify功能更强。这几个是ReentrantLock的优势~~
synchronized 锁对象是任意对象。ReentrantLock 锁对象就是自己本身。如果你多个线程针对不同的 ReentrantLock 调用 lock 方法,此时是不会产生锁竞争的。
2) 原子类的应用场景
-
计数需求
播放量、点赞量、投币量、转发量、收藏量等……
同一个视频,有很多人同时播放、点赞、收藏……
-
统计效果
统计出现错误的请求数量。使用原子类,记录出错的请求的数目
3) 信号量 Semaphore
semaphore 是并发编程中的一个重要组件。它可以用来控制同时访问某个资源的线程数量。Semaphore维护了一个许可证集合,线程在访问资源前必须先获取许可证,如果许可证已经全部被占用,则线程必须等待其他线程释放许可证后才能获取许可证并访问资源。
准确来说,Semaphore 是一个计数器(变量),描述了“可用资源的个数”。
描述当前线程,是否“有临界资源可以使用”。(多个线程修改同一个变量,这个变量就可以认为是临界资源)
acquire 方法表示申请资源(P操作),release 方法表示释放资源(V操作)。
import java.util.concurrent.Semaphore;
// 信号量
public class Demo2 {
public static void main(String[] args) throws InterruptedException {
// 构造方法中, 就可以用来指定计数器的初始值.
Semaphore semaphore = new Semaphore(4); //申请 4 个资源
semaphore.acquire(); // 计数器 -1
System.out.println("执行 P 操作 1");
semaphore.acquire(); // 计数器 -1
System.out.println("执行 P 操作 2");
semaphore.acquire(); // 计数器 -1
System.out.println("执行 P 操作 3");
semaphore.acquire(); // 计数器 -1
System.out.println("执行 P 操作 4"); //到此为止所有资源已占用完,如果再申请资源则阻塞等待。
semaphore.release(); // 计数器 +1
System.out.println("执行 V 操作 1");
semaphore.acquire(); // 计数器 -1
System.out.println("执行 P 操作 5");
}
}
打印结果:
4) CountDownLatch
同时等待 N 个任务执行结束。
好像跑步比赛,10个选手依次就位,哨声响才同时出发;所有选手都通过终点,才能公布成绩。
当需要把一个任务拆成多个任务,如何衡量现在是把多个任务都搞定了呢?这时候就需要用到 CountDownLatch.
import java.util.concurrent.CountDownLatch;
public class Demo3 {
public static void main(String[] args) throws InterruptedException {
// 构造方法中, 指定创建几个任务.
CountDownLatch countDownLatch = new CountDownLatch(10);
for (int i = 0; i < 10; i++) {
int id = i;
Thread t = new Thread(() -> {
System.out.println("线程" + id + "开始工作!");
try {
// 使用 sleep 代指某些耗时操作, 比如下载.
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("线程" + id + "结束工作!");
// 每个任务执行结束这里, 调用一下方法
// 把 10 个线程想象成短跑比赛的 10 个运动员. countDown 就是运动员撞线了.
countDownLatch.countDown();
});
t.start();
}
// 主线程如何知道上述所有的任务都完成了呢??
// 难道要在主线程中调用 10 次 join 嘛?
// 万一要是任务结束, 但是线程不需要结束, join 不就也不行了嘛?
// 主线程中可以使用 countDownLatch 负责等待任务结束.
// a => all 等待所有任务结束. 当调用 countDown 次数 < 初始设置的次数, await 就会阻塞.
countDownLatch.await();
System.out.println("多个线程的所有任务都执行完毕了!!");
}
}
7、集合类
原来的集合类, 大部分都不是线程安全的。
Vector,Stack,HashTable,是线程安全的(不建议用),其他的集合类不是线程安全的。
1) 多线程环境使用 ArrayList
-
自己使用同步机制 (synchronized 或者 ReentrantLock)
-
Collections.synchronizedList(new ArrayList);
-
使用 CopyOnWriteArrayList
2) 多线程环境使用队列
-
ArrayBlockingQueue
基于数组实现的阻塞队列
-
LinkedBlockingQueue
基于链表实现的阻塞队列
-
PriorityBlockingQueue
基于堆实现的带优先级的阻塞队列
-
TransferQueue
最多只包含一个元素的阻塞队列
3) 多线程环境使用哈希表
HashMap 本身不是线程安全的.
在多线程环境下使用哈希表可以使用:
- Hashtable
- ConcurrentHashMap