前言

今天学习了Java内存模型第一课的视频,讲了硬件层面的知识,还是和大学时一样,醍醐灌顶。老师讲得太好了。

Java内存模型,感觉以前学得比较抽象。很繁杂,抽象。

这次试着系统一点跟着2个老师学习一下。

学习Java内存模型目的:

1.高并发情况下,java内存模型是怎么提供支持的?

2.一个对象创建后,在内存中的布局?

为什么在聊JVM内存模型、happens-before、八大原子指令之前需要学习硬件层面的并发优化基础知识?

任何语言都是靠CPU执行它的指令来运行的。所以java虚拟机只是在CPU的指令执行上层包装的一些东西(虽然也很高深精妙),再所以先学基于硬件层面的并发优化的基础知识,后面学JVM的内存模型就容易多了。

存储器的层次结构



对于现代cpu而言,性能瓶颈则是对于内存的访问。cpu的速度往往都比主存(即内存)的速度高至少两个数量级。

CPU读数的时候,会先从金字塔从上往下找,找到就依次放到缓存。

当多核CPU的时候,前三层的缓存和寄存器就是多个。

Cache一致性(Cache Coherence)问题

这里的Cache指的是CPU内部的两级缓存

一致性问题的产生-信息不对称导致的问题

在多核处理器中,内存中有一个数据x,值为3,被缓存到CPU0和CPU1中,如果CPU0将x修改为5,而CPU1不知道x被修改,还在使用旧值,就会导致程序出错,这就是cache的不一致。

Cache一致性的底层操作为了保证cache的一致性,处理器提供了两个保证cache一致性的底层操作:Writeinvalidate和Write update。

  1. Write invalidate(置无效):当一个内核修改了一份数据,其他内核上如果有这份数据的复制,就置成无效。
  2. Write update(写更新):当一个内核修改了一份数据,其他地方如果有这份数据的复制,就都更新到最新值。

    参考计算机体系结构(第五版)-复习-MESI&MOESI协议

    参考CPU Cache一致性问题

解决方案

1.总线锁

顾名思义,总线锁就是用来锁住总线的,我们可以通过上图来了解总线在这个场景中所处的位置。当一个CPU核执行一个线程去访问数据做操作的时候,它会向总线上发送一个LOCK信号,此时其他的线程想要去请求主内存的时候,就会被阻塞,这样该处理器核心就可以独享这个共享内存。可以理解为,总线锁通过把内存和CPU之间的通信锁住,把并行化的操作变成了串行,这会导致很严重的性能问题,这与我们需要多核多线程并行操作来提高程序的效率的目的大相径庭。

所以,随着技术的发展,就出现了缓存锁。

2.缓存锁

简单的说,如果某个内存区域数据,已经同时被两个或以上处理器核缓存,缓存锁就会通过缓存一致性机制阻止对其修改,以此来保证操作的原子性,当其他处理器核回写已经被锁定的缓存行的数据时会导致该缓存行无效。

就是说当某块CPU核对缓存中的数据进行操作了之后,就通知其他CPU放弃储存在它们内部的缓存,或者从主内存中重新读取。

处理器上有一套完整的协议,来保证缓存的一致性,比较经典的应该就是MESI协议了,其实现方法是在CPU缓存中保存一个标记位,以此来标记四种状态。另外,每个Core的Cache控制器不仅知道自己的读写操作,也监听其它Cache的读写操作,就是嗅探(snooping)协议。

M:被修改的。处于这一状态的数据,只在本CPU核中有缓存数据,而其他核中没有。同时其状态相对于内存中的值来说,是已经被修改的,只是没有更新到内存中。

E:独占的。处于这一状态的数据,只有在本CPU中有缓存,且其数据没有修改,即与内存中一致。

S:共享的。处于这一状态的数据在多个CPU中都有缓存,且与内存一致。

I:无效的。本CPU中的这份缓存已经无效。



