数据结构论坛

首页 » 分类 » 定义 » 37道并发面试题总结含答案解析和思维
TUhjnbcbe - 2021/6/27 23:04:00
北京中医雀斑医院 http://m.39.net/baidianfeng/a_8714675.html
前言

关于Java面试中并发系列面试知识点总结了一个思维导图,分享给大家。

Q1:JMM的作用是什么?

Java线程的通信由JMM控制,JMM的主要目的是定义程序中各种变量的访问规则。变量包括实例字段、静态字段,但不包括局部变量与方法参数,因为它们是线程私有的,不存在多线程竞争。JMM遵循一个基本原则:只要不改变程序执行结果,编译器和处理器怎么优化都行。例如编译器分析某个锁只会单线程访问就消除锁,某个volatile变量只会单线程访问就把它当作普通变量。

JMM规定所有变量都存储在主内存,每条线程有自己的工作内存,工作内存中保存被该线程使用的变量的主内存副本,线程对变量的所有操作都必须在工作空间进行,不能直接读写主内存数据。不同线程间无法直接访问对方工作内存中的变量,线程通信必须经过主内存。

关于主内存与工作内存的交互,即变量如何从主内存拷贝到工作内存、从工作内存同步回主内存,JMM定义了8种原子操作:

操作作用变量范围作用lock主内存把变量标识为线程独占状态unlock主内存释放处于锁定状态的变量read主内存把变量值从主内存传到工作内存load工作内存把read得到的值放入工作内存的变量副本user工作内存把工作内存中的变量值传给执行引擎assign工作内存把从执行引擎接收的值赋给工作内存变量store工作内存把工作内存的变量值传到主内存write主内存把store取到的变量值放入主内存变量中Q2:as-if-serial是什么?

不管怎么重排序,单线程程序的执行结果不能改变,编译器和处理器必须遵循as-if-serial语义。

为了遵循as-if-serial,编译器和处理器不会对存在数据依赖关系的操作重排序,因为这种重排序会改变执行结果。但是如果操作之间不存在数据依赖关系,这些操作就可能被编译器和处理器重排序。

as-if-serial把单线程程序保护起来,给程序员一种幻觉:单线程程序是按程序的顺序执行的。

Q3:happens-before是什么?

先行发生原则,JMM定义的两项操作间的偏序关系,是判断数据是否存在竞争的重要手段。

JMM将happens-before要求禁止的重排序按是否会改变程序执行结果分为两类。对于会改变结果的重排序JMM要求编译器和处理器必须禁止,对于不会改变结果的重排序,JMM不做要求。

JMM存在一些天然的happens-before关系,无需任何同步器协助就已经存在。如果两个操作的关系不在此列,并且无法从这些规则推导出来,它们就没有顺序性保障,虚拟机可以对它们随意进行重排序。

程序次序规则:一个线程内写在前面的操作先行发生于后面的。管程锁定规则:unlock操作先行发生于后面对同一个锁的lock操作。volatile规则:对volatile变量的写操作先行发生于后面的读操作。线程启动规则:线程的start方法先行发生于线程的每个动作。线程终止规则:线程中所有操作先行发生于对线程的终止检测。对象终结规则:对象的初始化先行发生于finalize方法。传递性:如果操作A先行发生于操作B,操作B先行发生于操作C,那么操作A先行发生于操作C。Q4:as-if-serial和happens-before有什么区别?

as-if-serial保证单线程程序的执行结果不变,happens-before保证正确同步的多线程程序的执行结果不变。

这两种语义的目的都是为了在不改变程序执行结果的前提下尽可能提高程序执行并行度。

Q5:什么是指令重排序?

为了提高性能,编译器和处理器通常会对指令进行重排序,重排序指从源代码到指令序列的重排序,分为三种:①编译器优化的重排序,编译器在不改变单线程程序语义的前提下可以重排语句的执行顺序。②指令级并行的重排序,如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。③内存系统的重排序。

Q6:原子性、可见性、有序性分别是什么?

原子性

基本数据类型的访问都具备原子性,例外就是long和double,虚拟机将没有被volatile修饰的64位数据操作划分为两次32位操作。

如果应用场景需要更大范围的原子性保证,JMM还提供了lock和unlock操作满足需求,尽管JVM没有把这两种操作直接开放给用户使用,但是提供了更高层次的字节码指令monitorenter和monitorexit,这两个字节码指令反映到Java代码中就是synchronized。

