Java 入门指南:Java 并发编程 —— JMM Java内存模型

JMM(Java Memory Model,Java 内存模型)(抽象模型)是用来描述和控制多线程之间内存可见性有序性原子性指令重排等问题的规范。

JMM 定义了一组规则,规定了在多线程环境下,线程在执行共享变量的读写操作时所应该遵守的顺序和限制,从而保证多线程之间的数据及时可见、有序和原子性。

![[JMM 模型架构.png]]
图片来源:悟空聊架构

线程的通信和同步问题

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

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

有两种并发模型可以解决这两个问题:
消息传递并发模型共享内存并发模型,Java 使用的是共享内存并发模型

![[不同模型下的线程的通信与同步.png]]

内存可见性

在栈中的变量(局部变量、方法定义的参数、异常处理的参数)不会在线程之间共享,也就不会有内存可见性的问题,也不受内存模型的影响。而在堆中的变量是共享的,一般称之为共享变量

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

JMM 抽象模型

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

![[Pasted image 20231212233758.png]]

  • 主内存属于共享数据区域,包含了堆和方法区

  • 本地内存属于私有数据区域,包含了程序计数器、本地方法栈、虚拟机栈

  1. 所有的共享变量都存在主存中。

  2. 每个线程都保存了一份该线程使用到的共享变量的副本。

  3. 如果线程 A 与线程 B 之间要通信的话,必须经历下面 2 个步骤:

    1. 线程 A 将本地内存 A 中更新过的共享变量刷新到主存中去。
    2. 线程 B 到主存中去读取线程 A 之前已经更新过的共享变量。

线程 A 无法直接访问线程 B 的工作内存,线程间通信必须经过主存。

根据 JMM 的规定,线程对共享变量的所有操作都必须在自己的本地内存中进行,不能直接从主存中读取

内存架构

![[Pasted image 20231214161241.png]]
图片来源:悟空聊架构

  • 主内存:Java堆中对象实例数据部分,对应于物理硬件的内存

  • 工作内存:Java栈中的部分区域,优先存储于寄存器和高速缓存

线程 B 并不是直接去主存中读取共享变量的值,而是先在本地内存 B 中找到这个共享变量,发现这个共享变量已经被更新了,然后本地内存 B 去主存中读取这个共享变量的新值,并拷贝到本地内存 B 中,最后线程 B 再读取本地内存 B 中的新值。

JMM 与 重排序

指令重排序

指令重排序是现代 CPU 为了优化代码执行效率所做出的一种优化技术。重排序指编译器和 CPU 为了最大程度地提高程序运行效率,对指令的执行顺序进行的优化

优化方式
  1. 编译器优化:在不改变单线程程序语义的前提下,通过重新安排指令的执行顺序,来减少程序的耗时。

  2. CPU 优化:现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性(即后一个执行的语句无需依赖前面执行的语句的结果),在执行指令的时间和底层硬件内部处理的时间不同时,通过使指令更好地利用处理器的性能,来减少程序的耗时。

指令重排可以保证串行语义一致,但没有义务保证多线程间的语义也一致

由于现代 CPU 的并行能力非常强,因此对代码顺序的优化也非常敏感。这就可能出现指令重排序问题,也就是指在编译器和 CPU 的优化过程中,程序本来规定的指令执行顺序被改变,从而导致程序出现错误或者异常的情况。

为了避免指令重排序带来的问题,Java 语言提供了 volatile 关键字,它可以保证多线程操作共享变量的可见性、禁止指令重排序。此外,还可以使用 锁机制同步机制 来确保指令的正确执行顺序。

synchronized 不仅保证可见性,同时也保证了原子性(互斥性)

