【Java 并发】三大特性

在 Java 的高并发中,对于线程并发问题的分析通常可以通过 2 个主核心进行分析

  1. JMM 抽象内存模型和 Happens-Before 规则
  2. 三大特性: 原子性, 有序性和可见性

JMM 抽象内存模型和 Happens-Before 规则, 前面我们讨论过了。这里讨论一下三大特性。

1 原子性

定义: 一个操作或者多个操作要么全部执行并且执行的过程不会被任何因素打断要么就都不执行

简单地说就是一个操作被定义为原子性了, 那么不管这个操作里面包含多少个步骤, 他们都是一个整体的, 这个整体只有执行成功, 或不执行, 不存在任何的中间态, 比如只执行一半, 或者一半成功, 一半失败。

而回到 Java 中, 很多操作看起来就一个操作, 好像是具备原子性, 但是实际中却不具备原子性。

猜猜下面的操作哪些是原子操作?

// 1
int a = 1;

// 2
a++;

// 3
int b = a + 1;

// 4
a = a + 1;

答案是: 只有第一个。

a++ 操作可以拆分为下面 3 步:

  1. 读取 a 的值
  2. a 的值加 1
  3. 将计算后的值重新赋值给 a

其他 2 个的分析类似。

1.1 原子操作

在 Java 内存模型中定义了 8 种原子操作

操作 作用对象 说明
lock (锁定) 主内存中的变量 把一个变量标识为一个线程独占的状态
unlock (解锁) 主内存中的变量 把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定
read (读取) 主内存的变量 把一个变量的值从主内存传输到线程的工作内存中,以便后面的 load 动作使用
load (载入) 工作内存中的变量 把 read 操作从主内存中得到的变量值放入工作内存中的变量副本
use (使用) 工作内存中的变量 把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用到变量的值的字节码指令时将会执行这个操作
assign (赋值) 工作内存中的变量 把一个从执行引擎接收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作
store (存储) 工作内存的变量 把工作内存中一个变量的值传送给主内存中以便随后的write操作使用
write (操作) 主内存的变量 把 store 操作从工作内存中得到的变量的值放入主内存的变量中

上面的这些指令操作是相当底层的,可以作为扩展知识面掌握下。

需要注意的一点就是: 指令与指令之间组合起来达到某个效果。
但是 Java 内存模型只要求这些组合指令之间是顺序执行的,不强制他们一定是连续执行的。

比如把一个变量从主内存中复制到工作内存中就需要执行 read, load 操作,将工作内存同步到主内存中就需要执行 store, write 操作。
也就是说 read 和 load 之间可以插入其他指令,store 和 writer 可以插入其他指令。
比如对主内存中的 a, b 进行访问就可以出现这样的操作顺序: read a, read b, load b, load a

支持变量操作的原子操作的有 read, load, use, assign, store, write。 基础数据类型比较简单, 基本只有使用到其中的一条, 所以可以看为基本数据类型的访问读写具备原子性 (long 和 double 的操作不具备操作性), 如上面的 int a = 1;

1.2 synchronized 和 volatile 对原子性的支持

synchronized

上面一共有八条原子操作,其中六条可以满足基本数据类型的访问读写具备原子性,还剩下 lock 和 unlock 两条原子操作。这 2 个可以用于支持更大范围的原子性操作。
尽管 JVM 没有把 lock 和 unlock 开放给我们使用,但 JVM 以更高层次的指令 monitorenter 和 monitorexit 指令开放给我们使用,
映射到 Java 代码中就是— synchronized 关键字, 也就是说 synchronized 满足原子性。

public void test() {
    int b = 0;
    synchronized(Test.class) {
        int a = 1;
        b = a;
    }
}

volatile

而说到关键字, Java 中另一个和并发相关的高频关键字 volatile, 是否可以保证原子性了。
先举一个例子。

public class VolatileExample {

    private static volatile int counter = 0;
 
    public static void main(String[] args) {

        // 启动 10 个线程
        for (int i = 0; i < 10; i++) {
            Thread thread = new Thread(new Runnable() {
                @Override
                public void run() {
                    for (int i = 0; i < 10000; i++)
                        counter++;
                }
            });
            thread.start();
        }

        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(counter);
    }
}

如上: counter 是 volatile 修饰的。
开启 10 个线程,每个线程都自加 10000 次,如果不出现线程安全的问题最终的结果应该就是:10 * 10000 = 100000。
但是运行多次都是小于 100000 的结果, 也就是说明: volatile 不能保证原子性

从上面的说明可以知道 counter++ 不是原子性操作。如果线程 A 读取 counter 到工作内存后,其他线程对这个值已经做了自增操作后,那么线程 A 的这个值自然而然就是一个过期的值,因此,总结果必然会是小于 100000 的。

如果让 volatile 保证原子性,必须符合以下两条规则:

  1. 运算结果并不依赖于变量的当前值,或者能够确保只有一个线程修改变量的值
  2. 变量不需要与其他的状态变量共同参与不变约束 (volatile 变量的变化不会与其它变量的变化有任何联系)

2 有序性

定义: 程序执行的顺序按照代码的先后顺序执行。

例如:

int i = 0;              
boolean flag = false;
i = 1;                // 语句1  
flag = true;          // 语句2

