New 构建你的护城河   登录后该广告会消失
锁 | 锁是计算机协调多个进程或纯线程并发访问某一资源的机制
IT面试
1067 ·
0 ·
2023-02-08 16:50:36
最新编辑原因:

公平锁

多个线程按照申请锁的顺序去获得锁,线程会直接进入队列去排队,永远都是队列的第一位才能得到锁。

优缺点

  • 优点:所有的线程都能得到资源,不会饿死在队列中。

  • 缺点:吞吐量会下降很多,队列里面除了第一个线程,其他的线程都会阻塞,cpu唤醒阻塞线程的开销会很大。

非公平锁

多个线程去获取锁的时候,会直接去尝试获取,获取不到,再去进入等待队列,如果能获取到,就直接获取到锁
优缺点

  • 优点:可以减少CPU唤醒线程的开销,整体的吞吐效率会高点,CPU也不必取唤醒所有线程,会减少唤起线程的数量。

  • 缺点:你们可能也发现了,这样可能导致队列中间的线程一直获取不到锁或者长时间获取不到锁,导致饿死。

 

ReentrantLock(默认非公平)

  • 集成了AbstractQueuedSynchronizer(就是我们常见的AQS)

  • 我们在操作锁的大部分操作,都是Sync本身去实现的。

可以初始化为公平锁(FairSync),也可以初始化为非公平锁(NofairSync)
默认实现的是非公平锁

public ReentrantLock() {
    sync = new NonfairSync();
}

 

锁升级

synchronized之前(<1.8)一直都是重量级的锁,但是后来java(>=1.8)官方是对他进行过升级的,他现在采用的是锁升级的方式去做的。
针对 synchronized 获取锁的方式,JVM 使用了锁升级的优化方式,就是先使用偏向锁优先同一线程然后再次获取锁,如果失败,就升级为 CAS 轻量级锁,如果失败就会短暂自旋,防止线程被系统挂起。最后如果以上都失败就升级为重量级锁。

针对 synchronized 获取锁的方式,JVM 使用了锁升级的优化方式,就是先使用偏向锁优先同一线程然后再次获取锁,如果失败,就升级为 CAS 轻量级锁,如果失败就会短暂自旋,防止线程被系统挂起。最后如果以上都失败就升级为重量级锁。

偏向锁

  • 解释1: 偏向锁是JDK6时加入的一种锁优化机制: 在无竞争的情况下把整个同步都消除掉,连CAS操作都不去做了。偏是指偏心,它的意思是这个锁会偏向于第一个获得它的线程,如果在接下来的执行过程中,该锁一直没有被其他的线程获取,则持有偏向锁的线程将永远不需要再进行同步。持有偏向锁的线程以后每次进入这个锁相关的同步块时,虚拟机都可以不再进行任何同步操作(例如加锁、解锁及对Mark Word的更新操作等)。

  • 解释2: 【会偏向第一个访问锁】的线程,当一个线程访问同步代码块获得锁时,会在对象头和栈帧记录里存储锁偏向的线程ID,当这个线程再次进入同步代码块时,就不需要CAS操作来加锁了,只要测试一下对象头里是否存储着指向当前线程的偏向锁。
    如果偏向锁未启动,new出的对象是普通对象(即无锁,有稍微竞争会成轻量级锁),如果启动,new出的对象是匿名偏向(偏向锁)
    对象头主要包括两部分数据:Mark Word(标记字段, 存储对象自身的运行时数据)、class Pointer(类型指针, 是对象指向它的类元数据的指针)

  • 解释3: 在没有实际竞争的情况下,还能够针对部分场景继续优化。如果不仅仅没有实际竞争,自始至终,使用锁的线程都只有一个,那么,维护轻量级锁都是浪费的。偏向锁的目标是,减少无竞争且只有一个线程使用锁的情况下,使用轻量级锁产生的性能消耗。轻量级锁每次申请、释放锁都至少需要一次CAS,但偏向锁只有初始化时需要一次CAS。
    “偏向”的意思是,偏向锁假定将来只有第一个申请锁的线程会使用锁(不会有任何线程再来申请锁),因此,只需要在Mark Word中CAS记录owner(本质上也是更新,但初始值为空),如果记录成功,则偏向锁获取成功,记录锁状态为偏向锁,以后当前线程等于owner就可以零成本的直接获得锁;否则,说明有其他线程竞争,膨胀为轻量级锁。
    偏向锁无法使用自旋锁优化,因为一旦有其他线程申请锁,就破坏了偏向锁的假定。

