【Java基础】Java内存模型(Java Memory Model)

86 阅读21分钟

Java内存模型(Java Memory Model)

1. Java内存模型的前导知识

共享内存开发模型

Java采用的是共享内存开发模型,这意味着线程通过共享内存中的公共状态进行通信和同步。

通信

在Java中,线程通过共享内存中的公共状态进行隐式通信。一个线程对共享变量的写入可以被另一个线程读取,从而实现线程间的信息传递。比如,当一个线程更新了一个共享变量,另一个线程可以读取到这个更新后的值,从而实现数据共享。

同步

为了避免多个线程同时修改共享状态导致数据不一致,需要显式地指定某段代码在多个线程之间互斥执行。这通常通过使用 synchronized 关键字或其他同步机制(如 Lock)来实现。这些机制确保了在同一时间只有一个线程可以执行被保护的代码块,从而保证数据的一致性。

内存可见性

内存可见性指的是一个线程对共享变量的修改能够及时被其他线程看到。例如,在一个取钱的场景中,如果一个线程取出一定的金额后,这个修改并没有立即更新到共享变量中,另一个线程在读取时可能会看到旧的值,导致数据不一致。这种情况就是内存可见性问题。

示例:取钱问题

假设有两个线程在操作同一个银行账户:

  • 线程A取出100元。
  • 线程B在几乎同时查看余额。

如果线程A的操作没有及时更新到共享变量中,线程B可能会看到取钱前的余额,导致错误的操作。这种延迟更新就是内存可见性问题的一个典型例子。

共享变量

在Java中,堆和方法区中的变量被称为共享变量。每个线程都可以读取这些区域中的数据。然而,本地栈中的变量(例如局部变量、方法参数、异常处理参数)不会在线程之间共享,因此不会有内存可见性的问题,不受Java内存模型(JMM)的影响。

Java运行时数据区域

因此,内存可见性问题主要针对的是堆中的共享变量。

了解了Java内存模型的基本概念和共享内存开发模型后,我们接下来将深入探讨内存可见性问题的发生原因。

2. 内存可见性问题的发生原因

缓存机制

现代计算机为了提高效率,通常会在高速缓冲区(缓存)中缓存共享变量,以减少读写内存所需的时间。

线程之间的共享变量存在于主存中,每个线程都有一个私有的本地内存,存储了该线程读写共享变量的副本。

注意

本地内存是Java内存模型的一个抽象概念,并不真实存在。它是程序员为了理解和推理多线程程序行为所创造的统一模型,涵盖了缓存、写缓冲区、寄存器等实际存在的硬件资源。在实际运行时,本地内存可能分布在CPU的寄存器、各级缓存(L1、L2、L3等)以及主内存(RAM)。

Java内存模型(JMM)

Java线程之间的通信由Java内存模型(JMM)控制。从抽象的角度来说,JMM定义了线程和主存之间的抽象关系。下图展示了JMM的抽象示意图:

JMM抽象示意图

如果线程A和线程B之间需要通信,必须经过以下两个步骤:

  1. 线程A将本地内存A中更新过的共享变量刷新到主存中。
  2. 线程B从主存中读取线程A所更新的共享变量。

因此,线程A无法直接访问线程B的本地内存,线程间的通信必须经过主存。

本地内存与主存的交互

在多线程环境中,每个线程都有自己的本地内存(缓存),它会缓存一些共享变量的值。当线程B读取共享变量时,首先会检查它的本地内存。如果发现本地缓存的值已经过期(即被其他线程更新过),它会从主存中获取最新的值,并更新自己的本地缓存。这样可以保证线程B读取到的是最新的共享变量值。

内存可见性问题的根本原因

根据JMM的规定,线程对共享变量的所有操作都必须在自己的本地内存中进行,不能直接从主存中读取。这就是内存可见性问题的根本原因。

既然我们已经了解了内存可见性问题的根源,那么接下来我们将探讨如何通过Java内存模型(JMM)来保证内存可见性。

3、如何保证内存可见性

JMM通过控制主内存与每个线程的本地内存之间的交互,来提供内存可见性的保证。

控制交互的方式有以下几点:

  1. 同步块和锁:使用 synchronized 关键字可以确保在进入同步块时,线程从主内存中获取最新的变量值,并在退出同步块时将更新后的值刷新回主内存。
  2. volatile 关键字:标记为 volatile 的变量会直接从主内存读取和写入,确保所有线程都能看到最新的值。对 volatile 变量的读写操作具有可见性和一定的顺序性。
  3. 原子类:如 AtomicInteger 等类,利用底层的 CAS(Compare-And-Swap)操作实现原子性和可见性。
  4. final 关键字:在对象构造完成后,final 字段的值被保证对其他线程可见。