先给变量 i 赋值,然后给 flag 赋值,语句 1 在 语句 2 的前面。
但是在编译器和处理器可能会为了性能对其进行重排序 (指令重排序), 因为语句 1 和 语句 2 之间没有依赖关系,所以语句 2 可能被重排序到语句 1 的前面。

2.1 synchronized 和 volatile 对有序性的支持

synchronized

synchronized 语义表示锁在同一时刻只能由一个线程进行获取,当锁被占用后,其他线程只能等待。
因此, synchronized 语义就要求线程在访问读写共享变量时只能 “串行” 执行, 因此 synchronized 具有有序性。

但是在 synchronized 内部的代码块的逻辑, JVM 没有禁止重排序, 也就是支持处理器为了性能, 在不影响结果的情况下, 调整执行顺序。

例子:

public void test() {
    int b = 0;
    synchronized(Test.class) {
        int a = 1;
        // 1
        b = a;
        // 2
        int c = a;
    }
}

上面 1,2 2 个操作没有存在结果的依赖, 如果为了性能, 处理器仍然可以对他们进行重排序, 变为 2, 1 的执行顺序。

volatile

在 Java 内存模型中说过,为了性能优化,编译器和处理器会进行指令重排序。
也就是说 Java 程序天然的有序性可以总结为:如果在本线程内观察,所有的操作都是有序的, 如果在一个线程观察另一个线程,所有的操作都是无序的。那么 volatile 具备有序性吗?

先看一个例子:volatile 和 双重检验锁定的方式(Double-checked Locking)的关系:

public class Singleton {

    private volatile static Singleton instance;

    private Singleton() { }

    public Singleton getInstance(){
        if(instance == null){
            synchronized (Singleton.class){

                if(instance == null){
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

这里的 instance 为什么要加 volatile 修饰?
创建一个对象,实际是经过 3 步实现的

  1. 分配对象内存空间
  2. 初始化对象
  3. 将对象指向我们刚刚分配的内存

但是不加 volatile 在重排序的作用下, 可能会出现下面的执行顺序:

【Java 并发】三大特性_第1张图片

如果 2 和 3 进行了重排序的话,线程 B 进行判断 if( instance == null) 时就会为 true,而实际上这个 instance 并没有初始化成功,显而易见对线程 B 来说之后的操作就会是错的。
而用 volatile 修饰的话,可以禁止 2 和 3 操作重排序,从而避免这种情况。volatile 包含禁止指令重排序的语义, 其具有有序性。

3 可见性

当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值

// 线程 1 执行的代码
int i = 0;
i = 10;
 
// 线程 2 执行的代码
j = i;

假若执行线程 1 的是 CPU1,执行线程 2 的是 CPU2。由上面的分析可知:
当线程 1 执行 i = 10 这句时,会先把 i 的初始值加载到 CPU1 的本地缓存,然后赋值为 10,那么在 CPU1 的本地缓存中把 i 的值变为 10,却没有立即写入到主存当中。

此时线程 2 执行 j = i,它会先去主存读取 i 的值并加载到 CPU2 的本地缓存当中,此时内存当中 i 的值还是 0,那么就会使得 j 的值为 0,而不是 10。
这就是可见性问题,线程 1 对变量 i 修改了之后,线程 2 没有立即看到线程 1 修改的值。

3.1 synchronized 和 volatile 对有序性的支持

synchronized
通过对 synchronized 的内存语义进行了分析,当线程获取锁时会从主内存中获取共享变量的最新值,释放锁的时候会将共享变量同步到主内存中。所以 synchronized 具有可见性 (Happens-Before 的 Monitor 规则保证)。

volatile

volatile 修饰的变量在写的时候,底层会在后面在添加 lock 指令,确保将修改的值刷新到主内存,所以 volatile 具备可见性。

4 总结

synchronized 具有: 原子性, 有序性, 可见性。

volatile 具有:有序性,可见性。

synchronized 是否保证有序性呢? 从上面的双重检测看起来, synchronized 貌似不保证有序性, 但是 synchronized 还是保证有序性的, 只是和 volatile 的有序性不一样。

volatile 关键字禁止 JVM 编译器和处理器对其进行重排序, 而 synchronized 保证的有序性是只有单线程可以获取锁, 串行地执行同步代码的结果, 但是同步代码里的语句是会发生指令重排序。
进入 synchronized 代码块前, 底层先添加一个 acquire barrier, 在最后添加一个 release barrier, 保证同步代码块中的代码不能和同步代码块外面的代码进行指令重排, 在其内部还是会发生指令重排但基本不会影响结果。


public void testSynchronized() {

    // 1
    int a = 1;

    // 2
    synchronized(TestDemo.class) {
        // 2.1
        a = 2;
        // 2.2
        int b = 3;
        // 2.3
        int c = 4;
    }

    // 3
    a = 3;
}

将 synchronized 内的代码块看着整个整体, 在 synchronized 的作用下, 1, 2, 3 是有序的,
但是 synchronized 不保证代码块内的代码是有序的, 在没有数据依赖的条件下, 运行指令重排序, 也就是可能存在 2.1 - 2.3 - 2.2 等情况。

5 参考

三大性质总结:原子性、可见性以及有序性

你可能感兴趣的:(#,Java,并发,开发语言,Java,Java,并发)