CPU的读取会遵循几个原则(其实就是上面说的嗅探)

一个处于M状态的缓存行,必须时刻监听所有试图读取该缓存行对应的主存地址的操作,如果监听到,则必须在此操作执行前把其缓存行中的数据写回CPU。(按我个人通俗一点说理解就是,我把内存中这个拼图玩具(数据)复制一份拿来玩,但是我要时刻注意有没有人要玩这个玩具,如果有人要玩,那我就要告诉他,这拼图已经被我拼成这样子了,你从这个进度开始拼吧。)

一个处于S状态的缓存行,必须时刻监听使该缓存行无效或者独享该缓存行的请求,如果监听到,则必须把其缓存行状态设置为I。

一个处于E状态的缓存行,必须时刻监听其他试图读取该缓存行对应的主存地址的操作,如果监听到,则必须把其缓存行状态设置为S。

当CPU需要读取数据时,如果其缓存行的状态是I的,则需要从内存中读取,并把自己状态变成S,如果不是I,则可以直接读取缓存中的值,但在此之前,必须要等待其他CPU的监听结果,如其他CPU也有该数据的缓存且状态是M,则需要等待其把缓存更新到内存之后,再读取。

当CPU需要写数据时,只有在其缓存行是M或者E的时候才能执行,否则需要发出特殊的RFO指令(Read Or Ownership,这是一种总线事务),通知其他CPU置缓存无效(I),这种情况下性能开销是相对较大的。在写入完成后,修改其缓存状态为M。

所以如果一个变量在某段时间只被一个线程频繁地修改,那么使用其内部缓存就完全可以了,并不需要涉及到总线事务。如果内存一会被这个CPU独占,一会被那个CPU 独占,这时才会不断产生RFO指令影响到并发性能。这其实是跟CPU协调机制有关,如果在CPU间调度不合理,会形成RFO指令的开销比任务开销还要大,喧宾夺主,我们反而不能提高效率。顺带一句题外话,之前听过一句话,程序玩到最后都是性能和安全的博弈,深以为然。

并非所有情况都会使用缓存一致性的,如被操作的数据不能被缓存在CPU内部或操作数据跨越多个缓存行(状态无法标识),则处理器会调用总线锁定;另外当CPU不支持缓存锁定时,自然也只能用总线锁定了,比如说奔腾486以及更老的CPU。

现代CPU一般采用总线锁+缓存锁实现

因为有一些数据无法被缓存,或者无法缓存到同一个缓存行里。想要解决一致性问题就必须用总线锁。

解决后的效果

缓存行与伪共享

概念

在计算机系统中,内存是以【缓存行】为单位存储的,一个缓存行存储的字节是2的倍数。不同机器上,缓存行大小也不一样,通常来说为64字节。

伪共享是指:在多个线程同时读写同一个【缓存行】上的不同数据时,尽管这些变量之间没有任何关系,但是在多线程之间仍然需要同步,从而导致性能下降。在多核处理器中,伪共享是影响性能的主要因素之一,通常称之为:“性能杀手”。

说明

线程1在CPU1上读写变量X,同时线程2上读写变量Y,但是巧合的是变量X,Y在同一个缓存行上,那边线程1,2就要互相竞争获取该缓存行的读写权限,才可以进行读写。

假如线程1在内核1上获取了缓存行的读写权限,进行了操作,就会导致其他内核中的x变量和y变量同时失效,那么线程2就必须刷新它的缓存后才能在内核2上获取缓存行的读写权限。

这就导致了这个缓存行在不同的线程之间多次通过L3缓存(共享的,见本文第一张图)进行交换最新复制的数据,极大的影响了CPU性能,如果CPU不在同一个槽上,性能更糟糕。

缓存行对齐在开源框架Disruptor的应用

