第20章 多线程入门
让程序同时做多件事,就是多线程。本节学习线程的创建方式、生命周期和简单同步。多线程是并发编程的基础,也是面试重点。
一、进程与线程
- 进程:正在运行的程序,是资源分配的基本单位。一个进程内有独立的内存空间。
- 线程:进程内的执行单元,是 CPU 调度的基本单位。一个进程至少有一个线程(主线程),可以有多个线程共享进程的内存。
进程和线程的区别:
| 对比项 | 进程 | 线程 |
|---|---|---|
| 资源 | 独立内存空间 | 共享所属进程的内存 |
| 切换开销 | 大(要切换内存空间) | 小 |
| 通信 | 跨进程麻烦 | 直接读写共享变量 |
| 崩溃影响 | 进程间互不影响 | 一个线程出问题可能带崩整个进程 |
多线程的好处:
- 充分利用多核 CPU(4 核 CPU 上 4 个线程可以真并行)
- 耗时任务(下载、查询)不阻塞主流程
- 提升程序响应速度(界面不卡)
为什么需要多线程?想象单线程下载大文件:下载的几秒里整条程序都"卡住",用户点哪都没反应。把下载扔进子线程,主线程继续响应点击、刷新界面——"等待"和"干活"并行,互不阻塞。
二、创建线程的两种方式
方式一:继承 Thread 类
classMyThreadextendsThread{@Overridepublicvoidrun(){for(inti=1;i<=5;i++){System.out.println(getName()+" 运行中:"+i);}}}MyThreadt1=newMyThread();t1.start();// 启动线程!不要直接调用 run()两个线程同时 start,输出会交替出现(顺序不固定)——这就是"并发"的直观感受:A、B 的打印穿插在一起,谁先谁后由 CPU 调度决定。
方式二:实现 Runnable 接口(推荐)
classMyRunnableimplementsRunnable{@Overridepublicvoidrun(){for(inti=1;i<=5;i++){System.out.println(Thread.currentThread().getName()+":"+i);}}}Threadt=newThread(newMyRunnable(),"工作线程");t.start();为什么推荐 Runnable:
- Java 单继承,实现接口不影响继承其他类
- 任务(run 逻辑)和线程(Thread)分离,更符合"把任务交给线程执行"的思想——同一份任务可以交给多个线程执行,而继承 Thread 做不到
- 后面学的线程池就基于 Runnable/Callable
方式三:Lambda 简化(JDK 8+)
Threadt=newThread(()->{System.out.println("Lambda 线程");});t.start();Runnable只有一个抽象方法,是函数式接口,所以能用 Lambda 简化。这个写法从 JDK 8 引入后,JDK 11、17 里用法完全一样,放心用。
三、start() 与 run() 的区别(高频考点)
MyThreadt=newMyThread();t.start();// 开启新线程,run() 在新线程中异步执行t.run();// 直接调用 run(),只是普通方法调用,不新开线程!start()才创建新线程,run()只是普通方法。忘了 start 直接调 run,代码会在主线程顺序执行,看不到并发效果。用输出顺序一眼看出区别:
t.run();// run():main 线程顺序执行,输出一定是"先 A 后 B"t.start();// start():新线程与主线程抢 CPU,输出顺序不固定另外,start() 只能调用一次:重复 start 会抛IllegalThreadStateException(易错点再展开)。
四、线程的生命周期
新建(New) → 就绪(Runnable) → 运行(Running) → 死亡(Terminated) ↑ ↓ 阻塞/等待(Blocked/Waiting) → 就绪- 新建:new Thread() 创建
- 就绪:start() 后,等待 CPU 调度
- 运行:获得 CPU 时间片执行
- 阻塞/等待:sleep、wait、IO 等待等,条件满足后回到就绪
- 死亡:run() 执行完,或异常终止
用t.getState()可以亲眼观察状态流转:start 前是 NEW,start 后是 RUNNABLE(大概率),执行完是 TERMINATED;sleep 期间是 TIMED_WAITING。
常用方法:
Thread.sleep(1000);// 静态方法:当前线程休眠 1000 毫秒t.join();// 等待 t 线程执行完,当前线程再继续(主线程等子线程收尾的典型用法)Thread.currentThread();// 获取当前线程对象t.getName();// 线程名t.setName("名字");// 设置线程名(也可以构造器传)五、线程安全问题
多个线程同时操作共享数据,会产生数据不一致:
classCounter{intcount=0;voidincrement(){count++;// 问题所在:count++ 不是原子操作!}}count++在 CPU 层面其实是三步:读 count → 加 1 → 写回。两个线程同时执行,可能都读到 0,都写成 1,导致结果少了。这属于并发三大问题里的原子性问题(另外两个是可见性、有序性)。
Counterc=newCounter();// 开 10 个线程,每个自增 1000 次for(inti=0;i<10;i++){newThread(()->{for(intj=0;j<1000;j++){c.increment();}}).start();}// 主线程等待所有线程执行完(简化写法)Thread.sleep(2000);System.out.println(c.count);// 期望 10000,实际可能不到!// 输出:9387(参考值,每次运行都不同,多数小于 10000)为什么会"丢"?假设 count 现在是 5,线程 A 读到 5 还没写回,线程 B 也读到 5,两个都写 6——加了两次,结果只加了一次。线程越多、竞争越激烈,丢得越厉害。
六、解决线程安全:synchronized
用synchronized给方法加锁,同一时刻只有一个线程能执行:
classCounter{intcount=0;synchronizedvoidincrement(){count++;}}加锁后再跑上一节的 10×1000 测试,结果永远是 10000。
synchronized两种用法:
// 1. 修饰方法:锁住整个方法publicsynchronizedvoidmethod(){}// 2. 修饰代码块:只锁关键部分,粒度更细、性能更好publicvoidmethod(){// 非共享代码不用锁synchronized(this){// 操作共享数据的代码}}锁的本质:线程进入同步代码前必须先获得对象锁,没拿到就阻塞等待。注意锁的对象是实例而不是代码——两个线程必须锁同一个对象才互斥;各自 new 一个对象再锁,等于没锁。
七、线程安全的集合
开发中常直接用线程安全容器,省去手动加锁:
// 老式的线程安全集合Vector<String>v=newVector<>();Hashtable<String,String>ht=newHashtable<>();// 并发包 java.util.concurrent(推荐):高性能线程安全集合// 从 JDK 8 起就是标准库标配,JDK 11、17 里同样内置ConcurrentHashMap<String,String>map=newConcurrentHashMap<>();CopyOnWriteArrayList<String>list=newCopyOnWriteArrayList<>();并发容器帮你把锁写好了,直接用:10 个线程各往 CopyOnWriteArrayList 加 100 个元素,结果稳定是 1000;换成普通 ArrayList 会小于 1000,甚至抛并发修改异常。老式 Vector/Hashtable 给每个方法都加了锁,并发高时性能差,是历史遗留,现代项目优先用 concurrent 包。
八、线程池(进阶预告)
频繁 new Thread 开销大,正式开发用线程池复用线程:
importjava.util.concurrent.ExecutorService;importjava.util.concurrent.Executors;ExecutorServicepool=Executors.newFixedThreadPool(4);// 4 个线程的池for(inti=0;i<10;i++){inttaskId=i;pool.execute(()->System.out.println("任务 "+taskId+" 执行中"));}pool.shutdown();// 关闭线程池Runnable 没有返回值;想让线程"算个结果回来",用 Callable + Future(pool.submit(() -> 42)返回 Future,future.get()阻塞取结果)。线程池核心参数:核心线程数、最大线程数、任务队列、拒绝策略——并发进阶再详讲。
九、扩展知识
9.1 sleep vs wait 初识
两者都能让线程"歇一会儿",但差别很大:
| 对比项 | Thread.sleep(ms) | Object.wait() |
|---|---|---|
| 谁的静态方法 | Thread 的静态方法 | Object 的实例方法 |
| 要不要锁 | 不需要,任何地方能调 | 必须在 synchronized 内 |
| 是否释放锁 | 不释放,抱着锁睡 | 释放锁,让别人进来 |
| 怎么醒来 | 到时间自动醒 | 等 notify/notifyAll 唤醒 |
典型配合:生产者/消费者。厨师做好菜就 wait 等通知,服务员取走菜就 notify:
classKitchen{synchronizedvoidcook()throwsInterruptedException{System.out.println("厨师做好菜,等待取走");wait();// 释放锁并等待通知System.out.println("菜被取走,继续做下一道");}synchronizedvoidtake(){System.out.println("服务员取走菜");notify();// 唤醒一个等待该锁的线程}}publicstaticvoidmain(String[]args)throwsInterruptedException{Kitchenk=newKitchen();newThread(()->{try{k.cook();}catch(InterruptedExceptione){}}).start();Thread.sleep(100);// 保证厨师先拿到锁k.take();}// 输出:厨师做好菜,等待取走// 服务员取走菜// 菜被取走,继续做下一道9.2 守护线程
- 用户线程:主线程结束不影响它,会自己跑完(如业务线程)
- 守护线程:所有用户线程结束后,守护线程自动结束(如 GC 垃圾回收线程、后台日志)
用法:daemon.setDaemon(true)必须在start()之前设置;之后主线程一结束,守护线程自动退出,不会无限跑下去。适合后台心跳、日志清理这类"陪跑"任务。
9.3 线程优先级
setPriority(1~10)给线程设置优先级,默认 5(Thread.MIN_PRIORITY=1、MAX_PRIORITY=10)。注意这只是给调度器的建议,具体由操作系统决定,不能依赖它保证执行顺序——了解即可,别在业务里依赖优先级。
9.4 死锁初识
两个线程各自持有一把锁,又都想拿对方的锁,互相等对方释放——谁都不让,程序卡死:
ObjectlockA=newObject();ObjectlockB=newObject();newThread(()->{synchronized(lockA){System.out.println("线程1拿到锁A");try{Thread.sleep(100);}catch(InterruptedExceptione){}synchronized(lockB){// 等锁B,但锁B在线程2手里System.out.println("线程1拿到锁B");}}}).start();newThread(()->{synchronized(lockB){System.out.println("线程2拿到锁B");try{Thread.sleep(100);}catch(InterruptedExceptione){}synchronized(lockA){// 等锁A,但锁A在线程1手里System.out.println("线程2拿到锁A");}}}).start();// 输出:线程1拿到锁A// 线程2拿到锁B// (之后互相等,程序卡死,这就是死锁)避免死锁的入门准则:所有线程按同样的顺序拿锁(都先 A 后 B),就永远不会互相等。
十、易错点
1. 直接调用 run() 而不是 start()
// ❌ run() 只是普通方法调用,没有并发效果t.run();// ✅ start() 才创建新线程t.start();2. start() 重复调用
t.start();t.start();// ❌ 抛 IllegalThreadStateException// ✅ 一个线程只能 start 一次;要再跑就 new 一个新线程3. 共享变量不加同步,数据错乱
// ❌ count++ 不是原子操作,多线程下结果变少// ✅ 加 synchronized,或直接用原子类(JDK 8 自带)importjava.util.concurrent.atomic.AtomicInteger;AtomicIntegercount=newAtomicInteger(0);count.incrementAndGet();// 原子自增,无需加锁4. sleep 抛 InterruptedException 未处理,编译报错
// ❌ Thread.sleep(1000); // 编译错误:Unhandled exception type InterruptedException// ✅ 捕获或声明抛出try{Thread.sleep(1000);}catch(InterruptedExceptione){e.printStackTrace();}5. Lambda 里直接使用循环变量
// ❌ 编译报错:i 不是 effectively final(循环中会变)for(inti=0;i<5;i++){newThread(()->System.out.println(i)).start();}// ✅ 先复制到局部变量for(inti=0;i<5;i++){inttaskId=i;newThread(()->System.out.println(taskId)).start();}6. 主线程不等子线程就结束
// ❌ main 跑完就退出,子线程可能没跑完// ✅ 用 join() 等子线程完成再收尾worker.join();十一、小结
- 进程是资源分配的基本单位,线程是 CPU 调度的基本单位;线程共享进程内存
- 创建线程:继承 Thread / 实现 Runnable(推荐)/ Lambda(JDK 8+,11、17 用法一致)
start()开新线程且只能调一次;run()只是普通方法调用- 生命周期:新建 → 就绪 → 运行 → 阻塞/等待 → 死亡
- 多线程操作共享数据有安全风险:count++ 不是原子操作,synchronized 加锁解决,或用原子类/并发容器
- sleep 不释放锁,wait 释放锁(必须在 synchronized 内,靠 notify 唤醒)
- 生产环境用线程池和并发容器,不手写裸线程;多把锁注意按相同顺序拿,避免死锁
下一节(最后一节)用综合项目串起前面所有知识。
下一篇:第21章 实战:学生信息管理系统(综合应用)