3.线程安全

145 阅读11分钟

一、什么是线程安全?

概念:如果多线程下使用某个类,不多线程如何使用调度该类,这个类总能表现出正确的预期行为,那么这个类就是线程安全的。反之我们就称这个类为线程不安全的。

二、为什么会线程不安全?

在我们使用一项技术时,一方面我们可以享受其带来的优点,另一方面我们也要忍受其带来的缺点。 多线程让我们提高了CPU利用率,优化了系统性能,但是也同时带来了线程安全问题需要我们解决。

要想了解线程不安全的原因,我们就需要对JMM(JAVA MEMORY MODEL)有一定了解。 Java内存模型与硬件内存架构之间存在差异,硬件内存架构没有区分线程栈和堆。对于硬件所有的线程栈和堆都分布在主内存中,部分线程栈和堆可能有时会出现在CPU缓存中和CPU内部的寄存器中。

image.png JMM定义了线程和主内存间的抽象关系:

  • 主内存:JMM规定所有变量都存储在主内存,主内存是共享区域所有线程都可访问。
  • 工作内存:每个线程都有自己的工作内存(工作内存是JMM中对CPU寄存器和高速缓存的抽象概念并不是真实存在),各线程间相互隔离。线程对变量的操作必须在工作内存中进行,先将变量从主内存拷贝到该线程的工作内存然后进行读写操作,操作完成后写入主内存中。
  • 每个线程都有个私有的本地内存,本地内存是JMM中的抽象概念并不是真实存在。它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化。本地内存中存储了该线程以读/写共享变量的拷贝副本
  • 从更低层来说,主内存就是硬件的内存,为了获取更好的运行速度,虚拟机及硬件系统可能会让工作内存优先存储于寄存器和高速缓存中。
  • Java内存模型中的线程的工作内存是CPU的寄存器和高速缓存的抽象描述。而JVM的静态内存模型只是一种内存的物理划分而已,它只局限在内存,而且只局限在JVM的内存。

image.png

写不安全

  1. 两个线程A\B同时读取并拷贝主内存中的变量m 10至自己的工作内存
  2. A线程在自己的工作内存中对变量m进行+3操作,B线程在自己的工作内存中对变量m进行+5操作
  3. 然后A\B线程将各自工作内存的变量副本更新至主内存 那么主内存的变量m将会是13或者15,而不是我们预期的18

总结:造成写不安全的根本原因,两个线程同时对主内存中同一对象进行修改。

读不安全(共享对象可见性)

  1. A线程读取共享内存中的变量m 10,然后拷贝到自己的工作内存
  2. A线程在自己的工作内存中操作该变量,进行+3操作。此时B线程读取主内存中的m变量,因为A线程操作的该线程工作内存中的拷贝变量且最新的值未更新至主内存。此时B线程读取的主内存中的m变量还是10,然后B线程将m变量在自己的工作内存中进行+5操作
  3. A线程将更新后的值13写入主内存中,后B线程将主内存中的m变量更新为15

总结:读不安全造成的根本原因是,A线程对主内存中对象的变更对其它线程是不可见的。主内存中的对象状态修改未及时同步到其它线程中,其它线程持有的对象可能还是修改前的值。

三、线程不安全的解决方案

上面我们分析了造成线程不安全的原因,读不安全和写不安全。那么我们如何解决这种问题呢? JAVA中给出了解决方案实现读写一致性。

1. 读不安全问题解决方案

JAVA中针对读不安全问题提供关键字volatile关键字来解决。被volatile修饰的成员变量,在发生变更时会通知所有线程去更新主内存中最新的值,这样就解决了读不安全的问题,实现了读一致性。

1.1. 线程缓存导致可见性问题

如果多个线程操作主内存同一共享对象。一个线程将主内存中的对象拷贝到其工作内存进行修改(修改后还未写入主内存),这一操作对其它线程来说是不可见的。其它线程此时读取主内存的对象还是未修改之前的值。

解决方案:

  • volatile:被volatile修饰的成员变量,该变量被修改后总是会立刻被写回到主内存中,其它线程在使用被volatile修饰的变量时,都会重新从主内存中读取最新的值。因此我们可以说,volatile保证了多线程操作共享变量的可见性。
  • synchronized:同步快的可见性是由“如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前需要重新执行load或assign操作初始化变量的值”、“对一个变量执行unlock操作之前,必须先把此变量同步回主内存中(执行store和write操作)”这两条规则获得的
  • final:被final修饰的字段在构造器中一旦被初始化完成,并且构造器没有把this引用穿踢出去,那么在其他线程就能看见final字段的值(无需同步)

1.2. 重排序导致的可见性问题

Java程序中天然的有序性可以总结为一句话:如果在本地线程内观察,所有操作都是有序的(“线程内表现为串行”);如果在一个线程中观察另一个线程,所有操作都是无序的(“指令排序”现象和“线程工作内存与主内存同步延迟现象”)。

