03-JMM

  1. JMM(Java_Memory_Model)抽象的概念,并不真实存在。不是对物理内存的规范,而是在VM基础上进行的规范和多线程相关的一组规范。每个JVM的实现都要遵守这样的规范,并发程序运行在不同的VM上时,得到的程序结果才是安全可靠可信赖的,从而实现平台一致性
  2. JMM结构规范
    • JMM规定了所有的变量都存储在主内存(Main_Memory)中。每个线程还有自己的工作内存(Working_Memory)。Working_Memory保存了该线程使用到的变量的Main_Memory的副本拷贝,线程对变量的所有操作(读取、赋值等)都必须在Working_Memory中进行,而不能直接读写Main_Memory中的变量(volatile变量仍然有Working_Memory的拷贝,但是由于它特殊的操作顺序性规定,所以看起来如同直接在Main_Memory中读写访问一般)。不同的线程之间也无法直接访问对方Working_Memory中的变量,线程之间值的传递都需要通过Main_Memory来完成
    • Java几个语言级别的关键字,volatile, final, synchronized,帮助程序员向编译器描述一个程序的并发需求
image-20230723210159560
  1. Java内存模型的抽象结构
    • 在Java中,所有实例域、静态域和数组元素都存储在Heap中,Heap在线程之间共享
      • 局部变量(LocalVariables),方法定义参数(Java语言规范称之为FormalMethodParameters)和异常处理器参数(ExceptionHandlerParameters)不会在线程之间共享,它们不会有内存可见性问题,也不受内存模型的影响
    • Java线程之间的通信由JMM控制,JMM决定一个线程对共享变量的写入何时对另一个线程可见
      • 从抽象的角度来看,JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存(MainMemory)中,每个线程都有一个私有的本地内存(LocalMemory),本地内存中存储了该线程以读/写共享变量的副本
      • 本地内存是JMM的一个抽象概念,并不真实存在。它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化
  2. 支撑Java内存模型的基础原理
    • 指令重排序
    • 数据依赖性

1. 硬件层的内存

全部数据量的80%都可以在一级缓存中找到,只剩下20%才需要从二级缓存、三级缓存或内存中读取。由此可见一级缓存是整个CPU缓存架构中最为重要的部分

image-20220712084642279
从cpu到大约需要的cpu周期大约需要的时间
L0_寄存器约 1 cycle
L1_cache约 3~4 cycles约1ns
L2_cache约 10 cycles约3ns
L3_cache约 40~45 cycles约15ns
QPI总结传输(between sockets, not drawn)约20ns
主存约60~80ns

1. 总线锁

CPU访问L3要经过bus总线。老的CPU,总线加锁。总线锁会锁住总线,使得其他CPU甚至不能访问内存中其他的地址,因而效率较低

2. 缓存行,伪共享

  1. 读取缓存以cache_line为基本单位,目前64B
  2. 非常快速的遍历连续的内存块中分配的任意数据结构。如果数据结构中的项在内存中不是彼此相邻的(链表),将得不到免费缓存加载所带来的优势
  3. L3缓存,CPU是共享的
  4. 伪共享:位于同一缓存行的两个不同数据,被两个不同CPU锁定,产生互相影响
    • 使用缓存行的对齐能够提高效率
public class T1_CacheLinePadding {
    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 < 10_000_000L; i++) {
                arr[0].x = i;
            }
        });

        Thread t2 = new Thread(() -> {
            for (long i = 0; i < 10_000_000L; i++) {
                arr[1].x = i;
            }
        });

        final long start = System.nanoTime();
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println((System.nanoTime() - start) / 1_000_000);
    }
}
public class T2_CacheLinePadding {
    private static class Padding {
        // long类型8个byte。cache_line为64B
        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 < 10_000_000L; i++) {
                arr[0].x = i;
            }
        });

        Thread t2 = new Thread(() -> {
            for (long i = 0; i < 10_000_000L; i++) {
                arr[1].x = i;
            }
        });

        final long start = System.nanoTime();
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println((System.nanoTime() - start) / 1_000_000);
    }
}
image-20220712084735360

1. 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

