一、简介

volatile是java虚拟机提供的轻量级同步机制

作用是: 1.保证可见性 2.禁止指令重排 3.不保证原子性

二、并发编程的3个基本概念

1.原子性

定义: 即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。

原子性是拒绝多线程操作的,不论是多核还是单核,具有原子性的量,同一时刻只能有一个线程来对它进行操作。简而言之,在整个操作过程中不会被线程调度器中断的操作,都可认为是原子性。例如
a=1是原子性操作,但是a++和a +=1就不是原子性操作。Java中的原子性操作包括:
(1)基本类型的读取和赋值操作,且赋值必须是值赋给变量,变量之间的相互赋值不是原子性操作。
(2)所有引用reference的赋值操作
(3)java.concurrent.Atomic.* 包中所有类的一切操作

2.可见性

定义:指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。

在多线程环境下,一个线程对共享变量的操作对其他线程是不可见的。Java提供了volatile来保证可见性,当一个变量被volatile修饰后,表示着线程本地内存无效,当一个线程修改共享变量后他会立即被更新到主内存中,其他线程读取共享变量时,会直接从主内存中读取。当然,synchronize和Lock都可以保证可见性。synchronized和Lock能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中。因此可以保证可见性。

3.有序性

定义:即程序执行的顺序按照代码的先后顺序执行。

Java内存模型中的有序性可以总结为:如果在本线程内观察,所有操作都是有序的;如果在一个线程中观察另一个线程,所有操作都是无序的。前半句是指“线程内表现为串行语义”,后半句是指“指令重排序”现象和“工作内存主主内存同步延迟”现象。

在Java内存模型中,为了效率是允许编译器和处理器对指令进行重排序,当然重排序不会影响单线程的运行结果,但是对多线程会有影响。Java提供volatile来保证一定的有序性。最著名的例子就是单例模式里面的DCL(双重检查锁)。另外,可以通过synchronized和Lock来保证有序性,synchronized和Lock保证每个时刻是有一个线程执行同步代码,相当于是让线程顺序执行同步代码,自然就保证了有序性。

三、Java的内存模型JMM以及共享变量的可见性

JMM(JavaMemoryModel):Java内存模型,是java虚拟机规范中所定义的一种内存模型,Java内存模型是标准化的,屏蔽掉了底层不同计算机的区别(注意这个跟JVM完全不是一个东西)。

JMM有以下规定:

所有的共享变量都存储于主内存,这里所说的变量指的是实例变量和类变量,不包含局部变量,因为局部变量是线程私有的,因此不存在竞争问题。

每一个线程还存在自己的工作内存,线程的工作内存,保留了被线程使用的变量的工作副本。

线程对变量的所有的操作(读,取)都必须在工作内存中完成,而不能直接读写主内存中的变量。

java 原子加 java volatile原子性_java

对于普通的共享变量来讲,线程A将其修改为某个值发生在线程A的本地内存中,此时还未同步到主内存中去;而线程B已经缓存了该变量的旧值,所以就导致了共享变量值的不一致。解决这种共享变量在多线程模型中的不可见性问题,较粗暴的方式自然就是加锁,但是此处使用synchronized或者Lock这些方式太重量级了,比较合理的方式其实就是volatile。

四、volatile变量的特性

1.可见性

下面来看一个例子

static boolean run = true;
static Object lock = new Object();
public static void main(String[] args) throws InterruptedException {
	 Thread t = new Thread(()->{
	 	 while(run){
		 // ....
		 }
	 });
	 t.start();
	 sleep(1);
	 run = false; // 线程t不会如预想的停下来
}

现象解释:程序一直在运行,main 线程对 run 变量的修改对于 t 线程不可见,导致了 t 线程无法停止。

现象分析:

  1. 初始状态, t 线程刚开始从主内存读取了 run 的值到工作内存。
  2. 因为 t 线程要频繁从主内存中读取 run 的值,JIT 编译器会将 run 的值缓存至自己工作内存中的高速缓存中,减少对主存中 run 的访问,提高效率。
  3. 1 秒之后,main 线程修改了 run 的值,并同步至主存,而 t 是从自己工作内存中的高速缓存中读取这个变量的值,结果永远是旧值。

    现在我们知道,造成这个现象的原因是:其他线程修改了共享变量的值但对另外的线程是不可见,工作内存中始终存储的是旧值。

解决方案:

1.加锁:synchronized

static boolean run = true;
static Object lock = new Object();
public static void main(String[] args) throws InterruptedException {
	 Thread t = new Thread(()->{
	 	synchronized(lock) {
		 	while(run){
			// ....
			}
	 	}
	 });
	 t.start();
	 sleep(1);
	 run = false; // 线程t不会如预想的停下来
}

为什么加锁可以解决可见性问题呢?

