cxuan 连这 10 个问题都不会...

共 6642字,需浏览 14分钟

 ·

2021-07-14 05:01

Hey guys ,我是 cxuan,欢迎你阅读我最新一期的技术文章。这一篇文章我要和你聊一聊 Java 并发中关于内存模型的那些事情,我会通过向你问问题的形式来展开,如果你有思路,可以先不要看我的答案,看看你的回答和我的答案是不是有出入,如果你有任何疑问,欢迎在这篇文章下方留言,下面开始我们的正文!

究竟什么是内存模型?

在多处理系统中,每个 CPU 通常都包含一层或者多层内存缓存,这样设计的原因是为了加快数据访问速度(因为数据会更靠近处理器) 并且能够减少共享内存总线上的流量(因为可以满足许多内存操作)来提高性能。内存缓存能够极大的提高性能。

但是同时,这种设计方式也带来了许多挑战。

比如,当两个 CPU 同时对同一内存位置进行操作时会发生什么?在什么情况下这两个 CPU 会看到同一个内存值?

现在,内存模型登场了!!!在处理器层面,内存模型明确定义了其他处理器的写入是如何对当前处理器保持可见的,以及当前处理器写入内存的值是如何使其他处理器可见的,这种特性被称为可见性,这是官方定义的一种说法。

然而,可见性也分为强可见性弱可见性,强可见性说的是任何 CPU 都能够看到指定内存位置具有相同的值;弱可见性说的是需要一种被称为内存屏障的特殊指令来刷新缓存或者使本地处理器缓存无效,才能看到其他 CPU 对指定内存位置写入的值,写入后的值就是内存值。这些特殊的内存屏障是被封装之后的,我们不研究源码的话是不知道内存屏障这个概念的。

内存模型还规定了另外一种特性,这种特性能够使编译器对代码进行重新排序(其实重新排序不只是编译器所具有的特性),这种特性被称为有序性。如果两行代码彼此没有相关性,那么编译器是能够改变这两行代码的编译顺序的,只要代码不会改变程序的语义,那么编译器就会这样做。

我们上面刚提到了,重新排序不只是编译器所特有的功能,编译器的这种重排序只是一种静态重排序,其实在运行时或者硬件执行指令的过程中也会发生重排序,重排序是一种提高程序运行效率的一种方式。

比如下面这段代码

Class Reordering {
  int x = 0, y = 0;
  public void writer() {
    x = 1;
    y = 2;
  }

  public void reader() {
    int r1 = y;
    int r2 = x;
  }
}

当两个线程并行执行上面这段代码时,可能会发生重排序现象,因为 x 、 y 是两个互不相关的变量,所以当线程一执行到 writer 中时,发生重排序,y = 2 先被编译,然后线程切换,执行 r1 的写入,紧接着执行 r2 的写入,注意此时 x 的值是 0 ,因为 x = 1 没有编译。这时候线程切换到 writer ,编译 x = 1,所以最后的值为 r1 = 2,r2 = 0,这就是重排序可能导致的后果。

所以 Java 内存模型为我们带来了什么?

Java 内存模型描述了多线程中哪些行为是合法的,以及线程之间是如何通过内存进行交互的。Java 内存模型提供了两种特性,即变量之间的可见性和有序性,这些特性是需要我们在日常开发中所注意到的点。Java 中也提供了一些关键字比如 volatile、final 和 synchronized 来帮助我们应对 Java 内存模型带来的问题,同时 Java 内存模型也定义了 volatile 和 synchronized 的行为。

其他语言,比如 C++ 会有内存模型吗?

其他语言比如 C 和 C++ 在设计时并未直接支持多线程,这些语言针对编译器和硬件发生的重排序是依靠线程库(比如 pthread )、所使用的编译器以及运行代码的平台提供的保证。

JSR - 133 是关于啥的?

在 1997 年,在此时 Java 版本中的内存模型中发现了几个严重的缺陷,这个缺陷经常会出现诡异的问题,比如字段的值经常会发生改变,并且非常容易削弱编译器的优化能力。

