🏆今日学习目标:
🍀java中的锁
✅创作者:林在闪闪发光
⏰预计时间:30分钟
🎉个人主页:林在闪闪发光的个人主页
🍁林在闪闪发光的个人社区,欢迎你的加入: 林在闪闪发光的社区
目录
1 序言
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提供了读写锁。
读写锁分为读锁和写锁这两种
多个读锁是不互斥的,读锁与写锁之间是互斥的。
在读的地方使用读锁,在写的地方使用写锁,在没有写锁的情况下,读时无阻塞的。
如果系统要求共享数据可以同时支持很多线程并发读,但不支持很多线程并发写,那么使用读锁能很大程度地提高效率
如果系统要求共享数据在同一时刻只能有一个线程在写,且在写的过程中不能读取该共享数据,则需要使用写锁。
特点:
多个读取可以同时进行读取
写数据必须互斥(只允许一个时刻只能有一个去写,在这个时刻也不能读)
写操作优先于读操作(一旦有了写操作,那么后续的读操作必须等待,唤醒时要优先考虑写操作)
共享锁和独占锁
在Java并发包当中也是提供出了独占锁和共享锁
独占锁:
也叫互斥锁,每次只允许一个线程持有该锁,ReentrantLock为独占的实现。
共享锁:
允许多个线程同时获取该锁,并发访问共享资源。ReentrantReadWriteLock中的读锁为共享锁的实现。
ReentrantReadWriteLock的加锁和解锁操作最终都调用内部类Sync来提供的方法。
Sync对象通过继承AQS(Abstract Queued Synchronizer)来实现。AQS的内部类Node定义了两个常量SHARED 和 EXCLUSIVE ,分别标识AQS 队列中等待线程的锁获取模式。
独占锁就是一种悲观的加锁策略,在同一时刻只允许一个读线程读取锁资源,限制了读操作的并发性;因为并发读线程并不会影响数据的一致性,因此共享锁采用了乐观的加锁策略,允许多个执行读操作的线程同时访问共享资源。
偏向锁、重量级锁和轻量级锁
无锁状态、 偏向锁状态、 轻量级锁状态、 重量级锁状态
无锁
无锁没有对资源进行锁定,所有的线程都能访问并修改同一个资源,但同时只有一个线程能修改成功。
无锁的特点就是修改操作在循环内进行,线程会不断的尝试修改共享资源。如果没有冲突就修改成功并退出,否则就会继续循环尝试。如果有多个线程修改同一个值,必定会有一个线程能修改成功,而其他修改失败的线程会不断重试直到修改成功。上面我们介绍的CAS原理及应用即是无锁的实现。无锁无法全面代替有锁,但无锁在某些场合下的性能是非常高的。
偏向锁
偏向锁是指一段同步代码一直被一个线程所访问,那么该线程会自动获取锁,降低获取锁的代价。
在大多数情况下,锁总是由同一线程多次获得,不存在多线程竞争,所以出现了偏向锁。其目标就是在只有一个线程执行同步代码块时能够提高性能。
当一个线程访问同步代码块并获取锁时,会在Mark Word里存储锁偏向的线程ID。在线程进入和退出同步块时不再通过CAS操作来加锁和解锁,而是检测Mark Word里是否存储着指向当前线程的偏向锁。引入偏向锁是为了在无多线程竞争的情况下尽量减少不必要的轻量级锁执行路径,因为轻量级锁的获取及释放依赖多次CAS原子指令,而偏向锁只需要在置换ThreadID的时候依赖一次CAS原子指令即可。
偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程不会主动释放偏向锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有字节码正在执行),它会首先暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态。撤销偏向锁后恢复到无锁(标志位为“01”)或轻量级锁(标志位为“00”)的状态。
偏向锁在JDK 6及以后的JVM里是默认启用的。可以通过JVM参数关闭偏向锁:-XX:-UseBiasedLocking=false,关闭之后程序默认会进入轻量级锁状态。
轻量级锁
是指当锁是偏向锁的时候,被另外的线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,从而提高性能。
在代码进入同步块的时候,如果同步对象锁状态为无锁状态(锁标志位为“01”状态,是否为偏向锁为“0”),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝,然后拷贝对象头中的Mark Word复制到锁记录中。
拷贝成功后,虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针,并将Lock Record里的owner指针指向对象的Mark Word。
如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位设置为“00”,表示此对象处于轻量级锁定状态。
如果轻量级锁的更新操作失败了,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行,否则说明多个线程竞争锁。
若当前只有一个等待线程,则该线程通过自旋进行等待。但是当自旋超过一定的次数,或者一个线程在持有锁,一个在自旋,又有第三个来访时,轻量级锁升级为重量级锁。
重量级锁
升级为重量级锁时,锁标志的状态值变为“10”,此时Mark Word中存储的是指向重量级锁的指针,此时等待锁的线程都会进入阻塞状态。
整体的锁状态升级流程如下:
轻量级锁通过自旋来实现
重量级锁通过操作系统来调度
分段锁
分段锁并非一种实际的锁,而是一种锁的设计思想,用于将数据分段并在每个分段上单独加锁,把锁进一步细粒度话,以提高并发效率。
在JDK 1.7及之前的版本里ConcurrentHashMap在内部就是使用分段锁来实现的。
同步锁和死锁
这个就是两个名词。
在有多个线程同时被阻塞的时候,它们之间相互等待对方释放锁资源,就会出现死锁。为了避免出现死锁。可以为锁操作添加超时时间,在线程持有所超时的时候会自动释放该锁。
对锁进行优化的几种方法
减少锁持有的时间
较少锁持有的时间指只在有线程安全要求的程序上加锁来尽量减少同步代码块对锁的持有时间。
public synchronized void syncMethod(){
othercode1();
mutextMethod();
othercode2();
}
像上述代码这样,在进入方法前就要得到锁,其他线程就要在外面等待。
这里优化的一点在于,要减少其他线程等待的时间,所以,只需要在有线程安全要求的程序代码上加锁
public void syncMethod(){
othercode1();
synchronized(this)
{
mutextMethod();
}
othercode2();
}
减少锁粒度
较少锁粒度指将单个耗时较多的锁操作拆分为多个耗时较少的锁操作来增加锁的并发读,减少同一个锁上的竞争。在减少竞争后,偏向锁、轻量级锁的使用率才会提高。减少锁粒度最经典的案例就是 JDK 1.7及之前版本的ConcurrentHashMap 的分段锁。
锁分离
锁分离就是指根据不同的应用场景将锁的功能进行分离,以应对不同的变化,最常用的锁分离思想就是读写锁(ReadWriteLock),它根据锁的功能将锁分离为读锁和写锁,这样读读不互斥,读写互斥,写写互斥,即保证了线程的安全性,又提高了性能。
操作分离思想可以进一步延伸为只要操作互不影响,进可以进一个拆分,比如LinkedBlockingQueue 从头部获取数据,并从尾部加入数据。
锁粗化
锁粗化是指为了保障性能,会要求尽可能将锁的操作细化以减少线程持有锁的时间,但是如果锁分得太细的话,将会导致系统频繁获取锁和释放锁,反而影响性能的提升。
在这种情况下,建议将关联性强的锁操作集中起来处理,以提高系统整体的效率。
锁消除
在开发中也会经常出现在不需要使用锁的情况下误用了锁操作而引起性能的下降,这多数是因为程序编码不规范锁造成的。
这时,我们就需要检查并消除这些不必要的锁来提高系统的性能。