mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
1992 words
5 minutes
并发编程一:线程基础与线程安全
2025-02-08

并发编程一:线程基础与线程安全#

并发编程最容易学乱,因为它同时涉及 Java 语法、JVM、操作系统、数据库和真实业务场景。

所以这组笔记我准备拆成几篇来写:

另外两个容易展开的主题单独写:

这篇只讲第一层:线程是什么、线程怎么创建、线程安全问题从哪里来,以及写 Java 代码时最容易踩到的基础坑。

为什么需要多线程#

在 Web 项目里,一个请求通常不只做一件事。

以下单为例,可能会涉及:

  • 创建订单;
  • 扣减库存;
  • 查询优惠;
  • 发送短信;
  • 写操作日志;
  • 推送站内通知。

如果所有事情都串行执行,用户就要一直等待。多线程的价值,就是让程序可以同时处理多个任务,提高吞吐量和响应速度。

不过,多线程不是越多越好。线程多了以后,会出现上下文切换、共享数据竞争、死锁、内存可见性等问题。

所以学习并发编程,第一步不是背 API,而是理解:

多线程提高性能的同时,也会把“执行顺序”变得不确定。

进程和线程#

进程是程序运行的基本单位。比如一个 IDEA、一个浏览器、一个后端服务进程。

线程是 CPU 调度的基本单位。一个进程里可以有多个线程。

可以简单理解:

进程:一个正在运行的程序
线程:程序里真正执行任务的执行流

比如一个电商后端服务进程里,可能同时有:

  • 处理 HTTP 请求的线程;
  • 执行定时任务的线程;
  • 消费 MQ 消息的线程;
  • 写日志的线程;
  • 线程池里的工作线程。

这些线程共享同一个进程里的内存,所以它们之间通信很方便,但也正因为共享内存,才会出现线程安全问题。

创建线程的几种方式#

Java 里创建线程的方式很多,但学习时先掌握三个就够了。

1. 继承 Thread#

public class OrderThread extends Thread {
@Override
public void run() {
System.out.println("处理订单逻辑");
}
public static void main(String[] args) {
new OrderThread().start();
}
}

注意要调用 start(),不是直接调用 run()

start() 会创建新线程,run() 只是普通方法调用。

这种方式现在业务开发里用得少,因为 Java 是单继承,继承了 Thread 之后就不能再继承其他类。

2. 实现 Runnable#

public class OrderTask implements Runnable {
@Override
public void run() {
System.out.println("订单创建成功");
}
public static void main(String[] args) {
Thread thread = new Thread(new OrderTask());
thread.start();
}
}

Runnable 更常见,因为它把“任务”和“线程”拆开了。

线程负责执行,Runnable 负责描述要做什么。

3. Callable + Future#

Runnable 没有返回值,也不能直接抛出受检异常。需要返回结果时,可以使用 Callable

import java.util.concurrent.Callable;
import java.util.concurrent.FutureTask;
public class StockTask implements Callable<Integer> {
@Override
public Integer call() {
return 100;
}
public static void main(String[] args) throws Exception {
FutureTask<Integer> task = new FutureTask<>(new StockTask());
new Thread(task).start();
Integer stock = task.get();
System.out.println("库存:" + stock);
}
}

FutureTask#get() 会等待任务执行完成。如果任务还没结束,当前线程会阻塞。

真实项目里不太会手动 new Thread,更多是把任务提交给线程池。线程池放到第二篇讲:并发编程二:线程池、JUC 工具与异步编程

线程的生命周期#

线程从创建到结束,大致会经历这些状态:

NEW -> RUNNABLE -> BLOCKED / WAITING / TIMED_WAITING -> TERMINATED

常见状态可以这样理解:

状态含义
NEW线程对象创建了,但还没调用 start()
RUNNABLE可以运行,正在等待 CPU 调度或已经运行中
BLOCKED等待锁,比如进入 synchronized 代码块失败
WAITING无限期等待,比如 Object.wait()
TIMED_WAITING限时等待,比如 Thread.sleep(1000)
TERMINATED线程执行结束

比如两个用户同时抢最后一件商品,一个线程拿到了库存锁,另一个线程可能就会进入 BLOCKED

什么是线程安全#

线程安全问题通常来自一句话:

多个线程同时读写同一份共享数据。

比如库存只有 1 件,两个用户同时下单:

用户A读取库存 = 1
用户B读取库存 = 1
用户A扣减库存 = 0
用户B扣减库存 = 0

两个请求都认为自己成功了,结果库存只有一件,却卖出了两件。

在 Java 内存里也一样:

public class Counter {
private int count = 0;
public void add() {
count++;
}
}

count++ 看起来是一行,实际上至少包含:

读取 count
加 1
写回 count

多个线程同时执行时,就可能出现丢失更新。

Spring Bean 里的共享变量坑#