可见性

可见性指当一个线程修改了共享变量时,其他线程能够立即得知修改。JMM通过在变量修改后将值同步回主内存,在变量读取前从主内存刷新的方式实现可见性,无论普通变量还是volatile变量都是如此,区别是volatile保证新值能立即同步到主内存以及每次使用前立即从主内存刷新。

除了volatile外,synchronized和final也可以保证可见性。同步块可见性由"对一个变量执行unlock前必须先把此变量同步回主内存,即先执行store和write"这条规则获得。final的可见性指:被final修饰的字段在构造方法中一旦初始化完成,并且构造方法没有把this引用传递出去,那么其他线程就能看到final字段的值。

有序性

有序性可以总结为:在本线程内观察所有操作是有序的,在一个线程内观察另一个线程,所有操作都是无序的。前半句指as-if-serial语义,后半句指指令重排序和工作内存与主内存延迟现象。

Java提供volatile和synchronized保证有序性,volatile本身就包含禁止指令重排序的语义,而synchronized保证一个变量在同一时刻只允许一条线程对其进行lock操作,确保持有同一个锁的两个同步块只能串行进入。

Q7:谈一谈volatile

JMM为volatile定义了一些特殊访问规则,当变量被定义为volatile后具备两种特性:

保证变量对所有线程可见

当一条线程修改了变量值,新值对于其他线程来说是立即可以得知的。volatile变量在各个线程的工作内存中不存在一致性问题,但Java的运算操作符并非原子操作,导致volatile变量运算在并发下仍不安全。

禁止指令重排序优化

使用volatile变量进行写操作,汇编指令带有lock前缀,相当于一个内存屏障,后面的指令不能重排到内存屏障之前。

使用lock前缀引发两件事:①将当前处理器缓存行的数据写回系统内存。②使其他处理器的缓存无效。相当于对缓存变量做了一次store和write操作,让volatile变量的修改对其他处理器立即可见。

静态变量i执行多线程i++的不安全问题

自增语句由4条字节码指令构成的,依次为getstatic、iconst_1、iadd、putstatic,当getstatic把i的值取到操作栈顶时,volatile保证了i值在此刻正确,但在执行iconst_1、iadd时,其他线程可能已经改变了i值,操作栈顶的值就变成了过期数据,所以putstatic执行后就可能把较小的i值同步回了主内存。

适用场景

①运算结果并不依赖变量的当前值。②一写多读,只有单一的线程修改变量值。

内存语义

写一个volatile变量时,把该线程工作内存中的值刷新到主内存。

读一个volatile变量时,把该线程工作内存值置为无效,从主内存读取。

指令重排序特点

第二个操作是volatile写,不管第一个操作是什么都不能重排序,确保写之前的操作不会被重排序到写之后。

第一个操作是volatile读,不管第二个操作是什么都不能重排序,确保读之后的操作不会被重排序到读之前。

第一个操作是volatile写,第二个操作是volatile读不能重排序。

JSR-增强volatile语义的原因

在旧的内存模型中,虽然不允许volatile变量间重排序,但允许volatile变量与普通变量重排序,可能导致内存不可见问题。JSR-严格限制编译器和处理器对volatile变量与普通变量的重排序,确保volatile的写-读和锁的释放-获取具有相同的内存语义。

Q8:final可以保证可见性吗?

final可以保证可见性,被final修饰的字段在构造方法中一旦被初始化完成,并且构造方法没有把this引用传递出去,在其他线程中就能看见final字段值。

在旧的JMM中,一个严重缺陷是线程可能看到final值改变。比如一个线程看到一个int类型final值为0,此时该值是未初始化前的零值,一段时间后该值被某线程初始化,再去读这个final值会发现值变为1。

为修复该漏洞,JSR-为final域增加重排序规则:只要对象是正确构造的(被构造对象的引用在构造方法中没有逸出),那么不需要使用同步就可以保证任意线程都能看到这个final域初始化后的值。

写final域重排序规则

禁止把final域的写重排序到构造方法之外,编译器会在final域的写后,构造方法的return前,插入一个StoreStore屏障。确保在对象引用为任意线程可见之前,对象的final域已经初始化过。

