一、案例需求
自定义数组集合,开启两个线程同时执行添加元素操作:
- 线程 1:添加字符串 "F"
- 线程 2:添加字符串 "e"
不做同步:会出现数据覆盖、索引错乱、线程安全问题。 使用 ReentrantLock 显式锁保证多线程原子操作,保证数据安全。
拓展:除了锁互斥,多线程还经常需要线程间通信,依靠 wait()、notify() 完成线程等待与唤醒,下面知识点会详细讲解。
二、知识点铺垫
1. Lock 锁简介
Lock 是 java.util.concurrent.locks 包下的显式锁接口 常用实现类:ReentrantLock(可重入锁) 区别于 synchronized(隐式锁自动上锁解锁) 手动上锁、手动解锁,控制粒度更精细、更灵活
2. 核心方法
- lock():手动获取锁,锁被占用则阻塞等待
- unlock():手动释放锁(必须放在 finally 中,保证一定释放)
3. wait () /notify () 线程通信【重点新增讲解】
💡 注意:wait()、notify()、notifyAll() 是Object 类的方法,不是 Lock 的方法! 这三个方法只能写在 synchronized 同步代码块 / 同步方法里面调用,不能直接配合 Lock 锁使用。 如果使用 ReentrantLock 想要实现等待唤醒,需要搭配Condition。
方法介绍
- 作用:让当前持有锁的线程释放锁,进入等待队列,进入阻塞状态。
- 线程会一直等待,直到其他线程调用notify()/notifyAll()唤醒它。
- 释放锁这点和Thread.sleep()完全不同!sleep 休眠不释放锁,wait 会释放锁。
- 作用:唤醒在该对象监视器上随机一个正在 wait 等待的线程。
- 调用 notify 不会立刻释放锁,需要当前同步代码块执行结束后,才会释放锁,被唤醒线程才可以竞争锁。
- 唤醒该对象上所有处于 wait 等待的线程,所有被唤醒线程再去争抢锁。
wait 和 sleep 的对比(面试高频)
表格
| 所属类 | Object 类 | Thread 类 |
| 是否释放锁 | ✅ 释放锁 | ❌ 不释放锁 |
| 使用位置 | 必须在 synchronized 同步代码块中 | 任意位置都可以写 |
| 结束条件 | 被 notify/notifyAll 唤醒,或 wait (long) 超时 | 休眠时间到自动恢复 |
补充:ReentrantLock 实现等待唤醒: Lock 锁不能直接调用 wait/notify,需要调用lock.newCondition()得到 Condition 对象,使用condition.await()代替 wait,condition.signal()代替 notify。
三、完整可运行代码
package Test_07;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
/**
* 本案例演示:ReentrantLock 显式锁解决多线程数据安全问题
*/
public class Test_03 {
public static void main(String[] args) throws InterruptedException {
// 共享资源对象(临界资源)
MyList3 list = new MyList3();
// 创建两个线程
ThreadF t1 = new ThreadF(list);
ThreadE t2 = new ThreadE(list);
// 启动线程
t1.start();
t2.start();
// 主线程等待两个子线程执行完毕
t1.join();
t2.join();
// 最终遍历输出集合数据
list.printList();
}
}
// 线程1:添加元素 F
class ThreadF extends Thread {
private MyList3 list;
public ThreadF(MyList3 list) {
this.list = list;
}
@Override
public void run() {
list.add("F");
}
}
// 线程2:添加元素 e
class ThreadE extends Thread {
private MyList3 list;
public ThreadE(MyList3 list) {
this.list = list;
}
@Override
public void run() {
list.add("e");
}
}
// 自定义数组集合类(存在临界资源)
class MyList3 {
// 底层数组容器
private String[] str = {"A", "B", "", ""};
// 元素下标计数器(临界资源,多线程共享)
private int index = 2;
// 创建显式锁对象(唯一锁对象,保证线程互斥)
private Lock lock = new ReentrantLock();
/**
* 加锁的添加方法
*/
public void add(String s) {
// 1.手动上锁
lock.lock();
try {
// 原子操作:赋值 + 休眠模拟业务耗时 + 索引自增
str[index] = s;
Thread.sleep(500);
index++;
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
// 2.手动释放锁(finally 保证锁一定释放,不死锁)
lock.unlock();
}
}
/**
* 遍历打印集合所有元素
*/
public void printList() {
System.out.println("数据中实际存储元素的个数:" + index);
for (int i = 0; i < str.length; i++) {
System.out.print(str[i] + " ");
}
System.out.println();
}
}
四、代码核心解析
1. 临界资源
- String[] str 数组容器
- int index 共享索引计数器
两个线程同时操作这两个变量,属于多线程临界资源。
2. 加锁原理
3. sleep 不释放锁
Thread.sleep(500) 休眠只会释放 CPU 资源 不会释放 Lock 锁 保证整个添加操作是完整原子操作,不会被打断
🔔 对比记忆:如果这里换成wait(),线程会直接把锁释放,其他线程就可以进入 add 方法,会破坏当前原子添加逻辑,所以本案例这里使用 sleep 而不是 wait。
五、运行结果
数据中实际存储元素的个数:4
A B F e
结果说明
- 两个线程依次执行,没有数据覆盖
- 索引正常递增,数据完整有序
- 线程同步成功,解决并发安全问题
六、如果不加锁会出现什么问题?
- 两个线程同时赋值同一个下标
- 数据覆盖、丢失数据
- index 错乱,元素个数统计错误
- 多线程并发安全问题
七、Lock 锁 VS synchronized 面试总结
- synchronized:隐式锁,自动上锁、自动解锁,语法简单;支持 wait/notify 线程通信
- Lock (ReentrantLock):显式锁,手动上锁、手动解锁;灵活性更高、可重试、可超时、可公平锁;线程通信依靠 Condition 的 await/signal
- Lock 必须在 finally 解锁,防止死锁
八、核心考点背诵
拓展示例:wait + notify 简易代码(synchronized 版本)
因为 wait/notify 不能直接用于 Lock 锁,这里单独写一段简易生产者消费者示例,方便你理解 wait 和 notify
public class WaitNotifyDemo {
public static void main(String[] args) {
Object lockObj = new Object();
//等待线程
new Thread(()->{
synchronized (lockObj){
try {
System.out.println("等待线程:进入等待,释放锁");
lockObj.wait();
System.out.println("等待线程:被唤醒,继续执行");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}).start();
//唤醒线程
new Thread(()->{
synchronized (lockObj){
System.out.println("唤醒线程:准备唤醒其他线程");
lockObj.notify();
System.out.println("唤醒线程:notify调用完毕,代码块结束后释放锁");
}
}).start();
}
}
运行流程:



