Java基础-多线程
一、线程创建方式(两种写法,解决“怎么开启多任务”问题 )
Java 里想让程序同时干多件事(比如一边下载、一边播放音乐 ),就得用多线程。而创建线程,最基础的就是继承 Thread 类和实现 Runnable 接口这两种方式,本质都是定义“线程要做的事(run()
方法 )”,再启动线程让它执行。
1. 继承 Thread 类方式
// 第一步:自定义类继承 Thread,重写 run() 方法
class MyThread extends Thread {// run() 里写的是“这个线程要执行的任务”@Overridepublic void run() {// 这里简单打印线程名称,实际可以是复杂逻辑(比如文件下载、数据处理等)System.out.println("线程执行:" + Thread.currentThread().getName());}
}// 第二步:在 main 方法(主线程)里启动线程
public class Main {public static void main(String[] args) {// 创建自定义线程对象MyThread t1 = new MyThread();// 启动线程!注意:必须用 start(),不能直接调 run()!// start() 会让 JVM 给线程分配资源(比如栈空间),然后自动调用 run() 执行任务// 如果直接调 run(),就和普通方法调用一样,不会开启新线程,还是在主线程里执行t1.start();}
}
- 关键点:
- 继承
Thread
后,run()
是线程的“任务入口”,JVM 会在启动线程后自动执行它。 start()
是真正“启动新线程”的关键方法,它会触发 JVM 的线程调度机制,给线程分配 CPU 时间片。- 缺点:Java 是单继承,要是你的类已经继承了其他类(比如
MyClass extends OtherClass
),就没法再继承Thread
了,所以这种方式灵活性稍差。
- 继承
2. 实现 Runnable 接口方式
// 第一步:自定义类实现 Runnable 接口,重写 run() 方法
class MyRunnable implements Runnable {@Overridepublic void run() {// 同样,这里定义线程要做的事,比如打印线程名System.out.println("线程执行:" + Thread.currentThread().getName());}
}// 第二步:在 main 方法里,用 Thread 类“包装” Runnable 对象,再启动
public class Main {public static void main(String[] args) {// 创建 Runnable 任务对象(只是定义了任务,还没线程执行它)MyRunnable runnableTask = new MyRunnable();// 把任务丢给 Thread,让 Thread 来管理线程的启动、调度Thread t2 = new Thread(runnableTask);// 启动线程,和之前一样,start() 会触发 JVM 执行 run()t2.start();}
}
- 关键点:
Runnable
是个接口,只规定了run()
方法,本身不涉及线程的“启动”能力。所以必须借助Thread
类的构造方法,把Runnable
任务“包装”进去,才能启动线程。- 优点:解决了单继承限制!比如
MyRunnable
还能继承其他类(像class MyRunnable extends OtherClass implements Runnable
),更灵活;而且,多个线程可以共享同一个Runnable
对象(比如多个线程一起处理同一个任务队列 )。
举个共享的例子:
MyRunnable sharedTask = new MyRunnable();
// 线程 A 和 线程 B 共享同一个任务对象
Thread tA = new Thread(sharedTask, "线程A");
Thread tB = new Thread(sharedTask, "线程B");
tA.start();
tB.start();
这样,线程 A 和 B 会基于同一个 sharedTask
执行任务,适合处理“多个线程协作完成同一份工作”的场景(比如共同处理一个计数器、一个文件写入操作 )。
二、线程的生命周期(理解线程“一生”的状态变化 )
线程从创建到销毁,会经历一系列状态变化,图里总结的流程是:
新建(New)→ 可运行(Runnable)→ 运行(Running)→(阻塞/等待/超时等待)→ 可运行 → 运行 → 终止(Terminated)
1. 新建(New)
- 场景:刚用
new
创建线程对象,还没调用start()
的时候。比如MyThread t1 = new MyThread();
这一步,线程只是个“空壳”,JVM 还没给它分配真正的线程资源(比如栈、程序计数器等 )。 - 此时程序没有任何变化,必须调
start()
才会进入下一步。
2. 可运行(Runnable)
- 场景:调用
start()
后,线程进入这个状态。此时,线程已经“准备好执行了”,但得等 CPU 调度(JVM 有个“线程调度器”,决定哪个线程先执行 )。 - 注意:“可运行”不代表真的在执行,只是在“就绪队列”里排队,等 CPU 时间片。比如电脑同时开了很多程序,CPU 同一时间只能干一件事(多核 CPU 可以并行,但调度逻辑更复杂 ),所以得排队。
3. 运行(Running)
- 场景:CPU 选中了这个线程,开始执行
run()
方法里的代码,真正“干活”的状态。 - 能持续多久:取决于 CPU 时间片!时间片用完,线程会回到可运行状态,重新排队;如果
run()
里的代码很快执行完(比如就打印一句话 ),也会直接进入终止状态。
4. 阻塞/等待/超时等待(Blocked / Waiting / Timed Waiting)
这三类状态都是线程“暂停执行”的情况,原因不同,处理方式也不同:
阻塞(Blocked):
场景:线程想进入 synchronized 修饰的代码块/方法,但锁被其他线程占用了,就会进入阻塞状态,一直等到锁释放。
例子: 线程 B 会卡在进入
synchronized
的地方,直到线程 A 释放锁。synchronized (lock) {// 假设线程 A 先拿到锁,线程 B 想进来就会阻塞 }
等待(Waiting):
场景:线程调用了
wait()
(必须在synchronized
里调用 )、join()
等方法,会进入“无限期等待”,直到被其他线程唤醒。例子:
synchronized (lock) {// 线程调用 wait(),释放锁,进入等待lock.wait();// 必须等其他线程调用 lock.notify() 或 notifyAll(),才会被唤醒,回到可运行状态 }
超时等待(Timed Waiting):
场景:线程调用了
sleep(long 毫秒)
、wait(long 毫秒)
、join(long 毫秒)
等方法,会进入“有限期等待”,到时间自动唤醒,或者提前被其他线程唤醒。例子: 或者:
// 线程睡 3 秒,期间不占用 CPU,到时间自动回到可运行状态 Thread.sleep(3000);
synchronized (lock) {// 最多等 5 秒,要是没人唤醒,到时间也会自己醒lock.wait(5000); }
回到可运行状态:
不管是阻塞、等待还是超时等待,只要“阻碍因素消失”(比如锁释放了、被其他线程唤醒了、超时时间到了 ),线程就会回到 可运行(Runnable) 状态,重新排队等 CPU 调度。
5. 终止(Terminated)
- 场景:
run()
方法执行完毕(正常跑完所有代码 ),或者线程被强制中断(比如抛了未捕获的异常 ),线程就会进入终止状态,生命周期结束,不能再重启。 - 注意:线程终止后,就算调用
start()
也没用,会报错!线程对象一旦终止,就“报废”了,想再执行任务,得重新new
一个。
三、线程常用方法
这些方法是 Thread
类或 Object
类(wait
、notify
等是 Object
的方法 )提供的,用来控制线程的状态、行为,解决“线程之间怎么配合、怎么调度”的问题。
方法 | 作用 & 细节 | 注意事项 |
---|---|---|
start() | 启动线程!让 JVM 为线程分配资源(栈、PC 等 ),并把线程放入“可运行”队列,等待 CPU 调度。必须用这个方法启动线程,不能直接调 run() (直接调 run() 就是普通方法调用,不会开新线程 )。 | 线程只能 start() 一次,第二次调会抛 IllegalThreadStateException 异常。 |
run() | 线程的“任务逻辑”入口,JVM 会在 start() 后自动调用它。你在这方法里写线程要做的事(比如循环、IO 操作等 )。 | 直接调用 run() 不会开新线程,就是普通方法执行,谨慎使用! |
sleep(long millis) | 让当前线程“休眠”指定毫秒数,不释放锁(如果当前线程拿着锁,sleep 期间锁不会放 ),到时间后回到“可运行”状态。 | 静态方法,直接 Thread.sleep(1000); 调用;可能会抛出 InterruptedException (比如休眠时被其他线程中断 ),需要处理异常。 |
join() | 让当前线程等待另一个线程执行完毕。比如线程 A 调用 threadB.join() ,线程 A 会进入“等待”,直到 threadB 的 run() 执行完,线程 A 才会继续跑。 | 常用来“协调线程执行顺序”,比如主线程等子线程跑完再汇总结果;也会抛 InterruptedException 。 |
wait() / notify() / notifyAll() | - wait() :让当前线程释放锁,进入“等待”状态,必须在 synchronized 代码块/方法里调用。<br>- notify() :唤醒一个等待在同一个锁上的线程(随机选一个 )。<br>- notifyAll() :唤醒所有等待在同一个锁上的线程。 | 这三个方法都是 Object 类的方法(因为锁可以是任意对象 ),必须在 synchronized 里用,否则抛 IllegalMonitorStateException 异常。 |
interrupt() | “中断”线程,但不是强制终止(别理解成杀死线程 )。会设置线程的“中断标志位”,如果线程在 sleep 、wait 、join 等方法里,会抛出 InterruptedException ,让线程感知到“被中断”,然后自己决定怎么处理(比如提前结束任务 )。 | 只是打个“中断标记”,具体怎么响应中断,得看线程内部逻辑。比如:<br>java<br>if (Thread.currentThread().isInterrupted()) {<br> // 自己处理中断,比如结束循环、返回结果<br>}<br> |
setPriority(int priority) | 设置线程的优先级(1 - 10,默认 5 ),只是给线程调度器“建议”,不保证优先级高的线程一定先执行。 | 优先级高的线程被 CPU 选中的概率大一些,但别依赖它控制执行顺序(不同系统调度策略不同 )。 |
yield() | 让当前线程“让出”CPU 资源,回到“可运行”状态,重新和其他线程一起竞争 CPU。只是建议,调度器可能不理会。 | 常用来“给优先级低的线程机会”,但实际效果看 JVM 心情,别当精确控制手段。 |
四、线程同步与锁(解决“多线程抢资源”问题,避免数据混乱 )
多线程同时访问共享资源(比如同一个变量、同一个文件 )时,容易出现“数据竞争”,导致结果混乱。线程同步就是解决这个问题的,核心思路是让多个线程“有序访问”共享资源,常用 synchronized
和 Lock
接口两种方式。
1. synchronized 关键字(内置锁,简单直接 )
synchronized
可以修饰方法或代码块,保证同一时间只有一个线程能进入“被保护的区域”,从而避免多线程冲突。
(1)修饰代码块(指定锁对象 )
class BankAccount {private int balance = 1000;// 自定义锁对象(也可以用 this、字节码对象等当锁,看需求 )private final Object lock = new Object();public void withdraw(int amount) {// 同步代码块:只有拿到 lock 锁的线程,才能进这个块synchronized (lock) {if (balance >= amount) {balance -= amount;System.out.println(Thread.currentThread().getName() + " 取款 " + amount + ",余额:" + balance);}}}
}
- 原理:线程想进入
synchronized (lock)
代码块,必须先拿到lock
对象的“锁”。如果锁被其他线程占用,当前线程就会进入阻塞(Blocked) 状态,直到锁释放。这样就保证了,同一时间只有一个线程能修改balance
,避免多线程同时减余额导致数据错误。
(2)修饰方法(隐式锁对象 )
修饰实例方法:
public synchronized void withdraw(int amount) {// 锁对象是 this(当前实例)if (balance >= amount) {balance -= amount;} }
相当于
synchronized (this) { ... }
,锁是当前对象实例。修饰静态方法:
public static synchronized void withdraw(int amount) {// 锁对象是 类的字节码对象(比如 BankAccount.class ) }
相当于
synchronized (BankAccount.class) { ... }
,锁是类对象。优缺点:
优点:简单易用,JVM 自动管理锁的获取和释放(进入同步块自动拿锁,退出自动放锁,包括抛异常的情况 )。
缺点:不够灵活(比如想尝试获取锁、设置超时时间,
synchronized
做不到 );如果同步范围太大,会影响性能(多个线程排队太久 )。
2. Lock 接口(显式锁,更灵活 )
java.util.concurrent.locks.Lock
是更灵活的锁机制,常用实现类是 ReentrantLock
(可重入锁,和 synchronized
类似,同一线程可以多次拿锁 )。
基本用法:
class BankAccount {private int balance = 1000;// 创建 Lock 对象,常用 ReentrantLockprivate Lock lock = new ReentrantLock();public void withdraw(int amount) {// 手动加锁lock.lock();try {// 临界区:操作共享资源的代码if (balance >= amount) {balance -= amount;System.out.println(Thread.currentThread().getName() + " 取款 " + amount + ",余额:" + balance);}} finally {// 手动释放锁!必须在 finally 里放,否则一旦代码抛异常,锁永远不会释放,导致死锁lock.unlock();}}
}
- 原理:和
synchronized
类似,lock()
会尝试获取锁,拿到锁的线程才能执行临界区代码;执行完后,必须在finally
里调用unlock()
释放锁,保证锁一定能释放(即使代码抛异常 )。
优点(对比 synchronized
):
更灵活:
- 可以尝试获取锁(
tryLock()
),拿不到锁可以不等待,去做别的事; - 可以设置超时时间(
tryLock(long time, TimeUnit unit)
),避免线程无限阻塞; - 可以实现更复杂的锁逻辑(比如读写锁
ReentrantReadWriteLock
,读锁共享、写锁独占,适合读多写少的场景 )。
- 可以尝试获取锁(
可中断:
lockInterruptibly()
方法允许在获取锁的过程中响应中断(比如等待锁时,其他线程调用了interrupt()
,可以提前放弃等待