CAS 轻量级锁 (自旋锁)

  • 在把线程进行阻塞操作之前先让线程自旋等待一段时间,可能在等待期间其他线程已经解锁,这时就无需再让线程执行阻塞操作,避免了用户态到内核态的切换。(自适应自旋时间为一个线程上下文切换的时间)

  • 在用自旋锁时有可能造成死锁,当递归调用时有可能造成死锁

  • 自旋锁底层是通过指向线程栈中Lock Record的指针来实现的

自旋锁升级到重量级锁条件

  • 某线程自旋次数超过10次

  • 等待的自旋线程超过了系统core数的一半;

轻量级锁和偏向锁

  • 轻量级锁是通过CAS来避免进入开销较大的互斥操作

  • 偏向锁是在无竞争场景下完全消除同步,连CAS也不执行

可重入锁概念

  • 可重入锁是指同一个线程可以多次获取同一把锁,不会因为之前已经获取过还没释放而阻塞

    易理解的解释: 广义上的可重入锁指的是可重复可递归调用的锁,在外层使用锁之后,在内层仍然可以使用,并且不发生死锁(前提得是同一个对象或者class),这样的锁就叫做可重入锁。

  • ReentrantLock和synchronized都是可重入锁

  • 可重入锁的一个优点是可一定程度避免死锁

独占锁(又称为写锁、排它锁、X锁 )

