JMM、volatie


JMM、volatile

1、JMM内存模型

什么是内存模型: 内存模型可以理解为在特定的操作协议下,对特定的内存或者高速缓存进行读写访问的过程抽象描述(即描述了程序中各个变量(实例域、静态域和数组元素)之间的关系,以及将变量从内存中取出和写入的底层细节),不同架构下的物理机拥有不一样的内存模型,Java虚拟机是一个实现了跨平台的虚拟系统,因此它也有自己的内存模型,即Java内存模型(Java Memory Model, JMM)。

因此它不是对物理内存的规范,而是在虚拟机基础上进行的规范从而实现平台一致性,屏蔽了不同硬件平台和操作系统的内存访问差异,以达到Java程序能够“一次编写,到处运行”(即让Java程序在各种平台下都能达到一致的内存访问效果)。

1)什么是JMM

JMM(Java内存模型Java Memory Model,简称JMM)本身是一种抽象的概念并不真实存在,它描述的是一组规则或规范,通过这组规范定义了程序中各个变量(包括实例字段,静态字段和构成数组对象的元素)的访问方式。

在这里插入图片描述

由于JVM运行程序的实体是线程,而每个线程创建时JVM都会为其创建一个工作内存(有些地方称为栈空间),工作内存是每个线程的私有数据区域,而Java内存模型中规定所有变量都存储在主内存,主内存是共享内存区域,所有线程都可以访问,但线程对变量的操作(读取赋值等)必须在工作内存中进行,首先要将变量从主内存拷贝的自己的工作内存空间,然后对变量进行操作,操作完成后再将变量写回主内存,不能直接操作主内存中的变量,各个线程中的工作内存中存储着主内存中的变量副本拷贝,因此不同的线程间无法访问对方的工作内存,线程间的通信(传值)必须通过主内存来完成

共享变量:

在java中,所有实例域、静态域和数组元素存储在堆内存中,堆内存在线程之间共享(本文使用“共享变量”这个术语代指实例域,静态域和数组元素)。局部变量(Local variables),方法定义参数(java语言规范称之为formal method parameters)和异常处理器参数(exception handler parameters)不会在线程之间共享,它们不会有内存可见性问题,也不受内存模型的影响。

Java 内存模型并没有限制执行引擎使用处理器的寄存器或者高速缓存来提升指令执行速度,也没有限制编译器对指令进行重排序。也就是说,在 java 内存模型中,也会存在缓存一致性问题和指令重排序的问题。

2)并发编程三个特性:

2.1 可见性

当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。

问题:

当某个线程对变量进行修改后,什么时候被写入主存是不确定的,切换到其他线程,操作的可能还是原来的旧值

解决方式:

  • volatile:对于加了volatile关键字的成员变量,在对这个变量进?修改后,会强制将新值刷新到主存,并通过底层的总线嗅探机制告知当前引用该变量的地址缓存失效,其他线程会重新从内存中读取变量,更新缓存的值。

  • 使用synchronized关键字,在同步方法/同步块开始时(Monitor Enter),使用共享变量时会从主内存中刷新变量值到工作内存中(即从主内存中读取最新值到线程私有的工作内存中),在同步方法/同步块结束时(Monitor Exit),会将工作内存中的变量值同步到主内存中去(即将线程私有的工作内存中的值写入到主内存进行同步)。

  • 使用Lock接口的最常用的实现ReentrantLock(重入锁)来实现可见性:当我们在方法的开始位置执行lock.lock()方法,这和synchronized开始位置(Monitor Enter)有相同的语义,即使用共享变量时会从主内存中刷新变量值到工作内存中(即从主内存中读取最新值到线程私有的工作内存中),在方法的最后finally块里执行lock.unlock()方法,和synchronized结束位置(Monitor Exit)有相同的语义,即会将工作内存中的变量值同步到主内存中去(即将线程私有的工作内存中的值写入到主内存进行同步)。

    synchronized的特点 一个线程执行互斥代码过程如下:

    1. 获得同步锁;

    2. 清空工作内存;

    3. 从主内存拷贝对象副本到工作内存;

    4. 执行代码(计算或者输出等);

    5. 刷新主内存数据;

    6. 释放同步锁。

  • final关键字的可见性是指:被final修饰的变量,在构造函数数一旦初始化完成,并且在构造函数中并没有把“this”的引用传递出去(“this”引用逃逸是很危险的,其他的线程很可能通过该引用访问到只“初始化一半”的对象),那么其他线程就可以看到final变量的值。

