Java 多线程-可见性问题 — 漠然

Java 多线程-可见性问题

2016/03/20 J2SE

一、相关定义

1、可见性

在多线程中,如果一个线程对某一 共享变量 的修改,能及时被其他线程所感知,这个特性或者说过程称之为线程可见性。

2、共享变量

当多线程同时操作一个变量时,该变量在多线程的 工作内存(私有内存) 中都存在一个副本,那么这个变量称之为这几个线程的共享变量。

3、工作内存

多线程工作时,每个线程都会复制主内存中的变量副本到自己的私有内存,这个私有内存称之为每个线程的工作内存。

二、Java 内存模型(Java Memory Model)

Java 内存模型(JMM) 描述了 Java 程序中对线程共享变量的访问规则,以及在 JVM 层面对内存读写变量的底层细节,详细可参考 InfoQ-深入理解Java内存模型

1、概述

简单地说,Java 内存模型规定,所有实例域、静态域和数组元素存储在堆内存中,堆内存在线程之间共享,局部变量(Local variables),方法定义参数(java语言规范称之为formal method parameters)和异常处理器参数(exception handler parameters)不会在线程之间共享;运行时将所有变量放入 主内存中,同时在多线程访问的情况下,每个线程会开辟自己的 工作内存(抽象)工作内存中用于存放该线程所用到的每个主内存中的变量副本,图例如下:

Java 内存模型1

2、线程读写变量规则

JMM 规定,多线程情况下,每个线程对变量的操作 必须在自己的工作内存 中完成,不允许线程直接对主内存进行操作

多线程变量传递需要借助主内存中转;也就是说,当A线程需要改变变量值时,需要先改变当前工作内存中的变量,再将其刷新到主内存,最后通过主内存刷新到线程B的工作内存。图例如下:

Java 内存模型2

Java 内存模型3

三、synchronized 实现可见性

Java 中对代码块或方法使用 symchroized 关键字,可保证其内部的共享变量实现多线程可见性。

1、JMM 对 synchroized 相关规定

  • 线程解锁前,必须将工作内存中数据刷新到主内存。
  • 线程加锁时,必须先清空工作内存,然后将主内存数据刷新到工作内存

2、线程执行互斥代码过程

  • 1、线程在 synchroized 入口处获得互斥锁
  • 2、线程清空自己的工作内存
  • 3、线程将主内存数据刷新到工作内存
  • 4、线程执行互斥代码
  • 5、线程将工作内存数据刷新到主内存
  • 6、线程退出 synchroized 代码块并释放互斥锁

3、指令重排序

具体可参考 InfoQ 深入理解Java内存模型(二)-重排序

定义:指令重排序,简单地说就是编译器和CPU为了提高并行执行速度,进行的代码重排序执行。

在CPU执行层面,每执行一个指令也会类似 Java 语言的 I/O 操作,在某一个命令未执行完成时必须进入等待(阻塞状态),然后执行下一个命令;而编译器和CPU为了最大限度利用有限时间执行更多的任务,可能会进行指令重排序;指令重排序结果如下:

// 原有代码
int a = 10;
int b = 20;

在编译器和CPU优化后,重排序可能会出现如下结果:

// 重排序后
int b = 20;
int a = 10;

但是指令重排序会遵循一点:as-if-serail 语义,即在单线程的情况下,保证最终执行结果不变,这时才会进行指令重排序;如上所示,a与b哪个先定义都不会产生结果的变更时才会重排序。

4、代码示例

package mkw.demo.syn;

public class SynchronizedDemo {
	//共享变量
    private boolean ready = false;
    private int result = 0;
    private int number = 1;   
    //写操作
    public void write(){
    	ready = true;	      		 //1.1				
    	number = 2;                      //1.2			    
    }
    //读操作
    public void read(){			   	 
    	if(ready){		         //2.1
    		result = number*3;	 //2.2
    	}   	
    	System.out.println("result的值为:" + result);
    }