JMM 重排序

  • 重排序不会对存在数据依赖关系的操作进行重排序

    比如:a=1; b=a; 这个指令序列,因为第二个操作依赖于第一个操作,所以在编译时和处理器运行时这两个操作不会被重排序。

  • 重排序是为了优化性能,但是不管怎么重排序,单线程下程序的执行结果不能被改变。

    比如:a=1; b=2; c=a+b 这三个操作,第一步 (a=1) 和第二步 (b=2) 由于不存在数据依赖关系,所以可能会发生重排序,但是 c=a+b 这个操作是不会被重排序的,因为需要保证最终的结果一定是 c=a+b=3

内存屏障

在更底层,JMM 通过内存屏障来实现内存的可见性以及禁止重排序。

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

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

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

  • 当程序执行到 volatile 变量的读操作或者写操作时,在其前面操作的更改肯定已经全部进行,且结果对后面的操作可见;在其后面的操作肯定还没有进行;

  • 在进行指令优化时,不能将 volatile 变量的语句放在其后面执行,也不能把 volatile 变量后面的语句放到其前面执行。

执行到 volatile 变量时,其前面的所有语句都必须执行完,后面所有得语句都未执行。且前面语句的结果对 volatile 变量及其后面语句可见。

as-if-serial

as-if-serial 是一种编译器优化的概念,它允许编译器对程序进行重排序和优化,只要最终的执行结果和串行执行的结果一致即可。

具体来说,as-if-serial 的原则是,编译器和处理器可以对指令进行重排序和优化,只要保证程序最后的执行结果与串行执行的结果一致即可

在单线程情况下,编译器和处理器可以对指令进行并行执行、重排序或者删除一些无关紧要的指令,以提高程序的性能。

但是,as-if-serial 并不意味着编译器和处理器可以违反语言规范所定义的操作顺序或者程序的语义。它仅仅是允许优化的一种原则,其中的优化必须在不改变程序执行结果的前提下进行。

多线程环境下,as-if-serial 原则需要受到线程间的同步机制的限制,比如 volatile 关键字、锁、原子操作等,以保证线程间的协作和一致性。

happens-before

一方面,开发者需要 JMM 提供一个强大的内存模型来编写代码;另一方面,编译器和处理器希望 JMM 对它们的束缚越少越好,这样它们就可以尽可能多的做优化来提高性能,希望的是一个弱的内存模型。

JMM 考虑了这两种需求,并且找到了平衡点,对编译器和处理器来说,只要不改变程序的执行结果(单线程程序和正确同步了的多线程程序),编译器和处理器怎么优化都行。

设计者提出了 happens-before 的概念,更加简单易懂,从而避免了程序员为了理解内存可见性而去学习复杂的重排序规则,以及这些规则的具体实现方法。

happens-before(JSR-133规范) 是 Java 并发编程中的一个重要概念,用于描述同一或不同线程中两个操作之间的关系。如果 操作 A “happens-before” 操作 B,那么操作 A 在时间上已经发生,并且操作 A 的执行结果对操作 B 可见

在 Java 并发编程中,如果两个线程中的两个操作在 “happens-before” 的关系下,那么它们之间的执行顺序是确定的。Java 内存模型通过定义一组规则来确定操作之间的 “happens-before” 关系,以确保多线程程序的可见性和一致性,保证正确同步的多线程程序的执行结果不被重排序改变。

Java 并发编程中的 happens-before 关系包括以下几种情况:

  1. 程序顺序规则:在单个线程中,操作按照编写的顺序执行。

  2. volatile 变量规则:对一个 volatile 变量的写操作 happens-before 后续的读操作。

  3. 锁规则:释放锁的操作 happens-before 后续的获取同一个锁的操作。

  4. 传递性规则:如果 A happens-before B,B happens-before C,那么 A happens-before C。

  5. start() 规则:在一个线程内,start() 方法调用之前的操作 happens-before start() 方法之后的操作。

  6. join() 规则:在一个线程内,join() 方法之后的操作 happens-before join() 方法返回之前的操作。

顺序一致性

顺序一致性是并发编程中的一个重要概念,指的是程序在多线程环境下的执行结果与在顺序执行的理想情况下的执行结果相同

