深入理解CAS
什么是CAS?CAS的全称是compareAndSet,比较并交换
一般通用的用法就是如果对象是期望的,那么就更新成给定的第二个。在原子类型的类中可以查看一下:
两个语句运行结果对比
其实大家记不记得我们之前就说过了,原子类是基于内存操作的。而众所周知的java不能操作内存啊?这是怎么回事呢?不得不提一个比较神奇的类,Unsafe类。
其实这个名字起的就很霸道,不安全的类。但是既然不安全为什么还要有这个类呢?那就说明了这个类的重要性了。这个类到底是干什么的呢?
JAVA无法操作内存。JAVA可以调用C++(native)。C++可以操作内存。而这个Unsafe类是java留的一个后门,可以通过这个类操作。
我们可以点进去看,Unsafe类里都是native方法。
CAS底层代码
CAS:比较当前工作内存中的值和主内存中的值。如果这个值是期望的则执行这个操作,否则一直循环。
-
缺点:
- 循环会耗时
- 一次只能保证一个共享变量的原子性
- ABA问题
ABA问题:简单来说,就是如果一个值是1, A线程CAS(1,2)将值改成2了。再CAS(2,1)将值改成1了。 这个时候B线程CAS(1,3)其实这个期望的1是最开始时候的1.而不是被A线程重置回来的1。虽然本质上得到的还是1,看似不影响什么。。但是其实差别还挺大的。看视频的时候一条弹幕特别好的解释了这种情况:你看上一个清纯可爱天真善良的女孩。然后打算追她。中间一段时间她交了好几百个男朋友。现在你要追到她了,女孩还是这个女孩没有变,但是请问她还是你想要的那个她了么?
原子引用
问题所如何解决这个ABA问题呢?其实以我们现有的知识也挺容易实现的。乐观锁其实就可以实现。在这个值的每次改动的时候记录一个版本号。并且在每次调用获取上次改动的版本号。这样我们就能知道本次获取到的对象除了值以外,是不是我们想要的那个。
其实实现的方法也简单的很,就是这个CAS方法多两个参数:一个是当前预期值和当前版本号。一个是想要更新值和想要更新的版本号。
比对的结果就不是单纯的值是不是相等了,也要判断版本号是不是相等。
同时如果值发生更新以后,也会更新版本号。
当然了,这个版本号是我习惯性的叫法,外国人一般都用邮票来表达这个意思的。我们去官方手册找到这个类的介绍:
截图下面是有个构造器方法参数中initialStamp可以理解为初始版本号。下面是这个方法的代码实现:
//CAS是compareAndSet 比较并交换
public static void main(String[] args) {
AtomicStampedReference<Integer> integer = new AtomicStampedReference<Integer>(122, 1);
System.out.println(integer.compareAndSet(122, 123, 1, 2));
System.out.println(integer.getStamp());
}
版本号变成2了
这个demo中有个坑,就是如果泛型是包装类。比如Integer 2020和 2020本身不是一样的。稍微有经验的也知道包装类的等于也要equals。不过工作中很少用这种类型。
反正利用这个原子引用就可以解决这个ABA问题了。
各种锁的理解
公平锁和非公平锁:
- 公平锁:非常公平,不能插队。严格按照线程的先来后到。
- 非公平锁:每一次获取锁都要抢,各种插队。但是设置非公平锁是为了公平。(synchronized和lock默认都是非公平的。比如A,B线程在等一把锁。A要执行两个小时,B要执行1秒钟。如果让B为了这一秒钟等两个小时本身就是有问题的)
这个我们可以在代码中看一下,常用的Lock锁的构造器:
无参默认非公平。有参并且是true才是公平锁
可重入锁:
这个其实挺好理解的,有时候汉字的好处就是见名可以知意。 其实这个就是当拿到这个锁了就可以开所有这个锁的门。下面一个demo:
public class D1 {
public static void main(String[] args) throws Exception {
Phone phone = new Phone();
for (int i = 0; i < 1000; i++) {
new Thread(()->{
phone.sms();
},"线程"+i).start();
}
}
}
class Phone{
public synchronized void sms() {
System.out.println(Thread.currentThread().getName()+"sms");
call();
}
public synchronized void call() {
System.out.println(Thread.currentThread().getName()+"call");
}
}
其实这个demo是想说明:sms和call都上锁的。然后我们又在sms中调用call。我特意跑了1000个线程,大家也可以试一下,结果集中一个线程的sms和call一定是一起执行的。按照我们正常的逻辑,执行完发短信后因为默认是非公平锁,应该大家都抢锁,怎么可能每次都sms和call都一起执行?
问题的答案只有一个。执行完了sms后,线程根本没释放锁,直接带着锁进入了call方法,所以说相当于我门在线程中这一个锁开了好几个门。而且这个demo是用synchronized,下面我们再用Lock试试。
demo运行结果
Lock版本看似代码和synchronized差不多,但是让我们能更明确的看懂这个锁机制。下面直接附上代码:
public class D1 {
public static void main(String[] args) throws Exception {
Phone phone = new Phone();
for (int i = 0; i < 1000; i++) {
new Thread(()->{
phone.sms();
},"线程"+i).start();
}
}
}
class Phone{
Lock lock = new ReentrantLock();
public void sms() {
lock.lock();
try {
System.out.println(Thread.currentThread().getName()+"sms");
call();
} catch (Exception e) {
// TODO: handle exception
}finally {
lock.unlock();
}
}
public void call() {
lock.lock();
try {
System.out.println(Thread.currentThread().getName()+"call");
} catch (Exception e) {
// TODO: handle exception
}finally {
lock.unlock();
}
}
}
敲黑板!上面的代码运行结果和synchronized的一样,所有我就不截图了。但是这个代码因为Lock要上锁和解锁,所以其实我们可以很明确的看出了sms中调用call,是上了两次锁并且解了两次锁。其本质应该如下:
lock1上锁->lock2上锁->lock2解锁->lock1解锁
这四个步骤我们随便在其中一个方法上少写一句都会造成错误的。而可重入的理念在这里也能看的更加清楚:其实这个lock1,lock2是一把锁。
而当sms拿到lock这个锁以后,在没有释放的前提下,还可以用这个锁进去到了call方法中。所以这个可重入是不是说的很形象?
注意:lock中加锁解锁是一对操作,一定要注意。看我下面代码:
这样的代码在call不单独调用的前提下,运行是完全没问题的。因为本质上还是加两次锁解两次锁。由此说明lock是针对锁的加锁解锁计数。
自旋锁:
不断循环判断,直到获得锁。
这个说真的,我都觉得没啥代码可说的。因为我们上面再cas的时候看底层源码其实就用到了自旋锁:
看到了没?do{}while()句式。这个while里加自己的判断。这里源码中是说当第一个线程进来了肯定是满足cas的。所以直接return了。但是如果是多线程的时候并发,第一个没执行完之前,肯定是不满足cas的,所以一直卡在do-while中,直到A执行完走了,就满足CAS了,所以B可以正常执行了。
我觉得这个没啥好说的。理论很简单,实际没用过,反正就这样,直到什么是自旋锁就行了。
死锁:
其实这个情况最好说了,也好理解。就是线程A有的锁1,等锁2。线程B有了锁2等锁1.就这样两个线程互相等着,就等死了。这种demo也很好写,我直接附上代码:
public class D1 {
public static void main(String[] args) throws Exception {
Phone phone = new Phone();
new Thread(()->{phone.sms();},"A").start();
new Thread(()->{phone.call();},"B").start();
}
}
class Phone{
Lock lock1 = new ReentrantLock();
Lock lock2 = new ReentrantLock();
public void sms() {
lock1.lock();
try {
TimeUnit.SECONDS.sleep(2);
System.out.println(Thread.currentThread().getName()+"sms");
call();
}catch (Exception e) {
}finally {
lock1.unlock();
}
}
public void call() {
lock2.lock();
try {
TimeUnit.SECONDS.sleep(2);
System.out.println(Thread.currentThread().getName()+"call");
sms();
}catch (Exception e) {
// TODO: handle exception
}finally {
lock2.unlock();
} }
}
这个线程执行不完了。卡在lock1等lock2.lock2等lock1的怪圈里。。
这里重点不说什么是死锁。主要是说怎么解决。
这里不得不说JDK的bin目录下的一个很实用的工具:jps
这个可以查看当前线程:命令是jps -l(可以查看正在运行的线程)
image.png
其实这里jps的命令可以自己去看的。这个工具毕竟是JDK自带的。感兴趣的可以深入了解一下。
自此,JUC的东西我就看完了,也记完了。其实这里面东西不多,但是很杂,这一块那一块的。但是其实干货不少,实际中能用到的也挺多的。一共是八个多小时,我零零落落看了三周。自我感觉是用了双倍甚至三倍的时间消化。反正算是完成了我今年计划中的一项了,接下来就是spring boot源码和netty代表的nio。有时间再学es。在这里也给自己打打气,学到即得到!也祝大家工作中保持进步,一起共勉!
本篇笔记就记到这里,如果稍微帮到你了记得点个喜欢点个关注,也祝大家工作顺顺利利!
网友评论