一.锁的分类
锁分的分类是根据不同应用场景被人们命名成了不同的场所,个人认为深入理解每一种锁可以去研究每个锁对应的典型案例的源码可以更好地理解每个锁的含义以及优缺点
乐观锁/悲观锁
独享锁/共享锁
互斥锁/读写锁
可重入锁
公平锁/非公平锁
分段锁
偏向锁/轻量级锁/重量级锁
自旋锁
参考美团相关的锁文章:https://tech.meituan.com/2018/11/15/java-lock.html
一般成对出现的是有对比的锁,一般是反义,也方便了记忆。
二.各类锁的详解
1.乐观锁/悲观锁
1.1乐观锁
顾名思义,认为我们每次去修改数据的时候别人不会修改,不需要上锁,但是在更新数据的时候会判断修改数据期间有没有人修改了数据,一般用版本号来控制。一般使用CAS(CompareAndSwap)来实现,多用于读的场景。
1.2悲观锁
每个线程在修改变量的时候都认为会有其他线程来竞争,所以每个线程修改变量的时候都会加锁。Synchronize和Lock都是悲观锁。
1.3乐观锁、悲观锁使用代码示例如下:
/**
* 悲观锁 Synchronize
*/
public synchronized void beiGuanLock1(){
//操作同步资源
}
/**
* 悲观锁 ReentrantLock
* 保证多个线程使用同一把锁
*/
private ReentrantLock lock = new ReentrantLock();// 保证多个线程使用同一把锁
public synchronized void beiGuanLock2(){
lock.lock();
//操作同步资源
lock.unlock();
}
/**
* 乐观锁(CAS:compare and swap)
*/
private AtomicInteger atomicInteger = new AtomicInteger();//保证多个线程使用同一个AtomicInteger
public void leGuanLock(){
atomicInteger.incrementAndGet();//执行自增1
}
1.4乐观锁、悲观锁特点及优缺点
1.4.1乐观锁的特点
适用于读场景比较多的情况
1.4.2乐观锁的优缺点
乐观锁使读的速度大幅度提升
1.4.3悲观锁的特点
适用于写操作比较较多的场景
1.4.4悲观锁的优缺点
只要对操作变量有读取操作必加锁,会影响性能
2.自旋锁/适应性自旋锁
当需要访问的资源被其他线程占用的时候,如果当前代码块很简单,那么可以尝试自旋等待资源的释放,会比阻塞当前线程这种状态转换消耗的资源更少,所以就有的自旋锁的存在。自旋锁和非自旋锁的区别借用美团博客的图片如下:
一个最典型的自旋就是CAS中等待修改值的时候的do while操作,示例如下:
一般自选锁的次数是10次,适应性自旋锁是根据该资源上的锁是否自旋成功获取过来自适应控制自旋次数,如果该锁之前成功过或自动延长次数反之会减少自旋次数提前进入或直接线程阻塞。
3.无锁、偏向锁、轻量级锁、重量级锁
该分类其实是一种对加锁策略的一种分类,保证最大程度上处理速度最快开销最小,以下是不同锁对应java对象头中存储的数据以及标志位
锁状态 | 存储内容 | 存储内容 |
无锁 | 对象的hashCode、对象分代年龄、是否是偏向锁(0) | 01 |
偏向锁 | 偏向线程ID、偏向时间戳、对象分代年龄、是否是偏向锁(1) | 01 |
轻量级锁 | 指向栈中锁记录的指针 | 00 |
重量级锁 | 指向互斥量(重量级锁)的指针 | 10 |
3.1无锁
即多个线程修改变量时不加锁,会有一个线程修改成功,其他线程使用CAS策略其他线程自旋等待知道修改成功,在某些场景下无锁的效率是很高的。
3.2偏向锁
此类锁是认为当前变量只有当前线程使用会自动获取锁降低开销,其他线程不会竞争的情况下。
一段同步代码会在java对象头中MarkWord中存储偏向线程的ID,在线程进入和退出同步块的时候不需要CAS操作,直接获取MarkWord中存储的线程ID和当前线程是否相同即可。如果不相同就存在多线程竞争了需要过渡到轻量级锁了。
3.3轻量级锁
当锁标志位为偏向锁时,还有其他线程访问时会升级到轻量级锁,其他线程会通过自旋来等待获取锁,不会阻塞从而提高性能。
在代码进入同步块的时候,如果同步对象锁状态为无锁状态(锁标志位为“01”状态,是否为偏向锁为“0”),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝,然后拷贝对象头中的Mark Word复制到锁记录中。
拷贝成功后,虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针,并将Lock Record里的owner指针指向对象的Mark Word。
如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位设置为“00”,表示此对象处于轻量级锁定状态。
如果轻量级锁的更新操作失败了,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行,否则说明多个线程竞争锁。
若当前只有一个等待线程,则该线程通过自旋进行等待。但是当自旋超过一定的次数,或者一个线程在持有锁,一个在自旋,又有第三个来访时,轻量级锁升级为重量级锁。
以下是轻量级锁判断是否拥有该对象的锁的示意图,通过判断栈帧中备份的MardWord和对象头中的MarkWord进行比较。
3.4重量级锁
升级为重量级锁时,锁标志的状态值变为“10”,此时Mark Word中存储的是指向重量级锁的指针,此时等待锁的线程都会进入阻塞状态。
整体的锁状态升级流程如下:
4.可重入锁、非可重入锁
4.1非可重入锁
当前线程获取当前对象的锁后访问A同步代码块后,如果要访问当前对象的B同步代码的话需要先释放锁再重新申请锁,会发生死锁的情况。
4.2可重入锁
当前线程获取当前对象的锁后访问A同步代码块后再进入不需要重新获取锁可以直接获取,称为可重入锁。Synchronize和ReentrantLock都是可重入锁。
可重入锁每次重入count都会+1,执行完一个同步代码块后count会-1,只有count重置为0的时候表示当前线程的可重入锁使用完成可以成功释放。
5.公平锁、非公平锁
5.1公平锁
再多线程获取同一对象锁时按照顺序排队,阻塞等待被唤醒,线程不会被饿死,但是开销会比非公平锁大。
5.2非公平锁
在有新的线程来获取锁的时候,如果当前持有锁的线程正好执行完成那么新来的线程不需要阻塞直接获取锁,俗称叫插队,这样会减少部分线程的阻塞,会减少开销,但是缺点是会有的线程一直被阻塞可能会被饿死。
6.独享锁、共享锁
独享锁和共享锁同样是一种概念。我们先介绍一下具体的概念,然后通过ReentrantLock和ReentrantReadWriteLock的源码来介绍独享锁和共享锁。
独享锁也叫排他锁,是指该锁一次只能被一个线程所持有。如果线程T对数据A加上排它锁后,则其他线程不能再对A加任何类型的锁。获得排它锁的线程即能读数据又能修改数据。JDK中的synchronized和JUC中Lock的实现类就是互斥锁。
共享锁是指该锁可被多个线程所持有。如果线程T对数据A加上共享锁后,则其他线程只能对A再加共享锁,不能加排它锁。获得共享锁的线程只能读数据,不能修改数据。
独享锁与共享锁也是通过AQS来实现的,通过实现不同的方法,来实现独享或者共享。
下图为ReentrantReadWriteLock的部分源码:
我们看到ReentrantReadWriteLock有两把锁:ReadLock和WriteLock,由词知意,一个读锁一个写锁,合称“读写锁”。再进一步观察可以发现ReadLock和WriteLock是靠内部类Sync实现的锁。Sync是AQS的一个子类,这种结构在CountDownLatch、ReentrantLock、Semaphore里面也都存在。
在ReentrantReadWriteLock里面,读锁和写锁的锁主体都是Sync,但读锁和写锁的加锁方式不一样。读锁是共享锁,写锁是独享锁。读锁的共享锁可保证并发读非常高效,而读写、写读、写写的过程互斥,因为读锁和写锁是分离的。所以ReentrantReadWriteLock的并发性相比一般的互斥锁有了很大提升。
那读锁和写锁的具体加锁方式有什么区别呢?在了解源码之前我们需要回顾一下其他知识。 在最开始提及AQS的时候我们也提到了state字段(int类型,32位),该字段用来描述有多少线程获持有锁。
在独享锁中这个值通常是0或者1(如果是重入锁的话state值就是重入的次数),在共享锁中state就是持有锁的数量。但是在ReentrantReadWriteLock中有读、写两把锁,所以需要在一个整型变量state上分别描述读锁和写锁的数量(或者也可以叫状态)。于是将state变量“按位切割”切分成了两个部分,高16位表示读锁状态(读锁个数),低16位表示写锁状态(写锁个数)。如下图所示:
了解了概念之后我们再来看代码,先看写锁的加锁源码:
protected final boolean tryAcquire(int acquires) {
Thread current = Thread.currentThread();
int c = getState(); // 取到当前锁的个数
int w = exclusiveCount(c); // 取写锁的个数w
if (c != 0) { // 如果已经有线程持有了锁(c!=0)
// (Note: if c != 0 and w == 0 then shared count != 0)
if (w == 0 || current != getExclusiveOwnerThread()) // 如果写线程数(w)为0(换言之存在读锁) 或者持有锁的线程不是当前线程就返回失败
return false;
if (w + exclusiveCount(acquires) > MAX_COUNT) // 如果写入锁的数量大于最大数(65535,2的16次方-1)就抛出一个Error。
throw new Error("Maximum lock count exceeded");
// Reentrant acquire
setState(c + acquires);
return true;
}
if (writerShouldBlock() || !compareAndSetState(c, c + acquires)) // 如果当且写线程数为0,并且当前线程需要阻塞那么就返回失败;或者如果通过CAS增加写线程数失败也返回失败。
return false;
setExclusiveOwnerThread(current); // 如果c=0,w=0或者c>0,w>0(重入),则设置当前线程或锁的拥有者
return true;
}
- 这段代码首先取到当前锁的个数c,然后再通过c来获取写锁的个数w。因为写锁是低16位,所以取低16位的最大值与当前的c做与运算( int w = exclusiveCount©; ),高16位和0与运算后是0,剩下的就是低位运算的值,同时也是持有写锁的线程数目。
- 在取到写锁线程的数目后,首先判断是否已经有线程持有了锁。如果已经有线程持有了锁(c!=0),则查看当前写锁线程的数目,如果写线程数为0(即此时存在读锁)或者持有锁的线程不是当前线程就返回失败(涉及到公平锁和非公平锁的实现)。
- 如果写入锁的数量大于最大数(65535,2的16次方-1)就抛出一个Error。
- 如果当且写线程数为0(那么读线程也应该为0,因为上面已经处理c!=0的情况),并且当前线程需要阻塞那么就返回失败;如果通过CAS增加写线程数失败也返回失败。
- 如果c=0,w=0或者c>0,w>0(重入),则设置当前线程或锁的拥有者,返回成功!
tryAcquire()除了重入条件(当前线程为获取了写锁的线程)之外,增加了一个读锁是否存在的判断。如果存在读锁,则写锁不能被获取,原因在于:必须确保写锁的操作对读锁可见,如果允许读锁在已被获取的情况下对写锁的获取,那么正在运行的其他读线程就无法感知到当前写线程的操作。
因此,只有等待其他读线程都释放了读锁,写锁才能被当前线程获取,而写锁一旦被获取,则其他读写线程的后续访问均被阻塞。写锁的释放与ReentrantLock的释放过程基本类似,每次释放均减少写状态,当写状态为0时表示写锁已被释放,然后等待的读写线程才能够继续访问读写锁,同时前次写线程的修改对后续的读写线程可见。
接着是读锁的代码:
protected final int tryAcquireShared(int unused) {
Thread current = Thread.currentThread();
int c = getState();
if (exclusiveCount(c) != 0 &&
getExclusiveOwnerThread() != current)
return -1; // 如果其他线程已经获取了写锁,则当前线程获取读锁失败,进入等待状态
int r = sharedCount(c);
if (!readerShouldBlock() &&
r < MAX_COUNT &&
compareAndSetState(c, c + SHARED_UNIT)) {
if (r == 0) {
firstReader = current;
firstReaderHoldCount = 1;
} else if (firstReader == current) {
firstReaderHoldCount++;
} else {
HoldCounter rh = cachedHoldCounter;
if (rh == null || rh.tid != getThreadId(current))
cachedHoldCounter = rh = readHolds.get();
else if (rh.count == 0)
readHolds.set(rh);
rh.count++;
}
return 1;
}
return fullTryAcquireShared(current);
}
可以看到在tryAcquireShared(int unused)方法中,如果其他线程已经获取了写锁,则当前线程获取读锁失败,进入等待状态。如果当前线程获取了写锁或者写锁未被获取,则当前线程(线程安全,依靠CAS保证)增加读状态,成功获取读锁。读锁的每次释放(线程安全的,可能有多个读线程同时释放读锁)均减少读状态,减少的值是“1<<16”。所以读写锁才能实现读读的过程共享,而读写、写读、写写的过程互斥。
此时,我们再回头看一下互斥锁ReentrantLock中公平锁和非公平锁的加锁源码:
我们发现在ReentrantLock虽然有公平锁和非公平锁两种,但是它们添加的都是独享锁。根据源码所示,当某一个线程调用lock方法获取锁时,如果同步资源没有被其他线程锁住,那么当前线程在使用CAS更新state成功后就会成功抢占该资源。而如果公共资源被占用且不是被当前线程占用,那么就会加锁失败。所以可以确定ReentrantLock无论读操作还是写操作,添加的锁都是都是独享锁。
Synchronized是非公平锁、独占锁、可重入锁