Multithreading Basics (4): Volatile and Visibility Issues and the happens-before Principle

Get into the habit of writing together! This is the 5th day of my participation in the "Nuggets Daily New Plan · April Update Challenge", click to view the details of the event .

@[toc] Know the use of synchronized in the previous article, and the basic composition of the java memory model JMM. So now, let's look at a new problem.

1. Visibility issues

Let's take a look at the following code:

package com.dhb.concurrent.test;

import java.util.concurrent.TimeUnit;

public class VolatileTest {

	private static int INIT = 0;
	private static int MAX = 10;

	public static void main(String[] args) {

		new Thread(() -> {
			int local = INIT;
			while (local < MAX) {
				if (local != INIT) {
					System.out.println("Get change for INIT [" + INIT + "] local is [" + local + "]");
					local = INIT;
				}
			}
		}, "Reader Thread").start();

		new Thread(() -> {
			int local = INIT;
			while (local < MAX) {
				System.out.println("Update value [" + local + "] to [" + (local++) + "]");
				INIT = local;
				try {
					TimeUnit.MILLISECONDS.sleep(500);
				} catch (InterruptedException e) {
					e.printStackTrace();
				}
			}
		}, "Writer Thread").start();
	}

}
复制代码

There are two threads, one thread writes and the other thread reads, so the result of the write thread can be read by the read thread every time? We execute the above code:

Update value [0] to [0]
Get change for INIT [1] local is [0]
Update value [1] to [1]
Update value [2] to [2]
Update value [3] to [3]
Update value [4] to [4]
Update value [5] to [5]
Update value [6] to [6]
Update value [7] to [7]
Update value [8] to [8]
Update value [9] to [9]
复制代码

It can be seen that while the writing thread increments the INIT result, the reading thread only reads the first value. After that, no matter how the value of INIT is changed by the writing thread, in the reading thread, this value is still the previous result, that is, the value added by the writing thread, which is actually invisible to the reading thread. That's the point we need to cover today, the visibility issue.read invisible

As shown in the figure above, each thread is actually a copy of the variable that operates in its own working memory. If this copy is modified, it will be synchronized back to the main memory. So the Writer writes the result to the INIT in main memory each time. However, for the Reader thread, it will only be read once when comparing after reading. The value at this time is 1. After that, the value will not be modified, but the value will always be placed in the thread. in working memory. This is where the visibility problem arises. In fact, since the Reader thread only reads, there is only a use operation inside, so it will not assign, so there is no need to load this variable from the main memory every time. The original intention of this design is to increase the efficiency of JVM memory calculation, because the actual part of the working memory may be calculated in the cache of the CPU. If it is loaded from the main memory every time, the speed of the cache and the main memory is very different. , then it will cause unnecessary system overhead. So how should we solve this problem. This is the key keyword that this article needs to introduce, volatile. This keyword has two effects:

  • maintain memory visibility
  • Quiescent Instruction Reordering

我们先来说内存的可见性。volatile是如何保持内存的可见性呢?很简单,实际上如果某个变量被volatile修饰的话,那么在工作内存中,就不再走工作内存的缓存了,而是每次都去主内存去加载。这样一来,虽然带来了一些性能的损耗,但是这样可以更好的解决系统的一致性。 read visible

如上图,这样一来,Reader线程的工作内存中在对INIT的读取的时候,每次都会从主内存中去同步。这样Writer线程对主内存的修改,对Reader线程来说,就是可见的了。我们修改代码如下:

public class VolatileTest {

	private volatile static int INIT = 0;
	private static int MAX = 10;

	public static void main(String[] args) {

		new Thread(() -> {
			int local = INIT;
			while (local < MAX) {
				if (local != INIT) {
					System.out.println("Get change for INIT [" + INIT + "] local is [" + local + "]");
					local = INIT;
				}
			}
		}, "Reader Thread").start();

		new Thread(() -> {
			int local = INIT;
			while (local < MAX) {
				System.out.println("Update value [" + local + "] to [" + (local++) + "]");
				INIT = local;
				try {
					TimeUnit.MILLISECONDS.sleep(500);
				} catch (InterruptedException e) {
					e.printStackTrace();
				}
			}
		}, "Writer Thread").start();
	}

}
复制代码

执行结果:

Update value [0] to [0]
Get change for INIT [1] local is [0]
Update value [1] to [1]
Get change for INIT [2] local is [1]
Update value [2] to [2]
Get change for INIT [3] local is [2]
Update value [3] to [3]
Get change for INIT [4] local is [3]
Update value [4] to [4]
Get change for INIT [5] local is [4]
Update value [5] to [5]
Get change for INIT [6] local is [5]
Update value [6] to [6]
Get change for INIT [7] local is [6]
Update value [7] to [7]
Get change for INIT [8] local is [7]
Update value [8] to [8]
Get change for INIT [9] local is [8]
Update value [9] to [9]
Get change for INIT [10] local is [9]
复制代码

这样reader线程每次都能读到最新的INIT的值了。

2.指令重排序

我们来看下面这个例子:

package com.dhb.concurrent.test;

public class VolatileTest2 {

	static int a,b,x,y;

	public static void main(String[] args) {
		long time = 0;
		while (true) {
			time ++;
			a = 0;
			b = 0;
			x = 0;
			y = 0;
			Thread t1 = new Thread(() -> {
				a = 1;
				x = b;
			});
			Thread t2 = new Thread(() -> {
				b = 1;
				y = a;
			});
			t1.start();
			t2.start();
			try {
				t1.join();
				t2.join();
			} catch (InterruptedException e) {
				e.printStackTrace();
			}

			if( x==0 && y==0) {
				break;
			}
		}
		System.out.println("time:["+time+"] x:["+x+"] y:["+y+"]");
	}
}
复制代码

对于指令重排序,如果在一个线程中,两条语句之间彼此没有任何关系,那么在jvm内部对指令进行优化的时候,就可以出现,写在后面的语句被优化到前面执行的情况。在这个例子中, a=1、x=b 这是两条没有任何关系的语句,同样,在另外一个线程中b=1、y=a也如此。这些语句彼此间没有先后关系。而在传统的逻辑中,这两种组合,无论哪个线程先执行或者同时执行,都不会出现x=0,y=0的情况。

执行情况 结果
t1先于t2执行 x=0,y=1
t2先于t1执行 x=1,y=0
t1、t2同时执行 x=1,y=1
只有当出现指令重排序,x=a,y=b先于a=1,b=1执行,那么才可能出现x=0,y=0的情况。
我们执行上述代码:
```
time:[102946] x:[0] y:[0]
```
这个指令重排序不是一个必然事件,因此这个代码每次执行的结果都不一样:
```
time:[8943] x:[0] y:[0]
```
如果运气好可能很快就会出现这个结果。
这就是指令重排序问题。那么volatile的另外一个作用就是能够禁止指令重排序,这种情况就不会产生。这也是一个比较常见的面试问题,DCL的单例模式需要加volatile的的原因。

3.synchronized与可见性

在JMM中,关于synchronized有两条规定:

  • 线程解锁之前,必须把共享变量的最新值刷新到主内存中。
  • 线程加锁的时候,将清空工作内存中的共享变量的值,从而使共享变量需要从主内存中重新获取最新的值。(加锁与解锁是同一个锁)

由此可见,synchronized,实际上也能实现可见性。此外,synchronized使用的是同步锁,还具有原子性。 再回到前文的例子中,我们将这个程序改造为如下方式:

package com.dhb.concurrent.test;

import java.util.concurrent.TimeUnit;

public class SyncTest {

	private static Count count = new Count();
	private static int MAX = 10;

	public static void main(String[] args) {

		new Thread(() -> {
			int local = count.getCount();
			while (local < MAX) {
				synchronized (count) {
					if (local != count.getCount()) {
						System.out.println("Get change for INIT [" + count.getCount() + "] local is [" + local + "]");
						local = count.getCount();
					}
				}
			}
		}, "Reader Thread").start();

		new Thread(() -> {

			int local = count.getCount();
			while (local < MAX) {
				synchronized (count) {
					System.out.println("Update value [" + local + "] to [" + (++local) + "]");
					count.setCount(local);
				}
				try {
					TimeUnit.MILLISECONDS.sleep(500);
				} catch (InterruptedException e) {
					e.printStackTrace();
				}
			}

		}, "Writer Thread").start();


	}

	private static class Count {
		int count = 0;

		public int add() {
			return ++count;
		}

		public int getCount() {
			return count;
		}

		public void setCount(int count) {
			this.count = count;
		}
	}

}
复制代码

之后我们再来看执行结果:

Update value [0] to [1]
Get change for INIT [1] local is [0]
Update value [1] to [2]
Get change for INIT [2] local is [1]
Update value [2] to [3]
Get change for INIT [3] local is [2]
Update value [3] to [4]
Get change for INIT [4] local is [3]
Update value [4] to [5]
Get change for INIT [5] local is [4]
Update value [5] to [6]
Get change for INIT [6] local is [5]
Update value [6] to [7]
Get change for INIT [7] local is [6]
Update value [7] to [8]
Get change for INIT [8] local is [7]
Update value [8] to [9]
Get change for INIT [9] local is [8]
Update value [9] to [10]
Get change for INIT [10] local is [9]
复制代码

