【Java并发基础】Java内存模型解决有序性和可见性

简介: 【Java并发基础】Java内存模型解决有序性和可见性

前言

解决并发编程中的可见性和有序性问题最直接的方法就是禁用CPU缓存和编译器的优化。但是,禁用这两者又会影响程序性能。于是我们要做的是按需禁用CPU缓存和编译器的优化

如何按需禁用CPU缓存和编译器的优化就需要提到Java内存模型。Java内存模型是一个复杂的规范。其中最为重要的便是Happens-Before规则。下面我们先介绍如何利用Happens-Before规则解决可见性和有序性问题,然后我们再扩展简单介绍下Java内存模型以及我们前篇文章提到的重排序概念。

volatile

在前一篇文章介绍编译优化带来的有序性问题时,给出的一个解决办法时将共享变量使用volatile关键字修饰。volatile关键字的作用可以简单理解为①禁用重排序,保证程序的有序性;②禁用缓存,保证程序的可见性。

volatile关键字不是Java语言中的特产,C语言中也有,其最原始的意义就是禁用CPU缓存,使得每次访问均需要直接从内存中读写

如果我们声明一个volatile变量,那么也就会让编译器不能从CPU缓存中去读取这个变量,而必须从内存中读取。

class VolatileExample {
  int x = 0;        // 1
  volatile boolean v = false; //2
  public void writer() {    //3
    x = 42;
    v = true;
  }
  public void reader() {    //4
    if (v == true) {
      // 这里 x 会是多少呢?
    }
  }
}

在这段代码中,假设线程A执行了3即writer()方法,设置了x=42和v=true。线程B执行了4即reader()方法,线程B可以看见线程A设置的v为true,那么B读到的x值会是多少呢?(想一想再点击我) 这要分Java版本来说,在1.5之前,会出现x=0的情况。

由于可见性问题,线程A修改的x可能存储在CPU缓存中对线程B是不可见的,于是线程B获取到的x为0。

在Java1.5之后,线程B获取到的x一定就是42。

这是因为Java内存模型对volatile语义进行了增强。增强体现在Java内存模型中的Happens-Before规则上。

Happens-Before规则

Happends-Before规则表达的是:前面一个操作的结果对之后操作是可见的,描述的是两个操作的内存可见性。

Happens-Before约束了编译器的优化行为,虽允许编译器优化,但是要求编译器遵循一定的Happens-Before规则进行优化。

Happens-Before规则包括:

  • 程序顺序规则
    在一个线程中,前面的操作Happens-Before于后续的任意操作。
  • volatile变量规则
    对volatile变量的写操作相对于之后对该volatile变量的读操作是可见的。(这个语义可等价适用于原子变量)
    对volatile变量的写操作 Happens-Before 对该volatile变量的读操作
  • 传递性
    如果操作A Happens-Before 操作B并且操作B Happens-Before 操作C, 那么操作A Happens-Bofore 操作C。

利用程序顺序规则、volatile变量规则、传递性规则说明例子

根据程序顺序规则,在一个线程中,之前的操作是Happens-Before后续的操作,所以x=42; Happens-Before v=true;;根据volatile变量规则,对volatile变量的写操作相对于之后对该volatile变量的读操作是可见的,于是写变量v=ture;Happens-Before读变量v==true;;根据传递性,得出x=42;Happens-Before读变量v==true;于是,最终读出的x值会是42。

  • 管程中的锁规则
    对同一个锁的解锁 Happens-Before 后续对这个锁的加锁。
    (管程:是一种同步原语,在Java中就是指synchronized。)
  • 线程启动规则
    线程的启动操作(即Thread.start()) Happens-Before 该线程的第一个操作。
    主线程A启动子线程B,那么子线程B能够看到线程A在启动B之前的任意操作。
Thread B = new Thread(()->{
  // 主线程调用 B.start() 之前
  // 所有对共享变量的修改,此处皆可见
  // 此例中,var==77
});
// 此处对共享变量 var 修改
var = 77;
// 主线程启动子线程
B.start();
  • 线程结束规则
    线程的最后一个操作 Happens-Before 它的终止事件。
    主线程A等待子线程B完成(A调用B.join())。当B完成之后(主线程A中的join()返回),主线程A可以看见子线程的操作。看到针对的是对共享变量。
