返回介绍

1.5 回到 Java:JMM

发布于 2024-08-21 22:20:21 字数 8953 浏览 0 评论 0 收藏 0

前面我已经介绍了有关并行程序的一些关键概念和定律。这些概念可以说是与语言无关的。无论你使用Java或者C,或者其他任何一门语言编写并发程序,都有可能会涉及这些问题。但本书依然是一本面向Java程序员的书籍。因此,在本章最后,我们还是希望可以探讨一下有关Java的内存模型(JMM)。

由于并发程序要比串行程序复杂很多,其中一个重要原因是并发程序下数据访问的一致性和安全性将会受到严重挑战。如何保证一个线程可以看到正确的数据呢?这个问题看起来很白痴。对于串行程序来说,根本就是小菜一碟,如果你读取一个变量,这个变量的值是1,那么你读到的一定是1,就这么简单的问题在并行程序中居然变得复杂起来。事实上,如果不加控制地任由线程胡乱并行,即使原本是1的数值,你也有可能读到2。因此,我们需要在深入了解并行机制的前提下,再定义一种规则,保证多个线程间可以有效地、正确地协同工作。而JMM也就是为此而生的。

JMM的关键技术点都是围绕着多线程的原子性、可见性和有序性来建立的。因此,我们首先必须了解这些概念。

1.5.1 原子性(Atomicity)

原子性是指一个操作是不可中断的。即使是在多个线程一起执行的时候,一个操作一旦开始,就不会被其他线程干扰。

比如,对于一个静态全局变量int i,两个线程同时对它赋值,线程A给他赋值1,线程B给它赋值为-1。那么不管这2个线程以何种方式、何种步调工作,i的值要么是1,要么是-1。线程A和线程B之间是没有干扰的。这就是原子性的一个特点,不可被中断。

但如果我们不使用int型而使用long型的话,可能就没有那么幸运了。对于32位系统来说,long型数据的读写不是原子性的(因为long有64位)。也就是说,如果两个线程同时对long进行写入的话(或者读取),对线程之间的结果是有干扰的。

大家可以仔细观察一下下面的代码:

public class MultiThreadLong {
  public static long t=0;
  public static class ChangeT implements Runnable{
    private long to;
    public ChangeT(long to){
      this.to=to;
    }
    @Override
    public void run() {
      while(true){
      MultiThreadLong.t=to;
      Thread.yield();
      }
    }
  }
  public static class ReadT implements Runnable{
    @Override
    public void run() {
      while(true){
       long tmp=MultiThreadLong.t;
       if(tmp!=111L && tmp!=-999L && tmp!=333L && tmp!=-444L)
         System.out.println(tmp);
      Thread.yield();
      }
    }
  }

  public static void main(String[] args) {
    new Thread(new ChangeT(111L)).start();
    new Thread(new ChangeT(-999L)).start();
    new Thread(new ChangeT(333L)).start();
    new Thread(new ChangeT(-444L)).start();
    new Thread(new ReadT()).start();
  }
}

上述代码有4个线程对long型数据t进行赋值,分别对t赋值为111、-999、333、444。然后,有一个读取线程,读取这个t的值。一般来说,t的值总是这4个数值中的一个。这当然也是我们的期望了。但很不幸,在32位的Java虚拟机中,未必总是这样。

如果读取线程ReadT总是读到合理的数据,那么这个程序应该没有任何输出。但是,实际上,这个程序一旦运行,就会大量输出以下信息:(再次强调,使用32位虚拟机)

……
-4294966963
4294966852
-4294966963
……

这里截取了部分输出。我们可以看到,读取线程居然读到了两个似乎根本不可能存在的数值。这不是幻觉,在这里,你看到的确实是事实,其中的原因也就是因为32位系统中long型数据的读和写都不是原子性的,多线程之间相互干扰了!

如果我给出这几个数值的2进制表示,大家就会有更清晰的认识了:

+111=0000000000000000000000000000000000000000000000000000000001101111
-999=1111111111111111111111111111111111111111111111111111110000011001
+333=0000000000000000000000000000000000000000000000000000000101001101
-444=1111111111111111111111111111111111111111111111111111111001000100
+4294966852=0000000000000000000000000000000011111111111111111111111001000100
-4294967185=1111111111111111111111111111111100000000000000000000000001101111

上面显示了这几个相关数字的补码形式,也就是在计算机内的真实存储内容。不难发现,这个奇怪的4294966852,其实是111或者333的前32位,与-444的后32位夹杂后的数字。而-4294967185只是-999或者-444的前32位与111夹杂后的数字。换句话说,由于并行的关系,数字被写乱了,或者读的时候,读串位了。

通过这个例子,我想大家都原子性应该有了基本的认识。

1.5.2 可见性(Visibility)

可见性是指当一个线程修改了某一个共享变量的值,其他线程是否能够立即知道这个修改。显然,对于串行程序来说,可见性问题是不存在的。因为你在任何一个操作步骤中修改了某个变量,那么在后续的步骤中,读取这个变量的值,一定是修改后的新值。

但是这个问题在并行程序中就不见得了。如果一个线程修改了某一个全局变量,那么其他线程未必可以马上知道这个改动。图1.14展示了发生可见性问题的一种可能。如果在CPU1和CPU2上各运行了一个线程,它们共享变量t,由于编译器优化或者硬件优化的缘故,在CPU1上的线程将变量t进行了优化,将其缓存在cache中或者寄存器里。这种情况下,如果在CPU2上的某个线程修改了变量t的实际值,那么CPU1上的线程可能并无法意识到这个改动,依然会读取cache中或者寄存器里的数据。因此,就产生了可见性问题。外在表现为:变量t的值被修改,但是CPU1上的线程依然会读到一个旧值。可见性问题也是并行程序开发中需要重点关注的问题之一。

图1.14 可见性问题

可见性问题是一个综合性问题。除了上述提到的缓存优化或者硬件优化(有些内存读写可能不会立即触发,而会先进入一个硬件队列等待)会导致可见性问题外,指令重排(这个问题将在下一节中更详细讨论)以及编辑器的优化,都有可能导致一个线程的修改不会立即被其他线程察觉。

下面来看一个简单的例子:

Thread 1   Thread 2
1: r2 = A;  3: r1 = B;
2: B = 1;   4: A = 2;

上述两个线程,并行执行,分别有1、2、3、4四条指令。其中指令1、2属于线程1,而指令3、4属于线程2。

从指令的执行顺序上看,r2==2并且r1==1似乎是不可能出现的。但实际上,我们并没有办法从理论上保证这种情况不出现。因为编译器可能将指令重排成:

Thread 1  Thread 2
B = 1;  r1 = B;
r2 = A;  A = 2;

在这种执行顺序中,就有可能出现刚才看似不可能出现的r2==2并且r1==1的情况了。

这个例子就说明,在一个线程中去观察另外一个线程的变量,它们的值是否能观测到、何时能观测到是没有保证的。

再来看一个稍微复杂一些的例子:

Thread 1    Thread 2
r1 = p;    r6 = p;
r2 = r1.x;    r6.x = 3;
r3 = q;
r4 = r3.x;
r5 = r1.x;

这里假设在初始时,p == q并且p.x == 0。对于大部分编译器来说,可能会对线程1进行向前替换的优化,也就是r5=r1.x这条指令会被直接替换成r5=r2。因为它们都读取了r1.x,又发生在同一个线程中,因此,编译器很可能认为第2次读取是完全没有必要的。因此,上述指令可能会变成:

Thread 1    Thread 2
r1 = p;    r6 = p;
r2 = r1.x;    r6.x = 3;
r3 = q;
r4 = r3.x;
r5 = r2;

