Java中的锁详解篇

什么是锁

在Java中的锁主要是用于保障线程在多并发的情况下数据的一致性。就是实现并发的原子性。

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

在Java当中有很多锁的名次,这些并不时全指锁,有的指所得特性锁的设计锁的状态

锁从乐观和悲观的角度可以分为乐观锁和悲观锁。

从获取资源的公平性角度可以分为公平锁和非公平锁。

从是否共享资源的角度可以分为共享锁和独占锁。

从锁的状况角度可以分为偏向锁、轻量级锁和重量级锁。

同时,在JVM当中还巧妙的设计了自旋锁以更快地使用CPU资源。

乐观锁和悲观锁

乐观锁与悲观锁不是指具体的什么类型的锁,而是指看待并发同步的角度。

乐观锁

乐观锁采用的是乐观的思想来处理数据,在每次读取数据的时候都会认为别人是不会修改数据的,所以是不会加锁的,但在更新的时候会判断在此期间别有没有更新该数据,通常采用在写时先读出当前版本号然后加锁的方法。

具体的过程:比较当前版本号与上一次的版本号,如果版本号一样的话,就更新。如果版本号不一样的话,就重复进行读、比较、写操作。

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

悲观锁

悲观锁采用的是悲观的思想来处理数据的,认为同一数据的并发操作,一定是会发生修改的,哪怕没有修改,也会认为是修改的。所以在每次读写数据的时候都会加锁,这样别人想读写这个数据时就会阻塞,等待直到获取锁。

在Java当悲观锁大部分会基于AQS(Abstract Queued Synchronized,抽象的队列同步器)架构实现,AQS定义了一套多线程访问共享资源的同步框架,许多同步类的实现都依赖于它。

例如:常用的Synchronized、ReentranlLock、Semaphore、CountDownLatch等。该框架下的锁会先尝试以CAS乐观锁去获取锁,如果获得不到的话,就会转换为悲观锁。


从上面的描述可以看出,悲观锁是适合写操作非常多的场景,乐观锁适合读操作非常多的场景,不加锁会带来大量的性能提升。

悲观锁在Java当中使用的时候,就是利用各种锁。

乐观锁在Java当中使用的时候,就是无锁编程,使用CAS算法,最典型的就是原子类,通过CAS自旋来实现原子操作的更新。

自旋锁

自旋锁认为:如果持有锁的线程能在很短的时间内释放锁资源,那么那些等待竞争锁的线程就不需要做内核态和用于态之间的切换进行阻塞、挂起状态,只需等一等(也叫做自旋),在等待持有锁的线程释放锁后即可立即获取锁,这样就避免了用户线程在用户态和内核态之间的频繁切换而导致的时间消耗。
说白了就是让线程自己不断的重试,当线程强索失败后,重试几次,要是抢到了锁就继续,要是抢不到就阻塞线程。

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

优点:
自旋锁可以减少CPU上下文的切换,对于占用锁的时间非常短或锁竞争不激烈的代码块来说性能是大幅度的提升,因为自旋的CPU耗时明显是少于线程阻塞、挂起、再唤醒时两次CPU上下文切换所用的时间。

缺点:
在持有锁的线程占用锁时间过长或者锁的竞争过于激烈的时候,线程在自旋的过程当中或长时间获取不到资源,将引起CPU的浪费。所以在系统中有复杂的锁依赖的情况下是不适合采用自旋锁的。

自旋锁的时间阈值:
自旋锁用于使当前线程占着CPU的资源不释放,等到下次自旋获取锁资源后立即执行相关操作。

但是如何选择自选的执行时间呢?

如果自旋的执行时间过长,就会大量的线程处于自选的状态而占有CPU资源,从而造成资源的浪费。因此,对自旋的周期选择将直接影响到系统的性能。

JDK的不同版本锁采用的自旋周期是不同的,在JDK 1.5之前是为固定的世界。而在JDK 1.6之后引入了适应性自旋锁。
适应性自旋锁的自旋时间不再是固定值,而是由上一次在同一个锁上的自旋时间以及所的拥有者的状态所决定的,可基本上认为一个线程上下文切换的时间就是一个锁自旋的最佳时间。

可重入锁

可重入锁也叫做递归锁,指在同一线程中,在外层函数获取到该锁之后,内层的递归函数任然可以继续获取该锁。

