多线程—Java内存模型

Java内存模型,JMM(Java Memory Model)。

概念:Java内存模型定义了final、volatile和synchronized关键字的行为并确保正确同步的Java程序能够正确运行在不同架构的处理器上。

作用:主要解决三个方面的问题

  1. 原子性问题

  2. 可见性问题

  3. 有序性问题

分析:

  1. 原子性:保证指令不会受到线程上下文切换的影响。

    理解:

    【原子】:字面意思是不可分割的意思,如果一个线程访问共享变量的操作,在其它线程看来是不可分割的,那么该操作就是原子操作,这个操作就具有原子性。

    【不可分割】:当一个线程访问共享变量的操作,在其他线程看来,这个操作要么执行结束,要么没有发生,其他线程无法看到该线程访问时的中间过程,讲到这里,是否让你联想到了MySQL数据库事务的原子性。另外,访问同一组共享变量的原子操作是不能够被交错的。

    注意:

    • 原子操作是针对访问共享变量的操作而言的。对于局部变量的访问,无所谓其是否是原子的。

    • 原子操作是从该操作线程以外的线程来描述的,所以,在多线程环境下才会有意义。

    实现原理:

    • 锁:锁具有排他性,它能保证一个共享变量在任意时刻只能被一个线程访问(竞态消除)。

    • CAS(Compare -and-Swap):它实现原子性的方式和锁实现原子性的方式实质上是一样的,差别在于,锁通常是在软件层面实现,而CAS直接在硬件(处理器和内存)层面实现,也就是俗称的”硬件锁“。

    常见的原子操作:

    基础类型中,除了long和double意外,其它基础类型变量的写操作都是原子操作。long和double类型变量的写操作,需要用到关键字volatile实现写的原子性。

    2、可见性

    概念:可见性就是指一个线程对共享变量的更新结果,对于读取相应共享变量的线程是否可见。

    实质:保证指令不受cpu缓存的影响。

    示例展示:

@Slf4j
public class TestOne {
    //创建一个标记
    static boolean run = true;
    public static void main(String[] args) throws InterruptedException {
        Thread t = new Thread(()->{
            //当标记为假 线程结束
            while(run){
				
            }
        },"t");
        t.start();
        sleep(1000);
        //更改标记 退出循环
        run = false;
        log.debug("标记已更改");
    }
}

运行结果展示:

多线程—Java内存模型

虽然标记已经被更改了,但是线程还是一直在循环,没有结束。

接下来分析一下程序执行的过程:

分析之前大家应该要知道,Java内存模型把内存分为主存工作内存

  • 主存:存储共享信息的内存。

  • 工作内存:每个线程存储私有信息的内存。

  1. 初始状态:主内存存储共享变量run,t线程从主内存中读取run变量的值到工作内存。

多线程—Java内存模型

 2. 示例代码的while循环中,t线程会不断的从主线程读取run的值,JIT编译器会将run变量的值缓存到自己工作内存的告诉缓存中,减少对主内存的访问,提升效率。

多线程—Java内存模型

 3. 主线程sleep一秒之后,修改了run的值,并同步到主内存,但是t线程还是从自己的工作内存里面读取run的值,此时读取到的值没有得到更新,所以线程不会结束。

多线程—Java内存模型

 

解决方式:

  1. 使用volatile关键字,使得t线程读取run变量时,就不会去自己的工作内存中读取,而是去主存中读取,run变量得到了即使的更新。

@Slf4j
public class TestOne {
    //创建一个标记,加上volatile关键字
    static volatile boolean run = true;
    public static void main(String[] args) throws InterruptedException {
        Thread t = new Thread(()->{
            //当标记为假 线程结束
            while(run){
				
            }
        },"t");
        t.start();
        sleep(1000);
        //更改标记 退出循环
        run = false;
        log.debug("标记已更改");
    }
}

2. 使用synchronized关键字,下面这段代码,如果去掉synchronized,那么线程将不会停止,而加上锁,就会实现volatile关键字一样的效果。

@Slf4j
public class TestOne {
    //创建一个标记,去掉了volatile
    static boolean run = true;
    //创建一个对象
    final static Object object = new Object();
    public static void main(String[] args) throws InterruptedException {
        Thread t = new Thread(()->{
            while(true) {
                synchronized (object) {
                    if (!run) {
                        break;
                    }
                }
            }
        },"t");
        t.start();
        sleep(1000);
        //更改标记 退出循环
        run = false;
        log.debug("标记已更改");
    }
}

两种解决方式比较:

  • 解决可见性问题使用volatile关键字更加轻量级一点,使用synchronized同步代码块代价更高,因为还要创建Monitor。

volatile的特点:

  • volatile只能保证变量的可见性,不能保证原子性。

  • synchronized既能保证代码块的原子性,也能保证代码块内变量的可见性,但是属于重量级操作,性能相对较低。

3、有序性

概念:有时一个处理器上运行的一个线程所执行的内存访问操作,在另外一个处理器上运行的其它线程看来是乱序的。

指令重排序:在源码顺序与程序顺序不一致,或者程序顺序与执行顺序不一致的情况下,就是发生了指令重排序。

解决指令重排序:创建变量时加上volatile关键字,在代码使用的时候,使用到该变量的代码行之前的代码无法进行指令重排序。

理解volatile保证可见性的原理:volatile底层实现原理是内存屏障。

  • 对volatile变量的写指令后会加入写屏障。

  • 对volatile变量的读指令前会加入读屏障。

    理解保证可见性的写/读屏障:

    • 写屏障保证在该屏障之前,对共享变量的改动,都同步到主存中,不仅是加了volatile关键字的共享变量,写屏障之前的所有共享变量。

    • 读屏障保证在该屏障之后,对共享变量的读取,加载的是主内存中的最新数据。

理解volatile保证有序性的原理:

理解保证可见性的写/读屏障:

  • 写屏障会保证指令重排序时,不会将写屏障之前的代码排在写屏障之后。

  • 读屏障会保证指令重排序时,不会将读屏障之后的代码排到读屏障之前。

注意:只能而解决本线程的指令重排,解决不了线程之间的指令重排。

上一篇:volatile探秘


下一篇:Hyperledger Fabric 1.2 --- Chaincode Operator 解读和测试(二)