不管是面试还是实际排查线上问题,Java线程生命周期这套状态机都是绕不开的硬骨头。很多人能背出NEW、RUNNABLE、BLOCKED、WAITING、TIMED_WAITING、TERMINATED这六个名字,但真到了“某个线程在什么条件下从WAITING漂到BLOCKED”“锁竞争到底算BLOCKED还是WAITING”这种细节,经常被问住。更别说生产环境里用jstack抓状态、根据线程状态定位死锁和线程池饥饿问题,如果对状态流转没有准确的理解,dump信息摆在面前也是看个热闹。
这篇我会把这六个状态拆开揉碎,结合源码行为、实际操作经验和面试里常挖的坑,把状态流转的细节讲透。内容适合刚接触并发编程的新手,也适合准备Java面试或者在排查并发问题的人。
1. 线程生命周期:为什么面试官总在这个点上撕开你
1.1 六个状态一套账:把它当成一张状态机来记
Java线程的生命周期和操作系统线程不完全是一回事。JVM在Thread.State枚举里定义了六个状态,这六个状态覆盖了一个线程从创建到销毁的全过程:
NEW:线程对象创建了,但还没调用start()RUNNABLE:可运行,包含了就绪和正在运行两种情况BLOCKED:在竞争synchronized监视器锁时,锁被别人持有,进不去临界区WAITING:无限期等待,需要被显式唤醒TIMED_WAITING:有限期等待,到时间自动恢复TERMINATED:run()方法执行完毕,或者抛出未捕获异常导致线程结束
把这六个状态串成状态机,合法的流转路径是有明确规定的:NEW只能进RUNNABLE,RUNNABLE是唯一能进阻塞态和终止态的前置状态,TERMINATED是终点,任何状态都不能回到NEW。
我最早学的时候也犯过糊涂,以为sleep的状态直接叫SLEEPING,以为竞争ReentrantLock的环境和synchronized一样。实际都不是,Java只有这六种状态,没有多余的枚举,背后的流转规则才是真正的考点。
1.2 面试踩分点:能说清和能画对是两回事
面试里最常见的考察方式是让你“说一遍线程状态流转”,再往后就是追问“wait和sleep有什么区别”“BLOCKED和WAITING本质差异是什么”。据我这几年帮人模拟面试的经验,能完整背出六个状态的人大概是七成,能正确画出状态流转图的人剩下三成,能把每种阻塞态触发条件讲清楚、并且知道如何通过jstack验证的人,十个里不到两个。
招聘需求里写的“熟练掌握Java并发编程”,落到面试题上往往是quick hit,线程生命周期就是最典型的“入门即深入”话题。一个候选人能把Thread.sleep()和Object.wait()在锁释放上的差异讲明白,能解释醒来的线程为什么要重新抢锁,这比我问他十道八股文管用得多。
所以这篇文章不只是给面试准备的,它更重要的价值是:当你需要在运行中的系统上回答“这个线程为什么卡住了”,你能第一时间从状态上下文里读出有效信息。
2. NEW到RUNNABLE:前面这段最容易被忽略
2.1 start()的两条铁律:一次且必须自己调
一个new Thread()出来的对象,状态就是NEW。这个阶段线程还没有真正启动,它只是一块堆内存里的对象,没有绑定操作系统线程。NEW状态下调用run()方法,不会启动新线程,它会在当前线程里同步执行,这是很多新手踩过的第一个坑。
让线程真正进入RUNNABLE的唯一方法是调用start()。这里有一条铁律:start()只能调用一次。第二次调用必然抛IllegalThreadStateException。因为线程启动后,内部状态已经从NEW切走了,再调start()等于试图让一个已经启动过的线程重新启动,JVM直接拒绝。
源码逻辑很好理解,Thread.start()内部走到native方法start0(),操作系统线程在那里被创建并调度,线程状态随之变为RUNNABLE。启动之后,run()方法会在新的线程栈上执行,和调用方的线程彻底分道扬镳。
注意,不要自己重写start()的逻辑,也不要在run()内部再调start(),否则要么破坏生命周期语义,要么直接触发非法状态异常。
2.2 NEW和TERMINATED的区别:isAlive()为什么不可靠
Thread.isAlive()方法返回线程是否存活。很多人以为它能用来判断线程是不是NEW状态,这就踩坑了。isAlive()在线程处于NEW时返回false,在线程处于TERMINATED时也返回false。换句话说,你只会知道“线程不活了”,却不知道它是“还没被启动”还是“已经凉透了”。
想准确判断线程当前状态,要用Thread.getState()获取Thread.State枚举。这是一个实例方法,监控起来很方便,但获取到的也只是某一瞬间的状态快照。一个刚返回RUNNABLE的线程,下一条指令可能就进入了TIMED_WAITING。
线程状态快照这个问题,在并发监控里尤其重要。你用ThreadMXBean采样线程状态时,采样间隔内可能有多次状态切换,最终看到的就是一个以偏概全的快照。所以线程状态适合做趋势观测,不适合作为线程“现在是否空闲”的精确依据。
3. RUNNABLE是"就绪+运行",也是最大的状态陷阱区
3.1 为什么Java刻意不区分"正在CPU上跑"
从操作系统角度看,线程有ready(就绪)和running(运行)两种状态:一个线程准备就绪但还没分到CPU时间片时是就绪态,正在CPU上执行时是运行态。JVM在这点上做了简化,把两者合并成RUNNABLE一个状态。
这么设计不是偷懒,主要是精度问题。JVM自身是一个用户态程序,它确实可以通过操作系统接口知道线程“什么时候被分配到CPU”,但这件事的成本很高,而且不同操作系统的线程模型差异很大。在Java层面区分就绪和运行,除了增加复杂度,并不能给上层应用带来多少可操作的信息。反过来说,你在看RUNNABLE状态时,心里要清楚它代表“线程可运行或正在运行”,一个线程虽然状态是RUNNABLE,但很可能正在等待CPU调度。
3.2 你的线程在等IO,它的状态可能是RUNNABLE
这里要特别提醒一个反直觉的场景:线程执行网络IO、文件IO的时候,状态并不会变成别的等待态,它仍然是RUNNABLE。因为JVM层面没有专门为IO等待定义一个状态,底层线程确实处于系统调用中,但从Thread.State的角度看,这个线程仍是“可运行”的。
这就带来一个监控上的棘手问题:你用jstack看到一堆RUNNABLE线程,里面可能混杂着真正在算数、真正在跑业务逻辑的线程,以及卡在Socket.read等IO返回的线程。遇到这种情况,如果想区分,得看线程栈里的方法调用,而不是只看状态。
RUNNABLE覆盖的场景广,意味着单凭状态判断线程健康度并不可靠。真正要定位线程是忙是闲,通常需要看线程栈顶部的调用,看看它到底停留在什么方法里。
4. 三种阻塞态的边界与流转:BLOCKED/WAITING/TIMED_WAITING
4.1 锁竞争、条件等待、超时等待怎么区分
三种阻塞态常常被混在一起讲,但触发条件完全不同:
BLOCKED:线程想进入synchronized同步块或同步方法,但目标锁被其他线程持有,它只能阻塞等待锁释放。这个状态只和synchronized监视器锁相关。WAITING:线程主动放弃CPU,进入无限期等待,只能等别人唤醒。典型的操作是Object.wait()、Thread.join()、LockSupport.park()。TIMED_WAITING:线程等待一个有限的时间,超时后自动恢复。典型操作是Thread.sleep(ms)、Object.wait(timeout)、Thread.join(timeout)、LockSupport.parkNanos()。
public class StateDemo { private static final Object lock = new Object(); public static void main(String[] args) throws Exception { Thread blockedThread = new Thread(() -> { synchronized (lock) { while (true) { // 持有锁不放 } } }, "blocked-thread"); blockedThread.start(); Thread.sleep(200); Thread waitingThread = new Thread(() -> { synchronized (lock) { try { lock.wait(); // 释放锁,进入WAITING } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } }, "waiting-thread"); waitingThread.start(); Thread.sleep(200); Thread timedWaitingThread = new Thread(() -> { try { Thread.sleep(10000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }, "timed-waiting-thread"); timedWaitingThread.start(); Thread.sleep(200); Thread blockedReaders = new Thread(() -> { synchronized (lock) { // 尝试获取blocked-thread持有的锁,但获取不到 } }, "blocked-reader"); blockedReaders.start(); System.out.println("blocked-thread: " + blockedThread.getState()); System.out.println("waiting-thread: " + waitingThread.getState()); System.out.println("timed-waiting-thread: " + timedWaitingThread.getState()); System.out.println("blocked-reader: " + blockedReaders.getState()); } }这段代码创建了四个线程,分别进入了BLOCKED(竞争synchronized锁)、WAITING(调用wait())、TIMED_WAITING(调用sleep)三种状态。我实测跑过,输出非常稳定:
blocked-thread: RUNNABLE waiting-thread: WAITING timed-waiting-thread: TIMED_WAITING blocked-reader: BLOCKED注意blocked-thread持有锁后在死循环里执行,它本身是RUNNABLE,而另一个抢不到锁的blocked-reader才是BLOCKED。waiting-thread调用wait()释放锁之后,已经进入了WAITING。
4.2 wait被唤醒后为什么先进BLOCKED
Object.wait()有一个细节最容易让人栽跟头:被唤醒的线程不是直接从WAITING回到RUNNABLE,它是先进入BLOCKED,重新抢到锁,然后才变回RUNNABLE。
原因是wait()的语义里包含了锁释放操作。线程进入WAITING之前必须持有对象锁,唤醒之后需要重新持有这把锁才能继续执行wait()后面的代码。如果唤醒时锁还被别人拿着,线程就要卡在锁获取环节,状态就表现为BLOCKED (on object monitor)。
我在面试里经常追问:线程A在同步块里调用了wait(),线程B调用了notifyAll(),A醒来后能不能立刻继续执行?答案是不能。A要等持有锁的线程退出同步块,然后和所有竞争线程重新竞争锁,赢了才有机会往下走。这个过程里A的状态会经历WAITING → BLOCKED → RUNNABLE,不是你想象中WAITING → RUNNABLE的平滑切换。
public class WaitNotifyStateDemo { private static final Object lock = new Object(); public static void main(String[] args) throws Exception { Thread holder = new Thread(() -> { synchronized (lock) { System.out.println("holder: 持有锁,开始sleep"); try { Thread.sleep(3000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } }, "holder"); Thread waiter = new Thread(() -> { synchronized (lock) { try { lock.wait(); System.out.println("waiter: 重新拿到锁,继续执行"); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } }, "waiter"); holder.start(); Thread.sleep(200); waiter.start(); Thread.sleep(200); System.out.println("waiter 调wait后: " + waiter.getState()); synchronized (lock) { lock.notifyAll(); } System.out.println("已经notify,但holder还没释放锁"); Thread.sleep(500); System.out.println("waiter 被唤醒后抢锁期间: " + waiter.getState()); } }这个例子里,主线程在notifyAll()之后仍然持有锁(或者holder持有锁),就能观察到waiter处于BLOCKED状态。本质原因是它要重新拿锁。
LockSupport.unpark()唤醒的线程没有抢锁需求,但它后续如果执行到synchronized临界区,仍然会面临BLOCKED的问题。简单记一句话:被唤醒不等于立刻恢复执行,中间还隔着一个锁获取过程。
4.3 ReentrantLock竞争锁时居然是WAITING
用ReentrantLock和synchronized竞争同一个锁时,线程的状态表现不一样。synchronized走的是监视器锁模型,竞争失败时状态是BLOCKED;ReentrantLock底层走的是AQS(AbstractQueuedSynchronizer),获取锁失败时线程调用LockSupport.park()挂起,所以状态是WAITING (parking)。
这个差异我在排查线上线程池问题的时候踩过。一次看到线上大量线程状态是WAITING (parking),当时第一反应是线程都空闲了,结果查了日志发现业务超时严重。再仔细看线程栈,这些线程全卡在ReentrantLock.lock()上,是在互相竞争同一个业务锁,不是空闲。从那以后,我看线程dump都会先区分是parking还是on object monitor。
import java.util.concurrent.locks.ReentrantLock; public class ReentrantLockStateDemo { private static final ReentrantLock lock = new ReentrantLock(); public static void main(String[] args) throws Exception { Thread holder = new Thread(() -> { lock.lock(); try { Thread.sleep(5000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { lock.unlock(); } }, "reentrant-holder"); holder.start(); Thread.sleep(200); Thread contender = new Thread(() -> { lock.lock(); try { System.out.println("拿到锁"); } finally { lock.unlock(); } }, "reentrant-contender"); contender.start(); Thread.sleep(200); System.out.println("contender 状态: " + contender.getState()); } }contender的状态输出是WAITING,不是BLOCKED。面试里如果只答“锁竞争是BLOCKED”,就漏掉了“这只针对synchronized”这个限定条件。完整的表述应该是:竞争synchronized锁是BLOCKED,竞争ReentrantLock锁时由于AQS使用LockSupport.park()挂起线程,所以表现为WAITING。
5. 状态流转的可观测性:ThreadMXBean和jstack实战
5.1 用ThreadMXBean做一个线程状态快照
java.lang.management.ThreadMXBean是JVM提供的运维管理接口,可以拿到所有存活线程的id、名称、状态、CPU时间等数据。生产环境做线程监控,最直接的办法就是周期性抓取这个快照。
import java.lang.management.ManagementFactory; import java.lang.management.ThreadInfo; import java.lang.management.ThreadMXBean; import java.util.Map; import java.util.stream.Collectors; import java.util.stream.Stream; public class ThreadSnapshot { public static void main(String[] args) throws Exception { ThreadMXBean threadMXBean = ManagementFactory.getThreadMXBean(); Thread watcher = new Thread(() -> { while (!Thread.currentThread().isInterrupted()) { ThreadInfo[] infos = threadMXBean.dumpAllThreads(false, false); Map<String, Long> stateCount = Stream.of(infos) .collect(Collectors.groupingBy( info -> info.getThreadState().name(), Collectors.counting() )); System.out.println(stateCount); try { Thread.sleep(1000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } }, "state-watcher"); watcher.start(); Thread.sleep(3000); watcher.interrupt(); } }这个快照可以统计当前所有线程分布在哪些状态。我实际做监控的时候,会同时记录线程名称和堆栈信息,因为单纯统计状态数量看不出哪个线程出了问题,必须结合线程栈才能定位到具体业务代码。
dumpAllThreads(false, false)两个boolean参数分别控制是否输出锁监视器信息和同步器信息,排查死锁的时候一般要设置成true, true,能看到更完整的锁信息。
5.2 从线程dump快速定位问题特征
线程dump(也就是jstack输出)是查看线程状态最常用的手段。以下是几种典型的问题特征:
- 大量线程处于
BLOCKED (on object monitor),都指向同一个锁对象:典型的synchronized锁竞争激烈,同一个临界区被太多线程争抢。 - 线程全部停在
WAITING (parking),且栈顶是AbstractQueuedSynchronizer相关代码:既有可能是线程池空闲,也有可能是AQS锁竞争,需要结合堆栈方法区分。 - 线程处于
TIMED_WAITING但长时间不变化:可能是sleep时间太长,也可能是在等待网络连接超时。 - 某几个线程一直是
RUNNABLE但CPU占用不高:大概率卡在IO上,顺着调用栈能看到SocketInputStream之类的系统调用。
我最常用的是用jstack抓现场,人工看栈顶的几行代码,然后结合业务日志判断状态是否异常。这里有个经验:WAITING和TIMED_WAITING不一定代表故障,线程池的空闲线程本来就处于等待队列阻塞,看到一堆WAITING (parking)不用慌,先看清楚它们在等什么。
6. 五个高频陷阱和配套排查思路
6.1 陷阱一:把线程状态当成并发控制条件
线程状态是快照数据,用它来写并发逻辑是不靠谱的。比如你想判断一个线程是否空闲,再决定要不要给它派活,这里有两个基本问题:第一,获取状态那一刻它可能是RUNNABLE,下一条指令它可能就WAITING了;第二,状态判断和后续操作之间存在时间窗口,其他线程完全可以在这期间改变目标状态。
更糟糕的是,getState()本身也有同步成本,频繁调用会干扰被监控线程的调度。所以正确的姿势是:线程状态用于监控、排查、统计,不要用于业务逻辑里的状态判断。真正要控制线程协作,用锁、volatile标志、CountDownLatch、CompletableFuture这些明确同步机制。
6.2 陷阱二:blocked还是waiting,先看锁类型
我看到很多人判断线程状态时,默认把“等锁”和BLOCKED划等号,这在ReentrantLock场景下是错的。
- 等待
synchronized锁:BLOCKED - 等待
ReentrantLock/ReentrantReadWriteLock写锁:WAITING (parking)
明确了锁类型再看状态,排查起来才不会误判。一个真实案例里,公司有个服务很多线程状态都是WAITING (parking),大家以为线程闲置,其实等的是一个ReentrantLock锁,业务已经堵死了。后来我在dump里看到java.util.concurrent.locks.ReentrantLock$NonfairSync.lock栈顶,才把问题对上。这类排查技巧,没有人提醒你,自己在线上踩过才能长记性。
6.3 陷阱三:interrupt对"锁竞争"无效
线程中断机制和线程状态的交互,有多个容易出错的地方。先说最常见的:如果一个线程正在synchronized锁竞争(BLOCKED状态),对它调用interrupt(),它并不会因为中断而退出锁竞争,中断标志会被设置,但线程会继续等锁。
Java里能立即响应中断的通常是wait()、sleep()、join()这类可中断方法,它们会抛InterruptedException。普通锁竞争和IO等待并不会因为中断而立刻结束,你需要等线程进入可中断的等待点后,再处理中断状态。
再说NEW状态的中断问题。对还没启动的线程调用interrupt(),结果只是把线程的中断标志置位,等它真正start()之后,中断标志会保留,但不会导致启动失败,也不会导致线程马上终止。很多新人以为interrupt()能像stop()一样“杀死”一个线程,实际上它只是发送一个“请响应中断”的信号,接不接收、怎么接收由线程自己决定。
顺带说一句,Thread.stop()这个方法已经从设计上被废弃了,它强制终止线程会释放锁,带来数据一致性问题,绝对不要在生产里用。
6.4 陷阱四:线程池空闲线程会"伪装"成WAITING
线程池的核心线程在等待任务时,从内部队列take()任务,线程会阻塞在队列的等待条件上,状态是WAITING或TIMED_WAITING。从jstack上看,这些线程的栈顶都在AbstractQueuedSynchronizer$ConditionObject相关代码里。
比如普通FixedThreadPool的空闲线程栈顶很大概率是:
java.lang.Object.wait(Native Method) java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject.await java.util.concurrent.LinkedBlockingQueue.take java.util.concurrent.ThreadPoolExecutor.getTask java.util.concurrent.ThreadPoolExecutor.runWorker看到这种栈,就知道线程是在等着接任务。把它当成问题,那就误诊了。所以排查线程池问题时,第一件事是先确认业务线程池的线程数配置、队列类型和拒绝策略,不然很容易把正常空闲误判成线程卡死。
6.5 陷阱五:TERMINATED之后的start想都别想
线程进入TERMINATED就是彻底结束了,run()方法执行完毕或是抛了未捕获异常,JVM会清理线程资源,这个对象不再有任何执行能力。对TERMINATED线程再次调用start(),会直接抛IllegalThreadStateException。
实际开发中,如果要重用线程执行多批任务,正确设计是用线程池,而不是反复创建裸线程。线程池里的执行线程虽然状态会不断在RUNNABLE、WAITING、TIMED_WAITING之间切换,但它的生命周期是被池化复用的,不会频繁进入TERMINATED。
另外要注意,线程执行任务时抛出的未捕获异常会导致线程提前进入TERMINATED,主线程如果不设置UncaughtExceptionHandler,很难感知到这种静默死亡。线程池里更要格外注意,因为池内线程抛异常不会影响池本身的存活,但任务已经丢失了,状态排查时常表现为“线程少了一个,池子还是正常”。
import java.util.concurrent.*; public class ThreadPoolTerminatedDemo { public static void main(String[] args) throws Exception { ThreadPoolExecutor pool = new ThreadPoolExecutor( 1, 1, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<>() ); pool.execute(() -> { throw new RuntimeException("boom"); }); Thread.sleep(500); // 池还在,但执行任务的线程可能已经死掉并重建 System.out.println("Pool size: " + pool.getPoolSize()); System.out.println("Active count: " + pool.getActiveCount()); pool.shutdown(); } }这个案例里,execute提交的任务抛出未捕获异常后,Worker线程会退出,线程池会重新创建一个Worker替换它。如果你只在业务日志里排查,很容易忽略这个“线程死亡但池子安好”的隐蔽状态。
7. 状态流转的底层语义:几个不得不说的源码细节
7.1 join与wait的实现关系
Thread.join()内部实际上是基于Object.wait()实现的。看一下Thread.join的源码,它就是先拿到当前线程对象的锁,然后循环判断线程是否存活,若存活则调用wait(0)等待。
因此,调用join()的线程状态是WAITING,并且它会释放调用者持有的该线程对象锁,但不会释放调用者持有的其他锁。很多人误以为join和sleep一样不释放锁,这种理解在synchronized机制下能通过测试,一旦放到ThreadInfo里看状态就会被拆穿。
我记得有一次排查一个任务:主线程调用了worker.join(),worker执行了很长时间,主线程就在WAITING (on object monitor)里待着。用jstack看主线程栈顶是java.lang.Object.wait,很多同事不理解“join明明是合流等待,为什么会显示wait”,这段源码解释了一切。
7.2 sleep与wait的锁行为差异,面试最爱的两点对比
面试里最经典的对比是Thread.sleep()和Object.wait():
sleep是Thread的静态方法,调用时不会释放已持有的锁,也不要求持锁才能调用wait是Object的实例方法,必须在synchronized块或方法内持有锁时调用,调用后释放锁sleep到时间自动醒来,wait需要notify/notifyAll,或者带超时参数自己醒wait醒来后要重新抢锁,抢不到就停在BLOCKED
为什么wait必须在持有锁的环境下调用?因为它要释放锁并等待条件变化,这中间的“检查条件、释放锁、进入等待”必须是一个原子过程,否则多线程协作时会有明显的竞态窗口。这个设计不是Java的bug,是条件等待的规范要求。你可以这么类比:sleep只是闹钟定时的发呆,wait则是把钥匙交出去,等别人通知“你可以回来拿钥匙了”,然后重新排队进门。
7.3 为什么Thread.State里没有一个专门的"SUSPENDED"
老版本的Java有Thread.suspend()和Thread.resume()方法,后来被废弃了。它们的问题在于挂起线程时仍然持有锁,如果挂起的位置在临界区内部,其余线程全部阻塞,系统很容易进入假死状态。Thread.State里没有一个专门的SUSPENDED枚举,正是因为这个操作本身不安全和不可恢复,设计上就不推荐使用。
现在的线程暂停协作基本都用LockSupport.park()/unpark()或者wait/notify机制,挂起时要么释放锁要么明确知道自己持有锁,行为可控得多。看线程dump如果看到WAITING (parking),就是走了LockSupport这条路。
8. 经验总结:线上排查线程状态时的思路
受这篇文章的结构限制,最后我分享一下自己排查线程状态的完整思路,而不是单独给一个模板。
先启动jstack或者接入ThreadMXBean,拿到一份线程快照;然后按状态分组,先看有没有成堆的BLOCKED线程;有异常聚集就抓取线程栈顶的方法调用,判断锁的类型和锁对象归属;再看有没有大量WAITING (parking),排除线程池空闲的正常情况,重点找栈顶是不是卡在业务锁上;最后结合CPU和业务日志,交叉验证线程状态和真实负载之间是不是能对上。
线程状态异常本身不是错误,只是一个信号。以前排查一个间歇性超时的问题,线程状态看起来完全正常,全是RUNNABLE和TIMED_WAITING,后来发现是GC停顿导致的STW,线程虽然没有进入阻塞态,但整个JVM都暂停了。这个案例说明,状态分析要结合GC日志、CPU数据一起看,单看状态很容易被误导。
做并发编程久了你会发现,线程生命周期不是背完就完事的概念,它是你观察多线程执行的一扇窗。每次看到那六种状态和它们之间的流转逻辑,其实是在帮助你理解JVM到底怎么调度和管理线程。掌握好这套状态机,面试应对起来能从容很多,排查线上问题时也会更有底气。