文章摘要
GPT 4
此内容根据文章生成,仅用于文章内容的解释与总结
投诉

一、内存模型(含八股内容)

1.1 内存模型及并发问题

Java 内存模型(Java Memory Model,JMM)定义了 Java 程序中的变量、线程如何和主存以及工作内存进行交互的规则。它主要涉及到多线程环境下的共享变量可见性、指令重排等问题,是理解并发编程中的关键概念。

并发编程的线程之间存在两个问题:

  • 线程间如何通信?即:线程之间以何种机制来交换信息
  • 线程间如何同步?即:线程以何种机制来控制不同线程间发生的相对顺序

有两种并发模型可以解决这两个问题:

  • 消息传递并发模型
  • 共享内存并发模型

这两种模型之间的区别如下图所示:

重要的事情说三遍

Java 使用的是共享内存并发模型!!!!

Java 使用的是共享内存并发模型!!!!

Java 使用的是共享内存并发模型!!!!

1.2 须知:Java共享内存并发模型

在共享内存的并发模型里,线程之间共享程序的公共状态,线程之间通过写-读内存中的公共状态来隐式进行通信。典型的共享内存通信方式,就是通过共享对象进行通信。

例如线程 A 与 线程 B 之间如果要通信的话,那么就必须经历下面两个步骤: 1. 线程 A 把本地内存 A 更新过得共享变量刷新到主内存中去。 2. 线程 B 到主内存中去读取线程 A 之前更新过的共享变量,也就是说,线程A和线程B之间一定要通过主存。

共享内存

1.3 八股:本地内存和主存有什么区别?

线程之间的共享变量存在于主存中,每个线程都有一个私有的本地内存,存储了该线程的读、写共享变量的副本。本地内存是 Java 内存模型的一个抽象概念,并不真实存在。它涵盖了缓存、写缓冲区、寄存器等(比如cpu的三级缓存)。

二、如何保证内存可见性?

由于java用的是内存共享并发模型,那么我们就按照这个来说。线程的安全问题包括原子性、可见性、活跃性、有序性,这里就先讨论可见性。

可见性就是指,如果线程A在本地内存中更新了一个变量,那么线程B以及其他线程需要同步修改后的数据。如何同步修改后的数据?此时就是需要JMM,JMM控制主存和本地内存之间的交互,来提供内存可见性保证。

Java 中的 volatile 关键字可以保证多线程操作共享变量的可见性以及禁止指令重排序,synchronized 关键字不仅保证可见性,同时也保证了原子性(互斥性)。

在更底层,JMM 通过内存屏障来实现内存的可见性以及禁止重排序。为了程序员更方便地理解,设计者提出了 happens-before 的概念(下文会细讲),它更加简单易懂,从而避免了程序员为了理解内存可见性而去学习复杂的重排序规则,以及这些规则的具体实现方法。

三、JMM与重排序

前面提到了,JMM 定义了多线程之间如何互相交互的规则,主要目的是为了解决由于编译器优化、处理器优化和缓存系统等导致的可见性、原子性和有序性。

那我们接下来就来聊聊重排序以及它所带来的顺序问题。

3.1 为什么指令重排可以提高性能?

大家都知道,计算机在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排。

那可能有小伙伴就要问:为什么指令重排序可以提高性能?

简单地说,每一个指令都会包含多个步骤,每个步骤可能使用不同的硬件。因此,流水线技术产生了,它的原理是指令 1 还没有执行完,就可以开始执行指令 2,而不用等到指令 1 执行结束后再执行指令 2,这样就大大提高了效率。

但是,流水线技术最害怕中断,恢复中断的代价是比较大的,所以我们要想尽办法不让流水线中断。指令重排就是减少中断的一种技术。

我们分析一下下面这段代码的执行情况:

1
2
a = b + c;
d = e - f ;

先加载 b、c(注意,有可能先加载 b,也有可能先加载 c),但是在执行 add(b,c) 的时候,需要等待 b、c 装载结束才能继续执行,也就是需要增加停顿,那么后面的指令(加载 e 和 f)也会有停顿,这就降低了计算机的执行效率。

为了减少停顿,我们可以在加载完 b 和 c 后把 e 和 f 也加载了,然后再去执行 add(b,c),这样做对程序(串行)是没有影响的,但却减少了停顿。

