☰
Java线程入门解析:从创建到并发优势与代价
2026/10/7 21:09:09 网站建设 项目流程

我先把话说在前面:做了这么多年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可以直接写成lambda:

new Thread(() -> System.out.println("lambda任务"), "worker-2").start();

简洁很多,可读性也好,日常开发推荐这样写。

2.3 实现Callable接口:任务执行完还能拿结果

public class CallableTask implements Callable<String> { @Override public String call() throws Exception { return "线程返回结果:" + Thread.currentThread().getName(); } } FutureTask<String> 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()时,如果任务还没执行完,当前线程会进入等待,直到任务完成被唤醒。这个机制其实是AQS(AbstractQueuedSynchronizer)的一次经典应用,理解了FutureTask,后面学线程池会轻松很多。

不过在真实项目里,我们很少直接new Thread去跑Callable,都是走线程池的submit方法。这正好引出第四种姿势。

2.4 线程池创建:生产环境的唯一答案

ExecutorService pool = Executors.newFixedThreadPool(8); Future<String> 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/unpark
TIMED_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 代价:上下文切换与资源消耗

我前面提过上下文切换的开销,这里再往深挖一点。一次线程切换涉及:保存当前线程的执行上下文(寄存器、程序计数器、栈指针、内存映射等),加载下一个线程的上下文,可能还要处理TLB(Translation Lookaside Buffer)失效。这些操作虽然是微秒级,但如果线程切得特别频繁,时间片都耗在切换上了,任务反而没跑多少,这就是所谓的“切换抖动”。

另外每个线程都有独立的栈空间,默认栈大小通常是1MB(虚拟机配置Xss决定)。你创建1000个线程,光是栈内存就吃掉约1GB,再算上线程控制块和JVM内部结构,内存压力非常大。这就是为什么无脑开线程一定会把系统搞挂。

4.3 并发带来的三类脏问题:原子性、可见性、有序性

多线程最大的坑不是性能,而是正确性。线程共享内存时,会出现三种典型问题:

原子性问题:多个线程同时执行i++这种复合操作,A线程读到的值还没写回,B线程又读了旧值,结果两个线程各自加1,最后i只加了1。i++在字节码层面其实是“读-改-写”三步,不是原子操作。

可见性问题:每个线程都有自己的工作内存,修改后的值不会立刻刷新到主内存,其他线程可能一直读到旧值。这就是Java内存模型的经典问题,解决办法是volatile或加锁。

有序性问题:编译器和CPU为了优化,可能调整指令执行顺序。在单线程内调整无伤大雅,多线程下就可能出现匪夷所思的顺序颠倒。

这三类问题导致我们常说“线程不安全”。所以多线程不是随便加的,加之前必须想清楚共享变量的并发策略。

4.4 AtomicInteger真的线程安全吗

很多人看到AtomicInteger就安心,觉得用了它就万事大吉。AtomicInteger确实是线程安全的,它底层依赖CAS(Compare 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核数+1;I/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并发工具包里的各种容器和同步器。这些内容每一块都能写出一大篇,等我把第一篇消化透,我们继续往下聊。

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

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

立即咨询