第20章 多线程入门
2026/9/16 23:51:39 网站建设 项目流程

第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

  1. Java 单继承,实现接口不影响继承其他类
  2. 任务(run 逻辑)和线程(Thread)分离,更符合"把任务交给线程执行"的思想——同一份任务可以交给多个线程执行,而继承 Thread 做不到
  3. 后面学的线程池就基于 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章 实战:学生信息管理系统(综合应用)

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询