Thread B = new Thread(()->{
  // 此处对共享变量 var 修改
  var = 66;
});
// 例如此处对共享变量修改,
// 则这个修改结果对线程 B 可见
// 主线程启动子线程
B.start();
B.join()
// 子线程所有对共享变量的修改
// 在主线程调用 B.join() 之后皆可见
// 此例中,var==66
  • 中断规则
    线程对其他线程的中断操作 Happens-Before被中断线程所收到中断事件。
    一个线程在另一个线程上调用interrupt,必须在被中断线程检测到interrupt调用之前执行。(被中断线程的InterruptedException异常,或者第三个线程针对被中断线程的Thread.interrupted或者Thread.isInterrupted调用)
  • 析构器规则
    构造器中的最后一个操作 Happens-Before 析构器的第一个操作
    或者说,对象的构造器必须在启动该对象的析构器之前执行完成。

需要注意,A操作 Happens-Before B操作,但并不意味着A操作必须要在B操作之前执行。

Happens-Before表达的是前一个操作执行后的结果是对后续一个操作是可见的,且前一个操作按顺序排在第二个操作之前。

Java内存模型的抽象

共享变量可指代存储与堆内存中的实例域、静态域和数组元素,共享变量是线程间共享的。局部变量、方法定义参数和异常处理器参数不会在线程之间共享,所以,它们不会有内存可见性问题。

Java线程之间的通信由Java内存模型(Java Memory Model, JMM)控制,JMM决定了一个线程对共享变量的写入何时对另一个线程可见。

从抽象角度看,JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存(main memory)中,每个线程都有一个私有的本地内存(local memory),本地内存存储了该线程以读/写共享变量的副本。

本地内存是JMM的一个抽象概念,实际并不存在,它主要是指代缓存、写缓冲区、寄存器以及其他的硬件和编译器优化。

Java内存模型的抽象示意图如下:(图来自程晓明的深入理解Java内存模型)

从上图来看,如果线程A和线程B要进行通信,需要进行两步:

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

线程A和B的通信过过了主内存,JMM通过控制主内存和每个线程的本地内存之间的交互,来为Java程序员提供内存可见性的保证。

重排序

重排序分类

在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排序处理。加上前面提到的编译器优化,重排序可以分为三种类型:

  1. 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。即在单线程中,重排序指令后执行的结果与未重排序执行的结果一致,那么就可以允许这种优化。
  2. 指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-Level Parallelism, ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器便可以改变语句对应机器指令的执行顺序。
  3. 内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。因为缓存可能会改变将写入变量提交到主内存的次序。

as-if-serial属性:在单线程情况下,虽然有可能不是顺序执行,但是经过重排序的执行结果要和顺序执行的结果一致。 编译器和处理器需要保证程序能够遵守as-if-serial属性。

数据依赖性:如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性。编译器和处理器不能对“存在数据依赖关系的两个操作”执行重排序。

从Java源代码到最终执行的指令序列,会经历下面的三种重排序:(图来自程晓明的深入理解Java内存模型)

第一个属于编译器重排序,第二三个属于处理器重排序。这些重排序都可能会导致夺多线程出现内存可见性问题。

针对编译器的重排序,JMM会有编译器重排序规则禁止特定类型的编译器重排序,不会禁止所有类型的编译器重排序。

针对处理器重排序,JMM的处理器重排序规则会要求Java编译器在生成指令序列时,插入特定类型的内存屏障(memory barriers)指令,来禁止特定类型的处理器重排序。

JMM属于语言级的内存模型,它确保在不同的编译器和处理器平台上,通过禁止特定类型的编译器重排序和处理器重排序,为程序员提供一致的内存可见性保证。

内存屏障

处理器架构提供了一些特殊的指令(称为内存屏障)用来在需要共享数据时实现存储协调。JMM使编译器在适当的位置插入内存屏障指令来禁止特定类型的处理器重排序。

内存屏障指令可分为下列四类:

