Java内存模型

为什么需要内存模型?

我们知道在并发编程中需要处理两个关键问题:线程之间的通信,以及线程之间的同步。线程之间的通信机制有两种:共享信息和消息传递。共享信息是指某个线程改变了共享信息,之后另外一个线程读取这个共享信息,这就完成了一次通信。从中我们可以看到这种通信是隐式进行的。但是在消息传递并发模式中,线程之间没有公共状态,它们之间必须通过发送消息来进行显示式通信。Java的并发采取的是共享信息的模式,线程之间的通信总是隐式进行,通信过程对程序员不透明,如果编码时候不理解这种隐式通信机制,就会遇到各种内存可见性的问题。

现代处理器为了提升数据存取的效率在CPU内部都加了L1,L2,L3这样的三级缓存(Cache):

cache

我们上文中提到的共享信息其实就是放到了主内存中的。线程A执行操作的时候如果发现Cache中没有就会从主存中拷贝一份副本,存储到本地,线程B需要数据的时候也从主存中拷贝一份副本到本地缓存中。如果线程A的操作完成之后将操作结果在线程B执行之前就刷新到主存中,那么线程B就可以看见最新的数值:

share_memory_communication

但是既然Cache的存在是为了减少访问主存的次数,那么如果每次都这样先更新本地缓存,然后立即刷新到主存,那么使用Cache就不能带来性能的提升了。所以这里就是有一个问题,什么时候A线程对共享变量的操作对线程B是可见的?

什么是Java内存模型?

针对上文中提出的问题,我们引入内存模型(JMM)的概念。内存模型其实就是一套规则,这套规则决定了一个线程对共享变量的写入何时对另一个线程可见。它的抽象结构示意图如下:

JMM Function

指令重排优点和缺点

指令重排是编译器和处理器在为了提升程序的执行效率而对代码所做的优化,这种优化在单线程中没有问题,但是在多线程中,就可能违背了JMM对内存可见性的约束,关于这方面的内容,请参考我的另外一篇文章:计算机基础知识。所以编译器为了满足JMM的要求,就需要在合适的位置给其所编译的代码加上内存屏障来禁止特定类型的重构排序,这种内存屏障还可以确保某个操作是否立即刷新到主存,以保证对之后的操作是可见的。内存屏障有四种类型:LoadLoad,LoadStore,StoreStore,StoreLoad。它们分别插在:读读,读写,写写,写读之间用来确保之后和之前的操作不能进行指令重排,与此同时如果前面的操作是写操作,还要保证它对于后面的操作是可见的。

volatile同步原语

上面说了JMM的相关概念,下面就用volatile来举例说明,JMM是如何确保它的可见性和原子性的内存语义的。
volatile也被称为轻量级锁,如果某个变量被声明为volatile,那么对它的读操作和写操作就具有原子性,但是如果是复合操作,那么就不具有原子性,比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class VolatileFeaturesExample {
volatile long v1 = 0L;
public void set(long l){
v1 = l;
}

public long get(){
returen v1;
}

public void getAndIncrement(){
v1 ++;
}
}

在这里set和get方法的语义和加了锁是一样的,其中getAndIncrement其实可以分为以下三个部分:

1
2
3
4
5
public void getAndIncrement(){
long tempt = get();
temp += 1L;
set(tempt);
}

这其中get和set方法是加锁的,但是tempt += 1L;在执行的时候可以有多个线程同时操作(比如两个线程同时判断了get()的值,然后在线程A对其进行改变的过程中,线程B也对其进行修改),所以不具有原子性,也就是说volatile让单个读和写操作具有原子性,但是对于复合操作是没有原子性的。

volatile变量读写的内存语义

什么是内存语义?内存语义就是说某段代码,在进行内存实际操作的时候是怎样的。 对volatile变量执行写操作,会将线程的本地内存值刷新到主存中;对volatile变量执行读操作,操作系统会将变量所在线程的本地内存置为无效,然后该线程就会从主内存中读取该volatile变量。这样就保证了写线程的操作对读线程是可见的了。这个过程其实就是两个线程之间进行通信的过程。

也就是说避免指令重排是实现可见性的基础,如果不限制相应的指令重排就不会具有可见性,而避免指令重排就需要插入相关的内存屏障,内存屏障不仅保证了内存不被重拍,同时保证了内存的可见性。比如StoreStore屏障,它可以保证前一个写操作的数据可以刷新到主存,也就是说对下一个操作是可见的。

volatile内存语义的实现原理

从上文可以看出,要确保volatile变量可见性的实现就必须要避免其前后的相关操作不进行指令重排,下面是JMM对编译器规定的指令重排规则:

  1. 当第二个操作是volatile写的时候,不管第一个操作是不是volatile操作,都不能将两个操作重排序。
  2. 当第一个操作是volatile读的时候,不管第二个操作是不是volatile操作,都不能将两个操作重排序。
  3. 第一个操作是volatile写,第二个操作是volatile读的时候不能,不能将两个操作重排序。

为了实现这个规则,编译器必须在volatile的前后插入内存屏障来做保证:

  • 在每个volatile写的前面加上StoreStore屏障。
  • 在每个volatile写的后面加上StoreLoad屏障。
  • 在每个volatile读的后面插入一个LoadLoad屏障。
  • 在每个volatile读的后面插入一个LoadStore屏障。

因为上文中JMM规定第一个volatile读操作后面的任意操作都不能重排序,所以加入了两个屏障。又因为volatile写操作的要避免和之前的指令以及之后的voloaile读操作指令重拍,所以要在其后面加上StoreLoad屏障,其前面加上StoreStore屏障可以避免之前的写操作和volatile写做指令重排。如下图所示:

barriers

在上面volatile写后面加入StoreLoad屏障是为了防止和下面可能出现的volatile读/写重排序,为什么是可能出现呢?因为编译器有时没有办法确定其后面是否有必要插入StoreLoad屏障,因为如果volatile在方法的最后一行,然后就return了,这时就没有必要了,但是为了防止少插入的情况,编译器就在volatile写的后面都加上了LoadStore屏障,这是一种保守的做法。从上面我们可以看到volatile读和写的内存屏障插入策略是非常保守的,然而再实际情况下,编译器可以根据代码的实际情况,在不影响内存语义的情况下,减少某些不必要的屏障插入(这些优化也跟处理器类型有关)。