Java多线程之内存可见性

时间:2022-02-16 15:09:02

1、什么是JAVA 内存模型

Java Memory Model (JAVA 内存模型)描述线程之间如何通过内存(memory)来进行交互。 具体说来, JVM中存在一个主存区(Main Memory或Java Heap Memory),对于所有线程进行共享,而每个线程又有自己的工作内存(Working Memory),工作内存中保存的是主存中某些变量的拷贝,线程对所有变量的操作并非发生在主存区,而是发生在工作内存中,而线程之间是不能直接相互访问,变量在程序中的传递,是依赖主存来完成的。
Java内存模型的抽象示意图如下:

Java多线程之内存可见性

从上图来看,线程A与线程B之间如要通信的话,必须要经历下面2个步骤:

1、线程A把本地内存A中更新过的共享变量刷新到主内存中去。
2、线程B到主内存中去读取线程A之前已更新过的共享变量。
下面通过示意图来说明这两个步骤:

Java多线程之内存可见性

如上图所示,本地内存A和B有主内存*享变量x的副本。假设初始时,这三个内存中的x值都为0。线程A在执行时,把更新后的x值(假设值为1)临时存放在自己的本地内存A中。当线程A和线程B需要通信时,线程A首先会把自己本地内存中修改后的x值刷新到主内存中,此时主内存中的x值变为了1。随后,线程B到主内存中去读取线程A更新后的x值,此时线程B的本地内存的x值也变为了1。

从整体来看,这两个步骤实质上是线程A在向线程B发送消息,而且这个通信过程必须要经过主内存。JMM通过控制主内存与每个线程的本地内存之间的交互,来为java程序员提供内存可见性保证。

2、什么是内存可见性?

从上图可知,如果线程A对共享变量X进行了修改,但是线程A没有及时把更新后的值刷入到主内存中,而此时线程B从主内存读取共享变量X的值,所以X的值是原始值,那么我们就说对于线程B来讲,共享变量X的更改对线程B是不可见的。如果共享的更新不可见,会导致什么问题呢? 请看下面的例子:

class MyThread implements Runnable {
int num = 1000000;
public void run() {
if (Thread.currentThread().getName().equals("t1")) {
increment();
} else {
decrement();
}
}

public void increment() {
for (int i = 0; i < 10000; i++) {
num++;
}
}

public void decrement() {
for (int i = 0; i < 10000; i++) {
num--;
}
}
}

public class Test {

public static void main(String[] args) {
MyThread thread = new MyThread();
Thread a = new Thread(thread, "t1");
Thread b = new Thread(thread, "t2");

a.start();
b.start();

try {
a.join();
b.join();
} catch (Exception e) {
e.printStackTrace();
}

System.out.println(thread.num);
}
}

从上面代码可以看出,这里有两个线程,其中一个对num执行1000次加1操作,另一个线程执行1000次减1操作,按理说最后num的值是不变的,但是当你运行后,发现num的值可能并不是初始值。那么为什么会有这种问题呢?这是内存不可见引起的。

Java多线程之内存可见性

从上图中我们可以看到,当线程1对num值加一以后,还未把最新值写入主内存,CPU就停止了线程1的执行,并且执行线程2,线程2首先从主内存中获取num的值,然后减一,最后把值更新到主内存中,这个时候,CPU终止了线程2的执行,转而继续执行线程1, 这个时候线程1把最新值刷入主内存,所以主内存结果变为了1000001.

通过上面的分析,我们得知:内存不可见是由于共享变量的值没有及时在主内存中更新,为什么没有及时更新呢?是因为加一(或者减一)的操作不具备原子性(例子中最后一步被打断)。那么如何保证操作具有原子性呢?这里我们引入synchronized关键字。

3、Synchronized关键字

synchronized用来给对象和方法或者代码块加锁,当它锁定一个方法或者一个代码块的时候,同一时刻最多只有一个线程执行这段代码。当两个并发线程访问同一个对象object中的这个加锁同步代码块时,一个时间内只能有一个线程得到执行。另一个线程必须等待当前线程执行完这个代码块以后才能执行该代码块。然而,当一个线程访问object的一个加锁代码块时,另一个线程仍然可以访问该object中的非加锁代码块。

JMM关于synchronized的两条规定:

1. 线程解锁前,必须把共享变量的最新值刷新到主内存中
2. 线程加锁时,讲清空工作内存*享变量的值,从而使用共享变量时需要从主内存中重新读取最新的值。

这样,线程解锁前对共享变量的修改在下次加锁时对其他线程可见。

所以,为了保证num在某个时刻的修改具有原子性,我们可以在下面两个方法前加synchronized. 如果你zhi'dui

public synchronized void increment() {
for (int i = 0; i < 10000; i++) {
num++;
}
}

public synchronized void decrement() {
for (int i = 0; i < 10000; i++) {
num--;
}
}
因为synchronized的本质是一把锁,所以我们还可以通过真正意义上的加锁和开锁来实现内存可见性。代码如下:

class MyThread implements Runnable {

int num = 1000000;
Lock lock = new ReentrantLock();

public void run() {
if (Thread.currentThread().getName().equals("t1")) {
increment();
} else {
decrement();
}
}

public void increment() {
for (int i = 0; i < 10000; i++) {
lock.lock();
num++;
lock.unlock();
}
}

public void decrement() {
for (int i = 0; i < 10000; i++) {
lock.lock();
num--;
lock.unlock();
}
}
}

你可能会问:我们可否在num前面加volatile 达到内存可见性呢? 答案是否定的,volatile实现共享变量内存可见性有一个条件,就是对共享变量的操作必须具有原子性。比如 num = 10; 这个操作具有原子性,但是 num++ 或者num--由3步组成,并不具有原子性,所以是不行的。

参考: http://www.infoq.com/cn/articles/java-memory-model-1  (本文第一部分主要来自这篇文章)
http://blog.csdn.net/xingjiarong/article/details/47603813 (本文第二部分主要来自这篇文章,代码做了修改)
http://baike.baidu.com/item/synchronized
http://www.imooc.com/learn/352