java并发编程:CAS、Unsafe类、重排序、伪共享

本文探讨了Java并发编程中的概念,包括悲观锁与乐观锁的对比,重点解析了CAS机制及其在Unsafe类中的实现,同时提到了指令重排序对并发的影响以及如何应对伪共享问题。通过AtomicStampedReference解决ABA问题,并介绍了LongAdder在高并发下的优化作用。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

1 Q:什么是悲观锁、乐观锁

悲观锁:指在更新数据时,数据被外界修改,持保守态度。认为在更新数据的时候,大概率会有其他线程争抢共享资源。此时为了避免出错,第一个拿到资源的线程会对资源进行加排它锁, 其他没争夺到资源的线程只能进入阻塞队列 。之前上面提到的synchronized就是悲观锁的一种实现方式。

乐观锁:在更新数据时,认为数据被外界修改的概率是很小的,在更新数据时,不会对共享数据加锁,但是在正式更新数据之前会检查数据是否被其他线程修改过, 如果未被其他线程改变过就将共享变量更新成最新值,如果发现共享变量已经被其他线程更新过了,就重试,直到成功为止。典型的实现方式是CAS机制。

2 Q:那CAS机制是什么

Compare and Swap,比较并交换。如同上面提到的乐观锁,CAS是乐观锁的实现,在写回内存的时候,首先比较内存中的值与预期值是否相同,相同才会写回。

例如:假设内存中的值是x,线程中的缓存备份(预期值)A,计算后将要写回内存的值B。将B写回内存时,首先将内存中的x与预期值A比较,如果相同的话,将B赋值给x。如果不相同,认为x此时已经被其他线程修改了,重新将x赋值给A,并重新计算B,重复比较

之前,我们提到了,使用锁会阻塞线程,会带来线程上下文的切换和重新调度开销;而volatile关键字解决了共享内存的可见性,但是不解决原子性问题。CAS的出现,解决了阻塞和非原子性的问题。

在JDK的Unsafe类提供了一系列compareAndSwap*方法,是JDK提供的非阻塞原子性操作,通过硬件保证了比较–更新操作的原子性。

例子:boolean compareAndSwapLong(Object Obj, long valueOffset, long except, long update)。

​ 操作数分别对应:对象内存位置,对象中的变量的偏移量,变量预期值和新的值。 操作含义:如果对象Obj中的内存偏移量为valueOffset的变量值为except,则使用新值update替换旧值except。这是处理器提供的一个原子指令。

3 Q:CAS是完美的吗(ABA问题)

从辩证法角度讲,肯定不是;实际上,也有一个很经典的ABA问题。

1 当一个线程①,使用CAS操作,尝试将初始值为A的共享变量修改为B。假设在执行CAS操作前,另一个线程②使用CAS操作将A修改了B,然后又将B修改为了A,此时线程①再继续操作CAS,因为内存中和线程中都是A,所以操作成功 ,但是此时的A,已经不是线程①获得的A了。

其核心问题就在于,通过值判断版本变化,如果出现环形转换,(A->B->A),CAS操作是无感的。

在JDK中的AtomicStampedReference类,给每一个变量的状态值都配备了一个时间戳,从而避免了ABA问题。

2 在线程之间竞争程度大的时候,如果使用CAS,每次都有很多的线程在竞争,也就是说CAS机制不能更新成功。这种情况下CAS机制会一直重试,这样就会比较耗费CPU。 在并发量非常高的环境中,如果仍然想通过原子类来更新的话,可以使用AtomicLong的替代类:LongAdder。

3 Java中的CAS机制只能保证共享变量操作的原子性,而不能保证代码块的原子性

优点:

  • 可以保证变量操作的原子性;
  • 并发量不是很高的情况下,使用CAS机制比使用锁机制效率更高;
  • 在线程对共享资源占用时间较短的情况下,使用CAS机制效率也会较高。

4 Q:提到的Unsafe类是什么

在JDK的rt.jar包中的Unsafe类提供了硬件级别的原子性操作,Unsafe类中的方法都是native方法,它们使用JNI的方式访问本地C++实现库。

通过Unsafe.getUnsafe()可以获取Unsafe的实例,查看源码会发现,其会校验是不是Bootstrap类加载器加载的localClass,我们自己编写的应用代码,通过main函数启动,使用的是AppClassLoader加载的,故会抛异常。

这是因为Unsafe提供了很多直接操作内存的方法,是很不安全的,所以增加了限制,不让开发人员在正规渠道开发使用,而是在rt.jar包里面的核心类中使用Unsafe功能。

但是也可以通过反射来实现,拿到相关的方法。

