Lock 锁 | JavaSE

Lock 锁

一、学习目标

完成本章后,你应该能够:

  • 理解 Lock 与 Java 内置 synchronized 的关系。
  • 理解 Lock 是接口,掌握最常用实现类 ReentrantLock
  • 独立使用 lock()unlock() 保护共享资源。
  • 理解为什么 unlock() 必须放在 finally 中。
  • 理解锁对象为什么通常应该设计成 private final
  • 理解 ReentrantLock 中“Reentrant”的含义。
  • 了解 tryLock()、定时 tryLock()lockInterruptibly() 等显式锁能力。
  • 了解公平锁与非公平锁的基本概念。
  • 能够比较 synchronizedReentrantLock
  • 理解死锁的形成机制,并掌握最基本的避免原则。

二、核心知识

2.1 为什么已经有 synchronized,还需要 Lock

上一章已经可以:

synchronized (this) {

    // 临界区
}

解决线程安全问题。

synchronized 最大的特点之一是:

使用简单
↓
获得锁由 JVM 管理
↓
退出同步区域时 JVM 自动释放锁

但是有些并发场景希望拥有更加灵活的控制能力:

能不能尝试一下,拿不到锁就不等?

能不能最多等待3秒?

能不能等待锁时响应线程中断?

能不能选择公平获取锁?

能不能拥有多个等待条件?

Java 在:

java.util.concurrent.locks

包中提供了显式锁框架。

其中最核心的是:

Lock

接口。

最常见实现:

ReentrantLock

2.2 Lock 是接口

导包:

import java.util.concurrent.locks.Lock;

Lock 表示:

一种显式的锁操作抽象。

常用方法包括:

| 方法 | 作用 | | --------------------- | ---------------------- | | lock() | 获得锁,必要时等待 | | unlock() | 释放锁 | | tryLock() | 尝试立即获得锁 | | tryLock(time, unit) | 在一定时间内尝试获得锁 | | lockInterruptibly() | 可响应中断地等待获得锁 | | newCondition() | 创建与锁关联的条件对象 |

本课程基础阶段首先必须掌握:

lock()
unlock()

2.3 ReentrantLock

Lock 是接口,所以不能:

new Lock();

通常使用:

ReentrantLock

创建对象:

Lock lock =
        new ReentrantLock();

完整导包:

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

2.4 ReentrantLock 的基本执行模型

代码:

lock.lock();

try {

    // 临界区

} finally {

    lock.unlock();
}

执行过程:

线程到达 lock.lock()
        ↓
尝试获得锁
        ↓

成功?
├── 是
│    ↓
│   进入临界区
│    ↓
│   执行业务
│    ↓
│   finally
│    ↓
│   unlock()
│
└── 否
     ↓
    等待

这与:

synchronized (lockObject) {

}

都能够形成:

互斥访问

但是 Lock 的:

加锁
解锁

由程序员显式控制。


三、使用方法

3.1 使用 Lock 重写银行账户

原教学案例使用:

private final Lock lock =
        new ReentrantLock();

账户:

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class Account {

    private final String cardId;
    private double money;

    private final Lock lock =
            new ReentrantLock();

    public Account(
            String cardId,
            double money
    ) {
        this.cardId = cardId;
        this.money = money;
    }

    public void drawMoney(
            double money
    ) {

        String name =
                Thread.currentThread()
                      .getName();

        lock.lock();

        try {

            if (this.money >= money) {

                System.out.println(
                        name
                        + "取钱成功,取出"
                        + money
                        + "元"
                );

                this.money -= money;

                System.out.println(
                        name
                        + "取钱后余额:"
                        + this.money
                );

            } else {

                System.out.println(
                        name
                        + "取钱失败,余额不足"
                );
            }

        } finally {

            lock.unlock();
        }
    }

    public double getMoney() {
        return money;
    }
}

核心就是:

lock.lock();

try {

    // 操作共享资源

} finally {

    lock.unlock();
}

3.2 为什么锁对象要共享

和 synchronized 完全一样:

多个竞争线程
↓
必须竞争同一把 ReentrantLock

如果:

public void drawMoney() {

    Lock lock =
            new ReentrantLock();

    lock.lock();

    ...
}

每个线程进入方法以后:

线程A → new ReentrantLock A
线程B → new ReentrantLock B

两个人根本没有竞争同一个锁。

所以不能实现:

互斥

正确做法通常是:

private final Lock lock =
        new ReentrantLock();

让这个锁与需要保护的对象长期绑定。