在顺序一致性模型中,所有的线程共享一个全局的执行序列,且所有操作按照它们在该序列中的顺序执行。在多线程并发执行的情况下,程序的执行结果必须与在单线程顺序执行的情况下一致。

顺序一致性要求对于每个线程来说,所有的操作都必须按照顺序一致的方式来执行,必须满足以下条件:

  1. 线程内部的操作必须按照程序的编写顺序执行(程序顺序一致性)。

  2. 不同线程之间的操作,如果它们之间存在数据依赖关系,那么这些操作必须按照顺序一致的方式相互配对执行。

  3. 无论程序是否同步,所有线程都只能看到一个单一的操作执行顺序。即在顺序一致性模型中,每个操作必须是原子性的,且立刻对所有线程可见

在实际的计算机系统中,为了提高性能和效率,可能会采用一些优化手段来改变操作的顺序,从而 违反了顺序一致性。为了解决这个问题,出现了弱一致性模型和内存模型,如 Java 内存模型(Java Memory Model, JMM),提供了更细粒度的内存访问控制和一致性保证。

JMM 不保证数据一致性

JMM 中,临界区内(同步块或同步方法中)的代码可以发生重排序(但不允许临界区内的代码“逃逸”到临界区之外,会破坏锁的内存语义)。

虽然线程 A 在临界区做了重排序,但是因为锁的特性,线程 B 无法观察到线程 A 在临界区的重排序。这种重排序既提高了执行效率,又没有改变程序的执行结果。

同时,JMM 会在退出临界区和进入临界区做特殊的处理,使得在临界区内程序获得与顺序一致性模型相同的内存视图。

JMM 的具体实现方针是:在不改变(正确同步的)程序执行结果的前提下,尽量方便编译期和处理器的优化。

对于未同步的多线程,JMM 只提供最小安全性:线程读取到的值,要么是之前某个线程写入的值,要么是默认值,不会无中生有。

为了实现这个安全性,JVM 在堆上分配对象时,首先会对内存空间清零,然后才会在上面分配对象(这两个操作是同步的)。

如果要保证执行结果一致,那么 JMM 需要禁止大量的优化,对程序的执行性能会产生很大的影响。所以 JMM 没有保证未同步程序的执行结果与该程序在顺序一致性中执行结果一致。

非同步程序

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

  1. 顺序一致性保证单线程内的操作会按程序的顺序执行

    JMM 不保证单线程内的操作会按程序的顺序执行。(因为重排序,但是 JMM 保证单线程下的重排序不影响执行结果)

  2. 顺序一致性模型保证所有线程只能看到一致的操作执行顺序

    JMM 不保证所有线程能看到一致的操作执行顺序。(因为 JMM 不保证所有操作立即可见)

  3. 顺序一致性模型保证对所有的内存读写操作都具有原子性

    JMM 不保证对 64 位的 longdouble 型变量的写操作具有原子性。

volatile

volatile,用于声明变量,用来修饰被不同线程访问和修改的共享变量。在 JVM 底层,volatile 是用内存屏障实现的。

观察汇编代码,对变量加入 volatile 关键字时,会多出一个 lock 前缀指令,lock 前缀指令实际上相当于一个内存屏障(也称内存栅栏)

应用场景和用法

  1. 修饰变量:可以使用 volatile 修饰变量,即 volatile 变量。被 volatile 修饰的变量具有可见性和禁止重排序的特性。

    volatile 修饰的成员变量在每次被线程访问时,都强制从共享内存中重新读取该成员变量的值

    成员变量发生变化时,会强制线程将变化值回写到共享内存。这样在任何时刻,两个不同的线程总是看到某个成员变量的同一个值。一个 volatile 对象引用可能是 null。

  2. 控制循环条件:当多个线程共同操作一个控制循环的标志变量时,可以使用 volatile 确保标志的及时可见性。

  3. 状态标记:当某个变量表示对象的状态,并且该状态可能被多个线程共享和修改时,使用 volatile 修饰该变量可以保证状态的一致性,确保共享变量的状态在不同线程间同步。

  4. 单例模式的实现:在双重检查锁定的单例模式中,可以使用 volatile 关键字修饰实例变量,确保实例的可见性和正确初始化。

