什么是CAS?
CAS全称是Compare And Swap,它是CPU的一条指令,相当于这是一个原子的操作。
它做的工作正如它的名字一样 ,比较并且交换。假设内存中的原数据V,预期旧值A,和希望更新的值B
- 比较V与A值是否相同
- 如果相同就让B赋值给V
比如java类库中的 AtomicInteger类,底层就是通过CAS实现的。
用Atomicinteger类也可以保证多线程情况下修改同一个数据的线程安全。
代码实现:
public class Test14 {
public static void main(String[] args) throws InterruptedException {
// 传入初始值为0
AtomicInteger atomicInteger = new AtomicInteger(0);
// 开启三个线程对atomicInteger进行加操作,每个线程都加1000次
for (int i = 0; i < 3; i++) {
new Thread(() -> {
for (int j = 0; j < 1000; j++) {
atomicInteger.getAndIncrement();
// System.out.println(atomicInteger);
}
}).start();
}
// 让主线程睡一秒,等待上面的线程执行完,再打印atomicInteger的值,也可以用join方法
Thread.sleep(1000);
// 输出atomicInteger的值,预期结果应该为3000
System.out.println(atomicInteger);
}
}
运行结果:
那么这种线程安全是怎么实现的呢?实际上atomicInteger的getAndIncrement()方法就是用CAS实现线程安全的。
为了更清楚了解getAndIncrement方法是如何利用CAS的,下面将用伪代码展示具体是如何实现的:
class AtomicInteger{
public int value;
public int getAndIncrement(){
int oldValue = value;//读取内存中的值
while(CAS(value, oldValue, oldValue + 1) != true){
oldValue = value; // CAS失败说明主内存中的值已经被更新,需要重新读取
}
return oldValue;
}
}
注意!!!因为CAS是cup上的一条指令,是原子性的,对主内存有修改操作,所以当多个线程同时执行CAS时,只有一个线程能执行成功。
1. CAS的应用
- 实现原子类,JAVA标准库中的java.util.concurrent.atomic包。
- 实现自旋锁。通过CAS来加锁。
2. ABA问题
假设存在3个线程,线程1、线程2和线程3,和一个共享变量num。num初始值为100,现在期望如果初始值是100,就修改为0。其中线程1和线程2的任务就是修改初始值,线程3的任务是对num进行加操作。
线程1优先执行了CAS,将num修改为了0。然后线程3对num的值又做了修改,直接让它加到100。这时候线程2来了,num的值又回到了100,如果线程2进行CAS操作,就会又将值改到0。但是我们只是希望对初始值为100的进行修改操作,这样就违背了我们的初衷。
由此我们也可以发现CAS的弊端,就是无法判断这个值是初始的,还是经过一系列变化最终又变回了初始值。这就是ABA问题。
如何解决ABA问题?
可以选择加入版本号来解决,在读取num值的时候也要读取版本号,只有值和版本号都符合预期才可以进行修改操作。num初始为100,版本号为0。
- 线程1进行CAS操作,num值和版本号都与初始情况相同,num成功修改为0,同时将版本号加1,版本号为1。
- 线程3对num进行加操作,num修改为100,同时版本号加1,版本号为2。
- 线程2进行CAS操作,发现num为100,符合预期,但是版本号为2与初始的0不同,认为已经被修改过了,不能进行修改。
CAS在JAVA中是如何实现的
针对不同的操作系统,JVM用到了不同的CAS实现原理
- JAVA的CAS利用的是unsafe类提供的CAS操作
- unsafe类的CAS依赖的是JVM针对不同操作系统实现的Atomic::cmpxchg
- Atomic::cmpxchg的实现使用了汇编的CAS操作,并使用cpu硬件提供的lock机制保证其原子 性
常见的锁策略
锁策略顾名思义就是设计锁的策略,我们可以了解常见的锁策略,设计出适合自己场景需求的锁。
1. 乐观锁 VS 悲观锁
1.1 乐观锁
乐观锁,可以简单理解为是一种比较乐观的锁设计策略,它的出发点就是会乐观的认为线程之间发生锁冲突的概率比较小,因此它并不会真的对资源进行锁定,而是在提交数据的时候再检查刚刚是否发生了线程冲突,如果冲突了就会提交失败,如果没有冲突就会提交成功。
举个例子:当你想去问老师题的时候,你不会事先问老师现在有没有其他同学在问题,你会乐观的认为现在肯定没有其他人在问老师问他,你去了就能直接问老师。然后你就直接去找老师问题(相当于尝试提交数据),结果到了办公室发现已经有人在问老师题了(资源已经被其他线程占用,发生线程冲突),于是你只能排队等待问题(数据提交失败)。
优缺点:乐观锁的优点在于,如果没有发生线程冲突的情况,执行效率就会比较高,因为你少去了资源锁定的过程,只是在最后判断一下是不是发生冲突了。但是它的缺点也很明显,就是如果冲突比较多的情况,就会耗费额外的资源,因为那些数据做了修改的动作,但是因为发生冲突不能提交成功,所以相当于白修改了,反而会影响执行性能。
乐观锁通常用CAS对数据进行冲突检查。正如在CAS中getAndIncrement方法伪代码的例子一样,如果发生冲突会进入循环,不断重试,直到CAS操作成功为止。
1.2 悲观锁
与乐观锁相对应,它是一种悲观的设计锁的策略。它会悲观的认为多线程之间发生锁冲突的概率比较大,所以它会避免这种情况发生。所以它会对资源进行锁定,只有拿到锁的线程才能对数据进行操作。
举个例子:当你想去问老师问题的时候,你会事先跟老师发消息问这个时候老师是否有空(这个过程相当于尝试获取锁),老师如果回复你,现在没有其他人你可以过来问我,这就相当于成功获取锁,把老师这个资源锁定了。如果老师回复你,现在有同学正在问我,等我叫你的时候再来,相当线程于获取锁失败,进入阻塞等待,等待重新被CPU调度尝试获取锁。
优缺点:悲观锁的优点是当线程冲突确实比较多的时候,避免了像乐观锁这种处理方式导致“白跑一趟”的情况,可以有效节省cpu资源。缺点就是,对资源进行锁定,开锁和释放锁都会开销很大。其他竞争锁失败进入阻塞等待的线程,也不能及时的在锁被释放的时候去竞争锁,因为这涉及到了CPU的调度,和上下文切换。
2. 读写锁
为什么会出现读写锁?
多线程之间,读数据和读数据之间是不会出现线程安全问题的,只有一个线程读,一个线程写,或者两个线程都在写数据,才会发生线程不安全问题。因此我们延伸出了读写锁,来提高锁的效率。
- 读锁和读锁:不互斥
- 写锁和读锁:互斥
- 写锁和写锁:互斥
JAVA标准库中提供了ReentrantReadWriteLock类,来实现读写锁。
- ReentrantReadWriteLock.ReadLock类表示读锁,这个对象提供了lock / unlock 方法进行加锁和解锁。
- ReentrantReadWriteLock.WriteLock类表示写锁,这个对象提供了lock / unlock 方法进行加锁和解锁。
注:Synchronized不是读写锁!读写锁最主要用在“频繁读,不频繁写”的场景中
3. 重量级锁 VS 轻量级锁
锁对资源的锁定,对线程的互斥。追根溯源是CPU这样的硬件提供的。
- CPU实现了原子操作指令
- 操作系统在CPU指令的基础上,实现了mutex互斥量
- JVM基于操作系统提供的互斥锁,实现了synchronized关键字和ReentrantReadWrite类。
3.1 重量级锁
重量级锁就是,大量了使用了mutex互斥量去实现锁,这样的设计是很重量的,因为mutex是操作系统层面的,如果频繁使用mutex去设计锁,必然会出现很多从用户态到内核态的切换,而且很容易引起线程的调度。一旦有线程的调度就意味着“沧海桑田”。因为CPU对线程的调度是随机不确定的。
概括来讲,重量级锁的实现加重了对OS提供的mutex的依赖。
- 大量的用户态到内核态的切换
- 容易引发线程的调度
3.2 轻量级锁
轻量级锁就是少量依赖mutex去实现锁,能在用户态搞定的,尽量不用内核态的mutex解决。
- 少量的用户态到内核态的切换
- 不容易引发线程调度
注:synchronized一开始是轻量级锁,当锁冲突比较严重的时候会升级为重量级锁。
一般认为乐观锁就是轻量级锁,悲观锁就是重量级锁。
4. 自旋锁
如果当其它线程获取锁失败,进入阻塞队列等待,这个时候就相当于放弃了CPU资源,下一次等CPU想起它们再调度的时候,已经是“沧海桑田”,因为这个过程开销是很大的,还涉及线程上下文切换。所以很可能出现调度不及时的情况,持有锁的线程已经释放锁了,那些等待的线程还没有被CPU调度进入竞争状态,尤其是当锁持有的时间很少的时候,这时候很可能等待线程被调度时间比执行锁代码的时间还要多,很大程度上影响了代码的性能。
而自旋锁就是针对这一情况的策略,如果锁持有的时间比较少,那么我们可以不用让其他获取锁失败的线程放弃CPU资源,而是不断的通过循环尝试竞争锁,一旦锁被释放,那么其他线程就能立马去竞争到锁。
自旋锁伪代码:
while(抢锁() == 失败){
}
所有没获取到锁的线程都会在循环里面不断尝试获取锁,直到成功。
自旋锁优点:不涉及线程的阻塞和调度,当锁被释放可以第一时间获取到锁。
自旋锁缺点:如果锁持有的时间很长,或者参与自旋的线程很多,就会耗费比较多的CPU资源,而阻塞等待是不消耗CPU的。
synchronized 中的轻量级锁策略大概率就是通过自旋锁的方式实现的.
5. 公平锁 VS 非公平锁
公平锁:获取锁失败的线程,遵循先来后到原则,越先等待的,当锁被释放会优先拿到锁。
非公平锁:不遵循先来后到原则,随机从等待的线程里面选取一个拿到锁。
注:synchronized是非公平锁
6. 可重入锁 VS 不可重入锁
可重入锁:允许同一个线程多次获取同一把锁。也就是说当线程拿到锁之后,在锁代码里面如果还遇到被这把锁锁起来的代码块,可以直接进入。
不可以重入锁:不允许同一个线程多次获取同一把锁。
注:JAVA里只要以Reentrant开头命名的锁都是可重入锁,synchronized也是可重入锁。Linux操作系统提供的mutex是不可重入锁。
Synchronized的优化过程
jdk1.6及之前,synchronized锁的实现都是直接对资源进行了锁定,大量依赖了互斥量mutex。在jdk1.8后,对synchronized做了很多优化,让它自适应面对不同的多线程情况。不再一开始就选择用重量级锁,而是有一个锁升级的过程,提升了性能。
锁的状态有四种,分别是无锁,偏向锁,轻量级锁和重量级锁。锁可以根据需求自动升级(膨胀),升级按:无锁 --> 偏向锁 --> 轻量级锁 --> 重量级锁。锁只能升级,不能降级。但是可以重新恢复到无锁状态(在长时间没有执行有锁的代码情况下)。
synchronized锁同步信息保存在锁对象的对象头中的Mark Word里面,锁升级成功主要依赖是否偏向锁和锁标志位的信息实现。下图是Mark Word存放的信息图。
无锁
对象的初始状态就是无锁状态。
public class Test15 {
public static void main(String[] args) throws InterruptedException {
Object obj = new Object();
System.out.println(ClassLayout.parseInstance(obj).toPrintable());
}
}
打印对象头的信息,锁的标志位是01,表示无锁状态。
偏向锁
在竞争比较小的时候,获取到锁的线程大概率都是同一个线程,这种情况就是锁偏向这个线程的情况,所以我们在此基础上延伸出了偏向锁。
偏向锁的原理就是在第一个线程获取锁的时候,就会记录该线程的线程id,之后其他线程再想获取锁就只需要比对线程id即可,并且偏向锁在加锁之后并不会主动撤销锁,而是会保持锁的状态,因为偏向锁针对的情况本身就是同一个线程反复拿到锁的情况,所以在锁代码执行完毕之后不用撤销锁,这样下一次还是这个线程拿到锁的时候,只用比对线程id,如果一样,直接进入执行代码,这样的操作比轻量级锁省去了加锁和释放锁的开销,只有在第一次拿到锁的时候才会加锁,然后就一直保持锁状态。
那么什么时候偏向锁会撤销呢?偏向锁不会主动撤销,但是当有不是偏向锁记录的线程去拿到锁的时候,这个时候偏向锁就会撤销,并且升级为轻量级锁。
代码演示
对Object对象o进行加锁,并在加锁代码中打印对象的信息。
public static void main(String[] args) {
Object o = new Object();
synchronized (o){
System.out.println(ClassLayout.parseInstance(o).toPrintable());
}
}
打印结果发现锁的标志位是00,轻量级锁。为什么不是偏向锁呢?明明这里只出现了一个线程调用锁。是因为偏向锁的启用有延迟,默认会延迟4秒才能激活偏向锁。所以我们这里可以先让程序睡眠五秒。
注意这里在休眠之后新建对象的对象才会启用偏向锁。
public static void main(String[] args) throws InterruptedException {
Thread.sleep(5000);
Object o = new Object();
synchronized (o){
System.out.println(ClassLayout.parseInstance(o).toPrintable());
}
}
睡眠五秒后,在打印加锁后的对象信息发现锁的标志位为101,是偏向锁的标志。
升级为轻量级锁代码
在进入synchronized之前,已经激活了偏向锁,所以打印对象信息显示锁标志位是101,但是没有线程持有偏向锁,所以没有id。进入synchronized之后,线程持有偏向锁,这时候带线程id。接着开启了一个新的线程去尝试获取锁,由于在主线程的synchronized的代码块中我们睡眠了3秒,这时候主线程的锁还没有释放,新的线程就已经开始尝试获取锁了,这时候会发生锁竞争,而我们偏向锁进行id检查发现新线程的线程id与绑定的id不符,这时候发生锁升级,由偏向锁升级为轻量级锁。
public static void main(String[] args) throws InterruptedException {
Thread.sleep(5000);
Object lock = new Object();
System.out.println("偏向锁(101)不带线程id" + ClassLayout.parseInstance(lock).toPrintable());
synchronized (lock){
System.out.println("偏向锁(101)带线程id" + ClassLayout.parseInstance(lock).toPrintable());
Thread.sleep(3000);
}
Thread t1 = new Thread(() -> {
synchronized (lock){
System.out.println("轻量级锁(000)" + ClassLayout.parseInstance(lock).toPrintable());
}
});
t1.start();
}
升级为重量级锁代码
升级为轻量级锁后,又开了两个新线程
当锁已经为轻量级锁时,两个新的线程对锁发生了竞争,这时候锁就会膨胀为重量级锁。
public static void main(String[] args) throws InterruptedException {
Thread.sleep(5000);
Object lock = new Object();
System.out.println("偏向锁(101)不带线程id" + ClassLayout.parseInstance(lock).toPrintable());
synchronized (lock){
System.out.println("偏向锁(101)带线程id" + ClassLayout.parseInstance(lock).toPrintable());
Thread.sleep(3000);
}
Thread t1 = new Thread(() -> {
synchronized (lock){
System.out.println("轻量级锁(000)" + ClassLayout.parseInstance(lock).toPrintable());
}
});
t1.start();
Thread.sleep(1000);
for (int i = 0; i < 2; i++) {
new Thread(() -> {
synchronized (lock) {
System.out.println("重量级锁(010)" + ClassLayout.parseInstance(lock).toPrintable());
}
}).start();
}
}
当所有带锁的代码块执行完毕后,在一定时间内没有发生锁竞争,对象中的锁标志位又会变为001,无锁标志。
public static void main(String[] args) throws InterruptedException {
Thread.sleep(5000);
Object lock = new Object();
System.out.println("偏向锁(101)不带线程id" + ClassLayout.parseInstance(lock).toPrintable());
synchronized (lock){
System.out.println("偏向锁(101)带线程id" + ClassLayout.parseInstance(lock).toPrintable());
Thread.sleep(3000);
}
Thread t1 = new Thread(() -> {
synchronized (lock){
System.out.println("轻量级锁(000)" + ClassLayout.parseInstance(lock).toPrintable());
}
});
t1.start();
Thread.sleep(1000);
for (int i = 0; i < 2; i++) {
new Thread(() -> {
synchronized (lock) {
System.out.println("重量级锁(010)" + ClassLayout.parseInstance(lock).toPrintable());
}
}).start();
}
Thread.sleep(5000);
System.out.println("锁代码块执行完毕后:无锁状态001" + ClassLayout.parseInstance(lock).toPrintable());
}
总结
- 默认开启偏向锁,但有延迟性,延迟4秒激活偏向锁
- 开启偏向锁后,有新线程竞争时,偏向锁被撤销,升级为轻量级锁
- 当锁竞争更激烈时,轻量级锁升级为重量级锁
其他优化操作
1. 锁消除
编译器+JVM判断锁是否可以消除,如果可以就直接消除。比如一些时候只有单个线程在使用加锁方法,那么就可以进行锁消除,进行性能优化。
2. 锁粗化
一般来说我们希望锁的粒度越细(即加锁的代码越少),是希望可以让其他线能快速获得锁,以此提高多线程跑代码的性能。但是在一些场景下,可能并没有其他线程来抢占锁,这种情况下频繁的开锁释放锁,反而会消耗资源,拉低性能,这时候JVM就会自动把锁粗化,避免性能降低。