2.2 原子性

一个操作或者多个操作要么全部执行,并且执行的过程不会被任何因素打断,要么就都不执行。

问题:

如给32位的变量赋值过程不具备原子性。假若一个线程执行到这个语句时,我暂且假设为一个32位的变量赋值包括两个过程:为低16位赋值,为高16位赋值。那么可能当将低16位数值写入之后,突然被中断,而此时又有一个线程去读取i的值,那么读取到的就是错误的数据。

解决方式:synchronized和Lock能保证同一时刻只有一个线程来执行相应操作

2.3 有序性

指令重排序,为了提高程序运行效率,可能会对输入代码进行优化,它不保证程序中各个语句的执行先后顺序同代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的。

在Java内存模型中,允许编译器和处理器对指令进行重排序,有三种类型的重排序(如下),指令重排序不会影响单个线程的执行结果,但多线程环境中线程交替执行,由于编译器优化重排的存在,两个线程中使用的变量能否保证一致性是无法确定的,结果无法预测。

处理器在进行重排序时是会考虑指令之间的数据依赖性,如果一个指令Instruction 2必须用到Instruction 1的结果,那么处理器会保证Instruction 1会在Instruction 2之前执行。

这里所说的数据依赖性仅针对单个处理器中执行的指令序列和单个线程中执行的操作,不同处理器之间和不同线程之间的数据依赖性不被编译器和处理器考虑

问题:

解决方式:

1) 先行发生(happens-before)原则

Java内存模型定义了两个操作之间如果有相互影响,则会使用happens-before原则规定两个操作的顺序,可以在编码中直接使用。

a.程序次序规则(Pragram Order Rule):在一个线程内,按照程序代码顺序,书写在前面的操作先行发生于书写在后面的操作。准确地说应该是控制流顺序而不是程序代码顺序,因为要考虑分支、循环结构。

b.管程锁定规则(Monitor Lock Rule):一个unlock操作先行发生于后面对同一个锁的lock操作。这里必须强调的是同一个锁,而”后面“是指时间上的先后顺序。

c.volatile变量规则(Volatile Variable Rule):对一个volatile变量的写操作先行发生于后面对这个变量的读取操作,这里的”后面“同样指时间上的先后顺序。

d.线程启动规则(Thread Start Rule):Thread对象的start()方法先行发生于此线程的每一个动作。

e.线程终于规则(Thread Termination Rule):线程中的所有操作都先行发生于对此线程的终止检测,我们可以通过Thread.join()方法结束,Thread.isAlive()的返回值等作段检测到线程已经终止执行。

f.线程中断规则(Thread Interruption Rule):对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测是否有中断发生。

g.对象终结规则(Finalizer Rule):一个对象初始化完成(构造方法执行完成)先行发生于它的finalize()方法的开始。

g.传递性(Transitivity):如果操作A先行发生于操作B,操作B先行发生于操作C,那就可以得出操作A先行发生于操作C的结论。

如果两个操作之间的关系不在上述中,它们就没有顺序性保障,虚拟机可以对它们进行随意地重排序。

一个操作”时间上的先发生“不代表这个操作会是”先行发生“,那如果一个操作”先行发生“是否就能推导出这个操作必定是”时间上的先发生 “呢?也是不成立的,一个典型的例子就是指令重排序。所以时间上的先后顺序与happens-before原则之间基本没有什么关系,所以衡量并发安全问题一切必须以happens-before 原则为准。

2)volatile:通过内存屏障指定了一定范围内的操作的顺序性

3)synchronized和Lock:保证每个时刻只有一个线程执行同步代码,相当于是让线程顺序执行同步代码,自然就保证了有序性