换句话说,既然 add(b,c) 需要停顿,那还不如去做一些有意义的事情(加载 e 和 f)。

综上所述,指令重排对于提高 CPU 性能十分必要,但也带来了乱序的问题。

3.2 重排序有哪几种?

指令重排一般分为以下三种:

  • 编译器优化重排,编译器在不改变单线程程序语义的前提下,重新安排语句的执行顺序。
  • 指令并行重排,现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性(即后一个执行的语句无需依赖前面执行的语句的结果),处理器可以改变语句对应的机器指令的执行顺序。
  • 内存系统重排,由于处理器使用缓存和读写缓存冲区,这使得加载(load)和存储(store)操作看上去可能是在乱序执行,因为三级缓存的存在,导致内存与缓存的数据同步存在时间差。

指令重排可以保证串行语义一致,但是没有义务保证多线程间的语义也一致。所以在多线程下,指令重排序可能会导致一些问题。

四、happens-before 关系模型

4.1 八股:什么是happens-before 关系模型?

happens-before 关系的定义如下:

  1. 如果一个操作 happens-before 另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。

  2. 两个操作之间存在 happens-before 关系,并不意味着 Java 平台的具体实现必须要按照 happens-before 关系指定的顺序来执行。如果重排序之后的执行结果,与按 happens-before 关系来执行的结果一致,那么 JMM 也允许这样的重排序。

总之,如果操作 A happens-before 操作 B,那么操作 A 在内存上所做的操作对操作 B 都是可见的,不管它们在不在一个线程。

4.2 了解:happens-before 关系有哪些?

在 Java 中,有以下天然的 happens-before 关系:

  • 程序顺序规则:一个线程中的每一个操作,happens-before 于该线程中的任意后续操作。
  • 监视器锁规则:对一个锁的解锁,happens-before 于随后对这个锁的加锁。
  • volatile 变量规则:对一个 volatile 域的写,happens-before 于任意后续对这个 volatile 域的读。
  • 传递性:如果 A happens-before B,且 B happens-before C,那么 A happens-before C。
  • start 规则:如果线程 A 执行操作 ThreadB.start()启动线程 B,那么 A 线程的 ThreadB.start()操作 happens-before 于线程 B 中的任意操作。
  • join 规则:如果线程 A 执行操作 ThreadB.join()并成功返回,那么线程 B 中的任意操作 happens-before 于线程 A 从 ThreadB.join()操作成功返回。

举例:

1
2
3
4
int a = 1; // A操作
int b = 2; // B操作
int sum = a + b;// C 操作
System.out.println(sum);

根据以上介绍的 happens-before 规则,假如只有一个线程,那么不难得出:

1
2
3
1> A happens-before B
2> B happens-before C
3> A happens-before C

注意,真正在执行指令的时候,其实 JVM 有可能对操作 A & B 进行重排序,因为无论先执行 A 还是 B,他们都对对方是可见的,并且不影响执行结果。

如果这里发生了重排序,这在视觉上违背了 happens-before 原则,但是 JMM 是允许这样的重排序的。

所以,我们只关心 happens-before 规则,不用关心 JVM 到底是怎样执行的。只要确定操作 A happens-before 操作 B 就行了。

重排序有两类,JMM 对这两类重排序有不同的策略:

  • 会改变程序执行结果的重排序,比如 A -> C,JMM 要求编译器和处理器都禁止这种重排序。
  • 不会改变程序执行结果的重排序,比如 A -> B,JMM 对编译器和处理器不做要求,允许这种重排序。

五、volatile关键字

5.1 八股:volatile关键字的作用

一旦一个共享变量(类的成员变量、类的静态成员变量)被volatile修饰之后,那么就具备了两层语义:

  1. 保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。
  2. 禁止进行指令重排序。
  3. volatile本质是在告诉jvm当前变量在寄存器(工作内存)中的值是不确定的,需要从主存中读取;synchronized则是锁定当前变量,只有当前线程可以访问该变量,其他线程被阻塞住。
  4. volatile仅能使用在变量级别;synchronized则可以使用在变量、方法、和类级别的。
  5. volatile仅能实现变量的修改可见性,并不能保证原子性;synchronized则可以保证变量的修改 可见性和原子性。
  6. volatile不会造成线程的阻塞;synchronized可能会造成线程的阻塞。
  7. volatile标记的变量不会被编译器优化;synchronized标记的变量可以被编译器优化。

