尝试Java加锁新思路:原子变量和非阻塞同步算法

简介: 进年以来,并发算法领域的重点都围绕在非拥塞算法,该种算法依赖底层硬件对于原子性指令的支持,避免使用锁来维护数据一致性和多线程安全。非拥塞算法虽然在设计上更为复杂,但是拥有更好的可伸缩性和性能,被广泛应用于实现计数器、序列发生器和统计数据收集器等1. 锁的劣势前文中曾经对比同步方法的内置锁相比和显式锁,来说明它们各自的优势,但是无论是内置说还是显式锁,其本质都是通过加锁来维护多线程安全。

进年以来,并发算法领域的重点都围绕在非拥塞算法,该种算法依赖底层硬件对于原子性指令的支持,避免使用锁来维护数据一致性和多线程安全。非拥塞算法虽然在设计上更为复杂,但是拥有更好的可伸缩性和性能,被广泛应用于实现计数器、序列发生器和统计数据收集器等

1. 锁的劣势

前文中曾经对比同步方法的内置锁相比和显式锁,来说明它们各自的优势,但是无论是内置说还是显式锁,其本质都是通过加锁来维护多线程安全。

由于加锁机制,线程在申请锁和等待锁的过程中,必然会造成线程的挂起和恢复,这样的线程上线文间切换会带来很大的资源开销,尤其是在锁资源竞争激烈的情况下。

同时,线程在等待锁的过程中,因为阻塞而什么也做,无限条件的等待不仅性能效率不佳,同时也容易造成死锁。

2. 悲观锁和乐观锁

无论是内置锁还是显式锁,都是一种独占锁,也是悲观锁。所谓悲观锁,就是以悲观的角度出发,认为如果不上锁,一定会有其他线程修改数据,破坏一致性,影响多线程安全,所以必须通过加锁让线程独占资源。

与悲观锁相对,还有更高效的方法——乐观锁,这种锁需要借助冲突检查机制来判断在更新的过程中是否存在来气其他线程的干扰,如果没有干扰,则操作成功,如果存在干扰则操作失败,并且可以重试或采取其他策略。换而言之,乐观锁需要原子性“读-改-写”指令的支持,来读取数据是否被其他线程修改,改写数据内容并将最新的数据写回到原有地址。现在大部分处理器以及可以支持这样的操作。

3. 比较并交换操作CAS

大部分处理器框架是通过实现比较并交换(Compare and Swap,CAS)指令来实现乐观锁。CAS指令包含三个操作数:需要读写的内存位置V,进行比较的值A和拟写入新值B。当且仅当V处的值等于A时,才说明V处的值没有被修改过,指令才会使用原子方式更新其为B值,否者将不会执行任何操作。无论操作是否执行, CAS都会返回V处原有的值。下面的代码模仿了CAS的语义。

public class SimulatedCAS {
    @GuardedBy("this") private int value;

    public synchronized int get() {
        return value;
    }

    // CAS = compare and swap
    public synchronized int compareAndSwap(int expectedValue,
                                           int newValue) {
        int oldValue = value;
        if (oldValue == expectedValue)
            value = newValue;
        return oldValue;
    }

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

当多个线程尝试更新同一个值时,只会有一个线程成功,其他线程都会失败,但是在CAS中,失败的线程不会被拥塞,可以自主定义失败后该如何处理,是重试还是取消操作,更具有灵活性。

通常CAS的使用方法为:先从V中读取A值,并根据A值计算B值,然后再通过CAS以原子的方法各部分更新V中的值。以计数器为例

public class CasCounter {
    private SimulatedCAS value;

    public int getValue() {
        return value.get();
    }