因为某一个线程进入synchronized代码块前后,线程会获得锁,清空工作内存,从主内存拷贝共享变量最新的值到工作内存成为副本,执行代码,将修改后的副本的值刷新回主内存中,线程释放锁。

2.Volatile修饰共享变量

我们知道,加锁是一项很昂贵的操作,即使有偏向锁和轻量级锁这样的优化,但对性能还是有一定影响,于是我们想到了用Volatile来保证可见性。

// 省略其他代码
static volatile boolean run = true;
// 省略其他代码

那么,为什么volatile修饰变量可以解决可见性问题呢?

volatile 的底层实现原理是内存屏障,Memory Barrier(Memory Fence)。

  • 对 volatile 变量的写指令后会加入写屏障
  • 对 volatile 变量的读指令前会加入读屏障

写屏障(sfence)保证在该屏障之前的,对共享变量的改动,都同步到主存当中。

public void actor2(I_Result r) {
	 num = 2;
	 ready = true; // ready 是 volatile 赋值带写屏障
	 // 写屏障
}

而读屏障(lfence)保证在该屏障之后,对共享变量的读取,加载的是主存中最新数据。

public void actor1(I_Result r) {
	 // 读屏障
	 // ready 是 volatile 读取值带读屏障
	 if(ready) {
	 	r.r1 = num + num;
	 } else {
	 	r.r1 = 1;
	 }
}

假设t1线程先执行actor2方法,t2线程后执行actor1方法

java 原子加 java volatile原子性_共享变量_02


t1对volatile修饰的变量ready修改,因为写屏障,它被同步到主存中去,t2线程因为读屏障,对ready的读取是从主存中得来,所以得到的是更新后的ready值。

每个线程操作数据的时候会把数据从主内存读取到自己的工作内存,如果某个线程操作了volatile变量并且写回了,因为写屏障,JMM会把该线程本地内存中的变量强制刷新到主存中去,其它线程的工作内存中已经读取到的共享变量副本就会失效了,因为读屏障,需要读数据进行操作又要再次去主存中读取了,volatile避免了线程从自己的工作内存中查找该值,必须到主存中获取它的值。

结论:volatile保证不同线程对共享变量操作的可见性,也就是说一个线程修改了volatile修饰的变量,当修改写回主内存时,另外一个线程立即看到最新的值。

2.有序性

1.什么是重排序?

为了提高性能,编译器和处理器常常会对既定的代码执行顺序进行指令重排序。

重排序需要遵守一定规则:

  • 重排序操作不会对存在数据依赖关系的操作进行重排序。
  • 比如:a=1;b=a; 这个指令序列,由于第二个操作依赖于第一个操作,所以在编译时和处理器运行时这两个操作不会被重排序。
  • 重排序是为了优化性能,但是不管怎么重排序,单线程下程序的执行结果不能被改变。
  • 比如:a=1;b=2;c=a+b这三个操作,第一步(a=1)和第二步(b=2)由于不存在数据依赖关系, 所以可能会发生重排序,但是c=a+b这个操作是不会被重排序的,因为需要保证最终的结果一定是c=a+b=3。

这里补充一个概念:as-if-serial

不管怎么重排序,单线程下的执行结果不能被改变。编译器、runtime和处理器都必须遵守as-if-serial语义。

重排序在单线程下一定能保证结果的正确性,但是在多线程环境下,可能发生重排序,影响结果。

下面来看一个典型的DCL单例模式:

public final class Singleton {
	private Singleton() { }
	private static Singleton INSTANCE = null;
	public static Singleton getInstance() { 
		if(INSTANCE == null) { // t2
			// 首次访问会同步,而之后的使用没有 synchronized
			synchronized(Singleton.class) {
				if (INSTANCE == null) { // t1
					INSTANCE = new Singleton();
				} 
 			}
 		}
		return INSTANCE;
	}
}

上面的代码在单线程是没问题的,但在多线程是有线程安全隐患的,getInstance 方法对应的字节码为:

0: getstatic #2 			// Field INSTANCE:Lcn/itcast/n5/Singleton;
3: ifnonnull 37
6: ldc #3 					// class cn/itcast/n5/Singleton
8: dup
9: astore_0
10: monitorenter
11: getstatic #2 			// Field INSTANCE:Lcn/itcast/n5/Singleton;
14: ifnonnull 27
17: new #3 					// class cn/itcast/n5/Singleton
20: dup
21: invokespecial #4 		// Method "<init>":()V
24: putstatic #2		    // Field INSTANCE:Lcn/itcast/n5/Singleton;
27: aload_0
28: monitorexit
29: goto 37
32: astore_1
33: aload_0
34: monitorexit
35: aload_1
36: athrow
37: getstatic #2 		   // Field INSTANCE:Lcn/itcast/n5/Singleton;
40: areturn

