硅基计划6.0 JavaEE 贰 多线程八股文

文章目录
- 一、常见锁策略
- 二、synchronized锁
- 三、CAS
- 1. 原子类
- 2. 伪代码解释
- 3. 自旋锁
- 4. CAS中的ABA问题
- 四、JUC常见类
- 1. callable接口
- 2. Reentrant可重入锁
- 3. 信号量
- 4. CountDownLatch
- 5. 线程安全集合类
- 6. 多线程哈希表
一、常见锁策略
以下集中锁策略与编程语言无关,属于是通用的那种
- 乐观锁&悲观锁:在加锁的时候我们就去预测这把锁的竞争是大还是小,如果是大并且实现原理复杂,那就是悲观锁,反之就是乐观锁
- 重量级锁&轻量级锁:对于重量级锁来说,加锁这个操作开销大并且它容易触发线程调度,反之就是轻量级锁
- 自旋锁&挂起等待锁:挂起等待锁是重量级锁的典型实现,当遇到锁冲突的时候,线程会进入阻塞状态,等待未来的某个时间唤醒,由于操作系统内部线程调度是随机的,开销比较大;自旋锁是轻量级锁的典型实现,遇到锁冲突时,线程会先重试获取锁,等到其他线程把锁释放了我们当前线程就可以得到锁了,并不会涉及到线程调度和CPU内核
- 公平锁&非公平锁:我们把先来先得到锁称为公平锁,反之如果是各凭本事得到就是非公平锁
- 可重入锁&不可重入锁:一个线程针对同一把锁,连续加锁多次不会发生死锁,就称之为可重入锁,会发生死锁就叫不可重入锁
- 读写锁&普通互斥锁:读操作的锁叫做读锁,写入操作的锁叫做写锁,读锁与读锁之间并不会冲突,反观读锁和写锁或者是写锁和写锁之间会产生冲突
二、synchronized锁
我们这把锁是根据当前锁竞争程度来自动调整锁策略的,我们感知不到也不能干预,因此它又被称之为智能锁
底层大致实现过程
进入代码块 进入代码块 阻塞线程数量到一定程度无锁 --> 偏向锁 --> 自旋锁 --> 重量级锁
我们来说说什么是偏向锁
其实偏向锁并不是真的上锁了,只是去做个标记,开销比较小
如果使用的时候其他线程没有来竞争这把锁,就始终是一种标记状态,一直到锁释放了就解除标记了,这种就是锁消除
反之如果有其他线程竞争这把锁,在这个线程竞争这把锁想要得到这把锁之前,就把这把锁升级,因此这个竞争这把锁的线程只能进入阻塞状态,如果有更多的线程来竞争,就升级为重量级锁
我们之前说过锁的粒度,越大其实锁的粗度也就越大
三、CAS
我们在实现线程安全的时候,是通过加锁去解决的,但是现在,我们有另一种解决思路
CAS全称为compare and swap
将内存中的某个变量当前值与我期望它现在是多少进行比较
如果相等,说明从我读取这个值到现在,没有其他线程修改过它,那我就安全地更新为新值
如果不相等,说明值已经被其他线程改过,那我这次 CAS 操作就失败,可以选择重试(自旋)或放弃

