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

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

原文地址:https://www.cnblogs.com/SimonZ/p/15721501.html