    //内部线程类
    private class ReadWriteThread extends Thread {
    	//根据构造方法中传入的flag参数,确定线程执行读操作还是写操作
    	private boolean flag;
    	public ReadWriteThread(boolean flag){
    		this.flag = flag;
    	}
        @Override                                                                    
        public void run() {
        	if(flag){
        		//构造方法中传入true,执行写操作
        		write();
        	}else{
        		//构造方法中传入false,执行读操作
        		read();
        	}
        }
    }

    public static void main(String[] args)  {
    	SynchronizedDemo synDemo = new SynchronizedDemo();
    	//启动线程执行写操作
    	synDemo .new ReadWriteThread(true).start();
    	//启动线程执行读操作
    	synDemo.new ReadWriteThread(false).start();
    }
}

运行 mian 方法,由于可见性问题,可能出现多种情况,比如 写线程执行到 1.1,读线程获取CPU资源立即执行、写线程指令 1.1、1.2重排序等等情况。

四、volatile 实现可见性控制

1、实现原理

volatile 通过加入内存屏障和禁止指令重排序实现可见性控制,其过程大致分为以下两个过程:

  • 对 volatile 变量进行写操作时,会在写操作后加入一条 store 屏蔽指令;该命令会将工作内存中内容强制刷新到主内存(覆盖);同时会防止编译器/CPU指令重排序时将前面的变量重排到 volatile 修饰的变量之后(禁止颠倒顺序)。
  • 对 volatile 变量进行读操作时,会在读操作前加入一条 load 屏蔽指令;该指令会强制使工作内存失效,从而达到必须从主内存刷新到工作内存的效果

总结:使用 valotile 修饰变量后,线程在每次读取该变量时,会强制从主内存刷新最新状态到工作内存;在每次写入该变量时,会强制从工作内存刷新到主内存,以保证线程可见性。

2、volatile 不能保证原子性

由上可知 volatile 在保证可见性的原理大致和 synchroized 类似,主要是控制工作内存与主内存的刷新关系;但是相对于 synchroized ,volatile 不能保证原子性操作,测试代码如下:

package mkw.demo.vol;

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class VolatileDemo {

        // 测试变量
	private volatile int number = 0;

	public int getNumber(){
		return this.number;
	}

	public void increase(){
		try {
			Thread.sleep(100);
		} catch (InterruptedException e) {
			e.printStackTrace();
		}

		this.number++;
	}

	public static void main(String[] args) {
		// TODO Auto-generated method stub
		final VolatileDemo volDemo = new VolatileDemo();
		for(int i = 0 ; i < 500 ; i++){
			new Thread(new Runnable() {

				@Override
				public void run() {
					volDemo.increase();
				}
			}).start();
		}

		//如果还有子线程在运行,主线程就让出CPU资源,
		//直到所有的子线程都运行完了,主线程再继续往下执行
		while(Thread.activeCount() > 1){
			Thread.yield();
		}

		System.out.println("number : " + volDemo.getNumber());
	}

}

当以上代码运行多次后,number 打印的值会出现很多小于500的情况;其原因是 volatile 无法保证 number++ 这行代码的原子性操作;实质上 number++ 执行了3个动作,首先读取 number 值,然后进行+1,最后写会 number;

首先假设 number=2,在多线程并发的情况下,A线程由于 volatile 原因,首先将 number 从主内存强制刷新到工作内存,然后进行 +1 操作,此时A线程工作内存中 number=3,当要回写到工作内存时。线程 B 获取了CPU资源开始执行;不难想象,B线程对 number+1 后,可能被A线程覆盖掉;此时 volatile 无法保证原子性的问题就暴露了出来。

3、volatile 适用场景

  • 对变量的写不依赖于当前值;即直接强制写,同时写的值跟上一次没关系。
  • 该变量不包含在其他变量的不变式中;即与其他 volatile 变量没关系。

4、volatile 与 synchroized 比较

在可见性上,volatile 与 synchroized 基本是相同的;但是在原子性上,volatile不支持;同时 synchroized 会执行加锁动作,开销较大,而 volatile 更轻量级。 转载请注明出处,本文采用 CC4.0 协议授权

Search

    Post Directory