一文搞懂: Java内存模式

前言

Java内存模式: 是一种虚拟机规范,规定了一个线程如何和何时可以看到由其他线程修改过后的共享变量的值,以及在必须时如何同步的访问共享变量。

根据jvm内存模型我们知道,调用栈和本地变量存放在线程栈上,对象存放在堆上
如下图(JVM内存模型)我们可以知道

  • 调用栈和本地变量是线程“私有”
  • 堆是线程“共有”
    JVM内存模型

那么到底什么是线程公有,什么是线程私有(可参考:一文搞懂: JVM内存模型以及GC回收机制

一:硬件内存架构

现代硬件内存模型与Java内存模型有一些不同,理解内存模型架构以及Java内存模型如何与它协同工作也是非常重要的。

现代计算机硬件架构的简单图示:

  • CPU寄存器:每个CPU都包含一系列的寄存器,它们是CPU内内存的基础。CPU在寄存器上执行操作的速度远大于在主存上执行的速度。这是因为CPU访问寄存器的速度远大于主存。
  • 高速缓存cache:由于计算机的存储设备与处理器的运算速度之间有着几个数量级的差距,所以现代计算机系统都不得不加入一层读写速度尽可能接近处理器运算速度的高速缓存(Cache)来作为内存与处理器之间的缓冲:将运算需要使用到的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存同步回内存之中,这样处理器就无须等待缓慢的内存读写了。CPU访问缓存层的速度快于访问主存的速度,但通常比访问内部寄存器的速度还要慢一点。每个CPU可能有一个CPU缓存层,一些CPU还有多层缓存。在某一时刻,一个或者多个缓存行(cache lines)可能被读到缓存,一个或者多个缓存行可能再被刷新回主存。
  • 内存:一个计算机还包含一个主存。所有的CPU都可以访问主存。主存通常比CPU中的缓存大得多。
  • 运作原理:通常情况下,当一个CPU需要读取主存时,它会将主存的部分读到CPU缓存中。它甚至可能将缓存中的部分内容读到它的内部寄存器中,然后在寄存器中执行操作。当CPU需要将结果写回到主存中去时,它会将内部寄存器的值刷新到缓存中,然后在某个时间点将值刷新回主存。

以上说了这么多,总结来说就是:在一个线程当中,如果我想改变一个对象的值的时候(这个对象在堆内存中,也就是我们所说的主内存),它会把这个值读取到高速缓存中(也可以理解为copy了这个值到缓存中),然后把这个值读取到CPU内部寄存器中进行相对应的改变,然后再某一时刻,把改变之后的值放到高速缓存中再刷新到主内存中

二:多线程中的主内存对象修改

多线程中的主内存对象修改,就涉及到了多个线程同时修改一个主内存对象,那么线程间通信必须要经过主内存。如下,如果线程A与线程B之间要通信的话,必须要经历下面2个步骤:
1) 线程A把本地内存A中更新过的共享变量刷新到主内存中去
2) 线程B到主内存中去读取线程A之前已更新过的共享变量

线程通讯图解

三:Java内存模型解决的问题

3.1 可见性

我们来看以下这种多线程场景:
跑在左边CPU的线程拷贝这个共享对象到它的CPU缓存中,然后将count变量的值修改为2。这个修改对跑在右边CPU上的其它线程是不可见的,因为修改后的count的值还没有被刷新回主存中去


场景一

为了达到多个线程看到同一个值达到我们预期的效果,我们需要满足线程的“可见性”

可见性(共享对象可见性):线程对共享变量修改的可见性。当一个线程修改了共享变量的值,其他线程能够立刻得知这个修改

线程缓存导致的可见性问题:
如果两个或者更多的线程在没有正确的使用volatile声明或者同步的情况下共享一个对象,一个线程更新这个共享对象可能对其它线程来说是不可见的:共享对象被初始化在主存中。跑在CPU上的一个线程将这个共享对象读到CPU缓存中,然后修改了这个对象。只要CPU缓存没有被刷新会主存,对象修改后的版本对跑在其它CPU上的线程都是不可见的。这种方式可能导致每个线程拥有这个共享对象的私有拷贝,每个拷贝停留在不同的CPU缓存中。

解决这个内存可见性问题你可以使用:

Java中的volatile关键字:volatile关键字可以保证直接从主存中读取一个变量,如果这个变量被修改后,总是会被写回到主存中去。Java内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方式来实现可见性的,无论是普通变量还是volatile变量都是如此,普通变量与volatile变量的区别是:volatile的特殊规则保证了新值能立即同步到主内存,以及每个线程在每次使用volatile变量前都立即从主内存刷新。因此我们可以说volatile保证了多线程操作时变量的可见性,而普通变量则不能保证这一点。

Java中的synchronized关键字:同步快的可见性是由“如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前需要重新执行load或assign操作初始化变量的值”、“对一个变量执行unlock操作之前,必须先把此变量同步回主内存中(执行store和write操作)”这两条规则获得的。

3.2 原子性

我们再来看以下这种多线程场景:
果线程A读一个共享对象的变量count到它的CPU缓存中,线程B也做了同样的事情,但是往一个不同的CPU缓存中。现在线程A将count加1,线程B也做了同样的事情。现在count已经被增加了两次,每个CPU缓存中一次。如果这些增加操作被顺序的执行,变量count应该被增加两次,然后原值+2被写回到主存中去。然而,两次增加都是在没有适当的同步下并发执行的。无论是线程A还是线程B将count修改后的版本写回到主存中取,修改后的值仅会被原值大1(就是如果做了同步,此时应该为3),尽管增加了两次:

场景二

很明显,此时有个临界条件,就是两个线程同时把主存中的数据读取到CPU缓存中,那么为了解决这个问题,我们就要满足多线程的原子性
原子性:持有同一个锁的两个同步块只能串行地进入

如果应用场景需要一个更大范围的原子性保证,需要使用同步块技术。Java内存模型提供了lock和unlock操作来满足这种需求。虚拟机提供了字节码指令monitorenter和monitorexist来隐式地使用这两个操作,这两个字节码指令反映到Java代码中就是同步快——synchronized关键字。

总结来说:volatile保证了可见性、synchronized保证了原子性和可见性

END

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

推荐阅读更多精彩内容