【并发编程】--- 并发编程中的可见性、原子性、有序性问题

源码地址:https://github.com/nieandsun/concurrent-study.git


1 可见性问题


1.1 复现可见性问题

可见性问题测试代码如下:

package com.nrsc.ch1.base.jmm.problem;

/***
 * Description: 可见性问题,一个线程对共享变量的修改,另一个线程不能立即得到最新值
 */
public class VisibilityProblem {
    /***共享变量*/
    private static boolean flag = true;

    public static void main(String[] args) throws InterruptedException {
        //线程1
        new Thread(() -> {
            while (flag) {
            }
        }).start();

        //睡一秒,保证线程1先运行
        Thread.sleep(1000);

        //线程2
        new Thread(() -> {
            flag = false;
            System.err.println("本线程已经将flag改为了: " + flag);
        }).start();
    }
}

按理来说,上诉代码的运行过程应该如下:

  • 线程1肯定会先运行,进入到一个死循环中;
  • 然后线程2开始运行,将共享变量flag改为false;
  • 最后由于共享变量flag的值已经改变,线程1应该感知到,并结束循环 —》 整个程序结束。

但是事实确实上的运行结果如下:
在这里插入图片描述
这就说明线程2对共享变量flag的修改,线程1是无法感知到的 — 》这就是并发编程中的可见性问题。


1.2 可见性问题产生的原因

可见性问题产生的原因与java内存模型(java memory model,JMM)有关 —》 JMM相关的概念请自行百度。
这里仅说一下可见性问题产生的原因。

首先应该知道,java的线程与主内存进行交互的动作有如下8个(均为原子性动作):
这里参考了文章《java内存模型JMM理解整理

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

1.1出现可见性问题的原因可以用下图来进行解释:
在这里插入图片描述


2 原子性问题

原子性问题测试代码如下:

package com.nrsc.ch1.base.jmm.problem;

import java.util.ArrayList;
import java.util.List;

/***
 * Description:  原子性问题
 */
public class AtomicityProblem {

    /***共享变量*/
    private static int num = 0;

    public static void main(String[] args) throws InterruptedException {

        Runnable increment = () -> {
            for (int i = 0; i < 1000; i++) {
                num++;
            }
        };

        List<Thread> threads = new ArrayList<>();

        //10个线程各执行1000次num++
        for (int i = 0; i < 10; i++) {
            Thread t = new Thread(increment);
            t.start();
            threads.add(t);
        }

        //确保10个线程都走完
        for (Thread thread : threads) {
            thread.join();
        }

        System.out.println("10个线程执行后的结果为:" + num);
    }
}

按理来说,上诉代码的运行结果应该为1000*10 = 10000,但是实际却可能产生如下结果:
在这里插入图片描述
导致该问题的原因,相信大家都知道 —> num ++ 不是原子操作 —> 这就是并发编程中的原子性问题。


3 有序性问题


3.1 复现有序性问题

有序性问题常规条件下不是很好复现,可以借助于java并发压测工具: jcstress来进行
官网: https://wiki.openjdk.java.net/display/CodeTools/jcstress


其用法如下:

package com.nrsc.ch1.base.jmm.problem;


import org.openjdk.jcstress.annotations.Actor;
import org.openjdk.jcstress.annotations.JCStressTest;
import org.openjdk.jcstress.annotations.Outcome;
import org.openjdk.jcstress.annotations.State;
import org.openjdk.jcstress.infra.results.II_Result;

import static org.openjdk.jcstress.annotations.Expect.ACCEPTABLE;
import static org.openjdk.jcstress.annotations.Expect.ACCEPTABLE_INTERESTING;

/***
 * Description: 有序性问题
 */
@JCStressTest
/***
 * r.r1 和 r.r2可能出现的结果
 */
@Outcome(id = {"0, 1", "1, 0", "1, 1"}, expect = ACCEPTABLE, desc = "ok")
@Outcome(id = "0, 0", expect = ACCEPTABLE_INTERESTING, desc = "danger")
@State
public class OrderProblem2 {

    int x, y;

