JAVA内存模型(JMM)详解

目录
  • 前言
  • JAVA并发三大特性
    • 可见性
    • 有序性
    • 原子性
  • Java内存模型真面目
  • Happens-Before规则
    • 1.程序的顺序性规则
    • 2. volatile 变量规则
    • 3.传递性
    • 锁的规则
    • 5.线程 start() 规则
    • 6.线程 join() 规则
  • 使用JMM规则
    • 方案一: 使用volatile
    • 方案二:使用锁
  • 小结:
    • volatile 关键字
    • synchronized 关键字
  • 总结

前言

开篇一个例子,我看看都有谁会?如果不会的,或者不知道原理的,还是老老实实看完这篇文章吧。

@Slf4j(topic = "c.VolatileTest")
public class VolatileTest {

    static boolean run = true;

    public static void main(String[] args) throws InterruptedException {
        Thread t = new Thread(() -> {
            while (run) {
                // do other things
            }

            // ?????? 这行会打印吗?
            log.info("done .....");
        });
        t.start();

        Thread.sleep(1000);

       // 设置run = false
        run = false;
    }
}

main函数中新开个线程根据标位run循环,主线程中sleep一秒,然后设置run=false,大家认为会打印"done ......."吗?

答案就是不会打印,为什么呢?

JAVA并发三大特性

我们先来解释下上面问题的原因,如下图所示,

现代的CPU架构基本有多级缓存机制,t线程会将run加载到高速缓存中,然后主线程修改了主内存的值为false,导致缓存不一致,但是t线程依然是从工作内存中的高速缓存读取run的值,最终无法跳出循环。

可见性

正如上面的例子,由于不做任何处理,一个线程能否立刻看到另外一个线程修改的共享变量值,我们称为"可见性"。

如果在并发程序中,不做任何处理,那么就会带来可见性问题,具体如何处理,见后文。

有序性

有序性是指程序按照代码的先后顺序执行。但是编译器或者处理器出于性能原因,改变程序语句的先后顺序,比如代码顺序"a=1; b=2;",但是指令重排序后,有可能会变成"b=2;a=1", 那么这样在并发情况下,会有问题吗?

在单线程情况下,指令重排序不会有任何影响。但是在并发情况下,可能会导致一些意想不到的bug。比如下面的例子:

public class Singleton {
  static Singleton instance;

  static Singleton getInstance(){
    if (instance == null) {
      synchronized(Singleton.class) {
        if (instance == null)
          instance = new Singleton();
        }
    }
    return instance;
  }
}

假设有两个线程 A、B 同时调用 getInstance() 方法,正常情况下,他们都可以拿到instance实例。

但往往bug就在一些极端的异常情况,比如new Singleton() 这个操作,实际会有下面3个步骤:

分配一块内存 M;

在内存 M 上初始化 Singleton 对象;

然后 M 的地址赋值给 instance 变量。

现在发生指令重排序,顺序变为下面的方式:

分配一块内存 M;

将 M 的地址赋值给 instance 变量;

最后在内存 M 上初始化 Singleton 对象。

优化后会导致什么问题呢?我们假设线程 A 先执行 getInstance() 方法,当执行完指令 2 时恰好发生了线程切换,切换到了线程 B 上;如果此时线程 B 也执行 getInstance() 方法,那么线程 B 在执行第一个判断时会发现 instance != null ,所以直接返回 instance,而此时的 instance 是没有初始化过的,如果我们这个时候访问 instance 的成员变量就可能触发空指针异常。

这就是并发情况下,有序性带来的一个问题,这种情况又该如何处理呢?

当然,指令重排序并不会瞎排序,处理器在进行重排序时,必须要考虑指令之间的数据依赖性。

原子性

如上图所示,在多线程的情况下,CPU资源会在不同的线程间切换。那么这样也会导致意向不到的问题。

比如你认为的一行代码:count += 1,实际上涉及了多条CPU指令:

指令 1:首先,需要把变量 count 从内存加载到 CPU 的寄存器; 指令 2:之后,在寄存器中执行 +1 操作; 指令 3:最后,将结果写入内存(缓存机制导致可能写入的是 CPU 缓存而不是内存)。

操作系统做任务切换,可以发生在任何一条CPU 指令执行完。假设 count=0,如果线程 A 在指令 1 执行完后做线程切换,线程 A 和线程 B 按照下图的序列执行,那么我们会发现两个线程都执行了 count+=1 的操作,但是得到的结果不是我们期望的 2,而是 1。

