Java并发编程之volatile关键字解析

keithk 3年前
   <p>volatile关键字虽然从字面上理解起来比较简单,但是要用好不是一件容易的事情。本文我们就从JVM内存模型开始,了解一下 volatile 的应用场景。</p>    <h2>volatile关键字</h2>    <h3>JVM内存模型</h3>    <p>在了解 volatile 之前,我们有必要对JVM的内存模型有一个基本的了解。Java的内存模型规定了所有的变量都存储在主内存中(即物理硬件的内存),每条线程还具有自己的工作内存(工作内存可能位于处理器的高速缓存之中),线程的工作内存中保存了该线程使用到的变量的主内存副本拷贝,线程对变量的所有操作(读取,赋值等)都必须在工作内存中进行,而不能直接读写主内存中的变量)。不同的线程之间无法直接访问对方工作内存之间的变量,线程间变量值的传递需要通过主内存来完成。</p>    <p>p.s: 对于上面提到的副本拷贝,比如假设线程中访问一个10MB的对象,并不会把这10MB的内存复制一份拷贝出来,实际上这个对象的引用,对象中某个在线程访问到的字段是有可能存在拷贝的,但不会有虚拟机实现把整个对象拷贝一次。</p>    <p>在并发编程中,我们通常会遇到以下三个问题:原子性,可见性,有序性,下面我们我们来具体看一下这三个特性与 volatile 之间的联系:</p>    <h3>有序性</h3>    <pre>  <code class="language-java">public class Testcase{   public static int number;   public static boolean isinited;     public static void main(String[] args){   new Thread(   () -> {   while (!isinited) {   Thread.yield();   }   System.out.println(number);   }   ).start();   number = 20;   isinited = true;   }  }  </code></pre>    <p>对于上面的代码我们上面的本意是想输出 20 ,但是如果运行的话可以发现输出的值可能会是 0 。这是因为有时候为了提供程序的效率,JVM会做进行及时编译,也就是可能会对指令进行重排序,将 isInited = true; 放在 number = 20; 之前执行,在单线程下面这样做没有任何问题,但是在多线程下则会出现重排序问题。如果我们将 number 声名为 volatile 就可以很好的解决这个问题,这可以禁止JVM进行指令重排序,也就意味着 number = 20; 一定会在 isInited = true 前面执行。</p>    <h3>可见性</h3>    <p>比如对于变量 a ,当线程一要修改变量a的值,首先需要将a的值从主存复制过来,再将a的值加一,再将a的值复制回主存。在单线程下面,这样的操作没有任何的问题,但是在多线程下面,比如还有一个线程二,在线程一修改a的值的时候,也从主存将a的值复制过来进行加一,随后线程一和线程二先后将a的值复制回主存,但是主存中a的值最终将只会加一而不是加二。</p>    <p>使用 volatile 可以解决这个问题,它可以保证在线程一修改a的值之后立即将修改值同步到主存中,这样线程二拿到的a的值就是线程一已经修改过的a的值了。</p>    <h3>原子性</h3>    <p>原子性是指CPU在执行一条语句的时候,不会中途转去执行另外的语句。比如 i = 1 就是一个原子操作,但是 ++i 就不是一个原子操作了,因为它要求首先读取 i 的值,然后修改 i 的值,最后将值写入主存中。</p>    <p>但是 volatile 却不能保证程序的原子性,下面我们通过一个实例来验证一下:</p>    <pre>  <code class="language-java">public class TestCase{   public volatile int v = 0;   public static final int threadCount = 20;     public void increase(){   v++;   }     public static void main(String[] args){   TestCase testCase = new TestCase();   for (int i=0; i<threadCount; i++) {   new Thread(   () -> {   for (int j=0; j<1000; j++) {   testCase.increase();   }   }   ).start();   }     while (Thread.activeCount() > 1) {   Thread.yield();   }   System.out.println(testCase.v);   }  }  </code></pre>    <p>输出结果:</p>    <p>上面我们的本意是想让输出 20000 ,但是运行程序后,结果可能会小于 20000 。因为 v++ 它本身并不是一个原子操作,它是分为多个步骤的,而且 volatile 本身也并不能保证原子性。</p>    <p>上面的程序使用 synchronzied 则可以很好的解决,只需要声明 public synchronized void increase() 就行了。</p>    <p>或者使用lock也行:</p>    <pre>  <code class="language-java">Lock lock = new ReentrantLock();    public void increase(){   lock.lock();   try {   v++;   } finally{   lock.unlock();   }  }  </code></pre>    <p>或者将 v 声明为 AtomicInteger v = new AtomicInteger(); 。在java 1.5的java.util.concurrent.atomic包下提供了一些原子操作类,即对基本数据类型的自增,自减,以及加法操作,减法操作进行了封装,保证这些操作是原子性操作。</p>    <h3>volatile的应用场景</h3>    <p>下面我们通过单例模式来看一下 volatile 的一个具体应用:</p>    <pre>  <code class="language-java">class Singleton{   private volatile static Singleton instance;     public static Singleton getInstance(){   if (instance == null) {   synchronized (Singleton.class) {   if (instance == null)   instance = new Singleton();   }   }   return instance;   }     public static void main(String[] args){   Singleton.getInstance();   }  }  </code></pre>    <p>上面 instance 必须要用 volatile 修饰,因为 new Singleton 是分为三个步骤的:</p>    <ol>     <li>给instance指向的对象分配内存,并设置初始值为null(根据JVM类加载机制的原理,对于静态变量这一步应该在 new Singleton 之前就已经完成了)。</li>     <li>执行构造函数真正初始化instance</li>     <li>将instance指向对象分配内存空间(分配内存空间之后instance就是非null了)</li>    </ol>    <p>在我们的步骤2, 3之间的顺序是可以颠倒的,如果线程一在执行步骤3之后并没有执行步骤2,但是被线程二抢占了,线程二得到的 instance 是非null,但是instance却还没有初始化。而使用instance则可以保证程序的有序性。</p>    <h2>References</h2>    <p><a href="/misc/goto?guid=4959715992181934710" rel="nofollow,noindex">深入理解Java虚拟机</a></p>    <p><a href="/misc/goto?guid=4959748132167493601" rel="nofollow,noindex">Java并发编程实战</a></p>    <p> </p>    <p>来自:http://www.ziwenxie.site/2017/04/24/java-multithread-volatile/</p>    <p> </p>