文章目录
- 锁的分类
- 一、乐观锁 VS 悲观锁
- 二、读写锁
- 三、可重入锁 VS 不可重入锁
- 四、重量级锁 VS 轻量级锁
- 五、公平锁 VS 非公平锁
- 六、自旋锁 VS 挂起等待锁
- 七、锁升级策略
- 1、无锁:
- 2、 偏向锁:
- 3、 轻量级锁
- 4、重量级锁
- 总结
- 八、锁的粗化
- 九、锁消除
锁的分类
加锁,是一个开销比较大的过程,我们希望在一些特定的场景下,针对场景做出一些取舍,可以让锁更加高效一些。就有了以下不同的锁。
一、乐观锁 VS 悲观锁
乐观锁: 假设一般情况下都不会产生锁冲突或基本没有冲突,只有在数据访问的时候才会判断有没有锁竞争。没有就直接修改数据,如果有锁冲突,然后再去处理。
悲观锁 : 假设一般情况下都会产生锁冲突,每次去访问数据的时候都会先上锁,然后再去访问数据。
二、读写锁
ReadWriteLock 管理一组锁,一个是读锁(共享锁),一个是写锁(互斥锁),读写锁默认是悲观锁策略 。
读锁:可以在没有写锁的时候被多个线程同时持有,但写锁是独占的,同时只能有一个线程去写。
一个获得了读锁的线程必须能看到前一个释放的写锁更新的内容。读写锁比互斥锁允许对于数据更大程度上的并发。读写锁适用于读多写少的情况,此时使用读写锁,就能大大的提高效率。
例:
假设有10个线程,t1 和 t2 是写线程,t3 - t9是读线程。
- 如果 t3 和t4 两个线程同时访问数据,此时两个读锁之间不会互斥,完全并发的执行。
- 如果 t1 和 t3 两个线程同时访问,此时读锁和写锁之间就会互斥,要么是读完再写,要么是写完再读。
- 如果是 t1 和 t2 两个写线程溶蚀访问,此时写锁和写锁之间就会互斥,执行过程一定是一个线程写完,另一个线程再写。
三、可重入锁 VS 不可重入锁
一个线程针对同一把锁连续加锁两次,如果出现死锁,就是 不可重入锁,如果没有出现死锁,就是可重入锁。
对于可重入锁,当前的锁会记录这个锁是谁持有的,如果发现,当前有同一个线程再次尝试获取锁。这个时候,就让代码能够继续运行,而不是阻塞等待。同时这个锁里面也维护一个计数器,这个计数器记录了当前这个线程,针对这把锁加了几次锁,每次加锁,计数器 +1 ,每次解锁,计数器 -1 ,直到计数器为零,此时才会真正的释放锁,其他线程才能才能够获取到这个锁。
四、重量级锁 VS 轻量级锁
加锁很重要的特性就是要保证原子性,原子性的共功能其实来源于硬件(硬件提供了相应的原子操作指令),所以在加锁的过程中,如果整个加锁的逻辑过程都是依赖于操作系统内核,那此时就是重量级锁,此时代码在内核中的开销很大。如果大多数操作,都是由用户自己完成的,少数由操作系统完成就是轻量级锁。
重量级锁 : 加锁、解锁的开销很大,往往是通过内核来完成的
轻量级锁: 加锁、解锁的开销很小,往往只是在用户态完成的。
不同的锁策略之间,并不是完全互不相关的,可能会有部分的重叠:
如 :
悲观锁,做的工作往往更过,因此开销也就更大,悲观锁大概率是重量级锁
乐观锁,做的工作往往更少 , 因此开销也更小,乐观锁大概率是轻量级锁
五、公平锁 VS 非公平锁
公平锁: 如果遵循先来后到的原则,多个线程按照申请锁的顺序去获得锁,线程会直接进入到队列去排队,永远是队列的第一位才能获取到锁。
优点: 所有的线程都能够得到资源,不会饿死再队列中
缺点 : 吞吐量会下降很多,队列里面处理第一个线程,其他的线程都会阻塞,cpu 唤醒阻塞线程的开销比较大。
非公平锁: 不遵循先来后到的原则,多个线程抢占式执行,获取不到锁的再去进入等待队列
优点:可以减少 CPU 唤醒线程的开销,整体的吞吐效率比较高
缺点 :由于所有的线程都在抢占执行,可能会导致队列中的线程一直获取不到锁导致饿死的现象。
六、自旋锁 VS 挂起等待锁
自旋锁: 如果线程获取不到锁,不是阻塞等待而是循环的快速的再是一次~~,因此就节省了操作系统调度线程的开销,要比挂起等待锁更能及时的获取到锁。(假设有两个线程,线程1 拿到了锁,线程2就会不断的通过循环来尝试获取这个锁,一旦线程1 释放了锁,线程2 就能够第一时间时间获取到这个锁,与此而来的问题是,自旋锁更浪费 CPU 资源,通常都是轻量级的锁)
挂起等待锁: 如果线程获取不到锁,就会阻塞等待,具体什么时候结束阻塞,取决于操作系统的具体调度,当线程挂起的时候,不占用 CPU 资源,通常都是重量级锁
应用场景:
- 如果锁的冲突的概率比较低,使用自旋锁比挂起等待锁,更合适
- 如果线程持有锁的时间比较短,使用自旋锁比挂起等待锁更合适
- 如果对 CPU 比较敏感,不希望占用太多的 CPU 资源,那么就不太适合使用自旋锁
七、锁升级策略
java对象在内存中的布局,可分为三个部分:
- 对象头:对象头主要包括 Mark Word(标记字段),用于存储对象自身的运行时数据。Klass Pointer(类型指针),对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例
- 实例变量 : 存储的是对象的属性信息,包括父类的属性信息,按照4字节对齐
- 填充字节 : 因为虚拟机要求对象字节必须是8字节的整数倍,填充字节只是为了内存对齐
Java SE1.6 为了减少获得锁和释放锁所带来的性能消耗,引入了“偏向锁”和“轻量级锁”,所以在 Java SE1.6 里锁一共有四种状态,无锁状态,偏向锁状态,轻量级锁状态和重量级锁状态,它会随着竞争情况逐渐升级。
锁可以升级但不能降级,意味着偏向锁升级成轻量级锁后不能降级成偏向锁。这种锁升级却不能降级的策略,目的是为了提高获得锁和释放锁的效率。
1、无锁:
没有对资源进行锁定,多有的线程都能够访问并修改同一个资源,但同时只有一个线程能修改成功
无锁的特点是修改操作会在循环内进行,线程会不断的尝试修改共享资源。如果没有冲突就修改成功并退出,如果有冲突就会循环尝试。如果有多个线程修改同一个值,必定会有一个线程能修改成功,而其他修改失败的线程会不断重试直到修改成功。
2、 偏向锁:
偏向锁引入:
经过研究,大多数时候是不存在锁竞争的,常常是一个线程多次获得同一个锁,因此如果每次都要竞争锁会增大很多没有必要付出的代价,为了降低获取锁的代价,才引入的偏向锁。
偏向锁升级过程:
偏向锁是指当一段同步代码一直被同一个线程访问时,即不存在多个线程的竞争时,那么该线程在后续访问时变会自动获得锁,从而降低获取锁代理的消耗。
当线程1 在访问同步代码块并获取到锁时,会在java对象头和栈帧中记录偏向的锁的线程ID,因为偏向锁不会主动释放锁,(偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程是不会主动释放偏向锁的)
因此以后线程1在次获取锁的时候,只需要比较当前线程的ID 和 java对象头中的线程ID是否一致:
如果一致,则无需使用CAS来加锁,解锁;
如果不一致,(如线程2需要竞争锁对象,而偏向锁不会主动释放因此还是存储1的ID ),那么就需要查看java对象头中记录的线程1是否存货,如果没有存货,那么锁对象被重置位无所状态,其他线程可以竞争将其设置为偏向锁;如果存活,那么立刻查找该线程(线程1)的的栈帧信息,如果还是需要继续下hi有这个锁对象,那么暂停当前线程1,撤销偏向锁,升级位轻量级锁,如果线程1不再使用该锁对象,那么将锁对象状态设置为无锁状态,重新偏向新的线程
3、 轻量级锁
轻量级锁引用:
轻量级锁考虑的是竞争多对象的线程不多,而且线程持有锁的时间也不长的情景。因为阻塞线程需要 CPU 从用户态转到内核态,代价比较大,如果刚刚阻塞不久,这个锁就被释放了,这个开销就很大,因此这个时候就干脆不阻塞这个线程,让他自旋着等待锁释放。线程不会阻塞,从而提高了性能
轻量级锁的获取主要有两种情况
- 当关闭偏向锁功能时
- 由于多个线程竞争偏向锁导致偏向锁升级为轻量级锁。
线程1获取轻量级锁时,会先把锁对象头 MarkWord 复制一份到线程1的栈帧中创建的用于存储锁记录的空间(称为DisplaceMarkWord),然后使用 CAS 把对象头中的内容替换位线程1存储的锁记录(DisplaceMarkWord)的地址;
如果在线程1复制对象的同时(在线程1 CAS之前),线程2也准备获取锁,复制了对象头到线程2的锁记录空间中,但是在2线程 CAS的时候,发现线程1已经把对象头换了,线程2CAS 失败,那么线程2就尝试使用自旋锁来等待线程1释放锁。(线程2不断CAS)
但是如果自旋时间太长,因为自旋是要消耗CPU的,因此自旋的次数是由限制的,如果自旋次数到了,线程1还没有释放锁,或者线程1 还在执行,线程2还在自旋等待,这时又有一个线程3过来竞争这个锁对象,那么这个轻量级锁就会膨胀为重量级锁。
4、重量级锁
重量级锁,是指当一个线程获取锁之后,其余所有等待获取该锁的线程都会处于阻塞状态,除拥有锁的线程都阻塞。
重量级锁是通过对象内部的监视器实现,而其中monitor的本质是依赖底层操作系统 Mutex Lock 实现,操作系统实现线程之间的切换需要从用户态切换到内核态,切换成本非常高。
由操作系统来负责线程间的调度和线程的状态变更。而这样出现偏饭的对线程云从状态的切换,线程的挂起和唤醒,从而消耗大量的系统资源,导致性能降低。
总结
八、锁的粗化
通常情况下,为了保证多线程间的有效并发,会尽量将同步代码块作用的范围控制的尽量小,只在共享的数据的实际作用域中才进行同步,这样是为了使得需要同步的操作数量尽可能变小,如果存在锁竞争,拿等待的锁的线程也能尽快拿到锁。
但是如果一个程序对同一个锁不间断、高频繁的反复加锁与解锁,会消耗不必要的资源开销,这样高频的锁请求反而不利于系统性能的优化。如果虚拟机探测到有这样遗传零碎的操作都对同一个对象加锁,将会把锁的范围扩展到整个操作程序的外部,合并成一个请求,以降低短时间内大量锁请求,同步。释放带来的性能损耗
fun(){synchronized(this){}synchronized(this){}synchronized(this){}
}
上面的代码会不断的加锁,释放锁,造成不必要的系统开销,与其这样还不如一次加锁完成三个任务
fun(){synchronized(this){}
}
九、锁消除
锁消除,是指虚拟机在执行编译器运行时,会一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除。显然这里有锁,但是可以被安全的消除掉,在即时编译之后。这段代码就会忽略掉所有的同步而直接执行。