前言
hello hello💕,这里是洋不写bug~😄,欢迎大家点赞👍👍,关注😍😍,收藏🌹🌹
这篇博客会深度解析锁内部实现的规则,这些内容是比较偏理论的,在面试时考到的概率就比较高,在写代码的时候使用的并不多
这些内容已经算是线程进阶部分了,这也是Java线程部分的倒数第二篇博客🐵🎆个人主页:洋不写bug的博客
🎆所属专栏:JavaEE学习
🎆铁汁们对于JavaEE的各种常用核心语法,都可以在上面的前端专栏学习,专栏正在持续更新中🏀,有问题可以写在评论区或者私信我哦~
1,锁策略
这篇博客中提到的锁策略主要是在实现一把锁的时候使用的,但是在工作中,基本上是不会让我们来实现一把锁的,这部分跟日常使用关系不大,主要会在面试中用到
1,乐观锁 VS 悲观锁
在使用锁的时候,预测这个锁遇到冲突的概率
如果预测遇到冲突的概率比较高,就称为“悲观锁”
如果预测遇到冲突的概率比较低,就称为“乐观锁”
有个概念,叫忙等(空转),也就是当线程遇到锁冲突的时候,线程不阻塞、不休眠、不让出 CPU,写死循环,一秒钟疯狂重复几十 / 上万次尝试抢锁,这样非常耗费cpu资源,但是有个好处,那就是一旦锁释放,能立刻拿到,速度快,几乎没有延迟
对于悲观锁,会经常发生锁冲突(多个线程抢一个锁),那线程竞争这把锁时,如果使用忙等的策略,每个线程都会一秒钟疯狂重复几十 / 上万次尝试抢锁,cpu资源就会崩溃卡死;
因此,对于悲观锁,线程抢锁失败就直接进入阻塞状态,不占用cpu资源去疯狂的抢锁,等别人用完锁再唤醒我即可,这样就可能有一些延迟,因为唤醒需要时间,线程被唤醒后,只是有重新参加锁竞争(比第一次阻塞前竞争要小),还会耗费一定时间
而对于乐观锁,认为锁遇到冲突的概率不高,没几个线程抢锁,竞争小,很快就能抢到,而且线程阻塞和唤醒也需要耗费资源,因此就直接让线程采用忙等的方式,循环尝试抢锁,这样延迟就会非常低(乐观锁就是竞争小,锁很快就会释放,稍微等一下就能拿到)
这里悲观锁和乐观锁的概念理解了,那接下来的所有概念基本上就都好理解了,因为基本上还是一个东西,只是不同讲法
2,重量级锁 VS 轻量级锁
悲观锁和乐观锁只是设计层面上的东西,在锁的实现时就把锁叫做重量级锁/轻量级锁
在日常中,也可以近似的把悲观锁和重量级锁,以及乐观锁和轻量级锁理解成一个东西
这里的重量和轻量指的时加锁开销的意思,重量级锁加锁开销大,轻量级锁加锁开销小
这里可能有的铁汁比较奇怪,那为什么悲观锁加锁的开销就大,乐观锁加锁开销就小呢?
原因如下:
- 悲观锁在设计的时候,里面要包含线程阻塞,唤醒的逻辑(线程状态切换),这些操作要依赖操作系统内核,开销是很高的
- 乐观锁在设计的时候,就简单了很多,不涉及线程状态切换,加锁和解锁只是简单的原子操作(解锁后不需要去唤醒其他线程),开销很小
因为悲观锁(重量级锁)和乐观锁(轻量级锁)的线程等待的方式是阻塞和自旋等待,因此,描述它们也可以称为阻塞锁和自旋锁
日常大家可以近似的把悲观锁/重量级锁/阻塞锁看成同个东西,把乐观锁/轻量锁/自旋锁看成一个东西,只是从不同的角度来描述的(严格理论上来说并不完全等价)
我们日常使用的sychronized采取的是自适应的方式,当锁竞争不激烈的时候,就会采取自旋锁的策略;当锁竞争比较激烈的时候,就会采取挂起等待的策略
3,公平锁 VS 不公平锁
这个是锁的获取规则的划分,跟前面的乐观/悲观锁,重量/轻量级锁的划分规则不是同一个维度
多个线程竞争同一把锁,一个线程拿到锁,执行完任务释放后
这时候其他线程拿到锁有两种规则:
- 遵循先来后到的排队规则,先来排队的线程先拿到锁
- 大家一起抢,跟谁先来后来没关系,谁抢到算谁的,大家抢到锁的概率均等
最早提出这个概念的大佬,就把“先来后到”方式称作公平,把“概率均等”叫做非公平
那要实现公平锁,让线程按照先来后到的方式拿锁,就需要引入队列,记录各个线程来的顺序
我们常用的synchroinzed就是非公平锁
4,可重入锁 VS 不可重入锁
这个在前面的死锁博客中详细提到过,这里再简单解析下:
“可重入锁”就是当发现一个线程被加了多把同样的锁,并且这些锁还是嵌套的关系,那里面套的锁就不会触发阻塞,例如下面这段代码,第一层锁加上后,下面这层锁并不会产生阻塞,代码还会向下继续运行
for(inti=0;i<50000;i++){synchronized(locker){synchronized(locker){count++;}}}区分锁看的是锁对象,“重入锁”机制就是让锁对象自身来保存使用该锁的线程的信息
Java中的对象,有一片存储区域保存对象属性,还有一片区域保存“对象头”,对象头是由JVM维护的,保存了这个对象的其他一些运行信息(例如加锁状态,哪个线程加了锁)
可重入锁可以在一定的情况下解决死锁的问题, synchronized就是可重入锁,不可重入锁就是没有这种功能的
5,读写锁 VS 互斥锁
synchronzed就是互斥锁,也就是两个线程不能同时使用一把锁,只能等一个用完,另一个线程才能用、
而读写锁就不是这样,读写锁中分为读锁和写锁,读锁和写锁是互斥的,写锁和写锁是互斥的,但是读锁和读锁之间并不互斥,两个线程可以同时读取,并不会产生安全问题
2,锁的优化策略
因为Java程序员的水平会有差别,一些程序员可能并不能正确的使用锁
为了保证这些程序员写的代码的执行效率差的不会太多,JVM就采取了一系列的优化策略,来优化代码🐵
1,锁升级
JVM会将synchronized分为无锁 -> 偏向锁 -> 自旋锁 -> 重量级锁四种状态
这四种状态只会升级,不会降级(越靠右等级越高)
无锁就是不加锁,状态升级解析如下:
- 偏向锁并不是真正的加锁,只是做个标记(做个标记要比加锁轻量很多) 那在整个过程中,如果没有其他线程来尝试竞争这个锁,偏向锁的状态就会一直保持,线程用完锁后修改标记即可(修改标记比正常的解锁要轻量很多)
- 如果有其他线程来竞争这个锁了,那偏向锁就要升级了,升级成自旋锁,这样如果竞争过其他线程,就用这个锁;如果竞争不过其他线程,这个锁就暂时不能使用了
- JVM内部会统计一个锁有多少个线程在等待获取,如果发现竞争大,等待获取的线程比较多,那这时候如果用自旋锁的话,那么多线程空等就会大量占用CPU资源,这时候就再次进行升级,升级成重量锁,一个线程使用锁时,其他线程阻塞,等待唤醒
可能有的铁汁会想:那为什么JVM不实现降级呢,竞争如果小了,进行降级会提高效率呀
可能是因为降级的资源开销比较大,或者会引入一些新的bug,JVM就没有实现降级
2,锁消除
有的代码程序员加了锁,但是JVM执行时发现这个地方没必要加锁,就会自动把锁给去掉
举个简单的例子:
StringBuilder是不带有synchronized的,线程不安全
StringBuffer是带有synchronized的,线程安全
有时候可能会用的不合适,在单线程环境下使用StringBuffer,那JVM就会把锁给去掉,提升效率
3,锁粗化
这里粗化的是锁的粒度
在加锁和解锁时,范围中的代码越多,锁的粒度就越粗,范围中的代码越少,锁的粒度就越细
例如下面代码中的这两个线程,t1线程锁的粒度就比t2线程锁的粒度要细
Threadt1=newThread(()->{for(inti=0;i<50000;i++){synchronized(locker){count++;}}});Threadt2=newThread(()->{synchronized(locker){for(inti=0;i<50000;i++){count++;}}});因为加锁解锁也是比较耗时的,所以JVM就会分析情况,如果分析后觉得能提升效率,就会把多次加锁解锁优化成一次(如下图,把三次加锁解锁优化成一次)
3,CAS
CAS也是锁优化策略中的一种,这部分内容比较多,在面试的时候出的概率也比较高,这里就单独写一个部分来聊
CAS就是Compare And Swap(比较和替换)的单词首字母拼写
CAS操作伪代码如下所示:
booleanCAS(内存地址V,预期值A,新值B){if(V里面存的值==A){// 比较:当前内存值是否等于预期值V里面存的值=B;// 赋值:如果相等,就把内存值改成新值Breturntrue;}returnfalse;}CAS操作是通过一条cpu指令完成的,这也意味着这个操作是原子的,不需要加锁和解锁,这个操作的效率就非常高
CPU的特殊指令完成了CAS操作,操作系统封装了这个指令,形成了一个系统API,Java又封装了操作系统的API
整个操作被封装在unsafe中的,这个操作比较底层,可能不安全,因此一般不会直接用CAS来写到代码中,而是使用CAS在其他地方的应用
1,原子类的实现
CAS能够实现很多的原子类,在原子类中++和- -操作都是原子的,在Java文档中(链接如下),找到java.util.concurrent.atomic包,这个包中的类就都是原子类,这些原子类就是通过CAS来实现的
文档地址
接下来就试下Integer原子类的效果,创建一个对象count
注意,Java中是不支持运算符重载的,所以不能直接写count++,要用方法去++,这里count++的方法就是getAndIncrement(),使用原子类,就算不加锁,结果也还是正确的,线程的load和add操作并不会受插队的影响,如下所示:
importjava.util.concurrent.atomic.AtomicInteger;publicclassDemo33{privatestaticAtomicIntegercount=newAtomicInteger();publicstaticvoidmain(String[]args)throwsInterruptedException{Threadt1=newThread(()->{for(inti=0;i<50000;i++){count.getAndIncrement();}});Threadt2=newThread(()->{for(inti=0;i<50000;i++){count.getAndIncrement();}});t1.start();t2.start();t1.join();t2.join();System.out.println(count);}}对于前置,后置++和- -,以及+=和赋值,都有对应的方法,如下所示
count.getAndIncrement();//count++count.incrementAndGet();//++countcount.getAndDecrement();//count--count.decrementAndGet();//--countcount.addAndGet(10);//count += 10count.getAndSet(10);//count = 10这样的操作,不仅线程安全,而且效率很高,不涉及到阻塞,我们在开发中,如果有计数的需求,就要优先考虑原子类,而不是去自己加锁
那CAS操作是如何在原子类中被使用的呢,下面就来解析一下:
CAS伪码如下所示:
booleanCAS(内存地址V,预期值A,新值B){if(V里面存的值==A){// 比较:当前内存值是否等于预期值V里面存的值=B;// 赋值:如果相等,就把内存值改成新值Breturntrue;}returnfalse;}就拿Integer原子类中的getAndIncrement方法来举例,在Java线程(三)博客中提到,之所以++操作会出现线程安全问题,就是因为不同线程在同时修改count时,load,add,save三个操作会进行插队,导致结果出错
CAS在getAndIncrement方法中如下所示:
首先会把value的值赋值给oldValue,这个oldValue就相当于一个寄存器,接着用CAS进行比较,CAS中会把value的值和oldValue的值进行比较,如果相等,那就把oldValue + 1(第三个参数)的值赋值给value,那就会有两种情况:
- 线程A在调用getAndIncrement时,线程B没有修改value中的值,那CAS判断的结果就是true,在CAS中让value = oldValue + 1,也就相当于是value++,while循环一次也不会执行,最后再返回oldValue的值(getAndIncrement的设定就是让value++,然后再返回修改前的oldValue的值)
- 线程A在调用getAndIncrement时,线程B刚好在修改value的值,线程A读取到的oldValue的值相当于说是错误的(load读取到了过时的数据)
这时候在CAS中判断oldValue跟value不相等,就知道读取到的oldValue是错误的,返回false,进入while循环,重新把value赋值给oldValue(相当于重新load),接着一直进行while循环,直到oldValue等于value了(也就是说中间没有线程修改,load到的值是正确的),再进行第1步
2,CAS的ABA问题
就拿前面CAS实现Integer原子类来说,其实是有一些漏洞的
CAS就算返回ture(value 等于 oldValue),也是保证不了其他线程中间没有修改value的
例如其他线程先修改了一下value,又把value改了回来,也就是先把A修改成B,再把B修改成A,这时候CAS是判断不出来的
可能有的铁汁会想,这算什么漏洞呀,修改后再改过来,这不是还是没有修改吗
在绝大部分场景下,这都不会产生安全问题
但是在一些特别特别极端的情况下,还是可能会出现安全问题的
举个例子:
假设我们账户里的余额有1000块,我们去银行取500
注:这时候CAS就不是在while循环中了,一旦发现balance不等于oldBalance,就取款失败,终止交易,让客户重新取款
假设ATM机卡了,又创建出了一个新线程帮我们操作这个事情(在图形界面编程中,当窗口卡死无响应时,常用的操作都是创建新线程来处理),那这时候原来的老线程后面进行CAS时,就会判断出balance和oldBanlance不相等,就会终止操作,这时候仍然是安全的
但是如果有人在新线程和老线程之间往这个账户打了500块(这是一种比较极端的情况),那老线程就仍然会给balance扣款500,这时候就相当于取了500块,但是扣了两次500块,也就是扣了1000块
要解决ABA问题也很简单,可以定义一个类,包含版本号和余额
每次修改余额时,版本号都 +1 处理,每次CAS比较的时候,再比较下版本号是否相同,如果版本号变了,就重新读取balance的值,再进行CAS比较🐵
3,实现自旋锁
前面的锁策略中提到,自旋就是线程不停的尝试获取锁,这样获取锁的延迟就非常低,自旋锁也是通过CAS来实现的
创建一个线程变量owner,记录哪个线程当前拥有这把锁,如果这把锁没有线程使用的话,那owner就是null
线程自旋抢锁,就是通过while循环不断的执行CAS方法,判断这个锁现在是不是空的,当其他线程用完锁,那抢锁的线程就会快速占用这个锁,让this.owner = Thread.currentThread()
结语💕💕
在实际开发中,经常会有一些统计类的需求,例如这个服务器一天有多少用户访问,这个广告被点击了多少次,点击带来了多少的计费,这些操作,一般是不建议我们自己加锁的,建议通过原子类来实现,这样效率会比较高🐵
以上就是今天的所有内容啦~完结撒花~🥳🎉🎉