并发编程 之 Java内存模型(详解)

Java 内存模型(JMM,Java Memory Model)可以说是并发编程的基础,跟众所周知的Java内存区域(堆、栈、程序计数器等)并不是一个层次的划分;

JMM用来屏蔽各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果;

本篇将从JMM的结构划分、交互协议、特定类型变量的特殊规则、"并发三大特性" 等方面进行详尽说明。

理论指导实践,实践检验理论:理论+代码的方式,帮助大家更容易理解。

目录

1. 主内存与工作内存

2. 内存间交互协议

    2.1  八种操作

    2.2   操作规则

 3. volatile 型变量的特殊规则

      3.1 可见性

         3.1.1 场景1 代码示例

        3.1.2  场景2 代码示例

        3.2  禁止指令重排

        3.3 有关volatile其他补充

4. double和long类型的特殊规则

5.  先行发生原则

6.总概


 1. 主内存与工作内存

         在物理机上,处理器的运算速度远远超过存储设备的IO操作,所以计算机系统通常会加入一层或多层读写速度尽可能接近CPU算力的高速缓存,运算完成后同步回主内存。       

        这就引出了经典的高速缓存与主内存的 缓存一致性 问题。为了解决一致性问题,每个处理器读写时都要遵循一些协议(例如 MSIMOSI、 Synapse、Firefly等)。

         其实物理机遇到的并发问题跟JVM虚拟机有很大相似之处,下面结合一张图来探讨JMM。

       Java内存模型说明:

         以下提到的变量与写代码中常说的有所区别,主要包括实例字段、静态字段、构成数组对象的元素等,但不包括局部变量、方法参数,因为后者是私有的不会共享,自然没有竞争问题。

        1)JMM规定所有的变量都存储在主内存中;

        2)工作内存中保存了所使用变量的主内存副本 ;

        3) 线程的所有操作都只能在属于自己私有的工作内存中进行,不能直接操作主内存;

        4)线程间无法互访工作内存,变量值的传递只能通过主内存来完成。

        特别说明:

  •   如果局部变量是个reference类型,那引用的对象在堆中是共享的,但reference本身是栈私有
  •   JMM不限制即时编译器优化代码执行顺序以提高性能。

2. 内存间交互协议

      本节内容 作为开发人员并不需要过分关注 (开发虚拟机的除外🤭),仅作了解即可。

    2.1  八种操作

       JMM中定义了8种操作来实现工作内存与主内存间的交互协议,Jvm虚拟机在实现时必须保证每种操作都是原子的,不可再分的。(double和long类型有所例外,稍后讨论)

       