屏障类型 指令示例 说明
LoadLoad Barriers Load1;LoadLoad;Load2 确保Load1数据的装载,之前于Load2及所有后续装载指令的装载。
StoreStore Barriers Store1;StoreStore;Store2 确保Store1数据对其他处理器可见(刷新到内存),之前于Store2及所有后续存储指令的存储。
LoadStore Barriers Load1;LoadStore;Store2 确保Load1数据装载,之前于Store2及所有后续的存储指令刷新到内存。
StoreLoad Barriers Store1;StoreLoad;Load2 确保Store1数据对其他处理器变得可见(刷新到内存),之前于Load2及所有后续装载指令的装载。StoreLoad Barriers会使该屏障之前的所有内存访问指令(存储和装载指令)完成之后,才执行该屏障之后的内存访问指令。

JMM、Happens-Before和重排序规则之间的关系

(图来自程晓明的深入理解Java内存模型)

看图中的概括,一个Happens-Before规则对应于一个或者多个编译器和处理器重排序规则。

对Java程序员来说,只需要熟悉Happens-Before规则,就可以使程序避免遭受内存可见性问题,并且不用为了理解JMM提供的内存可见性保证而学习复杂的重排序规则以及这些规则的具体实现。

再谈volatile

为了不打乱前面的行文思路,于是就在后面补充关于volatile的知识。

volatile变量是Java语言提供的一种较弱的同步机制,用来确保将变量的更新操作都通知到其他线程。将变量声明为volatile类型后,编译器与运行时都会注意到这个变量是共享的,不会将该变量上的操作与其他内存操作一起重排序,即我们前面所说的保证程序有序性。volatile变量不会被缓存在寄存器或者CPU缓存中对其他处理器不可见,读取volatile类型的变量时总会返回最新写入值,即我们前面说的保证程序可见性。然而,频繁地访问 volatile 字段也会因为不断地强制刷新缓存而严重影响程序的性能。

从内存可见性角度来看,写入volatile变量相当于退出同步代码块,而读取volatile变量相当于进入同步代码块。然而,并不建议过度依赖volatile变量提供的可见性。如果在代码中依赖volatile变量来控制状态的可见性,通常比使用锁的代码更脆弱也更加难以理解。(下一篇文章将介绍Java并发中的同步机制)

仅当volatile变量能简化代码的实现以及对同步策略的验证时,才应该使用。

volatile变量的正确使用方式包括:确保自身状态的可见性,确保它们所引用对象的状态的可见性以及标识一些重要的程序生命周期事件的发生(例如,初始化或者关闭)。

下面的例子是volatile变量的一种典型用法:检查某个状态标记以判断是否退出循环。

volatile boolean asleep;
...
    while(!asleep)
        countSomeSheep();

为了能使这个程序正确执行,alseep必须要为volatile变量。否则,当asleep被另外一个线程修改时,执行判断的线程却发现不了。后面也会讲用锁操作也可以确保asleep更新操作的可见性,但是这将会使代码变得复杂。

需要注意,尽管volatile变量经常用于表示某种状态信息如某个操作完成、发生中断或者标记,但是volatile的语义是不足以确保递增操作(count++)的原子性 ,除非确保只有一个线程对变量执行写操作。后面将要介绍的同步机制中的加锁机制既可以确保可见性又可以确保原子性,而volatile变量只能确保可见性。

小结

行文思路总体看起来有点乱ε(┬┬﹏┬┬)3,不过这也不是有意为之。本打算是重点介绍Happens-Before规则,然后稍微介绍一点Java内存模型。可奈何中途瞥见了一个网友力推程晓明的深入理解Java内存模型,于是就去拜读了一遍。看完发现还是要补充介绍一些东西,于是补着补着就乱了。唉,也怪我这深入浅出介绍知识的能力不够,各位看官择其所需看看就好。

参考:

[1]极客时间专栏王宝令《Java并发编程实战》

[2]Brian Goetz.Tim Peierls. et al.Java并发编程实战[M].北京:机械工业出版社,2016

[3]程晓明.深入理解Java内存模型.https://www.infoq.cn/article/java_memory_model

