面试题有很多 ,尤其是并发编程这一块。但是很多都写的比较趋于专业。对于不理解的人来说靠死记硬背这些面试题实在是过于苦涩,而且不能转化成自己的话语,在回答面试官的时候 有经验的面试官一眼就能看出是背的 。针对这个问题,我以我的最近对并发编程的学习, 总结了一些关于并发编程的面试题,并且尽量转化成大白话版的。以便于更好的去理解,希望能帮助到大家。
一.请谈一下你对volatile的理解?
这个主要是往3个方面来谈;
1.1 谈一谈volatile 的特性
volatile 主要是实现了 可见性,禁止指令重排序,和单原子性。
1.2 它是怎么实现可见性的?
首先得知道 什么是可见性。打个比方,就拿买车票这件事来说吧 。有一个代表车票数量的变量 int count=20 由 售票员来代表共享内存 , 有小红 和小明 分别代表两个线程。小红先到的 先把知道还有20张票, 买了一张 20-1 。这时候小明也来了 他也要买车票 。而他俩是线程不安全的 ,小明不管小红有没有买完车票,他就要买。 而小红这边20已经减了1了, 但是还没有告诉给售票员。那就会出现小明这边也拿到的是一个20 。最终出现小明这边20-1 ,小红这边也20-1 票实际上是卖了两张 ,但是最终还剩19张票的情况。 这就是他们之间的不可见性。
而volatile解决的就是 做到了两步,
第一步 ,让小红的买完票的结果告诉给售票员,(私有变量写回共享内存)
第二步,告诉小明 等 现在票已经减1啦 不是20啦(通过MESI协议实现的)
volatile提供的内存语义:写、读操作内存语义,相当于锁的释放与获取
写操作
volatile a = 1;
内存屏障:ll/ls/ss/sl
x86 sfence/lfence/mfence
lock前缀
读操作
本地内存中的副本无效了,重新去内存中读取
!https://www.scss.tcd.ie/Jeremy.Jones/vivio/caches/MESIHelp.htm(MESI协议动画演示网址)
1.3禁止指令重排序又是个什么鬼?
这个要涉及到编译器优化,cpu级别的优化。我们的程序看起来是按照顺序执行的。其实编译器在编译的时候会对我们的程序做一些优化,比如说类似于没有io操作的赋值比有io 操作的代码行优先执行什么的。单线程遵循as-if-serial原则 ,多线程遵循happends-beffo 原则。这也有可能会导致线程不安全问题。
as-if-serial:
1.4 单原子性是指的是对于一个共享变量的一次操作是原子性的。(对于i++这种就无法保证其原子性)
1.5volatile的应用:
共享成员变量,用来做线程间的通信与同步。
j.u.c成员类:aqs、atomicInteger、ConcurrentHashMap、CopyOnWriteArrayList
二.DCL(Double Check Lock)单例为什么要加volatile?
这个先要说 这个是指的是单例模式双重检查模式
先来看一份代码:
volatile User user;
public User getInstance(){
if(user == null){
synchrnized(this){
if(user == null){
user = new User();
}
}
}
return user;
}
这段代码的意思是 在并发情况下有可能有一个线程已经成功new 了一个user 但是第二个线程有可能并没有看到new出来的User 所以他也会new 一个 这是这里要加一个synchrnized原因,
还有一个问题 。这个问题就涉及到 一个对象创建的过程
大致分为 ,
1.检查类有没有加载
2.为对象非配空间
3.讲引用写回桟
(当然具体细节不止这3步具体看对象的创建过程)
这个是因为 对象创建过程这件事本身他不是有序进行的 cpu 可以先把引用写回桟 然后再进行空间的分配。所以这时候 会导致不同的线程间,去访问新new 出来的User的时候 可能会出现引用存在 但是实例确不能使用的情况。(当然单线程是没问题的)
三.CAS是什么?
全称是 Compare And Swap 比较并替换。
可以简单的说 是一种乐观锁的一种实现
线程A 是 把共享变量 拿到本地内存后 先保存个原有值,再去改一个预期值,当把私有变量写回公共内存时先进行比较,原有值不变就把预期值写进去,原有值发现已经变了 ,那就自旋。
举个例子,还是小明去买票,先把共享变量拿到本地。此时小明多做一个动作,我先记住我刚问票的时候的数量是20,我先在这个基础上进行-1 。当我付账的时候,我不是直接 付,我先拿我记住的库存和现有的的库存比较,还是不是20,如果已经不是20了。 那我就再来一遍。
四.CAS的ABA问题如何解决?
先搞清楚,啥是ABA问题。
还是买票的例子,小红买了一张票,小明也买了张票 ,他俩都没结账(没写回共享内存) 但是小红觉得她又不想走了,她又把票退了,这个时候小明结账的时候发现数量是对的 。但是,但是什么呢。这中间小红明明也操作了一下,那怎么让小明知道他买的这张票是小红退掉的那张呢。这就是ABA问题。
解决这个问题一般的方法是加版本号。或者有个标记 ture 或false 去专门标记这个共享变量是不是原始数据。
五.请谈一下AQS,为什么AQS的底层是CAS+volatile?
AQS是一个同步器,他的本质思想是通过cas 操作一个volatile的变量state 来实现同步。
而又 因为volatile能实现线程间的可见性 保证了这个state标志是线程安全的 CAS 则保证线程在不阻塞,通过自旋的方式去访问这个变量
六.你都用过哪些AQS实现的同步组件?
Lock ReentrantLock:
state 0 -1 -2 -3 只要是不等于0,就表示锁没有被释放。
ReadWriteReentrantLock:
拆分 16位一拆,高16位表示读锁、低16位表示写锁。
CountDownLatch:
count,倒计时,countDown -1;
CyclicBarrier:
初始值为0,指定的parities,只允许增加,满足了parities又开始新的一轮。
Semaphore:
n,获取锁的 -1,归还锁 +1;
七.请描述synchronized和ReentrantLock的异同?**
使用方面:lock更灵活、多个等待队列 lock.newConditon(); 公平与非公平锁的实现。lock锁可中断。
底层实现的不同:
有前提synchronized优化之前,JVM实现底。lock实现在Java语言。
synchronized重量级锁,重在哪里?需要操作系统的配合。
lock 用户态,cas(尽量不进行上下文的切换),解决锁竞争的问题,阻塞重新唤醒。
都有同步队列、等待队列。
八.请描述锁的四种状态和升级过程?
无锁–》偏向锁----》轻量级锁-----》重量级锁
九.解释一下锁的四种状态?
无锁:锁还未被线程获取 的状态 ownerId为空。
偏向锁:一个线程获取到锁 ownerId指向该线程。
轻量级锁:偏向锁发现有线程在竞争就升级成轻量级锁。这个时候还要看竞争的强度,如果竞争特别大有可能直接升为重量级锁。
重量级锁:轻量级锁发现锁竞争还是很大就会升级成重量级锁。
十.请描述一下锁的分类以及JDK中的应用?
这就比较广泛了 ,
从公平非公平来说 ,有公平锁,和非公平锁。入ReenTrantlock就可以实现公平和非公平。
从锁的等级来说又分为,偏向锁,轻量级锁,和重量级锁
比如jdk优化后synchronized锁的升级。
从锁得独占和共享来说,又有独占锁,和共享锁。
另外还有乐观锁 和悲观锁等等。。。。
十一.自旋锁一定比重量级锁效率高吗?
不一定,因为自旋锁虽然解决了线程间上下文切换的问题,但是自旋也是需要cpu来运行的,也是要看次数,如果一直自旋下去,则会一直占用cpu。
十二.打开偏向锁是否一定会提升效率?为什么?
不一定,因为当知道肯定会有多个线程竞争锁的时候,打开偏向锁会导致锁要升级,而锁升级也是需要浪费一定的性能的操作。
十三.请描述synchronized和ReentrantLock的底层实现及重入的底层原理?
13.1synchronized 可重入、独占、悲观锁
关于synchronized底层实现原理各种文章说的很多。我这里提供一个简化版的。
先说对象,每个对象都是有一个对象头,推向头有一个markword指向一个monitor也就是每个对象都有一个monitor。
每个线程进来的时候都会进到这个对象的monitor 并通过cas去竞争锁。如果是调用了wait方法就放到等待队列里去。如果竞争到了锁 那么这个对象头里的ownerID就是这个线程的id 直到当前线程释放锁 monitorexit 那么锁再被其他线程竞争。
修改mark word如果失败,会自旋CAS一定次数,该次数可以通过参数配置:
超过次数,仍未抢到锁,则锁升级为重量级锁,进入阻塞。
13.2ReentrantLock
ReentrantLock的基本实现可以概括为:先通过CAS尝试获取锁。如果此时已经有线程占据了锁,那就加入AQS队列并且被挂起。当锁被释放之后,排在CLH队列队首的线程会被唤醒,然后CAS再次尝试获取锁。在这个时候,如果:
非公平锁:如果同时还有另一个线程进来尝试获取,那么有可能会让这个线程抢先获取;
公平锁:如果同时还有另一个线程进来尝试获取,当它发现自己不是在队首的话,就会排到队尾,由队首的线程获取到锁。
关于锁的重入,那就是说的是一把锁可能会被同一个线程再次获取到,这个时候只需要在计数器上加1就可以了。
十四.请描述一下对象的创建过程?
(1) 虚拟机接收到一条new指令时,先去虚拟机中检查这个 指令的参数是否能在常量池中定位到一个类的符号引用,即类有没有被加载到方法区;
(2) 若类未被加载到方法区,则先进行类加载,若类已被加载,则继续;
(3) 获取被加载的类的对象长度;
(4) 确认是否在TLAB中分配内存,若是,则在TLAB中分配内存,否则在EDEN中分配内存;
(5) 将分配到的内存空间设置为零值;
(6) 设置对象的头信息;
(7) 将对象的引用入虚拟机栈;
十五.对象在内存中的布局?
对象在内存中的布局:对象头(MarkWord)、类指针(ClassPointer)、数组长度(length,仅限于数组)、实例数据(对象成员属性)、对齐(padding)
十六.Object o = new Object()在内存中占了多少字节?
markword 8字节,java默认使用calssPointer压缩,classpointer 4字节,padding 4字节 因此是16字节
不开启classpointer默认压缩,markword 8字节,classpointer 8字节,padding 0字节也是16字节
十七.你了解ThreadLocal吗?你知道ThreadLocal中如何解决内存泄露问题吗?
并不是所有时候,都要用到共享数据,若数据都被封闭在各自的线程之中,就不需要
同步,这种通过将数据封闭在线程中而避免使用同步的技术称为线程封闭。
它是一个线程级别变量,每个线程都有一个ThreadLocal就是每个线程都拥有了自己独立的一个变量,
竞争条件被彻底消除了,在并发模式下是绝对安全的变量
ThreadLocal 其实是以他本身作为key值 和存储的值作为value 占用的是线程本地内存 。所以不同的线程相同的key获取到的值是不一样的。ThreadLocal有一个说法就是用空间换时间。
关于threadLocal内存泄露问题的解决。其实ThreadLocal在set 和get方法内部就应经有清空操作。
十八.线程间有几种通信方式?
wait notify
Countdownloatch
volatile关键字
join
使用 ReentrantLock 结合 Condition
十九.为什么要用线程池?
为了提升性能,减少线程的创建。可以让线程得到很好的复用。一个线程完成任务以后可以去申请任务。
二十.线程池底层原理?
大白话可以这么说的 ,
当一个任务丢给线程池的时候,先看核心线程数到没到 ,如果没到就创建一个线程去执行任务。如果核心线程数到了就把任务丢给队列等着,队列都满了怎么办?那就看最大线程数到没到 如果没到就再创建个线程去处理任务。如果 最大线程数也到了 就根据提前设定好的处理策略去处理这个任务。如重试,抛异常等等。。。。(当然里边还有很多的细节没说到 感兴趣的可以去研究研究)
二十一. 工作中有用JDK提供的线程池吗?那为什么阿里巴巴开发手册中又不建议使用?
jdk确实给我们提供了几种线程池,有个Excutors就是专门创建jdk提供的线程池工厂。我想阿里不建议使用是因为 。这里边有些线程池参数设计的并不是很合理。
像
newFixedThreadPool(int nThreads) 创建一个固定大小、任务队列容量无界的线程池。核心线程数=最大线程数。
newCachedThreadPool() 创建的是一个大小无界的缓冲线程池。它的任务队列是一个同步队列。任务加入到池中,如果池中有空闲线程,则用空闲线程执行,如无则创建新线程执行。池中的线程空闲超过60秒,将被销毁释放。线程数随任务的多少变化。适用于执行耗时较小的异步任务。池的核心线程数=0 ,最大线程数= Integer.MAX_VALUE
newSingleThreadExecutor() 只有一个线程来执行无界任务队列的单一线程池。该线程池确保任务按加入的顺序一个一个依次执行。当唯一的线程因任务异常中止时,将创建一个新的线程来继续执行后续的任务。与newFixedThreadPool(1)的区别在于,单一线程池的池大小在 newSingleThreadExecutor方法中硬编码,不能再改变的。
newScheduledThreadPool(int corePoolSize) 能定时执行任务的线程池。该池的核心线程数由参数指定,最大线程数= Integer.MAX_VALUE
newFixedThreadPool是无界队列的线程池,这个说明任务可以无限被放入队列这显然是不合理的 ,而newCachedThreadPool() 的最大线程数是Integer的最大值 那一般的cpu支持不了创建那么多的线程的
newSingleThreadExecutor()只有一个线程 那显然不行
等等。。。。
二十二.聊一聊HashMap的底层实现
jdk1.7 和1.8实现是不一样的
1.8相对于1.7增加了红黑树 扩容时由头部插入 优化为从尾部插入 (解决并发环境下死循环问题)
jdk1.8中 hashmap 是基于 数组+单向链表 +红黑树实现的
其中数组的默认初始长度为16
什么时候扩容呢?当达到负载因子时扩容 默认是0.75
扩容时每次扩容是2倍的扩容 (这是为了让key分的更均匀)
二十三.HashMap中存储数据的过程是怎样的?
1.当一个key value 调用set方法时 hashmap 会根据key先去计算hash 值
2.计算到的hash值 去进行数组长度取模(这时候 hashmap为了让key分布的更均匀又进行了一些搅动操作)
3.如果存储的桶 (数组)已经有值了 (hash碰撞)就追加单项链表
4.相同的key覆盖
5.如果单项两表达到了8 就转化成红黑树
二十四.JDK8中为什么要用红黑树?为什么不直接使用红黑树呢?
红黑树可以增加查找的效率避免链表过长的情况,不直接使用红黑树那是因为 红黑树就算是可以增加查找的效率,但是如果数据较小的情况下 红黑树其实并没有使用单项链表性能好。红黑树只是在数据比较大的情况下占优。
二十五.ConcurrentHashMap 和Hashtable 有什么区别?
concurrentHashMap 相对于hashtable来说锁的粒度更细了
hashtable只有一把锁。
二十六.聊聊ConcurrentHashMap的底层实现。
在jdk1.7中
ConcurrentHashMap是由Segment数组结构和HashEntry数组结构组成。Segment数组长度是不可变的 其实是每个Segment数组上都加一把锁,每个数组的节点上再存一个hashMap 。 这样的做法并没有解决多少并发问题。所以在jdk1.8 中有了更细粒度的优化。
jdk1.8中 是没有改变hashmap的数据结构的前提上 给每个数组的节点上加上一把synchrnized锁 然后通过cas自旋去过去这把锁。
二十七.CopyOnWriteArrayList的实现原理?
相比较ArrayList除了多了一把lock锁,还有一个写时复制的特性。
1.读取的时候不需要加锁,直接读取即可
2.修改的时候,先获取锁,复制一份数组,然后进行修改,避免读线程和写线程争抢同一个内存地址的资源。
为什么要这样设计?读写分离:如果只采用一把独占锁控制读写操作,写线程获取到锁,其他线程包括读线程阻塞,反之亦然。改时复制,修改时不是直接往容器中进行修改,而是先复制一份进行修改,改完后再将原容器的引用指向新的容器,这样写操作则不会影响到正在读取的线程。
注意的问题:虽然不会产生读的并发安全问题,但会产生数据不一致的问题,写线程没来得及写回内存,读线程就会读到脏数据。
适用场景:适合读多写少的场景,相反写多读少的场合就不合适。
二十八.你用过哪些原子类
AtomicBoolean: 原子更新布尔类型。
AtomicInteger: 原子更新整型。
AtomicLong: 原子更新长整型。
AtomicIntegerArray: 原子更新整型数组里的元素。 AtomicLongArray: 原子更新长整型数组里的元素。
AtomicReferenceArray: 原子更新引用类型数组里的元素。
AtomicReference: 原子更新引用类型。
AtomicReferenceFieldUpdater: 原子更新引用类型的字段。
AtomicMarkableReferce: 原子更新带有标记位的引用类型,可以使用构造方法更新一个布尔类型的标记位和引用类型。
AtomicIntegerFieldUpdater: 原子更新整型的字段的更新器。
AtomicLongFieldUpdater: 原子更新长整型字段的更新器。
AtomicStampedFieldUpdater: 原子更新带有版本号的引用类型。
JDK8新增原子类简介
DoubleAccumulator
LongAccumulator
DoubleAdder
LongAdder
二十九.LinkedBelockingQueue和ArrayBelockingQueue的区别
1.队列大小有所不同,ArrayBlockingQueue是有界的初始化必须指定大小,而LinkedBlockingQueue可以是有界的也可以是无界的(Integer.MAX_VALUE),对于后者而言,当添加速度大于移除速度时,在无界的情况下,可能会造成内存溢出等问题。
2.数据存储容器不同,ArrayBlockingQueue采用的是数组作为数据存储容器,而LinkedBlockingQueue采用的则是以Node节点作为连接对象的链表。
3.由于ArrayBlockingQueue采用的是数组的存储容器,因此在插入或删除元素时不会产生或销毁任何额外的对象实例,而LinkedBlockingQueue则会生成一个额外的Node对象。这可能在长时间内需要高效并发地处理大批量数据的时,对于GC可能存在较大影响。
4.两者的实现队列添加或移除的锁不一样,ArrayBlockingQueue实现的队列中的锁是没有分离的,即添加操作和移除操作采用的同一个ReenterLock锁,而LinkedBlockingQueue实现的队列中的锁是分离的,其添加采用的是putLock,移除采用的则是takeLock,这样能大大提高队列的吞吐量,也意味着在高并发的情况下生产者和消费者可以并行地操作队列中的数据,以此来提高整个队列的并发性能。
应用场景
表象:一个是有界、一个是无界,实质是应用场合的不同。
Array适用于高效简单,能够知道任务的并发数量。
Linked适用于不知道任务并发数量,任务操作比较耗时的,批量场景。
三十.你都知道jdk里边的哪些队列
ArrayBlockingQueue :一个由数组支持的有界队列。* LinkedBlockingQueue :一个由链接节点支持的可选有界队列。
PriorityBlockingQueue :一个由优先级堆支持的无界优先级队列。
DelayQueue :一个由优先级堆支持的、基于时间的调度队列。
SynchronousQueue :一个利用 BlockingQueue 接口的简单聚集(rendezvous)机制。
三十一.怎么保证线程安全?
线程安全就是多个线程对于共享变量的操作,只要实现
三大特性,原子性,有序性 ,可见性。
三十二.什么是死锁?
死锁就是两个线程,互相持有对方的锁 互相等待对方锁的释放。
死锁案例:
public class DeadLock {
final Object lockA = new Object();
final Object lockB = new Object();
public static void main(String[] args) {
DeadLock demo = new DeadLock();
demo.startLock();
}
public void startLock(){
ThreadA a= new ThreadA(lockA,lockB);
ThreadB b= new ThreadB(lockA,lockB);
//start threads
a.start();
b.start();
}
}
class ThreadA extends Thread{
private Object lockA = null;
private Object lockB = null;
public ThreadA(Object a, Object b){
this.lockA = a;
this.lockB = b;
}
public void run() {
synchronized (lockA) {
System.out.println("*** Thread A: ***: Lock A" );
try {
sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (lockB) {
System.out.println("*** Thread A: ***: Lock B" );
}
}
System.out.println("*** Thread A: ***: Finished" );
}
}
class ThreadB extends Thread{
private Object lockA = null;
private Object lockB = null;
public ThreadB(Object a, Object b){
this.lockA = a;
this.lockB = b;
}
public void run() {
synchronized (lockB) {
System.out.println("*** Thread B: ***: Lock B" );
try {
sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (lockA) {
System.out.println("*** Thread B: ***: Lock A" );
}
}
System.out.println("*** Thread B: ***: Finished" );
}
}
三十三.ConcurrentSkipListMap
三十四.线程的生命周期
线程的生命周期。
1.新建状态
2.调用start方法使线程变成就绪状态
3.线程争夺到了时间片,执行run方法就是线程的运行状态
4.线程阻塞状态,当线程调用sleep()或者调用wait
sleep() 和wait 方法的区别 sleep方法设定时间,在指定时间过后线程重新恢复到运行状态。
而wait方法 不会有指定的时间,只有当另一个线程调用notify方法。线程才会重新回到运行状态。
5.线程执行完毕,进入到销毁状态
(谢谢观看 ,如果觉得能帮到你的话 我辈荣幸之至,另希望能值得抬起你发财的小手,点个赞吧)