音视频开发之旅(52) - Java并发编程 之内存模型与volatile

Posted 音视频开发之旅

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了音视频开发之旅(52) - Java并发编程 之内存模型与volatile相关的知识,希望对你有一定的参考价值。

目录

 

  1. JVM内存结构和内存模型

  2. 并发编程中的三个概念与重排序

  3. happens-before原则

  4. volatile原理

  5. volatile使用场景

  6. 资料

  7. 收获

一、JVM内存结构和内存模型

1.1 JVM内存结构

图片来自图书《深入理解Java虚拟机》

Java虚拟机在运行程序时会把其自动管理的内存划分为以上几个区域,每个区域都有的用途以及创建销毁的时机

方法区属于线程共享的内存区域,主要用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。

Java 堆也是属于线程共享的内存区域,它在虚拟机启动时创建,是Java 虚拟机所管理的内存中最大的一块,主要用于存放对象实例,几乎所有的对象实例都在这里分配内存,注意Java 堆是垃圾收集器管理的主要区域

**程序计数器** 属于线程私有的数据区域,是一小块内存空间,主要代表当前线程所执行的字节码行号指示器。

虚拟机栈属于线程私有的数据区域,与线程同时创建,总数与线程关联,代表Java方法执行的内存模型。每个方法执行时都会创建一个栈桢来存储方法的的变量表、操作数栈、动态链接方法、返回值、返回地址等信息。每个方法从调用至结束就对应一个栈桢在虚拟机栈中的入栈和出栈过程

**本地方法栈** 属于线程私有的数据区域,这部分主要与虚拟机用到的 Native 方法相关。

1.2 JVM内存模型

 

图片来自:图书《Java并行编程的艺术》

Java内存模型(即Java Memory Model,简称JMM)本身是一种抽象的概念,并不真实存在,它描述的是一组规则或规范,通过这组规范定义了程序中各个变量(包括实例字段,静态字段和构成数组对象的元素)的访问方式。由于JVM运行程序的实体是线程,而每个线程创建时JVM都会为其创建一个工作内存(有些地方称为栈空间),用于存储线程私有的数据,而Java内存模型中规定所有变量都存储在主内存,主内存是共享内存区域,所有线程都可以访问,但线程对变量的操作(读取赋值等)必须在工作内存中进行,首先要将变量从主内存拷贝的自己的工作内存空间,然后对变量进行操作,操作完成后再将变量写回主内存,不能直接操作主内存中的变量,工作内存中存储着主内存中的变量副本拷贝,前面说过,工作内存是每个线程的私有数据区域,因此不同的线程间无法访问对方的工作内存,线程间的通信(传值)必须通过主内存来完成
引用自:全面理解Java内存模型(JMM)及volatile关键字

二、 并发编程中的三个概念与重排序

2.1 原子性

原子性指的是一个操作是不可中断的,即使是在多线程环境下,一个操作一旦开始就不会被其他线程影响

2.2 可见性

可见性指的是当一个线程修改了某个共享变量的值,其他线程是否能够马上得知这个修改的值
由于线程对共享变量的操作都是线程拷贝到各自的工作内存进行操作后才写回到主内存中的,这就可能存在一个线程A修改了共享变量x的值,还未写回主内存时,另外一个线程B又对主内存中同一个共享变量x进行操作,但此时A线程工作内存中共享变量x对线程B来说并不可见,这种工作内存与主内存同步延迟现象就造成了可见性问题,
另外指令重排以及编译器优化也可能导致可见性问题,通过前面的分析,我们知道无论是编译器优化还是处理器优化的重排现象,在多线程环境下,确实会导致程序轮序执行的问题,从而也就导致可见性问题。

2.3 有序性

程序编译成机器码指令后可能会出现指令重排现象,重排后的指令与原指令的顺序未必一致

重排序

计算机在执行程序时,为了提高性能,编译器和处理器的常常会对指令做重排,一般分以下3种

  • 编译器优化的重排
    编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。

  • 指令并行的重排
    现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性(即后一个执行的语句无需依赖前面执行的语句的结果),处理器可以改变语句对应的机器指令的执行顺序

  • 内存系统的重排
    由于处理器使用缓存和读写缓存冲区,这使得加载(load)和存储(store)操作看上去可能是在乱序执行,因为三级缓存的存在,导致内存与缓存的数据同步存在时间差

重排只会保证单线程中串行语义的执行的一致性,但并不会关心多线程间的语义一致性

三、happens-before原则

