Java 从入门到精通(十四):多线程入门,为什么程序一并发就开始变得“不听话”?

张开发
2026/4/13 23:05:08 15 分钟阅读

分享文章

Java 从入门到精通(十四):多线程入门,为什么程序一并发就开始变得“不听话”?
Java 从入门到精通十四多线程入门为什么程序一并发就开始变得“不听话”前一篇我们把 NIO 这条线讲清楚了为什么 Java 后来不满足于传统 IO为什么会引入 Path、Files、Buffer、Channel、Selector 这些更偏工程化的抽象。但当你继续往后学很快就会遇到另一个更容易让人头疼的话题多线程。很多人第一次接触多线程时都会有一种很强烈的落差感。前面学变量、分支、循环、方法、类、集合时代码基本都还符合一种“顺着往下执行”的直觉第 1 行执行完再执行第 2 行然后执行第 3 行程序逻辑像一条清晰的线但一旦进入并发世界这种直觉会迅速被打破。因为这时你要面对的不再只是“代码写没写对”而是为什么明明逻辑没问题结果却偶尔不对为什么有时候运行正常有时候结果乱掉为什么线程一多程序反而更慢为什么两个线程同时改一个变量就会出现莫名其妙的 bug为什么别人总说“线程安全”但新手很难直观理解它到底在说什么所以这篇文章不是要把并发包一次性讲完而是先把多线程最重要的入门骨架搭起来线程到底是什么它和进程是什么关系为什么程序需要并发而不是一直单线程Java 里创建线程有哪些方式start() 和 run() 到底差在哪什么叫线程安全为什么共享数据最容易出问题学多线程时初学者最容易踩哪些坑你先把这套基础认知搭稳后面再去学同步、锁、线程池、并发容器、JUC才不会一上来就被术语砸晕。一、先搞清楚什么是进程什么是线程很多教程上来就说线程是“程序执行的最小单位”这句话没错但太抽象。更直观一点的理解是1进程正在运行的一个程序实例比如你电脑上同时开着IDEA微信Chrome音乐播放器这些运行中的应用每一个都可以看作一个进程。进程有自己的资源空间比如内存文件句柄网络连接运行上下文所以进程更像是一个“独立的运行容器”。2线程进程内部真正执行任务的路径一个进程里不一定只有一条执行线。比如浏览器可以同时做这些事渲染页面加载图片执行 JavaScript处理用户点击发网络请求如果这些事情全都串行排队体验会非常差。所以一个进程内部通常会有多个线程分别负责不同工作。你可以先把它粗略理解成进程是房子线程是房子里活动的人房子提供空间和资源人真正去干活。二、为什么单线程很多时候不够用很多初学者一开始会想“一个程序顺着执行不是挺好吗为什么非要搞多线程”因为现实任务并不总适合排成一条线。1为了同时处理多个任务比如一个聊天程序可能要同时接收消息显示界面发送图片保存聊天记录如果全部都靠一条线程串着做只要其中某一步卡住其他事情就会被拖住。2为了避免界面卡死这是桌面程序和移动端里很常见的动机。比如主线程负责界面渲染如果你把一个耗时操作直接放进去读取大文件请求网络复杂计算那界面就会卡住用户会感觉“程序死了”。所以常见做法是主线程负责交互后台线程负责耗时任务3为了更好利用多核 CPU现代机器通常不是只有一个 CPU 核心。如果程序能把任务拆开并行执行就有机会把多核算力利用起来。当然这里要注意一个现实问题不是“开线程”就一定更快。如果任务本身拆不开或者线程切换成本太高线程越多反而越慢。所以多线程不是银弹它只是解决特定问题的一种手段。三、Java 程序其实从一开始就有线程很多人以为“写了多线程代码程序才有线程”。其实不是。Java 程序启动后至少就已经有一条主线程在执行 main() 方法。例如publicclassDemo{publicstaticvoidmain(String[]args){System.out.println(Thread.currentThread().getName());}}运行后通常会输出main这说明你的程序天然就在一个线程里运行。所谓“多线程”只是说除了主线程之外你又创建了新的执行路径。四、Java 创建线程的两种经典方式入门阶段最常见的是这两种继承 Thread实现 Runnable后面你会发现工程里更常用线程池和 ExecutorService但基础认知还是得从这里来。五、方式一继承 Thread 类先看最经典的写法publicclassMyThreadextendsThread{Overridepublicvoidrun(){for(inti1;i5;i){System.out.println(getName() 正在执行i);}}}然后在主方法里启动它publicclassDemo{publicstaticvoidmain(String[]args){MyThreadt1newMyThread();MyThreadt2newMyThread();t1.start();t2.start();}}你会看到输出顺序通常不是固定的。这很正常。因为两个线程是并发执行的调度顺序由 JVM 和操作系统共同决定。这种写法的优点直观好理解适合第一次接触线程时建立概念它的局限Java 只能单继承如果你的类已经继承了别的父类就不能再继承 Thread“任务逻辑”和“线程对象”耦合得太紧所以在真实开发里更推荐第二种方式。六、方式二实现 Runnable 接口publicclassMyTaskimplementsRunnable{Overridepublicvoidrun(){for(inti1;i5;i){System.out.println(Thread.currentThread().getName() 执行任务i);}}}使用时这样写publicclassDemo{publicstaticvoidmain(String[]args){MyTasktasknewMyTask();Threadt1newThread(task,线程A);Threadt2newThread(task,线程B);t1.start();t2.start();}}这里要注意一个关键变化Runnable 负责描述“要做什么事”Thread 负责把这个任务放到线程里执行这就比直接继承 Thread 更灵活。为什么 Runnable 更常用因为它把任务执行载体拆开了。这是一种更合理的设计。后面学线程池时你会更明显感受到这一点线程池管理的是线程而你提交进去的是任务。七、run() 和 start() 到底有什么区别这是多线程入门最经典、也最容易被面试反复问的一个点。很多初学者第一次写线程时会这样MyThreadtnewMyThread();t.run();然后以为“线程启动了”。其实没有。1直接调用 run()本质上只是把一个普通方法当普通方法执行。也就是说代码还是跑在当前线程里。2调用 start()才是真正告诉 JVM请启动一个新线程并在这个新线程里执行 run()。所以一定要记住run()普通方法调用start()启动新线程看一个对比publicclassDemo{publicstaticvoidmain(String[]args){ThreadtnewThread(()-{System.out.println(子线程Thread.currentThread().getName());});t.run();System.out.println(主线程Thread.currentThread().getName());}}这里很可能两行都输出 main。但如果改成t.start();那么子任务就会在另一个线程里执行。这个区别必须吃透。八、为什么线程的输出顺序经常“乱”很多人第一次运行多线程程序都会困惑“我代码明明写得很顺为什么输出不按顺序来”因为多个线程并发执行时谁先拿到 CPU 时间片谁先输出并不是你代码书写顺序决定的。例如publicclassDemo{publicstaticvoidmain(String[]args){Threadt1newThread(()-{for(inti0;i5;i){System.out.println(A-i);}});Threadt2newThread(()-{for(inti0;i5;i){System.out.println(B-i);}});t1.start();t2.start();}}输出可能是A-0A-1B-0A-2B-1B-2A-3B-3A-4B-4也可能完全是另一种顺序。这不是 bug而是并发的正常表现。所以多线程世界里一个基本认知是不要依赖“看起来刚好是这样”的执行顺序。如果逻辑必须有顺序保障那你需要显式控制而不是碰运气。九、什么叫共享数据为什么它最危险真正让多线程变复杂的不是“同时执行”本身而是多个线程同时操作同一份数据。比如这样一个例子publicclassCounter{intcount0;publicvoidincrement(){count;}}如果只有一个线程调用 increment()基本没问题。但如果多个线程同时调它就可能出错。为什么 count 不安全很多初学者会觉得“加 1 不是一个动作吗”其实从底层看它往往不是单一步骤而更像先读出 count在原值上加 1再把结果写回去如果两个线程同时做这三步就可能发生覆盖。比如线程 A 读到 5线程 B 也读到 5A 写回 6B 也写回 6结果两次加一最后却只变成了 6。这就是典型的并发问题。十、什么是线程安全你现在不用追求特别严格的定义先抓住最实用的理解如果一段代码在多个线程同时使用时结果仍然正确、不会出现数据错乱那它就是线程安全的。对应地不安全的常见信号包括结果偶尔不对同样代码重复跑每次结果不一样明明逻辑正确却会“随机出 bug”小规模测试没问题一并发就出事这也是为什么并发 bug 特别烦人。因为它们常常不是“必现”的而是“偶发”的。偶发 bug 比稳定 bug 更难查。十一、先别急着记锁先记住三类并发风险初学多线程时不要一上来就背 synchronized、Lock、volatile 一堆术语。先把问题本身认出来更重要。1竞争条件多个线程争抢同一份资源导致结果依赖执行时机。例如多个线程同时修改一个计数器。2可见性问题一个线程改了变量另一个线程未必立刻看得到。这类问题初学时比较抽象但后面学 volatile 就会更清楚。3有序性问题某些操作在底层执行时顺序未必和你表面看到的一模一样。这一点在深入并发模型时很关键不过入门阶段先知道它存在就够了。先记住并发问题不是只有“抢变量”这么简单。它至少还涉及“看不看得见”和“执行顺序会不会变”。十二、线程常见的几个基础方法先建立直觉入门阶段你不需要一口气全背完但下面这些很常见1sleep()让当前线程暂停一段时间。try{Thread.sleep(1000);}catch(InterruptedExceptione){e.printStackTrace();}常用于演示线程交替执行模拟耗时任务但要注意sleep() 不会释放你已经拿到的锁。2join()让一个线程等待另一个线程执行完。例如ThreadtnewThread(()-{System.out.println(子线程开始);});t.start();t.join();System.out.println(主线程继续);这表示主线程要等 t 执行结束再往下走。3currentThread()获取当前正在执行的线程对象。它对调试非常有用。System.out.println(Thread.currentThread().getName());4setName() / 线程名给线程起个名字调试时会清楚很多。比如ThreadtnewThread(task,订单处理线程);比一堆默认的 Thread-0、Thread-1 更容易看懂。十三、为什么说“线程越多不一定越快”这是一个特别值得尽早建立的观念。很多人初学并发时会天然觉得一个线程干活不如十个线程一起干十个线程不如一百个线程一起干听起来很合理但现实并不是线性增长。因为线程本身也有成本创建成本销毁成本上下文切换成本内存占用成本共享数据同步成本如果任务很小、线程很多程序可能大部分时间都浪费在线程调度上。所以多线程的目标不是“开更多线程”而是用合适的并发方式提高吞吐、响应性或资源利用率。这也是后面为什么要学线程池。线程池的核心思想本质上就是别频繁手搓线程统一管理更合理。十四、初学多线程最容易踩的 6 个坑1把 run() 当成启动线程的方法这是最经典的坑。再次强调run() 只是普通方法start() 才会开启新线程2看到输出顺序乱就以为程序错了并发本来就不保证自然顺序。不能拿单线程直觉去要求多线程。3多个线程共享一个变量却没意识到会出问题这会直接导致数据错乱。4以为加了线程性能就一定提升实际还要看任务是否适合并行线程切换是否过多有没有锁竞争CPU 是否真能吃满5用 sleep() 解决同步问题很多新手喜欢这样写“我先 sleep(100)另一个线程应该就跑完了吧。”这非常不稳。sleep() 只能“拖时间”不能保证逻辑同步正确。真正需要顺序控制时应考虑 join()、锁、条件变量等更可靠手段。6一上来就猛学高级并发包基础却没稳如果你连线程是什么共享数据为什么危险start() / run() 区别为什么结果会乱序这些基础都没吃透直接上 AQS、线程池源码、CAS通常只会越学越乱。十五、给你一个更稳的学习顺序如果你现在刚接触多线程我建议按这个顺序学第一步先建立线程直觉先彻底理解进程和线程的关系为什么要并发如何创建线程start() 和 run() 的区别第二步再理解共享数据问题重点搞清楚什么是线程安全为什么 count 会出错什么叫竞争条件第三步再学同步手段包括synchronized锁对象同步代码块volatile原子类第四步最后进入工程化并发包括线程池并发容器FutureCompletableFutureJUC 工具类这样推进比一上来就硬啃高级并发原理稳得多。十六、最后总结多线程难不是因为 API 多而是因为“时间”进来了如果说前面学集合、泛型、异常、IO本质上还是在处理“单条执行线里的正确性”那么多线程带来的最大变化是程序不再只和数据打交道而是开始和“时间顺序”打交道。也正因为这样问题会突然变复杂同样的代码先后顺序不同结果就可能不同多个线程同时执行输出天然可能交错共享变量一旦出现逻辑就不再只是“写对语法”那么简单所以你这篇真正要带走的不是会写两个线程打印数字而是这几个核心认识1线程是进程内部的执行路径Java 程序启动后本身就有主线程。2多线程是为了解决并发任务、响应性和资源利用率问题但它不是越多越好。3创建线程的经典方式有两种继承 Thread实现 Runnable工程里通常更偏向 Runnable 这种任务与线程分离的方式。4start() 和 run() 必须分清这是多线程入门的第一道门槛。5真正危险的地方在共享数据线程安全问题本质上多数都和共享状态有关。从这里开始Java 学习就正式进入一个更像“真实工程”的阶段了。因为并发不是可选装饰而是现代程序几乎绕不开的一块地基。下一篇最自然的继续方向就是线程同步与 synchronized为什么多个线程改同一个变量时结果总会乱

更多文章