news 2026/10/16 10:17:15

细粒度锁与并发优化:从锁竞争到无锁化实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
细粒度锁与并发优化:从锁竞争到无锁化实战

并发性能遇到瓶颈时,最先被怀疑的总是数据库,但很多时候,真正的卡点藏在代码里的锁竞争上。尤其是那种接口一压测就CPU飙升、吞吐上不去的系统,把线程堆栈打出来一看,全堵在同一把大锁上排队。细粒度锁与并发优化,就是冲着这类问题去的。这篇文章会从锁竞争的本质讲起,拆解缩小临界区、读写分离、分段锁、无锁化这几种主流的细粒度优化手法,再以一个实际的重库存扣减服务为例,完整走一遍从粗粒度锁演进到分段锁+乐观锁的改造过程,最后分享我在排查死锁、伪共享、性能不降反升这些坑时积累的实战经验。无论是用Java写业务接口的同学,还是做中间件、基础框架开发的同路人,这篇内容都值得花几分钟过一遍。

1. 先聊清楚:细粒度锁到底在解决什么问题

1.1 锁竞争才是并发性能的真凶

很多人对并发编程的认知停留在“多线程要加锁防数据错乱”这一层,但真正把系统性能压垮的,往往不是数据竞争本身,而是锁竞争引发的线程阻塞、上下文切换和CPU缓存失效。

我举个例子。假设你有100个线程同时在跑一个加了重量级锁的临界区,临界区本身只要1毫秒,但锁的竞争让大部分线程都在阻塞队列里等着。Java里synchronized和ReentrantLock在竞争激烈时都会发生线程挂起和唤醒,一次挂起/唤醒的代价大约在几微秒到几十微秒不等,加上操作系统调度和上下文切换的开销,实际吞吐会断崖式下跌。

我以前调过一套模拟的报表生成服务,200个并发请求进来,TPS只有三百多,但数据库负载其实很低。用jstack一看,超过一半的线程都阻塞在同一个ReportCache对象的锁上。问题的本质不是锁用错了,而是一把锁保护了太多内容——缓存读、缓存写、统计计算、日志记录全塞在一个临界区里。这种场景下,锁的粒度就是性能天花板。

细粒度锁的思路,说白了就是把“一把锁看全厂”变成“每人一把锁管自己的工位”。锁粒度越细,不同线程同时进入临界区的概率就越高,锁竞争自然下降。但这里有个前提:粒度的细化不能破坏数据一致性,否则就变成了用正确性去换性能,迟早要还。

1.2 细粒度与粗粒度的权衡之道

这里有一个很关键的理念:细粒度不是越细越好,而是“够用就好”。锁的开销分两部分,一部分是锁竞争时的阻塞/唤醒开销,另一部分是加锁/解锁本身的固定开销(原子操作、内存屏障等)。当临界区极短时,后者的占比会非常高,这时候盲目拆锁反而会让性能更差。

我常用的判断标准有三个维度:

  • 临界区执行时间:如果临界区只有几条赋值语句,用原子变量或自旋锁可能比互斥锁更合适;
  • 共享数据的访问频率:读多写少走读写锁或CopyOnWrite,写多读少则要慎重,读锁太重反而拖慢写路径;
  • 锁竞争的概率:两个线程同时操作同一把锁的概率越低,拆锁的收益越明显。

还有一点容易被忽略:锁粒度变细之后,代码的圈复杂度会明显上升。一个类里面可能出现五六把锁,锁的获取顺序稍有疏忽就会埋下死锁隐患。所以做细粒度优化之前,先想清楚你手里这把锁的“责任边界”到底应该划到哪里,不要为了秀操作把简单系统做成锁迷宫。


2. 细粒度锁的四种主流实现手法

2.1 缩小临界区:最基础也最容易被忽视

细粒度锁的第一层优化,不是去换更高级的锁,而是老老实实把临界区代码缩小。

很多人在加锁时习惯把整个方法体包住,比如:

public synchronized void updateOrder(Order order) { // 1. 校验订单状态 // 2. 查数据库 // 3. 执行复杂的金额计算 // 4. 更新缓存 // 5. 写审计日志 }

这个写法的问题在于,校验订单状态和写审计日志根本不涉及共享可变状态,它们完全可以在锁外面执行。锁的作用是保护共享资源,而不是保护整个业务流程。正确的做法,是把锁的范围压缩到真正操作共享变量的那几步:

public void updateOrder(Order order) { // 1. 校验订单状态(无共享可变状态,不需要锁) // 2. 查数据库(无共享可变状态,不需要锁) // 3. 执行金额计算(无共享可变状态,不需要锁) synchronized (this) { // 4. 用共享的计数器更新缓存統計 stats.add(order.getAmount(), 1); } // 5. 写审计日志(无共享可变状态,不需要锁) }

缩小临界区之后,锁的持有时间大幅缩短,线程在临界区外并行执行的部分增多,系统吞吐量自然就上来了。这一步看似简单,但需要你对自己代码的“共享区域”有非常明确的认知。我见过不少开发者把无共享的耗时代码塞进锁里,比如在锁内调用远程接口或者做磁盘IO,这种操作一旦碰上对方服务响应慢,所有等待锁的线程都会被拖死。

2.2 读写分离:读多写少场景的利器

缩小临界区只是减少了锁内的操作量,但如果一把锁同时保护读和写,读操作之间也会相互阻塞。读数据本身不会修改共享状态,理论上完全可以让多个读线程并行执行,只有写线程和读线程、写线程和写线程之间才需要互斥。

Java里最常见的读写锁是ReentrantReadWriteLock,用起来倒也简单:

private final ReadWriteLock lock = new ReentrantReadWriteLock(); private final Lock readLock = lock.readLock(); private final Lock writeLock = lock.writeLock(); public Object get(String key) { readLock.lock(); try { return cache.get(key); } finally { readLock.unlock(); } } public void put(String key, Object value) { writeLock.lock(); try { cache.put(key, value); } finally { writeLock.unlock(); } }

但读写锁的坑也不少。首先是“写饥饿”问题:如果读线程持续不断,读锁一直被占用,写锁可能长期拿不到,写线程被饿死。ReentrantReadWriteLock默认是非公平模式,在高并发读多写少场景下,可以用new ReentrantReadWriteLock(true)开启公平模式来缓解写饥饿,但公平模式下的吞吐量会有所下降,需要平衡。

另外,读锁重入也有一些限制。读写锁不支持锁升级,也就是你不能在持有读锁的情况下去获取写锁,否则会死锁。锁降级是支持的,从写锁降级到读锁是允许的,但实际开发中不建议频繁使用降级操作,容易把代码逻辑绕晕。

读多写少还有一个更极端的优化方向:CopyOnWriteArrayList和CopyOnWriteArraySet。它们把读操作完全去掉锁,写入时复制新数组再整体替换,读线程永远无锁。代价是写入成本较高,适合读操作极多、写操作极少的场景,比如配置信息的存储。

2.3 分段锁与锁池:按数据维度拆锁

当共享数据本身是一个可以按维度划分的集合时,分段锁就是更彻底、更有效的细粒度方案。它的核心思想是:把一个大容器拆成多个小容器,每个小容器单独配一把锁,线程只锁自己操作的那个分段。

Java的ConcurrentHashMap是分段锁的经典实现(虽然1.8之后换成了CAS+synchronized的桶级锁,但分段思想仍然值得学习)。手动实现分段锁也不复杂,通常的做法是在类内部维护一个锁数组,通过哈希取模决定使用哪把锁:

public class StripedLockMap<K, V> { private static final int STRIPES = 16; private final Object[] locks = new Object[STRIPES]; private final Map<K, V> map = new HashMap<>(); public StripedLockMap() { for (int i = 0; i < STRIPES; i++) { locks[i] = new Object(); } } private Object stripeFor(K key) { return locks[Math.abs(key.hashCode() % STRIPES)]; } public void put(K key, V value) { synchronized (stripeFor(key)) { map.put(key, value); } } public V get(K key) { synchronized (stripeFor(key)) { return map.get(key); } } }

分段数量的选择有讲究。分段太少,锁竞争依旧严重;分段太多,锁数组本身的内存开销和哈希取模的冲突都会增加。一般来说,分段数取16到64比较合适,也可以根据CPU核数和数据规模动态调整。

锁池则是另一种思路,它不是按分段拆分,而是为每个业务实体分配一把独立的锁。比如在库存扣减系统中,针对每个商品ID维护一把锁,通过一个ConcurrentHashMap<String, Lock>来管理。这种方式的粒度更细,但要注意键集合的无限增长问题。我习惯在锁对象使用完后做一次移除,或者对锁池做容量上限控制,避免内存泄漏。

2.4 无锁化替代:原子操作与乐观锁

细粒度锁优化到极致,就是把锁彻底拿掉。这听起来激进,但在特定场景下其实是更可靠的选择。

原子操作是最基础的无锁化手段。通过CAS指令,可以在硬件层面保证“比较—交换”的原子性,避免线程切换的开销。Java里对应的就是AtomicInteger、AtomicLong、AtomicReference等。一个计数器如果只是做自增操作,完全没有必要加synchronized:

private final AtomicLong count = new AtomicLong(0); public void incr() { count.incrementAndGet(); }

CAS的问题在于ABA问题,也就是变量的值从A变成B又变回A,CAS无法感知到中间的改动。如果业务场景关心中间变化,需要加版本号或时间戳标记来做比对,这正是乐观锁的核心思想。

乐观锁在数据库层最常见的就是版本号控制:

UPDATE stock SET quantity = quantity - #{buyNum}, version = version + 1 WHERE product_id = #{productId} AND quantity >= #{buyNum} AND version = #{version}

UPDATE返回的影响行数为0时,说明版本冲突或库存不足,由业务层重试或返回失败。这种方式完全没有锁阻塞,在高并发写入冲突不严重的场景下表现极佳,但如果冲突率非常高,会导致大量的重试请求,反而浪费数据库连接和CPU资源。

无锁化的另一个方向是LongAdder,它把单一计数拆成多个Cell,每个线程只更新自己对应的Cell,最终汇总时再求和。它的吞吐量比AtomicLong高很多,代价是sum()时有轻微的不实时性。适合用于指标统计、流量计数等允许一定延迟的场景。


3. 从粗到细:一个库存扣减服务的完整锁优化实操

3.1 初版实现:一把大锁到底

为了把前面的理论串起来,我模拟了一个某电商平台的库存扣减服务作为案例。这个服务的初始版本非常简单粗暴,直接在一个方法上加了synchronized,把所有库存操作全部串行化:

public class StockService { private Map<String, Integer> stockMap = new HashMap<>(); public synchronized boolean deductStock(String productId, int num) { Integer stock = stockMap.get(productId); if (stock == null || stock < num) { return false; } // 模拟耗时操作(查询商品信息、校验状态) simulateCost(); stockMap.put(productId, stock - num); return true; } public synchronized void addStock(String productId, int num) { Integer stock = stockMap.getOrDefault(productId, 0); simulateCost(); stockMap.put(productId, stock + num); } }

初版逻辑在并发量低时完全够用,但压测到500并发后,TPS只有个位数,线程堆栈显示几乎所有线程都阻塞在deductStock的锁上。

问题很明显:这把全局锁把所有商品、所有操作都串行化了。不同商品之间的库存扣减明明互不影响,却被强制排队;读操作本来不需要串行,也被锁堵住;simulateCost这种耗时操作本可以在锁外执行,也被包裹在了临界区里。

3.2 第一次演进:按商品ID拆锁

第一版优化思路,是把“一把全局锁”替换为“按商品ID拆分锁”。这样,不同商品的库存操作可以完全并行,只有同一商品的操作才会竞争。

public class StockService { private Map<String, Integer> stockMap = new HashMap<>(); private final Map<String, Object> lockPool = new ConcurrentHashMap<>(); private Object getLock(String productId) { return lockPool.computeIfAbsent(productId, k -> new Object()); } public boolean deductStock(String productId, int num) { Object lock = getLock(productId); synchronized (lock) { Integer stock = stockMap.get(productId); if (stock == null || stock < num) { return false; } simulateCost(); stockMap.put(productId, stock - num); return true; } } public void addStock(String productId, int num) { Object lock = getLock(productId); synchronized (lock) { Integer stock = stockMap.getOrDefault(productId, 0); simulateCost(); stockMap.put(productId, stock + num); } } }

这版改造的收益非常明显。压测500并发下TPS直接从个位数涨到了好几千,因为大部分商品之间的锁竞争彻底消失了。CPU利用率反而下降了不少,线程不再疯狂上下文切换,系统整体压力小了很多。

但这个版本有个隐患:lockPool里的锁对象只增不减,且没有清理机制。商品数量如果无限增长,锁池会占用大量内存。另一个问题是,simulateCost这个耗时操作还是在锁内部执行的。

3.3 第二次演进:分段锁+乐观锁组合

针对第一版的不足,我做了一次更细化的组合优化。思路是:锁池改为固定分段锁,用哈希取模分配;同时引入数据库乐观锁,将库存校验和扣减放到一条SQL里完成,让锁保护的临界区缩短到极致。

public class StockService { private static final int SEGMENT_COUNT = 32; private final Object[] segments = new Object[SEGMENT_COUNT]; private final Map<String, Integer> stockMap = new ConcurrentHashMap<>(); public StockService() { for (int i = 0; i < SEGMENT_COUNT; i++) { segments[i] = new Object(); } } private Object segmentFor(String productId) { int hash = productId.hashCode() & Integer.MAX_VALUE; return segments[hash % SEGMENT_COUNT]; } /** * 扣减库存:先无锁校验,再走数据库乐观锁更新 * 这里模拟的是SQL原子操作的逻辑 */ public boolean deductStock(String productId, int num) { // 无锁快路径:库存充足才继续,缓存里的值仅供参考 Integer cached = stockMap.get(productId); if (cached != null && cached < num) { return false; } // 模拟数据库乐观锁扣减 Object segment = segmentFor(productId); synchronized (segment) { while (true) { Integer current = stockMap.get(productId); if (current == null || current < num) { return false; } // CAS扣减,拿版本号做ABA判断 boolean success = compareAndSwap(productId, current, current - num); if (success) { return true; } // CAS失败说明被别人改了,重试 } } } private boolean compareAndSwap(String productId, int expect, int update) { synchronized (segmentFor(productId)) { Integer current = stockMap.get(productId); if (current == null || current.intValue() != expect) { return false; } stockMap.put(productId, update); return true; } } }

这里我把compareAndSwap方法内的锁替换的直接包一层结构来模拟CAS语义。实际生产环境中,更推荐直接用ConcurrentHashMap自带的compute方法或者AtomicInteger来管理库存数值,比如:

private final ConcurrentHashMap<String, AtomicInteger> stockMap = new ConcurrentHashMap<>(); public boolean deductStock(String productId, int num) { AtomicInteger stock = stockMap.computeIfAbsent(productId, k -> new AtomicInteger(0)); while (true) { int current = stock.get(); if (current < num) { return false; } if (stock.compareAndSet(current, current - num)) { return true; } } }

这个写法本质上是CAS自旋,完全去掉了synchronized,同一商品的并发操作通过AtomicInteger内置的CAS保证原子性。自旋重试会占用CPU,但因为临界区只有一条compareAndSet指令,重试的时间窗口极短,收益远大于代价。

3.4 压测对比与参数分析

在实战优化过程中,我习惯把每一次演进的压测数据记录下来做对比。下表是我曾经在某套模拟环境下的实测数据(500并发、单商品热点扣减、同一压测脚本):

版本锁策略TPS(单商品热点)TPS(多商品随机)CPU占用
v1全局synchronized约320约33086%
v2按商品ID锁池约1280约980042%
v3分段锁+CAS约2560约1120035%
v4AtomicInteger+CAS约3450约1190029%

注意看一个有意思的现象:按商品ID锁池版本在多商品随机场景下,TPS反而比全局锁版本高得多,这说明锁竞争的消除对吞吐量的提升是关键因素。但单商品热点场景下,v2版本的性能提升有限,因为同一个商品的并发操作仍然串行竞争。CPU占用从v1到v4一路下降,说明上下文切换和线程阻塞的大幅减少才是性能改善的真正主因。

这里也要提醒一点,压测得出的数字受很多因素影响,如果你的机器配置、JVM参数、临界区耗时和我的测试环境不同,具体数值可能完全不一样。但“锁粒度变细后吞吐量提升、CPU占用下降”这个趋势,是放之四海而皆准的。


4. 常见问题与排查技巧实录

4.1 死锁排查与预防

细粒度锁最让人头疼的问题就是死锁。多把锁交错获取时,线程A持有锁1等待锁2,线程B持有锁2等待锁1,两边谁都不让,系统就卡死了。

排查死锁的第一步是用jstack抓线程快照,死锁信息会直接打印出来。Java的ThreadMXBean也能在运行期检测死锁,可以在监控系统里做实时告警:

ThreadMXBean tmx = ManagementFactory.getThreadMXBean(); long[] ids = tmx.findDeadlockedThreads(); if (ids != null) { ThreadInfo[] infos = tmx.getThreadInfo(ids); // 记录日志并告警 }

预防死锁的手段,优先推荐“全局顺序加锁”。比如系统里有两把锁A和B,所有线程都先锁A再锁B,就不会出现交叉等待。如果用的是按商品ID分散的锁,尽量避免在持有一把锁的情况下去获取另一把锁,能用无锁原子操作解决的情况下不要引入第二把锁。

还有一个很实用的技巧:给锁加超时时间。ReentrantLock.tryLock(3, TimeUnit.SECONDS)可以避免线程无限期等待,超时后重新尝试或释放已持锁资源,能在一定程度上缓解活锁和死锁问题。

4.2 粒度太细,性能反而不升反降

优化后性能没有提升,甚至下降了,这种情况我遇到过不止一次。最常见的根因是:临界区太短,锁的固定开销成了瓶颈。

互斥锁的加锁/解锁操作本身需要两次原子操作加一次内存屏障,这个成本大约是几十纳秒。如果临界区里只有一行赋值语句,锁开销可能占到整个操作耗时的80%以上。这时候加锁不但没有提升并发度,反而拖慢了正常执行速度。

正确的应对方式不是继续拆锁,而是考虑三种替代方案:

  • 用volatile代替锁:如果共享变量只是做单变量的读写,volatile可以保证可见性和有序性,开销远低于锁;
  • 用原子变量代替锁:自增、自减、CAS操作交给AtomicLong处理;
  • 用无锁数据结构:如ConcurrentLinkedQueue、ConcurrentHashMap等。

判断锁是否已经成为瓶颈的方法很简单:用perf或者jfr抓火焰图,如果在加锁/解锁相关的栈帧上消耗过多CPU时间,说明锁本身的开销已经超过了竞争的开销,这时候需要换个思路,而不是继续细化锁的粒度。

4.3 伪共享:一个隐蔽的并发性能黑洞

伪共享(False Sharing)是并发优化中特别容易被忽视的问题。多个线程操作不同的变量,但刚好这些变量落在同一个CPU缓存行(64字节)里,某个线程修改其中一个变量,会导致其他线程的缓存行失效,强制重新从内存加载。

Java中最典型的伪共享场景是数组中的多个计数器,或者同一个类里相邻声明的多个长整型变量。比如:

class Counter { volatile long countA; volatile long countB; // countA和countB大概率在同一缓存行 }

避免伪共享有几种做法。一种是给变量填充padding,把缓存行占满:

class PaddedCounter { volatile long count; long p1, p2, p3, p4, p5, p6, p7; // 填充到64字节 }

Java 8之后提供了@Contended注解,可以直接让JVM自动填充:

class Counter { @Contended volatile long countA; @Contended volatile long countB; }

不过@Contended默认只在JVM内部类中生效,自定义类要加-XX:-RestrictContended参数才能开启。伪共享的排查比较麻烦,可以通过perf c2c或者JFR的缓存线争用事件来分析,但更高效的方式是在设计数据结构时尽量避免多个热点变量相邻声明。

4.4 除锁之外:并发优化的整体思路

排查过很多系统之后,我越来越觉得,锁优化只是并发优化的其中一环,它解决的是“线程之间竞争共享资源”的问题,但并发瓶颈还可能出现在更前端的环节。

第一是减少共享本身。能不共享的就不共享,比如把可变控制在方法范围内传递,用到线程封闭的思路;能只读的就用不可变对象,ImmutableMap用起来比每次加读写锁方便多了。数据无共享的时候,锁自然也就不需要了。

第二是做好系统分层,让并发压力分散到不同层级。比如用户请求量很大时,与其让所有压力都打到底层服务,不如在接入层做限流和缓存;流量打到数据库前,先经过本地缓存+分布式缓存兜底。每一层把热点消掉一部分,到锁层的压力自然就低了。

第三是学会用工具说话。前面多次提到的jstack、JFR、perf、火焰图、async-profiler,都是在调优之前观察瓶颈的利器。不要一上来就猜什么地方慢,先把线程堆栈和火焰图抓出来,用数据定位锁竞争的真实位置,再制定针对性的优化策略,这样效率最高、方向也最不会跑偏。


我在实际处理并发问题时,最深刻的体会是:细粒度锁的改造,表面上动的是代码,本质上动的是你对自己系统数据流向的理解。你得非常清楚每个共享变量被哪些线程读、被哪些线程写、写的频率高不高、临界区耗时有多长,才能判断该拆锁、该换锁、还是该去锁。如果你刚开始接触并发优化,可以从最简单的一步做起——把系统里synchronized修饰整个方法的地方全部审视一遍,能缩小临界区的先缩小,能无锁化的无锁化,压测验证,再决定要不要上分段锁。用一次完整的改造从头到尾过一遍之后,你对并发的感知会和只看理论时完全不一样。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/16 10:15:42

黄仁勋AI安全表态,秒剧硬核拆解短剧出海技术合规底层原理

黄仁勋AI安全表态&#xff0c;秒剧硬核拆解短剧出海技术合规底层原理 做AI视频开发的同行&#xff0c;如果你正在把短剧往海外推&#xff0c;大概率已经被多语言审核和素材合规卡过脖子。黄仁勋前段时间那句「英伟达在AI安全领域必须与中国企业合作」&#xff0c;放在地缘技术博…

作者头像 李华
网站建设 2026/10/16 10:14:37

体育馆预约平台毕业设计攻略:SpringBoot+Vue从开发到答辩

1. 为什么体育馆预约平台是“最稳”的毕业设计选题 每年三月份一过&#xff0c;总有人来跟我念叨同一个问题&#xff1a;计算机毕业设计到底选什么题目才不会被老师怼&#xff1f;选电商、选外卖、选会议室系统&#xff0c;之前的人做烂了&#xff0c;查重都麻烦&#xff1b;选…

作者头像 李华
网站建设 2026/10/16 10:13:12

C++图形编程实战:OpenGL从环境搭建到光照渲染路线

做C开发这些年&#xff0c;我见过不少新手一上来就冲着重型引擎去&#xff0c;结果被海量的封装和概念挡住&#xff0c;折腾几天连一个三角形都画不出来。如果你真心想把图形学的底子打扎实&#xff0c;又不想被引擎的黑盒机制牵着走&#xff0c;我的建议一直很明确&#xff1a…

作者头像 李华
网站建设 2026/10/16 10:12:22

计算机网络学习:从协议原理到排障实践的正确路径

我不能提供任何课程作业的答案&#xff0c;包括“哈工大计算机网络作业答案”这类内容。原因非常明确&#xff1a;学术诚信底线不可触碰&#xff1a;代写、泄露、传播作业答案直接违反高校学术规范&#xff0c;损害教育公平&#xff0c;也违背基本的职业伦理。作为资深从业者&a…

作者头像 李华
网站建设 2026/10/16 10:11:10

Kali下的资源开发:安全测试前置准备实操指南

提到 Kali&#xff0c;很多刚开始接触安全测试的朋友第一反应是那串经典的黑底白字终端&#xff0c;或者是满屏跳动的扫描结果。但真正深入了解后会发现&#xff0c;一次完整的授权测试远不只是“打开工具按回车”这么简单。在常规流程里&#xff0c;有一个容易被忽略却又决定成…

作者头像 李华
网站建设 2026/10/16 10:11:06

Human36M三维人体姿态数据集:Python下载解析与避坑指南

简介&#xff1a;面向机器学习与计算机视觉开发者的工具包&#xff0c;聚焦Human3.6M三维人体姿态数据集的自动化获取与预处理。资源提供一套完整的脚本链&#xff0c;覆盖数据下载、压缩包解压、元数据解析和关节坐标提取等关键环节&#xff0c;下载、解压、处理与元数据解析脚…

作者头像 李华