2. @Contended

  • @Contended用于类型上或属性上,JVM会自动进行填充,避免伪共享。这个注解在JDK8 ConcurrentHashMap, ForkJoinPool, Thread 等类中都有应用
  • @sun.misc.Contended注解在user_classpath中是不起作用的,需要通过JVM参数来开启:-XX:-RestrictContended
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.FIELD, ElementType.TYPE})
public @interface Contended {
    String value() default "";
}

JDK8中ConcurrentHashMap中运用@Contended解决伪共享问题

  • ConcurrentHashMap的size操作通过CounterCell来计算,哈希表中的每个节点都用了一个CounterCell,每个CounterCell记录了对应Node的键值对数目。这样每次计算size时累加各个CounterCell就可以了。ConcurrentHashMap中CounterCell以数组形式保存,而数组在内存中是连续的,CounterCell中只有一个long类型的value属性,这样CPU会缓存CounterCell临近的CounterCell,就形成了伪共享。ConcurrentHashMap中用@Contended注解自动对CounterCell来进行填充
/**
 * Table of counter cells. When non-null, size is a power of 2.
 */
private transient volatile CounterCell[] counterCells; // CounterCell数组,CounterCell在内存中连续

public int size() {
    long n = sumCount();
    return ((n < 0L) ? 0 :
            (n > (long)Integer.MAX_VALUE) ? Integer.MAX_VALUE :
            (int)n);
}

// 计算size时直接对各个CounterCell的value进行累加
final long sumCount() {
    CounterCell[] as = counterCells; CounterCell a;
    long sum = baseCount;
    if (as != null) {
        for (int i = 0; i < as.length; ++i) {
            if ((a = as[i]) != null)
                sum += a.value;
        }
    }
    return sum;
}

// 使用Contended注解自动进行填充避免伪共享
@sun.misc.Contended static final class CounterCell {
    volatile long value;
    CounterCell(long x) { value = x; }
}

3. MESI,缓存一致性协议

  1. MESI——CPU缓存一致性协议open in new window
  2. MSI, MESI, MOSI, Synapse, Firefly Dragon,这些都是数据一致性协议
  3. intel_cpu用的是MESI
  4. MESI缓存锁实现之一,有些无法被缓存的数据或者跨越多个缓存行的数据依然必须使用总线锁
    • 现代CPU的数据一致性实现 = 缓存锁(MESI...)+ 总线锁

cache_line的四种标记

  • M(修改,Modified):本地处理器已经修改缓存行,即是脏行,与内存中的内容不一样,并且此cache只有本地一个拷贝(专有)
  • E(专有,Exclusive):缓存行内容和内存中的一样,而且其它处理器都没有这行数据
  • S(共享,Shared):缓存行内容和内存中的一样, 有可能其它处理器也存在此缓存行的拷贝
  • I(无效,Invalid):缓存行失效,不能使用

1. E状态

  • 此时只有core1访问缓存行,它的缓存行的状态为E,表示core1独占
image-20230725091312714

2. S状态

  • core1和core2都会访问缓存行,他们的缓存行状态为S,表示缓存行处于共享状态
image-20230725091403426

3. M和I状态

  • 此时core1修改了缓存行,因此core1的缓存行状态为M,代表已经修改,而core2的缓存行状态为I,代表已经失效,需要从主存中读取
image-20230725091443483

4. 缓存行状态转换

在MESI协议中,每个Cache的Cache控制器不仅知道自己的读写操作,而且也监听(snoop)其它Cache的读写操作。每个Cache_line所处的状态根据本Core和其他Core的读写操作在4个状态间进行迁移。MESI协议状态迁移图如下:

image-20230725091521220
  • 初始:一开始时,缓存行没有加载任何数据,所以它处于 I
  • 本地写(Local Write):如果本地处理器写数据至处于 I 的缓存行,则缓存行的状态变成 M
  • 本地读(Local Read):如果本地处理器读取处于 I 的缓存行,很明显此缓存没有数据给它。此时分两种情况:
    1. 其它处理器的缓存里也没有此行数据,则从内存加载数据到此缓存行后,再将它设成 E ,表示只有我一家有这条数据,其它处理器都没有
    2. 其它处理器的缓存有此行数据,则将此缓存行的状态设为 S 。(备注:如果处于M的缓存行,再由本地处理器写入/读出,状态是不会改变的)
  • 远程读(Remote Read):假设有两个处理器 c1 和 c2,如果 c2 需要读另外一个处理器 c1 的缓存行内容,c1 需要把它缓存行的内容通过内存控制器 (Memory Controller)发送给 c2,c2 接到后将相应的缓存行状态设为 S。在设置之前,内存也得从总线上得到这份数据并保存
  • 远程写(Remote Write):其实确切地说不是远程写,而是 c2 得到 c1 的数据后,不是为了读,而是为了写。也算是本地写,只是 c1 也拥有这份数据的拷贝,这该怎么办呢?c2 将发出一个 RFO(Request For Owner)请求,它需要拥有这行数据的权限,其它处理器的相应缓存行设为 I,除了它自已,谁都不能动这行数据。保证了数据的安全,同时处理 RFO 请求以及设置I的过程将给写操作带来很大的性能消耗

2. 乱序问题

现代cpu的合并写技术对程序的影响open in new window

