Java 学习笔记 · 第三课:多线程与并发编程(线程、同步、死锁、Lock、乐观锁与悲观锁)


前两课我们夯实了基础语法和面向对象思想。这一课进入 Java 进阶的重中之重------多线程与并发。这就像让程序从"单兵作战"升级为"团队协作",能极大提升效率,但也带来了资源争抢、数据安全等新挑战。


  1. 进程与线程概述

· 进程(Process):操作系统中正在运行的程序实例(如打开的 IDEA、浏览器)。拥有独立的内存空间。 · 线程(Thread):进程中的最小执行单元,同一进程内的多个线程共享该进程的内存资源(如堆、方法区),但每个线程拥有自己的栈和程序计数器。

Java 天生支持多线程,主方法(main)本身就是一个主线程。


  1. 创建线程的三种方式

2.1 继承 Thread 类

java 复制代码
class MyThread extends Thread {
    @Override
    public void run() {
        System.out.println(Thread.currentThread().getName() + " 执行中");
    }
}

// 启动
MyThread t1 = new MyThread();
t1.start(); // 调用 start() 才会开启新线程,直接调用 run() 只是普通方法

2.2 实现 Runnable 接口(推荐,因 Java 单继承限制)

java 复制代码
class MyRunnable implements Runnable {
    @Override
    public void run() {
        System.out.println(Thread.currentThread().getName() + " 执行中");
    }
}

// 启动
Thread t2 = new Thread(new MyRunnable());
t2.start();

2.3 实现 Callable 接口(可带返回值,配合 Future)

java 复制代码
import java.util.concurrent.*;

class MyCallable implements Callable<String> {
    @Override
    public String call() throws Exception {
        return "线程执行完毕,返回结果";
    }
}

// 启动(需借助线程池或 FutureTask)
FutureTask<String> task = new FutureTask<>(new MyCallable());
new Thread(task).start();
System.out.println(task.get()); // 阻塞获取返回值

  1. 线程调度与生命周期

3.1 线程状态(Java 中 6 种)

状态 说明 NEW(新建) 线程对象创建,尚未 start() RUNNABLE(可运行) 调用了 start(),等待 CPU 时间片(包含就绪和运行中) BLOCKED(阻塞) 等待获取锁(synchronized) WAITING(无限等待) 调用了 wait() / join(),需被显式唤醒 TIMED_WAITING(计时等待) 调用了 sleep(long) / wait(long) TERMINATED(终止) run() 执行完毕或异常退出

3.2 调度常用方法

· sleep(long millis):当前线程休眠指定毫秒,不释放锁。 · yield():主动让出 CPU 时间片,回到就绪状态,供同优先级线程竞争(不保证成功)。 · join():当前线程等待调用线程执行完毕再继续(常用于保证顺序)。

java 复制代码
public class JoinDemo {
    public static void main(String[] args) throws InterruptedException {
        Thread t = new Thread(() -> {
            for (int i = 0; i < 3; i++) System.out.println("子线程:" + i);
        });
        t.start();
        t.join(); // 主线程阻塞,等 t 跑完
        System.out.println("主线程后执行");
    }
}

线程优先级:setPriority(1-10),默认 5。优先级不保证绝对顺序,仅增加权重,底层依赖操作系统。


  1. 线程同步(Synchronization)

多线程共享资源时,容易发生数据竞争(如卖票出现负数)。解决方法:加锁,保证同一时刻只有一个线程操作共享数据。

4.1 synchronized 关键字(内置锁 / 对象锁)

用法一:同步方法

java 复制代码
public class TicketSeller {
    private int tickets = 10;
    
    // 锁住当前实例对象(this)
    public synchronized void sell() {
        if (tickets > 0) {
            System.out.println(Thread.currentThread().getName() + " 卖出第 " + tickets-- + " 张票");
        }
    }
}

用法二:同步代码块(更灵活,锁定指定对象)

java 复制代码
public void sell() {
    // 锁住当前实例(也可以锁任意 Object)
    synchronized (this) {
        if (tickets > 0) {
            System.out.println(Thread.currentThread().getName() + " 卖出第 " + tickets-- + " 张票");
        }
    }
}

注意:静态同步方法锁的是 Class 对象(类名.class)。

4.2 同步原理

每个 Java 对象都有一个内置的监视器锁(Monitor)。线程进入 synchronized 代码块前,必须先获取该锁;执行完毕或异常时自动释放锁。


  1. 死锁(Deadlock)

定义:两个或以上线程互相持有对方需要的锁,且都等待对方释放,导致所有线程无限阻塞。

5.1 死锁产生的四个必要条件

  1. 互斥:资源只能被一个线程持有。
  2. 占有并等待:线程持有一个资源,并等待另一个被占有的资源。
  3. 不可抢占:已分配的资源不能被强制剥夺。
  4. 循环等待:线程间形成首尾相接的等待环路。

5.2 经典死锁示例