Java提供了volatile和synchronized两个关键字来保证线程间操作的有序性:

  • volatile:该关键字本身就包含了禁止指令重排序的语义
  • synchronized:该关键字由“一个变量在同一时刻只允许一条线程对其进行lock操作”这条规则获得的,这个规则决定了持有同一个锁的两个同步块只能串行的进入。

指令序列的重排序

  1. 编译器优化的重排序:编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
  2. 指令级并行的重排序:现代处理器采用了指令级并行技术来将多条指令重叠执行,如果不存在数据依赖性处理器可以改变语句对应机器指令的执行顺序。
  3. 内存系统的重排序:由于处理器使用缓存和读/写缓冲区,使得加载和存储操作看上去可能是在乱序执行。

image.png

数据依赖: 编译器和处理器在重排序时会遵守数据依赖性,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序。(这里所说的数据依赖性仅针对单个处理器中执行的指令序列和单个线程中执行的操作,不同处理器间和不同线程间的数据依赖性不被编译器和处理器考虑)

image.png

指令重排序改变多线程程序的执行结果例子

image.png

flag变量是一个标记,用来标识变量a是否已被写入,这里假设有两个线程A\B,A首先执行writer()方法,随后B线程接着执行reader()方法。线程B在执行操作4时能否看到线程A在操作1对共享变量的写入呢?

答案是:不一定能看到

由于操作1和操作2没有数据依赖关系,编译器和处理器可以对这两个操作重排序;同样,操作3和操作4没有数据依赖关系,编译器和处理器也可以对这两个操作重排序。

as-if-serial: 不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能改变。

2. 写不安全问题解决方案

2.1. 互斥锁(synchronized)

互斥锁:也叫独占锁或悲观锁,它是指线程之间是互斥的,某一个线程获取某个资源的锁后,那么其他线程只能睡眠等待,不再具有操作该资源的能力直到该线程释放资源锁。所以互斥锁不存在并发,只能同步执行

java中互斥锁实现一般叫做同步线程锁,使用关键字synchronized实现。它锁住的范围是它修饰的作用域。锁的对象是:对象(对象锁)或类(类锁)或代码块,锁释放前,其它线程必将阻塞,保存成锁作用范围内的操作是原子性的。

  • 对象锁:当它修饰方法、代码块时会锁住当前对象
  • 类锁:修饰类、静态方法时,锁住类的所有对象

Java内存模型定义了如下八种操作完成线程从主内存拷贝变量到工作内存的过程:

  • lock(锁定):作用于主内存的变量,把该变量标识为线程独占的状态
  • unlock(解锁):作用于主内存变量,把一个处于锁定状态的变量释放出来,释放后的变量才能被其他线程锁定。
  • read(读取):作用于主内存变量,把一个变量值从主内存传输到线程的工作内存,以便其后的load动作使用。
  • load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放到工作内存中的变量副本中。
  • use(使用):作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行该操作
  • assign(赋值):作用于工作内存的变量,它把一个执行引擎接收到的值赋值给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码执行时执行该操作。
  • store(存储):作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中,以便随后的write操作。
  • write(写入):作用于主内存的变量,它把store操作从工作内存中一个变量的值传送到主内存的变量中。

总结:当对象和变量存放在计算机各种不同的内存区域中时,可能出现一些具体问题。Java内存模型建立所围绕的问题:在多线程并发过程中,如何处理多线程读同步问题与可见性(多线程缓存与指令排序)、多线程写同步问题与原子性(多线程竞争race condition)

2.2. 自旋锁()

自旋锁:也是一种锁概念,在其他场景也叫作乐观锁等。自旋锁本质是不加锁,而是通过对比旧数据来决定是否更新

image.png 如图所示,不管线程执行先后顺序,最终结果都会是15,由此实现了读写一致性。自旋只是一种概念:表示操作失败后,线程会循环进行上一步操作,直到成功。

优点:

  • 避免了线程的上下文切换带来的额外资源消耗
  • 避免了线程互斥问题,允许一定程度的并发

缺点:

  • 并发较高的情况下,会导致某些线程一直无法更新成功(因为一直有其它线程更改了目标对象的值),导致线程长时间占用CPU。所以自旋锁属于低并发的解决方案
  • java提供自旋的操作类很多太过原始(AtomicInteger,AtomicLong等)。所以java在此基础上封装了些类,能够简单直接的接近于synchronized那样方便的对某段代码上锁,即ReentrantLock及ReentrantReadWriteLoc

2.3. 线程隔离

前文提到自旋锁只适用于低并发的解决方案,那么遇到高并发该如何处理呢?

答案是将成员变量设置成线程隔离的,就是说每个线程都各自使用自己的变量,彼此互不相关。这样各线程间不存在资源共享,自然也就不会存在线程不安全的问题了。

JAVA中提供了ThreadLocal类来实现这一效果。

参考来源: