Java 多线程(七)原子变量与非阻塞同步机制

在 JAVA 并发包的许多类中,例如SemaphoreConcurrentLinkedQueue,都提供了比synchronized机制更高的性能和可伸缩性。而这种性能的提升主要来源与原子变量非阻塞同步机制的应用。

锁的劣势

调度开销

当多个线程竞争锁时,JVM 需要借助操作系统的功能将一些线程挂起并且在稍后恢复运行。当线程恢复执行时,必须等待其他线程执行完它们的时间片以后,才能被调度执行。在挂起和恢复线程的过程中存在很大的开销,并且存在较长时间的中断。

volatile的局限问题

volatile变量是一种更轻量级的同步机制,因为在使用这些变量时不会发生上下文切换或者线程调度等操作。然而,volatile虽然提供了可见性保证,但不能用于构建原子的符合操作。

例如:i++自增问题。看起来像是原子操作,但事实上包含了三个独立的操作:

  • 获取变量的当前值
  • 将值增加1
  • 写入新值

到目前为止,实现这种原子操作的唯一方式就是加锁。同样会导致调度开销问题。

阻塞问题

当一个线程正在等待锁时,它不能做任何其他事情。如果一个线程在持有锁的情况下被延迟执行,那么所有需要这个锁的的线程都无法执行下去。

优先级翻转(Priority Inversion)

多线程竞争时,如果被阻塞的线程优先级较高,而持有锁的线程优先级较低,即使优先级高的线程可以抢先执行,但仍需要等待锁被释放。

硬件对并发的支持

早期针对并发的多处理器中提供了一些特殊指令,例如:测试并设置(Test-and-Set)、获取并递增(Fetch-and-Increment)、交换(Swap)等。现在,几乎多有的处理器中都包含了某种形式的原子--指令,例如比较并交换(Compare-and-Swap)、关联加载/条件储存(Loading-Linked/Store-Conditional)。操作系统和 JVM 通过这些指令来实现锁和并发的数据结构。

独占锁是一项悲观技术,它假设最坏的情况,需要在确保其他线程不会造成干扰的情况下才能正确执行下去。

对于细粒度的操作,乐观锁是一种更高效的方法,可以在不发生干扰的情况下完成更新操作。这种方法需要捷足冲突检查机制来判断在更新过程中是否存在来自其他线程的干扰,如果存在,这个操作将失败。

CAS 指令

在大多数处理器架构中会实现一个比较并交换(CAS)指令

CAS包含了三个操作数:

  • 需要读写的内存位置 V
  • 进行比较的值 A
  • 拟写入的新值 B

CAS的含义是:我认为位置V的值应该是A,如果是,那么将V的值更新为B,否则不修改并告诉我V的值实际为多少。

Java实现版本-非正式版:

public class SimulatedCAS {
    private int value;
    public synchronized int get(){
        return value;
    }
    public synchronized int compareAndSwap(int expectValue,int newValue){
        int oldValue = value;
        if(oldValue == expectValue){
            value = newValue;
        }
        return oldValue;
    }

    public synchronized boolean compareAndSet(int expectValue,int newValue){
        return expectValue == compareAndSwap(expectValue, newValue);
    }
}

CAS 是一项乐观的技术,它希望能成功的执行更新操作,并且如果有另一个线程修改过该变量,CAS能检测到这个错误。

一个很管用的经验法则是:在大多数处理器上,在无竞争的锁获取和释放的『快速代码路径』上的开销,大约是 CAS 开销的两倍

JAVA锁 和 CAS

虽然 Java 语言的锁定语法比较简洁,但 JVM 和在管理锁时需要完成的任务并不简单。在实现锁定时需要遍历 JVM 中一条非常复杂的代码路径,并可能导致操作系统级的锁定、线程挂起、上下文切换等。CAS 的主要缺点是,调用者需要主动处理竞争问题(重试、回退、放弃),而锁中能自动处理问题(阻塞)。

在 CAS 失败时不执行任何操作,这是一种明智的做法。当 CAS 失败,意味着其他线程可能已经完成了你想要执行的操作。

Java 对 CAS 的支持

JAVA 5.0后引入了原子变量类,为数字类型和引用类型提供了一种高效的 CAS 操作。在java.util.concurrent.atomic包下(例如:AtomicIntegerAtomicReference等)

原子变量类

原子变量比锁的粒度更细,量级更轻,在多处理器上实现高性能的并发代码是非常关键的。
原子变量可以用做一种『更好的 volatile类型变量』。它提供了与volatile类型变量相同的内存语义,此外还支持原子的更新操作。

JAVA 5 增加了12个原子变量类,分为4组:标量类更新器类数组类复合变量类

标量类 更新器类 数组类 复合变量类
AtomicBoolean AtomicIntegerFieldUpdater AtomicIntegerArray AtomicStampedReference
AtomicLong AtomicLongFieldUpdater AtomicLongArray AtomicMarkableReference
AtomicReference AtomicReferenceFieldUpdater AtomicReferenceArray
AtomicInteger

