【Java多线程】常见锁策略及synchronized

常见的锁策略以及Synchronized的实现

1.1 常见的锁策略

请注意,下面所叙述的关于锁策略的话题不仅仅针对于Java语言,事实上锁策略是针对整个并发编程系统设计的。不同的操作系统、编程语言层面会采用不同的锁策略进行实现与封装。

1.1.1 乐观锁与悲观锁

乐观锁:预估当前发生锁冲突的概率会比较小,因此对于加锁过程没有做过多的操作

悲观锁:预估当前发生锁冲突的概率会比较大,因此对于加锁过程中会做很多的操作

举个例子:同学A与同学B中午下课后都想去食堂吃饭

同学A认为:现在才刚下课,如果我走快点就可以赶在大家前面吃上饭,这样别人就抢不过我了,因此同学A会直接赶往食堂(没有加锁,直接访问资源),如果这会食堂人确实不多,那么可以直接吃上饭,但是如果这时食堂人很多,那么A同学就会先回寝室休息一下再来食堂吃饭(虽然没有加上锁,但是可以识别出数据冲突),这个是乐观锁

同学B认为:现在正是下课的时候,等我过去的时候食堂可能人已经非常多了,我大概率得排长时间的队,因此同学B会先打电话咨询食堂阿姨:“现在食堂人多不多呀?”如果发现人不多同学B就可以前往食堂,反之同学B就会先等待一会,之后在确定时间,这就是悲观锁。

注意:这里两种方式优劣不能一概而论

1、如果现在食堂确实人很多,那么悲观锁的策略更合适,如果采用乐观锁会导致“多跑很多趟”,浪费资源

2、如果现在食堂人不多,那么乐观锁的策略比较合适,而悲观锁会降低效率。

1.1.2 重量级锁和轻量级锁

首先我们需要知道锁的“原子性”特征的由来:

  • 底层硬件CPU提供原子操作指令
  • 操作系统依据原子指令,实现互斥锁mutex
  • JVM在操作系统提供的mutex的基础上,实现了synchronizedReentranLock等关键字和类

在这里插入图片描述

重量级锁:加锁机制重度依赖OS提供的mutex

  • 大量用户态与内核态的切换
  • 容易引发线程调度

上述两种行为的成本都比较高,一旦涉及到内核态与用户态的切换,就意味着“沧海桑田”

轻量级锁:加锁机制尽可能不使用mutex锁,而是尽量在用户态中完成,实在不行借助mutex

  • 少量用户态与内核态的切换
  • 不容易发生线程调度问题

这里简述内核态 VS 用户态

想象一下银行办理业务的场景:

如果全部交由用户自己来做,就是用户态,时间成本可控

但是如果交由工作人员代办,就是内核态,时间成本不可控

1.1.3 自旋锁和挂起等待锁

自旋锁(Spin Lock):按照之前的方式,线程在竞争锁失败的情况下就会进入阻塞状态,放弃被CPU调度,过了一段时间才能被CPU调度,但是有时候虽然竞争锁失败,但是锁很快就会被释放,就没必要放弃CPU,这个时候就可以使用自旋锁来处理这样的场景。

自旋锁伪代码

while (竞争锁(lock) == 失败) {}

上述伪代码中我们可以看出自旋锁的实现思路:如果线程获取锁失败,立即再次尝试获取锁,直到获取成功为止,一旦锁被其他线程释放就很容易获取锁。

理解自旋锁 VS 挂起等待锁

假设下课五分钟时间,你此时准备上厕所,但是坑位被占了,你发现此时那个人正在冲水,你预估他马上就要出来了,就一直站在坑位前面等待,此时只要里面的人出来,你就立马可以抢占坑位,这就是自旋锁。

如果是挂起等待锁,那么你需要离开一段时间,等到上课铃响后再进去,这时可能坑位已经被很多个人占过了

自旋锁是一种很经典的轻量级锁实现方式:

  • 优点:没有放弃CPU,不涉及线程阻塞以及调度,一旦锁被释放很容易第一时间获取到锁
  • 缺点:如果锁对象被其他线程持有时间过长,那么就会持续的消耗CPU资源

1.1.4 公平锁和非公平锁

考虑这样一个场景:线程A、B、C尝试获取同一锁对象,A成功获取到锁对象,此时线程B来了,进入阻塞等待状态,又过了一会C线程来了,进入阻塞等待状态,如果此时锁被释放之后,B线程和C线程谁会获取到锁对象呢?

公平锁:遵守“先来后到的原则”,B比C先来,因此B线程先与C线程获取到锁

非公平锁:不遵守“先来后到的原则”,B和C都有可能获取到锁

注意:

  • 操作系统内部的线程调度策略可以认为是随机的,如果不做任何额外的限制,实现的就是“非公平锁”,如果想要实现“公平锁”,就需要借助额外的数据结构,来记录线程的先后顺序
  • 公平锁和非公平锁也没有优劣之分,适用于不同的场景

