锁作为核心同步机制,在实际的开发的过程中,熟练的运用锁,能够帮助我们更好的管理共享资源,避免数据不一致或死锁等问题。
本文将探讨Java中锁的分类体系,从传统synchronized关键字到java.util.concurrent包中的高级实现(如ReentrantLock和StampedLock),我们将剖析其维度、特性及应用场景。
下面是我整理的锁的整体分类结构:
一、乐观锁和悲观锁
从概念上讲,悲观锁就是先上锁再干活。默认假设会冲突,获取失败就排队/阻塞。
悲观锁认为自己在用数据的时候一定有别的线程来修改数据,所以在获取数据的时候就会先加锁,保证数据不会被别的线程修改。
synchronized、ReentrantLock、ReentrantReadWriteLock的写锁就是悲观锁。
乐观锁就是先干活,提交前校验。不阻塞别人;如果发现被别人改过就重试/放弃。
乐观锁认为自己在使用数据的时候不会有别的线程修改数据,所以不会加锁,只是在更新数据的时候去判断之前有没有别的线程更新了这个数据。
如果这个数据没有被更新,当前线程修改的数据就能成功写入。如果数据已经被其他线程改了,就会根据不同的实现方式执行不同的操作(报错或者自动重试)。
在Java里面,通过使用无锁编程来实现,最常采用的是CAS算法,Java原子类中的递增操作就通过CAS自旋实现的。
从上面这些描述来看:
悲观锁适合写操作多的场景,先加锁可以保证写操作时数据正确。
乐观锁适合读操作多的场景,不加锁的特点能够使其读操作的性能大幅提升。
package com.lazy.snail.day41;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.locks.ReentrantLock;
/**
* @ClassName Day41Demo
* @Description TODO
* @Author lazysnail
* @Date 2025/8/25 11:58
* @Version 1.0
*/
public class Day41Demo {
// ------------------------- 悲观锁调用 -------------------------
public synchronized void methodA() {
}
private ReentrantLock lock = new ReentrantLock();
public void methodB() {
lock.lock();
// 操作同步资源
lock.unlock();
}
// ------------------------- 乐观锁调用 -------------------------
public void add() {
AtomicInteger atomicInteger = new AtomicInteger();
atomicInteger.incrementAndGet();// 自增1
}
}
二、互斥锁和共享锁
互斥锁也叫独享锁或者排他锁,是指这个锁一次只能被一个线程所持有。如果线程T对数据A加上互斥锁之后,其他线程不能再对A加任何类型的锁。
获得互斥锁的线程即能读数据又能修改数据。JDK里面的synchronized和JUC里Lock的实现类就是互斥锁。
共享锁是指这个锁可以被多个线程持有。如果线程T对数据A加上共享锁之后,那么其他线程只能对A再加共享锁,不能加互斥锁。获得共享锁的线程只能读数据,不能修改数据。
互斥锁和共享锁也是通过AQS来实现的,通过实现不同的方法,来实现互斥或者共享。
上一篇我们讲的ReentrantReadWriteLock就有两把锁,ReadLock和WriteLock,一个读锁一个写锁。
ReadLock和WriteLock是靠内部类Sync实现的锁,Sync是AQS的一个子类,这种结构在CountDownLatch、ReentrantLock、Semaphore里面也都存在。
ReentrantReadWriteLock里面,读锁和写锁的锁主体都是Sync,但读锁和写锁的加锁方式不一样。
读锁是共享锁,写锁是独享锁。读锁的共享锁可保证并发读非常高效,而读写、写读、写写的过程互斥,因为读锁和写锁是分离的。
所以ReentrantReadWriteLock的并发性相比一般的互斥锁有了很大提升。
三、公平锁和非公平锁
这个分类是基于锁的公平性,指的是线程获取锁的顺序是不是跟他们发出请求的顺序一致。
公平锁遵循"先来后到"(FIFO)的原则。一个线程发出锁请求之后,如果锁当前被占用,这个线程会被放到一个等待队列里。当锁被释放的时候,队列头部的线程(等待时间最长的线程)就会被唤醒获取锁。
非公平锁就不保证先来后到的顺序。当一个线程请求锁时,它会首先尝试直接获取锁(这个过程可以看成是插队)。如果恰好锁是可用的,那么这个新来的线程就能立即获得锁,不需要进入等待队列。如果尝试失败,它才会进入等待队列的末尾,并像公平锁一样排队等待。
在讲ReentrantLock的时候,我们提到过ReentrantLock默认使用非公平锁,因为这样可以提供更高的吞吐量。
ReentrantLock的公平和非公平特性都是基于他内部类Sync实现的,而Sync本身是 AbstractQueuedSynchronizer (AQS) 的子类。AQS是一个用来构建锁和同步器的框架,它内部维护了一个CLH队列来管理等待线程。
公平锁和非公平锁的核心区别在于他们各自的lock()方法实现,特别是获取锁的关键步骤tryAcquire()上。
一起来大致的了解下这两种实现,同时也加深下对ReentrantLock的理解:
公平锁(FairSync)的实现
公平锁在尝试获取锁时,会进行一个关键的检查:他会去判断等待队列里面是不是有比自己等待时间更长的线程。
lock()方法的核心流程如下:
调用acquire(1),在acquire(1)内部,首先调用tryAcquire(1)。
检查锁的当前状态。如果锁没被占用(state == 0),它会调用hasQueuedPredecessors方法。
hasQueuedPredecessors()会检查等待队列的头部后面是不是还有其他节点。如果有,意味着有线程比当前线程等待得更久,这个时候tryAcquire()会返回 false,当前线程不能获取锁。
如果没有其他等待的线程,他才会尝试通过CAS操作来获取锁。
如果锁已经被占用,但持有锁的是当前线程自己(可重入特性),就增加重入计数,并成功返回。
如果tryAcquire()失败,线程将被封装成一个节点加入到AQS等待队列的末尾,然后挂起,等待被前一个节点唤醒。
一句话总结,公平锁在获取锁之前,总会先看看前面有没有人在排队。
非公平锁(NonFairSync)的实现
非公平锁相对来说就更加激进,他会先尝试抢占锁,失败之后再去排队。
lock()方法的核心流程如下:
一来就尝试插队。立马尝试通过CAS把锁的状态从0变成1。如果成功,表示它插队成功,直接获取了锁。
如果插队失败(可能锁已经被占用,或者CAS操作失败),就调用acquire(1),进入和公平锁类似的流程。
可以看出非公平锁并没有调用hasQueuedPredecessors方法进行检查。
他只要发现锁是可用的(state == 0),就会直接通过CAS尝试获取锁。
如果锁已经被占用,但持有锁的是当前线程自己,就增加重入计数。
如果tryAcquire()失败,线程同样会被加入等待队列并挂起。
一句话总结,非公平锁会先冲上去插个队,抢不到在排队。
四、可重入锁和不可重入锁
可重入锁
可重入锁允许线程重复地获取他已经持有的锁。线程不会被自己阻塞。
可重入锁从设计上来说要稍微复杂一些,一般都会记录当前是哪个线程持有这个锁,另外有个计数器来记录当前线程获取该锁的次数。
lock的过程分三种情况:
锁没被占用,JVM会将锁的持有者设置成当前线程,然后把计数器设置成1。
锁已经被当前线程占用,JVM检查发现锁的持有者就是当前线程,直接把计数器加1,拿到锁,干后续的事情。
锁被其他线程占用了,当前线程就只能等待(阻塞),直到持有锁的线程释放锁。
unlock的过程:
一个线程执行解锁操作,JVM检查发现是锁的持有者线程在请求解锁,直接把计数器减1。
只有当计数器变成0的时候,才表示这个线程完全释放了锁。这个时候,锁的持有者被清空,其他等待的线程才有机会获取该锁。
package com.lazy.snail.day41;
import java.util.concurrent.locks.ReentrantLock;
/**
* @ClassName Day41Demo2
* @Description TODO
* @Author lazysnail
* @Date 2025/8/25 14:25
* @Version 1.0
*/
public class Day41Demo2 {
private ReentrantLock lock = new ReentrantLock();
public void increment() {
lock.lock(); // 线程t1获取锁,计数器变成1
try {
doSomething();
} finally {
lock.unlock(); // 线程t1释放锁,计数器变回0
}
}
public void doSomething() {
lock.lock(); // 线程t1是持有者,再次获取锁,计数器变成2
try {
// ...
} finally {
lock.unlock(); // 计数器变回1
}
}
}
上面的示例代码中,假设线程t1调用increment方法时:
线程t1调用lock.lock(),成功获取锁,锁的持有者是t1,被记录下来,计数器是1。
进doSomething()方法后,线程t1又调用lock.lock(),发现还是线程t1,直接把计数器增加到了2。
doSomething执行完,调用unlock后,计数器从2减成了1。
increment()执行完,调用unlock(),计数器从1减成0。这个时候锁完全释放。
Java中synchronized关键字是语言层面内置的锁,算是隐式的可重入锁。
当一个线程进入一个synchronized方法的时候,他可以自由地调用这个对象上其他的synchronized方法。
而ReentrantLock是JDK层面提供的API锁,显式地实现了可重入性。
不可重入锁
相对于可重入锁,不可重入锁从设计上来看,就简单一些。他只需要关注一件事,那就是这个锁当前有没有被某个线程所持有。一般只需要一个状态来标识锁是否被占用。
很多的博客或者教程当中会提到这样一个类"NonReentrantLock",其实这个类是不存在于JDK中的。
大多数情况下是为了讲不可重入锁时基于AQS自定义的一个类。
不可重入锁在实际开发的时候基本上用不上,因为很容易引发死锁。
绝大数需要加锁的场景中都没办法保证不会出现方法嵌套调用的情况。
就是因为死锁的风险太大,所以Java官方也没有把不可重入锁作为标准API提供给我们。
五、自旋锁和阻塞锁
这两个概念不像公平锁和可重入锁那样指代锁的具体类型,他其实描述的是线程在等待锁的时候的不同行为方式。
如果一个线程尝试获取一个已经被其他线程占用的锁时,他必须等待。怎么等待,就是自旋锁和阻塞锁的根本区别。
阻塞锁会让线程放弃CPU,进入阻塞状态。当锁被释放后,操作系统会唤醒这个线程,并将其重新放到就绪队列,等待CPU调度。
而自旋锁是不会放弃CPU的,他在一个循环里面,不停反复的检查锁是不是已经释放。线程在此期间一直都处于运行状态。
一起来看下阻塞锁代码示例:
package com.lazy.snail.day41;
import java.util.concurrent.locks.ReentrantLock;
/**
* @ClassName Day41Demo3
* @Description TODO
* @Author lazysnail
* @Date 2025/8/25 15:13
* @Version 1.0
*/
public class Day41Demo3 {
private static final ReentrantLock lock = new ReentrantLock();
public static void main(String[] args) {
new Thread(() -> {
lock.lock();
try {
System.out.println(Thread.currentThread().getName() + " 获得锁,开始长时间工作...");
Thread.sleep(5000);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
System.out.println(Thread.currentThread().getName() + " 释放锁。");
lock.unlock();
}
}, "线程1").start();
try { Thread.sleep(100); } catch (InterruptedException e) {}
System.out.println("线程2 准备获取锁...");
new Thread(() -> {
lock.lock();
try {
System.out.println(Thread.currentThread().getName() + " 获得了锁!");
} finally {
lock.unlock();
}
}, "线程2").start();
}
}
示例代码中线程1先拿到了锁,当线程2尝试去获取锁的时候,会被操作系统挂起,这个时候线程2会进入BLOCKED状态。它不会消耗任何CPU资源。5秒之后,当线程1释放锁,线程2被唤醒并获得锁。
我们再来模拟下自旋锁的情况:
package com.lazy.snail.day41;
import java.util.concurrent.atomic.AtomicReference;
/**
* @ClassName Day41Demo4
* @Description TODO
* @Author lazysnail
* @Date 2025/8/25 15:55
* @Version 1.0
*/
public class Day41Demo4 {
private static final SpinLockDemo spinLock = new SpinLockDemo();
public static void main(String[] args) {
new Thread(() -> {
spinLock.lock();
try {
System.out.println(Thread.currentThread().getName() + " 正在工作...");
Thread.sleep(2000);
} catch (InterruptedException e) {}
finally {
spinLock.unlock();
}
}, "线程A").start();
try { Thread.sleep(100); } catch (InterruptedException e) {}
new Thread(() -> {
System.out.println(Thread.currentThread().getName() + " 开始尝试获取自旋锁...");
spinLock.lock();
try {
System.out.println(Thread.currentThread().getName() + " 正在工作...");
} finally {
spinLock.unlock();
}
}, "线程B").start();
}
}
class SpinLockDemo {
private final AtomicReference<Thread> owner = new AtomicReference<>();
public void lock() {
Thread currentThread = Thread.currentThread();
while (!owner.compareAndSet(null, currentThread)) {
// 自旋,不释放CPU
}
System.out.println(currentThread.getName() + " 获得了自旋锁");
}
public void unlock() {
Thread currentThread = Thread.currentThread();
owner.compareAndSet(currentThread, null);
System.out.println(currentThread.getName() + " 释放了自旋锁");
}
}
当线程B调用spinLockDemo.lock()的时候,由于锁被线程A持有,owner.compareAndSet(null, currentThread)会一直失败。
线程B会卡在while循环里,一直执行这个指令,CPU的某个核心使用率会飙升。直到线程A调用unlock()把owner设回null,线程B才能跳出循环并获得锁。
六、无锁、偏向锁、轻量级锁、重量级锁
关于这几个概念的描述,已经在Day37 | 线程安全与synchronized中进行了详细的说明。
结语
本文从不同的维度对Java中的锁进行了分析整理。
在学习和使用的过程中,可以根据不同的特性,不同的应用场景,进行合理的选择。
同样有了这些概念,对于我们后续多线程的学习也大有帮助。
下一篇预告
Day42 | 关于Java中J.U.C的概述
如果你觉得这系列文章对你有帮助,欢迎关注专栏,我们一起坚持下去!
更多文章请关注我的公众号《懒惰蜗牛工坊》