尝试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
相关文章
|
6天前
|
存储 缓存 安全
【Java面试题汇总】多线程、JUC、锁篇(2023版)
线程和进程的区别、CAS的ABA问题、AQS、哪些地方使用了CAS、怎么保证线程安全、线程同步方式、synchronized的用法及原理、Lock、volatile、线程的六个状态、ThreadLocal、线程通信方式、创建方式、两种创建线程池的方法、线程池设置合适的线程数、线程安全的集合?ConcurrentHashMap、JUC
【Java面试题汇总】多线程、JUC、锁篇(2023版)
|
2天前
|
Java 数据库
JAVA并发编程-一文看懂全部锁机制
曾几何时,面试官问:java都有哪些锁?小白,一脸无辜:用过的有synchronized,其他不清楚。面试官:回去等通知! 今天我们庖丁解牛说说,各种锁有什么区别、什么场景可以用,通俗直白的分析,让小白再也不怕面试官八股文拷打。
|
2天前
|
安全 Java 开发者
Java并发编程中的锁机制解析
本文深入探讨了Java中用于管理多线程同步的关键工具——锁机制。通过分析synchronized关键字和ReentrantLock类等核心概念,揭示了它们在构建线程安全应用中的重要性。同时,文章还讨论了锁机制的高级特性,如公平性、类锁和对象锁的区别,以及锁的优化技术如锁粗化和锁消除。此外,指出了在高并发环境下锁竞争可能导致的问题,并提出了减少锁持有时间和使用无锁编程等策略来优化性能的建议。最后,强调了理解和正确使用Java锁机制对于开发高效、可靠并发应用程序的重要性。
11 3
|
22天前
|
存储 Java
Java锁是什么?简单了解
在高并发环境下,锁是Java中至关重要的概念。锁或互斥是一种同步机制,用于限制多线程环境下的资源访问,确保排他性和并发控制。例如,超市储物柜仅能存放一个物品,若三人同时使用,则需通过锁机制确保每次只有一个线程访问。Java中可以通过`synchronized`关键字实现加锁,确保关键代码段的原子性,避免数据不一致问题。正确使用锁可有效提升程序的稳定性和安全性。
Java锁是什么?简单了解
|
14天前
|
Oracle Java 关系型数据库
【颠覆性升级】JDK 22:超级构造器与区域锁,重塑Java编程的两大基石!
【9月更文挑战第6天】JDK 22的发布标志着Java编程语言在性能和灵活性方面迈出了重要的一步。超级构造器和区域锁这两大基石的引入,不仅简化了代码设计,提高了开发效率,还优化了垃圾收集器的性能,降低了应用延迟。这些改进不仅展示了Oracle在Java生态系统中的持续改进和创新精神,也为广大Java开发者提供了更多的可能性和便利。我们有理由相信,在未来的Java编程中,这些新特性将发挥越来越重要的作用,推动Java技术不断向前发展。
|
21天前
|
设计模式 缓存 算法
揭秘策略模式:如何用Java设计模式轻松切换算法?
【8月更文挑战第30天】设计模式是解决软件开发中特定问题的可重用方案。其中,策略模式是一种常用的行为型模式,允许在运行时选择算法行为。它通过定义一系列可互换的算法来封装具体的实现,使算法的变化与客户端分离。例如,在电商系统中,可以通过定义 `DiscountStrategy` 接口和多种折扣策略类(如 `FidelityDiscount`、`BulkDiscount` 和 `NoDiscount`),在运行时动态切换不同的折扣逻辑。这样,`ShoppingCart` 类无需关心具体折扣计算细节,只需设置不同的策略即可实现灵活的价格计算,符合开闭原则并提高代码的可维护性和扩展性。
37 2
|
17天前
|
安全 Java API
Java线程池原理与锁机制分析
综上所述,Java线程池和锁机制是并发编程中极其重要的两个部分。线程池主要用于管理线程的生命周期和执行并发任务,而锁机制则用于保障线程安全和防止数据的并发错误。它们深入地结合在一起,成为Java高效并发编程实践中的关键要素。
10 0
|
19天前
|
开发者 C# Windows
WPF与游戏开发:当桌面应用遇见游戏梦想——利用Windows Presentation Foundation打造属于你的2D游戏世界,从环境搭建到代码实践全面解析新兴开发路径
【8月更文挑战第31天】随着游戏开发技术的进步,WPF作为.NET Framework的一部分,凭借其图形渲染能力和灵活的UI设计,成为桌面游戏开发的新选择。本文通过技术综述和示例代码,介绍如何利用WPF进行游戏开发。首先确保安装最新版Visual Studio并创建WPF项目。接着,通过XAML设计游戏界面,并在C#中实现游戏逻辑,如玩家控制和障碍物碰撞检测。示例展示了创建基本2D游戏的过程,包括角色移动和碰撞处理。通过本文,WPF开发者可更好地理解并应用游戏开发技术,创造吸引人的桌面游戏。
52 0
|
19天前
|
开发者 C# 存储
WPF开发者必读:资源字典应用秘籍,轻松实现样式与模板共享,让你的WPF应用更上一层楼!
【8月更文挑战第31天】在WPF开发中,资源字典是一种强大的工具,用于共享样式、模板、图像等资源,提高了应用的可维护性和可扩展性。本文介绍了资源字典的基础知识、创建方法及最佳实践,并通过示例展示了如何在项目中有效利用资源字典,实现资源的重用和动态绑定。
35 0
|
19天前
|
开发者 Java Spring
【绝技揭秘】掌握Vaadin数据绑定:一键同步Java对象,告别手动数据烦恼,轻松玩转Web应用开发!
【8月更文挑战第31天】Vaadin不仅是一个功能丰富的Java Web应用框架,还提供了强大的数据绑定机制,使开发者能轻松连接UI组件与后端Java对象,简化Web应用开发流程。本文通过创建一个简单的用户信息表单示例,详细介绍了如何使用Vaadin的`Binder`类实现数据绑定,包括字段与模型属性的双向绑定及数据验证。通过这个示例,开发者可以更专注于业务逻辑而非繁琐的数据同步工作,提高开发效率和应用可维护性。
38 0