icode icode
首页
  • Android学习

    • 📁基础内容
    • 📺AndroidCore
    • 🎨Android-UI
    • 🏖️Components
    • 📊Fragment
    • 🔗网络操作
    • 🔏异步机制
    • 📦数据存储
    • 🗃️Gradle
  • 学习笔记

    • 『框架』笔记
    • 『Kotlin』笔记
    • 《Vue》笔记
    • 《Git》学习笔记
    • 『Bug踩坑记录』
  • ListView
  • RecyclerView
  • ViewPager
  • Java笔记

    • 🟠JavaSE
    • 🟢JavaWeb
    • 🔴JavaEE
    • ⚪JavaTopic
    • 🍳设计模式
  • 计算机基础

    • 📌计算机网络
    • 🔍数据结构
    • 📦数据库
    • 💻OS
  • 技术文档
  • GitHub技巧
  • Nodejs
  • 博客搭建
  • 学习
  • 面试
  • 心情杂货
  • 实用技巧
  • 友情链接
  • 关于

    • 📫关于我
  • 收藏

    • 网站
    • 资源
    • Vue资源
  • 分类
  • 标签
  • 归档
GitHub (opens new window)

iqqcode

保持对技术的探索实践与热爱
首页
  • Android学习

    • 📁基础内容
    • 📺AndroidCore
    • 🎨Android-UI
    • 🏖️Components
    • 📊Fragment
    • 🔗网络操作
    • 🔏异步机制
    • 📦数据存储
    • 🗃️Gradle
  • 学习笔记

    • 『框架』笔记
    • 『Kotlin』笔记
    • 《Vue》笔记
    • 《Git》学习笔记
    • 『Bug踩坑记录』
  • ListView
  • RecyclerView
  • ViewPager
  • Java笔记

    • 🟠JavaSE
    • 🟢JavaWeb
    • 🔴JavaEE
    • ⚪JavaTopic
    • 🍳设计模式
  • 计算机基础

    • 📌计算机网络
    • 🔍数据结构
    • 📦数据库
    • 💻OS
  • 技术文档
  • GitHub技巧
  • Nodejs
  • 博客搭建
  • 学习
  • 面试
  • 心情杂货
  • 实用技巧
  • 友情链接
  • 关于

    • 📫关于我
  • 收藏

    • 网站
    • 资源
    • Vue资源
  • 分类
  • 标签
  • 归档
GitHub (opens new window)
  • JavaSE

    • 面向对象

    • 常用API

    • 集合类

    • 多线程

      • Thread

      • JUC

        • JUC包下的常用工具类
        • JUC包下常用的辅助类
        • 阻塞队列,同步队列
        • Java内存模型JMM详解
          • 【引言】
          • 1. 多核并发缓存架构
            • CPU缓存
            • JMM内存模型简介
          • 2. JMM内存模型验证
            • volatile验证内存模型
            • JMM内存模型8大原子操作
          • 3. JMM缓存不一致问题
            • 总线加锁
            • MESI缓存一致性协议
          • 4. volatile可见性底层实现原理
            • 保证可见性原理验证
          • 5. volatile不保证原子性
            • 原子性
            • 保证原子性
            • volatile不保证原子性验证
            • 【问题解决】
          • 6. volatile保证有序性
            • volatile禁止指令重排序
            • 内存屏障简介
            • 读写屏障
            • JMM的Happens-Before原则
        • volatile
        • CAS自旋
        • CAS与volatile无锁解决并发
        • AQS
        • 线程池
        • Future接口
    • JDK8新特性

    • IO流

    • JVM

  • JavaWeb

  • JavaEE

  • JavaTopic

  • 设计模式

  • 计算机基础

  • Java后端
  • JavaSE
  • 多线程
  • JUC
iqqcode
2021-06-17
目录

Java内存模型JMM详解

# 【引言】

这一切的一切,还得从一个叫volatile的关键字说起

