美文网首页
多线程与并发(五):volatile

多线程与并发(五):volatile

作者: lilykeke | 来源:发表于2021-09-08 09:16 被阅读0次

Monitor 主要关注的是访问共享变量时,保证临界区代码的原子性。

1. java 内存模型

JMM即Java Memory Model ,它定义了主存、 工作内存的抽象概念,底层对应着CPU寄存器,缓存,硬件内存,CPU指令集的优化等。

JMM体现在以下几个方面:

  • 原子性-保证指令不会受到线程上下文切换的影响
  • 可见性-保证指令不会受到cpu缓存的影响
  • 有序性-保证指令不会受到cpu指令并行优化的影响

2. 可见性

退不出的循环

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

public class Test11 {

    static boolean run = true;

    public static void main(String[] args) {

        Thread t = new Thread(()->{
            while (run){
                ...
            }
        });

        t.start();

        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        run = false; //线程t不会如预想的停下来
    }
}

为什么呢?

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


    可见性-2.jpg
  1. 因为线程t 要频繁从主内存读取run的值,JIT编译器会将run的值缓存至自己的工作内存中的高速缓存中,减少对主存中的run访问,提高效率。
可见性-3.jpg
  1. 一秒之后,main线程修改了run的值,并同步到主内存,而t 是从自己的工作内存中的高速缓存中读取的这个变量的值,结果永远是旧值。
可见性-4.jpg

解决方法

给变量添加volatile关键字

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

可见性 VS 原子性

可见性保证在多个线程之间,一个线程对volatile变量的修改对另一个线程可见,不能保证原子性,仅用在一个写线程,多个读线程的情况

比较之前我们举的例子:两个线程一个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变量的修改,想一想为什么?

查阅源码,找原因

终止模式--两阶段终止模式

在一个线程t1中如何优雅终止线程t2? 优雅 指的是给t2线程一个料理后事的机会。

1. 错误思路
  • 使用线程对象的stop()方法停止线程
    -stop 方法会真正的杀死线程,如果这时线程锁住了共享资源,那么当它被杀死之后就再也没有机会释放锁。
  • 使用System.exit(int)方法停止线程
    • 目的仅是停止线程,但这样的做法会让整个程序都停止

解决方法:添加一个volatile变量来控制

public class Test13 {
    public static void main(String[] args) {
        TwoPhaseTermination twoPhaseTermination = new TwoPhaseTermination();

        twoPhaseTermination.start();

        try {
            Thread.sleep(2000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        twoPhaseTermination.stop();
    }
}

class TwoPhaseTermination{
    private Thread monitorThread;

    private volatile boolean stop;

    //启动监控线程
    public void start(){
        monitorThread = new Thread(()->{
            while(true){
                Thread current = Thread.currentThread();
                if (stop){
                    break;
                }

            }

        });

        monitorThread.start();
    }

    //停止监控线程
    public void stop(){
        stop = true;
    }
}

同步模式--Balking

Balking (犹豫)模式用在一个线程发现另一个线程或本线程已经做了某一件相同的事,那么本线程就无需再做了,直接结束返回。

例如:

public class Thread14 {
    public static void main(String[] args) {
        MonitorService monitorService = new MonitorService();
        monitorService.start();
        monitorService.start();
    }
}

class MonitorService{
    //用来表示是否已经有线程已经执行启动了
    private volatile boolean starting;

    public void start(){
        System.out.println("尝试启动监控线程");
        synchronized (this){
            if (starting){
                return;
            }

            starting = true;
        }

        System.out.println("真正启动监控线程");
    }
}

当前端页面多次惦记按钮调用start时。

3. 有序性

JVM会在不影响正确性的前提下,可以调整语句的执行顺序。
即 指令重排

多线程下指令重排会影响正确性。为什么要有指令重排这项优化?从CPU执行指令的原理来理解一下吧

诡异的结果
    int num = 0;
    boolean ready = false;

    //线程一执行此方法
    public void actor1(I_Result r) {
        if (ready) {
            r.r1 = num + num;
        } else {
            r.r1 = 1;
        }
    }

    //线程二执行此方法
    public void actor2(I_Result r) {
        num = 2;
        ready = true;
    }

I_Result 是一个对象,有一个属性r1 用来保存结果,问,可能的结果有几种?
有同学这么分析:
情况一:线程一先执行,这时 ready = false, 所以进入else分支的结果为1
情况二:线程二先执行num = 2,但没来得及执行ready = true,线程一执行,还是进入else分支,结果为1
情况三:线程二执行到ready = true,线程一执行,这回进入if分支,结果为4 (因为num已经执行过了)

  • 但我告诉你,结果有可能是0!
    这种情况下是:线程2 执行ready = true,切换到线程1,进入if分支,相加为0,再切回线程2执行num = 2.

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

4. volatile 原理

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

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

4.1 保证可见性

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

2.如何保证有序性

  • 写屏障会确保指令重排序时,不会将写屏障之前的代码排在写屏障之后
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;
   }
}

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

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