3.3 为什么建议 final

代码:

private final Lock lock =
        new ReentrantLock();

final 的意义是:

这个字段以后不能重新指向另一把锁

避免:

lock = new ReentrantLock();

这种危险操作。

假设:

线程A持有旧锁

此时程序把字段改成新锁:

线程B拿新锁

那么:

A和B又能同时进入

整个互斥设计就被破坏了。

因此:

用于保护固定共享状态的锁对象,通常应该保持稳定。


3.4 为什么 unlock() 必须放 finally

错误写法:

lock.lock();

if (money >= amount) {

    money -= amount;

}

lock.unlock();

看起来正常。

但是如果临界区出现:

RuntimeException

程序可能直接跳过:

lock.unlock();

结果:

线程A已经持有lock
↓
业务异常
↓
没有unlock
↓
其他线程永远等锁

所以必须:

lock.lock();

try {

    // 临界区

} finally {

    lock.unlock();
}

无论:

正常执行
还是异常退出

finally 都负责释放锁。

这是使用显式 Lock 时必须形成的肌肉记忆:

lock
↓
try
↓
finally unlock

3.5 为什么不能把 lock() 也随便放进 try

最常见标准结构是:

lock.lock();

try {

    // 临界区

} finally {

    lock.unlock();
}

思想是:

已经成功获得锁以后,才进入负责释放它的 try/finally 区域。

尤其当使用一些可能失败或被中断的锁获取方法时,必须确保:

只有真正获得锁
↓
才去 unlock

否则可能出现:

当前线程根本没持有锁
↓
却执行 unlock()

这样的错误。


四、原理与进阶

4.1 ReentrantLock 为什么叫“可重入锁”

Reentrant:

Re-entrant
↓
可重新进入

表示:

同一个线程已经获得这把锁以后,可以再次获得同一把锁。

例如:

private final ReentrantLock lock =
        new ReentrantLock();

public void methodA() {

    lock.lock();

    try {

        methodB();

    } finally {

        lock.unlock();
    }
}

public void methodB() {

    lock.lock();

    try {

        System.out.println("B");

    } finally {

        lock.unlock();
    }
}

线程:

T1

进入:

methodA
↓
获得lock

然后:

methodA
↓
调用methodB
↓
再次lock

不会因为:

“锁已经有人拿了”

而把自己阻塞。

因为:

拿锁的人就是T1自己

4.2 可重入不是“多拿几次都不用还”

虽然同一线程可以多次:

lock.lock();

但内部会维护:

持有次数

例如:

第一次 lock
持有次数 = 1

第二次 lock
持有次数 = 2

那么:

第一次 unlock
持有次数 = 1

锁仍然属于当前线程。

必须:

第二次 unlock
持有次数 = 0

才真正完全释放。

所以:

lock几次
原则上就要对应unlock几次

4.3 synchronized 其实也是可重入的

不要误认为:

只有ReentrantLock可重入

上一章已经知道:

synchronized

的 Monitor 也支持同一个线程重复获得。

所以:

ReentrantLock

这个名字强调了它的特性,但:

可重入

并不是它相对于 synchronized 的独占优势。

它的真正优势是:

显式锁控制
+
更多扩展能力

4.4 tryLock():拿不到就不等

普通:

lock.lock();

如果锁被别人持有:

当前线程等待

而:

lock.tryLock()

表示:

现在尝试一次获取锁。

返回:

boolean

例如:

if (lock.tryLock()) {

    try {

        System.out.println(
                "获得锁,执行任务"
        );

    } finally {

        lock.unlock();
    }

} else {

    System.out.println(
            "锁正在被别人占用"
    );
}

特点:

获得成功
→ true

当前拿不到
→ false
→ 立即返回

这比:

lock()

更加灵活。


4.5 定时 tryLock()

还可以:

lock.tryLock(
        3,
        TimeUnit.SECONDS
);

表示:

最多等待指定时间尝试获取锁。

例如:

boolean acquired = false;

try {

    acquired =
            lock.tryLock(
                    3,
                    TimeUnit.SECONDS
            );

    if (!acquired) {

        System.out.println(
                "3秒内没有获得锁"
        );

        return;
    }

    // 临界区

} catch (InterruptedException e) {

    Thread.currentThread()
          .interrupt();

} finally {

    if (acquired) {
        lock.unlock();
    }
}

它比普通:

synchronized

提供了更加显式的:

超时控制

能力。


4.6 lockInterruptibly()

普通等待锁:

lock.lock();

一旦因为别人持锁而等待,程序希望能够:

响应中断

可以:

lock.lockInterruptibly();

例如:

try {

    lock.lockInterruptibly();

    try {

        // 临界区

    } finally {

        lock.unlock();
    }

} catch (InterruptedException e) {

    Thread.currentThread()
          .interrupt();
}

这样:

线程正在等待锁
↓
收到 interrupt
↓
可以抛出 InterruptedException

这对于:

任务取消
超时控制
避免无限等待

等复杂并发场景更灵活。


4.7 公平锁与非公平锁

默认:

new ReentrantLock()

相当于:

new ReentrantLock(false)

也就是默认:

非公平锁

还可以:

new ReentrantLock(true)

创建:

公平锁

4.8 什么叫公平

假设:

T1
T2
T3
T4

都在等锁。

公平锁会尽量:

优先照顾等待时间更长的线程

形成更接近:

先来先服务

的访问策略。

非公平锁则不保证:

等待最久的一定最先拿到

新来的线程有可能在恰当时机直接获得锁。


4.9 公平是不是一定更好

不是。

公平通常意味着:

调度与排队管理成本增加

可能降低整体吞吐量。

因此:

公平

不是:

高级版本

也不是:

永远应该开启

默认 ReentrantLock 使用非公平模式,就是因为很多场景更关注:

整体吞吐能力

而不是绝对的排队顺序。

同时:

锁的公平性也不等于整个操作系统线程调度一定公平。


五、Lock 与 synchronized 对比

5.1 共同点

两者都可以实现:

互斥访问共享资源

都可以保护:

临界区

都具有:

可重入

的基本能力。

正确使用时,都可以建立必要的线程间内存同步关系。


5.2 synchronized 的特点

synchronized (lock) {

}

优点:

语法简单
JVM自动管理释放
异常退出也自动解锁
不容易漏掉unlock

适合:

大多数简单、结构化互斥场景

5.3 ReentrantLock 的特点

lock.lock();

try {

} finally {

    lock.unlock();
}

优点:

控制更加显式
支持tryLock
支持超时获取
支持可中断获取
支持公平策略
可以配合Condition

代价:

代码更复杂
必须自己正确释放
更容易因为使用错误产生Bug

5.4 对比表

| 对比项 | synchronized | ReentrantLock | | ------------ | ---------------------------------- | --------------------- | | 类型 | Java 语言内置同步机制 | Java 类库中的显式锁 | | 加锁 | 自动进入 Monitor | lock() | | 解锁 | JVM 自动 | unlock() | | 异常退出 | 自动释放 | 必须 finally | | 可重入 | 是 | 是 | | 尝试获取 | 基础语法不直接提供 tryLock | 支持 | | 超时获取 | 基础语法不直接提供 | 支持 | | 可中断等待锁 | 基础 Monitor 获取不通过该 API 提供 | lockInterruptibly() | | 公平策略 | 不提供此构造级策略 | ReentrantLock(true) | | 使用复杂度 | 较低 | 较高 | | 典型场景 | 常规互斥 | 需要高级锁控制 |


5.5 到底应该选哪个

如果只是:

一个共享对象
+
一个简单临界区
+
互斥访问

通常:

synchronized

已经非常合适。

如果明确需要:

尝试获取
超时
可中断等待
公平策略
Condition

再考虑:

ReentrantLock

不能形成:

ReentrantLock 比 synchronized 高级
所以所有代码都应该改成 ReentrantLock

这种错误思维。

技术选型的核心永远是:

根据需求选择最简单且正确的机制。


六、死锁

6.1 什么是死锁

死锁(Deadlock)是:

多个线程相互持有对方需要的资源,并且都等待对方释放,导致所有相关线程永久无法继续执行。

最经典模型:

线程A:

已经拥有 Lock1
↓
还需要 Lock2


线程B:

已经拥有 Lock2
↓
还需要 Lock1

于是:

A拿着Lock1
等B释放Lock2

B拿着Lock2
等A释放Lock1

形成循环:

A等待B
↑     ↓
B等待A

程序卡住。


6.2 死锁示例

public class DeadlockDemo {

    private static final Object LOCK_A =
            new Object();

    private static final Object LOCK_B =
            new Object();

