锁无关数据结构 -- AtomicReference

tech2023-12-02  35

基于jdk提供的原子类原语实现,例如AtomicReference

锁无关(Lock free)算法,顾名思义,即不牵涉锁的使用。这类算法可以在不使用锁的情况下同步各个线程。对比基于锁的多线程设计,锁无关算法有以下优势: 对死锁、优先级倒置等问题免疫:它属于非阻塞性同步,因为它不使用锁来协调各个线程,所以对死锁、优先级倒置等由锁引起的问题免疫;

原语 Compare-and-swap (CAS) ,Herlihy 证明了 CAS 是实现锁无关数据结构的通用原语, CAS 可以原子地比较一个内存位置的内容及一个期望值,如果两者相同,则用一个指定值取替这个内存位罝里的内容,并且提供结果指示这个操作是否成功。很多现代的处理器已经提供了 CAS 的硬件实现,例如在 x86 架构下的 CMPXCHG8 指令。而在 Java 下,位于 java.util.concurrent.atomic 内的 AtomicReference<V> 类亦提供了 CAS 原语的实现,并且有很多其他的扩展功能。 CAS 操作将会是稍后实现的锁无关数据算法无可缺少的指令。

自 JDK 1.5 推出之后,当中的 java.util.concurrent.atomic 的一组类为实现锁无关算法提供了重要的基础。本文介绍如何将锁无关算法应用到基本的数据结构中,去避免竞争条件,允许多个线程同时存取和使用集合中的共享数据。如果一个数据结构本身并非是线程安全的,一旦在多线程环境下使用这个数据结构,必须施加某种同步机制,否则很可能会出现竞争条件。

栈 栈能以数组或者链表作为底下的储存结构,虽然采取链表为基础的实现方式会占用多一点空间去储存代表元素的节点,但却可避免处理数组溢出的问题。故此我们将以链表作为栈的基础。 首先,我们分析一下一个非线程安全的版本。为了清楚表达和集中于文章的主题,代码没有包含对异常及不正当操作的处理,读者请留意。它的代码如下:

清单 1. 非线程安全的栈实现

class Node<T> {    Node<T> next;    T value;        public Node(T value, Node<T> next) {        this.next = next;        this.value = value;   } } ​ ​ public class Stack<T> {    Node<T> top;        public void push(T value) {        Node<T> newTop = new Node<T>(value, top);        top = newTop;   }        public T pop() {        Node<T> node = top;        top = top.next;        return node.value;   }        public T peek() {        return top.value;   } }

数据成员 top 储存着栈顶的节点,它的类型为 Node<T> ,这是因为我们的栈是基于链表的。 Node<T> 代表一个节点,它有两个数据成员, value 储存着入栈的元素,而 next 储存下一个节点。这个类有三个方法,分别是 push 、 pop 和 peek ,它们是基本的栈操作。除了 peek 方法是线程安全之外,其余两个方法在多线程环境之下都有可能引发竞争条件。

push 方法 让我们先考虑一下 push 方法,它能将一个元素入栈。调用 push 时,它首先建立一个新的节点,并将 value 数据成员设定为传入的参数,而 next 数据成员则被赋值为当前的栈顶。然后它把 top 数据成员设定成新建立的节点。假设有两个线程 A 和 B 同时调用 push 方法,线程 A 获取当前栈顶的节点去建立新的节点( push 方法代码第一行),但由于时间片用完,线程 A 暂时挂起。此时,线程 B 获取当前栈顶的节点去建立新的节点,并把 top 设定成新建立的节点( push 方法代码第二行)。然后,线程 A 恢复执行,更新栈顶。当线程 B 对 push 的调用完成后,线程 A 原本获得的栈顶已经「过期」,因为线程 B 用新的节点取代了原本的栈顶。

