Skip to content

Java锁

公平锁

多个线程按照申请锁的顺序去获得锁,线程会直接进入队列去排队,永远都是队列的第一位才能得到锁,即优先分配排队时间最长的线程。

优缺点

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

缺点:

  • 吞吐量会下降很多,队列里面除了第一个线程,其他的线程都会阻塞,cpu唤醒阻塞线程的开销会很大。
  • 公平锁需多维护一个锁线程队列,效率低

非公平锁

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

优缺点

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

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

ReentrantLock(默认非公平)

  • FairSync
  • NoFairSync

偏向锁

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

    tips:【同步操作】一般是机器耗费资源的,例如把其他线程的操作这个变量的结果同步到当前线程,这个步骤就是比较耗时的

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

自旋锁

Java没有自旋锁的API,因为自旋锁并不是一种锁,而是一种锁优化技术

互斥同步进入阻塞状态的开销都很大,应该尽量避免。在许多应用中,共享数据的锁定状态只会持续很短的一段时间。自旋锁的思想是让一个线程在请求一个共享数据的锁时执行忙循环(自旋)一段时间,如果在这段时间内能获得锁,就可以避免进入阻塞状态。

自旋锁虽然能避免进入阻塞状态从而减少开销,但是它需要进行忙循环操作占用 CPU 时间,它只适用于共享数据的锁定状态很短的场景。

在 JDK 1.6 中引入了自适应的自旋锁。自适应意味着自旋的次数不再固定了,而是由前一次在同一个锁上的自旋次数及锁的拥有者的状态来决定。

CAS 轻量级锁

轻量级锁是相对于重量级锁而言的。使用轻量级锁时,不需要申请互斥量,仅仅将Mark Word中的部分字节CAS更新指向线程栈中的Lock Record,如果更新成功,则轻量级锁获取成功,记录锁状态为轻量级锁;否则,说明已经有线程获得了轻量级锁,目前发生了锁竞争(不适合继续使用轻量级锁),接下来膨胀为重量级锁。

轻量级锁是一种乐观锁,它认为锁存在竞争的概率比较小,所以它不使用互斥同步,而是使用CAS操作来获得锁,这样能减少互斥同步所使用的『互斥量』带来的性能开销。

偏向锁和轻量级锁的区别

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

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

重量级锁

线程的挂起/唤醒需要CPU切换上下文,此过程代价比较大,因此称此种锁为重量级锁。

原理: 看ObjectMonitor源码的时候,会发现Atomic::cmpxchg_ptr,Atomic::inc_ptr等内核函数,对应的线程就是park()和upark()。在重量级锁中没有竞争到锁的对象会 park 被挂起,退出同步块时 unpark 唤醒后续线程。唤醒操作涉及到操作系统调度会有额外的开销。

流程

  1. 用户态把一些数据放到寄存器,或者创建对应的堆栈,表明需要操作系统提供的服务。
  2. 用户态执行系统调用(系统调用是操作系统的最小功能单位)。
  3. CPU切换到内核态,跳到对应的内存指定的位置执行指令。
  4. 系统调用处理器去读取我们先前放到内存的数据参数,执行程序的请求。
  5. 调用完成,操作系统重置CPU为用户态返回结果,并执行下个指令。

锁升级 也称之为锁膨胀机制💥

在 JDK 1.5 时,synchronized 需要调用监视器锁(Monitor)来实现,监视器锁本质上又是依赖于底层的操作系统的 Mutex Lock(互斥锁,所以重量级锁也称为互斥锁)实现的,互斥锁在进行释放和获取的时候,需要从用户态转换到内核态,这样就造成了很高的成本,也需要较长的执行时间,这种依赖于操作系统 Mutex Lock 实现的锁我们称之为“重量级锁”。

在 JDK 1.6 时,采用了所升级的方式来对synchronized进行优化。

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

锁只能升级,不能降级。

synchronized 锁升级的过程可以有效地减少锁竞争,提高多线程并发性

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

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

可重入锁概念

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

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

ReentrantLock和synchronized都是可重入锁

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

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

指该锁一次只能被一个线程所持有

ReentrantLock为独占锁(悲观加锁策略)

共享锁

