写点什么

☕【JVM 技术之旅】字节码指令重排序

发布于: 2021 年 06 月 05 日
☕【JVM技术之旅】字节码指令重排序

前提概要

指令重排序有两类,编译器重排序处理器重排序(至于内存系统指令重排较为复杂不是本章重点)


重排序分为两类:编译期重排序和运行期重排序,分别对应编译时和运行时环境。编译器重排序发生在编译期,处理器重排序发生在运行时。其实指令重排序的本意是提高程序并发效率,原则是重排序后的程序运行结果和单线程运行结果一致。(AS IF SERIAL)

指令重排的原因

  • 为什么指令重排序会提高程序并发效率呢?这里先理解一下 CPU 的最小调度单位是线程这个概念。

  • 一个 CPU 同时只能处理一个线程,在最初单核 CPU 的时候,是通过轮询的方式去完成多线程的,在线程之间完成上下文切换。

  • 现在都是多核 CPU,其中每个 CPU 也是在轮询线程,只不过多核 CPU 并发效率更高了。


这就存在一个问题,CPU 的运算速度要远快于对内存的操作,将工作内存数据写入主内存即物理内存时,如果两个 CPU 同时需要写入同一块内存区域,这就需要一个 CPU 等待另一个 CPU 写入完成后再写,这就造成了 CPU 的浪费,而这种情况在单核 CPU 是不存在的,所以需要指令重排序

举个例子

int a = 1;int b = 2;
复制代码

指令排序案例分析

  • a 和 b 需要写入不同的内存区域,在多线程中:

  • 如果 CPU1 是先写入 a 到内存 a,再写入 b 到内存 b。

  • 那么 CPU2 必然也是这个顺序,这就容易造成两个 CPU 想同时往内存 a 中写入,这就需要一个 CPU 等待另一个写入完成,这就造成了 CPU 的等待浪费。

  • 但是如果线程 2 中指令重排序一下,变为 int b = 2; int a = 1;

  • 那么 CPU2 就是先写入 b 到内存 b,再写入 a 到内存 a。

  • 这样两块 CPU 就可以同时写入,这才是真正的多核 CPU,这就是指令重排序的目的。

指令排序的局限性

当然指令重排序也是有条件的,有一个语句间依赖性的概念,分为数据依赖性和控制依赖性。

数据依赖性

指后一条语句要使用上一条语句的数据,控制依赖性是指后一条语句要使用上一条语句的判断结果。语句间有依赖性就不可以指令重排序了,这也很好理解。但是在高并发中,如果不对共享变量做并发处理,指令重排序会造成严重问题。举个例子:



  • 如何线程 a 调用了 write 方法,并进行了指令重排序,先将 b=true 写入内存再将 a=1 写入内存,这就可能出现一种情况。

  • 线程 a 将 b=true 写入内存后还没有来得及将 a=1 写入内存,此时线程 b 正在调用 read 方法,由于从内存中读到的 b 为 true。

  • if(b)判断成功后去读 a,此时 a 并没有被写入为 1,所以这时候共享变量就发生的错误。


可见性


可以用 volatile 关键字去修饰共享变量,这时候如果该变量在工作内存中被修改,那么不需要写入主内存就对其他线程是可见的,即保证了该变量的可见性


例如同步锁保证得到锁时从内存里重新读入数据刷新缓存,释放锁时将数据写回内存以保数据可见,而 volatile 变量干脆都是读写内存。


有序性


  • 同时被 volatile 修饰的变量不允许被指令重排序和缓存(内存屏障)。

  • 避免多线程中指令重排序问题需要我们的编码遵循 happen-and-before 原则,都是有关于并发编程加锁机制

  • unlock 操作,先行发生于对同一对象的 lock 操作,这里包括发生在其它线程中的 lock 操作。

  • volatile 修饰的变量写先发生于读,这保证了该变量的可见性。

  • thread 的 start()方法先行发生于该线程的每一个动作。

  • thread 的 join()方法即终止方法后发生先于该线程的每一个动作,可以用 thread.alive()方法的返回值判断该线程是否已经终止。

  • thread 的 interrupte()方法即中断方法先行发生于被中断线程的代码检测到中断事件的发生,通过 thread.interrupte()方法检测线程是否已中断。

  • 一个对象的初始化完成即其构造方法的调用结束要先行与他的终结方法例如 finalize()。

  • 动作有传递性,如果动作 A 先于动作 B,动作 B 先于动作 C,那么动作 A 先于动作 C。




在并发程序中,程序员会特别关注不同进程或线程之间的数据同步,特别是多个线程同时修改同一变量时,必须采取可靠的同步或其它措施保障数据被正确地修改,这里的一条重要原则是:不要假设指令执行的顺序,你无法预知不同线程之间的指令会以何种顺序执行。


理想的模型是:各种指令执行的顺序是唯一且有序的,这个顺序就是它们被编写在代码中的顺序,与处理器或其它因素无关,这种模型被称作顺序一致性模型,也是基于冯·诺依曼体系的模型。


当然,这种假设本身是合理的,在实践中也鲜有异常发生,但事实上,没有哪个现代多处理器架构会采用这种模型,因为它是在是太低效了。而在编译优化和 CPU 流水线中,几乎都涉及到指令重排序。

编译期重排序

编译期重排序的典型就是通过调整指令顺序,在不改变程序语义的前提下,尽可能减少寄存器的读取、存储次数,充分复用寄存器的存储值。


  • 第一条指令计算一个值赋给变量 A 并存放在寄存器中,

  • 第二条指令与 A 无关但需要占用寄存器(假设它将占用 A 所在的那个寄存器)

  • 第三条指令使用 A 的值且与第二条指令无关。

  • 那么如果按照顺序一致性模型,A 在第一条指令执行过后被放入寄存器,第二条指令执行时 A 不再存在,第三条指令执行时 A 重新被读入寄存器,而这个过程中,A 的值没有发生变化

  • 通常编译器都会交换第二和第三条指令的位置,这样第一条指令结束时 A 存在于寄存器中,接下来可以直接从寄存器中读取 A 的值,降低了重复读取的开销

重排序对于流水线的意义

现代 CPU 几乎都采用流水线机制加快指令的处理速度,一般来说,一条指令需要若干个 CPU 时钟周期处理,而通过流水线并行执行,可以在同等的时钟周期内执行若干条指令,具体做法简单地说就是把指令分为不同的执行周期,例如读取、寻址、解析、执行等步骤,并放在不同的元件中处理,同时在执行单元 EU 中,功能单元被分为不同的元件,例如加法元件、乘法元件、加载元件、存储元件等,可以进一步实现不同的计算并行执行。


流水线架构决定了指令应该被并行执行,而不是在顺序化模型中所认为的那样。重排序有利于充分使用流水线,进而达到超标量的效果。

发布于: 2021 年 06 月 05 日阅读数: 41
用户头像

我们始于迷惘,终于更高水平的迷惘。 2020.03.25 加入

🏆 【酷爱计算机技术、醉心开发编程、喜爱健身运动、热衷悬疑推理的”极客狂人“】 🏅 【Java技术领域,MySQL技术领域,APM全链路追踪技术及微服务、分布式方向的技术体系等】 🤝未来我们希望可以共同进步🤝

评论

发布
暂无评论
☕【JVM技术之旅】字节码指令重排序