ReentrantLock为独占锁(悲观加锁策略) (https://juejin.cn/post/6844904132537483277#heading-8)

共享锁

ReentrantReadWriteLock中读锁为共享锁 (https://juejin.cn/post/6850418109413589000)

邮戳锁(StampedLock)

JDK1.8 邮戳锁(StampedLock), 不可重入锁
读的过程中也允许获取写锁后写入!这样一来,我们读的数据就可能不一致,所以,需要一点额外的代码来判断读的过程中是否有写入,这种读锁是一种乐观锁, 乐观锁的并发效率更高,但一旦有小概率的写入导致读取的数据不一致,需要能检测出来,再读一遍就行

读写锁的实现方式

常用的读写锁ReentrantReadWritelock,这个其实和ReentrantLock相似,也是基于AQS的,但是这个是基于共享资源的,不是互斥,关键在于state的处理,读写锁把高16为记为读状态,低16位记为写状态,就分开了,读读情况其实就是读锁重入,读写/写读/写写都是互斥的,只要判断低16位就好了。

 

乐观锁

CAS(Compare And Swap 比较并且替换)是乐观锁的一种实现方式,是一种轻量级锁,JUC 中很多工具类的实现就是基于 CAS 的。

CAS 是怎么实现线程安全的?
线程在读取数据时不进行加锁,在准备写回数据时,先去查询原值,操作的时候比较原值是否修改,若未被其他线程修改则写回,若已被修改,则重新执行读取流程。

存在的问题

  1. ABA问题
    业务中如何解决该问题? 加标志位,例如搞个自增的字段,操作一次就自增加一,或者搞个时间戳,比较时间戳的值。

  2. CUP开销
    是因为CAS操作长时间不成功的话,会导致一直自旋,相当于死循环了,CPU的压力会很大。

  3. 只能保证一个共享变量原子操作
    CAS操作单个共享变量的时候可以保证原子的操作,多个变量就不行了,JDK 5之后 AtomicReference可以用来保证对象之间的原子性,就可以把多个对象放入CAS中操作。

    第2点和第3点,可以AtomicInteger举例,他的自增函数incrementAndGet() 就是这样实现的,其中就有大量循环判断的过程,直到符合条件才成功。

业务实现
订单表,流水表,为了防止并发问题

 

悲观锁

悲观锁就是悲观的认为每次都会变,所以从一开始就加锁

JVM层面的synchronized
synchronized实现原理

底层是由一对monitorenter和monitorexit指令实现的(监视器锁)。
每个对象有一个监视器锁(monitor)。当monitor被占用时就会处于锁定状态,线程执行monitorenter指令时尝试获取monitor的所有权。
过程:
如果monitor的进入数为0,则该线程进入monitor,然后将进入数设置为1,该线程即为monitor的所有者。如果线程已经占有该monitor,只是重新进入,则进入monitor的进入数加1.如果其他线程已经占用了monitor,则该线程进入阻塞状态,直到monitor的进入数为0,再重新尝试获取monitor的所有权。

基础概念介绍

  • contentionList(请求锁线程队列)

  • entryList(有资格的候选者队列)

  • waitSet(wait方法后阻塞队列)

  • onDeck(竞争候选者)

  • Owner(竞争到锁线程)

  • !Owner(执行成功释放锁后状态)

  • Synchronized 是非公平锁。 Synchronized 在线程进入 ContentionList 时,等待的线程会先尝试自旋获取锁,如果获取不到就进入 ContentionList,这明显对于已经进入队列的线程是不公平的,还有一个不公平的事情就是自旋获取锁的线程还可能直接抢占 OnDeck 线程的锁资源。

enter image description here

是如何保证同一时刻只有一个线程可以进入临界区呢?

synchronized,代表这个方法加锁,相当于不管哪一个线程(例如线程A),运行到这个方法时,都要检查有没有其它线程B(或者C、 D等)正在用这个方法(或者该类的其他同步方法),有的话要等正在使用synchronized方法的线程B(或者C 、D)运行完这个方法后再运行此线程A,没有的话,锁定调用者,然后直接运行。

加锁位置

synchronized 对对象进行加锁,在 JVM 中,对象在内存中分为三块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。
enter image description here

对象头:我们以Hotspot虚拟机为例,Hotspot的对象头主要包括两部分数据:Mark Word(标记字段)、Klass Pointer(类型指针)。

详细过程
当 Monitor 被某个线程持有后,就会处于锁定状态,如图中的 Owner 部分,会指向持有 Monitor 对象的线程。 另外 Monitor 中还有两个队列分别是EntryList和WaitList,主要是用来存放进入及等待获取锁的线程。 如果线程进入,则得到当前对象锁,那么别的线程在该类所有对象上的任何操作都不能进行。

Mark Word:默认存储对象的HashCode,分代年龄和锁标志位信息。它会根据对象的状态复用自己的存储空间,也就是说在运行期间Mark Word里存储的数据会随着锁标志位的变化而变化。
Klass Point:对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
其他
  • 在对象级使用锁通常是一种比较粗糙的方法,为什么要将整个对象都上锁,而不允许其他线程短暂地使用对象中其他同步方法来访问共享资源?

    • 如果一个对象拥有多个资源,就不需要只为了让一个线程使用其中一部分资源,就将所有线程都锁在外面。

  • synchronized 应用在方法上时,在字节码中是通过方法的 ACC_SYNCHRONIZED 标志来实现的。

  • synchronized 应用在同步块上时,在字节码中是通过 monitorenter 和 monitorexit 实现的。

    • 每个对象都会与一个monitor相关联,当某个monitor被拥有之后就会被锁住,当线程执行到monitorenter指令时,就会去尝试获得对应的monitor。步骤如下:

      1. 每个monitor维护着一个记录着拥有次数的计数器。未被拥有的monitor的该计数器为0,当一个线程获得monitor(执行monitorenter)后,该计数器自增变为 1 。

        • 当同一个线程再次获得该monitor的时候,计数器再次自增;

        • 当不同线程想要获得该monitor的时候,就会被阻塞。

      2. 当同一个线程释放 monitor(执行monitorexit指令)的时候,计数器再自减。当计数器为0的时候,monitor将被释放,其他线程便可以获得monitor。

总结
  1. 同步方法和同步代码块底层都是通过monitor来实现同步的。两者的区别:同步方式是通过方法中的access_flags中设置ACC_SYNCHRONIZED标志来实现,同步代码块是通过monitorenter和monitorexit来实现。

  2. 以前我们一直说synchronized是重量级的锁,为啥现在都不提了?

    在多线程并发编程中 synchronized 一直是元老级角色,很多人都会称呼它为重量级锁。

    但是,随着 Java SE 1.6 对 synchronized 进行了各种优化之后,有些情况下它就并不那么重,Java SE 1.6 中为了减少获得锁和释放锁带来的性能消耗而引入的偏向锁和轻量级锁。

    针对 synchronized 获取锁的方式,JVM 使用了锁升级的优化方式,就是先使用偏向锁优先同一线程然后再次获取锁,如果失败,就升级为 CAS 轻量级锁,如果失败就会短暂自旋,防止线程被系统挂起。最后如果以上都失败就升级为重量级锁。

 

锁只能升级,不能降级

enter image description here

其他同步手段

ReentrantLock

 

重量级锁

ObjectMonitor源码的时候,会发现Atomic::cmpxchg_ptr,Atomic::inc_ptr等内核函数,对应的线程就是park()和upark()
enter image description here
流程

  1. 用户态把一些数据放到寄存器,或者创建对应的堆栈,表明需要操作系统提供的服务。

  2. 用户态执行系统调用(系统调用是操作系统的最小功能单位)。

  3. CPU切换到内核态,跳到对应的内存指定的位置执行指令。

  4. 系统调用处理器去读取我们先前放到内存的数据参数,执行程序的请求。

  5. 调用完成,操作系统重置CPU为用户态返回结果,并执行下个指令。

synchronized和Lock的区别

  1. synchronized是关键字,是JVM层面的底层啥都帮我们做了,而Lock是一个接口,是JDK层面的有丰富的API

  2. synchronized会自动释放锁,而Lock必须手动释放锁

  3. synchronized是不可中断的,Lock可以中断也可以不中断。

  4. 通过Lock可以知道线程有没有拿到锁,而synchronized不能

  5. synchronized能锁住方法和代码块,而Lock只能锁住代码块。

  6. Lock可以使用读锁提高多线程读效率。

  7. synchronized是非公平锁,ReentrantLock可以控制是否是公平锁。

其他相关的知识点

tryLock 和 lock 和 lockInterruptibly 的区别
  1. tryLock 能获得锁就返回 true,不能就立即返回 false

  2. tryLock(long timeout,TimeUnit unit),可以增加时间限制,如果超过该时间段还没获得锁,返回 false

  3. lock 能获得锁就返回 true,不能的话一直等待获得锁

  4. lock 和 lockInterruptibly,如果两个线程分别执行这两个方法,但此时中断这两个线程, lock 不会抛出异常,而 lockInterruptibly 会抛出异常。

 

CountDownLatch和CyclicBarrier的区别是什么?
  1. CountDownLatch是等待其他线程执行到某一个点的时候,再继续执行逻辑(子线程不会被阻塞,会继续执行),只能被使用一次。最常见的就是join形式,主线程等待子线程执行完任务,再用主线程去获取结果的方式(当然不一定)。
    内部是用计数器相减实现的(没错,又是AQS),AQS的state承担了计数器的作用,初始化的时候,使用CAS赋值,主线程调用await(),则被加入共享线程等待队列里面,子线程调用countDown的时候,使用自旋的方式,减1,直到为0,就触发唤醒。

  2. CyclicBarrier回环屏障,主要是等待一组线程到底同一个状态的时候,放闸。CyclicBarrier还可以传递一个Runnable对象,可以到放闸的时候,执行这个任务。CyclicBarrier是可循环的,当调用await的时候如果count变成0了则会重置状态,
    如何重置呢,CyclicBarrier新增了一个字段parties,用来保存初始值,当count变为0的时候,就重新赋值。还有一个不同点,CyclicBarrier不是基于AQS的,而是基于RentrantLock实现的。存放的等待队列是用了条件变量的方式。

什么是信号量Semaphore

信号量是一种固定资源的限制的一种并发工具包,基于AQS实现的,在构造的时候会设置一个值,代表着资源数量。信号量主要是用于多个共享资源的互斥使用和用于并发线程数的控制(druid的数据库连接数,就是用这个实现的),信号量也分公平和非公平的情况,基本方式和reentrantLock差不多,在请求资源调用task时,会用自旋的方式减1,如果成功,则获取成功了,如果失败,导致资源数变为了0,就会加入队列里面去等待。调用release的时候会加一,补充资源,并唤醒等待队列。

Semaphore 应用

  1. acquire()、 release() 可用于对象池,资源池的构建,比如静态全局对象池,数据库连接池;

  2. 可创建计数为1的Semaphore,作为互斥锁(二元信号量)

公平锁与非公平锁
  1. 公平锁指在分配锁前检查是否有线程在排队等待获取该锁,优先分配排队时间最长的线程,非公平直接尝试获取锁

  2. 公平锁需多维护一个锁线程队列,效率低;默认非公平


本作品系原创,采用《署名-非商业性使用-禁止演绎4.0 国际》许可协议.转载请说明出处
本文链接:https://www.upupor.com/u/lRBJwna 复制

无内容