如果线程本地的计算量较少,那么在锁和原子变量上的竞争将非常激烈。
如果线程本地的计算量较多,那么在锁和原子变量上的竞争会降低。

在中低程度的竞争下,原子变量能提供更高的可伸缩性,而在高强度的竞争下,锁能够有效的避免竞争。

如果能够避免使用共享状态,那么开销会更小。我们可以通过提高处理竞争的效率来提高伸缩性,但只有完全消除竞争,才能实现真正的可伸缩性。(真鸡儿抽象,但是从示例代码来看,我们可以了解下ThreadLocal类)

非阻塞算法

某种算法中,一个线程的失败或挂起不会导致其他线程的失败或挂起,那么这种算法被称为非阻塞算法。

许多常见的数据结构中都可以使用非阻塞算法,包括栈、队列、优先队列、散列表等。

安全计数器-非阻塞版本:

public class CasCounter {
    /**
     * 原子操作,线程安全。这是个假的 CAS 类,纯粹演示用哈
     */
    private SimulatedCAS simulatedCAS;
    /**
     * 非线程安全变量
     */
    private int temp;
    public CasCounter() {
        this.simulatedCAS = new SimulatedCAS();
    }
    public int get() {
        return simulatedCAS.get();
    }
    public int increment() {
        int value;
        do {
            value = simulatedCAS.get();
        } while (value != simulatedCAS.compareAndSwap(value, value + 1));
        return value + 1;
    }
    public void tempIncrement() {
        temp++;
    }
    public static void main(String[] args) throws InterruptedException {
        CasCounter casCounter = new CasCounter();
        CountDownLatch count = new CountDownLatch(50);

        for (int i = 0; i < 50; i++) {
            new Thread(new Runnable() {
                @Override
                public void run() {
                    for (int j = 0; j < 30; j++) {
                        try {
                            Thread.sleep(100);
                        } catch (InterruptedException e) {
                            e.printStackTrace();
                        }
                        casCounter.increment();

                        try {
                            Thread.sleep(100);
                        } catch (InterruptedException e) {
                            e.printStackTrace();
                        }
                        casCounter.tempIncrement();
                    }
                    count.countDown();
                }
            }).start();
        }
        count.await();
        System.out.println("Thread safe final cas Counter : " + casCounter.get());
        System.out.println("Thread unsafe final temp value : " + casCounter.temp);
    }
}

非阻塞的栈

创建非阻塞算法的关键在于,找出如何将将原子修改的范围缩小到单个变量上,同时还要维护数据的一致性。

栈是最简单的链式数据结构:每个元素仅指向一个元素,并且每个元素只被一个元素引用。

/**
 * 通过 AtomicReference 实现线程安全的入栈和出栈操作
 *
 * @param <E> 栈元素类型
 */
public class ConcurrentStack<E> {
    private final AtomicReference<Node<E>> top = new AtomicReference<>();

    /**
     * 将元素放入栈顶
     *
     * @param item 待放入的元素
     */
    public void push(E item) {
        Node<E> newHead = new Node<>(item);
        Node<E> oldHead = null;
        do {
            oldHead = top.get();
            newHead.next = oldHead;
        } while (!top.compareAndSet(oldHead, newHead));
    }

    /**
     * 弹出栈顶部元素
     *
     * @return 栈顶部元素,可能为 null
     */
    public E pop() {
        Node<E> oldHead;
        Node<E> newHead;
        do {
            oldHead = top.get();
            if (oldHead == null) {
                return null;
            }
            newHead = oldHead.next;
        } while (!top.compareAndSet(oldHead, newHead));
        return oldHead.item;
    }

    /**
     * 单向链表
     *
     * @param <E> 数据类型
     */
    private static class Node<E> {
        public final E item;
        public Node<E> next;

        public Node(E item) {
            this.item = item;
        }
    }
}

非阻塞的链表

链表队列比栈更复杂,因为它需要单独维护的头指针和尾指针。当成功插入一个新元素时,这两个指针都需要采用原子操作来更新。

我们需要了解如下两个技巧:

技巧1

在包含多个步骤的更新操作中,要确保数据结构处于一致的状态。这样,当 B 线程到达时,如果发现 A 正在执行更新,那么 B 线程就可以知道有一个操作已部分完成,并且不能立即开始执行自己的更新操作。然后 B 可以等待(通过反复检查队列标志)直到 A 完成更新,从而是两个线程不会互相干扰

技巧2

如果 B 到达时发现 A 正在修改数据结构,那么在数据结构中应该有足够多的信息,使得 B 能完成 A 的更新操作。如果 B『帮助』A 完成了更新操作,那么 B 可以执行自己的操作,而不用等待 A 的操作完成。当 A 恢复后再试图完成其他操作时,会发现 B 已经替它完成了。

