目录
- 1 面试官为什么都喜欢问并发编程的问题?
- 2 synchronized关键字的底层原理是什么?
- 3 能聊聊你对CAS的理解以及其底层实现原理可以吗?
- 4 ConcurrentHashMap实现线程安全的底层原理到底是什么?
- 5 JDK中的AQS理解吗?AQS的实现原理是什么?
- 6 线程池的底层工作原理可以吗?
- 7 线程池的核心配置参数都是干什么的?平时我们应该怎么用?
- 8 如果在线程池中使用无界阻塞队列会发生什么问题?
- 9 你知道如果线程池的队列满了之后,会发生什么事情吗?
- 10 如果线上机器突然宕机,线程池的阻塞队列中的请求怎么办?
- 11 谈谈你对Java内存模型的理解可以吗?
- 12 你知道Java内存模型中的原子性、有序性、可见性是什么吗?
- 13 能聊聊volatile关键字的原理吗?
- 14 你知道指令重排以及happens-before原则是什么吗?
- 15 volatile底层是如何基于内存屏障保证可见性和有序性的?
1 面试官为什么都喜欢问并发编程的问题?
synchronized实现原理、CAS无锁化的原理、AQS是什么、Lock锁、ConcurrentHashMap的分段加锁的原理、线程池的原理、java内存模型、volatile说一下吗、对java并发包有什么了解?一连串的问题
写一些java web系统,运用一些框架和一些第三方技术,写一些类似于crud的业务逻辑,把各种技术整合一下,写一些crud而已,没什么技术含量。很多人可能写好几年的代码,都不会用到多少java并发包下面的东西
如果说你要面试一些稍微好一点的公司,技术稍微好一点,你只要去做一个技术含量稍微高一点的系统,并发包下面的东西还是很容易会用到的。尤其是BAT,中大厂,有一定规模的公司,做出来的系统还是有一定的技术含量的
2 synchronized关键字的底层原理是什么?
之前有一些同学去一线互联网大厂里去面试,聊并发编程这块的内容,问的比较深一点,就说synchronized的底层原理是什么呢?他当时就答不出来了
如果我要是对synchronized往深了讲,他是可以很深很深的,内存屏障的一些东西,cpu之类的硬件级别的原理,原子性、可见性、有序性,指令重排,JDK对他实现了一些优化,偏向锁,几个小时
面试突击第三季,快速过一下常见的高频面试题而已
其实synchronized底层的原理,是跟jvm指令和monitor有关系的
你如果用到了synchronized关键字,在底层编译后的jvm指令中,会有monitorenter和monitorexit两个指令
monitorenter
// 代码对应的指令
monitorexit
那么monitorenter指令执行的时候会干什么呢?
每个对象都有一个关联的monitor,比如一个对象实例就有一个monitor,一个类的Class对象也有一个monitor,如果要对这个对象加锁,那么必须获取这个对象关联的monitor的lock锁
他里面的原理和思路大概是这样的,monitor里面有一个计数器,从0开始的。如果一个线程要获取monitor的锁,就看看他的计数器是不是0,如果是0的话,那么说明没人获取锁,他就可以获取锁了,然后对计数器加1
这个monitor的锁是支持重入加锁的,什么意思呢,好比下面的代码片段
// 线程1
synchronized(myObject) { -> 类的class对象来走的
// 一大堆的代码
synchronized(myObject) {
// 一大堆的代码
}
}
加锁,一般来说都是必须对一个对象进行加锁
如果一个线程第一次synchronized那里,获取到了myObject对象的monitor的锁,计数器加1,然后第二次synchronized那里,会再次获取myObject对象的monitor的锁,这个就是重入加锁了,然后计数器会再次加1,变成2
这个时候,其他的线程在第一次synchronized那里,会发现说myObject对象的monitor锁的计数器是大于0的,意味着被别人加锁了,然后此时线程就会进入block阻塞状态,什么都干不了,就是等着获取锁
接着如果出了synchronized修饰的代码片段的范围,就会有一个monitorexit的指令,在底层。此时获取锁的线程就会对那个对象的monitor的计数器减1,如果有多次重入加锁就会对应多次减1,直到最后,计数器是0
然后后面block住阻塞的线程,会再次尝试获取锁,但是只有一个线程可以获取到锁
3 能聊聊你对CAS的理解以及其底层实现原理可以吗?
取值,询问,修改
多个线程他们可能要访问同一个数据
HashMap map = new HashMap();
此时有多个线程要同时读写类似上面的这种内存里的数据,此时必然出现多线程的并发安全问题,几个月培训班的同学,都应该知道
我们可能要用到并发包下面的很多技术,synchronized
synchronized(map) {
// 对map里的数据进行复杂的读写处理
}
并发包下面的其他的一些技术
CAS
一段代码:
此时,synchronized他的意思就是针对当前执行这个方法的myObject对象进行加锁
只有一个线程可以成功的堆myObject加锁,可以对他关联的monitor的计数器去加1,加锁,一旦多个线程并发的去进行synchronized加锁,串行化,效率并不是太高,很多线程,都需要排队去执行
CAS,compare and set
CAS在底层的硬件级别给你保证一定是原子的,同一时间只有一个线程可以执行CAS,先比较再设置,其他的线程的CAS同时间去执行此时会失败
4 ConcurrentHashMap实现线程安全的底层原理到底是什么?
JDK 1.8以前,多个数组,分段加锁,一个数组一个锁
JDK 1.8以后,优化细粒度,一个数组,每个元素进行CAS,如果失败说明有人了,此时synchronized对数组元素加锁,链表+红黑树处理,对数组每个元素加锁
多个线程要访问同一个数据,synchronized加锁,CAS去进行安全的累加,去实现多线程场景下的安全的更新一个数据的效果,比较多的一个场景下,可能就是多个线程同时读写一个HashMap
synchronized,也没这个必要
HashMap的一个底层的原理,本身是一个大的一个数组,[有很多的元素]
ConcurrentHashMap map = new ConcurrentHashMap();
// 多个线程过来,线程1要put的位置是数组[5],线程2要put的位置是数组[21]
map.put(xxxxx,xxx);
明显不好,数组里有很多的元素,除非是对同一个元素执行put操作,此时呢需要多线程是需要进行同步的
JDK并发包里推出了一个ConcurrentHashMap,他默认实现了线程安全性
在JDK 1.7以及之前的版本里,分段
[数组1] , [数组2],[数组3] -> 每个数组都对应一个锁,分段加锁
// 多个线程过来,线程1要put的位置是数组1[5],线程2要put的位置是数组2[21]
JDK 1.8以及之后,做了一些优化和改进,锁粒度的细化
[一个大的数组],数组里每个元素进行put操作,都是有一个不同的锁,刚开始进行put的时候,如果两个线程都是在数组[5]这个位置进行put,这个时候,对数组[5]这个位置进行put的时候,采取的是CAS的策略
同一个时间,只有一个线程能成功执行这个CAS,就是说他刚开始先获取一下数组[5]这个位置的值,null,然后执行CAS,线程1,比较一下,put进去我的这条数据,同时间,其他的线程执行CAS,都会失败
分段加锁,通过对数组每个元素执行CAS的策略,如果是很多线程对数组里不同的元素执行put,大家是没有关系的,如果其他人失败了,其他人此时会发现说,数组[5]这位置,已经给刚才又人放进去值了
就需要在这个位置基于链表+红黑树来进行处理,synchronized(数组[5]),加锁,基于链表或者是红黑树在这个位置插进去自己的数据
如果你是对数组里同一个位置的元素进行操作,才会加锁串行化处理;如果是对数组不同位置的元素操作,此时大家可以并发执行的
5 JDK中的AQS理解吗?AQS的实现原理是什么?
ReentrantLock
state变量 -> CAS -> 失败后进入队列等待 -> 释放锁后唤醒
非公平锁,公平锁
多线程同时访问一个共享数据,sychronized,CAS,ConcurrentHashMap(并发安全的数据结构可以来用),Lock
synchronized就有点不一样了,你可以自己上网看一下 => AQS,Abstract Queue Synchronizer,抽象队列同步器
Semaphore、其他一些的并发包下的
ReentrantLock lock = new ReentrantLock(true); => 非公平锁
// 多个线程过来,都尝试
lock.lock();
lock.unlock();
6 线程池的底层工作原理可以吗?
但凡是参加过几个月java就业培训的同学,都应该知道一个概念,线程池
系统是不可能说让他无限制的创建很多很多的线程的,会构建一个线程池,有一定数量的线程,让他们执行各种各样的任务,线程执行完任务之后,不要销毁掉自己,继续去等待执行下一个任务
频繁的创建线程,销毁线程,创建线程,销毁线程
ExecutorService threadPool = Executors.newFixedThreadPool(3) -> 3: corePoolSize
threadPool.submit(new Callable() {
public void run() {}
});
提交任务,先看一下线程池里的线程数量是否小于corePoolSize,也就是3,如果小于,直接创建一个线程出来执行你的任务
如果执行完你的任务之后,这个线程是不会死掉的,他会尝试从一个无界的LinkedBlockingQueue里获取新的任务,如果没有新的任务,此时就会阻塞住,等待新的任务到来
你持续提交任务,上述流程反复执行,只要线程池的线程数量小于corePoolSize,都会直接创建新线程来执行这个任务,执行完了就尝试从无界队列里获取任务,直到线程池里有corePoolSize个线程
接着再次提交任务,会发现线程数量已经跟corePoolSize一样大了,此时就直接把任务放入队列中就可以了,线程会争抢获取任务执行的,如果所有的线程此时都在执行任务,那么无界队列里的任务就可能会越来越多
fixed,队列,LinkedBlockingQueue,无界阻塞队列
7 线程池的核心配置参数都是干什么的?平时我们应该怎么用?
newFixedThreadPool(3)
代表线程池的类是ThreadPoolExecutor
创建一个线程池就是这样子的,corePoolSize,maximumPoolSize,keepAliveTime,queue,这几个东西,如果你不用fixed之类的线程池,自己完全可以通过这个构造函数就创建自己的线程池
corePoolSize:3
maximumPoolSize:Integer.MAX_VALUE
keepAliveTime:60s
new ArrayBlockingQueue(200)
如果说你把queue做成有界队列,比如说new ArrayBlockingQueue(200),那么假设corePoolSize个线程都在繁忙的工作,大量任务进入有界队列,队列满了,此时怎么办?
这个时候假设你的maximumPoolSize是比corePoolSize大的,此时会继续创建额外的线程放入线程池里,来处理这些任务,然后超过corePoolSize数量的线程如果处理完了一个任务也会尝试从队列里去获取任务来执行
如果额外线程都创建完了去处理任务,队列还是满的,此时还有新的任务来怎么办?
只能reject掉,他有几种reject策略,可以传入RejectedExecutionHandler
(1)AbortPolicy
(2)DiscardPolicy
(3)DiscardOldestPolicy
(4)CallerRunsPolicy
(5)自定义
如果后续慢慢的队列里没任务了,线程空闲了,超过corePoolSize的线程会自动释放掉,在keepAliveTime之后就会释放
根据上述原理去定制自己的线程池,考虑到corePoolSize的数量,队列类型,最大线程数量,拒绝策略,线程释放时间
一般比较常用的是:fixed线程,
8 如果在线程池中使用无界阻塞队列会发生什么问题?
面试题:
在远程服务异常的情况下,使用无界阻塞队列,是否会导致内存异常飙升?
调用超时,队列变得越来越大,此时会导致内存飙升起来,而且还可能会导致你会OOM,内存溢出
9 你知道如果线程池的队列满了之后,会发生什么事情吗?
有界队列,可以避免内存溢出
corePoolSize: 10
maximumPoolSize : 200
ArrayBlockingQueue(200)
自定义一个reject策略,如果线程池无法执行更多的任务了,此时建议你可以把这个任务信息持久化写入磁盘里去,后台专门启动一个线程,后续等待你的线程池的工作负载降低了,他可以慢慢的从磁盘里读取之前持久化的任务,重新提交到线程池里去执行
你可以无限制的不停的创建额外的线程出来,一台机器上,有几千个线程,甚至是几万个线程,每个线程都有自己的栈内存,占用一定的内存资源,会导致内存资源耗尽,系统也会崩溃掉
即使内存没有崩溃,会导致你的机器的cpu load,负载,特别的高
10 如果线上机器突然宕机,线程池的阻塞队列中的请求怎么办?
必然会导致线程池里的积压的任务实际上来说都是会丢失的
如果说你要提交一个任务到线程池里去,在提交之前,麻烦你先在数据库里插入这个任务的信息,更新他的状态:未提交、已提交、已完成。提交成功之后,更新他的状态是已提交状态
系统重启,后台线程去扫描数据库里的未提交和已提交状态的任务,可以把任务的信息读取出来,重新提交到线程池里去,继续进行执行
11 谈谈你对Java内存模型的理解可以吗?
read、load、use、assign、store、write
后台留言,并发这块讲解的好像有的地方有点浅,面试突击第一季和第二季,面试突击第一季,扫盲的作用,对并发、mysql、网络比较基础的知识,常见的面试题,根本就不太了解,4个月的培训班里出来的
直接楞住了,说,不好意思,concurrenthashmap从来没用过,crud
12 你知道Java内存模型中的原子性、有序性、可见性是什么吗?
连环炮:Java内存模型 -> 原子性、可见性、有序性 -> volatile -> happens-before / 内存屏障
也就是并发编程过程中,可能会产生的三类问题
(1)可见性
之前一直给大家代码演示,画图演示,其实说的就是并发编程中可见性问题
没有可见性,有可见性
(2)原子性
有原子性,没有原子性
原子性:data++,必须是独立执行的,没有人影响我的,一定是我自己执行成功之后,别人才能来进行下一次data++的执行
(3)有序性
对于代码,同时还有一个问题是指令重排序,编译器和指令器,有的时候为了提高代码执行效率,会将指令重排序,就是说比如下面的代码
具备有序性,不会发生指令重排导致我们的代码异常;不具备有序性,可能会发生一些指令重排,导致代码可能会出现一些问题
重排序之后,让flag = true先执行了,会导致线程2直接跳过while等待,执行某段代码,结果prepare()方法还没执行,资源还没准备好呢,此时就会导致代码逻辑出现异常。
13 能聊聊volatile关键字的原理吗?
内存模型 -> 原子性、可见性、有序性 -> volatile
讲清楚volatile关键字,直接问你volatile关键字的理解,对前面的一些问题,这个时候你就应该自己去主动从内存模型开始讲起,原子性、可见性、有序性的理解,volatile关键字的原理
volatile关键字是用来解决可见性和有序性,在有些罕见的条件之下,可以有限的保证原子性,他主要不是用来保证原子性的
可见性,概念进行了加强和深化,volatile在可见性上的作用和原理,有一个很清晰的了解
在很多的开源中间件系统的源码里,大量的使用了volatile,每一个开源中间件系统,或者是大数据系统,都多线程并发,volatile
14 你知道指令重排以及happens-before原则是什么吗?
volatile关键字和有序性的关系,volatile
是如何保证有序性的,如何避免发生指令重排的
java中有一个happens-before
原则:
编译器、指令器可能对代码重排序,乱排,要守一定的规则,happens-before原则,只要符合happens-before的原则,那么就不能胡乱重排,如果不符合这些规则的话,那就可以自己排序
- 1、程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作
- 2、锁定规则:一个unLock操作先行发生于后面对同一个锁的lock操作,比如说在代码里有先对一个lock.lock(),lock.unlock(),lock.lock()
- 3、volatile变量规则:对一个volatile变量的写操作先行发生于后面对这个volatile变量的读操作,volatile变量写,再是读,必须保证是先写,再读
- 4、传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C
- 5、线程启动规则:Thread对象的start()方法先行发生于此线程的每个一个动作,thread.start(),thread.interrupt()
- 6、线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生
- 7、线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行
- 8、对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始
上面这8条原则的意思很显而易见,就是程序中的代码如果满足这个条件,就一定会按照这个规则来保证指令的顺序。
很多同学说:好像没听懂,模模糊糊,这些规则写的非常的拗口,晦涩难懂,在面试的时候比如面试官问你,happens-before原则,你必须把8条规则都背出来,反问,没有任何一个人可以随意把这个规则背出来的
规则制定了在一些特殊情况下,不允许编译器、指令器对你写的代码进行指令重排,必须保证你的代码的有序性
但是如果没满足上面的规则,那么就可能会出现指令重排,就这个意思。
这8条原则是避免说出现乱七八糟扰乱秩序的指令重排,要求是这几个重要的场景下,比如是按照顺序来,但是8条规则之外,可以随意重排指令。
比如这个例子,如果用volatile来修饰flag变量,一定可以让prepare()指令在flag = true之前先执行,这就禁止了指令重排。
因为volatile要求的是,volatile前面的代码一定不能指令重排到volatile变量操作后面,volatile后面的代码也不能指令重排到volatile前面。
指令重排 -> happens-before -> volatile起到避免指令重排
个人笔记
- 什么是重排序? 为了提高性能,编译器和处理器常常会对既定的代码执行顺序进行指令重排序。
- 重排序的类型有哪些呢?源码到最终执行会经过哪些重排序呢?
在不改变程序执行结果的前提下,尽可能提高执行效率。
- 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序;
- 指令级并行的重排序。现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序;
- 内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行的。
happens-before
在什么情况下不进行指令重排
15 volatile底层是如何基于内存屏障保证可见性和有序性的?
连环炮:内存模型 -> 原子性、可见性、有序性 - > volatile+可见性 -> volatile+有序性(指令重排 + happens-before) -> voaltile+原子性 -> volatile底层的原理(内存屏障级别的原理)
volatile + 原子性:不能够保证原子性,虽然说有些极端特殊的情况下有保证原子性的效果,杠精,拿着一些极端场景下的例子,说volatile也可以原子性,oracle,64位的long的数字进行操作,volatile
保证原子性,synchronized,lock,加锁
volatile底层原理,如何实现保证可见性的呢?如何实现保证有序性的呢?
(1)lock指令:volatile保证可见性
对volatile修饰的变量,执行写操作的话,JVM会发送一条lock前缀指令给CPU,CPU在计算完之后会立即将这个值写回主内存,同时因为有MESI缓存一致性协议,所以各个CPU都会对总线进行嗅探,自己本地缓存中的数据是否被别人修改
如果发现别人修改了某个缓存的数据,那么CPU就会将自己本地缓存的数据过期掉,然后这个CPU上执行的线程在读取那个变量的时候,就会从主内存重新加载最新的数据了
lock前缀指令 + MESI缓存一致性协议
(2)内存屏障:volatile禁止指令重排序
volatille是如何保证有序性的?加了volatile的变量,可以保证前后的一些代码不会被指令重排,这个是如何做到的呢?指令重排是怎么回事,volatile就不会指令重排,简单介绍一下,内存屏障机制是非常非常复杂的,如果要讲解的很深入
Load1:
int localVar = this.variable
Load2:
int localVar = this.variable2
LoadLoad屏障:Load1;LoadLoad;Load2,确保Load1数据的装载先于Load2后所有装载指令,他的意思,Load1对应的代码和Load2对应的代码,是不能指令重排的
Store1:
this.variable = 1
StoreStore屏障
Store2:
this.variable2 = 2
StoreStore屏障:Store1;StoreStore;Store2,确保Store1的数据一定刷回主存,对其他cpu可见,先于Store2以及后续指令
LoadStore屏障:Load1;LoadStore;Store2,确保Load1指令的数据装载,先于Store2以及后续指令
StoreLoad屏障:Store1;StoreLoad;Load2,确保Store1指令的数据一定刷回主存,对其他cpu可见,先于Load2以及后续指令的数据装载
volatile的作用是什么呢?
volatile variable = 1
this.variable = 2 => store操作
int localVariable = this.variable => load操作
对于volatile修改变量的读写操作,都会加入内存屏障
每个volatile写操作前面,加StoreStore屏障,禁止上面的普通写和他重排;每个volatile写操作后面,加StoreLoad屏障,禁止跟下面的volatile读/写重排
每个volatile读操作后面,加LoadLoad屏障,禁止下面的普通读和voaltile读重排;每个volatile读操作后面,加LoadStore屏障,禁止下面的普通写和volatile读重排
并发这块,往深了讲,synchronized、volatile,底层都对应着一套复杂的cpu级别的硬件原理,大量的内存屏障的原理;lock API,concurrenthashmap,都是各种复杂的jdk级别的源码,技术深度是很深入的