1.1.5 可重入锁和不可重入锁

可重入锁:允许同一个线程对象多次获取同一把锁

不可重入锁:不允许同一个线程对象多次获取同一把锁

理解如下代码:

// 第一次加锁, 加锁成功
synchronized(locker) {
   // 第二次加锁, 加锁成功
   synchronized(locker) {
       //TODO...
   }
}

其中Java的synchronized就是可重入锁,内部会依靠一套计数器机制,判断如果当前锁对象被当前线程持有,那么再次加锁不会发生死锁现象。除此以外,Java中的以Reentran开头的锁均为可重入锁。

如果是不可重入锁执行以上的代码,此时线程获取锁对象之后尝试再次获取该锁对象,由于此时锁对象已经被其他线程占有(就是线程本身),所以线程会进入阻塞等待,此时就发生了死锁!

1.1.6 普通互斥锁和读写锁

在多线程的场景下,数据的读取方之间不会发生线程安全问题,但是数据的写入方之间以及与读取方之间都需要进行互斥,但是各种场景都采用一种锁就会产生不必要的性能消耗,读写锁应运而生。

读写锁:见名知意,读写锁即需要在加锁的时候表明读写意图,读者之间并不互斥,但是写者和写着者以及读者和写者之间需要进行互斥

  • 两个线程都只是读取一个数据,没有线程安全问题,不需要进行互斥
  • 两个线程写同一个数据,可能引发线程安全问题,需要进行互斥
  • 一个线程读数据,一个线程写数据,可能存在线程安全问题,需要进行互斥

1.2 synchronized原理

结合上面的锁策略,我们可以总结出,synchronized具有以下特性(JDK1.8)

  1. 一开始是乐观锁,若锁冲突频繁,会切换为悲观锁
  2. 一开始是轻量级锁,如果需要频繁使用到mutex,就切换为重量级锁
  3. 一开始是自旋锁,如果锁被线程占用时间长就切换为挂起等待锁
  4. 是一种非公平锁
  5. 是一种非读写锁
  6. 是一种可重入锁

1.2.1 加锁升级工作过程

JVM将synchronized锁分为无锁、偏向锁、轻量级锁、重量级锁状态,会根据不同情况依次升级。

在这里插入图片描述

  1. 阶段一:偏向锁

    第一个尝试加锁的线程就会优先进入偏向锁阶段,偏向锁不是真正的加锁,而是在锁对象头中做一个“偏向锁标记”,记录这个锁属于哪个线程,如果后续没有其他线程来竞争该锁,那么就省去了加锁解锁过程的开销,如果后续有线程来竞争该锁,因此就依据锁对象头中的“偏向锁标记”,优先让该线程持有锁,此时锁升级为轻量级锁阶段。

    偏向锁本质是一种“懒汉模式”,遵循能不加锁就不加锁,能晚加锁就晚加锁的原则

  2. 阶段二:轻量级锁

    随着其他线程进入竞争,偏向锁状态被解除,进入轻量级锁阶段(自旋锁实现)此处的自旋锁依据CAS实现

    • 通过CAS检查并更新一块内存(null => 该线程引用)
    • 如果更新成功,认为加锁成功
    • 如果更新失败,继续自旋式等待(不放弃CPU)
  3. 阶段三:重量级锁

    如果线程竞争进一步激烈,自旋方式无法立即获取到锁,就会升级为重量级锁。此处的重量级锁就是依赖操作系统内核提供的mutex

1.2.2 锁消除策略

JVM帮我们在synchronized内部进行了优化,可以在一定情况下帮我们消除不必要的锁

来分析如下代码:

StringBuffer sBuffer = new StringBuffer();
sBuffer.append("a");
sBuffer.append("b");
sBuffer.append("c");
sBuffer.append("d");

此时由于StringBuffer对象是线程安全的,但是在单线程运行环境下,这些加锁解锁操作完全没有必要,因此编译器就会帮我们省去这些锁

注意:synchronized消除锁的策略是比较保守的,明显不会发生线程安全问题的代码才会消除锁,例如:

  • 变量只涉及局部变量,没有全局变量
  • 多个线程只对变量做读取操作,不涉及修改操作

1.2.3 锁粗化策略

如果在同一段代码逻辑中,多次频繁的加锁解锁操作,编译器和JVM会帮助我们将其合并为一次加锁解锁操作
在这里插入图片描述

如上图所示,当一段逻辑代码中涉及多个细粒度的锁,JVM和编译器就会将其优化为一个粗粒度的锁,在实际开发的过程中,使用细粒度的锁是希望一个线程使用完锁后释放锁,另外的线程可以使用以此加快效率,但是当没有别的线程使用时,就会优化成粗粒度的锁,省去了频繁加锁解锁的额外开销。

  • 30
    点赞
  • 25
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值