读final域重排序规则

在一个线程中,初次读对象引用和初次读该对象包含的final域,JMM禁止处理器重排序这两个操作。编译器在读final域操作的前面插入一个LoadLoad屏障,确保在读一个对象的final域前一定会先读包含这个final域的对象引用。

Q8:谈一谈synchronized

每个Java对象都有一个关联的monitor,使用synchronized时JVM会根据使用环境找到对象的monitor,根据monitor的状态进行加解锁的判断。如果成功加锁就成为该monitor的唯一持有者,monitor在被释放前不能再被其他线程获取。

同步代码块使用monitorenter和monitorexit这两个字节码指令获取和释放monitor。这两个字节码指令都需要一个引用类型的参数指明要锁定和解锁的对象,对于同步普通方法,锁是当前实例对象;对于静态同步方法,锁是当前类的Class对象;对于同步方法块,锁是synchronized括号里的对象。

执行monitorenter指令时,首先尝试获取对象锁。如果这个对象没有被锁定,或当前线程已经持有锁,就把锁的计数器加1,执行monitorexit指令时会将锁计数器减1。一旦计数器为0锁随即就被释放。

例如有两个线程A、B竞争monitor,当A竞争到锁时会将monitor中的owner设置为A,把B阻塞并放到等待资源的ContentionList队列。ContentionList中的部分线程会进入EntryList,EntryList中的线程会被指定为OnDeck竞争候选者,如果获得了锁资源将进入Owner状态,释放锁后进入!Owner状态。被阻塞的线程会进入WaitSet。

被synchronized修饰的同步块对一条线程来说是可重入的,并且同步块在持有锁的线程释放锁前会阻塞其他线程进入。从执行成本的角度看,持有锁是一个重量级的操作。Java线程是映射到操作系统的内核线程上的,如果要阻塞或唤醒一条线程,需要操作系统帮忙完成,不可避免用户态到核心态的转换。

不公平的原因

所有收到锁请求的线程首先自旋,如果通过自旋也没有获取锁将被放入ContentionList,该做法对于已经进入队列的线程不公平。

为了防止ContentionList尾部的元素被大量线程进行CAS访问影响性能,Owner线程会在释放锁时将ContentionList的部分线程移动到EntryList并指定某个线程为OnDeck线程,该行为叫做竞争切换,牺牲了公平性但提高了性能。

Q9:锁优化有哪些策略?

JDK6对synchronized做了很多优化,引入了自适应自旋、锁消除、锁粗化、偏向锁和轻量级锁等提高锁的效率,锁一共有4个状态,级别从低到高依次是:无锁、偏向锁、轻量级锁和重量级锁,状态会随竞争情况升级。锁可以升级但不能降级,这种只能升级不能降级的锁策略是为了提高锁获得和释放的效率。

Q10:自旋锁是什么?

同步对性能最大的影响是阻塞,挂起和恢复线程的操作都需要转入内核态完成。许多应用上共享数据的锁定只会持续很短的时间,为了这段时间去挂起和恢复线程并不值得。如果机器有多个处理器核心,我们可以让后面请求锁的线程稍等一会,但不放弃处理器的执行时间,看看持有锁的线程是否很快会释放锁。为了让线程等待只需让线程执行一个忙循环,这项技术就是自旋锁。

自旋锁在JDK1.4就已引入,默认关闭,在JDK6中改为默认开启。自旋不能代替阻塞,虽然避免了线程切换开销,但要占用处理器时间,如果锁被占用的时间很短,自旋的效果就会非常好,反之只会白白消耗处理器资源。如果自旋超过了限定的次数仍然没有成功获得锁,就应挂起线程,自旋默认限定次数是10。

Q11:什么是自适应自旋?

JDK6对自旋锁进行了优化,自旋时间不再固定,而是由前一次的自旋时间及锁拥有者的状态决定。

如果在同一个锁上,自旋刚刚成功获得过锁且持有锁的线程正在运行,虚拟机会认为这次自旋也很可能成功,进而允许自旋持续更久。如果自旋很少成功,以后获取锁时将可能直接省略掉自旋,避免浪费处理器资源。

有了自适应自旋,随着程序运行时间的增长,虚拟机对程序锁的状况预测就会越来越精准。

Q12:锁消除是什么?