综上:要想并发程序正确地执行,必须要保证原子性、可见性以及有序性。只要有一个没有被保证,就有可能会导致程序运行不正确。

2、volatile

三大特征:可见性,不保证原子性,禁止指令重排

1)可见性:

对于加了volatile关键字的成员变量,在对这个变量进?修改后,会强制将新值刷新到主存,并通过底层的总线嗅探机制告知当前引用该变量的地址缓存失效,其他线程会重新从内存中读取变量,更新缓存的值。

import java.util.concurrent.TimeUnit;
?
/**
 * 假设是主物理内存
 */
class MyData {
?
    //volatile int number = 0;
    int number = 0;
?
    public void addTo60() {
        this.number = 60;
    }
}
?
/**
 * 验证volatile的可见性
 * 1. 假设int number = 0, number变量之前没有添加volatile关键字修饰
 */
public class VolatileDemo {
?
    public static void main(String args []) {
?
        // 资源类
        MyData myData = new MyData();
?
        // AAA线程 实现了Runnable接口的,lambda表达式
        new Thread(() -> {
?
            System.out.println(Thread.currentThread().getName() + "\t come in");
?
            // 线程睡眠3秒,假设在进行运算
            try {
                TimeUnit.SECONDS.sleep(3);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            // 修改number的值
            myData.addTo60();
?
            // 输出修改后的值
            System.out.println(Thread.currentThread().getName() + "\t update number value:" + myData.number);
?
        }, "AAA").start();
?
        // main线程就一直在这里等待循环,直到number的值不等于零
        while(myData.number == 0) {}
?
        // 按道理这个值是不可能打印出来的,因为主线程运行的时候,number的值为0,所以一直在循环
        // 如果能输出这句话,说明AAA线程在睡眠3秒后,更新的number的值,重新写入到主内存,并被main线程感知到了
        System.out.println(Thread.currentThread().getName() + "\t mission is over");
?
    }
}

运行结果:

AAA  come in
AAA  update number value:60
//main线程一直在执行没有退出
   
==========================================
 //加了volatile执行结果
AAA  come in
AAA  update number value:60
main     mission is over

分析:

  • 上述程序中,如果number不加volatile,在main和AAA线程读取变量后会将其放到自己CPU的缓存中,由于普通变量不可见,main线程while(myData.number == 0),会一直从缓存中读取该数据,判断,循环,读取,判断,循环,依次往复,即使线程AAA对该变量修改写入了主存,但没有人通知main线程该变量已经改变了。

  • 加了volatile后,当线程AAA修改变量后写入主存,其他线程的缓存行失效,当main线程从缓存中读取数据时发现缓存已经失效,会从主存中重新读取数据,继续执行

2)不能保证原子性:

class MyData2 {
    /**
     * volatile 修饰的关键字,是为了增加 主线程和线程之间的可见性,只要有一个线程修改了内存中的值,其它线程也能马上感知
     */
    volatile int number = 0;
      public void addPlusPlus() {
        number ++;
    }
}
?
public class VolatileAtomicityDemo {
?
    public static void main(String[] args) {
        MyData2 myData = new MyData2();
?
        // 创建10个线程,线程里面进行1000次循环
        for (int i = 0; i < 20; i++) {
            new Thread(() -> {
                // 里面
                for (int j = 0; j < 1000; j++) {
                    myData.addPlusPlus();
                }
            }, String.valueOf(i)).start();
        }
?
        // 需要等待上面20个线程都计算完成后,在用main线程取得最终的结果值
        // 这里判断线程数是否大于2,为什么是2?因为默认是有两个线程的,一个main线程,一个gc线程
        while(Thread.activeCount() > 2) {
            // yield表示不执行
            Thread.yield();
        }
?
        // 查看最终的值
        // 假设volatile保证原子性,那么输出的值应该为:  20 * 1000 = 20000
        System.out.println(Thread.currentThread().getName() + "\t finally number value: " + myData.number);
?
    }
?
}

不能保证原子性分析

number++在多线程下是非线程安全的,number++的过程是非原子的

