收录于话题面试
#多线程数据库
6个安全
关于线程安全一提到可能就是加锁,在面试中也是面试官百问不厌的考察点,每每能看出面试者的基本功和是否对线程安全有本身的思考。多线程
那锁自己是怎么去实现的呢?又有哪些加锁的方式呢?并发
我今天就简单聊一下乐观锁和悲观锁,他们对应的实现 CAS ,Synchronized,ReentrantLockjvm
一个120斤一身黑的小伙子走了进来,看到他微微发福的面容,看来是最近疫情伙食好运动少的结果,他难道就是今天的面试官渣渣丙?ide
等等难道是他?前几天刷B站看到的不会是他吧!!!函数
是的我已经开始把面试系列作成视频了,之后会有各类级别的面试,从大学生到阿里P7+的面试,还有阿里,拼多多,美团,字节风格的面试我也都约好人了,就差时间了,你们能够去B站搜:三太子敖丙观看工具
我也很少跟你BB了,咱们直接开始好很差,你能跟我聊一下CAS么?this
CAS(Compare And Swap 比较而且替换)是乐观锁的一种实现方式,是一种轻量级锁,JUC 中不少工具类的实现就是基于 CAS 的。
CAS 是怎么实现线程安全的?
线程在读取数据时不进行加锁,在准备写回数据时,先去查询原值,操做的时候比较原值是否修改,若未被其余线程修改则写回,若已被修改,则从新执行读取流程。
举个栗子:如今一个线程要修改数据库的name,修改前我会先去数据库查name的值,发现name=“帅丙”,拿到值了,咱们准备修改为name=“三歪”,在修改以前咱们判断一下,原来的name是否是等于“帅丙”,若是被其余线程修改就会发现name不等于“帅丙”,咱们就不进行操做,若是原来的值仍是帅丙,咱们就把name修改成“三歪”,至此,一个流程就结束了。
有点懵?理一下停下来理一下思路。
Tip:比较+更新 总体是一个原子操做,固然这个流程仍是有问题的,我下面会提到。
他是乐观锁的一种实现,就是说认为数据老是不会被更改,我是乐观的仔,每次我都以为你不会渣我,差很少是这个意思。
你这个栗子不错,他存在什么问题呢?
有,固然是有问题的,我也恰好想提到。
大家看图发现没,要是结果一直就一直循环了,CUP开销是个问题,还有ABA问题和只能保证一个共享变量原子操做的问题。
你能分别介绍一下么?
好的,我先介绍一下ABA这个问题,直接口述可能有点抽象,我画图解释一下:
看到问题所在没,我说一下顺序:
线程1读取了数据A
线程2读取了数据A
线程2经过CAS比较,发现值是A没错,能够把数据A改为数据B
线程3读取了数据B
线程3经过CAS比较,发现数据是B没错,能够把数据B改为了数据A
线程1经过CAS比较,发现数据仍是A没变,就写成了本身要改的值
懂了么,我尽量的幼儿园化了,在这个过程当中任何线程都没作错什么,可是值被改变了,线程1却没有办法发现,其实这样的状况出现对结果自己是没有什么影响的,可是咱们仍是要防范,怎么防范我下面会提到。
循环时间长开销大的问题:
是由于CAS操做长时间不成功的话,会致使一直自旋,至关于死循环了,CPU的压力会很大。
只能保证一个共享变量的原子操做:
CAS操做单个共享变量的时候能够保证原子的操做,多个变量就不行了,JDK 5以后 AtomicReference能够用来保证对象之间的原子性,就能够把多个对象放入CAS中操做。
我还记得你以前说在JUC包下的原子类也是经过这个实现的,能举个栗子么?
那我就拿AtomicInteger举例,他的自增函数incrementAndGet()就是这样实现的,其中就有大量循环判断的过程,直到符合条件才成功。
大概意思就是循环判断给定偏移量是否等于内存中的偏移量,直到成功才退出,看到do while的循环没。
乐观锁在项目开发中的实践,有么?
有的就好比咱们在不少订单表,流水表,为了防止并发问题,就会加入CAS的校验过程,保证了线程的安全,可是看场景使用,并非适用全部场景,他的优势缺点都很明显。
那开发过程当中ABA大家是怎么保证的?
加标志位,例如搞个自增的字段,操做一次就自增长一,或者搞个时间戳,比较时间戳的值。
举个栗子:如今咱们去要求操做数据库,根据CAS的原则咱们原本只须要查询本来的值就行了,如今咱们一同查出他的标志位版本字段vision。
以前不能防止ABA的正常修改:
update table set value = newValue where value = #{oldValue}
//oldValue就是咱们执行前查询出来的值
带版本号能防止ABA的修改:
update table set value = newValue ,vision = vision + 1 where value = #{oldValue} and vision = #{vision}
// 判断原来的值和版本号是否匹配,中间有别的线程修改,值可能相等,可是版本号100%不同
除了版本号,像什么时间戳,还有JUC工具包里面也提供了这样的类,想要扩展的小伙伴能够去了解一下。
聊一下悲观锁?
悲观锁从宏观的角度讲就是,他是个渣男,你认为他每次都会渣你,因此你每次都提防着他。
咱们先聊下JVM层面的synchronized:
synchronized加锁,synchronized 是最经常使用的线程同步手段之一,上面提到的CAS是乐观锁的实现,synchronized就是悲观锁了。
它是如何保证同一时刻只有一个线程能够进入临界区呢?
synchronized,表明这个方法加锁,至关于无论哪个线程(例如线程A),运行到这个方法时,都要检查有没有其它线程B(或者C、 D等)正在用这个方法(或者该类的其余同步方法),有的话要等正在使用synchronized方法的线程B(或者C 、D)运行完这个方法后再运行此线程A,没有的话,锁定调用者,而后直接运行。
我分别从他对对象、方法和代码块三方面加锁,去介绍他怎么保证线程安全的:
synchronized 对对象进行加锁,在 JVM 中,对象在内存中分为三块区域:对象头(Header)、实例数据(Instance
Data)和对齐填充(Padding)。
对象头:咱们以Hotspot虚拟机为例,Hotspot的对象头主要包括两部分数据:Mark Word(标记字段)、Klass Pointer(类型指针)。
你能够看到在对象头中保存了锁标志位和指向 monitor 对象的起始地址,以下图所示,右侧就是对象对应的 Monitor 对象。
当 Monitor 被某个线程持有后,就会处于锁定状态,如图中的 Owner 部分,会指向持有 Monitor 对象的线程。
另外 Monitor 中还有两个队列分别是EntryList和WaitList,主要是用来存放进入及等待获取锁的线程。
若是线程进入,则获得当前对象锁,那么别的线程在该类全部对象上的任何操做都不能进行。
Mark Word:默认存储对象的HashCode,分代年龄和锁标志位信息。它会根据对象的状态复用本身的存储空间,也就是说在运行期间Mark Word里存储的数据会随着锁标志位的变化而变化。
Klass Point:对象指向它的类元数据的指针,虚拟机经过这个指针来肯定这个对象是哪一个类的实例。
在对象级使用锁一般是一种比较粗糙的方法,为何要将整个对象都上锁,而不容许其余线程短暂地使用对象中其余同步方法来访问共享资源?
若是一个对象拥有多个资源,就不须要只为了让一个线程使用其中一部分资源,就将全部线程都锁在外面。
因为每一个对象都有锁,能够以下所示使用虚拟对象来上锁:
class FineGrainLock{
MyMemberClassx,y;
Object xlock = new Object(), ylock = newObject();
public void foo(){
synchronized(xlock){
//accessxhere
}
//dosomethinghere-butdon'tusesharedresources
synchronized(ylock){
//accessyhere
}
}
public void bar(){
synchronized(this){
//accessbothxandyhere
}
//dosomethinghere-butdon'tusesharedresources
}
}
synchronized 应用在方法上时,在字节码中是经过方法的 ACC_SYNCHRONIZED 标志来实现的。
我反编译了一小段代码,咱们能够看一下我加锁了一个方法,在字节码长啥样,flags字段瞩目:
synchronized void test();
descriptor: ()V
flags: ACC_SYNCHRONIZED
Code:
stack=0, locals=1, args_size=1
0: return
LineNumberTable:
line 7: 0
LocalVariableTable:
Start Length Slot Name Signature
0 1 0 this Ljvm/ClassCompile;
反正其余线程进这个方法就看看是否有这个标志位,有就表明有别的仔拥有了他,你就别碰了。
synchronized 应用在同步块上时,在字节码中是经过 monitorenter 和 monitorexit 实现的。
每一个对象都会与一个monitor相关联,当某个monitor被拥有以后就会被锁住,当线程执行到monitorenter指令时,就会去尝试得到对应的monitor。
步骤以下:
每一个monitor维护着一个记录着拥有次数的计数器。未被拥有的monitor的该计数器为0,当一个线程得到monitor(执行monitorenter)后,该计数器自增变为 1 。
当同一个线程再次得到该monitor的时候,计数器再次自增;
当不一样线程想要得到该monitor的时候,就会被阻塞。
当同一个线程释放 monitor(执行monitorexit指令)的时候,计数器再自减。
当计数器为0的时候,monitor将被释放,其余线程即可以得到monitor。
一样看一下反编译后的一段锁定代码块的结果:
public void syncTask();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=3, locals=3, args_size=1
0: aload_0
1: dup
2: astore_1
3: monitorenter //注意此处,进入同步方法
4: aload_0
5: dup
6: getfield #2 // Field i:I
9: iconst_1
10: iadd
11: putfield #2 // Field i:I
14: aload_1
15: monitorexit //注意此处,退出同步方法
16: goto 24
19: astore_2
20: aload_1
21: monitorexit //注意此处,退出同步方法
22: aload_2
23: athrow
24: return
Exception table:
//省略其余字节码.......
同步方法和同步代码块底层都是经过monitor来实现同步的。
二者的区别:同步方式是经过方法中的access_flags中设置ACC_SYNCHRONIZED标志来实现,同步代码块是经过monitorenter和monitorexit来实现。
咱们知道了每一个对象都与一个monitor相关联,而monitor能够被线程拥有或释放。