java多线程安全相关

79 阅读5分钟

什么是线程安全?

多个线程访问同一个资源进行操作,操作的行为总能得到正确结果,那就认为线程是安全的。

什么导致了线程不安全?

可以从三方面入手:

  • 不满足原子性:一个或者多个操作在 CPU 执行的过程中被中断
  • 不满足可见性:一个线程对共享变量的修改,另外一个线程不能立刻看到
  • 不满足有序性:程序执行的顺序没有按照代码的先后顺序执行

原子性

原子(atomic)本意是“不能被进一步分割的最小粒子”,而原子操作(atomic operation)意 为“不可被中断的一个或一系列操作”。在 java 并发编程中我们可以将其理解为:一组要么成功要么失败的操作
在多线程环境下cpu会根据算法给各个线程时间片执行,时间片结束就会暂停当前线程切换到下一个线程执行,进而本任务会暂时的停止执行。多线程场景下,由于时间片在线程间轮换,就会发生原子性问题
以count++为例,两线程执行count++(count=0),各1000下,最终结果将不会是2000而是0到2000的任意数。
count++ 的执行实际上这个操作不是原子性的,因为 count++ 会被拆分成以下三个步骤执行(这样的步骤不是虚拟的,而是真实情况就是这么执行的)
第一步:读取 count 的值;
第二步:计算 +1 的结果;
第三步:将 +1 的结果赋值给 count变量
那么在cpu调度的情况下就可能发送下面的情况:count=0,线程1取值,计算+1结果,线程1时间片截止,保存执行线程2,线程2取值为0,执行+1操作,将结果写回,主内存count=1,线程1继续操作,执行第三步,将count=1写回。这样虽然执行了2次+1操作,但结果只是执行了1次+1操作。

可见性

可见性:一个线程对共享变量的修改,另外一个线程能够立刻看到,我们称为可见性。
由于cpu和内存、磁盘的运行速度差距大,为了提高运行速度,每个CPU和线程都有自己的本地缓存,而且对于缓存的修改是不会立刻写回主内存的 单核时期,仅有一个cup运行多个线程,cpu的内存线程间共享,一个线程对于某个数据的改变对于另一个线程是立马可见的。
多核时代,一个cpu负责一个线程。每个cup都有自己的缓存,cpu1负责线程1,cpu2负责线程2,而线程1对cpu中的数据修改对于线程2来说不是立马可见的,这就造成了可见性问题。
比如a=0,线程a和b分别对其进行1万次加一操作,正常结果应该是2万,但运行结果却是1万,因为两线程的cpu分别读取a进入自己的缓存进行操作,期间没有进行通信,这就导致双方并不知道对方对a的修改,只能各做各的。这样就导致了可见性问题。

有序性

实际上编译器为了提高程序执行的性能。会改变我们代码的执行顺序的。即你写在前面的代码不一定是先被执行完的。
例如:int a = 1;int b =4;从表面和常规角度来看,程序的执行应该是先初始化 a ,然后初始化 b 。但是实际上非常有可能是先初始化 b,然后初始化 a。因为在编译器看了来,先初始化谁对这两个变量不会有任何影响。即这两个变量之间没有任何的数据依赖。 指令重排序有三种类型,分别为:
① 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
② 指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-Level Parallelism,ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应 机器指令的执行顺序。
③ 内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上 去可能是在乱序执行。
有序性的案例最常见的就是 DCL了(double check lock)就是单例模式中的双重检查锁功能。

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

正常的执行顺序应该是这样:

  1. 分配一块内存 M;
  2. 在内存 M 上初始化 Singleton 对象;
  3. 然后 M 的地址赋值给 instance 变量。

而优化后的操作顺序可能是这样:

  1. 分配一块内存 M;
  2. 将 M 的地址赋值给 instance 变量;
  3. 最后在内存 M 上初始化 Singleton 对象。 那么就会导致下图的结果

image.png

如何实现线程安全

保证线程安全以是否需要同步手段分类,分为同步方案和无需同步方案。
同步方案又可分为互斥/阻塞和非阻塞方案。

互斥/阻塞

可以使用synchronize或ReentrantLock来实现同步。
互斥同步最主要的问题就是进行线程阻塞和唤醒所带来的性能问题,因此这种同步也成为阻塞同步。从处理问题的方式上说,互斥同步属于一种悲观的并发策略,总是认为只要不去做正确地同步措施(例如加锁),那就肯定会出现问题,无论共享数据是否真的会出现竞争,它都要进行加锁。

非阻塞同步

CAS来解决

无需同步方案

同步只是保证共享数据争用时的正确性的手段,如果一个方法本来就不涉及共享数据,那它自然就无需任何同步操作去保证正确性,因此会有一些代码天生就是线程安全的。
经常使用的就是ThreadLocal类