为了提高性能,重排序分三种类型

  1. 编译器优化的重排序
    • 编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序
  2. 指令级并行的重排序
    • CPU采用了指令级并行技术(Instruction-Level Parallelism,ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序
    • CPU为了提高指令执行效率。会在一条指令执行过程中(eg:从内存读数据(慢100倍)),同时执行另一条指令,两条指令没有依赖关系。这样CPU的执行就是乱序的
      • 读指令的同时,可以同时执行不相关的其他指令
      • 写的同时可以进行合并写
    • 必须使用Memory_Barrier来做好指令排序。volatile底层就是这么实现的(windows是lock指令)
  3. 内存系统的重排序
    • 由于CPU使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行
image-20230724002054363

1. 合并写技术

  1. 现代cpu的合并写技术对程序的影响open in new window
  2. WC_buffer(write_combine):合并写缓存,比L1缓存速度还快。只有4B
  3. 满足合并写要比分开写,快10倍
public final class T3_WriteCombining {

    private static final int ITERATIONS = Integer.MAX_VALUE;
    private static final int ITEMS = 1 << 24;           // 2^24
    private static final int MASK = ITEMS - 1;

    private static final byte[] arrayA = new byte[ITEMS];
    private static final byte[] arrayB = new byte[ITEMS];
    private static final byte[] arrayC = new byte[ITEMS];
    private static final byte[] arrayD = new byte[ITEMS];
    private static final byte[] arrayE = new byte[ITEMS];
    private static final byte[] arrayF = new byte[ITEMS];

    /**
     * 1. 合并写技术验证
     */
    public static void main(final String[] args) {
        for (int i = 1; i <= 3; i++) {
            System.out.println(i + " SingleLoop duration (ns) = " + runCaseOne());
            System.out.println(i + " SplitLoop  duration (ns) = " + runCaseTwo());
        }
    }

    public static long runCaseOne() {
        long start = System.nanoTime();
        int i = ITERATIONS;

        while (--i != 0) {
            int slot = i & MASK;
            byte b = (byte) i;
            arrayA[slot] = b;
            arrayB[slot] = b;
            arrayC[slot] = b;
            arrayD[slot] = b;
            arrayE[slot] = b;
            arrayF[slot] = b;
        }
        return System.nanoTime() - start;
    }

    /**
     * 2. 分开写,利用合并写技术(4byte)
     */
    public static long runCaseTwo() {
        long start = System.nanoTime();
        int i = ITERATIONS;
        while (--i != 0) {
            int slot = i & MASK;
            byte b = (byte) i;
            arrayA[slot] = b;
            arrayB[slot] = b;
            arrayC[slot] = b;
        }
        i = ITERATIONS;
        while (--i != 0) {
            int slot = i & MASK;
            byte b = (byte) i;
            arrayD[slot] = b;
            arrayE[slot] = b;
            arrayF[slot] = b;
        }
        return System.nanoTime() - start;
    }
}

--------------------------------------------------------
1 SingleLoop duration (ns) = 8203582013
1 SplitLoop  duration (ns) = 5460269295
2 SingleLoop duration (ns) = 7708767558
2 SplitLoop  duration (ns) = 5375433295
3 SingleLoop duration (ns) = 7685294916
3 SplitLoop  duration (ns) = 5981501565

2. 乱序证明

原始参考:Memory Reordering Caught in the Actopen in new window

public class T4_Disorder {
    private static int x = 0, y = 0;
    private static int a = 0, b = 0;

    /**
     * 当t1和t2的两条语句,同时乱序时。出现(x == 0 && y == 0)
     */
    public static void main(String[] args) throws InterruptedException {
        int i = 0;
        for (; ; ) {
            i++;
            a = 0;
            b = 0;
            x = 0;
            y = 0;
            Thread t1 = new Thread(new Runnable() {
                public void run() {
                    // t1先启动,等一等t2。可根据自己电脑实际性能适当调整等待时间
                    // shortWait(100000);
                    a = 1;
                    x = b;
                }
            });

            Thread t2 = new Thread(new Runnable() {
                public void run() {
                    b = 1;
                    y = a;
                }
            });

            t1.start();
            t2.start();
            t1.join();
            t2.join();

            String result = "第" + i + "次 (" + x + "," + y + ")";
            if (x == 0 && y == 0) {
                System.err.println(result);
                break;
            } else {
                // System.out.println(result);
            }
        }
    }


    public static void shortWait(long interval) {
        long start = System.nanoTime();
        long end;
        do {
            end = System.nanoTime();
        } while (start + interval >= end);
    }
}
8091237(0,0

3. 保障有序性

1. CPU级别内存屏障

  • X86_CPU 内存屏障,硬件级别

1. fence屏障

  • sfence:store_fence(写屏障)在sfence指令前的写操作当必须在sfence指令后的写操作前完成
  • lfence:load_fence(读屏障)在lfence指令前的读操作当必须在lfence指令后的读操作前完成
  • mfence:modify/mix_fence(读写屏障)在mfence指令前的读写操作当必须在mfence指令后的读写操作前完成

2. lock指令

intel_lock汇编指令

  1. 原子指令
    • eg:x86上的lock …指令是一个Full_Barrier,执行时会锁住内存子系统来确保执行顺序,甚至跨多个CPU
  2. Software_Locks通常使用了内存屏障或原子指令来实现变量可见性和保持程序顺序

2. JVM级别

  • JVM只是基于硬件fence的规范
  • JVM级别如何规范(JSR133)(主语是:所有CPU)
  1. LoadLoad屏障:
    • 语句:Load1; LoadLoad; Load2
    • 在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕
  2. StoreStore屏障:
    • 语句:Store1; StoreStore; Store2
    • 在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见(刷新到内存)
  3. LoadStore屏障:
    • 语句:Load1; LoadStore; Store2
    • 在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕
  4. StoreLoad屏障:
    • 语句:Store1; StoreLoad; Load2
    • 在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。StoreLoad_Barriers会使该屏障之前的所有内存访问指令(存储和装载指令)完成之后,才执行该屏障之后的内存访问指令

1. 数据依赖性

如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性。数据依赖分下列三种类型

名称代码示例说明
写后读a = 1; b = a写一个变量后,再读这个位置
写后写a = 1; a = 2写一个变量后,再写这个变量
读后写a = b; b = 1读一个变量后,再写这个变量

上面三种情况,只要重排序两个操作的执行顺序,程序的执行结果将会被改变

  1. 编译器、处理器可能会对操作做重排序。重排序时,会遵守数据依赖性,存在依赖关系的操作顺序不变
  2. 数据依赖性仅针对单个cpu中执行的指令序列和单个线程中执行的操作,不同cpu之间和不同线程之间数据依赖性不被编译器、处理器考虑

4. volatile实现

1. 字节码层面

/**
 * volatile对应byteCode查看
 */
public class T5_Volatile {
    int i;
    volatile int j;
}

ACC_VOLATILE,只是加了这个关键字

image-20230422084817040

2. JVM层面


  • StoreStoreBarrier => volatile写操作 => StoreLoadBarrier
  • volatile读操作 => LoadLoadBarrier & LoadStoreBarrier

1. volatile写

基于保守策略下,《volatile写》插入内存屏障后生成的指令序列示意图

image-20230724000739039
  1. StoreStore可以保证在《volatile写》之前,其前面的所有普通写操作已经对任意处理器可见了。因为StoreStore将保障上面所有的普通写在volatile写之前刷新到主内存
  2. 《volatile写》后面的StoreLoad:避免《volatile写》与后面可能有的volatile读/写操作重排序
    • volatile写-读内存语义的常见使用模式是:一个写线程写volatile变量,多个读线程读同一个volatile变量。当读线程的数量大大超过写线程时,选择在volatile写之后插入StoreLoad将带来可观的执行效率的提升

2. volatile读

《volatile读》插入内存屏障后生成的指令序列示意图

image-20230724000926707
  • LoadLoad用来禁止处理器把上面的《volatile读》与下面的普通读重排序
  • LoadStore用来禁止处理器把上面的《volatile读》与下面的普通写重排序

3. OS和硬件层面

  • hsdis - HotSpot_Dis_Assembler
  • windows_lock指令实现 => MESI实现

5. synchronized实现

1. 字节码层面

public class T6_synchronized {
    synchronized void m() {

    }

    void n() {
        synchronized (this) {

        }
    }

    public static void main(String[] args) {

    }
}
  • ACC_SYNCHRONIZED
image-20230422085505641
  • monitorenter, monitorexit
image-20230422085342185

2. JVM层面

C、C++的锁实现,调用了OS提供的同步机制

3. OS和硬件层面

6. 排序规范

  • Java 8大原子操作(虚拟机规范)(已弃用,了解即可)
  • 最新的JSR-133已经放弃这种描述,但JMM没有变化。《深入理解Java虚拟机》P364
  1. lock:主内存,标识变量为线程独占
  2. unlock:主内存,解锁线程独占变量
  3. read:主内存,读取内容到工作内存
  4. load:工作内存,read后的值放入线程本地变量副本
  5. use:工作内存,传值给执行引擎
  6. assign:工作内存,执行引擎结果赋值给线程本地变量
  7. store:工作内存,存值到主内存给write备用
  8. write:主内存,写变量值

1. hanppens-before

  • Happen-Before规则(先行发生原则)。JLS(Java_Language_Specification)
  • 从JDK5开始,Java使用新的JSR-133内存模型
  • JSR-133使用happens-before的概念来阐述操作之间的内存可见性。在JMM中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须要存在happens-before关系。这里提到的两个操作既可以是在一个线程之内,也可以是在不同线程之间
  1. 程序次序规则(Program Order Rule)
    • 在一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作。准确地说应该是控制流顺序而不是代码顺序,因为要考虑分支、循环等结构
  2. 监视器锁定规则(Monitor Lock Rule)
    • 一个unlock操作先行发生于后面对同一个对象锁的lock操作。这里强调的是同一个锁,而“后面”指的是时间上的先后顺序,如发生在其他线程中的lock操作
  3. volatile变量规则(Volatile Variable Rule)
    • volatile变量的写先发生于读,这保证了volatile变量的可见性
  4. 线程启动规则(Thread Start Rule)
    • Thread独享的start()方法先行于此线程的每一个动作
  5. 线程终止规则(Thread Termination Rule)
    • 线程中的每个操作都先行发生于对此线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值检测到线程已经终止执行
  6. 线程中断规则(Thread Interruption Rule)
    • 对线程interrupte()方法的调用优先于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测线程是否已中断
  7. 对象终结原则(Finalizer Rule)
    • 一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的开始
  8. 传递性(Transitivity)
    • 如果操作A先行发生于操作B,操作B先行发生于操作C,那就可以得出操作A先行发生于操作C的结论

说明:正是以上这些规则保障了happen-before的顺序,如果不符合以上规则,那么在多线程环境下就不能保证执行顺序等同于代码顺序,也就是“如果在本线程中观察,所有的操作都是有序的;如果在一个线程中观察另外一个线程,则不符合以上规则的都是无序的”,因此,如果多线程程序依赖于代码书写顺序,那么就要考虑是否符合以上规则,如果不符合就要通过一些机制使其符合,最常用的就是synchronizedLock以及volatile修饰符

2. as_if_serial

  • 不管如何重排序,单线程执行结果不会改变