1
lock (锁定)
作用于主内存的变量,它把一个变量标识为一条线程独占的状态
2
unlock (解锁)
作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
3
read (读取)
作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的load 动作使用
4
load (载入)
作用于工作内存的变量,它把 read 操作从主内存中得到的变量值放入工作内存的变量副本中。
5
use (使用)
作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。
6
assign (赋值)
作用于工作内存的变量,它把一个从执行引擎接收的值赋给工作内存的变量, 每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
7
store (存储)
作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随后的write 操作使用。
8
write (写入)
作用于主内存的变量,它把 store 操作从工作内存中得到的变量的值放入主内存的变量中。

             如果要把一个变量从主内存拷贝到工作内存,那就要按顺序执行readload操作,如果要把变量从工作内存同步回主内存,就要按顺序执行storewrite操作;

             注意JMM只要求上述俩操作必须顺序执行,并不要求连续执行。

      2.2   操作规则

             Java内存模型还规定了在执行上述8种基本操作时必须满足如下规则限定:  

  •      不允许readloadstorewrite操作之一单独出现,即不允许一个变量从主内存读取了但工作内 存不接受,或者工作内存发起回写了但主内存不接受的情况出现。
  •      不允许一个线程丢弃它最近的assign操作,即变量在工作内存中改变了之后必须把该变化同步回 主内存。
  •      不允许一个线程无原因地(没有发生过任何assign操作)把数据从线程的工作内存同步回主内存 中。
  •      一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化(loadassign)的变量,换句话说就是对一个变量实施usestore操作之前,必须先执行assignload操作。
  •      一个变量在同一个时刻只允许一条线程对其进行lock操作,但lock操作可以被同一条线程重复执 行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。
  •      如果对一个变量执行lock操作,那将会清空工作内存中此变量的值,在执行引擎使用这个变量 前,需要重新执行loadassign操作以初始化变量的值。
  •     如果一个变量事先没有被lock操作锁定,那就不允许对它执行unlock操作,也不允许去unlock一个 被其他线程锁定的变量。
  •     对一个变量执行unlock操作之前,必须先把此变量同步回主内存中(执行storewrite操作)。

        这8种内存访问操作以及上述规则限定,再加上针对volatile的一些特殊规定,已经能准确地描述出Java程序中哪些内存访问操作在并发下才是安全的。

        备注:基于理解难度和严谨性考虑,Java团队将8八种操作简化为4种(lock、unlock、read、write) 来定义JMM的访问协议,仅仅是描述方式改变了,Java内存模型并没有改变。

 3. volatile 型变量的特殊规则

        用volatile修饰变量可以说是虚拟机提供的最轻量级的同步机制 (注意不是锁),正确了解volatile语义不管是平时写代码还是阅读JDK源码都至关重要。

        Java内存模型为volatile专门定义了一些特殊的访问规则,当变量被volatile修饰后,将具备两个特性:可见性和禁止指令重排

      3.1 可见性

          基本定义:当一个线程修改了volatile变量,新值对其他线程来说是立即可见的。换句话说对volatile变量的所有写操作都会立刻反映到其他线程中。
        
          常见误区
          问:volatile变量在各线程间是一致的,所以基于volatile变量的运算也是并发安全的吧?
          答:Java里的运算操作符并非原子操作,这导致volatile变量的运算在并发下也是不安全的。
        
         由于volatile变量只能保证可见性,在不符合以下两条规则的运算场景中,我们仍然要通过加锁(synchronized、java.util.concurrent中的锁或原子类)来保证原子性:
        场景1)运算结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量;
        场景2)变量不需要与其他的状态变量共同参与不变约束。

         3.1.1 场景1 代码示例

             我们用一个volatile Integer的自增来举例,下面代码保证只被一条线程修改值,执行多少次得到的计算结果都是对的。

package org.springblade.test;

import com.google.common.util.concurrent.ThreadFactoryBuilder;

import java.util.concurrent.*;

/**
 * @Auther: liuzujie
 * @Date: 2025/1/7 20:19
 * @Desc: JMM测试类
 */
public class JMMTest {
	private static ThreadFactory threadFactory = new ThreadFactoryBuilder().setNameFormat("jmm-test-thread-%s")
		.setDaemon(false).build();

	private static ThreadPoolExecutor executor = new ThreadPoolExecutor(
		12, 25, 60L, TimeUnit.SECONDS,
		new LinkedBlockingQueue<>(2560), threadFactory, new ThreadPoolExecutor.AbortPolicy()
	);

	private volatile static Integer counter = 0;  // 使用 volatile 修饰,保证可见性,但不保证原子性

	public static void main(String[] args) {
		Future<Integer> future = executor.submit(new Callable<Integer>() {
			@Override
			public Integer call() throws Exception {
				for (int i = 0; i < 1000; i++) {
					counter++;
				}
				return counter;
			}
		});
		try {
			System.out.println("只有一条线程,volatile运算原子性测试:" + future.get());
		} catch (InterruptedException e) {
			e.printStackTrace();
		} catch (ExecutionException e) {
			e.printStackTrace();
		}
		executor.shutdown();
	}
	
}

          结果永远是正确值1000。

         启动两条线程各增50万会怎样呢?答案是f1和f2每次都是千差万别不一样

