目录在app上无法点击,应该是CSDN的bug。大家当做简介看好了。下拉正文。
目录
前言:JMM基础-计算机原理
面试的时候,我们经常会将JVM的内存模型和JVM的运行时数据区搞混。
其实Java内存模型即Java Memory Model,简称JMM。JMM定义了Java 虚拟机(JVM)在计算机内存(RAM)中的工作方式。JVM是整个计算机虚拟模型,所以JMM是隶属于JVM的。Java1.5版本对其进行了重构,现在的Java仍沿用了Java1.5的版本。Jmm遇到的问题与现代计算机中遇到的问题是差不多的。
物理计算机中的并发问题,物理机遇到的并发问题与虚拟机中的情况有不少相似之处,物理机对并发的处理方案对于虚拟机的实现也有相当大的参考意义。
根据《Jeff Dean在Google全体工程大会的报告》我们可以看到
计算机在做一些我们平时的基本操作时,需要的响应时间是不一样的。(以下案例仅做说明,并不代表真实情况。)
如果从内存中读取1M的int型数据由CPU进行累加,耗时要多久?
做个简单的计算,1M的数据,Java里int型为32位,4个字节,共有1024*1024/4 = 262144个整数 ,则CPU 计算耗时:262144 *0.6 = 157 286 纳秒,而我们知道从内存读取1M数据需要250000纳秒,两者虽然有差距(当然这个差距并不小,十万纳秒的时间足够CPU执行将近二十万条指令了),但是还在一个数量级上。但是,没有任何缓存机制的情况下,意味着每个数都需要从内存中读取,这样加上CPU读取一次内存需要100纳秒,262144个整数从内存读取到CPU加上计算时间一共需要262144*100+250000 = 26 464 400 纳秒,这就存在着数量级上的差异了。
而且现实情况中绝大多数的运算任务都不可能只靠处理器“计算”就能完成,处理器至少要与内存交互,如读取运算数据、存储运算结果等,这个I/O操作是基本上是无法消除的(无法仅靠寄存器来完成所有运算任务)。早期计算机中cpu和内存的速度是差不多的,但在现代计算机中,cpu的指令速度远超内存的存取速度,由于计算机的存储设备与处理器的运算速度有几个数量级的差距,所以现代计算机系统都不得不加入一层读写速度尽可能接近处理器运算速度的高速缓存(Cache)来作为内存与处理器之间的缓冲:将运算需要使用到的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存同步回内存之中,这样处理器就无须等待缓慢的内存读写了。
在计算机系统中,寄存器划是L0级缓存,接着依次是L1,L2,L3(接下来是内存,本地磁盘,远程存储)。越往上的缓存存储空间越小,速度越快,成本也更高;越往下的存储空间越大,速度更慢,成本也更低。从上至下,每一层都可以看做是更下一层的缓存,即:L0寄存器是L1一级缓存的缓存,L1是L2的缓存,依次类推;每一层的数据都是来至它的下一层,所以每一层的数据是下一层的数据的子集。
在现代CPU上,一般来说L0, L1,L2,L3都集成在CPU内部,而L1还分为一级数据缓存(Data Cache,D-Cache,L1d)和一级指令缓存(Instruction Cache,I-Cache,L1i),分别用于存放数据和执行数据的指令解码。每个核心拥有独立的运算处理单元、控制器、寄存器、L1、L2缓存,然后一个CPU的多个核心共享最后一层CPU缓存L3
1、物理内存模型带来的问题
基于高速缓存的存储交互很好地解决了处理器与内存的速度矛盾,但是也为计算机系统带来更高的复杂度,因为它引入了一个新的问题:缓存一致性(Cache Coherence)。在多处理器系统中,每个处理器都有自己的高速缓存,而它们又共享同一主内存(MainMemory)。当多个处理器的运算任务都涉及同一块主内存区域时,将可能导致各自的缓存数据不一致。
现代的处理器使用写缓冲区临时保存向内存写入的数据。写缓冲区可以保证指令流水线持续运行,它可以避免由于处理器停顿下来等待向内存写入数据而产生的延迟。同时,通过以批处理的方式刷新写缓冲区,以及合并写缓冲区中对同一内存地址的多次写,减少对内存总线的占用。虽然写缓冲区有这么多好处,但每个处理器上的写缓冲区,仅仅对它所在的处理器可见。这个特性会对内存操作的执行顺序产生重要的影响:处理器对内存的读/写操作的执行顺序,不一定与内存实际发生的读/写操作顺序一致。
处理器A和处理器B按程序的顺序并行执行内存访问,最终可能得到x=y=0的结果。
处理器A和处理器B可以同时把共享变量写入自己的写缓冲区(步骤A1,B1),然后从内存中读取另一个共享变量(步骤A2,B2),最后才把自己写缓存区中保存的脏数据刷新到内存中(步骤A3,B3)。当以这种时序执行时,程序就可以得到x=y=0的结果。
从内存操作实际发生的顺序来看,直到处理器A执行A3来刷新自己的写缓存区,写操作A1才算真正执行了。虽然处理器A执行内存操作的顺序为:A1→A2,但内存操作实际发生的顺序却是A2→A1。
如果真的发生这种情况,那同步回到主内存时以谁的缓存数据为准呢?为了解决一致性的问题,需要各个处理器访问缓存时都遵循一些协议,在读写时要根据协议来进行操作,这类协议有MSI、MESI(Illinois Protocol)、MOSI、Synapse、Firefly及Dragon Protocol等。
2、伪共享
前面我们已经知道,CPU中有好几级高速缓存。但是CPU缓存系统中是以缓存行(cache line)为单位存储的。目前主流的CPU Cache的Cache Line大小都是64Bytes。Cache Line可以简单的理解为CPU Cache中的最小缓存单位,今天的CPU不再是按字节访问内存,而是以64字节为单位的块(chunk)拿取,称为一个缓存行(cache line)。当你读一个特定的内存地址,整个缓存行将从主存换入缓存。
一个缓存行可以存储多个变量(存满当前缓存行的字节数);而CPU对缓存的修改又是以缓存行为最小单位的,在多线程情况下,如果需要修改“共享同一个缓存行的变量”,就会无意中影响彼此的性能,这就是伪共享(False Sharing)。
为了避免伪共享,我们可以使用数据填充的方式来避免,即单个数据填充满一个CacheLine。这本质是一种空间换时间的做法。但是这种方式在Java7以后可能失效。
Java8中已经提供了官方的解决方案,Java8中新增了一个注解@sun.misc.Contended。
比如JDK的ConcurrentHashMap中就有使用
加上这个注解的类会自动补齐缓存行,需要注意的是此注解默认是无效的,需要在jvm启动时设置-XX:-RestrictContended才会生效。
测试代码
public class FalseSharing implements Runnable
{
public final static int NUM_THREADS =
Runtime.getRuntime().availableProcessors();
public final static long ITERATIONS = 500L * 1000L * 1000L;
private final int arrayIndex;
/*数组大小和CPU数相同*/
// private static VolatileLong[] longs = new VolatileLong[NUM_THREADS];
// private static VolatileLongPadding[] longs = new VolatileLongPadding[NUM_THREADS];
private static VolatileLongAnno[] longs = new VolatileLongAnno[NUM_THREADS];
static{
/*将数组初始化*/
for (int i = 0; i < longs.length; i++){
longs[i] = new VolatileLongAnno();
}
}
public FalseSharing(final int arrayIndex){
this.arrayIndex = arrayIndex;
}
public static void main(final String[] args) throws Exception{
final long start = System.nanoTime();
runTest();
System.out.println("duration = " + (System.nanoTime() - start));
}
private static void runTest() throws InterruptedException{
/*创建和CPU数相同的线程*/
Thread[] threads = new Thread[NUM_THREADS];
for (int i = 0; i < threads.length; i++){
threads[i] = new Thread(new FalseSharing(i));
}
for (Thread t : threads){
t.start();
}
/*等待所有线程执行完成*/
for (Thread t : threads){
t.join();
}
}
/*访问数组*/
public void run(){
long i = ITERATIONS + 1;
while (0 != --i){
longs[arrayIndex].value = i;
}
}
public final static class VolatileLong {
public volatile long value = 0L;
}
// long padding避免false sharing
// 按理说jdk7以后long padding应该被优化掉了,但是从测试结果看padding仍然起作用
public final static class VolatileLongPadding {
public long p1, p2, p3, p4, p5, p6, p7;
public volatile long value = 0L;
volatile long q0, q1, q2, q3, q4, q5, q6;
}
/**
* jdk8新特性,Contended注解避免false sharing
* Restricted on user classpath
* Unlock: -XX:-RestrictContended
*/
@sun.misc.Contended
public final static class VolatileLongAnno {
public volatile long value = 0L;
}
一个类中,只有一个long类型的变量:
定义一个VolatileLong类型的数组,然后让多个线程同时并发访问这个数组,这时可以想到,在多个线程同时处理数据时,数组中的多个VolatileLong对象可能存在同一个缓存行中。
运行后,可以得到运行时间
花费了39秒多。
我们改用进行了缓存行填充的变量
花费了8.1秒,如果任意注释上下填充行的任何一行,时间表现不稳定,从8秒到20秒都有,但是还是比不填充要快。具体原因目前未知。
再次改用注解标识的变量,同时加入参数-XX:-RestrictContended
花费了7.7秒。
由上述的实验结果表明,伪共享确实会影响应用的性能。
3、Java内存模型(JMM)
从抽象的角度来看,JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存(Main Memory)中,每个线程都有一个私有的本地内存(Local Memory),本地内存中存储了该线程以读/写共享变量的副本。本地内存是JMM的一个抽象概念,并不真实存在。它涵盖了缓存、写缓冲区、寄