在Java环境中,ReentrantLock和synchronized都是可重入锁。

可重入锁的一个好处就是在一定程度上避免了死锁。

public class Demo{
    
    
	synchronized void setA() throws Exception{
    
    
		System.out.println("方法A");
		setB;
	}
	synchronized void setB() throws Exception{
    
    
		System.out.println("方法B");
	}
}

上面的代码,若在synchronized关键字所表示的可重入锁的性质的话,那么setB就不会被当前线程执行,造成死锁。

公平锁和非公平锁

这两锁是按照资源的公平性来区分的

公平锁(Fair Lock)是指在分配锁前检查是否有线程在排队等待获取该锁,优先将锁分配给排队时间最长的线程。

非公平锁(Nonfair Lock)是指在分配锁时不考虑线程排队等待的情况,直接尝试获取锁,在获取不到锁时再排到队尾等待。

因为公平锁需要在多线程的情况下维护一个锁线程等待队列,基于该队列进行锁的分配,因此效率是比非公平锁低很多的。

在Java当中synchronized 是非公平锁。
ReentrantLock默认的lock方法采用的是非公平锁。但在底层可以通过AQS来实现线程调度,所以可以使其变成公平锁。

读写锁

在Java当中是可以通过Lock接口及对象可以方便地对对象加锁和释放锁,但是这种锁不区分读写,叫做普通锁。
所以为了提高性能,Java提供了读写锁。

读写锁分为读锁和写锁这两种
多个读锁是不互斥的,读锁与写锁之间是互斥的。

在读的地方使用读锁,在写的地方使用写锁,在没有写锁的情况下,读时无阻塞的。

如果系统要求共享数据可以同时支持很多线程并发读,但不支持很多线程并发写,那么使用读锁能很大程度地提高效率
如果系统要求共享数据在同一时刻只能有一个线程在写,且在写的过程中不能读取该共享数据,则需要使用写锁。

特点:

  1. 多个读取可以同时进行读取
  2. 写数据必须互斥(只允许一个时刻只能有一个去写,在这个时刻也不能读)
  3. 写操作优先于读操作(一旦有了写操作,那么后续的读操作必须等待,唤醒时要优先考虑写操作)

在这里插入图片描述

共享锁和独占锁

在Java并发包当中也是提供出了独占锁和共享锁

独占锁:
也叫互斥锁,每次只允许一个线程持有该锁,ReentrantLock为独占的实现。

共享锁:
允许多个线程同时获取该锁,并发访问共享资源。ReentrantReadWriteLock中的读锁为共享锁的实现。

ReentrantReadWriteLock的加锁和解锁操作最终都调用内部类Sync来提供的方法。
Sync对象通过继承AQS(Abstract Queued Synchronizer)来实现。AQS的内部类Node定义了两个常量SHARED 和 EXCLUSIVE ,分别标识AQS 队列中等待线程的锁获取模式。

独占锁就是一种悲观的加锁策略,在同一时刻只允许一个读线程读取锁资源,限制了读操作的并发性;因为并发读线程并不会影响数据的一致性,因此共享锁采用了乐观的加锁策略,允许多个执行读操作的线程同时访问共享资源。

偏向锁、重量级锁和轻量级锁

无锁状态、 偏向锁状态、 轻量级锁状态、 重量级锁状态

锁的状态是通过对象监视器在对象头中的字段来表明的。
四种状态会随着竞争的情况而逐渐升级。但在Java当中锁只能单向升级,不会降级。
这四种状态都是Java语言中的锁,而是JVM为了提高锁的获取与释放效率而做的优化(使用synchronized时)

重量级锁

重量级锁是基于操作系统的互斥锁(Mutex Lock)而实现的锁,会导致进程在用户态和内核态之间切换,相对开销较大。

synchronized在内部是基于监视器的锁(Monitor)来实现的,监视器锁基于底层的操作系统Mutex Lock来实现的,因此synchronized属于重量级锁。
重量级锁是需要在用户态和核心态之间转换的,所以synchronized的运行效率并不高。

轻量级锁

JDK在1.6之后的版本里面,为了减少获取锁和释放锁带来的性能消耗及提高性能,引入了轻量级锁和偏向锁。