pop 方法 至于 pop 方法,它把栈顶的元素弹出。 pop 方法把栈顶暂存在一个本地变量 node ,然后用下一个节点去更新栈顶,最后返回变量 node 的 value 数据成员。如果两个线程同时调用这个方法,可能会引起竞争条件。当一个线程将当前栈顶赋值到变量 node ,并准备用下一个节点更新栈顶时,这个线程挂起。另一个线程亦调用 pop 方法,完成并返回结果。刚刚被挂起的线程恢复执行,但由于栈顶被另一个线程变更了,所以继续执行的话会引起同步问题。

peek 方法 而 peek 方法只是简单地返回当前位于栈顶的元素,这个方法是线程安全的,没有同步问题要解决。 在 Java 要解决 push 和 pop 方法的同步问题,可以用 synchronized 这个关键词,这是基于锁的解决方案。现在我们看看锁无关的解决方案,以下是锁无关栈实现的代码:

清单 2. 锁无关的栈实现

import java.util.concurrent.atomic.*; ​ class Node<T> {    Node<T> next;    T value;        public Node(T value, Node<T> next) {        this.next = next;        this.value = value;   } } ​ ​ public class Stack<T> {    AtomicReference<Node<T>> top = new AtomicReference<Node<T>>();        public void push(T value) {        boolean sucessful = false;        while (!sucessful) {            Node<T> oldTop = top.get();            Node<T> newTop = new Node<T>(value, oldTop);            sucessful = top.compareAndSet(oldTop, newTop);       };   }        public T peek() {        return top.get().value;   }        public T pop() {        boolean sucessful = false;        Node<T> newTop = null;        Node<T> oldTop = null;        while (!sucessful) {            oldTop = top.get();            newTop = oldTop.next;            sucessful = top.compareAndSet(oldTop, newTop);       }        return oldTop.value;   } }

这个新的实现方式和刚刚的很不同,看似比较复杂。成员数据 top 的类型由 Node<T> 改为 AtomicReference<Node<T>> , AtomicReference<V> 这个类可以对 top 数据成员施加 CAS 操作,亦即是可以允许 top 原子地和一个期望值比较,两者相同的话便用一个指定值取代。从上文可知,我们需要解决遇到栈顶「过期」的问题。

push 方法 现在我们先分析新的 push 方法如何处理这个问题,确保竞争条件不会出现。在 while 循环中,通过在 top 数据成员调用 AtomicReference.get() , oldTop 持有当前栈顶节点,这个栈顶稍后会被取替。变量 newTop 则被初始化为新的节点。最重要的一步, top.compareAndSet(oldTop, newTop) ,它比较 top 和 oldTop 这两个引用是否相同,去确保 oldTop 持有的栈顶并未「过期」,亦即未被其他线程变更。假如没有过期,则用 newTop 去更新 top ,使之成为新的栈顶,并返回 boolean 值 true 。否则, compareAndSet 方法便返回 false ,并且令到循环继续执行,直至成功。因为 compareAndSet 是原子操作,所以可以保证数据一致。

pop 方法 pop 方法把栈顶的元素弹出,它的实现方式和 push 方法十分类同。在 while 循环内, compareAndSet 检查栈顶有没有被其他线程改变,数据一致的话便更新 top 数据成员并把原本栈顶弹出。如果失败,便重新尝试,直至成功。 push 和 pop 都没有使用任何锁,所以全部线程都不用停下来等待。

ABA 问题 因为 CAS 操作比较一个内存位置的内容及一个期望值是否相同,但如果一个内存位置的内容由 A 变成 B,再由 B 变成 A, CAS 仍然会看作两者相同。不过,一些算法因为需求的关系无法容忍这种行为。当一些内存位置被重用的时候,这个问题便可能会发生。在没有垃圾回收机制的环境下,ABA 问题需要一些机制例如标记去解决。但由于 JVM 会为我们处理内存管理的问题,故此以上的实现足以避免 ABA 问题的出现。

最新回复(0)