锁消除指即时编译器对检测到不可能存在共享数据竞争的锁进行消除。

主要判定依据来源于逃逸分析,如果判断一段代码中堆上的所有数据都只被一个线程访问,就可以当作栈上的数据对待,认为它们是线程私有的而无须同步。

Q13:锁粗化是什么?

原则需要将同步块的作用范围限制得尽量小,只在共享数据的实际作用域中进行同步,这是为了使等待锁的线程尽快拿到锁。

但如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁操作是出现在循环体之外的,即使没有线程竞争也会导致不必要的性能消耗。因此如果虚拟机探测到有一串零碎的操作都对同一个对象加锁,将会把同步的范围扩展到整个操作序列的外部。

Q14:偏向锁是什么?

偏向锁是为了在没有竞争的情况下减少锁开销,锁会偏向于第一个获得它的线程,如果在执行过程中锁一直没有被其他线程获取,则持有偏向锁的线程将不需要进行同步。

当锁对象第一次被线程获取时,虚拟机会将对象头中的偏向模式设为1,同时使用CAS把获取到锁的线程ID记录在对象的MarkWord中。如果CAS成功,持有偏向锁的线程以后每次进入锁相关的同步块都不再进行任何同步操作。

一旦有其他线程尝试获取锁,偏向模式立即结束,根据锁对象是否处于锁定状态决定是否撤销偏向,后续同步按照轻量级锁那样执行。

Q15:轻量级锁是什么?

轻量级锁是为了在没有竞争的前提下减少重量级锁使用操作系统互斥量产生的性能消耗。

在代码即将进入同步块时,如果同步对象没有被锁定,虚拟机将在当前线程的栈帧中建立一个锁记录空间,存储锁对象目前MarkWord的拷贝。然后虚拟机使用CAS尝试把对象的MarkWord更新为指向锁记录的指针,如果更新成功即代表该线程拥有了锁,锁标志位将转变为00,表示处于轻量级锁定状态。

如果更新失败就意味着至少存在一条线程与当前线程竞争。虚拟机检查对象的MarkWord是否指向当前线程的栈帧,如果是则说明当前线程已经拥有了锁,直接进入同步块继续执行,否则说明锁对象已经被其他线程抢占。如果出现两条以上线程争用同一个锁,轻量级锁就不再有效,将膨胀为重量级锁,锁标志状态变为10,此时MarkWord存储的就是指向重量级锁的指针,后面等待锁的线程也必须阻塞。

解锁同样通过CAS进行,如果对象MarkWord仍然指向线程的锁记录,就用CAS把对象当前的MarkWord和线程复制的MarkWord替换回来。假如替换成功同步过程就顺利完成了,如果失败则说明有其他线程尝试过获取该锁,就要在释放锁的同时唤醒被挂起的线程。

Q16:偏向锁、轻量级锁和重量级锁的区别?

偏向锁的优点是加解锁不需要额外消耗,和执行非同步方法比仅存在纳秒级差距,缺点是如果存在锁竞争会带来额外锁撤销的消耗,适用只有一个线程访问同步代码块的场景。

轻量级锁的优点是竞争线程不阻塞,程序响应速度快,缺点是如果线程始终得不到锁会自旋消耗CPU,适用追求响应时间、同步代码块执行快的场景。

重量级锁的优点是线程竞争不使用自旋不消耗CPU,缺点是线程会阻塞,响应时间慢,适应追求吞吐量、同步代码块执行慢的场景。

Q17:Lock和synchronized有什么区别?

Lock接是juc包的顶层接口,基于Lock接口,用户能够以非块结构来实现互斥同步,摆脱了语言特性束缚,在类库层面实现同步。Lock并未用到synchronized,而是利用了volatile的可见性。

重入锁ReentrantLock是Lock最常见的实现,与synchronized一样可重入,不过它增加了一些高级功能:

**等待可中断:**持有锁的线程长期不释放锁时,正在等待的线程可以选择放弃等待而处理其他事情。公平锁:公平锁指多个线程在等待同一个锁时,必须按照申请锁的顺序来依次获得锁,而非公平锁不保证这一点,在锁被释放时,任何线程都有机会获得锁。synchronized是非公平的,ReentrantLock在默认情况下是非公平的,可以通过构造方法指定公平锁。一旦使用了公平锁,性能会急剧下降,影响吞吐量。锁绑定多个条件:一个ReentrantLock可以同时绑定多个Condition。synchronized中锁对象的wait跟notify可以实现一个隐含条件,如果要和多个条件关联就不得不额外添加锁,而ReentrantLock可以多次调用newCondition创建多个条件。

