Java内存模型

  • Java内存模型规定了所有的变量都存储在主内存中。每条线程中还有自己的工作内存,线程的工作内存中保存了被该线程所使用到的变量(这些变量是从主内存中拷贝而来)。线程对变量的所有操作(读取,赋值)都必须在工作内存中进行。不同线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成。
  • i = 10; // 该赋值程序执行时线程必须先在自己的工作线程中对变量i所在的缓存行进行赋值操作,然后再写入主存当中。而不是直接将数值10写入主存当中。
  • 基于此种内存模型,便产生了多线程编程中的数据“脏读”等问题(一个线程修改数据没有及时写回主内存,另一个线程读取没修改的主内存数据,最终为主内存赋值时产生错误的结果)。

原子性

  • 原子性是指一个操作或者多个操作,要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。
  • 原子性经典的例子银行账户转账问题:比如从账户A向账户B转1000元,那么必然包括2个操作:从账户A减去1000元,往账户B加上1000元。这2个操作必须要具备原子性才能保证不出现一些意外的问题。
  • 在Java中,对基本数据类型的变量的读取和赋值操作是原子性操作,即这些操作是不可被中断的,要么执行,要么不执行。
  • Java内存模型只保证了基本读取和赋值是原子性操作,如果要实现更大范围操作的原子性,可以通过synchronized和Lock来实现。由于synchronized和Lock能够保证任一时刻只有一个线程执行该代码块,那么自然就不存在原子性问题了,从而保证了原子性。
1
2
3
4
x = 10;		// 原子性操作(直接将数值10赋值给x,线程执行这个语句时会直接将数值10写入到工作内存中。)
y = x; // 非原子性操作(包含2个操作,它先要去读取x的值,再将x的值写入工作内存。)
x++; // 非原子性操作(包括3个操作:读取x的值,进行加1操作,写入新的值。)
x = x + 1; // 非原子性操作(包括3个操作:读取x的值,进行加1操作,写入新的值。)

可见性

  • 可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。
  • 当一个共享变量被volatile修饰时,它会保证修改的值会立即被更新到主内存,当有其他线程需要读取时,它会去内存中读取新值。而普通的共享变量不能保证可见性,因为普通共享变量被修改之后,什么时候被写入主存是不确定的,当其他线程去读取时,此时内存中可能还是原来的旧值,因此无法保证可见性。
  • 通过synchronized和Lock也能够保证可见性,synchronized和Lock能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中。因此可以保证可见性。
1
2
3
4
5
6
7
8
9
10
// 可见性问题实例
int i = 0;

//线程1执行的代码
i = 10;

//线程2执行的代码
j = i;

// 线程2执行时,如果线程1执行了但是没有把结果立即写回到主内存当中,那么线程2读取i加载到工作内存的依然是0,此时线程2没有立即看到线程1修改的值。

有序性

  • 有序性是指即程序执行的顺序按照代码的先后顺序执行。
1
2
3
4
5
6
7
// 有序性实例
int i = 0;
boolean flag = false;

i = 1; //语句1
flag = true; //语句2
// 从代码顺序上看,语句1是在语句2前面的,但是此时可能会发生指令重排序(Instruction Reorder),因为语句1和语句2谁先执行对最终的程序结果并没有影响,JVM在真正执行这段代码的时候不会保证语句1一定会在语句2的前面。
  • 指令重排序:一般来说,处理器为了提高程序运行效率,可能会对输入代码进行优化,它不保证程序中各个语句的执行先后顺序同代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的。
  • 处理器在进行重排序时是会考虑指令之间的数据依赖性,如果一个指令Instruction 2必须用到Instruction 1的结果,那么处理器会保证Instruction 1会在Instruction 2之前执行。
  • 要想并发程序正确地执行,必须要保证原子性、可见性以及有序性。只要有一个没有被保证,就有可能会导致程序运行不正确。
1
2
3
4
5
6
7
8
9
10
11
12
// 指令重排序实例
//线程1:
context = loadContext(); //语句1
inited = true; //语句2

//线程2:
while(!inited ){
sleep()
}
doSomethingwithconfig(context);

// 由于语句1和语句2没有数据依赖性,因此可能会被重排序。假如发生了重排序,在线程1执行过程中先执行语句2,而此是线程2会以为初始化工作已经完成,那么就会跳出while循环,去执行doSomethingwithconfig(context)方法,而此时context并没有被初始化,就会导致程序出错。
  • 在Java内存模型中,允许编译器和处理器对指令进行重排序,但是重排序过程不会影响到单线程程序的执行,却会影响到多线程并发执行的正确性。
  • 在Java里面,可以通过volatile关键字来保证一定的“有序性”。另外可以通过synchronized和Lock来保证有序性,很显然,synchronized和Lock保证每个时刻是有一个线程执行同步代码,相当于是让线程顺序执行同步代码,自然就保证了有序性。

理解volatile

  • 一旦一个共享变量(类的成员变量、类的静态成员变量)被volatile修饰之后,那么就保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。
  • volatile关键字能禁止指令重排序,所以volatile能在一定程度上保证有序性。
  • volatile不能确保原子性

volatile的实现原理

  • 《深入理解Java虚拟机》中写道:“观察加入volatile关键字和没有加入volatile关键字时所生成的汇编代码发现,加入volatile关键字时,会多出一个lock前缀指令”
  • 如果对声明了volatile的变量进行了写操作,JVM就会向处理器发送一条Lock前缀的指令,将这个变量所在的缓存行的数据写回到系统内存。在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议,每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改了,就会将当前处理器的缓存行设置为无效状态,重新从系统内存中把数据读到处理器缓存里。
  • Lock前缀指令实际上相当于一个内存屏障(也成内存栅栏),它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成。

volatile的应用场景

  • synchronized关键字是防止多个线程同时执行一段代码,那么就会很影响程序执行效率,而volatile关键字在某些情况下性能要优于synchronized,但是要注意volatile关键字是无法替代synchronized关键字的,因为volatile关键字无法保证操作的原子性。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// 状态标记量,根据状态标记,终止线程。
volatile boolean flag = false;
//线程1
while(!flag){
doSomething();
}
//线程2
public void setFlag() {
flag = true;
}

// 单例模式中的double check,主要在于instance = new Singleton()这句,这并非是一个原子操作。
class Singleton{
private volatile static Singleton instance = null;
private Singleton() {

}
public static Singleton getInstance() {
if(instance==null) {
synchronized (Singleton.class) {
if(instance==null)
instance = new Singleton();
}
}
return instance;
}
}

参考文章