    public int increment() {
        int v;
        do {
            // 获得当前的值
            v = value.get();
        } while (v != value.compareAndSwap(v, v + 1));
        // 如果返回值不同,则说明更新成功了
        return v + 1;
    }
}

以不加锁的方式实现了原子的“读-改-写”操作。

CAS的方法在性能上有很大优势:在竞争程度不是很大的情况下,基于CAS的操作,在性能上远远超过基于锁的计数器;在没有竞争的情况下,CAS的性能更高。

但是CAS的缺点是:将竞争的问题交给调用者来处理,但是悲观锁自身就能处理竞争。

4. 原子变量

随着硬件上对于原子操作指令的支持,Java中也引入CAS。对于int、long和对象的引用,Java都支持CAS操作,也就是原子变量类,JVM会把对于原子变量类的操作编译为底层硬件提供的最有效的方法:如果硬件支持CAS,则编译为CAS指令,如果不支持,则编译为上锁的操作。

原子变量比锁的粒度更细, 更为轻量级,将竞争控制在单个变量之上。因为其不需要上锁,所以不会引发线程的挂起和恢复,因此避免了线程间上下文的切换,性能更好,不易出现延迟和死锁的现象。

常见的原子变量有AtomicIntegerAtomicLongAtomicBooleanAtomicReference,这些类都支持原子操作,使用get和set方法来获取和更新对象。原子变量数组只支持AtomicIntegerAtomicLongAtomicReference类型,保证数组中每个元素都是可以以volatile语义被访问。

需要注意的是原子变量没有定义hashCode和equals方法,所以每个实例都是不同的,不适合作为散列容器的key。

原子变量可以被视为一种更好volatile变量,通过compareAndSet方法尝试以CAS方式更新数据,下面以实现数字区间为示例代码展示如何使用AtomicReference

public class CasNumberRange {
    @Immutable
    private static class IntPair {
        // INVARIANT: lower <= upper
        final int lower;
        final int upper;

        public IntPair(int lower, int upper) {
            this.lower = lower;
            this.upper = upper;
        }
    }

    
    //源自引用 IntPair 初始化为[0,0]
    private final AtomicReference<IntPair> values =
            new AtomicReference<IntPair>(new IntPair(0, 0));

    public int getLower() {
        return values.get().lower;
    }

    public int getUpper() {
        return values.get().upper;
    }

    //设置下限
    public void setLower(int i) {
        //开始循环尝试
        while (true) {
            // 获得变量值
            IntPair oldv = values.get();
            // 如果下限设置比当前上限还要大
            if (i > oldv.upper)
                //抛出异常
                throw new IllegalArgumentException("Can't set lower to " + i + " > upper");
            IntPair newv = new IntPair(i, oldv.upper);
            //原子性更新
            if (values.compareAndSet(oldv, newv))
                //如果更新成功则直接返回,否者重新尝试
                return;
        }
    }

    //设置上限 过程和setLower类似
    public void setUpper(int i) {
        while (true) {
            IntPair oldv = values.get();
            if (i < oldv.lower)
                throw new IllegalArgumentException("Can't set upper to " + i + " < lower");
            IntPair newv = new IntPair(oldv.lower, i);
            if (values.compareAndSet(oldv, newv))
                return;
        }
    }
}

性能对比:

前文已经提过,原子变量因其使用CAS的方法,在性能上有很大优势:在竞争程度不是很大的情况下,基于CAS的操作,在性能上远远超过基于锁的计数器;在没有竞争的情况下,CAS的性能更高;但是在高竞争的情况下,加锁的性能将会超过原子变量性能(类似于,交通略拥堵时,环岛疏通效果好,但是当交通十分拥堵时,信号灯能够实现更高的吞吐量)。

不过需要说明的是,在真实的使用环境下,资源竞争的强度绝大多数情况下不会大到可以让锁的性能超过原子变量。所以还是应该优先考虑使用原子变量。

锁和原子变量在不同竞争程度上性能差异很好地说明了各自的优势:在中低程度的竞争之下,原子变量能提供更高的可伸缩性,而在高强度的竞争下,锁能够有效地避免竞争。

当然,如果能避免在多线程间使用共享状态,转而使用线程封闭(如ThreadLocal),代码的性能将会更进一步地提高。

5. 非阻塞算法

如果某种算法中,一个线程的失败或者挂起不会导致其他线程也失败和挂起,这该种算法是非阻塞的算法。如果在算法的每一步中都存在某个线程能够执行下去,那么该算法是无锁(Lock-free)的算法。

如果在算法中仅仅使用CAS用于协调线程间的操作,并且能够正确的实现,那么该算法既是一种无阻塞算法,也是一种无锁算法。在非拥塞算法中,不会出现死锁的优先级反转的问题(但是不排除活锁和资源饥饿的问题,因为算法中会反复尝试)。

上文中的CasNumberRange 就是一种非阻塞算法,其很好的说明了非拥塞算法设计的基本模式:在更新某个值时存在不确定性,如果失败就重新尝试。其中关键点在于将执行CAS的范围缩小在单一变量上。

5.1 非阻塞的栈

我们以非阻塞的栈为例说明非拥塞算法的设计思路。创建非阻塞算法的关键在于将原子修改的范围缩小到单个变量上,同时保证数据一致性。

栈是最简单的链式数据结构:每个元素仅仅指向一个元素,每个元素也仅被一个元素引用,关键的操作入栈(push)和出栈(pop)都是针对于栈顶元素(top)的。因此每次操作只需要保证栈顶元素的一致性,将原子操作的范围控制在指向栈顶元素的引用即可。实例代码如下:

//非阻塞的并发栈
public class ConcurrentStack <E> {
    //原子对象 栈顶元素
    AtomicReference<Node<E>> top = new AtomicReference<Node<E>>();

