Java内存模型(一)

Java虚拟机规范中试图定义一种Java内存模型(Java Memory Model,JMM)来屏蔽各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果。

一、主内存与工作内存

Java内存模型的主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样的底层细节。

此处的变量(Variables),不同于程序中的变量,它包括实例字段、静态字段和构成数组对象的元素但不包括局部变量和方法参数。因为后者是线程私有的,不会被共享

如果局部变量是一个reference类型,它引用的对象在Java堆中可被各个线程共享,reference本身在Java栈的局部变量表中,它是线程私有的。

为了获的较好的执行效能,Java内存模型并没有限制执行引擎使用处理器的特定寄存器或缓存来和主内存进行交互,也没有限制即时编译器进行调整代码执行顺序这类的优化。

Java内存模型规定了所有的变量都存储在主内存(Main Memory)中,(此主内存只是虚拟机内存的一部分,并非物理硬件的主内存)。每条线程还有自己的工作内存(Working Memory)(类似于物理硬件的高速缓存),线程的工作内存中保存了被该线程使用到的变量的主内存副本拷贝,线程对变量的所有操作(读取、赋值)都必须在工作内存中进行,而不能直接读写主内存中的变量。

线程间变量传递通过主内存来完成。

此处的内存模型和虚拟机运行时内存区域没有关系。

二、内存间交互

Java内存模型中定义了以下8种操作来完成“一个变量从主内存拷贝到工作内存、从工作内存同步回主内存之类的细节实现”。以下的每种操作必须是原子的、不可再分的。

  • lock(锁定):作用域主内存的变量,它把一个变量表示为一条线程独占的状态
  • unlock(解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定
  • read(读取):作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用
  • load(载入):作用于工作内存的变量,它把read操作从主内存中的到的变量值放入工作内存的变量副本
  • use(使用):作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用到变量的值的字节码指令时将会执行这个操作
  • assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的赋值给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作
  • store(存储):作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随后的write操作使用
  • write(写入):作用于主内存的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中

Java内存模型要求store、writeread、load两组操作必须按顺序执行,但没有要求必须是连续执行(中间可以穿插其他操作)。

Java内存模型还规定了执行上述8个操作时必须满足如下规则:

  • 不允许read和load、store和write操作之一单独出现,即不允许一个变量从主内存读取了但工作内存不接受,或者从工作内存发起了写会但主内存不接受的情况出现
  • 不允许一个线程丢弃它的最近的assign操作,即变量在工作内存中改变了之后必须把该变化同步回主内存
  • 不允许一个线程无原因的(没有发生过任何assign操作)把数据从线程的工作内存同步回主内存=-
  • 一个新的变量只能在主内存中“诞生”,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量
  • 一个变量在同一时刻只允许一条线程对其进行lock操作,但lock操作可以被同一条线程反复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁
  • 如果对一个变量执行lock操作,那将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行load或assign操作初始化变量的值
  • 如果一个变量实现没有被lock锁定,那就不允许对它执行unlock操作,也不允许去unlock一个被其他线程锁住的变量
  • 对一个变量执行unlock操作时,必须先把此变量同步回主内存中(执行store、write操作)

三、volatile变量的特殊规则

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

1) 保证此变量对所有线程的可见性

当一个变量定义为volatile之后,它将具备两种特性,第一是保证此变量对所有线程的可见性,这里的“可见性”是指当一条线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的。而不同变量不能做到这点,普通变量的值在线程间传递均需要通过主内存来完成。

volatile变量在各个线程的工作内存中不存在一致性问题(每次使用前都刷新,执行引擎看不到不一致的情况),但是Java里面的运算并非原子操作,导致volatile变量的运算在并发下一样是不安全的

//volatile变量自增运算
public class VolatileTest {
    //定义volatile变量
    public static volatile int race = 0;

    //自增操作
    public static void increase(){
        race++;
    }

    //开启线程数
    private static final int THREADS_COUNT = 20;

