Java 多线程详解:从入门到线程安全

Java 多线程详解:从入门到线程安全(初阶篇)

多线程是 Java 后端面试与实际开发中的重中之重。本文将从 什么是线程 讲起,逐步介绍 线程的创建方式 、线程的状态 ,最后重点分析 线程安全问题及其解决方案,帮你一次性梳理清楚多线程初阶的核心知识体系。


目录

  1. 什么是线程
  2. 如何创建线程
  3. 线程的状态
  4. 线程存在的问题与解决方案
  5. 总结

一、什么是线程

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++ 实际上对应三步操作:

  1. 从内存把数据读到 CPU;
  2. 在 CPU 中进行加 1 运算;
  3. 把结果写回内存。

如果线程 A 执行到第 2 步时,线程 B 插进来把这三步完整执行了一遍,A 恢复后继续写回旧值,就会导致一次自增"丢失"。

④ 可见性

一个线程对共享变量的修改,不能及时被其他线程看到。

Java 内存模型(JMM)规定:

  • 线程之间的共享变量存放在主内存中;
  • 每个线程都有自己的工作内存(对应 CPU 的寄存器和高速缓存);
  • 线程读共享变量时,先从主内存拷贝到工作内存再读;修改时也是先改工作内存中的副本,再同步回主内存。

为什么要这么设计?因为 CPU 访问寄存器/缓存比访问内存快 3~4 个数量级(几千到上万倍),缓存能大幅提升效率。但副作用就是:线程 1 修改了副本,主内存和其他线程的副本不一定能及时同步,导致读到"过期数据"。

⑤ 指令重排序

编译器/CPU 为了优化性能,可能会调整指令的执行顺序(前提是保持单线程逻辑不变)。例如:

  1. 去前台取 U 盘;
  2. 去教室写 10 分钟作业;
  3. 去前台取快递。

单线程下按 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)
  • ConcurrentHashMap
  • Vector、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 多线程初阶的核心内容:

  1. 什么是线程:线程是轻量级的执行流,比进程创建/销毁/调度更快,同进程线程共享内存;
  2. 如何创建线程 :继承 Thread、实现 Runnable、匿名内部类、Lambda,核心都是覆写 run() 并调用 start();
  3. 线程的状态 :NEW、RUNNABLE、BLOCKED、WAITING、TIMED_WAITING、TERMINATED 六种状态及其转移;
  4. 线程存在的问题与解决 :线程不安全的根源是随机调度 + 共享数据 + 非原子操作 + 可见性 + 指令重排序 ;用 synchronized 保证原子性和互斥 (可重入),用 volatile 保证内存可见性(但不保证原子性),二者互为补充。

多线程的世界远不止于此,线程池、wait/notify、死锁、CAS、ConcurrentHashMap 原理等进阶内容,我会在后续文章中继续分享。如果本文对你有帮助,欢迎 点赞 + 收藏 + 关注 三连支持!🚀

相关推荐
岁月宁静1 小时前
如何设计支持 Skills 的 Agent?一套可扩展的四层架构
python·agent
Shaoshing1 小时前
LeetCode100 49. 字母异位词分组
java·算法
岁月宁静1 小时前
CrewAI 快速上手:10 分钟搭建你的第一个 Agent 团队
python·agent
曲径海盗2 小时前
【Python 学习第 40 天】for 循环
python·学习笔记·编程入门
专注仿真2 小时前
知识问答大模型-rag算法详解
人工智能·python·控制算法
划破黑暗的第一缕曙光2 小时前
[C++]:5.STL:string(2)
java·c++·redis
hhhjhl2 小时前
Undici 跨端三方库仓颉适配全流程
java·开发语言·ubuntu
染指11102 小时前
158.Agent-LangGraph-子图(共享状态和不共享状态模式)
开发语言·人工智能·python·ui·agent·agents
wangchunyu1142 小时前
JAVA JVM内存结构与运行时数据区
java·开发语言·jvm