线程同步volatile与synchronized详解

在之前的文章java面试 synchronized关键字中,已经详细的介绍了synchronized关键字的用法和使用规则,synchronized主要有如下规则

1) 当两个或者多个并发线程同时访问一个object中的synchronized(this)同步代码块时,一个时间内只能有一个线程得到执行,其他线程必须要等到当前线程执行完这个代码块之后才能继续执行该代码块。
2) 然而,当一个线程访问object中的synchronized(this)同步代码块时,其他线程仍然可以调用object中的其它非synchronized(this)同步代码块。
3) 尤其关键的是,当一个线程访问object中的一个synchronized(this)同步代码块时,其他线程对于object中的其它synchronized(this)同步代码块的访问,都将被阻塞。
4) 第3条中的说明同样适用于其它同步代码块,也就是说,当一个线程访问object中的一个synchronized(this)同步代码块时,它就获得了整个object的对象锁,因此,其他所有线程对于object的所有同步代码块的访问都将被暂时阻塞,直至当前线程执行完这个代码块。
5) 上述的规则对于其他对象锁也同样适用。

因此,在这边文章中,我们将着重介绍volatile关键字,以及volatile与synchronized的区别

一、 使用volatile修饰的变量具有可见性

可见性就是说一旦某个线程修改了volatile关键字修饰的变量,则该变量将会立即保存修改后的值到物理内存中,其他线程读取该变量时,也可以立即获取修改后的值。
在java运行中,为了提高程序的运行效率,对于一些变量的操作通常是在寄存器或者cpu缓存上进行的,之后才会保存到物理内存中,而使用volatile关键字修饰的变量则是直接读取物理内存。
如下代码:

class MyThread extends Thread {             
    private volatile boolean isStop = false;          
    public void run() {      
        while (!isStop) {      
            System.out.println("do something");      
        }      
    }      
    public void setStop() {      
        isStop = true;      
    }            
} 

线程在启动后,一直运行中,如果我们想要跳出循环,只需要调用setStop()即可,因为在代码中,isStop是使用volatile关键字修饰的,我们可以通过调用方法修改该变量的值,修改后,程序会即刻读取到变更后的值,从而跳出while循环。

Java为了保证其平台性,使Java应用程序与操作系统内存模型隔离开,需要定义自己的内存模型。
在Java内存模型中,内存分为主内存和工作内存两个部分,其中主内存是所有线程所共享的,而工作内存则是每个线程分配一份,各线程的工作内存间彼此独立、互不可见,在线程启动的时候,虚拟机为每个内存分配一块工作内存,不仅包含了线程内部定义的局部变量,也包含了线程所需要使用的共享变量(非线程内构造的对象)的副本,即为了提高执行效率,读取副本比直接读取主内存更快(这里可以简单地将主内存理解为虚拟机中的堆,而工作内存理解为栈(或称为虚拟机栈),栈是连续的小空间、顺序入栈出栈,而堆是不连续的大空间,所以在栈中寻址的速度比堆要快很多)。
工作内存与主内存之间的数据交换通过主内存来进行,如下图:

线程同步volatile与synchronized详解_第1张图片

同时,Java内存模型还定义了一系列工作内存和主内存之间交互的操作及操作之间的顺序的规则(这规则比较多也比较复杂,参见《深入理解Java虚拟机-JVM高级特性与最佳实践》第12章12.3.2部分),这里只谈和volatile有关的部分。对于共享普通变量来说,约定了变量在工作内存中发生变化了之后,必须要回写到工作内存( 迟早要回写但并非马上回写), 但对于volatile变量则要求工作内存中发生变化之后,必须马上回写到工作内存,而线程读取volatile变量的时候,必须马上到工作内存中去取最新值而不是读取本地工作内存的副本,此规则保证了前面所说的“当线程A对变量X进行了修改后,在线程A后面执行的其他线程能看到变量X的变动”。

