带你整理面试过程中关于锁的相关知识点上(synchronizedReentrantLock)

Posted 南淮北安

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了带你整理面试过程中关于锁的相关知识点上(synchronizedReentrantLock)相关的知识,希望对你有一定的参考价值。

文章目录

一、Java中的锁

Java中的锁主要用于保障多并发线程情况下数据的一致性
在多线程编程中为了保障数据的一致性,我们通常需要在使用对象或者方法之前加锁,这时如果有其他线程也需要使用该对象或者该方法,则首先要获得锁,如果某个线程发现锁正在被其他线程使用,就会进入阻塞队列等待锁的释放,直到其他线程执行完成并释放锁,该线程才有机会再次获取锁进行操作。这样就保障了在同一时刻只有一个线程持有该对象的锁并修改对象,从而保障数据的安全。

锁从乐观和悲观的角度可分为乐观锁和悲观锁,从获取资源的公平性角度可分为公平锁和非公平锁,从是否共享资源的角度可分为共享锁和独占锁,从锁的状态的角度可分为偏向锁、轻量级锁和重量级锁。同时,在JVM中还巧妙设计了自旋锁以更快地使用CPU资源。下面将详细介绍这些锁。

二、乐观锁

乐观锁采用乐观的思想处理数据,在每次读取数据时都认为别人不会修改该数据,所以不会上锁,但在更新时会判断在此期间别人有没有更新该数据,通常采用在写时先读出当前版本号然后加锁的方法
具体过程为:比较当前版本号与上一次的版本号,如果版本号一致,则更新,如果版本号不一致,则重复进行读、比较、写操作。

Java中的乐观锁大部分是通过CAS(Compare And Swap,比较和交换)操作实现的,CAS是一种原子更新操作,在对数据操作之前首先会比较当前值跟传入的值是否一样,如果一样则更新,否则不执行更新操作,直接返回失败状态。

深入学习:原子性、可见性、有序性

三、悲观锁

悲观锁采用悲观思想处理数据,在每次读取数据时都认为别人会修改数据,所以每次在读写数据时都会上锁,这样别人想读写这个数据时就会阻塞、等待直到拿到锁

Java中的悲观锁大部分基于AQS(Abstract QueuedSynchronized,抽象的队列同步器)架构实现。AQS定义了一套多线程访问共享资源的同步框架,许多同步类的实现都依赖于它,例如常用的Synchronized、ReentrantLock、Semaphore、CountDownLatch等。该框架下的锁会先尝试以CAS乐观锁去获取锁,如果获取不到,则会转为悲观锁(如RetreenLock)。

四、自旋锁

自旋锁的详细内容可参考:自旋锁深入学习

自旋锁认为:如果持有锁的线程能在很短的时间内释放锁资源,那么那些等待竞争锁的线程就不需要做内核态和用户态之间的切换进入阻塞、挂起状态,只需等一等(也叫作自旋),在等待持有锁的线程释放锁后即可立即获取锁,这样就避免了用户线程在内核状态的切换上导致的锁时间消耗

线程在自旋时会占用CPU,在线程长时间自旋获取不到锁时,将会产CPU的浪费,甚至有时线程永远无法获取锁而导致CPU资源被永久占用,所以需要设定一个自旋等待的最大时间。在线程执行的时间超过自旋等待的最大时间后,线程会退出自旋模式并释放其持有的锁。

(1)自旋锁的优缺点

  • 优点:自旋锁可以减少CPU上下文的切换,对于占用锁的时间非常短或锁竞争不激烈的代码块来说性能大幅度提升,因为自旋的CPU耗时明显少于线程阻塞、挂起、再唤醒时两次CPU上下文切换所用的时间。
  • 缺点:在持有锁的线程占用锁时间过长或锁的竞争过于激烈时,线程在自旋过程中会长时间获取不到锁资源,将引起CPU的浪费。所以在系统中有复杂锁依赖的情况下不适合采用自旋锁。