原子性:在Java并发编程中,原子性是指一个或多个操作要么全部执行且在执行过程中不被任何因素打断,要么全部不执行。这是确保代码安全性的关键之一,除此之外,还需要保证代码的可见性和有序性。

在多线程编程中,内存可见性是一个关键问题。通过 Java 内存模型(JMM),我们可以使用同步块、volatile 关键字等机制来确保线程间的内存可见性。这些机制确保一个线程对共享变量的更新能够及时被其他线程看到,从而避免数据不一致的问题。

然而,仅仅解决内存可见性问题还不足以保证线程安全。为了进一步优化性能,编译器和处理器可能会对指令进行重排序。这种重排序可以提高执行效率,但如果不加以控制,可能会导致线程间操作顺序的混乱,进而影响程序的正确性。

因此,JMM 通过 “happens-before” 原则来限制重排序,以确保在并发环境中,程序的执行结果与开发者的预期一致。这种机制允许在保证正确性的前提下进行必要的性能优化。

通过结合内存可见性和重排序控制,我们可以在多线程环境中编写既高效又安全的程序。

在确保了内存可见性之后,我们还需要关注另一个重要问题——重排序,它对多线程程序的正确性有着巨大的影响。

4. JMM与重排序

重排序的作用

重排序可以提升性能,因为编译器和处理器会对指令进行优化排列,以提高执行效率。这种优化利用了CPU的流水线和并行执行能力,减少了等待时间。例如,两个独立的操作可以被重排序,使得它们可以同时执行,而不必严格按照代码的书写顺序。这样可以更好地利用硬件资源,提高程序的运行速度。

重排序的类型

重排序分为以下三种:

  1. 编译器优化重排:编译器在不改变单线程程序语义的前提下,重新安排语句的执行顺序。
  2. 指令并行重排:处理器在执行指令时,可能会改变指令的执行顺序,以提高流水线的利用率。如果不存在数据依赖性(即后一个执行的语句无需依赖前面执行的语句的结果),处理器可以改变语句对应的机器指令的执行顺序。
  3. 内存系统重排:由于处理器使用缓存和读写缓存冲区,这使得加载(load)和存储(store)操作看上去可能是在乱序执行。三级缓存的存在导致内存与缓存的数据同步存在时间差。

重排序示例分析

让我们通过一个简单的示例来理解重排序的影响:

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 性能十分必要,但也带来了乱序的问题

重排序的问题

在多线程环境中,乱序执行可能导致线程间操作顺序的混乱,进而影响程序的正确性。例如,一个线程对共享变量的写操作可能会在另一个线程的读操作之后执行,从而导致数据不一致。

那么我们如何解决乱序的问题?程序员提出了顺序一致性模型。

5. JMM与顺序一致性模型

顺序一致性模型

顺序一致性模型(Sequential Consistency Model)保证程序的执行结果与其在程序代码中定义的顺序一致。这意味着在单线程的视角下,程序的指令会按照书写顺序执行。

示例代码

public class Main {
    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            System.out.print("a1 ");
            System.out.print("a2 ");
            System.out.print("a3 ");
        });
​
        Thread t2 = new Thread(() -> {
            System.out.print("b1 ");
            System.out.print("b2 ");
            System.out.print("b3 ");
        });
​
        t1.start();
        t2.start();
    }
}

在顺序一致性模型中,我们期望的输出结果为:

a1 a2 a3 b1 b2 b3

然而,在实际执行中,输出结果可能并不是我们想要的。这是因为 JMM不保证顺序一致性。线程 t1t2 的执行顺序是不可预测的,可能会交错执行,导致输出结果的顺序不同于代码中的顺序。这意味着在多线程并发执行的情况下,线程之间的操作可能会以不同的顺序被观察到。

重温内存可见性问题

例如,在当前线程把写过的数据缓存在本地内存中,在没有刷新到主存之前,这个写操作仅对当前线程可见;从其他线程的角度来看,这个写操作根本没有被当前线程所执行。只有当前线程把本地内存中写过的数据刷新到主存之后,这个写操作才对其他线程可见。在这种情况下,当前线程和其他线程看到的执行顺序是不一样的。

JMM与顺序一致性模型

在顺序一致性模型中,所有操作完全按照程序的顺序串行执行。但是在JMM中,临界区内(同步块或同步方法中)的代码可以发生重排序(但不允许临界区内的代码“逃逸”到临界区之外,因为会破坏锁的内存语义)。

示例代码分析

public class Main {
    private static int x = 0;
    private static int y = 0;
​
    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            x = 1;
            y = 1;
        });