现在思考这么一种场景。假设线程2中的r6.x=3发生在r2 = r1.x和r4 = r3.x之间,而编译器又打算重用r2来表示r5。那么就有可能会出现非常奇怪的现象。你看到的r2是0,r4是3,但是r5还是0。因此,如果从线程1代码的直观感觉上看就是:p.x的值从0变成了3(因为r4是3),接着又变成了0(这是不是算一个非常怪异的问题呢?)。

1.5.3 有序性(Ordering)

有序性问题可能是三个问题中最难理解的了。对于一个线程的执行代码而言,我们总是习惯地认为代码的执行是从先往后,依次执行的。这么理解也不能说完全错误,因为就一个线程内而言,确实会表现成这样。但是,在并发时,程序的执行可能就会出现乱序。给人直观的感觉就是:写在前面的代码,会在后面执行。听起来有些不可思议,是吗?有序性问题的原因是因为程序在执行时,可能会进行指令重排,重排后的指令与原指令的顺序未必一致。下面来看一个简单的例子:

01 class OrderExample {
02 int a = 0;
03 boolean flag = false;
04 public void writer() {
05   a = 1;
06   flag = true;
07 }
08 public void reader() {
09   if (flag) {
10     int i =  a +1;
11     ……
12   }
13 }
14 }

假设线程A首先执行writer()方法,接着线程B执行reader()方法,如果发生指令重排,那么线程B在代码第10行时,不一定能看到a已经被赋值为1了。如图1.15所示,显示了两个线程的调用关系。

图1.15 指令重排引起线程间语义不一致

这确实是一个看起来很奇怪的问题,但是它确实可能存在。注意:我这里说的是可能存在。因为如果指令没有重排,这个问题就不存在了,但是指令是否发生重排、如何重排,恐怕是我们无法预测的。因此,对于这类问题,我认为比较严谨的描述是:线程A的指令执行顺序在线程B看来是没有保证的。如果运气好的话,线程B也许真的可以看到和线程A一样的执行顺序。

不过这里还需要强调一点,对于一个线程来说,它看到的指令执行顺序一定是一致的(否则的话我们的应用根本无法正常工作,不是吗?)。也就是说指令重排是有一个基本前提的,就是保证串行语义的一致性。指令重排不会使串行的语义逻辑发生问题。因此,在串行代码中,大可不必担心。

注意:指令重排可以保证串行语义一致,但是没有义务保证多线程间的语义也一致。

那么,好奇的你可能马上就会在脑海里闪出一个疑问,为什么要指令重排呢?让他一步一步执行多好呀!也不会有那么多奇葩的问题。

之所以那么做,完全是因为性能考虑。我们知道,一条指令的执行是可以分为很多步骤的。简单地说,可以分为以下几步:

· 取指IF

· 译码和取寄存器操作数ID

· 执行或者有效地址计算EX

· 存储器访问MEM

· 写回WB

我们的汇编指令也不是一步就可以执行完毕的,在CPU中实际工作时,它还是需要分为多个步骤依次执行的。当然,每个步骤所涉及的硬件也可能不同。比如,取指时会用到PC寄存器和存储器,译码时会用到指令寄存器组,执行时会使用ALU,写回时需要寄存器组。

注意:ALU指算术逻辑单元。它是CPU的执行单元,是CPU的核心组成部分,主要功能是进行二进制算术运算。

由于每一个步骤都可能使用不同的硬件完成,因此,聪明的工程师们就发明了流水线技术来执行指令,如图1.16所示,显示了流水线的工作原理。

图1.16 指令流水线

可以看到,当第2条指令执行时,第1条执行其实并未执行完,确切地说第一条指令还没开始执行,只是刚刚完成了取值操作而已。这样的好处非常明显,假如这里每一个步骤都需要花费1毫秒,那么指令2等待指令1完全执行后,再执行,则需要等待5毫秒,而使用流水线后,指令2只需要等待1毫秒就可以执行了。如此大的性能提升,当然让人眼红。更何况,实际的商业CPU的流水线级别甚至可以达到10级以上,则性能提升就更加明显。

