Java并发 - Java中所有的锁
前言
Java提供了种类丰富的锁,每种锁因其特性的不同,在适当的场景下能够展现出非常高的效率。本文旨在对锁相关源码(本文中的源码来自JDK 8和Netty 3.10.6)、使用场景进行举例,为读者介绍主流锁的知识点,以及不同的锁的适用场景。
Java中往往是按照是否含有某一特性来定义锁,我们通过特性将锁进行分组归类,再使用对比的方式进行介绍,帮助大家更快捷的理解相关知识。下面给出本文内容的总体分类目录:

Java 中的锁机制是并发编程的核心,用于解决多线程环境下的资源竞争问题。根据不同的分类维度,Java 锁可以分为多种类型。本文将从 锁的特性、实现方式、锁的状态 等维度,全面梳理 Java 中所有的锁类型。
一、按锁的实现方式分类
1. 内置锁(Intrinsic Lock)- Synchronized
- 定义:Java 语言内置的锁机制,通过
synchronized关键字实现。 - 实现原理:基于 JVM 内置监视器锁(Monitor),依赖于底层操作系统的互斥量(Mutex)实现。
- 使用方式:
- 同步方法:
public synchronized void method() {} - 同步代码块:
synchronized (this) {} - 类锁:
synchronized (ClassName.class) {}
- 同步方法:
- 特性:
- 可重入锁(同一线程可多次获取同一锁);
- 非公平锁(线程获取锁的顺序不确定);
- 独占锁(同一时间仅一个线程可持有锁);
- 自动释放锁(方法或代码块执行完毕后自动释放)。
2. 显式锁(Explicit Lock)- Lock 接口
- 定义:Java 5 引入的
java.util.concurrent.locks包中的锁,需要手动获取和释放。 - 核心接口:
Lock:基础锁接口,定义了lock()、unlock()、tryLock()等方法;ReadWriteLock:读写锁接口,支持读共享、写独占;StampedLock:Java 8 引入的乐观读写锁,支持乐观读、悲观读、写锁。
- 特性:
- 手动获取和释放锁(需在
finally块中释放,避免死锁); - 支持中断锁获取(
lockInterruptibly()); - 支持超时获取锁(
tryLock(long time, TimeUnit unit)); - 支持公平 / 非公平锁配置。
- 手动获取和释放锁(需在
二、按锁的竞争策略分类
1. 公平锁(Fair Lock)
- 定义:线程获取锁的顺序严格按照请求顺序(FIFO),先请求的线程先获取锁。
- 实现:
ReentrantLock构造函数传入true:new ReentrantLock(true);ReentrantReadWriteLock构造函数传入true:new ReentrantReadWriteLock(true)。
- 优点:避免线程饥饿(长期无法获取锁);
- 缺点:性能较低(需要维护等待队列,线程唤醒开销大)。
公平锁是指多个线程按照申请锁的顺序来获取锁,线程直接进入队列中排队,队列中的第一个线程才能获得锁。
公平锁的优点是等待锁的线程不会饿死。
缺点是整体吞吐效率相对非公平锁要低,等待队列中除第一个线程以外的所有线程都会阻塞,CPU唤醒阻塞线程的开销比非公平锁大。
2. 非公平锁(Unfair Lock)
- 定义:线程获取锁的顺序不严格按照请求顺序,新请求的线程可能直接获取锁(插队)。
- 实现:
synchronized内置锁(默认非公平);ReentrantLock构造函数默认false:new ReentrantLock()(或new ReentrantLock(false))。
- 优点:性能较高(减少线程上下文切换,充分利用 CPU 资源);
- 缺点:可能导致线程饥饿(某些线程长期无法获取锁)。
非公平锁是多个线程加锁时直接尝试获取锁,获取不到才会到等待队列的队尾等待。
但如果此时锁刚好可用,那么这个线程可以无需阻塞直接获取到锁,所以非公平锁有可能出现后申请锁的线程先获取锁的场景。
非公平锁的优点是可以减少唤起线程的开销,整体的吞吐效率高,因为线程有几率不阻塞直接获得锁,CPU不必唤醒所有线程。缺点是处于等待队列中的线程可能会饿死,或者等很久才会获得锁。
3. 例子
下面一个例子来讲述一下公平锁和非公平锁。

如上图所示,假设有一口水井,有管理员看守,管理员有一把锁,只有拿到锁的人才能够打水,打完水要把锁还给管理员。
每个过来打水的人都要管理员的允许并拿到锁之后才能去打水,如果前面有人正在打水,那么这个想要打水的人就必须排队。
管理员会查看下一个要去打水的人是不是队伍里排最前面的人,如果是的话,才会给你锁让你去打水;如果你不是排第一的人,就必须去队尾排队,这就是公平锁。
但是对于非公平锁,管理员对打水的人没有要求。
即使等待队伍里有排队等待的人,但如果在上一个人刚打完水把锁还给管理员而且管理员还没有允许等待队伍里下一个人去打水时,刚好来了一个插队的人,这个插队的人是可以直接从管理员那里拿到锁去打水,不需要排队,原本排队等待的人只能继续等待。
如下图所示:

更多请参看JUC - ReentrantLock详解。
三、按锁的共享特性分类
1. 独享锁(独占锁/排他锁)
- 定义:同一时间仅允许一个线程持有锁,其他线程必须等待。
- 实现:
synchronized内置锁;ReentrantLock(默认独占);ReadWriteLock的写锁(writeLock())。
- 适用场景:写操作(修改共享资源)。
独享锁也叫排他锁,是指该锁一次只能被一个线程所持有。如果线程T对数据A加上排它锁后,则其他线程不能再对A加任何类型的锁。获得排它锁的线程即能读数据又能修改数据。JDK中的synchronized和JUC中Lock的实现类就是互斥锁。
2. 共享锁
- 定义:同一时间允许多个线程持有锁,仅当写线程请求时才会阻塞。
- 实现:
ReadWriteLock的读锁(readLock());StampedLock的乐观读和悲观读。
- 适用场景:读多写少的场景(如缓存、配置读取)。
共享锁是指该锁可被多个线程所持有。如果线程T对数据A加上共享锁后,则其他线程只能对A再加共享锁,不能加排它锁。获得共享锁的线程只能读数据,不能修改数据。
3. 读写锁
- 定义:结合了读共享和写独占的锁机制,支持多线程读、单线程写。
- 实现:
ReentrantReadWriteLock:可重入读写锁;StampedLock:乐观读写锁(Java 8+)。
- 特性:
- 读锁共享:多个线程可同时获取读锁;
- 写锁独占:写线程获取锁时,所有读线程和其他写线程必须等待;
- 读锁升级:读锁无法直接升级为写锁(避免死锁);
- 写锁降级:写锁可降级为读锁(需先获取写锁,再获取读锁,最后释放写锁)。
独享锁与共享锁也是通过AQS来实现的,通过实现不同的方法,来实现独享或者共享。
下图为ReentrantReadWriteLock的部分源码:

我们看到ReentrantReadWriteLock有两把锁:ReadLock和WriteLock,由词知意,一个读锁一个写锁,合称“读写锁”。
再进一步观察可以发现ReadLock和WriteLock是靠内部类Sync实现的锁。
Sync是AQS的一个子类,这种结构在CountDownLatch、ReentrantLock、Semaphore里面也都存在。
在ReentrantReadWriteLock里面,读锁和写锁的锁主体都是Sync,但读锁和写锁的加锁方式不一样。
读锁是共享锁,写锁是独享锁。读锁的共享锁可保证并发读非常高效,而读写、写读、写写的过程互斥,因为读锁和写锁是分离的。
所以ReentrantReadWriteLock的并发性相比一般的互斥锁有了很大提升。
更多请参看
四、按锁的可重入性分类
1. 可重入锁(Reentrant Lock)
可重入锁又名递归锁,是指在同一个线程在外层方法获取锁的时候,再进入该线程的内层方法会自动获取锁(前提锁对象得是同一个对象或者class),不会因为之前已经获取过还没释放而阻塞。
- 定义:同一线程可多次获取同一锁,不会导致死锁。
- 实现:
synchronized内置锁(可重入);ReentrantLock(可重入,名称中的Reentrant即表示可重入);ReentrantReadWriteLock(读锁和写锁均支持可重入)。
- 原理:锁内部维护了线程 ID 和重入次数,同一线程获取锁时,重入次数加 1,释放锁时重入次数减 1,直到重入次数为 0 时才真正释放锁。
- 优点:避免同一线程多次获取锁导致的死锁;
- 适用场景:递归调用、父子方法均需要同步的场景。
2. 不可重入锁
定义:同一线程无法多次获取同一锁,第二次获取时会阻塞。
实现:Java 标准库中没有直接提供不可重入锁,需自定义实现。
示例:
class NonReentrantLock { private boolean isLocked = false; public synchronized void lock() throws InterruptedException { while (isLocked) { wait(); } isLocked = true; } public synchronized void unlock() { isLocked = false; notify(); } }缺点:容易导致死锁(同一线程递归调用时);
适用场景:极少使用,主要用于特殊的同步需求。
Java中ReentrantLock和synchronized都是可重入锁,可重入锁的一个优点是可一定程度避免死锁。
下面用示例代码来进行分析:
public class Widget {
public synchronized void doSomething() {
System.out.println("方法1执行...");
doOthers();
}
public synchronized void doOthers() {
System.out.println("方法2执行...");
}
}
在上面的代码中,类中的两个方法都是被内置锁synchronized修饰的,doSomething()方法中调用doOthers()方法。因为内置锁是可重入的,所以同一个线程在调用doOthers()时可以直接获得当前对象的锁,进入doOthers()进行操作。
如果是一个不可重入锁,那么当前线程在调用doOthers()之前需要将执行doSomething()时获取当前对象的锁释放掉,实际上该对象锁已被当前线程所持有,且无法释放,所以此时会出现死锁。
而为什么可重入锁就可以在嵌套调用时可以自动获得锁呢?我们通过图示和源码来分别解析一下。
还是打水的例子,有多个人在排队打水,此时管理员允许锁和同一个人的多个水桶绑定。
这个人用多个水桶打水时,第一个水桶和锁绑定并打完水之后,第二个水桶也可以直接和锁绑定并开始打水,所有的水桶都打完水之后打水人才会将锁还给管理员。
这个人的所有打水流程都能够成功执行,后续等待的人也能够打到水。这就是可重入锁。

但如果是非可重入锁的话,此时管理员只允许锁和同一个人的一个水桶绑定。
第一个水桶和锁绑定打完水之后并不会释放锁,导致第二个水桶不能和锁绑定也无法打水。当前线程出现死锁,整个等待队列中的所有线程都无法被唤醒。

之前我们说过ReentrantLock和synchronized都是重入锁,那么我们通过重入锁ReentrantLock以及非可重入锁NonReentrantLock的源码来对比分析一下为什么非可重入锁在重复调用同步资源时会出现死锁。
首先ReentrantLock和NonReentrantLock都继承父类AQS,其父类AQS中维护了一个同步状态status来计数重入次数,status初始值为0。
当线程尝试获取锁时,可重入锁先尝试获取并更新status值,如果status == 0表示没有其他线程在执行同步代码,则把status置为1,当前线程开始执行。如果status != 0,则判断当前线程是否是获取到这个锁的线程,如果是的话执行status+1,且当前线程可以再次获取锁。而非可重入锁是直接去获取并尝试更新当前status的值,如果status != 0的话会导致其获取锁失败,当前线程阻塞。
释放锁时,可重入锁同样先获取当前status的值,在当前线程是持有锁的线程的前提下。如果status-1 == 0,则表示当前线程所有重复获取锁的操作都已经执行完毕,然后该线程才会真正释放锁。而非可重入锁则是在确定当前线程是持有锁的线程之后,直接将status置为0,将锁释放。

更多请参看:
五、按锁的获取方式分类
1. 悲观锁(Pessimistic Lock)
- 定义:假设每次操作都会发生并发冲突,因此在操作前先获取锁,确保独占资源。
- 实现:
synchronized内置锁;ReentrantLock;ReadWriteLock的读锁和写锁。
- 适用场景:写操作频繁、并发冲突严重的场景。
2. 乐观锁(Optimistic Lock)
- 定义:假设每次操作不会发生并发冲突,仅在提交操作时检查是否有冲突,若有冲突则重试。
- 实现:
- CAS(Compare-And-Swap)算法:Java 中的
Atomic类(如AtomicInteger、AtomicReference); - 版本号机制:数据库乐观锁的常用实现;
StampedLock的乐观读(tryOptimisticRead())。
- CAS(Compare-And-Swap)算法:Java 中的
- 特性:
- 无锁机制(无需获取锁,减少线程上下文切换);
- 依赖硬件支持(CAS 是 CPU 指令,原子性操作);
- 可能导致 ABA 问题(可通过版本号解决)。
- 适用场景:读操作频繁、并发冲突较少的场景。
乐观锁与悲观锁是一种广义上的概念,体现了看待线程同步的不同角度。在Java和数据库中都有此概念对应的实际应用。
对于同一个数据的并发操作,悲观锁认为自己在使用数据的时候一定有别的线程来修改数据,因此在获取数据的时候会先加锁,确保数据不会被别的线程修改。Java中,synchronized关键字和Lock的实现类都是悲观锁。
而乐观锁认为自己在使用数据时不会有别的线程修改数据,所以不会添加锁,只是在更新数据的时候去判断之前有没有别的线程更新了这个数据。如果这个数据没有被更新,当前线程将自己修改的数据成功写入。如果数据已经被其他线程更新,则根据不同的实现方式执行不同的操作(例如报错或者自动重试)。
乐观锁在Java中是通过使用无锁编程来实现,最常采用的是CAS算法,Java原子类中的递增操作就通过CAS自旋实现的。

根据从上面的概念描述我们可以发现:
悲观锁适合写操作多的场景,先加锁可以保证写操作时数据正确。
乐观锁适合读操作多的场景,不加锁的特点能够使其读操作的性能大幅提升。
光说概念有些抽象,我们来看下乐观锁和悲观锁的调用方式示例:
// ------------------------- 悲观锁的调用方式 -------------------------
// synchronized
public synchronized void testMethod() {
// 操作同步资源
}
// ReentrantLock
private ReentrantLock lock = new ReentrantLock(); // 需要保证多个线程使用的是同一个锁
public void modifyPublicResources() {
lock.lock();
// 操作同步资源
lock.unlock();
}
// ------------------------- 乐观锁的调用方式 -------------------------
private AtomicInteger atomicInteger = new AtomicInteger(); // 需要保证多个线程使用的是同一个AtomicInteger
atomicInteger.incrementAndGet(); //执行自增1
通过调用方式示例,我们可以发现悲观锁基本都是在显式的锁定之后再操作同步资源,而乐观锁则直接去操作同步资源。那么,为何乐观锁能够做到不锁定同步资源也可以正确的实现线程同步呢?具体可以参看JUC原子类: CAS, Unsafe和原子类详解。
六、按锁的状态分类(Java 对象头中的锁状态)
这些是 JVM 优化 synchronized 锁的几种状态。
Java 对象头中包含锁的状态信息,JVM 会根据竞争情况自动升级锁的状态,从低到高依次为:
1. 无锁状态
- 定义:对象未被任何线程锁定,可自由访问。
- 适用场景:单线程环境或并发冲突极少的场景。
2. 偏向锁(Biased Locking)
- 定义:当只有一个线程多次获取锁时,JVM 会将锁偏向该线程,减少锁获取的开销。
- 实现原理:对象头中存储偏向线程 ID,后续该线程获取锁时,只需检查线程 ID 是否匹配,无需 CAS 操作。
- 升级条件:当有其他线程尝试获取锁时,偏向锁会升级为轻量级锁。
- 优点:减少单线程环境下的锁开销;
- 缺点:多线程竞争时,偏向锁的撤销会带来额外开销。
- 开启 / 关闭:
- 开启:
-XX:+UseBiasedLocking(Java 6+ 默认开启); - 关闭:
-XX:-UseBiasedLocking。
- 开启:
3. 轻量级锁(Lightweight Locking)
- 定义:当多个线程交替获取锁时,JVM 会使用轻量级锁,通过 CAS 操作尝试获取锁。
- 实现原理:线程在栈帧中创建锁记录(Lock Record),将对象头中的 Mark Word 复制到锁记录,然后通过 CAS 尝试将对象头中的 Mark Word 替换为指向锁记录的指针。
- 升级条件:当 CAS 操作失败(表示有线程竞争),轻量级锁会升级为重量级锁。
- 优点:减少线程上下文切换(无需进入内核态);
- 缺点:多线程同时竞争时,CAS 自旋会消耗 CPU 资源。
4. 重量级锁(Heavyweight Locking)
- 定义:当多个线程同时竞争锁时,JVM 会使用重量级锁,依赖底层操作系统的互斥量实现。
- 实现原理:线程获取锁失败时,会进入阻塞状态(内核态),等待其他线程释放锁后唤醒。
- 优点:适合多线程同时竞争的场景,避免 CPU 自旋消耗;
- 缺点:线程上下文切换开销大(用户态 ↔ 内核态)。
七、其他特殊类型的锁
1. 自旋锁(Spin Lock)
定义:线程获取锁失败时,不会立即阻塞,而是通过循环(自旋)尝试获取锁,减少线程上下文切换的开销。
实现:
轻量级锁的底层实现(CAS 自旋);
Atomic类的 CAS 操作;自定义自旋锁:
class SpinLock {
private AtomicReference<Thread> owner = new AtomicReference<>();
public void lock() {
Thread current = Thread.currentThread();
// 自旋尝试获取锁
while (!owner.compareAndSet(null, current)) {
// 空循环,自旋
}
}
public void unlock() {
Thread current = Thread.currentThread();
owner.compareAndSet(current, null);
}
}
优点:减少线程上下文切换(适用于锁持有时间短的场景);
缺点:自旋会消耗 CPU 资源(适用于多核 CPU)。
2. 自适应自旋锁(Adaptive Locking)
- 定义:自旋次数不固定,根据前一次自旋的结果动态调整。
- 实现:JVM 内置实现(轻量级锁的自旋优化);
- 原理:若前一次自旋成功获取锁,则增加自旋次数;若失败,则减少自旋次数或直接阻塞。
- 优点:根据实际情况动态调整,平衡 CPU 消耗和阻塞开销。
在介绍自旋锁前,我们需要介绍一些前提知识来帮助大家明白自旋锁的概念。
阻塞或唤醒一个Java线程需要操作系统切换CPU状态来完成,这种状态转换需要耗费处理器时间。如果同步代码块中的内容过于简单,状态转换消耗的时间有可能比用户代码执行的时间还要长。
在许多场景中,同步资源的锁定时间很短,为了这一小段时间去切换线程,线程挂起和恢复现场的花费可能会让系统得不偿失。如果物理机器有多个处理器,能够让两个或以上的线程同时并行执行,我们就可以让后面那个请求锁的线程不放弃CPU的执行时间,看看持有锁的线程是否很快就会释放锁。
而为了让当前线程“稍等一下”,我们需让当前线程进行自旋,如果在自旋完成后前面锁定同步资源的线程已经释放了锁,那么当前线程就可以不必阻塞而是直接获取同步资源,从而避免切换线程的开销。这就是自旋锁。

自旋锁本身是有缺点的,它不能代替阻塞。自旋等待虽然避免了线程切换的开销,但它要占用处理器时间。如果锁被占用的时间很短,自旋等待的效果就会非常好。反之,如果锁被占用的时间很长,那么自旋的线程只会白浪费处理器资源。所以,自旋等待的时间必须要有一定的限度,如果自旋超过了限定次数(默认是10次,可以使用-XX:PreBlockSpin来更改)没有成功获得锁,就应当挂起线程。
自旋锁的实现原理同样也是CAS,AtomicInteger中调用unsafe进行自增操作的源码中的do-while循环就是一个自旋操作,如果修改数值失败则通过循环来执行自旋,直至修改成功。
自旋锁相关可以看关键字 - synchronized详解 - 自旋锁与自适应自旋锁
自旋锁时间阈值
自旋锁的目的是为了占着CPU的资源不释放,等到获取到锁立即进行处理。但是如何去选择自旋的执行时间呢?如果自旋执行时间太长,会有大量的线程处于自旋状态占用CPU资源,进而会影响整体系统的性能。因此自旋的周期选的额外重要!
JVM对于自旋周期的选择,jdk1.5这个限度是一定的写死的,在1.6引入了适应性自旋锁,适应性自旋锁意味着自旋的时间不在是固定的了,而是由前一次在同一个锁上的自旋时间以及锁的拥有者的状态来决定,基本认为一个线程上下文切换的时间是最佳的一个时间,同时JVM还针对当前CPU的负荷情况做了较多的优化
- 如果平均负载小于CPUs则一直自旋
- 如果有超过(CPUs/2)个线程正在自旋,则后来线程直接阻塞
- 如果正在自旋的线程发现Owner发生了变化则延迟自旋时间(自旋计数)或进入阻塞
- 如果CPU处于节电模式则停止自旋
- 自旋时间的最坏情况是CPU的存储延迟(CPU A存储了一个数据,到CPU B得知这个数据直接的时间差)
- 自旋时会适当放弃线程优先级之间的差异
自旋锁的开启
- JDK1.6中-XX:+UseSpinning开启
- -XX:PreBlockSpin=10 为自旋次数
- JDK1.7后,去掉此参数,由jvm控制
3. 分段锁(Striped Locking)
- 定义:将共享资源分为多个段,每个段独立加锁,支持多线程同时访问不同段。
- 实现:
ConcurrentHashMap(Java 7 及之前的版本); - 原理:
ConcurrentHashMap将数组分为多个 Segment,每个 Segment 独立加锁,支持多线程同时读写不同 Segment; - 优点:提高并发度(多线程可同时操作不同段);
- 缺点:实现复杂,内存开销大(Java 8 中
ConcurrentHashMap已改用 CAS + synchronized 实现,不再使用分段锁)。
4. 公平读写锁
- 定义:读写锁的一种,严格按照请求顺序分配锁,支持读共享、写独占。
- 实现:
ReentrantReadWriteLock构造函数传入true:new ReentrantReadWriteLock(true); - 特性:
- 读锁和写锁均遵循公平策略;
- 写锁优先级高于读锁(避免写线程饥饿)。
5. 非公平读写锁
- 定义:读写锁的一种,不严格按照请求顺序分配锁,新请求的线程可能直接获取锁。
- 实现:
ReentrantReadWriteLock构造函数默认false:new ReentrantReadWriteLock(); - 特性:
- 读锁和写锁均遵循非公平策略;
- 性能高于公平读写锁。
6. StampedLock(乐观读写锁)
- 定义:Java 8 引入的乐观读写锁,支持乐观读、悲观读、写锁三种模式。
- 核心方法:
tryOptimisticRead():获取乐观读戳记(无锁);validate(long stamp):验证乐观读期间是否有写操作;readLock():获取悲观读锁;writeLock():获取写锁;tryConvertToWriteLock(long stamp):尝试将读锁转换为写锁。
- 特性:
- 乐观读模式下无锁开销,性能极高;
- 支持锁转换(读锁 → 写锁、写锁 → 读锁);
- 不可重入(同一线程多次获取锁会导致死锁);
- 适用场景:读多写少、写操作持续时间短的场景。
7. 互斥锁
互斥锁与悲观锁、独占锁同义,表示某个资源只能被一个线程访问,其他线程不能访问。
- 读-读互斥
- 读-写互斥
- 写-读互斥
- 写-写互斥
Java中的互斥锁: synchronized
8. 同步锁
同步锁与互斥锁同义,表示并发执行的多个线程,在同一时间内只允许一个线程访问共享数据。
Java中的同步锁: synchronized
八、Java 各种锁
| 序号 | 锁名称 | 应用 |
|---|---|---|
| 1 | 乐观锁 | CAS |
| 2 | 悲观锁 | synchronized、vector、hashtable |
| 3 | 自旋锁 | CAS |
| 4 | 可重入锁 | synchronized、Reentrantlock、Lock |
| 5 | 读写锁 | ReentrantReadWriteLock,CopyOnWriteArrayList、CopyOnWriteArraySet |
| 6 | 公平锁 | Reentrantlock(true) |
| 7 | 非公平锁 | synchronized、reentrantlock(false) |
| 8 | 共享锁 | ReentrantReadWriteLock中读锁 |
| 9 | 独占锁 | synchronized、vector、hashtable、ReentrantReadWriteLock中写锁 |
| 10 | 重量级锁 | synchronized |
| 11 | 轻量级锁 | 锁优化技术 |
| 12 | 偏向锁 | 锁优化技术 |
| 13 | 分段锁 | concurrentHashMap |
| 14 | 互斥锁 | synchronized |
| 15 | 同步锁 | synchronized |
| 16 | 死锁 | 相互请求对方的资源 |
| 17 | 锁粗化 | 锁优化技术 |
| 18 | 锁消除 | 锁优化技术 |
九、Java 锁的比较与选择
| 锁类型 | 实现方式 | 可重入 | 公平性 | 适用场景 |
|---|---|---|---|---|
| synchronized | 内置锁 | 是 | 非公平 | 简单同步场景,性能要求不高 |
| ReentrantLock | Lock 接口 | 是 | 可配置(默认非公平) | 复杂同步场景,需要中断、超时、公平锁 |
| ReentrantReadWriteLock | ReadWriteLock 接口 | 是 | 可配置 | 读多写少的场景 |
| StampedLock | Lock 接口 | 否 | 非公平 | 读极多写极少的场景,追求极致性能 |
| Atomic 类 | CAS 算法 | 无锁 | 无 | 简单原子操作(如计数、状态更新) |
| ConcurrentHashMap | CAS + synchronized | 无锁 | 无 | 高并发哈希表操作 |
十、锁优化
减少锁的时间
不需要同步执行的代码,能不放在同步快里面执行就不要放在同步快内,可以让锁尽快释放;
减少锁的粒度
它的思想是将物理上的一个锁,拆成逻辑上的多个锁,增加并行度,从而降低锁竞争。它的思想也是用空间来换时间;
java中很多数据结构都是采用这种方法提高并发操作的效率:
ConcurrentHashMap
java中的ConcurrentHashMap在jdk1.8之前的版本,使用一个Segment 数组
Segment< K,V >[] segments
Segment继承自ReenTrantLock,所以每个Segment就是个可重入锁,每个Segment 有一个HashEntry< K,V >数组用来存放数据,put操作时,先确定往哪个Segment放数据,只需要锁定这个Segment,执行put,其它的Segment不会被锁定;所以数组中有多少个Segment就允许同一时刻多少个线程存放数据,这样增加了并发能力。
LongAdder
LongAdder 实现思路也类似ConcurrentHashMap,LongAdder有一个根据当前并发状况动态改变的Cell数组,Cell对象里面有一个long类型的value用来存储值; 开始没有并发争用的时候或者是cells数组正在初始化的时候,会使用cas来将值累加到成员变量的base上,在并发争用的情况下,LongAdder会初始化cells数组,在Cell数组中选定一个Cell加锁,数组有多少个cell,就允许同时有多少线程进行修改,最后将数组中每个Cell中的value相加,在加上base的值,就是最终的值;cell数组还能根据当前线程争用情况进行扩容,初始长度为2,每次扩容会增长一倍,直到扩容到大于等于cpu数量就不再扩容,这也就是为什么LongAdder比cas和AtomicInteger效率要高的原因,后面两者都是volatile+cas实现的,他们的竞争维度是1,LongAdder的竞争维度为“Cell个数+1”为什么要+1?因为它还有一个base,如果竞争不到锁还会尝试将数值加到base上;
LinkedBlockingQueue
LinkedBlockingQueue也体现了这样的思想,在队列头入队,在队列尾出队,入队和出队使用不同的锁,相对于LinkedBlockingArray只有一个锁效率要高;
拆锁的粒度不能无限拆,最多可以将一个锁拆为当前cup数量个锁即可;
锁粗化
大部分情况下我们是要让锁的粒度最小化,锁的粗化则是要增大锁的粒度; 在以下场景下需要粗化锁的粒度: 假如有一个循环,循环内的操作需要加锁,我们应该把锁放到循环外面,否则每次进出循环,都进出一次临界区,效率是非常差的;
使用读写锁
ReentrantReadWriteLock 是一个读写锁,读操作加读锁,可以并发读,写操作使用写锁,只能单线程写;
读写分离
CopyOnWriteArrayList 、CopyOnWriteArraySet CopyOnWrite容器即写时复制的容器。通俗的理解是当我们往一个容器添加元素的时候,不直接往当前容器添加,而是先将当前容器进行Copy,复制出一个新的容器,然后新的容器里添加元素,添加完元素之后,再将原容器的引用指向新的容器。这样做的好处是我们可以对CopyOnWrite容器进行并发的读,而不需要加锁,因为当前容器不会添加任何元素。所以CopyOnWrite容器也是一种读写分离的思想,读和写不同的容器。 CopyOnWrite并发容器用于读多写少的并发场景,因为,读的时候没有锁,但是对其进行更改的时候是会加锁的,否则会导致多个线程同时复制出多个副本,各自修改各自的;
使用cas
如果需要同步的操作执行速度非常快,并且线程竞争并不激烈,这时候使用cas效率会更高,因为加锁会导致线程的上下文切换,如果上下文切换的耗时比同步操作本身更耗时,且线程对资源的竞争不激烈,使用volatiled+cas操作会是非常高效的选择;
消除缓存行的伪共享
除了我们在代码中使用的同步锁和jvm自己内置的同步锁外,还有一种隐藏的锁就是缓存行,它也被称为性能杀手。 在多核cup的处理器中,每个cup都有自己独占的一级缓存、二级缓存,甚至还有一个共享的三级缓存,为了提高性能,cpu读写数据是以缓存行为最小单元读写的;32位的cpu缓存行为32字节,64位cup的缓存行为64字节,这就导致了一些问题。 例如,多个不需要同步的变量因为存储在连续的32字节或64字节里面,当需要其中的一个变量时,就将它们作为一个缓存行一起加载到某个cup-1私有的缓存中(虽然只需要一个变量,但是cpu读取会以缓存行为最小单位,将其相邻的变量一起读入),被读入cpu缓存的变量相当于是对主内存变量的一个拷贝,也相当于变相的将在同一个缓存行中的几个变量加了一把锁,这个缓存行中任何一个变量发生了变化,当cup-2需要读取这个缓存行时,就需要先将cup-1中被改变了的整个缓存行更新回主存(即使其它变量没有更改),然后cup-2才能够读取,而cup-2可能需要更改这个缓存行的变量与cpu-1已经更改的缓存行中的变量是不一样的,所以这相当于给几个毫不相关的变量加了一把同步锁; 为了防止伪共享,不同jdk版本实现方式是不一样的:
- 在jdk1.7之前会 将需要独占缓存行的变量前后添加一组long类型的变量,依靠这些无意义的数组的填充做到一个变量自己独占一个缓存行;
- 在jdk1.7因为jvm会将这些没有用到的变量优化掉,所以采用继承一个声明了好多long变量的类的方式来实现;
- 在jdk1.8中通过添加sun.misc.Contended注解来解决这个问题,若要使该注解有效必须在jvm中添加以下参数: -XX:-RestrictContended
sun.misc.Contended注解会在变量前面添加128字节的padding将当前变量与其他变量进行隔离; 关于什么是缓存行,jdk是如何避免缓存行的,网上有非常多的解释,在这里就不再深入讲解了;
十、总结
Java本身已经对锁本身进行了良好的封装,降低了研发同学在平时工作中的使用难度。但是研发同学也需要熟悉锁的底层原理,不同场景下选择最适合的锁。而且源码中的思路都是非常好的思路,也是值得大家去学习和借鉴的。
Java 中的锁机制丰富多样,每种锁都有其适用场景。选择合适的锁类型需要考虑以下因素:
- 并发程度:高并发场景优先选择乐观锁、共享锁;
- 读写比例:读多写少优先选择读写锁、StampedLock;
- 锁持有时间:锁持有时间短优先选择自旋锁、轻量级锁;
- 公平性要求:需要避免线程饥饿优先选择公平锁;
- 代码复杂度:简单场景优先选择 synchronized,复杂场景选择 ReentrantLock。
资料
- Java中的21种锁
- Java中有多少种类型的锁?
- java 中的锁 -- 偏向锁、轻量级锁、自旋锁、重量级锁
- 《Java并发编程艺术》
- Java中的锁
- Java CAS 原理剖析
- Java并发——关键字synchronized解析
- Java synchronized原理总结
- 聊聊并发(二)——Java SE1.6中的Synchronized
- 深入理解读写锁—ReadWriteLock源码分析
- 【JUC】JDK1.8源码分析之ReentrantReadWriteLock
- Java多线程(十)之ReentrantReadWriteLock深入分析
- Java–读写锁的实现原理