volatile变量特性

  1. 可见性:被 volatile 修饰的变量对于所有线程都是可见的。当一个线程修改了 volatile 变量的值,其他线程可以立即看到最新的值,而不会使用过期的缓存副本。

  2. 禁止重排序:被 volatile 修饰的变量的读写操作具有禁止[[JMM Java内存模型#指令重排序|重排序]]的效果。它可以保证 volatile 变量的赋值操作不会被编译器重排序到其他内存操作的前面或后面。

  3. 轻量级同步机制:volatile 提供了一种轻量级的线程同步机制,避免了使用锁造成的线程切换和上下文切换的开销。

使用规范和注意事项

  1. 可见性要求:只有当变量的值可能会被多个线程同时访问并且其中一个线程对变量的写操作,而其他线程需要读取该变量的最新值时,才使用volatile 关键字。

    如果变量只会被单个线程访问,或者多个线程访问但只有某个线程对其进行写操作,那么 volatile 关键字是不必要的。

  2. 不具备原子性volatile 关键字只能确保对变量的读取和写入操作的可见性,但并不能保证复合操作的原子性。对于多线程并发更新的场景,需要使用其他具有原子性保证的机制

  3. 替代锁的使用:在某些场景下,volatile 可以作为一种轻量级的替代锁的机制,用于实现简单的线程同步。但对于复杂的同步需求,volatile不能保证访问变量的线程之间的互斥性

    例如,多个线程对一个 volatile 变量进行自增操作时,可能会发生竞态条件。这种情况下,应该使用其他同步机制(例如 Locksynchronized)来保证原子性。

    如果需要实现互斥访问,应该使用其他同步机制,如 synchronizedReentrantLock 等。

  4. 避免依赖于先前状态:当使用 volatile 关键字修饰变量时,应当避免依赖于变量的先前状态。因为多个线程之间无法保证操作的顺序,某个线程读取到的值可能并不是最新的。

    如果需要依赖于先前状态,那么 volatile 关键字可能并不适用,应该使用其他同步机制。

  5. 深入理解用法:使用 volatile 关键字需要深入理解其特性和适用场景,避免滥用。正确地使用 volatile 可以解决一些特定的并发问题

volatile的缺点

  1. 无法保证原子性:使用 volatile 修饰变量可以保证在多个线程之间的可见性,但不保证原子性。

    如果对于该变量的操作涉及到多个步骤,例如递增或递减操作,那么使用 volatile 修饰的变量可能无法保证原子性,可能会导致数据不一致。

  2. 有限的使用场景volatile 适用于一些简单的并发场景,例如作为标志位或开关,在多个线程之间共享状态信息。

    在复杂的并发操作中,如复合操作或多个变量之间的依赖关系,volatile 可能无法提供足够的保证。

  3. 降低性能:使用 volatile 修饰变量可能会导致一些性能开销。由于volatile 防止了编译器和处理器对变量的优化,可能会导致额外的内存访问和同步操作,从而降低性能。

  4. 可能引发缓存不一致问题:在多核处理器中,每个核心通常都有自己的缓存,使用 volatile 变量可能导致缓存不一致的问题。

    当一个线程修改了 volatile 变量的值,其他线程需要立即看到这个变化,但是缓存一致性协议(如MESI)可能需要时间来更新其他核心的缓存,这可能会导致一定的延迟。

  5. 不保证线程安全:使用 volatile 修饰变量可以确保可见性,但并不提供互斥性。如果多个线程对于同一个 volatile 变量进行写操作,可能会导致竞态条件和数据不一致性

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值