Java虚拟机 12:Java内存模型

什么是 Java 内存模型快速到底

  Java 虚拟机自己实现了一套内存模型(没有使用物理硬件和操作系统自带的用来屏蔽掉各种硬件和操作系统的访问差异从而实现 Java 程序在各种平台都能达到一致的内存访问效果。主流程序语言(如C/C++等)直接使用物理硬件和操作系统的内存模型,因此,会由于不同平台上内存模型的差异,有可能导致程序在一套平台上并发完全正常,而在另外一套平台上并发访问却经常出错,因此在某些场景下就不许针对不同的平台来编写程序。

主内存和工作内存

  Java 内存模型的主要目的是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样的底层细节。注意一下,此处的变量并不包括局部变量与方法参数,因为它们是线程私有的,不会被共享,自然也不会存在竞争,此处的变量应该是实例字段、静态字段和构成数组对象的元素

  Java 内存模型规定了所有的变量都存储在主内存(Main Memory)中(此处的主内存与介绍物理硬件时的主内存名字一样,两者可以相互类比,但此处仅仅是虚拟机内存的一部分),每条线程还有自己的工作内存(Working Memory,可与前面讲的处理器缓存相类比),线程的工作内存中保存了被该线程使用到的变量和主内存副本拷贝(注意这里绝不会是整个对象的拷贝,试想一个 10M 的对象,在每个用到这个对象的工作内存中有一个 10M 的拷贝,内存还受得了?也就是一些在线程中用到的对象中的字段罢了线程对变量所有的操作(读取、赋值)都必须在工作内存中进行,而不能直接读写主内存中的变量。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成,线程、主内存、工作内存三者的交互关系如图:

内存间相互交互

关于主内存与工作内存之间具体的交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步回主内存之类的实现细节,Java 内存模型中定义了以下 8 种操作来完成,虚拟机实现时必须保证下面提及的每一种操作都是原子的、不可再分的(这 8 个指令是主内存和工作内存之间的交互的指令,注意和 Java 运行时数据区中的栈的操作指令不要搞混了)

1、lock(锁定):作用于主内存中的变量,它把一个变量标识为一条线程独占的状态

2、unlock(解锁):作用于主内存中的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定

3、read(读取):作用于主内存中的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的 load 动作使用

4、load(载入):作用于工作内存的变量,它把 read 操作从主内存中得到的变量值放入工作内存的变量副本中

5、use(使用):作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎(线程执行引擎),每当虚拟机遇到一个需要使用到变量的值的字节码指令时将会执行这个操作

6、assign(赋值):作用于工作内存中的变量,它把一个从执行引擎接收到的赋值给工作内存中的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作

7、store(存储):作用于工作内存中的变量,它把工作内存中一个变量的值传送到主内存中,以便随后的 write 操作使用

8、write(写入):作用于主内存中的变量,它把 store 操作从工作内存中得到的变量值放入主内存的变量中

注意

主内存和工作内存之间的交互其实并不是变量的交互,而是变量的值的交互

如果要把一个变量从主内存复制到工作内存,那就要顺序地执行 read 和 load 操作,如果要把变量从工作内存同步回主内存,就要顺序地执行 store 和 write 操作。注意,Java 内存模型只要求上述两个操作必须按顺序执行,而没有保证是连续执行。也就是说,read 和 load 之间、store 与 write 之间是可以插入其他指令的,如对主内存中的变量 a、b 进行访问时,一种可能出现的顺序是 read a、read b、load b、load a。除此之外,Java 内存模型还规定了在执行上述 8 中基本操作时必须满足如下规则

1、不允许 read 和 load、store 和 write 操作之一单独出现(即不允许一个变量从主内存读取了但还是工作内存不接受,或者从工作内存发起了回写但主内存不接受的情况出现

2、不允许一个线程丢弃它的最近的 assign 操作,即变量(在这里我认为是变量的值发生了改变)在工作内存中改变了之后必须把该变化同步回主内存

3、不允许一个线程无原因地把数据从线程的工作内存同步回主内存中(即变量的值没有发生任何的 assign 赋值操作)

4、一个新的变量只能从主内存中诞生,不允许在工作内存中直接使用一个未被初始化(load 或 assign)的变量

5、一个变量在同一时刻只允许一条线程对其进行 lock 操作,但 lock 操作可以被同一条线程重复执行多次,多次执行 lock 后,只有执行相同次数的 unlock 操作,变量才会被解锁

6、如果对同一个变量执行 lock 操作,那将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行 load 或 assign 操作初始化变量的值

7、如果一个变量事先没有被 lock 操作锁定,那就不允许对它进行 unlock 操作,也不允许去 unlock 一个被其他线程锁定的变量(也就是说是谁锁的就由谁来解锁

8、对一个变量执行 unlock 操作之前,必须先把此变量同步回主内存中(执行 store、write 操作)

volatile 型变量的特殊规则

关键字 volatile 可以说是 Java 虚拟机提供的最轻量级的同步机制。

一个变量被定义为 volatile 后,它将具备两种特性:

1、保证此变量对所有线程的 "可见性",所谓"可见性"是指当一条线程修改了这个变量的值,新值对于其它线程来说都是可以立即得知的(意思就是对 Volatile 变量所有的写操作都能立即反应到其他线程中,注意这里并没有违反上面的“不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成”这一原则,具体它是怎么实现的,见这篇博客,而普通变量不能做到这一点,普通变量的值在在线程间传递均需要通过主内存来完成,关于volatile 关键字的操作请参见 volatile 关键字使用举例,再强调一遍,volatile 只保证了可见性,并不保证基于 volatile 变量的运算在并发下是安全的。

这里有一个问题:有很多人都会这么想(我开始也是这么想的):vloatile 对变量对所有线程都是立即可见的,对 volatile 所有的写操作都能理解反应到其他的线程中,换句话说,volatile 变量在各个线程中都是一致的,所以基于 volatile 变量的运算在并发下是安全的。这句话的论据是正确的,但是并不能够得出“基于 volatile 变量的运算在并发下是安全的”这一结论。首先先说明,volatile 在各个线程的工作内存中也可以存在不一致的情况,但是由于每次使用(这里使用指的是线程引擎执行赋值操作的时候要使用 volatile 变量的值)之前都要进行刷新(刷新操作指的是线程执行引擎)操作,执行引擎看不到一直的情况,因此可以认为 volatile 变量不存在不一致的问题。但是由于 Java 里面的匀速并非原子性操作,导致 volatile 变量的运算在并发情况下一样也会是不安全的,我们可以通过一段简单的代码来说明原因。

public class VolatileTest
{
    public static volatile int race = 0;
    
    public static void increase()
    {
        race ++;
    }
    
    private static final int THREAD_COUNT = 20;
    
    public static void main(String[] args)
    {
        Thread[] threads = new Thread[THREAD_COUNT];
        
        for (int i = 0; i < THREAD_COUNT; i++)
        {
            threads[i] = new Thread(new Runnable()
            {
                
                @Override
                public void run()
                {
                    for (int j = 0; j < 1000; j++)
                    {
                        increase();
                    }
                    
                }
            });
            threads[i].start();
        }
        // 等待所有的累加线程都结束
        while(Thread.activeCount()>1)
            Thread.yield();
        System.out.println(race);
    }
} 

运行结果

19564

结果并不是我们预期的 20000,那么为什么呢?

我们用 Javap 反编译看一下

Compiled from "VolatileTest.java"
public class com.array.VolatileTest {
  public static volatile int race;

  static {};
    Code:
       0: iconst_0      
       1: putstatic     #13                 // Field race:I
       4: return        

  public com.array.VolatileTest();
    Code:
       0: aload_0       
       1: invokespecial #18                 // Method java/lang/Object."<init>":()V
       4: return        

  public static void increase(); 
    Code:
       0: getstatic     #13                 // Field race:I
       3: iconst_1      
       4: iadd          
       5: putstatic     #13                 // Field race:I
       8: return        

  public static void main(java.lang.String[]);
    Code:
       0: bipush        20
       2: anewarray     #25                 // class java/lang/Thread
       5: astore_1      
       6: iconst_0      
       7: istore_2      
       8: goto          37
      11: aload_1       
      12: iload_2       
      13: new           #25                 // class java/lang/Thread
      16: dup           
      17: new           #27                 // class com/array/VolatileTest$1
      20: dup           
      21: invokespecial #29                 // Method com/array/VolatileTest$1."<init>":()V
      24: invokespecial #30                 // Method java/lang/Thread."<init>":(Ljava/lang/Runnable;)V
      27: aastore       
      28: aload_1       
      29: iload_2       
      30: aaload        
      31: invokevirtual #33                 // Method java/lang/Thread.start:()V
      34: iinc          2, 1
      37: iload_2       
      38: bipush        20
      40: if_icmplt     11
      43: goto          49
      46: invokestatic  #36                 // Method java/lang/Thread.yield:()V
      49: invokestatic  #39                 // Method java/lang/Thread.activeCount:()I
      52: iconst_1      
      53: if_icmpgt     46
      56: getstatic     #43                 // Field java/lang/System.out:Ljava/io/PrintStream;
      59: getstatic     #13                 // Field race:I
      62: invokevirtual #49                 // Method java/io/PrintStream.println:(I)V
      65: return        
}

问题就在于上面的 increase() 方法:当 getstatic 指令把 race 的值取到操作栈顶时(这一步是从主内存中取到工作内存中),volatile 关键字保证了此工作线程(取名:a 线程)中取到的一定是最新的 race 的值,但是在执行 iconst_1、add 这些指令的时候,有另一个线程(取名:b 线程)刚刚也拿到了 race 的值,并且执行完了 iconst_1、add 指令,且已经生成了最新的一个值(假设这个值比 race 大),但是这时候 a 线程先将它的值写到主内存中了(b 线程还没有将它的值存储到主内存里面)。所以,b 线程根据缓存一致性就会拿到最新的 race 值(而实际上这个值是由 a 线程写进去的小的值),所以,这就造成了上面的问题。

所以,总结起来就是:volatile 并不能够保证线程安全,它只是能够保证每一次线程执行引擎拿到的这个 volatile 变量值都是主内存中最新的值。

由于 volatile 只能够保证可见性,在不符合以下两条规则运算中的场景中,我们仍然要通过加锁(使用 synchronized 或 java.util.concurrent 中的原子类)来保证原子性。

  • 运算结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值。
  • 变量不需要与其他的状态变量共同参与不变约束。(这句话是啥意思?)

而像下面这种情况就特别适合 volatile 变量来控制并发,当 shutdown() 方法被调用时,能保证所有线程中执行的 doWork() 方法都立即停下来

volatile boolean shutdownRequested;
    public void shutdown()
    {
        shutdownRequested = true;
    }
    
    public void doWork()  // 假设现在所有的线程都在执行 doWork() 这个方法,此时只有一个线程执行了 shutdown() 方法(执行该方法的线程引擎将 true 写回了主内存),那么此时所有的工作内存中的 shutdownRequested 的值都会变成 true
    {
        while(!shutdownRequested)  
        {
            // doStuff
        }
    }

对上面的 “变量不需要与其他的状态变量共同参与不变约束” 的解释

public class NumberRange {
    private volatile int lower, upper;
public int getLower() { return lower; } public int getUpper() { return upper; }
public void setLower(int value) { if (value > upper) throw new IllegalArgumentException(...); lower = value; } public void setUpper(int value) { if (value < lower) throw new IllegalArgumentException(...); upper = value; } }

在这里有一个不变约束:下界总是小于或等于上界

这种方式限制了范围的状态变量,因此将 lower 和 upper 字段定义为 volatile 类型不能够充分实现类的线程安全,从而仍然需要使用同步。例如,如果初始状态是 (0, 5),同一时间内,线程 A 调用 setLower(4) 并且线程 B 调用 setUpper(3),显然这两个操作交叉存入的值是不符合条件的,那么两个线程都会通过用于保护不变式的检查,使得最后的范围值是 (4, 3),这显然是不对的。也就是说,最好这个变量是它自己玩自己,不要依赖于其他变量,一旦依赖就有可能受制于其他变量的条件约束,就有可能出问题。

2、使用 volatile 变量的第二个语义是禁止指令重排序优化,普通变量仅仅会保证在该方法的执行过程中所有依赖赋值结果的地方都能获取到正确的结果,而不能保证变量赋值操作的顺序与程序代码中的执行顺序一致

接下来我们看一个例子说明指令重排为什么会干扰程序的并发执行,演示程序如图所示:

Map configOptions;
char[] configText;
// 此变量必须定义为 volatile
volatile boolean initialized = false;

// 假设以下代码在线程 A 中执行
// 模拟读取配置信息,当读取完成后将 initialized 设置为 true 以通知其他线程配置可用
configOptions = new HashMap();
configText = readConfigFile(fileName);
processConfigOptions(configText,configOptions);
initialized = true;

// 假设以下代码在线程 B 中执行
// 等待 intialized 为 true,代表线程 A 已经把配置信息初始化完成
while(!initialized)
{
    sleep();
}
// 使用线程 A 中初始化好的配置信息
doSomethingWithConfig();

上面是一段伪代码,其中 场景十分常见,只是我们在配置文件时一般不会出现并发。如果定义 initialized 变量时没有使用 volatile 修饰,就可能会由于指令重排的优化导致位于线程 A 中最后一句的代码 "initialized = true" 被提前执行(这里虽然是使用 Java 伪代码,但所指的重排序优化是机器级的优化操作,提前执行是指这句话对应的汇编代码被提前执行),这样在线程 B 中使用配置信息的代码就可能出现错误而 volatile 关键字则可以避免此类情况的发生。

在分析 volatile 为什么能够禁止指令重排的发生之前,我们先弄明白为什么 CPU 要进行指令重排?
首先 CPU 是允许将多条指令不按程序规定的顺序分开发送给各相应的电路单元处理。但并不是说指令任意重排序,CPU 需要能正确处理指令依赖情况以保证程序能得出正确的执行结果。譬如指令 1 把地址 A 中的值加 10,指令 2 把地址 A 中的值乘以 2,指令 3 把地址 B 中的值减去 3,这时候指令 1 和指令 2 是有所依赖的。它们之间的顺序不能重排,但是指令 3 可以重排到指令 1、2之前或者是中间,只要保证 CPU 执行后面依赖到 A、B 的值的操作时能获得正确的 A 和 B 值即可。

我们来看一下使用 volatile 关键字下的 DCL 方式实现的单例设计模式在 JDK1.5 之前为什么是线程不安全的

public class Singleton {
    private volatile static Singleton instance = null;
    private Singleton(){
 
    }
    public static  Singleton getInstance(){
        if(instance == null){
            synchronized(Singleton.class){
                if(instance == null){
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }

在 JDK1.5 之前,java 的内存模型允许 out-of-writer,具体如下:
(1)java 的 new不是原子的,具体在更细的层面还是有诸多步骤。

  1、 分配新对象的内存

  2、调用类的构造器,初始化成员字段

  3、 instance 被赋为指向新的对象的引用。

说白了,JDK1.5 之前的 volatile 关键字不能保证有修饰的对象构造内部是有序的(也就是被修饰的变量所对应的 new 对象的这一操作是完全屏蔽指令重排的)。线程 A 发现 instance 没有被实例化,它获得锁,然后去实例化该对象,JVM 容许在没有完全实例化完成的时候,将实例的指针赋给这个 instance 变量,而此时 instance==null 就为 false了,在初始化完成之前,线程 B 进入此方法,发现 instance 不为空,认为已经初始化完成了,于是便使用了这个尚未完全初始化的实例对象,可能引起其他的异常。按道理来说,volatile 应该能够保证 new 对象这个操作中的所有的指令都禁止指令重排,但是它在 JDK1.5 之前没有做到。

 

总结一下 Java 内存模型对 volatile 变量定义的特殊规则:

1、当执行引擎每次使用工作内存中的某个 volatile 变量的时候都必须线从主内存刷新最新的值,用于保证能看见其他线程对该变量所做的修改之后的值

2、在工作内存中,每次修改完某个变量后都必须立刻同步回主内存中,用于保证其他线程能够看见自己对该变量所做的修改

3、volatile 修饰的变量不会被指令重排序优化,保证代码的执行顺序与程序顺序相同

猜你喜欢

转载自www.cnblogs.com/tkzL/p/8927101.html