Java内存模型与线程——Java内存模型

 2019-12-10 15:59  阅读(781)
文章分类:Java Core

文章目录

不同架构的物理机,可以拥有不一样的内存模型,而Java虚拟机也有自己的内存模型。Java虚拟机的内存模型是为了屏蔽硬件、操作系统的内存访问差异,让java程序在各种平台上都能达到一直的内存访问效果。

一、主内存与工作内存

1.1 Java内存模型中的变量

Java内存模型的目标是定义程序中各个变量的访问规则。然而这里的变量并不与java语言中的变量一样。这里的变量(实例字段、静态字段、构成数组对象的元素)是指不在栈上的变量,因为栈上的变量是线程共享的。如果局部变量是一个reference类型,它引用的对象在Java堆中,然而reference本身是在Java栈中的局部变量表中的。

1.2 主内存与工作内存

主内存:

java内存模型规定所有的变量都存储在主内存中。需要注意的是这个名字与物理硬件的主内存一样,然而这里说的内存只是虚拟机内存的一部分,比如栈也是虚拟机内存的一部分。虚拟机内存也只是硬件的主内存的一部分。这里可以将java虚拟机的主内存类比与硬件的主内存。

工作内存:

Java虚拟机的每个线程都有自己的工作内存,这个工作内存类比与高速缓存。每个线程对变量的访问修改都是发生在这个工作内存的,而不能之间作用于主内存。

工作内存中保存了使用到的变量的主内存副本拷贝1

线程、主内存、工作内存三者的交互关系

Java线程 工作内存 Java线程 工作内存 Java线程 工作内存 Sava和Load操作 主内存

二、主内存与工作内存间交互操作

主内存与工作内存之间的交互协议,即是怎样将一个变量从主内存拷贝到工作内存中,将一个变量从工作内存中同步会主内存中。这些操作具有原子性(对于double、long在某些平台某些命令上有例外)。

操作 作用于主内存还是工作内存中的变量 说明
lock(锁定) 作用于主内存 把一个变量标识为一个线程独占状态
unlock(解锁) 作用于主内存 把一个被标识为线程独占的变量释放出来
read(读取) 作用于主内存 把一个变量的值从主内存中传输到线程的工作内存中
load(载入) 作用于工作内存 把read读到的变量值放入工作内存的变量副本中
use(使用) 作用于工作内存 它把一个变量的值从工作内存传递到执行引擎中
assign(赋值) 作用于工作内存 把执行引擎收到的值赋值给工作内存中的变量
store(存储) 作用于公共内存 把工作内存中一个变量的值传送到主内存
write(写人) 作用于主内存 将store从中从工作内存中得到的变量的值放入主内存的变量中

需要注意的是,read与load,store与write都是要顺序执行,没有要求要连续执行。

java内存模型还规定了在执行上述8种基本操作时必须满足的规则:

  1. 不允许read和load,store和wirte单独出现。
  2. 不允许一个线程丢弃它的最近的assign操作。
  3. 不运行一个线程无原因(没有发生过任何assign操作)把数据从线程的工作内存同步回主内存中。
  4. 不运行在工作内存中直接使用一个未被初始化(load或assign)的变量。
  5. 一个变量在同一时刻只允许一个线程对其进行lock操作,但lock操作可以被同一条线程重复执行多次
  6. 如果一个变量执行lock操作,那将会清空工作内存中此变量的值,在执行引擎使用这个变量之前,必需要新执行load或assign。
  7. 如果一个变量没有被lock,那就不允许对它执行unlock操作,也不允许unlock一个其他线程lock的变量。
  8. 对一个变量执行unlock之前,必须先把此变量同步回主内存。

上述8访问操作与8种规则,再加上对volatile的一些特殊规定,就已经晚期确定了Java程序中那些内存访问操作在并发下是安全的。

三、对于volatile型变量的特殊规则

关键子volatile可以说是Java虚拟机提供的最轻量级的同步机制。volatile有连个特性——可见性、禁止指令重排序优化

3.1 可见性

可见性:

一个线程对象volatile修饰的变量进行修改后,对于其他线程是立即可知的。

为什么说volatile变量的运算在并发下是不安全的

因为java运算不具有原子性。具有原子性的是机器语言指令。

例子,自增量:

public class Main {
        public static volatile int   race=0;
        private static final   int THREAD_COUNT=20;//同时执行的线程数
        public static void increase(){
            race++;
        }
        public static void main(String[] args) {
            Thread[] threads=new Thread[THREAD_COUNT];
            for(int i=0;i<THREAD_COUNT;i++){
                threads[i]=new Thread(()->{
                    for(int j=0;j<10000;j++){
                        increase();
                    }
                });
                threads[i].start();
            }
            //让所有线程都结束
            while (Thread.activeCount()>1){
                Thread.yield();//让主线程让步一下,是开启的所有子线程都执行完
            }
            System.out.println(race);
        }

    }

结果就有可能不是200000。
20191210001471\_1.png
为什么会这样呢?原因在于自增量运算不具有原子性。我们需要查看自增量的机器码。不过这里查看字节码就可以解答这个问题了

public static void increase();
        descriptor: ()V
        flags: ACC_PUBLIC, ACC_STATIC
        Code:
          stack=2, locals=0, args_size=0
             0: getstatic     #2                  // 获取的到rice结果是正确的
             3: iconst_1
             4: iadd
             5: putstatic     #2                  // Field race:I
             8: return
          LineNumberTable:
            line 7: 0
            line 8: 8