指该锁可以被多个线程持有。比较典型的就是读锁,读操作并不会产生副作用,所以可以允许多个线程同时对数据进行读操作,而不会有线程安全问题。

ReadWriteLock中读锁为共享锁

读写锁的实现方式

读写锁定义: 一个资源可以被多个读线程访问,或者被一个写线程访问,但不能同时存在读写线程。(读读共享,读写互斥)

写的权力>读的权力

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

写锁可以降级为读锁,但是读锁无法变成写锁。【锁降级】是为了让当前线程感知到数据的变化,目的是保证数据可见性(写后立刻读,感知数据变化)

乐观锁

乐观锁在操作数据时非常乐观,认为别人不会同时修改数据。因此乐观锁不会上锁,只是在执行更新的时候判断一下在此期间别人是否修改了数据:如果别人修改了数据则放弃操作,否则执行操作

乐观锁的实现方式主要有两种:CAS机制和版本号机制

  • CAS(Compare And Swap 比较并且替换)是乐观锁的一种实现方式,是一种轻量级锁,JUC 中很多工具类的实现就是基于 CAS 的。
  • 版本号机制很好理解,每次读取时读出version的值,写时比较version的值和读出的值是否一致,如果一致则将 版本号+1 更新进去,如果不一致则不提交更新,因为已经是过期的数据了

    版本号也可以使用时间戳字段替代,原理是一样的

CAS是怎么实现线程安全的?

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

实际上是直接利用了 CPU 层面的指令,所以性能很高。即利用了 CPU 的 cmpxchg 指令完成比较并替换。这也是CAS(比较并交换)的思想,用于保证并发时的无锁并发的安全性

存在哪些问题?

  1. ABA问题

    业务中如何保证? 加标志位,例如搞个自增的字段,操作一次就自增加一,或者搞个时间戳,比较时间戳的值。

  2. CPU开销

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

    自己的解释: 一直有另一个线程在修改,导致cas老是即将要写入的和读取的不一致,然后就一直在读,判断,读,判断,就导致了长时间的不成功

  3. 只能保证一个共享变量原子操作

    CAS操作单个共享变量的时候可以保证原子的操作,多个变量就不行了。

举例: AtomicInteger

AtomicInteger举例,其自增函数incrementAndGet()就是这样实现的,里面就有大量循环判断的过程,直到符合条件才成功。

JDK 5之后 AtomicReference原子引用可以用来保证对象之间的原子性。如果你需要在多线程环境中原子性地操作多个对象或数据结构,你可能需要使用其他类,比如AtomicReferenceArray、AtomicReferenceFieldUpdater 等

// 类成员变量
AtomicReference<Moatkon> moatkonRef = new AtomicReference<>(new Moatkon()); // 使用AtomicReference来定义一个起点,方便后面多线程对这个起点进行操作
// 下面是线程操作
Moatkon moatkon = moatkonRef.get();// 使用 AtomicReference.get()获取
Moatkon newMoatkon = new Moatkon();// 每个线程的操作,至于操作什么这里不关心,例如:增加moatkon.com的pv
moatkonRef.compareAndSet(moatkon, newMoatkon); // 将每个线程操作好的结果进行CAS,进行非阻塞更新

悲观锁

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

JVM层面synchronized

synchronized实现原理

底层是由监视器Monitor实现的(monitorenter,monitorexit)

具体实现:

  • Contention List:所有请求锁的线程将被首先放置到该竞争队列。

    _cxq: 竞争队列所有请求锁的线程首先会被放在这个队列中(单向)。_cxq 是一个临界资源 JVM 通过 CAS 原子指令来修改_cxq 队列。每当有新来的节点入队,它的 next 指针总是指向之前队列的头节点,而_cxq指针会指向该新入队的节点,所以是后来居上,所以不公平。(synchronized 实现原理,取自小米信息部技术团队)

  • Entry List:候选者列表,Contention List中那些有资格成为候选人的线程被移到Entry List
  • Wait Set:等待集合,那些调用wait方法被阻塞的线程被放置到Wait Set
  • OnDeck:竞争候选者,任何时刻最多只能有一个线程正在竞争锁,该线程称为OnDeck
  • Owner:获得锁的线程称为Owner
  • !Owner:在Owner线程释放后,会从Owner的状态变为!Owner

