Java多线程全体系教程 - 第二篇:Java多线程核心原理·线程安全与锁机制篇

Java多线程全体系教程 - 第二篇:Java多线程核心原理·线程安全与锁机制篇

适合人群:已经掌握多线程基础用法,需要解决线程安全问题、吃透锁机制、掌握线程通信的开发者

核心定位:多线程面试90%的考点都在本篇,也是开发中解决并发问题的核心,彻底搞懂「为什么会有线程安全问题」「怎么解决」「各种锁的区别与用法」。

一、什么是线程安全问题?为什么会出现?

1.1 线程安全问题的本质

当多个线程同时读写共享资源(共享变量、共享对象、静态资源、文件、数据库)时,由于CPU调度的随机性,多个线程交叉执行,导致共享数据被篡改、计算结果错误、数据不一致,这就是线程安全问题。

1.2 线程安全问题产生的3个必要条件

三个条件同时满足,才会出现线程安全问题,破坏任意一个,即可解决线程安全问题:

  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的核心特性
  1. 可重入锁:同一个线程,可以多次获取同一把锁,不会出现自己锁死自己的问题;

  2. 非公平锁:线程竞争锁,完全随机,不遵循先来后到顺序;

  3. 自动加锁、自动释放锁:代码执行完毕、出现异常,JVM都会自动释放锁,不会出现锁泄漏;

  4. 阻塞等待:拿不到锁的线程,会进入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的优势
  1. 支持公平锁/非公平锁可配置,synchronized只能是非公平锁;

  2. 支持超时获取锁,线程可以等待指定时间,拿不到锁就放弃,不会一直阻塞;

  3. 支持尝试获取锁,拿不到锁可以直接去做其他逻辑,不用阻塞等待;

  4. 支持精准唤醒指定线程,synchronized只能随机唤醒一个,或者唤醒所有线程。

2.3 synchronized与Lock锁的核心区别(必考)

对比维度 synchronized ReentrantLock
实现层面 JVM内置关键字,JVM层面实现 JDK接口,代码层面实现
锁释放 自动释放,执行完毕/异常自动释放 手动释放,必须调用unlock()
锁类型 非公平锁,不可配置 公平/非公平锁可自由配置
等待机制 无限阻塞等待,不可中断 支持超时等待、可中断、尝试获取锁
线程唤醒 只能随机唤醒/全部唤醒 支持Condition精准唤醒指定线程
使用成本 写法简单,不易出错 写法繁琐,容易忘记释放锁
开发选择建议
  • 普通的线程安全场景,优先使用synchronized,简单、安全、不易出错;

  • 复杂的高并发场景,需要公平锁、超时等待、精准唤醒等高级功能,使用ReentrantLock。

三、线程通信:wait()、notify()、notifyAll()

多个线程之间,除了同步执行,还需要相互通信、相互配合,完成业务逻辑(比如生产者消费者模型:生产线程生产完数据,通知消费线程消费;消费线程消费完,通知生产线程生产)。

Java提供了3个方法,实现线程之间的通信,这3个方法是Object类的方法,不是Thread类的方法,且必须在同步代码块/同步方法中使用。

3个方法的作用

  1. wait():让当前持有锁的线程,释放锁,进入WAITING等待状态,直到被其他线程唤醒;

  2. wait(long timeout):限时等待,超时自动唤醒;

  3. notify():随机唤醒一个正在当前锁上等待的线程;

  4. notifyAll():唤醒当前锁上所有正在等待的线程。

核心注意事项

  1. wait()、notify()、notifyAll()必须在同步代码中使用,且必须是当前锁对象调用,否则会抛出IllegalMonitorStateException异常;

  2. wait()方法会释放持有的锁,而sleep()方法不会释放锁,这是二者最核心的区别;

  3. 开发中优先使用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点:

  1. 线程安全问题的本质、产生条件,以及锁机制的解决思路;

  2. synchronized与ReentrantLock的用法、区别、使用场景;

  3. wait()、notify()线程通信机制,掌握生产者消费者模型;

  4. 各类锁的分类、特性、区别,应对面试考点。 Java多线程全体系教程 - 第二篇:Java多线程核心原理·线程安全与锁机制篇 适合人群:已经掌握多线程基础用法,需要解决线程安全问题、吃透锁机制

相关推荐
徐志斌2 小时前
Linux 内核与 Zero-Copy 零拷贝
后端
Java编程爱好者2 小时前
Spring-Boot-缓存实战-@Cacheable-这10个坑
后端
沛沛rh453 小时前
用 Rust 实现用户态调试器:mini-debugger项目原理剖析与工程复盘
开发语言·c++·后端·架构·rust·系统架构
消失的旧时光-19433 小时前
Spring Boot + MyBatis 从 0 到 1 跑通查询接口(含全部踩坑)
spring boot·后端·spring·mybatis
SamDeepThinking4 小时前
Spring AOP记录日志,生产环境的代码长什么样
java·后端·架构
小江的记录本4 小时前
【网络安全】《网络安全三大加密算法结构化知识体系》
java·前端·后端·python·安全·spring·web安全
GetcharZp4 小时前
「干掉 Gin?」极致性能的 Go Web 框架 Fiber:这才是真正的“快”!
后端
希望永不加班4 小时前
SpringBoot 中 AOP 实现多数据源切换
java·数据库·spring boot·后端·spring
超级无敌攻城狮5 小时前
Agent 到底是怎么跑起来的
前端·后端·架构