volatile、缓存行、内存屏障、指令重排、Happens-Before原则、as-if-serial语义大乱炖

120 阅读28分钟

1.CPU指令结构

image.png

控制单元

控制单元是整个CPU的指挥控制中心,由指令寄存器IR(Instruction Register)、指令译码器ID(Instruction Decoder)和操作控制器OC(Operation Controller)等组成,对协调整个电脑有序工作极为重要。它根据用户预先编好的程序,依次从存储器中取出各条指令,放在指令寄存器IR中,通过指令译码(分析)确定应该进行什么操作,然后通过操作控制器OC,按确定的时序,向相应的部件发出微操作控制信号。操作控制器OC中主要包括:节拍脉冲发生器、控制矩阵、时钟脉冲发生器、复位电路和启停电路等控制逻辑。

运算单元

运算单元是运算器的核心。可以执行算术运算(包括加减乘数等基本运算及其附加运算)和逻辑运算(包括移位、逻辑测试或两个值比较)。相对控制单元而言,运算器接受控制单元的命令而进行动作,即运算单元所进行的全部操作都是由控制单元发出的控制信号来指挥的,所以它是执行部件。

存储单元

存储单元包括CPU片内缓存Cache和寄存器组,是CPU中暂时存放数据的地方,里面保存着那些等待处理的数据,或已经处理过的数据,CPU访问寄存器所用的时间要比访问内存的时间短。寄存器是CPU内部的元件,寄存器拥有非常高的读写速度,所以在寄存器之间的数据传送非常快。采用寄存器,可以减少CPU访问内存的次数,从而提高了CPU的工作速度。寄存器组可分为专用寄存器和通用寄存器。专用寄存器的作用是固定的,分别寄存相应的数据;而通用寄存器用途广泛并可由程序员规定其用途。

2.CPU高速缓存和缓存行

CPU在摩尔定律的指导下以每18个月翻一番的速度在发展,然而内存和硬盘的发展速度远远不及CPU。这就造成了高性能的内存和硬盘价格及其昂贵。然而CPU的高度运算需要高速的数据。为了解决这个问题,CPU厂商在CPU中内置了少量的高速缓存以解决I\O速度和CPU运算速度之间的不匹配问题。 CPU缓存是位于CPU与内存之间的临时数据交换器,它的容量比内存小的多但是交换速度却比内存要快得多。CPU缓存一般直接跟CPU芯片集成或位于主板总线互连的独立芯片上。

缓存行CacheLine:为了简化与内存之间的通信,高速缓存控制器是针对数据块,而不是字节进行操作的。高速缓存其实就是一组称之为缓存行(Cache Line)的固定大小的数据块组成的,缓存存储数据的单元,典型的一行是64字节。CPU读取内存中数据是按块读取的,利用程序局部性原理可以提高效率,充分发挥总线cpu针脚等一次性读取更多数据的能力。实际工业中缓存行大小一般为64字节:如果缓存行越大,局部性空间效率越高,但是读取时间越慢;如果缓存行越小,局部性空间效率越低,但是读取时间越快。所以取一个折中值为64字节

  在CPU访问存储设备时,无论是存取数据抑或存取指令,都趋于聚集在一片连续的区域中,这就被称为局部性原理

  • 时间局部性(Temporal Locality):如果一个信息项正在被访问,那么在近期它很可能还会被再次访问。比如循环、递归、方法的反复调用等。
  • 空间局部性(Spatial Locality):如果一个存储器的位置被引用,那么将来他附近的位置也会被引用。比如顺序执行的代码、连续创建的两个对象、数组等。

测试空间局部性demo:

public class TwoDimensionalArraySum {  
    private static final int RUNS = 100;  
    private static final int DIMENSION_1 = 1024 * 1024; //1M  
    private static final int DIMENSION_2 = 6;  
    private static long[][] longs;  

