Java多线程之volatile关键字

x33g5p2x  于2021-10-04 转载在 Java  
字(5.7k)|赞(0)|评价(0)|浏览(282)

Java多线程之volatile关键字

多线程内存模型

我们来了解下多线程内存模型是怎么工作的

通过图片我们可以看出来 每一个线程都有一个自己的工作空间 而他们是怎么工作的呢?

1.java中所有的变量都是存在主内存里的。

2.各自的线程在工作的时候会自己拿到一块工作内存。里面保存了该线程用到的变量的副本。

3.线程对变量的操作,都是操作自己工作内存中,副本变量,不能操作主内存。

4.线程执行完后,会将工作内存中的数据同步回主内存,来完成主内存中变量的更新的。

通过以上4点,我们发现会出现变量可见性的问题

举个例子吧:

public class test {
    public static  boolean pd=false;
    public static void main(String[] args) {
        new Thread(new Runnable() {
            @Override
            public void run() {
                System.out.println("检测线程A是否满足需求");
                while (!pd){ }
                System.out.println("满足需求");

            }
        },"B").start();
        new Thread(new Runnable() {
            @Override
            public void run() {
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println("我是线程"+Thread.currentThread().getName()+"正在执行中");
                //满足需求改变状态
                pd=true;
            }
        },"A").start();
    }
}

检测线程A是否满足需求
我是线程A正在执行中

上面这个例子你会发现 一直卡在while循环了 我明明已经将pd改为true了

你试试加 volatile关键字在 pd变量上 会有奇迹发生

就好比老板安排 AB两员工,去找快递公司谈合同 公司只需要一家快递公司

假设A员工顺利和顺丰快递谈好了合作,然后回到公司了,

按照现实来说 B员工应该停止继续去找快递公司谈合作 因为A员工已经谈好了

但是在程序中 B员工并不知道 A员工已经谈好了 所以B员工继续找快递公司谈合作

随后和邮政快递谈好了合作,然后回到公司, 老板看到AB员工都带着一份快递公司的协议回来了瞬间炸毛.

为了解决上面的问题 我们有好多种方式 比如加上同步锁 synchronized 一次只让一个人去谈合作 但是这样会有损性能

我们来看看java内存模型在设计所围绕的三个问题
可见性:一个线程对共享变量做了修改之后,其他的线程立即能够看到(感知到)该变量这种修改(变化)。

Java内存模型是通过将在工作内存中的变量修改后的值立即同步到主内存,在读取变量前先从主内存中获取最新值到工作内存中,这种依赖主内存的方式来实现可见性的。

有序性:在本线程内观察,操作都是有序的;如果在一个线程中观察另外一个线程,所有的操作都是无序的。

java内存模型所保证的是,同线程内,所有的操作都是由上到下的,但是多个线程并行的情况下,则不能保证其操作的有序性。

原子性:一个操作不能被打断,要么全部执行完毕,要么不执行。

估计你是不理解原子性 简单来说就是 只有当变量的值和自身引用 无关时 对该变量的操作才是原子级别的,如n = m + 1,这个的m+1 和n的引用无关 只是m+1的值把n原来的给覆盖了 ,a=2 这是也是原子性 因为a的引用和2无关 只是2将a原来的值给覆盖了

而a++就是 a=a+1; 这就不是原子性 他可以被拆分为

1.取出a的值

2.将取出来的a值进行+1

3.将计算好的值赋值给a

假象如果有两个线程t1,t2,对public static int num=0;

在进行num这样的操作。t1在第一步取值做完之后还没来得及加1操作就被线程调度器中断了 于是t2开始执行num 这就就会出现问题 因为t1没有进行+1

也就是还是原来的值0 那么t2就是 0+1 结果num=1

那么如何保证原子性呢 使用synchronized (num.getClass()){num++}来让它变成一个原子操作,(不可分割)

注意synchronized只能锁对象比如包装类型 字符串 obj…

还可以使用原子数据结构。AtomicInteger、AtomicLong、AtomicReference等。

以上是java内存模型中,单线程针对这三种问题作出的最基本的控制,但是并发编程的场景中, 多线程的出现会导致这三个问题频频发生。

那么聪明的你一定会问了,我们应该如何去控制呢?

这里就引出了我们接下来要讲的volatile关键字

可见性:

volatile关键字:通过volatile关键字修饰的变量,该变量数据在线程之间共享

volatile的特殊规则保证了被修饰变量的值修改后新值立刻同步到主内存,每次获取volatile变量前立即从主内存中获取最新值,因此volatile保证了多线程之间的操作变量的可见性,而没有被volatile修饰的普通变量则不能保证这一点。

但是volatile只是对关键字进行了修饰,保证了其可见性,而对于线程的有序性和变量的原子性,volatile根本没有什么卵用,也就是什么意思呢?

private static volatile int volatileCount = 0;
    private static void volatileCount() {
   System.out.println("volatile count: " + ++volatileCount);
    }

    public static void main(String[] args) {
        int a=50;
        for (int i = 0; i < a; i++) {
            new Thread(new Runnable() {
                @Override
                public void run() {
                    volatileCount();
                }
            }).start();
        }
    }

结果部分内容

volatile count: 40
volatile count: 41
volatile count: 42
volatile count: 43
volatile count: 44
volatile count: 45
volatile count: 46
volatile count: 49
volatile count: 48
volatile count: 48
volatile count: 47

原因是:

假设某a=2,线程A与B从主存中同时读取到a=2到自己的工作内存。线程A计算得出了a+1=3,但是还没来得及赋值给a;此时线程B计算出了a+1=3并且已经将3赋值给了a,并且同步刷入主内存中。此时线程A通过主内存知道了a的值已经发生改变,但是线程A已经完成了a+1的计算,于是还是将3赋值给了a,并且同步刷入主内存中。 也就造成了数据混乱

解决办法保证volatileCount是原子性 或者上锁synchronized

只有当变量的值和自身上一个值无关时对该变量的操作才是原子级别的,

如n= m,这个就是原子级别的。执行这条语句时 m无法在拆分了是不可能被中断的 ,也就是其他线程没有机会加进来了.

volatile 还能限制指令重排

什么是指令重排?

指令重排是指JVM在编译Java代码的时候,或者CPU在执行JVM字节码的时候,对现有的指令顺序进行重新排序。

指令重排的目的是为了在不改变程序执行结果的前提下,优化程序的运行效率。需要注意的是,这里所说的不改变执行结果,指的是不改变单线程下的程序执行结果。

然而,指令重排是一把双刃剑,虽然优化了程序的执行效率,但是在某些情况下,会影响到多线程的执行结果。我们来看看下面的例子

public class Thread_a extends Thread {
 public static   boolean pd = false;
    public static   String str = null;
    public static void main(String[] args) {
        new Thread(new Runnable() {
            @Override
            public void run() {
                while( !pd){
                    try {
                        Thread.sleep(100);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
                System.out.println(str);

            }
        },"B").start();
        new Thread(new Runnable() {
            @Override
            public void run() {
                str = "值以改";
                //模拟线程调度器
                try {
                    Thread.sleep(200);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                pd = true;
            }
        },"A").start();
    }
}

结果

值以改

但是如果我把 str = “值以改”; 和 pd = true;调换下位置呢

@Override
            public void run() {
                pd = true;
                try {
                    Thread.sleep(200);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                str = "值以改";

            }

结果就是

null

因为很可能str = “值以改”; 还没有完成赋值的操作,线程调度器就切换到线程B了

线程B直接跳出了循环结果自然会出现null。

而给变量加入volatile后重排序规则

(1)如果第一个操作为volatile读的时候,不管第二个操作是啥,都不能重排序。这条规则确保了volatile读之后的操作不会被编译器重排序到volatile读之前。

(2)如果第二个操作为volatile写的时候,不管第一个操作是啥,都不能重排序。这条规则确保了volatile写的操作不会被编译器重排序带volatile写之后。

(3)如果第一个操作为volatile写,第二个操作为volatile读的时候,不能重排序。

那么针对这一些排序规则,volatile底层到底是怎么实现的呢?这就要靠我们的内存屏障来进行保证了。

我们先来看看什么是内存屏障

(1)在每一个volatile写操作前面插入一个StoreStore屏障。这确保了在进行volatile写之前,前面的所有普通的写操作都已经刷新到了内存。

(2)在每一个volatile写操作后面插入一个StoreLoad屏障。这样可以避免volatile写操作与后面可能存在的读写操作发生重排序。

(3)在每一个volatile读操作后面插入一个LoadLoad屏障。这样可以避免volatile读操作和后面普通的读操作进行重排序。

(4)在每一个volatile读操作后面插入一个LoadStore屏障。这样可以避免volatile读操作和后面普通的写操作进行重排序。

这样就有效的解决了 在多线程中 指令重排序问题

有序性:
synchronized,日常用的最多的东西,当使用synchronized关键字时,只能有一个线程执行直到执行完成后或异常,才会释放锁。所以可以保证在同一时间synchronized代码块或方法只会有一个线程执行,保障了程序的有序性。

public class Thread_a extends Thread {
 public static    Integer num = 0;
    public static void main(String[] args) {
      int a=10000;
        for (int i = 0; i < a; i++) {
            new Thread(new Runnable() {
                @Override
                public void run() {
                    synchronized (num.getClass()){
                        num++;
                    }
                }
            }).start();
        }
        try {
            Thread.sleep(100);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(num);
    }
}

结果看上去很美好创建10000线程每个线程加1,结果10000 一个不差。
但是,synchronized能不能保证原子性和可见性呢?还记得单例的DCL写法么

public class Singleton{
    private static volatile Singleton instance;
    private Singleton(){
    }
    public static  Singleton getInstance(){

        if(instance==null){
            synchronized(Singleton.class){
                if(instance==null){
                    instance=new Singleton();
                }
            }
        }
        return instance;
    }
}
// instance是静态变量整个程序中只会初始化一次 所以不好测试

如果不加volatile会有什么问题呢?
java内存模型(jmm)并不限制处理器重排序,在执行instance=new Singleton();时,并不是原子语句,实际是包括了下面三大步骤:
1.为对象分配内存
2.初始化实例对象
3.把引用instance指向分配的内存空间
这个三个步骤并不能保证按序执行,处理器会进行指令重排序优化,存在这样的情况:
优化重排后执行顺序为:1,3,2, 这样在线程1执行到3时,instance已经不为null了,

由于第一个if没有synchronized,所以synchronized带来的有序性,对第一个if是不生效的。于是就会出现一种情况, 其他线程此时判断instance!=null,则直接返回instance引用,但现在实例对象还没有初始化完毕 此时使用未生成实例的引用instance可能会造成程序崩溃。
所以说,实际上synchronized只是保证了其有序性和原子性,解决上面这些问题 是依靠volatile关键字来保证 可见性和防止指令重排 的。

通过以上文章可能你对多线程的掌握又加深了

相关文章

微信公众号

最新文章

更多