【灵魂拷问开始】

  1. 面试官:Java并发这块了解的怎么样?说说你对volatile关键字的理解?

  2. 面试官:能不能详细说下什么是内存可见性,什么又是指令重排序呢?

  3. 面试官:volatile怎么保证可见性的?多个线程之间的可见性,你能讲一下底层原理是怎么实现的吗?

  4. 面试官:volatile关键字是怎么保证有序性的?

  5. 面试官:volatile能保证可见性和有序性,但是能保证原子性吗?为什么?

  6. 面试官:了解过JMM内存模型吗?简单的讲讲

到这里,我的眼里已是常含泪水了。不是因为我对代码爱的深沉,而是因为我菜的真诚!

没事,不就是个破volatile吗?别念了,我学习还不行吗!

PS: 文章的内容是我看视频,博客,查资料的理解。在这一块可能很多人的理解有所不同,小编我尚无工作经验,只是总结研究来学习,做个面试题的记录。文章内容从理解到查资料学习再到画图写出来,肝了挺长时间的吧。大家当做一篇面筋来看就好,主要是回答面试问题,至于深入到底层通过字节码汇编等来通过代码说明,俺还在研究中。本文只是比较浅显的发现问题,解决问题的。不做实际的工作开发。如有不正请立即指出。


# 1. 多核并发缓存架构

缓存Cache设置的目的是为了解决磁盘和CPU速度不匹配的问题。但是,对于CPU来说,Cache还是不够快,缓存的概念再次被扩充,不仅在内存和磁盘之间也有Cache(磁盘缓存),而且在CPU和主内存之间有Cache(CPU缓存),乃至在硬盘与网络之间也有某种意义上的Cache──称为Internet (opens new window)临时文件夹或网络内容缓存等。凡是位于速度相差较大的两种硬件 (opens new window)之间,用于协调两者数据传输速度差异的结构,均可称之为Cache。

# CPU缓存

CPU缓存(Cache Memory)是位于CPU与内存之间的临时存储器,它的容量比内存小的多。但是交换速度却比内存要快得多。缓存大小是CPU的重要指标之一,而且缓存的结构和大小对CPU速度的影响非常大,CPU内缓存的运行频率极高,一般是和处理器同频运作,工作效率远远大于系统内存和硬盘。

CPU缓存可以分为三级:

一级缓存L1

一级缓存(Level 1 Cache)简称L1 Cache,位于CPU内核的旁边,是与CPU结合最为紧密的CPU缓存。一般来说,一级缓存可以分为一级数据缓存(Data Cache,D-Cache)和一级指令缓存(Instruction Cache,I-Cache)

二级缓存L2

L2 Cache(二级缓存)是CPU的第二层高速缓存,分内部和外部两种芯片。内部的芯片二级缓存运行速度与主频相同,而外部的二级缓存则只有主频的一半。L2高速缓存容量也会影响CPU的性能,原则是越大越好。

三级缓存L3

三级缓存是为读取二级缓存后未命中的数据设计的—种缓存,在拥有三级缓存的CPU中,只有约5%的数据需要从内存中调用,这进一步提高了CPU的效率。

任务管理器查看CPU缓存使用情况:

所以说,在我们的程序执行时,在CPU和Cache之间,是通过CPU缓存来做交互的。CPU从CPU缓存读取数据,CPU缓存从内存中读取数据;CPU将计算完的数据写回到CPU缓存中,然后CPU缓存再同步回内存中,内存再写回到磁盘中。

# JMM内存模型简介

JMM(Java Memory Model), 是Java虚拟机平台对开发者提供的多线程环境下的内存可见性、是否可以重排序等问题的无关具体平台的统一的保证。

JMM定义了一个线程与主存之间的抽象关系,它就像我们的数据结构中的逻辑结构一样,只是概念性的东西,并不是真实存在的,但是能够让我们更好的理解多线程的底层原理。

首先,一定要先明确一个概念:CPU的运算是非常非常快的,和其他硬件不在一个量级上。

Java内存模型类比于上面硬件的内存模型,它是基于CPU缓存模型来构建的。

每一个线程在操作共享变量的时候,都将共享变量拷贝一份到自己的工作区间中(因为如果多个线程同时在内存中操作数据,就像CPU与内存直接交互一样,速度非常慢),等到当前线程的CPU运算完之后,在写回主内存。