所以,Java 提出了一项雄心勃勃的畅想:合并内存模型,这是编程语言规范第一次尝试合并一个内存模型,这个模型能够为跨各种架构的并发性提供一致的语义,但是实际操作起来要比畅想困难很多。

最终,JSR-133 为 Java 语言定义了一个新的内存模型,它修复了早期内存模型的缺陷。

所以,我们说的 JSR - 133 是关于内存模型的一种规范和定义。

JSR - 133 的设计目标主要包括:

  • 保留 Java 现有的安全性保证,比如类型安全,并加强其他安全性保证,比如线程观察到的每个变量的值都必须是某个线程对变量进行修改之后的。

  • 程序的同步语义应该尽可能简单和直观。

  • 将多线程如何交互的细节交给程序员进行处理。

  • 在广泛、流行的硬件架构上设计正确、高性能的 JVM 实现。

  • 应提供初始化安全的保证,如果一个对象被正确构造后,那么所有看到对象构造的线程都能够看到构造函数中设置其最终字段的值,而不用进行任何的同步操作。

  • 对现有的代码影响要尽可能的小。

重排序是什么?

在很多情况下,访问程序变量,比如对象实例字段、类静态字段和数组元素的执行顺序与程序员编写的程序指定的执行顺序不同。编译器可以以优化的名义任意调整指令的执行顺序。在这种情况下,数据可以按照不同于程序指定的顺序在寄存器、处理器缓存和内存之间移动。

有许多潜在的重新排序来源,例如编译器、JIT(即时编译)和缓存。

重排序是硬件、编译器一起制造出来的一种错觉,在单线程程序中不会发生重排序的现象,重排序往往发生在未正确同步的多线程程序中。

旧的内存模型有什么错误?

新内存模型的提出是为了弥补旧内存模型的不足,所以旧内存模型有哪些不足,我相信读者也能大致猜到了。

首先,旧的内存模型不允许发生重排序。再一点,旧的内存模型没有保证 final 的真正 不可变性,这是一个非常令人大跌眼睛的结论,旧的内存模型没有把 final 和其他不用 final 修饰的字段区别对待,这也就意味着,String 并非是真正不可变,这确实是一个非常严重的问题。

其次,旧的内存模型允许 volatile 写入与非 volatile 读取和写入重新排序,这与大多数开发人员对 volatile 的直觉不一致,因此引起了混乱。

什么是不正确同步?

当我们讨论不正确同步的时候,我们指的是任何代码

  • 一个线程对一个变量执行写操作,

  • 另一个线程读取了相同的变量,

  • 并且读写之间并没有正确的同步

当违反这些规则时,我们说在这个变量上发生了数据竞争现象。具有数据竞争现象的程序是不正确同步的程序。

同步(synchronization)都做了哪些事情?

同步有几个方面,最容易理解的是互斥,也就是说一次只有一个线程可以持有一个监视器(monitor),所以在 monitor 上的同步意味着一旦一个线程进入一个受 monitor 保护的同步代码块,其他线程就不能进入受该 monitor 保护的块直到第一个线程退出同步代码块。

但是同步不仅仅只有互斥,它还有可见,同步能够确保线程在进入同步代码块之前和同步代码块执行期间,线程写入内存的值对在同一 monitor 上同步的其他线程可见。

在进入同步块之前,会获取 monitor ,它具有使本地处理器缓存失效的效果,以便变量将从主内存中重新读取。在退出一个同步代码块后,会释放 monitor ,它具有将缓存刷新到主存的功能,以便其他线程可以看到该线程所写入的值

新的内存模型语义在内存操作上面制定了一些特定的顺序,这些内存操作包含(read、write、lock、unlock)和一些线程操作(start 、join),这些特定的顺序保证了第一个动作在执行之前对第二个动作可见,这就是 happens-before 原则,这些特定的顺序有

  • 线程中的每个操作都 happens - before 按照程序定义的线程操作之前。

  • Monitor 中的每个 unlock 操作都 happens-before 相同 monitor 的后续 lock 操作之前。

  • 对 volatile 字段的写入都 happens-before 在每次后续读取同一 volatile 变量之前。

  • 对线程的 start() 调用都 happens-before 在已启动线程的任何操作之前。

  • 线程中的所有操作都 happens-before 在任何其他线程从该线程上的 join() 成功返回之前。

