随着技术人才大幅增长以及公司招聘更加严苛,程序员的职场正面临着前所未有的激烈竞争。以Java为例,不仅要了解操作系统、掌握JVM等知识点,还要深耕数据结构与算法,掌握Spring全家桶等框架。
而在这其中,对于并发与多线程的处理,也是一个优秀的技术工程师成长过程中必须攻下的难关。它贯穿着日常工作,也是入职面试重点考察的重点。
我们用5分钟复习一下并发与多线程。
01知识点汇总
多线程协作时,因为对资源的锁定与等待会产生死锁,需要了解产生死锁的四个基本条件,要明白竞争条件与临界区的概念,知道通过破坏造成死锁的4个条件来防止死锁。
除了了解进程间的通信方式,还要知道线程的通信方式,通信主要指线程之间的协作机制,例如wait、notify
另外需要知道java为多线程提供的一些机制,例如threadlocal用来保存线程独享的数据,fork/join机制用于大任务的分割与汇总,volatile对多线程数据可见性的保证以及线程的中断机制。
其他还有: threadlocal的实现机制。fork/join的工作窃取算法等内容。
02知识点详解
1、详解-线程的状态转换
先介绍线程状态转换。
线程是jvm执行任务的最小单元,理解线程的状态转换是理解后续多线程问题的基础。
在jvm运行中,线程一共有NEW、RUNNABLE、BLOCKED、WAITING、TIMED_WAITING、TERMINATED六种状态,这些状态对应Thread.State枚举类中的状态。
当创建一个线程的时候,线程处在new状态,运行thread的start方法后,线程进入runnable可运行状态。
这个时候,所有可运行状态的线程并不能马上运行,而是需要先进入就绪状态等待线程调度,如图中间的ready状态。在获取到cpu后才能进入运行状态,如图中的running。运行状态可以随着不同条件转换成除new以外的其他状态。
在运行态中的线程进入synchronized同步块或者同步方法时,如果获取锁失败,则会进入到blocked状态。当获取到锁后,会从blocked状态恢复到就绪状态。
运行中的线程还会进入等待状态,这两个等待一个是有超时时间的等待,例如调用object.wait、thread.join等。另外一个时无超时的等待,例如调用thread.join或者locksupport.park。
这两种等待都可以通过notify或unpark结束等待状态恢复到就绪状态。
最后是线程运行完成结束时,线程状态变成TERMINATED
2、详解-CAS与ABA问题
解决线程同步与互斥的主要方式是cas、synchronized、和lock。
cas是属于乐观锁的一种实现,是一种轻量级锁,juc中很多工具类的实现就是基于cas。
cas操作是线程在读取数据时不进行加锁,在准备写回数据时,比较原值是否修改,若未被其他线程修改则写回,若已被修改,则重新执行读取流程。这是一种乐观策略,认为并发操作并不总会发生。
比较并写回的操作是通过操作系统原语实现的,保证执行过程中不会被中断。
CAS容易出现ABA问题,如果线程T1读取值A之后,发生过两次写入,先由线程T2写回了b,又由t3写回了a,此时t1在写回比较时,值还是a,就无法判断是否发生过修改。
aba问题不一定会影响结果,但还是需要防范,解决的办法可以增加额外的标志位或者时间戳。juc工具包中提供了这样的类。
3、详解-synchronized
synchronized是最常用的线程同步手段之一,它是如何保证同一时刻只有一个线程可以进入临界区呢?
我们知道synchronized是对对象进行加锁,在JVM中,对象在内存中分为三块区域:对象头、实例数据和对齐填充。在对象头中保存了锁标志位和指向monitor对象的起始地址。当monitor被某个线程持有后,就会处于锁定状态,owner部分会指向持有monitor对象的线程。另外monitor中还有两个队列,用来存放进入及等待获取锁的线程。
synchronized应用在方法上时,在字节码中是通过方法的ACC_SYNCHRONIZED标志来实现的,synchronized应用在同步块上时,在字节码中是通过monitorenter和monitorexit实现的。
针对synchronized获取锁的方式,jvm使用了锁升级的优化方式,就是先使用偏向锁优先同一线程再次获取锁,如果失败,就升级为CAS轻量级锁,如果再失败会短暂自旋,防止线程被系统挂起。最后如果以上都失败就是升级为重量级锁。
4、详解-aqs与lock
在介绍lock前,先介绍aqs,也就是队列同步器,这是实现lock的基础。
aqs有一个state标记位,值为1时表示有线程占用,其他线程需要进入到同步队列等待。同步队列是一个双向链表。
当获得锁的线程需要等待某个条件时,会进入condition的等待队列,等待队列可以有多个。
当condition条件满足时,线程会从等待队列重新进入到同步队列进行获取锁的竞争。
reentrantlock就是基于aqs实现的,reentrantlock内部有公平锁和非公平锁两种实现,差别就在于新来的线程会不会比已经在同步队列中的等待线程更早获得锁。
和reentrantlock实现方式类似,semaphore也是基于aqs,差别在于reentrantlock是独占锁,semaphore是共享锁。
5、详解-线程池
线程池通过复用线程,避免线程频繁创建和销毁。
java的Executors工具类中,提供了5种类型线程池的创建方法,它们的特点和适用场景如下:
第 1 种是:固定大小线程池,特点是线程数固定,使用无界队列,适用于任务数量不均匀的场景、对内存压力不敏感,但系统负载比较敏感的场景;
第 2 种是:cached线程池,特点是不限制线程数,适用于要求低延迟的短期任务场景;
第 3 种是:单线程线程池,也就是一个线程的固定线程池,适用于需要异步执行但需要保证任务顺序的场景;
第 4 种是:scheduled线程池,适用于定期执行任务场景,支持按固定频率定期执行和按固定延时定期执行两种方式;
第 5 种是:工作窃取线程池,使用的ForkJoinPool,是固定并行度的多任务队列,适合任务执行时长不均匀的场景。
6、详解-线程池参数介绍
前面提到的线程池,除了工作窃取线程池外,都是通过ThreadPoolExecutor的不同初始化参数来创建的。
第一个参数设置核心线程数。默认情况下核心线程会一直存活。
第二个参数设置最大线程数。决定线程池最多可以创建的多少线程。
第三个参数和第四个参数用来设置线程空闲时间,和空闲时间的单位,当线程闲置超过空闲时间就会被销毁。可以通过allowCoreThreadTimeOut方法来允许核心线程被回收。
第五个参数设置缓冲队列,图中左下方的三个队列是设置线程池时常使用的缓冲队列。其中ArrayBlockingQueue是一个有界队列,就是指队列有最大容量限制。LinkedBlockingQueue是无界队列,就是队列不限制容量。最后一个是SynchronousQueue,是一个同步队列,内部没有缓冲区。
第六个设置线程池工厂方法,线程工厂用来创建新线程,可以用来对线程的一些属性进行定制,例如线程的group、线程名、优先级等。一般使用默认工厂类即可。
第七个设置线程池满时的拒绝策略。如右下角所示有四种策略,abort策略在线程池满后,提交新任务时会抛出RejectedExecutionException,这个也是默认的拒绝策略。
Discard策略会在提交失败时对任务直接进行丢弃。CallerRuns策略会在提交失败时,由提交任务的线程直接执行提交的任务。DiscardOldest策略会丢弃最早提交的任务。
前面的5种线程池都是使用怎样的参数来创建的呢?
固定大小线程池创建时核心和最大线程数都设置成指定的线程数,这样线程池中就只会使用固定大小的线程数。队列使用无界队列linkedblockingqueue。
single线程池就是线程数设置为1的固定线程池。cached线程池的核心线程数设置为0,最大线程数是Integer.MAX_VALUE,主要是通过把缓冲队列设置成SynchronousQueue,这样只要没有空闲线程就会新建。scheduled线程池与前几种不同的是使用了DelayedWorkQueue,这是一种按延迟时间获取任务的优先级队列。
7、详解-线程池执行流程
我们向线程提交任务时可以使用execute和submit,区别就是submit可以返回一个future对象,通过Future对象可以了解任务执行情况,可以取消任务的执行,还可获取执行结果或执行异常。submit最终也是通过execute执行的。
线程池提交任务时的执行顺序如下:
向线程池提交任务时,会首先判断线程池中的线程数是否大于设置的核心线程数,如果不大于,就创建一个核心线程来执行任务。
- 如果大于核心线程数,就会判断缓冲队列是否满了,如果没有满,则放入队列,等待线程空闲时执行任务。
- 如果队列已经满了,则判断是否达到了线程池设置的最大线程数,如果没有达到,就创建新线程来执行任务。
- 如果已经达到了最大线程数,则执行指定的拒绝策略。这里需要注意队列的判断与最大线程数判断的顺序,不要搞反。
8、详解-juc工具类
前面基础知识部分已经提到过,juc是java提供的用于多线程处理的工具类库,其中的常用工具类的作用如下:
第一行的类都是基本数据类型的原子类:包括atomicboolean、atomiclong、atomicinteger类。
AtomicLong通过unsafe类实现,基于CAS。unsafe类是底层工具类,juc中很多类的底层都使用到了unsafe包中的功能。unsafe类提供了类似c的指针操作,提供CAS等功能。Unsafe类中的所有方法都是native修饰的;
另外longadder等四个类是jdk1.8中提供的更高效的操作类。LongAdder基于Cell实现,使用分段锁思想,是一种空间换时间的策略,更适合高并发场景;
LongAccumulator提供了比LongAdder更强大的功能,能够指定对数据的操作规则,例如可以把对数据的相加操作改成相乘操作。
第二行中的类提供了对对象的原子读写功能,后两个类AtomicStampedReference和AtomicMarkableReference是用来解决我们前面提到的ABA问题,分别基于时间戳和标记位来解决。
9、详解-juc2
这一页表格中,第一行的类主要是锁相关的类,例如我们前面介绍过的reentrant重入锁。
与ReentrantLock的独占锁不同,Semaphore是共享锁,允许多个线程共享资源,适用于限制使用共享资源线程数量的场景,例如100个车辆要使用20个停车位,那么最多允许20个车占用停车位。
StampedLock是1.8改进的读写锁,是使用一种CLH的乐观锁,能够有效防止写饥饿。所谓写饥饿就是在多线程读写时,读线程访问非常频繁,导致总是有读线程占用资源,写线程很难加上写锁。
第二行中主要是异步执行相关的类,这里可以重点了解jdk1.8中提供的CompletableFuture,可以支持流式调用,可以方便的进行多future的组合使用,例如可以同时执行两个异步任务,然后对执行结果进行合并处理。还可以很方便的设置完成时间。
另外一个是1.7中提供的ForkJoinPool,采用分治思想,将大任务分解成多个小任务处理,然后在合并处理结果。ForkJoinPool的特点是使用工作窃取算法,可以有效平衡多任务时间长短不一的场景。
10、详情-juc3
表格中第一行是常用的阻塞队列,刚才讲解线程池时已经简单介绍过了,这里在补充一点,LinkedBlockingDeque是双端队列,也就是可以分别从队头和队尾操作入队、出队。
而ArrayBlockingQueue单端队列,只能从队尾入队,队头出队。
第二行是控制多线程协作时使用的类。其中CountDownLatch实现计数器功能,可以用来控制等待多个线程执行任务后进行汇总。
CyclicBarrier可以让一组线程等待至某个状态之后,再全部同时执行,一般在测试时使用,可以让多线程更好的并发执行。
Semaphore前面已经介绍过,用来控制对共享资源的访问并发度。
最后一行是比较常用的两个集合类,可以了解一下CopyOnWriteArrayList,COW通过在写入数据时进行copy修改,然后在更新引用的方式,来消除并行读写中的锁使用,比较适合读多写少,数据量比较小,但是并发非常高的场景。
03面试考察点
1、要理解线程的同步与互斥的原理,包括临界资源、临界区的概念,知道重量级锁、轻量级锁、自旋锁、偏向锁、重入锁、读写锁的概念。
2、要掌握线程安全相关机制,例如 cas、synchronized、lock三种同步方式的实现原理、要明白threadlocal是每个线程独享的局部变量,了解threadlocal使用弱引用的ThreadLocalMap保存不同的threadlocal变量。
3、要了解JUC中的工具类的使用场景与主要的几种工具类的实现原理,例如reentrantlock,concurrenthashmap、longadder等实现方式
4、要熟悉线程池的原理、使用场景、常用配置,例如大量短期任务的场景适合使用cached线程池;系统资源比较紧张时,可以选择固定线程池。
另外注意慎用无界队列,可能会有oom的风险。
5、要深刻理解线程的同步与异步、阻塞与非阻塞,同步和异步的区别是任务是否是同一个线程执行,阻塞与非阻塞的区别是异步执行任务时,线程是不是会阻塞等待结果,还是会继续执行后续逻辑。
04面试加分项
掌握了上面这些内容,如果能做到这几点加分项,一定会给面试官留下更好的印象。
1、可以结合实际项目经验或者实际案例介绍原理,例如介绍线程池设置时,可以提到自己的项目中有一个需要高吞吐量的场景,使用了cached的线程池;
2、如果有过解决多线程问题的经验或者排查思路的话会获得面试加分;
3、能够熟悉常用的线程分析工具与方法,例如会用jstack分析线程的运行状态,查找锁对象持有状况等;
4、了解Java8对JUC工具类做了哪些增强,例如提供了longadder来替换atomiclong,更适合并发度比较高的场景;
5、可以了解Reactive异步编程思想,了解back pressure背压的概念与应用场景。
以上内容摘取自 《32个Java面试必考点》 第04讲:并发与多线程,点此学习更多