「JVM 高效并发」Java 内存模型

Amdahl 定律代替摩尔定律成为了计算机性能发展的新源动力,也是人类压榨计算机运算能力的最有力武器;

  • 摩尔定律,描述处理器晶体管数量与运行效率之间的发展关系;
  • Amdahl 定律,描述系统并行化与串行化的比重与系统运算加速能力之间的关系;

让计算机同时做几件事情,可以在大量 I/O 等待中充分利用计算机的计算能力,避免计算资源处于等待其他资源的空闲状态;

TPS,每秒处理的事务数,是衡量一个服务性能高低的重要指标,这也与程序的并发能力有密切关系;

程序线程并发协调的有条不紊,效率才能最佳;线程间频繁争用数据,相互阻塞甚至死锁,则会大大降低程序并发能力;

硬件的效率与一致性

计算机的存储与处理器的运算速度存在几个数量级的差距,而计算过程不可能消除存储设备(内存交互读取运算数据存储运算结果等),为了尽可能给运算加速,计算机系统不得不加入一层或多层高速缓存(Cache)作为内存与处理器之间的缓冲;

请添加图片描述

  • 缓存一致性Cache Coherence),多路处理器每个处理器都有自己的高速缓存(共享内存多核系统,Shared Memory Multiprocessors System),它们又共享统一主内存(Main Memory),当多处理器的运算任务涉及统一主内存区域,可能导致各自缓存数据的不一致;为了解决不一致问题,需要定义一些协议,如 MSI、MESI(Illinois Protocal)、MOSI、Synapse、Firefly、Dragon Protocal 等;
  • 内存模型,在内存缓存一致性协议下,对特定内存或高速缓存进行读写访问的过程抽象;不同架构的物理机拥有不一样的内存模型,JVM 也有自己的内存模型;
  • 乱序执行Out-Of-Order Execution),为了使处理器内存的运算单元能力尽可能充分利用,处理器将输入的代码乱序执行,在将乱序执行的结果重组,但不保证各语句计算的先后顺序与代码中定义的顺序一致,这可能导致计算结果与顺序执行时不一致;
  • 指令重排Instruction Reorder),JVM 即时编译器中的乱序执行

  • Java Memory Model,JMM,Java 内存模型,定义程序中各种变量(实例字段、静态字段、构成数组对象的元素等,不包含局部变量与方法参数等线程私有变量)的访问规则(如 JVM 把变量存储在内存、从内存中取出变量值的底层细节),试图屏蔽各种硬件和操作系统的内存访问差异,实现让 Java 程序在各平台达到一致内存访问效果;(C/C++ 等主流程序语言直接使用物理硬件和操作系统的内存模型,导致一些场景在不同平台需要编写正对性的程序);JDK 2 建立,JDK 5 成熟并完善;

JMM 的定义需要让 JVM 的实现有足够自由地去利用硬件的各种特性(寄存器、高速缓存和指令集中某些特有的指令)来获取更好的执行速度;

1. 主内存与工作内存

请添加图片描述

  • 主内存Main Memory),与物理硬件的主内存类似,实际只是 JVM 内存的一部分;主要对应 Java Heap 中对象实例数据部分,对应了物理硬件的内存;
  • 工作内存Working Memory),与物理硬件的高速缓存类似,每条线程独占,保存了该线程使用的变量的主内存副本(仅对象中被线程访问到的字段,非整个对象),线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行(不能直接在主内存进行);线程间的变量值的传递必须通过主内存来进行;主要对应 Java VM Stack 中的部分区域;JVM 为了更好的运行速度,可能会让工作内存优先存储在寄存器和高速缓存中,因为程序运行时主要访问的是工作内存;

reference 类型引用的对象在 Java Heap 被各个线程共享,但 reference 本身是 Java Stack 的局部变量,是线程私有的;

volatile 变量的读写也必须经过工作内存的拷贝,但其有序性让其如同直接在主内存读写;