具体描述:

synchronized在收到新的锁请求时首先自旋,如果通过自旋也没有获取锁资源,则将被放入锁竞争队列ContentionList中。

为了防止锁竞争时ContentionList尾部的元素被大量的并发线程进行CAS访问而影响性能,Owner线程会在释放锁资源时将ContentionList中的部分线程移动到EntryList中,并指定EntryList中的某个线程(一般是最先进入的线程)为OnDeck线程。

Owner线程并没有直接把锁传递给OnDeck线程,而是把锁竞争的权利交给OnDeck,让OnDeck线程重新竞争锁。在Java中把该行为称为“竞争切换”,该行为牺牲了公平性,但提高了性能。(最新进入的只能拥有竞争的权力,并不一定能100%获取到锁)

获取到锁资源的OnDeck线程会变为Owner线程,而未获取到锁资源的线程仍然停留在EntryList中。Owner线程在被wait()阻塞后,会被转移到WaitSet队列中,直到某个时刻被notify()或者notifyAll()唤醒,会再次进入EntryList中。

ContentionList、EntryList、WaitSet中的线程均为阻塞状态,该阻塞是由操作系统来完成的(在Linux内核下是采用pthread_mutex_lock内核函数实现的)。

Owner线程在执行完毕后会释放锁的资源并变为!Owner状态

Synchronized 是非公平锁

上面的解释很详细了,这里再浓缩下

容易理解的解释: Synchronized 是非公平锁,因为它在多个线程竞争同一把锁时,不保证先等待的线程先获得锁,而是通过操作系统的调度算法进行竞争,不考虑等待时间长短。如果当前持有锁的线程释放了锁,那么所有在等待这个锁的线程都会被唤醒,然后通过竞争获得锁。

相对于公平锁,非公平锁的优点在于可以更高效地利用系统资源,避免了线程切换的开销。但是,由于它的竞争策略不考虑等待时间长短,因此容易导致某些线程一直无法获得锁,从而出现“饥饿”现象,导致程序性能下降。

值得一提的是,在 Java 5 中,Synchronized 的实现发生了改变,引入了偏向锁和轻量级锁等机制,使得锁的竞争效率得到了很大提高。不过,Synchronized 仍然是一种非公平锁。如果需要使用公平锁,可以使用 ReentrantLock 的 fair 属性来设置。

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

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

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

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

同步方法和同步代码块底层实现——也是monitor监视器

同步方法和同步代码块底层都是通过monitor来实现同步的。两者的区别:

  1. synchronized 应用在同步方法上是通过方法中的access_flags中设置ACC_SYNCHRONIZED标志来实现。

    JVM就是根据该标识符来实现方法的同步的:当方法调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先获取monitor,获取成功之后才能执行方法体,方法执行完后再释放monitor。在方法执行期间,其他任何线程都无法再获得同一个monitor对象。 其实本质上没有区别,只是方法的同步是一种隐式的方式来实现,无需通过字节码来完成

  2. synchronized 应用在同步代码块上是通过monitorEnter和monitorExit来实现。

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

  1. 每个monitor维护着一个记录着拥有次数的计数器。未被拥有的monitor的该计数器为0,当一个线程获得monitor(执行monitorenter)后,该计数器自增变为1
    • 当同一个线程再次获得该monitor的时候,计数器再次自增;
    • 当不同线程想要获得该monitor的时候,就会被阻塞。
  2. 当同一个线程释放 monitor(执行monitorexit指令)的时候,计数器再自减。
  3. 当计数器为0的时候,monitor将被释放,其他线程便可以获得monitor。
是如何保证同一时刻只有一个线程可以进入临界区呢?

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

在对象级使用锁通常是一种比较粗糙的方法,为什么要将整个对象都上锁,而不允许其他线程短暂地使用对象中其他同步方法来访问共享资源?

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

