可见性、有序性、原子性问题

生活富能量大约 4 分钟

可见性、有序性、原子性问题

并发编程有什么用? 毋庸置疑是为了挖掘服务器的潜力,提升程序的性能。但是这又给我们带来了新的问题。

  • 多核CPU 增加了缓存,以均衡与内存的速度差异;导致 可见性问题

  • 编译程序优化指令执行次序,使得缓存能够得到更加合理地利用。导致 有序性问题

  • 进程,线程,分时使用CPU, 以均衡CPU与I/O之间的差异。导致了数据的原子性

学习使用并发编程就是既要提升性能,同时又要避免上面的问题。

可见性问题

每颗CPU都有自己的缓存,多个线程在不同的CPU上执行时,这些线程操作的是不同的CPU缓存。比如下图中,线程A操作的是CPU-1上的缓存,而线程B操作的是CPU-2上的缓存,很明显,这个时候线程A对变量V的操作对于线程B而言就不具备可见性了

下面用代码的方式将可见性问题复现

我们使用for循环的方式创建多个线程去操作同一个变量cnt

package com.bdf.juc.visibility;

import cn.hutool.core.thread.ThreadUtil;

import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class ThreadUnsafeExample {

    private int cnt = 0;

    public void add() {
        cnt++;
    }

    public int get() {
        return cnt;
    }

    public static void main(String[] args) throws InterruptedException {
        final int threadSize = 1000;
        ThreadUnsafeExample example = new ThreadUnsafeExample();
        final CountDownLatch countDownLatch = new CountDownLatch(threadSize);
        ExecutorService executorService = ThreadUtil.newExecutor();
        for (int i = 0; i < threadSize; i++) {
            executorService.execute(() -> {
                example.add();
                countDownLatch.countDown();
            });
        }
        countDownLatch.await();
        executorService.shutdown();
        System.out.println(example.get());

    }

}

输出结果

986

期望的输出结果是1000。可是最终的输出结果是变化的, 总是达不到1000。

线程是在CPU里面运行的,会将线程需要运行的数据拉到CPU的缓存中运算。多个CPU就会有多个缓存。 这里就是将CNT拉取到不同的缓存中, 每一个线程都在自己的缓存中操作,操作完成之后在同步到内存中。

这样当线程A和线程B都去内存中读取CNT,读取到的值都是1, 并将CNT改写成2,写到内存中。此时正确实的值应该是3,可是最终内存中的值是2。 这就是可见性问题。

原子性问题

首先要明确的一点是多线程的原子性和数据库的原子性类似,但不是一回事。

多线程开发的原子性是指的 一个或者多个操作在CPU执行的过程中不被中断的特性称为原子性。 一个操作是CPU的一条指令。

数据库的原子性是事务不可分割,事务中的逻辑要么全部成功要么全部失败。 事务中的逻辑可以交由多个线程去实现。

在java代码中一个简单的语句也需要CPU中多条指令来完成。 例如: count+=1这个操作。

通常来说一个java语句去操作一个变量,同时这个语句使用多个线程运行, 是会有原子性问题的。 因为这是有很多个CPU指令去完成的。

怎么来保证线程的切换不影响我们的业务,这就需要一些高级语言给我们提供的一些工具。 至于什么工具,怎么用这些工具? 我们会在后面讲。

有序性问题

我们在写代码的时候是按照一定的顺序来写的,也希望代码有顺序的执行。 但是在编译器执行代码的时候并不是按照顺序来执行。

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

语句1和语句2一定是先执行 语句1和语句2吗? 不一定。 在程序执行是为了提高性能,编译器和处理器会对指令进行重新排序。

一个经典的单例问题, 其中只分析当有序性可能会引发的问题。

public class Singleton {
  static Singleton instance;
  static Singleton getInstance(){
    if (instance == null) {                 //节点B
      synchronized(Singleton.class) {
        if (instance == null)
          instance = new Singleton();
        }
    }
    return instance;
  }
}

首先需要知道 instance = new Singleton(); 这个语句是由多个命令组成:

  1. 分配一块内存M
  2. 在内存M上初始化Singleton对象。
  3. 然后M的地址赋值给instance变量。

假设有两个线程A、B调用getInstance()方法,线程A获取到了锁,开始执行创建对象语句。 而因为指令重排序。 instance = new Singleton(); 的命令顺序有所改变(假设! 不确定! 有可能会变成下面说的那样)

  1. 分配一块内存M;
  2. 将M的地址赋值给instance变量;
  3. 最后在内存M上初始化Singleton对象。

而当创建对象指令执行到第二步时并且 线程B执行到第一个if (instance == null) { 代码中节点B时。 线程B执行这个判断语句会得到false,最终会得到一个instance对象,而这个instance对象还没有创建。 如果这个时候使用就会空指针。

评论
  • 按正序
  • 按倒序
  • 按热度
Powered by Waline v2.15.5