    /****
     * 线程1 执行的代码
     * @param r
     */
    @Actor
    public void actor1(II_Result r) {
        x = 1;
        r.r2 = y;
    }


    /****
     * 线程2 执行的代码
     * @param r
     */
    @Actor
    public void actor2(II_Result r) {
        y = 1;
        r.r1 = x;
    }
}

这里注意一下这个II_Result 对象,由于我这里想看两个值的结果,所以用到了II_Result 对象,如果我只想看一个值的结果,就得用I_Result 对象。@Outcome注解里列出的就是r.r1和r.r2可能的结果值。
在这里插入图片描述

另一个测试有序性的代码如下, 该代码就用到了I_Result对象。

package com.nrsc.ch1.base.jmm.problem;

import org.openjdk.jcstress.annotations.*;
import org.openjdk.jcstress.infra.results.I_Result;

/***
 * Description: 有序性问题
 */
@JCStressTest
/***
 * r.r1可能出现的结果
 */
@Outcome(id = {"1", "4"}, expect = Expect.ACCEPTABLE, desc = "ok")
@Outcome(id = "0", expect = Expect.ACCEPTABLE_INTERESTING, desc = "danger")
@State
public class OrderProblem1 {
    int num = 0;
    boolean ready = false;

    /***
     * 线程1 执行的代码
     * @param r
     */
    @Actor
    public void actor1(I_Result r) {
        if (ready) {
            r.r1 = num + num;
        } else {
            r.r1 = 1;
        }
    }

    /***
     * 线程2 执行的代码
     * @param r
     */
    @Actor
    public void actor2(I_Result r) {
        num = 2;
        ready = true;
    }
}
  • (3) 打包 —> 运行命令mvn clean install

注意: 由于我是按照多模块的方式创建的项目,所以必须要先进入到本项目所在的目录下,如:
在这里插入图片描述
命令运行完会在targe目录下生成两个jar包,我们要用的是下面那个
在这里插入图片描述
-(4) 通过java -jar jar包的方式运行jar包, 来进行并发压测
在这里插入图片描述


在展示压测结果之前,我们先分析一下上面两个代码。
按理来说:

上面的第一个代码(r1,r2)的值应该为(0,1)或(1,0);当然由于不符合原子性,也有可能出现(1,1) —> 但肯定应该不会出现(0,0)

上面的第二个代码(r1)的值应该为(1)或(4) —> 肯定不会出现其他的情况。

但是事实却并非如此。


某轮压测结果如下:
在这里插入图片描述
在这里插入图片描述
由此可以看到结果中出现了在我们看来不可能发生的情况。

而要出现这种问题的原因,以第二个代码为例,就是

  • 先执行了线程2的ready = true;语句
  • 紧接着又执行了线程1的if(ready)分支

这种现象其实就是并发编程中的有序性问题。


3.2 有序性问题产生的原因

其实通过分析已经可以知道,之所以出现3.1中的问题,就是因为代码出现了重排序问题,为什么代码会进行重排序呢?

通过下图可以看到,重排序后可以明显减少代码的指令 — 》 指令减少了,速度也就快了
也就是说重排序可以提高代码的处理速度
在这里插入图片描述
其实我们写的代码到最终的执行指令,一般会涉及到三种重排序:

  • (1) 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序;
  • (2)指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-LevelParallelism,ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
  • (3)内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行的。

在这里插入图片描述
这时候,我想你一定像我刚知道这些时一样疑惑:
(1)我平时写代码没感觉到它进行过重排序啊;
(2)多线程情况下,如果每个线程都可能会发生重排序问题,那为了能写出按照我们的意愿执行的代码,那我们写代码时得考虑多少问题啊。。。

但是实际上以我们的开发经验来说,我们并不需要考虑这么多,这是为什么呢???
其实很简单,就是JMM规定了一些不可进行重新排序的规则,对此我们或许并不知道,但其实已经受益于这些规则了 —> 即happens-before规则 —> 抽空再好好对happens-before整理一下。


发布了212 篇原创文章 · 获赞 266 · 访问量 48万+

猜你喜欢

转载自blog.csdn.net/nrsc272420199/article/details/104826427