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.原子性问题

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

  • 相关阅读:
    QListView和QListWidget的区别
    Qt下QTableWidget的使用
    用c++封装linux系统调用
    读写锁的简单示例
    SQL 使用序列
    SQL 事务
    SQL ALTER TABLE 命令
    SQL 语句快速参考
    java中三种常见内存溢出错误的处理方法(good)
    Java 内存溢出(java.lang.OutOfMemoryError)的常见情况和处理方式总结
  • 原文地址:https://www.cnblogs.com/SimonZ/p/15721501.html
Copyright © 2011-2022 走看看