后端项目里,很多线程安全问题不是直接写 Thread 写出来的,而是因为 Spring Bean 默认是单例。

错误示例:

@Service
public class OrderService {
private Long currentUserId;
public void createOrder(Long userId) {
this.currentUserId = userId;
// 后续逻辑继续使用 currentUserId
}
}

多个请求会共用同一个 OrderService 对象。A 用户刚把 currentUserId 设置成 1001,B 用户可能马上覆盖成 1002。

更推荐把请求级数据放在方法参数或局部变量里:

@Service
public class OrderService {
public void createOrder(Long userId) {
// userId 作为参数向下传递
}
}

原则很简单:

单例 Bean 里不要放会随着请求变化的成员变量。

synchronized 解决的是什么问题#

synchronized 可以保证同一时刻只有一个线程进入临界区。

public class StockService {
private int stock = 10;
public synchronized void deduct() {
if (stock > 0) {
stock--;
System.out.println("扣减成功,剩余库存:" + stock);
}
}
}

这段代码保证 deduct() 同一时刻只能被一个线程执行。

不过,锁本身是一个很大的专题。synchronizedReentrantLock、乐观锁、悲观锁、分布式锁的区别,我单独放到这里:Java 锁专题:从 synchronized 到分布式锁

这篇只需要先记住:

  • 有共享数据;
  • 有并发读写;
  • 有复合操作;

这三个条件同时出现时,就要考虑线程安全。

volatile 解决的是什么问题#

volatile 主要解决两个问题:

  • 可见性;
  • 禁止指令重排。

比如一个支付状态标记:

public class PayStatus {
private volatile boolean success = false;
public void callback() {
success = true;
}
public void waitPaySuccess() {
while (!success) {
// 等待支付回调
}
System.out.println("支付成功");
}
}

一个线程修改 success 后,其他线程能尽快看到变化。

但要注意:

volatile 不保证原子性。

下面这种代码即使用了 volatile 也不安全:

private volatile int count = 0;
public void add() {
count++;
}

因为 count++ 仍然是读、改、写三个步骤。

如果要做原子计数,可以使用 AtomicIntegerLongAdder。这部分放在 CAS 专题里:Java CAS 专题:从 AtomicInteger 到乐观并发

ThreadLocal 适合放什么#

ThreadLocal 可以给每个线程保存一份独立变量。

常见用途:

  • 当前登录用户;
  • TraceId;
  • 租户 ID;
  • 请求上下文。

示例:

public class UserContext {
private static final ThreadLocal<Long> USER_HOLDER = new ThreadLocal<>();
public static void set(Long userId) {
USER_HOLDER.set(userId);
}
public static Long get() {
return USER_HOLDER.get();
}
public static void clear() {
USER_HOLDER.remove();
}
}

使用时一定要在请求结束后清理:

try {
UserContext.set(userId);
chain.doFilter(request, response);
} finally {
UserContext.clear();
}

如果在线程池里忘记 remove(),线程会被复用,上一个请求的数据可能残留到下一个请求里。

死锁是什么#

死锁就是多个线程互相等待对方释放资源,最后谁也走不下去。

典型场景:

线程A:持有订单锁,等待库存锁
线程B:持有库存锁,等待订单锁

示例代码:

public class DeadLockDemo {
private static final Object ORDER = new Object();
private static final Object STOCK = new Object();
public static void main(String[] args) {
new Thread(() -> {
synchronized (ORDER) {
synchronized (STOCK) {
System.out.println("A 执行完成");
}
}
}).start();
new Thread(() -> {
synchronized (STOCK) {
synchronized (ORDER) {
System.out.println("B 执行完成");
}
}
}).start();
}
}

避免死锁的常见方式:

  • 统一加锁顺序;
  • 减少锁嵌套;
  • 不要在持锁时调用外部接口;
  • 使用 tryLock 设置等待时间;
  • 让事务尽量短。

这一篇先记住什么#

第一篇不需要把所有 JUC 工具都背完,先抓住几个基础点:

  • 线程是程序里的执行流,多个线程共享进程内存。
  • 多线程能提高吞吐量,但会带来执行顺序不确定的问题。
  • 线程安全问题来自多个线程同时读写共享数据。
  • Spring 单例 Bean 不要保存请求级成员变量。
  • synchronized 解决互斥问题,volatile 解决可见性问题,但它们不是一回事。
  • ThreadLocal 用完要清理,尤其在线程池环境里。
  • 死锁本质是多个线程互相等待资源。

下一篇继续看线程池、JUC 工具和异步编程:并发编程二:线程池、JUC 工具与异步编程

Share

If this article helped you, please share it with others!

并发编程一:线程基础与线程安全
https://mizuki.mysqil.com/posts/java-multithreading-ecommerce-notes/
Author
梦幻晨风
Published at
2025-02-08
License
CC BY-NC-SA 4.0

Some information may be outdated

Table of Contents