volatile关键字和CAS的原子性操作
volatile 关键字
volatile 是 Java 中的关键字,用于修饰变量。它的作用是确保对被修饰变量的读写操作具有可见性和顺序性。
可见性:当一个线程修改了 volatile 变量的值,其他线程可以立即看到最新的值。这是因为 volatile 变量在修改时会强制将最新的值刷新到主内存中,并在读取时从主内存中获取最新的值。
顺序性:在使用 volatile 变量进行读写操作时,编译器和处理器会禁止指令重排序,保证了操作的顺序性。
不保证原子性
由于 volatile 强制要求变量的读写从主内存进行,而不是从线程的缓存中读取,这可能会导致额外的性能开销。
CAS(Compare and Swap)
CAS 是一种乐观锁的原子操作,用于实现多线程环境下的并发控制。CAS 操作包含三个参数:主内存(线程共享的内存区域)的值、期望值、更新值。它的执行过程如下:
首先,它会比较主内存的值与期望值是否相等。
如果相等,则将更新值写入该变量。
如果不相等,则表示其他线程已经修改了该变量,CAS 操作失败,需要重新读取最新的变量值作为期望值并再次尝试(自旋)。
CAS 操作是一种乐观并发控制方式,通过循环重试的方式来避免线程的阻塞和切换开销,相对于传统的使用锁的悲观并发控制方式,它可以提供更高的并发性能。
CAS 存在的 ABA 问题:
ABA 问题指的是在使用 CAS 进行比较时,可能出现值从 A 变为 B,然后再次变回 A 的情况。这种情况下,CAS 操作在比较时可能会成功,尽管中间的操作已经改变了变量的值。这可能引发一些潜在的问题。
此时CAS认为期望值和新值相等,会误认为可以修改变量,但其实变量已经发生了修改,CAS的原子性操作已无法保证!
为了应对ABA问题,Java 提供了 AtomicStampedReference 和 AtomicMarkableReference 类
@Test
void CASTest(){
AtomicStampedReference<String> casObject = new AtomicStampedReference<>("A", 0);
// 线程1:尝试修改值
Thread thread1 = new Thread(() -> {
int stamp = casObject.getStamp(); // 获取当前标记值
String expectedReference = casObject.getReference(); // 获取当前期望值
try {
Thread.sleep(1000); // 为了模拟thread2线程的修改
} catch (InterruptedException e) {
e.printStackTrace();
}
// 尝试使用 CAS 修改值
boolean success = casObject.compareAndSet(expectedReference, "C", stamp, stamp + 1);
log.info("Thread 1 - CAS operation success: " + success);
});
// 线程2:修改值为初始值
Thread thread2 = new Thread(() -> {
int stamp = casObject.getStamp(); // 获取当前标记值
String expectedReference = casObject.getReference(); // 获取当前期望值
// 将值从 A 修改为 B
casObject.compareAndSet(expectedReference, "B", stamp, stamp + 1);
log.info("Thread 2: Value changed to B");
// 将值从 B 修改回 A
stamp = casObject.getStamp();
casObject.compareAndSet("B", "A", stamp, stamp + 1);
log.info("Thread 2: Value changed back to A");
});
thread1.start();
thread2.start();
try {
thread1.join();
thread2.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
/*
最后输出结果为 Final value: A
这说明thread1线程在修改变量casObject时保证了原子性,否则就会输出Final value: C
这是因为AtomicStampedReference不仅会比较期望值,还会比较标记值stamp,当两者都相等时才会执行Swap行为修改变量
*/
log.info("Final value: " + casObject.getReference());
}
由于修改AtomicStampedReference对象时,你可以在stamp标记位做标识,如果标记位的值发生了变化,那么CAS操作就会被取消,即有效避免了ABA问题。
关于CAS的一些思考
CAS操作允许多个线程同时访问共享资源,但只有一个线程能成功地执行更新操作。
CAS是一种乐观锁的原子操作,是因为它相对于synchronized等悲观锁实现,CAS操作不需要加锁和解锁过程,因此减少了线程间的竞争和阻塞,提高了并发性能。悲观锁在占据资源时,其他线程访问该资源,则需要等待锁释放资源+竞争锁。
悲观锁担心资源很容易被修改,为了避免多个线程看到的资源不一致,因此并发访问在锁持有资源的时候就成了串行访问。
乐观锁认为只有资源被修改时,才会发生并发安全问题,因此直接允许多线程访问资源,当更新资源时,工作内存中资源的期望值与主内存中资源的值不相等时(线程访问的资源是从主内存拷贝到线程工作区间的资源,因此才能比较),才会做出限制。
最后,「CAS + 原子类」方案其实并没有完全实现原子性操作,因为在获取stamp标记位之前这段时间里,资源发生ABA问题,那么当前的CAS就会执行更新。因此CAS+原子类并非100%线程安全。
在 JDK 8 中,ConcurrentHashMap 就使用了「CAS + synchronized」的方案来保证线程安全。它通过 CAS 操作实现了无锁的并发操作,并在必要时使用 synchronized 进行加锁来处理竞争情况。这种组合方案既提高了并发性能,又确保了线程安全。
CAS 主要用于以下操作:
- 插入操作:使用 CAS 来原子地插入一个新的节点。
- 更新操作:使用 CAS 来原子地更新一个节点的值。
- 删除操作:使用 CAS 来原子地删除一个节点。
synchronized 主要用于以下操作:
- 扩容操作:使用 synchronized 来保证扩容操作是原子性的。
- 读取操作:使用 synchronized 来防止读取操作过程中发生哈希冲突导致的死循环。
JDK8 放弃分段锁(Segment)设计的原因主要有以下几个:
- 分段锁会导致内存碎片化。
- 分段锁会导致竞争激烈时性能下降。
- JDK8 中使用了 CAS 等技术来提高并发性能,因此不需要分段锁。