轻量级锁是相对于重量级锁而言的。轻量级锁的核心设计是在没有多线程竞争的前提下,减少重量级锁的使用以提高系统性能。
轻量级锁使用与线程交替执行同步代码块的情况(即互斥操作),如果在同一时刻有多个线程访问同一个锁,则将会导致轻量级锁膨胀为重量级锁。

偏向锁

除了多线程之间存在竞争获取锁的情况,还会经常出现同一个锁被同一个线程多次获取的情况。
偏向锁用于在某个线程获取某个锁之后,消除这个线程锁重入的开销,看起来似乎是这个线程得到了该锁的偏向(偏袒)

偏向锁的主要目的是在同一个线程多次获取某个锁的情况下尽量减少轻量级锁的执行路劲,因为轻量级锁的获取及释放需要多次CAS(Compare and Swap)原子操作,而偏向锁只需要在切换ThreadID时执行一次CAS原子操作,因此可以提高锁的运行效率。

在出现多线程竞争锁的情况时,JVM会自动撤销偏向锁,因此偏向锁的撤销操作的耗时必须少于节省下来的CAS原子操作的耗时。

三种锁的区别

偏向锁就是指在一段同步代码一直被一个线程所访问,那么该线程会自动获取锁,降级获取锁的代价。

轻量级锁就是指当锁是偏向锁的时候,此时又有一个线程被访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会发生阻塞,性能得到提高。

重量级锁就是指当锁为轻量级锁的时候,另一个线程虽然自旋,但自旋不会一直持续下去,当自旋一定次数的时候,还没有获取到锁的时候,就会进入阻塞,当前锁就膨胀为重量级锁。

轻量级锁通过自旋来实现
重量级锁通过操作系统来调度

轻量级锁就是用于提高多个线程交替执行同步块时的性能,偏向锁则在某个线程交替执行同步块时进一步提高性能。

分段锁

分段锁并非一种实际的锁,而是一种锁的设计思想,用于将数据分段并在每个分段上单独加锁,把锁进一步细粒度话,以提高并发效率。

在JDK 1.7及之前的版本里ConcurrentHashMap在内部就是使用分段锁来实现的。

同步锁和死锁

这个就是两个名词。
在有多个线程同时被阻塞的时候,它们之间相互等待对方释放锁资源,就会出现死锁。为了避免出现死锁。可以为锁操作添加超时时间,在线程持有所超时的时候会自动释放该锁。

对锁进行优化的几种方法

  1. 减少锁持有的时间
    较少锁持有的时间指只在有线程安全要求的程序上加锁来尽量减少同步代码块对锁的持有时间。

  2. 减少锁粒度
    较少锁粒度指将单个耗时较多的锁操作拆分为多个耗时较少的锁操作来增加锁的并发读,减少同一个锁上的竞争。在减少竞争后,偏向锁、轻量级锁的使用率才会提高。减少锁粒度最经典的案例就是 JDK 1.7及之前版本的ConcurrentHashMap 的分段锁。

  3. 锁分离
    锁分离就是指根据不同的应用场景将锁的功能进行分离,以应对不同的变化,最常用的锁分离思想就是读写锁(ReadWriteLock),它根据锁的功能将锁分离为读锁和写锁,这样读读不互斥,读写互斥,写写互斥,即保证了线程的安全性,又提高了性能。
    操作分离思想可以进一步延伸为只要操作互不影响,进可以进一个拆分,比如LinkedBlockingQueue 从头部获取数据,并从尾部加入数据。

  4. 锁粗化
    锁粗化是指为了保障性能,会要求尽可能将锁的操作细化以减少线程持有锁的时间,但是如果锁分得太细的话,将会导致系统频繁获取锁和释放锁,反而影响性能的提升。
    在这种情况下,建议将关联性强的锁操作集中起来处理,以提高系统整体的效率。

  5. 锁消除
    在开发中也会经常出现在不需要使用锁的情况下误用了锁操作而引起性能的下降,这多数是因为程序编码不规范锁造成的。
    这时,我们就需要检查并消除这些不必要的锁来提高系统的性能。


下一篇: ===》 乐观锁中提到的CAS特性

下一篇: ===》synchronized 和 ReentrantLock 实现并发中的锁

上一篇:===》详说Java内存模型(JMM)

猜你喜欢

转载自blog.csdn.net/weixin_45970271/article/details/125173691