[JUC-15] Volatile

1、Volatile 是什么

  前提,要掌握 Volatile 关键字,就得掌握 JMM。

  JMM 学习链接:Java 内存模型

  volatile 是一个类型修饰符。volatile 的作用是作为指令关键字,确保本条指令不会因编译器的优化而省略。

  volatile 的特性:
  1、保证了不同线程对这个变量进行操作时的可见性;
  2、不保证原子性;
  3、禁止指令重排。

2、保证可见性

  当写一个volatile变量时,JMM会把该线程对应的本地中的共享变量值刷新到主内存。

2.1 没有可见性的案例

  下面下一个测试代码:

public class JMMTest {
    
    
    private static int num = 0;

    public static void main(String[] args)throws Exception {
    
    
        Thread MyThread = new Thread(() -> {
    
    
            while (num == 0) {
    
    

            }
        });

        MyThread.start();

        TimeUnit.SECONDS.sleep(1);

        num = 2;

        System.out.println(num);
    }
}

  程序运行结果如下:
在这里插入图片描述
  可见,main 线程修改了 num 的值并刷新回给了主内存,但是 myThread 线程使用的还是它工作内存中的 num。

2.2 实现可见性

  改进:使用关键字 Volatile 修饰 num:

public class JMMTest {
    
    
    private volatile static int num = 0;

    public static void main(String[] args)throws Exception {
    
    
        Thread MyThread = new Thread(() -> {
    
    
            while (num == 0) {
    
    

            }
        });

        MyThread.start();

        TimeUnit.SECONDS.sleep(1);

        num = 2;

        System.out.println(num);
    }
}

  运行结果如下:
在这里插入图片描述
  可见,main 线程对 num 的修改可以被 myThread 线程实时感知。

3、不保证原子性

3.1 不保证原子性的案例

  下面写一个测试代码:

public class VolatileTest {
    
    
    private volatile static int num = 0;

    public static void add() {
    
    
        num++;
    }

    public static void main(String[] args) {
    
    
        //理论上num结果应该为 2 万
        for (int i = 1; i <= 20; i++) {
    
    
            new Thread(() -> {
    
    
                for (int j = 0; j < 1000; j++) {
    
    
                    add();
                }
            }).start();
        }
        while (Thread.activeCount() > 2) {
    
     // 至少存在 main gc 两个线程
            Thread.yield();
        }
        System.out.println(Thread.currentThread().getName() + " " + num);
    }
}

  运行结果如下:
在这里插入图片描述
  按道理来说,20 个线程,每个线程 执行 1000 次add()方法,最后的 num 应该为 20 × 1000 = 20000 才对,可是实际结果为不足 2 万。

  我们来查看add()方法的字节码:
在这里插入图片描述
  可见,我们以为只是执行了一行num++;,其实在字节码文件中执行了 5 行。
  1、拿到 num 的值;
  2、加载到操作数栈;
  3、取出操作数栈进行加一操作,接着放回操作数栈;
  4、操作数栈出栈赋值给 num;
  5、return。

  当线程 A 拿到 num 的值为 10,然后执行到第二步时,线程 B 把 1-5 都执行了一遍,接着线程 A 继续往下执行时就已经不对了,因为此时 num 已经是 11 了,但是线程 A 还是在 10 的基础上加一。

  因此,我们可以分析出,num 不足 20000,就是因为很多线程拿到的值都是旧值,做了很多重复的工作。

3.2 实现原子性

  如果我们既要 Volatile 的可见性,还得保证原子性,怎么办呢?

  第一个想法肯定就是对add()方法进行加锁。那么有没有另外的办法,既不加锁,也能原子性呢?当然可以,这就是java.util.concurrent.atomic包的作用了:
在这里插入图片描述
  可见,它有很多类,都是和数据类型相关。

  把 int 改成 AtomicInteger,并且把add()函数进行修改:

public class VolatileTest {
    
    
    private volatile static AtomicInteger num = new AtomicInteger();

    public static void add() {
    
    
        num.getAndIncrement();
    }

    public static void main(String[] args) {
    
    
        //理论上num结果应该为 2 万
        for (int i = 1; i <= 20; i++) {
    
    
            new Thread(() -> {
    
    
                for (int j = 0; j < 1000; j++) {
    
    
                    add();
                }
            }).start();
        }
        while (Thread.activeCount() > 2) {
    
     // 至少存在 main gc 两个线程
            Thread.yield();
        }
        System.out.println(Thread.currentThread().getName() + " " + num);
    }
}

  运行结果:
在这里插入图片描述
  查看getAndIncrement()源码:

在这里插入图片描述

  调用了UnSafe类型对象的方法,继续挖掘:

在这里插入图片描述
在这里插入图片描述
  这些类的底层都直接和操作系统挂钩!在内存中修改值!Unsafe类是一个很特殊的存在!

4、禁止指令重排

4.1 指令重排现象

  什么是指令重排:你写的程序,计算机并不是按照你写的那样去执行的。

  源代码–>编译器优化的重排–> 指令并行也可能会重排–> 内存系统也会重排—> 执行

  处理器在进行指令重排的时候,考虑:数据之间的依赖性!

  比如一段代码如下:

int x = 1; // 1
int y = 2; // 2
x = x + 5; // 3
y = x * x; // 4

  我们以为会按照 1、2、3、4 的顺序执行,但是它也可能按照 2134 或1324 的顺序执行,但是不可能是 4123 的顺序,因为第 4 行依赖于第 1 行。

  假设有两个线程如下:

线程A 线程B
x = a y = b
b = 2 a = 3

  a 和 b 的初始值为 0,在正常情况下,线程 A 的执行结果就是:x = 0;线程 B 的执行结果就是 y = 0;

  如果发生了指令重排:

线程A 线程B
b = 2 a = 3
x = a y = b

  于是,线程 A 的结果就是 x = 3;线程 B 的结果就是 y = 2。

  指令重排机制判断线程 A 和 B 的语句没有上下依赖,因此进行性能优化,进行了指令重排,导致了结果异常。

  我们知道,在单线程中指令重排是只有好处没有坏处,但是在多线程环境下,指令重排就容易搞事情了。

4.1 Volatile 禁止指令重排原理

  首先了解一个概念,内存屏障(Memory Barrier)又称内存栅栏,是一个CPU指令,它的作用有两个:
  1、保证特定操作的顺序;
  2、保证某些变量的内存可见性(利用该特性实现volatile的内存可见性)

  由于编译器和处理器都能执行指令重排的优化,如果在指令间插入一条 Memory Barrier 则会告诉编译器和CPU,不管什么指令都不能和这条 Memory Barrier 指令重排序,也就是说 通过插入内存屏障禁止在内存屏障前后的指令执行重排序优化。 内存屏障另外一个作用是刷新出各种CPU的缓存数,因此任何CPU上的线程都能读取到这些数据的最新版本。
在这里插入图片描述
  也就是说,在对 Volatile 修饰的变量的写和读的时候,加入屏障,防止出现指令重排。

  简单来说,如下图:
在这里插入图片描述

猜你喜欢

转载自blog.csdn.net/qq_29051413/article/details/108005390