摘要
现如今,不管是应届毕业生还是工作三五年的工程师,在面试招聘的时候JUC并发编程是必须掌握的一个技能,否者你的面试结果可能会很差。本博文主要是整理JUC相关面试问题和答案。帮助大家在面试过程中能够回答面试官问题。本人将不断更新的JUC并发编程的相关面试问题。帮助小伙伴更好通过面试。
CAS是怎么实现线程安全的?
线程在读取数据时不进行加锁,在准备写回数据时,先去查询原值,操作的时候比较原值是否修改,若未被其他线程修改则写回,若已被修改,则重新执行读取流程。举个例子:现在一个线程要修改数据库的name,修改前我会先去数据库查name的值,发现name=“庄小焱”,拿到值了,我们准备修改成name=“傻逼”,在修改之前我们判断一下,原来的name是不是等于“庄小焱”,如果被其他线程修改就会发现name不等于“庄小焱”,我们就不进行操作,如果原来的值还是庄小焱,我们就把name修改为“傻逼”,至此,一个流程就结束了。
CAS场景下出现ABA问题?
- 1.线程1读取了数据A
- 2.线程2读取了数据A
- 3.线程2通过CAS比较,发现值是A没错,可以把数据A改成数据B
- 4.线程3读取了数据B
- 5.线程3通过CAS比较,发现数据是B没错,可以把数据B改成了数据A
- 6.线程1通过CAS比较,发现数据还是A没变,就写成了自己要改的值
循环时间长开销大的问题:是因为CAS操作长时间不成功的话,会导致一直自旋,相当于死循环了,CPU的压力会很大。只能保证一个共享变量的原子操作:CAS操作单个共享变量的时候可以保证原子的操作,多个变量就不行了,JDK 5之后 AtomicReference可以用来保证对象之间的原子性,就可以把多个对象放入CAS中操作。
那开发过程中ABA你们是怎么保证的?
加标志位,例如搞个自增的字段,操作一次就自增加一,或者搞个时间戳,比较时间戳的值。举个栗子∶现在我们去要求操作数据库,根据CAS的原则我们本来只需要查询原本的值就好了,现在我们一同查出他的标志位版本字段vision。除了版本号,像什么时间戳,还有JUC工具包里面也提供了这样的类。
之前不能防止ABA的正常修改:
update table set value = newValue where value = #{oldValue}
//oldValue就是我们执行前查询出来的值
带版本号能防止ABA的修改:
update table set value = newValue , vision = vision + 1 where value = #{oldValue} and vision = #{vision}
//判断原来的值和版本号是否匹配,中间有别的线程修改,值可能相等,但是版本号100%不一样
如何保证同一时刻只有一个线程可以进入临界区呢?
synchronized,代表这个方法加锁,相当于不管哪一个线程(例如线程A),运行到这个方法时,都要检查有没有其它线程B(或者C、D等)正在用这个方法(或者该类的其他同步方法),有的话要等正在使用synchronized方法的线程B(或者C、D)运行完这个方法后再运行此线程A,没有的话,锁定调用者,然后直接运行。
锁synchronized是重量级的锁,为啥现在都不提了?
但是,随着Java SE 1.6对synchronized进行了各种优化之后,有些情况下它就并不那么重,Java SE1.6中为了减少获得锁和释放锁带来的性能消耗而引入的偏向锁和轻量级锁。针对synchronized获取锁的方式,JVM使用了锁升级的优化方式,就是先使用偏向锁优先同一线程然后再次获取锁,如果失败,就升级为CAS 轻量级锁,如果失败就会短暂自旋,防止线程被系统挂起。最后如果以上都失败就升级为重量级锁。
线程同步手段还有那些?
ReentrantLock但是在介绍这玩意之前,我觉得我有必要先介绍AQS(AbstractQueuedSynchronizer) 。AQS:也就是队列同步器,这是实现 ReentrantLock的基础。AQS有一个state标记位,值为1时表示有线程占用,其他线程需要进入到同步队列等待,同步队列是一个双向链表。
当获得锁的线程需要等待某个条件时,会进入condition 的等待队列,等待队列可以有多个。当condition条件满足时,线程会从等待队列重新进入同步队列进行获取锁的竞争。ReentrantLock就是基于AQS 实现的,如下图所示,ReentrantLock内部有公平锁和非公平锁两种实现,差别就在于新来的线程是否比已经在同步队列中的等待线程更早获得锁。和ReentrantLock实现方式类似,Semaphore也是基于AQS的,差别在于ReentrantLock是独占锁,Semaphore是共享锁。
从图中可以看到,ReentrantLock里面有一个内部类Sync,Sync继承AQS (AbstractQueuedSynchronizer),添加锁和释放锁的大部分操作实际上都是在Sync中实现的。它有公平锁FairSync和非公平锁NonfairSync两个子类。ReentrantLock默认使用非公平锁,也可以通过构造器来显示的指定使用公平锁。
synchronized和lock的区别?
synchronized 竞争锁时会一直等待 | ReentrantLock 可以尝试获取锁,并得到获取结果 |
synchronized 获取锁无法设置超时 | ReentrantLock 可以设置获取锁的超时时间 |
synchronized 无法实现公平锁 | ReentrantLock 可以满足公平锁,即先等待先获取到锁 |
synchronized 控制等待和唤醒需要结合加锁对象的 wait() 和 notify()、notifyAll() | ReentrantLock 控制等待和唤醒需要结合 Condition 的 await() 和 signal()、signalAll() 方法 |
synchronized 是JVM 层面实现的 | ReentrantLock 是 JDK 代码层面实现 |
synchronized 在加锁代码块执行完或者出现异常,自动释放锁。 | ReentrantLock 不会自动释放锁,需要在 finally{} 代码块显示释放 |
synchronized和volatile的区别?
synchronized | volatile |
synchronized 可以修饰类、方法、变量。 | volatile 是变量修饰符 |
而 synchronized则可以保证变量的修改可见性和原子性 | volatile 仅能实现变量的修改可见性,不能保证原子性 |
synchronized 可能会造成线程的阻塞 | volatile 不会造成线程的阻塞 |
synchronized标记的变量可以被编译器优化 | volatile标记的变量不会被编译器优化 |
Synchronized是重量级的锁 | volatile关键字是线程同步的轻量级实现 |
ThreadLocal原理
ThreadLocal的作用主要是做数据隔离,填充的数据只属于当前线程,变量的数据对别的线程而言是相对隔离的,在多线程环境下,如何防止自己的变量被其它线程篡改。spring采用Threadlocal的方式,来保证单个线程中的数据库操作使用的是同一个数据库连接,同时,采用这种方式可以使业务层使用事务时不需要感知并管理connection对象,通过传播级别,巧妙地管理多个事务配置之间的切换,挂起和恢复。
Spring的事务主要是ThreadLocal和AOP去做实现的,我这里提一下,大家知道每个线程自己的链接是靠ThreadLocal保存的就好了。
之前我们上线后发现部分用户的日期居然不对了,排查下来是 SimpLeDatarormat的错误,调用了SimpleDataFormat的parse()方法,内部有一个Calendar对象,调用SImpleuataromaLpaS把S5先调用Calendar.clear () ,然后调用Calendar.add(),如果一个线程先调用了add()然后另一个线程又调用了clear(),这时候parse()方法解析的时间就不对了。
其实要解决这个问题很简单,让每个线程都new 一个自己的 SimpleDataFormat就好了,但是1000个线程难道new1000个SimpleDataFormat ?所以当时我们使用了线程池加上ThreadLocal包装SimpleDataFormat ,再调用initialValue让每个线程有一个SimpleDataFormat的副本,从而解决了线程安全的问题,也提高了性能。
package 计算机程序算法分类.单例设计模式;
/**
* @Classname ThreadLocalTest
* @Description TODO
* @Date 2021/5/11 10:11
* @Created by xjl
*/
public class ThreadLocalTest {
public static void main(String[] args) {
ThreadLocal<String> localName = new ThreadLocal<>();
localName.set("庄小焱");
String mame = localName.get();
localName.remove();
}
public void set(T value) {
Thread t = Thread.currentThread();//获取当前线程
ThreadLocalMap map = getMap(t);//获取ThreadLocalmap对象
if (map != null) {//校验是否为空
map.set(this, value);//部位空的时候设置的值
} else {
createMap(t, value);//为空的时候创建一个map对象
}
}
}
ThreadLocalMap底层结构是怎么样子的呢?
为什么需要数组呢?没有了链表怎么解决Hash冲突呢?
用数组是因为,我们开发过程中可以一个线程可以有多个TreadLocal来存放不同类型的对象的,但是他们都将放到你当前线程的ThreadLocalMap里,所以肯定要数组来存。
在Java中,栈内存归属于单个线程,每个线程都会有一个栈内存,其存储的变量只能在其所属线程中可见,即栈内存可以理解成线程的私有内存,而堆内存中的对象对所有线程可见,堆内存中的对象可以被所有线程访问。
那么是不是说ThreadLocal的实例以及其值存放在栈上呢?
其实不是的,因为ThreadLocal实例实际上也是被其创建的类持有(更顶端应该是被线程持有)﹐而ThreadLocal的值其实也是被线程实例持有,它们都是位于堆上,只是通过一些技巧将可见性修改成了线程可见。
如果我想共享线程的ThreadLocal数据怎么办?
使用InheritableThreadLocal可以实现多个线程访问ThreadLocal的值,我们在主线程中创建一个InheritableThreadLocal 的实例,然后在子线程中得到这个InheritableThreadLocal实例设置的值。在子线程中我是能够正常输出那一行日志的,这也是我之前面试视频提到过的父子线程数据传递的问题。
传递的逻辑很简单,我在开头Thread代码提到threadLocals的时候,你们再往下看看我刻意放了另外一个变量:
那为什么ThreadLocalMap的key要设计成弱引用?
key不设置成弱引用的话就会造成和entry中value—样内存泄漏的场景。补充一点:ThreadLocal的不足,我觉得可以通过看看netty的fastThreadLocal来弥补。
JMM内存模型
其实早期计算机中cpu和内存的速度是差不多的,但在现代计算机中,cpu的指令速度远超内存的存取速度,由于计算机的存储设备与处理器的运算速度有几个数量级的差距,所以现代计算机系统都不得不加入一层读写速度尽可能接近处理器运算速度的高速缓存(Cache)来作为内存与处理器之间的缓冲。将运算需要使用到的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存同步回内存之中,这样处理器就无须等待缓慢的内存读写了。基于高速缓存的存储交互很好地解决了处理器与内存的速度矛盾,但是也为计算机系统带来更高的复杂度,因为它引入了一个新的问题:缓存一致性(CacheCoherence) 。在多处理器系统中,每个处理器都有自己的高速缓存,而它们又共享同一主内存(MainMemory)。Java内存模型(JavaMemoryModel))描述了Java程序中各种变量(线程共享变量)的访问规则以及在JVM中将变量,存储到内存和从内存中读取变量这样的底层细节。
为啥加锁可以解决可见性问题呢?
因为某一个线程进入synchronized代码块前后,线程会获得锁,清空工作内存,从王内存拷贝共学效重最新的值到工作内存成为副本,执行代码,将修改后的副本的值刷新回主内存中,线程释放锁。而获取不到锁的线程会阻塞等待,所以变量的值肯定一直都是最新的。
volatile的实现原理?
每个线程操作数据的时候会把数据从主内存读取到自己的工作内存,如果他操作了数据并且写会了,他其他已经读取的线程的变量副本就会失效了,需要都数据进行操作又要再次去主内存中读取了。volatile保证不同线程对共享变量操作的可见性,也就是说一个线程修改了volatile修饰的变量,当修改写回主内存时,另外一个线程立即看到最新的值。
MESI(缓存一致性协议)
当CPU写数据时,如果发现操作的变量是共享变量,即在其他CPU中也存在该变量的副本,发出信号通知其他CPU将该变量的缓存行置为无效状态,当其他CPU需要读取这个变量时,发现自己缓存中缓存该变量的缓存行是无效的,那么它就会从内存重新读取。
总线嗅探机制
每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存里。
总线风暴
由于Volatile的MESI缓存一致性协议,需要不断的从主内存嗅探和cas不断循环,无效交互会导致总线带宽达到峰值。所以不要大量使用Volatile,至于什么时候去使用Volatile什么时候使用锁,根据场景区分
禁止指令重排序
什么是重排序?:为了提高性能,编译器和处理器常常会对既定的代码执行顺序进行指令重排序。重排序的类型有哪些呢?源码到最终执行会经过哪些重排序呢?
—般重排序可以分为如下三种:
- 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序;指令级并行的重排序。
- 指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序;
- 内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行的。
as-if-serial
不管怎么重排序,单线程下的执行结果不能被改变。编译器、runtime和处理器都必须遵守as-if-serial语义。
那Volatile是怎么保证不会被执行重排序的呢?
内存屏障:java编译器会在生成指令系列时在适当的位置会插入内存屏障指令来禁止特定类型的处理器重排序。为了实现volatile的内存语义,JMM会限制特定类型的编译器和处理器重排序,JMM会针对编译器制定volatile重排序规则表:
需要注意的是: volatile写是在前面和后面分别插入内存屏障,而volatile读操作是在后面插入两个内存屏障。
happens-before是啥原理?
如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须存在happens-before关系。volatile域规则:对一个volatile域的写操作,happens-before于任意线程后续对这个volatile域的读。如果现在我的变了falg变成了false,那么后面的那个操作,一定要知道我变了。聊了这么多,我们要知道Volatile是没办法保证原子性的,一定要保证原子性,可以使用其他方法。
volatile与synchronized的区别
- volatile只能修饰实例变量和类变量,而synchronized可以修饰方法,以及代码块。
- volatile保证数据的可见性,但是不保证原子性(多线程进行写操作,不保证线程安全);而synchronized是一种排他(互斥)的机制。volatile用于禁止指令重排序:可以解决单例双重检查对象初始化代码执行乱序问题。
- volatile可以看做是轻量版的synchronized,volatile不保证原子性,但是如果是对一个共享变量进行多个线程的赋值,而没有其他的操作,就可以用volatile来代替synchronized,因为赋值本身是有原子性的,而volatile又保证了可见性,所以就可以保证线程安全了。
- volatile修饰符适用于以下场景:某个属性被多个线程共享,其中有一个线程修改了此属性,其他线程可以立即得到修改后的值,比如booleanflag;或者作为触发器,实现轻量级同步。
- volatile属性的读写操作都是无锁的,它不能替代synchronized,因为它没有提供原子性和互斥性。因为无锁,不需要花费时间在获取锁和释放锁_上,所以说它是低成本的。
- volatile只能作用于属性,我们用volatile修饰属性,这样compilers就不会对这个属性做指令重排序。
- volatile提供了可见性,任何一个线程对其的修改将立马对其他线程可见,volatile属性不会被线程缓存,始终从主存中读取。
- volatile提供了happens-before保证,对volatile变量v的写入happens-before所有其他线程后续对v的读操作。
- volatile可以使得long和double的赋值是原子的。
- volatile可以在单例双重检查中实现可见性和禁止指令重排序,从而保证安全性。
公平锁:多个线程按照申请锁的顺序去获得锁,线程会直接进入队列去排队,永远都是队列的第一位才能得到锁。
- 优点:所有的线程都能得到资源,不会饿死在队列中。
- 缺点︰吞吐量会下降很多,队列里面除了第一个线程,其他的线程都会阻塞,cpu唤醒阻塞线程的开销会很大。
非公平锁:多个线程去获取锁的时候,会直接去尝试获取,获取不到,再去进入等待队列,如果能获取到,就直接获取到锁。
- 优点:可以减少CPU唤醒线程的开销,整体的吞吐效率会高点,CPU也不必取唤醒所有线程,会减少唤起线程的数量。
- 缺点:你们可能也发现了,这样可能导致队列中间的线程一直获取不到锁或者长时间获取不到锁,导致饿死。