我们潜意识认为的这个count+=1操作是一个不可分割的整体,就像一个原子一样,我们把一个或者多个操作在 CPU 执行的过程中不被中断的特性称为原子性。但实际情况就是不做任何处理的话,在并发情况下CPU进行切换,导致出现原子性的问题,我们一般通过加锁解决,这个不是本文的重点。

Java内存模型真面目

前面讲解并发的三大特性,其中原子性问题可以通过加锁的方式解决,那么可见性和有序性有什么解决的方案呢?其实也很容易想到,可见性是因为缓存导致,有序性是因为编译优化指令重排序导致,那么是不是可以让程序员按需禁用缓存以及编译优化, 因为只有程序员知道什么情况下会出现问题 。 顺着这个思路,就提出了JAVA内存模型(JMM)规范。

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

默认情况下,JMM中的内存机制如下:

系统存在一个主内存(Main Memory),Java 中所有变量都存储在主存中,对于所有线程都是共享的 每条线程都有自己的工作内存(Working Memory),工作内存中保存的是主存中某些变量的拷贝 线程对所有变量的操作都是先对变量进行拷贝,然后在工作内存中进行,不能直接操作主内存中的变量 线程之间无法相互直接访问,线程间的通信(传递)必须通过主内存来完成

同时,JMM规范了 JVM 如何提供按需禁用缓存和编译优化的方法,主要是通过volatilesynchronizedfinal 三个关键字,那具体的规则是什么样的呢?

JMM 中的主内存、工作内存与 JVM 中的 Java 堆、栈、方法区等并不是同一个层次的内存划分,这两者基本上是没有关系的。

Happens-Before规则

JMM本质上包含了一些规则,那这个规则就是大家有所耳闻的Happens-Before规则,大家都理解了些规则吗?

Happens-Before规则,可以简单理解为如果想要A线程发生在B线程前面,也就是B线程能够看到A线程,需要遵循6个原则。如果不符合 happens-before 规则,JMM 并不能保证一个线程的可见性和有序性。

1.程序的顺序性规则

在一个线程中,逻辑上书写在前面的操作先行发生于书写在后面的操作。

这个规则很好理解,同一个线程中他们是用的同一个工作缓存,是可见的,并且多个操作之间有先后依赖关系,则不允许对这些操作进行重排序。

2. volatile 变量规则

指对一个 volatile 变量的写操作, Happens-Before 于后续对这个 volatile 变量的读操作。

怎么理解呢?比如线程A对volatile变量进行写操作,那么线程B读取这个volatile变量是可见的,就是说能够读取到最新的值。

3.传递性

这条规则是指如果 A Happens-Before B,且 B Happens-Before C,那么 A Happens-Before C

这个规则也比较容易理解,不展开讨论了。

锁的规则

这条规则是指对一个锁的解锁 Happens-Before于后续对这个锁的加锁,这里的锁要是同一把锁, 而且用synchronized或者ReentrantLock都可以。

如下代码的例子:

synchronized (this) { // 此处自动加锁
  // x 是共享变量, 初始值 =10
  if (this.x < 12) {
    this.x = 12;
  }
} // 此处自动解锁

假设 x 的初始值是 8,线程 A 执行完代码块后 x 的值会变成 12(执行完自动释放锁) 线程 B 进入代码块时,能够看到线程 A 对 x 的写操作,也就是线程 B 能够看到 x==12

5.线程 start() 规则

主线程 A 启动子线程 B 后,子线程 B 能够看到主线程在启动子线程 B 前的操作。

这个规则也很容易理解,线程 A 调用线程 B 的 start() 方法(即在线程 A 中启动线程 B),那么该 start() 操作 Happens-Before 于线程 B 中的任意操作。

6.线程 join() 规则

线程 A 中,调用线程 B 的 join() 并成功返回,那么线程 B 中的任意操作 Happens-Before 于该 join() 操作的返回。

使用JMM规则

我们现在已经基本讲清楚了JAVA内存模型规范,以及里面关键的Happens-Before规则,那有啥用呢?回到前言的问题中,我们是不是可以使用目前学到的关于JMM的知识去解决这个问题。

方案一: 使用volatile

根据JMM的第2条规则,主线程写了volatile修饰的run变量,后面的t线程读取的时候就可以看到了。

