【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;
        }
    }
}
0次:-26401次:02次:18913次:-13174次:294

2.2 问题分析

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

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

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

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

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

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

【JVM】内存模型:原子性、可见性、有序性的问题引出与解决_第1张图片

单线程情况:没有问题

如果是单线程以上 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

多线程情况:出现问题

多线程下这 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

出现正数的情况之一:

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

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;
        }
    }
}

三、可见性

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;
    }
}

3.2 问题分析

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

    【JVM】内存模型:原子性、可见性、有序性的问题引出与解决_第2张图片

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

    【JVM】内存模型:原子性、可见性、有序性的问题引出与解决_第3张图片

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

    【JVM】内存模型:原子性、可见性、有序性的问题引出与解决_第4张图片

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

比较一下之前我们将线程安全时举的例子:两个线程一个 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

思考

如果在前面示例的死循环中加入 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;
}

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 = ...;

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

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

也可以是

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

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

单例应用:双重检测

/**
 * 加 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;
	}
}

以上的实现特点是:

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

4.3 解决方法

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

你可能感兴趣的:(#,JVM相关,jvm,java,开发语言)