ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Java线程入门解析:从创建到并发优势与代价

2026/10/7 21:09:18 拓冰建站 浏览量
Java线程入门解析:从创建到并发优势与代价 我先把话说在前面做了这么多年Java开发面试别人和被别人面试都经历过不少每次聊到线程总能发现很多人卡在同一个地方——能说出Thread和Runnable但一旦问到“为什么需要线程”“线程切换为什么会消耗资源”“AtomicInteger到底凭什么线程安全”就支支吾吾了。今天这篇“Java线程一入门解析从创建到优势解析”就是把这块地基重新夯一遍适合刚接触Java并发、准备校招社招、以及写了好几年CRUD但一直没系统梳理过线程知识的朋友。读完你至少能回答清楚线程是什么、怎么创建、生命周期怎么走、多线程到底带来了哪些优势和代价以及日常开发里最容易踩的坑在哪。1. 先搞懂线程到底是个什么玩意儿1.1 进程与线程从“公司”和“员工”说起要理解线程绕不开进程。进程是操作系统分配资源的基本单位每个进程都有自己的内存空间、文件句柄、环境变量等。你可以把进程理解成一家独立的公司公司有自己的办公场地、营业执照、资金账户互相之间不能随便用对方的资源。线程则是进程内部的一条执行路径是CPU调度的基本单位。还是拿公司打比方一个公司里可以有多个员工大家共享公司的办公场地、设备、资料库各自干活。员工就是线程公司就是进程。进程之间的通信成本极高因为内存不共享靠管道、共享内存、Socket这些外部机制而线程之间天然共享进程的内存空间通信成本低得多但随之而来的是资源竞争和可见性问题。Java里的线程是直接由JVM映射到操作系统线程的所谓一对一模型主流HotSpot虚拟机都这么干所以你在代码里new一个Thread背后真的会向操作系统申请一个原生线程这也是为什么线程不能无限创建——系统资源是有限的。1.2 为什么我们需要多线程说直接点多线程的本质是用并发来对抗硬件和业务的矛盾。现在的CPU动辄多核如果程序是单线程的一个核忙死其他核闲着吞吐量完全上不去。这就像一家公司只派一个员工干活其他人喝茶效率自然拉胯。更关键的是I/O场景。绝大多数业务系统都在做网络读写、数据库操作、文件读写这些操作的特点是CPU几乎不参与线程大部分时间在等I/O返回。单线程写一个HTTP服务请求一个接一个排队每个请求都要等数据库200毫秒那并发量基本残废。多线程可以让等待I/O的请求挂起CPU去处理其他请求这就是我们常说的“并发提升吞吐量”。但要注意多线程不是免费的午餐。线程的创建和销毁是有开销的线程切换要保存和恢复上下文包括程序计数器、寄存器、栈指针等这些开销在高频切换时非常可观。所以后来才有了线程池池化的目的就是复用线程减少创建销毁和切换成本。1.3 Java线程模型的核心部件Java的线程模型有三个核心部件Thread类、Runnable接口、以及JVM线程调度机制。Thread是线程的抽象你可以继承它或直接用它Runnable是任务的抽象把“要执行什么”和“用哪个线程执行”解耦调度机制则由操作系统负责Java无法强制某个线程优先运行只能给提示就是那个经常被误用的优先级。还有一个很容易混淆的概念Thread本身不是线程它是线程的句柄和入口。真正干活的线程由JVM创建Thread对象只是你操控这个线程的工具。很多初学者以为new Thread就是创建了线程其实严格说是创建了一个Thread对象只有调用start()之后才会真正诞生一个操作系统线程。2. Java里创建线程的四种姿势从最原始到最现代2.1 继承Thread类最直观但最不推荐public class MyThread extends Thread { Override public void run() { System.out.println(线程执行了 getName()); } } public static void main(String[] args) { Thread t new MyThread(); t.start(); }这种方式历史最悠久所有Java教程开篇都会讲。继承Thread之后重写run方法把任务逻辑写在里面然后start()启动。我不想一棍子打死但实际工作中我几乎没见过谁正经项目里这样写。原因有三个第一个Java是单继承你继承了Thread就没办法继承其他业务类扩展性极差。第二个把任务代码和线程实现耦合在一起一个线程只能干一种活复用不了。第三个run方法没有返回值子线程的计算结果根本拿不到异常处理也麻烦。所以这种方式顶多在小demo里用真正干活选下面这几种。2.2 实现Runnable接口把任务和线程分离public class Task implements Runnable { Override public void run() { System.out.println(任务执行中当前线程 Thread.currentThread().getName()); } } Thread t new Thread(new Task(), worker-1); t.start();Runnable是函数式接口只有一个run方法无参数无返回值。它的最大价值是解耦——任务本身只是“一段可以被执行的逻辑”它不关心自己被哪个线程执行可以通过线程池反复提交同一个Runnable实例。要注意调用Runnable的run()和调用Thread.start()是两回事。直接调run()就是在当前线程里同步执行任务没有任何新建线程的动作只有通过Thread的start()启动才会在新线程里回调run()。这个区别我在面试里问过很多人十个里有三个会答错。另外自Java 8之后Runnable可以直接写成lambdanew Thread(() - System.out.println(lambda任务), worker-2).start();简洁很多可读性也好日常开发推荐这样写。2.3 实现Callable接口任务执行完还能拿结果public class CallableTask implements CallableString { Override public String call() throws Exception { return 线程返回结果 Thread.currentThread().getName(); } } FutureTaskString futureTask new FutureTask(new CallableTask()); Thread t new Thread(futureTask); t.start(); String result futureTask.get(); // 阻塞等待结果Runnable的run方法不能返回结果也不能抛受检异常这在很多需要“子线程算完给我个值”的场景里十分难用。Callable就是来补这个洞的。call方法可以返回泛型结果可以抛出异常配合FutureTask可以异步拿结果。FutureTask的实现原理值得聊一下。它内部维持了一个状态机NEW、COMPLETING、NORMAL等通过CAS来保证任务状态的线程安全。调用get()时如果任务还没执行完当前线程会进入等待直到任务完成被唤醒。这个机制其实是AQSAbstractQueuedSynchronizer的一次经典应用理解了FutureTask后面学线程池会轻松很多。不过在真实项目里我们很少直接new Thread去跑Callable都是走线程池的submit方法。这正好引出第四种姿势。2.4 线程池创建生产环境的唯一答案ExecutorService pool Executors.newFixedThreadPool(8); FutureString future pool.submit(() - 任务结果); String result future.get(); pool.shutdown();为什么说生产环境唯一答案因为线程的创建和销毁是真的贵。我做过一个简单统计在普通Linux机器上创建并销毁一个线程的总体开销大约在几十微秒到上百微秒量级看着不多但如果是高频任务积累起来的开销会让系统吞吐明显下降。线程池的核心思想就是提前创建一批线程任务丢进队列线程们轮着取任务执行这样线程的创建开销被摊薄在池的生命周期里切换也更可控。Executors框架提供了几种现成的池FixedThreadPool固定线程数、CachedThreadPool按需创建闲置回收、SingleThreadPool单线程串行执行、ScheduledThreadPool做定时任务。但我在开发中更建议直接用ThreadPoolExecutor自己配置因为Executors的默认队列长度是Integer.MAX_VALUE如果任务堆积太多内存会被打爆而且默认的拒绝策略是AbortPolicy直接抛异常业务上经常不是你想要的。这个部分先点到为止线程池细节我后面会专门展开一篇。现在先把创建线程的基础姿势搞扎实。3. 线程生命周期从出生到消亡的六种状态3.1 六种状态对应关系Java线程在任意时刻都处于且只处于一种状态Thread.State枚举一共六种NEW、RUNNABLE、BLOCKED、WAITING、TIMED_WAITING、TERMINATED。这里有个大众误区把RUNNABLE等同于“正在运行”。其实RUNNABLE状态包含“就绪”和“运行中”两种情况Java层面不区分线程是否真的在CPU上跑全交给操作系统了。我用一张表帮大家快速记忆状态转换的关键点状态进入方式退出方式NEWnew Thread()start()RUNNABLEstart()时间片耗尽、遇到锁、调wait等BLOCKED竞争synchronized锁失败获取到锁WAITING调wait()/join()/park()被notify/notifyAll/unparkTIMED_WAITINGsleep(time)/wait(time)/join(time)时间到或被唤醒TERMINATEDrun()正常返回或抛出未捕获异常无需要特别说明的是BLOCKED和WAITING的区别。BLOCKED是线程在等一把synchronized锁其他线程占着茅坑不拉屎WAITING是线程主动放弃了CPU等一个明确的条件比如调了wait()等notify或调了join()等对方线程结束。前者是被动等待锁后者是主动等待条件语义完全不同。3.2 Thread.sleep、join、yield的差异这三个方法是最容易被混淆的。Thread.sleep(time)让当前线程从RUNNABLE进入TIMED_WAITING睡满时间后回到RUNNABLE。注意sleep不会释放任何锁如果你在持有synchronized锁时sleep其他线程照样进不来。Thread.join()是让当前线程等待调用join的那个线程执行完毕。比如在main里调t.join()main线程会进入WAITING态直到t线程结束后才继续。它本质上是线程间协同的一种朴素手段底层用wait/notify实现。Thread.yield()比较特殊它的含义是“我主动让出CPU时间片”让其他同优先级线程先跑。但yield只是给调度器的建议听不听全看操作系统心情。这个玩意在实际开发中用处真不大我也就调试算法时的并发demo里用过。还有一个常见操作是中断。调用t.interrupt()不会像电影里那样把线程拍死而是设置一个中断标志位。如果线程正卡在sleep、wait、join这些可中断操作上会抛InterruptedException并清除标志位。所以处理中断的正确姿势是在catch里把中断状态恢复或者根据业务选择退出任务而不是单纯catch后咽下去。3.3 守护线程和用户线程的区别守护线程Daemon Thread是JVM的“后勤部队”典型代表是GC线程。判断标准就一句话如果整个进程里只剩守护线程JVM就会直接退出而用户线程没跑完JVM会一直等着。设置守护线程很简单start()之前调setDaemon(true)就行。这里有个经典坑如果你在守护线程里写了一些不重要的定时上报任务JVM退出时守护线程会被强制终止数据可能没发出去就没了。所以在实际项目里需要完整性保障的任务绝对不能放守护线程比如日志异步写入、消息确认这种搞成守护线程等于自断后路。3.4 获取当前线程名最简单也最常被忽略String threadName Thread.currentThread().getName(); System.out.println(当前线程是 threadName);这个API在排查问题时几乎是救命的。你想想生产环境遇到CPU飙高你dump线程栈出来如果每个线程Name都是默认的Thread-0、Thread-1你根本不知道哪个是业务线程哪个是垃圾。所以强烈建议创建线程或配置线程池的时候给线程起一个有意义的名字比如“order-async-worker-1”“trade-settle-pool-2”。多花几秒排查问题省几个小时。4. 多线程的优势到底在哪里代价又是什么4.1 优势吞吐、响应、资源利用的三重提升多线程最核心的优势是提升系统的吞吐量。在Web应用里每个请求交给一个线程处理线程之间互不阻塞同一个时间窗口内能同时服务大量请求。没有多线程你的服务就退回到“一个人办理所有业务”的窗口柜后面排队的人早晚骂街。其次是提升响应速度。对于单个大任务可以拆分成多个小任务并行执行比如一份报表要同时查询三个数据源串行可能要6秒三个线程各查一个源2秒就能聚合完成。响应时间从6秒降到2秒用户感知差距巨大。还有一点很多人没意识到多线程能更高效地利用CPU多核资源。单线程程序在四核机器上CPU利用率基本就是25%左右剩下三核闲着。并行处理能让CPU利用率大幅提高尤其是计算密集型场景比如图片处理、大数据量排序多线程带来的加速比非常明显。4.2 代价上下文切换与资源消耗我前面提过上下文切换的开销这里再往深挖一点。一次线程切换涉及保存当前线程的执行上下文寄存器、程序计数器、栈指针、内存映射等加载下一个线程的上下文可能还要处理TLBTranslation Lookaside Buffer失效。这些操作虽然是微秒级但如果线程切得特别频繁时间片都耗在切换上了任务反而没跑多少这就是所谓的“切换抖动”。另外每个线程都有独立的栈空间默认栈大小通常是1MB虚拟机配置Xss决定。你创建1000个线程光是栈内存就吃掉约1GB再算上线程控制块和JVM内部结构内存压力非常大。这就是为什么无脑开线程一定会把系统搞挂。4.3 并发带来的三类脏问题原子性、可见性、有序性多线程最大的坑不是性能而是正确性。线程共享内存时会出现三种典型问题原子性问题多个线程同时执行i这种复合操作A线程读到的值还没写回B线程又读了旧值结果两个线程各自加1最后i只加了1。i在字节码层面其实是“读-改-写”三步不是原子操作。可见性问题每个线程都有自己的工作内存修改后的值不会立刻刷新到主内存其他线程可能一直读到旧值。这就是Java内存模型的经典问题解决办法是volatile或加锁。有序性问题编译器和CPU为了优化可能调整指令执行顺序。在单线程内调整无伤大雅多线程下就可能出现匪夷所思的顺序颠倒。这三类问题导致我们常说“线程不安全”。所以多线程不是随便加的加之前必须想清楚共享变量的并发策略。4.4 AtomicInteger真的线程安全吗很多人看到AtomicInteger就安心觉得用了它就万事大吉。AtomicInteger确实是线程安全的它底层依赖CASCompare And Swap机制也就是比较并交换。简单说更新一个值的时候先读期望值如果内存中的当前值等于期望值就替换成新值否则就重试。这个操作由CPU指令级保证原子性不带锁效率高。但我要敲黑板AtomicInteger保证的是“单个操作”的原子性不保证“复合操作”的原子性。比如你用AtomicInteger做计数器的自增没问题但如果你要做的是一次“先判断再更新”的业务流程那必须用锁或CAS循环自己控制。另外在极高竞争场景下CAS会频繁失败导致自旋浪费CPU性能可能反而不如synchronized。所以选型要看场景别迷信任何工具类。5. 线程安全实战锁、volatile与常见排查5.1 synchronized与Lock的选择保证线程安全最基础的手段是synchronized它可以锁代码块、锁方法、锁Class对象。Java 6之后synchronized引入了偏向锁、轻量级锁、重量级锁的升级路径性能没那么差你不用一上来就嫌弃它。Lock接口主要是ReentrantLock提供更精细的控制可尝试获取锁tryLock、可中断获取锁lockInterruptibly、公平锁支持还有Condition来精细控制等待/唤醒。我的建议是简单场景用synchronized代码更简洁需要超时、可中断、多条件等待等高级能力时再上Lock。这里提醒一个最容易犯的错误锁的对象选错了。如果你用synchronized(this)两个线程操作不同的实例对象时代码块不会被互斥要跨实例互斥必须锁同一个类的class对象。又一个隐蔽问题是锁和事务混用在Spring事务方法里先加锁再提交事务锁释放了事务还没提交另一个线程读到的可能是旧数据。5.2 volatile轻量级的可见性保证volatile的作用有两个保证变量修改的可见性禁止指令重排序。但它不保证原子性。经典例子是多个线程同时读写的boolean标志位一个线程置false其他线程立刻看到这个场景用volatile非常适合。我见过不少新手把volatile当“万能同步关键字”用给一个int字段加volatile然后多个线程做累加结果该丢数还是丢数因为累加的非原子性根本没有被解决。记住一句话volatile适合写状态标志、适合发布不可变对象但不适合做计数器。5.3 线程死锁是怎么发生的线程死锁是面试高频题也是生产环境非常棘手的故障。死锁发生的条件有四个互斥、持有并等待、不可剥夺、循环等待。四个条件缺一不可理论上打破任意一个就能防止死锁。给个最典型的案例线程A持有锁X等待锁Y线程B持有锁Y等待锁X。两个线程互相等对方放手谁也动弹不得。排查死锁的通用手法是先jps找到进程号然后jstack导出线程栈看到“Found one Java-level deadlock”就实锤了。日常开发预防死锁的手段包括尽量用tryLock带超时、加锁顺序一致、缩小锁粒度。5.4 线程数量与任务类型的关系这是个很容易被忽视的点。线程池到底开多少线程不是拍脑袋定的。CPU密集型任务线程数建议设置成CPU核数1I/O密集型任务因为大部分时间线程在等待可以开更多线程常见经验公式是CPU核数 * 2或者更精细地用“CPU核数 * (1 等待时间/计算时间)”来估算。我在一个订单推送系统里踩过教训当时线程池开了64个线程处理第三方接口推送本来以为是I/O密集型结果三方接口响应特别快CPU反而成为瓶颈大量线程频繁切换导致吞吐不升反降。后来把线程数调到32关闭了多余线程性能立刻回升。所以线程数设置一定要结合真实压测公式只是起点。5.5 排查线程问题的三板斧线程卡死、CPU飙升这些故障排查工具很重要。第一板斧是jstack导出线程栈看看线程卡在哪个方法、锁等待在哪里第二板斧是jstat看GC和类加载排除GC停顿引起的线程假死第三板斧是Arthas这类在线诊断工具可以动态查看线程状态、反编译热点方法、实时监控调用链。有一次线上有个接口偶发超时jstack发现大量线程BLOCKED在同一个对象锁上再一看锁是由一个全局单例对象持有的而持有锁的线程卡在一个外呼接口上等响应。原来是外呼接口超时时间设太长导致锁长时间不释放。把超时缩短并改用读写锁后问题解决。多线程问题通常不复杂难的是你不去看线程栈凭猜永远找不到根因。6. 基于线程的常用并发工具与框架扩展6.1 Future与CompletableFuture的演进前面提到FutureTask它是Future的一个实现。但Future有个尴尬问题它只能阻塞式get()或者轮询isDone()没办法在任务完成时回调。Java 8之后的CompletableFuture解决了这个问题它用回调函数式地编排异步任务。CompletableFuture.supplyAsync(() - { return queryOrderInfo(orderId); }).thenApply(order - { return enrichOrder(order); }).thenAccept(order - { sendNotify(order); });这种链式写法让异步流程清晰得多。实际开发中CompletableFuture可以配合线程池来限制并发数它的allOf可以等所有子任务完成anyOf可以等最早完成的那个返回。这些在批量查询、多路汇合场景下非常好用比手写CountDownLatch更省事。6.2 线程安全的高并发容器如果你已经走到“多线程访问集合”这一步就别用HashMap、ArrayList了。线程安全的容器有好几个流派ConcurrentHashMap是分段锁或CAS实现的同读高并发下表现优秀CopyOnWriteArrayList用于读多写少场景写时复制一份数组读不用加锁BlockingQueue系列里面有ArrayBlockingQueue、LinkedBlockingQueue、SynchronousQueue等在线程池的任务队列里全是它们的天下。关于“线程池的阻塞队列怎么选”我简单给个方向固定线程池通常配合LinkedBlockingQueue可以无界也可以设上限要严格控制任务堆积就选ArrayBlockingQueue想直接依赖提交的线程处理的就选SynchronousQueue。队列的选择直接影响背压策略这事我们线程池专题再见细节。6.3 线程与主流框架的融合现在的Java业务开发基本离不开Spring Boot和MyBatis-Plus这类框架。Spring的Async注解就是基于线程池实现的默认的SimpleAsyncTaskExecutor每次都会new线程生产环境建议自己配置ThreadPoolTaskExecutor来覆盖。MyBatis-Plus这类ORM本身不直接管线程但SQL执行都是在业务线程里完成的如果业务线程不安全连接池的获取和释放就可能出问题。所以理解线程是理解整个框架并发行为的基础。还有个小技巧Spring里获取当前线程名做链路追踪很常见配合MDC可以把traceId放进log里线程池里异步执行时记得用TaskDecorator把MDC上下文传递过去否则异步的日志里traceId就丢了。这个坑我踩过不止一次排查跨线程日志特别痛苦大家一定要在异步入口处处理一下上下文传递。7. 我的实操心得和一点掏心窝的建议我做了几年Java开发从最初把线程池参数抄到配置里就不管到现在能根据业务模型自己算线程数量、自己写阻塞队列策略这个过程最大的体会是线程不是一个孤立的技术点它和操作系统、硬件、框架设计全都挂钩。你把它学明白了很多看似复杂的并发问题其实都能追溯到最基础的几个概念上。给新手一个建议不要急着背线程池的七种参数和状态转换的图先动手写几个小demo——用三种方式创建线程、用AtomicInteger做计数器、故意构造一个死锁然后jstack排查。等这些基础操作成肌肉记忆了再看源码和框架就顺畅很多。说到后续扩展我觉得值得单独展开的内容至少还有三块线程池的源码级解析ThreadPoolExecutor内部的工作线程如何循环取任务、AQS与Lock的实现原理ReentrantLock、Semaphore、CountDownLatch都建立在它之上、以及JDK并发工具包里的各种容器和同步器。这些内容每一块都能写出一大篇等我把第一篇消化透我们继续往下聊。