常见锁策略

目录

1.乐观锁/悲观锁

2.重量级锁/轻量级锁(轻量重量是站在加锁开销的角度)

3.挂起等待锁/自旋锁

4.公平锁/非公平锁

5.可重入锁与不可重入锁

6.读写锁

synchronized

面试题:是什么偏向锁?

锁的升级:

锁消除:

锁粗化:编译器的优化策略

关于锁的一些面试题

CAS

2.CAS实现自旋锁

3.CAS的ABA问题,版本号的引入

4.相关面试题CAS

7.Callable接口

8.ReentrantLock 可重入互斥锁

ReentrantLock 和 synchronized 的区别

举例:​编辑

如何更好的选择使用synchronized / ReentrantLock锁?

面试题:

9.信号量Semaphore

10.CountDownLatch 同时等待N个任务结束——锁存器


1.乐观锁/悲观锁

这两个词不是指某个锁,而是指某类锁是锁的特点

乐观锁:加锁的时候,假设锁的冲突概率不大 ——> 接下来围绕锁要做的工作就会更少。一般情况下不会加锁,只有在数据提交更新的时候才会对数据进行并发冲突检测,如果发现并发冲突了就会返回给用户信息,让用户决定如何处置。

悲观锁:加锁的时候,假设锁冲突的概率很大 ——> 接下来围绕锁要做的工作很多。总是假设最坏的情况,每次在取数据时都以为数据会修改,所以每次访问数据的时候都会加锁。

乐观锁与悲观锁,背后要做的事情是不同的。

synchronized 这把锁算是乐观/悲观锁?

这是自适应锁,初始情况下是乐观锁(预估接下来的冲突概率不大),当发现锁竞争比较频繁的时候就会自动切换为悲观锁。

在背后会默默统计冲突的次数,达到一定次数就会转变为悲观锁。


2.重量级锁/轻量级锁(轻量重量是站在加锁开销的角度)

重量级锁:加锁的开销比较大,要做的工作往往更多。

会有大量的用户态内核态的代码切换。很容易引发线程调度。

轻量级锁:加锁的开销比较小,要做的工作就较少。

尽量在用户态的代码完成,实在不行再调用内核态。不太容易引发线程调度。

虽然这两个锁的效果与乐观锁/悲观锁是重叠的,站在的角度不一样。

往往悲观锁要做的工作更多,乐观锁要做的工作更少。

在重量级锁/轻量级锁之间,synchronized也是自适应的。冲突比较严重会变成重量级锁。

不能100%认为 重量级锁与悲观锁等价,轻量级锁与乐观锁等价。


3.挂起等待锁/自旋锁

挂起等待锁是悲观锁/重量级锁的一种典型表现。

自旋锁就属于是乐观锁/轻量级锁的一种典型是实现。

按照之前的方式,线程在抢锁失败后就会进入阻塞放弃cpu资源,等待被再次调用。采用自旋锁就可以在没有抢到锁后一直尝试获取锁,直到锁释放后第一时间获取锁。

而挂起等待锁是先让出cpu资源,再去做些别的事情,等到锁被释放再尝试去加锁。

synchronized是自适应的,一开始自旋锁,等到一段时间还没有获取到锁,就变为挂起等待锁。

轻量级锁是基于 自旋 的方式在jvm内部用户态的代码实现的。

重量级锁是基于 挂起等待 的方式实现的,调用操作系统api,内核中实现的。


4.公平锁/非公平锁

假如说女神分手了,但是追她的人有一大堆,接下来的的人如果是按照先来后到的方式这就是公平锁。如果这些人不按照先来后到,按概率均等的方式就是非公平锁

操作系统内部线程调度是随机的,不做任何额外的限制,锁就是非公平锁。

如果想要实现公平锁就要依赖额外的数据结构,来记录线程的顺序

锁的公平与非公平没有好坏之分,要结合具体的场景。


5.可重入锁与不可重入锁

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

不可重入锁:只允许加锁一次的线程。

死锁问题:

如果一个线程针对同一把锁连续枷锁两次,就有可能出现死锁,如果是可重入锁,就可以避免死锁。

6.读写锁

所谓的读写锁 就是把读写操作分为两个情况。

多线程之间,数据的读取不会产生线程安全问题,但是数据的写入会有这种问题。

1.两个数据都是读操作,直接并发读即可

2.两个数据都要写,有线程安全问题

3.一个线程读,一个线程写,有线程安全问题。

读写锁就是把读操作和写操作区别对待,java标准库中提供了ReentrantReadWriteLock.ReadLock

类 表示读锁。这个对象提供了lock/unlock方法

ReentrantReadWriteLock.WriteLock 类表示写锁,这个对象也提供了lock/unlock进行加锁解锁

synchronized不是读写锁。


synchronized

1.乐观悲观,自适应

2.重量轻量,自适应

3.自旋挂起等待,自适应

4.非公平锁

5.可重入锁