相关文章
|
2月前
|
安全 Java 应用服务中间件
Spring Boot + Java 21:内存减少 60%,启动速度提高 30% — 零代码
通过调整三个JVM和Spring Boot配置开关,无需重写代码即可显著优化Java应用性能:内存减少60%,启动速度提升30%。适用于所有在JVM上运行API的生产团队,低成本实现高效能。
263 3
|
3月前
|
存储 缓存 Java
Java数组全解析:一维、多维与内存模型
本文深入解析Java数组的内存布局与操作技巧,涵盖一维及多维数组的声明、初始化、内存模型,以及数组常见陷阱和性能优化。通过图文结合的方式帮助开发者彻底理解数组本质,并提供Arrays工具类的实用方法与面试高频问题解析,助你掌握数组核心知识,避免常见错误。
|
1月前
|
Java 大数据 Go
从混沌到秩序:Java共享内存模型如何通过显式约束驯服并发?
并发编程旨在混乱中建立秩序。本文对比Java共享内存模型与Golang消息传递模型,剖析显式同步与隐式因果的哲学差异,揭示happens-before等机制如何保障内存可见性与数据一致性,展现两大范式的深层分野。(238字)
60 4
|
1月前
|
安全 Java 数据库连接
一把锁的两种承诺:synchronized如何同时保证互斥与内存可见性?
临界区指多线程中访问共享资源的代码段,需通过互斥机制防止数据不一致与竞态条件。Java用`synchronized`实现同步,保证同一时刻仅一个线程执行临界区代码,并借助happens-before规则确保内存可见性与操作顺序,从而保障线程安全。
126 11
|
1月前
|
存储 缓存 Java
【深入浅出】揭秘Java内存模型(JMM):并发编程的基石
本文深入解析Java内存模型(JMM),揭示synchronized与volatile的底层原理,剖析主内存与工作内存、可见性、有序性等核心概念,助你理解并发编程三大难题及Happens-Before、内存屏障等解决方案,掌握多线程编程基石。
|
2月前
|
缓存 监控 Kubernetes
Java虚拟机内存溢出(Java Heap Space)问题处理方案
综上所述, 解决Java Heap Space溢出需从多角度综合施策; 包括但不限于配置调整、代码审查与优化以及系统设计层面改进; 同样也不能忽视运行期监控与预警设置之重要性; 及早发现潜在风险点并采取相应补救手段至关重要.
495 17
|
3月前
|
监控 Kubernetes Java
最新技术栈驱动的 Java 绿色计算与性能优化实操指南涵盖内存优化与能效提升实战技巧
本文介绍了基于Java 24+技术栈的绿色计算与性能优化实操指南。主要内容包括:1)JVM调优,如分代ZGC配置和结构化并发优化;2)代码级优化,包括向量API加速数据处理和零拷贝I/O;3)容器化环境优化,如K8s资源匹配和节能模式配置;4)监控分析工具使用。通过实践表明,这些优化能显著提升性能(响应时间降低40-60%)同时降低资源消耗(内存减少30-50%,CPU降低20-40%)和能耗(服务器功耗减少15-35%)。建议采用渐进式优化策略。
198 1
|
3月前
|
存储 监控 算法
Java垃圾回收机制(GC)与内存模型
本文主要讲述JVM的内存模型和基本调优机制。
|
4月前
|
SQL 缓存 安全
深度理解 Java 内存模型:从并发基石到实践应用
本文深入解析 Java 内存模型(JMM),涵盖其在并发编程中的核心作用与实践应用。内容包括 JMM 解决的可见性、原子性和有序性问题,线程与内存的交互机制,volatile、synchronized 和 happens-before 等关键机制的使用,以及在单例模式、线程通信等场景中的实战案例。同时,还介绍了常见并发 Bug 的排查与解决方案,帮助开发者写出高效、线程安全的 Java 程序。
227 0
|
3月前
|
边缘计算 算法 Java
Java 绿色计算与性能优化:从内存管理到能耗降低的全方位优化策略与实践技巧
本文探讨了Java绿色计算与性能优化的技术方案和应用实例。文章从JVM调优(包括垃圾回收器选择、内存管理和并发优化)、代码优化(数据结构选择、对象创建和I/O操作优化)等方面提出优化策略,并结合电商平台、社交平台和智能工厂的实际案例,展示了通过Java新特性提升性能、降低能耗的显著效果。最终指出,综合运用这些优化方法不仅能提高系统性能,还能实现绿色计算目标,为企业节省成本并符合环保要求。
146 0

热门文章

最新文章