又来了,第三波女朋友面试4个月的面试题复盘,今天是关于并发的题目。
直接上题目。并发题目相对还是比较少的,还是老套路,高频的面试题已经标星,有面试需要的同学可以先点收藏起来。
并发问的问题不是特别多,但是并发的知识其实是比较难懂,也很难讲清楚。这些面试题中问的最多的问题就是线程池,其实线程池的原理和源码是相对比较容易理解的。
下面开始进入面试题的复盘。
线程池面试题
线程池的使用场景
线程池是一种池化复用线程的设计思想,帮助解决应用无限创建线程和销毁线程开销的问题。
在项目中,我们通常有几类场景会使用线程池来优化我们的程序性能:
- 1、多任务并行执行场景:针对C端请求的接口,如果接口涉及到多个可以并行执行的rpc或者db等io耗时的操作,我们会使用线程池来优化,加快接口响应速度。
下面的例子就是,比如在用户权益页面,使用线程池并行查询查询优惠券和积分等权益。
ExecutorService threadPool = Executors.newFixedThreadPool(5);
CompletableFuture<Void> userTask = CompletableFuture.runAsync(()->{
//查询用户信息
userService.getUser();
}, threadPool);
CompletableFuture<Void> couponTask = CompletableFuture.runAsync(()->{
//查询用户优惠券
couponService.getUserCoupon();
}, threadPool);
CompletableFuture<Void> scoreTask = CompletableFuture.runAsync(()->{
//查询用户优惠券
scoreService.getUserScore();
}, threadPool);
CompletableFuture<Void> allTask = CompletableFuture.allOf(userTask, couponTask, scoreTask);
allTask.get(1000, TimeUnit.MILLISECONDS);
- 2、批量异步处理任务场景:针对一些定时跑批量任务的后台job,我们可以通过线程池来提升处理任务的吞吐量。
比如给会员用户发送过期提醒的短信或者push通知。我们一般会分页查询到需要发过期提醒的会员用户,然后分批发送push通知。
List<List<String>> userIdGroup = new ArrayList<>();
for (List<String> userIdList : userIdGroup) {
//push
threadPool.execute(() -> {
//发送过期提醒
doPush(userIdList);
});
}
- 3、中间件的使用
在中间件的源码中,有很多的线程池的使用案例:
Tomcat就是使用线程池来处理客户端请求。
dubbo通过线程池处理rpc的请求。
rocketmq的消费者通过线程池来处理业务逻辑。
谈谈线程池的原理
线程池的工作原理,我们需要了解它的实现,我们可以从提交任务和执行任务来给面试官说明。
提交任务是execute和sumbit方法的流程:
执行任务主要是java.util.concurrent.ThreadPoolExecutor#runWorker
方法的流程:
通过不断的从阻塞队列获取任务,调用task.run();
方法执行任务。
什么时候回收线程:
线程数超过了最大线程数或者指定时间都没有获取到任务
核心线程数大于1以及任务队列是空的
boolean timed = allowCoreThreadTimeOut || wc > corePoolSize;
if ((wc > maximumPoolSize || (timed && timedOut))
&& (wc > 1 || workQueue.isEmpty())) {
if (compareAndDecrementWorkerCount(c))
return null;
continue;
}
try {
Runnable r = timed ?
workQueue.poll(keepAliveTime, TimeUnit.NANOSECONDS) :
workQueue.take();
if (r != null)
return r;
timedOut = true;
} catch (InterruptedException retry) {
timedOut = false;
}
线程池参数如何合理化设置
这个问题,我们需要根据实际的业务场景。
- 如果是io密集型
核心线程池设置成 2 * cpu
- 如果是cpu密集型
可以把核心线程数设置为核心数+1。
为什么要加一呢?
《Java并发编程实战》一书中给出的原因是:即使当计算(CPU)密集型的线程偶尔由于页缺失故障或者其他原因而暂停时,这个“额外”的线程也能确保 CPU 的时钟周期不会被浪费。
上面都是理论上的设置,最后我们需要和面试官说明,我们可以将线程池的参数进行动态配置化,根据压测情况和实际线上的流量进行动态调整。
线程同步工具面试题
ThreadLocal的使用场景和原理
threadLocal主要就是用于隐式传参
ThreadLocal其实可以理解成一个工具类,它负责操作Thread对象的ThreadLocalMap属性的,更新和查询指定线程的参数。
我们可以和面试官深入谈一下对Thread
、ThreadLocalMap
、ThreadLocal
三者的关系,那么面试官就可以明白了你对ThreadLocal真正的理解了。
1、Thread持有ThreadLocalMap的引用,他们是1对1关系。
2、Entry是ThreadLocalMap的内部类,并且ThreadLocalMap持有Entry类型的数组。也就是一个ThreadLocalMap对应多个Entry。
3、ThreadLocal和ThreadLocalMap的关系是最难描述的,因为
ThreadLocalMap是ThreadLocal的子类,而ThreadLocalMap中存储的key类型是ThreadLocal。并且ThreadLocal是弱类型的。
借用网上的一张图:
volatile关键字的作用和原理
volatile关键字是解决并发问题的轻量级手段,它能够解决可见性和有序性。
为什么有并发问题呢?其实是因为Java内存模型决定的,Java中每个线程都有工作内存的概念,如果多个cpu都同时操作同一个共享变量可能就会出现问题了。
volatile在Jvm层面使用的内存屏障技术来解决并发问题的。
针对volatile关键字的读操作:
基于c++的volatile关键字
,每次从主存中读取。
C++的volatile禁止对这个变量相关的代码进行乱序优化(重排序),也就具有内存屏障的作用了。
针对volatile关键字的写操作:
基于c++的volatile关键字和 lock addl指令的内存屏障
,每次将新值刷新到主存,同时其他cpu缓存的值失效。
synchronized关键字的使用场景和原理
synchronized
可以实现多线程同步等待,将多线程并行执行改成串形执行,解决多线程并发安全问题。
它能够解决可见性,有序性,原子性。
原理层面,我们可以从jdk1.6的升级前后来说明:
在JDK1.6之前,synchronized属于重量级锁,效率低下,因为Monitor是依赖于底层的操作系统的互斥原语mutex来实现,这会导致线程在“用户态和内核态”两个态之间来回切换,对性能有较大影响。
庆幸的是在JDK1.6之后Java官方对从JVM层面对synchronized较大优化,所以现在的synchronized锁效率也优化得很不错了,JDK1.6之后,为了减少获得锁和释放锁所带来的性能消耗,为了减少这种重量级锁的使用,引入了轻量级锁和偏向锁,这两个锁可以不依赖Monitor的操作。
偏向锁是发生只有一个线程抢占锁的阶段,只需要通过cas设置线程id就可以完成加锁.
轻量级锁是通过自适应自旋锁来实现的,在自旋一定次数如果加锁成功,就是轻量级锁。
如果轻量级锁加锁没有成功,则会转换成重量级锁。重量级锁加锁成功后执行内存屏障,保证可见性和有序性,其他未加锁成功的线程会进入等待队列同时进入阻塞队列。
juc并发工具包
讲一讲aps的原理
aqs(AbstractQueuedSynchronizer)是一个实现线程同步的底层工具,它是提供两种功能:
独占锁,同时一个线程获取锁
共享锁,同时多个线程获取锁
可以和面试官说,aqs定义了实现独占锁和共享锁的接口,如果需要实现自定义的锁,只需要继承aqs并实现他的的方法就可以。
aqs内部由一个fifo双向链表和一个整形状态字段来实现控制加解锁和加锁线程排队的能力。
哈哈,并发专题很难讲清楚,面试的时候和面试官贵在把自己理解的点和面试官讲透。