    public static void main(String[] args) throws Exception {  
        // 初始化数组  
        longs = new long[DIMENSION_1][];  
        for (int i = 0; i < DIMENSION_1; i++) {  
            longs[i] = new long[DIMENSION_2];  
            for (int j = 0; j < DIMENSION_2; j++) {  
                longs[i][j] = 1L;  
            }  
        }  

        //从左至右,从上到下:每一个格子占8bytes,一个缓存行大小一般为64bytes  
        // 每一行在内存中是连续存储的,根据空间局部性原则,一个缓存行可以同时从内存中加载8个格子入三级缓存中  
        long sum = 0L;  
        long start = System.currentTimeMillis();  
        for (int r = 0; r < RUNS; r++) {  
            for (int i = 0; i < DIMENSION_1; i++) {//DIMENSION_1=1024*1024  
                for (int j = 0; j < DIMENSION_2; j++) {//6  
                    sum += longs[i][j];  
                }  
            }  
        }  
        System.out.println("spend time1:" + (System.currentTimeMillis() - start));  

        start = System.currentTimeMillis();  
        for (int r = 0; r < RUNS; r++) {  
            for (int j = 0; j < DIMENSION_2; j++) {//6  
                for (int i = 0; i < DIMENSION_1; i++) {//1024*1024  
                    sum += longs[i][j];  
                }  
            }  
        }  
        System.out.println("spend time2:" + (System.currentTimeMillis() - start));  
    }  
}

3.CPU缓存结构

参考链接:www.cnblogs.com/h--d/p/1417… 现代CPU为了提升执行效率,减少CPU与内存的交互(交互影响CPU效率),一般在CPU上集成了多级缓存架构,常见的为三级缓存结构

  • L1 Cache,分为数据缓存和指令缓存,逻辑核独占
  • L2 Cache,物理核独占,逻辑核共享
  • L3 Cache,所有物理核共享

image.png

存储器存储空间大小:内存>L3>L2>L1>寄存器

存储器速度快慢排序:寄存器>L1>L2>L3>内存

还有一点值得注意的是:缓存是由最小的存储区块-缓存行(cacheline)组成,缓存行大小通常为64byte

image.png

CPU读取存储器数据过程

  1. CPU要取寄存器X的值,只需要一步:直接读取。
  2. CPU要取L1 cache的某个值,需要1-3步(或者更多):把cache行锁住,把某个数据拿来,解锁,如果没锁住就慢了。
  3. CPU要取L2 cache的某个值,先要到L1 cache里取,L1当中不存在,在L2里,L2开始加锁,加锁以后,把L2里的数据复制到L1,再执行读L1的过程,上面的3步,再解锁。
  4. CPU取L3 cache的也是一样,只不过先由L3复制到L2,从L2复制到L1,从L1到CPU。
  5. CPU取内存则最复杂:通知内存控制器占用总线带宽,通知内存加锁,发起内存读请求,等待回应,回应数据保存到L3(如果没有就到L2),再从L3/2到L1,再从L1到CPU,之后解除总线锁定。

4. JVM-JMM-CPU底层全执行流程

image.png

5.CPU缓存一致性协议

因为cpu和内存处理速度不匹配问题和避免处理器访问主内存的时间开销,CPU厂商们引入多级缓存结构以提高性能,弊端是。多核的CPU至少拥有着不止一个一级缓存,当高速缓存中拷贝了内存中同一个数据多个副本时,CPU操作的是每一个副本,而如何保证副本与副本之间,以及副本与主存的数据一致。解决方案比较常见的缓存一致性(MESI), 当然也有锁住总线

Intel系列CPU的缓存一致性协议是mesi cache,其他cpu厂商有可能不是此命名

MESI中每个缓存行都有四个状态,分别是E(exclusive)、M(modified)、S(shared)、I(invalid)

MESI协议是以缓存行cacheline(缓存的基本数据单位,在Intel的CPU上一般是64字节)的几个状态来命名的(Modified、Exclusive、Share、Invalid)。该协议要求在每个缓存行上维护两个状态位,使得每个数据单位可能处于M、E、S和I这四种状态之一

