前言
个人珍藏的80道Java多线程/并发经典面试题,现在给出11-20的答案解析哈,并且上传github哈~
https://github.com/whx123/JavaHome
11、为什么要用线程池?Java的线程池内部机制,参数作用,几种工作阻塞队列,线程池类型以及使用场景
回答这些点:
为什么要用线程池?
Java的线程池原理
线程池核心参数
几种工作阻塞队列
线程池使用不当的问题
线程池类型以及使用场景
为什么要用线程池?
线程池:一个管理线程的池子。
管理线程,避免增加创建线程和销毁线程的资源损耗。
提高响应速度。
重复利用。
Java的线程池执行原理
为了形象描述线程池执行,打个比喻:
核心线程比作公司正式员工
非核心线程比作外包员工
阻塞队列比作需求池
提交任务比作提需求
线程池核心参数
need-to-insert-img
public ThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue<Runnable> workQueue, ThreadFactory threadFactory, RejectedExecutionHandler handler)
corePoolSize: 线程池核心线程数最大值
maximumPoolSize: 线程池最大线程数大小
keepAliveTime: 线程池中非核心线程空闲的存活时间大小
unit: 线程空闲存活时间单位
workQueue: 存放任务的阻塞队列
threadFactory: 用于设置创建线程的工厂,可以给创建的线程设置有意义的名字,可方便排查问题。
handler:线城池的饱和策略事件,主要有四种类型拒绝策略。
四种拒绝策略
AbortPolicy(抛出一个异常,默认的)
DiscardPolicy(直接丢弃任务)
DiscardOldestPolicy(丢弃队列里最老的任务,将当前这个任务继续提交给线程池)
CallerRunsPolicy(交给线程池调用所在的线程进行处理)
几种工作阻塞队列
ArrayBlockingQueue(用数组实现的有界阻塞队列,按FIFO排序量)
LinkedBlockingQueue(基于链表结构的阻塞队列,按FIFO排序任务,容量可以选择进行设置,不设置的话,将是一个无边界的阻塞队列)
DelayQueue(一个任务定时周期的延迟执行的队列)
PriorityBlockingQueue(具有优先级的无界阻塞队列)
SynchronousQueue(一个不存储元素的阻塞队列,每个插入操作必须等到另一个线程调用移除操作,否则插入操作一直处于阻塞状态)
线程池使用不当的问题
线程池适用不当可能导致内存飙升问题哦
有兴趣可以看我这篇文章哈:源码角度分析-newFixedThreadPool线程池导致的内存飙升问题
线程池类型以及使用场景
newFixedThreadPool
适用于处理CPU密集型的任务,确保CPU在长期被工作线程使用的情况下,尽可能的少的分配线程,即适用执行长期的任务。
newCachedThreadPool
用于并发执行大量短期的小任务。
newSingleThreadExecutor
适用于串行执行任务的场景,一个任务一个任务地执行。
newScheduledThreadPool
周期性执行任务的场景,需要限制线程数量的场景
newWorkStealingPool
建一个含有足够多线程的线程池,来维持相应的并行级别,它会通过工作窃取的方式,使得多核的 CPU 不会闲置,总会有活着的线程让 CPU 去运行,本质上就是一个 ForkJoinPool。)
有兴趣可以看我这篇文章哈:面试必备:Java线程池解析
12、谈谈volatile关键字的理解
volatile是面试官非常喜欢问的一个问题,可以回答以下这几点:
vlatile变量的作用
现代计算机的内存模型(嗅探技术,MESI协议,总线)
Java内存模型(JMM)
什么是可见性?
指令重排序
volatile的内存语义
as-if-serial
Happens-before
volatile可以解决原子性嘛?为什么?
volatile底层原理,如何保证可见性和禁止指令重排(内存屏障)
vlatile变量的作用?
保证变量对所有线程可见性
禁止指令重排
现代计算机的内存模型
其中高速缓存包括L1,L2,L3缓存~
缓存一致性协议,可以了解MESI协议
总线(Bus)是计算机各种功能部件之间传送信息的公共通信干线,CPU和其他功能部件是通过总线通信的。
处理器使用嗅探技术保证它的内部缓存、系统内存和其他处理器的缓存数据在总线上保持一致。
Java内存模型(JMM)
什么是可见性?
可见性就是当一个线程 修改一个共享变量时,另外一个线程能读到这个修改的值。
指令重排序
指令重排是指在程序执行过程中,为了提高性能, 编译器和CPU可能会对指令进行重新排序。
volatile的内存语义
当写一个 volatile 变量时,JMM 会把该线程对应的本地内存中的共享变量值刷新到主内存。
当读一个 volatile 变量时,JMM 会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量。
as-if-serial
如果在本线程内观察,所有的操作都是有序的;即不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不会被改变。
need-to-insert-img
double pi = 3.14; //Adouble r = 1.0; //Bdouble area = pi * r * r; //C
步骤C依赖于步骤A和B,因为指令重排的存在,程序执行顺讯可能是A->B->C,也可能是B->A->C,但是C不能在A或者B前面执行,这将违反as-if-serial语义。
Happens-before
Java语言中,有一个先行发生原则(happens-before):
程序次序规则:在一个线程内,按照控制流顺序,书写在前面的操作先行发生于书写在后面的操作。
管程锁定规则:一个unLock操作先行发生于后面对同一个锁额lock操作
volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作
线程启动规则:Thread对象的start()方法先行发生于此线程的每个一个动作
线程终止规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行
线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生
对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始
传递性:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C
volatile可以解决原子性嘛?为什么?
不可以,可以直接举i++那个例子,原子性需要synchronzied或者lock保证
need-to-insert-img
public class Test { public volatile int race = 0; public void increase() { race++; } public static void main(String[] args) { final Test test = new Test(); for(int i=0;i<10;i++){ new Thread(){ public void run() { for(int j=0;j<100;j++) test.increase(); }; }.start(); } //等待所有累加线程结束 while(Thread.activeCount()>1) Thread.yield(); System.out.println(test.race); }}
volatile底层原理,如何保证可见性和禁止指令重排(内存屏障)
volatile 修饰的变量,转成汇编代码,会发现多出一个lock前缀指令。lock指令相当于一个内存屏障,它保证以下这几点:
1.重排序时不能把后面的指令重排序到内存屏障之前的位置
2.将本处理器的缓存写入内存
3.如果是写入动作,会导致其他处理器中对应的缓存无效。
2、3点保证可见性,第1点禁止指令重排~
有兴趣的朋友可以看我这篇文章哈:Java程序员面试必备:Volatile全方位解析
13、AQS组件,实现原理
AQS,即AbstractQueuedSynchronizer,是构建锁或者其他同步组件的基础框架,它使用了一个int成员变量表示同步状态,通过内置的FIFO队列来完成资源获取线程的排队工作。可以回答以下这几个关键点哈:
state 状态的维护。
CLH队列
ConditionObject通知
模板方法设计模式
独占与共享模式。
自定义同步器。
AQS全家桶的一些延伸,如:ReentrantLock等。
state 状态的维护
state,int变量,锁的状态,用volatile修饰,保证多线程中的可见性。
getState()和setState()方法采用final修饰,限制AQS的子类重写它们两。
compareAndSetState()方法采用乐观锁思想的CAS算法操作确保线程安全,保证状态 设置的原子性。
对CAS有兴趣的朋友,可以看下我这篇文章哈~ CAS乐观锁解决并发问题的一次实践
CLH队列
CLH(Craig, Landin, and Hagersten locks) 同步队列 是一个FIFO双向队列,其内部通过节点head和tail记录队首和队尾元素,队列元素的类型为Node。AQS依赖它来完成同步状态state的管理,当前线程如果获取同步状态失败时,AQS则会将当前线程已经等待状态等信息构造成一个节点(Node)并将其加入到CLH同步队列,同时会阻塞当前线程,当同步状态释放时,会把首节点唤醒(公平锁),使其再次尝试获取同步状态。
ConditionObject通知
我们都知道,synchronized控制同步的时候,可以配合Object的wait()、notify(),notifyAll() 系列方法可以实现等待/通知模式。而Lock呢?它提供了条件Condition接口,配合await(),signal(),signalAll() 等方法也可以实现等待/通知机制。ConditionObject实现了Condition接口,给AQS提供条件变量的支持 。
ConditionObject队列与CLH队列的爱恨情仇:
调用了await()方法的线程,会被加入到conditionObject等待队列中,并且唤醒CLH队列中head节点的下一个节点。
线程在某个ConditionObject对象上调用了singnal()方法后,等待队列中的firstWaiter会被加入到AQS的CLH队列中,等待被唤醒。
当线程调用unLock()方法释放锁时,CLH队列中的head节点的下一个节点(在本例中是firtWaiter),会被唤醒。
模板方法设计模式
什么是模板设计模式?
在一个方法中定义一个算法的骨架,而将一些步骤延迟到子类中。模板方法使得子类可以在不改变算法结构的情况下,重新定义算法中的某些步骤。
AQS的典型设计模式就是模板方法设计模式啦。AQS全家桶(ReentrantLock,Semaphore)的衍生实现,就体现出这个设计模式。如AQS提供tryAcquire,tryAcquireShared等模板方法,给子类实现自定义的同步器。
独占与共享模式
独占式: 同一时刻仅有一个线程持有同步状态,如ReentrantLock。又可分为公平锁和非公平锁。
共享模式:多个线程可同时执行,如Semaphore/CountDownLatch等都是共享式的产物。
自定义同步器
你要实现自定义锁的话,首先需要确定你要实现的是独占锁还是共享锁,定义原子变量state的含义,再定义一个内部类去继承AQS,重写对应的模板方法即可啦
AQS全家桶的一些延伸。
Semaphore,CountDownLatch,ReentrantLock
可以看下之前我这篇文章哈,AQS解析与实战
15、 说一下 Runnable和 Callable有什么区别?
Callable接口方法是call(),Runnable的方法是run();
Callable接口call方法有返回值,支持泛型,Runnable接口run方法无返回值。
Callable接口call()方法允许抛出异常;而Runnable接口run()方法不能继续上抛异常;
need-to-insert-img
@FunctionalInterfacepublic interface Callable<V> { /** * 支持泛型V,有返回值,允许抛出异常 */ V call() throws Exception;}@FunctionalInterfacepublic interface Runnable { /** * 没有返回值,不能继续上抛异常 */ public abstract void run();}
看下demo代码吧,这样应该好理解一点哈~
need-to-insert-img
/* * @Author 捡田螺的小男孩 * @date 2020-08-18 */public class CallableRunnableTest { public static void main(String[] args) { ExecutorService executorService = Executors.newFixedThreadPool(5); Callable <String> callable =new Callable<String>() { @Override public String call() throws Exception { return "你好,callable"; } }; //支持泛型 Future<String> futureCallable = executorService.submit(callable); try { System.out.println(futureCallable.get()); } catch (InterruptedException e) { e.printStackTrace(); } catch (ExecutionException e) { e.printStackTrace(); } Runnable runnable = new Runnable() { @Override public void run() { System.out.println("你好呀,runnable"); } }; Future<?> futureRunnable = executorService.submit(runnable); try { System.out.println(futureRunnable.get()); } catch (InterruptedException e) { e.printStackTrace(); } catch (ExecutionException e) { e.printStackTrace(); } executorService.shutdown(); }}
运行结果:
need-to-insert-img
你好,callable你好呀,runnablenull
16、wait(),notify()和suspend(),resume()之间的区别
wait() 使得线程进入阻塞等待状态,并且释放锁
notify()唤醒一个处于等待状态的线程,它一般跟wait()方法配套使用。
suspend()使得线程进入阻塞状态,并且不会自动恢复,必须对应的resume() 被调用,才能使得线程重新进入可执行状态。suspend()方法很容易引起死锁问题。
resume()方法跟suspend()方法配套使用。
suspend()不建议使用,suspend()方法在调用后,线程不会释放已经占有的资 源(比如锁),而是占有着资源进入睡眠状态,这样容易引发死锁问题。
18、线程池如何调优,最大数目如何确认?
在《Java Concurrency in Practice》一书中,有一个评估线程池线程大小的公式
Nthreads=NcpuUcpu(1+w/c)
Ncpu = CPU总核数
Ucpu =cpu使用率,0~1
W/C=等待时间与计算时间的比率
假设cpu 100%运转,则公式为
need-to-insert-img
Nthreads=Ncpu*(1+w/c)
估算的话,酱紫:
如果是IO密集型应用(如数据库数据交互、文件上传下载、网络数据传输等等),IO操作一般比较耗时,等待时间与计算时间的比率(w/c)会大于1,所以最佳线程数估计就是 Nthreads=Ncpu*(1+1)= 2Ncpu 。
如果是CPU密集型应用(如算法比较复杂的程序),最理想的情况,没有等待,w=0,Nthreads=Ncpu。又对于计算密集型的任务,在拥有N个处理器的系统上,当线程池的大小为N+1时,通常能实现最优的效率。所以 Nthreads = Ncpu+1
有具体指参考呢?举个例子
比如平均每个线程CPU运行时间为0.5s,而线程等待时间(非CPU运行时间,比如IO)为1.5s,CPU核心数为8,那么根据上面这个公式估算得到:线程池大小=(1+1.5/05)*8 =32。
参考了网上这篇文章,写得很棒,有兴趣的朋友可以去看一下哈:
19、 假设有T1、T2、T3三个线程,你怎样保证T2在T1执行完后执行,T3在T2执行完后执行?
可以使用join方法解决这个问题。比如在线程A中,调用线程B的join方法表示的意思就是**:A等待B线程执行完毕后(释放CPU执行权),在继续执行。**
代码如下:
need-to-insert-img
public class ThreadTest { public static void main(String[] args) { Thread spring = new Thread(new SeasonThreadTask("春天")); Thread summer = new Thread(new SeasonThreadTask("夏天")); Thread autumn = new Thread(new SeasonThreadTask("秋天")); try { //春天线程先启动 spring.start(); //主线程等待线程spring执行完,再往下执行 spring.join(); //夏天线程再启动 summer.start(); //主线程等待线程summer执行完,再往下执行 summer.join(); //秋天线程最后启动 autumn.start(); //主线程等待线程autumn执行完,再往下执行 autumn.join(); } catch (InterruptedException e) { e.printStackTrace(); } }}class SeasonThreadTask implements Runnable{ private String name; public SeasonThreadTask(String name){ this.name = name; } @Override public void run() { for (int i = 1; i <4; i++) { System.out.println(this.name + "来了: " + i + "次"); try { Thread.sleep(100); } catch (InterruptedException e) { e.printStackTrace(); } } }}
运行结果:
need-to-insert-img
春天来了: 1次春天来了: 2次春天来了: 3次夏天来了: 1次夏天来了: 2次夏天来了: 3次秋天来了: 1次秋天来了: 2次秋天来了: 3次
41. 公平锁非公平锁
这个是在ReentrankLock中实现的,synchronized没有,是用一个队列实现的,在公平锁好理解,就是先进这个队列的,也先出队列获得资源,而非公平锁的话,则是还没有进队列之前可以与队列中的线程竞争尝试获得锁,如果获取失败,则进队列,此时也是要乖乖等前面出队才行
44. 偏向锁轻量级锁重量级锁
在jdk1.6中做了第synchronized的优化,偏向锁指的是当前只有这个线程获得,没有发生争抢,此时将方法头的markword设置成0,然后每次过来都cas一下就好,不用重复的获取锁
轻量级锁:在偏向锁的基础上,有线程来争抢,此时膨胀为轻量级锁,多个线程获取锁时用cas自旋获取,而不是阻塞状态
重量级锁:轻量级锁自旋一定次数后,膨胀为重量级锁,其他线程阻塞,当获取锁线程释放锁后唤醒其他线程。(线程阻塞和唤醒比上下文切换的时间影响大的多,涉及到用户态和内核态的切换)
自旋锁:在没有获取锁的时候,不挂起而是不断轮询锁的状态
43.独享锁共享锁
共享锁可以由多个线程获取使用,而独享锁只能由一个线程获取。 对ReentrantReadWriteLock其读锁是共享锁,其写锁是独占锁 读锁的共享锁可保证并发读是非常高效的,读写,写读,写写的过程是互斥的。其中获得写锁的线程还能同时获得读锁,然后通过释放写锁来降级。读锁则不能升级