说到并发,大家会想到并发和并行这2个概念。
并发:同时拥有两个或者多个线程,如果程序在单核处理器上运行多个线程将交替的换出或者换入内存,这些线程是同时存在的。每个线程都会处于执行过程中的某个状态,如果运行在多核处理器上,程序中的每个线程都将会分配到一个处理器核,因此可以同时运行。
在多个线程操作相同的资源时,我们要保证线程安全,合理使用资源。
对于并发和并行的概念,网络上有更好的解释:
并行是指两个或者多个事件在同一时刻发生。比如火影忍者中的鸣人,可以使用多重影分身之术。一个分身上学,真身在宿舍玩LOL。
并发是指两个或者多个事件在同一时间间隔内发生,比如我先上完学后,回到宿舍里面玩LOL。
我们搞java的,平时写业务代码,会很少涉及到这方面的知识。但是如果要写出高性能的java代码,对于CPU Cache一块还是要有所了解的。
首先来讲寄存器为什么会比内存快(借鉴阮一峰的文章)
寄存器是在CPU内部的,当然读起来会更加快一点。以3GHz(Hz是每秒的周期次数)的CPU为例,电流每秒钟可以震荡30亿次。1秒=10亿纳秒,每次耗时大约为10/30纳秒,也就是0.33纳秒。光速是每秒300000000米,也就是说光在1纳秒可以前进30cm,在cpu的一个时钟周期内,光可以前进10cm。(理想状态下,真实情况下还要考虑到能量损失等等很多因素),如果内存距离CPU超过5cm,就不可以在一个时钟周期内完成数据的读取。
内存在硬件设计上,每个位就是一个电容和一个晶体管,而寄存器的晶体管一直有电,而内存的晶体管只有用到的才有电,没有用到就没有电,这样有利于省电。
寄存器和内存的工作方式不太相同。
寄存器的工作方式
1.找到相关的位 2.读取这些位
内存的工作方式
1.找到数据的指针。(指针可能会存放在寄存器中,所以这一步就已经包括寄存器的全部工作了) 2.将指针送往内存管理单元(MMU),由MMU将虚拟的内存地址转换成实际的物理地址。 3.物理地址送往内存控制器(Memory Controller)。由内存控制器找出该地址在哪一根内存插槽上。 4.确认数据在哪一个内存块上,从该块读取数据 5.数据先送回内存控制器,再送回CPU,然后开始使用。
后面我们会讲到用Perf性能工具测试CPU缓存的丢失次数。这里先讲一下CPU流水线的概念
CPU流水线技术是一种将指令分解成多步,并让不同指令的各步操作重叠,从而实现几条指令并行处理,以加速程序运行的技术。这里想想富士康工厂加工iPhone的过程。
计算机体系结构教材中被提及最多的经典MIPS五级流水线。在此流水线中一条指令的生命周期分为:
1.取指:是指将指令从存储器中读取出来的过程。 2.译码:是指将存储器中取出的指令进行翻译的过程。经过译码之后得到指令需要的操作数寄存器索引,可以使用此索引从通用寄存器组中将操作数读出。 3.执行:指令译码之后所需要进行的计算类型都已得知,并且已经从通用寄存器组中读取出了所需的操作数,那么接下来便进行指令执行。指令执行时指对指令进行真正运算的过程。如果指令是一条加法运算指令,则对操作数进行加法操作;如果指令是一条减法运算指令,则进行减法操作。在执行阶段的最常见部件为算术逻辑部件运算器,作为实施具体运算的硬件功能单元。 4.访存:存储器访问指令往往是指令集中最重要的指令类型之一,访存是指存储器访问指令将数据从存储器中读出,或者写入存储器的过程。 5.写回:写回是指将指令执行的结果写回到通用寄存器组的过程。如果是普通运算指令,该结果值来自于执行阶段计算的结果;如果是存储器读指令,该结果来自于访存阶段从存储器中读取出来的数据。
在工业制造中采用流水线可以提高单位时间的生产量,同样在处理器中采用流水线设计也助于提高处理器的性能。由上述的五级流水线为例,由于前一条指令在完成了取指进入到译码阶段,下一个指令马上就可以进入到取指阶段,以此类推。
造成CPU流水线阻塞的原因由如下几点:
1.资源冲突:多个任务在同一时间周期内争用同一流水段。例如:在指令流水线中,如果数据和指令是放在同一存储器中,并且访问接口也只有一个,那么两条指令就会争用同一个存储器。 2.数据依赖:比如A运算必须得到B运算的结果。但是B运算还未开始,A运算动作就必须等待,直到B运算完成,因为两个操作不能同时执行。 3.条件转移:如果第一条指令是一个条件转移指令,那么系统就会不清楚下面应该执行哪一条指令。这时就必须等待第一条指令的结果出来才能执行第二条指令。条件转移所造成的流水线停顿时间比数据依赖还要严重的多。
简单介绍了CPU流水线,我们来捋一捋CPU缓存架构。
现在的CPU一般有3级缓存架构(后面会讲到),为什么需要CPU Cache呢?因为CPU震荡的频率太快了,快到主存跟不上了。这样在处理器时钟周期内,CPU常常要等待主存,浪费资源。所以Cache的出现,是为了缓解CPU和内存之间速度的匹配问题。
带有CPU Cache的CPU执行计算的流程:
1.将程序和数据加载到主内存中。 2.将指令和数据加载到CPU Cache。 3.CPU执行指令,将结果写到CPU Cache。 4.CPU Cache写回到主内存中。
CPU缓存.jpg
CPU Cache的意义在何处?
1.时间局部性:如果某个数据被访问,那么在不久的将来它很可能被再次访问。 2.空间局部性:如果某个数据被访问,那么与它相邻的数据很快也可能会被访问。
在CPU访问存储设备时,无论是存取数据或者是存取指令,都趋于聚集在一片连续的区域中,被称为局部性原理。
说到这里,我要说一个协议,CPU多级缓存一致性协议(MESI)。这个协议用于保证多个CPU Cache之间缓存共享数据的一致性。我的电脑就有CPU三级缓存,后面会详细介绍CPU三级缓存。
在CPU里面,缓存是以缓存行(Cache line)的形式存在。Cache line缓存存储数据的单元,每个Cache line有4种状态,分别是Modified(修改)、Exclusive(独占)、Shared(共享)、Invaild(无效)。一个Cache line有64Byte(字节,一个字节等于8bit)
4种状态详细说明如下,更多的详细的介绍请看 并发研究之CPU缓存一致性协议(MESI)
4种状态详细说明.png
对于M和E状态而言总是精确的,它们和该缓存行的真正状态是一致的,而S状态可能是非一致的。如果一个缓存将处于S状态的Cache line作废了,而另一个缓存实际上独享了该Cache line,但该缓存却不会将该Cache line设置为E状态。
这是因为其他缓存不会广播它们作废该Cache line的通知,同样由于缓存并没有保存该Cache line的Copy数量,即便有这种通知也无法确定自己是否已经独享了该缓存行。
可以看出E状态是一种投机性优化。如果一个CPU想修改一个处于S状态的Cache line,总线事务需要将所有Cache line的Copy变成Invalid状态,而修改E状态的缓存不需要使用到总线事务。
下面的状态图示意了当一个Cache line调整状态时,另一个Cache line状态的改变。
状态切换图.png
如果cache1种有一个变量z=0的Cache line处于S状态,那么其他拥有z变量的Cache2,Cache3的Cache line状态需要转换成S状态或者I状态。
存储单元是以字节为单位的,1B=8b 1KB=1024B 1MB=1024KB 1GB=1024MB 2^32 = 4GB 2^64=1800万TB
比如64位的操作系统只是字节的64位,数据总线是64根,真正决定内存大小的是地址总线的数目,即寻址空间。
数据总线: 1.是CPU与内存或者其他器件之间的数据传送的通道。 2.数据总线的宽度决定了CPU和外界的数据传送速度。 3.每条传输线一次只能传输1位二进制数据,比如32位操作系统,32根数据线一次可传送32位二进制数据,也就是4个字节。 4.数据总线是数据线数量之和。
地址总线: 1.CPU是通过地址总线来执行存储单元的。 2.地址总线决定了CPU所能访问的最大内存空间大小。比如32位操作系统有32根地址线,那么CPU就能访问2^32=4GB存储单元。 3.地址总线是地址线数量之和。
控制总线: 1.CPU通过控制总线对外部器件进行控制。 2.控制总线的宽度决定了CPU对外部器件中的控制能力。 3.控制总线是控制线数量之和。
来总结一波,每个CPU芯片都有很多管脚,这些管脚和总线相连。也就是说这些管脚引出总线。一个CPU可以引出3种总线的宽度标志了这个CPU的不同方面的性能。
在上面的篇幅中,我们提到过CPU三级缓存架构。我们可以鲁大师或者CPU-Z软件查看自己电脑的CPU三级缓存。
鲁大师.png
CPU-Z.png
级别越小的缓存越接近CPU,意味着速度越快且容量少。L1最接近CPU,容量最小,速度最快。我小米PRO顶配L1缓存的容量是32KB,是4核8线程。每个核上面都有2个L1缓存。一个存数据 L1d Cache,一个存指令 L1iCache。
L2Cache会更大一些,但是速度也要慢一些。每个核上都有一个独立的Cache,我电脑上L2Cache是256KB。
L3Cache 速度最慢,但是容量却是最大的。在同一个CPU插槽之间的核共享一个L3Cache。如图我电脑上的L3Cache容量是8MB。
当CPU运行时,它首先从L1寻找它所需要的数据,然后去L2,最后L3。如果三级缓存都没有找到它所需要的数据,则从内存里获取数据。寻找的路径越长,耗时越长。
我们可以从CPU-Z上看到CPU一级缓存有8-way set associative, 64-byte line size
这样子的描述。它的意思是8路集相联,每个缓存块是64Byte,32KB缓存有512个缓存块。
集相联技术是为了解决缓存直接匹配未命中的数据时,需要清空缓存的代价。假设没有此技术,缓存中多个内存地址共享一个缓存块,当数据未命中时,需要清空缓存块,重新载入新的内存块数据。其他被清除的数据可能在下一次运算被命中,这样缓存会不断的载入数据,又清除数据,延迟太高。
但是集相联路数太多反而会增加查询数据的时间(缓存中,是以索引+标签+缓存块内偏移量确定数据的内存地址的)。
Cache的策略是与内存进行块的映射,查询Cache是先查找索引,再查找Tag,最后确认有效位。如果具体的映射不存在,则刷新Cache.set.associative。这里是指几个存储器块组成一个组,整体映射到Cache的一个组,这样当块需要被刷新时,可以选择没有被占用或者无效的块来进行替换,这样同一个组的历史数据将不会被清除,避免了反复的缺失。组相联查找数据时通过索引和Tag找到组,要比较每个块的组内偏移量。逻辑比较复杂,大大增大了访问延迟,但是减少了缺失率。
总结一波,L1和L2看中访问延迟,尽可能快速得到数据,保持流水线充满。L3是一个比较大的过滤池,尽可能提高命中率,防止内存的访问。所以L3总是较多组关联,同时容量也大。
三级缓存.png
话外音,再提一个"时间片"的概念。时间片即CPU分配给各个程序的时间,每个线程被分配一个时间段,这称之为它的时间片。即该进程允许执行的时间,使各个程序表面上看是同时进行的。
如果在时间片结束时进程还在运行,则CPU将会剥夺时间片给另一个进程。如果进程在时间片结束前阻塞或者结束,则CPU当即进行切换,而不会造成CPU资源浪费。
在宏观上,我们可以同时打开多个应用程序,每个程序并行不悖,同时运行。但是在微观上,由于只有一个CPU,一次只能处理程序要求的一部分。如果处理公平,一种办法就是引入时间片,每个程序轮流执行。
Perf是Linux性能调优工具,几乎能够处理所有与性能相关的事件。 主要关注点: 1.算法优化(空间复杂度,时间复杂度),代码优化(提高执行速度,减少内存占用)。 2.评估程序对硬件资源的使用情况,例如各级Cache的访问次数,各级Cache的丢失次数,流水线停顿周期,总线访问次数。 3.评估程序对操作系统资源的使用情况,系统调用次数,上下文切换次数,任务迁移次数。
通过perf list
命令查看当前硬件环境下所能支持的性能事件。Software event由内核软件产生,Hardware cache event由硬件缓存产生。后续我们会通过L1-dcache-load-misses
来查看没有命中L1-dCache的次数,而从判定一个程序的性能好坏。
image.png
HotSpot虚拟机中,对象在内存中存储的布局,可以分为三块区域:对象头(header),实例数据(Instance Data),对象填充(Padding)。
对象头包括markword和类型指针(class对象指针)。如果是数组,还包括数组长度。 实例数据:对象实际的数据。 对象填充:对齐,按8字节对齐。Java内存地址按照8字节对齐,长度必须是8的倍数。
markword:用于存储对象自身运行时的数据,如哈希码,GC分代年龄,锁状态标志,线程持有的锁,偏向线程ID,偏向时间戳。
在32位JVM中,markword是32bit,类型指针是32bit,数组长度是32bit。从而得知一个普通对象头是8个字节,一个数组对象头是16个字节。
在64位JVM中,markword是64bit,类型指针是64bit,开启指针压缩时是32bit,数组长度是32bit。从而得知一个无压缩的普通对象头是16个字节,一个开启压缩的普通对象头是12个字节。一个无压缩的数组对象头是24个字节,一个开启压缩的数组对象头是16个字节。
对象头在32位系统占用8字节,而在64位系统上占用16字节。Referece类型在32位系统上每个占用4字节,而在64位系统上每个占用8字节。
JVM默认是开启压缩参数,-XX:+UseCompressedOops
。如果不想压缩指针,指定-XX:-UseCompressedOops
。
HotSpot的对齐方式为8字节对齐,(对象头+实例数据+padding) % 8 等于0 且 0 <= padding < 8
一些常用对象占用字节.png
在讲解计算对象占用的字节例子前,我们要知道Instrumentation这个概念。Instrumentation JDK中对它的介绍如下:这个类为JVM上运行时的程序提供测量手段。很多工具通过Instrumentation修改方法字节码实现收集数据目的。这些通过Instrumentation搜集数据的工具不会改变程序的状态和行为。
有2种方式来获取Instrumentation接口实例: 1.启动JVM时指定agent类,这种方式Instrumentation的实例是通过agent class的premain方法被传入。 2.JVM提供一种当JVM启动完成之后再开启agent机制。这种情况下,Instrumentation实例通过agent class中的agentmain方法传入了。
本文由于篇幅太长,只讲述第一种方式。第二种方式的示例请参考instrumentation 功能介绍
一个agent是被作为Jar文件形式来部署的。在Jar中的MAINFEST.MF文件中指定哪一个类作为agent类。具体的实现包括通过命令行直接指定选项开启agent,也支持JVM启动程序后通过工具attach到该程序上。
具体步骤:
1.通过premain方法注入Instrumentation实例。后续通过Instrumentation实例的getObjectSize方法去计算一个对象占用的字节数。
image.png
2.在MAINFEST.MF文件中指定agent类。
image.png
Manifest-Version: 1.0
Premain-Class: com.cmazxiaoma.cpucache.SizeOfObject
Can-Redefine-Classes: true
Class-Path: 你当前项目依赖的所有jar包
Mainfest-Version: 1.0
3.打包成jar包。 4.运行程序时,指定jar所处的位置,就可以使用Instrumentation了。
image.png
我们其实还可以通过ObjectSizeCalculator
这个类,来计算一个对象所占有的字节数。
image.png
计算对象占用的字节例子如下(本身不复杂,要细心一点计算),需要提一点就是: 1.对象本身大小:直接计算当前对象占用空间大小,包括当前类以及超类的基本类型实例字段大小,引用类型实例字段引用大小,实例基本类型数组引用本身占用空间大小,实例引用类型数组引用本身占用空间大小。但是不包括从超类继承下来的和当前类声明的实例引用字段的对象本身的小,实例引用数组引用的对象本身的大小。 2.对象占用总空间大小:递归计算当前对象占用空间总大小,包括当前类和超类的实例字段大小和实例字段引用对象大小,如图所示。
对象占用总空间大小.png
public class SizeOfObject {
static Instrumentation inst;
public static void premain(String args, Instrumentation instP) {
inst = instP;
}
/**
* 直接计算当前对象占用空间大小,包括当前类及超类的基本类型实例字段大小、<br></br>
* 引用类型实例字段引用大小、实例基本类型数组引用本身占用空间大小、实例引用类型数组引用本身占用空间大小;<br></br>
* 但是不包括超类继承下来的和当前类声明的实例引用字段的对象本身的大小、实例引用数组引用的对象本身的大小 <br></br>
*
* @param obj
* @return
*/
public static long sizeOf(Object obj) {
return inst.getObjectSize(obj);
}
/**
* 递归计算当前对象占用空间总大小,包括当前类和超类的实例字段大小以及实例字段引用对象大小
*
* @param objP
* @return
* @throws IllegalAccessException
*/
public static long fullSizeOf(Object objP) throws IllegalAccessException {
Set<Object> visited = new HashSet<Object>();
Deque<Object> toBeQueue = new ArrayDeque<>();
toBeQueue.add(objP);
long size = 0L;
while (toBeQueue.size() > 0) {
Object obj = toBeQueue.poll();
//sizeOf的时候已经计基本类型和引用的长度,包括数组
size += skipObject(visited, obj) ? 0L : sizeOf(obj);
Class<?> tmpObjClass = obj.getClass();
if (tmpObjClass.isArray()) {
//[I , [F 基本类型名字长度是2
if (tmpObjClass.getName().length() > 2) {
for (int i = 0, len = Array.getLength(obj); i < len; i++) {
Object tmp = Array.get(obj, i);
if (tmp != null) {
//非基本类型需要深度遍历其对象
toBeQueue.add(Array.get(obj, i));
}
}
}
} else {
while (tmpObjClass != null) {
Field[] fields = tmpObjClass.getDeclaredFields();
for (Field field : fields) {
if (Modifier.isStatic(field.getModifiers()) //静态不计
|| field.getType().isPrimitive()) { //基本类型不重复计
continue;
}
field.setAccessible(true);
Object fieldValue = field.get(obj);
if (fieldValue == null) {
continue;
}
toBeQueue.add(fieldValue);
}
tmpObjClass = tmpObjClass.getSuperclass();
}
}
}
return size;
}
/**
* String.intern的对象不计;计算过的不计,也避免死循环
*
* @param visited
* @param obj
* @return
*/
static boolean skipObject(Set<Object> visited, Object obj) {
if (obj instanceof String && obj == ((String) obj).intern()) {
return true;
}
return visited.contains(obj);
}
public static void main(String[] args) throws IllegalAccessException {
// 开启-XX:+UseCompressedOops
System.out.println(sizeOf(new Object()));
System.out.println(sizeOf(new Integer[0]));
System.out.println(sizeOf(new Integer[1]));
System.out.println(sizeOf(new Integer[2]));
System.out.println(sizeOf(new Integer[3]));
System.out.println(sizeOf(new Integer[4]));
System.out.println(sizeOf(new Integer[5]));
System.out.println(sizeOf(new long[6]));
long[] longArray = new long[6];
System.out.println(sizeOf(longArray));
System.out.println(sizeOf(Integer.valueOf(1)));
System.out.println(sizeOf(Double.valueOf(1)));
String s = new String("cmazxiaoma");
System.out.println("string=" + sizeOf(s));
// 4 + 4 + 4 + 4 + 4 + 4 + 4 + 4 + 12 + padding
System.out.println("c:" + sizeOf(new C()));
System.out.println(fullSizeOf(new C()));
// 对象总空间占有: 12 + 4 + 4 + 4 + (16 + ( (12 + 4 + 4 + padding) * 3) + padding + 12)
// + (16 + ( 6 * 8 ) + padding) + 4 + 4 + (16 + ( 4 * 6) + padding) + padding + 4 + 4 + 4
/**
* 1 => 24 + (16 + 72 + padding + 12) + (64 + padding) + 8 + (40 + padding) + padding + 12
* 2 => 24 + 104 + 64 + 8 + 40 + padding + 12
* 3 => 240 + 12 + padding
* 4 => 256
*/
// 对象本身占有空间: 12 + 4 + 4 + 4 + 4 + 4 + 4 + 4 + 4 + padding = 48
System.out.println(ObjectSizeCalculator.getObjectSize(new C()));
MyVolatileVar[] myVolatileVars = new MyVolatileVar[4];
for (int i = 0; i < 4; i++) {
myVolatileVars[i] = new MyVolatileVar();
}
// 本身占有:16 + 4 * 4 + padding = 32
// 16 + 4 * 4 + padding = 32
System.out.println(sizeOf(myVolatileVars));
// 总空间占有:16 + 4 * 4 + padding + (12 + padding + 8) * 4 = 32 + 96 + padding = 128
// 16 + 4 * 4 + padding + (12 + padding + 7 * 8 ) * 4 = 32 + 72 * 4 + padding = 320
System.out.println(fullSizeOf(myVolatileVars));
}
static class B {
int a;
int b;
}
static class C extends B {
int ba;
B[] as = new B[3];
long[] longArray = new long[6];
Integer integer;
B b;
int[] intArray = new int[6];
// /*
// 20 + (16 + (12 + 4 + 4 + padding ) * 4 + padding + 16) + padding + 4
// = 20 + (16 + 96 + padding + 16) + padding + 4
// = 24 + padding + 128
// = 152
// */
// B[] as = new B[4];
// B类只有B[] as = new B[3]; 计算对象总空间占有大小
// 4 + 4 + 12 + (16 + (12 + 4 + 4 + padding) * 3 + padding + 12) + padding + 4
// 20 + (16 + 72 + padding + 12) + padding + 4
// 20 + 104 + padding + 4
// 128
C() {
for (int i = 0; i < as.length; i++) {
as[i] = new B();
}
}
}
static class MyVolatileVar {
public volatile long value = 0L;
public long long1,long2,long3,long4,long5,long6;
}
}
16
16
24
24
32
32
40
64
64
16
24
string=24
c:48
256
256
32
320
如果还不会计算一个对象所占的字节数,可以好好看代码注释,已经写的很详细了。
我们在写程序时,如果没有合理的考虑到CPU缓存这层面,写出来的程序效率会很低下。下面用L1CacheMissFast
和L1CacheMissSlow
这2个类来对比执行效率。
/**
* @author cmazxiaoma
* @version V1.0
* @Description: TODO
* @date 2018/11/21 12:57
*/
public class L1CacheMissFast {
private static final int time = 10;
private static final int row = 1024 * 1024;
private static final int column = 6;
// long占8个字节 16 + 8 * 6 + padding = 64个字节 L1d Cache每个缓存块是64Byte
private static long[][] longs;
public static void main(String[] args) throws Exception {
TimeUnit.SECONDS.sleep(1);
longs = new long[row][];
for (int i = 0; i < row; i++) {
longs[i] = new long[column];
for (int j = 0; j < column; j++) {
longs[i][j] = 0L;
}
}
System.out.println("starting....");
long sum = 0L;
for (int t = 0; t < time; t++) {
final long start = System.nanoTime();
//fast
for (int i = 0; i < row; i++) {
for (int j = 0; j < column; j++) {
sum += longs[i][j];
}
}
System.out.println("t=" + t + ",const=" + (System.nanoTime() - start) + "ns");
}
TimeUnit.SECONDS.sleep(1);
}
}
starting....
t=0,const=13536611ns
t=1,const=16494484ns
t=2,const=8840719ns
t=3,const=10315543ns
t=4,const=7966311ns
t=5,const=8896237ns
t=6,const=10900023ns
t=7,const=7640400ns
t=8,const=10156699ns
t=9,const=8244415ns
public class L1CacheMissSlow {
private static final int time = 10;
private static final int row = 1024 * 1024;
private static final int column = 6;
// long占8个字节
private static long[][] longs;
public static void main(String[] args) throws Exception {
TimeUnit.SECONDS.sleep(1);
longs = new long[row][];
for (int i = 0; i < row; i++) {
longs[i] = new long[column];
for (int j = 0; j < column; j++) {
longs[i][j] = 0L;
}
}
System.out.println("starting....");
long sum = 0L;
for (int t = 0; t < time; t++) {
final long start = System.nanoTime();
//slow
for (int j = 0; j < column; j++) {
for (int i = 0; i < row; i++) {
sum += longs[i][j];
}
}
//fast
// for (int i = 0; i < row; i++) {
// for (int j = 0; j < column; j++) {
// sum += longs[i][j];
// }
// }
System.out.println("t=" + t + ",const=" + (System.nanoTime() - start) + "ns");
}
TimeUnit.SECONDS.sleep(1);
}
}
starting....
t=0,const=26504678ns
t=1,const=26703103ns
t=2,const=23249167ns
t=3,const=25284825ns
t=4,const=26026606ns
t=5,const=22916059ns
t=6,const=22472944ns
t=7,const=23734435ns
t=8,const=25486335ns
t=9,const=26571505ns
通过对比,我们明显发现第一个例子要比第二个例子要快。long[6]占64字节,刚好L1d Cache每个缓存块是64Byte。也就是说longs每一行中的数据处于同一条缓存行。我们在循环遍历时,从内存中取出的数据块实际上覆盖了longs[i][0]到longs[i][5]的全部数据,因此遍历时的数据可以直接在L1d Cache中命中,所以速度非常快。而L1CacheMissSlow
类中由于颠倒了循环的顺序,导致遍历所需的数据在L1d Cache命中率非常低(很多数据还是从主存中取到的),大大增加了访问延迟,影响了执行效率。
我们在实际开发中,要避免缓存失效情况的发生,尽可能的将数据落在L1缓存中。
一般来说,缓存失效有3种情况: 1.第一次访问数据,在cache中根本不存在这条数据,所以cache miss,可以通过prefetch(预读)解决。 2.cache冲突,需要通过补齐来解决(伪共享的产生)。 3.cache满,一般情况下我们需要减少操作的数据大小,尽量按照数据的物理顺序访问数据。
这里,我们来了解一下伪共享的概念。
伪共享:缓存系统中是缓存行为单位存储的,当多线程修改互相独立的变量时,如果这些变量共享同一个缓存行,就会无意中影响彼此的性能,这就是伪共享。
不同处理器上的线程修改了位于同一个Cache line上的数据,导致 Cache line 失效,强制内存更新来维护Cache一致性。
上面我们提到过MESI协议,这里再深入缓存行的4种状态的转换过程。
初始:一开始时,缓存行没有加载任何数据,所以处于I状态。
本地写(Local Write):如果本地处理器写数据来处于I状态的缓存行,则缓存行的状态变成M。
本地读(Local Read):如果本地处理器读取处于I状态的缓存行,很明显此缓存没有数据给它。此时分成2种情况:1.其他处理器的缓存里也没有此数据,则从内存加载数据到此缓存行,再将它设置为E状态,表示独占,其他处理器没有。2.其他处理器的缓存有此行数据,则将此缓存行的状态设置为S状态。话外音:如果处于M状态的缓存行,再由本地处理器写入/写出,状态是不会改变的。
远程读(Remote Read):假设有2个处理器A1和A2。如果A2需要读另外一个处理器A1的缓存行内容,A1需要将它缓存行的内容通过内存控制器(上面讲内存工作的方式有提到过)发送给A2,A2接收到后将相应的缓存行状态设置为S。在设置之前,内存也要从总线上得到这份数据加以保存。
远程写(Remote Write):确认的说不是远程写,而是A2得到了A1的数据后,不是为了读,而是为了写。可以算是本地写。同时A1也有这份数据的拷贝。A2会发出一个RFO(Request For Owner)请求,它需要拥有这行数据的权限,其他处理器的相应缓存行设置为I状态,除了它自己,谁不能动这行数据。这保证了数据的安全,同时处理RFO请求以及设置I的过程将给写操作带来了很大的性能消耗。
同样写操作的代价很高,特别是需要发送RFO消息。以下2种情况会发生RFO请求: 1.线程的工作从一个处理器移到另一个处理器,它操作的所有缓存行都需要移到新的处理器。此后如果再写缓存行,则此缓存行在不同核上有多个拷贝,需要发送RFO请求了。 2.两个不同的处理器确实需要操作相同的缓存行。
在Java程序中,数组的成员在缓存中也是连续的。Java对象的相邻成员变量也会加载到同一缓存行中。如果多个线程操作不同的成员变量,且这几个成员变量处于同一缓存行,那么为伪共享的问题就发生了。我们来看下面2个例子。
/**
* @author cmazxiaoma
* @version V1.0
* @Description: TODO
* @date 2018/12/13 12:43
*/
public class FalseShareSlowTest implements Runnable {
public static int NUM_THREADS = 4;
public static final long ITERATIONS = 500 * 1000 * 1000L;
public final int arrayIndex;
public static FalseShareSlowTest.MyVolatileLong[] myVolatileLongs;
public static long SUM_TIME = 0L;
public FalseShareSlowTest(int arrayIndex) {
this.arrayIndex = arrayIndex;
}
public static void main(String[] args) throws InterruptedException, IllegalAccessException {
for (int i = 0; i < 10; i++) {
System.out.println("i=" + i);
myVolatileLongs = new FalseShareSlowTest.MyVolatileLong[NUM_THREADS];
int length = myVolatileLongs.length;
for (int j = 0; j < length; j++) {
myVolatileLongs[j] = new FalseShareSlowTest.MyVolatileLong();
}
System.out.println("myVolatileLongs占有空间大小:" + SizeOfObject.sizeOf(myVolatileLongs));
System.out.println("myVolatileLongs占有总空间大小:" + SizeOfObject.fullSizeOf(myVolatileLongs));
long start = System.nanoTime();
delegateRun();
long end = System.nanoTime();
SUM_TIME += end - start;
}
System.out.println("avg const time:" + SUM_TIME / 10);
}
public static void delegateRun() throws InterruptedException {
Thread[] threads = new Thread[NUM_THREADS];
for (int i = 0; i < threads.length; i++) {
threads[i] = new Thread(new FalseShareSlowTest(i));
}
for (Thread thread : threads) {
thread.start();
}
for (Thread thread : threads) {
thread.join();
}
}
@Override
public void run() {
long i = ITERATIONS + 1;
while (--i != 0) {
myVolatileLongs[arrayIndex].value = i;
}
}
/**
* MyVolatileLong分配填充变量后, MyVolatileLong每个对象占有的空间为 12 + 8 * 7 + padding = 72
* MyVolatileLong未分配填充变量前, MyVolatileLong每个对象占有的空间为 12 + 8 + padding = 24
*
*/
public static class MyVolatileLong {
public volatile long value = 0L;
}
}
i=0
myVolatileLongs占有空间大小:32
myVolatileLongs占有总空间大小:128
i=1
myVolatileLongs占有空间大小:32
myVolatileLongs占有总空间大小:128
i=2
myVolatileLongs占有空间大小:32
myVolatileLongs占有总空间大小:128
i=3
myVolatileLongs占有空间大小:32
myVolatileLongs占有总空间大小:128
i=4
myVolatileLongs占有空间大小:32
myVolatileLongs占有总空间大小:128
i=5
myVolatileLongs占有空间大小:32
myVolatileLongs占有总空间大小:128
i=6
myVolatileLongs占有空间大小:32
myVolatileLongs占有总空间大小:128
i=7
myVolatileLongs占有空间大小:32
myVolatileLongs占有总空间大小:128
i=8
myVolatileLongs占有空间大小:32
myVolatileLongs占有总空间大小:128
i=9
myVolatileLongs占有空间大小:32
myVolatileLongs占有总空间大小:128
Disconnected from the target VM, address: '127.0.0.1:61256', transport: 'socket'
avg const time:35952528919
public class FalseShareFastTest implements Runnable {
public static int NUM_THREADS = 4;
public static final long ITERATIONS = 500 * 1000 * 1000L;
public final int arrayIndex;
public static MyVolatileLong[] myVolatileLongs;
public static long SUM_TIME = 0L;
public FalseShareFastTest(int arrayIndex) {
this.arrayIndex = arrayIndex;
}
public static void main(String[] args) throws InterruptedException, IllegalAccessException {
for (int i = 0; i < 10; i++) {
System.out.println("i=" + i);
myVolatileLongs = new MyVolatileLong[NUM_THREADS];
int length = myVolatileLongs.length;
for (int j = 0; j < length; j++) {
myVolatileLongs[j] = new MyVolatileLong();
}
System.out.println("myVolatileLongs占有空间大小:" + SizeOfObject.sizeOf(myVolatileLongs));
System.out.println("myVolatileLongs占有总空间大小:" + SizeOfObject.fullSizeOf(myVolatileLongs));
long start = System.nanoTime();
delegateRun();
long end = System.nanoTime();
SUM_TIME += end - start;
}
System.out.println("avg const time:" + SUM_TIME / 10);
}
public static void delegateRun() throws InterruptedException {
Thread[] threads = new Thread[NUM_THREADS];
for (int i = 0; i < threads.length; i++) {
threads[i] = new Thread(new FalseShareFastTest(i));
}
for (Thread thread : threads) {
thread.start();
}
for (Thread thread : threads) {
thread.join();
}
}
@Override
public void run() {
long i = ITERATIONS + 1;
while (--i != 0) {
myVolatileLongs[arrayIndex].value = i;
}
}
/**
* MyVolatileLong分配填充变量后, MyVolatileLong每个对象占有的空间为 12 + 8 * 7 + padding = 72
* MyVolatileLong未分配填充变量前, MyVolatileLong每个对象占有的空间为 12 + 8 + padding = 24
*
*/
public static class MyVolatileLong {
public volatile long value = 0L;
public long long1,long2,long3,long4,long5,long6;
}
}
i=0
myVolatileLongs占有空间大小:32
myVolatileLongs占有总空间大小:320
i=1
myVolatileLongs占有空间大小:32
myVolatileLongs占有总空间大小:320
i=2
myVolatileLongs占有空间大小:32
myVolatileLongs占有总空间大小:320
i=3
myVolatileLongs占有空间大小:32
myVolatileLongs占有总空间大小:320
i=4
myVolatileLongs占有空间大小:32
myVolatileLongs占有总空间大小:320
i=5
myVolatileLongs占有空间大小:32
myVolatileLongs占有总空间大小:320
i=6
myVolatileLongs占有空间大小:32
myVolatileLongs占有总空间大小:320
i=7
myVolatileLongs占有空间大小:32
myVolatileLongs占有总空间大小:320
i=8
myVolatileLongs占有空间大小:32
myVolatileLongs占有总空间大小:320
i=9
myVolatileLongs占有空间大小:32
myVolatileLongs占有总空间大小:320
Disconnected from the target VM, address: '127.0.0.1:61251', transport: 'socket'
avg const time:16243372879
我们可以看到FalseShareFastTest
和FalseShareSlowTest
这2个类的区别在于静态内部类MyVolatileLong
的成员变量个数。填充public long long1,long2,long3,long4,long5,long6;
后的类执行效率更快一点。这是为什么呢?我们可以看到FalseShareFastTest
中MyVolatileLong
每个对象大小是72字节。FalseShareSlowTest
中MyVolatileLong
每个对象大小是24字节。具体字节计算可以参考代码中的注释和本文上述。
35952528919ns 填充前
16243372879ns 填充后
那么填充变量前和填充变量后的myVolatileLongs
数组对象大小是16+4*4=32个字节,填充变量前的myVolatileLongs
数组对象占用总空间大小是16 + (12 + 8 * 7 + padding) * 4) + padding + 4 * 4 = 128个字节。一个缓存行是64Byte,也就说myVolatileLongs
数组数据占用2个缓存行。
填充变量后的myVolatileLongs
数组对象占用总空间大小是16 + (12 + 8 + padding) * 4) + padding + 4 * 4 = 320 个字节,数据占用5个缓存行。
显而易见,填充变量后的类发送的RFO请求较少。因为数据分布的缓存行要多,从而造成缓存行失效的次数较少,减少了性能的开销。只要保证不同线程尽可能的不要操作同一缓存行就可以了。
伪共享在多核编程中很容易发生,而且很隐蔽。例如在JDK的LinkedBlockingQueue
中,存在指向队列头的引用head和指向队列尾的引用last。这种队列经常在异步编程中使用到,这2个引用的值经常的被不同的线程修改,但它们却很可能在同一缓存行。于是就产生了伪共享。线程越多,核越多,对性能产生的负面效果越大。
话外音:写着写着就想起了JVM。如果是建立过多线程导致的内存溢出,在不能减少线程数的情况下,就只能通过减少最大堆和减少栈容量来获取更多的线程。嘻嘻,如果没有这方面的处理经验,这种通过减少内存的手段来解决内存溢出的方式会比较难以想到。
最后,某些java编译器会将没有使用到的补齐数据在编译时优化掉,可以加入以下代码防止在编译器被优化到。
public static long preventFromOptimization(MyVolatileLong myVolatileLong) {
return myVolatileLong.long1 + myVolatileLong.long2 + myVolatileLong.long3
+ myVolatileLong.long4 + myVolatileLong.long5 + myVolatileLong.long6;
}
JVM定义了内存模型,规范了Java虚拟机与计算机内存是怎么样协同工作的。它规范了线程何时看到其他线程修改的共享变量的值,以及如何同步访问共享变量。
image.png
为了获得最佳速度,允许线程保存共享变量的私有拷贝,而且只当线程进入或者离开同步代码块时才与共享成员变量的原始值进行比对。
image.png
CPU访问缓存要快于CPU访问主存,但比访问内存的寄存器快一些(上面已经提过)。
6.jpg
我们再来讲讲Java内存模型中的操作与同步规则。
image.png
Lock(锁定):作用于主内存的变量,把一个变量标识为一条线程独占状态。 Unlock(解锁):作用于主内存的变量,把一个处于锁定状态的变量释放出来。释放后的变量可以被其他线程锁定。 Read(读取):作用于主内存的变量,把一个变量值从主内存传输到线程的工作内存中,以便随后的Load作用使用。 Load(载入):作用于工作内存的变量,它把Read操作从主内存中得到的变量值放入到工作内存的变量副本中。 Use(使用):作用于工作内存的变量,把工作内存中的一个变量值传递到执行引擎。 Assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋值给工作内存的变量。 Store(存储):作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中,以便以后的write操作。 Write(写入):作用于主内存的变量,它把Store操作从工作内存中一个变量的值传送到主内存的变量。
Java内存模型要记住以下同步规则:
1.如果再把一个变量从主内存中复制到工作内存中,就需要按顺序的执行read和load操作。如果把变量从工作内存中同步回主内存中就需要按顺序的执行store和write操作。但Java内存模型只要求上述操作必须顺序的执行,而没保证必须是连续执行。
2.不允许read和load, store和write操作之一单独出现。
3.不允许一个线程丢弃它的最近assign操作,即变量在工作内存中改变之后没有同步到主内存中。
4.一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化(Load或者是Assign)的变量,即就是对一个变量进行Use和Store操作之前,必须执行过了Load和Assign操作。
5.一个变量在同一时刻只允许一条线程对其进行Lock操作。但Lock操作可以被同一条线程重复执行多次,多次执行lock后,只有执行相同次数的UnLock操作,变量才会被解锁。Lock与UnLock必须成对出现。
6.如果对一个变量执行Lock操作,将会清空工作内存中此变量的值,将会清空工作内存中此便利的值,在执行引擎使用这个变量前需要重新执行Load或者是Assign操作初始化变量的值。
7.如果一个变量事先没有被Lock操作锁定,则不允许对它执行UnLock操作,也不允许去Unlock一个被其他线程锁定的值。
8.对一个变量执行Unlock操作前,必须先把此变量同步到主内存中(执行Store、Write操作)。
话外音,如何判断一个线程成功获取了锁。
/**
* @author cmazxiaoma
* @version V1.0
* @Description: TODO
* @date 2018/12/27 13:32
*/
public class CheckLockTest {
private static final Object object = new Object();
public static void main(String[] args) {
synchronized (object) {
// 判断是否获取object锁
System.out.println("get lock result:" + Thread.holdsLock(object));
}
System.out.println("get lock result:" + Thread.holdsLock(object));
}
}
1.并发研究之CPU缓存一致性协议(MESI) 2.一文读懂处理器流水线 3.从Java视角理解CPU缓存和伪共享
对付拖延症,提高执行力。