6.不是读写锁

面试题:是什么偏向锁?

不是真正的加锁(真正的加锁开销比较大),偏向锁只是做一个标记,(标记的过程非常轻量高效)。如果出现锁竞争,再取消偏向锁的状态,进入轻量锁。本质上是推迟了加锁的时机,是懒汉思想的体现。


锁的升级:

ynchronized的加锁过程:刚开始使用时,会处于一个“偏向锁的状态”遇到线程之间的锁竞争,就会升级到“轻量级锁” 进一步统计竞争出现的频次,达到一定层次之后就会升级到“重量级锁”就是为了能够让synchronized很好的使用不同的场景。降低程序员的使用负担。

对于jvm来说,锁的升级过程是不可逆的。

锁消除:

在编写代码时,编译器会对加上的synchronized进行判断合不合适,如果没有必要就会把这个所给优化掉。避免了无脑加锁。

锁粗化:编译器的优化策略

锁的粒度:就是粗和细,如果一段逻辑中频发出现加锁解锁,编译器+jvm会自动进行锁的组化。

开发时使用细粒度的锁就是期望释放锁的时候其他线程能够及时获取锁,但实际上如果没有别的线程来抢占,这种情况jvm就会自动把锁粗化,避免频繁申请释放锁。


关于锁的一些面试题

1.介绍一下读写锁?

读写锁就是把读操作 和 写操作 分别进行加锁。

读锁 与 读锁之间不互斥;

读锁 与 写锁之间互斥;

写锁 与 写锁之间互斥;

2.什么是自旋锁,为什么要使用自旋锁,缺点是什么?

如果获取锁失败,立即尝试获取锁,无限循环,知道获取到锁为止,第一次获取锁失败紧接着第二次尝试就会到来,一旦锁被释放,就能第一时间获取到锁。

相比于挂起等待锁,优点:一旦锁被释放就能第一时间获取到锁,更加高效,在锁的持有时间较短的场景下非常有用。

缺点:如果所得持有时间较长,就会非常浪费cpu资源。

3.synchronized是可重入锁吗?

是的;

可重入锁就是连续加几次锁不会导致死锁;

在锁中记录该锁持有的线程身份,以及一个计数器(记录加锁次数),如果发现加锁的线程就是当前锁的持有线程,那么就直接计数自增。


CAS

比较内存与寄存器中的内容,如果发现相同,就将另一个寄存器的值赋值给内存。

说明:

一个内存数据 和 寄存器1,寄存器2.

比较内存与寄存器1的值,如果不相等就不操作

如果相等:就将寄存器2的值赋值给内存,

CAS的使用场景:

1.基于CAS实现”原子类“

在多线程中int / long 等这些定义的变量的赋值中,赋值操作++,-- 都不是原子的

通过对 int long 这些类型的封装,从而可以实现原子的操作。

由上图可见,在多线程中并没有加锁,但是并没有发生线程安全问题,因为这个基于CAS的对基本类型进行封装的方法进行运算时,可以看作是一个指令·完成的。

这些都是对基本数据类型的封装。

CAS实现原子类的原理?


2.CAS实现自旋锁


3.CAS的ABA问题,版本号的引入

CAS确实好用,但是会有ABA问题。因为CAS在比较的过程中会判断有没有别的线程插入进来执行

而CAS判断是否有这一结果的依据是判断值有没有被修改,如果值相同,就认为没有别修改如果值不同就认为有线程插入修改了但是值的相等不相等并不等同于线程有没有插入执行。

通常情况下ABA问题不会带来bug,但是有一个极端的例子就是转账

因为余额既能加,又能减。所以会发生ABA问题,如果只能加不能减,或者只能减少就不会发生ABA问题。于是引入一个新的概念“版本号”,这就是一个只能增加的整数。

4.相关面试题CAS

1.请你说一下自己理解的CAS?

全称Compare and swap 即:“比较并交换”,相当于一个原子操作,同时完成”读取内存,比较是否相等,修改内存“这三个步骤。也可以是实现模拟自旋锁。

2.ABA问题怎么解决?

给要修改的数据引入版本号,即比较并交换当前值和就值得同时,也要比较版本号是否符合预期,如果版本号与预期一致就真正修改,并且让版本号自增。如果发现当前版本号比之前的大就修改失败。


7.Callable接口

1.Callable和Runnable 相对都是描述一个任务,Callable是描述带有返回值的任务,Runnable描述的是不带返回值的任务。

2.Callable通常需要搭配FutureTask使用,FutureTask用来保存Callable的返回结果。因为Callbale是在另一个线程里执行的,啥时候执行结束不确定,TutureTask就负责等待结果出来的工作。

使用Callable计算线程1+2+3+.......+1000

理解Callbale :

1.创建一个匿名内部类,实现Callble接口,Callble带有泛型参数,泛型参数表示返回值类型。

2.重写Callable的call方法,完成累加的过程,直接通过返回值返回计算结果。

