(一)volatile关键字
Java 因为指令重排序,优化我们的代码,让程序运行更快,也随之带来了多线程下,指令执行顺序的不可控。
1.volatile关键字的作用:
volatile的底层是通过内存屏障实现的,第一个作用是禁止指令重排。内存屏障另一个作用是强制更新一次不同 CPU 的缓存。
synchronized 看作重量级的锁,而 volatile 看作轻量级的锁 。synchronized使用的锁的层面是在JVM层面,虚拟机处理字节码文件实现相关指令。volatile 底层使用多核处理器实现的 lock 指令,更底层,消耗代价更小。
(二)CAS
CAS 的全称是 Compare-And-Swap , 它是一条 CPU 并发原语。
CAS 并不是一种实际的锁,它仅仅是实现乐观锁的一种思想,java 中的乐观锁(如自旋锁)基本都是通过 CAS 操作实现的,CAS 是一种更新的原子操作,比较当前值跟传入值是否一样,一样则更新,否则失败。
乐观锁一般会使用版本号机制或 CAS 算法实现
1.版本号机制
一般是在数据表中加上一个数据版本号 version 字段,表示数据被修改的次数,当数据被修改时,version 值会加一。当线程 A 要更新数据值时,在读取数据的同时也会读取 version 值,在提交更新时,若刚才读取到的 version 值为当前数据库中的 version 值相等时才更新,否则重试更新操作,直到更新成功。
2.CAS 算法
即 compare and swap(比较与交换),是一种有名的无锁算法。无锁编程,即不使用锁的情况下实现多线程之间的变量同步,也就是在没有线程被阻塞的情况下实现变量的同步,所以也叫非阻塞同步(Non-blocking Synchronization)。 CAS 算法涉及到三个操作数
当且仅当 V 的值等于 A 时,CAS 通过原子方式用新值 B 来更新 V 的值,否则不会执行任何操作(比较和替换是一个原子操作)。一般情况下是一个自旋操作,即不断的重试。
3.synchronized与CAS的比较
synchronized涉及线程之间的切换,存在用户状态和内核状态的切换,耗费巨大。CAS只是CPU的一条原语,是一个原子操作,消耗较少。
Atomic 包中的类基本的特性就是在多线程环境下,当有多个线程同时对单个(包括基本类型及引用类型)变量进行操作时,具有排他性,即当多个线程同时对该变量的值进行更新时,仅有一个线程能成功,而未成功的线程可以向自旋锁一样,继续尝试,一直等到执行成功。
Atomic 系列的类中的核心方法都会调用 unsafe 类中的几个本地方法。这个类包含了大量的对 C 代码的操作,包括很多直接内存分配以及原子操作的调用,而它之所以标记为非安全的,是告诉你这个里面大量的方法调用都会存在安全隐患。unsafe 是 java 提供的获得对对象内存地址访问的类,它的作用就是在更新操作时提供 “比较并替换” 的作用。
CAS 并发原语现在 Java 语言中就是 sun.misc.Unsafe 类的各个方法,调用 Unsafe 类中的 CAS 方法,JVM 会帮我们实现 CAS 汇编指令,这是一种完全依赖硬件的功能,通过它实现了原子操作,由于 CAS 是一种系统原语,原语属于操作系统用语范畴,是由于诺干条指令组成的,用于完成某个功能的一个过程,并且原语的执行必须是连续的,在执行过程中不允许被中断,也就是说 CAS 是一条 CPU 原子指令,不会造成所谓的数据不一致问题。
可以看出atomic证原子性就是通过:自旋 + CAS(乐观锁)
仔细分析 concurrent 包的源代码实现,会发现一个通用化的实现模式:
首先,声明共享变量为 volatile;
然后,使用 CAS 的原子条件更新来实现线程之间的同步;
同时,配合以 volatile 的读 / 写和 CAS 所具有的 volatile 读和写的内存语义来实现线程之间的通信。
优缺点:
CAS 相对于其他锁,不会进行内核态操作,有着一些性能的提升。但同时引入自旋,当锁竞争较大的时候,自旋次数会增多。cpu 资源会消耗很高。CAS + 自旋适合使用在低并发有同步数据的应用场景。
多个线程即可以入列也可以出列,也就是数据的操作方向不一致,那么可能出现 ABA 的情况。
T1 线程准备出栈,对于出栈操作我们只需要将栈顶位置由 sp 通过 CAS 操作更新为 newSP 即可,如图 1 所示。但是在 T1 线程执行 tail.compareAndSet (sp,newSP) 之前系统进行了线程调度,T2 线程开始执行。T2 执行了三个操作,A 出栈,B 出栈,然后又将 A 入栈。此时系统又开始调度,T1 线程继续执行出栈操作,但是在 T1 线程看来,栈顶元素仍然为 A,(即 T1 仍然认为 B 还是栈顶 A 的下一个元素),而实际上的情况如图 2 所示。T1 会认为栈没有发生变化,所以 tail.compareAndSet (sp,newSP) 执行成功,栈顶指针被指向了 B 节点。而实际上 B 已经不存在于堆栈中,T1 将 A 出栈后的结果如图 3 所示,这显然不是正确的结果。
解决方法:
除了要比较当对象的前值和预期值以外,还要比较当前(操作的)戳值和预期(操作的)戳值,当全部相同时,compareAndSet 方法才能成功。每次更新成功,戳值都会发生变化,戳值的设置是由编程人员自己控制的。
【Java 面试那点事】
这里致力于分享 Java 面试路上的各种知识,无论是技术还是经验,你需要的这里都有!
这里可以让你【快速了解 Java 相关知识】,并且【短时间在面试方面有跨越式提升】
面试路上,你不孤单!