(2)自旋锁的时间阈值
自旋锁用于让当前线程占着CPU的资源不释放,等到下次自旋获取锁资源后立即执行相关操作。但是如何选择自旋的执行时间呢?如果自旋的执行时间太长,则会有大量的线程处于自旋状态且占用CPU资源,造成系统资源浪费。因此,对自旋的周期选择将直接影响到系统的性能!
JDK的不同版本所采用的自旋周期不同,JDK 1.5为固定DE时间,JDK1.6引入了适应性自旋锁。适应性自旋锁的自旋时间不再是固定值,而是由上一次在同一个锁上的自旋时间及锁的拥有者的状态来决定的,可基本认为一个线程上下文切换的时间是就一个最佳时间。

五、synchronized

synchronized 详细内容可参考学习:一篇文章带你深入剖析 synchronized 的底层原理

synchronized关键字用于为Java对象、方法、代码块提供线程安全的操作。synchronized属于独占式的悲观锁,同时属于可重入锁
在使用synchronized修饰对象时,同一时刻只能有一个线程对该对象进行访问;在synchronized修饰方法、代码块时,同一时刻只能有一个线程执行该方法体或代码块,其他线程只有等待当前线程执行完毕并释放锁资源后才能访问该对象或执行同步代码块。

Java中的每个对象都有个monitor对象,加锁就是在竞争monitor对象。对代码块加锁是通过在前后分别加上monitorenter和monitorexit指令实现的,对方法是否加锁是通过一个标记位ACC_SYNCHRONIZED来判断的。

1. synchronized 的作用范围

(1)synchronized作用于成员变量和非静态方法时,锁住的是对象的实例,即this对象。

(2)synchronized作用于静态方法时,锁住的是Class实例,因为静态方法属于Class而不属于对象

(3)synchronized作用于一个代码块时,锁住的是所有代码块中配置的对象。

2. synchronized的实现原理

在synchronized内部包括ContentionList、EntryList、WaitSet、OnDeck、Owner、! Owner这6个区域,每个区域的数据都代表锁的不同状态。

  • ContentionList:锁竞争队列,所有请求锁的线程都被放在竞争队列中
  • EntryList:竞争候选列表,在Contention List中有资格成为候选者来竞争锁资源的线程被移动到了Entry List中。
  • WaitSet:等待集合,调用wait方法后被阻塞的线程将被放在WaitSet中。
  • OnDeck:竞争候选者,在同一时刻最多只有一个线程在竞争锁资源,该线程的状态被称为OnDeck。
  • Owner:竞争到锁资源的线程被称为Owner状态线程。
  • !Owner:在Owner线程释放锁后,会从Owner的状态变成!Owner。

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

为了防止锁竞争时ContentionList尾部的元素被大量的并发线程进行CAS访问而影响性能,Owner线程会在释放锁资源时将ContentionList中的部分线程移动到EntryList中,并指定EntryList中的某个线程(一般是最先进入的线程)为OnDeck线程。Owner线程并没有直接把锁传递给OnDeck线程,而是把锁竞争的权利交给OnDeck,让OnDeck线程重新竞争锁。在Java中把该行为称为“竞争切换”,该行为牺牲了公平性,但提高了性能。

获取到锁资源的OnDeck线程会变为Owner线程,而未获取到锁资源的线程仍然停留在EntryList中。

Owner线程在被wait方法阻塞后,会被转移到WaitSet队列中,直到某个时刻被notify方法或者notifyAll方法唤醒,会再次进入EntryList中。ContentionList、EntryList、WaitSet中的线程均为阻塞状态,该阻塞是由操作系统来完成的(在Linux内核下是采用pthread_mutex_lock内核函数实现的)。

Owner线程在执行完毕后会释放锁的资源并变为!Owner状态,如图3-6所示。


在synchronized中,在线程进入ContentionList之前,等待的线程会先尝试以自旋的方式获取锁,如果获取不到就进入ContentionList,该做法对于已经进入队列的线程是不公平的,因此synchronized是非公平锁。另外,自旋获取锁的线程也可以直接抢占OnDeck线程的锁资源。

synchronized是一个重量级操作,需要调用操作系统的相关接口,性能较低,给线程加锁的时间有可能超过获取锁后具体逻辑代码的操作时间

JDK 1.6对synchronized做了很多优化,引入了适应自旋、锁消除、锁粗化、轻量级锁及偏向锁等以提高锁的效率。锁可以从偏向锁升级到轻量级锁,再升级到重量级锁。这种升级过程叫作锁膨胀。在JDK1.6中默认开启了偏向锁和轻量级锁,可以通过-XX:UseBiasedLocking禁用偏向锁。

