JUC学习 第五章 共享模型之内存

本章内容

上一章讲解的 Monitor 主要关注的是访问共享变量时,保证临界区代码的原子性
这一章我们进一步深入学习共享变量在多线程间的【可见性】问题与多条指令执行时的【有序性】问题

5.1 Java 内存模型

JMM Java Memory Model ,它定义了主存、工作内存抽象概念,底层对应着 CPU 寄存器、缓存、硬件内存、CPU 指令优化等。
JMM 体现在以下几个方面
  • 原子性 - 保证指令不会受到线程上下文切换的影响
  • 可见性 - 保证指令不会受 cpu 缓存的影响
  • 有序性 - 保证指令不会受 cpu 指令并行优化的影响

5.2 可见性

退不出的循环
先来看一个现象, main 线程对 run 变量的修改对于 t 线程不可见,导致了 t 线程无法停止:

可以看到线程在flag被修改为false后依旧在运行。

为什么呢,因为Java内存被划为了主存和工作内存。

1.在初始状态下,线程t将flag从主存读取到了工作内存里

 2.因为线程t要频繁读取flag这个变量,所以JIT为了提高效率,就会把flag的值缓存到工作内存里,以减少对主存的读取,提高效率

3.而一秒后,主存的flag被修改为了false。但是工作内存里的flag依旧是true。所以这个修改并不生效。

解决方法

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

t线程也被停止了。

虽然有所损失效率,但是保证了共享变量在多线程间的可见性。

tips:其实用synchronized也可以实现,在Java内存模型中,synchronized规定,线程在加锁时, 先清空工作内存→在主内存中拷贝最新变量的副本到工作内存 →执行完代码→将更改后的共享变量的值刷新到主内存中→释放互斥锁。

可见性对比原子性

注意 synchronized 语句块既可以保证代码块的原子性,也同时保证代码块内变量的可见性。但缺点是synchronized 是属于重量级操作,性能相对更低
如果在前面示例的死循环中加入 System.out.println() 会发现即使不加 volatile 修饰符,线程 t 也能正确看到对 run 变量的修改了,想一想为什么?

调用print方法,因为内部加了synchronized,所以读取了主存里的最新值后赋值给了工作内存的flag,线程停止运行。

设计模式:两阶段终止模式

        利用volatile重新改写之前写的两阶段终止模式。

public class Test27 {
    public static void main(String[] args) throws InterruptedException {
        TwoPhaseTermination twoPhaseTermination = new TwoPhaseTermination();
        twoPhaseTermination.start();
        Thread.sleep(3000);
        twoPhaseTermination.stop();
    }

}
class TwoPhaseTermination{
    private volatile boolean stop = false;
    private Thread monitor;
    //启动监控线程
    public void start(){
        monitor = new Thread(()->{
            while (true){
                Thread current = Thread.currentThread();
                if (stop){
                    System.out.println("料理后事咯");
                    break;
                }else {
                    try {
                        TimeUnit.SECONDS.sleep(1); //被打断情况1
                        System.out.println("执行监控操作"); //被打断情况2
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                        System.out.println("正在Sleep中被打断");
                        current.interrupt();
                    }
                }
            }
        });
        monitor.start();
    }
    //停止监控线程
    public void stop(){
        stop = true;
    }
}

犹豫模式

加个参数验证下是否启动,根据参数判断是否执行start。

 可以加个interrupt来打断一下,否则线程还会再执行一秒,然后进入判断。

class TwoPhaseTermination{
    private boolean starting = false;
    private volatile boolean stop = false;
    private Thread monitor;
    //启动监控线程
    public void start(){
        synchronized (this){
            if (starting){
                return;
            }
        }
        starting = true;
        monitor = new Thread(()->{
            while (true){
                Thread current = Thread.currentThread();
                if (stop){
                    System.out.println("料理后事咯");
                    break;
                }else {
                    try {
                        TimeUnit.SECONDS.sleep(1); //被打断情况1
                        System.out.println("执行监控操作"); //被打断情况2
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                        System.out.println("正在Sleep中被打断");
                        current.interrupt();
                    }
                }
            }
        });
        monitor.start();
    }
    //停止监控线程
    public void stop(){
        stop = true;
        monitor.interrupt();
    }
}