    public void push(E item) {
        Node<E> newHead = new Node<E>(item);
        Node<E> oldHead;
        do { //循环尝试
            oldHead = top.get();//获得旧值
            newHead.next = oldHead;
        } while (!top.compareAndSet(oldHead, newHead)); //比较旧值是否被修改,如果没有则操作成功,否者继续尝试;
    }

    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;
    }

    private static class Node <E> {
        public final E item;
        public Node<E> next;

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

以上代码充分体现了非阻塞算法的特点:某项操作的完成具有不确定性,如不成功必须重新执行。这个栈通过compareAndSet来修改栈顶元素,该方法为原子操作,如果发现被其他线程干扰,则修改操作失败,方法将重新尝试。

算法中的多线程安全性依赖于compareAndSet,其提供和加锁机制一样的安全性。既保证原子性,有保证了可见性。除此之外,AtomicReference对象上使用get方法,也保证了内存可见性, 和使用volatile变量一样。

5.2 非阻塞的链表

链表的结构比栈更为复杂,其必须支持头指针和尾指针,且同时有两个指针指向尾部,分别是尾指针和最后一个元素next指针。如何保证两个指针的数据一致性是一个难题,这不能通过一个CAS操作来完成。

这个难题可以应用这样一个技巧来解决:当线程B发现线程A正在修改数据结构时,数据结构中应该有足够多的信息使得线程B能帮助线程A完成操作,保证数据结构维持一致性。

我们以插入操作为例分析。在插入过程中有两个步骤:

  1. 插入新节点,将原有尾节点的next域指向该节点;
  2. 将尾指针移动到新的尾节点处。

所以我们可以根据尾节点的next域判断链表是否在稳定状态:如尾节点的next域为null,则说明该链表是稳定状态,没有其他线程在执行插入操作;反之,节点的next域不为null,则说明有其他线程在插入数据。

如果链表不处于稳定状态该怎么办呢?可以让后到的线程帮助正在插入的线程将尾部指针向后推移到新插入的节点处。示例代码如下:

public class LinkedQueue <E> {

    private static class Node <E> {
        final E item;
        //下一个节点
        final AtomicReference<Node<E>> next;

        public Node(E item, Node<E> next) {
            this.item = item;
            this.next = new AtomicReference<Node<E>>(next);
        }
    }

    //哑结点 也是头结点
    private final Node<E> dummy = new Node<E>(null, null);
    private final AtomicReference<Node<E>> head
            = new AtomicReference<Node<E>>(dummy);
    //尾部节点
    private final AtomicReference<Node<E>> tail
            = new AtomicReference<Node<E>>(dummy);

