Java 内存模型

我们常说Java可以一次编译到处运行,得益于它不同的虚拟机,正是由于Java虚拟机屏蔽了各种底层硬件和操作系统访问的差异,保证了Java程序在各种平台上运行都能保证效果一致

而Java内存模型(Java Memory Model,JMM),就是Java虚拟机提供了的一种符合内存模型的规范。

一、计算机内存模型

1.1 内存结构

在介绍JMM之前,我们先简单介绍一下什么是内存模型,为什么需要内存模型以及内存模型有哪些问题

我们都知道,计算机在执行程序的时候,每一条指令都是在CPU中执行的,而执行指令的时候通常是需要与数据结合的,而计算机上面的数据又是放在主存中的,也就是我们常说的物理内存,所以CPU执行指令的需要是需要频繁与主存交互的。

但随着CPU处理性能的提高,CPU与主存交互的频次就大幅增加,这就导致了主存的读写速度成了计算机性能的瓶颈,由于这个原因,人们就通过在CPU和主存之间增加高速缓存的方式来进一步提高计算机的性能,于是就有了现在计算机多存缓存的架构,通常是三级缓存,越靠近CPU的缓存,其存储空间越小,但访问效率越高。而每一级缓存中所存储的数据都是下一级缓存的一部分

假设计算机具有三级缓存,下面展示了多核CPU工作时数据的的获取

当CPU需要读取一个数据时,首先从一级缓存中查找,如果没有再从二级缓存中查找,如果还是没有就去三级缓存或主存中查找。

对于单核CPU来说,它只含有一套L1、L2、L3缓存;而如果是多核CPU,每个核心都含有一套L1、L2缓存,然后所有核心共享L3缓存

1.2 一致性的问题

1.2.1 缓存导致的一致性问题

随着计算机能力升级,开始支持多线程运行。那么随之而来的就是多个线程同时在多个核心运行时,它们对同一数据操作的一致性问题如何保证呢?

我们具体分析一下单线程、多线程分别在单核CPU和多核CPU下运行时的影响

单线程:不论在单核或者多核下面,CPU核心的缓存之被一个线程访问,线程独占缓存,不会出现一致性的问题

单核CPU&多线程:进程中的所有线程都是并发执行的,它们会交替访问进程中共享数据,不会出现缓存访问的冲突,自然也就不会出现缓存一致性的问题

多核CPU&多线程:每个核心都至少有一个L1缓存,进程中的所有线程可以并行执行,就会出现同时访问进程中的某个共享内存,而这多个线程又在不同的CPU上执行,而且每个CPU核心都会在各自的Cache中保留一份共享内存的缓冲数据,就会出现CPU单独写各自缓存的情况,最后导致缓存之间的数据不一致

上面提到了并行和并发两个概念:

并发:CPU的使用被分成一个个的时间片,然后多个线程都在一个CPU上运行,但是只有获取到CPU时间片的线程才能运行,也就在一个CPU上,同时只能运行一个线程,而这些线程可以交替获得CPU的时间片在CPU上运行,继而造成所有线程在同时运行的假象

并行:多个线程分别单独在多核CPU核心上独立运行,是真正的多个线程同时运行

在CPU和主存之间增加缓存,在多核处理器的多线程场景下,就可能存在缓存一致性问题,也就是说在多个CPU核心中,每个核心的缓存中,关于同一个数据的缓存内容可能不一致

1.2.2 指令优化导致的缓存一致性问题

除了上面缓存在多核CPU的多线程场景下会出现一致性问题外,程序运行时,处理器也会导致缓存一致性的问题。处理器为了内部的运算单元能够尽量被充分利用,处理器可能会对输入代码进行乱序执行处理,这就是处理器优化

除了处理器会进行乱序处理外,很多编程语言的编译器也会有类似的优化,比如Java虚拟机的即时编译器(JIT)会进行指令重排