3.把Callable实例的call用FtureTask包装一下

4.创建线程,把FutureTask的实例传入Thread,线程就会执行FutureTask内部的Callable的call方法

5.在主线程中调用futureTask.get()放法获取返回值。futureTask能够阻塞等待新线程执行完毕。

线程的创建方式

1.直接继承Thread

2.使用Runnable

3.使用Callable

4.使用lambda

5.使用线程池


8.ReentrantLock 可重入互斥锁

通过lock/unlock方法来加锁减锁。

trylock(超时间):加锁,如果取不到锁等待一段时间就放弃等待

ReentrantLock lock = new ReentrantLock();


lock.lock();
  try{

} finally{
    lock.unlock();
}
ReentrantLock 和 synchronized 的区别

1.synchronized()是关键字,底层是jvm的c++代码实现的

    ReentrantLock 是标准库提供的类,是java代码实现的。

2.synchronized通过控制代码块加锁解锁,ReentrantLock通过lock/unlock加锁减锁。

3.ReetrantLock 提供啦tryLock这样的方法,在加锁的时候不会阻塞,而是等待一段时间后直接返回,通过返回值来反馈加锁成功还是失败。

4.synchronized是公平锁。ReentrantLock是默认非公平锁。也可以实现公平锁。

5.ReentrantLock还提供啦“等待通知机制” 基于Condition类,能力比wait/notify更强一些。

举例:
如何更好的选择使用synchronized / ReentrantLock锁?

1.锁竞争不激烈的时候使用synchronized,效率更高,自动释放。更加方便

2.锁竞争不激烈的时候使用ReentrantLock,搭配tryLock 更加灵活的控制锁的行为,不是死等。

3.如果需要使用公平锁,使用ReentrantLock。

面试题:

1.线程同步的方式有哪些?

synchronized,ReentrantLock,Semaphore等都可以用于线程同步。

2.为什么有了synchronized还需要juc的lock?

ReentrantLock使用时需要手动释放,使用更加灵活。

synchronized申请失败时会死等,ReentrantLocck可以通过tryLock的方式等待一段时间后就放弃。

ReentrantLock默认是非公平锁,可以通过构造方法传入true开始公平锁

synchronized是通过Object的wait/notify实现等待,每次唤醒一个随即等待的线程。ReentrantLock搭配Condition实现等待与唤醒可以更加精确唤醒指定线程。        


9.信号量Semaphore

信号量相等于一个计数器,通过计数器的衡量可用资源的个数。

申请资源 计数器+1,释放资源 计数器-1;这些操作是原子的可以在多线程环境下使用

值为1的计数器相当于“锁”。

举例:


10.CountDownLatch 同时等待N个任务结束——锁存器

好比跑步比赛,只有所有选手都过了终点才会公布成绩。

1.构造CountDownLatch实例,初话化10表示有10个任务

2.每个任务执行完毕都调用latch.countDown(),在CountDownLatch内部的计数器同时自减

3,主线程使用latch.await(),阻塞等待所有任务都执行完毕,相当于计数器为0了


11.多线程环境下使用哈希表

1.Hashtable:

只是简单地把关键方法加上关键字;

 

这相当于直接对Hashtable对象本身加锁。

1.如果多个线程访问同一个Hashtable会频繁出发锁冲突。因为每个方法几乎都有synchronized,任何一个操作都会触发锁竞争。

2.size属性也是通过synchronized来控制同步的,所以更新的也是比较慢的。

3.一旦出发扩容,就由该线程完成完成整个扩容,创建新的hash表,再把所有元素搬进去。这样非常耗时,这一系列操作可能是一次put就完成,使得这次put的开销非常之大。

2.ConcurrentHashMap

相比于Hashtable做出了一系列的改进与优化。以java1.8为例

1.读操作没有加锁而是使用关键字volatile保证从内存读取结果正确,只对写操作进行加锁,加锁的方式不是用synchronized也不是整个对象而是通过“锁桶”(以每个链表的头节点作为锁对象),大大降低了所冲突的概率。

2.引入CAS原子操作,针对修改size这个样的操作,借助CAS完成并不会加锁。

3.针对Hash表的扩容进行了特殊的优化,ConcurrentHashMap进行“化整为零”,不会在一次操作中就把所有的数据搬运,而是一次只搬运一部分。此后每次操作都会触发一部分的key搬运,最终把key搬运完成。(这里的扩容机制需要用到B+树,查询的开销十分稳定)

当新表旧表同时存在时,插入操作会插入到新的空间;

查询/修改/删除都是需要新表与旧表都要查询的。

网上资料有”分段锁“说法,这其实与ConcurrentHashMap早期的思想方式一致,只不过是一个锁要管理好几个链表,这种实现方式处理冲突的做的还不彻底,分段锁的实现方式也更复杂。


  • 15
    点赞
  • 27
    收藏
    觉得还不错? 一键收藏
  • 2
    评论
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值