1、ThreadLocal
解决线程安全问题除了 synchronized 的同步体系之外,还有 ThreadLocal 类。二者虽然都用于解决多线程并发访问,但是有着本质差别:
- synchronized 是利用锁的机制,使变量或代码块在某一时该仅仅能被一个线程访问。
- ThreadLocal 是线程本地变量(或称线程本地存储),为每个线程都提供了变量的副本,使得每个线程在某一时间访问到的并非同一个对象,不会和其它线程的变量副本冲突,实现了线程的数据隔离。
1.1 基本使用
ThreadLocal<T> 是一个能存放 T 类型的 ThreadLocal 对象,此时不论什么一个线程能够并发访问这个变量,对它进行写入、读取操作,都是线程安全的。
下面来看个使用示例:
/**
* ThreadLocal 常用的 4 个方法:
* 1.public void set(Object value)
* 设置当前线程的线程局部变量的值
* 2.public Object get()
* 该方法返回当前线程所对应的线程局部变量
* 3.public void remove()
* 将当前线程局部变量的值删除,目的是为了减少内存的占用。需要指出的是,
* 当线程结束后,对应该线程的局部变量将自动被垃圾回收,所以显式调用该
* 方法清除线程的局部变量并不是必须的操作,但它可以加快内存回收的速度。
* 4.protected Object initialValue()
* 返回该线程局部变量的初始值,该方法是一个 protected 方法,
* 显然是为了让子类覆盖而设计的,缺省实现直接返回一个 null。该
* 方法被延迟调用,在线程第1次调用 get() 时才执行,并且仅执行1次
*/
public class ThreadLocalTest {
private static final int THREAD_COUNT = 3;
private static ThreadLocal<String> threadLocal1 = new ThreadLocal<>();
private static ThreadLocal<Integer> threadLocal2 = new ThreadLocal<>();
private static class TestThread implements Runnable {
private int id;
public TestThread(int id) {
this.id = id;
}
@Override
public void run() {
String threadName = Thread.currentThread().getName();
threadLocal1.set("线程" + id);
if (id == 1) {
threadLocal2.set(10086); // 只有 Thread-1 设置 threadLocal2。
}
// ① 如果线程中的 ThreadLocalMap 里没有 ThreadLocal 作为 key 的
// Entry,那么 ThreadLocal.get() 拿到的就是一个初始值 null
System.out.println(threadName + ":" + threadLocal1.get() + "/" + threadLocal2.get());
}
}
private void testThreadLocal() {
Thread[] threads = new Thread[THREAD_COUNT];
for (int i = 0; i < THREAD_COUNT; i++) {
threads[i] = new Thread(new TestThread(i));
}
for (int i = 0; i < THREAD_COUNT; i++) {
threads[i].start();
}
}
public static void main(String[] args) {
new ThreadLocalTest().testThreadLocal();
}
}
输出结果如下:
Thread-0:线程0/null
Thread-2:线程2/null
Thread-1:线程1/10086
1.2 实现解析
静态关系
Thread 内部有一个 ThreadLocalMap 对象,该对象持有一个 Entry 数组,Entry 是 ThreadLocal 与该 ThreadLocal 对象保存的值的键值对,所以 Entry 数组保存了所属线程内所有的 ThreadLocal 对象。以上面示例代码为例,Thread 与 ThreadLocal 的关系如下图:
参考上图,进入源码。Thread 类持有一个 ThreadLocalMap 对象:
public class Thread implements Runnable {
// threadLocals 被 ThreadLocalMap 类维护
ThreadLocal.ThreadLocalMap threadLocals = null;
}
ThreadLocalMap 内定义了 Entry,并且持有一个 Entry 数组:
static class ThreadLocalMap {
static class Entry extends WeakReference<ThreadLocal<?>> {
/** The value associated with this ThreadLocal. */
Object value;
Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v;
}
}
// 初始容量,必须是 2 的幂
private static final int INITIAL_CAPACITY = 16;
// 可以扩容,但是数组长度必须是 2 的幂
private Entry[] table;
// Entry 的数量
private int size = 0;
// table 扩容后的容量阈值,初始为 0
private int threshold;
}
以上代码能反映出上图中各类之间的静态关系,下面再看看 ThreadLocal 中的操作是如何将这些关系动态建立起来的。
set 方法
public void set(T value) {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null)
map.set(this, value);
else
createMap(t, value);
}
set() 内部会先获取当前线程持有的 ThreadLocalMap 对象:
ThreadLocalMap getMap(Thread t) {
return t.threadLocals;
}
如果这个 ThreadLocalMap 已经创建过了,就直接将 value 设置进去:
static class ThreadLocalMap {
private void set(ThreadLocal<?> key, Object value) {
Entry[] tab = table;
int len = tab.length;
int i = key.threadLocalHashCode & (len-1);
for (Entry e = tab[i];
e != null;
e = tab[i = nextIndex(i, len)]) {
ThreadLocal<?> k = e.get();
if (k == key) {
e.value = value;
return;
}
if (k == null) {
replaceStaleEntry(key, value, i);
return;
}
}
// 将新的 Entry 存入 table
tab[i] = new Entry(key, value);
int sz = ++size;
if (!cleanSomeSlots(i, sz) && sz >= threshold)
rehash();
}
}
如果 ThreadLocalMap 对象还没创建,那么就创建它并存入新的 Entry:
void createMap(Thread t, T firstValue) {
t.threadLocals = new ThreadLocalMap(this, firstValue);
}
static class ThreadLocalMap {
ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) {
table = new Entry[INITIAL_CAPACITY];
int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1);
// 存入新的 Entry
table[i] = new Entry(firstKey, firstValue);
size = 1;
setThreshold(INITIAL_CAPACITY);
}
}
get 方法
get() 会返回 ThreadLocal 内保存的值,或者在 ThreadLocal 尚未创建的情况下为其赋初始值并返回:
public T get() {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null) {
ThreadLocalMap.Entry e = map.getEntry(this);
if (e != null) {
@SuppressWarnings("unchecked")
T result = (T)e.value;
return result;
}
}
return setInitialValue();
}
主要看当前线程持有的 ThreadLocalMap 是否已经被创建:
- 如果已经创建,那么就通过 ThreadLocalMap 的 getEntry() 去获取当前 ThreadLocal 对象对应的 Entry,找到 Entry 之后取出 Value 并返回。
- 如果没有创建,或者创建了,但是在 ThreadLocalMap 中没有找到 ThreadLocal 对象对应的 Entry,就调用 setInitialValue() 返回一个初始值。
setInitialValue() 会生成一个初始值,并且将这个初始值设置到 ThreadLocalMap 的 Entry 中,如果此时还没有创建 ThreadLocalMap 对象,它还会创建该对象并设置初始值:
private T setInitialValue() {
// initialValue() 的默认实现会直接返回一个 null,子类可重写该方法
T value = initialValue();
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null)
map.set(this, value);
else
createMap(t, value);
return value;
}
这里我们能看到,ThreadLocal 做了一个“延迟赋初始值”的操作,也就是说,一个 ThreadLocal 如果此前没有调用过 set(),没有在 ThreadLocalMap 中创建自己的 Entry<ThreadLocal,Value> 的话,那么直到调用 get() 时,ThreadLocal 才会将 Entry<ThreadLocal,InitialValue> 存入 ThreadLocalMap。而不是在创建 ThreadLocalMap 时就将所有的 ThreadLocal 及其对应的初始值封装进 Entry 并存入 ThreadLocalMap。
延迟初始化会导致线程内部的存储结构不同。我们回头看【简单使用】一节中的 ThreadLocalTest,按照那套代码,线程内的存储结构应该是下面这样:
但如果去掉 ① 处的打印语句,那么三个线程内部的状态图就应该像【静态关系】一节中的图一样,只有 Thread-1 的 Entry[] 中有 ThreadLocal1 和 ThreadLocal2,而另外两个线程中就只有 ThreadLocal1,因为只有 Thread-1 调用了 ThreadLocal1 的 set(),而另外两个线程没有调用 get() 和 set(),所以 Entry 数组中根本就没有 ThreadLocal2 对应的 Entry。
1.3 实现方式与效率
观察上面的线程存储结构图,发现ThreadLocalMap 和 ThreadLocal 对象在每一个线程中都有一份,而没有采用在 ThreadLocal 中用 Map<Thread,T> 这种形式:
public class MyThreadLocal<T> {
/*存放变量副本的map容器,以Thread为键,变量副本为value*/
private Map<Thread,T> threadTMap = new HashMap<>();
public synchronized T get() {
return threadTMap.get(Thread.currentThread());
}
public synchronized void set(T t) {
threadTMap.put(Thread.currentThread(),t);
}
}
这种写法确实实现了为每个线程保留各自的值,但是有一个显而易见的问题,多个线程共用一个 Map,而 Map 是同步资源,当线程数量过多时,会引起 ThreadLocal 的效率急剧降低。
《Java 并发编程实战》一书中指明,在适度竞争的情况下,ThreadLocal 的效率要高于 Automic 和 ReentrantLock:
ThreadLocal 不能代替同步机制。同步是为了同步多个线程相同资源的并发访问,是多线程之间进行通信的有效方式;而 ThreadLocal 是为了隔离多个线程的数据共享,从根本上避免多个线程之间对共享资源(变量)的竞争,也就不需要对多个线程进行同步了。
2、CAS 基本原理
原子操作:一个操作无论简单还是复杂,所有指令要么全都做完了,要么全都没做。synchronized 包围的语句就是原子操作。
虽然用 synchronized 就能完成原子操作,但是如果我只想让 i++ 这样的简单语句变成原子操作,难道也要使用 synchronized 吗?显然不是。现代 CPU 提供了 CAS 指令,可以用来执行轻量级的原子操作。
2.1 实现原理
CAS(Compare and Swap)的原理:利用了现代处理器都支持的 CAS 指令,循环这个指令,直到成功为止。
结合上图说一下 CAS 的执行步骤:
1. 所有线程先从内存拿到要修改的变量的值,即图中说的旧值。
2. 所有线程对变量进行计算得到新值(这一步执行后,一个变量存在于 3 个位置,分别是内存、线程旧值、线程新值)。
3. 先做 compare 操作,拿变量当前在内存中的值和第 1 步中获取的旧值进行比较,如果二者相同,则进行 swap 操作,把第 2 步得到的新值存入内存中,否则说明有其它线程先于当前线程更改了变量值,因此不能做 swap 操作,只能从第 1 步重来。这一步的 compare 和 swap 两个操作是由 CPU 指令保证的原子操作,只能都执行或者都不执行。
在执行第 3 步的时候,只有执行速度最快的那个线程才能 compare 成功并执行 swap 操作,剩余的线程都要再重新开始这个步骤,一直无法 compare 成功的线程就一直在循环这个过程,也称为自旋
。
比如说现在就有三个线程要执行 count++ 操作,count 初始值为0,使用 CAS 其过程图如下:
假设三个线程编号为 0、1、2,count 初始值为 0,三个线程中保存的 count 的值也为初始值 0。接下来的运行步骤:
1. 图 1,三个线程先从内存中获取到 count 的旧值为 0,然后对 count 进行计算得到新值 1(注意旧值和新值不是一个变量存的)。
2. 图 2,进入 CAS 步骤,假设线程 0 执行速度最快先进行 compare 操作,比较线程 0 中的 count 旧值与当前内存中的 count 值是否相等,相等就进行 swap 把计算出来的新值替换给内存中的 count,此时内存中的 count 值变为 1,线程 0 任务执行完毕。
3. 图 3,接下来悲催的线程 1 和线程 2 也要做 compare 操作了,这两个线程中 count 的旧值还是 0,进入 CAS 区判断时,由于内存 count 在上一步计算后已经变为 1,判断失败就不会做 swap 操作。线程 1、2 只能像图 4 那样重新来过。
注意 CAS 本身是没有让 compare 失败的线程循环的,它只是一个原子操作,要么成功要么失败。自旋是我们为了让业务代码一定能够被执行,自己加上去的。
CAS 这种先进行计算后进行校验的锁称为乐观锁,而像 synchronized 这种先进行校验,没有锁就不能执行的锁,称为悲观锁。通常情况下,ThreadLocal 的效率最高,然后是原子操作,性能最不好的是悲观锁:
这是因为对于像 ReentrantLock 和 synchronized 这样的悲观锁,没有获得锁的线程要进入阻塞状态,一旦阻塞就会发生上下文切换,每次切换大概需要 5000~20000 个单位时间,也就是大概 3~5 毫秒,进入阻塞状态和从阻塞状态出来都要进行上下文切换,这就是 6~10 毫秒。而 CAS 中的线程不会主动进入阻塞状态, 执行一条指令的时间大概在 0.6 纳秒,即使可能需要循环多次,比如 1000 次,600 纳秒也还是要比悲观锁切换上下文的时间少很多。因此现在并发编程都是朝着无锁化的 CAS 机制发展的。
2.2 CAS 的缺点
既然 CAS 这么强,那为什么 synchronized 和 ReentrantLock 还有生存空间呢?任何事务都有两面性,不可能只有优点没有缺点。CAS 具有以下缺点:
- ABA 问题:先执行的线程把变量从初值A改成B,再改回为初值A,后续执行的线程在只做值比较时不会发现变量值已经被改过。在某些场景下可能会引发问题。
- 开销问题:拿不到执行权的线程会一直自旋,对 CPU 是一种额外开销。
- 使用限制:只能保证一个共享变量的原子操作,想把改变多个变量值的操作变成原子操作是不能用 CAS 方式实现的,还是得用 synchronized 和 ReentrantLock 等。
ABA 问题是指,一个 CAS 原子操作的变量,初值为 A,线程 1 在线程 2 执行之前做了两次 CAS 操作,先将变量的值由 A 改为 B,再由 B 改成 A,等到线程 2 进行 compare 时发现值并没有发生变化,进而做了 swap 操作。但是实际上,现在的变量值 A 已经不是初值 A 了。
ABA 问题可能不会对业务产生影响。比如说对于一个单纯的库存数量来说,先执行的线程做的 ABA 操作,并不会引发后续执行线程在库存数量的处理上产生什么问题。但是,换一个环境,如果多线程在做同一个堆栈的处理,就可能会发生问题:
Thread1 的任务是弹栈,Thread2 的任务是把 A1 元素的值改为 A2。假如 Thread1 先弹出了 A1、B、C 后 Thread2 获得了执行权,compare 时发现仍是初值 A1,于是把 D 上面的这个 A1 改成了 A2,这就造成了两种不同的结果。
规避掉 ABA 问题的一个方法,使用带引用的 CAS,如 AtomicMarkableReference 和 AtomicStampedReference(下一节会介绍)。相当于给数据加上版本号,只要有线程改了数据,所有数据的版本号都要变成新的,然后在 compare 时不仅比较数据,也比较版本号,如果版本号不同,即使数据的值相同也认为数据发生了变化。
2.3 JDK 提供的原子操作类
JDK 中提供的原子操作类:
更新基本类型类:AtomicBoolean,AtomicInteger,AtomicLong
更新数组类:AtomicIntegerArray,AtomicLongArray,AtomicReferenceArray
更新引用类型:AtomicReference,AtomicMarkableReference,AtomicStampedReference
基本类型使用示例:
public class UseAtomicInt {
private static AtomicInteger ai = new AtomicInteger(10);
public static void main(String[] args) {
// 先 get 值再自加1,相当于 i++
System.out.println(ai.getAndIncrement()); // 10
// ++i
System.out.println(ai.incrementAndGet()); // 12
// 先加24再get值
System.out.println(ai.addAndGet(24)); // 36
// 先get值再加24
System.out.println(ai.getAndAdd(24)); // 36
System.out.println(ai); // 60
}
}
AtomicReference 可以解决“只能保证一个共享变量的原子操作”的问题。把多个共享变量打包到一个对象中去改:
/**
* 单一的原子对象无法通过 CAS 操作更新两个共享变量,但是我们可以将这两个变量封装到
* 一个类当中,如这里的 UserInfo,然后通过 AtomicReference 更新 UserInfo 达到目的
*/
public class UseAtomicReference {
private static AtomicReference<UserInfo> atomicReference;
public static void main(String[] args) {
UserInfo user = new UserInfo("Tom", 14);
atomicReference = new AtomicReference<>(user);
// 更新 user
UserInfo updateUser = new UserInfo("Jay", 16);
atomicReference.compareAndSet(user, updateUser);
System.out.println(atomicReference.get()); // UserInfo{name='Jay', age=16}
System.out.println(user); // UserInfo{name='Tom', age=14}
}
static class UserInfo {
private volatile String name;
private int age;
public UserInfo(String name, int age) {
this.name = name;
this.age = age;
}
// getters and setters...
}
}
AtomicMarkableReference 和 AtomicStampedReference 正是我们上面说的解决 ABA 问题的版本戳。前者只关心数据有没有被动过,后者不仅关注数据是否被动过,还关心被动了几次:
public class UseAtomicStampedReference {
/*
* @param initialRef the initial reference
* @param initialStamp the initial stamp
*/
private static AtomicStampedReference<String> asr = new AtomicStampedReference<>("test", 0);
public static void main(String[] args) throws InterruptedException {
//拿到当前的版本号(旧)
final int oldStamp = asr.getStamp();
final String oldReference = asr.getReference();
System.out.println(oldReference + "============" + oldStamp); // test============0
Thread rightStampThread = new Thread(new Runnable() {
@Override
public void run() {
// Thread-0:当前变量值:test-当前版本戳:0-true
System.out.println(Thread.currentThread().getName() + ":当前变量值:"
+ oldReference + "-当前版本戳:" + oldStamp + "-"
+ asr.compareAndSet(oldReference,
oldReference + "+Java", oldStamp,
oldStamp + 1));
}
});
Thread errorStampThread = new Thread(new Runnable() {
@Override
public void run() {
String reference = asr.getReference();
// Thread-1:当前变量值:test+Java-当前版本戳:1-false
System.out.println(Thread.currentThread().getName()
+ ":当前变量值:"
+ reference + "-当前版本戳:" + asr.getStamp() + "-"
+ asr.compareAndSet(reference,
reference + "+C", oldStamp,
oldStamp + 1));
}
});
rightStampThread.start();
rightStampThread.join();
errorStampThread.start();
errorStampThread.join();
System.out.println(asr.getReference() + "============" + asr.getStamp()); // test+Java============1
}
}
AtomicStampedReference 的构造方法有两个参数,initialRef 和 initialStamp 分别表示初始的引用对象和版本戳。示例中初始值给的是 “test” 和0。
然后我们启动两个线程对 AtomicStampedReference 对象做 CAS 的原子操作,用到了 AtomicStampedReference 的 compareAndSet() 方法:
/**
* Atomically sets the value of both the reference and stamp
* to the given update values if the
* current reference is {@code ==} to the expected reference
* and the current stamp is equal to the expected stamp.
*
* @param expectedReference the expected value of the reference
* @param newReference the new value for the reference
* @param expectedStamp the expected value of the stamp
* @param newStamp the new value for the stamp
* @return {@code true} if successful
*/
public boolean compareAndSet(V expectedReference,
V newReference,
int expectedStamp,
int newStamp) {
Pair<V> current = pair;
return
expectedReference == current.reference &&
expectedStamp == current.stamp &&
((newReference == current.reference &&
newStamp == current.stamp) ||
casPair(current, Pair.of(newReference, newStamp)));
}
方法需要的四个参数分别为:期望的引用对象、新的引用对象、期望的时间戳、新的时间戳。“期望的值”可以理解为线程在做原子操作之前,该对象在内存中的值。如果期望值和在做 compare 指令时,AtomicStampedReference 对象中保存的值相同,说明没有其它线程抢在当前线程之前改了这个值,那么就可以去做接下来的 swap 操作。否则,compare 失败,就要更新期望值,开始自旋。
在上面的例子中,rightStampThread 通过 join() 抢先执行,它执行成功并且把 AtomicStampedReference 的对象和时间戳分别更新为 “test+Java” 和 1。而 errorStampThread 后执行,虽然它期望的对象值已经更新为 “test+Java”,但是使用的还是老时间戳 0,因此 CAS 操作失败,compareAndSet() 返回 false,最后一行的打印结果是 rightStampThread 更改后的结果。
如果想让 errorStampThread 也能成功更新 AtomicStampedReference 对象的内容,那么就要传入当前的时间戳:
Thread errorStampThread = new Thread(new Runnable() {
@Override
public void run() {
String reference = asr.getReference();
int stamp = asr.getStamp();
// Thread-1:当前变量值:test+Java-当前版本戳:1-false
System.out.println(Thread.currentThread().getName()
+ ":当前变量值:"
+ reference + "-当前版本戳:" + asr.getStamp() + "-"
+ asr.compareAndSet(reference,
reference + "+C", stamp,
stamp + 1));
}
});
我们介绍了这么保证多线程安全的机制,不可能说有哪一种是无敌的,可以在所有情况下都好用的机制,每种都有各自的优缺点,不能单纯的说哪一种机制好,效率高就用哪一种,哪种缺点多就不用,而是需要结合产品本身的应用场景和体量,来决定使用哪一种最适合,这是作为架构师的基本能力。
另外,不论是 Kotlin、Java 还是 Scala,都是基于 JVM 的,学习的时候不能只学习语言表面上的东西,还要理解其中的机制。
任何学习就是不断的重复,学习也是这个世界上为数不多,你复出了就一定有回报的东西。