Java并发编程之 Java内存模型

有时候,不是因为你没有能力,也不是因为你缺少勇气,只是因为你付出的努力还太少,所以,成功便不会走向你。而你所需要做的,就是坚定你的梦想,你的目标,你的未来,然后以不达目的誓不罢休的那股劲,去付出你的努力,成功就会慢慢向你靠近。

导读:本篇文章讲解 Java并发编程之 Java内存模型,希望对大家有帮助,欢迎收藏,转发!站点地址:www.bmabk.com,来源:原文

1、Java 内存模型

JMM 即 Java Memory Model ,它从Java层面定义了 主存、工作内存 抽象概念,底层对应着CPU 寄存器、缓存、硬件内存、CPU 指令优化等。JMM 体现在以下几个方面:

  • 原子性 – 保证指令不会受 线程上下文切换的影响
  • 可见性 – 保证指令不会受 cpu 缓存的影响 (JIT对热点代码的缓存优化)
  • 有序性 – 保证指令不会受 cpu 指令并行优化的影响

2、可见性

2.1、退不出的循环

main线程对run变量的修改对于t线程不可见,导致了 t 线程无法停止

public class Test1 {
    volatile static boolean run = true;
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            while (run) {
                // 如果打印一句话
                // 此时就可以结束, 因为println方法中, 使用到了synchronized
                // synchronized可以保证原子性、可见性、有序性
                // System.out.println("123");
            }
        });

        t1.start();
        Thread.sleep(1000);
        run = false;
        System.out.println(run);
    }
}
  • 一开始一直不结束, 是因为无限循环, run都是true, JIT及时编译器, 会对t1线程所执行的run变量,进行缓存, 缓存到本地工作内存. 不去访问主存中的run. 这样可以提高性能
  • 也可以说是JVM打到一定阈值之后, while(true)变成了一个热点代码, 所以一直访问的都是缓存到本地工作内存(局部)中的run.
  • 当主线程修改主存中的run变量的时候,t1线程一直访问的是自己缓存的, 所以不认为run已经改为false了. 所以一直运行.
  • 我们为主存(成员变量)进行volatile修饰, 增加变量的可见性, 当主线程修改run为false, t1线程对run的值可见. 这样就可以退出循环

使用synchronized解决

public class Test1 {
    static boolean run = true;
    final static Object obj = new Object();
    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            // 1s内,一直都在无限循环获取锁. 1s后主线程抢到锁,修改为false, 此时t1线程抢到锁对象,while循环也退出
            while (run) {
                synchronized (obj) {

                }
            }
        });

        t1.start();
        Sleeper.sleep(1);
        // 当主线程获取到锁的时候, 就修改为false了
        synchronized (obj) {
            run = false;
            System.out.println("false");
        }
    }
}

分析run变量的不可见性原因?

  1. 初始状态, t线程刚开始从主内存(成员变量), 因为主线程sleep(1)秒, 这时候t1线程循环了好多次run的值, 超过了一定的阈值, JIT就会将主存中的run值读取到工作内存 (相当于缓存了一份, 不会去主存中读run的值了)
    在这里插入图片描述
  2. 因为t1线程频繁地从主存中读取run的值,JIT即时编译器会将run的值缓存至自己工作内存中的高速缓存中,减少对主存中run的访问以提高效率
    在这里插入图片描述
  3. 1 秒之后,main线程修改了run的值, 并同步至主存。而 t线程是从自己工作内存中的高速缓存中读取这个变量的值,结果永远是旧值
    在这里插入图片描述

2.2、实现可见性方法

  • 使用volatile(表示易变关键字的意思),它可以用来修饰成员变量和静态成员变量,它可以避免线程从自己的工作缓存中查找变量的值,必须到主存中获取它的值,线程操作 volatile 变量都是直接操作主存

  • volatile 可以认为是一个轻量级的锁,被 volatile 修饰的变量,汇编指令会存在于一个”lock”的前缀。在CPU层面与主内存层面,通过缓存一致性协议,加锁后能够保证写的值同步到主内存,使其他线程能够获得最新的值

  • 使用synchronized关键字也有相同的效果, 在Java内存模型中,synchronized规定,线程在加锁时, 先清空工作内存 → 在主内存中拷贝最新变量的副本到工作内存 → 执行完代码 → 将更改后的共享变量的值刷新到主内存中 → 释放互斥锁

2.3、可见性 vs 原子性

两个线程一个 i++ 一个 i– ,只能保证看到最新值(可见性),不能解决指令交错(原子性)

