Java面试系列之并发编程专题-Java线程池灵魂拷问
作者:
修罗debug
版权声明:本文为博主原创文章,遵循 CC 4.0 by-sa 版权协议,转载请附上原文出处链接和本声明。
金三银四跳槽季即将来临,想必有些猿友已经蠢蠢欲动在做相关的准备了!在接下来的日子里,笔者将坚持写作、分享Java工程师在面试求职期间的方方面面,包括简历制作、面试场景复现、面试题解答、谈薪技巧 以及 项目的实战!今天我们来聊聊Java中线程池相关的知识!
在上一篇文章中我们模拟了一个面试场景,灵魂式拷问了Java中Synchronized相关的知识,可以点击链接查看详情:Java面试系列之并发编程专题-Synchronized灵魂拷问
下面我们开撸!值得一提的是,以下内容来自程序员实战基地fightjava.com一位网友最近的面试场景,笔者尝试着将其复现,其中有些是笔者自行整理补充的,若有不对的地方还请多多指正!!
1. 面试官:Java线程池底层是怎么实现的?大概说下
(1)画外音:TND的,一上来就问底层实现原理……真应了那句“面试造火箭,工作拧螺丝”,没办法只能硬着头皮上,既然说“大概说下”,那大概能回答 “工作线程队列”和“任务队列”应该就阔以了!
(2)回答:在Java中,所谓的线程池中的“线程”,其实是被抽象为一个静态内部类Worker,即“工作线程”,它基于AQS(抽象队列同步器)实现、存放在线程池一个成员变量中,其名为:“工作线程队列” HashSet<Worker> workers,而将等待被执行的任务存放在成员变量 “任务队列” workQueue(BlockingQueue<Runnable> workQueue)中;
这样一来,整个线程池实现的基本思想大概就是:从任务队列workQueue中不断取出需要执行的任务,放在工作线程队列Workers中进行处理;
2.面试官:嗯,不错,说一说创建线程池的几个核心构造参数?
(1)画外音:这个倒不难,撸过ThreadPoolExecutor的估计都晓得!
(2)回答: Java中创建线程池其实非常灵活,我们可以通过配置不同的参数,创建出行为不同的线程池,这几个参数包括:
A. corePoolSize:线程池的核心线程数;
B. maximumPoolSize:线程池允许的最大线程数;
C. keepAliveTime:超过核心线程数时闲置线程的存活时间;
D. workQueue:任务执行前保存任务的队列,保存着execute方法待提交的Runnable任务;
附上代码:
public ThreadPoolExecutor(int corePoolSize, int maximumPoolSize,
long keepAliveTime,TimeUnit unit,
BlockingQueue<Runnable> workQueue,
RejectedExecutionHandler handler);
3.面试官:那线程池中的线程是怎么创建的?是一开始就随着线程池的启动就创建好的吗?
(1)画外音:这还是挺考验底层源码阅读能力的,看过ThreadPoolExecutor的创建、executor下的执行方法API 即execute()方法的应该可以回答上!
(2)回答:不是;线程池在创建后执行初始化策略时默认是不启动工作线程Worker的,而是等待有请求到来时才启动,每当我们调用execute()方法添加一个任务时,线程池会做如下判断:
A.如果正在运行的线程数量小于corePoolSize,那么马上创建线程运行这个任务;如果正在运行的线程数量大于或等于corePoolSize,那么将这个任务放入队列workQueue;
如果这时候队列满了,而且正在运行的线程数量小于 maximumPoolSize,那么还是要创建非核心线程立刻运行这个任务;
如果队列满了,而且正在运行的线程数量大于或等于maximumPoolSize,那么线程池会抛出一个拒绝执行的异常RejectExecutionException;只有当一个线程完成任务时,它会从队列中取下一个任务来执行;
而当一个线程无事可做(也就是空闲) 且 超过一定的时间(keepAliveTime)时,线程池会判断如果当前运行的线程数大于corePoolSize,那么这个线程就被停掉(销毁线程回收资源的过程),所以当线程池的所有任务完成后,它最终会收缩到corePoolSize的大小;
4.面试官:你刚刚提到可以通过配置不同的参数创建出不同的线程池,那么Java中默认实现好的线程池又有哪些呢?请比较它们的异同?
(1)画外音:这其实就是考察Executors下几种常见的线程池了,下面的回答有点官方哈!
(2)回答:
A.SingleThreadExecutor线程池:这种线程池只有一个核心线程在工作,也就是相当于单线程串行执行所有任务;如果这个唯一的线程因为异常结束,那么会有一个新的线程来替代它,此线程池保证所有任务的执行顺序按照任务的提交顺序执行;其中涉及到的参数含义为:
Executors.newSingleThreadExecutor();
corePoolSize:1,只有一个核心线程在工作;
maximumPoolSize:1;
keepAliveTime:0L;
workQueue:newLinkedBlockingQueue<Runnable>(),其缓冲队列是无界的;
B.FixedThreadPool线程池:这种线程池是固定大小的线程池,只有核心线程;每次提交一个任务就创建一个线程,直到线程达到线程池的最大大小;线程池的大小一旦达到最大值就会保持不变,如果某个线程因为执行异常而结束,那么线程池会补充一个新线程;FixedThreadPool多数是针对一些很稳定很固定的正规并发线程;
Executors.newFixedThreadPool(N); N是根据实际情况自定义设置的线程数
corePoolSize:nThreads
maximumPoolSize:nThreads
keepAliveTime:0L
workQueue:newLinkedBlockingQueue<Runnable>(),其缓冲队列是无界的。
C.CachedThreadPool线程池:这种线程池是无界线程池,如果线程池的大小超过了处理任务所需要的线程,那么就会回收部分空闲(60秒不执行任务)线程,当任务数增加时,此线程池又可以智能的添加新线程来处理任务;
线程池的大小完全依赖于操作系统(或者说JVM)能够创建的最大线程大小,SynchronousQueue是一个是缓冲区为1的阻塞队列;
缓存型池子通常用于执行一些生存期很短的异步型任务,因此在一些面向连接的daemon型Server中用得不多,但对于生存期短的异步任务,它是Executor的首选;
Executors.newCachedThreadPool();
corePoolSize:0
maximumPoolSize:Integer.MAX_VALUE
keepAliveTime:60L
workQueue:newSynchronousQueue<Runnable>(), 一个缓冲区为1的阻塞队列。
D.ScheduledThreadPool线程池:一种核心线程数固定、大小无限制的线程池;此线程池适合 定时以及周期性执行任务需求的场景(定时任务);如果闲置,非核心线程池会在DEFAULT_KEEPALIVEMILLIS时间内回收;
Executors.newScheduledThreadPool(10);
corePoolSize:corePoolSize
maximumPoolSize:Integer.MAX_VALUE
keepAliveTime:DEFAULT_KEEPALIVE_MILLIS
workQueue:newDelayedWorkQueue()
5.面试官:如何在Java线程池中提交线程?
(1)画外音:这个只要能回答上execute()、submit()就阔以了
(2)回答:线程池最常用的提交任务的方法有两种:
A.execute():ExecutorService.execute方法接收一个Runable实例,它用来执行一个任务:
B.submit():ExecutorService.submit()方法返回的是Future对象;可以用isDone()来查询Future是否已经完成,当任务完成时,可以通过调用get()方法来获取结果;也可以不用isDone()进行检查就直接调用get(),在这种情况下,get()将阻塞,直至结果准备就绪
友情提示:关于Future/FutureTask的相关知识点可以查看debug以前写过的一遍文章:
https://www.fightjava.com/web/index/blog/article/87
6.面试官:什么是Java的内存模型,Java中各个线程是怎么彼此看到对方的变量的?
(1)画外音:这有点触及知识盲区了,莫非是想聊主存和线程工作内存……
(2)回答:Java的内存模型定义了程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出这样的底层细节。此处的变量包括实例字段、静态字段和构成数组对象的元素,但是不包括局部变量和方法参数,因为这些是线程私有的,不会被共享,所以不存在竞争问题 (并发安全的源头)
那么Java中各个线程是怎么彼此看到对方的变量的呢?:Java中定义了主内存与工作内存的概念:所有的变量都存储在主内存,每条线程还有自己的工作内存,保存了被该线程使用到的变量的主内存副本拷贝;
线程对变量的所有操作(读取、赋值)都必须在工作内存中进行,不能直接读写主内存的变量,不同的线程之间也无法直接访问对方工作内存的变量,线程间变量值的传递需要通过主内存。
7.面试官: 请谈谈volatile有什么特点,为什么它能保证变量对所有线程的可见性?
(1)画外音:这问的就有点深度了~
(2)回答:关键字volatile是Java虚拟机提供的最轻量级的同步机制,当一个变量被定义成volatile之后,具备两种特性:
A.保证此变量对所有线程的可见性:当一条线程修改了这个变量的值,新值对于其他线程是可以立即得知的,而普通变量做不到这一点。
B.禁止指令重排序优化:普通变量仅仅能保证在该方法执行过程中,得到正确结果,但是不保证程序代码的执行顺序;
Java的内存模型定义了8种内存间操作:lock和unlock把一个变量标识为一条线程独占的状态,把一个处于锁定状态的变量释放出来,释放之后的变量才能被其他线程锁定;
read和write把一个变量值从主内存传输到线程的工作内存,以便load把store操作从工作内存得到的变量的值,放入主内存的变量中;
load和store把read操作从主内存得到的变量值放入工作内存的变量副本中,把工作内存的变量值传送到主内存,以便write;
use和assgin把工作内存变量值传递给执行引擎,将执行引擎值传递给工作内存变量值;
volatile的实现基于这8种内存间操作,保证了一个线程对某个volatile变量的修改,一定会被另一个线程看见,即保证了可见性。
(吐槽:回答得太官方了,我觉得能回答上8种内存间的操作以及操作名就不错了!)
8.面试官:既然volatile能够保证线程间变量的可见性,是不是就意味着基于volatile变量的运算就是并发安全的?
(1)画外音:当然不是啦……
(2)回答:不是,基于volatile变量的运算在并发下不一定是安全的;volatile修饰的变量在各个线程的工作内存,不存在一致性问题(各个线程的工作内存中volatile变量,每次使用前都要刷新到主内存);
但是Java里面的运算并非原子操作,导致volatile变量的运算在并发下一样是不安全的(其实就是时间先后问题:ThreadA刷新到主内存之前,ThreadB已经读取了主内存变量最新值,导致不一致)
9.面试官:请简单对比下volatile对比Synchronized的异同?
(1)画外音:这没办法,只能正儿八经的记了……
(2)回答:Synchronized既能保证可见性,又能保证原子性,而volatile只能
保证可见性,无法保证原子性;
(嘴贱多说了ThreadLocal)ThreadLocal和Synchonized都可用于解决多线程并发访问共享资源时产生冲突;
但是ThreadLocal与Synchronized有本质的区别;Synchronized用于实现同步机制,是利用锁的机制使变量或代码块在某一时该只能被一个线程访问,是一种“以时间换空间”的方式;而ThreadLocal为每一个线程都提供了变量的副本,使得每个线程在某一时间访问到的并不是同一个对象(除了对变量的共享),是一种“以空间换时间”的方式。
10.面试官:既然谈到了ThreadLocal,那你说一说它是怎么解决并发安全的?
(1)画外音:若能提到“线程私有内存”、“变量副本”那基本上就阔以了
(2)回答:它是Java提供的一种保存线程私有信息的机制,因为其在整个线程生命周期内有效,所以可以方便地在一个线程关联的不同业务模块之间传递信息,比如事务ID、Cookie等上下文相关信息。
ThreadLocal为每一个线程维护变量的副本,把共享数据的可见范围限制在同一个线程之内,其实现原理是,在ThreadLocal类中有一个Map,用于存储每一个线程的变量的副本。
11.面试官:很多人都说要慎用ThreadLocal,谈谈你的理解,使用ThreadLocal需要注意些什么?
(1)画外音:应该是想说remove操作吧.
(2)回答:使用ThreadLocal要注意remove;因为ThreadLocal的实现是其实基于一个ThreadLocalMap,在ThreadLocalMap中,它的key是一个弱引用;
而通常弱引用都会和引用队列配合清理机制使用,但是ThreadLocal是个例外,它并没有这么做;这意味着,废弃项目的回收依赖于显式地触发,否则就要等待线程结束,进而回收相应ThreadLocalMap,这就是很多OOM的来源;
所以通常都会建议,应用一定要自己负责remove,并且尽量不要和线程池一起使用!
面试官:好,那咱们今天就面到这里吧!!!
总结:
我是debug,一个相信技术改变生活、技术成就梦想 的攻城狮;关注下方debug的技术公众号,里面有更多精彩文章和技术干货等着你哦!并动动手指收藏、点赞、以及转发哦!!!