多线程并发线程不安全的原因(讲明白其中的原因,包含计算机结构及JAVA内存模型)


前言

相信很多人在面试的时候都会遇到这样的的面试问题,“什么是线程安全的,什么不是线程安全的”,想要回答这两个问题,你就必需搞明白线程安全是什么,什么情况下才会是线程安全的?

当然搞明白上面的问题也不全是为了面试,对你平时码代码也会有很大的帮助,提升你的代码质量。


一、并发编程中影响线程安全的三个问题

1. 可见性问题(Visibility)

  • **概念:**当一个线程对共享变量进行了修改,另外的线程可以立即看到修改后的最新值。
  • 演示: 演示可见性问题
    1. 创建一个共享变量。
    2. 创建一个线程不断读取共享变量。
    3. 创建一个线程修改共享变量。
    4. 预期结果:当线程A读取到flag变为false时退出循环,程序终止。
  • 代码:
import java.util.concurrent.TimeUnit;

public class TestSynchronizedPro {
    
    

    // 1. 创建一个共享变量
    public static boolean flag = true;

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

        // 开启一个线程读取共享变量的值
        new Thread(() -> {
    
    
            while(flag) {
    
    
                
            }
        }, "A").start();

        TimeUnit.SECONDS.sleep(3);


        // 开启一个线程修改共享变量的值
        new Thread(() -> {
    
    
            flag = false;
            System.out.println("线程" + Thread.currentThread().getName() + "已将flag的值修改为:" + flag);
        }, "B").start();

    }
}

  • 执行结果:
    执行结果
  • 总结: 从结果中我们可以看出,共享变量被B线程修改后并没有被A线程读取到,这就是多线程并发访问中的可见性问题,存在这一问题的多线程代码就是线程不安全,因为它会存在脏读的情况。

2.原子性问题(Atomicity)

  • 概念: 在一次或多次操作中只会出现两种情况,
    • 所有的操作都执行并且不会受其他因素干扰而中断;
    • 所有的操作都不执行。
  • 演示: 演示原子性问题
    1. 定义一个共享变量number
    2. 开启5个线程
    3. 每个线程对number进行1000次的++操作。
    4. number最后的结果应该是5000。
  • 代码:
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;

public class TestSynchronizedPro {
    
    

    // 1. 定义一个共享变量number
    private static int number;

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

        // 每个线程都执行1000次的++操作
        Runnable increament = () -> {
    
    
            for (int i = 0; i < 1000; i++) {
    
    
                number++;
            }
        };

        // 将线程放到集合中,以便拿到最后的结果
        List<Thread> list = new ArrayList<>();

        // 创建5个线程共同操作
        for (int i = 0; i < 5; i++) {
    
    
            Thread t = new Thread(increament);
            t.start();
            list.add(t);
        }

        // join所有线程
        for (Thread thread : list) {
    
    
            thread.join();
        }

        // 打印结果
        System.out.println(number);

    }
}
  • 执行结果: 我们可以多执行几次,看看结果如何
    第一次执行结果
    第二次执行结果
    第三次执行结果

  • 说明: 执行了三次,可以看到只拿到了一次预期结果,这是为什么呢?

  • 深入底层: 想要知道结果为什么会这样,我们必需得清楚计算机执行number++时都进行了哪些操作

    1. 对.class字节码文件进行反汇编,命令如下:
    # 命令参数说明:
    # -p 私有的信息也显示出来
    # -v 输出所有信息
    javap -p -v .\TestSynchronizedPro.class
    
    1. 查看汇编信息,找到lamada表达式的执行代码:
      字节码指令
    2. 从上图字节码指令中不难看出,number++对应的指令是四条,如下图:
      执行number++的指令
    3. 根据上图可知:当两个线程同时进入number++这四条指令时,两个线程在执行第一条指令时拿到的都是相同的值(假如是:0),当一个线程执行完最后一条指令时会将+1后的值(也就是:1)赋值给number,另一个线程执行完最后一条指令也会将+1后的值(也是:1)赋值给number,这时就结果就出现了问题,本来应该是2,但是最后是1。
  • 总结: 并发编程时,会出现原子性问题,当一个线程对共享变量操作到一半时,另外的线程也有可能来操作共享变量,干扰了前一个线程的操作,导致出现了预期之外的结果,这也是线程不安全的原因之一。

