Java内存模型学习笔记
本随笔目录:
一.内存模型的相关概念与缓存一致性问题
二.并发编程中的三个概念
三.Java内存模型
1、内存模型相关知识与缓存一致性问题
- 程序运行过程中的临时数据是存放在主存(物理内存)当中的,但CPU执行速度很快,而从内存读取数据 和 向内存写入数据 的速度 跟 CPU执行指令的速度比起来要慢的多,因此如果任何时候对数据的操作都要通过和内存的交互来进行,会大大降低指令执行的速度
- 为了弥补CPU与内存速度上的差距,在CPU里面就有了高速缓存
- 当程序在运行过程中,会将运算需要的数据从主存复制一份到CPU的高速缓存当中,那么CPU进行计算时就可以直接从它的高速缓存读取数据和向其中写入数据,当运算结束之后,再将高速缓存中的数据刷新到主存当中
- 举个例子:以前没有书包,每次上课的时候,得回家去拿课本;现在用书包把书装起来,上课直接拿出来
i=i+1; //当线程执行这个语句时,会先从主存当中读取i的值,然后复制一份到高速缓存当中,然后CPU执行指令对i进行加1操作,然后将数据写入高速缓存,最后将高速缓存中i最新的值刷新到主存当中
- 在多核CPU中,每条线程可能运行于不同的CPU中,因此每个线程运行时有自己的高速缓存(对单核CPU来说,其实也会出现这种问题,只不过是以线程调度的形式来分别执行的)
- 例如:2个线程执行这段代码,初始时i的值为0,那么我们希望两个线程执行完之后i的值变为2。但是事实会是这样吗?
- 可能存在下面一种情况:初始时,两个线程分别读取i的值,存入各自所在的CPU的高速缓存当中,然后线程1进行加1操作,然后把i的最新值1写入到内存;此时线程2的高速缓存当中i的值还是0,进行加1操作之后,i的值为1,然后线程2把i的值写入内存
- 最终结果i的值是1,而不是2。这就是著名的缓存一致性问题;通常称这种被多个线程访问的变量为共享变量
- 也就是说,如果一个变量在多个CPU中都存在缓存(一般在多线程编程时才会出现),那么就可能存在缓存不一致的问题
1.1、总线锁 与 缓存一致性协议
为了解决缓存不一致性问题,通常来说有以下2种解决方法(硬件层面上提供的方式):
1)在总线加 LOCK#锁 的方式
2)缓存一致性协议
- 1.总线锁<锁住其他CPU的总线,阻塞其它CPU对内存的访问>:在早期的CPU当中,是通过在总线上加 LOCK#锁 的形式来解决缓存不一致的问题。因为CPU和其他部件进行通信都是通过总线来进行的,如果对总线加LOCK#锁的话,也就是说阻塞了其他CPU对其他部件访问(如内存),从而使得只能有一个CPU能使用这个变量的内存。比如上面例子中 如果一个线程在执行 i=i+1,如果在执行这段代码的过程中,在总线上发出了LCOK#锁的信号,那么只有等待这段代码完全执行完毕之后,其他CPU才能从变量i所在的内存读取变量,然后进行相应的操作。这样就解决了缓存不一致的问题
缺点:锁住总线期间,其他CPU无法访问内存,导致效率低下 - 2.缓存一致性协议:最出名的就是Intel的MESI协议,其保证了每个缓存中使用的共享变量的副本是一致的;
它核心的思想是:当CPU写数据时,如果发现操作的变量是共享变量,即在其他CPU的缓存中也存在该变量的副本,会发出信号通知其他CPU将该变量的缓存行置为无效状态,因此当其他CPU需要读取这个变量时,发现 缓存中 缓存的 该变量的 缓存行 是无效的,那么它就会从内存重新读取
2、并发编程中的三个概念:原子性、可见性、有序性
-
- 原子性:一个或者多个操作 要么全部执行并成功且执行的过程不会被任何因素打断,要么就都不执行
例如:经典的银行转帐问题
- 原子性:一个或者多个操作 要么全部执行并成功且执行的过程不会被任何因素打断,要么就都不执行
-
- 可见性:指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值
例如:
- 可见性:指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值
//线程1执行的代码
int i=0;
i=10;
//线程2执行的代码
j=i;
-
- 假若执行线程1的是CPU1,执行线程2的是CPU2。由上面的分析可知,当线程1执行 i=10;这句时,会先把i的初始值加载到CPU1的高速缓存中,然后赋值为10,那么在CPU1的高速缓存当中i的值变为10了,却没有立即写入到主存当中
-
- 此时线程2执行 j = i; 它会先去主存读取i的值并加载到CPU2的缓存中,注意此时内存当中i的值还是0,那么就会使得j的值为0,而不是10
总结:可见性问题,一个线程对共享变量修改后,其他线程没有看到这个修改
- 此时线程2执行 j = i; 它会先去主存读取i的值并加载到CPU2的缓存中,注意此时内存当中i的值还是0,那么就会使得j的值为0,而不是10
-
- 有序性:程序的执行顺序与代码的先后顺序相同
int i=0;
boolean j=true;
i=10; //语句1
j=false; //语句2
-
- 上面代码定义了一个int型变量i,定义了一个boolean类型变量就,然后分别对两个变量进行赋值操作;从代码顺序上看,语句1是在语句2前面的,那么JVM在真正执行这段代码的时候会保证语句1一定会在语句2前面执行吗?不一定,为什么呢?这里可能会发生指令重排序(Instruction Reorder)
2.1、指令重排序(Instruction Reorder)
总结:保证最终结果的一致性
处理器为了提高程序运行效率,可能会对输入代码进行优化,它不保证程序中各个语句的执行先后顺序同代码中的顺序一致,但是它会保证程序最终执行结果 和 代码顺序执行的结果是一致的
2.2、为什么要指令重排序?指令重排序如何提高程序运行效率?
总结:尽量CPU减少读取和保存的次数
上图左侧是 3 行 Java 代码,右侧是这3行代码可能被转化成的指令;可以看出 a = 100 对应的是 Load a、Set to 100、Store a,意味着从主存中读取 a 的值,然后把值设置为 100,并存储回去,同理, b = 5 对应的是下面三行 Load b、Set to 5、Store b,最后的 a = a + 10,对应的是 Load a、Set to 110、Store a。如果你仔细观察,会发现这里有两次“Load a”和两次“Store a”,说明存在一定的重排序的优化空间。
重排序后, a 的两次操作被放到一起,指令执行情况变为 Load a、Set to 100、Set to 110、 Store a。下面和 b 相关的指令不变,仍对应 Load b、 Set to 5、Store b。
可以看出,重排序后 a 的相关指令发生了变化,节省了一次 Load a 和一次 Store a
总结:重排序通过减少执行指令,从而提高整体的运行速度,这就是重排序带来的优化和好处
2.3、指令重排序的 3 种情况
编译器重排序、CPU重排序、内存“重排序”
3、Java内存模型
在Java虚拟机规范中试图定义一种Java内存模型(Java Memory Model,JMM)来屏蔽各个硬件平台和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果;
JMM,定义了程序中变量的访问规则,往大一点说是定义了程序执行的次序;
注意,为了获得较好的执行性能,Java内存模型并没有限制执行引擎使用处理器的寄存器或者高速缓存来提升指令执行速度,也没有限制编译器对指令进行重排序。也就是说,在java内存模型中,也会存在缓存一致性问题和指令重排序的问题。
Java内存模型规定:
1、所有的变量都保存在主存当中(类似于前面说的物理内存)
2、每个线程都有自己的工作内存(类似于前面的高速缓存)
3、线程对变量的所有操作都必须在工作内存中进行,而不能直接对主存进行操作
4、并且每个线程不能访问其他线程的工作内存
举个例子:
int i=10;
执行线程必须先在自己的 工作内存 中对变量i所在的缓存行进行赋值操作,然后再写入主存;而不是直接将10写入主存当中
3.1、原子性
在Java中,对基本数据类型变量的 读取操作 和 赋值操作 都是原子性操作,即这些操作是不可被中断的,要么执行,要么不执行
例如:
x=10; //语句1
y=x; //语句2
x++; //语句3
x=x+1; //语句4
以上四个语句中,只有语句1是原子性操作,其他三个语句都不是原子性操作;
1、语句1是直接将数值10赋值给x,也就是说线程执行这个语句的会直接将数值10写入到工作内存中
2、语句2实际上包含2个操作,它先要去读取x的值,再将x的值写入工作内存,虽然读取x的值以及 将x的值写入工作内存,这2个操作都是原子性操作,但是合起来就不是原子性操作
3、同样的,x++和 x=x+1包括3个操作:读取x的值,进行加1操作,写入新的值
- 所以上面4个语句只有语句1的操作具备原子性。
也就是说,只有简单的读取、赋值(而且必须是将数字赋值给某个变量,变量之间的相互赋值不是原子操作)才是原子操作。
从上面可以看出,Java内存模型只保证了基本读取和赋值是原子性操作,如果要实现更大范围操作的原子性,可以通过 synchronized 和 Lock 来实现。由于synchronized和Lock能够保证任一时刻只有一个线程执行该代码块,那么自然就不存在原子性问题了,从而保证了原子性
3.2、可见性
对于可见性,Java提供了volatile关键字来保证可见性;
当一个共享变量被volatile修饰时,它会保证修改的值会 立即 被更新到主存,当有其他线程需要读取这个变量时,它会去内存中读取新值
而普通的共享变量不能保证可见性,因为普通共享变量被修改之后,什么时候被写入主存是不确定的,当其他线程去读取时,此时内存中可能还是原来的旧值,因此无法保证可见性
另外,通过synchronized和Lock也能够保证可见性,synchronized和Lock能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中。因此可以保证可见性。
3.3、有序性
在Java内存模型中,允许编译器和处理器对指令进行重排序,但是重排序过程不会影响到单线程程序的执行,却会影响到多线程并发执行的正确性
(happens-before原则(先行发生原则)的第一条:程序次序规则:个人理解:虚拟机可能会对程序代码进行指令重排序。虽然进行重排序,但是重排序后的最终执行的结果是与顺序执行的结果是一致的,它只会对不存在数据依赖性的指令进行重排序)
在Java里面,可以通过volatile关键字来保证一定的“有序性”
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· 单线程的Redis速度为什么快?
· SQL Server 2025 AI相关能力初探
· AI编程工具终极对决:字节Trae VS Cursor,谁才是开发者新宠?
· 展开说说关于C#中ORM框架的用法!