并发编程中存在两个关键问题:

  1. 线程之间如何通信
    1. 通信只指:线程之间以什么样的机制来交换信息
    2. 线程之间的通信主要有两种机制:
      1. 共享内存: 线程之间共享程序的公共状态。通过读-写公共状态来进行隐式通信。Java采用这种方式
      2. 消息传递: 线程之间没有公共状态,通过发送消息来进行显示通信
  2. 线程之间如何同步
    1. 同步是指:控制不同线程间操作发生相对顺序的机制
    2. 共享内存并发模型中,同步是显示进行的。必须显示指定某个方法或者代码片段需要在线程之间互斥执行。

Java是以共享内存作为并发编程模型。线程之间的通信是隐式进行的。如果不了解隐式执行的线程之间是如何进行通信的,就会遇到奇怪的内存可见性问题。

1 - 引入JMM

我们知道,Java对象是在堆中分配内存,而堆是线程之间共享的。方法参数、局部变量等是在栈中分配,是线程私有的。线程之间共享的内存,就会有内存可见性问题,而线程私有的内存,就没有可见性问题。

Java线程之间的通信由Java内存模型(JMM)来控制。JMM决定一个线程对共享变量的写入何时能对其他线程可见。

JMM(Java Memory Model), 是Java虚拟机平台对开发者提供的多线程环境下的内存可见性、是否可以重排序等问题的无关具体平台的统一的保证。JMM定义了一个线程与主存之间的抽象关系,它就像我们的数据结构中的逻辑结构一样,只是概念性的东西,并不是真实存在的,但是能够让我们更好的理解多线程的底层原理。

JMM定义了线程和主内存之间的抽象关系:

  1. 线程之间的共享变量存储在主内存中,每个线程都有一个私有的本地内存。
  2. 本地内存中存储了该线程读写共享变量的副本

JMM是语言级的内存模型,确保在不同的编译器和不同的处理器平台上,通过禁止特定类型的编译器重排序和处理器重排序,为程序员提供一致的内存可见性保证

处理器的执行速度要比内存快很多倍,现代处理器采用写缓冲区临时保存向内存写入的数据。
好处:

  1. 可以避免处理器停顿下来等待向内存写入数据,而带来的延迟
  2. 通过批处理的方式刷新写缓冲区,以及合并写缓冲区对同一内存地址的多次写。从而减少了对内存总线的占用

坏处:每个处理器都有自己的写缓冲区,而且仅对自己可见。这一特性会对内存操作顺序产生影响:

  1. 处理器对内存读写操作的执行顺序,不一定和实际的操作顺序一致

    2 - happens-before

jdk5开始使用JSR-133内存模型。该内存模型使用happens-before原则来阐述操作之间的内存可见性。在JMM中,如果一个操作执行的结果需要对另外一个操作可见,那么这些操作之间必须要存在happens-before关系

规则简述:

  1. 程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作
  2. 监视器锁规则:对一个锁的解锁操作happens-before于对该锁的加锁操作。即:锁的临界区代码,解锁后对后续获得锁的线程可见。
  3. volatile变量规则:对volatile变量的写,happens-before于对这个变量的读
  4. 传递性:如果A happens-before B且B happens-before C,那么A happens-before C

happens-before关系定义的操作的可见性。

3 - 重排序

3.1 - 定义

重排序:编译器和处理器为了优化程序性能,对指令序列进行重新排序的一种手段。

3.1 - 分类

重排序分为两类:

  1. 编译器重排序:在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序
  2. 处理器重排序:
    1. 指令级并行的重排序:现在处理器采用了指令级并行技术将多条指令重叠执行。如果语句之间不存在数据依赖性,那么处理器会改变指令的执行顺序。
    2. 内存重排序:处理器使用了缓存和读/写缓冲区,使得读/写内存看起来好像是乱序执行

虽然会有两种类型的重排序,但也不意味着一定会重排序。在特定的条件下,编译器和处理器并不会改变操作的执行顺序:

  1. 当存在数据依赖。如果多个操作访问同一个变量,且其中有写操作,那么这些操作之间就存在依赖性。如果对这些操作进行重排序,就会改变程序的执行效果。数据依赖性仅针对单个处理器和单个线程中执行的操作。
  2. as-if-seria语义。不管怎么重排序,单线程程序的执行结果不能被改变。为了遵守这个语义,编译器和处理器不会对存在数据依赖的操作进行重排序。如果不存在数据依赖,还是有可能被重排序的。

3.2 - 重排序导致的问题

以上两种类型的重排序,可能导致多线程程序出现内存可见性问题。

重排序时,仅保证单线程执行的语义不变。但是多线程执行环境下,语义就会被破坏。

3.3 - 如何解决

JMM针对两种类型的重排序,有不同的解决办法:

  1. 编译器重排序:JMM提出了编译器重排序规则。规则会禁止特定类型的编译器重排序。
  2. 处理器重排序:JMM提出了处理器重排序规则。规则要求编译器在生成指令时,插入特定类型的内存屏障指令。从而禁止特定类型的处理器重排序

综上,JMM通过禁止特定类型的重排序,为Java程序员提供内存可见性保证。

3.4 - 重排序原则

3.4.1 - 不重排序数据依赖性的操作

定义:如果两个语句访问同一个变量,并且其中一个是写操作。那么这两个语句之间就是存在数据依赖性。
分为三种类型:

类型 代码示例 说明
写后读 a = 1;
b = a;
写一个变量后,再读这个变量的值
写后写 a = 1;
a = 2;
写一个变量后,再写这个变量
读后写 a = b;
b = 1;
读了一个变量的值后,又对这个变量赋值

3.4.2 - as-if-serial语义

含义:不管怎么重排序,单线程程序的执行结果不能被改变。举个栗子:

  1. double pi = 3.14;
  2. double r = 2;
  3. double area = pi * r * r;

个人总结:
如果要理解volatile、synchronized语义:

  1. 首先要理解四个happens-before原则。这四个原则定义了volatile、synchronized关键字的内存可见性
  2. 而为了实现happens-before原则,JMM限制了特定类型重排序

synchronized: 临界区里的代码仍然可以重排序

内存模型主要解决了两个问题:

  1. 内存可见性
  2. 原子性

解决内存可见性,也就是在解决cpu缓存的数据一致性。