《JSR-133:Java Memory Model and Thread Specification》中定义了happens-before的规则,具体如下:

3.1 程序顺序规则

一个线程中的每个操作,happens-before于该线程中的任意后续操作

这里有个疑惑:** 程序顺序规则和编译器的指令重排序不冲突吗?**
今天和朋友讨论了这个问题,发现自己对happens-before的含义没有理解。
其实happens-before关注的是结果上的可见性,而不是执行顺序上的可见性。
引用朋友的一句很到位的话:执行结果没有相关性是一种特殊的可见
下面我们 看下来自如何理解happens-before中的程序顺序规则和编译器的指令重排序?
https://www.zhihu.com/question/65373167/answer/331717849 案例 学习下:

double pi  = 3.14;    //A
double r   = 1.0;     //B
double area = pi * r * r; //C

程序顺序规则
根据happens- before的程序顺序规则,上面计算圆的面积的示例代码存在三个happens- before关系:A happens- before B;
B happens- before C;
A happens- before C;
这里的第3个happens- before关系,是根据happens- before的传递性推导出来的。

这里A happens- before B,但实际执行时B却可以排在A之前执行(看上面的重排序后的执行顺序)。

如果A happens- before B,JMM并不要求A一定要在B之前执行。JMM仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前。这里操作A的执行结果不需要对操作B可见;而且重排序操作A和操作B后的执行结果,与操作A和操作B按happens- before顺序执行的结果一致。在这种情况下,JMM会认为这种重排序并不非法(not illegal),JMM允许这种重排序。

3.2 监视器锁规则

对于一个锁的解锁,happens-before于随后对这个锁的加锁

3.3 volatile变量规则

对于一个volatile变量的写,happens-before于任意后续对这个volatile变量的读

3.4 传递性

如果A happens-before B,且B happens-before C,那么 A happens-before C.

3.5 start()规则

如果线程A执行操作ThreadB.start(启动线程B),那么A线程的ThreadB.start()操作happens-before于线程B的任意操作

3.6 join()规则

如果线程A执行操作ThreadB.join()并成功返回,那么线程B中任意操作happens-before于线程A从ThreadB.join()操作成功返回

四、volatile作用及原理

在程序的执行过程中,涉及到两个方面:指令的执行和数据的读写。其中指令的执行通过处理器来完成,而数据的读写则要依赖于系统内存,但是处理器的执行速度要远大于内存数据的读写,因此在处理器中加入了高速缓存。在程序的执行过程中,会先将数据拷贝到处理器的高速缓存中,待运算结束后再回写到系统内存当中。

4.1 volatile的作用

这样如果在多个线程中多一个变量进行读写,就可能引起可见性的问题。而volatile可以很好的解决可见性和有序性问题。但不能保证原子性。它相比 synchronized 不会引起线程上下文的切换和调度。

那它是如何保证可见性和有序性的呐?

变量声明了volatile,在对该变量进行写操作时,

  1. JVM会向CPU发送一条Lock前缀的指令,将这个变量所在缓存行(CPU高速缓存中可以分配的最小存储单元,一个高速缓冲行通常是64个字节宽)写回到系统内存。

  2. 对于多处理器,为了保证各个处理器的缓存一致性,每个处理器通过嗅探(类似于观察者)在总线上传播的数据来检查自己的缓存是否过期,当处理器发现自己缓存行对应的内存地址被修改,就会将该处理器的高速缓存行设置为无效状态,当需要这个变量的数据时,会重新从系统内存中读取到该处理器的高速缓存中。
    引用自 《Java并发编程的艺术》

volatile在并发编程中很常见,但也容易被滥用
volatile变量带来可见性的保证,还防止了指令重排序。不过这一切是以牺牲优化(消除缓存,直接操作主存开销增加)为代价,所以不应该滥用volatile,仅在确实需要增强变量可见性的时候使用。

4.2 volatile写-读建立的happens-before关系

我们通过《Java并发编程的艺术》中的例子来一起学习下

class VolatileExample {

    int a = 0;
    volatile boolean flag = false;
    
    public void write(){
    
        a = 1;         //1
        flag = true;   //2
    }

    public void reader(){
    
        if(flag) {.      //3
            int i = a;   //4
        }
    }
}

假设线程A执行write方法之后,线程B执行reader方法,根据happens-before规则
关系如下:
**根据程序顺序规则:** 1 happens-before 2; 3 happens-before 4
根据volatile规则: 2 happens-before 3
传递性规则: 1 happens-before 4

 

五、volatile使用场景

5.1 状态标志