synchronized和Lock的区别

  • synchronized是关键字,是JVM层面的底层啥都帮我们做了,而Lock是一个接口,是JDK层面的有丰富的API
  • synchronized会自动释放锁,而Lock必须手动释放锁
  • synchronized是不可中断的,Lock可以中断也可以不中断。
  • 通过Lock可以知道线程有没有拿到锁,而synchronized不能
  • synchronized能锁住方法和代码块,而Lock只能锁住代码块。
  • Lock可以使用读锁提高多线程读效率。
  • synchronized是非公平锁,ReentrantLock可以控制是否是公平锁。

java锁相关的面试题

tryLock 和 lock 和 lockInterruptibly 的区别

首先它们都是来获取锁的

  1. tryLock 能获得锁就返回 true,不能就立即返回 false(因为是尝试,不行立马就返回,哈哈)

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

  2. lock 能获得锁就返回 true,不能的话一直等待获得锁
  3. lock 与 lockInterruptibly比较区别在于: lock 方法如果获取不到锁会一直阻塞等待;而 lockInterruptibly 方法虽然也会阻塞等待获取锁,但它却能中途响应线程的中断

CountDownLatch和CyclicBarrier的区别是什么?

  1. CountDownLatch是等待其他线程执行到某一个点的时候,再继续执行逻辑(子线程不会被阻塞,会继续执行),只能被使用一次。最常见的就是join形式,主线程等待子线程执行完任务,再用主线程去获取结果的方式。

    内部是用计数器相减实现的(没错,又是AQS,AbstractQueuedSynchronizer),AQS的state承担了计数器的作用,初始化的时候,使用CAS赋值,主线程调用await(),则被加入共享线程等待队列里面,子线程调用countDown的时候,使用自旋的方式,减1,直到为0,就触发唤醒。

    比如要处理一个非常耗时的任务,处理完之后需要更新这个任务的状态,需要开多线程去分批次处理任务中的各个子任务,当所有的子任务全部执行完毕之后,就可以更新任务状态了。这个时候就需要使用CountDownLatch

  2. CyclicBarrier回环屏障,主要是等待一组线程到达同一个状态的时候,放闸。CyclicBarrier还可以传递一个Runnable对象,可以到放闸的时候,执行这个任务。CyclicBarrier是可循环的,调用reset方法可以重置到初始状态。

    比如一个抽奖活动,每个线程进行抽奖,当奖品全部抽完之后对各个线程中的用户进行后续操作。

CountDownLatch底层使用的是共享锁(它有个内部类Sync,这个Sync继承AQS,实现了共享锁),CyclicBarrier底层使用的是ReentrantLock和这个lock的条件对象Condition

什么是信号量Semaphore

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

Semaphore 应用:

  • acquire()、 release() 可用于对象池,资源池的构建,比如静态全局对象池,数据库连接池;
  • 可创建计数为1的Semaphore,作为互斥锁(二元信号量)

死锁

多个线程之间互相持有,就会造成死锁。

  • 互斥:在一段时间内某资源仅被一个线程所占有。此时若有其他线程请求该资源,则请求线程只能等待。
  • 不可剥夺:线程所获得的资源在未使用完毕之前,不能被其他线程强行夺走,即只能由获得该资源的线程自己来释放(只能是主动释放)。
  • 请求与保持:线程已经保持了至少一个资源,但又提出了新的资源请求,而该资源已被其他线程占有,此时请求线程被阻塞,但对自己已获得的资源保持不放。
  • 循环等待:在发生死锁时必然存在一个进程等待队列 {P1,P2,…,Pn},其中 P1 等待 P2 占有的资源,P2 等待 P3 占有的资源,…,Pn 等待 P1 占有的资源,形成一个进程等待环路,环路中每一个进程所占有的资源同时被另一个申请,也就是前一个进程占有后一个进程所申请的资源。

只要系统发生死锁,这些条件必然成立,而只要上述条件之一不满足,就不会发生死锁。

如果解决死锁

  1. 按照顺序加锁:尝试让所有线程按照同一顺序获取锁,从而避免死锁。
  2. 设置获取锁的超时时间:尝试获取锁的线程在规定时间内没有获取到锁,就放弃获取锁,避免因为长时间等待锁而引起的死锁。

如何排查死锁

jstack:可以查看 Java 应用程序的线程状态和调用堆栈,可用于发现死锁线程的状态。

网站当前构建日期: 2024.06.25