状态描述监听任务
M 修改 (Modified)该Cache line有效,数据被修改了,和内存中的数据不一致,数据只存在于本Cache中,还没有更新到内存中缓存行必须时刻监听所有试图读该缓存行相对就主存的操作,这种操作必须在缓存将该缓存行写回主存并将状态变成S状态之前被延迟执行
E 独享、互斥 (Exclusive)该Cache line有效,数据和内存中的数据一致,数据只存在于本Cache中。缓存行也必须监听其它缓存读主存中该缓存行的操作,一旦有这种操作,该缓存行需要变成S状态
S 共享 (Shared)该Cache line有效,数据和内存中的数据一致,数据存在于很多Cache中。缓存行也必须监听其它缓存使该缓存行无效或者独享该缓存行的请求,并将该缓存行变成无效
I 无效 (Invalid)本CPU中该Cache line无效。

MESI状态转换图

image.png

6.CPU缓存行伪共享

什么是伪共享?

CPU缓存系统中是以缓存行为单位存储的。目前主流CPU Cache的CacheLine大小是64Bytes。在多线程情况下,如果需要修改“共享同一个缓存行的变量”,就会无意中影响彼此的性能,这就是伪共享(False Sharing),总结:伪共享指的是多个线程同时读写同一个缓存行的不同变量时导致的 CPU缓存失效

举个例子: 现在有2long 型变量 ab,如果有t1在访问at2在访问b,
而ab刚好在同一个cache line中,此时t1先修改a,将导致b被刷新!
(t1访问a前,先在三级缓存中看是否已经缓存过,如果没有就将a加载到cpu中,
缓存在三级缓存中,t2同理,因为事件局部性和空间局部性,
cpu一次性加载64bytes大小的缓存行。如果此时t1修改了a,
因为mesi缓存一致性原理,会使t2的缓存行失效,
t2访问b就需要重新从主内存中加载缓存行)

  怎么解决伪共享?

解决伪共享的方法一般都是使用直接填充,我们只需要保证不同线程的变量存在
于不同的 CacheLine 即可,使用多余的字节来填充可以做点这一点,
这样就不会出现伪共享问题。例如在Disruptor队列的设计中就有类似设计

image.png

image.png

Java8中新增了一个注解:@sun.misc.Contended,加上这个注解的类会自动补齐缓存行,同时设置 -XX:-RestrictContended 才会生效

@sun.misc.Contended
public final static class TulingVolatileLong {

    public volatile long value = 0L;
    
    //public long p1, p2, p3, p4, p5, p6;
}

7.MESI优化和引入的问题

缓存的一致性消息传递是要时间的,这就使其切换时会产生延迟。当一个缓存被切换状态时其他缓存收到消息完成各自的切换并且发出回应消息这么一长串的时间中CPU都会等待所有缓存响应完成。可能出现的阻塞都会导致各种各样的性能问题和稳定性问题

CPU切换状态阻塞解决-存储缓存(Store Bufferes)

比如你需要修改本地缓存中的一条信息,那么你必须将I(无效)状态通知到其他拥有该缓存数据的CPU缓存中,并且等待确认。等待确认的过程会阻塞处理器,这会降低处理器的性能。应为这个等待远远比一个指令的执行时间长的多。

Store Bufferes

为了避免这种CPU运算能力的浪费,Store Bufferes被引入使用。处理器把它想要写入到主存的值写到缓存,然后继续去处理其他事情。当所有失效确认(Invalidate Acknowledge)都接收到时,数据才会最终被提交。这么做有两个风险

Store Bufferes的风险

第一、就是处理器会尝试从存储缓存(Store buffer)中读取值,但它还没有进行提交。这个的解决方案称为Store Forwarding,它使得加载的时候,如果存储缓存中存在,则进行返回。

第二、保存什么时候会完成,这个并没有任何保证。

value = 3void exeToCPUA(){
  value = 10;
  isFinsh = true;
}

void exeToCPUB(){
  if(isFinsh){
    //value一定等于10?!
    assert value == 10;
  }
}

试想一下开始执行时,CPU A保存着finished在E(独享)状态,而value并没有保存在它的缓存中。(例如,Invalid)。在这种情况下,value会比finished更迟地抛弃存储缓存。完全有可能CPU B读取finished的值为true,而value的值不等于10。

即isFinsh的赋值在value赋值之前。