3. 为什么添加synhronized 能保证变量的可见性?

  1. 线程解锁前,必须把共享变量的最新值刷新到主内存中。
  2. 线程加锁前,将清空工作内存中共享变量的值,从而使用共享变量时需要从主内存中重新读取最新的值。
  3. volatile 的可见性都是通过内存屏障( Memnory Barrier )来实现的。
  4. synchronized 靠操作系统内核互斥锁实现,相当于 JMM 中的 lock 、 unlock 。退出代码块时刷新变量到主内存。

4. synchronized 有序性

保证不管编译器和处理器为了性能优化会如何进行指令重排序,都需要保证单线程下的运行结果的正确性

也就是常说的:如果在本线程内观察,所有的操作都是有序的;如果在一个线程观察另一个线程,所有的操作都是无序的。

注意这里的有序性和 volatile 的防止指令重排序不一样。

六、ReentrantLock

详细内容学习可参考:一篇文章带你深入了解关键字 synchronized 的功能扩展:重入锁

ReentrantLock继承了Lock接口并实现了在接口中定义的方法,是一个可重入的独占锁。ReentrantLock通过自定义队列同步器(Abstract Queued Sychronized, AQS)来实现锁的获取与释放。

独占锁指该锁在同一时刻只能被一个线程获取,而获取锁的其他线程只能在同步队列中等待;可重入锁指该锁能够支持一个线程对同一个资源执行多次加锁操作

ReentrantLock支持公平锁和非公平锁的实现。公平指线程竞争锁的机制是公平的,而非公平指不同的线程获取锁的机制是不公平的

ReentrantLock不但提供了synchronized对锁的操作功能,还提供了诸如可响应中断锁、可轮询锁请求、定时锁等避免多线程死锁的方法

1. ReentrantLock的用法

ReentrantLock有显式的操作过程,何时加锁、何时释放锁都在程序员的控制之下。具体的使用流程是定义一个ReentrantLock,在需要加锁的地方通过lock方法加锁,等资源使用完成后再通过unlock方法释放锁。具体的实现代码如下:

ReentrantLock之所以被称为可重入锁,是因为ReentrantLock锁可以反复进入。即允许连续两次获得同一把锁,两次释放同一把锁。将上述代码中的注释部分去掉后,程序仍然可以正常执行。注意,获取锁和释放锁的次数要相同,如果释放锁的次数多于获取锁的次数,Java就会抛出java.lang.IllegalMonitorStateException异常;如果释放锁的次数少于获取锁的次数,该线程就会一直持有该锁,其他线程将无法获取锁资源。

2. ReentrantLock如何避免死锁:响应中断、可轮询锁、定时锁

(1)响应中断:在synchronized中如果有一个线程尝试获取一把锁,则其结果是要么获取锁继续执行,要么保持等待。ReentrantLock还提供了可响应中断的可能,即在等待锁的过程中,线程可以根据需要取消对锁的请求。具体的实现代码参考详细学习里的代码。

(2)可轮询锁:通过boolean tryLock()获取锁。如果有可用锁,则获取该锁并返回true,如果无可用锁,则立即返回false。

(3)定时锁:通过boolean tryLock(long time, TimeUnit unit)throws InterruptedException获取定时锁。如果在给定的时间内获取到了可用锁,且当前线程未被中断,则获取该锁并返回true。如果在给定的时间内获取不到可用锁,将禁用当前线程,并且在发生以下三种情况之前,该线程一直处于休眠状态

  • 当前线程获取到了可用锁并返回true
  • 当前线程在进入此方法时设置了该线程的中断状态,或者当前线程在获取锁时被中断,则将抛出InterruptedException,并清除当前线程的已中断状态。
  • 当前线程获取锁的时间超过了指定的等待时间,则将返回false。如果设定的时间小于等于0,则该方法将完全不等待。

3. Lock接口的主要方法

void lock():给对象加锁,如果锁未被其他线程使用,则当前线程将获取该锁;如果锁正在被其他线程持有,则将禁用当前线程,直到当前线程获取锁。