java 复制代码
public class DeadlockDemo {
    private static final Object A = new Object();
    private static final Object B = new Object();

    public static void main(String[] args) {
        new Thread(() -> {
            synchronized (A) {
                System.out.println("线程1 持有 A,等待 B...");
                try { Thread.sleep(100); } catch (Exception e) {}
                synchronized (B) {
                    System.out.println("线程1 获取 B");
                }
            }
        }).start();

        new Thread(() -> {
            synchronized (B) {
                System.out.println("线程2 持有 B,等待 A...");
                synchronized (A) {
                    System.out.println("线程2 获取 A");
                }
            }
        }).start();
    }
}
// 结果:互相等待,程序卡死(可按 IDEA 的 Thread Dump 查看)

避免策略:保持锁顺序一致(如都先锁 A 后锁 B),或使用 tryLock(见下文)。


  1. 重入锁(ReentrantLock)

ReentrantLock 是 java.util.concurrent.locks 包下的显式锁,相比 synchronized 更加灵活。"重入" 意味着同一个线程可以多次获取同一把锁(内部有计数器)。

6.1 基本用法(必须手动解锁)

java 复制代码
import java.util.concurrent.locks.ReentrantLock;

public class LockExample {
    private final ReentrantLock lock = new ReentrantLock();
    private int count = 0;

    public void increment() {
        lock.lock(); // 加锁
        try {
            count++;
            System.out.println(Thread.currentThread().getName() + " -> " + count);
        } finally {
            lock.unlock(); // 务必在 finally 中释放,防止死锁
        }
    }
}

6.2 核心优势(对比 synchronized)

特性 synchronized ReentrantLock 语法 自动(代码块/方法结束释放) 手动 lock()/unlock() 灵活性 低 高(支持公平锁、中断、超时) 尝试获取 不支持 支持 tryLock()(尝试获取,失败立即返回) 中断响应 不支持 支持 lockInterruptibly() 公平锁 非公平 构造参数可指定 new ReentrantLock(true) 性能(Java 早期) 较差 较好;现代 JDK 两者差距不大

6.3 高效用法:tryLock 避免死锁

java 复制代码
public void transfer(Lock lockA, Lock lockB) {
    while (true) {
        if (lockA.tryLock()) {
            try {
                if (lockB.tryLock()) {
                    try {
                        System.out.println("成功获取两把锁,执行转账...");
                        return;
                    } finally {
                        lockB.unlock();
                    }
                }
            } finally {
                lockA.unlock();
            }
        }
        // 休眠一会再重试,避免活锁
        try { Thread.sleep(10); } catch (Exception e) {}
    }
}

6.4 Condition 精准唤醒(代替 wait/notify)

ReentrantLock 可以创建多个 Condition 条件,实现分组唤醒,比 synchronized 的 wait/notifyAll 更精准。

java 复制代码
ReentrantLock lock = new ReentrantLock();
Condition notEmpty = lock.newCondition();
Condition notFull = lock.newCondition();
// 类似生产者-消费者模式中,分别唤醒"不满"和"不空"的等待线程

  1. 锁的宏观分类:乐观锁 vs 悲观锁(重要补充)

上文的 synchronized 和 ReentrantLock 都属于悲观锁,但并发包中还有基于乐观锁思想的一套工具,两者是面试和选型的高频考点。

7.1 核心思想对比

· 悲观锁(Pessimistic Lock):"我很保守,肯定有人改"。每次拿数据都假设别人会修改,直接加锁(阻塞其他线程)。适合写多读少、冲突频繁的场景。 · 乐观锁(Optimistic Lock):"我很乐观,没人来改"。拿数据时不加锁,更新时再检查数据是否被改过。适合读多写少、冲突极少的场景(如缓存)。

7.2 Java 中的实现方式

悲观锁代表:synchronized、ReentrantLock、数据库 select ... for update。

乐观锁代表(核心是 CAS):

· CAS(Compare-And-Swap,比较并交换):CPU 级别的原子指令。更新时比较当前值与预期值,若相等则更新,否则重试(自旋)。 · Java 实现:java.util.concurrent.atomic 包下的工具类。

java 复制代码
import java.util.concurrent.atomic.AtomicInteger;

public class OptimisticDemo {
    public static void main(String[] args) {
        AtomicInteger count = new AtomicInteger(0);
        
        // CAS 更新:期望值是 0,如果是 0 就改为 1
        boolean success = count.compareAndSet(0, 1);
        System.out.println("更新成功?" + success + ",当前值:" + count.get());
        
        // 失败示例(期望值是 2,但实际是 1,所以失败)
        boolean fail = count.compareAndSet(2, 3);
        System.out.println("更新成功?" + fail + ",当前值:" + count.get());
    }
}

乐观锁的另一种实现:版本号机制(常见于数据库)

sql 复制代码
-- 查询时带版本号
SELECT id, name, version FROM user WHERE id = 1; -- version=1
-- 更新时检查版本号
UPDATE user SET name='新名', version=version+1 
WHERE id=1 AND version=1; -- 影响行数为 0 则表示被修改过

