JMM

2023年 7月 14日 34.8k 0

JMM

JMMJava Memory ModelJMM(Java内存模型)主要定义了对于一个共享变量,当另一个线程对这个共享变量执行写操作后,这个线程对这个共享变量的可见性。JMM说白了就是定义了一些规范可以直接使用并发相关的一些关键字和类(比如 volatilesynchronized、各种 Lock)即可开发出并发安全的程序。

  • 原子性:

一次操作或者多次操作,要么所有的操作全部都得到执行并且不会受到任何因素的干扰而中断,要么都不执行。在Java中,可以借助synchronized、各种 Lock 以及各种原子类实现原子性。synchronized 和各种 Lock 可以保证任一时刻只有一个线程访问该代码块,因此可以保障原子性。各种原子类是利用CAS(compare and swap)操作(可能也会用到 volatile或者final关键字)来保证原子操作。

  • 可见性:

当一个线程对共享变量进行了修改,那么另外的线程都是立即可以看到修改后的最新值。在Java中,可以借 助synchronizedvolatile 以及各种 Lock 实现可见性。如果我们将变量声明为 volatile ,这> > >就指示 JVM,这个变量是共享且不稳定的,每次使用它都到主存中进行读取。

有序性:

由于指令重排序问题,代码的执行顺序未必就是编写代码时候的顺序。指令重排序可以保证串行语义一致,但是没有义务保证多线程间的语义也一致 ,所以在多线程下,指令重排序可能会导致一些问题。在Java中,volatile 关键字可以禁止指令进行重排序优化。

JVM 与 JMM 区别?

  • JVM内存结构和Java虚拟机的运行时区域相关,定义了JVM在运行时如何分区存储程序数据,就比如说堆主要用于存放对象实例。
  • JMM内存模型和Java的并发编程相关,抽象了线程和主内存之间的关系就比如说线程之间的共享变量必须存储在主内存中,规定了从Java源代码到CPU可执行指令的这个转化过程要遵守哪些和并发相关的原则和规范,其主要目的是为了简化多线程编程,增强程序可移植性的。

案例

原子性

image.png
如上图所示,在多线程的情况下,CPU资源会在不同的线程间切换。那么这样也会导致意想不到的问题。比如你认为的一行代码:count += 1,实际上涉及了多条CPU指令:

  • 指令 1:首先,需要把变量count从内存加载到CPU的寄存器;
  • 指令 2:之后,在寄存器中执行+1操作;
  • 指令 3:最后,将结果写入内存(缓存机制导致可能写入的是CPU缓存而不是内存)。

操作系统做任务切换,可以发生在任何一条CPU 指令执行完。假设 count=0,如果线程A在指令1执行完后做线程切换,线程A和线程B按照下图的序列执行,那么我们会发现两个线程都执行了count+=1的操作,但是得到的结果不是我们期望的2,而是1。
image.png
我们潜意识认为的这个count+=1操作是一个不可分割的整体,就像一个原子一样,我们把一个或者多个操作在 CPU执行的过程中不被中断的特性称为原子性。但实际情况就是不做任何处理的话,在并发情况下CPU进行切换,导致出现原子性的问题,我们一般通过加锁解决。

可见性

public class VolatileTest {
    
    static boolean run = true;

    public static void main(String[] args) throws InterruptedException {
        Thread t = new Thread(() -> {
            while (run) {
            }
            log.info("done .....");
        });
        t.start();
        
        Thread.sleep(1000);

       // 设置run = false
        run = false;
    }
}

//`main`函数中新开个线程根据标位`run`循环,主线程中`sleep`一秒,然后设置`run=false`,却不会打//印"done ....."

现代的CPU架构基本有多级缓存机制,t线程会将run加载到高速缓存中,然后主线程修改了主内存的值为false,导致缓存不一致,但是t线程依然是从工作内存中的高速缓存读取run的值,最终无法跳出循环。如图:
image.png
正如上面的例子,由于不做任何处理,一个线程能否立刻看到另外一个线程修改的共享变量值,我们称为可见性。

有序性

有序性是指程序按照代码的先后顺序执行。但是编译器或者处理器出于性能原因,改变程序语句的先后顺序,比如代码顺序"a=1; b=2;",但是指令重排序后,有可能会变成"b=2;a=1", 在单线程情况下,指令重排序不会有任何影响。但是在并发情况下,可能会导致一些意想不到的bug。比如下面的例子:

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

假设有两个线程 A、B 同时调用 getInstance()方法,正常情况下,他们都可以拿到instance实例。
但往往bug就在一些极端的异常情况,比如new Singleton() 这个操作,实际会有下面3个步骤:

  • 分配一块内存M
  • 在内存M上初始化Singleton对象;
  • 然后M的地址赋值给instance变量。
  • 现在发生指令重排序,顺序变为下面的方式:

  • 分配一块内存M
  • M的地址赋值给instance变量;
  • 最后在内存M上初始化Singleton对象。
  • 我们假设线程A先执行getInstance()方法,当执行完指令2时恰好发生了线程切换,切换到了线程B上;如果此时线程B也执行getInstance()方法,那么线程B在执行第一个判断时会发现instance != null ,所以直接返回 instance,而此时的instance是没有初始化过的,如果我们这个时候访问instance的成员变量就可能触发空指针异常。

    这就是并发情况下,有序性带来的一个问题,指令重排序并不会瞎排序,处理器在进行重排序时,必须要考虑指令之间的数据依赖性。

    JMM

    JMM详解

    原子性问题可以通过加锁的方式解决,可见性是因为缓存导致,有序性是因为编译优化指令重排序导致,那么是不是可以让程序员按需禁用缓存以及编译优化,就提出了JAVA内存模型(JMM)规范。
    Java内存模型是 Java Memory Model(JMM),本身是一种抽象的概念,实际上并不存在,描述的是一组规则或规范,通过这组规范定义了程序中各个变量(包括实例字段,静态字段和构成数组对象的元素)的访问方式。

    image.png

    默认情况下,JMM中的内存机制如下:

    • 系统存在一个主内存(Main Memory),Java中所有变量都存储在主存中,对于所有线程都是共享的。
    • 每条线程都有自己的工作内存(Working Memory),工作内存中保存的是主存中某些变量的拷贝。
    • 线程对所有变量的操作都是先对变量进行拷贝,然后在工作内存中进行,不能直接操作主内存中的变量。
    • 线程之间无法相互直接访问,线程间的通信(传递)必须通过主内存来完成。

    同时,JMM规范了JVM如何提供按需禁用缓存和编译优化的方法,主要是通过volatilesynchronizedfinal 三个关键字,那具体的规则是什么样的呢?

    Happens-Before规则

    JMM本质上包含了一些规则,那这个规则就是大家有所耳闻的Happens-Before规则,Happens-Before规则,可以简单理解为如果想要A线程发生在B线程前面,也就是B线程能够看到A线程,需要遵循6个原则。如果不符合 happens-before 规则,JMM 并不能保证一个线程的可见性和有序性。

    程序的顺序性规则

    在一个线程中,逻辑上书写在前面的操作先行发生于书写在后面的操作。同一个线程中他们是用的同一个工作缓存是可见的,并且多个操作之间有先后依赖关系,则不允许对这些操作进行重排序。

    volatile变量规则

    指对一个volatile变量的写操作,Happens-Before 于后续对这个volatile变量的读操作。比如线程A对volatile变量进行写操作,那么线程B读取这个volatile变量是可见的,就是说能够读取到最新的值。

    传递性

    这条规则是指如果A Happens-Before B,且B Happens-Before C,那么 A Happens-Before C

    锁的规则

    这条规则是指对一个锁的解锁 Happens-Before于后续对这个锁的加锁,这里的锁要是同一把锁, 而且用synchronized或者ReentrantLock都可以。

    synchronized (this) { // 此处自动加锁
      // x 是共享变量, 初始值 =10
      if (this.x < 12) {
        this.x = 12; 
      }  
    } // 此处自动解锁
    
    • 假设 x 的初始值是 8,线程 A 执行完代码块后 x 的值会变成 12(执行完自动释放锁)
    • 线程 B 进入代码块时,能够看到线程 A 对 x 的写操作,也就是线程 B 能够看到 x==12

    线程start()规则

    主线程A启动子线程B后,子线程B能够看到主线程在启动子线程B前的操作。线程A调用线程B的start()方法(即在线程A中启动线程B),那么该start()操作 Happens-Before 于线程B中的任意操作。

    线程join()规则

    线程A中调用线程B的join()并成功返回,那么线程B中的任意操作Happens-Before于该join()操作的返回。

    解决办法

    方式一:volatile

    public class VolatileTest {
        volatile static boolean run = true;
        public static void main(String[] args) throws InterruptedException {
            Thread t = new Thread(() -> {
                while (run) {
                }
                log.info("done .....");
            });
            t.start();
            Thread.sleep(1000);
           // 设置run = false
            run = false;
        }
    }
    // done .....
    //根据JMM的第2条规则,主线程写了`volatile`修饰的`run`变量,后面的t线程读取的时候就可以看到了。
    

    方式二:枷锁

    public class VolatileTest {
        static boolean run = true;
        final static Object lock = new Object();
        public static void main(String[] args) throws InterruptedException {
            Thread t = new Thread(() -> {
                while (run) {
                    synchronized(lock){
                        if(!run){
                            break;
                        }
                    }
                }
                log.info("done .....");
            });
            t.start();
            Thread.sleep(1000);
            //设置run = false
            synchronized(lock){
                 run = false;      
            }
        }
    }
    // done .....
    //利用`synchronized`锁的规则,主线程释放锁,那么后续t线程加锁就可以看到之前的内容了。
    

    总结

    volatile 关键字

    • 保证可见性
    • 不保证原子性
    • 保证有序性(禁止指令重排)

    volatile 修饰的变量进行读操作与普通变量几乎没什么差别,但是写操作相对慢一些,因为需要在本地代码中插入很多内存屏障来保证指令不会发生乱序执行,但是开销比锁要小。volatile的性能远比加锁要好。

    synchronized 关键字

    • 保证可见性
    • 保证原子性
    • 保证有序性

    加了锁之后,只能有一个线程获得到了锁,获得不到锁的线程就要阻塞,所以同一时间只有一个线程执行,相当于单线程,由于数据依赖性的存在,单线程的指令重排是没有问题的。
    线程加锁前,将清空工作内存中共享变量的值,使用共享变量时需要从主内存中重新读取最新的值;线程解锁前,必须把共享变量的最新值刷新到主内存中。

    相关文章

    JavaScript2024新功能:Object.groupBy、正则表达式v标志
    PHP trim 函数对多字节字符的使用和限制
    新函数 json_validate() 、randomizer 类扩展…20 个PHP 8.3 新特性全面解析
    使用HTMX为WordPress增效:如何在不使用复杂框架的情况下增强平台功能
    为React 19做准备:WordPress 6.6用户指南
    如何删除WordPress中的所有评论

    发布评论