以下是几个主要的方法:

  • long objectFieldOffset(Field field)方法:返回指定的变量在所属类中的内存偏移地址,该偏移地址仅仅在该Unsafe函数中访问指定字段时使用。
  • int arrayBaseOffset(Class arrayClass)方法:获取数组中第一个元素的地址。
  • int arrayIndexScale(Class arrayClass)方法:获取数组中一个元素占用的字节。
  • boolean compareAndSwapLong(Object obj, long offset, long expect, long update)方法:比较对象obj中偏移量为offset的变量的值是否与expect相等,相等则使用update值更新,然后返回true,否则返回false。
  • public native long getLongvolatile(Object obj, long offset)方法:获取对象obj中偏移量为offset的变量对应volatile语义的值。
  • void putLongvolatile(Object obj, long offset, long value)方法:设置obj对象中offset偏移的类型为long的field的值为value,支持volatile语义。
  • void putOrderedLong(Object obj, long offset, long value)方法:设置obj对象中offset偏移地址对应的long型field的值为value。这是一个有延迟的putLongvolatile方法,并且不保证值修改对其他线程立刻可见。只有在变量使用volatile修饰并且预计会被意外修改时才使用该方法。
  • void park(boolean isAbsolute, long time)方法:阻塞当前线程,其中参数isAbsolute等于false且time等于0表示一直阻塞。time大于0表示等待指定的time后阻塞线程会被唤醒,这个time是个相对值,是个增量值,也就是相对当前时间累加time后当前线程就会被唤醒。如果isAbsolute等于true,并且time大于0,则表示阻塞的线程到指定的时间点后会被唤醒,这里time是个绝对时间,是将某个时间点换算为ms后的值。另外,当其他线程调用了当前阻塞线程的interrupt方法而中断了当前线程时,当前线程也会返回,而当其他线程调用了unPark方法并且把当前线程作为参数时当前线程也会返回。
  • void unpark(Object thread)方法:唤醒调用park后阻塞的线程。

JDK8中新增的Long类型操作:

  • long getAndSetLong(Object obj, long offset, long update)方法:获取对象obj中偏移量为offset的变量volatile语义的当前值,并设置变量volatile语义的值为update。
  • long getAndAddLong(Object obj, long offset, long addValue)方法:获取对象obj中偏移量为offset的变量volatile语义的当前值,并设置变量值为原始值+addValue,原理和上面的方法类似。

5 Q:指令重排序对并发有什么影响

JAVA的内存模型允许编译器和处理器对指令重排序以提高运行性能,并且只会对不存在数据依赖性的指令重排序(体系结构上应该还是有一定优化的)。

在单线程下,指令重排序与顺序执行的结果一致,在多线程下,可能会产生不同的结果。

例如:

public static int num = 0;
public static boolean ready = false;
// 某读线程
public void run(){
    if(ready){// (1)
        System.out.println(num+num);// (2)
    }
}
...
// 某写线程
public void run(){
    num = 2;// (3)
    ready = true;// (4)
}
...
// 调起线程
    

(3)(4)操作无依赖关系,可能被重排序。

又因为(1)(2)在另一线程,所以这将会带来多种执行顺序组合,结果也不完全一样:

(4)(3)(1)(2):4;

(4)(1)(3)(2):4;

(4)(1)(2)(3):0;

如上例子,这是指令重排序可能带来的问题。当然对于上述问题,根源还是共享变量,在重排序后带来的不确定。此外其实该例子还有内存可见性的问题,可以使用volatile关键字修饰ready,既可以避免内存不可见的问题,又可以避免重排序。

写volatile变量时,可以确保在写之前的操作不会被编译器重编译到写之后。

读volatile变量时,可以确保在读之后的操作,不会被编译器重排序到volatile读之前。

6 Q:什么是伪共享

学过计组的话,我们知道利用程序局部性原理,设计了cache缓存机制,且缓存内以缓冲行为单位(可能包含多个变量)。一般会对每个核心设计一个一级缓存,然后共用一个二级缓存。

在单核情况下,确实会因为局部性原理,减少对内存的访问,以提升CPU效率。

但是在多线程下,当CPU1将变量x的值修改,首先修改CPU1的一级缓存,根据缓存一致性协议,对于CPU2的一级缓存中的该缓存行也失效了。当CPU2需要访问x,就只能去二级缓存中寻找。

原因:

​ 多线程下,并发修改一个缓存行中的多个变量时,就会竞争缓存行,从而降低程序效率。

7 Q: 如何避免伪共享

JDK8之前,一般是通过字节填充的方式,创建一个变量时,使用填充字段填充该变量所在的缓冲行。

public final static class FilledLong{
    public volatile long value = 0L;
    public long p1, p2, p3, p4, p5, p6;
}

如果缓存行为64Byte,将类中填充6个long类型的变量,每个占8个字节+value的8个+类对象的8个一共64字节。

但是这种方法依靠开发去根据实际编写,实用性不强。

JDK8提供了一个sun.misc.Contended注解。既可以修饰类,也可以修饰方法。

默认情况下,@Contended注解只用于java核心类,用户类路径下的类使用注解,需要添加JVM参数-XX:-RestrictContended填充的默认宽度为128,要自定义宽度可以设置-XX:ContendedPaddingWidth参数。

参考文献:《java并发编程之美》

评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值