天天看点

从volatile关键字谈下并发问题的个人理解

    先来看一篇详细分析volatile语义的文章。

    http://www.infoq.com/cn/articles/java-memory-model-4

    若问及volatile关键字的含义,一般都会得到如下答案。

    (1)对volatile修饰的变量,各线程间的写操作将立即对其他线程可见。

    (2)volatile修饰的变量的操作是原子性的,并且如果volatile的赋值操作依赖于他的前一个值,则会失去原子性。 

      其实,(1)有着更深层次的含义,即对可见性的理解。(2)volatile本身和原子性并无直接关系,(2)中所说的原子性其实并不是真正意义上的原子性,其实是可见性的另一种表现形式。

      那何为可见性问题呢,对于同一段代码,执行线程和观察线程所见到的执行顺序不一定是一致的。听起来有点拗口,看如下例子。

   乱序执行导致的可见性问题

         以下文伪代码为例,解释了何谓乱序执行导致的可见性问题。

          线程1:

                       Resource resource=init();

                       boolean hasInit=true;

                       afterOperation();

          线程2:

                      if(hasInit){

                        getResource();

                       more operation;

                       }

          上述程序粗看没问题,但当线程2中hasInit=true时,resource一定初始化好了吗。答案是否定的。

          JAVA为了执行效率,代码在执行时候会有一定程度上的指令乱序。但对同一个线程内的程序来说,乱

序执行不影响每一句代码对之前执行的代码的可见性。对上述程序来说,当线程1中的afterOperation()获取到flag值为true,resource一定是初始化完成了的。 但对线程2来说,resource有可能未初始化完成时,就获取了为true的flag值。

         回到之前volatile的语义,如下:

  • 当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量刷新到主内存。
  • 当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量。

        简单来说,当上述的flag变量设置为volatile时,对线程2来说,resource的初始化一定是在volatile的写之前执行。即禁止flag的写语句重排到定义的位置之前。

       那对线程2来说,线程1中的afterOperation()被线程2观察到时,是否flag一定已经是true了呢?答案是否定的。具体原因么,可参考volatile的内存含义的第二条。即需要volatile的读来禁止这种情况。

      以上便是文章开头所说的 “可见性”的真正所指。另外,乱序执行是对编译后的命令来说的,一句JAVA命令被编译后的执行命令,也可以被乱序执行。这在下文提到的单例模型中会讲到。

     顺便插一句,如何快速地判断多线程情况下的代码可见性呢,实际应用中可参考happens-before原则

   以上其实便是可见性问题。实际应用中,除了可见性问题,更常见的便是原子性问题

   原子性操作被破坏

        先从最简单的变量自增操作说起。众所周知,JAVA的自增操作不是原子性的。一句JAVA命令,会被编译器分解为多条命令执行。以自增操作为例 ,  a++  执行时的操作时的字节码如下

       public void getNext(); Code: 0:   aload_0 //加载局部变量表index为0的变量,在这里是this

       1:   dup                 //将当前栈顶的对象引用复制一份

       2:   getfield        #2; //Field id:I,获取id的值,并将其值压入栈顶

       5:   iconst_1            //将int型的值1压入栈顶

       6:   iadd                //将栈顶两个int类型的元素相加,并将其值压入栈顶

       7:   putfield        #2; //Field id:I,将栈顶的值赋值给id

       10:  return }

     若在执行自增操作的时候,同时有其他线程对同一变量a进行写操作或者读操作,则操作的对象可能是执行过程中状态异常的变量,并导致获取期望之外的结果。 对于具体的自增操作的多线程测试,网上很多。

      原子性的概念,简单来说,就是进行一些列操作的时候,操作中的动作要么不执行,要么全部执行完后才让执行结果对其他线程可见。

      上述概念一样可以运用于多条语句,举例来说,在一个方法中需要执行如下write操作。

       boolean flag=true;

       Date date=new Date();

      这两个变量的状态必须一起更新,若当flag=true执行完毕后就立刻被其他线程读取,则其他线程中可能会获得错误的date变量。即上文说的“执行过程中状态异常的变量”。

     解决方式,便是上述write操作和read操作用同一个锁进行synchronized。这样可以保证,在write操作时,不正确的值不会被其他线程读取。

   原子性问题有时会和可见性问题同时出现,典型代表便是著名的基于双加锁的单例模式。

private Resource resource;
public static Resource getInstance(){  
        if( resource == null ){   //(1)   
        synchronized(lock){      
             if( resource == null ){    
                   resource = new Resource();  //(4) 
             }  
         }  
     } 
           

     该写法中的resource变量为竞争资源,其中标注为4的语句可能会引起失败。原因就在于resource=new Resouce()这句话至少包含2个步骤,一是是调用resource对象的构造函数并初始化,二是将新分配的Resource对象的内存引用赋值给resource变量。 当另一个线程中执行语句1时,由于并未将resource设置为volatile,则可能导致获取到不为空但尚未初始化完成的resource对象。这就是上文所说的乱序执行导致的问题。

       总结:      

               并发问题主要分为资源竞争时原子操作的被破坏、和多线程情况下乱序执行导致的线程可见性。

              凡是在并发过程中遇到上述两种问题,都需要考虑解决方式。

              常见的解决方式是通过加锁来实现。锁的获取也具有volatile读的内存语义,锁的释放具有volatile写的内存语义。如果功力不够,在多线程编程时则少用volatile变量来解决并发问题。用锁则稳妥些(效率牺牲)。