Into the analysis of the implementation principle of volatile

Through the previous chapter, we learned that synchronized is a heavyweight lock, although the JVM has done a lot of optimization on it, and the volatile introduced below is a lightweight synchronized. If a variable uses volatile, it is cheaper than using synchronized because it does not cause thread context switching and scheduling. The Java Language Specification defines volatile as follows:

The Java programming language allows threads to access shared variables. To ensure that shared variables can be updated accurately and consistently, threads should ensure that the variable is acquired individually through an exclusive lock.

The above is a bit of a twist. In layman's terms, it means that if a variable is modified with volatile, Java can ensure that all threads see the same value of the variable. If a thread updates the shared variable modified by volatile, then other threads This update can be seen immediately, which is called thread visibility.

Although volatile seems relatively simple, it is nothing more than adding volatile in front of a variable, but it is not easy to use well (LZ admits that I still do not use it well, and it is still ambiguous when it is used).

Memory Model Related Concepts

Understanding volatile is actually a bit difficult. It is related to Java's memory model. Therefore, before understanding volatile, we need to understand the concept of Java memory model. This is only a preliminary introduction. Later, LZ will introduce the Java memory model in detail.

operating system semantics

When a computer runs a program, each instruction is executed in the CPU, and data reading and writing is bound to be involved in the execution process. We know that the data that the program runs is stored in the main memory. At this time, there will be a problem. Reading and writing data in the main memory is not as fast as executing instructions in the CPU. If any interaction needs to deal with the main memory, it will be greatly reduced. Affect efficiency, so there is a CPU cache. CPU caches are unique to a certain CPU and are only relevant to threads running on that CPU.

With the CPU cache, although the efficiency problem is solved, it will bring a new problem: data consistency. When the program is running, a copy of the data required for the operation will be copied to the CPU cache. When the operation is performed, the CPU no longer has to deal with the main memory, but directly reads and writes data from the cache. will flush the data to main memory. Take a simple example:

i++i++

When the thread runs this code, it first reads i from main memory ( i = 1 ), then copies a copy to the CPU cache, then the CPU does + 1 (2), then copies the data (2) Write to tell cache, and finally flush to main memory. In fact, there is no problem in doing this in a single thread, but the problem is in multithreading. as follows:

If two threads A and B both perform this operation (i++), according to our normal logical thinking, the value of i in the main memory should be 3, but is this the case? analyse as below:

Two threads read the value of i (1) from the main memory into their respective caches, then thread A performs the +1 operation and writes the result to the cache, and finally writes to the main memory, at this time the main memory i ==2, thread B does the same operation, i still = 2 in main memory. So the final result is 2 not 3. This phenomenon is a cache coherency problem.

There are two solutions to cache coherency:

  1. By adding LOCK# to the bus
  2. via a cache coherence protocol

However, there is a problem with scheme 1. It is implemented in an exclusive way, that is, if the bus is locked with LOCK#, only one CPU can run, and other CPUs have to be blocked, which is inefficient.

The second scheme, the Cache Coherency Protocol (MESI Protocol), ensures that copies of shared variables used in each cache are consistent. The core idea is as follows: when a CPU is writing data, if it finds that the variable being manipulated is a shared variable, it will notify other CPUs that the variable's cache line is invalid, so when other CPUs read the variable, they will find that the variable's cache line is invalid. Invalidation reloads the data from main memory.

212219343783699

Java memory model

The above explains how to ensure data consistency from the operating system level. Let's take a look at the Java memory model and study a little bit about what guarantees the Java memory model provides us and what methods and mechanisms are provided in Java to allow us to perform multiple tasks. Thread programming can guarantee the correctness of program execution.

In concurrent programming, we generally encounter these three basic concepts: atomicity, visibility, and orderliness. Let's take a look at volatile

atomicity

Atomicity: That is, an operation or multiple operations are either all executed and the process of execution is not interrupted by any factor, or none of them are executed.

Atomicity is like transactions in a database, they are a team that lives and dies together. In fact, understanding atomicity is very simple. Let's look at the following simple example:

i =0;---1
j = i ;---2
i++;---3
i = j +1;---4

上面四个操作,有哪个几个是原子操作,那几个不是?如果不是很理解,可能会认为都是原子性操作,其实只有1才是原子操作,其余均不是。

1—在Java中,对基本数据类型的变量和赋值操作都是原子性操作; 
2—包含了两个操作:读取i,将i值赋值给j 
3—包含了三个操作:读取i值、i + 1 、将+1结果赋值给i; 
4—同三一样