如果任由处理器和编译器对指令进行重排的话,就可能导致各种各样的问题,所以指令重排就要遵守一定的规则的。

不管怎么排序,(单线程)程序的执行结果不会改变。编译器、runtime和处理器为了遵守这个规则,它们不会对存在数据依赖关系的操作进行重排序,因为这种重排序结果会改变执行结果。但是,如果操作之间如果不存在数据依赖关系,这些操作就可能被编译器和处理器重排序

int a = 5;  // A操作
int b = 10; // B操作
int c = a * b; // C操作

上面的代码,A和C之间存在数据依赖关系,同时B和C之间也存在依赖关系。因此,在最终执行的指令序列中,C不能被重排序到A和B的前面(C排到A和B的前面,程序的结果会改变)。但是A和B之间没有依赖关系,编译器和处理器可以重排序A和B之间的执行顺序。

1.3 并发编程

与内存结构和缓存一致性关联最大的就是并发编程,并发编程可以极致开发处理器的性能,但是在并发编程的前提是需要保证安全,所以就有并发编程的三大特性:原子性、可见性和有序性。

这三大特性是抽象的概念,对应于程序底层的问题就是前面介绍的处理器优化、缓存一致性和指令重排序

原子性:一个或多个操作在CPU中执行时,要么全部执行且在执行过程中不被任何因素打断,要么全部不执行

可见性:当多个线程访问同一个变量时,一个线程改变了这个变量的值,其他线程能够立即看到修改的值

有序性:程序执行的顺序按照代码的先后顺序执行。JVM存在指令重排,所以存在有序性问题

二、Java内存模型

2.1 Java内存结构

Java虚拟机规范中定义了Java内存模型(Java Memory Model,JMM),而JMM规范了Java虚拟机与计算机内存是如何协同工作的:规定了一个线程如何和何时可以看到由其他线程修改过的共享变量的值,以及在必须时,如何同步地访问共享变量

JMM描述的是一组规则,通过这组规则来控制程序中各个变量在共享数据区域和私有数据区域的访问方式,JMM主要是围绕原子性、可见性和有序性展开的。

Java的内存模型图如下(左),而JMM定义的是一套规范,如右图所示:

在JVM中,对于共享变量,当线程需要使用的时候,就把共享变量拷贝一份到线程内存(线程栈)中,当用完之后,再把变量同步到主存中。

Java内存模型与硬件内存架构之间存在差异,硬件架构中没有区分线程栈和堆,对于硬件,所有的线程栈和堆都分布在主内存中。而Java虚拟机中定义的各种内存区域并没有特定的硬件内存与之对应,Java的内存模型和计算机硬件的内存架构是一种交叉关系。

2.1 内存交互

在Java的内存模型中,关于主内存和工作内存内存之间具体的交互协议,即一个共享变量如何从主内存拷贝到工作内存、如何从工作内存拷贝到主内存之间的实现细节,Java内存模型提供了八种操作来完成。

  • lock(锁定):作用于主内存的变量,把一个变量标识为一条线程独占状态
  • unlock(解锁):作用于主内存变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定

上面这两个操作主要用于同步控制,与内存间的数据交互关系不是很大,下面六种操作才真正与数据操作有关,我们通过一张图来介绍这六个操作:

read(读取):作用于主内存的变量,把一个变量值从主内存传输到工作内存,以便随后的load动作

load(加载):作用与工作内存的变量,它把read操作从主存中得到的变量值,放入到工作内存的变量副本中

use(使用):作用于工作内存的变量,它把工作内存中的一个变量值传递给执行引擎,每当虚拟机遇到需要使用变量的字节码时就会执行该操作

assign(赋值):作用于工作内存的变量,它把从执行引擎接收到的值赋值给工作内存的变量,每当虚拟机需要给变量赋值时就会执行该操作

store(存储):作用于工作内存的变量,它把工作内存中一个变量的值传输到主存中,以便后面的write操作

