这是我参与8月更文挑战的第8天,活动详情查看:8月更文挑战
Java并发已经是Java面试必问的一块内容,对这块知道越多的细节,就越容易让面试官刮目相看。
我结合自身学习和面试经历,总结了Java并发的相关面试题,包括线程基础、多线程与并发、线程安全、线程池、锁、内存模型、JUC、集合与并发这几块内容。
因篇幅限制,分成三篇文章
中篇:内存模型、锁
下篇:JUC、集合与并发
内存模型
JVM内存结构 VS Java内存模型 VS Java对象模型
容易混淆:三个截然不同的概念,但是很多人容易弄混
- JVM内存结构,和Java虚拟机的运行时区域有关
- Java内存模型,和Java的并发编程有关
- Java对象模型,和Java对象在虚拟机中的表现形式有关
什么是JMM
Java Memory Model
- C语言不存在内存模型的概念
- 依赖处理器,不同处理器结果不一样
- 无法保证并发安全
- 需要一个标准,让多线程运行的结果可预期
JMM是规范
- 是一组规范,需要各个JVM的实现来遵守JMM规范,以便于开发者可以利用这些规范,更方便地开发多线程程序
- 如果没有这样一个JMM内存模型来规范,那么很可能经过了不同JVM的不同规则的排序之后,导致不同的虚拟机上运行的结果不一样,那是很大的问题。
JMM是工具类和关键字的原理
- volatile syschronized lock等的原理都是JMM
- 如果没有JMM,那就需要自己指定什么时候用内存栅栏等,那是相当麻烦的,幸好有了JMM,让我们只需要用同步工具和关键字就可以开发并发程序
什么是重排序
什么是重排序:在线程1内部的两行代码的实际执行顺序和代码在Java文件中的顺序不一致,代码指令并不是严格按照代码语句顺序执行的,它们的顺序被改变了,这就是重排序,这里被颠倒的是y=a和b=1这两行语句。
重排序的好处:提高处理速度
对比重排序前后的指令优化。重排序明显提高了处理速度
重排序的3种情况
- 编译器优化:包括JVM,JIT编译器等
- CPU指令重排:就算编译器不发生重排,CPU也可能对指令进行重排
- 内存的重排序:线程A的修改线程B却看不到,引出可见性问题
什么是可见性
用volatile解决问题,强制把数据更改flush到主内存当中
为什么会有可见性问题
- CPU有多级缓存,导致读的数据过期
- 高速缓存的容量比主内存小,但是速度仅次于寄存器,所以在CPU和主内存之间就多了Cache层
- 线程间的对于共享变量的可见性问题不是直接由多核引起的,而是由多缓存引起的
- 如果所有个核心都只用一个缓存,那么也就不存在内存可见性问题了
- 每个核心都会将自己需要的数据读到独占缓存中,数据修改后也是写入到缓存中,然后等待刷入到主存中。所以会导致有些核心读取的值是一个过期的值
JMM的抽象:主内存和本地内存
- Java作为高级语言,屏蔽了这些底层细节,用JMM定义了一套读写内存数据的规范,虽然我们不再需要关心一级缓存和二级缓存的问题,但是,JMM抽象了主内存和本地内存的概念
- 这里说的本地内存并不是真的是一块给每个线程分配的内存,而是JMM的一个抽象,是对于寄存器、一级缓存、二级缓存等的抽象。
主内存和本地内存的关系 JMM有以下规定:
- 所有的变量都存储在主内存中,同时每个线程也有自己独立的工作内存,工作内存中的变量内容是主内存的拷贝
- 线程不能直接读写主内存中的变量,而是只能操作自己工作内存中的变量,然后再同步到主内存中
- 主内存是多个线程共享的,但线程不共享工作内存,如果线程间需要通信,必须借助主内存中转来完成
所有的共享变量存在于主内存中,每个线程有自己的本地内存,而且线程读写共享数据也是通过本地内存交换的,所以才导致了可见性问题
什么是Happens-Before原则
什么是Happens-Before
- Happens-Before规则是用来解决可见性问题的:在时间上,动作A发生在动作B之前,B保证能看见A,这就是Happens-Before
- 两个操作可以用Happens-Before来确定它们的执行顺序:如果一个操作Happens-Before于另一个操作,那么我们说第一个操作对于第二个操作是可见的
什么不是Happens-Before
- 两个线程没有相互配合的机制,所以代码X和Y的执行结构并不能保证总被对法看到的,这就不具备Happens-Before
Happens-Before规则有哪些?
- 单线程规则(一个线程之内,后面的代码一定能看到前面的语句发生了什么)(重排序还是可能的)(Happens-Before不影响重排序)
- 锁操作(Synchronized和Lock)
- volatile变量
- 线程启动(子线程能看到主线程的结果)
- 线程join()
- 传递性:如果hb(A,B)而且hb(B,C),那么可以推出hb(A,C)
- 中断:一个线程被其他线程interrupt时,那么检查中断(isInterrputed)或者抛出InterruptedException一定能看到
- 构造方法:对象构造方法的最后一行指令Happens-Before于finalize()方法的第一行指令
- 工具类的Happens-Before原则
- 线程安全的容器get一定能看到在此之前的put等存入动作
- CountDownLatch
- Semaphore
- Future
- 线程池
- CyclicBarrier
Happens-Before有一个原则是:如果A是对volatile变量的写操作,B是对同一个变量的读操作,那么hb(A,B)
volatile关键字
如果只是沉迷于业务开发,不在业务开发中总结,业余时间提高,遇到复杂问题永远没办法解决。 不去系统梳理多线程的知识点,知识是难以运用的
是什么?
- volatile是一种同步机制,比synchronized或者Lock相关类更轻量,因为使用volatile并不会发生上下文切换等开销很大的行为。
- 如果一个变量被修饰成volatile,那么JVM就知道了这个变量可能会被并发修改
- 但是开销小,相应的能力也小,虽然说volatile是用来同步的保证线程安全的,但是volatile做不到synchronized那样的原子保护,volatile仅在很有限的场景下才能发挥作用。
不适用场景:a++
适用场合:
- boolean flag,如果一个共享变量自始至终只被各个线程赋值,而没有其他的操作,那么就可以用volatile来代替synchronized或者代替原子变量,因为赋值自身是有原子性的,而volatile又保证了可见性,所以就足以保证线程安全。(不一定是布尔变量)
- 作为刷新之前变量的触发器(FieldVisibility)
作用:
- 可见性:读一个volatile变量之前,需要先使相应的本地缓存失效,这样就必须到主内存读取最新值,写一个volatile属性会立即刷入到主内存。
- 禁止指令重排序优化:解决单例双重锁乱序问题
volatile和Synchronized的关系
- volatile在这方面可以看做是轻量版的synchronized:如果一个共享变量自始至终只被各个线程赋值,而没有其他的操作,那么就可以用volatile来代替synchronized或者代替原子变量,因为赋值自身是有原子性的,而volatile又保证了可见性,所以就足以保证线程安全
用volatile修正重排序 OutOfOrderExecution
小结:
- volatile修饰符适用于以下场景:某个属性被多个线程共享,其中有一个线程修改了此属性,其他线程可以立即得到修改后的值,比如boolean flag;或者作为触发器,实现轻量级同步
- volatile属性的读写操作都是无锁的,它不能代替synchronized,因为它没有提供原子性和互斥性。因为无锁,不需要花费时间在获取锁和释放锁上,所以说它是低成本的
- volatile只能作用于属性,我们用volatile修饰属性,这样compilers就不会对这个属性做指令重排序
- volatile提供了可见性,任何一个线程对其的修改将立马对其他线程可见。volatile属性不会被线程缓存,始终从主存中读取
- volatile提供了happens-before保证,对volatile变量v的写入happens-before所有其他线程后续对v的读操作
- volatile可以使得long和double的赋值是原子的,后面马上会讲long和double的原子性
能保证可见性的措施:
- 除了volatile可以让变量保证可见性外,synchronized、Lock、并发集合,Thread.join()和Thread.start()等都可以保证可见性
- 具体看happens-before原则的规定
升华:对synchronized可见性的正确理解
- synchronized不仅保证了原子性,还保证了可见性
- synchronized不仅让被保护的代码安全,还近朱者赤(synchronized之前的代码都能被看到)
什么是原子性
- 一系列的操作,要么全部执行成功,要么全部不执行,不会出现执行一般的情况,是不可分割的
- ATM里取钱
- i++不是原子性的(i=1,i+1,i=2)
- 用synchronized实现原子性
Java中的原子操作有哪些?
- 除了long和double之外的基本类型(int,byte,boolean,short,char,float)的赋值操作
- 所有引用reference的赋值操作,不管是32为机器还是64位机器
- Java.concurrent.Atomic.*包中所有类的原子操作
原子操作+原子操作!=原子操作
- 简单地把原子操作组合在一起,并不能保证整体依然具有原子性
- 全同步的HashMap也不完全安全
锁
Java中同步加锁的关键字是什么?
synchronized
synchronized 的原理是什么?
Java中的每个对象都是对象锁(Object monitor),主要使用对象头标记字来实现。
synchronized 方法使用的是哪个对象锁?
实例方法锁的是 this 代表的对象;
静态方法锁的是对应的 Class 对象;
synchronized块使用的是 this 对象。
synchronized(obj)使用的是obj对象。
synchronized 有哪些优化?
synchronized方法优化
偏向锁: BiaseLock, 轻量锁,其开销相当于没有锁。
wait/notify 方法有什么作用?
- object.wait() : 放弃锁
- object.notify() : 通知一个等待的线程来抢这个锁
- object.notifyAll() : 通知所有等待的线程来抢这个锁
synchronized 和 Lock 有什么区别?
synchronized方式的问题:
- 同步块的阻塞无法中断(不能Interruptibly)
- 同步块的阻塞无法控制超时(无法自动解锁)
- 同步块无法异步处理锁(即不能立即知道是否可以拿到锁)
- 同步块无法根据条件灵活的加锁解锁(即只能跟同步块范围一致)
Lock 是更灵活的锁,使用方式灵活可控,支持更灵活的编程方式,性能开销小。 Lock接口设计:
// 1.支持中断的API
void lockInterruptibly() throws InterruptedException;
// 2.支持超时的API
boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
// 3.支持非阻塞获取锁的API
boolean tryLock();
// 4.可以根据条件灵活控制,newCondition设置多个通知信号
synchronized 和 Lock 相比,谁的性能高?
不一定,看具体场景。
synchronized退化成重量锁(Mutex)之后,高负载情况下性能开销会很大。
什么是可重入锁? 对象锁是不是可重入锁?
同一个线程,在执行到不同的方法时,可以多次获取这个锁。
synchronized 对应的锁属于可重入锁。
Java中的锁,一般都是重入锁,例如最基本的 ReentrantLock。
什么是公平锁? 对象锁是不是公平锁?
公平锁就是按申请的时间顺序,排队等待,依次分配。
synchronized 对应的锁是非公平锁,,这样做的目的是为了提高执行性能,缺点是可能会产生线程饥饿 现象。
ReentrantLock 提供了公平锁和非公平锁的实现。 无参构造函数默认创建的是非公平锁。
公平锁: new ReentrantLock(true)
非公平锁: new ReentrantLock(false)
什么是乐观锁? 什么是悲观锁?
悲观锁和乐观锁是一种逻辑上的概念,最早出现在数据库中。
悲观锁适用于比较悲观的场景(并发争用很激烈),采取直接加锁的方式。悲观地认为,不加锁的并发操 作一定会出问题。例如 synchronized 锁,或者数据库的 select for update 等。
乐观锁并不真实存在锁的状态,适用于比较乐观,并发竞争情况不高的场景。 避免了悲观锁独占锁资源 的现象,同时也提高了乐观场景下的并发程序执行性能。 比如数据库操作使用版本号,Java的原子类 等。
在具体使用时, 乐观锁只在更新数据的时候,通过判断现有的数据是否和原数据一致来判断数据是否被 其他线程操作,如果没被其他线程修改则进行数据更新,如果被其他线程修改则不进行数据更新(+自旋 重试/while循环)。
什么是自旋锁?
自旋一般就是while循环,持续进行条件比较,比如Java的CAS操作。
缺点是如果情况很悲观,长时间获取锁不成功而一直自旋,会给 CPU 带来很大的开销。
什么是独占锁和共享锁?
独占锁是指任何时候都只有一个线程能获取的锁。 【信号量=1的场景】
共享锁是指可以同时被多个线程共同持有的锁【信号量=N+的场景】。
什么是读写锁?
Java 中的 ReentrantReadWriteLock, 允许一个线程进行写操作,允许多个线程读操作。
其中包括了两把锁:
- 读锁, readerLock; 共享锁; 允许多个线程共同持有;
- 写锁, writerLock; 独占锁, 互斥锁; 只能有1个线程获取; 同时排斥对应的读锁;
注意:ReadWriteLock管理一组锁,一个读锁,一个写锁。
读锁可以在没有写锁的时候被多个线程同时持有,写锁是独占的。
所有读写锁的实现必须确保写操作对读操作的内存影响。每次只能有一个写线程,但是同时可以有多个 线程并发地读数据。ReadWriteLock适用于读多写少的并发情况。
使用锁有哪些注意事项
粒度、性能、重入、公平、自旋
根据具体场景来确定:
- 保证业务需求, 所以需要使用的时候就使用。
- 适当降低锁的粒度, 提高性能。
Doug Lea《Java 并发编程:设计原则与模式》一书中,推荐的三个用锁的最佳实践,分别是:
- 永远只在更新对象的成员变量时加锁
- 永远只在访问可变的成员变量时加锁
- 永远不在调用其他对象的方法时加锁
有那么锁使用的经验:
- 减少synchronized的范围
- 同步代码块中尽量短,减少同步代码块中代码的执行时间,减少锁的竞争。
- 降低synchronized锁的粒度
- 将一个锁拆分为多个锁提高并发度(ashtable锁整个表、ConcurrentHashMap锁列)
- 读写分离
- 读取时不加锁,写入和删除时加锁
synchronized 锁升级是怎么回事?
锁的4中状态:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态(级别从低到高)
(1)偏向锁:
为什么要引入偏向锁?
因为经过HotSpot的作者大量的研究发现,大多数时候是不存在锁竞争的,常常是一个线程多次获得同 一个锁,因此如果每次都要竞争锁会增大很多没有必要付出的代价,为了降低获取锁的代价,才引入的 偏向锁。
偏向锁的升级
当线程1访问代码块并获取锁对象时,会在java对象头和栈帧中记录偏向的锁的threadID,因为偏向锁 不会主动释放锁,因此以后线程1再次获取锁的时候,需要比较当前线程的threadID和Java对象头中的 threadID是否一致,如果一致(还是线程1获取锁对象),则无需使用CAS来加锁、解锁;如果不一致 (其他线程,如线程2要竞争锁对象,而偏向锁不会主动释放因此还是存储的线程1的threadID),那么 需要查看Java对象头中记录的线程1是否存活,如果没有存活,那么锁对象被重置为无锁状态,其它线 程(线程2)可以竞争将其设置为偏向锁;如果存活,那么立刻查找该线程(线程1)的栈帧信息,如果 还是需要继续持有这个锁对象,那么暂停当前线程1,撤销偏向锁,升级为轻量级锁,如果线程1 不再 使用该锁对象,那么将锁对象状态设为无锁状态,重新偏向新的线程。
偏向锁的取消:
偏向锁是默认开启的,而且开始时间一般是比应用程序启动慢几秒,如果不想有这个延迟,那么可以使 用-XX:BiasedLockingStartUpDelay=0;
如果不想要偏向锁,那么可以通过-XX:-UseBiasedLocking = false来设置;
(2)轻量级锁
为什么要引入轻量级锁?
轻量级锁考虑的是竞争锁对象的线程不多,而且线程持有锁的时间也不长的情景。因为阻塞线程需要 CPU从用户态转到内核态,代价较大,如果刚刚阻塞不久这个锁就被释放了,那这个代价就有点得不偿 失了,因此这个时候就干脆不阻塞这个线程,让它自旋这等待锁释放。
轻量级锁什么时候升级为重量级锁?
线程1获取轻量级锁时会先把锁对象的对象头MarkWord复制一份到线程1的栈帧中创建的用于存储锁 记录的空间(称为DisplacedMarkWord),然后使用CAS把对象头中的内容替换为线程1存储的锁记录 (DisplacedMarkWord)的地址;
如果在线程1复制对象头的同时(在线程1CAS之前),线程2也准备获取锁,复制了对象头到线程2的锁 记录空间中,但是在线程2CAS的时候,发现线程1已经把对象头换了,线程2的CAS失败,那么线程2就 尝试使用自旋锁来等待线程1释放锁。
但是如果自旋的时间太长也不行,因为自旋是要消耗CPU的,因此自旋的次数是有限制的,比如10次或 者100次,如果自旋次数到了线程1还没有释放锁,或者线程1还在执行,线程2还在自旋等待,这时又 有一个线程3过来竞争这个锁对象,那么这个时候轻量级锁就会膨胀为重量级锁。重量级锁把除了拥有 锁的线程都阻塞,防止CPU空转。
synchronized 和 volatile 的区别是什么?
- volatile本质是在告诉jvm当前变量在寄存器(工作内存)中的值是不确定的,需要从主存中读 取; synchronized则是锁定当前变量,只有当前线程可以访问该变量,其他线程被阻塞住。
- volatile仅能使用在变量级别;synchronized则可以使用在变量、方法、和类级别的
- volatile仅能实现变量的修改可见性,不能保证原子性;而synchronized则可以保证变量的修改可 见性和原子性
- volatile不会造成线程的阻塞;synchronized可能会造成线程的阻塞。
- volatile标记的变量不会被编译器优化;synchronized标记的变量可以被编译器优化
什么是死锁?怎么防止死锁?
什么是死锁
死锁是指多个进程因竞争资源而造成的一种僵局(互相等待),若无外力作用,这些进程都将无法向前 推进。例如,在某一个计算机系统中只有一台打印机和一台输入 设备,进程P1正占用输入设备,同时 又提出使用打印机的请求,但此时打印机正被进程P2 所占用,而P2在未释放打印机之前,又提出请求 使用正被P1占用着的输入设备。这样两个进程相互无休止地等待下去,均无法继续执行,此时两个进程 陷入死锁状态。
死锁产生的原因
- 系统资源的竞争
系统资源的竞争导致系统资源不足,以及资源分配不当,导致死锁。
- 进程运行推进顺序不合适
进程在运行过程中,请求和释放资源的顺序不当,会导致死锁。
死锁的四个必要条件
互斥条件:一个资源每次只能被一个进程使用,即在一段时间内某 资源仅为一个进程所占有。此时若有 其他进程请求该资源,则请求进程只能等待。
请求与保持条件:进程已经保持了至少一个资源,但又提出了新的资源请求,而该资源 已被其他进程占 有,此时请求进程被阻塞,但对自己已获得的资源保持不放。
不可剥夺条件:进程所获得的资源在未使用完毕之前,不能被其他进程强行夺走,即只能 由获得该资源 的进程自己来释放(只能是主动释放)。
循环等待条件: 若干进程间形成首尾相接循环等待资源的关系
这四个条件是死锁的必要条件,只要系统发生死锁,这些条件必然成立,而只要上述条件之一不满足, 就不会发生死锁。
死锁的避免
死锁避免的基本思想:系统对进程发出的每一个系统能够满足的资源申请进行动态检查,并根据检查结 果决定是否分配资源,如果分配后系统可能发生死锁,则不予分配,否则予以分配,这是一种保证系统 不进入死锁状态的动态策略。
如果操作系统能保证所有进程在有限时间内得到需要的全部资源,则系统处于安全状态否则系统是不安 全的。