3. double-checked locking 原理

以著名的double-checked locking 单例模式为例

final class Singleton{
    private Singleton(){}
    private static Singleton INSTANCE;
    
    public static Singleton getInstance(){
        if (INSTANCE == null) {
            synchronized(Singleton.class){
                if(INSTANCE == null){
                    INSTANCE = new Singleton();
                }
             }
        }
        return INSTANCE;
    }
}

以上的实现特点是:

  • 懒惰实例化(用到才创建)
  • 首次使用getInstance()才使用synchronized 加锁,后续使用时无需加锁
  • 有隐含的,但很关键的一点是:第一个if 使用了INSTANCE变量,是在同步块之后
    但在多线程环境下,上面的代码是有问题的,getInstance 对应的字节码为
         0: getstatic     #2                  // Field INSTANCE:Lcom/lily/threadpool/Singleton;
         3: ifnonnull     37
         6: ldc           #3                  // class com/lily/threadpool/Singleton  获得类对象
         8: dup
         9: astore_0
        10: monitorenter
        11: getstatic     #2                  // Field INSTANCE:Lcom/lily/threadpool/Singleton;
        14: ifnonnull     27
        17: new           #3                  // class com/lily/threadpool/Singleton
        20: dup
        21: invokespecial #4                  // Method "<init>":()V
        24: putstatic     #2                  // Field INSTANCE:Lcom/lily/threadpool/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:Lcom/lily/threadpool/Singleton;
        40: areturn

其中,注意17 ~ 24行内容

  • 17 表示创建对象,将对象引用入栈 //new Singleton
  • 20 表示复制一份对象引用, //地址
  • 21 表示利用一个对象引用,调用构造方法
  • 24 表示利用一个对象引用,赋值给static INSTANCE
    也许JVM会优化为:先执行24,再执行21 。如果两个线程 t1 ,t2 按如下时间序列执行


    dcl.jpg

关键在于0:getstatic 这行代码在monitor 控制之外,它就像之前举例中不守规则的人,可以越过monitor 读取INSTANCE变量的值。
这时t1还未完全将构造方法执行完毕,如果构造方法中要执行很多初始化操作,那么t2拿到的将是一个未完成初始化的单例。

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

4. double-checked locking解决

synchronized 不能阻止重排序,volatile 可以阻止重排序

public class Singleton {
    private Singleton(){}
    private static volatile Singleton INSTANCE; //添加volatile 关键字

    public static synchronized Singleton getInstance(){
        if (INSTANCE == null) {
            synchronized(Singleton.class){
                if(INSTANCE == null){
                    INSTANCE = new Singleton();
                }
            }
        }
        return INSTANCE;
    }
}
-----------------------------------------------------------------------------------加入对INSTANCE变量的读屏障
         0: getstatic     #2                  // Field INSTANCE:Lcom/lily/threadpool/Singleton;
         3: ifnonnull     37
         6: ldc           #3                  // class com/lily/threadpool/Singleton
         8: dup
         9: astore_0
        10: monitorenter-----------------------------------------------------保证原子性、可见性
        11: getstatic     #2                  // Field INSTANCE:Lcom/lily/threadpool/Singleton;
        14: ifnonnull     27
        17: new           #3                  // class com/lily/threadpool/Singleton
        20: dup
        21: invokespecial #4                  // Method "<init>":()V
        24: putstatic     #2                  // Field INSTANCE:Lcom/lily/threadpool/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:Lcom/lily/threadpool/Singleton;
        40: areturn

如上面的注释内容所示,读写volatile 变量时会加入内存屏障,保证下面两点:

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

happens-before

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

  • 线程解锁m之前对变量的写,对于接下来对m加锁的其他线程对该变量的读可见
public class Test4 {

    static int x;
    static Object m = new Object();

    public static void main(String[] args) {
        new Thread(() -> {
            synchronized (m) {
                x = 10;
            }
        }, "t1").start();
        new Thread(() -> {
            synchronized (m) {
                System.out.println(x);
            }
        }, "t2").start();
    }
}
  • 线程对volatile 变量的写,对接下来其他线程对该变量的读可见
public class Test5 {
    volatile static int x;
    public static void main(String[] args) {
        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()等)
static int x;

Thread t1 = new Thread(() -> {
    x = 10;
}, "t2");
t1.start();
t1.join();
System.out.println(x);
  • 线程t1打断t2前对变量的写,对于其他线程得知t2被打断后对变量的读可见(通过t2.interrupted 或 t2.isInterrupted)
public class Test5 {
    static int x;