有了流水线这个神器,我们CPU才能真正高效的执行,但是,别忘了一点,流水线总是害怕被中断的。流水线满载时,性能确实相当不错,但是一旦中断,所有的硬件设备都会进入一个停顿期,再次满载又需要几个周期,因此,性能损失会比较大。所以,我们必须要想办法尽量不让流水线中断!

那么答案就来了,之所以需要做指令重排,就是为了尽量少的中断流水线。当然了,指令重排只是减少中断的一种技术,实际上,在CPU的设计中,我们还会使用更多的软硬件技术来防止中断,不过对它们的讨论已经远远超出本书范围,有兴趣的读者可以查阅相关资料。

让我们来仔细看一个例子。图1.17展示了A=B+C这个操作的执行过程。写在左边的指令就是汇编指令。LW表示load,其中LW R1,B,表示把B的值加载到R1寄存器中。ADD指令就是加法,把R1、R2的值相加,并存放到R3中。SW表示store,存储,就是将R3寄存器的值保存到变量A中。

图1.17 A=B+C的执行过程

右边就是流水线的情况。注意,在ADD指令上,有一个大叉,表示一个中断。也就是说ADD在这里停顿了一下。为什么ADD会在这里停顿呢?原因很简单,R2中的数据还没有准备好!所以,ADD操作必须进行一次等待。由于ADD的延迟,导致其后面所有的指令都要慢一个节拍。

理解了上面这个例子,我们就可以来看一个更加复杂的情况:

a=b+c
d=e-f

上述代码的执行应该会是这样,如图1.18所示。

图1.18 重排前指令执行过程

由于ADD和SUB都需要等待上一条指令的结果,因此,在这里插入了不少停顿。那么对于这段代码,是否有可能消除这些停顿呢?显然是可以的,如图1.19所示,显示了减少这些停顿的方法。我们只需要将LW Re, e和LW Rf, f移动到前面执行即可。思想很简单,先加载e和f对程序是没有影响的。既然在ADD的时候一定要停顿一下,那么停顿的时间还不如去做点有意义的事情。

图1.19 指令重排,以消除停顿

重排后,最终的结果如图1.20所示。可以看到,所有的停顿都已经消除,流水线已经可以十分顺畅地执行。

图1.20 重排后的指令

由此可见,指令重排对于提高CPU处理性能是十分必要的。虽然确实带来了乱序的问题,但是这点牺牲是完全值得的。

1.5.4 哪些指令不能重排:Happen-Before规则

在前文已经介绍了指令重排,虽然Java虚拟机和执行系统会对指令进行一定的重排,但是指令重排是有原则的,并非所有的指令都可以随便改变执行位置,以下罗列了一些基本原则,这些原则是指令重排不可违背的。

· 程序顺序原则:一个线程内保证语义的串行性

· volatile规则:volatile变量的写,先发生于读,这保证了volatile变量的可见性

· 锁规则:解锁(unlock)必然发生在随后的加锁(lock)前

· 传递性:A先于B,B先于C,那么A必然先于C

· 线程的start()方法先于它的每一个动作

· 线程的所有操作先于线程的终结(Thread.join())

· 线程的中断(interrupt())先于被中断线程的代码

· 对象的构造函数执行、结束先于finalize()方法

以程序顺序原则为例,重排后的指令绝对不能改变原有的串行语义。比如:

a=1;
b=a+1;

由于第2条语句依赖第一条的执行结果。如果冒然交换两条语句的执行顺序,那么程序的语义就会修改。因此这种情况是绝对不允许发生的。因此,这也是指令重排的一条基本原则。

此外,锁规则强调,unlock操作必然发生在后续的对同一个锁的lock之前。也就是说,如果对一个锁解锁后,再加锁,那么加锁的动作绝对不能重排到解锁动作之前。很显然,如果这么做,加锁行为是无法获得这把锁的。

其他几条原则也是类似的,这些原则都是为了保证指令重排不会破坏原有的语义结构。

如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。
列表为空,暂无数据
    我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
    原文