• 【JVM】内存模型:原子性、可见性、有序性的问题引出与解决


    一、内存模型

    很多人将【java 内存结构】与【java 内存模型】傻傻分不清,【java 内存模型】是 Java MemoryModel(JMM)的意思。

    • 简单的说,JMM 定义了一套在多线程读写共享数据时(成员变量、数组)时,对数据的可见性、有序性、和原子性的规则和保障

    关于它的权威解释,请参考:链接

    二、原子性

    2.1 指令交错

    原子性在学习线程时讲过,下面来个例子简单回顾一下:

    问题提出,两个线程对初始值为 0 的静态变量一个做自增,一个做自减,各做 5000 次,结果是 0 吗?

    public class Atomicity1 {
        static int a=0;
        public static void main(String[] args) throws InterruptedException {
            for (int j=0;j<5;j++) {
                Thread thread1 = new Thread(() -> {
                    for (int i = 0; i < 5000; i++) {
                        a++;
                    }
                });
                Thread thread2 = new Thread(() -> {
                    for (int i = 0; i < 5000; i++) {
                        a--;
                    }
                });
                thread1.start();
                thread2.start();
                thread1.join();
                thread2.join();
                System.out.println("第"+j+"次:"+a);
                a=0;
            }
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    0次:-26401次:02次:18913次:-13174次:294
    
    • 1
    • 2
    • 3
    • 4
    • 5

    2.2 问题分析

    以上的结果可能是正数、负数、零。为什么呢?因为 Java 中对静态变量的自增,自减并不是原子操作

    a++ 实际产生的字节码指令:

    getstatic a 	// 获取静态变量a的值
    iconst_1 		// 准备常量1
    iadd 			// 加法
    putstatic a 	// 将修改后的值存入静态变量a
    
    • 1
    • 2
    • 3
    • 4

    i++ 实际产生的字节码指令:

    getstatic a 	// 获取静态变量a的值
    iconst_1 		// 准备常量1
    isub 			// 减法
    putstatic a 	// 将修改后的值存入静态变量a
    
    • 1
    • 2
    • 3
    • 4

    内存模型如下:一个线程要完成静态变量的自增、自减,需要从主内存中获取静态变量的值到线程内存中进行计算,然后再写到主存中

    在这里插入图片描述

    单线程情况:没有问题

    如果是单线程以上 8 行代码是顺序执行(不会交错)没有问题:

    getstatic a 	// 线程1-获取主内存静态变量i的值:线程内i=0
    iconst_1 		// 线程1-准备常量1
    iadd 			// 线程1-加法:线程内i=1
    putstatic a 	// 线程1-将修改后的值存入静态变量i:主内存静态变量i=1
    getstatic a 	// 线程1-获取静态变量i的值:线程内i=1
    iconst_1 		// 线程1-准备常量1
    isub 			// 线程1-减法:线程内i=0
    putstatic a 	// 线程1-将修改后的值存入静态变量i:主内存静态变量i=0
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    多线程情况:出现问题

    多线程下这 8 行代码可能交错运行(为什么会交错?思考一下):

    出现负数的情况之一:

    getstatic a 	// 线程1-获取主内存静态变量a的值:线程内a=0
    getstatic a 	// 线程2-获取主内存静态变量a的值:线程内a=0
    iconst_1 		// 线程1-准备常量1
    iadd 			// 线程1-加法:线程内a=1
    putstatic a 	// 线程1-将修改后的值存入静态变量a:主内存静态变量a=1
    iconst_1 		// 线程2-准备常量1
    isub 			// 线程2-减法:线程内a=-1
    putstatic a 	// 线程2-将修改后的值存入静态变量a:主内存静态变量a=-1
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    出现正数的情况之一:

    getstatic a 	// 线程1-获取主内存静态变量a的值:线程内a=0
    getstatic a 	// 线程2-获取主内存静态变量a的值:线程内a=0
    iconst_1 		// 线程1-准备常量1
    iadd 			// 线程1-加法:线程内a=1
    iconst_1 		// 线程2-准备常量1
    isub 			// 线程2-减法:线程内a=-1
    putstatic a		// 线程2-将修改后的值存入静态变量a:静态变量a=-1
    putstatic a 	// 线程1-将修改后的值存入静态变量a:静态变量a=1
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    2.3 问题解决

    synchronized

    • 优点:可以保证代码块内的 原子性、可见性
    • 缺点:属于重量级操作,性能相对更低

    让操作共享变量的线程只能同时存在一个,不让操作a的指令交错执行。使用 synchronized **锁住同一个对象 **进行保证

    public class Atomicity1 {
        static int a=0;
        static Object lock=new Object();	//锁对象
        public static void main(String[] args) throws InterruptedException {
            for (int j=0;j<5;j++) {
                Thread thread1 = new Thread(() -> {
                    synchronized (lock){	//线程操作共享变量时竞争锁
                        for (int i = 0; i < 5000; i++) {
                            a++;
                        }
                    }
                });
                Thread thread2 = new Thread(() -> {
                    synchronized (lock){	//线程操作共享变量时竞争锁
                        for (int i = 0; i < 5000; i++) {
                            a--;
                        }
                    }
                });
                thread1.start();
                thread2.start();
                thread1.join();
                thread2.join();
                System.out.println("第"+j+"次:"+a);
                a=0;
            }
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28

    三、可见性

    3.1 退不出的循环

    public class visibility1 {
        static boolean run=true;
        public static void main(String[] args) throws InterruptedException {
            new Thread(()->{
                while(run){
    
                }
            },"t").start();
            Thread.sleep(1000);
            System.out.println("1秒后");
            run=false;
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13

    3.2 问题分析

    1. 初始状态, t 线程刚开始从主内存读取了 run 的值到工作内存

      在这里插入图片描述

    2. 因为 t 线程要频繁从主内存中读取 run 的值,JIT 编译器会将 run 的值缓存至自己工作内存中的高速缓存中,减少对主存中 run 的访问,提高效率

      在这里插入图片描述

    3. 1 秒之后,main 线程修改了 run 的值,并同步至主存,而 t 是从自己工作内存中的高速缓存中读取这个变量的值,结果永远是旧值

      在这里插入图片描述

    3.3 问题解决

    • volatile(易变关键字)
      • 它可以用来修饰 成员变量和静态成员变量
      • 它可以避免线程从自己的工作缓存中查找变量的值,必须到主存中获取它的值,线程操作 volatile 变量都是直接操作主存
      • 不能保证原子性。仅用在一个写线程,多个读线程的情况
    • synchronized
      • 优点:可以保证代码块内的 原子性、可见性
      • 缺点:属于重量级操作,性能相对更低

    给该例子的 run 加上volatile,保障的实际就是可见性问题。从字节码理解是这样的:

    getstatic run 	// 线程t:获取 run true
    getstatic run 	// 线程t:获取 run true
    getstatic run 	// 线程t:获取 run true
    getstatic run 	// 线程t:获取 run true
    putstatic run 	// 线程main:修改 run 为 false, 仅此一次
    getstatic run 	// 线程t:获取 run false
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    比较一下之前我们将线程安全时举的例子:两个线程一个 i++ 一个 i-- ,此时 volatile 只能保证看到最新值,不能解决指令交错

    getstatic a 	// 线程1-获取主存静态变量a的值:线程内a=0
    getstatic a 	// 线程2-获取主存静态变量a的值:线程内a=0
    iconst_1 		// 线程1-准备常量1
    iadd 			// 线程1-加法:线程内a=1
    putstatic a 	// 线程1-将修改后的值存入静态变量:主存静态变量a=1
    //注意:此时线程2无需再执行取值操作,所以线程1存值时就算有 volatile 也于事无补
    iconst_1 		// 线程2-准备常量1
    isub 			// 线程2-减法:线程内a=-1
    putstatic a 	// 线程2-将修改后的值存入静态变量:主存静态变量a=-1
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

    思考

    如果在前面示例的死循环中加入 System.out.println() 会发现即使不加 volatile 修饰符,线程 t 也能正确看到对 run 变量的修改了,想一想为什么?

    四、有序性

    4.1 出现指令重排

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

    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;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15

    I_Result 是一个对象,有一个属性 r1 用来保存结果,问,可能的结果有几种?有同学分析了三种情况

    • r1 = 1:线程1 直接执行完。
    • r1 = 4:线程2 直接执行完。
    • r1 = 1:线程2 先执行到 num = 2,但没来得及执行 ready = true,线程1 执行进入 else 分支

    r1=0:JIT 进行指令重排导致的有序性问题

    这种情况下是:线程2 直接执行 ready = true,切换到 线程1 进入 if 分支,相加为 0,再切回线程2 执行num = 2。

    4.2 分析

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

    static int i;
    static int j;
    // 在某个线程内执行如下赋值操作
    i = ...; // 较为耗时的操作
    j = ...;
    
    • 1
    • 2
    • 3
    • 4
    • 5

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

    i = ...; // 较为耗时的操作
    j = ...;
    
    • 1
    • 2

    也可以是

    j = ...;
    i = ...; // 较为耗时的操作
    
    • 1
    • 2

    这种特性称之为**『指令重排』**。多线程下『指令重排』会影响正确性

    单例应用:双重检测

    /**
     * 加 volatile 的原因:
     *		1.线程1:new 关键字给INSTANCE分配空间,此时INSTANCE不为null
     *		2.线程2:获取到了还没完全初始化好的 INSTANCE
     */
    public final class Singleton {
    	private Singleton() { }
    	private static volatile Singleton INSTANCE = null;
    	public static Singleton getInstance() {
    		//1.实例未创建才竞争
    		if (INSTANCE == null) {
    			synchronized (Singleton.class) {
    				//2.前面获得锁的线程已经创建对象了
    				if (INSTANCE == null) {
    					INSTANCE = new Singleton();
    				}
    			}
    		}
    		return INSTANCE;
    	}
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21

    以上的实现特点是:

    • 懒惰实例化
    • 首次使用 getInstance() 才使用 synchronized 加锁,后续使用时无需加锁

    4.3 解决方法

    volatile 修饰的变量,可以禁用指令重排

  • 相关阅读:
    计算机基本工作原理
    Word处理控件Aspose.Words功能演示:在Word文档中创建可填充表单
    四轴飞行器MiniFly学习笔记01——飞行姿态
    golang 并发--goroutine(四)
    C++ STL进阶与补充(string容器)
    Idea中查看SpringSecurity各Filter信息
    Windows——sentry接入C/C++程序
    【Golang】简记操作:Centos安装、卸载、升级Golang运行环境
    React@16.x(14)context 举例 - Form 表单
    C++ 运算符
  • 原文地址:https://blog.csdn.net/weixin_43401592/article/details/128020228