其中:

  • 17 表示创建对象,将对象引用入栈// new Singleton
  • 20 表示复制一份对象引用 // 引用地址
  • 21表示利用一个对象引用,调用构造方法
  • 24 表示利用一个对象引用,赋值给 static INSTANCE

也许 jvm 会优化为:先执行 24,再执行 21。如果两个线程 t1,t2 按如下时间序列执行:

java 原子加 java volatile原子性_后端_03


假设:

  1. t1执行new指令,创建了对象,将对象的引用入栈。
  2. t1复制了一份对象引用。
  3. 因为重排序,t1跳过了invokespecial,先执行了putstatic,赋值给 static INSTANCE。
  4. 这时t2来了,getstatic获取INSTANCE引用。
  5. t2执行ifnonnull,判断INSTANCE不为null。
  6. t2执行getstatich获取INSTANCE引用。
  7. t2指向areturn,返回INSTANCE引用。
  8. t2使用对象。
  9. t1得到CPU时间片,最终执行invokespecial(调用构造方法)

问题:

因为重排序,t1先给INSTANCE引用赋值了,这是还未来得及调用构造方法,t2就得到了CPU执行权,得到了INSTANCE引用并且判断不为空,这时使用INSTANCE引用的对象就出现了问题,因为缺少了构造方法里的逻辑。

解决方案:

public final class Singleton {
	private Singleton() { }
	// 加上volatile修饰
	private static volatile Singleton INSTANCE = null;
	public static Singleton getInstance() { 
		if(INSTANCE == null) { // t2
			// 首次访问会同步,而之后的使用没有 synchronized
			synchronized(Singleton.class) {
				if (INSTANCE == null) { // t1
					INSTANCE = new Singleton();
				} 
 			}
 		}
		return INSTANCE;
	}
}
// -------------------------------------> 加入对 INSTANCE 变量的读屏障
0: getstatic #2 			// Field INSTANCE:Lcn/itcast/n5/Singleton;
3: ifnonnull 37
6: ldc #3 					// class cn/itcast/n5/Singleton
8: dup
9: astore_0
10: monitorenter 	//-----------------------> 保证原子性、可见性
11: getstatic #2 			// Field INSTANCE:Lcn/itcast/n5/Singleton;
14: ifnonnull 27
17: new #3 					// class cn/itcast/n5/Singleton
20: dup
21: invokespecial #4 		// Method "<init>":()V
24: putstatic #2		    // Field INSTANCE:Lcn/itcast/n5/Singleton;
// -------------------------------------> 加入对 INSTANCE 变量的写屏障
27: aload_0
28: monitorexit 	//-----------------------> 保证原子性、可见性
29: goto 37
32: astore_1
33: aload_0
34: monitorexit
35: aload_1
36: athrow
37: getstatic #2 		   // Field INSTANCE:Lcn/itcast/n5/Singleton;
40: areturn

如上面的注释内容所示,读写 volatile 变量时会加入内存屏障(Memory Barrier(Memory Fence)),保证下面两点:

可见性:
1.写屏障(sfence)保证在该屏障之前的 t1 对共享变量的改动,都同步到主存当中。
2.读屏障(lfence)保证在该屏障之后 t2对共享变量的读取,加载的是主存中最新数据。

有序性:
1.写屏障会确保指令重排序时,不会将写屏障之前的代码排在写屏障之后。
读屏障会确保指令重排序时,不会将读屏障之后的代码排在读屏障之前。

所以,volatile就禁止了17,20,21,24这几行指令的重排序,保证了先调用构造方法再赋值的顺序。

注意:我们知道synchronized是可以保证原子性,可见性和有序性的,这里可能就有人疑惑了,INSTANCE = new Singleton(); 这行代码不是在synchronized同步代码块内,所以应该是指令应该是有序的,为什么还会出现指令重排序呢?首先,对于synchronized的有序性是建立在原子性的基础上,因为synchronized同步代码块的内容同一时间内只允许一个线程执行,而又对于单线程,指令的重排序是不会出现问题的,但INSTANCE是类变量,它不完全在synchronized内:if(INSTANCE == null) 就脱离了synchronized,所以发生了重排序后其他线程可以得重排序影响后的结果,就有可能出现了问题。

五、volatile与synchronized的区别

  • volatile只能修饰实例变量和类变量,而synchronized可以修饰方法,以及代码块。
  • volatile保证数据的可见性,但是不保证原子性(多线程进行写操作,不保证线程安全);而synchronized是一种排他(互斥)的机制。
  • volatile用于禁止指令重排序:可以解决单例双重检查对象初始化代码执行乱序问题。
  • volatile可以看做是轻量版的synchronized,但volatile不保证原子性,但是如果是对一个共享变量进行多个线程的赋值,而没有其他的操作,那么就可以用volatile来代替synchronized,因为赋值本身是有原子性的,而volatile又保证了可见性,所以就可以保证线程安全了。