修改 volatile变量 分为四步:
1)读取volatile变量到local
2)修改变量值
3)local值写回
4)插入 内存屏障 ,即lock指令,让其他线程可见
从上述中前三步都是不安全的,取值和写回之间,不能保证没有其他线程修改,原子性需要锁来保证。
这也就是为什么,volatile只用来保证变量可见性,但不保证原子性。
volatile:从最终汇编语言从面来看,volatile使得每次将i进行了修改之后,增加了一个内存屏障lock addl $0x0,(%rsp)保证修改的值必须刷新到主内存才能进行 内存屏障 后续的指令操作。但是内存屏障之前的指令并不是原子的
代码例子
public static volatile int race = 0;
public static void increase() {
race++;
}
?
字节码:
public static void increase();
Code:
0: getstatic #2  Field race:I
3: iconst_1
4: iadd
5: putstatic #2  Field race:I
8: return
?
?
volatile方式的 i++ ,总共是四个步骤:
?
i++实际为load、Increment、store、Memory Barriers 四个操作。
内存屏障是线程安全的,但是内存屏障之前的指令并不是.在某一时刻线程1将i的值load取出来,放置到 cpu缓存 中,然后再将此值放置到 寄存器 A中,然后A中的值自增1(寄存器A中保存的是中间值,没有直接修改i,因此其他线程并不会获取到这个自增1的值)。如果在此时线程2也执行同样的操作,获取值i==10,自增1变为11,然后马上刷入主内存。此时由于线程2修改了i的值,实时的线程1中的i==10的值缓存失效,重新从主内存中读取,变为11。接下来线程1恢复。将自增过后的A寄存器值11赋值给cpu缓存i。这样就出现了线程安全问题。
?
?
从上述中也可以看出,volatile所谓的可见性是相对于cpu缓存的可见性,寄存器还是不可见的
?
自己遇到的问题:
一、
既然他从主存中读取了最新的11那什么不会把这个值放到寄存器中加一呢,而是把之前的值直接写会主存?
上述问题可分为两点回答:
1)为什么寄存器中的值还在:
这个涉及到MESI缓存一致性协议,为了保证不同缓存的一致性,一旦某个线程执行了修改(Modify)操作,会立刻使其他层级的缓存失效(invaild),然后立刻从主存中读取最新值~协议就是这样规定的
一个线程会有3个操作内存:1主存2缓存3寄存器  只有从缓存读到寄存器这一步才会检查值是否无效。一但读入寄存器,就不会进行检查了,即时已经被无效。
我知道你的疑问在哪里。作者的意思是,一个线程会有3个操作内存:1主存2缓存3寄存器  只有从缓存读到寄存器这一步才会检查值是否无效。一但读入寄存器,就不会进行检查了,即时已经被无效。
2)为什么读取新值后不执行+1操作了
不会自加的,自加这个操作已经完成了,只是cpu缓存的值会更新
二、
第四步插入内存屏障后,会使其他CPU的缓存无效化,这个不是会使其他线程重新刷新缓存,读取volatile变量吗?这样即使过程中线程切换,一旦发生别的线程写回内存,就会重新读取一次变量的情况会发生吗?
回答:不会发生,内存屏障只会让其他线程每次读取强制从主存读取。你还没修改之前别人已经读了,自然没办法刷新。
如果按照你说的,那就不存在原子性问题了,如果一小时、一天前读取了内存里的值,被别人修改了,难道cpu还帮重新读取么?可见性指的是当前可见性,你可以再想想。

解决方法:

1)synchronized

      public synchronized void addPlusPlus() {
        number ++;
    }

2)lock

public class Test {
    public  int inc = 0;
    Lock lock = new ReentrantLock();//创建锁对象
    
    public  void increase() {
        lock.lock();
        try {
            inc++;
        } finally{
            lock.unlock();
        }
    }
    public static void main(String[] args) {
     。。。。。。
    }
}

3)AtomicInteger

public class Test {
    public  AtomicInteger inc = new AtomicInteger();
     
    public  void increase() {
        inc.getAndIncrement();
    }
    
