浅析volatile关键字

Tips:线程安全主要考虑三个方面:

1,可见性:多个线程并发的读写某个共享资源时,每个线程总能读取到该共享资源的最新数据。

2,原子性:某个线程对一个或多个共享资源所做的一连串写操作不会被中断,在此期间不会有其他线程同时对这些共享资源执行写操作

3,有序性:单个线程内的操作必须是有序的。

内存可见性问题

要理解volatile关键字,首先要了解多线程的内存模型,如下图所示:

      我们声明的对象的成员变量、静态变量等非局部变量都位于主内存(JVM的堆)中。而每个线程又有自己的工作内存(working memory 工作内存是CPU中寄存器与高速缓存的抽象描述,并不是真正的额一块内存)。一般情况 下,当线程获得CPU使用权后,开始执行前会将主内存中的变量Load到自己的工作内存中,处理完再Save回主内存。由于每一个线程都有自己的工作内存,其对变量的修改都在自己的工作内存中进行,因此其他线程无法感知这些修改,这就是内存可见性问题。

可见性问题Volatile解决     

       当定义了一个变量为volatile之后,他将具备两个特性,第一是保证了此变量对所有线程的可见性,这里的“可见性”是指当一个线程修改了这个变量的值,通过MESI缓存一致性协议和总线嗅探机制,新值对于其他线程来说是立即可见的。

可见性测试简单代码(可以将修饰isPrintFlag的volatile关键字去掉查看执行结果)

public class VolatileTest implements Runnable {
    private static final Logger log = LoggerFactory.getLogger(VolatileTest.class);
    //可以将volatile删除,再看执行情况
    private volatile boolean isPrintFlag = true;
    int i = 0;

    public void setVolatileTest(boolean isPrintFlag) {
        this.isPrintFlag = isPrintFlag;
    }

    public void print() {
        while (isPrintFlag) {
            i++;
        }
        log.info(Thread.currentThread().getName()+"退出执行,i为" + i);
    }

    @Override
    public void run() {
        print();
    }
}
 /**
     * Volatile关键字可见性测试
     */
    @Test
    public void ThreadVolatileTest() throws InterruptedException {
        PropertyConfigurator.configure("config/log4j.properties");
        VolatileTest volatileTest = new VolatileTest();
        Thread print = new Thread(volatileTest,"print");
        print .start();
        //确保线程获得CPU使用权,将变量从主内存取到工作内存
        Thread.sleep(100);
        volatileTest.setVolatileTest(false);
        System.out.println("设置flag为false,操作线程名为:" + 
         Thread.currentThread().getName());
        //等待测试线程结束
        print .join();
    }

    

执行结果如下:

设置flag为false,操作线程名为:main
 INFO [print] - print退出执行,i为76159298

禁止指令重排序优化

      普通的变量仅仅会保证在该方法的执行过程中所依赖赋值结果的地方都能取到正确的结果,而不能保证变量赋值操作的顺序和程序代码中的执行顺序一致。

为了实现 volatile 内存语义,JMM 会分别限制编译器重排序和处理器重排序

1. JMM 针对编译器制定的 volatile 重排序规则表

  • 当第二个操作是 volatile 写时,不管第一个操作是什么,都不能重排序。这个规则确保 volatile 写之前的操作不会被编译器重排序到 volatile 写之后。
  • 当第一个操作是 volatile 读时,不管第二个操作是什么,都不能重排序。这个规则确保 volatile 读之后的操作不会被编译器重排序到 volatile 读之前。
  • 当第一个操作是 volatile 写,第二个操作是 volatile 读时,不能重排序

2.编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。

StoreStore 屏障 LoadLoad屏障
volatile写 volatile读
 StoreLoad 屏障 LoadStore屏障

不保证原子性        

     程序运行在获取取修饰的变量时会取主内存取,但是比如i++这种操作本身并不是原子性的,

  public void addPlusPlus();
    Code:
       0: aload_0                 //将第一个 引用 型局部变量推送至栈顶
       1: dup                     //复制栈顶数值并将复制值压入栈顶
       2: getfield      #2        // Field a:I 获取指定类的实例字段,并将其压入栈顶
       5: iconst_1                //将 int 型 1 推送至栈顶
       6: iadd                    //将栈顶两 int 型数值相加并将结果压入栈顶
       7: putfield      #2        // Field a:I 为指定类的实例字段赋值
      10: return

在A、B两个线程都读取到i的之后,A先执行完+1操作并将值刷入主内存,这个操作会导致其他线程工作内存内的变量i被标为失效,并重新从主内存获取,但是当B执行完iadd操作后,在赋值的这一步i被指为最新值,然后刷入到主内存,导致会B线程本次执行的i+1操作失效,大致是  ①load i  ② temp=i+1 ③ i=temp,执行完②后,i已经为i+1的值,再次赋为最新值,导致本次+1做了无用功。

(不知道对不对~~!记录一下此刻的想法)所以可以看到volatile不保证原子性。

简单的多个线程对一个共享变量做i++操作,能很清楚的看到执行的值不一样,例子如下:

public class AddPlus {

	private int a;

	public void addPlusPlus() {
		a++;
	}

	public static void main(String[] args) {
		AddPlus addPlus = new AddPlus();
		for (int i = 0; i < 20; i++) {
			new Thread(() -> {
				for (int j = 0; j < 2000; j++) {
					addPlus.addPlusPlus();
				}
			}).start();
		}

		if (Thread.activeCount() > 2) {
			Thread.yield();
		}
		System.out.println(addPlus.a);
	}
}

     

原文地址:https://www.cnblogs.com/demo-alen/p/13547235.html