上面是完善后的代码,主要修改了stop和start里前一段。

有序性

        

 鱼罐头的故事

加工一条鱼需要五十分钟,只能一条鱼一条鱼的顺序加工。

你要去鳞清晰,蒸煮沥水,加汤,杀菌,真空封罐。。。。。

即使最理想的状态,也是十分钟同时做好五件事,因为第一条鱼的真空封罐不会影响第二条鱼的去鳞清洗。。。

指令重排优化

        事实上现代处理器会设计一个时钟周期完成一条执行时间最长的CPU指令。这么做的意义是:指令还可以再划分成一个个更小的阶段,例如每条指令可以分为:取指令,指令译码,执行指令,内存访问,数据写回。这五个阶段。

        指令重排序的目的是为了通过分工分阶段的方式提高运行效率,所有指令冲排序的前提是不能影响结果。

 指令重排序

首先看下以下线程A和线程B的部分代码:

线程A:
content = initContent();    //(1)
isInit = true;                //(2)

线程B
while (isInit) {            //(3)
    content.operation();    //(4)
}

从常规的理解来看,上面的代码是不会出问题的,但是JVM可以对它们在不改变数据依赖关系的情况下进行任意排序以提高程序性能(遵循as-if-serial语义,即不管怎么重排序,单线程程序的执行结果不能被改变),而这里所说的数据依赖性仅针对单个处理器中执行的指令序列和单个线程中执行的操作,不同处理器之间和不同线程之间的数据依赖性不会被编译器和处理器考虑,也即是说对于线程A,代码(1)和代码(2)是不存在数据依赖性的,尽管代码(3)依赖于代码(2)的结果,但是由于代码(2)和代码(3)处于不同的线程之间,所以JVM可以不考虑线程B而对线程A中的代码(1)和代码(2)进行重排序,那么假设线程A中被重排序为如下顺序:

线程A:
isInit = true;                //(2)
content = initContent();    //(1)

对于线程B,则可能在执行代码(4)时,content并没有被初始化,而造成程序错误。那么应该如何保证绝对的代码(2) happens-before 代码(3)呢?没错,仍然可以使用volatile关键字。

volatile关键字除了之前提到的保证变量的内存可见性之外,另外一个重要的作用便是局部阻止重排序的发生,即保证被volatile关键字修饰的变量编译后的顺序与 也即是说如果对isInit使用了volatile关键字修饰,那么在线程A中,就能保证绝对的代码(1) happens-before 代码(2),也便不会出现因为重排序而可能造成的异常。
3. 总结

综上所诉,volatile关键字最主要的作用是:

    保证变量的内存可见性
    局部阻止重排序的发生
————————————————
版权声明:本文为CSDN博主「BarackHusseinObama」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/t894690230/article/details/50588129

对于指令重排,解决方法就是用volatile禁用指令重排

附带:JMM

内存可见性

Java 内存模型规定,对于多个线程共享的变量,存储在主内存当中,每个线程都有自己独立的工作内存,并且线程只能访问自己的工作内存,不可以访问其它线程的工作内存。工作内存中保存了主内存中共享变量的副本,线程要操作这些共享变量,只能通过操作工作内存中的副本来实现,操作完毕之后再同步回到主内存当中,其 JVM 模型大致如下图。

这里写图片描述
JVM 模型规定:1) 线程对共享变量的所有操作必须在自己的内存中进行,不能直接从主内存中读写; 2) 不同线程之间无法直接访问其它线程工作内存中的变量,线程间变量值的传递需要通过主内存来完成。这样的规定可能导致得到后果是:线程对共享变量的修改没有即时更新到主内存,或者线程没能够即时将共享变量的最新值同步到工作内存中,从而使得线程在使用共享变量的值时,该值并不是最新的。这就引出了内存可见性。