    public static void main(String[] args) {

        Thread t1 = new Thread(() -> {

            synchronized (LOCK_A) {

                System.out.println(
                        "线程1获得A"
                );

                synchronized (LOCK_B) {

                    System.out.println(
                            "线程1获得B"
                    );
                }
            }

        });

        Thread t2 = new Thread(() -> {

            synchronized (LOCK_B) {

                System.out.println(
                        "线程2获得B"
                );

                synchronized (LOCK_A) {

                    System.out.println(
                            "线程2获得A"
                    );
                }
            }

        });

        t1.start();
        t2.start();
    }
}

某种执行顺序:

t1获得A
↓
切换

t2获得B
↓
t2等待A
↓

t1继续
↓
t1等待B

于是:

t1等t2
t2等t1

都走不下去。

注意:

这段程序不保证每一次运行都一定复现死锁,因为线程调度具有不确定性。

但是它存在明确的死锁风险。


6.3 最基础的死锁避免原则:统一锁顺序

如果所有线程都按照:

先A
后B

获得多把锁:

synchronized (LOCK_A) {

    synchronized (LOCK_B) {

    }
}

而不是:

线程1:A → B

线程2:B → A

就可以破坏:

循环等待

条件。

因此工程中极其重要:

多个地方需要获取多把锁时,应建立并遵守统一的锁获取顺序。


6.4 不要让持锁范围无限扩张

例如:

lock.lock();

try {

    // 数据库调用
    // 网络请求
    // 用户输入
    // 复杂IO
    // 调用大量未知代码

} finally {

    lock.unlock();
}

可能导致:

锁长时间不释放
↓
其他线程大量等待

甚至与其他锁组合形成复杂死锁。

所以:

锁应该保护真正需要保护的共享状态,而不是把所有耗时操作全部塞进临界区。


6.5 tryLock 可以帮助设计“拿不到就退出”

ReentrantLock 的:

tryLock()

或:

tryLock(timeout, unit)

允许程序:

获得不到锁
↓
不永久等待
↓
主动失败、回退或重试

因此在一些高级死锁规避策略中:

tryLock + timeout

比无限等待更加灵活。

但必须明确:

tryLock() 本身不会自动让一个设计错误的并发系统“永不死锁”。

真正首先需要的是:

清晰的锁设计
统一顺序
缩小锁范围
减少不必要的嵌套锁

七、常见问题

7.1 Lock 是类吗?

Lock 是接口。

最常用实现:

ReentrantLock

7.2 为什么不能 new Lock()?

因为:

Lock

是接口。

应该:

Lock lock =
        new ReentrantLock();

7.3 为什么 Lock 字段建议 final?

为了保证:

保护同一份共享状态时
始终使用同一个锁对象

避免运行中被替换。


7.4 为什么 unlock 一定放 finally?

为了保证临界区无论:

正常结束
还是异常退出

都尽可能正确释放已经获得的锁。


7.5 ReentrantLock 比 synchronized 一定好吗?

不是。

synchronized 更简单,而且自动释放 Monitor。

只有确实需要:

tryLock
超时
中断响应
公平策略
Condition

等高级能力时,显式锁优势才更加明显。


7.6 ReentrantLock 是不是不可重入?

恰恰相反。

名字中的:

Reentrant

就是:

可重入

7.7 synchronized 也是可重入的吗?

是。

不要把“可重入”错误理解成 ReentrantLock 独有功能。


7.8 公平锁是不是绝对按照线程创建顺序执行?

不是。

公平策略关注的是:

发生锁竞争时的等待顺序倾向

并不能保证操作系统线程整体调度严格公平。


7.9 tryLock() 拿不到锁会怎样?

无参数:

tryLock()

会立即返回:

false

而不是一直等待。


7.10 lockInterruptibly() 有什么价值?

线程在等待锁时:

可以响应interrupt

从而支持更加灵活的:

任务取消
超时控制

7.11 什么是死锁?

简单口述:

A 拿着 B 要的锁,B 拿着 A 要的锁,两个人谁都不释放,最终互相永久等待。


7.12 使用一把锁就不会有死锁吗?

经典循环等待死锁通常涉及多种资源或多把锁。

但并发设计仍然需要关注:

等待关系
锁范围
外部阻塞操作
调用链

不能只通过“我用了锁”判断系统一定正确。


八、练习与验收

