前言
并发和操作系统的相关度很高,会多次回到操作系统中,为什么要回到操作系统呢?JVM屏蔽了底层硬件、操作系统的不一致性,以向程序员在不同的硬件、操作系统上提供一致的开发环境,但是,最终的实现还是要依赖底层硬件、操作系统的,java中的线程还是要映射到操作系统上的
大部分是各种资料的总结,若有错误或者不足,欢迎交流讨论
1、基本概念
关于竞争条件、临界区、原子性、互斥等概念,请移步操作系统,这里说下同步和互斥的区别,还是先回到操作系统,看下用互斥量实现经典的生产者—消费者问题,注意生产者/消费者可以是进程也可以是线程
#define N 100 //数据缓冲区大小
semaphore mutex;
mutex.count = 1;
semaphore empty;
empty.count = N;
semaphore full;
full.count = 0;
void producer(void){
int item;
while(TRUE){
item = produce_item();
P(&empty);
P(&mutex);
insert_item(item);
V(&mutex);
V(&full);
}
}
void consumer(void){
int item;
while(TURE){
p(&full);
P(&mutex);
item = remove_item(item);
V(&mutex);
V(&empty);
consume_item(item);
}
}
两者的区别:
- 不论是生产者还是消费者进入临界区(insert_item和remove_item),P、V操作都是由同一个进程或线程来执行的,这是互斥,临界区中 只能有一个进程或线程
- 由于数据缓冲区为空或满,那么消费者或生产者就不能进入临界区,需要控制进程或线程的顺序发生或不发生,这是同步,也就是互斥量empty和full的操作,可以看到其P、V操作是在不同的进程或线程中执行的,
- 互斥强调的是只有一个,排他性;同步强调的是顺序,协调一致(访问缓冲区是否合法)
两者的联系:
- 同步的时候,由于限制了访问,临界区中只有一个进程或线程,这也就实现了互斥的目的
深入理解java虚拟机上说同步是因,互斥是果,互斥是,同步是目的,不能说是错的,但是不够准确,毕竟在java中的synchronized的实现比较复杂;参考兰亭风雨的博文时,我认为比较准确,再配上用互斥量实现生产者-消费者,就很明朗了。但在java并发中,到底是同步?还是互斥?个人认为应该是互斥,因为很多时候是和锁打交道,锁就肯定是具有排他性,所以倾向于互斥
锁和管程:在操作系统中,锁和管程已有提及,在java中,常用synchronized(当然还有显式的Lock,后面再说),任意对象可以作为锁(默认是本类的Class对象),这些锁称内置锁(Intrinsic Lock)或监视器锁(Monitor Lock,也就是管程锁)
内存可见性:一个线程的操作对于另外一个操作是可见的,立马能看到的,和JMM和重排序有关
2、JMM
还是先回到操作系统中,进程间通信有多种方式,线程间呢,主要有两种——消息传递、共享内存,java采用的是共享内存,线程间通信是隐式进行的,但是结果是透明的,若不了解java内存模型(Java Memory Model),编写并发程序时可能会遇到内存可见性问题
先来看一下操作系统中,处理器、缓存和主内存(也就是常说的内存,RAM)的关系
在JMM中,线程之间的共享变量存储在主存中,每个线程都有一个私有的本地内存(local memory),本地内存中存储了该线程以读/写共享变量的副本,本地内存仅是一个抽象而已,实际并不存在,它包括缓冲区、寄存器以及其他硬件和编译优化
如图中两个线程A B所示,若要通信,则需要线程A把本地内存A中更新过的共享变量(可能与B中不同)的副本刷新到主存中去,然后线程B到主存读取线程A之前更新过的共享变量。若线程A没有将共享变量副本刷新到主存中,那么其对共享变量的操作,是对B不可见的,刷新操作时机是不确定的,若A B使用锁进行同步,那么刷新的时间就是确定的,确保B在读取前A已经刷新了
这里的共享变量指的是实例域、静态域和数据元素,它们位于堆内存中,线程共享;局部变量、方法定义参数、异常处理参数位于栈内存中,线程私有,不存在数据竞争
3、重排序
3.1 重排序分类
实际执行程序的顺序与实际所编写程序执行顺序的不同,引起这一现象的原因,称重排序,重排序是为了提高性能。重排序的分类:
- 编译器级重排序:编译器在不改变单线程语义的前提下,可以重新安排语句的执行顺序
- 指令级并行重排序:现代处理器的指令级并行技术(ILP)将多条指令重叠执行,若不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序
- 内存级重排序:处理器使用缓冲和读写缓冲(尤其写),加载和存储操作看上去可能是在乱序执行
1为编译器重排序,JMM的重排序规则会禁止特定类型的编译器重排序;2、3为处理器重排序,JMM处理器重排序规则会插入内存屏障指令来禁止特定的类型的处理器重排序
内存屏障,也称内存栅栏,内存栅障,屏障指令等,是一类同步屏障指令,是CPU或编译器在对内存随机访问的操作中的一个同步点,使得此点之前的所有读写操作都执行后才可以开始执行此点之后的操作。——摘自wikipedia
3.2 as-if-serial
数据依赖:如果两个操作同时访问一个变量,其中一个操作是写操作,此时这两个操作就构成了数据依赖,比如i++、i–,存在数据依赖就不能重排序,否则执行结果可能会被改变
as-if-serial:不管编译器和处理器怎么重排序,单线程程序的执行结果不能被改变。
为了遵守as-if-serial,存在数据依赖关系的操作不会被重排序,否则就改变了执行结果,但是若不存在数据依赖关系,那么就可以重排序。结果就是保护了单线程程序,无需担心单线程程序的执行顺序和内存可见性
控制依赖:一个变量的值会影响程序的执行流程,控制依赖会影响指令序列的并行度,编译器和处理器会采用猜测执行来克服控制相关性对并行度的影响。什么意思呢?就是先把所有的流程先计算下来缓存,然后再判断,比如if语句,先提前计算if语句内的结果并缓存,然后再执行if判断,也就是说做了重排序
3.3 顺序一致性
JMM对数据竞争的定义:
- 在一个线程中写变量
- 在另外一个线程中读变量
- 读写没有通过同步来排序
如果程序是正确同步的,程序的执行将有顺序一致性——也就是说程序的执行结果与该程序在顺序一致内存模型中的执行结果是一致的。
注意这里的同步是广义上的同步,包括对常见同步原语的使用——synchronized、volatile、final
3.3.1 顺序一致性模型
- 一个线程中的所有操作必须按照程序的顺序来执行
- (不管程序是否同步)所有线程都只能看到一个单一的操作执行顺序。在顺序一致内存模型中,每一个操作都必须是原子执行且立即对所有线程可见。
从内存上来看,顺序一致性模型有一个单一的全局内存,在任意时刻只有一个线程在该全局内存上读写,当多个线程并发执行时,所有读写操作都是串行化的——和程序的顺序一致;从执行顺序上来看,不论同步还是未同步,对于某个特定的线程来说,程序执行的顺序是有序既定的。
然而,顺序一致性模型是个理想模型,并不存在,JMM对上述描述均没有保证。那为什么要提到这个模型呢?采用合理的同步策略,可以使得程序的执行结果符合顺序一致性模型的描述
3.3.2 同步的顺序一致性模型
class SynchronizedExample {
int a = 0;
boolean flag = false;
public synchronized void writer() {
a = 1;
flag = true;
}
public synchronized void reader() {
if (flag) {
int i = a;
……
}
}
}
假设A线程执行writer()方法后,B线程执行reader()方法。以下是在两个模型中的执行结果
在JMM中,临界区代码可以重排序,JMM在进入和退出临界区这两个时间点会做一些处理(和锁有关),使得两个线程在两个时间点和顺序一致性模型中有相同的内存视图
3.4 JMM对重排序的处理
说了半天,上面这些究竟有什么用?可以概括为一句话——保证并发的正确性,如何保证呢?那就是对重排序的处理了。对于处理器重排序,JMM会插入特定类型的内存屏障指令,来禁止特定类型的处理器(并不是所有都需要禁止,比如临界区就允许)
先来看下和内存屏障指令相关的内存访问指令:
- read:读取操作,将共享变量的值从主存传输到线程的本地内存中,供随后的load使用
- load:载入操作,把read操作从主存中得到的变量值放入本地内存的变量副本中
- store:存储操作,把本地内存中的共享变量副本的值传送到主存中,以便随后的write操作使用
- write:写入操作,把stroe操作从本地内存中得到的值放入主存中的共享变量中
常见处理器的重排序规则:
处理器规则 | load-load | load-store | store-store | store-load | 数据依赖 |
---|---|---|---|---|---|
SPARC-TSO | N | N | N | Y | N |
x86 | N | N | N | Y | N |
IA64 | Y | Y | Y | Y | N |
SPARC-TSO | Y | Y | Y | Y | N |
常见的处理器都不允许对存在数据依赖的操作做重排序;常见的处理器都允许store-load(对于某一线程,可以看做写-读操作)重排序。SPARC-TSO和x86(包括x64和AMD64)拥有相对较强的处理器内存模型,它们仅允许对写-读操作做重排序(因为它们都使用了写缓冲区)
写-读操作的重排序显然可能会导致程序的执行结果出错,JMM有如下内存屏障指令,来禁止特定类型的重排序:
屏障指令 | 指令示例 | 描述 |
---|---|---|
LoadLoad Barriers | Load1; LoadLoad; Load2 | 确保Load1数据的装载先于Load2以及所有后续载入指令 |
StoreStore Barriers | Store1; StoreStore; Store2 | 确保Store1的数据对其他处理器可见(刷新到内存,并且其他处理器的缓存行无效)先于Store2及所有后续存储指令的载入 |
LoadStore Barriers | Load1; LoadStore; Store2 | 确保Load1数据载入先于Store2及所有后续存储指令刷新到主存 |
StoreLoad Barriers | Store1; StoreLoad; Load2 | 确保Store1数据对其他处理器可见(刷新到内存,并且其他处理器的缓存行无效)先于Load2及所有后续载入指令的载入。该指令会使得该屏障之前的所有内存访问指令完成之后,才能执行该屏障之后的内存访问指令。 |
参考资料: