0%

Java - 内存模型简述

volatile,synchronized,都是在Java多线程编程中,用来处理线程之间共享数据中的一些问题,那么这些关键字是怎么来解决问题的呢?

建议先阅读CPU缓存与MESI了解硬件部分的内存模型。

数据可见性

在Java语言中,采用的是共享内存的机制来进行线程间通信,线程之间共享程序的公共状态,线程之间通过写-读内存中的公共状态来隐式进行通信。Java多线程的内存抽象模型如下:

1

  • 线程之间的共享变量存储在主内存中,每条线程还有自己的工作内存,线程的工作内存中保存了该线程中是用到的变量的主内存副本拷贝;
  • 线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存;
  • 本地内存是个抽象概念,并不是真实存在的;
  • 不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量的传递均需要自己的工作内存和主存之间进行数据同步进行;

注意,这里提到的主存和工作内存的结构,跟硬件层面的主存和高速缓存的概念可以进行类比,但是并不是指Java中的主存就是硬件内存模型中的主存,工作内存就是高速缓存,这其中并没有直接关联的关系。

上述抽象内存模型中,如果是单线程工作,则访问的内存都在自己的工作内存中,因此没有什么问题。但是如果是多线程工作环境,则会出现一个数据可见性的问题:某个线程对某个共享变量做了修改之后,其他线程再读取这个共享变量的时候,如何才能保证读取到最新的数据。

对于普通变量,多线程访问过程中会出现上述的数据可见性问题,但是如果采用了Java关键字做修饰,则可以避免这个问题:

  • volatile

    如果将一个变量修饰为volatile,则在线程对其更新的时候,新值会立刻同步到主内存中去;当另外的线程需要读取这个值的时候,也会从主内存中去刷新这个值,由此来保证多线程下,访问变量的可见性。

  • synchronized

    synchronized关键字同样也可以解决多线程的数据不可见问题。在同步方法/同步块开始时,从主内存中读取最新值到线程私有的工作内存中,在同步方法/同步块结束时,线程私有的工作内存中的值会被写入到主内存中。

  • lock

    lock也能实现同样的功能,lock.lock()方法类似于synchronized的同步块开始时,lock.unlock()类似于synchronized的同步块结束时。

重排序

现在很多流行的处理器,为了使处理器内部的运算单元能够尽量的被充分利用,处理器可能会对输入代码进行乱序执行处理。很多编程语言的编译器也会有类似的优化,比如Java虚拟机的即时编译器(JIT)也会做指令重排,但是编译器在重排序的时候,也会遵循一些规则:

as-if-serial

as-if-serial是指:不管怎么重排序,单线程程序的执行结果不能被改变。遵守as-if-serial规则的编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。

这里的数据依赖性是指,如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性。例如:a=1;b=a,后一个操作依赖于前一个操作的结果,因此这两个操作存在数据依赖性,若重排序两个操作的执行顺序,程序的执行结果将会被改变。

这里所说的数据依赖性仅针对单个处理器中执行的指令序列和单个线程中执行的操作,不同处理器之间和不同线程之间的数据依赖性不被编译器和处理器考虑。

happens-before

从JDK 5开始,JMM使用happens-before的概念来约定两项操作之间的次序关系。如果说操作A happens-before 操作B,那么操作A产生的影响能被操作B观察到,这里的“影响”包含了修改了内存中共享变量的值、发送了消息、调用了方法等。

操作A happens-before 操作B,并不代表着在时间上,操作A一定要先执行,happens-before仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前。时间上的先后顺序与happens-before原则之间基本没有什么关系。

下面是Java内存模型下一些happens-before关系:

  • 程序次序规则:在一个线程内,按照程序代码顺序,书写在前面的操作先行发生于书写在后面的操作。准确地说应该是控制流顺序而不是程序代码顺序,因为要考虑分支、循环结构。
  • 管程锁定规则:一个unlock操作先行发生于后面对同一个锁的lock操作。这里必须强调的是同一个锁,而”后面“是指时间上的先后顺序。
  • volatile变量规则:对一个volatile变量的写操作先行发生于后面对这个变量的读取操作,这里的”后面“同样指时间上的先后顺序。
  • 线程启动规则:Thread对象的start()方法先行发生于此线程的每一个动作。
  • 线程终于规则:线程中的所有操作都先行发生于对此线程的终止检测,我们可以通过Thread.join()方法结束,Thread.isAlive()的返回值等作段检测到线程已经终止执行。
  • 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测是否有中断发生。
  • 对象终结规则:一个对象初始化完成(构造方法执行完成)先行发生于它的finalize()方法的开始。
  • 传递性:如果操作A先行发生于操作B,操作B先行发生于操作C,那就可以得出操作A先行发生于操作C的结论。

硬件层面

CPU缓存与MESI中所述,数据在硬件层面,也会存在数据可见性,重排序等等问题,文章中最终提到了内存屏障的概念,将操作权开放给代码编写者来控制。

在JMM中,将系统底层的内存屏障Load BarrierStore Barrier封装成了四种:

  • LoadLoad屏障

    对于这样的语句Load1; LoadLoad; Load2,在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。

  • StoreStore屏障

    对于这样的语句Store1; StoreStore; Store2,在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见。

  • LoadStore屏障

    对于这样的语句Load1; LoadStore; Store2,在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。

  • StoreLoad屏障

    对于这样的语句Store1; StoreLoad; Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。它的开销是四种屏障中最大的。在大多数处理器的实现中,这个屏障是个万能屏障,兼具其它三种内存屏障的功能。

JMM中并发相关的关键字,会使用如上四种指令,来解决硬件层面的不可见,指令重排序的问题问题,例如被volatile修饰的变量,在每个写操作前都会插入StoreStore屏障,在写操作后插入StoreLoad屏障;在每个volatile读操作前插入LoadLoad屏障,在读操作后插入LoadStore屏障。

参考



-=全文完=-