你会看到Disruptor消除这个问题,至少对于缓存行大小是64字节或更少的处理器架构来说是这样的(有可能处理器的缓存行是128字节,那么使用64字节填充还是会存在伪共享问题),通过增加补全来确保ring buffer的序列号不会和其他东西同时存在于一个缓存行中。

//Disruptor框架部分源码
public long p1, p2, p3, p4, p5, p6, p7; // cache line padding
private volatile long cursor = INITIAL_CURSOR_VALUE;
public long p8, p9, p10, p11, p12, p13, p14; // cache line padding

缓存行对齐(Cache Line Padding)Java示例

例1:不同变量位于相同缓存行示例
public class CacheLinePadding1 {
private static class T {
public volatile long x = 0L;
} public static T[] arr = new T[2]; static {
arr[0] = new T();
arr[1] = new T();
} public static void main(String[] args) throws Exception {
Thread t1 = new Thread(()->{
for (long i = 0; i < 1000_0000L; i++) {
arr[0].x = i;
}
}); Thread t2 = new Thread(()->{
for (long i = 0; i < 1000_0000L; i++) {
arr[1].x = i;
}
}); final long start = System.nanoTime();
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println((System.nanoTime() - start)/100_0000);
}
}
例2:不同变量位于不同缓存行示例
public class CacheLinePadding2 {
private static class Padding {
//默认创建7个long类型的数据,一个long占8个字节
public volatile long p1, p2, p3, p4, p5, p6, p7;
} private static class T extends Padding {
//实际操作变量占一个缓存行的最后一个字节
public volatile long x = 0L;
} public static T[] arr = new T[2]; static {
arr[0] = new T();
arr[1] = new T();
} public static void main(String[] args) throws Exception {
Thread t1 = new Thread(()->{
for (long i = 0; i < 1000_0000L; i++) {
arr[0].x = i;
}
}); Thread t2 = new Thread(()->{
for (long i = 0; i < 1000_0000L; i++) {
arr[1].x = i;
}
}); final long start = System.nanoTime();
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println((System.nanoTime() - start)/100_0000);
}
}

执行结果:例2(操作变量位于不同缓存行)比例1性能高1倍多。

参考汇总

计算机体系结构(第五版)-复习-MESI&MOESI协议

CPU Cache一致性问题

MESI--CPU缓存一致性协议

JMM基础(总线锁、缓存锁、MESI缓存一致性协议、CPU 层面的内存屏障)

剖析Disruptor:为什么会这么快?(二)神奇的缓存行填充

Java中的伪共享以及应对方案

