共享模型之无锁
文章目录
一、CAS 与 volatile
AtomicInteger
的使用举例
//创建实例
AtomicInteger balance = new AtomicInteger(int n);
//CAS操作
while(true) {
// 获取实例的最新值
int prev = balance.get();
// 修改后的实例的值
int next = prev - amount;
// 真正修改,将修改后的数据同步到主存中
// compareAndSet参数1是获取到的最新值,参数2是修改后的值,将参数2写往主存中
if(balance.compareAndSet(prev, next)) { //修改成功返回true
break;
}
}
//上述循环的代码可以使用如下方法替换
balance.getAndAdd(-1 * amount);
1. CAS
AtomicInteger内部并没有用锁来保护共享变量的线程安全
它的机制是 compareAndSet方法,简称就是 CAS ,它有两个操作:比较和设置值
如下图①的位置,线程2已经将余额修改为90,但线程1获取到的余额还是100,此时cas操作会将第一个参数(线程1拿到的最新值100)与主存中的数据(已被修改为90)进行比较
-
如果不一致,cas操作会返回fasle,表示修改主存的值失败,再次进入循环,重新获取主存中最新的值
-
如果一致,执行set,cas操作返回true,表示成功修改主存的值
核心思想在于采用不断比较并不断获取新值的方式保证了线程安全
2. volatile
AtomicInteger的值通过value属性保存,此属性使用了volatile关键字 ,即 private volatile int value;
获取共享变量时,为了保证该变量的可见性,故需要使用 volatile 修饰
CAS 必须借助 volatile 才能读取到共享变量的最新值来实现比较并交换的效果
3. 为什么无锁效率高
- 无锁情况下,即使重试失败,线程始终在高速运行,没有停歇,而 synchronized 会让线程在没有获得锁的时候,发生上下文切换,进入阻塞
- 但无锁情况下,因为线程要保持运行,需要额外 CPU 的支持
4. CAS 的特点
-
结合 CAS 和 volatile 可以实现无锁并发、无阻塞并发,适用于线程数少、多核 CPU 的场景
- 如果竞争激烈(写操作多),可以想到重试必然频繁发生,反而效率会受影响
-
CAS 是基于乐观锁的思想:最乐观的估计,不怕别的线程来修改共享变量,即使改了也可以再重载
-
synchronized 是基于悲观锁的思想:最悲观的估计,防止其它线程修改共享变量,释放锁,别的线程才有机会
二、原子整数
JUC并发包提供了一些并发工具类,这里把它分成五类:
- 原子整数
- 原子引用
- 原子数组
- 字段更新器
- 原子累加器
下面先讨论原子整数类:
对类中保存的整数类型的数据的操作具有原子性
- AtomicInteger:整型原子类
- AtomicLong:长整型原子类
- AtomicBoolean :布尔型原子类
上面三个类提供的方法几乎相同,以 AtomicInteger 为例讨论它的api接口:通过观察源码可以发现,AtomicInteger 内部都是通过cas的原理来实现的,也就是说所有方法都可以保证原子性,不会有线程安全的问题
public static void main(String[] args) {
//1. 创建AtomicInteger对象
AtomicInteger i = new AtomicInteger(0);
//参数表示初始值,保存在value属性中
//2. get方法拿到最新值
//3. compareAndSet方法通常需要与while循环结合使用,使用起来不方便
//4. 获取并自增(i = 0, 结果 i = 1, 返回 0),类似于 i++
System.out.println(i.getAndIncrement());
//5. 自增并获取(i = 1, 结果 i = 2, 返回 2),类似于 ++i
System.out.println(i.incrementAndGet());
//6. 自减并获取(i = 2, 结果 i = 1, 返回 1),类似于 --i
System.out.println(i.decrementAndGet());
//7. 获取并自减(i = 1, 结果 i = 0, 返回 1),类似于 i--
System.out.println(i.getAndDecrement());
//8. 获取并加值(i = 0, 结果 i = 5, 返回 0)
System.out.println(i.getAndAdd(5));
//9. 加值并获取(i = 5, 结果 i = 0, 返回 0)
System.out.println(i.addAndGet(-5));
//10. 获取并更新(形参p为i的当前值, 函数体中写对p的操作;i = 0, 结果i = -2, 返回 0)
// 参数是函数式编程接口,其中函数中的操作能保证原子
System.out.println(i.getAndUpdate(p -> p - 2));
//11. 更新并获取(i = -2, p 为 i 的当前值, 结果 i = 0, 返回 0)
// 参数是函数式编程接口,其中函数中的操作能保证原子
System.out.println(i.updateAndGet(p -> p + 2));
//12. 获取并计算(i = 0, p 为 i 的当前值, x 为参数1的值, 结果 i = 10, 返回 0)
// 函数式编程接口,其中函数中的操作能保证原子
System.out.println(i.getAndAccumulate(10, (p, x) -> p + x));
//13. 计算并获取(i = 10, p 为 i 的当前值, x 为参数1值, 结果 i = 0, 返回 0)
// 函数式编程接口,其中函数中的操作能保证原子
System.out.println(i.accumulateAndGet(-10, (p, x) -> p + x));
}
三、原子引用
保证引用类型的共享变量是线程安全的
常用的有以下三种:
- AtomicReference:引用类型原子类
- AtomicStampedReference:带有版本号的引用类型原子类
- AtomicMarkableReference:带有标记的引用类型原子类
1. AtomicReference
用法与原子整数一致,也要通过循环CAS操作保证线程安全,泛型中声明此实例对应的类型
private AtomicReference<BigDecimal> balance ;
2. ABA 问题
cas操作中,某一线程仅能判断出共享变量此时的值与最初获取到的值 A 是否相同,不能感知到别的线程将共享变量从 A 改为 B 又改回 A 的情况,如果某一线程希望:只要有其它线程修改过共享变量,那么自己的 cas 操作就算失败,这时,仅比较值是不够的,需要再加一个版本号
使用 AtomicStampedReference
来解决
3. AtomicStampedReference
代码示例
@Slf4j(topic = "c.Test36")
public class Test36 {
//参数1表示初始值,参数2表示初始版本号
static AtomicStampedReference<String> ref = new AtomicStampedReference<>("A", 0);
public static void main(String[] args) throws InterruptedException {
log.debug("主线程启动");
//获取初始值A
String prev = ref.getReference();
//获取版本号
int stamp = ref.getStamp();
log.debug("主线程获取到的版本 {}", stamp);
other(); //other方法中定义了两个新的线程,制造ABA问题
log.debug("主线程睡眠1秒");
Thread.sleep(1000);
// 主线程尝试将A修改为C
// compareAndSet第三个参数是获取到的版本号,第四个参数表示修改后的版本号
log.debug("主线程尝试将 A->C {}", ref.compareAndSet(prev, "C", stamp, stamp + 1));
}
//定义other方法
private static void other() throws InterruptedException {
new Thread(() -> {
log.debug("新线程1尝试将 A->B {}", ref.compareAndSet(ref.getReference(), "B", ref.getStamp(), ref.getStamp() + 1));
log.debug("新线程1更新后的版本为 {}", ref.getStamp());
}, "t1").start();
Thread.sleep(1000); //睡眠1秒后再启动第二个线程
new Thread(() -> {
log.debug("新线程2尝试将 B->A {}", ref.compareAndSet(ref.getReference(), "A", ref.getStamp(), ref.getStamp() + 1));
log.debug("新线程2更新后的版本为 {}", ref.getStamp());
}, "t2").start();
}
}
//主线程获取的A的版本号和目前的A的版本号不同,修改失败
4. AtomicMarkableReference
如果只是单纯的关心共享变量是否更改过,而不关心更改过几次,可以使用 AtomicMarkableReference
代码示例
//1. 创建对象
//参数1表示初始值,参数2表示初始标记
AtomicMarkableReference<T> ref = new AtomicMarkableReference<>("T类型的初始值", true);
//2. 获取值
T prev = ref.getReference();
//3. 修改主存中的值
//参数3表示期望的标记,参数4表示修改之后的标记,如果主存的数据不是期望的标记则修改失败
ref.compareAndSet("获取到的值", "修改后的值", true, false);
//如果有别的线程对主存中的数据进行修改,则标记变为false,此线程会修改失败
四、原子数组
使用原子的方式操作数组里的某个元素(不会产生线程安全问题),而使用普通的数组无法保证原子性
- AtomicIntegerArray:整型数组原子类
- AtomicLongArray:长整型数组原子类
- AtomicReferenceArray :引用类型数组原子类
//创建数组方式一,定义数组长度
AtomicIntegerArray array = new AtomicIntegerArray(10);
//创建数组方式二,定义数组内容
int[] a = new int[]{1, 2, 3};
AtomicIntegerArray array = new AtomicIntegerArray(a);
其中常用的方法:i参数表示索引值
五、原子更新器
保证操作类中的属性时的原子性
-
AtomicReferenceFieldUpdater:表示类类型的字段(属性)
-
AtomicIntegerFieldUpdater:表示整型的字段
-
AtomicLongFieldUpdater:表示长整型的字段
@Slf4j(topic = "c.Test40")
public class Test40 {
public static void main(String[] args) {
Student stu = new Student();
//参数1表示属性所属的类
//参数2表示属性的类型
//参数3表示属性的名称
AtomicReferenceFieldUpdater updater =
AtomicReferenceFieldUpdater.newUpdater(Student.class, String.class, "name");
//参数1表示要修改哪个对象中的属性
//参数2表示期望属性的初始值是什么
//参数3表示要把属性的值修改为什么
//初始值为期望才可以修改成功
System.out.println("是否修改成功:" + updater.compareAndSet(stu, null, "张三"));
System.out.println(stu);
}
}
class Student {
//属性使用volatile来修饰
volatile String name;
@Override
public String toString() {
return "Student{" +
"name='" + name + '\'' +
'}';
}
}
-
运行结果:
-
注意:字段更新器,只能配合 volatile 修饰的字段使用,否则会出现异常
-
如果把name属性的volatile关键字去掉,运行结果:
六、原子累加器LongAdder
-
LongAdder累加器的使用
LongAdder adder = new LongAdder(); //创建一个累加器,初始值为0 adder.increment(); //对累加器的值加1 adder.decrement(); //对累加器的值减一 adder.add(5); //对累加器的值增加指定的数值
-
相比于原子整数的
getAndIncrement()
性能有了很大的提升(二者都可以保证累加的原子性) -
原子整数的
getAndIncrement()
会在多个线程有竞争的情况下,循环的去比较,线程较多竞争较激烈时,循环的次数会越多,必然会造成性能的下降 -
LongAdder性能提升的原因很简单,就是在有竞争时,设置多个累加单元(不是在一个共享变量Cell上累加),Therad-0 累加 Cell[0],而 Thread-1 累加Cell[1]… 最后将结果汇总
-
这样它们在进行累加操作时,操作不同的 Cell 变量,因此减少了 CAS 失败重试的次数,从而提高性能
-
累加单元数不会超过CPU的核心数