2. 内存间交互操作

主内存与工作内存交互协议的 8 种原子性操作

  • lock(锁定),把一个主内存的变量标识为某个线程独占状态;
  • unlock(解锁),把一个主内存的锁定状态的变量释放出来,释放之后的变量才可以被其他线程锁定;
  • read(读取),把一个主内存的变量传输到线程的工作内存中;
  • load(载入),把通过 read 操作传输到工作内存的变量放入工作内存的变量副本中;
  • use(使用),把工作内存中一个变量的值传递给执行引擎(JVM 需要使用变量的字节码指令时所需执行的操作);
  • assign(赋值),把一个从执行引擎接收的值赋给工作内存的变量(JVM 给变量赋值的字节码指令所需执行的操作);
  • store(存储),把一个工作内存的变量传递给主内存;
  • write(写入),把通过 store 操作传输到主内存的变量放入主内存的变量中;

JMM 只要求 read 和 load、store 和 write 是顺序成对执行,但中间可以插入其他指令(如: read a、read b、load a、load b);

JMM 基本操作的规则

  • 不允许 read 和 load、store 和 write 操作单独出现;不允许主内存变量传输到工作内存,但工作内存不接收,反之亦然;
  • 不允许一个线程丢弃它最近的 assign 操作;变量在工作内存中改变后,必须将改变同步会主内存;
  • 不允许一个线程无 assign 操作时无原因的将数据同步回主内存;
  • 新的变量必须在主内存中诞生,对一个变量实施 use、store 之前,必须先执行 assign、load 操作;
  • 一个变量在同一时刻只允许被一个线程 lock,但可以被同一线程多次 lock,多次 lock 后,需要执行相同次数的 unlock 操作,变量才会被解锁;
  • 不允许对一个没有被 lock 的变量执行 unlock,也不允许对其他线程 lock 的变量执行 unlock;
  • 对一个变量 unlock 之前,必须先把它同步回主内存(执行 store、write 操作);

double 和 long 类型的变量在一些平台的 load、store、read、write 操作允许拆分;

3. 对于 volatile 型变量的特殊规则

volatile 是 JVM 提供的最轻量的同步机制;JMM 为 volatile 变量定义了一些特殊的访问规则;

  • 可见性,JMM 保障 volatile 变量对所有线程可见,即当一个线程修改了这个变量,新值对其他所有线程立即可见(普通变量的传递需要通过主内存,A 线程回写新值,且 B 线程从主内存读取新值,新增对 B 线程才可见);
    • volatile 变量在各线程的工作内存中是不存在一致性问题的,但 Java 的运算操作并非原子操作,因此 volatile 变量的运算在并发下也不是安全的;
  • 禁止指令重排,普通变量仅保障在方法执行过程中所依赖赋值结果的地方得到正确的结果,不保障变量赋值操作的顺序与程序代码中的顺序一致;(线程内部表现为串行的语义、Within-Thread As-If-Serial Semantics);
/**
 * volatile 变量自增运算测试
 *
 * @author Aurelius Shu
 * @since 2023-02-25
 */
public class VolatileTest {

    public static volatile int race = 0;

    public static void increase() {
        race++;
    }

    private static final int THREADS_COUNT = 20;

    public static void main(String[] args) throws InterruptedException {
        Thread[] threads = new Thread[THREADS_COUNT];
        for (int i = 0; i < THREADS_COUNT; i++) {
            threads[i] = new Thread(() -> {
                for (int i1 = 0; i1 < 10000; i1++) {
                    increase();
                }
            });
            threads[i].start();
        }

        // 等待所有累加线程都结束
        for (Thread thread : threads) {
            thread.join();
        }
        System.out.println(race);
    }
}

执行结果并非预期的 200000,而是小于 200000,且每次都不一样;

Class 字节码

