Java内存模型(一)

2014/07/17

本文原创作者:Cloud Chou. 欢迎转载,请注明出处和本文链接

主存储器和工作存储器

Java虚拟机在执行Java程序的过程中会把它管理的内存划分为若干个不同的数据区域,这些区域包括方法区,堆,虚拟机栈,本地方法栈,程序计数器。方法区存储类信息,常量,字节码等数据,堆内存存储所有生成的对象,方法区和堆内存为所有线程共享,而虚拟机栈是每个线程独有的,也就是说每个线程有自己的虚拟机栈,线程执行时,每调用一个方法,就会在虚拟机栈上创建一个栈帧,栈帧信息包括方法的局部变量表,操作数栈。

Java内存模型将内存分为主存储器和工作内存,主存储器也叫主内存,对应着Java内存区域划分的堆内存,工作内存对应着虚拟机栈和本地方法栈,所有线程共享主存储器,但是每个线程有各自的工作内存。所有对象在主内存分配,线程不能直接使用主内存的内容,必须先将主内存的内容加载到工作内存后,才能使用,修改工作内存里的内容后,也只有同步到主内存后,别的线程才可以看到。Java 内存模型如下图所示:

JavaMemoryModel

字段的引用

线程无法直接操作主存储器(堆内存),因此它无法直接引用字段的值。当线程需要引用字段时,需要经过3个操作:

  • 1)read操作 将字段的值拷贝到工作内存区(虚拟机栈)
  • 2)load操作 将read操作从主内存得到的变量值放入工作内存的变量副本
  • 3)use操作 将变量副本的值传递给执行引擎

当线程再次引用同一字段时,线程可能直接引用刚才拷贝得到的工作拷贝,这时候执行的操作是use, 也可能再次从堆内存拷贝到工作内存区(虚拟机栈),然后拷贝到变量副本,最后才引用,这时候执行的操作是(read->load->use)。这两种方式的选择是由Java虚拟机执行子系统决定的。

字段的赋值

线程无法直接对主存储器进行操作,因此它也无法将值直接指定给主存储器的字段。当线程需要赋值给主存储器的字段时,需要经过以下三个步骤:

  • 1)assign 将值指定给位于工作内存区的工作拷贝
  • 2)store 将工作内存的值传递给主存储器
  • 3)write 将store操作从工作内存得到的变量的值放入主内存的变量里

当同一线程反复给同一字段赋值时,可能只会对工作拷贝赋值,即只执行assign操作,只有指定的最后结果才会拷贝到主存储器,即执行store->write,也可能每次赋值,都会先赋值给工作拷贝,然后拷贝到主存储器,即执行assign->store->write。这两种方式的选择由Java执行处理系统决定。

内存间交互操作

Java内存模型定义了8种操作来完成关于主内存和工作内存之间具体的交互,这些操作都是原子的,不可分割(long double类型除外)。这8种操作如下所示:

  • 1)lock(锁定) 作用于主内存的变量,它把一个变量标志为一条线程独占的状态
  • 2)unlock(解锁) 作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其它线程锁定
  • 3)read(读取) 作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用
  • 4)load(载入) 作用于工作内存的变量,它把read操作从主内存得到的变量值放入工作内存的变量副本中
  • 5)use(使用) 作用于工作内存的变量,它把变量副本的值传递给执行引擎,每当虚拟机遇到一个需要使用的变量的值的字节码指令时,将会执行这个操作。
  • 6)assign(赋值) 作用于工作内存的变量,它把一个从执行引擎接收到的值赋值给工作副本变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作
  • 7)store(存储) 作用于工作内存的变量,将工作副本变量的值传输给主内存,以便随后的write操作使用
  • 8)write(写入) 作用于主内存的变量, 它把store操作从工作内存得到的变量的值放入主内存的变量

