并发编程一:线程基础与线程安全
并发编程最容易学乱,因为它同时涉及 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 默认是单例。
错误示例:
@Servicepublic class OrderService {
private Long currentUserId;
public void createOrder(Long userId) { this.currentUserId = userId; // 后续逻辑继续使用 currentUserId }}多个请求会共用同一个 OrderService 对象。A 用户刚把 currentUserId 设置成 1001,B 用户可能马上覆盖成 1002。
更推荐把请求级数据放在方法参数或局部变量里:
@Servicepublic 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() 同一时刻只能被一个线程执行。
不过,锁本身是一个很大的专题。synchronized、ReentrantLock、乐观锁、悲观锁、分布式锁的区别,我单独放到这里: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++ 仍然是读、改、写三个步骤。
如果要做原子计数,可以使用 AtomicInteger 或 LongAdder。这部分放在 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 工具与异步编程。
If this article helped you, please share it with others!
Some information may be outdated






