从多线程模型理解并发

学习完各种线程模型之后,试图从线程模型出发去理解设计者设计这些锁,多线程工具的时候的思想
https://articles.zsxq.com/id_rk2jkvxq1n4d.html

MESA 管程模型

提到多线程就不得不提MESA这种管程的模型,因为我们常用的锁,如synchronized,Reentrantlock背后的AQS,他们都使用到了这一思想。

synchronized

synchronized最开始接触的时候,被他各种锁升级给镇住了,在深入了解后,我更愿意称之为对象锁,它的锁永远是给对象上的。其中分为三种情况:
给普通方法上锁;锁对象为调用方法的实例对象本身
给静态方法上锁;锁对象为类对象
给代码块上锁;锁对象为synchronized(obj),括号里面的这个obj
在清楚了这些之后,应该了解对象的布局,由三方面组成:对象头(markword,klass point),实例数据,对齐填充。如果是数组对象的话会多出一个数组长度的属性,在这就不深究了。

markword

可以说markword是synchronized的核心之一,在使用objmonitor之前它都是用markword去进行锁的保障。
markword的结构网上也已经烂大街,实在是不想记住各种具体的多少位多少位,还是联系对应的功能比较舒适,其实到最后真问起来也能反推。
无锁有hashcode,锁标志位为01,偏向锁标志为0
偏向锁无hashcode,有记录重偏向计数的epoch,有指向偏向线程真正的id,锁标志为01,偏向锁标志为1
轻量级锁有指向线程栈中lockrecord的指针,出现了replace markword,锁标志位为00
重量级锁有指向objmonitor的指针,锁标志位为10

各种锁的用途

之后对这些锁的学习我觉得也是从功能出发比较合适:
偏向锁的出现就是为了解决某些不需要多线程竞争的场景,它没必要使用那么强度大的锁,使用偏向锁即可。甚至在有些确保无竞争的时候,如初始化的时候会有延迟偏向,这时候连偏向锁都给你取消掉了。
轻量级锁的存在就是相对于重量级锁的一种缓解,偏向锁的一种补充,当线程它确实存在着轻量竞争的时候,如一个线程用完了,另一个线程再接着用,这是轻量级锁适合的场景,同时也是偏向锁解决不了的场景。
重量级锁就是严格的互斥锁,用objmonitor模拟着mutex的作用。
那么在知道了这些锁的用途以后,你会发现,偏向锁撤销,锁膨胀,似乎也就在眼前了。

偏向锁撤销

偏向锁撤销就是当发现偏向锁不适合当前的环境的时候,对锁要进行一种升级,而且并不是就只会升级为轻量级锁。
思考后发现偏向锁撤销比较简单,也只是对各种情况的枚举:
最简单的一种;出现竞争,发现此时对象markword中的线程id并不是自己的,需要偏向锁撤销,再次加锁就升级为轻量级锁。
hashcode;可以发现偏向锁是没有能力去存储hashcode的,那么他就需要撤销了。如果此时处于正在偏向状态,也就是正在加锁状态,调用了hashcode,那么将会升级为重量级锁。否则轻量级锁。
同时偏向锁还会有一些个优化:
批量重偏向:之前提过epoch,这是对于一个类中偏向锁撤销次数的计算,同步于每个线程当中,如果当次数大于20次的时候,jvm认为此时对第一批偏向锁的赋予是出问题了的,后续仍适合用偏向锁,只需切换偏向的线程即可,所以在20次之后会进行批量重偏向,之后就不再是升级为轻量级锁而是替换为执行当前线程id的偏向锁。
批量锁撤销:我觉得这一块又比较有趣哈,设计jvm的哥思想也是反复,当次数大于40次的时候,他又开始想,是不是我之前的批量重偏向错了,其实不是因为第一次赋予的偏向锁有问题,是我这个环境就竞争很多,不适合用偏向锁,毕竟偏向锁撤销是一个比较耗时的操作。那么他就干脆不用偏向锁了,从此以后这个类就没有偏向锁了。
一定注意,这两个操作都是针对于类的。其中批量重偏向是只会发生一次,之后不再会发生,批量锁撤销是以25秒为一个单位,超过25秒会重新计数。

轻量级锁

轻量级锁基于CAS,服务的是轻量竞争,也就是你用完我用,咱两互不打扰的情况。他有一个replace markword的操作,他会把他的markword放入到栈帧当中,将自己的原位置的markword放着指向lock record的指针,做如此的一个绑定。所以它也就可以存储着hashcode。同时轻量级锁是支持重入的,当重入的时候,他会在栈帧中插入一个又一个的lock record,但是其中不含markword,所以这样他就能知道现在的锁该不该把markword替换回去。

锁膨胀

锁膨胀一定是出现在有重量竞争的时候,从以下角度考虑。
wait;我相信有wait的出现,它一定是需要竞争的场景,不然它wait谁呢?回到正题,synchronized实现了管程的思想,这在重量级锁体现,在objmonitor中,就有waitset,cxq,entrylist,分别实现了等待队列和入口同步队列。
出现激烈竞争,轻量级锁膨胀为重量级锁,当出现激励竞争的时候,也就是B线程发现CAS获取轻量级锁失败了,那么它就生气了,它就默认的把一个假的重量级锁的指针创建出来了,给它换到对象头上。当A线程回来释放锁的时候CAS回markword就发现不对劲了,这时候它会真正的创建一个objmonitor,让对象头指向它。

有个有趣的现象,重量级锁会进行自适应自旋获取锁的,而轻量级锁其实并不会。

synchronized的优化

锁粗化
当对一个对象反复加锁的时候,会对这个代码快的加锁操作进行一个优化,变成一个更大范围粒度更粗的锁

锁消除
锁消除就不得不提到逃逸分析了。逃逸分析分为线程逃逸和方法逃逸,也就是一个对象它如果只在一个线程中使用了,那肯定只需要出现在当前线程的局部变量中,不用在堆中创建,锁也是如此。如果你对一个只在你线程中出现过的变量进行加锁,还有什么意义呢

ReentrantLock/AQS

同样的AQS其实也是实现了一种管程的模型,它有入口同步队列,有等待队列。
AQS使用了我们经典的模板模式,它提供出了五个抽象方法让子类实现,其内部已经编排好了管程的逻辑。

简述一下ReentrantLock的源码

先说非公平锁,AQS的非公平锁其实就分两部分,一是CAS尝试加锁,二是在加入同步队列后尝试加锁,如果失败就会被阻塞,下面简述一下逻辑。

tryAcquire,先去尝试CAS获取锁,如果此时这个volatile的变量state是0,那么就会被CAS加锁成功。因为是支持重入的,那么就判断一下此时线程是不是独占锁的线程,如果是的话就进行重入操作,否则获取锁失败。
之后就要去加入同步队列了,这里是for(;;)这样的循环去确保一定加入同步队列,这里面有个小判断挺细节的,它会判断当前的同步队列是不是创建了,如果不是他会将CAS当前节点设置为头尾节点。之后enq方法中使用了经典的CAS+自旋的这么一种锁去确保一定加入同步队列。
之后在同步队列中尝试获取锁,此时也是用for(;;)这样的循环去确保一定要获取锁,如果获取锁失败就会被park阻塞,此时会记录好它的中断状态的,等待下次唤醒再去获取锁。

其实AQS也就是实现了这么一个同步队列,没什么特别的,内部还是有一个等待唤醒机制,它对state的使用以及对node的状态设置则是它的亮点。

基于AQS和ReentrantLock的扩展

读写锁:读写锁的精妙之处就在于把state拆成了两份,高16位作为读的状态,低16位作为写的状态,这样去进行读写控制。再之后利用threadlocal去保存每个读线程的acquire,并且是支持锁降级的,当获取写锁的时候是能够获取到读锁的。
公平锁:公平不公平其实就看最开始那个CAS,如果最开始你直接上来就一个CAS,跟同步队列里面的人抢,那么肯定不公平啦,所以要在同步队列里面没有前驱节点了,你才能够去CAS。
CountDownLatch:依赖于AQS的共享锁实现,它初始化传入一个int参数,他就给定义到了state上,然后它自定义的tryAcquire方法是当state的等于0的时候才能获取锁。而countdown方法会释放锁,也就是让state--。当countdown到0的时候,就可以获取到锁啦。并且这样下来,state不再能恢复到定义的数字了,所以只能一次性使用。
CyclicBarrier:使用了ReentrantLock的独占锁以及condition。condition是用来等待唤醒,而ReentrantLock是为了对需要wait的线程总数的变量的更改。

posted @ 2022-11-26 13:30  azxx  阅读(31)  评论(0编辑  收藏  举报