面试难度:★★★★★
考察概率:★★★★★
本人从毕业开始一直在一线互联网大厂工作,现任技术TL,出版过《深入理解Java并发》一书,折腾过技术开源项目,并长期作为面试官参与面试,深谙双方的诉求与技术沟通。如今归零心态,再出发。#莫等闲,白了少年头#
技术交流+v:xxxyxsyy1234(和笔者一起努力,每日打卡) 2000+以面试官视角总结的考点,可与我共同打卡学习
面试官视角
JMM体系可以说是整个java并发的理论基石,只有能够对JMM有一定的理解,才能知道高并发下线程安全问题的来源,进而对这些问题进行分析。这块的知识是比较枯燥的,也是大部分候选人的知识盲点,面试官如果考察到这块的知识点,是很容易分辨出候选人自学能力以及对知识点的刨根问底的能力的。至少从我作为面试官的经历来看,如果是面试高阶开发岗位时,我是会必问这块的。
面试题
- 什么是JMM机制?
- JMM为什么会带来线程安全的问题?
- 什么是指令重排序以及如何解决重排序带来的线程安全问题?
- happens-before规则?
- JMM与happens-before关系?
回答要点
1. 什么是JMM机制?
对多线程而言,最重要的是多线程间对共享变量的读写操作以及线程间的通信如何管理的问题。因此,JMM可以通俗的理解成就是沉淀了一个逻辑模型,去解决上述这两类问题。线程之间以何种机制来交换信息,主要有两种:共享内存和消息传递。在JVM的通信机制中选择的是基于共享内存的并发模型,线程之间主要通过读-写共享变量来完成隐式通信。如果程序员无法深刻的JMM(Java Memory Model)模型,在实际开发中很容易遇到一些很难理解的“奇怪现象”,并且很难解决这类线上问题。
- 哪些是共享变量
根据JVM运行时内存结构可知,在java程序中所有实例域,静态域和数组元素都是放在堆内存中(所有线程均可访问到,是可以共享的),而局部变量,方法定义参数和异常处理器参数不会在线程间共享。共享数据会出现线程安全的问题,而非共享数据不会出现线程安全的问题。 - JMM抽象结构模型
CPU的处理速度和主存的读写速度不是一个量级的,为了平衡这种巨大的差距,实际上每个CPU核心都会有缓存。因此,共享变量会先放在主存中,每个线程都有属于自己的工作内存,并且会把位于主存中的共享变量拷贝到自己的工作内存,之后的读写操作均使用位于工作内存的变量副本,并在某个时刻将工作内存的变量副本写回到主存中去。JMM从抽象层次定义了这种方式,并且JMM决定了一个线程对共享变量的写入何时对其他线程是可见的。
如上图所示线程A和线程B之间要完成通信的话,要经历如下两步:
- 线程A从主内存中将共享变量读入线程A的工作内存后会形成当前变量值的副本,然后线程A会基于这份变量副本执行相应的业务操作后,线程A之后在特定的时机将数据重新写回到主内存中;
- 线程B从主存中读取到由A进行“加工处理”后的值,再继续执行线程B的业务功能;
可加分亮点
面试官心理:要能解决一项难题,最重要的前提是要能够十分清晰的定义到问题是什么?很多候选人都只会很支离破碎的背诵一些八股文,对并发为什么容易产生线程安全的原因很少有自己的深刻理解。其实以JMM逻辑模型为基础,进行发散思考,就可以很清晰的定义,并发容易产生线程安全的问题,进而针对这些原因很自然而然参考操作系统中多进程间通信的解决方案,就会很自然而然的想到潜在的解法。如果能有这个发散和总结思考的能力,这种结构化思维,在实际工作中是一种很稀缺的思维方式,相信我,如果你能讲出这个逻辑,面试官会两眼放光。
至少在我面试的时候,我都会去问这类问题,我更希望候选者能够有自己的思考,而不是千篇一律的去背八股文
并发编程模型是什么?
如上图,并发编程模型主要包含了两个重点:
- 线程间通信机制:在多线程条件下,多个线程肯定会相互协作完成一件事情,一般来说就会涉及到多个线程间相互通信告知彼此的状态以及当前的执行结果等。多线程间并发协作对数据的并更是基于JMM共享内存的隐式通信机制完成的。另外基于JMM系统理论体系,不同的JVM厂商会实现不同的同步语义;
- 线程同步机制:要想解决多线程间对共享数据的安全变更,那么就涉及到多线程对数据访问相对顺序的控制,以达到线程安全的目的。在实际开发中会基于同步组件来实现对系统共享变量的访问控制。
2. JMM为什么会带来线程安全的问题?
JMM(Java Memory Model)是Java虚拟机规范,它规定了Java程序中多线程访问共享内存时,内存模型的行为规则。由于Java支持多线程编程,多个线程可能同时访问同一份共享数据,而JMM机制的作用是保证多线程访问共享内存的可见性和有序性。
在JMM中,线程之间的通信是通过内存中的共享变量来完成的。每个线程都拥有自己的工作内存和独立的线程栈,而这些变量实际上都存放在主内存中。当线程要访问一个共享变量时,它先从主内存中读取变量的值,然后将这个值复制到自己的工作内存中,在工作内存中对变量进行操作,最后将修改后的值写回主内存中。
JMM机制保证了多个线程对共享变量的操作具有原子性、可见性和顺序性,保障了Java多线程编程的正确性。但同时,JMM机制也会带来一些线程安全问题。例如,当一个线程修改了一个共享变量的值,如果没有使用合适的同步机制,其他线程可能无法立即感知到这个修改,从而产生数据不一致的问题。
另外,JMM中的重排序操作也可能会导致线程安全问题。JMM允许对程序顺序进行优化,因此在编译器和处理器中会对指令进行重排序,以提高程序的运行效率。但当这些重排序操作涉及到共享变量时,就可能会出现线程安全问题,因为这些操作可能改变程序的执行顺序,导致程序的行为不是预期的。
为了解决这些线程安全问题,程序员需要使用同步机制(如synchronized关键字、volatile关键字等)来保证代码的正确性,并避免程序出现意外的行为。
3. 什么是指令重排序以及如何解决重排序带来的线程安全问题?
指令重排序是处理器为了提高程序性能,根据数据和控制相关性的可靠分析,在不改变程序执行结果的前提下,通过重新安排指令的执行顺序,以最大限度地减少处理器等待操作而进行的一种优化。指令重排序的目的是为了让程序运行效率更高,从而提高程序的性能。指令重排序的过程如下:
1.编译器优化的重排序:编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序;
2. 指令级并行的重排序: 现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序;
3. 内存系统的重排序: 由于处理器使用缓存和读/写缓冲区,会对变量值的加载和存储的执行顺序重新进行排列,尽可能的利用到缓存的高速读写特性;
但是,指令重排序也会给Java并发编程带来一些潜在的线程安全风险,因为各个线程对同一共享变量的访问顺序可能会被重排序,从而影响程序的正确性。比如,假设一个对象的构造函数中某个共享变量的初始化问题,如果在初始化完成之前该对象被另一个线程使用,就可能出现未知的结果。
为了解决指令重排序带来的线程安全问题,Java虚拟机提供了volatile和synchronized两种机制,保证Java程序的线程安全。其中,volatile关键字强制线程在每次访问变量时必须读取主内存中的最新值,同时也会强制禁止指令重排序。而synchronized关键字则是通过互斥量和临界区来保证同一时间只有一个线程能够访问被保护的共享资源,从而保证了线程安全。
可加分亮点
面试官心理:通常在写业务代码时,都会感觉计算机执行代码时是一行一行执行的,带着这样的“错觉”时分析并发安全的问题,程序员基本上很难去构造和想象那些极端的场景去复现。关于这种错觉其实是有个语义理论支撑的,这块实际上也很冷门,如果候选人能够大致聊聊,也会是很不错的一个点。
as-if-serial语义
as-if-serial语义的意思是:不管怎么重排序(编译器和处理器为了提供并行度),(单线程)程序的执行结果不能被改变。编译器处理器都必须遵守as-if-serial语义。
在通常情况下,代码的执行总会给人一种“错觉”代码的执行是按照代码编写的顺序,一行一行的按照顺序执行的。实际上这就是as-if-serial的语义给单线程视角下代码执行顺序带来的保护,只要程序之间没有任何数据依赖性,在能够提升代码执行效率的前提下依然会进行指令的重排。但是,对于开发者而言就不需要去关注到底层指令是如何重排的以及也无需担心值可见性的问题,只需要关注到代码本身的业务功能即可,大大的提升了开发效率。
从最终目的而言,happens-before关系本质上和as-if-serial语义是一回事,都是为了保护开发者,保证开发者能够拥有最高的开发效率摒弃底层繁杂的指令排序的规则。as-if-serial是在单线程状态下定义的数据可见性以及执行时序的约束,而happens-before则是在并发实体下给到的标准约束。
下面来比较一下as-if-serial和happens-before:
-
as-if-serial语义保证单线程内程序的执行结果不被改变,而happens-before关系保证正确同步的多线程程序的执行结果不被改变;
-
as-if-serial语义为开发者创造了一个“错觉”,单线程下程序的执行顺序是按照代码编程顺序执行的,而happens-before关系则在多个并行实体的情况下,数据的跨线程可见性是按照happens-before来决定的。这几项规则非常精简的抽象了常见的数据访问的场景,开发者只需要关注到这几项规则就能够胜任复杂的并发编程;
-
as-if-serial语义和happens-before这么做的目的,都是为了在不改变程序执行结果的前提下,尽可能地提高程序执行的并行度。而对开发者而言,规范和标准的建立,则是尽最大的程度降低开发者的负担,提升开发者的开发效率。
4. happens-before规则?
(1)程序顺序规则:一个线程中的每个操作happens-before于该线程中的任意后续操作。
(2)监视器锁规则:对一个锁的解锁happens-before于随后对这个锁的加锁。这保证了同步代码块的互斥性。
(3)volatile变量规则:对一个volatile域的写happens-before于任意后续对这个volatilel的读。这保证了对volatile变量的可见性。
(4)传递性:如果A happens-before B,且B happens-before C,那么 A happens-before C。这保证了操作之间的顺序一致。
(5)start()规则:如果线程A执行操作ThreadB.start()(启动线程B),那么线程A.ThreadB.start()操作 happens-before于线程B中的任意操作。
(6)join()规则:如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意
作 happens-before 于线程A从ThreadB.join()操作成功返回。
(7)程序中断规则:对线程interrupt()方法的调用先于被中断线程的响应操作。
(8)线程终止规则:所有操作都先于线程终止操作,如执行完run()方法或调用stop()方法等。
(9)对象finalize规则:一个对象的初始化完成(构造函数执行结束)先行于发生它finalize()方法的开始。
5. JMM与happens-before关系?
JMM(Java Memory Model)是Java语言中用来规范多线程的内存访问模型与内存语义的规范,它定义了程序中各个线程之间的操作执行顺序。同时,JMM又是happens-before规则的实现基础。
在JMM中,每个线程都有自己的本地内存区域,线程之间共享的是主内存。当线程对共享变量进行操作时,会先将变量从主内存拷贝到本地内存,并进行读写操作。操作完成后,才会将变量的最新值刷新回主内存。在多线程并发执行时,可能会存在竞态条件(Race Condition)的情况,即两个或多个线程尝试同时访问同一个共享资源,导致数据的不一致性。为了避免这种情况的发生,需要满足一些约束条件,即happens-before规则。
happens-before规则使用一组约束条件来定义在多线程环境下操作执行的顺序,并保证了线程安全性。它是对Java内存模型的具体实现。在Java程序中,当两个操作之间满足happens-before关系时,就可以保证这两个操作执行的顺序。happens-before关系是一种强约束条件,同时也是可传递的,因此它可以组成一条操作序列。
JMM通过happens-before规则保证了同步代码块、volatile变量、Thread.start()、Thread.join()等各种多线程操作的正确性及顺序执行。通过使用volatile关键字、synchronized关键字、Atomic类等工具,在多线程程序中正确使用JMM和happens-before规则,可以保证正确的多线程程序执行。
代码考核
这块不会有手撕代码的情况,但有可能会给到一串代码,按照happens-before规则进行推演,来判断变量可见性的情况,具体可以参照第4点解答。