如果此时一个共享变量发生了改变,为了保证数据一致性,就必须立刻通知其他线程这个共享变量的值发生了改变,让其他线程工作内存中的副本更新,保证拿到的数据是一致的。

在这通知之间,线程之间就必然会有联系和沟通

就好比两个人同时拿着同一张银行卡到银行取钱,卡里有100块,一个人取了50,账户余额立即就变成了50。虽然两人是同时同步并行取钱,但是第二个人是在这50的基础上来取钱的,不可能还在100的基础上取钱。

那么,Java是怎么保证银行卡的余额立即变为50,并且是做了什么操作来保证余额的正确性呢?

# 2. JMM内存模型验证

# volatile验证内存模型

来,整一段代码再唠......

/**
 * @Author: Mr.Q
 * @Date: 2020-06-10 09:47
 * @Description:JMM内存模型验证--volatile保证可见性测试
 */
public class VolatileVisibilityTest {

    //此处是否添加volatile,来验证内存模型
    private static boolean initFlag = false; 

    public static void main(String[] args) throws InterruptedException {
        new Thread(() -> {
            System.out.println("等待数据中....");
            while (!initFlag) {

            }
            System.out.println("--------------success-----------");
        }).start();

        Thread.sleep(3000);

        new Thread(() -> {
            prepareData();
        }).start();
    }