    public boolean put(E item) {
        Node<E> newNode = new Node<E>(item, null);
        while (true) {
            Node<E> curTail = tail.get();
            Node<E> tailNext = curTail.next.get();
            //得到尾部节点
            if (curTail == tail.get()) {
                // 1. 尾部节点的后续节点不为空,则队列处于不一致的状态
                if (tailNext != null) {
                    // 2. 将为尾部节点向后退进;
                    tail.compareAndSet(curTail, tailNext);
                } else {
                    // 3. 尾部节点的后续节点为空,则队列处于一致的状态,尝试更新
                    if (curTail.next.compareAndSet(null, newNode)) {
                        // 4. 更新成功,将为尾部节点向后退进;
                        tail.compareAndSet(curTail, newNode);
                        return true;
                    }
                }
            }
        }
    }
}

假如步骤一处发现链表处在非稳定状态,则会以原子的方法尝试将尾指针移动到新插入的节点,无论是否成功这时链表都会回到稳定状态,tail.next=null,此时再去重新新尝试。如果步骤二出已经将链表的尾指针移动,则步骤四处的原子操作就会失败,不过这没有关系,因为别的线程已经帮助其完成了该操作,链表保持稳定状态。

5.3 原子域更新器

上面提到的非拥塞链表,在ConcurrentLinkedQueue就有所应用,但是ConcurrentLinkedQueue并不是使用原子变量,而是使用普通的volatile变量,通过基于反射的原子域更新器(AtomicReferenceFieldUpdater)来进行更新。

原子域更新器是现有volatile域的一种基于反射的“视图”,能够在volatile域上使用CAS指令。原子域更新器没有构造器,要构建对象需要使用工厂方法newUpdater,函数然注释如下

    /**
    * @param tclass 持有待更新域的类
     * @param vclass 待更新域的类型
     * @param fieldName 待更新域的名字
     */
    public static <U,W> AtomicReferenceFieldUpdater<U,W> newUpdater(Class<U> tclass,                                                           
    Class<W> vclass,
    String fieldName);

使用更新器的好处在于避免构建原子变量的开销,但是这只适用于那些频繁分配且生命周期很短对象,比如列表的节点,其他情况下使用原子变量即可。

5.4 带有版本号原子变量

CAS操作是通过比较值来判断原值是否被修改,但是还有可能出现这样的情况:原值为A被修改为B,然后又被修改为A,也就是A-B-A的修改情况。这时再通过比较原值就不能判断是否被修改了。这个问题也被称为ABA问题

ABA问题的解决方案是为变量的值加上版本号,只要版本号变化,就说明原值被修改了,这就是带有时间戳的原子变量AtomicStampedReference

//原值和时间戳
public AtomicStampedReference(V initialRef, int initialStamp);

总结

非拥塞算法通过底层CAS指令来维护多线程的安全性,CAS指令被封装成原子变量的形式对外公开,是一种更好的volatile变量,可以提供更好伸缩性,防止死锁,但是设计和实现较为复杂,对开发人员要求很高。

扩展阅读:

  1. 多线程安全性:每个人都在谈,但是不是每个人都谈地清
  2. 对象共享:Java并发环境中的烦心事
  3. 从Java内存模型角度理解安全初始化
  4. 从任务到线程:Java结构化并发应用程序
  5. 关闭线程的正确方法:“优雅”的中断
  6. 驾驭Java线程池:定制与扩展
  7. 探秘Java并发模块:容器与工具类
  8. Java高级上锁机制:显式锁 ReentrantLock
相关文章
|
22天前
|
监控 算法 网络协议
Java 实现局域网电脑屏幕监控算法揭秘
在数字化办公环境中,局域网电脑屏幕监控至关重要。本文介绍用Java实现这一功能的算法,涵盖图像采集、数据传输和监控端显示三个关键环节。通过Java的AWT/Swing库和Robot类抓取屏幕图像,使用Socket进行TCP/IP通信传输图像数据,并利用ImageIO类在监控端展示图像。整个过程确保高效、实时和准确,为提升数字化管理提供了技术基础。
59 15
|
14天前
|
运维 监控 算法
企业局域网监控软件中 Java 优先队列算法的核心优势
企业局域网监控软件是数字化时代企业网络安全与高效运营的基石,犹如一位洞察秋毫的卫士。通过Java实现的优先队列算法,它能依据事件优先级排序,确保关键网络事件如异常流量、数据泄露等被优先处理,保障系统稳定与安全。代码示例展示了如何定义网络事件类并使用PriorityQueue处理高优先级事件,尤其在面对疑似风险时迅速启动应急措施。这一核心技术助力企业在复杂网络环境中稳健前行,护航业务腾飞。
57 32
|
4天前
|
存储 监控 算法
剖析基于Java算法驱动的智能局域网管控之道
本文探讨了基于Java语言的局域网控制方案,结合链表数据结构与令牌桶算法,解决设备管理和流量调度难题。通过链表灵活存储网络设备信息,实现高效设备管理;令牌桶算法则精准控制流量,确保网络平稳运行。二者相辅相成,为校园、企业等局域网提供稳固高效的控制体系,保障业务连续性和数据安全。
|
2天前
|
算法 搜索推荐 Java
【潜意识Java】深度解析黑马项目《苍穹外卖》与蓝桥杯算法的结合问题
本文探讨了如何将算法学习与实际项目相结合,以提升编程竞赛中的解题能力。通过《苍穹外卖》项目,介绍了订单配送路径规划(基于动态规划解决旅行商问题)和商品推荐系统(基于贪心算法)。这些实例不仅展示了算法在实际业务中的应用,还帮助读者更好地准备蓝桥杯等编程竞赛。结合具体代码实现和解析,文章详细说明了如何运用算法优化项目功能,提高解决问题的能力。
27 6
|
2天前
|
算法 Java C++
【潜意识Java】蓝桥杯算法有关的动态规划求解背包问题
本文介绍了经典的0/1背包问题及其动态规划解法。
26 5
|
12天前
|
存储 监控 算法
探秘局域网桌面监控:深入剖析 Java 语言核心算法
在数字化办公时代,局域网桌面监控如同企业的“智慧鹰眼”,确保工作效率与数据安全。本文以Java为载体,揭示哈希表在监控中的关键应用。通过高效的数据结构和算法,哈希表能快速索引设备连接信息,大幅提升监控的时效性和响应速度。代码示例展示了如何用Java实现设备网络连接监控,结合未来技术如AI、大数据,展望更智能的监控体系,助力企业在数字化浪潮中稳健前行。
|
28天前
|
缓存 算法 搜索推荐
Java中的算法优化与复杂度分析
在Java开发中,理解和优化算法的时间复杂度和空间复杂度是提升程序性能的关键。通过合理选择数据结构、避免重复计算、应用分治法等策略,可以显著提高算法效率。在实际开发中,应该根据具体需求和场景,选择合适的优化方法,从而编写出高效、可靠的代码。
35 6
|
2月前
|
缓存 Java
java中的公平锁、非公平锁、可重入锁、递归锁、自旋锁、独占锁和共享锁
本文介绍了几种常见的锁机制,包括公平锁与非公平锁、可重入锁与不可重入锁、自旋锁以及读写锁和互斥锁。公平锁按申请顺序分配锁,而非公平锁允许插队。可重入锁允许线程多次获取同一锁,避免死锁。自旋锁通过循环尝试获取锁,减少上下文切换开销。读写锁区分读锁和写锁,提高并发性能。文章还提供了相关代码示例,帮助理解这些锁的实现和使用场景。
java中的公平锁、非公平锁、可重入锁、递归锁、自旋锁、独占锁和共享锁
|
2月前
|
Java 调度
Java 线程同步的四种方式,最全详解,建议收藏!
本文详细解析了Java线程同步的四种方式:synchronized关键字、ReentrantLock、原子变量和ThreadLocal,通过实例代码和对比分析,帮助你深入理解线程同步机制。关注【mikechen的互联网架构】,10年+BAT架构经验倾囊相授。
Java 线程同步的四种方式,最全详解,建议收藏!
|
2月前
|
Java 开发者
Java 中的锁是什么意思,有哪些分类?
在Java多线程编程中,锁用于控制多个线程对共享资源的访问,确保数据一致性和正确性。本文探讨锁的概念、作用及分类,包括乐观锁与悲观锁、自旋锁与适应性自旋锁、公平锁与非公平锁、可重入锁和读写锁,同时提供使用锁时的注意事项,帮助开发者提高程序性能和稳定性。
120 3

热门文章

最新文章