上述我们的一系列操作属于是原子性操作,内部实现了自旋锁
1. 原子类
我们在开发中使用,比如我们上一篇文章最开始的时候,对于int变量count++问题上会产生线程安全问题,因此此时我们使用原子类就可以很大程度上避免这个问题
public class Demo1 {private static AtomicInteger count = new AtomicInteger(0);public static void main(String[] args) throws InterruptedException {Thread t1 = new Thread(()->{for (int i = 0; i < 100; i++) {count.incrementAndGet();//相当于count++}});Thread t2 = new Thread(()->{for (int i = 0; i < 100; i++) {count.incrementAndGet();}});t1.start();t2.start();t1.join();t2.join();System.out.println(count);}
}
除了使用incrementAndGet()或者是getAndIncrement()是变量自增
还可以使用getAndDecrement()或者是decrementAndGet()是变量自减
2. 伪代码解释
private int value;
getAndIncrement(){//把内存中的值读取到寄存器中int oldValue = value;//判断value值和oldValue值是否相同,相同就触发交换,判断为true,循环进不来//执行CAS操作,把寄存器value+1位置的值和内存中的值交换//反之当其他线程穿插执行的时候,我们先不赋予值//把其他线程穿插的值再重新加载到寄存器上,while(CAS(value,oldValue,oldValue+1) != true){//再次加载值,再进行CAS判断oldValue = value;}
}
3. 自旋锁
我们之前讲得锁升级的过程,其实synchronized锁内部实现了CAS,其本质是轻量级锁
因此比起阻塞状态等待,我们还不如循环等待,开销就会小很多,下面我写个循环等待的伪代码
private Thread owner;//用于记录是哪个线程持有这把锁
public void lock(){//如果我当前锁持有对象是空的,我就把这把锁赋予到当前线程while(!CAS(this.owner,null,Thread.currentThread())){//如果当前锁对象被另一个对象持有了,我们就进入循环体内部等待//直到另一个线程释放了锁,我们再去CAS判断 }
}
public void unlock(){//解锁就是把持有锁的对象设为空this.owner = null;
}
4. CAS中的ABA问题
可能存在一种情况,一个线程把一个变量值从A改到了B,但是另一个线程又把同一个变量值从B改回了A,此时在CAS看来,这个值好像没有改过一样,因此触发ABA问题
我们来举一个转钱的例子
int oldMoney = money;
CAS(money,oldmoney,oldmoney-500)

面对这种问题,本质上是不能让其他线程同时修改,我们可以约定一个版本号,余额可以加减,但是版本号只能加不能减,每次改动余额的时候我们都原子性的把版本号+1,我们在用CAS判断版本号是否被修改过就好
四、JUC常见类
就是在Java.Uitl.concurrent包下常见的关于线程的类
1. callable接口
这个接口类似于runnable,但是它的call方法可以直接去接收线程的返回值
public class Demo2 {public static void main(String[] args) throws ExecutionException, InterruptedException {Callable<Integer> callable = new Callable<Integer>() {@Overridepublic Integer call() throws Exception {int sum = 0;for (int i = 0; i < 1000; i++) {sum += i;}return sum;}};//因为对于Thread类来说无法直接接收Callable类的返回值//需要一个类表示Callable的未来会接收到的结果FutureTask<Integer> futureTask = new FutureTask<>(callable);Thread t = new Thread(futureTask);t.start();//此时get方法如果我们Callable中没有执行完就进入阻塞状态//等到执行完了我们就使用get方法获取值System.out.println(futureTask.get());}
}
2. Reentrant可重入锁
这个是上古时期的手动加锁,因为那个时候synchronized还没有那么智能,因此那时候普遍使用这个类表示可重入锁,需要自己手动的加锁和解锁
同时也支持trylock,对比lock加锁不成功就进入阻塞等待状态,trylock就是加锁不成功可以主动返回或者是等待指定时间
而且,我们synchronized中的wait和notify只能随机唤醒一个,但是我们Reentrant可以搭配Condition指定唤醒哪个线程
如果我们在new ReentrantLock()参数中填入true,就表示是公平锁
原则:先来先服务,按照线程请求锁的顺序分配
实现:内部维护一个等待队列,新来的线程排队等待
public class Demo3 {public static int count = 0;public static void main(String[] args) throws InterruptedException {ReentrantLock locker = new ReentrantLock();//由于我们在lock和unlock之间可能存在throw异常或者是return返回//因此我们使用try-finally,在try中上锁,为了保证解锁一定会执行//我们在finally中解锁Thread t1 = new Thread(()->{for (int i = 0; i < 1000; i++) {try {locker.lock();count++;}finally{locker.unlock();}} });Thread t2 = new Thread(()->{for (int i = 0; i < 1000; i++) {try {locker.lock();count++;}finally {locker.unlock();}}});t1.start();t2.start();t1.join();t2.join();}
}
3. 信号量
类名是Semaphore,是Java对操作系统提供的机制进行了进一步的封装,本质上就是一个计数器,描述了当前系统“可用资源”的个数
最多减少到0,如果此时线程进行资源申请(P操作)就会造成阻塞,如果有其他线程释放了资源(U操作)当前线程就会从阻塞状态重新变成就绪状态
public static void main(String[] args) throws InterruptedException {//我们参数决定其初识资源个数Semaphore s = new Semaphore(2);//进行几次p操作再进行u操作s.acquire();System.out.println("获取资源");s.acquire();System.out.println("获取资源");s.release();//此时释放了资源,重新进入就绪状态System.out.println("添加了资源");s.acquire();//到这里因为资源耗尽,陷入阻塞System.out.println("再次获取到资源");}
我们还可以实现类似于“锁”的效果,我们让资源只有一个,当一个线程在执行的时候,把资源获取
此时资源就是空,其他线程只能阻塞,等到当前线程执行完毕后,再把资源添加回去,让其他线程执行
public static int count = 0;public static void main(String[] args) throws InterruptedException {Semaphore s = new Semaphore(1);Thread t1 = new Thread(()->{for (int i = 0; i < 500; i++) {try {s.acquire();} catch (InterruptedException e) {throw new RuntimeException(e);}count++;s.release();}});Thread t2 = new Thread(()->{for (int i = 0; i < 500; i++) {try {s.acquire();} catch (InterruptedException e) {throw new RuntimeException(e);}count++;s.release();}});t1.start();t2.start();t1.join();t2.join();System.out.println(count);//1000
}
因此到目前为止,解决线程安全问题我们有了几个策略
- 避免多线程修改同一个变量
- 使用
synchronized锁 - 使用
ReentrantLock锁 - 使用
Semaphore信号量中资源值特性 - CAS或使用原子类
4. CountDownLatch
比如我们要执行一个大任务,我们可以把大任务分成小任务,然后让每一个线程去执行这个小任务
只有当所有线程执行完自己小任务之后,这个大任务才算完成
public class Demo5 {public static void main(String[] args) throws InterruptedException {//参数表示任务数量,我们把主线程的大任务拆分成小任务CountDownLatch c = new CountDownLatch(5);for (int i = 0; i < 5; i++) {Thread t = new Thread(()->{//假设我们每个任务都要执行两秒钟try {Thread.sleep(2000);} catch (InterruptedException e) {throw new RuntimeException(e);}//两秒后完成任务,并且向c提交c.countDown();});t.start();}//我们主线程要等所有线程的小任务执行完毕//可以使用join,我们使用CountDownLatch中的方法c.await();}
}
这种我们的场景就是多线程下载内容,把一个大内容拆分成许多个小内容,我们再让子线程分别去执行一个小内容,当所有子线程内容都下载完毕后,主线程把所有内容进行整合即可
5. 线程安全集合类
我们很多类型都是线程不安全的,天生线程安全的有Vector,HashTable,Stack,String等待
但是对于线程不安全的类我们可以通过几种方式使其线程安全
- 添加
synchroinzed关键字,之前讲过 - 通过
Collections.synchroinzed(new ArrayList),但是不常用
接下来就是重量级嘉宾,使用copyOnWriteArrayList,即写时拷贝
在对一个变量进行修改的时候,我们先复制一份,然后在这个复制后的副本内修改值,再把修改后的副本值覆盖原来的值,避免了那种修改了但是只修改了一半的情况,即脏数据
6. 多线程哈希表
我们之前说过HashMap线程不安全,虽然HashTable天生线程安全,但是它的加锁策略有很大问题

因此我们使用concurrentHashMap就可以避免这种情况,它是有很多把锁,针对每一个下标(即每一个下标的链表头节点)进行加锁
这样就可以避免上述那种情况,并且对每个链表头节点加锁开销不是很大,我们把这种结构称之为锁通,因此这个类又叫做哈希桶
而且对于哈希表中的size表示的键值对总数,我们使用CAS让其线程安全,避免了加锁
如果后续我们想扩容concurrentHashMap不会一次性把所有数据都拷贝到新的哈希数组
因为如果哈希数组很大的话并且每个链表长度比较长,一次性复制开销会非常大,因此concurrentHashMap同时维护新数组和旧数组,分成几个部分进行拷贝
