• 【JAVA多线程】JMM,成体系聊一下JAVA线程安全问题


    目录

    1.什么是JMM

    2.现代计算机架构

    3.线程安全理论

    3.1.造成线程安全问题的原因

    3.1.1.内存不可见

    3.1.2.重排序

    3.2.解决思路

    3.2.1.as-if-serial

    3.2.2.happen-before

    4.线程安全实现

    4.1.Synchronized

    4.2.volatile


    1.什么是JMM

    JMM,全称为Java Memory Model,是Java内存模型的简称。它是一种规范,定义了Java程序中多线程环境下的内存交互规则,确保了在不同的硬件和操作系统平台上的内存访问行为具有一致性。说白了就是JAVA的一种保证,保证了JAVA在多线程环境下可以是靠谱的,这种靠谱体现在具有保证线程安全的能力。如何保证线程安全其实就是JMM的核心。

    2.现代计算机架构

    要聊线程安全问题,我们首先要聊一下现代计算机的架构,因为要对现代计算机的架构有所认识,才知道为什么会引发线程安全问题。

    现代计算机的模型中与JMM相关的部分是CPU、内存。现代计算机的内存架构的目的是在尽可能保证CPU能被最大利用,因此为了匹配内存与CPU间读写速率量级上的差异, 采用了多级缓存架构,层层加速,从而使得数据的读写能匹配 上CPU的速率。以一个4核心的CPU为例:

    每一个core都有一套属于自己的多级缓存,L1、L2。

    L1、L2是各个内核自己独有一套。 

    L3是所有内核共用一个,由L3级缓存来与内存统一进行数据交互。

    读数据是从L3到L2最后到L1,写数据是从L1到L2最后到L3

    之所以这样设计目的就是通过层层缓存来加速,尽量跟上CPU的读写速度。

    好,单核CPU的架构我们已经解开了,总结起来就一句话:

    为了跟上CPU的读写效率,疯狂的堆了多级缓存。

    3.线程安全理论

    3.1.造成线程安全问题的原因

    前面我们说了现代计算机的架构种关于内存这一部分总结起来就是疯狂堆了多级缓存。这种架构拉高了CPU的使用率,但是又带来了在并发环境下线程之间的数据一致性是无法保障的线程不安全的问题。

    造成线程不安全问题的原因有以下两点:

    • 内存不可见
    • 重排序

    3.1.1.内存不可见

    内存可见是指在多线程环境中,一个线程对数据进行更新后,后续的其他线程能立即读到这个更新 结果。

    但是在现代计算机多级缓存的架构下是会存在“内存不可见”的问题的。前面我们说了CPU处理完数据后数据首先被写入L1缓存,然后可能移动到L2,最终到达L3缓存,但是L3缓存是实时往内存中回写的吗?

    L3不是实时向内存中回写的!因为缓存存在的主要目的是给CPU喂数据,所有其大量的时间都是拿来给CPU喂数据了,何时把数据回写回内存?对缓存来说就是——“抽空再说吧。”

    当然现代计算机中关于缓存中的数据何时会写到内存,其规则和实现是有多种的,但根本上都没办法实现实时回写到内存中。就是这一小小的回写延迟,就会造成内存不可见,从而出现缓存不一致。

    可以说“内存可见”和“CPU高效利用”之间是个互斥的关系,现代计算机架构选择了“CPU高效利用”。 那么解决内存不可见问题以及其附带而来的各个CPU之间缓存不一致的问题,就需要自己想办法解决了。这也正是JAVA的JMM要解决的核心问题之一。

    3.1.2.重排序

    软件技术和硬件技术的共同目标其实都是在不改变程序运行结果的前提下,尽可能的提高执行时候的性能(也就是并行度)。为了达成这一目的,常常不会按顺序的去执行指令,而是会将指令层层递进的进行重排序找到一个最优的执行顺序然后再执行。

    程序在执行时存在着两种重排序现象:

    • 指令重排序
    • 内存重排序

    指令重排序:

    编译器编译时,基于性能考虑,在不改变结果的前提下,可能不会按照代码层面的顺序来编译出最终指令,编译结果的会按照执行时性能最优的方式进行指令重排序。javac编译器一般不会执行指令重排序,指令重排序一般由JVM中的JIT即时编译器来进行指令重拍。

    内存重排序:

    处理器执行时,基于性能考虑,可能不按照编译出来的顺序进行执行。比如程序编译出多条指令,CPU为了性能,会首先调度执行已经准备好资源的指令。

    重排序机制会提升性能,在单线程环境下不会造成线程安全问题,但是在多线程环境下会对结果的准确性产生影响,因此在多线程环境下需要保证指令执行的有序性。

    3.2.解决思路

    JMM为了解决内存不可见、指令重排序造成的线程安全问题,遵循了两大原则:

    • as-if-serial
    • happen-before

    3.2.1.as-if-serial

    as-if-serial规范,用于限制指令重排序,保证单线程中执行结果的正确,即不管基于性能考量如何重排 序,都要保证单线程的执行结果的正确性。as-if-serial规范是一个需要编译器、CPU共同遵守的约定。 遵守此约定,就能保证单线程执行结果的正确。

    3.2.2.happen-before

    happen-before规范,用于实现内存可见性,保证多线程之间数据的准确性,即A happen-before B, 则A的执行结果必须对B可见。这是JMM自身在语言层面对开发者给出的保证,即在JAVA语言层面一系列 用来保证内存可见性的机制,如:

    • 单线程的每个操作,happen-before该线程后续的任意操作。
    • volatile变量的写入,happen-before后续对该变量的读取操作。
    • synchronized的解锁,happen-before对应后续对这个锁的加锁。

    等等......

    4.线程安全实现

    4.1.Synchronized

    Synchronized,同步关键字,使主内存中的共享数据在同一时间只能有一条线程可以持有,用于保 证happen-before,同时也规避了as-if-serial,主存中的数据被串行持有,就能实现线程A happenbefore 线程B。

    本质上是利用JAVA对象头中的Mark Word字段来实现锁。Synchronized为了避免CPU态的频繁切 换,设计了锁升级机制,即随着参与争抢锁的竞争者数量的增加,锁的类型和具体实现会变化:

    初期锁对象刚创建时,还没有任何线程来竞争,偏向锁标识位是0,锁状态01,说明该对象处于无 锁状态(无线程竞争它)。

    当有一个线程来竞争锁时,先用偏向锁,表示锁对象偏爱这个线程,这个线程要执行这个锁关联的 任何代码,不需要再做任何检查和切换,这种竞争不激烈的情况下,效率非常高。这时Mark Word 会记录自己偏爱的线程的ID,把该线程当做自己的熟人。

    当有两个线程开始竞争这个锁对象,情况发生变化了,不再是偏向(独占)锁了,锁会升级为轻量 级锁,两个线程公平竞争,哪个线程先占有锁对象并执行代码,锁对象的Mark Word就执行哪个线 程的栈帧中的锁记录。

    如果竞争的这个锁对象的线程更多,导致了更多的切换和等待,JVM会把该锁对象的锁升级为重量 级锁,这个就叫做同步锁,这个锁对象Mark Word再次发生变化,会指向一个监视器对象,这个监 视器对象用集合的形式,来登记和管理排队的线程。

    代码示例:

    1. public class Counter {
    2. private int count = 0;
    3. // synchronized 方法,同一时刻只允许一个线程访问
    4. public synchronized void increment() {
    5. count++;
    6. }
    7. public synchronized int getCount() {
    8. return count;
    9. }
    10. }
    11. public class Demo {
    12. public static void main(String[] args) throws InterruptedException {
    13. Counter counter = new Counter();
    14. Thread thread1 = new Thread(() -> {
    15. for (int i = 0; i < 10000; i++) {
    16. counter.increment();
    17. }
    18. });
    19. Thread thread2 = new Thread(() -> {
    20. for (int i = 0; i < 10000; i++) {
    21. counter.increment();
    22. }
    23. });
    24. thread1.start();
    25. thread2.start();
    26. thread1.join();
    27. thread2.join();
    28. System.out.println("Final count: " + counter.getCount()); // 应该输出20000
    29. }
    30. }

    4.2.volatile

    volatile,JAVA虚拟机提供的最轻量级的同步机制。其通过实现“缓存一致性协议”和“内存屏障”,保证了happen-before和强制禁止了指令重排序。

    • 缓存一致性协议 保证工作内存(缓存)中的数据和主内存(内存)中的数据的一致性,即一旦工作内存中的数据有变,马上刷新回主内存。 其底层实现是CPU的嗅探机制,所有CPU都盯住总线,监听总线中的数据变化,一旦工作内存中存 在的数据在总线中出现了assign操作,会立即让工作内存中的相应值失效,从而重新从主内存中去读取值。 不同的CPU有不同的缓存一致性协议。
    • 内存屏障用于禁止指令重排序, 具体的实现是在需要禁止重排序的两条代码(指令)之间插入一个标志,标 识标志两边的代码(指令)禁止重排序。这个标志是汇编级别的。

    如果对总线相关内容比较模糊的小伙伴可以异步作者另一篇文章:

    计算机组成原理(2)总线_单总线和双总线的区别-CSDN博客

    代码示例:

    1. public class VolatileExample {
    2. private volatile boolean keepRunning = true;
    3. public void runTask() {
    4. while (keepRunning) {
    5. // 执行任务...
    6. }
    7. }
    8. public void stopTask() {
    9. keepRunning = false; // 更改volatile变量,会立即对其他线程可见
    10. }
    11. public static void main(String[] args) {
    12. VolatileExample example = new VolatileExample();
    13. Thread taskThread = new Thread(example::runTask);
    14. taskThread.start();
    15. try {
    16. Thread.sleep(1000); // 模拟一段时间后停止任务
    17. } catch (InterruptedException e) {
    18. e.printStackTrace();
    19. }
    20. example.stopTask(); // 请求停止任务
    21. }
    22. }

    volatile在各类JVM的底层实现细节存在不同,此处以Hotspot为例。

    volatile关键字修饰的变量在汇编层面会被汇编语言的lock修饰。

    lock指令的作用:

    1. 锁定这块内存区域的缓存
    2. 将这块缓存的数据立即写回系统内存。
    3. 写回内存的操作会引起其他CPU里缓存了该内存地址的数据无效(内存一致性协议)
    4. 提供内存屏障功能,使得lock指令前后不能重排序。

  • 相关阅读:
    软件测试基本概念
    Spring源码解析——Spring事务是怎么通过AOP实现的?
    数据结构(8)树形结构——B树、B+树(含完整建树过程)
    UVa10537 The Toll! Revisited(Dijkstra)
    IDM短信发送接口设计说明
    皮肤性病科专家谭巍主任提出HPV转阴后饮食七点建议
    库存管理方法有哪些?
    Wpf 使用 Prism 实战开发Day01
    LeetCode 每日一题 2022/8/15-2022/8/21
    oracle使用正则表达式REGEXP_SUBSTR提取XML里面的内容
  • 原文地址:https://blog.csdn.net/Joker_ZJN/article/details/139984900