Java中涉及到各种各样的锁,往往初学者很乱。想要学习Java中的各种锁,首先要对他们的特性进行分类,Java中往往是按照是否含有某一特性来定义锁,我们通过特性将锁进行分组归类。
一、线程要不要锁住同类资源
锁从宏观上分类,分为悲观锁与乐观锁。
悲观锁(多写场景)
总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁,
即共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程。
传统的关系型数据库里边就用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。Java中synchronized和ReentrantLock等独占锁就是悲观锁思想的实现。
乐观锁(多读场景)
总是假设最好的情况,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号机制和CAS算法实现。
乐观锁适用于多读的应用类型,这样可以提高吞吐量,像数据库提供的类似于write_condition机制,其实都是提供的乐观锁。在Java中java.util.concurrent.atomic包下面的原子变量类就是使用了乐观锁的一种实现方式CAS实现的。
CAS算法
即compare and swap(比较与交换),是一种有名的无锁算法。无锁编程,即不使用锁的情况下实现多线程之间的变量同步,也就是在没有线程被阻塞的情况下实现变量的同步,所以也叫非阻塞同步(Non-blocking Synchronization)。
CAS算法涉及到三个操作数:
- 需要读写的内存值 V
- 进行比较的值 A
- 拟写入的新值 B
当且仅当 V 的值等于 A时,CAS通过原子方式用新值B来更新V的值,否则不会执行任何操作(比较和替换是一个原子操作)。一般情况下是一个自旋操作,即不断的重试。
乐观锁的缺点
- ABA 问题
如果一个变量V初次读取的时候是A值,并且在准备赋值的时候检查到它仍然是A值,那我们就能说明它的值没有被其他线程修改过了吗?很明显是不能的,因为在这段时间它的值可能被改为其他值,然后又改回A,那CAS操作就会误认为它从来没有被修改过。这个问题被称为CAS操作的 "ABA"问题。
JDK 1.5 以后的 AtomicStampedReference 类就提供了此种能力,其中的 compareAndSet 方法就是首先检查当前引用是否等于预期引用,并且当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。
- 循环时间长开销大
自旋CAS(也就是不成功就一直循环执行直到成功)如果长时间不成功,会给CPU带来非常大的执行开销。 - 只能保证一个共享变量的原子操作
CAS 只对单个共享变量有效,当操作涉及跨多个共享变量时 CAS 无效。但是从 JDK 1.5开始,提供了AtomicReference类来保证引用对象之间的原子性,你可以把多个变量放在一个对象里来进行 CAS 操作.所以我们可以使用锁或者利用AtomicReference类把多个共享变量合并成一个共享变量来操作。
CAS与synchronized的使用情景
简单的来说CAS适用于写比较少的情况下(多读场景,冲突一般较少),synchronized适用于写比较多的情况下(多写场景,冲突一般较多)
- 对于资源竞争较少(线程冲突较轻)的情况,使用synchronized同步锁进行线程阻塞和唤醒切换以及用户态内核态间的切换操作额外浪费消耗cpu资源;而CAS基于硬件实现,不需要进入内核,不需要切换线程,操作自旋几率较少,因此可以获得更高的性能。
- 对于资源竞争严重(线程冲突严重)的情况,CAS自旋的概率会比较大,从而浪费更多的CPU资源,效率低于synchronized。
Java并发编程这个领域中synchronized关键字一直都是元老级的角色,很久之前很多人都会称它为 “重量级锁” 。但是,在JavaSE 1.6之后进行了主要包括为了减少获得锁和释放锁带来的性能消耗而引入的 偏向锁 和 轻量级锁 以及其它各种优化之后变得在某些情况下并不是那么重了。synchronized的底层实现主要依靠 Lock-Free 的队列,基本思路是 自旋后阻塞,竞争切换后继续竞争锁,稍微牺牲了公平性,但获得了高吞吐量。在线程冲突较少的情况下,可以获得和CAS类似的性能;而线程冲突严重的情况下,性能远高于CAS。
二、锁住同步资源失败,线程要不要阻塞?
不阻塞状态下分为:自旋锁,自适应自旋锁
临界区:每个进程中访问临界资源的那段程序称为临界区,每次只允许一个进程进入临界区,进入后不允许其他进程进入。
互斥锁:用于保护临界区,确保同一时间只有一个线程访问数据。对共享资源的访问,先对互斥量进行加锁,如果互斥量已经上锁,调用线程会阻塞,直到互斥量被解锁。在完成了对共享资源的访问后,要对互斥量进行解锁。
自旋锁:是一种互斥锁的实现方式而已,相比一般的互斥锁会在等待期间放弃cpu,自旋锁(spinlock)则是不断循环并测试锁的状态,这样就一直占着cpu。
自旋锁与互斥锁的区别:线程在申请自旋锁的时候,线程不会被挂起,而是处于忙等的状态。
自适应自旋锁:意味着自旋的时间(次数)不再固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。
如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也是很有可能再次成功,进而它将允许自旋等待持续相对更长的时间。如果对于某个锁,自旋很少成功获得过,那在以后尝试获取这个锁时将可能省略掉自旋过程,直接阻塞线程,避免浪费处理器资源。
三、多个线程竞争同步资源的流程细节有没有区别?
- 无锁状态:不锁住资源,多个线程中有一个能修改资源成功,其他线程会重试。
- 偏向锁:同一个线程执行同步资源时自动获取资源。大多数情况下,锁不存在多线程竞争,而是总是由同一线程多次获得时,为了使线程获得锁的代价更低而引入了偏向锁。
- 轻量级锁:多个线程竞争同步资源时,没有获取资源的线程自旋等待锁释放。
- 重量级锁:多个线程竞争同步资源时,没有获取资源的线程阻塞等待唤醒。
四种状态会随着竞争的情况逐渐升级,而且是不可逆的过程,即不可降级。
要注意的是,这四种状态都不是Java语言中的锁,而是JVM为了提高锁的获取与释放效率而做的优化(使用synchronized时)。
无锁 -> 偏向锁
偏向锁的获取方式是将对象头MarkWord部分中, 标记上线程ID,以表示哪一个线程获得了偏向锁。
- 首先读取目标对象的 MarkWord, 判断是否处于可偏向的状态。
- 如果为可偏向状态, 则尝试用 CAS 操作, 将自己的线程 ID 写入MarkWord
- 如果 CAS 操作成功(状态转变为下图), 则认为已经获取到该对象的偏向锁, 执行同步块代码 。
- 如果 CAS 操作失败, 则说明, 有另外一个线程 Thread B 抢先获取了偏向锁。 这种状态说明该对象的竞争比较激烈, 此时需要撤销 Thread B 获得的偏向锁,将 Thread B 持有的锁升级为轻量级锁。
- 如果 CAS 操作成功(状态转变为下图), 则认为已经获取到该对象的偏向锁, 执行同步块代码 。
- 如果是已偏向状态, 则检测 MarkWord 中存储的 thread ID 是否等于当前 thread ID
- 如果相等, 则证明本线程已经获取到偏向锁, 可以直接继续执行同步代码块
- 如果不等, 则证明该对象目前偏向于其他线程, 需要撤销偏向锁
偏向锁的撤销(revoke) 是一个很特殊的操作, 为了执行撤销操作, 需要等待全局安全点(Safe Point), 此时间点所有的工作线程都停止了字节码的执行。
当其他线程尝试竞争偏向锁时,就会释放锁,锁的撤销,需要等待全局安全点,分为以下几个步骤:
- 暂停拥有偏向锁的线程,检查线程是否存活
- 处于非活动状态,则设置为无锁状态
- 存活,则重新偏向于其他线程或者恢复到无锁状态或者标记对象不适合作为偏向锁
- 唤醒线程
偏向锁 -> 轻量级锁
偏向锁撤销后, 对象可能处于两种状态:不可偏向的无锁状态,不可偏向的已锁 ( 轻量级锁) 状态
- 原来已经获取了偏向锁的线程可能已经执行完了同步代码块, 使得对象处于 “闲置状态”,相当于原有的偏向锁已经过期无效了。此时该对象就应该被直接转换为不可偏向的无锁状态。
- 原来已经获取了偏向锁的线程也可能尚未执行完同步代码块, 偏向锁依旧有效, 此时对象就应该直接被转换为轻量级加锁的状态
轻量级锁的加锁:
- 如果成功使用CAS将对象头重的Mark Word替换为指向锁记录的指针,则获得锁,失败则当前线程尝试使用自旋(循环等待)来获取锁。
轻量级锁的解锁:
- 当有另一个线程与该线程同时竞争时,锁会升级为重量级锁。为了防止继续自旋,一旦升级,将无法降级。
轻量级锁 -> 重量级锁
其他线程试图获取锁时,都会被阻塞,只有持有锁的线程释放锁之后才会唤醒这些线程,进行竞争。
总结
- 偏向锁:仅有一个线程进入临界区
- 轻量级锁:多个线程交替进入临界区
- 重量级锁:多个线程同时进入临界区
四、多个线程竞争锁时要不要排队?
公平锁
加锁的线程全部是按照先来后到的顺序,依次进入等待队列(AQS) 中排队的,不会盲目的胡乱抢占加锁,非常的公平。
公平锁源码:
java.util.concurrent.locks.ReentrantLock$FairSync.java
protected final boolean tryAcquire( int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
//状态为0,说明当前没有线程占有锁
if (c == 0 ) {
//如果当前线程是等待队列的第一个或者等待队列为空,则通过cas指令设置state为1,当前线程获得锁
if (isFirst(current) &&
compareAndSetState( 0 , acquires)) {
setExclusiveOwnerThread(current);
return true ;
}
}
//如果当前线程本身就持有锁,那么叠加状态值,持续获得锁
else if (current == getExclusiveOwnerThread()) {
int nextc = c + acquires;
if (nextc < 0 )
throw new Error( "Maximum lock count exceeded" );
setState(nextc);
return true ;
}
//以上条件都不满足,那么线程进入等待队列。
return false ;
}
非公平锁
例子:假如有线程A,线程B,线程C
- 现在线程A加了锁,然后线程B尝试加锁,失败后进入了等待队列,处于阻塞中。然后线程A释放了锁,准备来唤醒线程B重新尝试加锁。(此时线程B可还停留在等待队列里,还没开始尝试重新加锁)
- 又有一个线程C,突然尝试对ReentrantLock发起加锁操作,最终会导致,线程B还没来得及重新尝试加锁,即还没来得及尝试重新执行CAS操作将state的值从0变为1,线程C直接一个CAS操作,尝试将state的值从0变为1,所以线程C获得该锁。
- 这就导致线程B被唤醒过后,重新尝试加锁执行CAS操作,最终线程B还是会加锁失败,重回阻塞队列。
这就是非公平锁
java.util.concurrent.locks.ReentrantLock$Sync.java
final boolean nonfairTryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
//如果当前没有线程占有锁,当前线程直接通过cas指令占有锁,管他等待队列,就算自己排在队尾也是这样
if (compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
else if (current == getExclusiveOwnerThread()) {
int nextc = c + acquires;
if (nextc < 0) // overflow
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
return false;
}
五、一个线程中能不能获取同一把锁?
public class Count{
Lock lock = new Lock();
//当前线程执行print()方法首先获取lock,接下来执行doAdd()方法就无法执行doAdd()中的逻辑,必须先释放锁。
public void print(){
lock.lock();
doAdd();
lock.unlock();
}
public void print2(){
lock.lock();
//do something
lock.unlock();
}
}
可重入锁
在同一线程在外层方法获取锁的时候,在进入内层方法会自动获取锁。ReentrantLock/Synchronized就是一个典型的可重入锁;
public class Lock{
boolean isLocked = false;
Thread lockedBy = null;
int lockedCount = 0;
public synchronized void lock()
throws InterruptedException{
Thread thread = Thread.currentThread();
while(isLocked && lockedBy != thread){
wait();
}
isLocked = true;
lockedCount++;
lockedBy = thread;
}
public synchronized void unlock(){
if(Thread.currentThread() == this.lockedBy){
lockedCount--;
if(lockedCount == 0){
isLocked = false;
notify();
}
}
}
}
不可重入锁
所谓不可重入锁,即若当前线程执行某个方法已经获取了该锁,那么在方法中尝试再次获取锁时,就会获取不到被阻塞。
public class Lock{
private boolean isLocked = false;
public synchronized void lock() throws InterruptedException{
while(isLocked){
wait();
}
isLocked = true;
}
public synchronized void unlock(){
isLocked = false;
notify();
}
}
六、多个线程能不能共享一把锁?
共享锁(读锁)
共享锁,又称为读锁,获得共享锁之后,可以查看但无法修改和删除数据。
排它锁(写锁,互斥锁)
排他锁,又称为写锁、独占锁。获准排他锁后,既能读数据,又能修改数据。
数据库的增、删、改操作默认都会加排他锁,而查询不会加任何锁。