如果要把一个变量从主内存复制到工作内存,那就要按顺序地执行read和load操作,如果要把变量从工作内存同步回主内存,那就要顺序地执行store和write操作。注意,Java内存模型只要求上述两个操作必须按顺序地执行,而没有保证必须是连续执行,也就是说read和load之间,store和write之间是可以插入其它指令的,如对内存中的变量a,b进行访问时,一种可能出现的顺序是read a, read b, load b, load a。除此之外,Java内存模型还规定了在执行上述8种基本操作时必须满足如下规则:

  • 1)不允许read和load, store和write操作之一单独出现,即不允许一个变量从主内存读取了但工作内存不接受,或者从工作内存发起会写了但主内存不接受的情况出现
  • 2)不允许一个线程丢弃它最近的assign操作,即变量在工作内存中改变了之后必须把该变化同步回主内存
  • 3)不允许一个线程无原因地(没有发生任何assign操作)把数据从线程的工作内存同步回主内存中
  • 4)一个新的变量只能在主内存中“诞生”,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量,换句话说就是对一个变量实施use和store操作之前,必须先执行了load和assign操作
  • 5)一个变量在同一个时刻只允许一条线程对其进行lock操作,但lock操作可以被同一条线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁
  • 6)如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行load操作初始化变量的值。synchronized与lock原语操作并不存在直接关系。详见synchronized的两项功能。
  • 7)如果一个变量事先没有被lock操作锁定,则不允许对它执行unlock操作;也不允许unlock一个被其它线程锁定住的变量
  • 8)对一个变量执行unlock之前,必须先把此变量同步回主内存中(执行store和write操作)synchronized与unlock原语操作并不存在直接关系。详见synchronized的两项功能。

synchronized的两项功能

synchronized有两项功能,线程同步和内存同步。

线程同步是指使用synchronized来制作临界区时,该区间仅让一个线程进行操作。在一个线程进入临界区后,其它线程必须在临界区的入口处等待,直到先前在临界区的线程执行完临界区代码后退出临界区,其它线程再竞争入口,谁胜利了,谁就能进入临界区执行操作。synchronized所规定的范围控制了线程的操作,这就是线程的同步。

内存的同步是指工作内存和主内存的同步问题。以下所陈述的内容可适用于synchronized方法与synchronized块两者。

  • 1)欲进入synchronized时

    进入synchronized时,如果工作内存有未映射到主内存的工作拷贝,则工作拷贝的值会被强制写入主内存,成为其它线程可以看得见的状态。然后工作内存上的所有工作拷贝都会被丢弃。之后,欲引入主内存上的值的线程,必定会从主内存将值拷贝到工作内存。

    总而言之,工作内存的内容会和主内存的内容予以同步。

  • 2)欲退出synchronized时

    如果工作内存有未映像到主内存的工作拷贝,则工作拷贝的值会被强制写入主内存。但是退出synchronized时,不会清空工作内存的工作拷贝,也就是说后续会直接使用工作内存上的工作拷贝。

注意synchronized关键字与内存交互操作的lock、unlock并无直接关系,进入synchronized块和退出synchronized块时怎么执行lock和unlock操作,要看虚拟机如何实现。

synchronized方法编译后对应的方法字节码里有属性ACC_SYNCHRONIZED,而synchronized代码块编译生成的字节码里有monitorenter和monitorexit指令,而关于这两条指令以及带有ACC_SYNCHRONIZED属性的方法如何执行要看虚拟机如何实现,可参考《Java虚拟机规范》以及《Java语言规范》

volatile的两项功能

volatile仅进行内存同步,并不进行线程同步。当线程引用volatile字段时,通常都会发生从主内存器到工工作的拷贝,当线程给volatile字段赋值时,会发生从工作内存到主内存的拷贝。

Java内存模型要求lock,unlock,read,load,assign,use,store和write这八个操作都具有原子性,但是对于64位的数据类型(long和double),在模型中定义了一条宽松的规定:允许虚拟机将没有被volatile修饰的64位数据的读写操作划分为两次32的操作来进行,即允许虚拟机可以不保证64位数据类型的load,store,read和write操作的原子性,这点就是long和double的非原子协定。如果有多个线程共享一个未声明为volatile的long或double类型的变量,并且同时对它们进行读取和修改操作,那么某些线程可能会读到一个既非原值,也不是其他线程修改值的代表了“半个变量”的数值。

实际开发中,目前各种平台下的商用虚拟机几乎都把64位数据的读写操作为原子操作来对待,因此我们在编写代码时一般不需要将用到的long和double变量专门声明为volatile。

Double Checked Locking Pattern的危险性

