ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

Java线程(七):锁策略,锁优化策略,CAS解析

Java线程(七):锁策略,锁优化策略,CAS解析 前言hello hello这里是洋不写bug~欢迎大家点赞关注收藏这篇博客会深度解析锁内部实现的规则这些内容是比较偏理论的在面试时考到的概率就比较高在写代码的时候使用的并不多这些内容已经算是线程进阶部分了这也是Java线程部分的倒数第二篇博客个人主页洋不写bug的博客所属专栏JavaEE学习铁汁们对于JavaEE的各种常用核心语法都可以在上面的前端专栏学习专栏正在持续更新中有问题可以写在评论区或者私信我哦~1锁策略这篇博客中提到的锁策略主要是在实现一把锁的时候使用的但是在工作中基本上是不会让我们来实现一把锁的这部分跟日常使用关系不大主要会在面试中用到1乐观锁 VS 悲观锁在使用锁的时候预测这个锁遇到冲突的概率如果预测遇到冲突的概率比较高就称为“悲观锁”如果预测遇到冲突的概率比较低就称为“乐观锁”有个概念叫忙等空转也就是当线程遇到锁冲突的时候线程不阻塞、不休眠、不让出 CPU写死循环一秒钟疯狂重复几十 / 上万次尝试抢锁这样非常耗费cpu资源但是有个好处那就是一旦锁释放能立刻拿到速度快几乎没有延迟对于悲观锁会经常发生锁冲突多个线程抢一个锁那线程竞争这把锁时如果使用忙等的策略每个线程都会一秒钟疯狂重复几十 / 上万次尝试抢锁cpu资源就会崩溃卡死因此对于悲观锁线程抢锁失败就直接进入阻塞状态不占用cpu资源去疯狂的抢锁等别人用完锁再唤醒我即可这样就可能有一些延迟因为唤醒需要时间线程被唤醒后只是有重新参加锁竞争比第一次阻塞前竞争要小还会耗费一定时间而对于乐观锁认为锁遇到冲突的概率不高没几个线程抢锁竞争小很快就能抢到而且线程阻塞和唤醒也需要耗费资源因此就直接让线程采用忙等的方式循环尝试抢锁这样延迟就会非常低乐观锁就是竞争小锁很快就会释放稍微等一下就能拿到这里悲观锁和乐观锁的概念理解了那接下来的所有概念基本上就都好理解了因为基本上还是一个东西只是不同讲法2重量级锁 VS 轻量级锁悲观锁和乐观锁只是设计层面上的东西在锁的实现时就把锁叫做重量级锁/轻量级锁在日常中也可以近似的把悲观锁和重量级锁以及乐观锁和轻量级锁理解成一个东西这里的重量和轻量指的时加锁开销的意思重量级锁加锁开销大轻量级锁加锁开销小这里可能有的铁汁比较奇怪那为什么悲观锁加锁的开销就大乐观锁加锁开销就小呢原因如下悲观锁在设计的时候里面要包含线程阻塞唤醒的逻辑线程状态切换这些操作要依赖操作系统内核开销是很高的乐观锁在设计的时候就简单了很多不涉及线程状态切换加锁和解锁只是简单的原子操作解锁后不需要去唤醒其他线程开销很小因为悲观锁重量级锁和乐观锁轻量级锁的线程等待的方式是阻塞和自旋等待因此描述它们也可以称为阻塞锁和自旋锁日常大家可以近似的把悲观锁/重量级锁/阻塞锁看成同个东西把乐观锁/轻量锁/自旋锁看成一个东西只是从不同的角度来描述的严格理论上来说并不完全等价我们日常使用的sychronized采取的是自适应的方式当锁竞争不激烈的时候就会采取自旋锁的策略当锁竞争比较激烈的时候就会采取挂起等待的策略3公平锁 VS 不公平锁这个是锁的获取规则的划分跟前面的乐观/悲观锁重量/轻量级锁的划分规则不是同一个维度多个线程竞争同一把锁一个线程拿到锁执行完任务释放后这时候其他线程拿到锁有两种规则遵循先来后到的排队规则先来排队的线程先拿到锁大家一起抢跟谁先来后来没关系谁抢到算谁的大家抢到锁的概率均等最早提出这个概念的大佬就把“先来后到”方式称作公平把“概率均等”叫做非公平那要实现公平锁让线程按照先来后到的方式拿锁就需要引入队列记录各个线程来的顺序我们常用的synchroinzed就是非公平锁4可重入锁 VS 不可重入锁这个在前面的死锁博客中详细提到过这里再简单解析下“可重入锁”就是当发现一个线程被加了多把同样的锁并且这些锁还是嵌套的关系那里面套的锁就不会触发阻塞例如下面这段代码第一层锁加上后下面这层锁并不会产生阻塞代码还会向下继续运行for(inti0;i50000;i){synchronized(locker){synchronized(locker){count;}}}区分锁看的是锁对象“重入锁”机制就是让锁对象自身来保存使用该锁的线程的信息Java中的对象有一片存储区域保存对象属性还有一片区域保存“对象头”对象头是由JVM维护的保存了这个对象的其他一些运行信息例如加锁状态哪个线程加了锁可重入锁可以在一定的情况下解决死锁的问题 synchronized就是可重入锁不可重入锁就是没有这种功能的5读写锁 VS 互斥锁synchronzed就是互斥锁也就是两个线程不能同时使用一把锁只能等一个用完另一个线程才能用、而读写锁就不是这样读写锁中分为读锁和写锁读锁和写锁是互斥的写锁和写锁是互斥的但是读锁和读锁之间并不互斥两个线程可以同时读取并不会产生安全问题2锁的优化策略因为Java程序员的水平会有差别一些程序员可能并不能正确的使用锁为了保证这些程序员写的代码的执行效率差的不会太多JVM就采取了一系列的优化策略来优化代码1锁升级JVM会将synchronized分为无锁 - 偏向锁 - 自旋锁 - 重量级锁四种状态这四种状态只会升级不会降级(越靠右等级越高)无锁就是不加锁状态升级解析如下偏向锁并不是真正的加锁只是做个标记做个标记要比加锁轻量很多 那在整个过程中如果没有其他线程来尝试竞争这个锁偏向锁的状态就会一直保持线程用完锁后修改标记即可修改标记比正常的解锁要轻量很多如果有其他线程来竞争这个锁了那偏向锁就要升级了升级成自旋锁这样如果竞争过其他线程就用这个锁如果竞争不过其他线程这个锁就暂时不能使用了JVM内部会统计一个锁有多少个线程在等待获取如果发现竞争大等待获取的线程比较多那这时候如果用自旋锁的话那么多线程空等就会大量占用CPU资源这时候就再次进行升级升级成重量锁一个线程使用锁时其他线程阻塞等待唤醒可能有的铁汁会想那为什么JVM不实现降级呢竞争如果小了进行降级会提高效率呀可能是因为降级的资源开销比较大或者会引入一些新的bugJVM就没有实现降级2锁消除有的代码程序员加了锁但是JVM执行时发现这个地方没必要加锁就会自动把锁给去掉举个简单的例子StringBuilder是不带有synchronized的线程不安全StringBuffer是带有synchronized的线程安全有时候可能会用的不合适在单线程环境下使用StringBuffer那JVM就会把锁给去掉提升效率3锁粗化这里粗化的是锁的粒度在加锁和解锁时范围中的代码越多锁的粒度就越粗范围中的代码越少锁的粒度就越细例如下面代码中的这两个线程t1线程锁的粒度就比t2线程锁的粒度要细Threadt1newThread(()-{for(inti0;i50000;i){synchronized(locker){count;}}});Threadt2newThread(()-{synchronized(locker){for(inti0;i50000;i){count;}}});因为加锁解锁也是比较耗时的所以JVM就会分析情况如果分析后觉得能提升效率就会把多次加锁解锁优化成一次如下图把三次加锁解锁优化成一次3CASCAS也是锁优化策略中的一种这部分内容比较多在面试的时候出的概率也比较高这里就单独写一个部分来聊CAS就是Compare And Swap(比较和替换)的单词首字母拼写CAS操作伪代码如下所示booleanCAS(内存地址V,预期值A,新值B){if(V里面存的值A){// 比较当前内存值是否等于预期值V里面存的值B;// 赋值如果相等就把内存值改成新值Breturntrue;}returnfalse;}CAS操作是通过一条cpu指令完成的这也意味着这个操作是原子的不需要加锁和解锁这个操作的效率就非常高CPU的特殊指令完成了CAS操作操作系统封装了这个指令形成了一个系统APIJava又封装了操作系统的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{privatestaticAtomicIntegercountnewAtomicInteger();publicstaticvoidmain(String[]args)throwsInterruptedException{Threadt1newThread(()-{for(inti0;i50000;i){count.getAndIncrement();}});Threadt2newThread(()-{for(inti0;i50000;i){count.getAndIncrement();}});t1.start();t2.start();t1.join();t2.join();System.out.println(count);}}对于前置后置和- -以及和赋值都有对应的方法如下所示count.getAndIncrement();//countcount.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也就相当于是valuewhile循环一次也不会执行最后再返回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步2CAS的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()结语在实际开发中经常会有一些统计类的需求例如这个服务器一天有多少用户访问这个广告被点击了多少次点击带来了多少的计费这些操作一般是不建议我们自己加锁的建议通过原子类来实现这样效率会比较高以上就是今天的所有内容啦完结撒花
返回列表