// 假设i的初始值为0
getstatic i // 线程2-获取静态变量i的值 线程内i=0
getstatic i // 线程1-获取静态变量i的值 线程内i=0
iconst_1 // 线程1-准备常量1
iadd // 线程1-自增 线程内i=1
putstatic i // 线程1-将修改后的值存入静态变量i 静态变量i=1
iconst_1 // 线程2-准备常量1
isub // 线程2-自减 线程内i=-1
putstatic i // 线程2-将修改后的值存入静态变量i 静态变量i=-1 
  • synchronized 语句块既可以保证代码块的原子性,也同时保证代码块内变量的可见性
  • 但缺点是 synchronized 是属于重量级操作,性能相对更低
  • 如果在前面示例的死循环中加入 System.out.println() 会发现即使不加 volatile 修饰符,线程 t 也能正确看到 对 run 变量的修改了,想一想为什么?
  • 因为println方法里面有synchronized修饰。还有那个等烟的示例, 为啥没有出现可见性问题?和synchrozized是一个道理

3、 有序性

  • 是JIT即时编译器的优化, 可能会导致指令重排
  • 为什么要优化? 因为CPU 支持多级指令流水线
  • 例如支持同时执行 取指令 – 指令译码 – 执行指令 – 内存访问 – 数据写回 的处理器,效率快
  • JVM会在不影响正确性的前提下,可以调整语句的执行顺序, 是一种优化

3.1、支持流水线的处理器

  • 现代 CPU 支持多级指令流水线,例如支持同时执行 取指令 – 指令译码 – 执行指令 – 内存访问 – 数据写回 的处理器,就可以称之为五级指令流水线
  • 流水线技术并不是说让多个指令并行执行,可能还是需要等他其他指令执行完才可以执行,那么这个时候等待就有一个停顿,我们可以让和这个指令后面不相干的指令继续执行,这就是指令重排

在这里插入图片描述

3.2、重排序要求

  • 指令重排序操作不会对存在数据依赖关系的操作进行重排序
  • 比如:a=1;b=a; 这个指令序列,由于第二个操作依赖于第一个操作,所以在编译时和处理器运行时这两个操作不会被重排序
  • 重排序是为了优化性能,但是不管怎么重排序,单线程下程序的执行结果不能被改变
  • 比如:a=1;b=2;c=a+b这三个操作,第一步(a=1)和第二步(b=2)由于不存在数据依赖关系,所以可能会发生重排序,但是c=a+b这个操作是不会被重排序的,因为需要保证最终的结果一定是c=a+b=3
  • 指令重排序 在 单线程模式下是一定会保证最终结果的正确性, 但是在多线程环境下,问题就出来了
  • volatile 修饰的变量,可以禁用指令重排

4、volatile 原理

  • volatile 的底层实现原理是内存屏障,Memory Barrier(Memory Fence)
  • 对 volatile 变量的写指令后会加入写屏障。(保证写屏障之前的写操作, 都能同步到主存中)
  • 对 volatile 变量的读指令前会加入读屏障。(保证读屏障之后的读操作, 都能读到主存的数据)

4.1、volatile是如何保证可见性

  • 写屏障(sfence)保证在该屏障之前的,对共享变量的改动,都同步到主存当中
public void actor2(I_Result r) {
     num = 2;
     ready = true; // ready是被volatile修饰的 ,赋值带写屏障
     // 写屏障.(在ready=true写指令之后加的, 
     //在该屏障之前对共享变量的改动, 都同步到主存中. 包括num)
}
  • 读屏障(lfence)保证在该屏障之后,对共享变量的读取,加载的是主存中最新数据
public void actor1(I_Result r) {
	 // 读屏障
	 //  ready是被volatile修饰的 ,读取值带读屏障
	 if(ready) {	// ready, 读取的就是主存中的新值
	 	r.r1 = num + num; // num, 读取的也是主存中的新值
	 } else {
	 	r.r1 = 1;
	 }
}

在这里插入图片描述

4.2、volatile是如何保证有序性

  • 写屏障会确保指令重排序时,不会将写屏障之前的代码排在写屏障之后
public void actor2(I_Result r) {
 num = 2;
 ready = true; //  ready是被volatile修饰的 , 赋值带写屏障
 // 写屏障
}

  • 读屏障会确保指令重排序时,不会将读屏障之后的代码排在读屏障之前
public void actor1(I_Result r) {
	 // 读屏障
	 //  ready是被volatile修饰的 ,读取值带读屏障
	 if(ready) {
	 	r.r1 = num + num;
	 } else {
	 	r.r1 = 1;
	 }
}

4.3、volatile不能解决指令交错 (不能解决原子性)

  • 写屏障仅仅是保证之后的读能够读到最新的结果,但不能保证其它线程的读, 跑到它前面去
  • 有序性的保证也只是保证了本线程内相关代码不被重排序
  • 下图t2线程, 就先读取了i=0, 此时还是会出现指令交错的现象, 可以使用synchronized来解决原子性

在这里插入图片描述

5、double-checked locking (双重检查锁) 问题

以著名的double-checked locking(双重检查锁) 单例模式为例,这是volatile最常使用的地方

// 最开始的单例模式是这样的
public final class Singleton {
    private Singleton() { }
    private static Singleton INSTANCE = null;
    public static Singleton getInstance() {
	    /*
	      多线程同时调用getInstance(), 如果不加synchronized锁, 此时两个线程同时
	      判断INSTANCE为空, 此时都会new Singleton(), 此时就破坏单例了.所以要加锁,
	      防止多线程操作共享资源,造成的安全问题
	     */
	    synchronized(Singleton.class) {
	    	if (INSTANCE == null) { // t1
	    		INSTANCE = new Singleton();
	        }
	    }
        return INSTANCE;
    }
}


