什么是线程安全?如何保证线程安全?
你好,我是猿java。
随着硬件技术的快速发展(比如多核处理器,超线程技术),我们通常会在代码中使用多线程(比如线程池)来提高性能,但是,多线程又会带来线程安全问题。因此,本文将深入探讨Java中的线程安全问题。
什么是线程安全?
首先,我们来看看维基百科对线程安全
是如何描述的,如下图:
总结一下:线程安全(Thread Safety)是指多个线程访问共享资源时,不会破坏资源的完整性。如下图:
请注意,导致线程安全问题一定要同时具备以下 3个条件,缺一不可:
- 1. 多线程环境:如果是单线程,程序肯定会串行顺序执行,不可能出现线程安全问题。
- 2. 操作共享资源:所谓共享资源是指多个线程或进程可以同时访问和使用的资源。如果每个线程都是操作自己的局部变量,尽管满足条件1,但也不会出现线程安全问题。
- 3. 至少存在一个写操作:如果是多线程读取共享资源,尽管满足了前 2个条件,但是读操作天然是幂等的,因此也不会出现线程安全的问题,所以线程中至少存在一个写操作。
上面从表象上说明线程安全需要具备的 3个条件,在 Java中,线程安全性通常涉及以下 3个指标:
- 原子性(Atomicity):操作要么全部完成,要么全部不完成。
- 可见性(Visibility):一个线程对共享变量的修改对其他线程是立即可见的。
- 有序性(Ordering):程序的执行顺序符合预期,不会因为编译器优化或CPU重排序而改变。
导致线程安全的根因
在 Java中,造成线程安全问题的根因是硬件结构,为了消除 CPU和主内存之间的硬件速度差,通常会在两者之间设置多级缓存(L1 ~ L3),如下图:
Java为了适配这种多级缓存的硬件构造,设计了一套与之对应的内存模型(JMM,Java memory model,包括主内存和工作内存,如下图:
- 主内存:所有的变量都存储在主内存中。
- 工作内存:每个线程都有自己的工作内存,会将主内存的共享变量复制到自己的工作内存中,然后做后续业务操作,最终再将工作内存中的变量刷新到主内存。
线程对变量的所有操作(读取、写入)都必须在工作内存中进行,而不能直接读写主内存中的变量。线程之间无法直接访问对方的工作内存,变量的传递需要通过主内存来完成。
关于 Java内存模型的原理,我们会在另外的文章中单独讲解,本文只是概要性的总结。
原子性
在数据库事务ACID
中也有原子性(Atomicity)的概念,它是指一个操作是不可分割的,即要么全部执行,要么全部不执行。Java线程安全中的原子性
与数据库事务中的原子性本质是一样的,只是它们应用的上下文和具体实现有所不同。
Java提供了多种方式来保证原子性,比如 同步块、锁或者原子类。
为了更好的说明原子性
,我们这里以一个反例来展示不具备原子性
的情况,如下代码:
1 | public class AtomicityTest { |
在上述代码中,i++
这种写法在我们的日常开发经常使用,但它不是一个原子操作,实际上i++
分为三步:
- 读取
i
的值 - 将
i
的值加 1 - 将结果写回给
i
如果多个线程同时执行increment()
方法,可能会导致i
的值不正确,比如有 3个线程A,B,C:
- 线程A读取
i
的值,并且将i
的值加 1,但是还未将结果写回给i
; - 此时,线程B读取
i
的值仍然是0,并且将i
的值加 1; - 线程A 将结果写回给
i
,将i
设置为 1; - 线程B 将结果写回给
i
,将i
设置为 1; - 线程C 读取
i
的值为1,并且将i
的值加 1,并且将结果写回给i
,将i
设置为 2;
3个线程都对i
进行i++
操作,预期i
的最终值是 3,但因为i++
无法保证原子性,因此,i
最终的值未达到预期的值。
可见性
可见性
是指一个线程对共享变量的修改,其他线程能立刻看到。在Java中,volatile
关键字可以保证变量的可见性。
为了更好的说明可见性
,我们这里以一个示例进行分析,如下代码:
1 | public class VisibilityTest { |
在上述代码中,变量running
是一个全局变量,如果没有使用volatile
关键字,running 变量的修改可能不会被其他线程立即看到。
有序性
有序性
是指程序代码的执行顺序。在单线程环境中,代码的执行顺序通常是按照代码的书写顺序执行的。然而,在多线程环境中,编译器、JVM和CPU可能会为了优化性能进行指令重排序(Instruction Reordering),这可能会导致代码的执行顺序与预期不一致。
Java内存模型(Java Memory Model, JMM)允许编译器和处理器进行指令重排序,但会保证单线程内的执行结果和多线程内的同步结果是正确的。
这里以一个反例来展示不具备有序性
,如下代码:
1 | public class ReorderingExample { |
在上述代码中,read()
方法可能会看到flag=true
,但x
仍然为 0,因为编译器或CPU可能对指令进行重排序。
如何保证线程安全
在 Java中,通常可以通过以下几个方式来保证线程安全。
synchronized关键字
synchronized
是Java的一个原语关键字,它可以保证方法或代码块在同一时刻只能被一个线程执行,从而确保原子性和可见性。
下面的代码是synchronized
关键字的简单使用:
1 | public class SynchronizedTest { |
Lock 接口
Lock
接口提供了比synchronized
更灵活的锁机制,常用的实现类有 ReentrantLock 可重入锁。
下面的代码是Lock
关键字的简单使用:
1 | import java.util.concurrent.locks.Lock; |
原子类
Java提供了一些原子类,如 AtomicInteger、AtomicLong 和 AtomicReference,它们通过CAS(Compare-And-Swap)操作实现了非阻塞的线程安全。
下面的代码是AtomicInteger
原子类的简单使用:
1 | import java.util.concurrent.atomic.AtomicInteger; |
ThreadLocal 类
ThreadLocal
类提供了线程局部变量,每个线程都有自己独立的变量副本,从而避免了共享数据的竞争。
下面的代码是ThreadLocal
类的简单使用:
1 | public class ThreadLocalExample { |
分布式锁
Redis 分布式锁 或者 Zookeeper分布式锁是分布式环境下保证线程安全的常用方法。关于两种分布式锁的原理,会在其他的文章详细分析。
结论
线程安全是 Java多线程编程中很重要的一部分,本文讲解了什么是线程安全以及产生线程安全问题的根因,并且通过原子性,有序性,可见性对线程安全进行了分析。
- 硬件的多级缓存和Java与之对应的内存模型是导致线程安全的根因;
volatile
可以保证变量的可见性,但不能保证原子性,因此无法保证线程安全;synchronized
,虚拟机锁,原子类,分布式锁可以保证线程的安全性;
学习交流
如果你觉得本文章对你有帮助,感谢转发给更多的好友,关注我的公众号:猿java,为你呈现更多的硬核文章。