方案二:使用锁

利用synchronized锁的规则,主线程释放锁,那么后续t线程加锁就可以看到之前的内容了。

小结:

volatile 关键字

保证可见性 不保证原子性 保证有序性(禁止指令重排)

volatile 修饰的变量进行读操作与普通变量几乎没什么差别,但是写操作相对慢一些,因为需要在本地代码中插入很多内存屏障来保证指令不会发生乱序执行,但是开销比锁要小。volatile的性能远比加锁要好。

synchronized 关键字

保证可见性 不保证原子性 保证有序性

加了锁之后,只能有一个线程获得到了锁,获得不到锁的线程就要阻塞,所以同一时间只有一个线程执行,相当于单线程,由于数据依赖性的存在,单线程的指令重排是没有问题的。

线程加锁前,将清空工作内存中共享变量的值,使用共享变量时需要从主内存中重新读取最新的值;线程解锁前,必须把共享变量的最新值刷新到主内存中。

总结

本文讲解了JAVA并发的3大特性,可见性、有序性和原子性。从而引出了JAVA内存模型规范,这主要是为了解决并发情况下带来的可见性和有序性问题,主要就是定义了一些规则,需要我们程序员懂得这些规则,然后根据实际场景去使用,就是使用volatilesynchronizedfinal关键字,主要final关键字也会让其他线程可见,并且保证有序性。那么具体他们底层的实现是什么,是如何保证可见和有序的,我们后面详细讲解。

到此这篇关于JAVA内存模型(JMM)详解的文章就介绍到这了,更多相关JAVA内存模型 JMM内容请搜索我们以前的文章或继续浏览下面的相关文章希望大家以后多多支持我们!

(0)

相关推荐

  • Java8内存模型PermGen Metaspace实例解析

    一.JVM 内存模型 根据 JVM 规范,JVM 内存共分为虚拟机栈.堆.方法区.程序计数器.本地方法栈五个部分. 1.虚拟机栈:每个线程有一个私有的栈,随着线程的创建而创建.栈里面存着的是一种叫"栈帧"的东西,每个方法会创建一个栈帧,栈帧中存放了局部变量表(基本数据类型和对象引用).操作数栈.方法出口等信息.栈的大小可以固定也可以动态扩展.当栈调用深度大于JVM所允许的范围,会抛出StackOverflowError的错误,不过这个深度范围不是一个恒定的值,我们通过下面这段程序可以测

  • Java内存模型之happens-before概念详解

    简介 happens-before是JMM的核心概念.理解happens-before是了解JMM的关键. 1.设计意图 JMM的设计需要考虑两个方面,分别是程序员角度和编译器.处理器角度: 程序员角度,希望内存模型易于理解.易于编程.希望是一个强内存模型. 编译器和处理器角度,希望减少对它们的束缚,以至于编译器和处理器可以做更多的性能优化.希望是一个弱内存模型. ​因此JSR-133专家组设计JMM的核心目标就两个: 为程序员提供足够强的内存模型对编译器和处理器的限制尽可能少 ​下面通过一段代

  • Java内存区域和内存模型讲解

    一.Java内存区域 方法区(公有):用户存储已被虚拟机加载的类信息,常量,静态常量,即时编译器编译后的代码等数据.异常状态 OutOfMemoryError. 堆(公有):是JVM所管理的内存中最大的一块.唯一目的就是存放实例对象,几乎所有的对象实例都在这里分配.Java堆是垃圾收集器管理的主要区域,因此很多时候也被称为"GC堆".异常状态 OutOfMemoryError. 虚拟机栈(线程私有): 描述的是java方法执行的内存模型:每个方法在执行时都会创建一个栈帧,用户存储局部变

  • 学习Java内存模型JMM心得

    有时候编译器.处理器的优化会导致runtime与我们设想的不一样,为此Java对编译器和处理器做了一些限制,JAVA内存模型(JMM)将这些抽象出来,这样编写代码时就无需考虑那么多底层细节,并保证"只要遵循JMM的规则编写程序,其运行结果一定是正确的". JMM的抽象结构 在Java中,所有的实例.静态变量存储在堆内存中,堆内存是可以在线程间共享的,这部分也称为共享变量.而局部变量.方法定义参数.异常处理参数是在栈中的,栈内存不在线程间共享. 而由于编译器.处理器的优化,会导致共享变量

  • Java内存模型与JVM运行时数据区的区别详解

    首先,这两者是完全不同的概念,绝对不能混为一谈. 1.什么是Java内存模型? Java内存模型是Java语言在多线程并发情况下对于共享变量读写(实际是共享变量对应的内存操作)的规范,主要是为了解决多线程可见性.原子性的问题,解决共享变量的多线程操作冲突问题. 多线程编程的普遍问题是: 所见非所得 无法肉眼检测程序的准确性 不同的运行平台表现不同 错误很难复现 故JVM规范规定了Java虚拟机对多线程内存操作的一些规则,主要集中体现在volatile和synchronized这两个关键字. vo

  • 细谈java同步之JMM(Java Memory Model)

    简介 Java内存模型是在硬件内存模型上的更高层的抽象,它屏蔽了各种硬件和操作系统访问的差异性,保证了Java程序在各种平台下对内存的访问都能达到一致的效果. 硬件内存模型 在正式讲解Java的内存模型之前,我们有必要先了解一下硬件层面的一些东西. 在现代计算机的硬件体系中,CPU的运算速度是非常快的,远远高于它从存储介质读取数据的速度,这里的存储介质有很多,比如磁盘.光盘.网卡.内存等,这些存储介质有一个很明显的特点--距离CPU越近的存储介质往往越小越贵越快,距离CPU越远的存储介质往往越大

  • Java内存模型JMM详解

    Java Memory Model简称JMM, 是一系列的Java虚拟机平台对开发者提供的多线程环境下的内存可见性.是否可以重排序等问题的无关具体平台的统一的保证.(可能在术语上与Java运行时内存分布有歧义,后者指堆.方法区.线程栈等内存区域). 并发编程有多种风格,除了CSP(通信顺序进程).Actor等模型外,大家最熟悉的应该是基于线程和锁的共享内存模型了.在多线程编程中,需要注意三类并发问题: ·原子性 ·可见性 ·重排序 原子性涉及到,一个线程执行一个复合操作的时候,其他线程是否能够看

  • 在Java内存模型中测试并发程序代码

    让我们来看看这段代码: import java.util.BitSet; import java.util.concurrent.CountDownLatch; public class AnExample { public static void main(String[] args) throws Exception { BitSet bs = new BitSet(); CountDownLatch latch = new CountDownLatch(1); Thread t1 = ne

  • Java 高并发三:Java内存模型和线程安全详解

    网上很多资料在描述Java内存模型的时候,都会介绍有一个主存,然后每个工作线程有自己的工作内存.数据在主存中会有一份,在工作内存中也有一份.工作内存和主存之间会有各种原子操作去进行同步. 下图来源于这篇Blog 但是由于Java版本的不断演变,内存模型也进行了改变.本文只讲述Java内存模型的一些特性,无论是新的内存模型还是旧的内存模型,在明白了这些特性以后,看起来也会更加清晰. 1. 原子性 原子性是指一个操作是不可中断的.即使是在多个线程一起执行的时候,一个操作一旦开始,就不会被其它线程干扰

  • Java内存模型(JMM)及happens-before原理

    我们知道java程序是运行在JVM中的,而JVM就是构建在内存上的虚拟机,那么内存模型JMM是做什么用的呢? 我们考虑一个简单的赋值问题: int a=100; JMM考虑的就是什么情况下读取变量a的线程可以看到值为100.看起来这是一个很简单的问题,赋值之后不就可以读到值了吗? 但是上面的只是我们源码的编写顺序,当把源码编译之后,在编译器中生成的指令的顺序跟源码的顺序并不是完全一致的.处理器可能采用乱序或者并行的方式来执行指令(在JVM中只要程序的最终执行结果和在严格串行环境中执行结果一致,这

  • 浅析Java内存模型与垃圾回收

    1.Java内存模型 Java虚拟机在执行程序时把它管理的内存分为若干数据区域,这些数据区域分布情况如下图所示: 程序计数器:一块较小内存区域,指向当前所执行的字节码.如果线程正在执行一个Java方法,这个计数器记录正在执行的虚拟机字节码指令的地址,如果执行的是Native方法,这个计算器值为空. Java虚拟机栈:线程私有的,其生命周期和线程一致,每个方法执行时都会创建一个栈帧用于存储局部变量表.操作数栈.动态链接.方法出口等信息. 本地方法栈:与虚拟机栈功能类似,只不过虚拟机栈为虚拟机执行J

随机推荐