内存可见性指:当一个线程修改了某个状态对象后,其它线程能够看到发生的状态变化。比如线程 1 修改了变量 A 的值,线程 2 能立即读取到变量 A 的最新值,否则线程 2 如果读取到的是一个过期的值,也许会带来一些意想不到的后果。那么如果要保证内存可见性,必须得保证以下两点:

  1.     线程修改后的共享变量值能够及时刷新从工作内存中刷新回主内存;
  2.     其它线程能够及时的把共享变量的值从主内存中更新到自己的工作内存中;

为此,Java 提供了一种稍弱的同步机制,即 volatile 变量,用来确保将变量的更新操作通知到其它线程。当把共享变量声明为 volatile 类型后,线程对该变量修改时会将该变量的值立即刷新回主内存,同时会使其它线程中缓存的该变量无效,从而其它线程在读取该值时会从主内中重新读取该值(参考缓存一致性)。因此在读取 volatile 类型的变量时总是会返回最新写入的值。

除了使用 volatile 关键字来保证内存可见性之外,使用 synchronizer 或其它加锁也能保证变量的内存可见性。只是相比而言使用 volatile 关键字开销更小,但是 volatile 并不能保证原子性,大致原理如下:

JAVA内存模型规定工作内存与主内存之间的交互协议,其中包括8种原子操作:

    lock:将主内存中的变量锁定,为一个线程所独占
    unclock:将lock加的锁定解除,此时其它的线程可以有机会访问此变量
    read:将主内存中的变量值读到工作内存当中
    load:将read读取的值保存到工作内存中的变量副本中。
    use:将值传递给线程的代码执行引擎
    assign:将执行引擎处理返回的值重新赋值给变量副本
    store:将变量副本的值存储到主内存中。
    write:将store存储的值写入到主内存的共享变量当中。

其中lock和unlock定义了一个线程访问一次共享内存的界限,而其它操作下线程的工作内存与主内存的交互大致如下图所示。

 

从上图可以看出,read and load 主要是将主内存中数据复制到工作内存中,use and assign 则主要是使用数据,并将改变后的值写入到工作内存,store and write 则是用工作内存数据刷新主存相关内容。但是以上的一系列操作并不是原子的,也既是说在 read and load 之后,主内存中变量的值发生了改变,这时再 use and assign 并不是取的最新的值。所以尽管 volatile 会强制工作内存与主内存的缓存更新,但是却仍然无法保证其原子性。
————————————————
版权声明:本文为CSDN博主「BarackHusseinObama」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/t894690230/article/details/50588129

volatile原理

volatile 的底层实现原理是内存屏障,Memory Barrier(Memory Fence)

  1. 对 volatile 变量的写指令后会加入写屏障
  2. 对 volatile 变量的读指令前会加入读屏障

如何保证可见性

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

    public void actor1(I_Result r) {
     // 读屏障
     //  ready是被volatile修饰的 ,读取值带读屏障
     if(ready) {
     	r.r1 = num + num;
     } else {
     	r.r1 = 1;
     }
    }

1594698374315

如何保证有序性

  1. 写屏障会确保指令重排序时,不会将写屏障之前的代码排在写屏障之后

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

    public void actor1(I_Result r) {
     // 读屏障
     //  ready是被volatile修饰的 ,读取值带读屏障
     if(ready) {
     	r.r1 = num + num;
     } else {
     	r.r1 = 1;
     }
    }

    1594698559052

还是那句话,不能解决指令交错:

  1. 写屏障仅仅是保证之后的读能够读到最新的结果,但不能保证其它线程的读跑到它前面去
  2. 而有序性的保证也只是保证了本线程内相关代码不被重排序

1594698671628

        

double-checked locking 问题

以著名的 double-checked locking 单例模式为例,这是volatile最常使用的地方。