public static void increase();
    Code:
        Stack=2, Locals=0, Args_size=0
        0: getstatic        #13; //Field race:I
        3: iconst_1
        4: iadd
        5: putstatic        #13; //Field race:I
        8: return
    LineNumberTable:
        line 14: 0
        line 15: 8

volatile 关键字保障了 getstatic 获取的 race 值是正确的,但在对栈顶的 race 执行 iconst_1、iadd 这些指令时,其他线程可能已经对 race 进行了更新,此时栈顶的 race 值就过期了,因此 putstatic 指令最终将较小的 race 值同步回了主内存;

一条字节码指令还可能被转化为若干本地机器指令,因此字节码指令也不是原子性操作;

通过 volatile 保障一致性的必要条件

  • 运算结果并不依赖变量的当前值,或者能够保障只有单一线程修改变量的值(一写多读);
  • 变量不需要与其他的状态变量共同参与不变约束;

volatile 使用场景示例

volatile boolean shutdownRequested;

public void shutdown() {
    shutdownRequested = true;
}

public void doWork() {
    while (!shutdownRequested) {
        // 代码的业务逻辑
    }
}

当 shutdown() 方法被调用时,可以保证所有线程中 doWork() 可以立即停止;

指令重排演示

Map configOptions;
char[] configText;
// 此变量必须定义为volatile
volatile boolean initialized = false;

// 假设以下代码在线程 A 中执行
// 模拟读取配置信息,当读取完成后
// 将 initialized 设置为 true,通知其他线程配置可用
configOptions = new HashMap();
configText = readConfigFile(fileName);
processConfigOptions(configText, configOptions);
initialized = true;

// 假设以下代码在线程 B 中执行
// 等待 initialized 为true,代表线程 A 已经把配置信息初始化完成
while (!initialized) {
    sleep();
}
// 使用线程A中初始化好的配置信息
doSomethingWithConfig();

若未对 initalized 变量使用 volatile,则线程 A 中 initialized = true; 操作(机器码级别指令)可能被提前执行,从而导致线程 B 中也提前以为配置信息已就绪,导致 B 线程出错;

DCL 单例模式

public class Singleton {
    private volatile static Singleton instance;
    public static Singleton getInstance() {
        if (instance == null) {
            synchronized (Singleton.class) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }

    public static void main(String[] args) {
        Singleton.getInstance();
    }
}

对 instance 变量赋值的汇编代码

0x01a3de0f: mov     $0x3375cdb0,%esi        ;...beb0cd75 33
                                            ; {oop('Singleton')}
0x01a3de14: mov     %eax,0x150(%esi)        ;...89865001 0000
0x01a3de1a: shr     $0x9,%esi               ;...c1ee09
0x01a3de1d: movb    $0x0,0x1104800(%esi)    ;...c6860048 100100
0x01a3de24: lock addl $0x0,(%esp)           ;...f0830424 00
                                            ;*putstatic instance
                                            ; - Singleton::getInstance@24

voliatile 修饰的变量在赋值后(mov %eax,0x150(%esi)),还插入了一个 lock add1 $0x0,(%esp) 操作,这是一个内存屏障;

  • 内存屏障(Memory Barrier、Memory Fence),指令重排不能把后面的指令排序到内存屏障之前的位置;若存在多个处理器同时访问一块内存,且其中一个在观测另一个,就需要内存屏障来保证一致性;

  • lock add1 $0x0,(%esp) (把 ESP 寄存器的值加 0)操作将本处理的缓存写入内存,并引起别的处理器或内核无效化(Invalidate)其缓存,相当于对缓存中的变量做了一次 JMM 中的 store 和 write 操作,通过这一操作,可以让 volatile 变量对其他处理器可见;这也正是指令重排无法越过内存屏障的原因;

  • 指令重排,处理器允许将多条指令不安程序代码顺序分发给各相应电路单元进行处理,但必须保证指令依赖情况保障得到正确执行结果;

// 指令 1:A=A+10;
// 指令 2:A=A*2;
// 执行 3:B=B-3;
/// 指令 1 与 2 存在 A 变量的依赖关系,不能重排
/// 指令 3 与 指令 1、2 不存在变量依赖关系,可以发生重排

volatile 变量的读操作与普通变量几乎无差别,写操作则稍慢,因为需要再本地代码中插入许多内存屏障指令来保障处理器不发生乱序执行;

volatile 的同步机制的性能优于锁;在 volatile 与锁中选择的唯一依据是 volatile 的语义是否满足使用场景的需求;

假定 T 线程对 V 和 W 两个 volatile 变量进行 read、load、use、assign、store、write 操作,需满足如下规则;

  • 在工作内存中,每次使用 V 前必须先从主内存刷新最新值,用于保障看见其他线程对 V 的修改(执行 use 前,必须先执行 load;只有执行 use 前才能执行 load;use 和 load、read 是必须连续一起出现的);
  • 在工作内存中,每次修改 V 后必须立即同步回主内存中,用于保障其他线程可以看到自己对 V 的修改(执行 assign 后,必须执行 store;只有执行 assign 后才能执行 store;assign 和 store、write 是必须连续一起出现的);
  • volatile 修饰的变量不会被指令重排,用于保障代码的执行顺序与程序中的顺序相同(T 对 V 的 use 或 assign 先于 T 对 W 的 use 或 assign,则相应的 T 对 V 的 read 或 write 必须先于 T 对 W 的 read 或 write);

volatile 的指令重排屏蔽语义在 JDK 5 中被完全修复,此前是无法完全避免的,因此在 JDK 5 之前无法安全的使用 DCL 实现单例模式;

4. 针对 long 和 double 型变量的特殊规则

  • long 和 double 的非原子性协定Non-Atomic Treatment of double and long Variables),JMM 特别规定:运行 VM 将没有被 volatile 修饰的 64 位数据的读写操作划分为 2 次 32 位数据进行操作(允许 JVM 自行选择是否保证 64 位数据的 load、store、read、write 的原子性);

若多个线程共享一个非 volatile 的 long 或 double 变量,多个线程同时对他们进行读写操作,可能读取到一个半个变量的数值(一般只出现在 32 位 JVM;由于存在浮点运算器 Floating Point Unit,FPU,double 类型通常不会出现非原子性访问问题);

  • -XX:+AlwaysAtomicAccesses,JDK 9 起可以如此约束 JVM 对所有数据类型进行原子性访问;

实际开发中,除非数据有明确的线程争用,否则一般不需要因此而刻意将 long 和 double 变量声明为 volatile;

5. 原子性、可见性与有序性

JMM 是围绕着并发过程中如何处理原子性、可见性、有序性着三个特征来建立的;

  • 原子性Atomicity),JMM 直接保证 read、load、assign、use、store、write 这 6 个操作的原子性,可以大致认为基本数据类型的访问、读写是原子性的(long 和 double 存在非原子性协定,但基本可以忽略);

在实际应用中更大范围的原子性保障可以通过 JMM 提供的 lock、unlock 操作来实现;在字节码层面相应是 monitorenter、monitorexit 指令;在 Java 代码层次则是同步块 synchronized 关键字;

  • 可见性Visibility),当一个线程修改了共享变量的值,其他线程能够立即得知这个修改;

JMM 规定变量的修改必须通过主内存来同步;volatile 变量相比普通变量,会保证新值立即同步到主内存,以及每次使用前立即从主内存刷新;

synchronized 可以实现可见性,因为对一个变量执行 unlock 之前,必须先把变量同步回主内存(执行 store、write);

final 被 final 修饰的字段在构造器中被初始化后会立即被其他线程看见;

public static final int i;
public final int j;

static {
    // 一经初始化其他线程可见
    i = 0;
    // ...
}