write(写入):作用于主内存的变量,它把store操作从工作内存中传输到主存的变量值传送到主内存的变量中

Java的内存模型还规定了在执行上面这八种操作时,必须满足下面的规则:

  • 如果变量从主存复制到工作内存,就需要按顺序执行read和load操作;如果变量从工作内存同步到主存,就需要按顺序执行store和write操作。但Java内存模型只要求上述操作必须按照顺序执行,并没有保证必须是连续执行
  • 不允许read和load、store和write操作之一单独出现
  • 不允许线程丢弃最近的assign操作,即变量在工作内存中改变了之后必须同步到主存中
  • 不允许线程无原因地(没有发生assign操作)把数据从工作内存同步到主存中
  • 新的共享变量只能在主存中诞生,不允许在工作内存中直接使用未初始化(load或assign)的变量。即就是对一个变量实施use和store之前,必须先执行过了assign和load操作
  • 一个变量在同一时刻,只能被一个线程对其进行lock操作,但一个线程可以对同一变量执行多次lock操作,但多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。lock和unlock操作必须成对出现
  • 如果对一个变量执行了lock操作,将会清空工作内存中变量的值,然后在执行引擎使用这个变量前需要重新执行load或assign操作初始化变量的值
  • 如果一个变量还没有被执行lock操作,不允许对它执行unlock操作,也不允许去unlock一个被其他线程lock的变量
  • 对一个变量执行unlock之前,必须先把变量同步到主存(执行store和write)

三、缓存一致性协议

缓存一致性问题在多核或多CPU的多线程情况下,是经常容易出现的,为了解决缓存一致性问题,最常见的两种机制是窥探机制(snooping)基于目录的机制(directory-based),这两种机制各有优缺点,如果有足够的带宽可用,基于协议的窥探往往更快,因为它的每个请求都需要广播到系统中的所有节点,这意味着随着系统变大,总线大小和带宽也必须增加。而基于目录的机制,消息是点对点的,而不是广播的,所以在大型系统中(>64位处理器)中一般使用这种类型的缓存一致性。

3.1 总线窥探

工作原理

当特定数据被多个缓存共享时,处理器修改了共享数据的值,更改必须传播到所有其他具有该数据副本的缓存中。这种更改传播可以防止系统违反缓存一致性,数据变更的通知通过总线窥探来完成。

所有的窥探者都在监视总线上的每一个事务,如果一个修改共享缓存块的事务出现在总线上,所有窥探者都会检查它们的缓存是否有共享块的相同副本。如果缓存中有共享块的副本,则相应的窥探者执行一个动作以确保缓存一致性。

这个动作可以是刷新缓存块或使缓存块失效,它还涉及到缓存块状态的改变,这取决于缓存一致性协议。

窥探协议类型

根据管理缓存副本的方式,有两种窥探协议:

  • Write-invalidate:当处理器写入一个共享缓存块时,其他缓存中的所有相同的共享副本都会通过总线窥探失效。这种方法确保只有一个处理器能读写数据的副本,其他缓存中的所有副本都无效。这是最常用的窥探协议。MSI、MESI、MOSI、MOESI和MESIF协议都属于该类型
  • Write-update:当处理器写入一个共享缓存块时,其他缓存的所有共享副本都会通过总线窥探更新。这种方法将写数据广播到总线的所有缓存中。它比write-invalidate协议引起更大的总线流量。

注:write-update有一个问题就是,共享缓存发生了更新之后,它只是把更新广播到了现有线程的缓存中,在它未同步到主存之前,如果有一个新的线程进来,这个新的线程拿到的仍然是主存上的未更新的数据,此时,缓存不一致还是存在的

3.2 一致性协议

为了保持一致性,人们设计了各种模型和协议,如MSI、MESI、write-once等等协议,我们现在用的比较多的是MESI协议。

MESI协议是一种基于write-invalidate的缓存一致性协议,是支持回写(write-back)缓存的最常用协议。

