文章目录
- Java中常用到的锁
- 公平锁与非公平锁
- 可重入锁与不可重入锁
- 共享锁与独占锁
- 悲观锁与乐观锁
- 自旋锁、适应性自旋锁
- 自旋锁
- 适应性自旋锁
- 偏向锁
- 轻量级锁与重量级锁
- 可中断锁
- 互斥锁
- 参考文章
Java中常用到的锁
在Java中根据锁的特性来区分可以分为很多,在程序中"锁"的作用无非就是保证线程安全,线程安全的目的就是保证程序正常执行。
在Java中具体"锁"的实现,无非就三种:使用synchronized
关键字、调用juc.locks
包下相关接口、使用CAS
思想。
公平锁与非公平锁
公平锁:多个线程按照申请锁的顺序来获取锁,线程直接进入队列中排队,队列中的第一个线程才能获得锁。
- 优点是等待锁的线程不会饿死;
- 缺点是整体吞吐效率相对非公平锁要低,等待队列中除第一个线程以外的所有线程都会阻塞,CPU唤醒阻塞线程的开销比非公平锁大;
非公平锁:多个线程加锁时直接尝试获取锁,获取不到才会到等待队列的队尾等待。但如果此时锁刚好可用,那么这个线程可以无需阻塞直接获取到锁,所以非公平锁有可能出现后申请锁的线程先获取锁的场景。
- 优点是可以减少唤起线程的开销,整体的吞吐效率高,因为线程有几率不阻塞直接获得锁,CPU不必唤醒所有线程。
- 缺点是处于等待队列中的线程可能会饿死,或者等很久才会获得锁。
相对来说,非公平锁会有更好的性能,因为它的吞吐量比较大。
当然,非公平锁让获取锁的时间变得更加不确定,可能会导致在阻塞队列中的线程长期处于饥饿状态。
在Java中公平锁和非公平锁的实现为ReentrantLock
、synchronized
。
其中synchronized
是非公平锁;ReentrantLock
默认是非公平锁,但是可以指定ReentrantLock
的构造函数创建公平锁。
/**
* Creates an instance of {@code ReentrantLock}.
* This is equivalent to using {@code ReentrantLock(false)}.
*/
public ReentrantLock() {
sync = new NonfairSync();
}
/**
* Creates an instance of {@code ReentrantLock} with the
* given fairness policy.
*
* @param fair {@code true} if this lock should use a fair ordering policy
*/
public ReentrantLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
}
可重入锁与不可重入锁
可重入锁:在同一个线程在外层方法获取锁的时候,再进入该线程的内层方法会自动获取锁(前提锁对象得是同一个对象或者class)不会因为之前已经获取过还没释放而阻塞。
所以可重入锁又叫做递归锁,就是因为能获取到加锁方法里面的加锁方法的锁。
- 可重入锁最大的作用就是避免死锁
不可重入锁:所谓不可重入锁,就是与可冲入锁作用相悖;即当前线程执行某个方法已经获取了该锁,那么在该方法中尝试再次获取加锁的方法时,就会获取不到被阻塞。
举个栗子: 当你进入你家时门外会有锁,进入房间后厨房卫生间都可以随便进出,这个叫可重入锁;
当你进入房间时,发现厨房,卫生间都有上锁.这个叫不可重入锁。
在Java中ReentrantLock
和synchronized
都是可重入锁。
共享锁与独占锁
- 独占锁: 又称排它锁,指该锁一次只能被一个线程独占;
- 共享锁:指该锁可被多个线程所持有;
在Java中,对于 ReentrantLock
和 synchronized
都是独占锁;对与 ReentrantReadWriteLock
其读锁是共享锁而写锁是独占锁。
读锁的共享可保证并发读是非常高效的。
悲观锁与乐观锁
乐观锁与悲观锁是一种广义上的概念,可以理解为一种标准类似于Java中的接口。
对于多线程并发操作,加了悲观锁的线程认为每一次修改数据时都会有其他线程来跟它一起修改数据,所以在修改数据之前先会加锁,确保其他线程不会修改该数据。
由于悲观锁在修改数据前先加锁的特性,能保证写操作时数据正确,所以悲观锁更适合写多读少的场景。
乐观锁则与悲观锁相反,每一次修改数据时,都认为没有其他线程来跟它一起修改,所以在修改数据之前不会去添加锁,如果这个数据没有被更新,当前线程将自己修改的数据成功写入。如果数据已经被其他线程更新,则根据不同的实现方式执行不同的操作。
由于乐观锁是一种无锁操作,所以在使用乐观锁的场景中读的性能会大幅度提升,适合读多写少。
在Java中悲观锁的实现有:synchronized
、Lock
实现类,乐观锁的实现有CAS
。
自旋锁、适应性自旋锁
自旋锁
当一个线程尝试获取某个锁时,如果该锁已被其他线程占用,就一直循环检测锁是否被释放,而不是进入线程挂起或睡眠状态,直到获取到某个锁。
自旋锁本身是有缺点的,它不能代替阻塞。如果锁被占用的时间很长,那么自旋的线程只会白白浪费处理器资源,带来性能上的浪费。
自旋锁的实现原理是CAS,例如AtomicInteger
中getAndUpdate()
方法
public final int getAndUpdate(IntUnaryOperator updateFunction) {
int prev, next;
do {
prev = get();
next = updateFunction.applyAsInt(prev);
} while (!compareAndSet(prev, next));
return prev;
}
源码中的do-while
循环就是一个自旋操作,如果修改数值失败则通过循环来执行自旋,直至修改成功。
为什么要使用自旋锁?
在许多场景中,同步资源的锁定时间很短,为了这一小段时间去切换线程,线程挂起和恢复现场的花费可能会让系统得不偿失。
简单来说就是,避免切换线程带来的开销。
自旋等待虽然避免了线程切换的开销,但它要占用处理器时间。如果锁被占用的时间很短,自旋等待的效果就会非常好。
反之,如果锁被占用的时间很长,那么自旋的线程只会白浪费处理器资源。
所以,自旋等待的时间必须要有一定的限度,如果自旋超过了限定次数(默认是10次,可以使用-XX:PreBlockSpin
来更改)没有成功获得锁,就应当挂起线程。
自旋锁在JDK 1.4中引入,默认关闭,但是可以使用-XX:+UseSpinning
开开启;在JDK1.6中默认开启,同时自旋的默认次数为10次,可以通过参数-XX:PreBlockSpin
来调整。
如果通过参数-XX:PreBlockSpin
来调整自旋锁的自旋次数,会带来诸多不便。
假如将参数调整为10,但是系统很多线程都是等你刚刚退出的时候就释放了锁(假如多自旋一两次就可以获取锁)。于是JDK1.6引入适应性自旋锁。
适应性自旋锁
适应性自旋锁是对自旋的升级、优化,自旋的时间不再固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态决定。
如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也是很有可能再次成功,进而它将允许自旋等待持续相对更长的时间。
如果对于某个锁,自旋很少成功获得过,那在以后尝试获取这个锁时将可能省略掉自旋过程,直接阻塞线程,避免浪费处理器资源。
偏向锁
引入偏向锁的目的是在没有多线程竞争的前提下,进一步减少线程同步的性能消耗。
《深入理解Java虚拟机》对偏向锁的解释:
Hotspot
的作者经过以往的研究发现大多数情况下锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入
了偏向锁。当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程 ID,以后该线程在进入和退出同步块时不需要花费
CAS操作来加锁和解锁,而只需简单的测试一下对象头的MarkWord
里是否存储着指向当前线程的偏向锁,如果测试成功,表示线程已经获得了锁,
如果测试失败,则需要再测试下MarkWord
中偏向锁的标识是否设置成 1(表示当前是偏向锁),如果没有设置,则使用 CAS 竞争锁,如果设置了,
则尝试使用 CAS 将对象头的偏向锁指向当前线程。
之所以叫偏向锁是因为偏向于第一个获取到他的线程,如果在程序执行中该锁没有被其他的线程获取,则持有偏向锁的线程将永远不需要再进行同步。
但是如果线程间存在锁竞争,会带来额外的锁撤销(CAS)的消耗。
个人理解,偏向锁就是对做了一个缓存,在没有多线程竞争的前提下,这样做会大幅度提升程序性能。
轻量级锁与重量级锁
重量级锁:传统的重量级锁,使用的是系统互斥量实现的;重量级锁会导致线程堵塞;
轻量级锁:相对于重量级锁而言的;他的出现并不是代替重量级锁,而是在没有多线程竞争的前提下,减少系统互斥量操作产生的性能消耗;是重量级锁的优化。
在Java中轻量级锁的经典实现是CAS中的自旋锁,所以优点缺点就很明显了。
- 优点:竞争的线程不会阻塞,提高了程序的响应速度;
- 缺点:如果始终得不到锁竞争的线程,使用自旋会消耗CPU;
所以适合,追求响应时间,同步块执行速度非常快的场景。
重量级锁优缺点:
- 优点:线程竞争不使用自旋,不会消耗CPU;
- 缺点:线程阻塞,响应时间慢;
适合追求吞吐量、同步块执行时间较长也就是线程竞争激烈的场景。
轻量级锁不是在任何情况下都比重量级锁快的,要看同步块执行期间有没有多个线程抢占资源的情况。
如果有,那么轻量级线程要承担 CAS + 互斥锁的性能消耗,就会比重量锁执行的更慢。
可中断锁
顾名思义,就是可以相应中断的锁。
如果某一线程A正在执行锁中的代码,另一线程B正在等待获取该锁,可能由于等待时间过长,线程B不想等待了,想先处理其他事情,我们可以让它中断自己或者在别的线程中中断它,这种就是可中断锁。
在Java中synchronized
就是不可中断锁,Lock
是可中断锁。
互斥锁
在编程中,引入了对象互斥锁的概念,来保证共享数据操作的完整性。
每个对象都对应于一个可称为" 互斥锁" 的标记,这个标记用来保证在任一时刻,只能有一个线程访问该对象。
互斥锁:在访问共享资源之前对对象进行加锁操作,在访问完成之后进行解锁操作。加锁后,任何其他试图再次加锁的线程会被阻塞,直到当前线程解锁其他线程才能访问公共资源。
如果解锁时有一个以上的线程阻塞,那么所有该锁上的线程都变为就绪状态,第一个变为就绪状态的线程又执行加锁操作,其他的线程又会进入等待。
在这种方式下,只有一个线程能够访问被互斥锁保护的资源。
在Java里最基本的互斥手段就是使用synchronized
关键字、ReentrantLock
。
Linux中提供一把互斥锁
mutex
也称之为互斥量。每个线程在对资源操作前都尝试先加锁,成功加锁才能操作,操作结束解锁。
要注意,同一时刻,只能有一个线程持有该锁。
所以,互斥锁实质上是操作系统提供的一把“建议锁”(又称“协同锁”),建议程序中有多线程访问共享资源的时候使用该机制。但,并没有强制限定。
因此,即使有了mutex
,如果有线程不按规则来访问数据,依然会造成数据混乱。
参考文章