public static void main(String[] args) {
		Future<Integer> f1 = executor.submit(new Callable<Integer>() {
			@Override
			public Integer call() throws Exception {
				for (int i = 0; i < 500000; i++) {
					counter++;
				}
				return counter;
			}
		});
		Future<Integer> f2 = executor.submit(new Callable<Integer>() {
			@Override
			public Integer call() throws Exception {
				for (int i = 0; i < 500000; i++) {
					counter++;
				}
				return counter;
			}
		});
		try {
			System.out.printf("两条线程,volatile运算测试。f1:%d,f2:%d", f1.get(), f2.get());
		} catch (InterruptedException e) {
			e.printStackTrace();
		} catch (ExecutionException e) {
			e.printStackTrace();
		}
		executor.shutdown();
	}

         由此可见像 counter++ 这种依赖当前值的操作,在并发下是不安全的,结果完全不可控。

         想得到正确结果除了用原子类、加锁之类,最简单的方法就是把线程池改成只能有一条线程的单例线程池😀 不就符合单线程场景了嘛。

            但这并不是我们想要的...

        3.1.2  场景2 代码示例

          volatile非常适合做各种标识,例如下面shutdown为true时进行资源回收。

private volatile static Boolean shutdonw = false;

	public static void main(String[] args) {

		executor.execute(new Runnable() {
			@Override
			public void run() {
				while (!shutdonw) {
					System.out.println("使用中.");
				}
				System.out.println("进行资源回收...");
			}
		});
		executor.execute(new Runnable() {
			@Override
			public void run() {
				try {
					shutdonw = true;
					System.out.println("设置关闭");
				} catch (Exception e) {
					e.printStackTrace();
				}
			}
		});

		executor.shutdown();
	}

          打印信息的先后顺序意义不大,只有保证程序执行的正确性即可。

         3.2  禁止指令重排

            指令重排是现代处理器为优化性能而在执行指令时重新安排指令顺序的行为,目的是提高 CPU 的流水线效率。但在并发程序中,指令重排可能会导致线程间的可见性问题和逻辑错误。

           由于指令重排很难复现,这里也是举个真实的编码场景来解释该问题:线程A用来初始化配置,线程B使用配置。(代码中的注释足以说明潜在问题,控制台的NullPointerException是模拟的)

    private static Boolean init = false;
	private static Map<String, String> config = Maps.newHashMapWithExpectedSize(1);

	public static void main(String[] args) {

		//线程A初始化配置
		executor.execute(new Runnable() {
			@Override
			public void run() { //以下两行代码有指令重排的可能
				config.put("name", "张三");
				init = true;
			}
		});
		//线程B使用配置
		executor.execute(new Runnable() {
			@Override
			public void run() {
				while (!init) {
					System.out.println(init);
				}
				System.out.println(config.get("name").trim()); // 如果线程A指令重排,此处会空指针
			}
		});

		executor.shutdown();
	}

       事实上,运行上面代码时不只有空指针问题。由于没有同步,线程B得到的init可能是个失效值,而且可能永远都是个失效值false,导致死循环(活跃性问题);而且还有可能出现进入while 打印 true的情况。

        一句话:失效数据不仅会导致严重的安全问题或活跃性问题,还会出现一些令人困惑的故障,比如意料之外的异常、被破坏的数据结构、不正确的计算和死循环等。

        3.3 有关volatile其他补充

          基本原理:volatile通过引入内存屏障机制,禁止对变量的指令重排,确保对 volatile 变量的读写按程序的顺序执行,从而避免了指令重排导致的线程间的可见性和有序性问题。

         性能对比: 某些情况下volatile同步性能要优于Java提供的各种锁,但由于JVM对锁进行了大量优化和消除,所以性能并不能成为我们选型时核心关注点;

                         在volatile修饰的变量和普通变量对比时,读性能几乎没有差别,写性能可能略逊于普通变量,因为它需要在本地代码中插入许多内存屏障指令来保证处理器不发生乱序执行。

         局限性:  volatile并不能完全代替各种锁,它仅保证可见性和禁止指令重排,不具有原子性

         使用场景: 仅当volatile变量能简化代码实现以及对同步策略的验证时,才应该使用他们,如果在验证正确性时需要对可见性进行复杂判断,则不适合使用volatile变量。它通常适合用做操作完成、发生中断或各种状态的标识,比如标识一些重要的程序生命周期事件(例如初始化、关闭等)。

