前言
谈到多线程,一般都会联想到高并发,但是实际上两者并不是一个概念,高并发一般指的是从业务方面的描述系统的并发负载能力,而多线程只不过是如何使CPU的利用率达到最大化。因此一般问到高并发,都会从你的项目业务角度出发,偏向于实战方面,而多线程一般是问底层的一些编程技术方面的问题。
当然,如果没有掌握多线程的技术,那就不用谈所谓的高并发场景了。因此我们接下来先了解 Java 当中多线程的一些基本知识点以及相应的面试题,而 JVM 层面的多线程面试题已经在《漫谈面试系列》——JVM(2) 中谈及,如果读者没有了解这方面的内容,可以先前往该章节了解一下为什么我们需要关注线程安全问题。
下面我们通过几道常问的面试题进行相关知识点的学习:
1. 实现多线程的方式有哪些,ThreadLocal使用中需要注意什么?
2. CAS 是什么?CAS 有什么缺陷,如何解决?
3. AQS 是什么,它有什么作用?
1. 多线程的实现方式以及ThreadLocal注意点
这里主要介绍一下 Future/Callable 的多线程实现方式,其余两种应该就算是初学者都懂的了。首先 Callable 算是 Runnable 接口的一个补充,因为 Runnable 的 run() 方法是不带返回值的,但是 Callable 的 call() 方法是带返回值,而为了不破坏原有的代码和风格,这个 call() 方法会在 run() 方法里面调用,代码如下:
//该run方法由FutureTask类实现
public void run() {
if (state != NEW ||!UNSAFE.compareAndSwapObject(this, runnerOffset,null, Thread.currentThread()))
return;
try {
Callable<V> c = callable;
if (c != null && state == NEW) {
V result;
boolean ran;
try {
//调用call方法
result = c.call();
ran = true;
} catch (Throwable ex) {
result = null;
ran = false;
setException(ex);
}
if (ran)
set(result);
}
} finally {
runner = null;
int s = state;
if (s >= INTERRUPTING)
handlePossibleCancellationInterrupt(s);
}
}
Callable 接口提供了带返回值的线程方法,但是具体如何操作 Callable 接口则由 Future 接口的实现类进行,并且其最终实现类有且唯有 FutureTask 类。
从上图可以看出,FutureTask 实现了 Future 接口,那么它便获得了针对 Callable 实现类的一些操作,例如获取返回值(阻塞)、判断任务是否完成、判断任务是否被取消、取消任务等;
同时它也实现了 Runnable 接口,那么它可以被 Thread 类使用。下面我们通过代码看看 Future 接口有哪些关乎 Callable 操作的方法:
public interface Future<V> {
//取消任务
boolean cancel(boolean mayInterruptIfRunning);
//任务是否结束,无论是完成导致的结束还是异常导致的结束
boolean isCancelled();
//任务是否完成
boolean isDone();
//阻塞式获取,只要 call() 方法没有接受,就一直阻塞
V get() throws InterruptedException, ExecutionException;
//阻塞式获取,但是带有时间参数,超过时间则直接返回
V get(long timeout, TimeUnit unit)
throws InterruptedException, ExecutionException, TimeoutException;
}
总的来说,我们需要定义 Callable 的 call() 方法,这个方法可以类比为平时我们 Runnable 的 run() 方法,然后将 Callable 对象传入给 FutureTask 类,在 FutureTask 的 run() 方法中调用这个 call() 方法,除此之外,如果我们想得知目前该线程的执行状态和返回结果,也可以通过 FutureTask 类的一些方法如 isCancelled()、isDone()、get()等方法。接下来我们通过一个图以及简单的代码demo了解下如何使用 FutureTask 实现多线程。
public void future(){
//这里通过lambda表达式直接生成匿名的Callable类
FutureTask futureTask = new FutureTask(() -> {
System.out.println("futureTask demo");
return "demo";
});
Thread thread = new Thread(futureTask);
thread.start();
//判断是否在运行
System.out.println(futureTask.isDone());
//判断是否以及取消
System.out.println(futureTask.isCancelled());
try {
//获取返回值
System.out.println(futureTask.get());
} catch (InterruptedException | ExecutionException e) {
e.printStackTrace();
}
}
这里不过多阐述 FutureTask 的内部细节,只为了让读者可以理清这种线程方式的逻辑,当然 FutureTask 的核心还是在于它提供的异步操作的方法,这些内容读者可以参考相关文章。
很多业务,我们需要在多线程环境下进行一些变量的传递,而如果我们使用共享变量,又会由于多线程环境下一致性的问题导致业务出错,针对这种共享变量的一致性,我们只能上锁进行操作,这种行为相对来说非常损耗资源;
而如果我们使用局部变量,又会在方法的调用上难以传参,如果我的方法栈很深的话,便会有大量的方法的形参都得加上这个局部变量。
因此,java 提供了一个方便我们线程隔离的局部变量工具类 ThreadLocal ,假如我们业务上不需要共享变量只需要局部变量的话,可以通过该工具类方便的进行方法的调用传参,保证每个线程获取到的数据都是属于各自线程的。
ThreadLocal 本质上并不存储任何东西,最终存储的内容都是存放到 Thread 类的一个内部 Map 类当中,既然说到 Map,那肯定有 key 也有 value,而 ThreadLocal 对象便是这个 Map 的 key,我们只要看看 ThreadLocal 的一个 get()/set() 方法源码,相信读者就理解这个类到底是做什么用了。
public T get() {
//获取当前线程
Thread t = Thread.currentThread();
//然后在这个线程里面获取上面提到的 map
ThreadLocalMap map = getMap(t);
if (map != null) {
//通过this来获取对应的value,这个this便是ThreadLocal对象
ThreadLocalMap.Entry e = map.getEntry(this);
if (e != null) {
@SuppressWarnings("unchecked")
T result = (T)e.value;
return result;
}
}
return setInitialValue();
}
public void set(T value) {
//这个类的方法都会通过这个语句获取当前的线程
Thread t = Thread.currentThread();
//然后在这个线程里面获取上面提到的 map
ThreadLocalMap map = getMap(t);
if (map != null)
//set操作是将 this 作为 key,至于这个 key 便是 ThreadLocal 对象了
map.set(this, value);
else
createMap(t, value);
}
//获取线程的map对象
ThreadLocalMap getMap(Thread t) {
return t.threadLocals;
}
基本上这两个方法已经表明了 ThreadLocal 到底是如何实现线程隔离的,其实数据本身就是存到对应的 Thread 对象当中,肯定是线程隔离的,只不过我们通过这个 ThreadLocal 作为 key ,方便我们获取其中的数据而已,同时,如果你想一个线程拥有多个局部变量,那么你就得申明多个 ThreadLocal 对象进行使用。
介绍完ThreadLocal 的基本知识,接下来我们回到问题,我们使用 ThreadLocal 的时候需要注意什么。
- 首先,我们的数据最终都是存储在线程 Thread 类自身,因此如果我们使用线程池的时候,由于存在线程的重复利用,那么就有可能出现这个线程里面的 map 存有了不同业务环境的变量,最终可能导致业务方面的数据问题,因此如果我们使用了线程池,务必在使用了 get() 方法后并且保证后续不需要这个变量了,在 finally 块当中调用 remove() 方法。
- 还有一点便是,由于 Thread 类中的 ThreadLocalMap 并不知道对应的 key 什么时候会没有引用指向,因此它无法判断这个 key 到底还有没有用,于是它通过一个弱引用的形式来存储这个 ThreadLocal 对象作为 key ,只要外界没有引用指向这个 ThreadLocal 对象,那么GC的时候便会自动清理掉这个 ThreadLocal 对象,不过这个带来的问题便是,会导致 map 里面出现一对 <null,Value> 的情况,假如出现这种情况后,线程一直没有销毁,也没执行 get\set\remove 方法的话,那么这个 Value 便会一直存在,最终导致内存泄漏,因此每次使用完 ThreadLocal 对象,尽量在调用一次 get\set\remove 方法,因为这些方法会将 map 中 key 为 null 的键值对删除。
2. CAS是什么?CAS 有什么缺陷,如何解决?
CAS 全称是 Compare And Swap ,即比较与交换,它由一条指令完成,是原子性的,如果多个线程对同一个共享变量执行 CAS 操作,最终只会有一个线程可以操作成功,操作成功返回 true,反之返回 false。
在 Java 的 sun.misc.Unsafe 类下,就有许多可以使用的 CAS 方法,这些方法都属于 native 方法,即最终实现由底层 C/C++ 完成,在 JUC 里面用的最多的应该就是 compareAndSwapInt 方法。
//第一个参数是需要修改的对象
//第二个参数是这个对象需要修改的字段的内存地址偏移量,用于直接找到相应的内存位置
//第三个参数是需要比较的值,如果当前内存的值与这个值相同,才会进行数据写入
//第四个参数是需要写入的值
public final native boolean compareAndSwapInt(Object var1, long offset, int expect, int update);
如果业务属于CPU密集类型,一般我们会一直循环调用CAS来实现非阻塞资源竞争,不需要通过锁的形式来修改某个共享变量,从而减少线程切换导致的性能损耗,但是这种做法可能会导致CPU的资源损耗,因为死循环会一直占用 CPU 资源。
当然,如果 CAS 能解决所有共享变量的线程安全问题,为什么我们还需要用锁的形式呢?接下来我们来看看 CAS 的一些问题以及相应的解决办法:
- 自旋的CAS开销大,如果线程一直竞争资源失败,则会导致cpu一直自旋,造成不必要的开销,如果业务允许放弃资源,则可以通过设置超时时间解决.
- CAS只能保证一个共享变量的原子操作,如果我们想对多个共享变量进行原子操作,只能通过锁的形式解决.
- ABA问题,如果ABA并没有对业务有实质上的影响的话,这个并不算问题,但如果业务想要了解该变量当前的语义,就会出现问题,例如栈的问题,存在栈A-B-C,A是栈顶,有线程X想要将B改为栈顶,线程X获取A的值时线程被挂起,同时线程Y介入,将栈A,B出栈,同时将A入栈,这个时候栈结构为A-C,线程X唤醒,此时他对栈进行出栈操作,并将B作为栈顶,但由于B已经出栈,B.next为null,这就导致了C的丢失,最终栈结构变为B而不是最初的想法B-C,这个问题可以通过加入时间戳来实现预期值变化的感知.
3. AQS 是什么,它有什么作用
在传统的多线程资源竞争当中,这些线程并没有进行统一的管理,可能导致的结果便是部分线程永远抢占不到锁,即饥饿问题。
为了对多线程并发进行一个统一管理,AQS 即 AbstractQueuedSynchronizer 诞生了。
AQS 的本质是通过一个双向链表和一个状态值的结构来管理多个线程,链表里面存储的是一个个封装好的线程对象节点,而状态值代表了当前锁的状态,其中 AQS 会使用 CAS 进行状态值的修改,从而保证当前锁的状态值无误。
接下来我们通过一系列的图片,来了解下 AQS 的基本原理。
首先我们来看看 AQS 的结构是怎么样的:
这里有个关键点,即 head 节点本质上不算是阻塞队列的一员,因为该节点的线程是持有锁的,后续节点的唤醒都需要通过 head 节点来实现,而这个 head 节点里面封装的线程具体是什么也不重要
AQS的结构非常简单,接着我们借助 ReentrantLock 来了解下 AQS 如何管理线程:
以下操作中,线程颜色对应文字颜色,即流程文字对应相应的线程操作
为了预防当前节点的前驱节点被中断取消导致无法唤醒当前节点,在 shouldParkAfterFailedAcquire() 方法中会通过遍历的形式寻找一个"有效的前驱节点"进行关联并设置这个前驱节点的 waitStatus 值
可以看出,AQS 入队操作主要分为 3 个步骤,分别是:
- 竞争锁,竞争成功直接持有锁,失败则进入步骤 2 ;如果当前是公平锁并且阻塞队列的 tail 不为空,则直接进入步骤 2 而不进行竞争
- 将当前线程包装为一个 Node 节点,如果队列没初始化则先进行初始化操作,等到阻塞队列的 tail 不为空,则将 tail 赋值为当前的 Node 节点,并与上任 tail 节点进行关联
- 如果当前节点的前驱节点为 head ,则尝试竞争锁,竞争失败则将当前节点的有效前驱节点的waitStatus设置为-1,最后进入阻塞状态,等待前驱节点的唤醒
接下来我们在快速过一遍入队过程:
看完了 AQS 的入队操作,接下来我们看看 AQS 的出队操作是如何进行:
这里为什么要从尾部往前遍历呢? 主要原因是防止中间有节点已经处于取消状态并且与它的后续节点断开关联导致无法唤醒阻塞队列其他的等待节点,因此从后往前遍历一个最接近 head 节点的阻塞节点进行唤醒
AQS 的出队以及唤醒操作主要分为 3 个步骤:
- 持有锁的线程修改 state 字段以及 exclusiveOwnerThread 字段(不考虑重入)
- 如果当前线程节点的 waitStatus 不为0,从后往前遍历寻找最接近 head 的节点并将该节点的线程唤醒
- 被唤醒的线程先将前驱节点设置为 head (如果已经是 head 则跳过该步骤),然后进行锁的竞争(非公平锁的话有可能竞争失败然后进入阻塞),竞争成功后修改 state 字段以及 exclusiveOwnerThread 字段,并将 head 赋值为当前节点
AQS 使用了模板设计模式,将阻塞队列的队列操作自行实现,然后给子类留下了 tryAcquire(竞争锁)/tryRelease(释放锁)等模板方法,例如自定义公平锁或非公平锁的实现,子类通过继承 AQS 来实现具体的上锁\释放锁的方法,因此 AQS 的作用是为同步工具类提供基于阻塞队列的组件。
同时,为了保证 AQS 在多线程进行入队-出队操作时的线程安全操作, AQS 内部使用了大量的自旋 CAS 操作保证线程安全,并利用了 LockSupport 类的 park() 和 unpark() 方法进行线程的阻塞和释放。
AQS 典型的实现类有 ReentrantLock ,它通过一个内部类 FairSync/NonfairSync 继承 AQS 定义了公平锁/非公平锁竞争锁的具体逻辑。搞懂了 AQS ,在看 JUC 包内的同步工具类便会变得很简单,因为你只需要关注他们不同的上锁/释放锁的逻辑即可,至于线程的管理,几乎都是一样。
总结
在该篇章中,我首先介绍了带有返回值的多线程实现方法,其次介绍了在同步容器中经常出现的 CAS ,最后介绍了同步容器的核心类 AQS ,其中 AQS 的内容可能偏多,建议读者在阅览 AQS 流程图解的时候可以关联下具体代码的实现,可以有效帮助理解 AQS 的运行流程和作用。
接下来,我们回到最初的三个问题
1. 实现多线程的方式有哪些,ThreadLocal使用中需要注意什么?
2. CAS 是什么?CAS 有什么缺陷,如何解决?
3. AQS 是什么,它有什么作用?
如果你们可以很流畅的回答这些问题,那么恭喜你,该章节的内容已经全部掌握,如果不行,希望可以回到对应问题讲解的地方,或者对某个不了解的点进行额外的知识搜索,尽量用自己组织的语言回答这些问题。
下一章节,我主要讲解多线程中的一些实战方面的面试题内容,有兴趣的读者可以通过传送门进入:通勤路上搞定 Java 多线程面试(2)