线程知识总结(三)

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 的,学习的时候不能只学习语言表面上的东西,还要理解其中的机制。

任何学习就是不断的重复,学习也是这个世界上为数不多,你复出了就一定有回报的东西。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值