Java内存模型详解

179 阅读11分钟

「这是我参与2022首次更文挑战的第25天,活动详情查看:2022首次更文挑战」。

一、Java内存模型定义:为了解决屏蔽掉各种硬件和操作系统的内存访问差异,对内存与高速缓存进行读写操作的过程抽象。

目前大多数硬件采用的缓存一致性策略或协议是MESI或基于MESI的变种:

M代表更改(modified),表示缓存中的数据已经更改,在未来的某个时刻将会写入内存;

E代表排除(exclusive),表示缓存的数据只被当前的核心所缓存;

S代表共享(shared),表示缓存的数据还被其他核心缓存;

I代表无效(invalid),表示缓存中的数据已经失效,即其他核心更改了数据。

二、JMM内存中定义了8种内存交互操作,每种操作都是原子的。

内存操作作用范围作用
lock主内存的变量把一个变量标识为线程独占状态
unlock主内存的变量将锁定状态变量释放出来
read主内存变量将主内存变量同步到工作内存
load工作内存变量将read操作从主内存得到的变量值放入工作内存变量副本
use作用用工作内存的变量把工作内存中变量副本传递给执行引擎
assign作用于工作内存的变量从执行引擎接收到的变量赋给工作内存变量
store作用于工作内存变把工作内存变量传到主内存,便于write操作使用
write作用于主内存的变量把store操作从工作内存中得到的变量的值放入主内存的变量中

如果要完成主内存复制到工作内存,则需要read与load顺序执行,不需要连续执行

如果要完成工作内存同步到主内存,则需要store与write顺序执行,不需要连续执行

还有如下规定:

1、read/load,store/write不能单独出现

2、不允许线程丢弃assign行为

3、lock一个时刻只能有1个线程执行成功,lock与unlock必须执行次数相同

4、lock执行后,工作内存的值会清空,执行引擎使用该变量时需要执行read/load操作从主内存读取,也就是sychronized操作为什么可以保证可见性的原因。

5、unlock之前必须把变量同步到主内存,也是sychronized可以实现线程可见性原因。

三、再说volatile在JMM中的特殊规则

1)修改volatile变量时会强制将修改后的值刷新的主内存中。 普通变量做不到

2)修改volatile变量后会导致其他线程工作内存中对应的变量值失效。因此,再读取该变量值的时候就需要重新从读取主内存中的值。 普通变量做不到

因此,对于volatile修饰的变量,如果只是读取操作,可以保证线程直接可见性。对于a++这种非原子操作并不保证线程安全。

volatile 对long,double64位的类型数据赋值操作具有原子性 普通变量对这种类型读写操作会进行分高32位低32位处理 修饰为volatile后,就使这种操作有了原子性 。但是对于这种变量自增/自减操作是不保证原子性的

17.7双长的非原子处理在Java编程语言内存模型中,对非易失长值或双值的单次写入被视为两次单独的写入:每32位一半写入一次,目前虚拟机都选择把64位数据的读写操作作为原子操作对待。

第二 禁止指令重排序  通过内存屏障 hapen-before 原则

编译volatile变量赋值的代码可知道,加入了lock 指令前缀(cpu级别的指令)

它有两个作用:

使得当前修改的变量立即同步到主内存

其他cpu持有的变量副本失效

volatile在不满足以下两种场景的情况下就可以使用:

1、对该变量读取后的操作,不依赖原来的值

2、不需要与其他状态变量来维护不变约束

JMM规定了对volatile指令的内存操作顺序行为来保证可见性:

场景:线程T,V,W都是修饰成volatile的变量

1、load V     use V必须连续出现,而read load又是依赖出现,所以表现为read 2、load use V必须连续出现 。这样就是使用V之前必须从主内存读取V的值,使用volatile变量时都是从主内存读取使用,不是直接从工作内存读取使用。

3、assign store  必须连续使用,而store write操作又是依赖出现,也就是每次修改volatile变量后,都必须立即刷新到主内存。

4、T分别对V进行use assign 操作A, W进行use assign 操作B,如果A早于B

那么T对V进行read操作P,W进行read操作Q,那么P早于Q这样就保证volatile修饰的变量不会被指令重排序优化。

四、 并发编程的三个问题

原子性:read,load,assIgin等指令都是原子性的。

而lock,unlock指令提供了原子性支持,jvm体现在字节码monitorenter于monitorexit隐式使用

程序中使用sychronized来实现该语义。

可见性,volatile,sychronized(unlock执行前,必须将变量写会主内存),final也能实现可见性

有序性:一个线程内操作都是有序的,线程内串行,如果存在多个线程共享变量,则会由于指令重排序优化与工作内存与主内存存在延迟,volatile,sychronized(一个变量在同一个时刻只允许一条线程对其lock操作,必须排队)

五、Java中happen-before原则,java中无需同步机制就可以保证的先行发生原则:判断数据是否有竞争,线程是否安全的依据:

1、程序次序规则 。控制流顺序,写在前面的先执行

2、管程锁定规则:同一个锁的unlock必须发生于后面同一个锁的lock操作前

3、volatile规则 代码中 volatile写操作必须早于后面的读取操作

4、Thread start方法会比这个线程其他动作早执行

5、Thread stop 结束线程前线程操作都执行完毕,Thread.join方法,Thread.isAlive方法探测线程是否结束。

6、Thread interrupt()方法早于interupted()方法(检测线程中断事件)

7、对象构造函数早于finalize方法执行前

8、传递性,如果a早于b,b早于c,则a早于c

9、先行发生原则与时间顺序无关。

Java的内存屏障通常所谓的四种即LoadLoad,StoreStore,LoadStore,StoreLoad实际上也是Load,Store两种的组合,完成一系列的屏障和数据同步功能。

LoadLoad屏障:对于这样的语句Load1; LoadLoad; Load2,在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。

StoreStore屏障:对于这样的语句Store1; StoreStore; Store2,在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见。

LoadStore屏障:对于这样的语句Load1; LoadStore; Store2,在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。

StoreLoad屏障:对于这样的语句Store1; StoreLoad; Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。它的开销是四种屏障中最大的。在大多数处理器的实现中,这个屏障是个万能屏障,兼具其它三种内存屏障的功能

LoadLoadLoad1(s); LoadLoad; Load2在加载2和任何后续加载操作之前 确保Load1完成(获取从内存加载的值)
StoreStoreStore1(s); StoreStore; Store2在存储2和任何后续存储确保Store1已经完成(对Store1的内存产生影响对其他处理器可见,volatile实现了内存可见性原因)
LoadStoreLoad1(s); LoadStore; Store2确保Load1在Store2和任何后续存储之前完成//操作
StoreLoadStore1(s); StoreLoad; Load2确保Store1完成操作在Load2和任何后续加载之前
fence我们将“栅栏”操作定义为双向屏障。它保证在围栏之前的任何内存访问都不会重新排序,程序中围栏之后的任何内存访问命令。这可用于防止指令重排序

Java中又定义了release和acquire,fence三种不同的语境的内存栅栏. 

如上图,loadLoad和loadStore两种栅栏对应的都是acquire语境,,acquire语境一般定义在java的读之前;在编译器阶段和cpu执行的时候,acquire之后的所有的(读和写)操作不能越过acquire,重排到acquire之前,acquire指令之后所有的读都是具有可见性的.

如上图,StoreStore和LoadStore对应的是release语境,release语境一般定义在java的写之后,在编译器和cpu执行的时候,所有release之前的所有的(读和写)操作都不能越过release,重排到release之后,release指令之前所有的写都会刷新到主存中去,其他核的cpu可以看到刷新的最新值.

对于fence,是由storeload栅栏组成的,比较消耗性能.在编译器阶段和cpu执行时候,保证fence之前的任何操作不能重排到屏障之后,fence之后的任何操作不能重排到屏障之前.fence具有acquire和release这两个都有的语境,即可以将fence之前的写刷新到内存中,fence之后的读都是具有可见性的.

内存屏障,也称内存栅栏,内存栅障,屏障指令等,是一类同步屏障指令,是CPU或编译器在对内存随机访问的操作中的一个同步点,使得此点之前的所有读写操作都执行后才可以开始执行此点之后的操作。语义上,内存屏障之前的所有写操作都要写入主存;内存屏障之后的读操作,直接读取的主存,可以获得内存屏障之前的写操作的结果。

