阿里面试,一面就倒在了 Java 内存模型上?赶紧来看看
最近金三银四跳槽找工作的高峰期,我也凑热闹准备出去看看机会,结果就写了简历,一边投一边复习准备试试水,结果一周内就接到了阿里的面试邀约,结果一面就凉了。
虽然还没开始就结束了,伤心归伤心,失落就失落,该复盘我们还是要的撒?
短短半个小时,我觉得回答最不好的可能就是面试官问:谈谈你对Java内存模型的理解?
这个问题吧,问的比较泛,可能是想考察我到底有没有真的理解掌握了,如果你最近也在面试,那么很大几率也是会问到这个问题,毕竟搞Java的,没有不问并发的。而Java内存模型是一个人并发水平的一种体现。 原因是当并发程序出现问题时,需要一行一行的审视代码,这个时候,只有掌握Java内存模型,才能慧眼如炬的发现问题。
什么是Java内存模型
在学习Java内存模型之前,我们先思考下,Java内存模型主要是为了用来做什么?为什么要定义它?带着这个问题我们接着往下看。
在并发编程中有一句话你要牢牢记住,那就是: 可见性、有序性、原子性 这三者是所有并发bug的源头。
可见性问题
多核时代,每颗 CPU 都有自己的缓存,这时 CPU 缓存与内存的数据一致性就没那么容易解决了,当多个线程在不同的 CPU 上执行时,这些线程操作的是不同的 CPU 缓存。比如下图中,线程 A 操作的是 CPU-1 上的缓存,而线程 B 操作的是 CPU-2 上的缓存,很明显,这个时候线程 A 对变量 V 的操作对于线程 B 而言就不具备可见性了。这个就属于硬件程序员给软件程序员挖的“坑”。
原子性问题
早期的操作系统基于进程来调度 CPU,不同进程间是不共享内存空间的,所以进程要做任务切换就要切换内存映射地址,而一个进程创建的所有线程,都是共享一个内存空间的,所以线程做任务切换成本就很低了。现代的操作系统都基于更轻量的线程来调度,现在我们提到的“任务切换”都是指“线程切换”。
Java 并发程序都是基于多线程的,自然也会涉及到任务切换,也许你想不到,任务切换竟然也是并发编程里诡异 Bug 的源头之一。任务切换的时机大多数是在时间片结束的时候,我们现在基本都使用高级语言编程,高级语言里一条语句往往需要多条 CPU 指令完成,例如上面代码中的count += 1,至少需要三条 CPU 指令。
指令 1:首先,需要把变量 count 从内存加载到 CPU 的寄存器;
指令 2:之后,在寄存器中执行 +1 操作;
指令 3:最后,将结果写入内存(缓存机制导致可能写入的是 CPU 缓存而不是内存)。
操作系统做任务切换,可以发生在任何一条 CPU 指令执行完,是的,是 CPU 指令,而不是高级语言里的一条语句。对于上面的三条指令来说,我们假设 count=0,如果线程 A 在指令 1 执行完后做线程切换,线程 A 和线程 B 按照下图的序列执行,那么我们会发现两个线程都执行了 count+=1 的操作,但是得到的结果不是我们期望的 2,而是 1。
这里你可能有一点不好理解,那就是 count+=1,一般来说我们就觉得这行代码是一个整体,就算 CPU 切换也是在这行代码之前或者之后,其实这里犯了想当然的毛病了。 一个或多个操作在CPU执行过程中不被中断的特性成为原子性。 CPU 保证的原子性是指的CPU指令层面, 而并不能保证我们高级语言的一行操作符的原子性,所以我们就需要在很多场景实现操作符层面的原子性。
指令重排序带来的问题
编译器为了优化性能,有时候会改变程序中语句的先后顺序,例如程序中:“a=6;b=7;”编译器优化后可能变成“b=7;a=6;”,在这个例子中,编译器调整了语句的顺序,但是不影响程序的最终结果。不过有时候编译器及解释器的优化可能导致意想不到的 Bug。
一个经典的案例就是利用双重检查创建单例对象,例如下面的代码:在获取实例 getInstance() 的方法中,我们首先判断 instance 是否为空,如果为空,则锁定 Singleton.class 并再次检查 instance 是否为空,如果还为空则创建 Singleton 的一个实例。
假设有两个线程 A、B 同时调用 getInstance() 方法,他们会同时发现 instance == null ,于是同时对 Singleton.class 加锁,此时 JVM 保证只有一个线程能够加锁成功(假设是线程 A),另外一个线程则会处于等待状态(假设是线程 B);线程 A 会创建一个 Singleton 实例,之后释放锁,锁释放后,线程 B 被唤醒,线程 B 再次尝试加锁,此时是可以加锁成功的,加锁成功后,线程 B 检查 instance == null 时会发现,已经创建过 Singleton 实例了,所以线程 B 不会再创建一个 Singleton 实例。这看上去一切都很完美,无懈可击,但实际上这个 getInstance() 方法并不完美。问题出在哪里呢?出在 new 操作上,我们以为的 new 操作应该是:
分配一块内存 M;
在内存 M 上初始化 Singleton 对象;
然后 M 的地址赋值给 instance 变量。
实际上优化后的执行路径却是这样的:
分配一块内存 M;
将 M 的地址赋值给 instance 变量;
最后在内存 M 上初始化 Singleton 对象。
优化后会导致什么问题呢?我们假设线程 A 先执行 getInstance() 方法,当执行完指令 2 时恰好发生了线程切换,切换到了线程 B 上;如果此时线程 B 也执行 getInstance() 方法,那么线程 B 在执行第一个判断时会发现 instance != null ,所以直接返回 instance,而此时的 instance 是没有初始化过的,如果我们这个时候访问 instance 的成员变量就可能触发空指针异常。
实际上为了提高性能,编译器和处理器在运行时都会对指令做重排序。可以分为以下三类:
编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-Level Parallelism,ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。
从Java源代码到最终实际执行的指令序列,会分别经历下面3种重排序:
上述的1属于编译器重排序,2和3属于处理器重排序。这些重排序可能会导致多线程程序出现内存可见性问题。对于编译器,JMM的编译器重排序规则会禁止特定类型的编译器重排序(不是所有的编译器重排序都要禁止)。对于处理器重排序,JMM的处理器重排序规则会要求Java编译器在生成指令序列时,插入特定类型的内存屏障(Memory Barriers,Intel称之为Memory Fence)指令,通过内存屏障指令来禁止特定类型的处理器重排序。
敲黑板
ok,经过上面的介绍,你肯定已经知道了,导致可见性的原因是缓存,导致有序性的原因是编译优化,那解决可见性、有序性最直接的办法就是禁用缓存和编译优化,但是这样问题虽然解决了,我们程序的性能可就堪忧了。
合理的方案应该是按需禁用缓存以及编译优化。那么,如何做到“按需禁用”呢?对于并发程序,何时禁用缓存以及编译优化只有程序员知道,这里你应该也明了了。所谓“按需禁用”只需要提供给程序员按需禁用缓存和编译优化的方法即可。 这里就该我们的主角出场了,Java内存模型。
Java 内存模型规范了 JVM 如何提供按需禁用缓存和编译优化的方法。具体来说,这些方法包括 volatile、synchronized 和 final 三个关键字,以及六项 Happens-Before 规则。解决了并发编程中的可见性以及有序性问题。
Java 内存模型里面,最晦涩的部分就是 Happens-Before 规则了,这里我们不对具体的规则进行论述,后续我们会专门的文章来学习 Happens-Before 的定义和规则,敬请期待。
一个happens-before规则对应于一个或多个编译器和处理器重排序规则。对于Java程序员来说,happens-before规则简单易懂,它避免Java程序员为了理解JMM提供的内存可见性保证而去学习复杂的重排序规则以及这些规则的具体实现方法。关系如下图所示:
总结
今天主要学习并发编程的源头:可见性、有序性、原子性,以及现代处理器为什么会出现这三种问题。而Java内存模型就是针对这三个问题中的可见性和有序性定义了一系列规范,在JVM层面提供了按需禁用缓存和编译优化的方法。具体包括 volatile、synchronized 和 final 三个关键字,以及六项 Happens-Before 规则,这些具体的实现手段我们后续文章会继续学习。
综上,Java 内存模型主要分为两部分,一部分面向你我这种编写并发程序的应用开发人员,另一部分是面向 JVM 的实现人员的,我们可以重点关注前者,也就是和编写并发程序相关的部分,这部分内容的核心就是 Happens-Before 规则。
相信通过本文的阅读,你已经对JMM有了深刻的理解,建议你在认为自己理解之后多和他人探讨或者讲给你的朋友,如果你能以自己的话语给别人讲明白,那么说明你真的学会了。
扩展
面试官:JMM为什么不保证对64位的long型和double型变量的写操作具有原子性?
对这个问题感兴的小伙伴欢迎关注小黑的公众号,回复 “0”查看解答哦!
评论 (2 条评论)