
做并发开发的前几年我最怕别人问“JUC包里到底有哪些东西”。不是不会用而是记不住。ReentrantLock、ConcurrentHashMap、线程池、Semaphore、CountDownLatch单独拎出来都能写点Demo可真到项目里要做锁等待超时、要做限流、要做任务汇总就不知道该选谁更不知道出了性能问题该往哪个方向查。后来我把JUC的设计主线捋了一遍才发现它根本不是一团散沙底层是CAS和volatile上面长出了AQS这套“排队加阻塞唤醒”的锁框架再往上才是Lock、并发容器、原子类、线程池、并发工具类。这篇JUC并发编程的“下篇”就沿着这条主线做一次基础速成把最常上手的组件一次讲透。目标很简单看完之后面对并发场景你能判断出该用什么也知道为什么用它。1. AQSJUC的地基不读懂它基本靠背很多人学JUC时先把API背一遍结果几天就忘。原因在于不知道这些类在解决同一个底层问题。AQSAbstractQueuedSynchronizer几乎撑起了JUC半壁江山ReentrantLock、Semaphore、CountDownLatch、ReentrantReadWriteLock内部都有一个继承AQS的同步器Sync。把AQS搞明白这些类在你眼里就不是孤立的API而是同一个模板换了几套参数。1.1 state锁的本质是一个可原子操作的整数AQS内部维护了一个volatile修饰的int变量state所有同步逻辑都围绕它展开。它是什么含义由子类自己定ReentrantLock里它表示“当前线程重入锁的次数”Semaphore里它表示“剩余许可数量”CountDownLatch里它表示“还需要等待多少个事件”。可以把它理解成一块共享计数板所有线程的竞争都落在这块板上。对state的修改不能有半点含糊必须原子。AQS里统一用compareAndSetState方法底层就是CAS指令。有了volatile保证可见性有了CAS保证原子性这块板上每一次加减都能被所有线程看到且不会错乱。理解AQS的钥匙其实就两把一个volatile int一个等待队列。1.2 等待队列抢锁失败的线程不是死等而是去排队当线程尝试获取同步状态失败时AQS会把它封装成一个Node节点挂到一个FIFO的双向队列尾部然后阻塞自己。等持有锁的线程释放后会唤醒队列中等待最久的下一个节点让它重新尝试抢锁。这个设计可以用银行柜台来类比。你到柜台办事发现窗口有人占用不能一直在窗口前站着而是取号排队。前面的人办完叫号系统通知下一个。AQS里的CLH队列变体就是这个“叫号系统”队列里的节点就是号牌。这套机制避免了线程空转自旋浪费CPU也让锁的竞争变得有序。有人问为什么阻塞唤醒比自旋好简单说就是自旋是让出CPU时间片但还在“忙等”线程状态一直是RUNNABLE高并发下大量线程自旋会把CPU打满而阻塞有一套完整的挂起和唤醒机制线程不消耗CPU代价是上下文切换。AQS在最初几次尝试失败后选择入队阻塞是一个典型的“快速失败然后优雅等待”策略。1.3 模板方法模式骨架固定钩子开放AQS最巧妙的设计是用了模板方法模式。获取和释放同步状态的流程骨架已经写死acquire先尝试tryAcquire成功就直接拿到锁失败则入队并阻塞唤醒后再循环尝试release先尝试tryRelease成功后唤醒队列里的下一个等待线程关键在于tryAcquire和tryRelease这两个方法AQS默认抛异常由子类去实现。于是不同组件只需要回答两个问题“我怎样算拿到锁”和“我怎样算释放锁”。ReentrantLock就实现成了“state从0变成1并且记录持有线程”Semaphore实现成了“state大于0就减1否则失败”CountDownLatch实现成了“state不为0就失败直到归零”。如果哪天你需要自定义一个同步器比如实现一个只允许两个线程同时访问的资源自己写一个类继承AQS实现tryAcquire和tryRelease即可排队和唤醒的脏活累活AQS全包了。1.4 公平锁与非公平锁就差一个hasQueuedPredecessors曾有人问ReentrantLock的公平和非公平有什么区别。代码层面看差别小得惊人。非公平锁的lock方法会先直接CAS抢一次state不管现在有没有人在排队抢不到才走AQS标准流程。公平锁则在抢锁前先调用hasQueuedPredecessors检查队列里有没有排在自己前面的线程如果有就绝不插队。用生活场景说非公平锁是“窗口刚办完一个业务新来的客户眼疾手快直接补位”虽然侵害了排队者的权益但反馈快、吞吐量高公平锁是“新来的客户先看看队伍里有没有人等有就老实排队”。实际开发中除非业务对公平性有强要求一般优先非公平锁因为它减少了线程切换性能更好。公平锁的代价是额外的排队检查还有可能降低吞吐。提示AQS这套设计里还有中断响应、超时等扩展但基础用法阶段不需要每个字都吃透。把“state 队列 模板方法”这三件事装进脑子后续的Lock和工具类就等于白送。2. 从synchronized到Lock体系锁粒度与可控性的全面升级很多Java初学者知道synchronized却在第一次看到Lock时困惑是synchronized不香了吗其实两者不是替代关系而是不同维度的工具。synchronized由JVM管理进入代码块自动加锁退出自动释放Lock是纯Java的接口一切获取和释放都交给开发者换来的是中断、超时、多条件、公平性这些synchronized给不了的控制力。2.1 synchronized给不了的三个能力synchronized最让人头疼的是“锁死了没法干预”。一个线程拿着锁执行耗时操作其他线程只能在门口无限期等着连中断信号都递不进去。Lock体系的第一个升级就是可中断lockInterruptibly()让等待锁的线程可以响应中断信号主动放弃等待。第二个升级是可超时。tryLock(3, TimeUnit.SECONDS)等三秒拿不到锁就放弃返回false。这种能力在解决死锁时特别好用两个线程互相持锁等待时只要其中一个用带超时的tryLock就能主动退一步打破循环。第三个升级是多个条件队列。synchronized搭配wait/notify时只有一个等待池notifyAll会唤醒所有线程经常造成“该醒的没醒、不该醒的醒了”。Lock可以new出多个Condition每个Condition相当于一条独立的等待队列精确控制唤醒某一类线程。这一点在下文的生产者消费者场景里体现得很明显。2.2 标准范式锁获取与释放的正确姿势用Lock必须手动释放所以有个铁律lock()要在try外调用unlock()必须在finally里。直接看代码ReentrantLock lock new ReentrantLock(); lock.lock(); try { // 处理业务逻辑 } finally { lock.unlock(); }为什么lock()放try外面如果try里先执行lock()且抛异常后面finally的unlock()会去释放一个根本没拿到的锁抛IllegalMonitorStateException把原始异常也盖掉了。所以严格顺序是先成功拿到锁再进try保护临界区最后finally释放。养成这个肌肉记忆能避开一堆线上事故。2.3 Condition把wait/notify的单一等待池拆开直接放一个经典的生产者消费者实现用两个Condition分别管理“队列未满”和“队列非空”public class BoundedBufferE { private final Lock lock new ReentrantLock(); private final Condition notFull lock.newCondition(); private final Condition notEmpty lock.newCondition(); private final Object[] items new Object[10]; private int count; public void put(E item) throws InterruptedException { lock.lock(); try { while (count items.length) { notFull.await(); } items[count] item; notEmpty.signal(); } finally { lock.unlock(); } } public E take() throws InterruptedException { lock.lock(); try { while (count 0) { notEmpty.await(); } E item (E) items[--count]; notFull.signal(); return item; } finally { lock.unlock(); } } }注意while循环的判断条件这是规范写法用来防御“虚假唤醒”。wait/await被唤醒后条件未必真的满足必须用循环重新检查。如果把while写成if就可能在线程被唤醒后继续往下走取到空数据或覆盖未读数据。2.4 ReentrantReadWriteLock与StampedLock读写分离与乐观读ReentrantReadWriteLock把锁分成读锁和写锁多个线程可以同时持读锁但写锁是排他的读锁与写锁互斥写锁与写锁也互斥。它适合读多写少的场景比如配置数据、热点商品信息。不过它有个大坑锁升级不被支持。一个线程先拿到读锁想再拿写锁可能直接死锁因为另一个读锁线程也在做同样的升级。反过来锁降级是允许的即持有写锁时再拿读锁。写锁降级为读锁是为了释放写锁后仍然保持读一致性。StampedLock是JDK 8加入的更激进方案它有三种模式写锁、读锁、乐观读。乐观读不加真正的锁先读数据并记一个版本号写完再检查版本号是否变化变了就用读锁兜底重新读。这适合读多写少且读操作很轻量的场景省掉读锁的加锁开销但需要接受偶尔的重读。经验在一次缓存框架优化里读写锁版本遇到“写锁频繁等待”的问题因为读操作太多写锁总被夹在中间。换成StampedLock乐观读后读路径几乎无锁化吞吐明显提升。不过乐观读对写竞争很敏感写操作频繁时反复重读反而比读锁更慢所以要对场景做测试再上。3. 并发容器选型ConcurrentHashMap、写时复制与阻塞队列的适用边界并发编程里容器选型几乎决定了系统的稳定性。很多人一上来就是“线程安全就用HashTable”可HashTable把所有方法都synchronized并发一高整个表都在互相等待。JUC里的容器解决的是“线程安全”和“并发效率”的平衡每种容器都有自己的适用边界。3.1 ConcurrentHashMap的两次进化从分段锁到CASsynchronizedJava 7的ConcurrentHashMap把数据分成16个Segment每个Segment自带一把锁。两个线程操作不同Segment时可以并行但同段内还是会串行。这种设计把锁粒度从整张表降到了段级并发度上限就是Segment数量。Java 8干脆废弃了Segment直接把数组的每个桶作为同步点插入时对桶下标做CAS如果该位置已经有节点再对这个桶的头节点加synchronized。锁粒度从一段降到了一个桶。理论上只要数据分散在不同的桶写入就能大幅并行。这是JDK 8后ConcurrentHashMap成为并发首选的核心原因。需要特别说明的是synchronized在Java 8之后的锁升级机制已经非常成熟用在“单个桶”这种低竞争部位反而比ReentrantLock更轻这也是JUC源码里很多地方用synchronized做细粒度锁的原因。3.2 弱一致性迭代器与size()的真相ConcurrentHashMap的迭代器是弱一致性的迭代过程中如果其他线程修改了map迭代器不会抛ConcurrentModificationException但新改动也不保证能立刻看到。它对“正在被遍历”的集合有很强的容错性很适合缓存快照、批量扫描这类场景。size()方法同样不是一个精确值。为了不锁住全表ConcurrentHashMap把计数拆成多个CounterCell不同线程的更新累加到不同Cell上size()时把这些Cell和一个base累加。累加过程不加锁所以得到的是一个近似值。如果你需要精确计数得额外加锁或用LongAdder配合维护一个外部计数器。很多人写并发统计时直接把map.size()当精确结果用结果越到临界值偏差越明显。3.3 CopyOnWriteArrayList用“写时复制”换读性能CopyOnWriteArrayList的思路是所有写操作add、set、remove都先复制一份新数组在新数组上改完再用新数组替换旧数组。读操作不加锁直接读当前数组内容。多个读线程天然并发因为读的是不可变的数组对象。它的代价也直白每次写都要复制全量数据写频繁时内存浪费巨大且读到的是旧数据属于最终一致性。所以它只适合读多写极少的场景典型就是监听器列表、配置快照。我在项目里用它在发布订阅框架中保存订阅者列表每次通知遍历订阅者时完全并行偶尔一个订阅者加入才触发一次数组复制代价很低。3.4 BlockingQueue四兄弟从有界到无界、从立即到延迟阻塞队列把生产者和消费者解耦是线程协作的最佳拍档。常用实现各有侧重队列结构边界特点典型场景ArrayBlockingQueue数组有界容量固定公平锁可选线程池工作队列、有界缓冲LinkedBlockingQueue链表可选默认无界吞吐高无界任务队列SynchronousQueue无存储有界0生产消费必须直接交接直接提交任务给线程PriorityBlockingQueue堆无界按优先级出队优先级任务调度DelayQueue堆无界延迟时间到了才能出队定时任务、超时处理选型最核心的考量是背压。生产速度远大于消费速度时无界队列会让任务在内存里无限积压最后OOM有界队列配合饱和策略才能让整个系统在超负荷时能“减速”而不是“爆掉”。线程池使用场景里LinkedBlockingQueue默认无界容易埋雷所以工程上更推荐有界的ArrayBlockingQueue或直接传一个自定义容量的LinkedBlockingQueue。注意SynchronousQueue不存储任何元素生产者put时必须等消费者take。用它做线程池队列时意味着任务不会被排队直接尝试创建新线程执行这对线程数控制是很大的风险别在不了解时随手用。4. CAS与原子类无锁方案背后的底层博弈并发编程有个反复出现的矛盾要保证原子性就必须加锁加锁就存在线程切换开销。CAS提供了一条“不加锁也能保证原子更新”的路代价是需要调用方自己去处理竞争失败。Java并发包里所有无锁方案底层都依赖CAS。4.1 CAS三步指令比较、交换、循环CAS的完整操作是读取内存值V给定期待值E和新值N只有当V和E相等时才把V改为N。整个比较和替换是一条CPU原子指令不会被打断。可以想象成“先核对账单金额确认没被别人改过才写入新金额”。Java里体现最典型的是AtomicInteger。它的incrementAndGet不是简单加一而是do-while循环里反复尝试CASAtomicInteger count new AtomicInteger(0); public int addOne() { int prev; do { prev count.get(); } while (!count.compareAndSet(prev, prev 1)); return prev 1; }如果两个线程同时读到prev5只有一个线程能CAS成功变成6另一个会重新读旧值、重新尝试。这就是“自旋”。自旋在低竞争时非常快因为不需要切换线程但高竞争下大量线程都在同一个内存地址上自旋CPU白白空转性能反而不如锁。4.2 ABA问题数值没变不代表状态没变CAS判断“值相等”就认为没人改动过但这个假设有漏洞。线程1读到值A线程2把A改成B又改成A线程1再次CAS时发现还是A认为过程没被打扰实际上数据中间被改过。这叫ABA问题。ABA在纯数值统计里往往无所谓但在链表、栈这类结构上可能致命。比如栈顶节点被回收后重新入栈地址一样但内容已经变了用CAS更新栈顶就可能覆盖掉其他操作。解决办法是给每个版本加编号每次修改编号1。JUC里的AtomicStampedReference就是干这个的它同时维护对象引用和整数stamp。4.3 AtomicInteger与LongAdder从单点计数到分段计数AtomicLong在高并发写同一变量时所有线程抢同一个内存地址CAS冲突概率随线程数上升自旋空转成本随之增加。LongAdder换了个思路内部维护一个base值和一个Cell数组。不同线程通过hash分散到不同Cell上各自累加最后sum()时把所有Cell和base加总。这就是“分段计数”把单点竞争拆成多点并行。看起来LongAdder很完美但它有一个特点sum()返回的是累加值不是严格实时的一致快照而且单个Cell的更新精度在极端情况下可能稍弱。用在统计请求数、PV次数这类场景非常合适但如果你需要强一致的自增结果来做判断还是要用AtomicInteger。简单说统计用LongAdder判断用Atomic。经验有一个在线请求计数模块最初用AtomicLong压测时发现线程数超过32后吞吐不再上升大量CPU时间耗在CAS自旋。换LongAdder后同一台机器吞吐直接翻倍。后来总结高并发热点计数LongAdder是默认优先方案只有需要读回精确值时才回到原子变量。4.4 别在业务代码里直接用Unsafe原子类底层基于Unsafe的compareAndSwapInt等本地方法实现但Unsafe不是给业务开发者用的。它允许绕过Java内存管理直接操作内存一旦偏移量算错轻则数据错乱重则JVM崩溃。而且它不属于标准API不同JDK版本内部的实现细节一直在变。如果确实需要自定义CAS操作优先找Java标准库提供的现成类组合实在不够用可以考虑JDK 9之后的VarHandle它提供了类型安全的引用和字段原子操作比Unsafe安全得多也更规范。我见过有人把Unsafe写进业务代码代码Review时每个人都看不懂最后只能回退到原子类加锁方案维护成本太高。5. 线程池的实际打开方式七个参数、工厂方法陷阱与自定义策略线程池是并发开发里最常用的组件却也是被误解最多的组件。很多人直接调用Executors工厂方法对底层参数一无所知直到线上OOM才回头研究。这一节就按实际链路拆一遍。5.1 七个参数提交任务后到底发生了什么ThreadPoolExecutor的完整构造函数有七个参数核心线程数corePoolSize、最大线程数maximumPoolSize、空闲存活时间keepAliveTime、时间单位unit、工作队列workQueue、线程工厂threadFactory、拒绝策略handler。当一个新的任务通过execute提交进来执行流程非常固定当前线程数小于corePoolSize直接创建核心线程执行任务当前线程数大于等于corePoolSize任务先丢进工作队列队列已满且当前线程数小于maximumPoolSize创建救急线程执行任务队列已满线程数也已达最大触发拒绝策略很多人不理解第2步为什么是先排队而不是先加线程。这是线程池对资源开销的一种“背压”设计核心线程还没忙完新任务先排队等待避免无限创建线程打爆系统。只有队列真正满时才认为“确实忙不过来了”开始扩张到最大线程数。理解了这套顺序你就知道调优线程池时为什么队列容量和maximumPoolSize必须一起考虑。5.2 execute与submit、优雅停机的细节execute提交Runnable没有返回值submit提交Callable时可以拿到Future。这里有个容易翻车的点submit返回的Future如果不用get读取任务内部抛出的异常会被吞进Future不会打印日志你以为任务执行成功了实际什么都没发生。所以用submit时要么在get处捕获ExecutionException要么在任务内部自己try-catch留痕。停机方法上shutdown是优雅关闭拒绝新任务但已经提交的任务继续执行shutdownNow是强制关闭尝试中断正在执行的任务并返回队列里还没执行的任务列表。生产环境做优雅停机我习惯shutdown之后加awaitTermination等待一段窗口期确认任务都收尾了再释放资源。如果直接shutdownNow正在写数据库的事务可能被拦腰截断。5.3 Executors工厂方法为什么被诟病JDK自带了一堆线程池工厂方法看起来很方便实际埋着大坑newFixedThreadPool工作队列是默认无界的LinkedBlockingQueue任务积压时队列无限变长内存迟早被撑爆newCachedThreadPool最大线程数是Integer.MAX_VALUE请求一多线程数会跟着请求量疯狂上涨系统可能被巨量线程拖垮newSingleThreadExecutor同样使用无界队列单个线程慢慢消费积压问题被隐藏得更深工厂方法不是不能用于学习Demo而是它把线程池最关键的决策全部交给了默认值你在生产上无法限制队列也无法感知超负荷。现在很多团队的代码规范里直接禁止使用这些工厂方法要求手动new ThreadPoolExecutor把参数显式写出来。不是为了显摆规范而是为了出事时你能看得懂自己的线程池。5.4 自定义线程池的工程习惯给一个实际可用的自定义线程池模板ThreadPoolExecutor executor new ThreadPoolExecutor( 8, // 核心线程数 16, // 最大线程数 60L, TimeUnit.SECONDS, // 救急线程空闲60秒回收 new ArrayBlockingQueue(100), // 有界队列防止OOM r - { Thread t new Thread(r); t.setName(order-worker- t.getId()); return t; }, new ThreadPoolExecutor.CallerRunsPolicy() );ThreadFactory里给线程命名是必须养成的习惯。以后线上出问题在jstack里一眼就能分辨哪些线程属于哪个业务线程池而不是一堆“pool-1-thread-1”。否则你连排查的抓手都没有。队列容量和最大线程数的组合没有万能公式。我的经验是先按任务特性给初值CPU密集型任务核心线程数约等于CPU核数加一IO密集型任务通常需要更多线程等待网络和磁盘。但最终数值一定要靠压测去调看队列积压趋势、线程空闲情况、拒绝策略触发次数。5.5 拒绝策略四选一与钩子方法拒绝策略有四种取舍很清晰策略行为适用场景AbortPolicy直接抛RejectedExecutionException明确超负荷必须告警CallerRunsPolicy提交任务的线程自己执行该任务制造反向背压放慢提交速度DiscardPolicy静默丢弃新任务不推荐丢任务无感知DiscardOldestPolicy丢弃队列里最老的任务再提交新任务允许牺牲旧任务保证新任务线上最常用CallerRunsPolicy因为当线程池满时由提交任务的线程自己来跑相当于把压力反向传回业务方提交速度自然下降系统进入自我保护。但要注意提交线程可能因此长时间阻塞调用接口的响应时间会上升必须让上游有超时兜底。ThreadPoolExecutor还预留了beforeExecute、afterExecute和terminated三个钩子方法子类覆盖它们就可以在任务执行前后统一埋点。我们曾在afterExecute里统一记录每个任务耗时慢任务TopN一眼可见比在业务代码里到处加stopwatch干净得多。6. CountDownLatch、CyclicBarrier、Semaphore三个工具类各自的边界这三个工具类经常被放在一起比但它们的语义完全不同。简单记有人等你完成你等大家一起限制最多几个人同时干活。搞清楚自己是哪个角色才能选对工具。6.1 CountDownLatch一次性的任务完成倒计时CountDownLatch的模型是一个计数器。初始化时设定N线程每完成一个任务就调用countDown()减一等待方调用await()阻塞直到计数归零。非常适合“多个子任务完成后主线程汇总”的聚合场景。CountDownLatch latch new CountDownLatch(5); for (int i 0; i 5; i) { executor.submit(() - { try { // 调用下游接口、查询数据 } finally { latch.countDown(); } }); } latch.await(10, TimeUnit.SECONDS);两个要点countDown必须放在finally里否则子任务抛异常时计数不减主线程会一直等下去await要带超时时间避免某个子任务卡死导致整个应用无响应。CountDownLatch是一次性的用完之后计数归零就废了想再等下一批任务只能重新new一个。6.2 CyclicBarrier可循环的相互等待屏障CyclicBarrier和CountDownLatch表面相似内里不同。CountDownLatch是“主线程等子线程完成”CyclicBarrier是“N个线程到达屏障后一起放行”它是线程等线程没有固定的主从关系。而且它能循环使用一轮放行后重置继续等下一轮。还有一个加分项CyclicBarrier可以传一个barrierAction在所有线程到达屏障时由最后一个到达的线程触发一次额外动作。比如分页批量处理数据每页数据被多个线程处理完后先执行一次汇总统计再进入下一页。CyclicBarrier的坑在于“屏障破碎”如果某个线程在等待时被中断、超时或异常退出会抛BrokenBarrierException屏障进入broken状态其他所有还在等待的线程也会跟着异常退出。如果你要做复杂阶段的并行处理必须捕获BrokenBarrierException并调用reset()重新建立屏障不能坐视整个流程卡死。6.3 Semaphore控制并发数量的许可信号Semaphore维护N个许可线程acquire()拿到一个许可就继续执行release()归还许可。它控制的核心指标是并发数不是速率。想限制某个接口同时最多允许10个请求进来用它最直接想限制每秒最多请求数那是RateLimiter的活两者概念别混。Semaphore semaphore new Semaphore(10, true); public void handleRequest() { try { semaphore.acquire(); // 只有10个线程能同时进入 } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { semaphore.release(); } }acquire和release必须严格配对release必须放finally。这个工具最容易踩的坑线程拿到许可后抛异常忘了释放许可数量越来越少最终业务方全部阻塞在acquire上。还有一点默认Semaphore是非公平的但构造方法可以传fair参数需要排队有序时选公平版本否则新来的线程可能一直插队。6.4 三个工具的选型速判给一张速查表遇到并发协作场景可以对照着选工具核心语义能否复用典型场景CountDownLatch等待N个事件完成否并发结果聚合、批量任务结束后汇总CyclicBarrierN个线程互相等待齐是多线程分阶段同步、每轮汇总Semaphore限制同时执行的线程数是接口并发限制、连接池、外部资源限流选型思路我一般是这样如果你是“被等待的人”用CountDownLatch如果你必须等齐其他同事再开工用CyclicBarrier如果只想限制“全公司同时只能有几个人进机房”用Semaphore。把问题转化成角色关系工具自己就浮出来了。写在最后的一点学习心得我自己把JUC串起来的路径是CAS和volatile打底然后啃AQS的state和队列设计再看Lock、Semaphore、CountDownLatch如何复用AQS最后才是并发容器和线程池的参数细节。这套顺序最大的好处是每学一个新组件都不是从零背API而是给已有的知识框架挂一个分支。还有一个小心得分享给正在入门的人写并发代码前先问自己三个问题——这里需要互斥访问共享数据吗需要多个线程协作完成一件事吗需要限制并发数量吗问题一旦定性最合适的工具往往只有一个而不是靠排列组合。最后再强调一次那个肌肉记忆锁的获取写在try之前释放写在finally里养成它能少处理很多线上告警。