Java多线程(5)JMM详解

上一篇:Java多线程(4)ReentrantLock详解

什么是JMM

JMM 即 Java Memory Model,它定义了主存、工作内存抽象概念,底层对应着 CPU 寄存器、缓存、硬件内存、CPU 指令优化等;

  1. 所有的变量都存储在主内存中
  2. 每个线程都有自己独立的工作内存,里面保存该线程使用到的变量的副本(主内存中该变量的一份拷贝)
    用下面的图可以直观的看到JMM中,线程、工作内存、主内存之间的关系:
    在这里插入图片描述

在JMM中,有两条规定:

  1. 程对共享变量的所有操作都必须在自己的工作内存中进行,不能直接从主内存中读写

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

JMM 体现在以下几个方面

  • 原子性 - 保证指令不会受到线程上下文切换的影响
  • 可见性 - 保证指令不会受 cpu 缓存的影响
  • 有序性 - 保证指令不会受 cpu 指令并行优化的影响

一. 原子性

原子性:即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行, 就是一个或某几个操作只能在一个线程执行完之后,另一个线程才能开始执行该操作,也就是说这些操作是不可分割的,线程不能在这些操作上交替执行。

1. 什么情况下是原子操作

Java中自带了一些原子性操作,比如给一个非long、double基本数据类型变量或者引用的赋值或者读取操作,另外,Java提供了AtomicInteger等原子类。再就是用原子性来控制并发比较麻烦,也容易出问题

一个很经典的例子就是银行账户转账问题:
比如从账户A向账户B转1000元,那么必然包括2个操作:从账户A减去1000元,往账户B加上1000元。
试想一下,如果这2个操作不具备原子性,会造成什么样的后果。假如从账户A减去1000元之后,操作突然中止。然后又从B取出了500元,取出500元之后,再执行 往账户B加上1000元 的操作。这样就会导致账户A虽然减去了1000元,但是账户B没有收到这个转过来的1000元
所以这2个操作必须要具备原子性才能保证不出现一些意外的问题。
同样地反映到并发编程中会出现什么结果呢?

举个最简单的例子,大家想一下假如为一个32位的变量赋值过程不具备原子性的话,会发生什么后果?

i = 9;

假若一个线程执行到这个语句时,我暂且假设为一个32位的变量赋值包括两个过程:为低16位赋值,为高16位赋值。
那么就可能发生一种情况:当将低16位数值写入之后,突然被中断,而此时又有一个线程去读取i的值,那么读取到的就是错误的数据,为了解决这种情况,**Java就直接将 对非long、double基本数据类型变量 读和赋值操作设计成原子性,所以说 i=9; 是原子性的 **

2. count++ 是不是原子操作呢

count++的执行分为三个阶段:

  1. 内存到寄存器
  2. 寄存器自增
  3. 写回内存

这三个阶段中间都可以被中断分离开

如果 count++只需要一条指令的话,并不能保证多个线程的count++操作,操作的是同一个变量count,并且还可以得到正确的结果。因为还有寄存器的因素,多个cpu对应多个寄存器。每次要先把i从内存复制到寄存器,然后++,然后再把i复制到内存中,这需要至少3步。从这个意义上讲,count++不是原子性的

3. 导致原子性的问题

导致原子性的原因是线程切换当一个线程对共享变量进行读写操作时,没有执行完,切换到另外一个线程对该共享变量进行读写

比如上面经典的count++问题
在这里插入图片描述

在写入内存前的操作都是在 线程私有的工作内存中执行的,这就造成了两个线程的 count = 1 指令 发生了两次,相当于两次操作都是将变量count赋值为 1,不是预想之中的count = 2

3. 如何将其他操作(代码)设计成原子的

其实我们只要保证同一时刻只有一个线程执行就可以了,我们叫做互斥,即,锁

采用同步synchronized解决
synchronized可以保证在同一时刻,只有一个线程可以执行某一个方法,或者某一个代码块,就避免了多线程下的非原子性操作被其他线程中断

4. 面试题目:i++在两个线程里边分别执行100次,能得到的最大值和最小值分别是多少?

假设两个线程的执行步骤如下:

  1. 线程A执行第一次i++,取出内存中的i,值为0,存放到寄存器后执行加1,此时CPU1的寄存器中值为1,内存中为0;
  2. 线程B执行第一次i++,取出内存中的i,值为0,存放到寄存器后执行加1,此时CPU2的寄存器中值为1,内存中为0;
  3. 线程A继续执行完成第99次i++,并把值放回内存,此时CPU1中寄存器的值为99,内存中为99;
  4. 线程B继续执行第一次i++,将其值放回内存,此时CPU2中的寄存器值为1,内存中为1;
  5. 线程A执行第100次i++,将内存中的值取回CPU1的寄存器,并执行加1,此时CPU1的寄存器中的值为2,内存中为1;
  6. 线程B执行完所有操作,并将其放回内存,此时CPU2的寄存器值为100,内存中为100;
  7. 线程A执行100次操作的最后一部分,将CPU2中的寄存器值放回内存,内存中值为2;
  8. 结束!