​
        Thread t2 = new Thread(() -> {
            int a = y;
            int b = x;
            System.out.println("a = " + a + ", b = " + b);
        });
​
        t1.start();
        t2.start();
    }
}

我们期望的输出结果是 a = 1, b = 1,但在实际执行中,可能会观察到 a = 1, b = 0。这是因为JMM允许线程 t1 的操作被重排序。

综上两段代码所述,Java 内存模型(JMM)的设计原则是:在不影响正确同步程序结果的情况下,尽可能允许编译器和处理器进行优化。

对于未同步的多线程程序,JMM 只提供最低限度的安全性:线程读取的值要么是之前某个线程写入的,要么是默认值,不会出现凭空生成的值。

为了确保这种安全性,JVM 在堆上分配对象时,会先将内存空间清零,然后再进行对象分配(这两个步骤是同步的)。

JMM与顺序一致性模型的执行特性差异

未同步程序在 JMM 和顺序一致性内存模型中的执行特性有如下差异:

  1. 单线程顺序:顺序一致性保证单线程内的操作会按程序的顺序执行;JMM 不保证单线程内的操作会按程序的顺序执行(因为重排序,但 JMM 保证单线程下的重排序不影响执行结果)。
  2. 操作可见性:顺序一致性模型保证所有线程只能看到一致的操作执行顺序,JMM 不保证所有线程能看到一致的操作执行顺序(因为 JMM 不保证所有操作立即可见)。
  3. 原子性:顺序一致性模型保证对所有的内存读写操作都具有原子性,而 JMM 不保证对 64 位的 long 型和 double 型变量的写操作具有原子性。

通过对比JMM与顺序一致性模型,我们认识到线程同步机制在多线程编程中的重要性,接下来我们将简要介绍这些机制。

6. 线程同步机制

如果你既想要JMM的内存优化,又想要顺序一致性,可以采用 synchronizedvolatile 等同步机制。虽然本篇主要讨论JMM,但还是需要简要提及这些机制:

  • synchronized:确保在同一时刻只有一个线程可以执行同步代码块,并且在进入和退出同步代码块时,会刷新主内存中的数据,保证可见性和有序性。
  • volatile:保证对变量的读写操作是直接从主内存进行的,从而确保变量的可见性,并禁止对其进行指令重排序优化。

这些同步机制在Java内存模型中扮演着重要角色,帮助程序员在多线程环境中实现正确的内存可见性和操作顺序。

7. JMM与happens-before

为了给程序员提供一种清晰的方式来定义多线程程序中操作的可见性与顺序性,Java内存模型引入了happens-before机制。这个机制定义了操作之间的偏序关系,使得程序员能够明确地知道在什么情况下,一个线程对共享变量的修改对另一个线程是可见的。

happens-before规则

happens-before机制的核心在于定义了两种操作之间的关系。如果一个操作happens-before另一个操作,则前一个操作的结果对后一个操作是可见的,并且前一个操作的执行顺序在后一个操作之前。

示例代码分析

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

根据happens-before机制,在单线程环境中,可以得出以下关系:

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

在这种情况下,A操作和B操作可以被重排序,因为它们对彼此是可见的,并且重排序不会影响最终的执行结果。这种重排序在视觉上似乎违背了happens-before原则,但实际上JMM允许这样的重排序。

多线程环境中的happens-before

在多线程环境中,happens-before规则更加重要。以下是一些常见的happens-before规则:

  1. 程序顺序规则:一个线程内,按照代码顺序,前面的操作happens-before后面的操作。
  2. 监视器锁规则:一个锁的解锁操作happens-before对同一个锁的加锁操作。
  3. volatile变量规则:对一个volatile变量的写操作happens-before后续对这个volatile变量的读操作。
  4. 线程启动规则:主线程A启动子线程B,子线程B中的操作happens-before主线程A检测到子线程B的终止。
  5. 线程终止规则:线程B的所有操作happens-before另一个线程A检测到线程B终止。

8、总结

Java内存模型(Java Memory Model, JMM)是理解多线程并发的重要基础。Java采用共享内存开发模型,线程通过共享内存中的公共状态进行通信和同步。线程间的通信通过共享变量进行隐式通信,而同步则通过使用synchronized等机制进行显式同步,以确保数据一致性和内存可见性。例如,在取钱问题中,线程A取钱后,线程B应及时看到更新后的余额。共享变量存在于堆和方法区中,而本地栈中的变量则不共享。

内存可见性问题主要由于现代计算机的缓存机制引起。为了减少读写内存的时间,计算机会在高速缓冲区中缓存共享变量。JMM定义了线程和主存之间的抽象关系,每个线程有自己的私有缓存,存储共享变量的副本。线程A更新共享变量后需要刷新到主存,线程B则从主存读取更新后的值。

