CAS与原子类
CAS概述
CAS即compare and swap,它体现一种**乐观锁(假设不会有其他线程影响当前线程操作,如果发现其他线程影响了当前线程的操作,就回滚当前线程,重新执行)**的思想,比如多个线程要对一个共享整型变量执行+1操作:
while(true){
int 旧值 = 共享变量;
int 结果 = 旧值+1;
/*
此时如果别的线程把共享变量修改了,本线程的正确结果1旧作废了,这时候compareAndSwap返回false,
重新尝试,知道compareAndSwap返回true,标识我本线程做修改的同时,没有其他线程干扰
*/
if(compareAndSwpap(旧值,结果)){
// 成功就退出循环
}
}
获取共享变量时,为了保证变量的可见性,需要使用volatile修饰。结合CAS和volatile可以实现无所并发,适用于竞争不激烈、多核CPU的场景下
- 因为没有使用synchronized,所以线程不会阻塞,这是效率提升的因素之一
- 但是如果竞争激烈,会不断发生重试,反而没有悲观锁(synchronized)效率高
CAS底层
CAS底层是使用的Unsafe对象,调用操作系统的compareAndSwap函数实现的
他会从函数传入旧值和新值,如果旧值和主内存中的值一样,就写入新值,不一样就回滚
乐观锁和悲观锁
- CAS是基于乐观锁的思想,最乐观的估计,不怕别的线程来修改共享变量,如果改了就重试
- synchronized是基于悲观锁的思想,最悲观的估计,得防着其他线程来修改共享变量,一个线程上了锁其他线程就只能等着,等当前线程操作结束解锁了,才有机会操作共享变量
原子操作类
JUC中提供了原子操作类,可以提供线程安全的操作,例如AtomicInteger、AtomicBoolean等,他们底层就是采用CAS技术+volatile来实现的
public class Main{
private static AtomicInteger num = new AtomicInteger(0);
public static void main(String[] args) throws InterruptedException, FileNotFoundException {
Thread t1 = new Thread(()->{
for (int i = 0; i < 1000; i++) {
// 获取并且自增
num.getAndIncrement();
}
});
Thread t2 = new Thread(()->{
for (int i = 0; i < 1000; i++) {
// 获取并自减
num.getAndDecrement();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(num);
}
}
synchronized优化
Java HotSpot虚拟机中,每个对象都有对象头(包括class指针和Mark Word)。Mark Word平时存储这个对象的哈希码、分代年龄,当加锁时,这些信息就根据情况被替换为标记位、线程锁记录指针、重量级锁指针、线程ID等
轻量级锁
如果一个对象虽然有多线程访问,但多线程访问的时间是错开的(也就是没有锁竞争),那么可以使用轻量级锁来优化
每个线程的栈帧都会包含一个锁记录的结构(这个结构是一个栈,可以有多个锁记录入栈,后加的锁要先解锁),内部可以存储锁定对象的Mark Word
轻量级锁执行流程:
static Object obj = new Object();
public static void m1(){
synchronized (obj){
// 同步块A
m2();
}
}
public static void m2(){
synchronized (obj) {
//同步块B
}
}
线程1 | 对象Mark Word | 线程2 |
---|---|---|
访问同步块A,将Mark赋值到线程1的锁记录 | 01(无锁) | - |
CAS将Mark修改为线程1的锁记录地址 | 01(无锁) | - |
修改成功 | 00(轻量级锁)线程1锁记录地址 | - |
执行同步块A | 00(轻量级锁)线程1锁记录地址 | - |
访问同步块B,把Mark复制到锁记录 | 00(轻量级锁)线程1锁记录地址 | - |
CAS将Mark修改为线程1的锁记录地址 | 00(轻量级锁)线程1锁记录地址 | - |
修改失败(发现是自己的锁) | 00(轻量级锁)线程1锁记录地址 | - |
锁重入 | 00(轻量级锁)线程1锁记录地址 | - |
执行同步块B | 00(轻量级锁)线程1锁记录地址 | - |
同步块B执行完毕 | 00(轻量级锁)线程1锁记录地址 | - |
同步块A执行完毕 | 00(轻量级锁)线程1锁记录地址 | - |
解锁 | 01(无锁) | - |
- | 01(无锁) | 访问同步块A,把Mark复制到线程的锁记录 |
… | … | … |
锁膨胀
以上是没有出现锁竞争的情况,如果出现了锁竞争,即超过两个以上的线程竞争同一个锁,轻量级锁就会膨胀成重量级锁,锁标志状态变成10;后面的线程也必须进入阻塞状态
static Object obj = new Object();
public staic void m1(){
synchronized(obj){
// 同步块
}
}
线程1 | 对象Mark Word | 线程2 |
---|---|---|
访问同步块,将Mark赋值到线程1的锁记录 | 01(无锁) | - |
CAS将Mark修改为线程1的锁记录地址 | 01(无锁) | - |
修改成功 | 00(轻量级锁)线程1锁记录地址 | - |
执行同步块 | 00(轻量级锁)线程1锁记录地址 | - |
执行同步块 | 00(轻量级锁)线程1锁记录地址 | 访问同步块,将Mark赋值到线程2的锁记录 |
执行同步块 | 00(轻量级锁)线程1锁记录地址 | CAS将Mark修改为线程2的锁记录地址 |
执行同步块 | 00(轻量级锁)线程1锁记录地址 | 修改失败(发现别人已经占了锁) |
执行同步块 | 00(轻量级锁)线程1锁记录地址 | CAS修改Mark为重量级锁 |
执行同步块 | 00(轻量级锁)线程1锁记录地址 | 阻塞 |
执行完毕 | 10(重量级锁)重量锁指针 | 阻塞 |
解锁失败(发现变成了重量级锁) | 10(重量级锁)重量锁指针 | 阻塞 |
释放重量锁,根据重量锁指针,唤起阻塞线程 | 10(重量锁) | 阻塞 |
- | 10(重量锁) | 竞争重量锁 |
- | 10(重量锁) | 加锁成功 |
… | … | … |
锁自旋
- 重量锁中的互斥同步对性能影响最大的是阻塞,挂起线程和恢复线程都需要转入内核态中完成
- 重量锁竞争的时候,在多核CPU中,还可以使用自旋来进行优化
- 锁自旋:一个处理器核心正在处理线程时,新的线程不会进入阻塞,而是又另一个cpu核心来让他执行一个忙循环(忙循环就是用一个循环让线程等待,它不像阻塞一样会放弃CPU,而是又CPU控制进入死循环) ,如果当前线程自旋成功(即这个时候持有锁的线程已经退出了同步块,释放了锁),这个自旋的线程就可以接手锁
- 自适应自旋:因为自旋会一直占用CPU,如果锁一直得不到释放,这样可能会得不偿失;可以通过-XX:PreBlockSpin来指定默认自旋次数;在jdk6的自旋是自适应的,上一次自旋次数,决定了下一次自旋次数,如果上一次成功,就认为这一次成功可能性高,就多自旋几次;失败就少自旋几次或者直接进入阻塞
- Java7开始不能控制是否开启自旋功能,默认自旋
自旋成功的情况
线程1(cpu1) | 对象Mark | 线程2(cpu2) |
---|---|---|
访问同步块,获取monitor | 10(重量锁)重量锁指针 | - |
成功(加锁) | 10(重量锁)重量锁指针 | - |
执行同步块 | 10(重量锁)重量锁指针 | - |
执行同步块 | 10(重量锁)重量锁指针 | 访问同步块,获取monitor |
执行同步块 | 10(重量锁)重量锁指针 | 自旋重试 |
执行完毕 | 10(重量锁)重量锁指针 | 自旋重试 |
成功(解锁) | 01(无锁) | 自旋重试 |
访问同步块 | 10(重量锁)重量锁指针 | 成功(加锁) |
… | … | … |
自旋失败的情况
线程1(cpu1) | 对象Mark | 线程2(cpu2) |
---|---|---|
访问同步块,获取monitor | 10(重量锁)重量锁指针 | - |
成功(加锁) | 10(重量锁)重量锁指针 | - |
执行同步块 | 10(重量锁)重量锁指针 | - |
执行同步块 | 10(重量锁)重量锁指针 | 访问同步块,获取monitor |
执行同步块 | 10(重量锁)重量锁指针 | 自旋重试 |
执行同步块 | 10(重量锁)重量锁指针 | 自旋重试 |
执行同步块 | 10(重量锁)重量锁指针 | 阻塞 |
… | … | … |
偏向锁
轻量级锁在没有竞争时,每次重入仍然需要进行CAS操作。Java6中引入了偏向锁来做优化,即偏向第一次加锁的线程,只有第一次使用CAS将线程id写入到对象的Mark Word,之后没有其他线程写入,线程第二次需要对这个对象加锁时发现线程ID是自己的,就不需要重新CAS加锁
- 如果出现了锁竞争,撤销偏向需要将持锁线程升级为轻量级锁,这个过程需要stop the world
- 访问对象的hashCode也会撤销偏向锁
- 如果对象虽然被多个线程访问,但没有竞争,这是偏向了线程T1的对象仍有机会重新偏向T2,重偏向会重置对象的Thread ID
- 撤销偏向和重偏向都市批量进行的,以类为单位
- 如果撤销偏向达到某个阈值,整个类所有对象都会变成不可偏向的
其他优化
- 减少上锁时间,同步代码块尽量短
- 减少锁的粒度,将一个锁拆分为多个锁提高并发度
- 锁粗化,多次循环进入同步块,不如在同步块内多次循环;另外JVM可能会做如下优化,
new StringBuffer().append("a").append("b")
把多次append的加锁粗化为一次,因为都是对一个对象的频繁加锁,没必要重入多次 - 锁消除,JVM会进行代码的逃逸分析,例如某个加锁对象是方法内部局部变量,不会被其他线程所访问到,被即使编译期忽略所有同步操作
- 读写分离,入CopyOnWriteArrayList、CopyOnWriteSet