自增量运算就由getstatic、iconst_1、idd、pustatic组成。由于在并发情况下,假如一个线程刚好在iconst_1或iadd时时间片用完,其他线程或得机会修改了race[1],而此时上一个线程有取得机会继续执行,那么[1]这次修改就被作废了。

可以使用volatile的修饰的变量的场景

  1. 运算结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值
  2. 变量不需要与其他的状态变量共同参与不变约束

比如下面这个场景就很适合用,这样就可以让所有线程执行的doword都停止下来。

public class Test {
        volatile boolean shutdownRequested;
        public void shutdown() {
            shutdownRequested=true;
        }

        public void doWork() {
            //比如这里,每一次访问shutdownRequested都需要从主存中获取;而普通变量就有可能只在工作内存中获取
            while(!shutdownRequested) {
                //do stuff
            }
        }
    }

3.2 禁止指令重排序优化

指令重排序

为了充分利用cpu的运算单元,当两个机器指令没有依赖关系时,就可以改变它们的执行顺序,这就做乱序执行。在java虚拟机层面,也有类似的机制——指令重排序优化。

例子

Map configOptions;
    char[] configText;
    volatile boolean initialized=false;

    //假设以下代码在线程A中执行
    configOptions=new HashMap();
    configText=readConfigFile(fileName);
    processConfigOptions(configText,configOptions);
    initialized=true;

    //假设以下代码在B线程中执行
    while(!initialized){
        sleep();
    }
    //使用A线程初始化好的配置信息
    doSomethingWitchConfig();

如果initiliazed没有用volatile修饰,那么如果A线程中发生指令重排序优化,initiallized=true在读取配置文件之间就执行了。那么线程B岂不是就蹦了!!!!

使用volatile可以禁止指令重排序优化。

volatile与其他同步工具的比较

在某些情况下,volatile的同步机制的性能的确由于锁。但是由于虚拟机对锁进行了很多的消除和优化,使得很难判断volatile比synchronized快多少。

volatile自身比较

读肯定快于写啊。读其实与普通变量的性能消耗没多大区别。但写操作就可能会慢一些了,因为它需要在代码中插入许多内存屏障指令来保证处理器不发生乱序执行。

3.3 java内存模型对volatile变量定义的特殊规则

假设用T表示一个线程,用V和W表示两个volatile的 变量,那么在进行read、load、use、assign、store、write操作时,必须满足如下规则:

  1. 对V/W,出现load/read/use时,必须满足read,load,use是连续的。这条规则用于保证每次读取volatile变量时,都是从主存中读取到的最新值。
  2. 对V/W出现assign/store/write时,必须满足assign,store,write是连续的。这条规则用于保证其他线程都可以看到当前线程对volatile变量的修改。就是改变了volatile变量就要里面存到主存去。
  3. 如果一个线程对V,W进行了操作,V先进行的use/assign,那么V就要先执行load/store。

四、8个操作中的例外

之前说8个基本操作都具有原子性,但是在对long、double中两个64位数据的访问可能会访问到半个数据。然而这种情况是非常罕见的。目前商用软件中是不会出现的,所以不要太过在意,只要想其他基本数据类型一样使用就行。

五、原子性、可见性、有序性

原子性:

就是一个操作要嘛做完,要嘛就不做

可见性:

就是在一个线程中对变量的修改,立马反应到其他线程。可以实现可见性的关键字有volatile、final、schonyied。

有序性:

在一个线程中看,代码的执行是顺序性的,在另外一个线程中看这个线程,代码的执行是无序的。可以用volatile、synchronized来保证线程间操作的有序性

六、happens-before与时间先后顺序

先行发生:

如果A先行发生与B,说明在发生操作B之前,操作B能观察到操作A对其产生的影响(如修改内存中共享变量的值、发送了消息、调用了方法)。

Java内存模型规定了一些天然的先行发生关系。如果两个关系之间的操作不能从下面规则及其推倒中得出。则虚拟机可以对他们随意地进行重排序。

规则 说明
程序次序规则 在一个线程中,按控制流顺序在前面的代码先行发生
管程锁定规则 一个unlock操作先行发生于后面(时间上的先后顺序)对同一个锁的lock操作
volatile变量规则 对一个volatile变量的写操作先行发生于后面(时间上的先后顺序)这个变量的读操作。
线程启动规则 Thread对象的start()方法先行发生与此线程的每一个动作
线程终止规则 线程中的所有操作先行发生与此线程的终止检测
线程中断规则 对线程interrupt()方法调用先行发生与被中断线程的代码检测到中断事件的发生。
对象终结规则 一个对象的初始化先行与对象的finlize
传递性 A先行于B,B先行与C,则A先行于C

A先行发生与B,并不意味者A在时间上先发生与B。同样,A在时间上先发生与B,并不意味这A先行发生与B。即时间先后顺序与先行发生原则之间并没有太大的关系
参考
https://docs.oracle.com/javase/specs/jls/se8/html/jls-17.html#jls-17.4.5

  1. 需要注意,如果线程访问的是一个10MB的对象,是不会把这个10MB的对象拷贝出来的,这个对象的引用、对象在某个线程访问到的字段是有可能存在拷贝的,但不会将整个对象拷贝一次。 ↩︎
点赞(0)
版权归原创作者所有,任何形式转载请联系作者; Java 技术驿站 >> Java内存模型与线程——Java内存模型

相关推荐