便可以得出最终结果,最小值为2,最大值为200

二. 可见性

先看代码案例:

static boolean run = true;
public static void main(String[] args) throws InterruptedException {
 Thread t = new Thread(()->{
 while(run){
 // ....
 }
 });
 t.start();
 sleep(1);
 run = false; // 线程t不会如预想的停下来
}

为什么呢?分析一下:

  1. 初始状态, t 线程刚开始从主内存读取了 run 的值到工作内存。
    在这里插入图片描述
  2. 因为 t 线程要频繁从主内存中读取 run 的值,JIT 编译器会将 run 的值缓存至自己工作内存中的高速缓存中,减少对主存中 run 的访问,提高效率
  3. 1 秒之后,main 线程修改了 run 的值,并同步至主存,而 t 是从自己工作内存中的高速缓存中读取这个变量的值,结果永远是旧值

在这里插入图片描述

1. 什么情况下一个线程对字段的修改才能确保对另一个线程可见:
1.1 synchronized修饰

一个写操作的线程释放一个锁之后,另一个读操作的线程随后获取了同一个锁。本质上,一个线程释放锁时会将强制提交工作内存中的新数据到主内存中,另一个线程获取这个锁的时候,将强制线程装载(或重新装载)字段的值(从主内存中获取)。锁提供对一个同步方法或块的互斥性执行,线程执行获取锁和释放锁时,所有对字段的访问的内存效果都是已定义的

JMM对于synchronized有两条规定:

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

线程执行互斥代码(被synchronized修饰的代码)的过程:

  1. 获得互斥锁
  2. 清空工作内存
  3. 从主内存拷贝变量的最新副本到自己工作内存
  4. 执行代码
  5. 将更改后的共享变量的值刷新到主内存中
  6. 释放互斥锁(wait方法执行,同步代码块里面的代码执行完毕)
1.2 volatile(易变关键字)

它可以用来修饰成员变量和静态成员变量,他可以 在每次被线程访问时,都强迫从主内存中读取该变量的值,而当该变量发生变化时,又会强迫线程将最新的值刷新到主内存,这样不同的线程总能看到该变量的最新值

原理:

  • volatile是通过加入内存屏障和禁止重排序优化来实现的
  • 对volatile变量执行写操作时,会在写操作后加入一条sotre屏障指令,强制写入主内存中
  • 对volatile变量执行读操作时,会在读操作前加入一条load屏障指令,强制清空缓冲区数据,需要时从主内存中拿,并禁止指令重排序
  • volatile不保证变量复合操作的原子性

三. 有序性

有序性是指程序在执行的时候,程序的代码执行顺序和语句的顺序是一致的
指令重排序:代码书写的顺序与实际执行的顺序不同,指令重排序是编译器或者处理器为了提高程序性能而做的优化。

  • 编译器优化的重排序(编译器优化)
  • 指令集并行重排序(处理器优化)
  • 内存系统的重排序(处理器优化)

JVM 会在不影响正确性的前提下,可以调整语句的执行顺序,思考下面一段代码

static int i;
static int j;
// 在某个线程内执行如下赋值操作
i = ...; 
j = ...;

可以看到,至于是先执行 i 还是 先执行 j ,对最终的结果不会产生影响。所以,上面代码真正执行时,既可以是

i = ...; 
j = ...;

也可以是

j = ...;
i = ...;

这种特性称之为『指令重排』,多线程下『指令重排』会影响正确性。为什么要有重排指令这项优化呢?从 CPU执行指令的原理来理解一下吧

原理之指令级并行

int num = 0;
boolean ready = false;
// 线程1 执行此方法
public void actor1(I_Result r) {
 if(ready) {
 r.r1 = num + num;
 } else {
 r.r1 = 1;
 }
}
// 线程2 执行此方法
public void actor2(I_Result r) { 
 num = 2;
 ready = true; 
}

I_Result 是一个对象,有一个属性 r1 用来保存结果,问,可能的结果有几种?

  1. 情况1:线程1 先执行,这时 ready = false,所以进入 else 分支结果为 1
  2. 情况2:线程2 先执行 num = 2,但没来得及执行 ready = true,线程1 执行,还是进入 else 分支,结果为1
  3. 情况3:线程2 执行到 ready = true,线程1 执行,这回进入 if 分支,结果为 4(因为 num 已经执行过了)
  4. 情况4:结果为0,因为指令重排,线程2 先执行 ready = true,切换到线程1,进入 if 分支,相加为 0,再切回线程2 执行 num = 2

