并发编程中存在两个关键问题:
- 线程之间如何通信
- 通信只指:线程之间以什么样的机制来交换信息
- 线程之间的通信主要有两种机制:
- 共享内存: 线程之间共享程序的公共状态。通过读-写公共状态来进行隐式通信。Java采用这种方式
- 消息传递: 线程之间没有公共状态,通过发送消息来进行显示通信
- 线程之间如何同步
- 同步是指:控制不同线程间操作发生相对顺序的机制
- 共享内存并发模型中,同步是显示进行的。必须显示指定某个方法或者代码片段需要在线程之间互斥执行。
Java是以共享内存作为并发编程模型。线程之间的通信是隐式进行的。如果不了解隐式执行的线程之间是如何进行通信的,就会遇到奇怪的内存可见性问题。
1 - 引入JMM
我们知道,Java对象是在堆中分配内存,而堆是线程之间共享的。方法参数、局部变量等是在栈中分配,是线程私有的。线程之间共享的内存,就会有内存可见性问题,而线程私有的内存,就没有可见性问题。
Java线程之间的通信由Java内存模型(JMM)来控制。JMM决定一个线程对共享变量的写入何时能对其他线程可见。
JMM(Java Memory Model), 是Java虚拟机平台对开发者提供的多线程环境下的内存可见性、是否可以重排序等问题的无关具体平台的统一的保证。JMM定义了一个线程与主存之间的抽象关系,它就像我们的数据结构中的逻辑结构一样,只是概念性的东西,并不是真实存在的,但是能够让我们更好的理解多线程的底层原理。
JMM定义了线程和主内存之间的抽象关系:
- 线程之间的共享变量存储在主内存中,每个线程都有一个私有的本地内存。
- 本地内存中存储了该线程读写共享变量的副本
JMM是语言级的内存模型,确保在不同的编译器和不同的处理器平台上,通过禁止特定类型的编译器重排序和处理器重排序,为程序员提供一致的内存可见性保证
处理器的执行速度要比内存快很多倍,现代处理器采用写缓冲区临时保存向内存写入的数据。
好处:
- 可以避免处理器停顿下来等待向内存写入数据,而带来的延迟
- 通过批处理的方式刷新写缓冲区,以及合并写缓冲区对同一内存地址的多次写。从而减少了对内存总线的占用
坏处:每个处理器都有自己的写缓冲区,而且仅对自己可见。这一特性会对内存操作顺序产生影响:
jdk5开始使用JSR-133内存模型。该内存模型使用happens-before原则来阐述操作之间的内存可见性。在JMM中,如果一个操作执行的结果需要对另外一个操作可见,那么这些操作之间必须要存在happens-before关系。
规则简述:
- 程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作
- 监视器锁规则:对一个锁的解锁操作happens-before于对该锁的加锁操作。即:锁的临界区代码,解锁后对后续获得锁的线程可见。
- volatile变量规则:对volatile变量的写,happens-before于对这个变量的读
- 传递性:如果A happens-before B且B happens-before C,那么A happens-before C
3 - 重排序
3.1 - 定义
重排序:编译器和处理器为了优化程序性能,对指令序列进行重新排序的一种手段。
3.1 - 分类
重排序分为两类:
- 编译器重排序:在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序
- 处理器重排序:
- 指令级并行的重排序:现在处理器采用了指令级并行技术将多条指令重叠执行。如果语句之间不存在数据依赖性,那么处理器会改变指令的执行顺序。
- 内存重排序:处理器使用了缓存和读/写缓冲区,使得读/写内存看起来好像是乱序执行
虽然会有两种类型的重排序,但也不意味着一定会重排序。在特定的条件下,编译器和处理器并不会改变操作的执行顺序:
- 当存在数据依赖。如果多个操作访问同一个变量,且其中有写操作,那么这些操作之间就存在依赖性。如果对这些操作进行重排序,就会改变程序的执行效果。数据依赖性仅针对单个处理器和单个线程中执行的操作。
- as-if-seria语义。不管怎么重排序,单线程程序的执行结果不能被改变。为了遵守这个语义,编译器和处理器不会对存在数据依赖的操作进行重排序。如果不存在数据依赖,还是有可能被重排序的。
3.2 - 重排序导致的问题
以上两种类型的重排序,可能导致多线程程序出现内存可见性问题。
重排序时,仅保证单线程执行的语义不变。但是多线程执行环境下,语义就会被破坏。
3.3 - 如何解决
JMM针对两种类型的重排序,有不同的解决办法:
- 编译器重排序:JMM提出了编译器重排序规则。规则会禁止特定类型的编译器重排序。
- 处理器重排序: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语义
含义:不管怎么重排序,单线程程序的执行结果不能被改变。举个栗子:
double pi = 3.14;
double r = 2;
double area = pi * r * r;
个人总结:
如果要理解volatile、synchronized语义:
- 首先要理解四个happens-before原则。这四个原则定义了volatile、synchronized关键字的内存可见性
- 而为了实现happens-before原则,JMM限制了特定类型重排序
synchronized: 临界区里的代码仍然可以重排序
内存模型主要解决了两个问题:
- 内存可见性
- 原子性
解决内存可见性,也就是在解决cpu缓存的数据一致性。