这样通过synchronized也能很好的解决可见性问题。由于synchronized在1.8中已经做了很多优化,其性能与ReentrantLock的性能无差别,因此,只要不涉及到指令的重排序,通过synchronized也能很好的完成可见性的效果。 lock visible

如上图所示,在加锁之后,每次读写的时候都需要从主内存中刷新同步。

4.Happens-Before规则

在《深入理解Java虚拟机》一书中,对Happens-Before原则进行了归纳,主要是以下8个:

  • 1.程序次序规则:在同一线程内部,按照代码顺序,关联代码书写在前面的操作优先发生于书写在后面的操作。
  • 2.管程锁定规则:一个unlock操作优先发生于此后对同一个锁的lock操作。
  • 3.volatile变量规则:对一个变量的写操作优先发生于后面对这个变量的读操作。(时间先后)
  • 4.线程启动规则:Thread的start方法优先发生于此线程的每一个动作。
  • 5.线程终结规则:线程中所有操作都优先发生于线程的终止检测,我们可以通过Thread.join()、Thread.isAlive()返回值等手段进行检测。
  • 6.线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到事件的发生。
  • 7.对象终结规则:一个对象的初始化完成先于发生他的finalize()方法的开始。
  • 8.传递性规则:如果操作A对操作B可见,而操作B又对操作C可见,则可以得出操作A对操作C也可见。

How to understand these Happens-Before rules? Happens-Before does not mean that the previous operation happens before the subsequent operation. Rather, it is meant to express that the result of a previous operation is visible to subsequent operations. In fact, Happens-Before constrains the behavior of the compiler. The compiler can optimize the execution order of multiple codes as needed. However, this optimization of the compiler must comply with the Happens-Before rule.

5. Understanding of Happens-Before

5.1 Program Order Rules

It should be noted that, for this rule, when a JVM is optimizing, if there is no dependency between the written code, it does not meet this rule. The following code:

int a = 3;     //代码1
int b = a + 1; //代码2
复制代码

Because of the dependencies, the result of code 1 is always visible to code 2.

int a = 3; //代码1
int b = 2; //代码2
复制代码

Then the above code will not have an order relationship, and the JVM instructions will be optimized as needed. The order after optimization is not necessarily the same. It may be code 1 first, or code 2 first.

5.2 Monitor Locking Rules

This rule is well understood, that is to say, when a lock needs to be locked, the unlock operation on it must be executed first.

5.3 volatile variable rules

If the variable is modified by volatile, then the write operation to this variable will be visible to all subsequent read operations. This is also demonstrated by the examples earlier in this article.

public class VolatileTest {

	private volatile static int INIT = 0;
	private static int MAX = 10;

	public static void main(String[] args) {

		new Thread(() -> {
			int local = INIT;
			while (local < MAX) {
				if (local != INIT) {
					System.out.println("Get change for INIT [" + INIT + "] local is [" + local + "]");
					local = INIT;
				}
			}
		}, "Reader Thread").start();

		new Thread(() -> {
			int local = INIT;
			while (local < MAX) {
				System.out.println("Update value [" + local + "] to [" + (local++) + "]");
				INIT = local;
				try {
					TimeUnit.MILLISECONDS.sleep(500);
				} catch (InterruptedException e) {
					e.printStackTrace();
				}
			}
		}, "Writer Thread").start();
	}

}
复制代码

5.4 Transitive Rules

If operation A occurs before operation B, and operation B occurs before operation C, it can be concluded that operation A occurs before operation C. This is somewhat similar to the transitive rule in mathematics. If A>B, B>C, then A>C.

package com.dhb.concurrent.test;

public class VolatileExample {

	static  int x = 0;
	static volatile boolean v = false;

	public static void main(String[] args) {
		Thread t1 = new Thread(() -> {
			x = 42;
			v = true;
		});

		Thread t2 = new Thread(() -> {
			if(v == true){
				System.out.println(x);
			}else {
				System.out.println(x);
			}
		});

		t1.start();
		t2.start();
		try {
			t1.join();
			t2.join();
		} catch (InterruptedException e) {
			e.printStackTrace();
		}
	}
}
复制代码

In the above example, if t1 must be executed first, then according to the transitive rule, the resulting output must be 42.

Guess you like

Origin juejin.im/post/7083066776560336904