需要注意非常重要的一点:两个线程在同一个 monitor 之间的同步非常重要。并不是线程 A 在对象 X 上同步时可见的所有内容在对象 Y 上同步后对线程 B 可见。释放和获取必须进行匹配(即,在同一个 monitor 上执行)才能有正确的内存语义,否则就会发生数据竞争现象。

另外,关于 synchronized 在 Java 中的用法,你可以参考这篇文章 synchronized 的超多干货!

final 在新的 JMM 下是如何工作的?

通过上面的讲述,你现在已经知道,final 在旧的 JMM 下是无法正常工作的,在旧的 JMM 下,final 的语义就和普通的字段一样,没什么其他区别,但是在新的 JMM 下,final 的这种内存语义发生了质的改变,下面我们就来探讨一下 final 在新的 JMM 下是如何工作的。

对象的 final 字段在构造函数中设置,一旦对象被正确的构造出来,那么在构造函数中的 final 的值将对其他所有线程可见,无需进行同步操作。

什么是正确的构造呢?

正确的构造意味着在构造的过程中不允许对正在构造的对象的引用发生 逃逸,也就是说,不要将正在构造的对象的引用放在另外一个线程能够看到它的地方。下面是一个正确构造的示例:

class FinalFieldExample {
  final int x;
  int y;
  static FinalFieldExample f;
  public FinalFieldExample() {
    x = 3;
    y = 4;
  }

  static void writer() {
    f = new FinalFieldExample();
  }

  static void reader() {
    if (f != null) {
      int i = f.x;
      int j = f.y;
    }
  }
}

执行读取器的线程一定会看到 f.x 的值 3,因为它是 final 的。不能保证看到 y 的值 4,因为它不是 final 的。如果 FinalFieldExample 的构造函数如下所示:

public FinalFieldExample() 
  x = 3;
  y = 4;
  // 错误的构造,可能会发生逃逸
  global.obj = this;
}

这样就不会保证读取 x 的值一定是 3 了。

这也就说是,如果在一个线程构造了一个不可变对象(即一个只包含 final 字段的对象)之后,你想要确保它被所有其他线程正确地看到,通常仍然需要正确的使用同步。

volatile 做了哪些事情?

我写过一篇 volatile 的详细用法和其原理的文章,你可以阅读这篇文章 volatile 的用法和实现原理

新的内存模型修复了双重检查锁的问题吗?

也许我们大家都见过多线程单例模式双重检查锁的写法,这是一种支持延迟初始化同时避免同步开销的技巧。

class DoubleCheckSync{
     private static DoubleCheckSync instance = null;
  public DoubleCheckSync getInstance() {
    if (instance == null) {
      synchronized (this) {
        if (instance == null)
          instance = new DoubleCheckSync();
      }
    }
    return instance;
  } 
}

这样的代码看起来在程序定义的顺序上看起来很聪明,但是这段代码却有一个致命的问题:它不起作用

??????

双重检查锁不起作用?

是的!

为毛?

原因就是初始化实例的写入和对实例字段的写入可以由编译器或缓存重新排序,看起来我们可能读取了初始化了 instance 对象,但其实你可能只是读取了一个未初始化的 instance 对象。

有很多小伙伴认为使用 volatile 能够解决这个问题,但是在 1.5 之前的 JVM 中,volatile 不能保证。在新的内存模型下,使用 volatile 会修复双重检查锁定的问题,因为这样在构造线程初始化 DoubleCheckSync 和返回其值之间将存在 happens-before 关系读取它的线程。





 往期推荐 

🔗

B 站,我直呼好家伙!!!

计算机网络的 89 个核心概念

TCP 基础知识

cxuan 的健身史。

说到做到!

IE 凉了?怎么可能!

毕业那年,我写过诗。

另外,cxuan 肝了六本 PDF,公号回复 cxuan ,领取作者全部 PDF 。


浏览 18
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报