CAS
- CAS的全称是 Compare And Swap(比较相同再交换),是现代CPU广泛支持的一种对内存中的共享数据进行操作的一种特殊指令
- 作用:CAS可以将比较和交换转换为原子操作,这个原子操作直接由CPU保证,CAS可以保证共享变量赋值时的原子操作
- CAS操作依赖三个值:内存中的值V、旧的预估值X、要修改的新值B,如果内存中的值V = 旧的预估值X,就将新值B保存到内存中。
CAS原理
- Java中的
AtomicInteget
类就是使用CAS 和 volatile 实现无锁并发的,通过看看AtomicInteget
类的getAndIncrement()
方法是如何实现来分析CAS原理,以下为JDK部分源码
public class AtomicInteger {
private static final Unsafe unsafe = Unsafe.getUnsafe();
// 存储value在内存中的偏移量
// 当前对象的地址加上这个偏移量得到的就是value值在内存中的地址,从而得到内存中value的值
private static final long valueOffset;
// 存储的value值,volatile修饰,保证可见性
private volatile int value;
public final int getAndIncrement() {
return unsafe.getAndAddInt(this, valueOffset, 1);
}
}
public final class Unsafe {
public final int getAndAddInt(Object paramObject, long paramLong, int paramInt)
{
int i; // 存储内存中的值
do {
i = getIntVolatile(paramObject, paramLong); // 这一步就是通过当前对象地址和偏移量得到内存中的value值
} while (!compareAndSwapInt(paramObject, paramLong, i, i + paramInt));
return i;
}
}
-
getAndIncrement()方法最终调用的是Unsafe类中的getAndAddInt()方法,这个方法传 当前AtomicInteger对象,偏移量,以及1。
-
getIntVolatile():通过当前对象地址和偏移量得到内存中的value值,赋值给i
-
compareAndSwapInt()有三个参数:内存中的值V、旧的预估值X、要修改的新值B,当内存中的值v = 旧的预估值X时,就将新值B保存到内存中,同时返回true。
-
两个线程竞争的场景:
- 如果两个线程同时调用getAndIncrement()方法
- 线程1获取到内存中的value值为0,也就是说i = 0
- 此时切换到线程2,线程2也获取到 i = 0
- 这时仍然是线程2执行,通过调用compareAndSwapInt()方法,内存中的value值变成了1,并返回true
- 不满足循环条件,线程2跳出循环,执行完毕
- 此时切换到线程1,调用compareAndSwapInt()方法是发现,内存中的value值为1,而预估的旧值i是0,两个值不相等,不会更新内存中的value值,返回false
- 满足循环条件,继续上面的操作,直到赋值成功
-
CAS获取共享变量时,为了保证该变量的可见性,需要加volatile修饰,结合CAS和volatile可以实现无锁并发,适用于竞争不激烈,多核CPU的场景下。
- 1、因为没有使用synchronized,所以线程不会陷入阻塞,这事效率提升的因素之一。
- 2、但如果竞争激烈,重试必然频繁发生,反而效率会受影响。
乐观锁和悲观锁
- 悲观锁:从悲观的角度出发。总是假设最坏的情况,每次去拿数据的时候,都认为别人会修改,所以每次再拿数据的时候都会上锁,这样别人想拿这个数据的时候就会阻塞。
- 悲观锁性能较差。
- synchronized我们也称为悲观锁。JDK中的ReentrantLock也是一种悲观锁。
- 乐观锁:从乐观的角度出发。总是假设最好的情况,每次去拿数据的时候,都认为别人不会修改,就算修改了也没关系,再重试即可。所以不会上锁,但是再更新的时候会判断一下此期间别人有没有去修改这个数据,如果没人修改则更新,有人修改则重试。
- 乐观锁综合性能较好。
- CAS这种机制就是乐观锁。
synchronized锁升级过程
- 无锁 — 偏向锁 —轻量级锁 — 重量级锁
Java对象的布局
- 一个Java对象包含对象头、实例对象、对齐填充
偏向锁
- 偏向锁时JDK1.6中的重要引进,因为HotSpot作者经过研究实践发现,在大多数情况下,锁不仅不存在多线程竞争,而且总是由一个线程多次获得,为了让线程获得锁的代价更低,引进了偏向锁。
- 偏向锁会偏向于第一个获得它的线程,会在对象头存储锁偏向的线程ID,以后该线程进入和退出同步块时只需要检查是否为偏向锁、锁标志位以及ThreadID即可。
- 不过一旦出现多个线程竞争时,必须撤销偏向锁,所以撤销偏向锁消耗的性能必须小于之前省下来的CAS原子操作的性能消耗,不然就得不偿失了。
偏向锁原理
- 当线程第一次访问同步代码块并获取锁时,偏向锁处理流程如下:
- 虚拟机将会把对象头中的标志位设置为01,偏向锁位设置为1,即偏向锁模式
- 同时使用CAS操作把获取到这个锁的线程ID记录在对象的Mark Work中
- 如果CAS操作成功,持有偏向锁的线程以后每次进入这个锁相关的同步代码块时,虚拟机都可以不再进行任何同步操作
- 偏向锁的效率高。
偏向锁的撤销
- 偏向锁的撤销动作必须等待全局安全点
- 暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态
- 撤销偏向锁(偏向锁位设置为0),恢复到无锁(标志位为01)或轻量级锁(标志位为00)的状态
偏向锁的好处
- 偏向锁是只有一个线程执行同步块时进一步提高性能,适用于一个线程反复获得同一个锁的情况,偏向锁可以提高带有同步但无竞争的程序性能。
- 它同样时一个带有效益权衡性质的优化,也就是说,它并不一定总是对程序运行有立,如果程序中大多数的锁总是被多个不同的线程访问,比如线程池,那偏向锁模式就是多余的
- 在JDK5中,偏向锁默认是关闭的,而到了JDK6中,偏向锁已经默认开启,但应用程序启动几秒后才会激活,可以使用
-XX:BiasedLockingStartupDelay=0
设置偏向锁延迟启动为0(默认是5s),如果确定应用程序中所有锁通常情况下处于竞争状态,可以通过-XX:-UseBiasedLocking=false
参数关闭偏向锁
轻量级锁
- 轻量级锁是JDK1.6之中加入的新型锁机制,它是相当于使用“monitor”的传统锁而言的,传统锁称为重量级锁。
- 轻量级锁不是为了代替重量级锁的,它只是在某些特定的场景下性能较优
- 目的:在多线程交替执行同步块的情况下(当前线程执行同步代码块过程中,不会有其他线程去获取锁),引入轻量级锁,尽量避免重量级锁引起的性能消耗。但是如果多个线程在同一时刻进入临界区,会导致轻量级锁膨胀升级重量级锁,所以轻量级锁并不是为了代替重量级锁
- 对于轻量级锁,其性能提升的依据是对于绝大部分的锁,在整个生命周期内都是不会存在竞争的,如果打破这个依据则除了互斥的开销外,还有额外的CAS操作,因此在有多线程竞争的情况下,轻量级锁比重量级锁更慢。
轻量级锁原理
- 当关闭偏向锁或多个线程竞争偏向锁导致偏向锁升级为轻量级锁,则会尝试获取轻量级锁,其步骤如下:
- 1、判断当前对象是否处于无锁状态(hashcode 、0、01),如果是,则JVM首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Work的拷贝,将对象的Mark Work复制到栈帧中的Lock Record中,Lock Record中的owner指向当前对象。(锁对象的Mark Work中的信息复制到Lock Record)
- 2、JVM利用CAS操作尝试将对象的Mark Work更新为指向Lock Record的指针,如果成功表示竞争到锁,则将锁标志位变成00,执行同步操作
- 3、如果失败,则判断当前对象的Mark Work是否指向当前线程的栈帧,如果是,则表示当前线程已经拥有当前对象的锁,执行同步代码块;否则,只能说明该锁对象被其他线程抢占了,这时轻量级锁需要膨胀为重量级锁,标志位变成10,后面等待的线程进入阻塞状态。
轻量级锁的释放
- 轻量级锁的释放也是通过CAS操作进行的,主要步骤如下:
- 1、取出在获取轻量级锁保存在LockRecord中MarkWork的数。
- 2、用CAS操作将取出的数据替换当前对象的MarkWork,如果成功,则说明释放锁成功。
- 3、如果CAS操作替换失败,说明其他线程尝试释放锁,则轻量级锁需要膨胀为重量级锁
自旋锁
- monitor实现锁的时候,会阻塞和唤起线程,线程的阻塞和唤起需要CPU从用户态转换为内核态,频繁的阻塞和唤起对CPU来说是一件负担很重的工作,对CPU开销很大,切换成本很高,给系统的并发性能带来很大的压力。
- 同时,虚拟机开发团队注意到在许多应用上,共享数据的锁定状态只会持续很短的一段时间,为了这段时间阻塞和唤起线程并不值得,如果物理机上有一个以上的处理器,能让两个或以上的线程同时执行,我们就让后面的线程“稍等一下”,但不放弃处理器的执行时间,看看持有锁的线程是否很快就会释放锁。
- 为了让线程执行一个忙循环(自旋),这项技术就是所谓的自旋锁。
- 自旋锁在JDK1.4.2中已经引入,但默认是关闭的,在JDK1.6中,更改为默认开启
- 自旋等待不能代替阻塞,它对处理器数量有要求,且自旋等待本身虽然避免了线程切换,但他是要占用CPU时间,因此,如果锁被占用时间很短,自旋等待的效果很好,反之会带来性能上的浪费。因此,自旋等待的时间必须要有一定的限度,如果自旋超过了限定的次数仍然没有成功获得锁,就应该使用传统方式去挂起线程了。
- 自旋次数默认是10次,可以用参数
-XX:ProBlockSpin
来更改
适应性自旋锁
- 在JDK1.6中引入了自适应的自旋锁。
- 自适应意味着自旋的时间不再固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者状态来决定
- 如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么JVM就会任务这次自旋也很有可能再次成功,进而它将允许自旋等待持续相对更长的时间
- 如果对于某个锁,自旋很少成功获得过,那以后要获取这个锁时将可能省略掉自旋过程,以避免浪费CPU资源
锁消除
- 锁削除是指虚拟机即时编译器在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行削除。
- 锁削除的主要判定依据来源于逃逸分析的数据支持,如果判断到一段代码中,在堆上的所有数据都不会逃逸出去被其他线程访问到,那就可以把它们当作栈上数据对待,认为它们是线程私有的,同步加锁自然就无须进行。
- 逃逸分析的基本行为就是分析对象动态作用域:当一个对象在方法中被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他地方中,称为方法逃逸。
public String contactString(String s1, String s2, String s2) {
StringBuffer stringBuffer = new StringBuffer();
return stringBuffer.append(s1).append(s2).append(s3).toString();
}
- 上面那段代码,StringBuffer的append()方法是同步方法,3次append()方法相当于加了3次锁。
- 同步方法的锁对象是this,也就是上面new出来的StringBuffer对象stringBuffer。
- 当两个线程调用contactString()方法时,两个线程的锁对象是不同的,也就是说,某种程度上来讲,这两个线程是不存在竞争关系的,此时加3次锁是毫无意义的。
- 实际上,根据逃逸分析,可以看到stringBuffer的作用域仅限在contactString()方法内,也就是说stringBuffer的所有引用永远不会“逃逸”到concatString()方法之外,其他线程无法访问到它,所以这里虽然有锁,但是可以被安全地削除掉,在即时编译之后,这段代码就会忽略掉所有的同步而直接执行了
锁粗化
- 通常情况下,为了保证多线程间的有效并发,会要求每个线程持有锁的时间尽可能短,但是大某些情况下,一个程序对同一个锁不间断、高频地请求、同步与释放,会消耗掉一定的系统资源,因为锁的讲求、同步与释放本身会带来性能损耗,这样高频的锁请求就反而不利于系统性能的优化了,虽然单次同步操作的时间可能很短。
- JVM会探测到一连串细小的操作都使用同一个对象加锁,将同步代码块的范围放大,放到这串操作的外面,这样就只需要加1次锁就可以了。
- 锁粗化就是告诉我们任何事情都有个度,有些情况下我们反而希望把很多次锁的请求合并成一个请求,以降低短时间内大量锁请求、同步、释放带来的性能损耗。
for (int i = 0; i < 100; i++) {
synchronized (obj) {
// 做一些能很快完成的操作
}
}
- 锁粗化后
synchronized (obj) {
for (int i = 0; i < 100; i++) {
// 做一些能很快完成的操作
}
}
日常代码中对synchronized的优化
减少synchronized的范围
- 同步代码块中尽量短,减少同步代码块中代码执行的时间,减少锁的竞争
- 这样轻量级锁或自旋锁就能保证线程安全,避免锁升级为重量级锁
降低synchronized锁的粒度
- 将一个锁拆分为多个锁,非相关的代码块使用两个不同的锁对象上锁
- Hashtable:get/put/remove操作使用的是同步方法,锁对象是当前对象this,锁定整个哈希表,一个操作正在进行时,其他操作也同时锁定,效率低下。
- ConcurrentHashMap:get无锁,put操作使用同步代码块,锁对象是当前桶的第一个元素,局部锁定,只锁定桶,对当前元素锁定时,对其他元素不锁定。
读写分离
- 读取时不加锁,写入和删除时加锁
- ConcurrentHashMap,CopyOnWriteArrayList和CopyOnWriteSet