MESI协议要求在缓存没有命中的情况下,如果共享数据块在另一个缓存时,允许缓存到缓存的数据复制。能节约大量带宽。

在MESI协议中,它为缓存行定义了四种状态,缓存行的大小通常为64Byte,这四种缓存行的状态就是MESI的简称:

Modified(已修改):缓存行数据是脏的(dirty),与主存的值不同。如果别的CPU内核要读主存这块数据,该缓存行必须回写到主存,然后状态变为Shared

Exclusive(独占):缓存行只在当前缓存中,但缓存数据与主存数据一致。当别的缓存读取它时,状态变为shared;当写数据时,状态变为modified

Shared(共享):缓存行也存在于其他缓存中,且与主存的数据保持一致。缓存行可以在任意时刻被丢弃

Invalid(无效):缓存行在其他缓存中发生了改变,当前缓存行状态变为invalid

我们以缓存数据的变化来描述缓存行状态的变化:

当前主存有一个共享变量num = 5,Thread1将数据加载进缓存行,此时Thread1中对应数据的缓存行状态为exclusive;然后Thread2也将数据加载进缓存,此时Thread1和Thread2中数据对应的缓存行状态都为shared;然后Thread1将共享变量改为num = 8,此时Thread1的缓存行状态为Modified,Thread2的缓存行状态为Invalid;最后Thread1将缓存行中num=8同步到主存中,Thread1的状态变为exclusive,如果Thread2需要使用时,需要再次从主存中获取,最后Thread1和Thread2中缓存行的状态又变为shared

3.3 伪共享问题

如果多个核的线程在操作同一缓存行的不同变量数据,那么就会出现的频繁的缓存失效,即使在代码层面看这两个线程操作的数据之间完全没有关系。这种不合理的资源竞争情况就是伪共享。

我们可以通过代码来看到伪共享的情况,为了保证可见性,我们为变量X和Y都用volatile修饰(不加就不会出现伪共享的问题)

我们可以通过加不加volatile来进行测试,加了volatile之后,程序运行时间将会增加50倍左右

public class CacheSharedTest {

    public static void main(String[] args) throws InterruptedException {
        testPointer(new Pointer());
    }

    private static void testPointer(Pointer pointer) throws InterruptedException {
        long start = System.currentTimeMillis();
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 100000000; i++) {
                pointer.x++;
            }
        });

        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 100000000; i++) {
                pointer.y++;
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();

        System.out.println(pointer.x+","+pointer.y);

        System.out.println(System.currentTimeMillis() - start);
    }
}

class Pointer {
    volatile long x;
    volatile long y;
}

出现这种情况的原因是,volatile修饰的变量,当它在缓存行的数据发生变化之后,其他线程中对应的缓存行就会失效,需要重新从主存读取。在代码中的体现就是,一个缓存行有64Byte,但long类型只有8Byte,所以变量x和y会处于同一缓存行中,当线程1中更改了x的变量后,变量x对应的缓存行就会失效,线程2就不能直接从缓存行中

来直接读取y,需要再次从主存加载进缓存,y的更改也会影响x的使用,就造成了变量x和y相互影响的情况,就导致了异常耗时。

解决伪共享有两种方案:

  • 缓存行填充

    class Pointer {
        volatile long x;
        //避免伪共享: 缓存行填充
        long p1, p2, p3, p4, p5, p6, p7;
        volatile long y;
    }
    
  • 注解+JVM参数

    @Contended可以作用于类上,也可以作用于变量上。它也是通过填充缓存行来避免伪共享

    class Pointer {
        // 避免伪共享: @Contended +  jvm参数:-XX:-RestrictContended  jdk8支持
        @Contended
        volatile long x;
        
        volatile long y;
    }
    

在变量y前面添加7个Long类型的变量,把第一个缓存行填满,然后变量y就处于一个新的缓存行了,x和y不再相互影响,就不会再有伪共享的问题了。

  • 4
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值