4. double和long类型的特殊规则

       在聊特殊规则之前,先了解一个概念,最低安全性保证

       当线程在没有同步的情况下读取变量时,可能会得到一个失效值,但至少是由之前某个线程设置的值,而不是一个随机值。

        这种安全性保证适用于绝大多数变量,但64位的数值变量double和long不适用。Java内存模型要求八种操作都具有原子性,但是对于64位的数据类型(long和double),在模型中特别定义了一条宽松的规定:允许虚拟机将没有被volatile修饰的64位数据的读写操作划分为两次32位的操作来进行,即允许虚拟机实现自行选择是否要保证64位数据类型的load、store、read和write这四个操作的原子性,这就是所谓的“long和double的非原子性协定”。

        在实际开发中,除非该数据有明确可知的线程竞争,否则我们在编写代码时一般不需要因为这个原因刻意把用到的longdouble变量专门声明为volatile

5.  先行发生原则

        在JMM中该原则非常重要,它是判断数据是否存在竞争,线程是否安全的非常有用的手段。依赖这个原则,可以通过8条简单规则解决并发环境下两个操作之间是否可能存在冲突的所有问题,而不陷入Java内存模型苦涩难懂的定义中。

        具体虚拟机实现,必要确保这8条原则:

  • 程序次序规则(Pragram Order Rule) 在一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作。准确地说应该是控制流顺序而不是程序代码顺序,因为要考虑分支、循环结构。
  • 对象锁(监视器锁)法则(Monitor Lock Rule ) 某个 管程(也叫做对象锁,监视器锁) 上的unlock动作happens-before同一个管程上后续的lock动作 。这里必须强调的是同一个锁,而”后面“是指时间上的先后。
  • volatile变量规则(Volatile Variable Rule) 对某个volatile字段的写操作happens- before每个后续对该volatile字段的读操作,这里的”后面“同样指时间上的先后顺序。
  • 线程启动规则(Thread Start Rule) 在某个线程对象 上调用start()方法happens- before该启动了的线程中的任意动作
  • 线程终止规则(Thread Termination Rule) 某线程中的所有操作都先行发生于对此线程的终止检测,我们可以通过Thread.join()方法结束(任意其它线程成功从该线程对象上的join()中返回),Thread.isAlive()的返回值等作段检测到线程已经终止执行。
  • 线程中断规则(Thread Interruption Rule) 对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测是否有中断发生
  • 对象终结规则(Finalizer Rule) 一个对象初始化完成(构造方法执行完成)先行发生于它的finalize()方法的开始
  • 传递性(Transitivity) 如果操作A先行发生于操作B,操作B先行发生于操作C,那就可以得出操作A先行发生于操作C的结论

        为了更好的理解先行发生原则,举几个经常接触的

        1) 将一个元素放入一个线程安全的容器Happens-Before从容器中取出这个元素;

        2) Future 任务的所有操作Happens-Before Future.get()操作;

        3) 向线程池提交任务要Happens-Before 任务执行;

        先行发生原则在Java内存模型中通过定义操作的先后顺序,确保了在多线程环境中,线程间对共享变量的修改和访问能够保持一致性和可见性。理解先行发生原则有助在并发编程中避免常见的并发问题如竞态条件和内存可见性问题。

6.总概

    Java内存模型就是围绕在并发过程中如何处理原子性、可见性和有序性这三个特征来建立的;

     在并发编程时提供了内存访问规则,确保不同线程之间的内存可见性和操作有序性;

     通过使用如volatile、锁等同步机制避免内存一致性错误等。正确理解JMM原理和同步机制至关重要,能够帮助我们编写更高效、更安全的并发程序。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:/a/951286.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

