zoukankan      html  css  js  c++  java
  • [Java多线程]线程安全问题

    Java基础--线程安全问题

    并发编程主要关注三个问题:安全性,活跃性,性能问题。其中安全性问题是最基本的要求。

    什么是线程安全问题?

    简单理解,就是在多线程环境下,对共享变量存在写操作时,共享变量能否正常读写的问题。

    public class TestConcurrentSafe {
        // 共享变量
        static int i = 0;
        public static void main(String[] args) {
            for (int j = 0; j < 5000; j++) {
                Thread t1 = new Thread(() -> i++);
                t1.start();
            }
            System.out.println(i);
        }
    }
    

    创建5000个线程,每个线程对静态成员变量做i++操作。由于i++操作并不是原子操作,所以最后得到的结果不一定是5000。这就是线程不安全。

    线程安全的微观原因:可见性,原子性,顺序性问题

    由于CPU,内存,I/O设备之间巨大的速度差异,我们的计算计体系结构引入了CPU缓存,分时系统,和编译优化。

    1. CPU 增加了缓存,以均衡与内存的速度差异;
    2. 操作系统增加了进程、线程,以分时复用 CPU,进而均衡 CPU 与 I/O 设备的速度差异;
    3. 编译程序优化指令执行次序,使得缓存能够得到更加合理地利用。

    其中CPU的缓存带来了多线程之间的可见性问题,CPU分时复用带来了指令的原子性问题,而编译优化带来了指令顺序性问题。

    CPU缓存与可见性问题

    对单核CPU来说,不存在变量的可见性问题,CPU对缓存中变量做任何修改,其他线程都能立刻“看到”。而对于多核CPU来说,CPU有各自的缓存,核心之间的缓存内的变量是互相看不见的。这就带来了可见性问题。

    image-20211222213339919

    比如上图中,三个CPU分别有自己的L1缓存,当执行i++操作时,CPU1将结果写入自己的L1缓存。而其他CPU需要从内存里读取i的值,看不到L1缓存中的结果,最后结果比预想的结果小。这就是缓存带来的可见性问题。

    线程切换带来原子性问题

    即便解决了可见性问题,线程安全还包括还有线程切换带来的原子性问题。

    对于i++这个操作,在CPU指令级别,可以看做三个指令

    1. 从内存取i的值,load
    2. CPU执行i=i+1
    3. 将新值写会内存,save

    由于CPU是分时执行,也就是说一个线程只能执行一段时间,即时间片。线程的执行不一定什么时候就会失去CPU的使用权,交给别的线程,这就是线程切换。

    img

    编译优化带来有序性问题

    编译器为了性能优化,会对代码的执行顺序做调整,但是不影响最终结果。

    但是有时会引起安全性的Bug.

    以双重校验锁实现的单例为例:

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

    在获取单例的时候,会校验instance == null,然后对单例类进行加锁。加完锁之后,还要再对instance == null做一次校验。这是为了防止这样一种异常情况:线程A和线程B同时调getInstance,线程A拿到锁,B被阻塞。线程A对实例做初始化,然后释放锁。B拿到锁,如果不判断一次instance == null,线程B会重新new一个实例,这就生成两个单例了;所以拿到锁之后,再进行一次instance == null的判断,如果已经不为null,则不再初始化。

    似乎看上去没有问题,但是其实还有个指令重排序的问题。问题就在new Singlton()这行代码上。

    一行高级语言代码可能对应多条指令。上述代码的字节码如下:

    0: getstatic     #2                  // Field INSTANCE:Lcn/itcast/n5/Singleton; 
    3: ifnonnull     37 
    6: ldc           #3                  // class cn/itcast/n5/Singleton 
    8: dup 
    9: astore_0 
    10: monitorenter 
    11: getstatic     #2                  // Field INSTANCE:Lcn/itcast/n5/Singleton; 
    14: ifnonnull     27 
    17: new           #3                  // class cn/itcast/n5/Singleton 
    20: dup 
    21: invokespecial #4                  // Method "<init>":()V 
    24: putstatic     #2                  // Field INSTANCE:Lcn/itcast/n5/Singleton; 
    27: aload_0 
    28: monitorexit 
    29: goto          37 
    32: astore_1 
    33: aload_0 
    34: monitorexit 
    35: aload_1 
    36: athrow 
    37: getstatic     #2                  // Field INSTANCE:Lcn/itcast/n5/Singleton; 
    40: areturn
    

    其中

    • 17 表示创建对象,将对象引用入栈 // new Singleton
    • 20 表示复制一份对象引用 // 引用地址
    • 21 表示利用一个对象引用,调用构造方法 <init>
    • 24 表示利用一个对象引用,赋值给 static INSTANCE

    指令重排序后有可能先执行24,后执行21,这在单线程环境下看是没什么问题,但是多线程环境下,线程A执行完24引用赋值后发生了线程切换,线程B使用instance对象,发现虽然instance的引用不为null,但是还没有初始化变量,导致使用时发生空指针异常。这就是指令重排序导致的问题。

    img

    如何解决可见性问题,有序性问题和原子性问题?

    1.可见性问题和有序性问题

    可见性问题是由缓存问题导致的,有序性问题是由指令重排序导致的,所以最直接的想法就是禁用缓存和重排序。但是完全禁用缓存,性能会下降,所以应该按需禁用。这个工作由JVM来做。Java 内存模型规范了 JVM 如何提供按需禁用缓存和编译优化的方法。具体来说,这些方法包括 volatile、synchronized 和 final 三个关键字,以及六项 Happens-Before 规则

    2.原子性问题

    原子性问题主要的解决办法就是互斥,也就是加锁,即对共享变量的写操作,线程之间是互斥的,一次只能有一个线程对共享变量执行写操作。

  • 相关阅读:
    pat甲级 1155 Heap Paths (30 分)
    pat甲级 1152 Google Recruitment (20 分)
    蓝桥杯 基础练习 特殊回文数
    蓝桥杯 基础练习 十进制转十六进制
    蓝桥杯 基础练习 十六进制转十进制
    蓝桥杯 基础练习 十六进制转八进制
    51nod 1347 旋转字符串
    蓝桥杯 入门训练 圆的面积
    蓝桥杯 入门训练 Fibonacci数列
    链表相关
  • 原文地址:https://www.cnblogs.com/SimonZ/p/15721501.html
Copyright © 2011-2022 走看看