Java乐观锁解决ABA问题(附带实例)
乐观锁的 ABA 问题是一种在并发编程中使用原子操作时可能遇到的问题。ABA 问题的名称源于在一个线程视角下,共享资源(如一个变量)的值从 A 变化到 B,再变回 A。
在乐观锁机制中,通常使用 CAS 操作来实现无锁的同步。在 CAS 操作中,一个线程在更新某个变量之前读取当前值(假设为 A),然后执行一些计算得到新值(假设为 B),最后使用 CAS 操作来原子性更新该变量。在这个过程中,CAS 操作会检查变量的当前值是否仍然为 A,如果是,则将其值更新为 B;否则,操作失败,可能会重试。
ABA 问题出现在以下情形中:
虽然线程 1 的 CAS 操作成功了,但是它对于在此期间变量值的变化一无所知。这可能会导致错误的假设,因为变量在此期间可能已经被其他线程使用过了,它的状态与线程 1 最初读取它时的状态已经不同。
下面让我们以一个多线程示例来进一步了解 ABA 问题。假设有一个表示银行账户余额的整数变量 balance,该变量的初始值为 100。现在有两个线程 A 和 B 同时操作这个 balance 变量:
这里我们使用伪代码来描述 ABA 场景:
为了解决 ABA 问题,可以使用版本号或时间戳等机制来确保在值 A 之间的变化能被检测到。在 Java 中,可以使用 AtomicStampedReference 或 AtomicMark-ableReference 类来实现,它们包装了引用并与一个版本号或标记一起更新,这样就可以检测到在两次读取之间是否有写入操作发生。
我们用一个示例来介绍一下如何使用 AtomicStampedReference 来解决 ABA 问题。假设我们有一个简单的栈,我们希望保护其 pop() 方法不受 ABA 问题的影响。
在不使用 AtomicStampedReference 的情况下,如果两个线程同时操作栈,一个线程从栈中弹出一个元素,另一个线程将相同的元素推回去,第一个线程可能不会意识到栈的状态改变过。
在示例中,我们使用 AtomicStampedReference 保存栈顶元素和一个时间戳(可以把它看作一个版本号),每次操作栈时,版本号都会增加,这样就能检测到在两次读取操作之间是否有写入操作发生。
如果时间戳在操作期间发生变化,意味着另一个线程已经修改了栈,compareAndSet() 会失败,当前操作会在一个循环中重试直到成功。线程可以通过时间戳的改变得知在其操作期间是否有其他线程对栈进行了修改。这样,栈的操作就能正确地反映出并发修改的实际情况。
在乐观锁机制中,通常使用 CAS 操作来实现无锁的同步。在 CAS 操作中,一个线程在更新某个变量之前读取当前值(假设为 A),然后执行一些计算得到新值(假设为 B),最后使用 CAS 操作来原子性更新该变量。在这个过程中,CAS 操作会检查变量的当前值是否仍然为 A,如果是,则将其值更新为 B;否则,操作失败,可能会重试。
ABA 问题出现在以下情形中:
- 线程 1 读取变量值 A;
- 线程 2 将变量的值从 A 改为 B,然后改回 A;
- 线程 1 进行 CAS 操作,发现变量值仍然是A(因为它没有注意到在此期间的变化),于是认为没有其他线程修改过变量,继续将 A 更新为 B。
虽然线程 1 的 CAS 操作成功了,但是它对于在此期间变量值的变化一无所知。这可能会导致错误的假设,因为变量在此期间可能已经被其他线程使用过了,它的状态与线程 1 最初读取它时的状态已经不同。
下面让我们以一个多线程示例来进一步了解 ABA 问题。假设有一个表示银行账户余额的整数变量 balance,该变量的初始值为 100。现在有两个线程 A 和 B 同时操作这个 balance 变量:
- 线程 A 要检查余额是否足够,如果足够就进行一些操作(例如扣费);
- 线程 B 进行两次操作:先取出一定金额,然后存回相同的金额。
这里我们使用伪代码来描述 ABA 场景:
初始状态:balance = 100 线程A: 读取 balance 得到 100(A1) 检查余额是否足够,线程暂停,未立即进行扣费(A2) 线程B: 读取 balance 得到 100(B1) 执行取款 50,balance 变为 50(B2) 执行存款 50,balance 变回 100(B3) 线程A: 线程恢复,使用 CAS 操作检查 balance 是否仍为 100(A3) 发现 balance 为 100,误以为未发生变化(A4) 进行扣费,比如扣掉 30(A5)在这个示例中,即使余额在两个线程 A 的操作之间发生了变化(由 100 变为 50,又变回 100),线程 A 仍然会认为账户的余额没有变化,因为余额在两次检查时都是 100。因此,线程 A 会继续它的操作。这就是 ABA 问题:线程 A 看到的 balance 值没有变化,但实际上后台发生了变化(A->B->A)。这可能导致线程 A 做出基于错误假设的决策。
为了解决 ABA 问题,可以使用版本号或时间戳等机制来确保在值 A 之间的变化能被检测到。在 Java 中,可以使用 AtomicStampedReference 或 AtomicMark-ableReference 类来实现,它们包装了引用并与一个版本号或标记一起更新,这样就可以检测到在两次读取之间是否有写入操作发生。
我们用一个示例来介绍一下如何使用 AtomicStampedReference 来解决 ABA 问题。假设我们有一个简单的栈,我们希望保护其 pop() 方法不受 ABA 问题的影响。
在不使用 AtomicStampedReference 的情况下,如果两个线程同时操作栈,一个线程从栈中弹出一个元素,另一个线程将相同的元素推回去,第一个线程可能不会意识到栈的状态改变过。
在示例中,我们使用 AtomicStampedReference 保存栈顶元素和一个时间戳(可以把它看作一个版本号),每次操作栈时,版本号都会增加,这样就能检测到在两次读取操作之间是否有写入操作发生。
import java.util.concurrent.atomic.AtomicStampedReference; public class Stack { private AtomicStampedReference<Integer> top = new AtomicStamped-Reference<>(null, 0); public Integer pop() { int[] stampHolder = new int[1]; Integer value; do { value = top.getReference(); // 获取当前栈顶元素 int stamp = top.getStamp(); // 获取当前时间戳 stampHolder[0] = stamp; // 如果栈为空,就返回null if (value == null) { return null; } // 尝试使用CAS操作移除栈顶元素,同时期望时间戳没有变化 } while (!top.compareAndSet(value, null, stampHolder[0],stampHolder[0] + 1)); return value; } public void push(Integer value) { int stamp; Integer currentTop; do { currentTop = top.getReference(); // 获取当前栈顶元素 stamp = top.getStamp(); // 获取当前时间戳 // 尝试使用CAS操作推送新的栈顶元素,同时期望时间戳没有变化 } while (!top.compareAndSet(currentTop, value, stamp, stamp + 1)); } }在上面的示例代码中,AtomicStampedReference 用于存储栈顶元素和它的时间戳。每次调用 pop() 或 push() 方法时,都会通过 getStamp() 和 getReference() 方法获取当前时间戳和栈顶元素。然后,compareAndSet() 方法使用这个时间戳去校验栈的状态是否发生了变化。
如果时间戳在操作期间发生变化,意味着另一个线程已经修改了栈,compareAndSet() 会失败,当前操作会在一个循环中重试直到成功。线程可以通过时间戳的改变得知在其操作期间是否有其他线程对栈进行了修改。这样,栈的操作就能正确地反映出并发修改的实际情况。