为了保证内存可见性,可以使用同步块和锁(如synchronized)确保线程从主存获取最新值并刷新;使用volatile关键字直接从主内存读写,确保可见性和顺序性;使用原子类如AtomicInteger,通过CAS操作实现原子性和可见性;以及使用final关键字,确保对象构造完成后,final字段对其他线程可见。

重排序是编译器和处理器为了提升性能而对指令执行顺序进行优化的过程,包括编译器优化重排、指令并行重排和内存系统重排。然而,重排序可能导致线程间操作顺序混乱,影响程序的正确性。顺序一致性模型保证程序执行结果与代码顺序一致,但JMM允许重排序,不保证顺序一致性。

在多线程同步机制中,synchronized确保同一时刻只有一个线程执行同步代码块,保证可见性和有序性;而volatile则保证变量读写直接从主内存进行,确保可见性并禁止重排序。JMM中的happens-before规则定义了操作之间的可见性和顺序关系,确保多线程程序的正确性。规则包括程序顺序规则、监视器锁规则、volatile变量规则、线程启动规则和线程终止规则。

总之,Java内存模型通过共享内存中的公共状态进行线程间通信和同步,确保内存可见性,处理重排序问题,并通过happens-before规则定义操作之间的可见性和顺序关系,保障多线程程序的正确性。

问题1:Java内存模型中的共享变量是什么?

答案:在Java中,堆和方法区中的变量被称为共享变量。每个线程都可以读取这些区域中的数据。然而,本地栈中的变量(例如局部变量、方法参数、异常处理参数)不会在线程之间共享,因此不会有内存可见性的问题,不受Java内存模型(JMM)的影响。

问题2:什么是内存可见性问题?

答案:内存可见性问题指的是一个线程对共享变量的修改能够及时被其他线程看到。例如,在一个取钱的场景中,如果一个线程取出一定的金额后,这个修改并没有立即更新到共享变量中,另一个线程在读取时可能会看到旧的值,导致数据不一致。这种情况就是内存可见性问题。

问题3:Java内存模型(JMM)如何保证内存可见性?

答案:JMM通过以下几种方式保证内存可见性:

  1. 同步块和锁:使用synchronized关键字可以确保在进入同步块时,线程从主内存中获取最新的变量值,并在退出同步块时将更新后的值刷新回主内存。
  2. volatile关键字:标记为volatile的变量会直接从主内存读取和写入,确保所有线程都能看到最新的值。
  3. 原子类:如AtomicInteger等类,利用底层的CAS(Compare-And-Swap)操作实现原子性和可见性。
  4. final关键字:在对象构造完成后,final字段的值被保证对其他线程可见。
问题4:什么是重排序?为什么需要重排序?

答案:重排序是指编译器和处理器为了提高性能,可能会对指令进行优化排列,以提高执行效率。这种优化利用了CPU的流水线和并行执行能力,减少了等待时间。例如,两个独立的操作可以被重排序,使得它们可以同时执行,而不必严格按照代码的书写顺序。这样可以更好地利用硬件资源,提高程序的运行速度。

问题5:JMM与顺序一致性模型的主要差异是什么?

答案:JMM与顺序一致性模型的主要差异在于:

  1. 单线程顺序:顺序一致性保证单线程内的操作会按程序的顺序执行;JMM不保证单线程内的操作会按程序的顺序执行(因为重排序,但JMM保证单线程下的重排序不影响执行结果)。
  2. 操作可见性:顺序一致性模型保证所有线程只能看到一致的操作执行顺序,JMM不保证所有线程能看到一致的操作执行顺序(因为JMM不保证所有操作立即可见)。
  3. 原子性:顺序一致性模型保证对所有的内存读写操作都具有原子性,而JMM不保证对64位的long型和double型变量的写操作具有原子性。
问题6:什么是happens-before规则?

答案happens-before规则是Java内存模型(JMM)中定义的一种机制,用于定义操作之间的可见性和顺序关系。如果一个操作happens-before另一个操作,则前一个操作的结果对后一个操作是可见的,并且前一个操作的执行顺序在后一个操作之前。常见的happens-before规则包括:

  1. 程序顺序规则:一个线程内,按照代码顺序,前面的操作happens-before后面的操作。
  2. 监视器锁规则:一个锁的解锁操作happens-before对同一个锁的加锁操作。
  3. volatile变量规则:对一个volatile变量的写操作happens-before后续对这个volatile变量的读操作。
  4. 线程启动规则:主线程A启动子线程B,子线程B中的操作happens-before主线程A检测到子线程B的终止。
  5. 线程终止规则:线程B的所有操作happens-before另一个线程A检测到线程B终止。