在单线程环境下我们可以认为整个步骤都是原子性操作,但是在多线程环境下则不同,Java只保证了基本数据类型的变量和赋值操作才是原子性的(注:在32位的JDK环境下,对64位数据的读取不是原子性操作*,如long、double)。要想在多线程环境下保证原子性,则可以通过锁、synchronized来确保。

volatile是无法保证复合操作的原子性

可见性

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

在上面已经分析了,在多线程环境下,一个线程对共享变量的操作对其他线程是不可见的。

Java提供了volatile来保证可见性。

当一个变量被volatile修饰后,表示着线程本地内存无效,当一个线程修改共享变量后他会立即被更新到主内存中,当其他线程读取共享变量时,它会直接从主内存中读取。 
当然,synchronize和锁都可以保证可见性。

有序性

有序性:即程序执行的顺序按照代码的先后顺序执行。

在Java内存模型中,为了效率是允许编译器和处理器对指令进行重排序,当然重排序它不会影响单线程的运行结果,但是对多线程会有影响。

Java提供volatile来保证一定的有序性。最著名的例子就是单例模式里面的DCL(双重检查锁)。这里LZ就不再阐述了。

剖析volatile原理

JMM比较庞大,不是上面一点点就能够阐述的。上面简单地介绍都是为了volatile做铺垫的。

volatile可以保证线程可见性且提供了一定的有序性,但是无法保证原子性。在JVM底层volatile是采用“内存屏障”来实现的。

上面那段话,有两层语义

  1. 保证可见性、不保证原子性
  2. 禁止指令重排序

第一层语义就不做介绍了,下面重点介绍指令重排序。

在执行程序时为了提高性能,编译器和处理器通常会对指令做重排序:

  1. 编译器重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序;
  2. 处理器重排序。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序;

指令重排序对单线程没有什么影响,他不会影响程序的运行结果,但是会影响多线程的正确性。既然指令重排序会影响到多线程执行的正确性,那么我们就需要禁止重排序。那么JVM是如何禁止重排序的呢?这个问题稍后回答,我们先看另一个原则happens-before,happen-before原则保证了程序的“有序性”,它规定如果两个操作的执行顺序无法从happens-before原则中推到出来,那么他们就不能保证有序性,可以随意进行重排序。其定义如下:

  1. 同一个线程中的,前面的操作 happen-before 后续的操作。(即单线程内按代码顺序执行。但是,在不影响在单线程环境执行结果的前提下,编译器和处理器可以进行重排序,这是合法的。换句话说,这一是规则无法保证编译重排和指令重排)。
  2. 监视器上的解锁操作 happen-before 其后续的加锁操作。(Synchronized 规则)
  3. 对volatile变量的写操作 happen-before 后续的读操作。(volatile 规则)
  4. 线程的start() 方法 happen-before 该线程所有的后续操作。(线程启动规则)
  5. 线程所有的操作 happen-before 其他线程在该线程上调用 join 返回成功后的操作。
  6. 如果 a happen-before b,b happen-before c,则a happen-before c(传递性)。

我们着重看第三点volatile规则:对volatile变量的写操作 happen-before 后续的读操作。为了实现volatile内存语义,JMM会重排序,其规则如下:

对happen-before原则有了稍微的了解,我们再来回答这个问题JVM是如何禁止重排序的?

20170104-volatile

观察加入volatile关键字和没有加入volatile关键字时所生成的汇编代码发现,加入volatile关键字时,会多出一个lock前缀指令。lock前缀指令其实就相当于一个内存屏障。内存屏障是一组处理指令,用来实现对内存操作的顺序限制。volatile的底层就是通过内存屏障来实现的。下图是完成上述规则所需要的内存屏障:

volatile暂且下分析到这里,JMM体系较为庞大,不是三言两语能够说清楚的,后面会结合JMM再一次对volatile深入分析。

20170104-volatile2

总结

volatile看起来简单,但是要想理解它还是比较难的,这里只是对其进行基本的了解。volatile相对于synchronized稍微轻量些,在某些场合它可以替代synchronized,但是又不能完全取代synchronized,只有在某些场合才能够使用volatile。使用它必须满足如下两个条件:

  1. 对变量的写操作不依赖当前值;
  2. 该变量没有包含在具有其他变量的不变式中。

volatile经常用于两个两个场景:状态标记两、double check

参考资料

  1. 周志明:《深入理解Java虚拟机》
  2. 方腾飞:《Java并发编程的艺术》
  3. Java并发编程:volatile关键字解析
  4. Java 并发编程:volatile的使用及其原理

http://cmsblogs.com/?p=2092

Guess you like

Origin http://10.200.1.11:23101/article/api/json?id=326593466&siteId=291194637