    public static void prepareData() {
        System.out.println("\n准备数据中....");
        initFlag = true; //此处为第30行代码
        System.out.println("initFlag = " + initFlag);
        System.out.println("数据准备完成!");
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33

首先,一个线程在等待数据,initFlag初始值为false,!initFlag进入到死循环中卡在此处。

另一个线程准备数据,将initFlag置为true。

由于是静态的成员共享变量,修改之后等待的线程能够感知到,此时跳出死循环,打印信息,程序运行结束。

但是,真的是这样吗?

我们发现并没有,此时程序依然处于死循环中,即initFlag依然为false

咦,这是怎么肥四呢?

单线程下跑,是没有问题的。可这是在多线程中,问题就来了。

这也就间接验证了JMM的存在,即每个线程在工作时,都会将共享数据拷贝到自己的工作内存来操作。如果不是的话,此处多线程下执行也不会出现问题。

这时,那个男人,那个叫volatile的蓝人,它基情满满的向我们走来了!

共享变量不一致是吧?操作没了可见性是吧?来吧,这种小事就交给我吧宝贝,么么哒😘

我们想要达到这样的效果:

private volatile static boolean initFlag = false;
1

用volatile修饰initFlag变量,只要有线程做了修改,其他线程立即可以感知。

正确的运行结果,让打印出成功信息。

问题是解决了。这时,面试官不厚道的笑了🙃。

# JMM内存模型8大原子操作

8大原子操作大家可能都有了解,但是具体到在底层是怎么交互的?每个原子操作之间的关系是怎样的?并没有很详细的说明。

那么,我们通过上面的程序来具体做个底层原理的分析,这也是能够讲清楚volatile关键字保证可见性最直观的说明了!

【JMM内存模型8大原子操作】

  • read读取: 从主内存中读取数据

  • load载入: 将主内存读取到的数据写入工作内存

  • use使用: 从工作内存读取出数据来计算

  • assign赋值: 将CPU计算出的值重新赋值到工作内存中

  • store存储: 将工作内存中更改后的值写入到主存

  • write写入: 将store回去的变量赋值给主存中的变量

  • lock锁定: 将主内存变量加锁,标识为线程独占状态

  • unlock解锁: 将主内存变量解锁,解锁后其他线程才能再次锁定该变量

还是上面程序的代码,针对上述程序出现的问题,我们来做个深入的分析了解:有图有真相😒

我们先来分析【线程1】:

  1. 首先,线程1将主内存中的initFlag = false read出来;

  2. 其次,将initFlag = false 拷贝一份到线程的工作内存中;

  3. 然后,CPU将线程工作内存(CPU缓存)中的数据拿到自己的寄存器中来计算。

此时,!initFlag为真,线程1阻塞在死循环中,等待数据中......

对于【线程2】:

  1. 前三步完全和线程1的操作一样,每个线程都是这么干的.

  2. 线程2中调用了prepareData方法使initFlag = true

  3. 然后CPU将改变后的值重新赋值到工作内存中,此时线程2的工作内存中initFlag = true

  4. 线程2的工作内存存储了true,并准备更新回主存中

  5. 线程2执行write操作,将initFlag = true写回到主存中

此时,主存中存放的是initFlag = true。而线程1的工作内存中任然是initFlag = false。就是线程2把initFalg改了,线程1还不知道,仍然拿的是原来的值,导致程序一直处在死循环中。这就是程序为什么卡在了这里的原因!

那后来加上了volatile关键字,它是怎么保证线程2改完initFlag后,线程1立马就知道了呢?换句话来说,线程2更改完initFlag后,是怎么让线程1的工作内存中拷贝的副本也立即更新呢?

# 3. JMM缓存不一致问题

就像上面图解的情况一样,JMM出现了缓存不一致新的问题,即线程2修改完initFlag之后,线程1工作内存中的副本和主存中不一致的问题。

那么,大佬们是如何解决这个问题的呢?

8个原子操作,这不还剩lock和unlock么!他俩呀,就干这事的!

# 总线加锁

起初,是通过对数据在总线上加锁来实现的:

一个线程在修改数据时,会加一把lock锁到总线上。此时,其他线程就不能再去读取数据了,等到线程2将数据修改完写回到主存,然后unlock释放锁,然后其他线程才能够读取。

这样,当然保证了其他线程拿到了最新的数据,数据一致性得到保证了,但是多核并行的操作,在加锁之后变成了单核串型的了,效率低下。就这样的速度,能叫并发吗?这还怎么过双十一呀🤣!

# MESI缓存一致性协议

MESI协议 (opens new window)

多个CPU从主内存读取同一个数据到各自的高速缓存,当其中某个CPU修改了缓存里的数据,该数据会马上同步回主内存,其它CPU通过总线侦听机制可以感知到数据的变化,从而将自己缓存里的数据失效。

总线侦听:

当几个缓存共享特定数据并且处理器修改共享数据的值时,更改必须传播到所有其他具有数据副本的缓存中。这种变化的传播可以防止系统违反高速缓存一致性 (opens new window)。可以通过总线侦听来完成数据更改的通知。所有侦听器都会监视总线上的每笔交易。如果修改共享缓存块的事务出现在总线上,则所有侦听器都会检查其缓存是否具有共享块的相同副本。如果缓存具有共享块的副本,则相应的窥探器将执行操作以确保缓存一致性。该动作可以是刷新 (opens new window)或无效 (opens new window)缓存块。它还依赖于缓存一致性协议来改变缓存块状态。

MESI缓存一致性协议,通过对总线的侦听机制,很好地解决了这个问题。

没错,硬件!就是这么硬核且高效。

【简单总结一下】:

总线上安装了多个监听器,发现有线程修改了内存中的数据,就会使其他线程工作区间不一致的副本立即失效,然后让他们重新并行读取。


# 4. volatile可见性底层实现原理

上面讲了硬件层面上的实现,那么,软件上是怎么实现的呢?

有了总线监听器,我们可以检测到线程修改数据的行为。但是,线程2修改了数据,监听器也检测到了,线程1是怎么知道并且修改的呢?

我们都知道,线程间各自工作都是独立的,线程2修改了数据,并不会告诉线程1我修改了。数据都在内存上,大家共有的,我修改凭什么要告诉你😒?换句话来说,他们都是通过主存来沟通交互的。

那么,volatile关键字是怎么保证修改的可见性的呢?

volatile的代码是用更加底层的C/C++代码来实现的

底层的实现,主要是通过汇编lock前缀指令,它会锁定内存区域的缓存(缓存行锁定),并写回到主内存中。

# 保证可见性原理验证

我们对程序做反汇编,查看汇编代码:

由于汇编代码比较长,虽然俺学了微机原理,但真的是看不懂😭。就挑最重要的一句摘录出来解释

0x0000000002c860bf:lock add dword ptr [rsp], Oh ; *putstatic initFlag 
iqqcode.jmm.VolatileVisibilityTest::prepareData@1 (line 30)
1
2

对应代码为

initFlag = true;
1

A-32架构软件开发者手册对lock指令的解释:

  1. 会将当前处理器缓存行的数据立即写回到系统内存

  2. 这个写回内存的操作会引起在其他CPU里缓存了该内存地址的数据无效

就是通过lock指令,让initFlag立即写回内存,且让其他线程中的副本失效。

相比于此前在总线上加的重量级锁,lock指令只是在会写主内存时加了锁,就是从store操作开始才加锁,而此前的总线上加锁是从read就开始了。一旦写回,立即unlock释放锁。由于CPU的读写是非常快的,这个过程是非常非常之短的。所以volatile是轻量级的锁,性能高。

Q:如果不加 lock - unlock 指令会怎样?

线程2在store到write之间,这时initFlag = true被CPU修改了值但是还没有写回主内存,总线监听机制发现了数检测的据被修改,立即使线程1工作内存的副本失效,线程1再次去读取initFlag,但此时由于没有加锁并且还没来得及修改initFlag = false这个脏数据,线程1又将initFlag = false错误的数据拷贝到工作内存中,还是处于死循环中,依然会存在问题。

所以,必须要在store和write之间加上lock和unlock,防止时间差带来的误读。

volatile保证可见性与有序性,但是不保证证原子性,保证原则性需要借助synchronized这样的锁机制


# 5. volatile不保证原子性

# 原子性

定义:

原子具有不可分割性。比如 i=1,这个操作是不可分割的,那么我们说这个操作是原子操作。再比如:i++,这个操作实际是i= i + 1,包括读取i,i+1,将结果写入内存三个操作,它们翻译成底层的字节码指令可能需要很多条指令来完成,是可以分割的,所以他不是一个原子操作。

非原子操作都会存在线程安全问题,需要我们使用相关技术(比如sychronized)让它变成一个原子操作。一个操作是原子操作,那么我们称它具有原子性。

这句话的丰富含义有:

  1. 原子操作是对于多线程而言的*,对于单一线程,无所谓原子性。

  2. 原子操作是针对共享变量的

  3. 原子操作是不可分割的。指访问某个共享变量的操作从其他任意线程来看是不可分割的。

# 保证原子性

保证多线程原子性的方式

1. 加锁

使用synchronized同步代码块保证线程的同步,从而保证多线程的原子性,但是加锁的话,就会使开销比较大。加锁和解锁是有消耗的。并且只要有加锁、解锁就会伴随着线程阻塞、线程唤醒,这样线程的切换也是消耗性能的。加锁本质上是将并发转变为串行来实现的,势必会影响吞吐量。

2. CAS无锁算法

CAS 是在不使用锁的情况下实现多线程之间的变量同步。

CAS包含 3 个参数:共享变量的原始值A、预期值B和新值 C。只有当A的值等于B,才能把A的值变成C。也就是说预期值B等于原始值A,说明共享变量没有被其他线程修改过,所以才允许更新新值,这样就保证了原子性!如果A不等于B,说明共享变量已经被其他线程修改过了,当前线程可以放弃此操作。

基于这样的算法,CAS算法即使没有锁,也可以发现其他线程对当前线程的干扰,并进行恰当的处理。

# volatile不保证原子性验证

还是通过代码来说明问题:

/**
 * @Author: Mr.Q
 * @Date: 2020-06-11 11:04
 * @Description:volatile不保证原子性测试
 */
public class VolatileAtomicityTest {

    public static volatile int num = 0;

    public static void increase() {
        num++;
    }

    public static void main(String[] args) throws InterruptedException {
        Thread[] threads = new Thread[10];
        for (int i = 0; i < threads.length; i++) {
            threads[i] = new Thread(() -> {
                for (int j = 1; j <= 1000; j++) {
                    increase();
                }
            });
            threads[i].start();
        }

        //主线程阻塞,等待线程数组中的10个线程执行完再继续执行
        for (Thread thread : threads) {
            thread.join();
        }

        System.out.println(num); // num <= 1000 * 10
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32

结果:num <= 10000

此时此刻,我已对并发编程的代码彻底干懵🙂,含着泪,继续往下学习!

按道理来说结果是10000,但是运行下很可能是个小于10000的值。

咦?volatile不是保证了可见性啊,一个线程对num的修改,另外一个线程应该立刻看到啊!

可是这里的操作num++是个复合操作,包括读取num的值,对其自增,然后再写回主存。

  • 假设线程1,读取了num的值为0,线程2刚好和线程2是同步操作,也为num=0;

  • 他俩都对num做了+1操作,同时准备write会主内存。

  • 看谁先通过总线(包括同时通过)

  • 假设是线程1先通过。MESI会将线程2工作内存中num = 1的副本立刻置位无效,此时线程1已将num = 0 --> 1修改,num = 1

  • 线程2只能再次重新读取num = 1,然后执行加一再回写主内存。num = 2,但是却执行了三次循环,此时i = 3

如果线程1和线程2同时通过,由于他们工作内存中num均为1,所以还是执行了3次循环而num自增了2次

这就是num < 10000的原因。如果没有出现上述情况,num = 10000

# 【问题解决】

1. 同步加锁解决volatile原子性问题

第一种补救措施很简单,就是简单粗暴的的加锁,这样可以保证给num加1这个方法是同步的,这样每个线程就会井然有序的运行,而保证了最终的num数和预期值一致。

2. CAS解决volatile原子性问题

针对num++这类复合类的操作,可以使用JUC并发包中的原子操作类,原子操作类是通过循环CAS的方式来保证其原子性的。

AtomicInteger这是个基于CAS的无锁技术,它的主要原理就是通过比较预期值和实际值,当其没有异常的以后,就进行增值操作。incrementAndGet这个方法实际上每次对num进行+1的过程都进行了比较,存在一个retry的过程。它在多线程处理中可以防止这种多次递增而引发的线程不安全的问题

# 6. volatile保证有序性

volatile保证有序性,就是禁止编译器在编译阶段对指令的重排序问题。

# volatile禁止指令重排序

public class VolatileSeriaTest {

    private static int a = 0, b = 0; //此处a,b变量是否添加volatile来修饰

    public static void main(String[] args) throws InterruptedException {
        Set<String> set = new HashSet<>();
        Map<String,Integer> map = new HashMap<>();

        for (int i = 0; i < 1000000; i++) {
            a = 0;
            b = 0;
            map.clear();

            Thread one = new Thread(() -> {
                b = 1;
                int x = a;
                map.put("x", x);
            });

            Thread two = new Thread(() -> {
                a = 1;
                int y = b;
                map.put("y", y);
            });

            one.start();
            two.start();

            one.join();
            two.join();

            set.add("x=" + map.get("x") + "," + "y=" + map.get("y"));
            System.out.println(set + " --> i = " + i);
        }
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36

我们可以看到,程序一共跑出了四种情况:

这三种情况,我们很容易想到

但是出现了x=0,y=0就不正常了,原因就是编译器对程序作了指令重排序

当两个线程以

  • x = a;

  • a = 1;

  • y = b;

  • b = 1;

顺序来执行,就会出现x=0,y=0这种特殊情况,这是单线程下现象不到的情景。

CPU指令重排序的定义为:CPU允许在某些条件下进行指令重排序,仅需保证重排序后单线程下的语义一致

保证的是单线程下的语义一致,多线程时是不保证的,所以就需要volatile来禁止指令重排序了。

那到底是怎么禁止的呢?

这里只是简单的说明问题,深入的源码分析研究,大家看看源码查查资料吧。

  • 附上一篇参考文章,文章中做了x = 0,y = 0这种情况的讲解
  • 《指令重排序与volatile关键字 (opens new window)》

这个涉及到内存屏障(Memory Barrier)

# 内存屏障简介

内存屏障有两个能力:

  1. 就像一套栅栏分割前后的代码,阻止栅栏前后的没有数据依赖性的代码进行指令重排序,保证程序在一定程度上的有序性。

  2. 强制把写缓冲区/高速缓存中的脏数据等写回主内存,让缓存中相应的数据失效,保证数据的可见性。

首先,指令并不是代码行,指令是原子的,通过javap命令可以看到一行代码编译出来的指令,当然,像int i=1;这样的代码行也是原子操作。

在单例模式中,Instance ins = new Instance();   就不是原子操作,它可以分成三步原子指令:

  1. 分配内存地址;

  2. new一个Instance对象;

  3. 将内存地址赋值给ins;

CPU为了提高执行效率,这三步操作的顺序可以是123,也可以是132。

如果是132顺序的话,当把内存地址赋给inst后,ins指向的内存地址还没有new出来单例对象,这时候,如果拿到ins的话,其实就是空的,会报空指针异常。

这就是为什么双重检查单例模式(DCL) 中,单例对象要加上volatile关键字。

内存屏障有三种类型和一种伪类型:

  • lfence:即读屏障(Load Barrier),在读指令前插入读屏障,可以让高速缓存中的数据失效,重新从主内存加载数据,以保证读取的是最新的数据。

  • sfence:即写屏障(Store Barrier),在写指令之后插入写屏障,能让写入缓存的最新数据写回到主内存,以保证写入的数据立刻对其他线程可见。

  • [ ] Lock前缀:Lock不是一种内存屏障,但是它能完成类似全能型内存屏障的功能。

volatile会给代码添加一个内存屏障,指令重排序的时候不会把后面的指令重排序到屏障的位置之前

PS😐:只有一个CPU的时候,这种内存屏障是多余的。只有多个CPU访问同一块内存的时候,就需要内存屏障了

# 读写屏障

写屏障:保证 屏障之前的代码不会进行重排序

读屏障:保证 屏障之后的代码不会进行重排序

static volatile boolean read;

public void actor1() {
    num = 2;
    ready = true; // 写屏障
}

public void actor2() {
    // 读屏障
    if(ready) {
        res = num + num;
    } else {
        res = 1;
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

通过读写屏障来保证内存之间的有序性,禁止指临重排。


# JMM的Happens-Before原则

Happens-Before 是java内存模型中的语义规范,来保证内存操作之间的可见性,可以确保一条语句的所有“写内存”操作对另一条语句是可见的。

Happens-Before原则如下:

  1. 程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后   面的操作;

  2. 锁定规则:一个unLock操作先行发生于后面对同一个锁额lock操作;

  3. volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作;

  4. 传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C;

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

  6. 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生;

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

  8. 对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始;

以上的happens-before原则为volatile关键字的可见性提供了强制保证。

并发编程三大特性:

  1. 可见性

  2. 原子性

  3. 有序性

并发三特性总结

特性 volatile synchronized Lock Atomic
原子性 无法保障 可以保障 可以保障 可以保障
可见性 可以保障 可以保障 可以保障 可以保障
有序性 可以保障 可以保障 可以保障 无法保障

【文章参考】

  1. CPU缓存 - 搜狗百科 (opens new window)

  2. 缓存 (opens new window)

  3. 面试官最爱的volatile关键字,你答对了吗? (opens new window)

  4. Java指令重排序与volatile关键字 (opens new window)

  5. Java Volatile关键字【公众号:并发编程网】

  6. volatile的原理分析 (opens new window)

  7. volatile是怎么保障内存可见性以及防止指令重排序的 (opens new window)

编辑 (opens new window)
上次更新: 2021/06/27, 10:49:09
阻塞队列,同步队列
volatile

← 阻塞队列,同步队列 volatile→

最近更新
01
匿名内部类
10-08
02
函数式接口
10-08
03
ARouter-Kotlin踩坑
10-05
更多文章>
Theme by Vdoing | Copyright © 2021-2023 iqqcode | MIT License | 备案号-京ICP备2021028793号
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式
×