举例说明:

public class LinkedQueue<E> {
    /**
     * 链表结构
     * next 使用 AtomicReference 来管理,用来保证原子性和线程安全
     *
     * @param <E> 数据类型
     */
    private static class Node<E> {
        final E item;
        /**
         * 通过 AtomicReference 实现指针的原子操作
         */
        final AtomicReference<Node<E>> next;

        /**
         *  Node 构造方法
         * @param item 数据元素
         * @param next 下一个节点
         */
        public Node(E item, Node<E> next) {
            this.item = item;
            this.next = new AtomicReference<>(next);
        }
    }

    /**
     * 哨兵,队列为空时,头指针(head)和尾指针(tail)都指向此处
     */
    private final Node<E> GUARD = new Node<>(null, null);
    /**
     * 头节点,初始时指向 GUARD
     */
    private final AtomicReference<Node<E>> head = new AtomicReference<>(GUARD);
    /**
     * 尾节点,初始时指向 GUARD
     */
    private final AtomicReference<Node<E>> tail = new AtomicReference<>(GUARD);

    /**
     * 将数据元素放入链表尾部
     *
     * 在插入新元素之前,将首先检查tail 指针是否处于队列中间状态,
     * 如果是,那么说明有另一个线程正在插入元素。
     *      此时线程不会等待其他线程执行完成,而是帮助他完成操作,将 tail 指针指向下一个节点。
     *      然后重复进行检查确认,直到 tail 完全处于队列尾部才开始执行自己的插入操作。
     * 如果两个线程同时插入元素,curTail.next.compareAndSet 会失败,这种情况下不会对当前数据结构造成破坏。当前线程只需重新读取tail 并再次重试。
     * 如果curTail.next.compareAndSet执行成功,那么插入操作已生效。
     * 此时 tail.compareAndSet(curTail, newNode) 会进行尾部指针的移动:
     *      如果移动失败,那么当前线程将直接返回,不需要进行重试
     *      因为另一个线程在检查 tail 时候会帮助更新。
     *
     * @param item 数据元素
     * @return true 成功
     */
    public boolean put(E item) {
        Node<E> newNode = new Node<>(item, null);
        while (true) {
            Node<E> curTail = tail.get();
            Node<E> tailNext = curTail.next.get();
            //判断下尾部节点是否出现变动
            if (curTail == tail.get()) {
                //tailNext节点为空的话,说明当前 tail 节点是有效的
                if (tailNext == null) {
                    //将新节点设置成 当前尾节点 的 next节点,此处为原子操作,失败则 while 循环重试
                    //技巧1 实现点
                    if (curTail.next.compareAndSet(null, newNode)) {
                        //将 tail 节点的指针指向 新节点
                        //此处不用担心 tail.compareAndSet 会更新失败
                        //因为当更新失败的情况下,肯定存在其他线程在操作
                        //另一个线程会进入 tailNext!=null 的情况,重新更新指针
                        tail.compareAndSet(curTail, newNode);
                        return true;
                    }
                } else {
                    //当前尾节点 的 next 不为空的话,说明链表已经被其他线程操作过了
                    //直接将 tail 的 next 指针指向下个节点
                    //技巧2 实现点
                    tail.compareAndSet(curTail, tailNext);
                }
            }
        }
    }
}

从最新的代码上来看,并发包中的很多工具类实现已做了变更优化,比如,内部实现改成了大多数并发类中的实现模式:

        private static final sun.misc.Unsafe UNSAFE;
        private static final long itemOffset;
        private static final long nextOffset;

        static {
            try {
                UNSAFE = sun.misc.Unsafe.getUnsafe();
                Class<?> k = Node.class;
                itemOffset = UNSAFE.objectFieldOffset
                    (k.getDeclaredField("item"));
                nextOffset = UNSAFE.objectFieldOffset
                    (k.getDeclaredField("next"));
            } catch (Exception e) {
                throw new Error(e);
            }
        }
    }

A-B-A 问题

CAS 操作对于 ABA 问题很是头疼,Java 提供的 AtomicStampedReference 通过引用上加上版本号来避免 ABA 问题。类似的AtomicMarkableReference 使用 boolean 类型来标记是否为已删除节点来对策 ABA 问题。

总结

非阻塞算法通过底层的并发原语(例如 CAS 而不是锁)来维持线程的安全性。这些底层的原语通过原子变量类对外公开。
非阻塞算法在设计和实现时非常困难,但通常能提供更高的可伸缩性。在 JVM 升级过程中,并发性能的主要提升都来自于(JVM 内部已经平台类库中)对非阻塞算法的使用。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,293评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,604评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,958评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,729评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,719评论 5 366
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,630评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,000评论 3 397
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,665评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,909评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,646评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,726评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,400评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,986评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,959评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,197评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,996评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,481评论 2 342