Java 多线程详解:从入门到线程安全(初阶篇)
多线程是 Java 后端面试与实际开发中的重中之重。本文将从 什么是线程 讲起,逐步介绍 线程的创建方式 、线程的状态 ,最后重点分析 线程安全问题及其解决方案,帮你一次性梳理清楚多线程初阶的核心知识体系。
目录
一、什么是线程
1.1 线程的概念
线程(Thread)就是一个"执行流"。每个线程都可以按照顺序执行自己的代码,多个线程之间"同时"执行着多份代码。
举个例子:一家公司去银行办理业务,既要财务转账,又要福利发放,还要缴社保。如果只有张三一个会计,就会忙不过来。于是张三找来李四、王五帮忙,三个人分别负责一件事,各自排队办理------这就形成了三个"执行流"共同完成任务。这种把一个大任务拆分成多个小任务、交给不同执行流分别执行的方式,就是多线程 。其中张三一般被称为主线程(Main Thread)。
1.2 为什么要有线程
首先,"并发编程"已经成为刚需:
- 单核 CPU 的发展遇到了瓶颈,想提高算力就要靠多核 CPU,而并发编程能充分利用多核资源;
- 有些任务需要"等待 IO",利用等待 IO 的时间去做其他工作,也需要并发编程。
其次,线程比进程更轻量:
| 对比项 | 进程 | 线程 |
|---|---|---|
| 创建 / 销毁 | 慢 | 快 |
| 调度 | 慢 | 快 |
| 内存空间 | 相互独立 | 同一进程内的线程共享 |
正因为线程更轻量,人们在此基础上又发展出了线程池(ThreadPool) 和协程(Coroutine)。
1.3 进程和线程的区别
- 进程是包含线程的,每个进程至少有一个线程(主线程);
- 进程之间不共享内存空间 ,而同一个进程的线程之间共享同一份内存空间;
- 进程是系统分配资源的最小单位 ,线程是系统调度的最小单位;
- 一个进程崩溃一般不影响其他进程,但一个线程崩溃可能把整个进程"带走"。
1.4 Java 线程与操作系统线程的关系
线程本质上是操作系统中的概念,操作系统内核实现了线程机制并提供 API(如 Linux 的 pthread 库)。Java 标准库中的 Thread 类,就是对这些操作系统 API 的进一步抽象和封装。
二、如何创建线程
2.1 方法一:继承 Thread 类
java
class MyThread extends Thread {
@Override
public void run() {
System.out.println("这里是线程运行的代码");
}
}
public class ThreadDemo1 {
public static void main(String[] args) {
MyThread t = new MyThread(); // 创建线程对象
t.start(); // 启动线程
}
}
⚠️ 注意:一定要调用
start()才会真正在操作系统层面创建出新线程。覆写run()只是提供了线程要执行的"指令清单",而调用start()才是喊一声"行动起来!"。如果直接调用run(),只是在当前线程里执行普通方法调用,不会产生多线程效果。
2.2 方法二:实现 Runnable 接口
java
class MyRunnable implements Runnable {
@Override
public void run() {
System.out.println("这里是线程运行的代码");
}
}
public class ThreadDemo2 {
public static void main(String[] args) {
Thread t = new Thread(new MyRunnable()); // Runnable 作为 target
t.start();
}
}
2.3 其他写法(匿名内部类 / Lambda)
java
// 匿名内部类创建 Thread 子类对象
Thread t1 = new Thread() {
@Override
public void run() {
System.out.println("使用匿名内部类创建 Thread 子类对象");
}
};
// 匿名内部类实现 Runnable
Thread t2 = new Thread(new Runnable() {
@Override
public void run() {
System.out.println("使用匿名内部类创建 Runnable 子类对象");
}
});
// Lambda 表达式(推荐,最简洁)
Thread t3 = new Thread(() -> System.out.println("使用 Lambda 创建线程"));
2.4 两种方法对比
| 方式 | 特点 |
|---|---|
| 继承 Thread | 直接用 this 就表示当前线程对象;缺点是 Java 单继承,占用了继承名额 |
| 实现 Runnable | this 表示的是 Runnable 的引用,需要用 Thread.currentThread() 获取线程;任务与线程解耦,更灵活,推荐使用 |
2.5 多线程真的能提高速度吗?
通过 System.nanoTime() 对比串行与并发执行 10 亿次自增操作:
- 串行:约 720 毫秒
- 双线程并发:约 400 毫秒
可以看到,在任务可拆分、CPU 资源充足的情况下,多线程确实能显著提高程序的运行效率。但多线程不一定总是更快------任务量太小、线程切换开销大于计算本身时,反而可能更慢。
三、线程的状态
3.1 线程的所有状态
线程状态是一个枚举类型 Thread.State,共有 6 种:
java
for (Thread.State state : Thread.State.values()) {
System.out.println(state);
}
| 状态 | 含义 |
|---|---|
NEW |
安排了工作,还未开始行动(线程对象创建好了,还没调用 start) |
RUNNABLE |
可工作的状态,包含"正在 CPU 上执行"和"在队列中等待被调度" |
BLOCKED |
等待获取锁(排队等着拿 synchronized 锁) |
WAITING |
无限期等待其他线程的通知(如调用了 wait() 没设置超时) |
TIMED_WAITING |
带时限的等待(如 sleep(1000)、wait(1000)) |
TERMINATED |
工作完成了(run 方法执行完毕) |
3.2 状态转移的理解
还是用银行办业务的例子来理解:
- 刚把李四、王五找来,任务还没安排到位 → NEW;
- 他们开始去窗口排队 → RUNNABLE(排队也属于可被调度的状态,是否真正被服务要看调度器);
- 中途需要去填表、回家取证件、发呆一会 → BLOCKED / WAITING / TIMED_WAITING;
- 忙完离开银行 → TERMINATED。
💡
isAlive()方法可以理解为:只要状态不是 NEW 也不是 TERMINATED,线程就是"活着"的。
几个关键结论:
- BLOCKED 表示等待获取锁;WAITING / TIMED_WAITING 表示等待其他线程发来通知;
- TIMED_WAITING 是带时限的等待,WAITING 是无限等待,等到被唤醒为止;
- 状态之间的典型转移:
NEW --start()--> RUNNABLE --获得锁失败--> BLOCKED、RUNNABLE --wait()--> WAITING --notify()--> RUNNABLE、RUNNABLE --sleep(n)--> TIMED_WAITING --时间到--> RUNNABLE、run() 执行完 --> TERMINATED。
3.3 Thread 类的常用方法
java
Thread t = new Thread(() -> { /* ... */ }, "我的线程");
t.getId(); // 线程唯一标识
t.getName(); // 线程名称
t.getState(); // 线程状态
t.getPriority(); // 优先级(优先级高理论上更容易被调度)
t.isDaemon(); // 是否为后台线程:JVM 会在所有非后台线程结束后才退出
t.isAlive(); // 是否存活
t.isInterrupted();// 是否被中断
其他常用操作:
start():启动线程,真正创建出操作系统层面的线程;join():等待目标线程执行结束,常用于控制线程执行顺序(如主线程等子线程算完再打印结果);Thread.currentThread():获取当前线程的引用;Thread.sleep(ms):让当前线程休眠。注意由于线程调度不可控,实际休眠时间只会 ≥ 设定值。
四、线程存在的问题与解决方案
4.1 先看一个线程不安全的例子
java
private static int count = 0;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
count++;
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
count++;
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("count: " + count); // 预期 10 万,实际每次结果都不同!
}
两个线程各对 count 自增 5 万次,预期结果是 10 万,但实际运行结果往往小于 10 万,且每次都不一样 ------这就是线程不安全。
线程安全的概念:如果多线程环境下代码运行的结果符合预期(即和单线程环境下的结果一致),就说这个程序是线程安全的。
4.2 线程不安全的原因
① 线程调度是随机的(罪魁祸首)
随机调度使程序在多线程环境下的执行顺序存在大量变数,程序员必须保证在任意执行顺序下代码都能正常工作。
② 修改共享数据
多个线程同时修改同一个变量(如上例中的 count),这个变量就是多线程都能访问到的共享数据,是问题产生的前提。
③ 原子性
一条 Java 语句不一定是原子的 。比如 count++ 实际上对应三步操作:
- 从内存把数据读到 CPU;
- 在 CPU 中进行加 1 运算;
- 把结果写回内存。
如果线程 A 执行到第 2 步时,线程 B 插进来把这三步完整执行了一遍,A 恢复后继续写回旧值,就会导致一次自增"丢失"。
④ 可见性
一个线程对共享变量的修改,不能及时被其他线程看到。
Java 内存模型(JMM)规定:
- 线程之间的共享变量存放在主内存中;
- 每个线程都有自己的工作内存(对应 CPU 的寄存器和高速缓存);
- 线程读共享变量时,先从主内存拷贝到工作内存再读;修改时也是先改工作内存中的副本,再同步回主内存。
为什么要这么设计?因为 CPU 访问寄存器/缓存比访问内存快 3~4 个数量级(几千到上万倍),缓存能大幅提升效率。但副作用就是:线程 1 修改了副本,主内存和其他线程的副本不一定能及时同步,导致读到"过期数据"。
⑤ 指令重排序
编译器/CPU 为了优化性能,可能会调整指令的执行顺序(前提是保持单线程逻辑不变)。例如:
- 去前台取 U 盘;
- 去教室写 10 分钟作业;
- 去前台取快递。
单线程下按 1→3→2 执行完全等价,还能少跑一趟前台。但在多线程环境下,编译器很难预测代码的执行效果,激进的重排序可能导致逻辑不等价,引发问题。
4.3 解决方案一:synchronized 关键字(监视器锁)
用锁改造上面的例子
java
private static int count = 0;
public static void main(String[] args) throws InterruptedException {
Object locker = new Object(); // 锁对象
Thread t1 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
synchronized (locker) {
count++;
}
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
synchronized (locker) {
count++;
}
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("count: " + count); // 稳定输出 100000
}
synchronized 的两大特性
1)互斥
- 进入
synchronized修饰的代码块相当于加锁 ,退出相当于解锁; - 某个线程持有锁时,其他线程执行到同一把锁的
synchronized就会阻塞等待; - 底层使用操作系统的 mutex lock 实现,锁信息存放在 Java 对象头里(类似厕所门上的"有人/无人"标识)。
注意:上一个线程解锁后,下一个线程不是立即 获取到锁,而是要靠操作系统唤醒;多个线程竞争时也不保证先来后到。
2)可重入
synchronized 是可重入锁,同一条线程可以对自己持有的锁重复加锁,不会出现"自己把自己锁死"的死锁问题。
可重入锁内部维护了 "线程持有者 + 计数器":
- 加锁时发现锁被自己占用,计数器自增,依然可以进入;
- 解锁时计数器递减,减到 0 才真正释放锁。
synchronized 的三种用法
java
// 1. 修饰代码块:明确指定锁哪个对象
synchronized (locker) { ... } // 锁任意指定对象
synchronized (this) { ... } // 锁当前对象
// 2. 修饰普通方法:锁的是当前实例对象(等价于 synchronized(this))
public synchronized void method() { ... }
// 3. 修饰静态方法:锁的是类对象(XXX.class)
public synchronized static void method() { ... }
🔑 核心要点 :两个线程竞争同一把锁才会阻塞等待;竞争两把不同的锁则互不影响。理解"锁的是什么"是使用 synchronized 的关键。
Java 标准库中的线程安全情况
线程不安全(多线程下需自行加锁):
ArrayList、LinkedList、HashMap、TreeMap、HashSet、TreeSet、StringBuilder
线程安全:
StringBuffer(核心方法带 synchronized)ConcurrentHashMapVector、HashTable(加锁实现但性能差,不推荐使用)
天然安全(不涉及修改):
String
4.4 解决方案二:volatile 关键字
volatile 解决可见性问题
观察下面的代码:
java
static class Counter {
public int flag = 0; // 注意:这里没有 volatile
}
public static void main(String[] args) {
Counter counter = new Counter();
Thread t1 = new Thread(() -> {
while (counter.flag == 0) {
// 空循环
}
System.out.println("循环结束!");
});
Thread t2 = new Thread(() -> {
Scanner scanner = new Scanner(System.in);
System.out.println("输入一个整数:");
counter.flag = scanner.nextInt();
});
t1.start();
t2.start();
}
预期 :用户输入非 0 值时,t1 的循环应该结束。
实际:即使输入了非 0 值,t1 的循环依然不会结束!
原因就是内存可见性 :t1 一直在读自己工作内存(寄存器/缓存)中的 flag 副本,感知不到 t2 对主内存的修改。
给 flag 加上 volatile 即可解决:
java
static class Counter {
public volatile int flag = 0; // 保证内存可见性
}
volatile 的效果:
- 写 volatile 变量时:修改工作内存副本 → 强制把新值立即刷新到主内存;
- 读 volatile 变量时:强制从主内存读取最新值到工作内存。
代价是读写速度变慢(无法享受缓存加速),但换来了数据的准确。
⚠️ volatile 不保证原子性
volatile 和 synchronized 有本质区别:
| 关键字 | 原子性 | 内存可见性 |
|---|---|---|
synchronized |
✅ 保证 | ✅ 保证 |
volatile |
❌ 不保证 | ✅ 保证 |
如果把上面自增的例子改成 volatile int count 而不加锁,结果依然会小于 10 万------因为 count++ 是"读-改-写"三步操作,volatile 管不住中间被插队的问题。
其他停止线程相关的要点(补充)
使用共享标志位(配合 volatile)或 interrupt() 方法都可以通知线程停止:
- 自定义标志位 :需要加
volatile,否则线程可能感知不到标志位的变化; thread.interrupt():设置线程内部的中断标志。如果目标线程正阻塞在wait / join / sleep上,则以InterruptedException异常的形式通知(并清除中断标志,是否结束由 catch 中的代码决定);否则只是设置中断标志,线程通过Thread.currentThread().isInterrupted()自行判断(不清除标志)。
4.5 小结:线程安全的解决思路
| 问题 | 原因 | 解决方案 |
|---|---|---|
| 原子性问题 | count++ 等复合操作被随机调度打断 |
synchronized 加锁,保证互斥执行 |
| 可见性问题 | 线程读的是工作内存副本,感知不到主内存变化 | volatile 强制读写主内存 |
| 指令重排序 | 编译器/CPU 优化调整指令顺序 | volatile 可禁止相关重排序 |
| 随机调度 | 线程调度是抢占式的、不可控 | 通过加锁等同步机制,保证任意执行顺序下结果都正确 |
五、总结
本文按"概念 → 创建 → 状态 → 问题与解决"的主线梳理了 Java 多线程初阶的核心内容:
- 什么是线程:线程是轻量级的执行流,比进程创建/销毁/调度更快,同进程线程共享内存;
- 如何创建线程 :继承
Thread、实现Runnable、匿名内部类、Lambda,核心都是覆写run()并调用start(); - 线程的状态 :
NEW、RUNNABLE、BLOCKED、WAITING、TIMED_WAITING、TERMINATED六种状态及其转移; - 线程存在的问题与解决 :线程不安全的根源是随机调度 + 共享数据 + 非原子操作 + 可见性 + 指令重排序 ;用
synchronized保证原子性和互斥 (可重入),用volatile保证内存可见性(但不保证原子性),二者互为补充。
多线程的世界远不止于此,线程池、wait/notify、死锁、CAS、ConcurrentHashMap 原理等进阶内容,我会在后续文章中继续分享。如果本文对你有帮助,欢迎 点赞 + 收藏 + 关注 三连支持!🚀