/*
	首先上面代码的效率是有问题的, 因为当我们创建了一个单例对象后, 又来一个线程获取到锁了,还是会加锁, 
	严重影响性能,再次判断INSTANCE==null吗, 此时肯定不为null, 然后就返回刚才创建的INSTANCE;
	这样导致了很多不必要的判断; 

	所以要双重检查, 在第一次线程调用getInstance(), 直接在synchronized外,判断instance对象是否存在了,
	如果不存在, 才会去获取锁,然后创建单例对象,并返回; 第二个线程调用getInstance(), 会进行
	if(instance==null)的判断, 如果已经有单例对象, 此时就不会再去同步块中获取锁了. 提高效率
*/
public final class Singleton {
    private Singleton() { }
    private static Singleton INSTANCE = null;
    public static Singleton getInstance() {
        if(INSTANCE == null) { // t2
            // 首次访问会同步,而之后的使用没有 synchronized
            synchronized(Singleton.class) {
                if (INSTANCE == null) { // t1
                    INSTANCE = new Singleton();
                }
            }
        }
        return INSTANCE;
    }
}
//但是上面的if(INSTANCE == null)判断代码没有在同步代码块synchronized中,
// 不能享有synchronized保证的原子性、可见性、以及有序性。所以可能会导致 指令重排

注意: 但在多线程环境下,上面的代码是有问题的,getInstance 方法对应的字节码为

0: getstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
3: ifnonnull 37 // 判断是否为空
// ldc是获得类对象
6: ldc #3 // class cn/itcast/n5/Singleton
// 复制操作数栈栈顶的值放入栈顶, 将类对象的引用地址复制了一份
8: dup
// 操作数栈栈顶的值弹出,即将对象的引用地址存到局部变量表中
// 将类对象的引用地址存储了一份,是为了将来解锁用
9: astore_0
10: monitorenter
11: getstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
14: ifnonnull 27
// 新建一个实例
17: new #3 // class cn/itcast/n5/Singleton
// 复制了一个实例的引用
20: dup
// 通过这个复制的引用调用它的构造方法
21: invokespecial #4 // Method "<init>":()V
// 最开始的这个引用用来进行赋值操作
24: putstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
27: aload_0
28: monitorexit
29: goto 37
32: astore_1
33: aload_0
34: monitorexit
35: aload_1
36: athrow
37: getstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
40: areturn
  • 17 表示创建对象,将对象引用入栈 // new Singleton
  • 20 表示复制一份对象引用 // 复制了引用地址, 解锁使用
  • 21 表示利用一个对象引用,调用构造方法 // 根据复制的引用地址调用构造方法
  • 24 表示利用一个对象引用,赋值给 static INSTANCE

可能jvm 会优化为:先执行 24(赋值),再执行 21(构造方法)

  • t1 还未完全将构造方法执行完毕,如果在构造方法中要执行很多初始化操作,那么 t2 拿到的是将是一个未初始化完毕的单例
  • 对 INSTANCE 使用 volatile 修饰即可,可以禁用指令重排

6、happens-before (对共享变量的写操作,对其它线程的读操作可见)

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

下面说的变量都是指 成员变量或静态成员变量

方式一 :

  • 线程解锁 m 之前对变量的写,对于接下来对 m 加锁的其它线程对该变量的读可见
  • synchronized锁, 保证了可见性
	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();

// 10

方式二 :

  • 线程对 volatile 变量的写,对接下来其它线程对该变量的读可见
  • 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()等待它结束)
  • 主线程获取的x值, 是线程执行完对x的写操作之后的值
  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); // 10, 打断了, 读取的也是打断前修改的值
                  break;
              }
          }
      },"t2");
      t2.start(); 
      
      new Thread(()->{
          sleep(1);
          x = 10;
          t2.interrupt();
      },"t1").start();
      
      while(!t2.isInterrupted()) {
          Thread.yield();
      }
      System.out.println(x);	// 10
  }

方式六 :

  • 对变量默认值(0,false,null)的写,对其它线程对该变量的 读可见 (最基本)
  • 具有传递性,如果 x hb-> y 并且 y hb-> z 那么有 x hb-> z ,配合 volatile 的防指令重排,有下面的例子
  • 因为x加了volatile, 所以在volatile static int x 代码的上面添加了读屏障, 保证读到的x和y的变化是可见的(包括y, 只要是读屏障下面都OK); 通过传递性, t2线程对x,y的写操作, 都是可见的

在这里插入图片描述

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。

文章由极客之音整理,本文链接:https://www.bmabk.com/index.php/post/148632.html

(0)
飞熊的头像飞熊bm

相关推荐

发表回复

登录后才能评论
极客之音——专业性很强的中文编程技术网站,欢迎收藏到浏览器,订阅我们!