*工作内存可以说是主内存的一份缓存,为了避免缓存的不一致性,所以volatile需要废弃此缓存。但除了内存缓存之外,在CPU硬件级别也是有缓存的,即寄存器。假如线程A将变量X由0修改为1的时候,CPU是在其缓存内操作,没有及时回写到内存,那么JVM是无法确保X=1能及时被之后执行的线程B看到的,所以我觉得JVM在处理volatile变量的时候,也同样用了硬件级别的缓存一致性原则(CPU的缓存一致性原则参见《
*Java的多线程机制系列:(二)缓存一致性和CAS
*
》。
*

二、 volatile禁止指令重排

我们可以先看一个代码示例

/**
 * 一个简单的展示Happen-Before的例子.
 * 这里有两个共享变量:a和flag,初始值分别为0和false.在ThreadA中先给a=1,然后flag=true.
 * 如果按照有序的话,那么在ThreadB中如果if(flag)成功的话,则应该a=1,而a=a*1之后a仍然为1,下方的if(a==0)应该永远不会为真,永远不会打印.
 * 但实际情况是:在试验100次的情况下会出现0次或几次的打印结果,而试验1000次结果更明显,有十几次打印.
 */
public class SimpleHappenBefore {
    /** 这是一个验证结果的变量 */
    private static int a=0;
    /** 这是一个标志位 */
    private static boolean flag=false;
    
    public static void main(String[] args) throws InterruptedException {
        //由于多线程情况下未必会试出重排序的结论,所以多试一些次
        for(int i=0;i<1000;i++){
            ThreadA threadA=new ThreadA();
            ThreadB threadB=new ThreadB();
            threadA.start();
            threadB.start();
            
            //这里等待线程结束后,重置共享变量,以使验证结果的工作变得简单些.
            threadA.join();
            threadB.join();
            a=0;
            flag=false;
        }
    }
    
    static class ThreadA extends Thread{
        public void run(){
            a=1;
            flag=true;
        }
    }
    
    static class ThreadB extends Thread{
        public void run(){
            if(flag){
                a=a*1;
            }
            if(a==0){
                System.out.println("ha,a==0");
            }
        }
    }
}

多次运行,可能得到多次不同的结果,每次打印出来的内容数量是不一的。

什么是指令重排序?有两个层面:

在虚拟机层面,为了尽可能减少内存操作速度远慢于CPU运行速度所带来的CPU空置的影响,虚拟机会按照自己的一些规则(这规则后面再叙述)将程序编写顺序打乱——即写在后面的代码在时间顺序上可能会先执行,而写在前面的代码会后执行——以尽可能充分地利用CPU。
拿上面的例子来说:假如不是a=1的操作,而是a=new byte1024*1024,那么它会运行地很慢,此时CPU是等待其执行结束呢,还是先执行下面那句flag=true呢?显然,先执行flag=true可以提前使用CPU,加快整体效率,当然这样的前提是不会产生错误(什么样的错误后面再说)。
虽然这里有两种情况:

  • 后面的代码先于前面的代码开始执行
  • 前面的代码先开始执行,但当效率较慢的时候,后面的代码开始执行并先于前面的代码执行结束。

不管谁先开始,总之后面的代码在一些情况下存在先结束的可能。

在硬件层面,CPU会将接收到的一批指令按照其规则重排序,同样是基于CPU速度比缓存速度快的原因,和上一点的目的类似,只是硬件处理的话,每次只能在接收到的有限指令范围内重排序,而虚拟机可以在更大层面、更多指令范围内重排序。硬件的重排序机制参见《从JVM并发看CPU内存指令重排序(Memory Reordering)》

重排序很不好理解,上面只是简单地提了下其场景,要想较好地理解这个概念,需要构造一些例子和图表,在这里介绍两篇介绍比较详细、生动的文章《happens-before俗解》和《深入理解Java内存模型(二)——重排序》。其中的“as-if-serial”是应该掌握的,即:不管怎么重排序,单线程程序的执行结果不能被改变。编译器、运行时和处理器都必须遵守“as-if-serial”语义。拿个简单例子来说,

public void execute(){
    int a=0;
    int b=1;
    int c=a+b;
}

这里a=0,b=1两句可以随便排序,不影响程序逻辑结果,但c=a+b这句必须在前两句的后面执行。

从前面那个例子可以看到,重排序在多线程环境下出现的概率还是挺高的,在关键字上有volatile和synchronized可以禁用重排序,除此之外还有一些规则,也正是这些规则,使得我们在平时的编程工作中没有感受到重排序的坏处。

  1. 程序次序规则(Program Order Rule)
    在一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作。准确地说应该是控制流顺序而不是代码顺序,因为要考虑分支、循环等结构。
  • 监视器锁定规则(Monitor Lock Rule)
    一个unlock操作先行发生于后面对同一个对象锁的lock操作。这里强调的是同一个锁,而“后面”指的是时间上的先后顺序,如发生在其他线程中的lock操作。
  • volatile变量规则(Volatile Variable Rule)
    对一个volatile变量的写操作发生于后面对这个变量的读操作,这里的“后面”也指的是时间上的先后顺序。
  • 线程启动规则(Thread Start Rule)
    Thread独享的start()方法先行于此线程的每一个动作。
  • 线程终止规则(Thread Termination Rule)
    线程中的每个操作都先行发生于对此线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值检测到线程已经终止执行。
  • 线程中断规则(Thread Interruption Rule)
    对线程interrupte()方法的调用优先于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测线程是否已中断。
  • 对象终结原则(Finalizer Rule)
    一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的开始。
  • 传递性(Transitivity)
    如果操作A先行发生于操作B,操作B先行发生于操作C,那就可以得出操作A先行发生于操作C的结论。

正是以上这些规则保障了happen-before的顺序,如果不符合以上规则,那么在多线程环境下就不能保证执行顺序等同于代码顺序,也就是“如果在本线程中观察,所有的操作都是有序的;如果在一个线程中观察另外一个线程,则不符合以上规则的都是无序的”,因此,如果我们的多线程程序依赖于代码书写顺序,那么就要考虑是否符合以上规则,如果不符合就要通过一些机制使其符合,最常用的就是synchronized、Lock以及volatile修饰符。

请注意,volatile不能保证操作原子性

下面通过一个代码案例来说明,我们通过启用1000个线程,来对一个变量执行自增加1的操作

public class Counter {
    public static int count = 0;

    public static void inc() {
        //这里延迟1毫秒,使得结果明显
        try {
            Thread.sleep(1);
        } catch (InterruptedException e) {
        }
        count++;
    }

    public static void main(String[] args) {
        //同时启动1000个线程,去进行i++计算,看看实际结果
        for (int i = 0; i < 1000; i++) {
            new Thread(new Runnable() {
                public void run() {
                    Counter.inc();
                }
            }).start();
        }

        //这里每次运行的值都有可能不同,可能为1000
        System.out.println("运行结果:Counter.count=" + Counter.count);
    }
}

执行结果为:运行结果:Counter.count=983,并不是预期的1000,且每次执行的结果都可能不一致。
那么如果使用volatile关键字修饰的变量是具有可见性的,那么使用关键字修饰之后的程序执行结果是否就是我们预期的1000呢?
代码修改为:

public class Counter {
    public static volatile int count = 0;

    public static void inc() {
        //这里延迟1毫秒,使得结果明显
        try {
            Thread.sleep(1);
        } catch (InterruptedException e) {
        }
        count++;
    }

    public static void main(String[] args) {
        //同时启动1000个线程,去进行i++计算,看看实际结果
        for (int i = 0; i < 1000; i++) {
            new Thread(new Runnable() {
                public void run() {
                    Counter.inc();
                }
            }).start();
        }

        //这里每次运行的值都有可能不同,可能为1000
        System.out.println("运行结果:Counter.count=" + Counter.count);
    }
}

执行结果依然不是预期的1000,下面我们具体分析一下问题原因:
在JVM结构、GC工作机制详解一文中,描述了jvm运行时刻内存的分配。其中有一个内存区域是jvm虚拟机栈,每一个线程运行时都有一个线程栈。
线程栈用于存储局部变量表、操作栈、方法返回值等。
当线程访问某一个对象时候值的时候,首先通过对象的引用找到对应在堆内存的变量的值,然后把堆内存变量的具体值load到线程本地内存中,建立一个变量副本,之后线程就不再和对象在堆内存变量值有任何关系,而是直接修改副本变量的值,在修改完之后的某一个时刻(线程退出之前),自动把线程变量副本的值回写到对象在堆中变量。
这样在堆中的对象的值就产生变化了。下面我们使用图片来描述这些交互

线程同步volatile与synchronized详解_第2张图片
Java线程运行时内存交互

  • read and load 从主存复制变量到当前工作内存
  • use and assign 执行代码,改变共享变量值
  • store and write 用工作内存数据刷新主内存相关内容

其中use and assign在线程执行过程中可以多次出现。
但是这一些操作并不是原子性,也就是说,在read and load之后,线程使用的变量值就是自己栈内存中的变量值备份副本了,这时如果主内存count变量发生修改之后,线程工作内存中的值由于已经加载,不会产生对应的变化,所以计算出来的结果会和预期不一样。

对于volatile修饰的变量,jvm虚拟机只是保证从主内存加载到线程工作内存的值是最新的。

例如线程1,线程2 在进行read and load操作中,发现主内存中count的值都是5,那么都会加载这个最新的值。
在线程1对count进行修改之后,会write到主内存中,主内存中的count变量就会变为6
线程2由于已经进行read and load操作,在后续的运算中,使用的均是自己栈内存中的副本,也就是使用5进行的运算,因此进行运算之后,更新主内存count的变量值也为6

这也就是导致两个线程及时用volatile关键字修改之后,仍会存在并发的情况的原因。

synchronized和volatile均能实现对象的可见性,而synchronized更是能保证操作的原子性。因此如果想要实现多线程时的数据准确,还是要使用synchronized关键字。

参考:
Java并发——线程同步volatile与synchronized详解
java中volatile关键字的含义
Java的多线程机制系列:(四)不得不提的volatile及指令重排序(happen-before)

你可能感兴趣的:(线程同步volatile与synchronized详解)