//最开始的单例模式是这样的
    public final class Singleton {
        private Singleton() { }
        private static Singleton INSTANCE = null;
        public static Singleton getInstance() {
        // 首次访问会同步,而之后的使用不用进入synchronized
        synchronized(Singleton.class) {
        	if (INSTANCE == null) { // t1
        		INSTANCE = new Singleton();
            }
        }
            return INSTANCE;
        }
    }
//但是上面的代码块的效率是有问题的,因为即使已经产生了单实例之后,
//之后调用了getInstance()方法之后还是会加锁,这会严重影响性能!
//因此就有了模式如下double-checked lockin:
    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;
        }
    }

以上的实现特点是:

 

  1.         懒惰实例化
  2.         首次使用getInstance才使用synchronized加锁,后续使用其实没必要加锁了
  3.         有隐含的,但很关键的一点。第一个if使用了Instance变量,是在同步块之外。

其中

  1. 17 表示创建对象,将对象引用入栈 // new Singleton
  2. 20 表示复制一份对象引用 // 复制了引用地址
  3. 21 表示利用一个对象引用,调用构造方法 // 根据复制的引用地址调用构造方法
  4. 24 表示利用一个对象引用,赋值给 static INSTANCE

也许 jvm 会优化为:先执行 24,再执行 21。如果两个线程 t1,t2 按如下时间序列执行:

1594701748458

关键在于 0: getstatic 这行代码在 monitor 控制之外,它就像之前举例中不守规则的人,可以越过 monitor 读取 INSTANCE 变量的值

这时 t1 还未完全将构造方法执行完毕,如果在构造方法中要执行很多初始化操作,那么 t2 拿到的是将是一个未初 始化完毕的单例

对 INSTANCE 使用 volatile 修饰即可,可以禁用指令重排,但要注意在 JDK 5 以上的版本的 volatile 才会真正有效

double-checked locking 解决

加volatile就行了

public final class Singleton {
        private Singleton() { }
        private static volatile Singleton INSTANCE = null;
        public static Singleton getInstance() {
            // 实例没创建,才会进入内部的 synchronized代码块
            if (INSTANCE == null) {
                synchronized (Singleton.class) { // t2
                    // 也许有其它线程已经创建实例,所以再判断一次
                    if (INSTANCE == null) { // t1
                        INSTANCE = new Singleton();
                    }
                }
            }
            return INSTANCE;
        }
    }

加了volatile可以阻止指令的重排序。

字节码上看不出来 volatile 指令的效果

// -------------------------------------> 加入对 INSTANCE 变量的读屏障
0: getstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
3: ifnonnull 37
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;
// -------------------------------------> 加入对 INSTANCE 变量的写屏障
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

如上面的注释内容所示,读写 volatile 变量操作(即getstatic操作和putstatic操作)时会加入内存屏障(Memory Barrier(Memory Fence)),保证下面两点:

  1. 可见性
    1. 写屏障(sfence)保证在该屏障之前的 t1 对共享变量的改动,都同步到主存当中
    2. 而读屏障(lfence)保证在该屏障之后 t2 对共享变量的读取,加载的是主存中最新数据
  2. 有序性
    1. 写屏障会确保指令重排序时,不会将写屏障之前的代码排在写屏障之后
    2. 读屏障会确保指令重排序时,不会将读屏障之后的代码排在读屏障之前
  3. 更底层是读写变量时使用 lock 指令来多核 CPU 之间的可见性与有序性

1594703228878

happens-before规则

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

线程安全单例习题

单例模式有很多实现方法,饿汉、懒汉、静态内部类、枚举类,试分析每种实现下获取单例对象(即调用 getInstance)时的线程安全,并思考注释中的问题

饿汉式:类加载就会导致该单实例对象被创建

懒汉式:类加载不会导致该单实例对象被创建,而是首次使用该对象时才会创建

实现1:

// 问题1:为什么加 final,防止子类继承后更改
// 问题2:如果实现了序列化接口, 还要做什么来防止反序列化破坏单例。如果进行反序列化的时候会生成新的对象,这样跟单例模式生成的对象是不同的。要解决直接加上readResolve()方法就行了,如下所示
public final class Singleton implements Serializable {
    // 问题3:为什么设置为私有? 放弃其它类中使用new生成新的实例。是否能防止反射创建新的实例?不能。因为反射可以得到你的构造器对象来暴力反射创建对象
    private Singleton() {}
    // 问题4:这样初始化是否能保证单例对象创建时的线程安全?没有,这是类变量,是jvm在类加载阶段就进行了初始化,jvm保证了此操作的线程安全性
    private static final Singleton INSTANCE = new Singleton();
    // 问题5:为什么提供静态方法而不是直接将 INSTANCE 设置为 public, 说出你知道的理由。
    //1.提供更好的封装性;2.提供范型的支持 3.对这个单例对象可以进行更多控制
    public static Singleton getInstance() {
        return INSTANCE;
    }
    public Object readResolve() {
        return INSTANCE;
    }
}

实现2:

        

// 问题1:枚举单例是如何限制实例个数的?枚举里定义了几个,那就只会有几个静态成员变量。
// 问题2:枚举单例在创建时是否有并发问题?没有,它也是个静态成员变量,他的线程安全性也是在类加载的时候完成的。
// 问题3:枚举单例能否被反射破坏单例?不能
// 问题4:枚举单例能否被反序列化破坏单例?枚举类默认都是实现了序列化接口,在设计时考虑到了反序列化破坏单例的情况,所以他可以避免。
// 问题5:枚举单例属于懒汉式还是饿汉式?饿汉式,因为它是在类加载的时候创建了静态对象。
// 问题6:枚举单例如果希望加入一些单例创建时的初始化逻辑该如何做?枚举也是可以写构造方法的。
enum Singleton { 
INSTANCE; 
}

实现3:

        

public final class Singleton {
    private Singleton() { }
    private static Singleton INSTANCE = null;
    // 分析这里的线程安全, 并说明有什么缺点
    //首先静态方法加了synchronized,这点就可以保证线程安全。相当于把锁加载了类对象上。
//缺点就是锁的范围略大了,因为只需要第一次检测需要用锁,后续都不需要用了。
    public static synchronized Singleton getInstance() {
        if( INSTANCE != null ){
            return INSTANCE;
        }
        INSTANCE = new Singleton();
        return INSTANCE;
    }
}

实现4:

        

public final class Singleton {
    private Singleton() { }
    // 问题1:解释为什么要加 volatile ?
    //为了保证synchronized代码块内的有序性,防止指令重排
    private static volatile Singleton INSTANCE = null;
    // 问题2:对比实现3, 说出这样做的意义
    //可以防治重复调用synchronized块的代码,提高效率。
    public static Singleton getInstance() {
        if (INSTANCE != null) {
            return INSTANCE;
        }
        synchronized (Singleton.class) {
// 问题3:为什么还要在这里加为空判断, 之前不是判断过了吗
//为了防止在多线程首次调用的时候,因为外部的if没有被synchronized保护,导致读取INSTANCE实例对象是否为空出现脏读的问题。
            if (INSTANCE != null) { // t2 
                return INSTANCE;
            }
            INSTANCE = new Singleton();
            return INSTANCE;
        }
    }
}

实现5:

        

public final class Singleton {
    private Singleton() { }
    // 问题1:属于懒汉式还是饿汉式
    //懒汉式,因为类加载本身就是懒汉式的。类只有在你第一次用到是才会加载和初始化。
    private static class LazyHolder {
        static final Singleton INSTANCE = new Singleton();
    }
    // 问题2:在创建时是否有并发问题
    //不会有,因为JVM会保证线程安全

    public static Singleton getInstance() {
        return LazyHolder.INSTANCE;
    }
}

本章小结

本章重点讲解了 JMM 中的

  1. 可见性 - 由 JVM 缓存优化引起
  2. 有序性 - 由 JVM 指令重排序优化引起
  3. happens-before 规则
  4. 原理方面
    1. volatile
  5. 模式方面
    1. 两阶段终止模式的 volatile 改进
    2. 同步模式之 balking
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值