    public static void main(String[] args) {
        Thread t2 = new Thread(() -> {
            while (true) {
                if (Thread.currentThread().isInterrupted()) {
                    System.out.println(x);
                    break;
                }
            }
        }, "t2");
        t2.start();

        new Thread(() -> {
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            x = 10;
            t2.interrupt();
        }, "t1").start();

        while (!t2.isInterrupted()) {
            Thread.yield();
        }
        System.out.println(x);
    }
}
  • 对变量默认值(0, false, null) 的写,对其他线程对该变量的读可见
  • 具有传递性,如果x hb->y,并且y hb->z ,那么有x hb->z,配合volatile 的防指令重排,有下面的例子
public class Thread6 {
    volatile static int x;
    static int y;
    public static void main(String[] args) {
        new Thread(()->{
            y = 10;
            x = 20;
        },"t1").start();

        new Thread(()->{
            //x = 20 对t2可见,同时 y = 10也对t2可见
            System.out.println(x);
        },"t2").start();
    }
}

变量都是指成员变量或静态成员变量

习题

balking 模式习题

希望doInit()方法仅被调用一次,下面的实现是否有问题,为什么?

public class Test7 {
    
    volatile boolean initialized = false;
    
    void init(){
        if (initialized){
            return;
        }
        
        doInit();
        initialized = true;
    }
    
    private void doInit(){
        
    }
}

解决方法:用同步代码块来解决

线程安全单例习题

单例模式有很多实现方法,饿汉式、懒汉式、静态内部类、枚举类,试分析每种实现下获取单例对象(即调用getInstance)时的线程安全,并思考注释中的问题
- 饿汉式: 类加载就会导致该单例对象被创建
- 懒汉式: 类加载不会导致该单例对象被创建,而是首次使用该对象时才会被创建

实现一:

//问题一:为什么加final  (不要子类)
//问题二:如果实现了序列化接口,还要做什么来防止反序列化破坏
public final class Singleton implements Serializable {
    //问题三:为什么设置为私有(不让别的类创建,但是设置为私有不能防止反射来创建实例)
    private Singleton(){}
    //问题四:这样初始化是否能保证单例对象创建时的线程安全(静态成员变量类加载阶段完成)
    private static final Singleton INSTANCE = new Singleton(); //添加volatile 关键字

    //问题五:为什么提供静态方法,而不是直接将INSTANCE 设置为public 
    //(更好的封装性;提供一些泛型的支持等)
    public static  Singleton getInstance(){
        return INSTANCE;
    }
    //解决问题二
    public Object readResolve(){
        return INSTANCE; 
    }
}

实现二:

//问题一:枚举单例是如何限制实例个数的
//问题二:枚举单例在创建时是否有并发问题
//问题三:枚举单例能否被反射破坏单例
//问题四:枚举单例能否被反序列化破坏单例(枚举类默认都实现了序列化)
//问题五: 枚举单例属于懒汉式还是饿汉式(饿汉式)
//问题六:枚举单例如果希望加入一些单例创建时的初始化逻辑该如何做(写一个构造方法)
enum Singleton1 {
    INSTANCE;
}

实现三:

public final class Singleton2 {
    private Singleton2(){};
    
    private static Singleton2 INSTANCE = null;
    //分析这里的线程安全问题,并说明有什么缺点?
    public static synchronized Singleton2 getInstance(){
        
        if (INSTANCE != null){
            return INSTANCE;
        }
        
        INSTANCE = new Singleton2();
        return INSTANCE;
    }
}

实现四:

public class Singleton3 {
    private Singleton3() {
    };
    //问题一:为什么要加volatile
    private static volatile Singleton3 INSTANCE = null;
    //问题二:对比实现三,说出这样做的意义
    public static Singleton3 getInstance() {
        if (INSTANCE != null) {
            return INSTANCE;
        }
        synchronized (Singleton3.class) {
            //问题三; 为什么还要在这里加入空判断,之前不是判断过了么
            if (INSTANCE != null) {
                return INSTANCE;
            }
            INSTANCE = new Singleton3();
            return INSTANCE;
        }
    }
}

实现五:

public class Singleton4 {
    private Singleton4(){}
    //问题一:属于懒汉式还是饿汉式
    private static class LazyHolder {
        static final Singleton4 INSTANCE = new Singleton4();
    }
    //问题二:是否有并发问题
    public static Singleton4 getInstance(){
        return LazyHolder.INSTANCE;
    }
}

相关文章

网友评论

      本文标题:多线程与并发(五):volatile

      本文链接:https://www.haomeiwen.com/subject/bzcxpktx.html