【Java虚拟机4】Java内存模型(硬件层面的并发优化基础知识--缓存一致性问题)的更多相关文章

  1. 【Java虚拟机5】Java内存模型(硬件层面的并发优化基础知识--指令乱序问题)

    前言 其实之前大家都了解过volatile,它的第一个作用是保证内存可见,第二个作用是禁止指令重排序.今天系统学习下为什么CPU会指令重排. 存储器的层次结构图 1.CPU乱序执行指令的根源 CPU读 ...

  2. Java虚拟机学习 - 体系结构 内存模型

    一:Java技术体系模块图 二:JVM内存区域模型 1.方法区 也称"永久代” .“非堆”, 它用于存储虚拟机加载的类信息.常量.静态变量.是各个线程共享的内存区域.默认最小值为16MB,最 ...

  3. Java虚拟机学习 - 体系结构 内存模型(1)

    一:Java技术体系模块图 二:JVM内存区域模型 1.方法区 也称"永久代" ."非堆",  它用于存储虚拟机加载的类信息.常量.静态变量.是各个线程共享的内 ...

  4. Java虚拟机学习 - 体系结构 内存模型(转载)

    一:Java技术体系模块图 二:JVM内存区域模型 1.方法区 也称"永久代” .“非堆”,  它用于存储虚拟机加载的类信息.常量.静态变量.是各个线程共享的内存区域.默认最小值为16MB, ...

  5. 深入理解Java虚拟机(一)——JVM内存模型

    文章目录 程序计数器 定义 作用 特点 Java虚拟机栈 定义 特点 本地方法栈 定义 Java堆 定义 特点 方法区 定义 特点 运行常量池 直接内存 总结 Java虚拟机的内存空间分为五个部分: ...

  6. 【java虚拟机】jvm内存模型

    作者:pengjunlee原文链接:https://blog.csdn.net/pengjunlee/article/details/71909239 目录 一.运行时数据区域 1.程序计数器 2.J ...

  7. 《深入理解Java虚拟机》Java内存区域与内存溢出异常

    注:“蓝色加粗字体”为书本原语 先来一张JVM运行时数据区域图,再接下来一一分析各区域功能:   程序计数器 程序计数器(program Counter Register)是一块较小的内存空间,它可以 ...

  8. 深入理解java虚拟机【Java内存结构】

    Java虚拟机规范规定的java虚拟机内存其实就是java虚拟机运行时数据区,其架构如下: 其中方法区和堆是由所有线程共享的数据区. Java虚拟机栈,本地方法栈和程序计数器是线程隔离的数据区. (1 ...

  9. Java虚拟机中的内存分配

    java虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域.这些区域都有各自的用途以及创建和销毁的时间. 栈:存放的是局部变量,包括:1.用来保存基本数据类型的值:2.保存类 ...

随机推荐

  1. Blazor 组件库开发指南

    翻译自 Waqas Anwar 2021年5月21日的文章 <A Developer's Guide To Blazor Component Libraries> [1] Blazor 的 ...

  2. Mybatis-Plus增强包

    简介 本框架(Gitee地址 )结合公司日常业务场景,对Mybatis-Plus 做了进一步的拓展封装,即保留MP原功能,又添加更多有用便捷的功能.具体拓展体现在数据自动填充(类似JPA中的审计).关 ...

  3. ELK学习之Logstash+Kafka篇

    上一篇介绍了一下Logstash的数据处理过程以及一些基本的配置功能,同时也提到了Logstash作为一个数据采集端,支持对接多种输入数据源,其中就包括Kafka.那么这次的学习不妨研究一下Logst ...

  4. C#中的“等待窗体”对话框

    这篇文章向您展示了如何在c#.net Windows窗体应用程序中创建一个等待窗体对话框.创建一个新表单,然后输入您的表单名称为frmWaitForm.接下来,将Label,Progress Bar控 ...

  5. AntDesign VUE:上传组件自定义限制的两种方式(Boolean、Promise)

    AntD上传组件 AntDesign VUE文档 第一种方式 beforeUpload(file) { let isLt = true if (filesSize) { isLt = file.siz ...

  6. 使用Java api对HBase 2.4.5进行增删改查

    1.运行hbase 2.新建maven项目 2.将hbase-site.xml放在项目的resources文件夹下 3.修改pom.xml文件,引入hbase相关资源 <repositories ...

  7. php move_uploaded_file保存文件失败

    move_uploaded_file保存失败后找错,先使用了try catch,但是没输出信息,才知道该函数在php中是警告属于error,不属于exeption,因此不能通过简单的if(!...)处 ...

  8. 创建一个Orchard Core CMS 应用程序

    开始使用Orchard Core作为NuGet软件包 在本文中,我们将看到使用Orchard Core提供的NuGet包创建CMS Web应用程序是多么容易. 你可以在这里找到Chris Payne写 ...

  9. Nginx系列(1)- Nginx简介

    公司产品出现瓶颈 公司项目刚上线的时候,并发量小,用户使用少,所以在低并发的情况下,一个jar包启动应用就够了,然后内部tomcat返回内容给用户 但是慢慢的,使用平台的用户越来越多,并发量慢慢增大了 ...

  10. Docker DevOps实战:GitLab+Jenkins(2)- CI/CD相关配置

    Jenkins关联GitLab Gitlab仓库配置Webhooks 上传项目到GitLab,Jenkins构建