    public static void main(String[] args) {
     。。。。。。
    }
}

3)禁止指令重排

//线程1:
context = loadContext();   //语句1
inited = true;             //语句2
 
//线程2:
while(!inited ){
  sleep()
}
doSomethingwithconfig(context);

语句2可能会在语句1之前执行,那么就可能导致 context 还没被初始化,而线程 2 中就使用未初始化的 context 去进行操作,导致程序出错。

这里如果用 volatile 关键字对 inited 变量进行修饰,就不会出现这种问题了,因为当执行到语句 2 时,必定能保证 语句1执行完毕

进制指令重排原理:

加入 volatile 关键字时,会多出一个 lock 前缀指令

lock 前缀指令实际上相当于一个内存屏障(也称内存栅栏)

内存屏障(Memory Barrier)又称内存栅栏,是一个CPU指令,它的作用有两个:

1.保证特定操作的执行顺序:通过插入内存屏障禁止在内存屏障前后的指令执行重排序优化;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成; 2.保证某些变量的内存可见性(利用该特性实现volatile的内存可见性):会强制将对缓存的修改操作立即写入主存,导致其他 CPU 中对应的缓存行无效,任何CPU上的线程都能读取到这些数据的最新版本

 

 

3、volatile使用场景

synchronized效率低, 但volatile不能保证原子性,通常使用 volatile 必须具备以下 2 个条件:

只要能保证原子性

状态标记量

1)
volatile boolean flag = false; 
while(!flag){
    doSomething();
}
 
public void setFlag() {
    flag = true;
}
2)
volatile boolean inited = false;
//线程1:
context = loadContext();  
inited = true;            
 
//线程2:
while(!inited ){
sleep()
}
doSomethingwithconfig(context);

double check:单列模式的完善

class Singleton{
    private volatile static Singleton instance = null;//加volatile禁止初始化时指令重排
     
    private Singleton() {
         
    }
     
    public static Singleton getInstance() {
        //第一次判空就是优化性能,单例模式第一次启动的时候会创建对象,以后的线程就永远不会走到第二个判空了,只有第一次创建的时候才会锁住,性能提升了很多
        if(instance==null) {
            synchronized (Singleton.class) {
                if(instance==null)
                    instance = new Singleton();
            }
        }
        return instance;
    }
}

 

4、缓存一致性协议

执行i=I+1
可能存在下面一种情况:初始时,两个线程分别读取i的值存入各自所在的CPU的高速缓存当中,然后线程1进行加1操作,然后把i的最新值1写入到内存。此时线程2的高速缓存当中i的值还是0,进行加1操作之后,i的值为1,然后线程2把i的值写入内存。
最终结果i的值是1,而不是2。这就是著名的缓存一致性问题。通常称这种被多个线程访问的变量为共享变量。
也就是说,如果一个变量在多个CPU中都存在缓存(一般在多线程编程时才会出现),那么就可能存在缓存不一致的问题。
为了解决缓存不一致性问题,通常来说有以下2种解决方法:
1)通过在总线加LOCK#锁的方式
2)通过缓存一致性协议
所以就出现了缓存一致性协议。最出名的就是Intel 的MESI协议,MESI协议保证了每个缓存中使用的共享变量的副本是一致的。它核心的思想是:当CPU写数据时,如果发现操作的变量是共享变量,即在其他CPU中也存在该变量的副本,会发出信号通知其他CPU将该变量的缓存行置为无效状态,因此当其他CPU需要读取这个变量时,发现自己缓存中缓存该变量的缓存行是无效的,那么它就会从内存重新读取。

4、暂未懂的问题

缓存一致性协议能保证变量的可见性,这个协议没有应用到JMM模型中吗,缓存一致性协议用在哪里

参考文档:

https://blog.csdn.net/zjcjava/article/details/78406330

https://www.zhihu.com/question/329746124/answer/1758828782?utm_source=qq&utm_medium=social&utm_oi=1300555120547954688

https://www.zhihu.com/question/329746124/answer/1205806238?utm_source=qq&utm_medium=social&utm_oi=1300555120547954688

 

相关