8.1 知识问答

  1. Lock 位于哪个包?
  2. Lock 是类还是接口?
  3. 最常用 Lock 实现类是什么?
  4. lock() 做什么?
  5. unlock() 做什么?
  6. 为什么锁对象通常应该使用 final
  7. 为什么 unlock() 通常必须放在 finally
  8. Reentrant 是什么意思?
  9. synchronized 是否同样可重入?
  10. tryLock()lock() 有什么区别?
  11. 定时 tryLock() 解决什么需求?
  12. lockInterruptibly() 解决什么需求?
  13. 什么是公平锁?
  14. 默认 ReentrantLock 是公平锁还是非公平锁?
  15. 公平锁是不是一定性能更高?
  16. synchronized 与 ReentrantLock 有哪些共同点?
  17. ReentrantLock 相比 synchronized 提供哪些扩展能力?
  18. 什么是死锁?
  19. 锁顺序为什么可能导致死锁?
  20. 如何通过统一锁顺序降低死锁风险?

8.2 代码阅读

阅读:

private final Lock lock =
        new ReentrantLock();

public void increment() {

    lock.lock();

    try {

        count++;

    } finally {

        lock.unlock();
    }
}

回答:

  1. 哪一部分是加锁?
  2. 哪一部分是临界区?
  3. 哪一部分释放锁?
  4. 为什么解锁在 finally 中?
  5. 多线程共享同一对象时是否共享同一个 lock?

阅读:

public void increment() {

    Lock lock =
            new ReentrantLock();

    lock.lock();

    try {

        count++;

    } finally {

        lock.unlock();
    }
}

回答:

  1. 多个线程进入方法时使用的是不是同一把锁?
  2. 是否能够正确保护共享 count
  3. 应该怎样修改?

8.3 手写代码

任务一:银行取钱

将上一章:

synchronized

版本完全改写成:

private final Lock lock =
        new ReentrantLock();

要求使用标准:

lock.lock();

try {

} finally {

    lock.unlock();
}

结构。


任务二:银行存钱

账户余额:

1000

两个线程:

每个线程存10次
每次100元

最终余额必须符合业务预期。

要求使用:

ReentrantLock

保护共享余额。


任务三:tryLock

创建两个线程竞争一把锁。

要求:

if (lock.tryLock()) {

    try {

        // 执行任务

    } finally {

        lock.unlock();
    }

} else {

    System.out.println(
            "没有获得锁,执行备用逻辑"
    );
}

观察与 lock() 的区别。

8.4 Debug

错误代码:

lock.lock();

money -= 100;

lock.unlock();

指出:

如果 money -= 100 前后存在异常风险,为什么这种写法不够健壮?


错误代码:

private Lock lock =
        new ReentrantLock();

public void resetLock() {

    lock = new ReentrantLock();
}

解释:

为什么保护固定共享状态的锁对象不应该随意替换?


错误代码:

lock.unlock();

lock.lock();

try {

    // 临界区

} finally {

}

指出锁生命周期问题并修正。

8.5 死锁 Debug

存在:

Lock A
Lock B

线程1:

A → B

线程2:

B → A

要求:

  1. 画出死锁等待图。
  2. 说明为什么两个线程都不能继续。
  3. 改成统一: A → B
  4. 分析修改后为什么破坏了循环等待。

8.6 综合训练

设计“转账系统”。

账户:

Account A
Account B

需要完成:

A → B 转账

业务逻辑:

检查A余额
↓
扣减A余额
↓
增加B余额

回答:

  1. 为什么这个业务比单账户取钱更复杂?

  2. 操作涉及几个共享账户?

  3. 如果两个线程同时执行:

    • A → B
    • B → A

    会涉及怎样的多锁问题?

  4. 如何设计统一锁顺序?

  5. 为什么不能随意出现:

    • 线程1:先锁A再锁B
    • 线程2:先锁B再锁A
  6. 如果使用 tryLock(timeout),可以设计怎样的失败回退策略?

本题重点不是写完整银行系统,而是训练:

多资源
+
多把锁
+
锁顺序
+
死锁

的分析能力。

8.7 本章验收

闭卷手写:

private final Lock lock =
        new ReentrantLock();

public void method() {

    lock.lock();

    try {

        // 临界区

    } finally {

        lock.unlock();
    }
}

并能够解释:

Lock是接口
ReentrantLock是常用实现

能够说清:

lock
unlock
tryLock
lockInterruptibly

各自的基本用途。

能够比较:

synchronized
vs
ReentrantLock

并且能够口述:

什么是死锁
为什么锁顺序冲突可能死锁
如何通过统一锁获取顺序降低风险

则本章达到基础掌握标准。

下一阶段进入:

线程池
ExecutorService

这意味着我们的关注点将从:

“怎么手动管理一条线程”

进一步升级为:

“怎样统一管理和复用大量线程”