这种现象叫做指令重排,是 JIT 编译器在运行时的一些优化,这个现象需要通过大量测试才能复现:

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

    public static void main(String[] args) throws InterruptedException {
        while(true){
            i++;
            x = 0;y = 0; 
            a = 0;b = 0;
            Thread thread1 = new Thread(new Runnable() {
                @Override
                public void run() {
                    waitTime(10000);
                    a = 1;
                    x = b;
                }
            });
            Thread thread2 = new Thread(new Runnable() {
                @Override
                public void run() {
                    b = 1;
                    y = a;
                }
            });
            thread1.start();
            thread2.start();
            thread1.join();
            thread2.join();
            System.out.println("第" + i + "次执行结果(" + x + "," + y + ")");
            if (x == 0 && y == 0){
                System.out.println("在第" + i + "次发生指令重排(" + x + "," + y + ")");
                break;
            }
        }
    }
    public static void waitTime(int time){
        long start = System.nanoTime();
        long end;
        do {
            end = System.nanoTime();
        }while (start + time >= end);
    }
}

运行结果:

在这里插入图片描述

虽然次数相对很少,但毕竟是出现了

Java 中可以使用 volatile 来保证顺序性(volatile 修饰的变量,可以禁用指令重排),synchronized 和 lock 也可以来保证有序性,和保证原子性的方式一样,通过同一段时间只能一个线程访问来实现的

    private volatile static int x = 0,y = 0;  //  volatile 
    private volatile static int a = 0,b = 0;  //  volatile 
    private static int i = 0;

加上volatile 之后运行结果:

第151017次执行结果(0,1)
第151018次执行结果(0,1)
第151019次执行结果(0,1)
第151020次执行结果(0,1)
第151021次执行结果(0,1)
第151022次执行结果(0,1)
第151023次执行结果(0,1)
第151024次执行结果(0,1)
第151025次执行结果(0,1)
第151026次执行结果(0,1)
第151027次执行结果(0,1)
第151028次执行结果(1,0)
第151029次执行结果(0,1)
第151030次执行结果(0,1)
第151031次执行结果(0,1)

Process finished with exit code 130

是我手动停下的,加上volatile 之后没有出现指令重排

除了通过 volatile 关键字显式的保证顺序之外, JVM 还通过 happen-before 原则来隐式的保证顺序性。

其中有一条就是适用于 volatile 关键字的,针对于 volatile 关键字的写操作肯定是在读操作之前,也就是说读取的值肯定是最新的。

四. happen-before

happens-before 规定了对共享变量的写操作对其它线程的读操作可见,它是可见性与有序性的一套规则总结,抛
开以下 happens-before 规则,JMM 并不能保证一个线程对共享变量的写,对于其它线程对该共享变量的读可见

  • 线程解锁 m 之前对变量的写,对于接下来对 m 加锁的其它线程对该变量的读可见

    static int x;
    static Object m = new Object();
    new Thread(()->{
     synchronized(m) {
     x = 10;
     }
    },"t1").start();
    new Thread(()->{
     synchronized(m) {
     System.out.println(x);
     }
    },"t2").start();
    
  • 线程对 volatile 变量的写,对接下来其它线程对该变量的读可见

    volatile static int x;
    new Thread(()->{
     x = 10;
    },"t1").start();
    new Thread(()->{
     System.out.println(x);
    },"t2").start();
    
  • 线程 start 前对变量的写,对该线程开始后对该变量的读可见

    static int x; x = 10;
    new Thread(()->{
     System.out.println(x);
    },"t2").start();
    
  • 线程结束前对变量的写,对其它线程得知它结束后的读可见(比如其它线程调用 t1.isAlive() 或 t1.join()等待它结束)

    static int x;
    Thread t1 = new Thread(()->{
     x = 10;
    },"t1");
    t1.start();
    t1.join();
    System.out.println(x);
    
  • 线程 t1 打断 t2(interrupt)前对变量的写,对于其他线程得知 t2 被打断后对变量的读可见(通过t2.interrupted 或 t2.isInterrupted)

    static int x;
    public static void main(String[] args) {
     Thread t2 = new Thread(()->{
     while(true) {
     if(Thread.currentThread().isInterrupted()) {
     System.out.println(x);
     break;
     }
     }
     },"t2");
     t2.start();
     new Thread(()->{
     sleep(1);
     x = 10;
     t2.interrupt();
     },"t1").start();
     while(!t2.isInterrupted()) {
     Thread.yield();
     }
     System.out.println(x);
    }
    
  • 对变量默认值(0,false,null)的写,对其它线程对该变量的读可见

  • 具有传递性,如果 x hb-> y 并且 y hb-> z 那么有 x hb-> z ,配合 volatile 的防指令重排,有下面的例子

    volatile static int x;
    static int y;
    new Thread(()->{ 
     y = 10;
     x = 20;
    },"t1").start();
    new Thread(()->{
     // x=20 对 t2 可见, 同时 y=10 也对 t2 可见
     System.out.println(x); 
    },"t2").start();
    

over

想深入的话可以看一下这个 深入理解Java虚拟机—Java内存模型与线程

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值