线程a执行doWork()的过程中,可能有另外的线程b调用了release,给flag变量添加volatile标记,保证其可见性。

volatile boolean flag;  
   
public void release() {   
    flag = true;   
}  
  
public void doWork() {   
    while (!flag) {   
        // do something
    }  
}  

5.2 一次性安全发布

volatile可以禁止指令重排优化,从而避免多线程环境下程序出现乱序执行的现象。下面看一个非常典型的禁止重排优化的例子双重检测锁的例子

private volatile static Singleton mInstace;     
 
public static Singleton getInstance(){     
 //第一次null检查       
 if(mInstace == null){              
     synchronized(Singleton.class) {           
         //第二次null检查         
         if(mInstace == null){              
             mInstace = new Singleton();  
         }    
     }             
 }    
 return mInstace;  

其中mInstace = new Singleton();可以分为以下3步完成(伪代码)
memory = allocate(); //1.分配对象内存空间
instance(memory); //2.初始化对象
instance = memory; //3.设置instance指向刚分配的内存地址,此时instance!=null

由于步骤2和步骤3间可能会重排序,如下:
memory = allocate(); //1.分配对象内存空间
instance = memory; //3.设置instance指向刚分配的内存地址,此时instance!=null,但是对象还没有初始化完成!
instance(memory); //2.初始化对象

如果mInstace没有加volatile标记,当一条线程访问mInstace不为null时,由于instance实例未必已初始化完成,也就造成了线程安全问题。

5.3 开销较低的“读-写锁”策略

使用 synchronized 确保增量操作是原子的,并使用 volatile 保证当前结果的可见性。如果更新不频繁的话,该方法可实现更好的性能.

public class CheesyCounter {  
     private volatile int value;  
  
    //读操作,没有synchronized,提高性能  
    public int getValue() {   
        return value;   
    }   
  
    //写操作,必须synchronized。因为x++不是原子操作  
    public synchronized int increment() {  
        return value++;  
    }  

5.4 独立观察(independent observation)

public class UserManager {  
    public volatile String lastUser;
  
    public boolean authenticate(String user, String password) {  
        boolean valid = passwordIsValid(user, password);  
        if (valid) {  
            User u = new User();  
            activeUsers.add(u);  
            //赋值操作,不涉及运算操作
            lastUser = user;  
        }  
        return valid;  
    }  
}   

5.5 “volatile bean” 模式

volatile bean 模式的基本原理是:用volatile修饰易变数据容器,该容器中所有的数据成员都是volatile类型(并且 getter 和 setter 方法必须非常普通),放入这些容器中的对象必须是线程安全的。

六、资料

  1. 图书:《java并发编程的艺术》

  2. 图书:《深入理解Java虚拟机》

  3. 深入理解Java内存模型(https://www.infoq.cn/profile/1C70A577591245/publish)

  4. 强烈推荐-全面理解Java内存模型(JMM)及volatile关键字(https://blog.csdn.net/javazejian/article/details/72772461)

  5. 多线程知识梳理(8) - volatile 关键字(https://juejin.cn/post/6844903552301662216)

  6. Java中Volatile关键字详解(https://www.cnblogs.com/zhengbin/p/5654805.html)

  7. Java并发编程:volatile关键字解析(https://www.cnblogs.com/dolphin0520/p/3920373.html)

  8. volatile的使用(https://blog.csdn.net/WSDS\\_MZM/article/details/77838245)

  9. 如何理解happens-before中的程序顺序规则和编译器的指令重排序?(https://www.zhihu.com/question/65373167/answer/331717849)

七、 收获

通过本篇的学习实践

  1. 学习了JVM的内存结构和内存模型

  2. 学习了并发编程的重排序、原子性、可见性、有序性

  3. 学习了happen-before原则

  4. 学习了volatile的原理以及使用场景

感谢你的阅读
下一篇我们继续并发编程系列之 synchronized,欢迎关注公众号“音视频开发之旅”,一起学习成长。
欢迎交流

以上是关于音视频开发之旅(52) - Java并发编程 之内存模型与volatile的主要内容,如果未能解决你的问题,请参考以下文章

音视频开发之旅(53) - Java并发编程 之 synchronized

音视频开发之旅(53) - Java并发编程 之 synchronized

音视频开发之旅(55)-阻塞队列与无锁并发容器

音视频开发之旅(55)-阻塞队列与无锁并发容器

并发编程系列之并发容器:ConcurrentHashMap

精练代码:一次Java函数式编程的重构之旅