美文网首页
六 .Java内存模型

六 .Java内存模型

作者: shallowinggg | 来源:发表于2019-03-03 12:11 被阅读0次

    在深入volatile和synchronized关键字的底层细节前,我们先对Java内存模型进行一定了解。Java内存模型指定了Java虚拟机如何与计算机内存(RAM)进行合作。Java虚拟机是整个计算机的模型,因此这个模型自然也包含了内存模型,也就是Java内存模型。

    如果你想要设计行为正确的并发程序,需要指定不同的线程如何以及何时看到其他线程写入共享变量的值,并且如何在需要时同步获取共享变量。

    原始的Java内存模型有些不足,所以Java内存模型在Java 1.5进行了修订。这个版本的Java内存模型在Java 8仍然在使用。

    The Internal Java Memory Model

    Java内存模型在JVM内部使用,在线程堆栈和堆之间分割内存。下面这个图从逻辑上描述了Java内存模型:

    每一个在Java虚拟机运行的线程都拥有自己的线程堆栈。线程堆栈包含了到达当前的执行点线程已经调用的方法信息。我将它称为"调用堆栈"。随着线程执行它的代码,调用堆栈也随之改变。

    线程堆栈也为每个正在执行的方法(在调用堆栈上的所有方法)储存了所有的局部变量。一个线程只可以访问它自己的线程堆栈,被一个线程创建的局部变量对其他所有的线程都是不可见的,除了创建它的线程外。即使两个线程在执行完全相同的代码,两个线程仍然在他们自己的线程堆栈中创建执行代码的局部变量。因此,每个线程拥有自己版本的局部变量。

    所有的原始类型(boolean, byte, short, char, int, long, float, double)的局部变量被完全存储在线程堆栈中,因此对其他线程来说是不可见的。一个线程可以传输给其他线程一个原始局部变量的拷贝,但是自己本身不能和其他线程共享原始局部变量。

    堆包含了所有在你的Java应用中创建的对象,不管是不是线程创建的。这包括了原始类型的对象版本(例如Byte,Integer,Long等)。它不关心是否一个对象被创建后赋值给一个局部变量,或者被创建作为其他对象的成员变量,无论如何这个对象总是被储存在堆中。

    这个图描述了存储在线程堆栈中的调用堆栈和局部变量以及存储在堆中的对象:

    一个局部变量可能是原始类型,此时它存储在线程堆栈中。

    一个局部变量可能是一个对象的引用。此时对象引用(那个局部变量)被存储到线程堆栈中,但对象本身被存储在堆中。

    一个对象可能拥有方法,并且这些方法可能包含局部变量。这些局部变量也被存储在线程堆栈中,即使这个方法属于的对象被存储在堆中。

    一个对象的成员变量也随对象一起存储在堆中,即使当成员变量是原始类型或者一个对象引用时也是如此。

    静态类变量也和类定义(Class对象)一起存储在堆中。

    在堆中的对象可以被任何拥有这个对象的引用的线程访问。当一个线程能够访问一个对象时,它也可以访问这个对象的成员变量。如果两个线程同时在一个相同的对象上调用一个方法,它们都能访问对象的成员变量,但是每个线程拥有自己的局部变量(位于方法内部)的副本。

    这是一个说明上述要点的图:

    两个线程有一组局部变量,其中一个局部变量(Local Variable 2)指向了堆上的一个共享变量(Object 3)。这两个线程都拥有对同一个对象的引用,他们的引用是局部变量,因此存储在每个线程的栈中。

    注意共享对象(Object 3)拥有一个对 Object 2Object 4 的引用,作为它的成员变量(由 Object 3Object 2Object 4 的箭头所示)。通过这些在 Object 3 中的成员变量引用,这两个线程可以访问到 Object 2Object 4 对象。

    这个图也展示了指向堆中两个不同对象的局部变量。此时两个线程中的引用指向两个不同对象( Object 1Object 5 ),而不是同一个对象。理论上两个线程都可以访问 Object 1Object 5 ,如果这两个线程都拥有对这两个对象的引用,但是上面的图中每个线程都只拥有一个对象的引用。

    所以,哪种Java代码可以形成上面的内存图呢?一种简单的代码示例如下:

    public class MyRunnable implements Runnable {
    
        public void run() {
            methodOne();
        }
    
        public void methodOne() {
            int localVariable1 = 45;
    
            MySharedObject localVariable2 =
                MySharedObject.sharedInstance;
    
            //... do more with local variables.
            methodTwo();
        }
    
        public void methodTwo() {
            Integer localVariable1 = new Integer(99);
    
            //... do more with local variable.
        }
    }
    
    public class MySharedObject {
    
        //static variable pointing to instance of MySharedObject
    
        public static final MySharedObject sharedInstance =
            new MySharedObject();
    
    
        //member variables pointing to two objects on the heap
    
        public Integer object2 = new Integer(22);
        public Integer object4 = new Integer(44);
    
        public long member1 = 12345;
        public long member2 = 67890;
    }
    

    如果两个线程执行了 run() 方法,那么上面的图将会是最终结果。run() 方法调用了 methodOne() 方法,然后 methodOne() 方法调用了 methodTwo() 方法。

    methodOne() 定义了一个原始局部变量(int类型的 localVariable1 )和一个对象引用局部变量( localVariable2 )。

    每个执行 methodOne() 的线程将会在自己的线程堆栈中创建它们自己的 localVariable1localVariable2 的拷贝。localVariable1 变量将会被彼此完全隔离,只在自己线程的线程堆栈上存活。其他线程无法看到这个线程对 localVariable1 拷贝的改变。

    每个执行 methodTwo() 的线程也会创建它们自己的 localVariable2 的拷贝。但是,这两个不同的 localVariable2 拷贝最终都指向了堆上的同一个对象。代码通过一个静态变量引用使得两个 localVariable2 的引用指向一个对象。类的静态变量只会被创建一次,并且这个对象被存储在堆中。因此, localVariable2 的两个拷贝最终都指向 MySharedObject (静态变量指向的)的同一个实例。MySharedObject 实例也被储存在堆中,这和上面图中的 Object 3 差不多。

    也注意 MySharedObject 类如何包含两个成员变量的。成员变量本身和对象一起存储在堆中,这两个成员变量指向两个不同的Integer对象。这两个Integer对象和上图中的 Object 2Object 4 类似。

    注意 methodTwo() 创建了一个叫做 localVariable1 的局部变量,这个局部变量是一个对Integer对象的引用。这个方法使 localVariable1 引用指向一个新的Integer实例。执行 methodTwo() 时每个线程都会将 localVariable1 引用存储到自己的副本中。这两个初始化了的Integer对象将会存储在堆中,但是由于每当这个方法被执行时,都会创建一个新的Integer对象,所以两个线程执行这个方法时将创建两个不同的Integer实例。在 methodTwo() 中创建的Integer对象和上图中的 Object 1Object 5 一样。

    注意 MySharedObject 类中的两个long类型成员变量都是原始类型。因为这些变量是成员变量,所以他们也会随着对象一起存储在堆中。只有局部变量被存储在线程堆栈中。

    硬件内存架构 Hardware Memory Architecture

    现代硬件内存架构与Java内存模型有些不同的地方。为了理解Java内存模型如何与它工作,理解硬件内存架构也很重要。这个部分描述了通用的硬件内存架构,在稍后的部分将讲述Java内存模型如何与之工作。

    这是一个简化的现代计算机硬件架构图:

    一个现代计算机一般有两个或更多CPU,其中一些CPU也可能拥有多个核心。在一个有两个或更多CPU的现代计算机上,多个线程同时运行是完全可能的。每一个CPU都能够运行一个线程,这意味着如果你的Java应用是多线程的,在你的Java应用内部可能有每个CPU一个线程在同时(并发)运行。

    每个CPU包含了一组寄存器,CPU在那些寄存器上执行操作远比在主内存中快的多,这是因为CPU访问寄存器比主内存更快。

    每个CPU还可以具有CPU高速缓存存储器层。事实上,大多数现代CPU都有某种大小的缓存存储层。CPU访问其高速缓存存储器比访问主存储器更快,但通常不会像访问其内部寄存器那样快。因此,CPU高速缓存存储器介于内部寄存器和主存储器的速度之间。某些CPU可能有多个缓存层(级别1和级别2),但要了解Java内存模型如何与内存交互,这一点并不重要。重要的是要知道CPU可以有某种缓存存储层。

    计算机还包含主存储区(RAM)。所有CPU都可以访问主存。主存储区通常比CPU的高速缓存存储器大得多。

    通常,当CPU需要访问主存储器时,它会将部分主存储器的内容读入其CPU缓存。它甚至可以将部分缓存读入其内部寄存器,然后对其执行操作。当CPU需要将结果写回主存储器时,它会将值从其内部寄存器刷新到高速缓冲存储器,并在某些时候将值从缓存刷新回主存储器。

    当CPU需要将其他东西存储在高速缓冲存储器中时,存储在高速缓冲存储器中的值通常需要被刷回到主存储器,因为可能没有足够的空间存储了。CPU缓存可以一次只在其部分内存中写入数据,也能一次刷新部分内存到主存中。它不必在每次更新时读/写完整缓存。通常,缓存在称为“缓存行”的较小存储块中更新。可以将一个或多个高速缓存行读入高速缓冲存储器,并且可以再次将一个或多个高速缓存行刷回到主存储器。

    建立Java内存模型与硬件内存架构之间的联系

    如前所述,Java内存模型和硬件内存架构是不同的。硬件内存架构不区分线程堆栈和堆。在硬件上,线程堆栈和堆都位于主存储器中。线程堆栈和堆的一部分有时也可能存在于CPU高速缓存和内部CPU寄存器中。这在图中说明:

    当对象和变量可以存储在计算机的各种不同存储区域中时,可能会出现某些问题。两个主要问题是:

    • 线程更新(写入)共享变量的可见性。
    • 读取,检查和写入共享变量时的竞争条件。

    以下将解释这两个问题。

    共享对象的可见性

    如果两个或多个线程共享一个对象,而没有正确使用 volatile 声明或同步,则一个线程对共享对象的更改对于在其他CPU上运行的线程是不可见的。这样,每个线程最终都可能拥有自己的共享对象副本,每个副本都位于不同的CPU缓存中,并且其中的内容不相同。

    下图简单说明了情况。在左边CPU上运行的一个线程将共享对象复制到其CPU缓存中,并将其 count 变量更改为2。此更改对于在CPU上运行的其他线程不可见,因为count的更新尚未刷新回主内存。

    要解决此问题,您可以使用Java的volatile关键字volatile 关键字可以确保变量从主内存中直接读取而不是从缓存中,并且更新的时候总是立即写回主内存。

    竞争条件

    如果两个或多个线程共享一个对象,并且多个线程更新该共享对象中的成员变量,则可能会出现竞争条件

    想象一下,如果线程A将共享对象的变量count读入其CPU缓存中。再想象一下,线程B也做了同样的事情,但是进入到了不同的CPU缓存。现在线程A添加一个值到count,线程B执行相同的操作。现在var1已经增加了两次,每次CPU缓存一次。

    如果这些增加操作按顺序执行,则变量count将增加两次并将"原始值+ 2"后产生的新值写回主存储器。

    但是,两个增加操作同时执行却没有进行适当的同步。无论线程A和B中的哪一个将其更新版本count写回主到存储器,更新的值将仅比原始值多1,尽管有两个增加操作。

    该图说明了如上所述的竞争条件问题的发生:

    要解决此问题,您可以使用Java synchronized块。同步块保证在任何给定时间只有一个线程可以进入代码的临界区。同步块还保证在同步块内访问的所有变量都将从主存储器中读入,当线程退出同步块时,所有更新的变量将再次刷新回主存储器,无论变量是否声明为volatile。

    Java Synchronized Blocks

    在说明了Java内存模型之后,后续将会简单说明synchronized和volatile关键字的使用。首先说明synchronized关键字。

    Java synchronized块将方法或代码块标记为同步。Java synchronized块可用于避免竞争条件

    Java synchronized 关键字

    Java中的同步块用 synchronized关键字标记,Java中的同步块在某个对象上同步。在同一对象上同步的所有同步块只能同时在其中执行一个线程。尝试进入同步块的所有其他线程都将被阻塞,直到同步块内的线程退出同步块。

    synchronized 关键字可用于标记四种不同类型的代码块:

    1. 实例方法
    2. 静态方法
    3. 实例方法中的代码块
    4. 静态方法中的代码块

    这些代码块在不同对象上同步,你需要哪种类型的同步块取决于具体情况。

    同步实例方法

    这是一个同步的实例方法:

    public synchronized void add(int value){
          this.count += value;
    }
    

    请注意在方法声明中使用了关键字synchronized,这告诉Java该方法是同步的。

    Java中的同步实例方法在拥有该方法的实例(对象)上同步。因此,每个实例都有自己的同步方法,这些方法在不同的对象上(即拥有此方法的实例)同步。在同步实例方法中只有一个线程可以执行。如果存在多个实例,则每个实例的同步实例方法内都可以有一个线程执行。每个实例一个线程。

    import org.junit.Test;
    
    import java.util.Date;
    
    import static util.SleepUtils.sleep;
    
    public class SynchronizedTest {
        private static CountDownLatch end = new CountDownLatch(4);
    
        /**
         * 测试同一实例以及不同实例上运行同一个同步方法
         * @throws Exception InterruptedException by CountDownLatch#await()
         */
        @Test
        public void instanceMethodTest() throws Exception {
            SynchronizedTest test1 = new SynchronizedTest();
            SynchronizedTest test2 = new SynchronizedTest();
    
            //在同一实例的同步方法上运行
            Thread thread = new Thread(() -> { method1(); end.countDown(); }, "Thread-1");
            Thread thread2 = new Thread(() -> { method1(); end.countDown(); }, "Thread-2");
            //在不同实例的同步方法上运行
            Thread thread3 = new Thread(() -> { test1.method1(); end.countDown(); }, "Thread-3");
            Thread thread4 = new Thread(() -> { test2.method1(); end.countDown(); }, "Thread-4");
    
            thread.start();
            thread2.start();
            thread3.start();
            thread4.start();
            //等待线程执行完成
            end.await();
        }
    
        private synchronized void method1() {
            //模拟逻辑执行,设置为2s可以较为清晰的观察执行效果
            sleep(2);
            System.out.println("execute synchronized method, @ " + new Date() + ", by " + Thread.currentThread().getName());
        }
    }
    

    输出如下:

    execute synchronized method, @ Mon Feb 25 16:43:14 CST 2019, by Thread-4
    execute synchronized method, @ Mon Feb 25 16:43:14 CST 2019, by Thread-1
    execute synchronized method, @ Mon Feb 25 16:43:14 CST 2019, by Thread-3
    execute synchronized method, @ Mon Feb 25 16:43:16 CST 2019, by Thread-2

    从输出中可以看出,使用不同实例时即使运行同一个同步方法,线程也不会被阻塞。

    同步静态方法

    静态方法被标记为同步,和实例方法使用synchronized关键字一样 。这是一个Java synchronized静态方法示例:

    public static synchronized void add(int value){
          count += value;
    }
    

    此处synchronized关键字告诉Java该方法是同步的。

    同步静态方法在同步静态方法所属的类的类对象上同步。由于每个类在JVM中只存在一个类对象,因此在同一个类中的静态同步方法中只能执行一个线程。

    如果静态同步方法位于不同的类中,则在每个类的静态同步方法内可以有一个线程执行。每个类一个线程,无论它调用哪个静态同步方法。

    /**
         * 测试在同一类中运行静态同步方法
         * @throws Exception InterruptedException by Thread#join()
         */
        @Test
        public void staticMethodTest() throws Exception {
            Thread thread = new Thread(() ->  staticMethod1() , "Thread-1");
            Thread thread2 = new Thread(() ->  staticMethod1(), "Thread-2");
            Thread thread3 = new Thread(() -> staticMethod3(), "Thread-3");
    
            thread.start();
            thread2.start();
            thread3.start();
            //等待线程执行完成
            thread.join();
            thread2.join();
            thread3.join();
        }
    
        private static synchronized void staticMethod1() {
            sleep(2);
            System.out.println("execute static synchronized method, @ " + new Date() + ", by " + Thread.currentThread().getName());
        }
    
        private static synchronized void staticMethod3() {
            sleep(2);
            System.out.println("execute static synchronized method3, @ " + new Date() + ", by " + Thread.currentThread().getName());
        }
    

    输出如下:

    execute static synchronized method, @ Mon Feb 25 16:54:10 CST 2019, by Thread-1
    execute static synchronized method, @ Mon Feb 25 16:54:12 CST 2019, by Thread-2
    execute static synchronized method3, @ Mon Feb 25 16:54:14 CST 2019, by Thread-3

    可以看出在同一个类中,无论运行同一个静态同步方法还是不同的静态的同步方法,一次只能有一个线程在运行。

    实例方法中的同步块

    您不必同步整个方法,有时最好只同步方法的一部分。方法中的Java同步块使这成为可能。

    这是一个非同步Java方法中的同步Java代码块:

    public void add(int value){
        synchronized(this){
           this.count += value;   
        }
    }
    

    此示例使用Java synchronized块构造将代码块标记为同步。,代码现在将像执行同步方法一样执行。

    请注意Java synchronized块构造在括号中使用对象。在示例中,使用“this”,这是调用add方法的实例。synchronized块在括号中使用的对象称为监视器对象,代码在监视器对象上同步。同步实例方法使用它所属的对象作为监视器对象。

    在同一监视器对象上同步的Java代码块内只有一个线程可以执行。

    以下两个示例都在调用它们的实例上同步。因此,它们在同步方面是等效的:

    public class MyClass {
    
        public synchronized void log1(String msg1, String msg2){
            log.writeln(msg1);
            log.writeln(msg2);
        }
    
        public void log2(String msg1, String msg2){
            synchronized(this){
                log.writeln(msg1);
                log.writeln(msg2);
            }
        }
    }
    

    因此,在这个例子中,只有一个线程可以在两个同步块中的任何一个内执行。

    如果有第二个同步块在不同于"this"的对象上同步,则在每个方法内可以有一个线程执行。

    静态方法中的同步块

    以下是与静态方法相同的两个示例,这些方法在方法所属的类的类对象上同步:

    public class MyClass {
    
        public static synchronized void log1(String msg1, String msg2){
           log.writeln(msg1);
           log.writeln(msg2);
        }
    
        public static void log2(String msg1, String msg2){
            synchronized(MyClass.class){
                log.writeln(msg1);
                log.writeln(msg2);  
            }
        }
    }
    

    同一时间在这两个方法中的任何一个内只有一个线程可以执行。

    如果第二个同步块在一个不同于MyClass.class的对象上同步,那么同一时间每个方法中都可以有一个线程执行。

    Java Synchronized Example

    下面是一个示例,它启动2个线程并让它们在同一个Counter实例上调用add方法。一次只有一个线程能够在同一个实例上调用add方法,因为该方法是在它所属的实例上同步的。

    public class Counter{
    
         long count = 0;
    
         public synchronized void add(long value){
           this.count += value;
         }
      }
    
    
    public class CounterThread extends Thread{
    
         protected Counter counter = null;
    
         public CounterThread(Counter counter){
            this.counter = counter;
         }
    
         public void run() {
            for(int i=0; i<10; i++){
                counter.add(i);
            }
         }
    }
    
    public class Example {
    
        public static void main(String[] args){
            Counter counter = new Counter();
            Thread  threadA = new CounterThread(counter);
            Thread  threadB = new CounterThread(counter);
    
            threadA.start();
            threadB.start(); 
        }
    }
    

    创建了两个线程,并在构造函数中将相同的Counter实例传递给它们。Counter.add()方法在this实例上同步,因为add方法是实例方法,并被标记为synchronized。因此,只有一个线程可以调用add()方法。另一个线程将等到第一个线程离开add()方法,然后才能执行该方法。

    如果两个线程引用了两个不同的Counter实例,那么同时调用add()方法就没有问题。调用将发生在不同的对象上,因此调用方法也将在不同的对象(拥有该方法的对象)上同步,因此调用不会阻塞。

    public class Example {
          public static void main(String[] args){
              Counter counterA = new Counter();
              Counter counterB = new Counter();
              Thread  threadA = new CounterThread(counterA);
              Thread  threadB = new CounterThread(counterB);
    
              threadA.start();
              threadB.start(); 
          }
    }
    

    注意两个线程threadA和threadB不再引用同一个计数器实例。counterAcounterBadd方法他们自己的实例上同步。在counterA上调用add()将不会阻塞counterB上调用add()

    Java并发组件

    synchronized机制是Java用于对多个线程共享的对象同步访问的第一种机制,但synchronized机制不是很先进。这就是为什么Java 5新增了一整套concurrency utility classes 来帮助开发人员实现比synchronized更细粒度的并发控制。

    Volatile关键字

    接下来描述volatile关键字。

    Java volatile关键字用于将Java变量标记为“存储在主存储器中”。更确切地说,这意味着,每次读取一个volatile变量都将从计算机的主内存中读取,而不是从CPU缓存中读取,并且每次写入volatile变量都将写入主内存,而不仅仅是CPU缓存。

    实际上,自Java 5以来,volatile关键字保证的不仅仅是向主存储器写入和读取volatile变量。我将在以下部分解释。

    变量的可见性问题

    Java volatile关键字保证了跨线程变量变化的可见性。这可能听起来有点抽象,所以让我详细说明。

    在线程使用非volatile变量的多线程应用程序中,出于性能原因,每个线程可以在处理它们时将变量从主存储器拷贝到CPU高速缓存中。如果您的计算机包含多个CPU,则每个线程可以在不同的CPU上运行。这意味着,每个线程都可以将变量复制到不同CPU的CPU缓存中。这在这里说明:

    对于volatile变量,无法保证Java虚拟机(JVM)何时将数据从主内存读取到CPU缓存中,或将数据从CPU缓存写入主内存。这可能会导致一些问题,我将在以下部分中解释。

    想象一下两个或多个线程可以访问共享对象的情况,该共享对象包含一个声明如下的计数器变量:

    public class SharedObject {
        public int counter = 0;
    }
    

    再想象一下,只有线程1对counter变量进行增加操作,但线程1和线程2都可能读取变量counter

    如果counter变量未声明volatile,则无法保证何时将counter变量的值从CPU缓存写回主存储器。这意味着,CPU高速缓存中的counter变量值可能与主存储器中的变量值不同。这种情况如下所示:

    线程没有看到变量的最新值的问题,是因为它还没有被另一个线程写回主内存,这被称为“可见性”问题,其他线程看不到一个线程的某些更新。

    Java volatile可见性保证

    Java volatile关键字旨在解决变量可见性问题。通过使用volatile声明counter变量,对变量counter的所有写操作都将立即写回主存储器。此外,counter变量的所有读取都将直接从主存储器中读取。

    下面是counter变量声明为volatile的样子:

    public class SharedObject {
        public volatile int counter = 0;
    }
    

    声明变量为volatile,对其他线程写入该变量 保证了可见性

    在上面给出的场景中,一个线程(T1)修改计数器,另一个线程(T2)读取计数器(但从不修改它),声明该counter变量为volatile足以保证写入counter变量对T2的可见性。

    但是,如果T1和T2都在增加counter变量,那么声明counter变量为volatile就不够了。稍后会详细介绍。

    完全volatile可见性保证 Full volatile Visibility Guarantee

    实际上,Java volatile的可见性保证超出了volatile变量本身。可见性保证如下:

    • 如果线程A写入volatile变量并且线程B随后读取这个volatile变量,则在写入volatile变量之前对线程A可见的所有变量在线程B读取volatile变量后也将对线程B可见。
    • 如果线程A读取volatile变量,则读取volatile变量时对线程A可见的所有变量也将从主存储器重新读取。

    让我用代码示例说明:

    public class MyClass {
        private int years;
        private int months
        private volatile int days;
    
        public void update(int years, int months, int days){
            this.years  = years;
            this.months = months;
            this.days   = days;
        }
    }
    

    udpate()方法写入三个变量,其中只有days是volatile变量。

    完全volatile可见性保证意味着,当将一个值写入days时,对线程可见的其他所有变量也会写入主存储器。这意味着,当一个值被写入daysyearsmonths的值也被写入主存储器(注意days的写入在最后)。

    当读取yearsmonthsdays的值你可以这样做:

    public class MyClass {
        private int years;
        private int months
        private volatile int days;
    
        public int totalDays() {
            int total = this.days;
            total += months * 30;
            total += years * 365;
            return total;
        }
    
        public void update(int years, int months, int days){
            this.years  = years;
            this.months = months;
            this.days   = days;
        }
    }
    

    注意totalDays()方法通过读取days的值到total变量中开始。当读取days的值时,后续monthsyears值的读取也会从主存储器中读取。因此使用上述读取序列可以保证看到最新的daysmonthsyears值。

    指令重排序挑战

    出于性能原因允许JVM和CPU重新排序程序中的指令,只要指令的语义含义保持不变即可。例如,查看下面的指令:

    int a = 1;
    int b = 2;
    
    a++;
    b++;
    

    这些指令可以按以下顺序重新排序,而不会丢失程序的语义含义:

    int a = 1;
    a++;
    
    int b = 2;
    b++;
    

    然而,当其中一个变量是volatile变量时,指令重排序会出现一个挑战。让我们看看MyClass这个前面Java volatile教程中的例子中出现的类:

    public class MyClass {
        private int years;
        private int months
        private volatile int days;
    
        public void update(int years, int months, int days){
            this.years  = years;
            this.months = months;
            this.days   = days;
        }
    }
    

    一旦update()方法写入一个值days,新写入的值,以yearsmonths也被写入主存储器。但是,如果JVM重新排序指令,如下所示:

    public void update(int years, int months, int days){
        this.days   = days;
        this.months = months;
        this.years  = years;
    }
    

    days变量被修改时monthsyears的值仍然写入主内存中,但是这一次它发生在新的值被写入monthsyears之前,也就是这两个变量的旧值会写入主存中,后面两句的写入操作只是写到缓存中。因此,新值不能正确地对其他线程可见。重新排序的指令的语义含义已经改变。

    Java有一个解决这个问题的方法,我们将在下一节中看到。

    Java volatile Happens-Before 保证

    为了解决指令重排序挑战,除了可见性保证之外,Java volatile关键字还提供“happens-before”保证。happens-before保证保证:

    • 如果读取/写入最初发生在写入volatile变量之前,读取/写入其他变量不能重新排序在写入volatile变量之后。
      写入volatile变量之前的读/写操作被保证 "happen before" 写入volatile变量。请注意,发生在写入volatile变量之后的读/写操作依然可以重排序到写入volatile变量前,只是不能相反。允许从后到前,但不允许从前到后。
    • 如果读/写操作最初发生在读取volatile变量之后,则读取/写入其他变量不能重排序到发生在读取volatile变量之前。请注意,发生在读取volatile变量之前的读/写操作依然可以重排序到读取volatile变量后,只是不能相反。允许从前到后,但不允许从后到前。

    上述 "happens-before"规则保证确保volatile关键字的可见性保证在强制执行。

    public class VolatileTest {
        private volatile int vi = 1;
        private int i = 2;
        private int i2 = 3;
    
        @Test
        public void test() {
            System.out.println(i);      //1  读取普通变量
            i=3;                        //2  写入普通变量
    
            //1 2 不能重排序到3之后,操作4可以重排序到3前面
            vi = 2;                     //3  写入volatile变量
            i2 = 5;                     //4  写入普通变量
        }
    
        @Test
        public void test2() {
            System.out.println(i);      //1  读取普通变量
    
            //3不能重排序到在2前,但1可以重排序到2后
            System.out.println(vi);     //2  读取volatile变量
            System.out.println(i2);     //3  读取普通变量
        }
    }
    

    volatile 并不总是足够的

    即使volatile关键字保证volatile变量的所有读取直接从主存储器读取,并且所有对volatile变量的写入都直接写入主存储器,仍然存在声明volatile变量不足够的情况。

    在前面解释的情况中,只有线程1写入共享counter变量,声明counter变量为volatile足以确保线程2始终看到最新的写入值。

    实际上,如果写入volatile变量的新值不依赖于其先前的值,则甚至可以多个线程写入共享变量,并且仍然可以在主存储器中存储正确的值。换句话说,就是将值写入共享volatile变量的线程开始并不需要读取其旧值来计算其下一个值。

    一旦线程需要首先读取volatile变量的旧值,并且基于该值为共享volatile变量生成新值,volatile变量就不再足以保证正确的可见性。读取volatile 变量和写入新值之间的短时间间隔会产生竞争条件 ,其中多个线程可能读取volatile变量的同一个旧值,然后为其生成新值,并将该值写回主内存 - 覆盖彼此的值。

    多个线程递增同一个计数器的情况正是 volatile变量并不足够的情况。以下部分更详细地解释了这种情况。

    想象一下,如果线程1将值为0的共享变量counter读入其CPU高速缓存,将其增加到1并且不将更改的值写回主存储器。然后,线程2也从主存储器读取相同的counter变量进入自己的CPU高速缓存,其中变量的值仍为0。然后,线程2也将计数器递增到1,也不将其写回主存储器。这种情况如下图所示:

    线程1和线程2现在失去了同步。共享变量counter的实际值应为2,但每个线程的CPU缓存中的变量值为1,而在主内存中,该值仍为0。这是一个混乱!即使线程最终将共享变量counter的值写回主存储器,该值也将是错误的。

    什么时候volatile足够使用?

    正如我前面提到的,如果两个线程都在读取和写入共享变量,那么使用 volatile关键字是不够的。 在这种情况下,您需要使用synchronized来保证变量的读取和写入是原子性的。读取或写入一个volatile变量不会阻塞其他线程读取或写入这个变量。为此,您必须在临界区周围使用synchronized关键字。

    作为synchronized块的替代方法,您还可以使用java.util.concurrent中众多的原子数据类型。例如,AtomicLong或者 AtomicReference或其他的。

    如果只有一个线程读取和写入volatile变量的值,而其他线程只读取这个变量,那么此线程将保证其他线程能看到volatile变量的最新值。如果不将变量声明为volatile,则无法保证。

    volatile关键字也可以保证在64位变量上正常使用。

    volatile的性能考虑

    读取和写入volatile变量会导致变量从主存中读取或写入主存,读取和写入主内存比访问CPU缓存开销更大。访问volatile变量也会阻止指令重排序,这是一种正常的性能提升技术。因此,当您确实需要强制实施变量可见性时,才使用volatile变量。

    其他

    关于底层实现原理,请参考以下网站内容,或者自行寻找:
    https://segmentfault.com/a/1190000013755069
    https://www.jianshu.com/p/04fac9c85fd8

    相关文章

      网友评论

          本文标题:六 .Java内存模型

          本文链接:https://www.haomeiwen.com/subject/ulgeyqtx.html