3. 有序性问题(ordering)

  • 概念: 程序中代码的执行顺序,JAVA在编译时和运行时会对代码进行优化,会导致程序最终的执行顺序不一定就是我们编写代码时的顺序。
  • 演示: 演示有序性问题
    1. 添加 jcstress 并发压测工具,pom文件添加如下信息:
    <properties>
        <!-- jdk版本 -->
        <javac.target>1.8</javac.target>
        <uberjar.name>jcstress</uberjar.name>
    </properties>
    
    
    <dependencies>
        <dependency>
          <groupId>org.openjdk.jcstress</groupId>
          <artifactId>jcstress-core</artifactId>
          <version>0.15</version>
        </dependency>
    </dependencies>
    
    <build>
        <plugins>
          <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-compiler-plugin</artifactId>
            <version>3.1</version>
            <!-- jar生成路径 -->
            <configuration>
              <compilerVersion>${javac.target}</compilerVersion>
              <source>${javac.target}</source>
              <target>${javac.target}</target>
            </configuration>
          </plugin>
    
          <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <!-- 将依赖的jar包打包到当前jar包 -->
            <artifactId>maven-shade-plugin</artifactId>
            <version>2.2</version>
            <executions>
              <execution>
                <id>main</id>
                <phase>package</phase>
                <goals>
                  <goal>shade</goal>
                </goals>
                <configuration>
                  <!-- 打包的名字 jcstress.jar -->
                  <finalName>${uberjar.name}</finalName>
                  <!-- 调用下面的两个类 -->
                  <transformers>
                    <transformer
                            implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
                      <mainClass>org.openjdk.jcstress.Main</mainClass>
                    </transformer>
                    <transformer implementation="org.apache.maven.plugins.shade.resource.AppendingTransformer">
                      <resource>META-INF/TestList</resource>
                    </transformer>
                  </transformers>
                </configuration>
              </execution>
            </executions>
          </plugin>
        </plugins>
    </build>
    
    1. 定义两个变量。
    2. 同时压测两个方法。
    3. 一个方法给压测对象中的赋值,一个方法修改两个变量。
  • 代码: 代码如下:
package com.juc.study;

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

/**
 * JCStressTest  开启压测工具
 * @Outcome(id = {"1", "4"}, expect = Expect.ACCEPTABLE, desc = "OK")
 * 表示我们预期可以接受的结果,分别为 1 和 4 。
 *
 * @Outcome(id = "0", expect = Expect.ACCEPTABLE_INTERESTING, desc = "danger")
 * 表示我们比较感兴趣的结果:0,但这个结果不是我们预期的结果。
 */
@JCStressTest
@Outcome(id = {
    
    "1", "4"}, expect = Expect.ACCEPTABLE, desc = "OK")
@Outcome(id = "0", expect = Expect.ACCEPTABLE_INTERESTING, desc = "danger")
@State
public class TestJC {
    
    
    // 定义两个变量
    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;
    }

}

压测工具JCStressTest的使用方法【注意:不要将压测类放到test目录下】 :

  • 普通方法:
    1. 打开终端(Terminal)
    2. 输入: mvn clean install
    3. 打包完成后在target里找到jcstress.jar的包
    4. 输入: java -jar jcstress.jar 即可
  • 直接使用IDEA进行操作:
    1. 使用maven打包,如下图:
      调出maven并执行
      打包完成
    2. 配置运行参数,如下图:
      IDEA 中 JCStressTest工具的配置
    3. 点击运行即可,如下图:
      直接运行
  • 执行结果: 执行结果如下图,从结果中我们可以看出,1和4正常出现,但是0也会出现,这是为什么呢?
    执行结果
  • 原因: 0会出现是因为有些情况下java在编译和运行时对代码进行了重新排序,也就是说调整了代码的执行顺序,如下图:
    执行顺序优化
  • 结论: 由于JAVA在编译期以及运行期的优化,导致程序代码在执行过程中的先后顺序未必就是开发者编写代码时的顺序。这也是多线程并发时最怕遇到的问题,也是线程不安全的原因之一。

