认真写文章,用心做分享。公众号:Java耕耘者 文章都会在里面更新,整理的资料也会放在里面。
什么是内存模型
在多CPU的系统中,每个CPU都有多级缓存,一般分为L1、L2、L3缓存,因为这些缓存的存在,提供了数据的访问性能,也减轻了数据总线上数据传输的压力0 M | , I : ) ^ 2,同时也带来了很多新的挑战,比如两个CPU同时去操作同 ( m Z ( d一个内存地址,会发生什么?在什么条件下,它们可以看^ 4 K Y到相同的结果?这些都是需要解决的。
所以在CPUC O H }的层面,内存模型定义了一个充分必要& J A @ w / c条件,保证其它CPU的写入动作对Z U V d . X 0该CPU是可见的,而且该CPU的写入动作对其它CPU也是可见的,W G K那这种可见性,应该如何实现呢?
有些处理器提供了强内存模型,所有CPU在任何时候都能看到内存中任意位置相同的s Q l F J值,这种完全是硬件提供的支持。
其它处理器,提供了弱内Z [ m . C .存模型,需要执行一些特殊指令(就是经常看到或者? , b q听到的,memory barriers内存屏障),刷新CPU缓存的数据到内存中,保证这个写操作能够被其它CPU可见,或者将CPU缓存的数据设置为无效状态,保证其它CPU的写操作对本CPU可见。通常这些内存屏障的行为由底层实现,对于上| ! S层语言的程序员来说是透明的(不需要太关心具体的内存屏障如何实现)。
前面说{ m F z J到的内存屏障,除了实现CPU之前的数据可见性之外,还有一个重要的职责,可以禁止R ; % +指令的重排序。p . U
这里说的重排序可以发生在好几个地方:编译器、运行时、JIT等,比如编译+ 8 q器会觉得把一个变量的写操作放在最后会更有效率,编译后,这个指令就在最后了(前提是只要不改变程序的s u ! z语义,编译器、执行器就可以这样自由的随意优化),% Q = _一旦编译器对某个变量的写$ & H 3 1 l 1操作进行优化(放到最后),那么在执行之前,另一个H , r线程将不会看到这个执行结果。
当然了,写入动作可能被移到后面,那也有可能被挪到了前面,这样的“优化”有什么影响呢?这种情况下,其它线t 3 5 /程可能会在程序实现“发生”之前,看到这个写入动作(这里怎么理解,指令已经执行了,但是在代码层面还没执行到)。通过内存屏障的功能,我们可以禁止一些不必要、或者会带来负面影响的重排序优化,在内存模型的范围内,实现更高的性_ k . - 2能,同时保证程序的正确性。
下面看一个重排序的例子:
Class Reordering {
int x = 0, y = 0;
public void- - c 9 # x f writer() {
x = 1;
y = 2;
}
public void reader() {
int r1 = y;
int r2 = x;
}
}
假设这段代码有2个线程并发执行,线程A执行, a N e * ! 5 cwriter方法,线程B执行reader方法,线程B看到y的值为2,因为把yR E e设置成2发生在y 1 p 5 d @变量x的写入之后(代码层面),所以能断定线程B这时看到的x就是1吗?
当然不行! 因为在writer方g y * f i法中,可能发生了重排序,y的写入动作可能发在x写入之前,这种情况下,线程B就有可能看到x的值还是0。
在Java内存模型中,描述了在多线程代码中,哪些行为是正确的、合法的,以及多线程之间如何进行通信,代码中变量的读写行为如何反应到内存、CPU缓存的底层细节。
在Java中包含了几个关键字:volatile、final和synchronized,帮助程{ % w 7 | 5 *序员把代码中的} u l l ?并发需求描述给编译器。Java内存模型中定义了它们的行为,确保正确同步的Java代码在@ * u 0 1 ; m }所有的处理器架构上都能! X F v C v正确执行。
synchronization 可以实现什么
Synchronization有多种语义,其中最容易理解的是互斥,对于一L ` ( m ~ w y r l个monitor对象,只能够被一个线程持有,意味着一旦有线程进入了同步代码块,那么其它线程就不能进入直到/ E # Q A H ) #第一个进入的线程退出代码块(这因为都能理解)。
但是b ;更多的时候,使7 5 4 D I $ G用synchronization并非单单互o K N R u U斥功能,SynY 7 z 8 Z 2chronization保证了线程在同步块之前或者期间写入动作,对于后续进入该代码块的线程是可见的(又是可见性,不过这里需要注意是对同一个monitor对象而言)。在一个线程退出同步块时,线程释放monitor对象,它的作用是把CPU缓存数据(本地缓存数据)刷新到主内存中,从而实现该线程的行为可以被v # p N a其它线程看到。在其它线程进入到该代码块时,需要获得monitor对象,它在作用是使CPU缓存失效,从而使变量从主内存中重新加载,然后就可以看到之前线程对该变量的修改。
但从缓存的s N 7 Z k /角度看,似乎这个问题只会影响多处理器的机器,对于单核来说没什么问题,但是别忘了,它还有一个语义是禁止指令的重排序,对于编译器来说,同步块中的代码不会移动到获取和释放monitor外面。l = m
下面这种代码,千万不要写,会让人笑掉大牙:
synchronized (new Object()) {}
这实际上是没有操作的操作,编译K L N { : -器完成可以删除这个同步语义,因为编译知道没有其它线程会在同一个monitor对象上同步。
所以,请注意:对于两个线程来说,在相同的monitor对象上同步是 M v [ c N 0 . U很重要的,以便正确的设置happen* , p M ; C b 8s-before关系。
final 可以影响什么
如果一个类包含final字段,且在构造( q @ ` I w函数中初始化,那么Y | E b P , } S正确的构造一个对象^ T l J : L - h m后,final字段被设置后对于其它线程是可见的。
这里所说的正确构g _ k O f b ^造对象,意思是X o y 9在对象的构造过程中,不允许对该对象进行引用,不然的话,可能存在其它线程在对象还没构造完成时就对该对象进行访问,造成不必要N # = f 9 c l )的麻烦。
c8 h g ; e G Y =lass FinalFieldExaw $ O 0 * Hmple {
final int x;
int y;
static FinalFieldExample f;o + ` & o R A N
public FinalFieldExample() {
x = 3;
y = 4;
}Y _ y 6 !
static void writer() {
f = new FinalFieldExX 0 m ] Kample();
}
static void reader 8 U P P e N =() {
if (f != null) {
int i = f.x;
int j = f.y;
}
}
}
上面这个例子描述了应该如何使用final字段,一个线程A执行reader方法,如果f已经在线程B初始化好,那么可以确保线程A看$ 1 u X 0 ? : N到x值是3,因为它是final修饰的,而不能确保看到y的值是4。 如果构造函数是下面这i 1 6 n [ q样的:
public FinalFieldExample() { /Q N F n [ &/ bad!
x = 3;
y = 4;
// bad construction - allowing this to escape
global.obj = this;
}
这样通过global.obj拿到对象后,并不能保证x的值是3.
###volatile可以做什么 Volatile字段主要用于线程之间进行通信,volatile字段Z s q m w I U ~ _的每次读行为都能看到其它线程最后一次对该字段的写行为,通过它就可以避免拿到缓存中陈旧数据。它们必须保证在被写入之后,会被刷新到主内存中,这样就可以立即对其它线程可以见。类似的,在读取volatile字段之前,缓A 0 w o ( s J E i存必须是无效的,以保证每次拿到的都是主内存的值l f , ? j,都是最新的值。a [ g 3 5 . = X ivolatile的内存语义和sychronize获取和释放monitor的实现目的是差不多的。
对于重新排序,volatile也有额外的限制。
下面看一个例子:
class VolatileExample, 3 X Z {
int x = 0;
volatile boolean v = false;
public void writer() {
x = 42;
v = true;
}
publ= p ~ c ! b A $ ic void reader() {
if (v == true) {
//uses x - guaranteedO a f ) P M 0 g K to see 42.
}
}
}
同样的,假设一个线程A执行writer,另一个线程X p b @B执行reader,writer中对变量v的写入把x的写入也刷新到主内存中。reader方法中会从主内存重新G u * }获取v的值,所以如果线程B看到v的值为true,就能保证拿到的x是42.(因为把x设置成42发生在把v设置成true之前,volatile禁止这两个写入u 8 w $ 0 J * A x行为的重排序)。
如果变量v不是volatile,那么以上的描述就不成立了,因为执行顺序可能是v=true, x=42,或者对于线程B来说,根本看不到v被设置成了true。
JVM内存操| ! )作的并发问题
结合前面介绍的物理机的处理器处理t o U v内存的问题,可以类比总结出JVM内存操作的问题,下p + I F : S A P `面介绍的Java内存模^ | u s I W = r型的执行处理将围绕解决这! : x ;2个问题展开:
- 1 工作内存数据一致性 各个线程操作数据时会保存使用到的主内存中的共享变量副本,当多个线程的运算任务都涉及同一个共享变量时,将导致各自的的共享变量副本不一致,如果真的发生这种情况,数据同步回主内存以谁的副本数据为准?3 1 8 3 # @ j ] { Java内存& 2 5模型主要通过一系列的o / ; 2数据同步协议、规则来保证数据的一致性,后面再详细介绍。
- 2 指令重排序优化p F } k ! $ Java中重排序通常是编译器或运行时环境为了优化程序性能而采取的对指令进行重新排序执行的一种手段。重; M | F A S { 3 !排序分为两类:编译期重排序和运行期重排序,分别对应编译时和运行时环境。 同样的,指令重排序不是随意重排序,它需要满足以下两个条件: 1 在单线程环境下不能改变程序运行的结果 即时编译器(和处理器)需要保证程序能够遵守 as-if-serial 属性。通俗地J v D /说,就是在单线程情况下,要给程序一个顺序执行的假象。即经过重排序的执行结果要与顺序执行的结果保持一致。 2 存在数据依赖关系的不允许重排序
多线程环境下,如果线程处理逻辑之间存在依赖关系,有可能因为指令重排序导致运行6 y * Q s +结果与预期不同,后面再展开Java内存模型如何解P 5 [ _ z决这种情况。
Java内存间的交互操作
在理解Java内存模型的系列协议、特殊规则之前,我们先理解Java中内存间的交互操作。
交互操作流程
为了更好理解内存的交互操作,以线程通信为例,我们看看具体如何进行线程间值e . e 8 [ G D 的同步:
线程1和线程2都有主内存中共享变量x的副本,初始时,q l } | j ? ` m {这3个内存中x的值都为0。线程1中更新x的值为1之后同步到线程2主要涉及2个步骤:
- 1 线程1把线程工作内存中更新过的x的值刷新q I H ) 2 w到主内存L v D中
- 2 线程u , 2 S E 9 ? X2到主内存中读取线程1之前已更新过的x变N 5 z D b量
从整体上看,这2个步骤是线程1在向线程2发消息,这个通信过程必须经过主内存。线程对变量的所有操作(读取,赋值)都必须在工作内存中进行。不同线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成,实现各个线程提供共享变量的可见性。v R j Q { : s 0
内存交互的基本操作
关于主内存与工作内存之间的具体交互* l H ~ / j n协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步回主内存之类的实现细节,Java内存模型中定义了下面介s Z v O k O绍8种操作来完+ n 8 A 0 7成。
虚拟机实现时必须保证下面介绍的每种操作都是原子的,不可再分的(对于double和long型的变量来说,load、storB q Qe、ready D 2、和write操作在某些平台上允许有例外,后面会z u c ; B 6 1 4 }介绍)。
8种基本操作
- lock (锁定) 作用于主内存的变量,它把一个变量标识为一条线程独占的状态。
- unlock (解锁) 作用于主内X W # p存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
- read (读取) 作用于主内存的变量,它把一Z 2 ,个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用。
- load (载入) 作用于工作X D L w !内存的变量,它把read操作从主内存中得到M s 8 W的变量值放入工作内存的变量副本中。
- use (使j d T ~ F ,用) 作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎,` Z b , i每当虚拟机遇到一个需要使用到变量的值得字节码指令时就V * V + ^会执行这个操作。
- assign (赋值) 作用于工作P Y q @ p Z a % L内存的变量,它把一个从执行引擎L d s 6 f B接收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
- store (存储) 作用于工作内存的变量,它把工作内存中一个变量的值] } l I u Y传送到主内存中,以便随后write操作使用。
- write (写入) 作用于主内存的变量,它把store操作从工作内存中得到的变量的值放E V L /入主内存的变量中。
Java内存模型运行规则
内存交互基本操作的3个特性
在介绍内存的交互的具体的8种基本操作之前,有必要先介绍一下操作的3个特性,Jal 5 Y l 8 ] B =va内存模型是围绕着在并发过程中如何处理这3个特性来建立的,这里先给出定义和基本实现的简单介绍,后面会逐步展开分析。
- 原子性(Atomicity) 即一个操作或者& L & K多个操作 要么全部} 3 c执行并且执行的过程不会被任何因素打断,要么就都不执行。即使在多个线程一起执行的时候,一个操作一旦开始,就不会被其他线程所干扰。
- 可见性(Visibility) 是指当多个线程访问同一个变R 8 ^ =量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。 正如上面“交互操作流程”中所说明的一样,JMM, y g D是通过在线程1变量工. 9 Z 7 s作内存修改后将新值同步回主内存,线程2在变量读取前从主内e U ^ 2 X @ # C存刷新变量值,这种依赖主内存作为传递媒介的方式来实现可见性。
- 有序性(Ordering) 有序性规则表现在以下两种场景: 线程内和线程间 线程内 从某个线程的角度看方法的执行,指令会按照一H 4 v / _ ^ H C %种叫“串行”(as-if-serial)的方式执行,此种方式已经应用于顺序编程语言。 线程间 这个线程“观察”到其他线程并发地执行非同步的代码时,由于指令重排序优化,任何代码都有可能交叉执行。唯一起作用的约束是:对于同步方法,同步块(synchronized关键字修饰)以及volatile字段的操作仍维持相对有序。
Java内存模型的一系列运行规则看起来有点A s ] r繁琐,但总结起来,是p r s G ; h围绕原子性、可见性、有序性特征建立。归根究底,是为实现共享变量的在多个线程0 p C Y _ Z的工作内存的数据一致性,多线程并发,指令重排序优化的环境中程序能如预期运行。
happens-before关系
介绍系列规则之前,首先了解一下happens-before关系:用于描述下2个操作的内存可$ 3 z 3见性:如果操作A happens-befol Ire 操作B,O K * r n f q那么A的结果对B可见。happens-before关系的分析需要分为单线程和多线程的情况:
- 单线程下的 happens-before 字节码的先后顺序天然包含happens-beJ X q G q M F e /fore关系:因为单线- j / { H程内共享一份工作内存,不存在数据一致性的问题。 在程序控制流路径中靠前的字节码 hap+ Q g z } wpens-before 靠后的字节码,即靠前的字节码执行完之后操作结果对靠后的字节码可见。然而,这并不意4 O S o & D / 4 Y味着前者一定在后者之前执行。实际上,如果后者不依赖前者的运行结果,那么它们可能会被重排序。
- 多线程下的 happens-before 多线程由于每个线程有/ o &共享变量的副本,如果没有对共享变量做同步处理,线程1更新执行操作A共享变量的值之后,线程2开始执行操作B,此时操作A产生的结果对操作B不一定可见。
为了方便程序开发,Java内存模型实v B L i X F d现了下Q 2 . 4述支持happens-before关系的操J M O ) p N作:
- 程序次序规则 一个线程内,按照代码Z R X z % 4 $ 0 o顺序,书写在前面的操作 happens-before 书写在后面的U R ; L h操作。
- 锁定规则 一个unLock操作 happens-before 后面对同一个锁的lock操作。
- volatile变量规则 对一个变量的写操作 hap; L _ 1 j i c ; Lpens-before 后面对这个变量的读操作。
- 传递规则 如果操作A happensS R } - 2-before 操作B,而操作B又 happens-before 操作C,则可以得出操作A happens-before 操作C。
- 线程启动规则 Thread对象的start()方法 happeA ( W Y Z Ons-befor| k U ] m e D ( le 此线程的每个一个动作。
- 线程中断规则 对线程interrupt()p @ l [ B i k 9方法的调用 happens-before 被中断线程的代码检测到中断事件的发生。
- 线程终结规则 线程中所有的操作都 happens-before 线程的终止检测,~ ( * m -我们可以通过Thread.join()方法结束、Thread.isA. Q n Q `live()的返回值手段检测到线程已经终止执行。
- 对象终结规则 一个对象的初始化完成 happens-befo; f G = 0 / B 2re 他的finalize()方法的开始
内存屏障
Java中如何保证底层操作的有序性和可K . s ]见性?可以通过内存屏障。
内存屏障是被插入两个CPU指令之间的一种指令,用来禁止处理器指令发生重排序(像屏障一样),从而保障有序性的。另外,为了达到屏障的效果= I K 0 P T P ;,它也会使处理器写入、读取M ( j T C ~ 6 0 E值之前,将主内存的值写入高速缓存,清n ( 3 8 e空无效队列,从而保障可见性。
举个例子:
Store1;
Store2;
Load1;
StoreLoad; //内存屏障
Store3;
Load^ ; : @ q ; z { z2;
Load3;
对于上面的一组CP^ = D e [U指令(Store表示写入指令,Load表示读取指令),S) X + utoreLoad屏障之前N L z |的Stor8 { A 4 te指令无法与StoreLoad屏障之后的Lo| D T h _ gad指令进行交换位置,即重排序。但是StoreLoad屏障之前和之后的指令G g X H 8 #是可以互换位置的,即Store1可以和Store2互换,Load2可以和Loadh s ! + | % O3O Z [ z互换。
常见有4种屏障
- LoadLoad屏障: 对于这样的语句 Load1; LoadLoad; Load2,在Load2及后续读取操作要- / Z读取的数据被访问前,保3 ] x D证Load1要读取的数据被v y V D & ) j f读取完毕。
- StoreStore屏障: 对于这样的语句 Store1; StoreStore; Store2,在Store2及后续写入操作执行前,d ; y y L O保证Store1的c ` 5写入操作对其它处理器可见。
- LoadStore屏障: 对于这样的语句Load1; LoadStore; Store2B % A,在Store2及后续写入操作被执X b 6行前,保证Load1要读取的数据被读取完毕。
- StoreLoad屏障: 对于这样的语句Store1; StoreLoad; Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。它的开销是四种屏- P 7 E ] s障中最大的(@ ( - + M X冲刷写缓冲器,清空无效化队列)。在大多数处理器的实现中,这个屏障是个万能屏障,兼具其它三种内存屏障的功能。
Java中对内存屏障的使用在一般的代码中不太容易见到,常见的有volatile和synchronized关键字修饰的代码块(后面再展开介绍),还可以通过Unsafe这个类来使用内存屏障。
8种操作同步的规则
JMM在执行前面介绍8种基本操作时,为了保证内存间数据一致性? 9 j . F } ,JMM中规定需要满足以下规则:
- 规则1:如果要把一个变o / H [ d 4 [ v量从主内存中复制到工作内存,就需要按顺序h ~ P o ) } / M的执行 read 和 load 操作,如果把变量从工作内b - + k |存中同步回主内存中,就要按顺序0 z J m 8 t的执行 store 和 write 操作。但 Java 内存模型只要s ; k k求上述操作必须按顺序执行,而没有保证必须是连续执行。
- 规则2:不允许 read 和 load、store 和 write 操作之) ( e E F E 一单独出现。
- 规则3:不允许一个线程丢弃它的最近 assign 的操作,即变量在工作内存中改变了之后必须同步到主内存中。
- 规则4:不允许一个线程无原因的(没有发N , ! B y ; [生过任何 assign 操作)把数据从工作内存同步回主内存中。6 * * ` A
- 规则5:一个新的变量只能在主内存中诞生_ ! Q _ J V 0 ; ^,不允许在工作内存中直接使用一个未被初始化(lo/ Q n {ad 或 assign )的变量。即就是对一个变量实施 usA | } b ? K ye 和 store 操作之前,必须先执行过了 load 或 assign 操作。
- 规则k 5 v ~ 4 M 2 L k6:一个变量在同一个时刻只允许一条线程4 2 d % 8 v T ; V对其进行 lock 操作,但 lock 操作可以被同一条线程重复执行多次,多次执行 lock 后, Z N,只有执行相同次数的 unlot P T sck 操作,变量才会被解锁。所以 locZ 1 Z 3 n fk 和 unlock 必须成对出现。
- 规则7:如果对一个变量执行 lock 操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前需要重新执行 load 或 assign 操作初始化变量的值。
- 规则8:如果一个变量事先没有被 lock 操作锁定,则不允许对它执行 unlock 操作;也不允许去 unlock 一个被其他线程锁定的变量。
- 规则9:对一个变量执行 unlock 操作之前,必须先把此变量同步到主内存M q M A U C C . .中(执行 store 和 write 操作)
看起来这些规则有些繁琐,其实也不难理解:
- 规则1、规则2 工作内存中e Y |的共享变量作为主内存的副本,主内存变量的值同步到工作内存需要read和load一起使用,工作内g y V d ] W w [存中的变量的值同步回主内存需要store和write一起使用,这2组操作各自都是是一个固定的有序搭配,不允许单独出现。
- 规则3、规则4 由于工作内存中的共享变量是主内存的副本,为保证数据一致性,当工作内存中的变量被字节码引擎重新赋值,必须同步回主内存。如果工作内存的变量没有被更新R & Y j 4 8 X,不允j ` E许无原因同步W P a回主内存。
- 规则5 由于工作内存中的共享变量是主内存的副本,必须从主内存诞生。
- 规则6、7、8、9 为了并发情况下~ ^ d * h O k Q安h q H # G ! 1 _ d全使用变量,线程可以基于lock操作独占主内存中的变量_ w 6 G h &,其他线程不允许使用或unlock该变量,直到变量被线程unlock。
volatile型变量的特殊规则
volatile的中文意思是不稳定的,易变的,用volatile修饰变量是为了保证变量的可见性。
volatile的语义
volatile主要有下面2种语义
语义1 保证可见性
保证了不同线程对该变量操作f u :的内存可见性。
这里保证可见性是不等同于volatile变量并发操作的安全性,保证可见性具体[ S C v g一点解释:
线程写volatile变量的过程:
- 1 改变线程工作内存中volatile? ? _ 8变量副本的值
- 2 将改变后的副本的值从工作) q g |内存刷新到主内存
线程读volatile变量的过程:
- 1 从主内存中读取volq y K A W D 5atile变量的最新值到线程的工作内存中
- 2 从工作内存中读取volatile变量的副本
但是如果多个线程同时把更J 8 ^ 3 :新后的变量值同时刷新回主内存,可能导致得到的值不是预期结果:
举个例子: 定义volatile int count = 0,2个G ; L @ 0 7 r _线程同时执行count++操作,每个线程都执行500次,最终结果小于1000,原因是每个线程执行count++需要以下3个步骤:
- 步骤1 线程从主内存读取最新的count的值
- 步骤2 执行引擎把count值加1,并赋值给线程工作内存
- 步骤3 线程工作内存把count值保存到主内存 有p G g可能某一时刻2个线程在步骤1读取到的值都是100,执行完步骤2得到的值都是101,最后刷新了2次101保存到主内存。
语义2 禁止进行指令重排序
具体一点解释,禁止重排序的规则如下:
- 当程序执行到 volatile变量的读操作或% l Z者写操作时,在其前面的操作的更改肯定全部已经进行,且结果已经对后面的操作可见;在其后[ # H面的操作肯定还没有进行;
- 在进行指令优化时,不能将在对 volatile 变量] F + Q Q b访问的语句放在其后面执行,也不能把 volatile 变量后面的语句放到其前面执行。
普通的变量仅仅会保证该方法的执行过程中所k ( & Z I有依赖赋值结果的地方都能获取到正确的结果,而不能保证赋值操作的顺序与程序代码中的执行顺序一致。
举个例子:
volatile boolean initialized = false;
/! } 0/ 下面代码线程A中2 . y 2 x V执行
// 读取配置信| a * :息,I . Z当读取完成后将initialized设置为true以通知其他i P j #线程配置可用
doSomethingReadConfg();
initialized = true;
// 下面代码线程B中执行
// 等待initiali& U ` / f !zed 为true,代表线程A已经把配置信息初始化完成
whileA w ~ q m (!initialized) {
sleep();
}
// 使用线程A初始化好的配置信息
doSomethingWithConfig();
上面代码中如果定义initialized变量时没有使用volatile修饰,就有可能会由于指令重排序的优化,导致线程A中最后一句代码 \"initialized = true\" 在 “doS~ [ d H ~omethingReadConfg()” 之前7 @ 7 E x被执行,这样会导致线程B中使用配置` N ] Q h 6 N信息的代码就可能出现错误,而volatile关键字就禁止重排P { 4 a a { X v序的语义可以避免此类情况发生。
volatile型变量实现原理
具体实现方式是在编译期生成字节码时,会在指令序列k J N I , q中增加内存屏障来保证,下面是基于保守策略J # j y s h N的JMM内存屏障插入策略:
- 在每个volatl j 4 u j ile写操作的前面插入一个StoreStore屏障。 该屏障除了保证了屏障之前的写操作和该屏障之后的写操作不能重排序,还会保证了volatile写操作之前,任何的读写操作都会先于volatile被提交。
- 在每个volatile写操作的后面插入一个StoreLoad屏障。 该屏障除了使volatile写操作不会与之后的读操作重排序外,还会刷新处理器缓存,使volatile变量的写更新对其他线程可见。
- 在每个volatile读操作的后面插入一个LoadLoad屏障。 该屏障除了使volatilO P 1 M { ) }e读操作不会与之前的写操作发生重排序外A l s 3,还会刷新处理器缓存,使volatile变量读取的为最新值。
- 在每个volatile读操作的后面插入一个LoadStore屏障。 该屏障除了禁止了volatile读操作与其之后的任何写操作进行重排Y c i t o序,还会刷新处理器缓存,使其他线程volatile变量的写更新对volatile读操作的线程可见。
volatile型变量使用场景
总结起来,就是“一次写入,到处读取”,某一线程负责更新变量,其他线程只读取变量(不 Z %更新变量),并根据变量的新值执d ] ( 8 G行相应逻辑。例如状态标志位更新,观察者模型变量值发布。
final型变量的特殊规D 1 C 8 N } y z则
我们知道,final成员变量必须在^ g c声明的时候初始化或者在构造器中初( { k _始化,否则就会报编译错误。 final关键字的可见性是指:被final修饰的字段在声明时或者构造器中,一旦初始化完成,那么在其他线程无须同$ m y 3 w f h M步就能正确看见final字段的值。这是因为一旦初始化完成,final! k E 7 | ` 9 A .变量的值立刻回写到主内存。
syn2 J o A 2 K u Echronized的特殊规则
通过 synchronized关键字包住的代码区域,对数据的读写进行控制:
- 读数据 当线程进入到该区域读取变量信息时,对数据的读取也不能从工作内存读取,只能从内存中读取,3 l E ) ? % 5 9保证读u 0 L m ! $ g到的是最新的值。
- 写数据 在同B L B | U T ! b步区内对变量的写入操作,在离开同步区时就将当前线程8 n U o [ # x & l内的数据刷新到内存中,保证更新的数据对其他线程的可见性w c a。
long和doubl- 2 ee型变量的特殊规则
Java内存模型要求lock? H = a D 1、unlo T h 8 + &ck、read、load、assign、use、store、write这8种操作都具有原子性,但是对于64位的数据类型(long和double),在模型中特别定义相对宽松的规定:允许虚拟机将没有被volatil$ 8 Ue修饰的64位数据的读写操作分为2次32位的操作来进行。也就是说虚拟机可选择不保证64位数据类型的load、store、read和write这4个操作的原子性。由于这种非原子性,有可能导致其他线程读到同步未完成的“32位的半个变量”的值。
不过实际~ 3 h ( F } ( ?开发中,Java内存模型强烈建议虚拟机把64位数据的读写实现为具有原子性,目前各种平台下的商用虚拟机都选择把64位数据的读写操作作为原子操作来对待,因此我们在编写代码时一般不需要把用到的long和doubt % 3 G kle变量专门声明r K C ! t为volatile。
总结
由于Java内存模型涉及系列规则,网上的文章大部分就是对这些规则进行解析,但是= - E O J / ; v U很多没有解释为什么需要这些规则,这些f ^ +规则的作用,其实这是不利于初学者学习的,容易绕进i ; 6 S T _ N e !去这些繁琐规则不知所以然,下面谈谈我的一点学习知识的个人体会:
学习知识的过程不是等同于只是理解知识和记忆知识,而是要对知识解决的问题的输入和输出* W K e h建立连接,知识的a E S & L 8 / 8本质是解决问题,所以在学习之前要理解问题,理解这个问题要的输出O 5 | X 9 i F和输出,而知识就是输入到输出的一个关系映射。知识的学习要结合大量的} B m p 6 V例子来理解这个映射关系,然后压缩知识,华罗庚说过:“把一本书读厚,然后再读薄”,解释的就是这个道理,先结合大量的例子理解知识,然后再压缩知识。
以学习Java内存模型为例:
- 理解问题,明确输入输出 首先理解Java内存模型是什么,有什么用,解决什么问题
- 理解内存模型系列协议 结合大量例子理解这些协议规则
- 压缩知识 大量规则其实就是通过数据同步协议,保证内存副本之间的数据一致性,同y E w ? g 9 /时防止重排序对程序的影响。
认真写文章,用心做分享。公众号:JT 0 @ 2 xava耕耘者 文章都会在里面更新,整理的资料也会放在里面。