ThreadLocal、CAS、Atomic及并发安全
一、ThreadLocal
1. 为什么要有ThreadLocal ?
ThreadLocal的核心作用是:让变量成为“线程私有”的副本,每个线程操作自己的那份,互不干扰,以此避免多线程共享变量的安全问题;同时,线程内的变量能在任意方法里直接获取,不用一层层传参数,简化代码。
2. ThreadLocal的使用
ThreadLocal的使用非常简单,只有下面四个方法:
为了性能上的优化,Thread内部设置了一个ThreadLocal.ThreadLocalMap的成员变量。这样每个线程访问自己内部的变量可以无需传参即可跨方法传递使用。
- void set(Object value)
- public Object get()
- public void remove()
- protected Object initialValue()
3. ThreadLocal的实现
1.结构设计
ThreadLocal的内部实现非常有意思,每个Thread内部设置了一个ThreadLocal.ThreadLocalMap的成员变量。ThreadLocalMap内部用来存储值是一个Entry数组,可以看作是一个基于数组结构实现的哈希表,Key值为ThreadLocal,Value值为具体存储的值,下标值的计算是根据计算出的哈希值对数组长度取余
2.哈希冲突
HashMap在面对哈希冲突时,使用链表+红黑树的方式来解决哈希冲突。但是ThreadLcoal在面对哈希冲突时,使用开放线性寻址法来解决哈希冲突(其实源码是加一个固定的偏移量),哈希冲突比较频繁的时候,会进行扩容来减少哈希冲突,并且重新根据哈希值计算每个元素的坐标。初始容量为16,扩容的阈值为内部存储元素占据Entry数组的2/3,每次扩容后数组长度都会翻倍
3.内存泄漏
内存泄漏是指程序运行过程中,由于程序代码的bug出现了无法被回收的内存区域,由于无法回收这部分内存空间。这种情况被称为内存泄漏。
使用ThreadLocal的时候,必须要提防一个内存泄漏的问题,实际上正常使用ThreadLocal(将ThreadLocal作为类中静态常量使用的时候)的时候,是不会产生内存泄漏的问题的。只有将ThreadLocal作为局部变量使用时,会出现无法回收内存空间的内存泄漏现象。这还和JVM的垃圾回收机制有关系。
在局部变量的使用场景下,每个value都会被设置到Entry,放到Entry数组中,虽然key是弱引用指向,每次垃圾回收都会回收掉Key值(弱引用特质,每次gc会回收内存空间),由于Java项目中都会使用线程池(天生多线程),线程对象会一直存活,JVM在进行垃圾回收时,由于有强引用指向ThreadLocalMap的value值(key回收掉了,value还在)
那value可以每次gc都回收掉吗?这样不就不会产生内存泄漏了么?我们需要站在设计者的角度进行考虑.如果把value值也设计成弱引用,java程序中发生gc是非常高频的,如果业务执行中,发生gc后使这个存储的value值失效,就会出现空指针异常。
为什么Key值设置成弱引用呢?
ThreadLocal的正确使用方式是调用完成后在可靠的代码区域进行remove回收内存,但肯定会存在有部分程序在编写时,不正常调用remove或者在安全代码区域外调用remove,进而导致remove代码失效,如果Key值设置为强引用,会导致key和value的双重泄漏,所以设置成弱引用本质上是一种防御性编程(xswl,想的太深了)
弱引用使用场景一般是用来做本地的缓存实现(GC后自动回收)
1 | public class ThreadLocal{ |
二、CAS & Atomic原子操作详解
什么是原子操作?如何实现原子操作?
原子性:实际上是事务的概念,指的是多个操作不可分割的特性,要么全部执行,要么全部不执行,这种特性叫做原子性。
CAS机制(Compare and swap):CAS基本思想是比较并替换
内存地址V,预期值A,新值B,具体执行逻辑只有两步:
- 1.比较:判断内存地址V中存储值是否为预期值A
- 2.替换:
- 若相等,将V中的值更新为B
- 若不等,不做任何操作或重试
一般实现原子操作都会使用锁机制,虽然锁机制可以满足简单基本的业务需求,但是有的时候我们需要更有效、灵活的机制。sychronized这种基于阻塞的锁机制,持有锁的时候,其他线程会被全部阻塞,直到持有锁的线程释放锁。为了解决这个问题,Java提供了Atomic的原子工具类。
在JDK早期版本:Aotmic内部机制一般均为循环CAS重试来实现的,CAS这种无锁无阻塞循环尝试更新的操作,在性能上通常比sychronized锁机制更良好。
但是JDK不断对sychronized锁进行优化,现在两者性能上差距基本上没什么区别了。
虽然CAS在锁竞争弱的情况下性能上比阻塞锁机制要优秀,但是这是有代价的。CAS有下面三个缺陷
ABA问题
ABA问题是CAS操作的弊病之一,在多线程并发的条件下,虽然期望值是A,但可能已经被其他线程修改成A了,如果此时进行更新操作,就会因为这种ABA问题发生并发条件下的数据脏写。ABA问题的解决方案一般是使用版本号进行记录数据版本,来根据版本号+期望值 双期望值来决定要不要更新数据。
循环开销问题
虽然CAS是无锁不阻塞的方式进行尝试更新数据,但是高并发环境下大量线程竞争,循环导致的额外开销会非常大,导致CPU的空转开销,造成性能上的浪费,这种情况下更适合使用sychronized阻塞锁来保证数据的原子性、安全性。
只能保证一个共享变量的原子操作
CAS的机制决定了只能保证对一个共享变量进行原子操作。但并不是绝对的,我们可以把多个共享变量合并成一个共享变量的对象进行操作(但是这种情况下又会导致锁竞争加剧,性能下降严重)
AtomicInteger
- int addAndGet():CAS原子加操作,并返回结果
- boolean compareAndSet(int expect,int update):如果输入的数值等于预期值,则以原子方式设置该值
- int getAndIncrement():原子方式加1
- int getAndSet(int newValue):原子方式设置新值
解决ABA问题的Atomic原子类
AtomicMarkableReference和AtomicStampedReference
为了解决ABA问题,Java提供了两个原子类,这两者的区别是前者只关心有没有被修改过,不关心具体被修改过几次。后者会记录修改次数
由于原子类对高并发的写入性能开销比较大,所以jdk1.8之后引入了LongAdder类来解决写热点的问题,其内部用一个base的long类型,和一个Cell[]数组,使用数组来分散写热点事件。
三、线程安全问题
线程安全性
所谓线程安全,即所写代码在并发情况下使用时,总是能表现出正确的行为。反之,未实现线程安全的代码,表现的行为是不可预知的。
线程封闭
实现好的并发是一件非常困难的事情,所以很多时候我们都想躲避并发。避免并发最简单的方法就是线程封闭。就是把对象封装到一个线程里,只有这一个线程能看到这个对象。这个对象就算不是线程安全的也不会出现任何安全问题。
栈封闭
栈封闭是我们编程中遇到的最多的线程封闭。最常见的就是局部变量,由于局部变量不是被多个线程共享,所以不会出现并发问题。所以使用局部变量不使用全局变量,能在一定程度上避免并发带来的安全问题,比如ThreadLocal就是一个实现线程封闭的很好的数据结构。
无状态的对象
没有任何成员变量的类,就叫做无状态的类,这种类一定是线程安全的。但其内部对于其他对象进行操作并不一定是线程安全的(类本身线程安全,但是内部行为不一定对操作对象是线程安全的)。
加锁和CAS
我们最常用保证线程安全的手段,是使用synchronized关键字,使用显式锁以及各种原子变量,修改数据时使用CAS机制等
死锁
死锁是指两个或两个以上的进程在执行过程中,互相持有其他线程所需要的锁资源,并且等待其他线程释放锁资源,又不会释放自己已经持有的锁资源,而造成的一种阻塞现象,若无外力作用,它们都将无法推进下去。此时系统就处于死锁状态。
学术上死锁发生死锁必须具备四个条件:
- 互斥条件:资源具有独占性,同一时间只能被一个进程占用。例如,打印机同一时间只能处理一个打印任务。
- 请求与保持条件:进程已持有至少一个资源,同时又向其他进程请求新的资源,且该资源正被其他进程占用。例如,进程 A 持有打印机,又请求进程 B 正在使用的扫描仪。
- 不可剥夺条件:进程已获得的资源,在未主动释放前,不能被其他进程强制剥夺。例如,进程 B 正在使用的扫描仪,不能被系统强制收回分配给进程 A。
- 循环等待条件:多个进程之间形成闭环的资源等待链,每个进程都在等待下一个进程所持有的资源。例如,进程 A 等待进程 B 的资源,进程 B 等待进程 C 的资源,进程 C 等待进程 A 的资源。
死锁的预防核心
预防死锁的本质的就是破坏四个条件中的任意一个,即可从根本上避免死锁发生。常见思路包括:采用资源预先分配策略(破坏请求与保持条件)、允许资源强制回收(破坏不可剥夺条件)、终止死锁中的线程(破坏循环等待)等。一般情况下互斥性是无法避免的。避免死锁还有一些常见算法:有序资源分配法和银行家算法等。
线程安全的单例模式
单例模式是一种比较常见的设计模式,最常见的是DCL(double check lock)单例实现,下面是说明和代码实现:
- volatile 关键字:防止instance = new Singleton()这句代码的指令重排序(分配内存→初始化对象→引用指向内存)。如果没有 volatile,可能导致其他线程获取到 “未完全初始化” 的实例(引用已指向内存,但对象还没初始化完)。
- 双重检查:
- 第一次检查(同步块外):避免每次调用getInstance()都进入同步块,减少性能损耗(多数情况下实例已初始化,直接返回)。
- 第二次检查(同步块内):防止多线程同时通过第一次检查后,在同步块内重复创建实例。
- 私有构造方法:禁止外部通过new Singleton()创建实例,确保唯一实例由getInstance()控制。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20public class Singleton {
// 1. 私有静态实例变量,用volatile修饰(关键!防止指令重排序)
private static volatile Singleton instance;
// 2. 私有构造方法,禁止外部通过new创建实例
private Singleton() {}
// 3. 公共静态方法,提供全局访问点
public static Singleton getInstance() {
// 第一次检查:未初始化时才进入同步块(减少同步开销)
if (instance == null) {
// 同步块:保证多线程下的原子性
synchronized (Singleton.class) {
// 第二次检查:防止多个线程同时通过第一次检查后重复创建实例
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}