【Java 高并发程序设计】03 锁的优化及注意事项

1 提高锁性能的建议

锁是最常用的同步方法之一。在高并发的环境下,激烈的锁竞争会导致程序的性能下降,因此我们有必要讨论一些有关锁的性能问题,以及一些注意事项,比如避 免死锁、减小锁粒度、锁分离等。

1.1 减少锁持有时间

`对千使用锁进行并发控制的应用程序而言,在锁竞争过程中,单个线程对锁的持有时间与系统性能有着直接的关系。如果线程持有锁的时间越长,那么相对地,锁的竞争程度也就越激烈。

1.2 减小锁粒度

减小锁粒度也是一种削弱多线程锁竞争的有效手段。这种技术典型的使用场景就是ConcurrentHashMap 类的实现。对千 HashMap 来说,最重要的两个方法就是 get() 和 put()。 一种最自然的想法就是, 对整个 HashMap 加锁从而得到一个线程安全的对象,但是这样做,加锁粒度太大。

对于 ConcurrentHashMap 类,它内部进一步细分了若干个小的 HashMap, 称之为段 (SEGMENT)。在默认情况下,一个 ConcurrentHashMap 类可以被细分为 16 个段。如果需要在 ConcurrentHashMap 类中增加一个新的表 项, 并不是将整个 HashMap 加锁, 而是首先根据 hashcode 得到该表项应该被存放到哪个段中,然后对该段加锁,并完成 put() 方法操作。在多线程环境中,如果多个线程同时进行 put () 方法操作,只要被加入的表项不存放在同一个段中,线程间便可以做到真正的并行。

1.3 用读写分离锁来替换独占锁

使用读写分离锁 ReadWriteLock 可以提高系统的性能。使用读写分离锁来替代独占锁是减小锁粒度的一种特殊况。如果说减小锁粒度是通过分割数据结构实现的,那么读写分离锁则是对系统功能点的分割。在读多写少的场合,读写锁对系统性能是很有好处的。

1.4 锁分离

如果将读写锁的思想进一步延伸,就是锁分离。读写锁根据读写操作功能上的不同,进行了有效的锁分离。依据应用程序的功能特点,使用类似的分离思想,也可以对独占锁进行分离。一个典型的案例就是 LinkedBlockingQueue 的实现。

在 LinkedBlockingQueue 的实现中,take() 函数和 put() 函数分别实现了从队列中取得数据和往队列中增加数据的功能。虽然两个函数都对当前队列进行了修改操作,但是两个操作分别作用于队列的前端和尾端, 从理论上说,两者并不冲突。

在 JDK 的实现中,使用两把不同的锁分离了 take() 方法和 put() 方法的操作。如下所示。

/** Lock held by take, poll, etc */
private final ReentrantLock takeLock = new ReentrantLock();

/** Wait queue for waiting takes */
private final Condition notEmpty = takeLock.newCondition();

/** Lock held by put, offer, etc */
private final ReentrantLock putLock = new ReentrantLock();

/** Wait queue for waiting puts */
private final Condition notFull = putLock.newCondition();

1.5 锁粗化

通常情况下,为了保证多线程间的有效并发,会要求每个线程持有锁的时间尽量短,即在使用完公共资源后,应该立即释放锁。只有这样,等待在这个锁上的其他线程才能尽早地获得资源执行任务。

但是,凡事都有一个度,如果对同一个锁不停地进行请求、同步和释放,其本身也会消耗系统宝贵的资源,反而不利于性能的优化。

为此,虚拟机在遇到一连串连续地对同一个锁不断进行请求和释放的操作时,便会把所有的锁操作整合成对锁的一次请求,从而减少对锁的请求同步的次数,这个操作叫作锁的粗化。

2 Java 虚拟机对锁优化所做的努力

2.1 锁偏向

锁偏向是一种针对加锁操作的优化手段。它的核心思想是:如果一个线程获得了锁,那么锁就进入偏向模式。当这个线程再次请求锁时,无须再做任何同步操作。这样就节省了大量有关锁申请的操作,从而提高了程序性能。

因此, 对千几乎没有锁竞争的场合,偏向锁有比较好的优化效果,因为连续多次极有可能是同一个线程请求相同的锁。 而对于锁竞争比较激烈的场合,其效果不佳。因为在竞争激烈的场合,最有可能的情况是每次都是不同的线程来请求相同的锁。 这样偏向模式会失效, 因此还不如不启用偏向锁。

使用 Java 虚拟机参数-XX:+UseBiasedLocking 可以开启偏向锁。

2.2 轻量级锁

如果偏向锁失败,那么虚拟机并不会立即挂起线程,它还会使用一种称为轻量级锁的优化手段。轻量级锁的操作也很方便,它只是简单地将对象头部作为指针指向待有锁的线程堆栈的内部,来判断一个线程是否持有对象锁。如果线程获得轻量级锁成功,则可以顺利进入临界区。如果轻量级锁加锁失败,则表示其他线程抢先争夺到了锁,那么当前线程的锁请求就会膨胀为重量级锁。

2.3 自旋锁

锁膨胀后,为了避免线程真实地在操作系统层面挂起,虚拟机还会做最后的努力——自旋锁。系统会假设在不久的将来,线程可以得到这把锁。因此,虚拟机会让当前线程做几个空循环(这也是自旋的含义),在经过若干次循环后,如果可以得到锁,那么就顺利进入临界区。如果还不能获得锁,才会真的将线程在操作系统层面挂起。

2.4 锁消除

锁消除是一种更彻底的锁优化。Java 虚拟机在 TIT 编译时,通过对运行上下文的扫描,去除不可能存在共享资源竞争的锁。通过锁消除,可以节省毫无意义的请求锁时间。

因为在 Java 软件开发过程中,我们必然会使用一些 JDK 的内置 API,比如 StringBuffer、Vector 等。你在使用这些类的时候,也许根本不会考虑这些对象到底内部是如何实现的。比如,你很有可能在一个不可能存在并发竞争的场合使用 Vector。如下所示。

private String[] createString() {
  Vector<String> vector = new Vector<>();
    for (int i = 0; i < 100; i++) {
        vector.add(Integer.valueOf(i).toString());
    }
    return vector.toArray(new String[0]);
}

上述代码中的 Vector , 由千变量 v 只在 createStrings() 函数中使用,因此它只是一个单纯的局部变量。 局部变量在线程栈上分配的,属于线程私有的数据,因此不可能被其他线程访问。在这种情况下,Vector 内部所有加锁同步都是没有必要的。如果虚拟机检测到这种情况,就会将这些无用的锁操作去除。

锁消除涉及的一项关键技术为逃逸分析。所谓逃逸分析就是观察某一个变量是否会逃出某一个作用域。逃逸分析必须在 -server 模式下进行,可以使用 -XX :+DoEscapeAnalysis 参数打开逃逸分析。 使用 -XX:+ EliminateLocks 参数可以打开锁消除。

3 ThreadLocal

除了控制资源的访问外,我们还可以通过增加资源来保证所有对象的线程安全。ThreadLocal 使用的就是增加资源来保证线程安全。

3.1 ThreadLocal 的简单使用

从ThreadLocal 的名字上可以看到,这是一个线程的局部变量。也就是说,只有当前线程可以访问。

观察下面示例

public class ThreadLocalDemo1 {

    private static final SimpleDateFormat df = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");

    private static class ParseDate implements Runnable {

        private final int i;

        public ParseDate(int i) {
            this.i = i;
        }

        @Override
        public void run() {
            try {
                Date date = df.parse("2021-02-21 20:15:" + 1 % 60);
                System.out.println(i + " " + date);
            } catch (ParseException e) {
                e.printStackTrace();
            }
        }
    }

    public static void main(String[] args) {
        // SimpleDateFormat 是线程不安全的
        ExecutorService es = Executors.newFixedThreadPool(10);
        for (int i = 0; i < 1000; i++) {
            es.execute(new ParseDate(i));
        }
    }
}

上述代码在多线程中使用 SimpleDateFormat 对象实例来解析字符串类型的日期。执行上述代码,一般来说,很可能得到一些异常:

Exception in thread "pool-1-thread-7" Exception in thread "pool-1-thread-3" Exception in thread "pool-1-thread-1" java.lang.NumberFormatException: For input string: ""

Exception in thread "pool-1-thread-5" java.lang.ArrayIndexOutOfBoundsException: -1

出现这些问题的原因是 SimpleDateFormat 不是线程安全的,因此,在线程池中共享这个对象必然导致错误。

下面使用 ThreadLocal 为每一个线程创造一个 SimpleDateFormat 实例。

public class ThreadLocalDemo2 {

    private static final ThreadLocal<SimpleDateFormat> THREAD_LOCAL = new ThreadLocal<>();

    private static class ParseDate implements Runnable {

        private final int i;

        public ParseDate(int i) {
            this.i = i;
        }

        @Override
        public void run() {
            try {
                SimpleDateFormat df;
                if ((df = THREAD_LOCAL.get()) == null) {
                    THREAD_LOCAL.set(df = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"));
                }
                Date date = df.parse("2021-02-21 20:15:" + 1 % 60);
                System.out.println(i + " " + date);
            } catch (ParseException e) {
                e.printStackTrace();
            }
        }
    }

    public static void main(String[] args) {
        ExecutorService es = Executors.newFixedThreadPool(10);
        for (int i = 0; i < 1000; i++) {
            es.execute(new ParseDate(i));
        }
    }
}

// 运行结果
1 Sun Feb 21 20:15:01 CST 2021
4 Sun Feb 21 20:15:01 CST 2021
8 Sun Feb 21 20:15:01 CST 2021
11 Sun Feb 21 20:15:01 CST 2021
12 Sun Feb 21 20:15:01 CST 2021

运行结果是没有异常信息的。需要注意的是为每一个线程分配不同的对象,需要在应用层面保证,ThreadLocal 只起到了简单的容器作用。

3.2 ThreadLocal 的实现原理

ThreadLocal 如何保证这些对象只被当前线程访问呢?

我们需要关注的是 ThreadLocal 的 se t() 方法和 get() 方法。首先查看 set 方法

public void set(T value) {
    Thread t = Thread.currentThread();
    // 取得当前线程中的 threadLocals 实例
    ThreadLocalMap map = getMap(t);
    if (map != null)
        map.set(this, value);
    else
        createMap(t, value);
}

ThreadLocal 进行 set 时:首先获取当前线程对象,通过 getMap() 方法拿到当前线程的 ThreadLocalMap,并将值存入 ThreadLocalMap 中。而 ThreadLocalMap 相当于一个 Map,是定义在 Thread 内部的成员。

// Thread.java
ThreadLocal.ThreadLocalMap threadLocals = null;

而设置到 ThreadLocal 中的数据,就是写入了 threadLocals 中。其中,key 为 ThreadLocal 当前对象,value 就是我们需要的值。threadLocals 本身就保存了当前自己所在线程的所有“局部变量”,即 ThreadLocal 变量的集合。

get 方法就是将这个 Map 中的数据取出来:

public T get() {
    Thread t = Thread.currentThread();
    // 取得当前线程中的 threadLocals 实例
    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;
        }
    }
    // 客户端如果实现了 initialValue 方法,就是自动创建一个实例
    return setInitialValue();
}

get 方法先取得当前线程的 ThreadLocalMap 对象,然后通过将自己作为 key 取得内部的实际数据。

ThreadLocal 中的这些变量是维护在 Thread 类内部的 ( ThreadLocalMap 定义所在类),这也意味着只要线程不退出,对象的引用将一直存在。

一般来说当线程退出时,Thread 类会进行一些清理工作,其中就包括清理 ThreadLocalMap。

// 在线程退出前,由系统回调,进行资源回收
private void exit() {
    if (group != null) {
        group.threadTerminated(this);
        group = null;
    }
    /* Aggressively null out all reference fields: see bug 4006245 */
    target = null;
    // 加速资源回收
    threadLocals = null;
    inheritableThreadLocals = null;
    inheritedAccessControlContext = null;
    blocker = null;
    uncaughtExceptionHandler = null;
}

所以,使用线程池就意味着当前线程未必会退出。如果这样,将一些大的对象设置到 ThreadLocal 中(它实际保存在线程持有的 threadLocalsMap 内), 可能会使系统出现内存泄漏的可能(设置了对象到 ThreadLocal 中, 但是不清理它, 在你使用几次后,这个对象也不再有用了,但是它却无法被回收)。

此时,如果你希望及时回收对象,最好使用 ThreadLocal.remove() 方法将这个变量移除。

另外,可以将 ThreadLocal 对应的实例设置为 null,那么这个 ThreadLocal 对应的所有线程的局部变量都有可能被回收。查看下述例子:

public class ThreadLocalDemo3 {

    static final int COUNT = 5;
    static volatile CountDownLatch countDownLatch = new CountDownLatch(COUNT);

    private static volatile ThreadLocal<SimpleDateFormat> threadLocal = new ThreadLocal() {
        @Override
        protected void finalize() throws Throwable {
            System.out.println(this.toString() + " is gc");
        }
    };
    
    private static class ParseDate implements Runnable {

        private final int i;

        public ParseDate(int i) {
            this.i = i;
        }

        @Override
        public void run() {
            try {
                SimpleDateFormat df;
                if ((df = threadLocal.get()) == null) {
                    threadLocal.set(df = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"){
                        @Override
                        protected void finalize() throws Throwable {
                            System.out.println(this.toString() + " is gc");
                        }
                    });

                    System.out.println(Thread.currentThread().getId() + " create SimpleDateFormat");
                }
                Date date = df.parse("2021-02-21 20:15:" + 1 % 60);
                System.out.println(i + " " + date);
            } catch (ParseException e) {
                e.printStackTrace();
            } finally {
                countDownLatch.countDown();
            }
        }
    }

    public static void main(String[] args) throws InterruptedException {
        ExecutorService es = Executors.newFixedThreadPool(5);
        for (int i = 0; i < COUNT; i++) {
            es.execute(new ParseDate(i));
        }
        countDownLatch.await();
        System.out.println("mission complete");
        threadLocal = null;
        System.out.println("first gc complete");

        // 在设置 ThreadLocal 的时候会清除其中的无效对象
        threadLocal = new ThreadLocal<>();
        countDownLatch = new CountDownLatch(COUNT);
        for (int i = 0; i < COUNT; i++) {
            es.execute(new ParseDate(i));
        }
        countDownLatch.await();
        Thread.sleep(COUNT);
        System.gc();
        System.out.println("second gc complete");
    }
}

// 执行结果
14 create SimpleDateFormat
13 create SimpleDateFormat
12 create SimpleDateFormat
15 create SimpleDateFormat
11 create SimpleDateFormat
0 Sun Feb 21 20:15:01 CST 2021
4 Sun Feb 21 20:15:01 CST 2021
2 Sun Feb 21 20:15:01 CST 2021
3 Sun Feb 21 20:15:01 CST 2021
1 Sun Feb 21 20:15:01 CST 2021
mission complete
first gc complete
15 create SimpleDateFormat
13 create SimpleDateFormat
14 create SimpleDateFormat
1 Sun Feb 21 20:15:01 CST 2021
11 create SimpleDateFormat
3 Sun Feb 21 20:15:01 CST 2021
2 Sun Feb 21 20:15:01 CST 2021
12 create SimpleDateFormat
0 Sun Feb 21 20:15:01 CST 2021
4 Sun Feb 21 20:15:01 CST 2021
second gc complete
com.bin.锁优化.ThreadLocalDemo3$1@25062e72 is gc

上述代码是为了跟踪 ThreadLocal 对象及其内部 SimpleDateFormat 对象的垃圾回收。因此我们重载了 ThreadLocal 和 SimpleDateFormat 的 finalize 方法。这样,在对象被回收时,就可以看到它们的踪迹了。

从执行结果中可以看到,在第二次 GC 后,第一次创建的 10 个 SimpleDateFormat 的实例全部被回收。虽然我们没有手工 remove 这些对象。

实际上,ThreadLocalMap 的实现使用了弱引用。 弱引用是比强引用弱得多的引用。Java 虚拟机在垃圾回收时,果发现弱引用,就会立即回收。ThreadLocalMap 内部由一系列 Entry 构成,每一个 Entry 都是 WeakReference<ThreadLocal<?>>。

// ThreadLocalMap.Entry
static class Entry extends WeakReference<ThreadLocal<?>> {
    /** The value associated with this ThreadLocal. */
    Object value;

    Entry(ThreadLocal<?> k, Object v) {
        super(k);
        value = v;
    }
}

这里的参数 K 就是 Map 的 key,v 就是 Map 的 value。其中 k 也是 ThreadLocal 实例,作为弱引用使用 。因此,虽然这里使用 ThreadLocal 作为 Map 的 key,但是实际上,它并不真的持有 ThreadLocal 的引用。而当ThreadLocal 的外部强引用被回收时,ThreadLocalMap 中的 key 就会变成 null。当系统进行 ThreadLocalMap 清理时(比如将新的变量加入表中,就会自动进行一次清理),就会将这些垃圾数据回。

3.3 对性能的帮助

为每一个线程分配一个独立的对象对系统性能是否有帮助,这完全取决于共享对象的内部逻辑。如果共享对象对于竞争的处理容易引起性能损失,我们应该考虑使用 ThreadLocal 为每个线程分配单独的对象。一个典型的案例就是在多线程下产生随机数。

public class ThreadLocalDemo4 {

    static final int COUNT = 10000000;
    static final int THREAD_COUNT = 4;
    static ExecutorService es = Executors.newFixedThreadPool(THREAD_COUNT);
    static Random rd = new Random(132);

    private static volatile ThreadLocal<Random> threadLocal = new ThreadLocal() {
        @Override
        protected Object initialValue() {
            return new Random(132);
        }
    };

    private static class RandomTask implements Callable<Long> {

        /**
         * 0 : 多线程共享一个 Random
         * 1 : 多个线程个分配一个 Random
         */
        private final int mode;

        public RandomTask(int mode) {
            this.mode = mode;
        }

        public Random getRandom() {
            if(mode == 0) {
                return rd;
            } else {
                return threadLocal.get();
            }
        }


        @Override
        public Long call() throws Exception {
            long start = System.currentTimeMillis();
            for (int i = 0; i < COUNT; i++) {
                getRandom().nextInt();
            }
            long end = System.currentTimeMillis();

            System.out.println(Thread.currentThread().getName() + " spend " + (end - start) + "ms");
            return end - start;
        }
    }

    public static void main(String[] args) throws InterruptedException, ExecutionException {
        Future<Long>[] futures = new Future[THREAD_COUNT];
        for (int i = 0; i < THREAD_COUNT; i++) {
            futures[i] = es.submit(new RandomTask(0));
        }
        long totalTime = 0;
        for (int i = 0; i < THREAD_COUNT; i++) {
            totalTime += futures[i].get();
        }
        System.out.println("多线程访问同一个 Random 实例:" + totalTime + "ms");

        // 使用 ThreadLocal
        for (int i = 0; i < THREAD_COUNT; i++) {
            futures[i] = es.submit(new RandomTask(1));
        }
        totalTime = 0;
        for (int i = 0; i < THREAD_COUNT; i++) {
            totalTime += futures[i].get();
        }
        System.out.println("使用 ThreadLocal 包装 Random 实例:" + totalTime + "ms");
        es.shutdown();
    }
}

// 执行结果
pool-1-thread-2 spend 2753ms
pool-1-thread-3 spend 2793ms
pool-1-thread-4 spend 2798ms
pool-1-thread-1 spend 2808ms
多线程访问同一个 Random 实例:11152ms
pool-1-thread-3 spend 105ms
pool-1-thread-1 spend 105ms
pool-1-thread-2 spend 105ms
pool-1-thread-4 spend 105ms
使用 ThreadLocal 包装 Random 实例:420ms

观察上述执行结果:很明显,在多线程共享一个 Random 实例的情况 下,总耗时达 11 秒之多。而在 ThreadLocal 模式下,耗时不到 1 秒。

4 无锁

对于并发控制而言,锁是一种悲观的策略。它总是假设每一次的临界区操作会产生冲突,必须对每次操作都小心翼翼。如果有多个线程同时需要访问临界区资源,则宁可牺牲性能让线程进行等待,所以说锁会阻塞线程执行。而无锁是一种乐观的策略,它假设对资源的访问是没有冲突的。 既然没有冲突,自然不需要等待,所以所有的线程都可以在不停顿的状态下待续执行。当遇到冲突时,无锁的策略使用一种叫作==比较交换( CAS, Compare And Swap) ==的技术来鉴别线程冲突,一旦检测到冲突产生,就重试当前操作直到没有冲突为止。

4.1 与众不同的并发策略:比较交换

与锁相比,使用比较交换会使程序看起来更加复杂一些,但由于其非阻塞性,它对死锁问题天生免疫,并且线程间的相互影响也远远比基于锁的方式要小。更为重要的是,使用无锁的方式完全没有锁竞争带来的系统开销,也没有线程间频繁调度带来的开销,因此,它要比基于锁的方式拥有更优越的性能。

CAS 算法的过程是:它包含三个参数 V,E,N。其中 V 表示要更新的变量,E 表示预期值,N 表示新值。仅当 V 值等于 E 值时,才会将 V 的值设为 N, 如果 V 值和 E 值不同,说明已经有其他线程做了更新,则当前线程什么都不做。最后,CAS 返回当前 V 的真实值。CAS 操作是抱着乐观的态度进行的,它总是认为自己可以成功完成操作。当多个线程同时使用 CAS 操作一个变量时,只有一个会胜出,并成功更新,其余均会失败。失败的线程不会被挂起,仅是被告知失败,并且允许再次尝试或放弃操作。因此,CAS 操作即使没有锁,也可以发现其他线程对当前线程的干扰,并进行恰当的处理。

简单地说,CAS 需要你额外给出一个期望值,也就是你认为这个变量现在应该是什么样子的。如果变量不是你想象的那样,则说明它已经被别人修改过了。你就重新读取,再次尝试修改就好了。

在硬件层面,大部分的现代处理器都已经支持原子化的 CAS 指令。在 JDK 5 以后,虚拟机便可以使用这个指令来实现并发操作和并发数据结构,并且这种操作在虚拟机中可以说是无处不在的。

4.2 无锁的线程安全整数: Atomiclnteger

在 JDK 并发包中有一个 atomic 包,里面实现了一些直接使用 CAS 操作的线程安全的类型。

其中,一个类就是 Atomiclnteger,可以把它 看作一个整数。与 Integer 不同,它是可变的,并且是线程安全的。对其进行修改等任何操作都是用 CAS 指令进行的。下面是 Atomiclnteger 的一些主要方法, 其他原子类操作也是非常类似的。

// 取得当前值
public final int get()
// 设置当前值
public final void set(int newValue)
// 设置当前值并返回旧值
public final int getAndSet(int newValue)
//  如果当前值为 expect,则设置为 update
public final boolean compareAndSet(int expect, int update)
// 当前值自增 1 并返回旧值 
public final int getAndIncrement()
// 当前值自减 1 并返回旧值 
public final int getAndDecrement()
// 当前值增加 delta, 返回旧值
public final int getAndAdd(int delta)
// 当前值加 1 , 返回新值
public final int incrementAndGet()
// 当前值减 1 , 返回新值
public final int decrementAndGet()
// 当前值增加 delta, 返回新值
public final int addAndGet(int delta)

在 AtomicInteger 内部中有如下两个核心字段。

// value 字段在 AtomicInteger 对象中的偏移量
private static final long valueOffset;
// 当前实际取值
private volatile int value;

Atomiclnteger 的简单使用如下:

public class AtomicIntegerDemo {

    static AtomicInteger i = new AtomicInteger();

    private static class AddThread implements Runnable {

        @Override
        public void run() {
            for (int k = 0; k < 10000; k++) {
                i.incrementAndGet();
            }
        }
    }

    public static void main(String[] args) throws InterruptedException {
        Thread[] ts = new Thread[10];
        for (int k = 0; k < 10; k++) {
            ts[k] = new Thread(new AddThread());
        }
        for (int k = 0; k < 10; k++) {
            ts[k].start();
            ts[k].join();
        }

        System.out.println(i);
    }
}

// 执行结果
100000

下面是 incrementAndGet 方法的内部实现:

public final int incrementAndGet() {
	// 委托给 Unsafe 类实现
	return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
}

// UnSafe.java 直接操作内存
//获取内存地址为obj+offset的变量值, 并将该变量值加上delta
public final int getAndAddInt(Object obj, long offset, int delta) {
    int v;
    do {
    	//通过对象和偏移量获取变量的值
    	//由于volatile的修饰, 所有线程看到的v都是一样的
        v= this.getIntVolatile(obj, offset);
   
	// while中的compareAndSwapInt()方法尝试修改v的值,具体地, 该方法也会通过obj和offset获取变量的值
	// 如果这个值和v不一样, 说明其他线程修改了obj+offset地址处的值, 此时compareAndSwapInt()返回false, 继续循环
	// 如果这个值和v一样, 说明没有其他线程修改obj+offset地址处的值, 此时可以将obj+offset地址处的值改为v+delta, compareAndSwapInt()返回true, 退出循环
	// Unsafe类中的compareAndSwapInt()方法是原子操作, 所以compareAndSwapInt()修改obj+offset地址处的值的时候不会被其他线程中断
    } while(!this.compareAndSwapInt(obj, offset, v, v + delta));

    return v;
}

和 Atomiclnteger 类似的类还有: AtomicLong 用来代表 long 型数据; AtomicBoolean 表示boolean 型数据;AtomicReference 表示对象引用。

4.3 Java 中的指针: Unsafe 类

在上一节的代码中,incrementAndGet 的具体实现是委托给特殊变量 unsafe 实现的。它是 sun.misc.Unsafe 类型,从名字看,这个类应该是封装了一些不安全的操作。学习过 C 或者 C++ 可以知道指针是不安全的,这也是在 Java 中把指针去除的重要原因。Unsafe 类提供了一些方法(原子性),主要有以下几种(以 int 操作为例,其他数据类型是类似的):

// 获得 offset 处的 int 值
public native int getInt(long offset);
// 获得 o + offset 处的 int 值
public native int getInt(Object o, long offset);
// 获得 o + offset 处的 int 值,使用 volatile 语义
public native int getIntVolatile(Object o, long offset);

// 设置 newValue 到 offset 处
public native void putInt(long offset, int newValue);
// 设置 newValue 到 o + offset 处
public native void putInt(Object o, long offset, int newValue);
// 设置 newValue 到 o + offset 处,使用 volatile 语义
public native void putIntVolatile(Object o, long offset, int newValue);

// 和 putIntVolatile 类似,要求 newValue 是 volatile 修饰的
public native void putOrderedInt(Object offset, long offset, int newValue);

// 比较 o + offset 处的值是否为 oldValue,如果是,设置为 newValue,并返回 true
// 不是就不进行任何操作,返回 false。
public final native boolean compareAndSwapInt(Object o, long offset, int oldValue, int newValue);

在 ConcurrentLinkedQueue 类中也使用到了 Unsafe 类来对 Node 进行一系列操作。

虽然 Java 抛弃了指针,但是在关键时刻,类似指针的技术还是必不可少的。这里底层的 Unsafe 类实现就是最好的例子。但是很不幸,JDK 并不希望大家使用这个类。 获得 Unsafe 类实例的方法是调动其工厂方法 getUnsafe(),但是它的实现却是这样的:

@CallerSensitive
public static Unsafe getUnsafe() {
    Class var0 = Reflection.getCallerClass();
    if (!VM.isSystemDomainLoader(var0.getClassLoader())) {
        throw new SecurityException("Unsafe");
    } else {
        return theUnsafe;
    }
}

该方法中会检查调用 getUnsafe() 函数的类,如果这个类的 ClassLoader 不为 null , 就直接抛出异 常,拒绝工作。因此,这也使得我们自己的应用程序无法直接使用 Unsafe 类。它是一个在 JDK 内部使用的专属类。

**注意:**根据Java 类加载器的工作原理,应用程序的类由 AppLoader 加载。而系统核心类,如 rt.jar 中的类由Bootstrap 类加载器加栽。Bootstrap 类加栽器是没有 Java 对象的对象,因此试图获得这个类加栽器会返回 null。所以,当一个类的类加载器为 null 时,说明它是由 Bootstrap 类加栽器加载的,而这个类也极有可能是 rt.jar 中的类。

4.4 无锁的对象引用: AtomicReference

AtomicReference 和 Atomiclnteger 非常类似,不同之处就在于 Atomiclnteger 是对整数的封装,而 AtomicReference 则是对应普通的对象引用。也就是它可以保证你在修改对象引用时的线程安全性。原子操作的逻辑上存在一个不足的地方。

线程判断被修改对象是否可以正确写入的条件是对象的当前值和期望值是否一致。这个逻辑从一般意义上来说是正确的。但有可能出现一个小小的例外,就是当你获得对象当前数据后,在准备修改为新值前,对象的值被其他线程连续修改了多次,而对象的值又恢复为旧值。这样,当前线程就无法正确判断这个对象究竟是否被修改过了。

一般来说,发生这种情况的概率很小,即使发生了,可能也不是什么大问题。比如,我们只是简单地要做一个数值加法,即使在取得期望值后,这个数字被不断地修改,只要它最终改回了我的期望值,加法计算就不会出错。 也就是说,当你修改的对象没有过程的状态信息时,所有的信息都只保存于对象的数值本身

但是,还可能存在另外一种场景,就是我们是否能修改对象的值,不仅取决于当前值,还和对象的过程变化有关, 这时,AtomicReference 就无能为力了。

举一例子,有一家蛋糕店,为了挽留客户,决定为贵宾卡里余额小于 20 元的客户一次性赠送 20 元,刺激客户充值和消费,但条件是,每一位客户只能被赠送一次。下述代码来模拟这个场景,首先使用 AtomicReference 实现这个功能:

public class AtomicReferenceDemo1 {

    static AtomicReference<Integer> money = new AtomicReference<>();

    static {
        // 设置账户初始值小于 20, 显然这是一个需要被充值的账户
        money.set(19);
    }

    private static class AddMoneyThread implements Runnable {

        @Override
        public void run() {
            while (true) {
                while (true) {
                    Integer m = money.get();
                    if (m < 20) {
                        if (money.compareAndSet(m, m + 20)) {
                            System.out.println("余额小于 20 元,充值成功,余额:" + money.get() + "元");
                            break;
                        } else {
                            System.out.println("余额大于 20 元,无需充值");
                            break;
                        }
                    }
                }
            }
        }
    }

    private static class SpendMoneyThread implements Runnable {

        @Override
        public void run() {
            for (int i = 0; i < 100; i++) {
                while (true) {
                    Integer m = money.get();
                    if (m > 10) {
                        System.out.println("大于 10 元");
                        if (money.compareAndSet(m, m - 10)) {
                            System.out.println("成功消费 10 元,余额:" + money.get() + " 元");
                            break;
                        }
                    } else {
                        System.out.println("没有足够的金额");
                    }
                }

                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    }

    public static void main(String[] args) {
        // 充值线程
        for (int i = 0; i < 3; i++) {
            new Thread(new AddMoneyThread()).start();
        }

        // 消费线程
        new Thread(new SpendMoneyThread()).start();
    }
}
// 执行结果
余额小于 20 元,充值成功,余额:39元
大于 10 元
成功消费 10 元,余额:29 元
大于 10 元
成功消费 10 元,余额:19 元
余额大于 20 元,无需充值
余额大于 20 元,无需充值
余额小于 20 元,充值成功,余额:39元
大于 10

从执行结果中可以看出,这个账户被先后反复多次充值。其原因正是账户余额被反复修改,修改后的值等于原有的数值,使得 CAS 操作无法正确判断当前数据的状态。

虽然这种情况出现的概率不大,但是依然是有可能出现的。 JDK 中使用 AtomicStampedReference 就可以很好地解决这个问题。

4.5 带有时间戳的对象引用:AtomicStampedReference

AtomicReference 无法解决上述问题的根本原因是,对象在修改过程中丢失了状态信息,对象值本身与状态被画上了等号。因此,我们只要能够记录对象在修改过程中的状态值,就可以很好地解决对象被反复修改导致线程无法正确判断对象状态的问题。

AtomicStampedReference 内部不仅维护了对象值,还维护了一个 时间戳(我这里把它称为时间戳,可以使任何一个整数来表示状态值)。当 AtomicStampedReference 对应的数值被修改时,除了更新数据本身外,还必须要更新时间戳。当 AtomicStampedReference 设置对象值时,对象值及时间戳都必须满足期望值,写入才会成功。因此,即使对象值被反复读写,写回原值,只要时间戳发生变化,就能防止不恰当的写入。

AtomicStampedReference 的几个 API 在 AtomicReference 的基础上新增了有关时间戳的信息。

// 比较设置,参数依次为:期望值、写入新值、期望时间戳、新时间戳
public boolean compareAndSet(V expectedReference,V newReference,int expectedStamp, int newStamp)
// 获得当前对象的引用
public V getReference()
// 获取时间戳
public int getStamp()
// 设置当前对象引用和时间戳
public void set(V newReference, int newStamp)

下述代码使用 AtomicStampedReference 试下上一节的充值问题。

public class AtomicStampedReferenceDemo {

    static AtomicStampedReference<Integer> money = new AtomicStampedReference<>(19, 0);

    public static void main(String[] args) {
        // 充值线程
        for (int i = 0; i < 3; i++) {
            final int moneyStamp = money.getStamp();
            new Thread() {
                @Override
                public void run() {
                    while (true) {
                        while (true) {
                            Integer m = money.getReference();
                            if (m < 20) {
                                if (money.compareAndSet(m, m + 20, moneyStamp, moneyStamp + 1)) {
                                    System.out.println("余额小于 20 元,充值成功,余额:" + money.getReference() + "元");
                                    break;
                                } else {
//                                    System.out.println("余额大于 20 元,无需充值");
                                    break;
                                }
                            }
                        }
                    }
                }
            }.start();
        }

        // 消费线程
        new Thread() {
            @Override
            public void run() {
                for (int i = 0; i < 100; i++) {
                    while (true) {
                        int moneyStamp = money.getStamp();
                        Integer m = money.getReference();
                        if (m > 10) {
                            System.out.println("大于 10 元");
                            if (money.compareAndSet(m, m - 10, moneyStamp, moneyStamp + 1)) {
                                System.out.println("成功消费 10 元,余额:" + money.getReference() + " 元");
                                break;
                            }
                        } else {
//                            System.out.println("没有足够的金额");
                            break;
                        }
                    }

                    try {
                        Thread.sleep(100);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }
        }.start();
    }
}

// 执行结果
余额小于 20 元,充值成功,余额:39元
大于 10 元
成功消费 10 元,余额:29 元
大于 10 元
成功消费 10 元,余额:19 元
大于 10 元
成功消费 10 元,余额:9

上述结果可以看到账户只被赠予了一次。

4.6 数组也能无锁:AtomiclntegerArray

除提供基本数据类型以外,JDK 还准备了数组等复合结构。当前可用的原子数组有:AtomicIntegerArray、AtomicLongArray 和 AtomicReferenceArray,分别表示整数数组、long 型数组和普通的对象数组。

以 AtomicIntegerArray 为例,展示原子数组的使用方式。本质上是对 int[]类型的封装,使用 Unsafe 类通过 CAS 的方式控制 int[]在多线程下的安全性。它提供了 以下几个核心 API。

// 获得数组第i 个下标的元素
public final int get(int i)
// 获得数组的长度
public final int length()
// 将数组第i 个下标设置为 newValue, 并返回旧的值
public final int getAndSet(int i , int newValue)
// 进行 CAS 操作,如果第 i 个下标的元素等于 expect, 则设置为 update, 设置成功返回true 
public final boolean compareAndSet(int i, int expect, int update)
// 将第i 个下标的元素加1
public final int getAndincrement(int i)
// 将第i 个下标的元素减1
public final int getAndDecrement(int i)
// 将第 i 个下标的元素增加 delta (delta可以是负数)
public final int getAndAdd(int i, int delta)

下面给出一个简单的使用示例:

public class AtomicIntegerArrayDemo {

    static AtomicIntegerArray array = new AtomicIntegerArray(10);

    private static class AddThread implements Runnable {

        @Override
        public void run() {
            for (int i = 0; i < 10000; i++) {
                array.getAndIncrement(i % array.length());
            }
        }
    }

    public static void main(String[] args) throws InterruptedException {
        Thread[] ts = new Thread[10];

        for (int i = 0; i < 10; i++) {
            ts[i] = new Thread(new AddThread());
            ts[i].start();
            ts[i].join();
        }

        System.out.println(array);
    }
}

// 执行结果
[10000, 10000, 10000, 10000, 10000, 10000, 10000, 10000, 10000, 10000]

上述结果说明了数组的线程安全性。

4.7 让普通变量也享受原子操作:AtomiclntegerFieldUpdater

有时候,由于初期考虑不周,或者后期的需求变化,一些普通变量可能也会有线程安全的需求。如果改动不大,则可以简单地修改程序中每一个使用或者读取这个变量的地方。但显然,这样并不符合软件设计中的一条重要原则开闭原则。而且,如果系统里使用到这个变量的地方 特别多,一个一个修改也是一件令人厌烦的事情。

因此,JDK 原子包里还有一个实用的工具类 AtomiclntegerFieldUpdater 它可以让你在不改动(或者极少改动) 原有代码的基础上,让普通的变量也享受CAS 操作带来的线程安全性,这样你可以通过修改极少的代码来获得线程安全的保证。

根据数据类型不同 Updater 有 AtomiclntegerFieldUpdater,AtomicLongFieldUpdater, AtomicReferenceFieldUpdater。顾名思义,它们分别可以对 int、long 和普通对象进行 CAS 修改。

存在这么一个场景: 假设某地要进行一次选举。现在模拟这个投票场景,如果选民投了候选人一票,就记为 l,否则记为 0。最终的选票显然就是所有数据的简单求和。

public class AtomicIntegerFieldUpdaterDemo {

    private static class Candidate {
        int id;
        volatile int score;
    }

    private final static AtomicIntegerFieldUpdater<Candidate> SCORE_UPDATER =
            AtomicIntegerFieldUpdater.newUpdater(Candidate.class, "score");

    /**
     * 用来检查 AtomicIntegerFieldUpdater 工作是否正确
     */
    private static AtomicInteger allScore = new AtomicInteger(0);

    public static void main(String[] args) throws InterruptedException {
        final Candidate stu = new Candidate();
        Thread[] ts = new Thread[10000];

        for (int i = 0; i < 10000; i++) {
            ts[i] = new Thread() {

                @Override
                public void run() {
                    if (Math.random() > 0.4) {
                        SCORE_UPDATER.incrementAndGet(stu);
                        allScore.incrementAndGet();
                    }
                }
            };

            ts[i].start();
            ts[i].join();
        }

        System.out.println("score = " + stu.score);
        System.out.println("allScore = " + allScore);
    }
}

// 运行结果
score = 6022
allScore = 6022

上述运行结果可以看到结果是一致的,说明 AtomicIntegerFieldUpdater 保证了线程的安全。

AtomicIntegerFieldUpdater 使用的注意事项:

  1. Updater 只能修改它可见范围内的变量,因为 Updater 使用反射得到这个变量。如果变量不可见,就会出错。 比如 score 声明为 private,就是不可行的。
  2. 为了确保变量被正确的读取,它必须是 volatile 类型的。如果我们原有代码中未声明这个类型,那么简单地声明一下就行,这不会引起什么问题。
  3. 由于 CAS 操作会通过对象实例中的偏移量直接进行赋值,因此,它不支持 static 字段 (Unsafe.objectFieldOffset()方法不支持静态变量)。
    通过 AtomicIntegerFieldUpdater 我们可以更加随心所欲地对系统关键数据进行线程安全的保护。

4.8 挑战无锁算法:无锁的 Vector 实现

相对于有锁的方法,无锁带来的好处也是显而易见的, 第一,在 高并发的情况下,它比有锁的程序拥有更好的性能;第二,它天生就是死锁免疫的。

介绍一种使用无锁方式实现的 Vector,即 amino 并发包中的 LockFreeVector。其特点是可以根据需求动态扩展其内部空间。下载路径:https://sourceforge.net/projects/amino-cbbs/files/cbbs/1.0/

在这里,我们使用二维数组来表示 LockFreeVector 的内部存储。

private final AtomicReferenceArray<AtomicReferenceArray<E>> buckets;

变量 buckets 存放所有的内部元素。它是一个保存着数组的数组,也就是通常所说的二维数组。这些数组都是使用 CAS 的原子数组。至于为什么使用二维数组去实现一个一维的 Vector,这是为了将来 Vector 进行动态扩展时可以更加方便。因为 AtomicReferenceArray 内部使用 Object[] 来进行实际数据的存储,这使得动态空间增加特别麻烦,因此使用二维数组的好处就是为了将来可以方便地增加新的元素。

此外,为了更有序的读写数组,定义一个称为 Descriptor 的元素。它的作用是使用 CAS 操作写入新数据。

static class Descriptor<E> {
   public int size;
    volatile WriteDescriptor<E> writeop;

    public Descriptor(int size, WriteDescriptor<E> writeop) {
        this.size = size;
        this.writeop = writeop;
    }

    public void completeWrite() {
        WriteDescriptor<E> tmpOp = writeop;
        if (tmpOp != null) {
            tmpOp.doIt();
            writeop = null; // this is safe since all write to writeop use
            // null as r_value.
        }
    }
}

static class WriteDescriptor<E> {
    
    public E oldV;
    
    public E newV;
    
    public AtomicReferenceArray<E> addr;
 
    public int addrInd;

    public WriteDescriptor(AtomicReferenceArray<E> addr, int addrInd,
            E oldV, E newV) {
        this.addr = addr;
        this.addrInd = addrInd;
        this.oldV = oldV;
        this.newV = newV;
    }
    
    public void doIt() {
        addr.compareAndSet(addrInd, oldV, newV);
    }
}

Descriptor 构造函数接收两个参数,第一个为整个 Vector 的长度,第二个为一个 writer。最终,写入数据是通过 writer 进行的(通过 completeWrite() 方法)。

WriteDescriptor 的构造函数接收四个参数。第一个参数 addr 表示要修改的原子数组,第二个参数为要写入的数组索引位置,第三个 oldV 为期望值,第四个 newV 为需要写入的值。

在构造 LockFreeVector 时,需要将 buckets 和 descriptor 进行初始化。

 public LockFreeVector() {
   buckets = new AtomicReferenceArray<AtomicReferenceArray<E>>(N_BUCKET);
    buckets.set(0, new AtomicReferenceArray<E>(FIRST_BUCKET_SIZE));
    descriptor = new AtomicReference<Descriptor<E>>(new Descriptor<E>(0,
            null));
}

在这里 N_BUCKET 为 30,也就是说这个 buckets 里面可以存放一共 30 个数组。 并且将第一个数组的大小FIRST_BUCKET_SIZE 设为 8。在 默认情况下,每次都会将总容量翻倍。每次空间扩张,新的数组的大小为原来的两倍(即每次空间扩展都启用一个新的数组),因此,第一个数组为 8, 第二个就是 16, 第三个就是 32,依次类推。

当有元素需要加入 LockFreeVector 时,使用一个名为 pushBack() 的方法,将元素压入 Vector 最后一个位置。

public void pushBack(E e) {
    Descriptor<E> desc;
    Descriptor<E> newd;
    // 循环,用来不断尝试对 descriptor 的设置。
    // 也就是通过 CAS 保证了 descriptor 的一致性和安全性
    do {
        desc = descriptor.get();
        // 为了防止上一个线程设置完 descriptor 后,
        // 还没来得及执行写入,因此做一次预防性操作。
        desc.completeWrite();

        // 通过当前 Vector 的大小
        // 计算新的元素应该落入哪个数组
        int pos = desc.size + FIRST_BUCKET_SIZE;
        // 计算前导零的个数
        int zeroNumPos = Integer.numberOfLeadingZeros(pos);
        // 每一次需要扩容时,前导零的个数会减一
        int bucketInd = ZERO_NUM_FIRST - zeroNumPos;

        // 如果不存在,新建一个数组
        if (buckets.get(bucketInd) == null) {
            // 新的数组大小为前一数组的两倍
            int newLen = 2 * buckets.get(bucketInd - 1).length();

            if (DEBUG)
                System.out.println("New Length is:" + newLen);

            // 写入到二维数组中
            buckets.compareAndSet(bucketInd, null,
                    new AtomicReferenceArray<E>(newLen));
        }

        // 计算出 pos 的除了第一位数字 1 以外的其他位的数值
        // 即元素在这个数组中的位置
        int idx = (MARK_FIRST_BIT >>> zeroNumPos) ^ pos;
        
        // 新建一个描述符
        newd = new Descriptor<E>(desc.size + 1, new WriteDescriptor<E>(
                buckets.get(bucketInd), idx, null, e));
    } while (!descriptor.compareAndSet(desc, newd));
    // 完成写入
    descriptor.get().completeWrite();
}

计算过程:需要计算出元素在第一个数组,以及在该数组中的位置。这里使用了位运算进行计算。

LockFreeVector 扩容每次比上一个数组增加两倍,它的第 1 个数组长度为 8,第二个为16,以此类推,二进制表示如下:

00000000	00000000	00000000	00001000: 第一个数组大小,28 个前导零
00000000	00000000	00000000	00010000: 第二个数组大小,27 个前导零
00000000	00000000	00000000	00100000: 第三个数组大小,26 个前导零
00000000	00000000	00000000	01000000: 第四个数组大小,25 个前导零

他们的和就是 LockFreeVector 的总大小

00000000	00000000	00000000	01111 000: 4个数组都恰好填满时的大小

导致这个数字进位的最小条件,就是加上二进制的 1000。而这个数字正好是 8(FIRST_BUCKET_SIZE)。它可以使得数组大小发生一次二进制的进位(如果不进位说明还在第一个数组中),进位后前导零的数量就会发生变化。每进行一次数组扩容,它的前导零就会减 1。如果从来没有扩容过,那么它的前导零就是 28 个,以后逐级减 1。

再看一下元素没有恰好填满的情况:

00000000	00000000	00000000	00001000: 第一个数组大小,28 个前导零
00000000	00000000	00000000	00010000: 第二个数组大小,27 个前导零
00000000	00000000	00000000	00100000: 第三个数组大小,26 个前导零
00000000	00000000	00000000	00000001: 第四个数组大小,只有一个元素

那么总大小就是:

00000000	00000000	00000000	00111001: 元素总个数(desc.size)

加上二进制 1000 后,得到:

00000000	00000000	00000000	01000001: pos

通过前导零的个数可以定位到第 4(索引 3) 个数组(28 - 25 = 3)。而剩余位表示元素在当前数组内的偏移量(也就是数组下标)。计算方式如下

int idx = (MARK_FIRST_BIT >>> zeroNumPos) ^ pos;

10000000	00000000	00000000	00000000: MARK_FIRST_BIT(0x80000000)
00000000	00000000	00000000	01000000: >>> zeroNumPos(25)
00000000	00000000	00000000	00000001: ^ pos

得到新元素位置的全部信息,剩下的就是将这些信息传递给 Descriptor,让它在给定的位置把元素 e 安置上去即可。通过 CAS 操作,保证写入正确性。

} while (!descriptor.compareAndSet(desc, newd));

在来看一下 get() 方法的实现。

public E get(int index) {
  // 元素的位置
    int pos = index + FIRST_BUCKET_SIZE;
    // 前导零个数
    int zeroNumPos = Integer.numberOfLeadingZeros(pos);
    // 在哪个数组中
    int bucketInd = ZERO_NUM_FIRST - zeroNumPos;
    // 在数组中的索引
    int idx = (MARK_FIRST_BIT >>> zeroNumPos) ^ pos;
    // 获取数据
    return buckets.get(bucketInd).get(idx);
}

4.9 让线程之间互相帮助: 细看 SynchronousQueue的实现

SynchronousQueue 是一个特殊的等待队列,它的容量为 0,任何一个对 SynchronousQueue 的写需要等待一个对SynchronousQueue 的读,反之亦然。SynchronousQueue 与其说是一个队列,不如说是一个数据交换通道。 实际上 SynchronousQueue 内部大量使用了无锁工具。

对 SynchronousQueue 来说,它将 put() 和 take() 两种功能截然不同的方法抽象为一个共同的方法 transfer()。 从字面上看,这就是数据传递的意思。它的完整签名如下:

// SynchronousQueue.java
abstract static class Transferer<E> {
 abstract E transfer(E e, boolean timed, long nanos);
}

当参数 e 为非空时,表示当前操作传递给一个消费者,如果为空,则表示当前操作需要请求一个数据。timed 参数决定是否存在 timeout 时间,nanos 决定了 timeout 的时长。如果返回值非空,则表示数据已经接受或者正常提供;如果为空,则表示失败(超时或者中断)。其有两个内部实现类,模拟栈和队列的操作:

static final class TransferStack<E> extends Transferer<E>{
}
static final class TransferQueue<E> extends Transferer<E>{
}

SynchronousQueue 内部会维护一个线程等待队列。 等待队列中会保存等待线程及相关数据的信息。比如,生产者将数据放入 SynchronousQueue 时, 如果没有消费者接收,那么数据本身和线程对象都会打包在队列中等待。

Transferer.transfer() 函数的实现是 SynchronousQueue 的核心,它大体上分为三个步骤。

  1. 如果等待队列为空,或者队列中节点的类型和本次操作是一致的,那么将当前操作压入队列等待。比如,等待队列中是读线程等待,本次操作也是读,因此这两个读都需要等待。进入等待队列的线程可能会被挂起,它们会等待一个“匹配(写)” 操作。
  2. 如果等待队列中的元素和本次操作是互补的(比如等待操作是读,而本次操作是写),那么就插入一个“完成“状态的节点, 并且让它“匹配” 到一个等待节点上。接着弹出这两个节点,并且使得对应的两个线程继续执行。
  3. 如果线程发现等待队列的节点就是“完成“ 节点,那么帮助这个节点完成任务,其流程和步骤 2 是一致的。

步骤一的实现如下:

SNode s = null;
// 当前模式,e 为 null 表示消费者,否则是生产者
int mode = (e == null) ? REQUEST : DATA;

// TransferStack.transfer
// 头节点
// 内部封装了当前线程、next 节点、匹配节点、数据内容等信息
SNode h = head;
// 当前等待队列为空,或者队列中元素的模式与本次操作相同
if (h == null || h.mode == mode) {  
    if (timed && nanos <= 0) {      // 不进行等待
        if (h != null && h.isCancelled())
        	// 处理取消行为,移除当前头节点,
        	// 新的头节点为当前头节点的 next 节点
            casHead(h, h.next);     
        else
            return null;
   	// 生成一个新的节点(snode 方法)并置于队列头部(栈),这个节点就代表当前线程。
    } else if (casHead(h, s = snode(s, e, h, mode))) {
    	// 该函数会进行自旋等待,可以设置超时时间,
    	// 并最终挂起当前线程。 
    	// 直到一个与之对应的操作产生,将其唤醒。
        SNode m = awaitFulfill(s, timed, nanos);
        if (m == s) {               // 等待被取消
            clean(s);
            return null;
        }
        if ((h = head) != null && h.next == s)
        	// 尝试帮助对应的线程完成两个头部节点的出队操作
            casHead(h, s.next);
        // 返回读取或者写入的数据    
        return (E) ((mode == REQUEST) ? m.item : s.item);
    }
}

步骤二的实现如下:

// 判断头部节点是否处于 FULFILLING 模式,则需要进入步骤3
// 否则,将视自己为对应的 fulfill 线程。
else if (!isFulfilling(h.mode)) { // try to fulfill
    if (h.isCancelled())            // 如果以前取消了
        casHead(h, h.next);         // 则弹出并重试
    // 生成一个 SNode 元素,设置为 fulfill 模式并将其压入队列头部。
    else if (casHead(h, s=snode(s, e, h, FULFILLING|mode))) {
        for (;;) { // 一直循环直到匹配(match) 或者没有等待者了
            SNode m = s.next;       // m 是s 的匹配者 (match)
            if (m == null) {        // 已经没有等待者了
                casHead(s, null);   // 弹出 fulfill 节点
                s = null;           // 下一次使用新的节点
                break;              // 重新开始主循环
            }
            SNode mn = m.next;
            if (m.tryMatch(s)) {
                casHead(s, mn);     // 弹出 s 和 m
                return (E) ((mode == REQUEST) ? m.item : s.item);
            } else                  // lost match
                s.casNext(m, mn);   // help unlink
        }
    }
}

步骤3的实现:

else {                            // help a fulfiller
   SNode m = h.next;               // m is h's match
    if (m == null)                  // waiter is gone
        casHead(h, null);           // pop fulfilling node
    else {
        SNode mn = m.next;
        if (m.tryMatch(h))          // help match
            casHead(h, mn);         // pop both h and m
        else                        // lost match
            h.casNext(m, mn);       // help unlink
    }
}

上述代码的执行原理和步骤 2 完全一致。唯一的不同是步骤 3 不会返回,因为步骤 3 所进行的工作是帮助其他线程尽快投递它们的数据,而自己并没有完成对应的操 作。因此,线程进入步骤 3 后,会再次进入大循环体, 从步骤 1 开始重新判断条件和投递数据。

从整个数据投递的过程中可以看到, 在 SynchronousQueue 中,参与工作的所有线程不仅仅是竞争资源的关系,更重要的是,它们彼此之间还会互相帮助。在一个线程内部,可能会帮助其他线程完成它们的工作。这种模式可以在更大程度上减少饥饿的可能,提高系统整体的并行度。

5. 有关死锁的问题

什么是死锁呢?

通俗地说,死锁就是两个或者多个线程相互占用对方需要的资源,而都不进行释放,导致彼此之间相互等待对方释放资源, 产生了无限制等待的现象。死锁一旦发生,如果没有外力介入,这种等待将永远存在,从而对程序产生严重的影响。

用来描述死锁问题的一个有名的场景是哲学家就餐问题,假设有两个哲学家,A 和 B, 桌面也只有两个叉子。 A 左手拿着其中一只叉子,B 也一样。这样他们的右手等待对方的叉子,并且这种等待会一直持续, 从而导致程序永远无法正常执行。

下面用一个简单的例子来模拟这个过程。

public class DeadLockDemo extends Thread {
    protected Object tool;

    /**
     * 两把叉子,代表被竞争的资源
     */
    static Object fork1 = new Object();
    static Object fork2 = new Object();

    public DeadLockDemo(Object tool) {
        this.tool = tool;

        if (tool == fork1) {
            this.setName("哲学家A");
        }

        if (tool == fork2) {
            this.setName("哲学家B");
        }
    }

    @Override
    public void run() {
        if (tool == fork1) {
            // 只有叉子1
            synchronized (fork1) {
                try {
                    Thread.sleep(500);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }

                // 尝试获取叉子2
                synchronized (fork2) {
                    System.out.println(this.getName() + "开始吃饭了");
                }
            }
        }

        if (tool == fork2) {
            // 只有叉子2
            synchronized (fork2) {
                try {
                    Thread.sleep(500);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }

                // 尝试获取叉子1
                synchronized (fork1) {
                    System.out.println(this.getName() + "开始吃饭了");
                }
            }
        }
    }

    public static void main(String[] args) throws InterruptedException {
        DeadLockDemo 哲学家A = new DeadLockDemo(fork1);
        DeadLockDemo 哲学家B = new DeadLockDemo(fork2);
        
        哲学家A.start();
        哲学家B.start();

        Thread.sleep(1000);
    }
}

上述代码模拟了两个哲学家 互相等待对方的叉子。 哲学家 A 先占用叉子 1 , 哲学家 B 占用叉子 2, 接着他们就相互等待,都没有办法同时获得两只叉子用餐。

在实际环境中,遇到了这种情况,通常的表现就是相关的进程不再工作,并且CPU 占用率为 0(因为死锁的线程不占用 CPU) 。 如果想要确认问题,可以使用 JDK 提供的一套专业工具。

首先,我们可以使用 jps 命令得到 Java 进程的进程 ID,接着使用 jstack 命令得到线程的线程堆栈。

C:\Users\14816>jps
16352 Jps
10404 RemoteMavenServer
16484 RemoteMavenServer
2836 DeadLockDemo
8220 Launcher
8364

C:\Users\14816>jstack 2836
// 省略部分输出 ...
"哲学??B" #12 prio=5 os_prio=0 tid=0x000000001a097000 nid=0x28a8 waiting for monitor entry [0x000000001acce000]
   java.lang.Thread.State: BLOCKED (on object monitor)
        at com.bin.死锁.DeadLockDemo.run(DeadLockDemo.java:57)
        - waiting to lock <0x00000000d64a4088> (a java.lang.Object)
        - locked <0x00000000d64a4098> (a java.lang.Object)

"哲学家A" #11 prio=5 os_prio=0 tid=0x000000001a096800 nid=0x3f58 waiting for monitor entry [0x000000001abce000]
   java.lang.Thread.State: BLOCKED (on object monitor)
        at com.bin.死锁.DeadLockDemo.run(DeadLockDemo.java:41)
        - waiting to lock <0x00000000d64a4098> (a java.lang.Object)
        - locked <0x00000000d64a4088> (a java.lang.Object)
// 省略部分输出 ...
// 自动找到了一个死锁,确认死锁的存在
Found one Java-level deadlock:
=============================
"哲学家B":
  waiting to lock monitor 0x00000000184f1738 (object 0x00000000d64a4088, a java.lang.Object),
  which is held by "哲学家A"
"哲学家A":
  waiting to lock monitor 0x000000001a09dc88 (object 0x00000000d64a4098, a java.lang.Object),
  which is held by "哲学家B"

Java stack information for the threads listed above:
===================================================
// 哲学家 B 占用了 0x00000000d64a4098, 等待 0x00000000d64a4088
"哲学家B":
        at com.bin.死锁.DeadLockDemo.run(DeadLockDemo.java:57)
        - waiting to lock <0x00000000d64a4088> (a java.lang.Object)
        - locked <0x00000000d64a4098> (a java.lang.Object)
// 哲学家 A 占用了 0x00000000d64a4088, 等待 0x00000000d64a4098        
"哲学家A":
        at com.bin.死锁.DeadLockDemo.run(DeadLockDemo.java:41)
        - waiting to lock <0x00000000d64a4098> (a java.lang.Object)
        - locked <0x00000000d64a4088> (a java.lang.Object)
// 因此产生死锁
Found 1 deadlock.

上面显示了 jstack 的部分输出。可以看 到,哲学家 A 和哲学家 B 两个线程发生了死锁。并且在最后,可以看到两者相互等待的锁的 ID。 同时,死锁的两个线程均处于 BLOCK 状态。

如果想避免死锁, 除使用无锁的函数之外,还有一种有效的做法是使用重入锁,通过重入锁的中断或者限时等待可以有效规避死锁带来的问题。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值