这种在可识别的行为中发生的变化称为重排序(reordings)。注意,这不意味着你的指令的位置被恶意(或者好意)地更改。

它只是意味着其他的CPU会读到跟程序中写入的顺序不一样的结果。

8.内存屏障memory barrier

又称内存栅栏memory fence,一个CPU指令:通过阻止屏障两边的指令重排序来避免编译器和硬件的不正确优化而提出的一种解决办法

作用:保证特定操作的执行顺序

保证某些变量的内存可见性(volatile变量正是通过内存屏障实现其在内存中的语义,即可见性和禁止重排优化

Intel硬件提供了一系列的内存屏障,主要有: 

  1. lfence,是一种Load Barrier 读屏障 
  2. sfence, 是一种Store Barrier 写屏障 
  3. mfence, 是一种全能型的屏障,具备ifence和sfence的能力 
  4. Lock前缀,Lock不是一种内存屏障,但是它能完成类似内存屏障的功能。Lock会对CPU总线和高速缓存加锁,可以理解为CPU指令级的一种锁。它后面可以跟ADD, ADC, AND, BTC, BTR, BTS, CMPXCHG, CMPXCH8B, DEC, INC, NEG, NOT, OR, SBB, SUB, XOR, XADD, and XCHG等指令。

不同硬件实现内存屏障的方式不同,Java内存模型屏蔽了这种底层硬件平台的差异,由JVM来为不同的平台生成相应的机器码。

Jvm内存屏障主要分Load和Store两类:

  • LoadBarrier:在读指令前插入读屏障,可以让高速缓存中的数据失效,重新从主内存加载数据
  • StoreBarrier:在写指令之后插入写屏障,能让写入缓存的最新数据写回到主内存

JVM中提供了四类内存屏障指令:

屏障类型指令示例说明
LoadLoadLoad1; LoadLoad; Load2保证load1的读取操作在load2及后续读取操作之前执行
StoreStoreStore1; StoreStore; Store2在store2及其后的写操作执行前,保证store1的写操作已刷新到主内存
LoadStoreLoad1; LoadStore; Store2在stroe2及其后的写操作执行前,保证load1的读操作已读取结束
StoreLoadStore1; StoreLoad; Load2保证store1的写操作已刷新到主内存之后,load2及其后的读操作才能执行

volatile的实现细节:

jvm层面

StoreStoreBarrier      volatile读    
volatile写             LoadLoadBarrier
StoreLoadBarrier       LoadStoreBarrier

9.指令重排

参考链接:www.cnblogs.com/yuluoxingko…

什么是指令重排序?有两个层面:

  1. 在虚拟机层面,为了尽可能减少内存操作速度远慢于CPU运行速度所带来的CPU空置的影响,虚拟机会按照自己的一些规则将程序编写顺序打乱——即写在后面的代码在时间顺序上可能会先执行,而写在前面的代码会后执行——以尽可能充分地利用CPU
  2. 在硬件层面,CPU会将接收到的一批指令按照其规则重排序,同样是基于CPU速度比缓存速度快的原因,只是硬件处理的话,每次只能在接收到的有限指令范围内重排序,而虚拟机可以在更大层面、更多指令范围内重排序

代码书写的顺序与实际执行的顺序不同,指令重排序是编译器或处理器为了提高程序性能而做的优化。在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排序。重排序分为3种类型:

  1. 编译器优化的重排序(编译器优化):在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排序
  2. 指令级并行重排序(处理器优化):现代处理器采用了指令级并行技术(Instruction-Level Parallelism,ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序
  3. 内存系统的重排序(处理器优化):由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行

java语言规范规定JVM线程内部维持顺序化语义。即只要程序的最终结果与它顺序化情况的结果相等,那么指令的执行顺序可以与代码顺序不一致,此过程叫指令的重排序

指令重排序的意义:

JVM能根据处理器特性(CPU多级缓存系统、多核处理器等)适当的对机器指令进行重排序,使机器指令能更符合CPU的执行特性,最大限度的发挥机器性能

image.png

public class Test重排序 {  
    private static int x = 0, y = 0;  
    private static int a = 0, b = 0;  
    private static int count = 0;  

    public static void main(String[] args) throws InterruptedException {  
        do {  
            a = 0;  
            b = 0;  
            x = 0;  
            y = 0;  
            test();  
        } while (true);  
    }  

    private static void test() throws InterruptedException {  
        Thread thread1 = new Thread(() -> {  
            a = 1;  
            x = b;  
        });  
        Thread thread2 = new Thread(() -> {  
            b = 1;  
            y = a;  
        });  
        thread1.start();  
        thread2.start();  
        thread1.join();  
        thread2.join();  
        count++;  
        if (x == 0 && y == 0) {  
            System.out.println("执行了" + count + "次"); //全凭运气,执行了1105729次  
            System.exit(0);  
        }  
    }  
}

防止指令重排:

  1. volatile关键字
  2. unsafe.fullFence();  //手动加上内存屏障

10. volatile关键字

volatile:保证共享变量的在内存的立即可见性,有序性(防止指令重排,但在一定程度上降低了代码执行效率),不保证原子性

作用:volatile在JMM中为了保证内存的可见性,即是线程之间操作共享变量的可见性

  • volatile 写的内存语义:当写一个volatile修饰的共享变量时,JMM会把该线程的本地内存的共享变量副本值刷新到主内存中;
  • volatile 读的内存语义:当读一个volatile修饰的共享变量时,JMM会将该线程的本地内存的共享变量副本置为无效,要求线程重新去主内存中获取最新的值。

  java内存模型控制与volatile冲突吗?什么区别?

不冲突!java内存模型控制线程工作内存与主内存之间共享变量会同步,即线程从主内存中读一份副本到工作内存,又刷新到主内存,那怎么还需要 volatile来保证可见性,不是JMM自己能控制吗,一般情况下JMM可以控制 2份内存数据一致性,但是在多线程并发环境下,虽然最终线程工作内存中的共享变量会同步到主内存,但这需要时间和触发条件,线程之间同时操作共享变量协作时,就需要保证每次都能获取到主内存的最新数据,保证看到的工作变量是最后一次修改后的值,这个JMM没法控制保证,这就需要volatile或者synchronized和锁的同步机制来实现了。

11. 原子性、可见性和有序性

原子性:

即一个操作或者多个操作要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。volatile是无法保证复合操作的原子性。要想在多线程环境下保证原子性,则可以通过锁、synchronized来确保。

可见性:

指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。当一个变量被volatile修饰后,表示着线程本地内存无效,当一个线程修改共享变量后他会立即被更新到主内存中,当其他线程读取共享变量时,它会直接从主内存中读取。synchronized和锁都可以保证可见性

有序性:

程序执行的顺序按照代码的先后顺序执行。指令重排

volatile变量修饰符如果使用恰当的话,它比synchronized的使用和执行成本会更低,因为它不会引起线程上下文的切换和调度

public class Jmm03_CodeVisibility {  
    private static boolean initFlag = false;  
    private static int counter = 0;  

    public static void refresh() {  
    initFlag = true;  
    }  

    public static void main(String[] args) throws InterruptedException {  
        new Thread(() -> {  
            while (!initFlag) {  
                counter++; //修改counter为Integer,或者在while中sout,意想不到的惊喜  
            }  
            System.out.println(Thread.currentThread().getName() + "当前线程嗅探到initFlag的状态的改变" + counter);  
        }, "threadA").start();  
        Thread.sleep(500);  
        new Thread(Jmm03_CodeVisibility::refresh, "threadB").start();  
    }  
}

12.volatile关键字的作用、底层实现、应用场景

java内存模型JMM规定:         cmpxchg指令

  1. Java所有变量都存储在主内存中。

  2. 每个线程都有自己独立的工作内存,里面保存该线程的使用到的变量副本(该副本就是主内存中该变量的一份拷贝)。线程对共享变量的所有操作都必须在自己的工作内存中进行,不能直接在主内存中读写

  3. 不同线程之间无法直接访问其他线程工作内存中的变量,线程间变量值的传递需要通过主内存来完成。

  4. 线程1对共享变量的修改,要想被线程2及时看到,必须经过如下2个过程:

    (1)把工作内存1中更新过的共享变量刷新到主内存中
    (2)将主内存中最新的共享变量的值更新到工作内存2中
    

作用:

通过内存屏障jvm保证了volatile变量的可见性和有序性(防止指令重排),但不具备原子性

可见性:

一个线程对共享变量的修改,更够及时的被其他线程看到(刷新到主存中,并使其他工作内存对该共享变量失效,需要重新从主存中读取)。

volatile实现:

通过store和load指令完成的,也就是对volatile变量执行写操作时,会在写操作后加入一条store指令,即强迫线程将最新的值刷新到主内存中;而在读操作时,会加入一条load指令,即强迫从主内存中读入变量的值

场景:

1.cas比较并替换
2.dcl双重校验锁

public class DoubleCheckLock {  
    private volatile static DoubleCheckLock instance; //volatile 禁止指令重排优化  

    private DoubleCheckLock() {  
    }  

    public static DoubleCheckLock getInstance() {  
        if (instance == null) {  
            synchronized (DoubleCheckLock.class) {  
                if (instance == null) {  
                    //多线程环境下可能会出现问题的地方  
                    instance = new DoubleCheckLock();  
                }  
            }  
        }  
        return instance;  
    }  
}

new对象主要分三步

0 new #2 分配内存空间,成员变量赋默认值,处于半初始化状态,还没有赋初始值    

3 dup

4 invokesepical #3  <T.>        调用默认构造器,赋初始值,这是初始化对象

7 astore _1 将符号引用指向实际堆内的实际地址 T t = new T()

当第三条指令和第二条指令重排时,就是astore先于invokespecial发生,此时t指向的对象就是处于半初始化状态

CPU的乱序执行:cpu在进行读等待的同时执行指令,是cpu乱序的根源,是为了提高效率

cpu发去一条指令去内存中读取数据,如果仅仅什么都不做仅仅是等待指令的返回数据就会浪费时间,所以cpu在等待过程中也可以执行其他的指令

13.volatile重排序规则表

JMM针对编译器制定的volatile重排序规则表:

是否能重排序****第二个操作****
第一个操作普通读/写volatile 读volatile 写
普通读/写  NO
volatile 读NONONO
volatile 写 NONO

其实一个一个格子分开看是很难读懂其中的前因后果的,但可以总结为三条:

  1. 当第二个操作是volatile写时,不管第一个操作是什么,都不能重排序。确保volatile写之前的操作不会被编译器重排序到volatile写之后。
  2. 当第一个操作是volatile读时,不管第二个操作是什么,都不能重排序。确保volatile读之后的操作不会被编译器重排序到volatile读之前。
  3. 当第一个操作是volatile写,第二个操作是volatile读时,不能重排序

2大规律

第一条规律:

当第二个操作是volatile 写时,不管第一个操作是什么,都不能重排序。如果进行重排序,那么volatile 写会使其他CPU的缓存行无效,就不能保证volatile 写之前的共享变量数据的一致,如此就违背了内存语义。

同理,在volatile 变量进行读操作的时候,会直接从主存中读取,再存储到缓存行。

第二条规律:

当第一个操作是volatile 读时,不管第二个操作是什么,都不能重排序。如果进行重排序,当前缓存行的数据就会被置为无效,那么缓存行中的普通共享变量也会再从主存中重新读取,如此就违背了内存语义

为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎不可能。为此JMM了采取保守策略。下面是基于保守策略的JMM内存屏障插入策略:

  • 在每个volatile写操作的前面插入一个StoreStore屏障
  • 在每个volatile写操作的后面插入一个StoreLoad屏障
  • 在每个volatile读操作的后面插入一个LoadLoad屏障
  • 在每个volatile读操作的后面插入一个LoadStore屏障

上述内存屏障插入策略非常保守,但它可以保证在任意处理器平台,任意的程序中都能得到正确的volatile内存语义

14.Lock指令

缓存行 : CPU高速缓存中可以分配的最小的存储单位。处理器填写缓存行时会加载整个缓存行。

volatile 变量在进行写操作的时候,会插入一条Lock前缀的指令。
这个指令在多核处理器下会发生两个事情:

  • 将当前处理器缓存行的数据写回主存
  • 使其他CPU里的缓存无效,下次访问相同内存地址时,将强制执行缓存行填充

15.Happens-Before原则(jvm规定重排序必须遵守的原则) JLS17.4.5

为什么会有happens-before原则:因为jvm会对代码进行编译优化,指令会出现重排序的情况,为了避免编译优化对并发编程安全性的影响,需要happens-before规则定义一些禁止编译优化的场景,保证并发编程的正确性。Happens-before的前后两个操作不会被重排序且后者对前者的内存可见

A happens-before B:就是A先行发生于B,定义为hb(A,B)。在Java内存模型中,happens-before的意思是前一个操作的结果可以被后续操作获取

  1. 程序次序原则:在一个线程内,按照程序代码的顺序,前面的代码运行的结果能被后面的代码可见
  2. 管程锁定规则:一个unlock操作先行发生于后面(时间上)对这个变量的读操作。就是无论是在单线程环境还是多线程环境,对于同一个锁来说,一个线程对这个锁解锁之后,另一个线程获取了这个锁都能看到前一个线程的操作结果!(管程是一种通用的同步原语,synchronized就是管程的实现)
  3. volatile变量:对一个volatile变量的写操作先行发生于后面(时间上)对这个变量的读操作。就是如果一个线程先去写一个volatile变量,然后一个线程去读这个变量,那么这个写操作的结果一定对读的这个线程可见
  4. 线程启动规则:thread的start方法先行发生于这个线程的每一个操作
  5. 线程终止规则;在主线程A执行过程中,子线程B终止,那么线程B在终止之前对共享变量的修改结果在线程A中可见。也称线程join()规则
  6. 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程代码检测到中断事件的发生,可以通过Thread.interrupted()检测到是否发生中断
  7. 对象终结规则:一个对象的初始化完成先行与发生它的finalize()方法的开始
  8. 传递性规则:就是happens-before具有传递性。如果a先行于b,b先行于c,那么a先行于c

两个操作之间具有happens-before关系,并不意味着前一个操作必须要在后一个操作之前执行,happens-before仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前

16.as-if-serial语义:

中文意思是:就像是串行的。其语义是:不管怎么重排序,单线程程序的执行结果不能被改变。编译器、runtime和处理器都必须遵守as-if-serial语义。所以编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作就可能被编译器和处理器重排序

所有的动作(Action)都可以为了优化而被重排序,但是必须保证它们重排序后的结果和程序代码本身的应有结果是一致的。Java编译器、运行时和处理器都会保证单线程下的as-if-serial语义

17. CPU相关

单核CPU:单核处理器,只有一个核心处理器

多核CPU:双核处理器,

超线程HT:Hyper-Threading

总核数 = 物理CPU个数 X 每颗物理CPU的核数

总逻辑CPU数 = 物理CPU个数 X 每颗物理CPU的核数 X 超线程数

查看物理CPU的个数:cmd+systeminfo

查看物理CPU数、CPU核心数、线程数:wmic+cpu get

18.主内存与工作内存

主内存:主要存储的是Java实例对象,所有线程创建的实例对象都存放在主内存中,不管该实例对象是成员变量还是方法中的本地变量(也称局部变量),当然也包括了共享的类信息、常量、静态变量。由于是共享数据区域,多条线程对同一个变量进行访问可能会发生线程安全问题。

JAVA内存模型规定了所有的变量都存储在主内存(Main Memory)中。所有的线程都有自己的工作内存,工作内存中保存了被该线程使用到的变量的主内存副本拷贝,线程对变量的所有操作(读取、赋值)都必须在工作内存中执行,而不能直接读写主内存中的变量。同时,线程之间也无法读写各自的工作内存

java语言层面实现可见性的方式:

synchronized(可见性+原子性):JMM关于synchronized的两条规定:

  • 线程解锁前,必须把共享变量的最新值刷新到主内存中去
  • 线程加锁时,将清空工作内存中共享变量的值,从而使用共享变量时需要从主内存中重新读取最新的值

volatile(原子性+防止指令重排)