Java多线程全体系教程 - 第二篇:Java多线程核心原理·线程安全与锁机制篇
适合人群:已经掌握多线程基础用法,需要解决线程安全问题、吃透锁机制、掌握线程通信的开发者
核心定位:多线程面试90%的考点都在本篇,也是开发中解决并发问题的核心,彻底搞懂「为什么会有线程安全问题」「怎么解决」「各种锁的区别与用法」。
一、什么是线程安全问题?为什么会出现?
1.1 线程安全问题的本质
当多个线程同时读写共享资源(共享变量、共享对象、静态资源、文件、数据库)时,由于CPU调度的随机性,多个线程交叉执行,导致共享数据被篡改、计算结果错误、数据不一致,这就是线程安全问题。
1.2 线程安全问题产生的3个必要条件
三个条件同时满足,才会出现线程安全问题,破坏任意一个,即可解决线程安全问题:
-
存在共享资源(多个线程同时访问同一个变量/对象);
-
多个线程对共享资源存在写操作(只有读操作,没有写操作,不会出现线程安全问题);
-
多个线程对共享资源的操作,不是原子操作。
1.3 线程不安全经典案例:卖票超卖
java
/**
* 线程不安全案例:多窗口卖票,出现超卖、重复卖票
*/
public class TicketSaleDemo {
// 共享资源:总票数
private static int ticketNum = 10;
public static void main(String[] args) {
// 3个线程,模拟3个售票窗口
for (int i = 1; i <= 3; i++) {
new Thread(() -> {
// 循环卖票
while (ticketNum > 0) {
try {
// 模拟售票耗时
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 卖票操作
System.out.println(Thread.currentThread().getName() + "卖出第" + ticketNum + "张票,剩余:" + (--ticketNum));
}
}, "窗口" + i).start();
}
}
}
运行结果会出现:剩余票数为负数、重复卖出同一张票,这就是典型的线程安全问题。
二、解决线程安全问题的核心:锁机制
解决线程安全问题的核心思路:将对共享资源的并发操作,改为串行执行。
同一时间,只允许一个线程持有锁,执行操作共享资源的代码,其他线程必须等待,持有锁的线程执行完毕,释放锁之后,其他线程才能竞争锁执行。
Java中提供了两种最基础的锁实现:synchronized同步锁 、Lock显式锁。
2.1 synchronized同步锁(JVM内置锁,最常用)
synchronized是Java关键字,是JVM层面实现的内置锁,自动加锁、自动释放锁,使用简单,不会出现死锁(锁未释放)问题,是开发中解决线程安全问题的首选。
synchronized的3种使用场景
场景1:修饰实例方法,锁当前对象实例
java
/**
* 修饰实例方法,锁this当前对象
*/
public class SynchronizedMethodDemo {
private int ticketNum = 10;
// 修饰实例方法,锁当前对象
public synchronized void saleTicket() {
if (ticketNum > 0) {
System.out.println(Thread.currentThread().getName() + "卖出第" + ticketNum + "张票,剩余:" + (--ticketNum));
}
}
public static void main(String[] args) {
SynchronizedMethodDemo demo = new SynchronizedMethodDemo();
// 多个线程,调用同一个对象的同步方法,共用同一把锁
for (int i = 1; i <= 3; i++) {
new Thread(demo::saleTicket, "窗口" + i).start();
}
}
}
场景2:修饰静态方法,锁当前类的Class对象
-
静态方法属于类,不属于对象实例;
-
修饰静态方法,锁是当前类的Class对象,全局唯一,所有线程访问该类的静态同步方法,共用同一把锁。
场景3:修饰同步代码块,灵活指定锁对象(推荐)
锁粒度更小,只锁需要同步的代码片段,不锁整个方法,性能更高,开发中优先使用。
java
// 同步代码块,锁对象可以自定义
public void saleTicket() {
// 任意对象都可以作为锁,锁对象必须是全局唯一的
synchronized (this) {
if (ticketNum > 0) {
// 操作共享资源的逻辑
}
}
}
核心注意事项 :synchronized的锁效果,完全取决于锁对象是否唯一。多个线程必须使用同一把锁,才能实现同步;锁对象不同,锁无效,依然会出现线程安全问题。
synchronized的核心特性
-
可重入锁:同一个线程,可以多次获取同一把锁,不会出现自己锁死自己的问题;
-
非公平锁:线程竞争锁,完全随机,不遵循先来后到顺序;
-
自动加锁、自动释放锁:代码执行完毕、出现异常,JVM都会自动释放锁,不会出现锁泄漏;
-
阻塞等待:拿不到锁的线程,会进入BLOCKED阻塞状态,一直等待,直到拿到锁。
2.2 Lock显式锁(JDK实现的接口,灵活度更高)
Lock是java.util.concurrent.locks包下的接口,是JDK代码层面实现的锁,需要手动加锁、手动释放锁,灵活度更高,功能更强大,适合复杂的并发场景。
最常用的实现类:ReentrantLock(可重入锁)。
ReentrantLock标准用法
java
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
/**
* ReentrantLock解决线程安全问题
*/
public class ReentrantLockDemo {
private static int ticketNum = 10;
// 创建锁对象,全局唯一
private static final Lock lock = new ReentrantLock();
public static void saleTicket() {
// 手动加锁
lock.lock();
try {
// 同步代码逻辑,操作共享资源
if (ticketNum > 0) {
System.out.println(Thread.currentThread().getName() + "卖出第" + ticketNum + "张票,剩余:" + (--ticketNum));
}
} finally {
// 手动释放锁,必须放在finally中,保证无论是否异常,锁一定会释放
lock.unlock();
}
}
public static void main(String[] args) {
for (int i = 1; i <= 3; i++) {
new Thread(ReentrantLockDemo::saleTicket, "窗口" + i).start();
}
}
}
核心注意事项 :Lock锁必须手动释放,且释放锁的代码必须放在finally代码块中。如果业务代码出现异常,锁没有释放,会导致锁泄漏,其他线程永远拿不到锁,引发死锁问题。
ReentrantLock相比synchronized的优势
-
支持公平锁/非公平锁可配置,synchronized只能是非公平锁;
-
支持超时获取锁,线程可以等待指定时间,拿不到锁就放弃,不会一直阻塞;
-
支持尝试获取锁,拿不到锁可以直接去做其他逻辑,不用阻塞等待;
-
支持精准唤醒指定线程,synchronized只能随机唤醒一个,或者唤醒所有线程。
2.3 synchronized与Lock锁的核心区别(必考)
| 对比维度 | synchronized | ReentrantLock |
|---|---|---|
| 实现层面 | JVM内置关键字,JVM层面实现 | JDK接口,代码层面实现 |
| 锁释放 | 自动释放,执行完毕/异常自动释放 | 手动释放,必须调用unlock() |
| 锁类型 | 非公平锁,不可配置 | 公平/非公平锁可自由配置 |
| 等待机制 | 无限阻塞等待,不可中断 | 支持超时等待、可中断、尝试获取锁 |
| 线程唤醒 | 只能随机唤醒/全部唤醒 | 支持Condition精准唤醒指定线程 |
| 使用成本 | 写法简单,不易出错 | 写法繁琐,容易忘记释放锁 |
开发选择建议
-
普通的线程安全场景,优先使用synchronized,简单、安全、不易出错;
-
复杂的高并发场景,需要公平锁、超时等待、精准唤醒等高级功能,使用ReentrantLock。
三、线程通信:wait()、notify()、notifyAll()
多个线程之间,除了同步执行,还需要相互通信、相互配合,完成业务逻辑(比如生产者消费者模型:生产线程生产完数据,通知消费线程消费;消费线程消费完,通知生产线程生产)。
Java提供了3个方法,实现线程之间的通信,这3个方法是Object类的方法,不是Thread类的方法,且必须在同步代码块/同步方法中使用。
3个方法的作用
-
wait():让当前持有锁的线程,释放锁,进入WAITING等待状态,直到被其他线程唤醒;
-
wait(long timeout):限时等待,超时自动唤醒;
-
notify():随机唤醒一个正在当前锁上等待的线程;
-
notifyAll():唤醒当前锁上所有正在等待的线程。
核心注意事项:
-
wait()、notify()、notifyAll()必须在同步代码中使用,且必须是当前锁对象调用,否则会抛出IllegalMonitorStateException异常;
-
wait()方法会释放持有的锁,而sleep()方法不会释放锁,这是二者最核心的区别;
-
开发中优先使用notifyAll(),避免notify()随机唤醒,导致线程饥饿,永远无法被唤醒。
经典案例:生产者消费者模型
java
/**
* 生产者消费者模型:线程通信经典案例
*/
public class ProducerConsumerDemo {
// 共享资源:商品库存
private static int stock = 0;
// 锁对象
private static final Object lock = new Object();
// 生产者线程:生产商品
static class Producer implements Runnable {
@Override
public void run() {
while (true) {
synchronized (lock) {
// 库存大于0,等待消费者消费
while (stock > 0) {
try {
// 释放锁,等待
lock.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
// 生产商品
stock++;
System.out.println(Thread.currentThread().getName() + "生产商品,当前库存:" + stock);
// 唤醒消费者线程
lock.notifyAll();
}
}
}
}
// 消费者线程:消费商品
static class Consumer implements Runnable {
@Override
public void run() {
while (true) {
synchronized (lock) {
// 库存为0,等待生产者生产
while (stock == 0) {
try {
lock.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
// 消费商品
stock--;
System.out.println(Thread.currentThread().getName() + "消费商品,当前库存:" + stock);
// 唤醒生产者线程
lock.notifyAll();
}
}
}
}
public static void main(String[] args) {
// 启动1个生产者,2个消费者
new Thread(new Producer(), "生产者1").start();
new Thread(new Consumer(), "消费者1").start();
new Thread(new Consumer(), "消费者2").start();
}
}
四、Java锁机制核心分类(面试高频)
Java锁按照不同的特性,分为很多类型,是面试必考点,这里用通俗的语言解释清楚,不绕弯子。
1. 公平锁 vs 非公平锁
-
公平锁:线程按照申请锁的顺序,排队获取锁,先来先得,不会出现线程饥饿;缺点:性能较低,需要维护等待队列;
-
非公平锁:线程竞争锁,随机获取,不排队,新来的线程可能直接抢到锁;优点:性能极高,吞吐量更大;缺点:可能出现线程饥饿,长时间拿不到锁。
默认情况:synchronized、ReentrantLock默认都是非公平锁。
2. 可重入锁 vs 不可重入锁
-
可重入锁:同一个线程,多次获取同一把锁,不会阻塞,不会自己锁死自己;
-
作用:避免同一个同步方法中,调用另一个同一个锁的同步方法,导致死锁。
synchronized、ReentrantLock都是可重入锁。
3. 共享锁 vs 排他锁(独占锁)
-
排他锁(独占锁):同一时间,只允许一个线程持有锁,读、写都加排他锁,synchronized、ReentrantLock都是排他锁;
-
共享锁:同一时间,多个线程可以同时持有锁,只能读,不能写;
-
经典实现:ReadWriteLock读写锁,读锁是共享锁,写锁是排他锁。
4. 乐观锁 vs 悲观锁
-
悲观锁:默认认为所有线程都会修改共享数据,所以每次访问都加锁,强制串行执行,synchronized、ReentrantLock都是悲观锁;
-
乐观锁:默认认为线程不会修改共享数据,不加锁,更新数据时,判断数据是否被修改,没有修改就更新,被修改就放弃或重试;
-
实现方式:CAS无锁机制,Java中的Atomic原子类,都是基于CAS乐观锁实现。
第二篇总结
本篇是Java多线程的核心难点,也是面试的重中之重,核心掌握4点:
-
线程安全问题的本质、产生条件,以及锁机制的解决思路;
-
synchronized与ReentrantLock的用法、区别、使用场景;
-
wait()、notify()线程通信机制,掌握生产者消费者模型;
-
各类锁的分类、特性、区别,应对面试考点。 Java多线程全体系教程 - 第二篇:Java多线程核心原理·线程安全与锁机制篇 适合人群:已经掌握多线程基础用法,需要解决线程安全问题、吃透锁机制