美文网首页
Java 多线程(七)原子变量与非阻塞同步机制

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

作者: 闲相思 | 来源:发表于2020-06-08 17:44 被阅读0次

    在 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 内部已经平台类库中)对非阻塞算法的使用。

    相关文章

      网友评论

          本文标题:Java 多线程(七)原子变量与非阻塞同步机制

          本文链接:https://www.haomeiwen.com/subject/fazrtktx.html