7.3 深度对比表

对比维度 悲观锁 乐观锁 加锁时机 访问数据前就加锁 更新数据时才检查 阻塞情况 会阻塞其他线程(挂起) 不阻塞,失败则重试(自旋) 适用场景 写操作多、竞争激烈(如银行扣款) 读操作多、竞争少(如生成全局自增 ID) 潜在问题 线程挂起/恢复开销大,易死锁 ABA 问题、自旋消耗 CPU Java 代表 synchronized、ReentrantLock AtomicInteger、LongAdder、StampedLock(乐观读)

7.4 乐观锁的"硬伤":ABA 问题

现象:变量从 A 改为 B,又改回 A。乐观锁通过值比较(A == A)会认为没变,但实际业务可能已经变了(比如余额被挪用又归还)。

解决方案:版本号/时间戳(AtomicStampedReference)。

java 复制代码
// 带版本号的原子引用,可解决 ABA
AtomicStampedReference<Integer> ref = 
    new AtomicStampedReference<>(100, 0); // (初始值, 初始版本号)

int stamp = ref.getStamp(); // 获取当前版本
ref.compareAndSet(100, 200, stamp, stamp + 1); // 版本号+1

7.5 实战选型建议(面试加分点)

  1. 竞争非常激烈(如秒杀扣库存)→ 选悲观锁(或 Redis 分布式锁),因为乐观锁自旋失败率太高,浪费 CPU。
  2. 轻度竞争(如计数器累加)→ 选乐观锁(AtomicLong),性能远高于 synchronized。
  3. 读多写少(如配置信息)→ 乐观锁(StampedLock 的乐观读模式),允许读时不加锁,性能极佳。
  4. 数据库层面:冲突高用 for update(悲观),冲突低用 version 字段(乐观)。

💡 一句话总结:悲观锁是"先抢房间再睡觉",乐观锁是"先睡觉,醒了发现有人抢就重睡"。Java 中前者靠 synchronized/Lock,后者靠 CAS(Atomic 类)。


  1. 本课总结

核心知识点 要点提炼 线程创建 Thread / Runnable / Callable,推荐 Runnable 线程调度 sleep(不释放锁)、join(等别人)、yield(让一下) 同步锁(同步) synchronized 方法/代码块,解决数据竞争,锁的是对象(实例/Class) 死锁 相互持有 + 相互等待,破坏四个必要条件之一(如顺序上锁) 重入锁(重入锁) 手动加锁/解锁,支持 tryLock 超时、中断、公平模式,避免死锁更灵活 乐观锁 vs 悲观锁 悲观锁阻塞(synchronized),乐观锁重试(CAS);根据竞争激烈程度选型,注意 ABA 问题

✨ 下一课预告:集合框架(ArrayList/LinkedList、HashSet/TreeSet、HashMap/ConcurrentHashMap)及泛型。


练习题(巩固)

  1. 使用 Runnable 创建两个线程,分别打印 15 和 AE,观察输出顺序(无规律才正确)。
  2. 模拟 3 个窗口卖 100 张票(使用 synchronized 保证不超卖、不错卖)。
  3. 改写第 2 题,使用 ReentrantLock 实现相同功能。
  4. 编写一个银行转账方法(Account a 转给 Account b,金额 amount),使用 ReentrantLock 和 tryLock 设计避免死锁(提示:按账户 ID 顺序上锁)。
  5. 思考题:在秒杀场景中,你会选择乐观锁还是悲观锁?为什么?如果使用乐观锁,如何避免 ABA 问题带来的业务风险?

💡 核心思想:锁保护的是共享资源,而非代码。合理设计锁粒度,既能保障数据安全,又能提升并发性能!加油 🔥

相关推荐
大勇前进41 分钟前
千万级大表 SQL Server 查询慢?一套可落地的性能调优实战路径
后端
站大爷IP41 分钟前
Python 的 defaultdict 把我坑惨了,原来缺失键会自动创建,但 `__missing__` 的副作用让我调试到崩溃
后端
用户名不能为空被占用44 分钟前
记一次数据权限改造,看 NestJS 装饰器与守卫的组合应用
后端·nestjs
摇滚侠1 小时前
SpringBoot 官网 阅读笔记 启用生产就绪功能 端点
spring boot·笔记·后端
花生了什么事o1 小时前
JVM 垃圾回收:对象如何被判定和回收
java·jvm
evans在进步1 小时前
HashMap 为什么线程不安全?ConcurrentHashMap 如何解决?
java·spring boot·spring
我命由我123452 小时前
匈牙利命名法
java·服务器·后端·学习·java-ee·kotlin·学习方法
闲猫2 小时前
LangChain / Integrations / Integrations by component / Tool
java·数据库·langchain
小田的博客2 小时前
SAP MM 供应商银行主数据更新报错!message R1228!
android·java·服务器