一般优先考虑使用synchronized:①synchronized是语法层面的同步,足够简单。②Lock必须确保在finally中释放锁,否则一旦抛出异常有可能永远不会释放锁。使用synchronized可以由JVM来确保即使出现异常锁也能正常释放。③尽管JDK5时ReentrantLock的性能优于synchronized,但在JDK6进行锁优化后二者的性能基本持平。从长远来看JVM更容易针对synchronized优化,因为JVM可以在线程和对象的元数据中记录synchronized中锁的相关信息,而使用Lock的话JVM很难得知具体哪些锁对象是由特定线程持有的。

Q18:ReentrantLock的可重入是怎么实现的?

以非公平锁为例,通过nonfairTryAcquire方法获取锁,该方法增加了再次获取同步状态的处理逻辑:判断当前线程是否为获取锁的线程来决定获取是否成功,如果是获取锁的线程再次请求则将同步状态值增加并返回true,表示获取同步状态成功。

成功获取锁的线程再次获取锁将增加同步状态值,释放同步状态时将减少同步状态值。如果锁被获取了n次,那么前n-1次tryRelease方法必须都返回fasle,只有同步状态完全释放才能返回true,该方法将同步状态是否为0作为最终释放条件,释放时将占有线程设置为null并返回true。

对于非公平锁只要CAS设置同步状态成功则表示当前线程获取了锁,而公平锁则不同。公平锁使用tryAcquire方法,该方法与nonfairTryAcquire的唯一区别就是判断条件中多了对同步队列中当前节点是否有前驱节点的判断,如果该方法返回true表示有线程比当前线程更早请求锁,因此需要等待前驱线程获取并释放锁后才能获取锁。

Q19:什么是读写锁?

ReentrantLock是排他锁,同一时刻只允许一个线程访问,读写锁在同一时刻允许多个读线程访问,在写线程访问时,所有的读写线程均阻塞。读写锁维护了一个读锁和一个写锁,通过分离读写锁使并发性相比排他锁有了很大提升。

读写锁依赖AQS来实现同步功能,读写状态就是其同步器的同步状态。读写锁的自定义同步器需要在同步状态,即一个int变量上维护多个读线程和一个写线程的状态。读写锁将变量切分成了两个部分,高16位表示读,低16位表示写。

写锁是可重入排他锁,如果当前线程已经获得了写锁则增加写状态,如果当前线程在获取写锁时,读锁已经被获取或者该线程不是已经获得写锁的线程则进入等待。写锁的释放与ReentrantLock的释放类似,每次释放减少写状态,当写状态为0时表示写锁已被释放。

读锁是可重入共享锁,能够被多个线程同时获取,在没有其他写线程访问时,读锁总会被成功获取。如果当前线程已经获取了读锁,则增加读状态。如果当前线程在获取读锁时,写锁已被其他线程获取则进入等待。读锁每次释放会减少读状态,减少的值是(),读锁的释放是线程安全的。

锁降级指把持住当前拥有的写锁,再获取读锁,随后释放先前拥有的写锁。

锁降级中读锁的获取是必要的,这是为了保证数据可见性,如果当前线程不获取读锁而直接释放写锁,假设此刻另一个线程A获取写锁修改了数据,当前线程无法感知线程A的数据更新。如果当前线程获取读锁,遵循锁降级的步骤,A将被阻塞,直到当前线程使用数据并释放读锁之后,线程A才能获取写锁进行数据更新。

Q20:AQS了解吗?

AQS队列同步器是用来构建锁或其他同步组件的基础框架,它使用一个volatileintstate变量作为共享资源,如果线程获取资源失败,则进入同步队列等待;如果获取成功就执行临界区代码,释放资源时会通知同步队列中的等待线程。

同步器的主要使用方式是继承,子类通过继承同步器并实现它的抽象方法来管理同步状态,对同步状态进行更改需要使用同步器提供的3个方法getState、setState和

1
查看完整版本: 37道并发面试题总结含答案解析和思维