二、JAVA程序运行的基础

1.计算机结构

1)计算机的5大构成

计算机由五大部分组成,它们分别是:

  • 输入设备: 键盘、鼠标、麦等。
  • 存储器: 内存,程序运行时会将数据加载进内存,供CPU使用。
  • 输出设备: 显示器、音响、打印机等。
  • 运算器: 是CPU的一部分,计算机运算的核心部分。
  • 控制器: 是CPU的另一部分,计算机控制的核心部分。

各部分的关系如下图:
计算机结构

2)缓存

  • 由于CPU的高速发展及内存的制造工艺和成本都比较高,导致内存跟不CPU的发展速度,为了解决这个问题,就在CPU和主内存之间增加了缓存。
  • 最靠近CPU的缓存是L1,然后依次是L2、L3和主内存。
  • 我们可以从任务管理器中查看自己电脑的缓存,如下图:
    如何开启任务管理器
    进入性能
    缓存信息
    可以看到右下角的三级缓存L1 L2 L3

3)CPU、缓存及内存之间的关系

  • CPU、缓存及内存之间的关系如下图:
    CPU、缓存及内存之间的关系
  • L1: 空间比较小,速度比较快,价格也比较高。
  • L2: 空间比L1稍大,速度比L1稍慢些,价格比L1稍便宜些。
  • L3: 空间比L1 和 L2 都大,速度比是最慢的,价格也是最便宜的。
  • 对比: 如下表,从表中我们就可以清晰的看出各储存之间的差别。
    各储存的对比
  • CPU的取数规则: CPU运算取数据的时候,它会先去L1中查找,如果L1中有则直接取数进行处理,处理完之后再保存到L1中和内存中;如果L1没有,则会到L2中去找,依此类推,最后是从内存中去找,从内存找到后又会将数据保存到L1中。

2. JAVA内存模型(JAVA Memory Model 简称JMM)

注意: JAVA内存模型不是JAVA内存结构,两个不要混淆了。

1) 基本定义

  • JAVA内存模型其实是一套规范。
  • 整套规范其实就是在说两个关键字:synchronized 和 volatile。
  • 是JAVA虚拟机规范中所定义的一种模型,JAVA内存模型是标准化的,屏蔽掉了底层不同计算机的区别。
  • JAVA内存模型是套规范,描述了JAVA程序中各种变量(线程共享变量)的访问规则,以及在JVM中将变量存储到内存和从内存读取变量这样的底层细节。

2)基本结构

下面让我们一起看一下JMM的结构,如下图
JAVA内存模型
【解读】:

  • 把这个模型分成两部分来看,一部分是主内存;一部分是工作内存
  • JAVA中的共享变量都放在主内存中,比如:类的成员变量(实例变量)、静态变量(类变量)。
  • 每个线程都可以访问主内存
  • 每个线程都有其工作内存,线程在执行代码时都在工作内存中进行处理。
  • 线程不能直接在主内存中操作共享变量,它必须把共享变量复制一份放到其工作内存中对数据进行处理,处理完之后它再把共享变量同步回主内存

3)JMM的作用

  • 多线程读写共享数据时,对共享数据的可见性、有序性、和原子性的规则和保障(通过sychronized 与 volatile 来保障),确保了线程并发的安全性。
  • JMM与真实的CPU及内存的关系如下图:
    JMM与计算机CPU及内存的关系

【结构解读】:

  • 左侧是JMM,即JAVA内存模型。
  • 右侧是计算机CPU硬件及内存硬件。
  • JMM中线程的工作内存可以是计算机CPU的寄存器、也可以是计算机CPU缓存还可以是计算机内存RAM。
  • JMM中的主内存可以是是计算机CPU的寄存器、也可以是计算机CPU缓存还可以是计算机内存RAM。

总结

多线程并发,线程不安全的的原因需要了解其底层原理,本文为大家提供一个研究的方向及思路。

猜你喜欢

转载自blog.csdn.net/yezhijing/article/details/128272003