早期内置锁synchronized的性能比较低,所以在实现懒汉式单例模式时采取Double Checked Locking Pattern模式,它通过尽量少执行内置锁的锁定以提高性能,如下面的代码所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class MySystem {
private static MySystem instance = null;
private Date date = new Date();

private MySystem() {

}

public static MySystem getInstance() {
if (instance == null) { //(a) 第一次test
  synchronized (MySystem.class) { //(b) 进入synchronized块
    if (instance == null)  //(c) 第二次test
  instance = new MySystem(); //(d) set
  } //(e) //退出synchronized块
} 
return instance; //(f)
}

public Date getDate() {
return date;
}

}

在(a)if语句的条件检查下(第一次test),当instance等于null时,便会进入(b)的synchronized块,取得锁定的对象为MySystem.class,亦即MySystem Class对象。

(a)的条件检查位于临界区之外,在(c)重新执行条件检查时(第二次test),进行了同步,synchronized可以保证别的线程如果确实创建好了instance,本线程可以看到,当instance确实等于null时,便会在(d)处生成MySystem的实例。(d)的实例实在(b)~(c)的临界区中产生,因此不会产生两个以上的MySystem实例。

只有在(a)的条件测试下instance等于null时,才会进入(b)的synchronized块。因此,第二次及以后调用getInstance方法时,几乎都不会进入synchronized块。故此不用担心内置锁synchronized带来的性能问题。

表面上看Double Checked Locking模式完美地解决了synchronized引入的性能问题,只要创建好实例,就不会再进入synchronized块。但是Double Checked Locking Pattern引入了一个新的问题,就是当单例对象还没有完全构造好时,别的线程调用getInstance可能返回单例对象,此时单例对象的某些字段可能为空。以上述程序为例,某个线程调用MySystem.getInstance().getDate()方法可能返回null,这看起来有点不可思议,我们来分析一下,假设有如下的线程执行顺序(只是一种可能):

线程A 线程B
(A-1)在(a)处判断instance == null
(A-2)在(b)处进入synchronized块
(A-3)在(c)判断instance==null
(A-4)在(d)制作MySystem的实例,指定给instance字段
线程在此处更新,线程A将instance字段从工作内存拷贝到主内存, 线程B就可以看到instance不为null
(B-1)在(a)判断instance=null
(B-2)在(f)将instance的值设为getInstance的返回值
(B-3)调用getInstance返回值之getDate方法

在制作MySystem的实例时,New Date()的值会指定给实例字段date,但这只是线程A对工作内存上的date工作拷贝进行赋值。若线程A退出synchronized块,date字段的值肯定会写入到主内存,但在退出前,date字段的值不保证会写入到主内存。instance=MySystem()也是一样,退出synchronized前instance字段的值可能也没有写入到主内存,但是也可能写入到主内存了。这里假设的线程执行顺序里,退出synchronized前,instance字段的值已经被写入到主内存,但是date字段的值并未写入到主内存,这是符合Java虚拟机规范的。

此时,线程B在(B-1)判断instance!=null,如此线程便不会进入synchronized块,而会立刻将getInstance的值当作返回值予以返回(return)。然后,线程B会在(B-3)调用getInstance的返回值的getDate方法,GetDate的返回值为date字段的值,线程B会引用date字段的值,这时候线程B会引用自己工作内存的date字段的值,结果date字段不在工作内存里,需要从主内存拷贝到工作内存,然而主内存里date字段的值为空,故此线程B调用MySystem.getInstance().getDate()方法返回null。

安全实现懒汉式单例模式的示范代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class MySystem {
private Date date = new Date();

private MySystem() {

}

private static class MySystemHolder {
private static MySystem instance = new MySystem();
}

public static MySystem getInstance() {
return MySystemHolder.instance;
}

public Date getDate() {
return date;
}

}

实现原理:Java虚拟机在加载类的时候会在使用某个类时才会加载该类,调用MySystem.getInstance()方法会执行return MySystemHodler.instance,此时才会加载MySystemHolder类,另外Java虚拟机会保证加载类是线程安全的,故此上述代码是线程安全的。

¥打赏5毛

取消

感谢您的支持,我会继续努力的!

扫码支持
赏个5毛,支持我把

打开支付宝扫一扫,即可进行扫码打赏哦

本篇目录