{
    // 一经初始化其他线程可见
    j = 0;
    // ...
}
  • 有序性Ordering),机器码执行的顺序与程序中代码顺序是否一致;

  • 在本线程内观察,所有操作都是有序的(线程内似表现为串行Within-Thread As-If-Serial Semantics);

  • 在一个线程中观察另一线程,所有操作都是无序的(指令重排、工作内存与主内存同步延迟);

Java 语言通过 volatile 和 synchronized 两个关键字保障线程间操作的有序性;volatile 本身直接禁止指令重排,synchromized 则同一时间只允许一个线程对变量进行 lock 操作;

synchronized 虽同时可以满足原子性、可见性、有序性,但滥用可能导致性能受到急剧影响;

6. 先行发生原则

先行发生Happens-Before)原则,描述 JMM 中两个操作的偏序关系;Java 语言无须任何同步手段就能保障的一些天然先行发生关系如下;

  • 程序次序规则Program Order Rule),在一个线程内的控制流中,书写在前面的操作先行发生于书写在后面的操作;需注意控制流不是代码顺序,而是分支、循环等结构;
  • 管程锁定规则Monitor Lock Rule),一个 unlock 操作先行发生于后面对同一锁的 lock 操作;
  • volatile 变量规则Volatile Variable Rule),对一个 volatile 变量的写操作先行发生于后面对这个变量的读操作;
  • 线程启动规则Thread Start Rule),Thread 对象的 start() 方法先行发生于此线程的每一个动作;
  • 线程终止规则Thread Termination Rule),线程中所有操作都先行发生于对此线程的终止检测(Thread:: join()、Thread::isAlive());
  • 线程中断规则Thread Interruption Rule),对线程 interrupt() 方法的调用先行发生于中断线程的代码检测到中断事件的发生(Thread::interupted() 可以检测中断是否发生);
  • 对象终结规则Finalizer Rule),一个对象的初始化完成先行发生于它 finalize() 方法的开始;
  • 传递性Transitivity),操作 A 先行发生于操作 B,操作 B 先行发生于操作 C,则 A 先行发生于 C;

先行发生原则演示

// A 线程执行;
i = 1;
// B 线程执行;
j = i;
// C 线程执行;
i = 2;

若 A 先行于 B(C 不存在),可以保证 j 的值是 1;
若 A 先行于 B,C 和 B 没有先行关系,则 C 可能发生在 A 与 B 之间,此时 j 的值可能是 1 也可能是 2,因为 B 可能不会观察到 C 对 i 的修改;这时不具备并发安全;

private int value = 0;

public void setValue(int vlaue) {
    this.value = value;
}

public int getValue() {
    return value;
}

假设两个方法在多线程中执行:

  • 不在一个线程,不适用程序次序规则
  • 没有同步快,不适用管程锁定规则
  • value 没有被 volatile 修饰,不适用 volatile 变量规则
  • 与线程启动、终止、中断、对象终结等规则无关;
  • 不存在规则可以传递
    因此对 value 的这两个操作不是线程安全的;

修复线程安全的方案:

  • 通过对 getter/setter 方法加 synchronized 同步,实现管程锁定规则;
  • 给 value 添加 volatile 修饰,套用 volatile 变量规则(value 的更新不依赖 value 的原值,这里可以适用);
// 在同一线程中执行,int j=2 可能先辈处理器执行,这不影响先行发生原则的正确性
int i = 1;
int j = 2;

时间先后顺序与先行发生原则之间基本没有因果关系,我们衡量并发安全问题时不能受到时间顺序的干扰,一切以先行发生原则为准;


上一篇:「JVM 编译优化」Graal 编译器
下一篇:「JVM 高效并发」Java 线程

PS:感谢每一位志同道合者的阅读,欢迎关注、评论、赞!


参考资料:

  • [1]《深入理解 Java 虚拟机》
  • 2
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Aurelius-Shu

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值