[QCustomPlot] 交互示例 Interaction Example

本文是官方例子的分析: Interaction Example 推荐笔记: qcustomplot使用教程–基本绘图 推荐笔记: 4.QCustomPlot使用-坐标轴常用属性 官方例子需要用到很多槽函数, 这里先一次性列举, 自行加入到qt的.h中.下面开始从简单的开始一个个分析. void qcustomplot_main_init(void); …

WPF控件Grid的布局和C1FlexGrid的多选应用

使用 Grid.Column和Grid.Row布局&#xff0c;将多个C1FlexGrid布局其中&#xff0c;使用各种事件来达到所需效果&#xff0c;点击复选框可以加载数据到列表&#xff0c;移除列表的数据&#xff0c;自动取消复选框等 移除复选框的要注意&#xff01;&#xff01;&#xff01;&am…

04、Redis深入数据结构

一、简单动态字符串SDS 无论是Redis中的key还是value&#xff0c;其基础数据类型都是字符串。如&#xff0c;Hash型value的field与value的类型&#xff0c;List型&#xff0c;Set型&#xff0c;ZSet型value的元素的类型等都是字符串。redis没有使用传统C中的字符串而是自定义了…

生物医学信号处理--随机信号的数字特征

前言 概率密度函数完整地表现了随机变量和随机过程的统计性质。但是信号经处理后再求其概率密度函数往往较难&#xff0c;而且往往也并不需要完整地了解随机变量或过程的全部统计性质只要了解其某些特定方面即可。这时就可以引用几个数值来表示该变量或过程在这几方面的特征。…

LabVIEW数据库管理系统

LabVIEW数据库管理系统&#xff08;DBMS&#xff09;是一种集成了数据库技术与数据采集、控制系统的解决方案。通过LabVIEW的强大图形化编程环境&#xff0c;结合数据库的高效数据存储与管理能力&#xff0c;开发人员可以实现高效的数据交互、存储、查询、更新和报告生成。LabV…

合并模型带来的更好性能

研究背景与问题提出 在人工智能领域&#xff0c;当需要处理多个不同任务时&#xff0c;有多种方式来运用模型资源。其中&#xff0c;合并多个微调模型是一种成本效益相对较高的做法&#xff0c;相较于托管多个专门针对不同任务设计的模型&#xff0c;能节省一定成本。然而&…

Virgo:增强慢思考推理能力的多模态大语言模型

每周跟踪AI热点新闻动向和震撼发展 想要探索生成式人工智能的前沿进展吗&#xff1f;订阅我们的简报&#xff0c;深入解析最新的技术突破、实际应用案例和未来的趋势。与全球数同行一同&#xff0c;从行业内部的深度分析和实用指南中受益。不要错过这个机会&#xff0c;成为AI领…

本地缓存:Guava Cache

这里写目录标题 一、范例二、应用场景三、加载1、CacheLoader2、Callable3、显式插入 四、过期策略1、基于容量的过期策略2、基于时间的过期策略3、基于引用的过期策略 五、显示清除六、移除监听器六、清理什么时候发生七、刷新八、支持更新锁定能力 一、范例 LoadingCache<…

Android adb shell GPU信息

Android adb shell GPU信息 先 adb shell 进入控制台。 然后&#xff1a; dumpsys | grep GLES Android adb shell命令捕获systemtrace_android 抓trace-CSDN博客文章浏览阅读2.5k次&#xff0c;点赞2次&#xff0c;收藏8次。本文介绍了如何使用adbshell命令配合perfetto工…

ElasticSearch | Elasticsearch与Kibana页面查询语句实践

关注&#xff1a;CodingTechWork 引言 在当今大数据应用中&#xff0c;Elasticsearch&#xff08;简称 ES&#xff09;以其高效的全文检索、分布式处理能力和灵活的查询语法&#xff0c;广泛应用于各类日志分析、用户行为分析以及实时数据查询等场景。通过 ES&#xff0c;用户…

