Java并发面试题整理(内存模型与锁)(附答案,持续更新)

768 阅读20分钟

这是我参与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方式的问题:

  1. 同步块的阻塞无法中断(不能Interruptibly)
  2. 同步块的阻塞无法控制超时(无法自动解锁)
  3. 同步块无法异步处理锁(即不能立即知道是否可以拿到锁)
  4. 同步块无法根据条件灵活的加锁解锁(即只能跟同步块范围一致)

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 并发编程:设计原则与模式》一书中,推荐的三个用锁的最佳实践,分别是:

  1. 永远只在更新对象的成员变量时加锁
  2. 永远只在访问可变的成员变量时加锁
  3. 永远不在调用其他对象的方法时加锁

有那么锁使用的经验:

  • 减少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占用着的输入设备。这样两个进程相互无休止地等待下去,均无法继续执行,此时两个进程 陷入死锁状态。

死锁产生的原因

  1. 系统资源的竞争

系统资源的竞争导致系统资源不足,以及资源分配不当,导致死锁。

  1. 进程运行推进顺序不合适

进程在运行过程中,请求和释放资源的顺序不当,会导致死锁。

死锁的四个必要条件

互斥条件:一个资源每次只能被一个进程使用,即在一段时间内某 资源仅为一个进程所占有。此时若有 其他进程请求该资源,则请求进程只能等待。

请求与保持条件:进程已经保持了至少一个资源,但又提出了新的资源请求,而该资源 已被其他进程占 有,此时请求进程被阻塞,但对自己已获得的资源保持不放。

不可剥夺条件:进程所获得的资源在未使用完毕之前,不能被其他进程强行夺走,即只能 由获得该资源 的进程自己来释放(只能是主动释放)。

循环等待条件: 若干进程间形成首尾相接循环等待资源的关系

这四个条件是死锁的必要条件,只要系统发生死锁,这些条件必然成立,而只要上述条件之一不满足, 就不会发生死锁。

死锁的避免

死锁避免的基本思想:系统对进程发出的每一个系统能够满足的资源申请进行动态检查,并根据检查结 果决定是否分配资源,如果分配后系统可能发生死锁,则不予分配,否则予以分配,这是一种保证系统 不进入死锁状态的动态策略。

如果操作系统能保证所有进程在有限时间内得到需要的全部资源,则系统处于安全状态否则系统是不安 全的。