boolean tryLock():试图给对象加锁,如果锁未被其他线程使用,则将获取该锁并返回true,否则返回false。tryLock()和lock()的区别在于tryLock()只是“试图”获取锁,如果没有可用锁,就会立即返回。lock()在锁不可用时会一直等待,直到获取到可用锁。

tryLock(long timeout TimeUnit unit):创建定时锁,如果在给定的等待时间内有可用锁,则获取该锁。

void unlock():释放当前线程所持有的锁。锁只能由持有者释放,如果当前线程并不持有该锁却执行该方法,则抛出异常。

Condition newCondition():创建条件对象,获取等待通知组件。该组件和当前锁绑定,当前线程只有获取了锁才能调用该组件的await(),在调用后当前线程将释放锁。

getHoldCount():查询当前线程保持此锁的次数,也就是此线程执行lock方法的次数。

getQueueLength():返回等待获取此锁的线程估计数,比如启动5个线程,1个线程获得锁,此时返回4。

getWaitQueueLength(Condition condition):返回在Condition条件下等待该锁的线程数量。比如有5个线程用同一个condition对象,并且这5个线程都执行了condition对象的await方法,那么执行此方法将返回5。

hasWaiters(Condition condition):查询是否有线程正在等待与给定条件有关的锁,即对于指定的contidion对象,有多少线程执行了condition.await方法。

hasQueuedThread(Thread thread):查询给定的线程是否等待获取该锁。

hasQueuedThreads():查询是否有线程等待该锁。

isFair():查询该锁是否为公平锁。

isHeldByCurrentThread():查询当前线程是否持有该锁,线程执行lock方法的前后状态分别是false和true。

isLock():判断此锁是否被线程占用。

lockInterruptibly():如果当前线程未被中断,则获取该锁。

4. 公平锁与非公平锁

ReentrantLock支持公平锁和非公平锁两种方式。公平锁指锁的分配和竞争机制是公平的,即遵循先到先得原则。非公平锁指JVM遵循随机、就近原则分配锁的机制

ReentrantLock通过在构造函数ReentrantLock(boolean fair)中传递不同的参数来定义不同类型的锁,默认的实现是非公平锁。这是因为,非公平锁虽然放弃了锁的公平性,但是执行效率明显高于公平锁。如果系统没有特殊的要求,一般情况下建议使用非公平锁。

5. tryLock、lock和lockInterruptibly的区别

tryLock若有可用锁,则获取该锁并返回true,否则返回false,不会有延迟或等待;tryLock(long timeout, TimeUnit unit)可以增加时间限制,如果超过了指定的时间还没获得锁,则返回false

lock若有可用锁,则获取该锁并返回true,否则会一直等待直到获取可用锁。

在锁中断时lockInterruptibly会抛出异常,lock不会

6. synchronized和ReentrantLock的比较

共同点:

(1)都用于控制多线程对共享对象的访问。
(2)都是可重入锁。
(3)都保证了可见性和互斥性。

不同点:

(1)ReentrantLock显式获取和释放锁;synchronized隐式获取和释放锁。为了避免程序出现异常而无法正常释放锁,在使用ReentrantLock时必须在finally控制块中进行解锁操作。
(2) ReentrantLock可响应中断、可轮回,为处理锁提供了更多的灵活性。
(3)ReentrantLock是API级别的,synchronized是JVM级别的
(4)二者的底层实现不一样:synchronized是同步阻塞,采用的是悲观并发策略;ReentrantLock是同步非阻塞,采用的是乐观并发策略。
(5)ReentrantLock是一个接口,而synchronized是Java中的关键字,synchronized是由内置的语言实现的。
(6)我们通过ReentrantLock可以知道有没有成功获取锁,通过synchronized却无法做到。
(7)ReentrantLock可以通过分别定义读写锁提高多个线程读操作的效率。

以上是关于带你整理面试过程中关于锁的相关知识点上(synchronizedReentrantLock)的主要内容,如果未能解决你的问题,请参考以下文章

带你整理面试过程中关于锁升级的过程

带你整理面试过程中关于数据库范式,事务,并发策略和锁的相关知识点

带你整理面试过程中关于一致性Hash算法的相关知识点

带你整理面试过程中关于 SQL优化的相关知识

带你整理面试过程中关于ARP 协议的相关知识点

带你整理面试过程中关于多线程中的线程池的相关知识点