5.2 八股:volatile禁止指令重排序(volatile和 happens-before)

在讲 JMM的时候,我们提到了指令重排,相信大家都还有印象,我们来回顾一下重排序需要遵守的规则:

  • 重排序不会对存在数据依赖关系的操作进行重排序。比如:a=1;b=a; 这个指令序列,因为第二个操作依赖于第一个操作,所以在编译时和处理器运行时这两个操作不会被重排序。
  • 重排序是为了优化性能,但是不管怎么重排序,单线程下程序的执行结果不能被改变。比如:a=1;b=2;c=a+b 这三个操作,第一步 (a=1) 和第二步 (b=2) 由于不存在数据依赖关系,所以可能会发生重排序,但是 c=a+b 这个操作是不会被重排序的,因为需要保证最终的结果一定是 c=a+b=3。

使用 volatile 关键字修饰共享变量可以禁止这种重排序。怎么做到的呢?

当我们使用 volatile 关键字来修饰一个变量时,Java 内存模型会插入内存屏障(一个处理器指令,可以对 CPU 或编译器重排序做出约束)来确保以下两点:

  • 写屏障(Write Barrier):当一个 volatile 变量被写入时,写屏障确保在该屏障之前的所有变量的写入操作都提交到主内存。
  • 读屏障(Read Barrier):当读取一个 volatile 变量时,读屏障确保在该屏障之后的所有读操作都从主内存中读取。

换句话说:

  • 当程序执行到 volatile 变量的读操作或者写操作时,在其前面操作的更改肯定已经全部进行,且结果对后面的操作可见;在其后面的操作肯定还没有进行;
  • 在进行指令优化时,不能将 volatile 变量的语句放在其后面执行,也不能把 volatile 变量后面的语句放到其前面执行。

5.3 volatile不保证原子性

比如i++操作:

在Java(以及许多其他编程语言中),i++ 是一个后缀递增操作符,它对变量 i 执行一个复合操作,该操作可以分解为以下三个步骤(尽管在实际执行时这些步骤可能会被优化或合并,但从概念上讲):

  1. 读取(Load):首先,从内存中读取变量 i 的当前值。这个值被加载到CPU的寄存器中,供后续操作使用。
  2. 修改(Increment):然后,将寄存器中的值增加1。这个步骤是在CPU内部完成的,不涉及内存的读写操作(除了可能涉及的缓存一致性协议)。
  3. 写回(Store):最后,将修改后的值写回到内存中变量 i 的位置。这一步确保了内存中 i 的值被更新为新的值。

需要注意的是,在多线程环境中,这三个步骤可能会被其他线程的类似操作打断,导致数据不一致的问题。特别是,如果两个线程几乎同时执行对同一个变量的 i++ 操作,它们可能会: 第一个线程读取了 i 的原始值(比如10)。 第二个线程也读取了 i 的相同原始值(因为第一个线程还没有写回新值)。 第一个线程增加1并将结果(11)写回内存。 第二个线程也增加1(基于它读取的旧值10),并将结果(也是11)写回内存,覆盖了第一个线程的结果。

这就是为什么在多线程环境中,即使使用了 volatile 关键字来确保变量的可见性,i++ 这样的复合操作也仍然需要额外的同步机制来确保原子性。volatile 保证了每次读取 i 时都会从主内存中获取最新值,但它并不保证 i++ 操作的原子性。

解决方式:使用synchronized关键字(重入锁)或者lock接口的实现类ReentrantLock(重入锁)进行加锁,总所周知学线程编程的时候都会学习的两个锁,可以保证原子性

六、synchronized关键字

由于我写累了就不写基本用法了,就是修饰静态代码块,同步方法(实例方法),静态方法(类方法)三种用途,不懂的可以自己去查资料

重点放在关键字保证的线程安全上,synchronized关键字是允许指令重排的,也就是说,满足了 happens-before模型的结果一致原则的指令重排。

另外,synchronized属于重入锁,这部分放到多线程和锁的内容去介绍。