    public static void main(String[] args) {
        Thread[] threads = new Thread[THREADS_COUNT];
        //遍历线程数组,创建新线程
        for (int i = 0; i < threads.length; i++) {
            threads[i] = new Thread(new Runnable() {
                @Override
                public void run() {
                    //循环自增volatile变量
                    for (int i = 0 ; i <10000 ; i++){
                        increase();
                    }
                }
            });
            //开启线程
            threads[i].start();
        }

        //等待所有线程累加结束
        while (Thread.activeCount() > 2){
            /**
             * 使用IDEA编译器时,编译器会额外开启一个名为main的线程(主线程守护线程)
             * 当使用其他eclipse或直接使用cmd编译执行时,不会额外开启守护线程
             * 这时线程组的判断条件为1
             * */
            //while (Thread.activeCount() > 1){
            
            /*当前线程的线程组中的数量大于2/1时,当前线程放弃cpu*/
            Thread.yield();
        }
        System.out.println(race);
    }
}

这里有必要说明一下,如果没有阅读类加载过程(三)最后一节的读者,直接阅读到这里时,可能会对笔者的代码和执行结果抱有质疑,这是因为笔者使用的开发工具和为IDEA,其中区别,详见:

杂谈——IDEA与Eclipse的线程开辟区别


运行了上述代码之后,会发现结果并非预想中的200000,而是一个小于正确结果的数字。

使用javap获得字节码文件

出现错误结果的原因是,在getstatic指令把race的值取到操作栈顶时,volatile关键字保证了race值的正确,但是,在后续的iconst_1、iadd指令执行时,其他线程可能已经修改了race的值,这时操作栈顶的值就变成了国企的数据,所以putstatic指令可能把较小的值同步回主内存。

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

  1. 运算结果并不依赖当前变量的值,或者能确保只有单一的线程修改变量的值
  2. 变量不需要与其他的状态变量共同参与不变约束
2) 禁止指令重排序优化

使用volatile变量的第二个语义是禁止指令重排序优化,不同的变量仅仅会保证在该方法的执行过程中所有依赖赋值结果的地方都能获取到正确的结果,而不能保证变量赋值操作的顺序与程序代码一致。因为在一个线程的方法执行过程中无法感知到这点,这也就是Java内存模型中描述的所谓的“线程内表现为串行的语义”(Within-Thread As-If-Serial Semantics)。

对于学习过“操作系统”这门课程的读者应该很好理解上述概念,对于没有学习过的读者可以理解为在线程假并行(真并发)的过程中,由于不同线程抢占CPU的的原因导致原本的主线程程序代码没有按照既定的顺序执行,导致运行结果错误,然而在每个子线程中程序的运行顺序确是既定不变的

由于这个问题相当经典,此处不再给出对应代码,有兴趣的读者可以自行学习“操作系统”课程,或者使用《深入理解Java虚拟机》中提供的代码进行调试。

3)volatile的特殊规则

假定T表示一个线程,V和W分别表示两个volatile变量,那么在进行read、load、use、assign、store和write操作时需要满足以下条件

  • 只有当线程T对变量V执行的前一个动作是load的时候,线程T才能对变量V执行use动作;并且只有当线程T对变量V执行的后一个动作是use时,线程T才能对变量V执行load动作。线程T对变量V的use动作可以认为是和线程T对变量V的load、read动作相关联,必须连续一起出现(要求工作内存中每次使用V前都必须从主内存刷新最新的值)
  • 只有当线程T对变量V执行的前一个动作是assign时,线程T才能对变量V执行store动作:并且,只有当线程T对变量V 执行的后一个动作是store时,线程T才能对变量V执行assign动作。新城T对变量V的assign动作可以认为是和线程T对变量V的store、write动作相关联,必须连续一起出现(要求工作内存中,每次修改V后都必须立刻同步会主内存)
  • 假定动作A是线程T对变量V实施的use或assign动作,假定动作F是和动作A相关联的load或store动作,假定动作P是和动作F相应的对变量V的rea或write动作;类似的,假定动作B是线程T对变量V实施的use或assign动作,假定动作F是和动作B相关联的load或store动作,假定动作Q是和动作G相应的对变量V的rea或write动作 。如果A优先于B,那么P优先于Q(要求volatile修饰的变量不会被指令重排序优化)

四、对于long和double型变量的特殊规则

Java内存模型要求lock、unlock、read、load、assign、use、store、write这8个操作都具有原子性,但是对于long和double可以不保证load、store、read和write这4个操作的原子性(即long和doubl的非原子性协定(Nonatomic Treatment of double and long Variables))。

如果有多个线程共享一个未声明为volatile的long或double型变量,并且同时对其进行读取和修改操作,那么某些线程可能读到“半个变量(32位)”的数值。虽然这种情况很罕见,但我们在编码时仍对long和double值一般不做volatile声明。