RK3588平台开发系列讲解(系统篇)Linux Kconfig的语法

文章目录 一、什么是Kconfig二、config模块三、menuconfig四、menu 和 endmenu五、choice 和 endchoice六、source七、depends on八、default九、help十、逻辑表达式沉淀、分享、成长,让自己和他人都能有所收获!😄 一、什么是Kconfig Kconfig的语法及代码结构非常简单。本博…

STM32 USB组合设备 MSC CDC

STM32 USB组合设备 MSC CDC实现 教程 教程请看大佬niu_88 手把手教你使用USB的CDCMSC复合设备&#xff08;基于stm32f407&#xff09; 大佬的教程很好&#xff0c;很详细&#xff0c;我调出来了&#xff0c;代码请见我绑定的资源 注意事项 值得注意的是&#xff1a; 1、 cu…

深入学习RabbitMQ的Direct Exchange(直连交换机)

RabbitMQ作为一种高性能的消息中间件&#xff0c;在分布式系统中扮演着重要角色。它提供了多种消息传递模式&#xff0c;其中Direct Exchange&#xff08;直连交换机&#xff09;是最基础且常用的一种。本文将深入介绍Direct Exchange的原理、应用场景、配置方法以及实践案例&a…

Node.js——path(路径操作)模块

个人简介 &#x1f440;个人主页&#xff1a; 前端杂货铺 &#x1f64b;‍♂️学习方向&#xff1a; 主攻前端方向&#xff0c;正逐渐往全干发展 &#x1f4c3;个人状态&#xff1a; 研发工程师&#xff0c;现效力于中国工业软件事业 &#x1f680;人生格言&#xff1a; 积跬步…

【Verdi实用技巧-Part2】

Verdi实用技巧-Part2 2 Verdi实用技巧-Part22.1 Dump波形常用的task2.1.1 Frequently Used Dump Tasks2.1.2 Demo 2.2 提取波形信息小工具--FSDB Utilities2.3 Debug in Source code view2.3.1 Find Scopes By Find Scope form 2.3.2 Go to line in Souce code View2.3.3 Use B…

web-前端小实验4

实现以上图片中的内容 代码 <!DOCTYPE html> <html lang"en"> <head><meta charset"UTF-8"><meta name"viewport" content"widthdevice-width, initial-scale1.0"><title>用户注册</title&…

NLP项目实战——基于Bert模型的多情感评论分类(附数据集和源码)

在当今数字化的时代&#xff0c;分析用户评论中的情感倾向对于了解产品、服务的口碑等方面有着重要意义。而基于强大的预训练语言模型如 Bert 来进行评论情感分析&#xff0c;能够取得较好的效果。 在本次项目中&#xff0c;我们将展示如何利用 Python 语言结合transformers库&…

TAS测评倍智题库 | 益丰大药房2025年中高层测评BA商业推理测评真题考什么?

您好&#xff01;您已被邀请参加360评估。您的评估与反馈将有助于被评估人更深入地了解个人情况&#xff0c;发现个人优势和潜在风险。请您秉持公正、开放的心态进行评估。请尽快完成评估&#xff0c;在此衷心感谢您的配合与支持&#xff01; ​ 相关事宜&#xff1a; 请您在…

优秀的大模型会不会做坏事?

主要围绕大型语言模型&#xff08;LLMs&#xff09;在特定情境下可能出现的欺骗行为及相关研究展开&#xff0c;具体如下&#xff1a; 研究背景与核心发现&#xff1a;研究发现即使在用户无意激励的情况下&#xff0c;LLMs 也可能说谎&#xff0c;而能使用工具的模型更易被诱导…

fiscoBcos落盘加密介绍

落盘加密 落盘加密是在机构内部进行的&#xff0c;每个机构独立管理自己硬盘数据的安全。内网中&#xff0c;每个节点的硬盘数据是被加密的。所有加密数据的访问权限&#xff0c;通过Key Manager来管理。Key Manager是部署在机构内网内&#xff0c;专门管理节点硬盘数据访问秘…