完全内存屏障(full memory barrier)保障了早于屏障的内存读写操作的结果提交到内存之后,再执行晚于屏障的读写操作,在loadbuffer和storebuffer中插入屏障,清空屏障之前的读和写操作。X86中对应MFence;

内存读屏障(read memory barrier)仅确保了内存读操作.在loadbuffe中插入屏障,清空屏障之前的读操作;LFence

内存写屏障(write memory barrier)仅保证了内存写操作.在storebuffer中插入屏障,清空屏障之的写操作; SFence

通过查看hotspot源码可知,使用了伪完全屏障与编译器屏障

inline void OrderAccess::loadload()   { compiler_barrier(); }

inline void OrderAccess::storestore() { compiler_barrier(); }

inline void OrderAccess::loadstore()  { compiler_barrier(); }

inline void OrderAccess::storeload()  { fence();            }

inline void OrderAccess::acquire()    { compiler_barrier(); }

inline void OrderAccess::release()    { compiler_barrier(); }

// A compiler barrier, forcing the C++ compiler to invalidate all memory //assumptions  使得所有缓存失效

static inline void compiler_barrier() 

{

 asm volatile ("" : : : "memory"); 

}

inline void OrderAccess::fence() {

// always use locked addl since mfence is sometimes expensive

#ifdef AMD64

asm volatile ("lock; addl $0,0(%%rsp)" : : : "cc", "memory");

#else

// lock cpu级别汇编指令两个作用:

1、使得当前处理器缓存数据同步到主内存,

2、其他cpu核心缓存中数据无效,实现缓存一致性问题

asm volatile ("lock; addl $0,0(%%esp)" : : : "cc", "memory");

#endif

compiler_barrier();

}

解释一下: asm :代表汇编代码开始.

volatile:禁止编译器对代码进行某些优化.

Lock :汇编代码,让后面的操作是原子操作.lock指令会锁住操作的缓存行(cacheline),一般用于read-Modify-write的操作例如 addl.“cc”,”mmeory”:cc代表的是寄存器,memory代表是内存;这边同时用了”cc”和”memory”,来通知编译器内存或者寄存器内的内容已经发生了修改,要重新生成加载指令(不可以从缓存寄存器中取).

这边的read/write请求不能越过lock指令进行重排,那么所有带有lock prefix指令(lock ,xchgl等)都会构成一个天然的x86 Mfence(读写屏障),这里用lock指令作为内存屏障,然后利用asm volatile("" ::: "cc,memory")作为编译器屏障.

AMD64这边判断是否是64位,64位机器中使用rsp栈指针寄存器,32位机器中使用32位机器中使用esp栈指针寄存器.

可以看到jvm开发组没有使用x86的内存屏障指令(mfence,lfence,sfence)

volatile写操作实现:

inline void oopDesc::obj_field_put_volatile(int offset, oop value) {
  OrderAccess::release();
  obj_field_put(offset, value);
  OrderAccess::fence();
}
inline volatile oop oopDesc::obj_field_volatile(int offset) const {
  volatile oop value = obj_field(offset);
  OrderAccess::acquire();
  return value;
}

引用官方说明

// According to the new Java Memory Model (JMM):

// (1) All volatiles are serialized wrt to each other.  ALSO reads &

//     writes act as aquire & release, so:

// (2) A read cannot let unrelated NON-volatile memory refs that

//     happen after the read float up to before the read.  It's OK for

//     non-volatile memory refs that happen before the volatile read to

//     float down below it.

// (3) Similar a volatile write cannot let unrelated NON-volatile

//     memory refs that happen BEFORE the write float down to after the

//     write.  It's OK for non-volatile memory refs that happen after the

//     volatile write to float up before it.

//

// We only put in barriers around volatile refs (they are expensive),

// not between memory refs (that would require us to track the

// flavor of the previous memory refs).  Requirements (2) and (3)

// require some barriers before volatile stores and after volatile

// loads.  These nearly cover requirement (1) but miss the

// volatile-store-volatile-load case.  This final case is placed after

// volatile-stores although it could just as well go before

// volatile-loads.