|
| 1 | +# 1 内存模型 |
| 2 | + |
| 3 | +## 1.1 什么是内存模型 |
| 4 | + |
| 5 | +多处理器系统中,处理器都会有多级缓存,就像前面说的这些高速缓存离处理器更近并且可以存储一部分数据,所以高速缓存可以改善处理器获取数据的速度和减少对共享内存数据总线的占用。虽然缓存能极大的提高性能,但是同时也带来了挑战。比如:当两个处理器同时操作同一个内存地址的时候,该如何处理?这两个处理器在什么条件下才能看到相同的值? |
| 6 | + |
| 7 | +而内存模型就是: |
| 8 | +**定义一些充分必要的规范,这些规范使得其他处理器对内存的写操作对当前处理器可见,或者当前处理器的写操作对其他处理器可见。** |
| 9 | + |
| 10 | +实现可见性要求: |
| 11 | +**其他处理器对内存的写一定发生在当前处理器对同一内存的读之前,称之为其他处理器对内存的写对当前处理器可见。** |
| 12 | + |
| 13 | + |
| 14 | +## 1.2 Java内存模型 |
| 15 | + |
| 16 | + |
| 17 | +Java内存模型简称JMM,而JMM指的就是一套规范,现在最新的规范为JSR-133**,此规范包括: |
| 18 | + |
| 19 | +1. **线程之间如何通过内存通信**; |
| 20 | +2. **线程之间通过什么方式通信才合法,才能得到期望的结果**。 |
| 21 | + |
| 22 | + |
| 23 | +并发编程模型的两个关键问题:线程之间如何`通信`及线程之间如何`同步`。通信是指线程之间以何种方式来交换信息。命令编程模式下主要有两种通信机制:`共享内存`和`消息传递`。同步是指程序中用于控制不同线程间操作发生相对顺序机制。Java并发采用的是`共享内存模式`。 |
| 24 | + |
| 25 | + |
| 26 | +## 1.3 Java内存模型的抽象结构 |
| 27 | + |
| 28 | +Java 内存模型将内存分为`共享内存`和`本地内存`。共享内存又称为堆内存,指的就是线程之间共享的内存,包含所有的实例域、静态域和数组元素。每个线程都有一个私有的,只对自己可见的内存,称之为本地内存。 |
| 29 | + |
| 30 | +- 堆内存在线程间共享。 |
| 31 | +- 局部变量、方法参数、异常处理参数不会被线程共享,不受内存模型的影响。 |
| 32 | + |
| 33 | +java线程之间的通信方式有JMM控制,JMM决定一个线程对共享变量的写入何时对另一个线程可见。JMM定义了线程和主内存之间的抽象关系。线程间的共享变量都存储在主内存中,而每个线程都有一个私有的本地内存,本地内存存储了该线程以读/写共享变量的副本,JMM规定线程不能直接在主内存修改共享变量。Java的内存模型抽象示意图如下: |
| 34 | + |
| 35 | + |
| 36 | + |
| 37 | + public class DemoThread{ |
| 38 | + int i = 0; |
| 39 | + |
| 40 | + //Thread A |
| 41 | + public void write(){ |
| 42 | + i = 1; |
| 43 | + } |
| 44 | + |
| 45 | + //Thread B |
| 46 | + public int read(){ |
| 47 | + return i; |
| 48 | + } |
| 49 | + |
| 50 | + } |
| 51 | + |
| 52 | +一个线程间通信的过程需要经历两个步骤: |
| 53 | +1. 线程A把本地内存中修改的共享变量i刷新到主内存中去。按顺序细分为下面三个步骤: |
| 54 | + - 读取主内存中的i,保存i的副本到本地内存中 |
| 55 | + - 修改本地内存中i的值 |
| 56 | + - 把i的值刷新到主内存中去 |
| 57 | +2. 线程B到主内存中去读取线程A之间更新过的共享变量。 |
| 58 | + |
| 59 | + |
| 60 | +JMM通过控制主内存与每个线程的本地内存之间的交互,来为Java程序员提供内存可见性的保证。 |
| 61 | + |
| 62 | +<br/><br/><br/> |
| 63 | + |
| 64 | + |
| 65 | +# 2 重排序 |
| 66 | + |
| 67 | +在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排序,`指令重排序`包括下面三种: |
| 68 | + |
| 69 | +- 编译器优化重排序,在不改变单线程程序语义的前提下。 |
| 70 | +- 指令级并行的重排序,如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。 |
| 71 | +- 内存系统重排序,由于处理器可以使用缓存和读写缓冲区,这使得加载和存储操作看起来可能是乱序执行的。 |
| 72 | + |
| 73 | +```flow |
| 74 | +st=>start: 源代码 |
| 75 | +e=>end: 最终执行结果 |
| 76 | +op1=>operation: 编译器优化重排序 |
| 77 | +op2=>operation: 指令级重排序 |
| 78 | +op3=>operation: 内存重排序 |
| 79 | +st->op1->op2->op3 |
| 80 | +op3->e |
| 81 | +``` |
| 82 | + |
| 83 | +这些重排序可能会导致多线程出现的内存可见性问题。 |
| 84 | + |
| 85 | +- 对于编译器,JMM的编译器重排序会禁止特定类型的重排序 |
| 86 | +- 对于处理器重排序,JMM的处理器重排序规则会要求java编译器在生成执行序列时,插入特定类型的内存屏障(Menory Barriers)指令,通过内存屏障指令来禁止特定类型的处理器重排序。 |
| 87 | + |
| 88 | +>JMM属于语言级的内存模型,它确保在不同的编译器和不同的处理器平台上,通过禁止特定类型的编译器重排序和处理器重排序为程序员提供一致的内存可见性保证。 |
| 89 | +
|
| 90 | + |
| 91 | +## 2.1 内存屏障 |
| 92 | +现代的处理器使用写缓存区临时保存向内存写入的数据,以此来减少对内存总线的占用,它们会通过批处理的方式刷新写缓冲区。虽然性能提升了,但是每个处理器上的写缓冲区只对自己可见,这个特性会对内存操作的执行顺序产生重要的影响:处理器对内存的读写操作的执行顺序,不一定与内存实际发生的读写顺序一致! |
| 93 | + |
| 94 | +而且现代的处理器都会使用缓冲区,因此现处理器都会允许对写-读操作进行重排序。而且不同cpu的重排序的行为不同。 |
| 95 | + |
| 96 | + |
| 97 | +下面是常见处理器允许的重排序类型的列表: |
| 98 | + |
| 99 | +|| Load-Load | Load-Store | Store-Store | Store-Load | 数据依赖 | |
| 100 | +| --- | --- | --- | --- | --- | --- | |
| 101 | +| sparc-TSO | N | N | N | Y | N | |
| 102 | +| x86 | N | N | N | Y | N | |
| 103 | +| ia64 | Y | Y | Y | Y | N | |
| 104 | +| PowerPC | Y | Y | Y | Y | N | |
| 105 | + |
| 106 | +由此可见,任何处理器都不会对存在数据依赖的指令进行重排序。 |
| 107 | + |
| 108 | + |
| 109 | +为了保证内存可见性,Java编译器在生成指令序列的适当位置插入了内存屏障指令来禁止特定类型的处理器的重排序,JMM把内存指令分为四类: |
| 110 | + |
| 111 | +| 屏障类型 | 指令示例 | 说明 | |
| 112 | +| --- | --- | --- | |
| 113 | +|LoadLoad Barriers|Load1; LoadLoad; Load2 |确保load1数据的装载先于Load2及其所有后续装载指令的装载| |
| 114 | +|StoreStore Barriers|Store1;StoreStore;Store2|确保Sotre1数据对其他处理器可见(刷新到主内存)先于Sotre2及其所有后续的存储指令的存储| |
| 115 | +|LoadStore Barriers|Load1;LoadStore;Store2|确保load1数据的装载先于Sotre2及其后续所有的存储指令刷新到主内存| |
| 116 | +|StoreLoad Barriers|Store1 ;StoreLoad;Load2|确保Store1数据对其他处理器可见(刷新到主内存)先于Load2及其所有后续装载指令的装载,StoreLoad Barriers会使该屏障之前的所有内存访问指令(存储和装载)完成后,才执行该屏障之后的内存访问指令| |
| 117 | + |
| 118 | +StoreLoad Barriers是全能型的指令,同时具有其他三个屏障指令的效果,现代的大多数处理器都支持该指令(其他类型的不一定支持),但是这个指令的执行也很昂贵,它要求当前处理器把缓冲区的所有数据全部刷新到内存中去。 |
| 119 | + |
| 120 | + |
| 121 | + |
| 122 | +## 2.2 happens-before简介 |
| 123 | +happens-before就是什么一定发生在什么之前,jsr133采用happens-before概念来说明操作之间的可见性。在JMM中如果一条操作要对另一条操作可见,那么他们一定存在happens-before关系。 |
| 124 | + |
| 125 | +与程序员密切相关的happens-before规则如下: |
| 126 | + |
| 127 | +* 程序顺序规则:一个线程中的每个操作,happens- before 于该线程中的任意后续操作。 |
| 128 | +* 监视器锁规则:对一个监视器锁的解锁,happens- before 于随后对这个监视器锁的加锁。 |
| 129 | +* volatile变量规则:对一个volatile域的写,happens- before 于任意后续对这个volatile域的读。 |
| 130 | +* 传递性:如果A happens- before B,且B happens- before C,那么A happens- before C。 |
| 131 | + |
| 132 | + |
| 133 | +**`注意`**,两个操作之间具有happens-before关系,并不意味着前一个操作必须要在后一个操作之前执行!happens-before仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前(the first is visible to and ordered before the second). |
| 134 | + |
| 135 | +这两点很重要: |
| 136 | +1. 并不意味着前一个操作必须要在后一个操作之前执行 |
| 137 | +2. 仅仅要求前一个操作(执行的结果)对后一个操作可见 |
| 138 | + |
| 139 | +一个happens-before规则通常对应于多个编译器和处理器重排序规则。对于java程序员来说,happens-before规则简单易懂,它避免java程序员为了理解JMM提供的内存可见性保证而去学习复杂的重排序规则以及这些规则的具体实现。 |
| 140 | + |
| 141 | + |
| 142 | +## 2.3 数据依赖性 |
| 143 | + |
| 144 | +两个操作访问同一个变量,且这两个操作中有一个为写操作,那么这两个操作存在数据依赖性。 |
| 145 | + |
| 146 | +如下面: |
| 147 | + |
| 148 | + 写后读 |
| 149 | + a = 1; |
| 150 | + b = a; |
| 151 | + |
| 152 | + 写后写 |
| 153 | + a = 1; |
| 154 | + a = 3; |
| 155 | + |
| 156 | + 读后写 |
| 157 | + a = b; |
| 158 | + b = 1; |
| 159 | + |
| 160 | +只要重排序上面的执行顺序,程序的结果就会改变,编译器和处理器都不会对存在数据依赖的指令做重排序。 |
| 161 | + |
| 162 | + |
| 163 | +需要注意的是,这里的数据依赖性仅仅针对**`单个处理器`**中执行的指令序列和**`单个线程`**中执行的操作。不同处理器和多线性间的数据依赖不被编译器和处理器考虑。 |
| 164 | + |
| 165 | + |
| 166 | + |
| 167 | +## 2.4 as-if-serial语义与程序顺序规则 |
| 168 | + |
| 169 | +as-if-serial语义是指,遍历器和处理器为了提高并行度时可以对某些执行进行重排序,但是不管怎么排序,(单线程)程序的执行结果不能被改变。编译器和处理器,rutime都必须遵守ai-if-serial语义 |
| 170 | + |
| 171 | + |
| 172 | +如果有下面三个步骤: |
| 173 | + |
| 174 | + A 获取A的面积 |
| 175 | + B 获取B的面积 |
| 176 | + C 用A+B获取总的面积 |
| 177 | + |
| 178 | +这里有三个happens-before规则: |
| 179 | + |
| 180 | +- A happens-before B |
| 181 | +- B happens-before C |
| 182 | +- A happens-before C |
| 183 | + |
| 184 | +虽然按顺序A在B之前。也就是说`A happens-before B`,但实际执行顺序上B可以A排在B前面,因为这里操作A的结果并不需要对操作B可见。JMM仅仅要求前一个操作(执行结果)对后一个操作可见。而重排序厚度操作与操作A和操作B按happens-before顺序执行的结果一致,无论A和B怎么重排序C的结果始终不会变。JMM认为这种重排序不非法。 |
| 185 | + |
| 186 | + |
| 187 | + |
| 188 | +软件技术和硬件技术都有一个共同的目标:在不改变程序执行结果的前提下,尽可能的提供并行度。从happens-before的定义可以看出,JMM遵守这一目标。 |
| 189 | + |
| 190 | + |
| 191 | +## 2.5 重排序对多线程的影响 |
| 192 | + |
| 193 | +假如有如下代码: |
| 194 | + |
| 195 | + public ReorderExample{ |
| 196 | + int a = 0; |
| 197 | + boolean flat = false; |
| 198 | + |
| 199 | + public void writer(){ |
| 200 | + a = 1; //1 |
| 201 | + flat = true; //2 |
| 202 | + } |
| 203 | + |
| 204 | + public vodi reader(){ |
| 205 | + if(flag){ //3 |
| 206 | + int i = a * a; //4 |
| 207 | + } |
| 208 | + } |
| 209 | + } |
| 210 | + |
| 211 | + |
| 212 | +假设线程A先执行writer(),然后线程B在执行B方法,线程B在执行4时,能否看到A在操作1对共享变量a做的修改呢? |
| 213 | + |
| 214 | +不一定,操作1和操作2没有数据依赖性,而操作3和操作4也没有树依赖性,所以有可能执行顺序如下: |
| 215 | + |
| 216 | +| 时间 | 线程 | 操作 | |
| 217 | +| ------------ | ------------ | ------------ | |
| 218 | +| t1 | 线程A | 2 | |
| 219 | +| t2 | 线程B | 3 | |
| 220 | +| t3 | 线程B | 4 | |
| 221 | +| t4 | 线程A | 1 | |
| 222 | + |
| 223 | +或者: |
| 224 | + |
| 225 | +| 时间 | 线程 | 操作 | |
| 226 | +| ------------ | ------------ | ------------ | |
| 227 | +| t1 | 线程B | 读取a计算a*a,写入重排序缓冲 | |
| 228 | +| t2 | 线程A | 1 | |
| 229 | +| t3 | 线程A | 2 | |
| 230 | +| t4 | 线程B | 3 | |
| 231 | +| t5 | 线程B | 4 | |
| 232 | + |
| 233 | +3和4存在控制依赖关系,当代码存在控制依赖关系时,会影响指令的执行并行度,为此编译器和处理器会采取猜测执行来克服控制相关性,以处理器猜测为例,执行线程B的处理器可以提前读取并计算a*a,然后把计算结果临时保存到一个名为重排序缓冲的硬件缓存中,当执行3的条件判断为true时,把该计算结果写入i中。 |
| 234 | + |
| 235 | + |
| 236 | + |
| 237 | +由此可见,**重排序对多线程并发操作共享变量会产生不可预估的影响。** |
0 commit comments