news 2026/10/1 5:40:16

Java读写锁ReentrantReadWriteLock源码解析与本地缓存压测实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Java读写锁ReentrantReadWriteLock源码解析与本地缓存压测实战

做并发编程的都知道,synchronized和ReentrantLock虽然可靠,但有个很要命的点:不管你是读数据还是写数据,所有线程都得抢同一把互斥锁。你想想,实际业务里读多写少才是常态,配置中心、路由表、本地缓存,几百个线程同时在读,只有少量写操作穿插其中,这时候用互斥锁等于让所有人排队进门,明明门口可以同时站十个人,非要一个一个过,大量线程卡在锁上干等,CPU和内存都被白白浪费。

ReadWriteLock读写锁就是专门解决这个问题的。它把锁拆成读锁和写锁,读锁之间共享,大家随便读,只有读写、写写才互斥,读多写少场景下能把并发吞吐提升好几个量级。这篇文章我会从设计思路一路讲到JDK源码实现,再带你用一个本地缓存案例完整跑一遍对比测试,适合已经能熟练使用synchronized和ReentrantLock、想进一步搞懂锁底层机制的同学,看完你至少能回答清楚:读锁和写锁到底怎么共存、锁降级为什么必须那样子写、以及读写锁什么时候该用、什么时候千万别用。

1. 为什么一定要有读写锁:设计思路与整体架构

1.1 互斥锁的痛点:读多写少场景下的无效排队

并发编程里,锁本质上是把临界区串行化:同一时刻只允许一个线程进入受保护的代码块。这个模型在写操作多、写操作必须严格串行的场景下非常正确,但在读多写少的场景下就显得很浪费。因为读操作本身不会修改共享数据,多个线程同时读不但不会出错,反而能把CPU的多个核心都利用起来,让吞吐量成倍提升。用互斥锁强行把读操作串行化,属于“明明没有竞争,却人为制造竞争”,这就是传统锁在缓存、配置、元数据这类场景里表现不佳的根源。

我自己踩过一个很典型的坑。以前做一个内部配置中心,热点配置项会被几十个线程同时读取,但真正更新配置的操作一天也没几次。最初图省事直接用synchronized包住整个读取方法,结果压测时发现吞吐量只有几万QPS,CPU使用率却很高,大部分时间都花在锁竞争和线程上下文切换上。后来改成读写锁,读取路径完全并行,吞吐量直接翻了接近5倍。这个案例给我一个很深的感受:锁的选型不是越强越好,而是越匹配业务特征越好。

1.2 读读共享、读写互斥:读写锁的语义边界

ReadWriteLock的语义可以总结成三句话:读锁与读锁共享,读锁与写锁互斥,写锁与写锁互斥。第一句是提升并发度的关键,第二句保证读操作不会读到写了一半的数据,第三句保证写操作之间必须串行。听上去很简单,但落到Java内存模型里,读写锁还承担着可见性保证:写锁释放时会把线程工作内存中修改过的变量强制刷新到主内存,读锁获取时会把主内存中的最新值重新加载到工作内存。换句话说,读锁和写锁之间形成了一条happens-before关系,写线程写完释放锁后,读线程获取锁再读,一定能看到最新的值,这比单纯在代码层面“挡住”互斥要强得多。

JVM层面还有一层聪明设计:读锁是共享锁,但一个线程可以重复获取同一个读锁;写锁是独占锁,一个线程也可以重复获取同一个写锁。所以ReentrantReadWriteLock名字里的“Reentrant”代表它支持可重入,只是读锁和写锁的重入规则不同,这个细节在后面的源码分析里会详细展开。

1.3 JDK的ReadWriteLock家族:接口、实现与选型

Java里ReadWriteLock本身只是一个接口,里面只有readLock()和writeLock()两个方法,返回两个Lock对象。日常开发中最常用的实现是ReentrantReadWriteLock,它底层基于AQS(AbstractQueuedSynchronizer)实现,既支持公平模式,也支持非公平模式,还内置了锁降级的能力。除了它之外,JDK 8还提供了StampedLock,支持乐观读,读性能比ReentrantReadWriteLock更高,但不可重入、API更复杂,而且用不好容易出现数据不一致,属于进阶工具。

写代码时的基本用法长这样:

ReadWriteLock rwLock = new ReentrantReadWriteLock(); Lock readLock = rwLock.readLock(); Lock writeLock = rwLock.writeLock(); // 读操作 readLock.lock(); try { // 读共享数据 } finally { readLock.unlock(); } // 写操作 writeLock.lock(); try { // 修改共享数据 } finally { writeLock.unlock(); }

注意一个小小的设计细节:ReadWriteLock接口没有定义“加锁”方法,而是通过返回两个Lock实例来区分读和写。这看起来多了一层对象包装,但实际上好处很大:读写锁的获取、释放逻辑可以完全复用Lock接口的能力,也方便在代码里把读锁和写锁分别传给不同的方法,职责更清晰。

2. 源码级拆解:ReentrantReadWriteLock是怎么工作的

2.1 一个int同时装下读锁和写锁的位运算设计

要理解ReentrantReadWriteLock的实现,第一关就是要看懂它怎么用一个int类型的state字段同时记录读锁和写锁的状态。这个state是AQS提供的核心同步状态,ReentrantLock里它只记录“一个线程重入了多少次”,而ReentrantReadWriteLock把它拆成了两个部分:高16位记录读锁的获取次数,低16位记录写锁的重入次数。

源码里对应的常量如下:

static final int SHARED_SHIFT = 16; static final int SHARED_UNIT = (1 << SHARED_SHIFT); // 65536 static final int MAX_COUNT = (1 << SHARED_SHIFT) - 1; // 65535 static final int EXCLUSIVE_MASK = (1 << SHARED_SHIFT) - 1; // 65535 static int sharedCount(int c) { return c >>> SHARED_SHIFT; } static int exclusiveCount(int c) { return c & EXCLUSIVE_MASK; }

举个例子:如果state的值是0x00050002,那么低16位里的2表示写锁被同一个线程重入了2次,而高16位里的5表示读锁一共被获取了5次。取读锁计数时把state无符号右移16位,取写锁计数时用0xFFFF做按位与,一次位运算就能把两个状态都拿出来,完全不需要额外锁保护,这就是用一个int管理两种锁状态的精妙之处。相比用两个字段分别维护,这种设计还能保证在CAS更新state时,读锁和写锁的状态变化不会出现“一个改了另一个没改”的中间窗口。

之所以用位运算而不是字段,还有另一层原因:AQS的acquire和release流程全部围绕state字段工作,比如同步队列里的线程唤醒、超时处理、中断响应,都依赖对state的统一判断。如果读写锁各搞一个状态字段,那同步队列的唤醒机制就没法复用了,整个AQS框架也就失去了意义。

2.2 写锁获取与释放:独占路径没那么神秘

写锁的获取入口是Sync.tryAcquire(int acquires),整个流程可以拆成三步:

第一步,读取当前的state。第二步,如果读锁计数不为0,说明有线程正在持有着读锁,根据读写互斥规则,写锁获取直接失败;如果读锁计数为0但写锁计数不为0,说明写锁已经被某个线程持有,此时判断持有者是不是当前线程,如果不是也直接失败。第三步,如果当前线程已经持有写锁,那就走重入逻辑,把低16位的写锁计数加1,检查是否超过MAX_COUNT,没超过就CAS更新state,成功后返回true。

这里最容易被忽视的是:写入锁获取时,只要存在任意读锁,哪怕读锁是当前线程自己持有的,写锁也拿不到。这话听起来有点绕,但想明白逻辑就不难:如果自己持有读锁还能再拿写锁,就会和锁升级产生纠缠,而锁升级恰恰是ReentrantReadWriteLock明确不支持的。后面第三章会专门讲为什么。

写锁释放对应tryRelease(int releases):

protected final boolean tryRelease(int releases) { if (!isHeldExclusively()) throw new IllegalMonitorStateException(); int nextc = getState() - releases; boolean free = exclusiveCount(nextc) == 0; if (free) setExclusiveOwnerThread(null); setState(nextc); return free; }

先确认当前线程确实持有写锁,然后直接把state减去释放次数,如果低16位归零,就把独占线程拥有者清空,整个临界区彻底让出来。因为只有持有写锁的线程才能走到释放逻辑,所以这里不需要CAS,直接setState也不会出错。

2.3 读锁获取与释放:共享路径的复杂之处

读锁获取走的是tryAcquireShared(int unused)方法,整体思路是:如果写锁被占用且持有者不是当前线程,直接返回-1进入同步队列等待;否则尝试用CAS把高16位的读锁计数加1。第一眼看好像只是改了个位段,但细看会发现比写锁复杂得多,因为读锁是共享锁,一个线程获取成功后,其他线程还可以继续获取,而且每个线程各自可以重入,所以必须记清楚“每个线程到底重入了几次”。

为了解决这个问题,ReentrantReadWriteLock引入了HoldCounter,用它保存线程id和当前线程的读锁重入次数;同时为了性能,又提供了firstReader和cachedHoldCounter两个优化。firstReader只记录第一个获取读锁的线程,当没有并发读竞争时,可以完全绕开ThreadLocal开销;cachedHoldCounter记录最近一次执行加锁或释放操作的线程对应的HoldCounter,适用于线程局部性强的场景。

读锁获取的快速路径大概是这样:

if (r == 0) { firstReader = current; firstReaderHoldCount = 1; } else if (firstReader == current) { firstReaderHoldCount++; } else { HoldCounter rh = cachedHoldCounter; if (rh == null || rh.tid != getThreadId(current)) { rh = readHolds.get(); cachedHoldCounter = rh; } rh.count++; }

读锁释放走tryReleaseShared(int unused),逻辑上刚好和获取对称:如果是firstReader,把firstReaderHoldCount减1,减到0就把firstReader清空;否则从cachedHoldCounter或ThreadLocal里拿到当前线程的HoldCounter,把count减1。最后不管哪个分支,都要通过CAS把state减少SHARED_UNIT,也就是把高16位的读锁总数减1。

这里有一个值得注意的细节:读锁计数是“所有线程获取读锁的次数总和”,而不是线程数。如果两个线程各获取1次读锁,高16位就是2;如果同一个线程获取2次,高16位也是2。释放时需要依赖HoldCounter里的每线程计数,才能精确扣减,这就是为什么要额外引入ThreadLocal结构。

2.4 锁降级与锁升级:源码明确说“不”的规则

锁降级指的是:当前线程持有写锁,先获取读锁,再释放写锁。这个过程在ReentrantReadWriteLock里是支持的,典型用途是保证“写完数据之后,还能继续以读锁保护的方式读取,不被其他写线程插一脚”。

代码模板长这样:

writeLock.lock(); try { // 写数据、更新缓存 readLock.lock(); } finally { writeLock.unlock(); } // 此时已经持有读锁,可以继续读取 readLock.lock(); try { // 读刚刚写过的数据 } finally { readLock.unlock(); }

注意顺序:必须先拿到读锁,再释放写锁。如果反过来,释放写锁之后再申请读锁,中间就会插入其他写线程,缓存数据可能已经被别人改掉了,降级就失去意义。

锁升级则完全不同:一个线程持有读锁时,又去申请写锁。ReentrantReadWriteLock在源码层面直接不支持,原因是逻辑上会死锁。设想一下:线程A、B、C都拿着读锁,线程A想升级成写锁必须等B和C释放读锁,但A自己又握着读锁不释放;B、C还没读完,A又不可能先释放读锁,因为释放了就不能执行写操作,于是整个系统进入循环等待。所以文档里干脆写得很明白:读锁升级为写锁,想都不要想。

3. 公平性、重入计数与并发边界

3.1 公平锁和非公平锁,究竟差在哪

ReentrantReadWriteLock构造时可以传入fair参数,默认是非公平锁。很多人以为公平锁和非公平锁只是“排队”和“插队”的差别,但落到读写锁上,细节会更微妙。

非公平锁模式下,writerShouldBlock()直接返回false,意思是写锁获取时允许插队:只要读锁没有被占用,写线程可以直接通过CAS抢锁,不用管同步队列里有没有其他线程在等待。但readerShouldBlock()不是直接返回false,它会检查当前同步队列头部节点是不是正在等待写锁,如果是,新来的读锁就要乖乖排队,不能插队。这个设计的出发点很实际:如果一个写锁正在队列里等着,结果新读锁一波接一波插进来抢读锁,写锁可能永远轮不到,直接饿死。所以非公平读锁在检测到队首有写锁等待时,会主动让路。

公平锁模式下,writerShouldBlock()和readerShouldBlock()都会检查hasQueuedPredecessors(),也就是同步队列里是否存在等待时间更长的节点。只要前面有人排队,当前线程就不允许插队,保证先来后到。公平锁能有效避免写饥饿,但代价是吞吐量下降,因为所有线程都要老老实实排队,读锁的并行优势也被削弱了。

实际项目中该怎么选?我的经验是:默认先用非公平锁,读写比例足够高且读操作很快时,非公平锁吞吐更好;如果出现过写锁长时间拿不到、监控里看到写操作延迟明显抖动的情况,再考虑切公平锁。

3.2 HoldCounter:读锁重入是怎么被记下来的

HoldCounter是ReentrantReadWriteLock内部定义的一个小对象,里面就两个字段:tid记录线程id,count记录当前线程读锁重入次数。它和ThreadLocal 配合使用,每个线程都维护自己的重入次数。你可能觉得奇怪:AQS本身就是线程安全的,为什么读锁重入还需要ThreadLocal?因为读锁是共享的,AQS状态的高16位只管总数,无法区分总数来自哪个线程;要支持重入并保证释放时扣减正确,就必须按线程单独记账。

但ThreadLocal的get/set开销毕竟比普通变量高,所以源码里做了两个层次的优化。第一个是firstReader:如果整个读锁只有当前线程一个使用者,就用两个字段firstReader和firstReaderHoldCount直接记录,完全避开ThreadLocal。第二个是cachedHoldCounter:如果当前线程不是firstReader,但也可能频繁获取释放读锁,源码会缓存最近一次操作线程的HoldCounter,再次操作时先比较线程id,相同就直接复用,省掉一次ThreadLocal查找。

理解了HoldCounter,你就明白为什么读锁释放时必须能找到当前线程对应的Counter,也就能理解为什么大多数规范会强调:读锁和写锁的加锁解锁必须在同一个线程内完成,跨线程转移锁的所有权在读写锁这里尤其危险。

3.3 为什么重入次数上限是65535

高16位和低16位各自能表示的数值范围是0到65535,MAX_COUNT就被定成了(1<<16)-1。这意味着一把ReentrantReadWriteLock的写锁最多只能被同一个线程重入65535次,所有线程的读锁获取次数总和也不能超过65535。源码里写锁重入会检查nextc > MAX_COUNT,读锁快速路径和完整路径里也会检查HoldCounter的count是否等于MAX_COUNT,超了就抛Error。

65535这个限制实际业务很难触达,正常递归几十层、循环几百次都远远达不到,除非你在写很极端的递归算法。但它揭示了一个设计约束:一个int字段拆两半,单边的位宽就只有16位,这是空间换性能的取舍。面试里经常考这个问题,能答清楚“为什么是高16位和低16位、为什么上限是65535”,基本说明对源码有真实的理解。

4. 实操:读写锁实现本地缓存并压测对比

4.1 本地缓存Demo:从get/put到锁降级

纸上谈兵没什么意思,我直接带你把一个本地缓存写出来。核心需求就是三个方法:按key读取、写入kv、按key更新。最朴素的做法是用HashMap存储数据,再用读写锁保护:

public class LocalCache<K, V> { private final Map<K, V> map = new HashMap<>(); private final ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock(); private final Lock readLock = rwLock.readLock(); private final Lock writeLock = rwLock.writeLock(); public V get(K key) { readLock.lock(); try { return map.get(key); } finally { readLock.unlock(); } } public void put(K key, V value) { writeLock.lock(); try { map.put(key, value); } finally { writeLock.unlock(); } } public void putIfAbsent(K key, V value) { writeLock.lock(); try { V old = map.get(key); if (old == null) { map.put(key, value); } } finally { writeLock.unlock(); } } }

这个版本已经可以工作了,但在“更新缓存”的场景里还能更进一步。假设数据源在外部数据库,查询时先读缓存,缓存没有就回源数据库,再把结果塞进缓存。如果在塞缓存前不对锁做特殊处理,可能存在多个线程同时回源,导致数据库压力被放大。这时可以用锁降级优化:

public V getAndLoad(K key, Supplier<V> loader) { V value = get(key); if (value != null) { return value; } writeLock.lock(); try { // 二次检查,防止前面多个线程同时回源 value = map.get(key); if (value == null) { value = loader.get(); // 回源,只有持写锁的线程进入 map.put(key, value); } // 锁降级:拿到读锁再释放写锁 readLock.lock(); } finally { writeLock.unlock(); } try { // 这里线程仍然持有读锁,其他写线程不能修改数据 return value; } finally { readLock.unlock(); } }

这里有个容易踩的坑:读锁获取成功后,一定要在当前线程退出写锁临界区后再返回,否则读锁没放掉,其他写线程就会一直等。同时,如果一个方法里既有读锁又有写锁,务必用多个try/finally把解锁逻辑分隔清楚,不然一旦中间抛异常,锁就泄露出去了。

4.2 和synchronized、ReentrantLock对比实测

光说理论不够,我写了个简单的压测场景模拟:一个map存储1000条数据,8个线程同时操作,读操作占80%,写操作占20%,每个读操作做简单的get,每个写操作做put。分别用synchronized方法、ReentrantLock和ReentrantReadWriteLock实现三版,跑固定时间看完成的请求总数。

测试代码核心部分可以这样组织:

// 三种锁实现同一个接口 public interface CacheBenchmark { void read(int key); void write(int key, int value); } // synchronized版 class SyncCache implements CacheBenchmark { private final Map<Integer, Integer> map = new HashMap<>(); public synchronized void read(int key) { map.get(key); } public synchronized void write(int key, int value) { map.put(key, value); } } // ReentrantLock版 class ReentrantCache implements CacheBenchmark { private final Map<Integer, Integer> map = new HashMap<>(); private final Lock lock = new ReentrantLock(); public void read(int key) { lock.lock(); try { map.get(key); } finally { lock.unlock(); } } public void write(int key, int value) { lock.lock(); try { map.put(key, value); } finally { lock.unlock(); } } } // ReadWriteLock版 class ReadWriteCache implements CacheBenchmark { private final Map<Integer, Integer> map = new HashMap<>(); private final ReentrantReadWriteLock rw = new ReentrantReadWriteLock(); public void read(int key) { rw.readLock().lock(); try { map.get(key); } finally { rw.readLock().unlock(); } } public void write(int key, int value) { rw.writeLock().lock(); try { map.put(key, value); } finally { rw.writeLock().unlock(); } } }

测试时用CountDownLatch控制线程同时启动,每轮跑10秒,记录总操作数。实测下来在8线程、读写8:2的场景里,ReadWriteLock版往往能比synchronized版快2到4倍,比ReentrantLock版快2倍左右。差异主要来自读操作并行化:synchronized和ReentrantLock同一时间只允许一个线程读,而ReadWriteLock允许8个线程同时读,CPU多核优势就被释放出来了。

4.3 测试结果解读:什么场景才能真正发挥读写锁优势

但如果你把读写比例改成5:5,甚至写操作占80%,结果会完全反转。这时候ReadWriteLock反而不如简单的互斥锁。原因并不难理解:读写锁获取每次都要进行更复杂的位运算、状态判断和可能的ThreadLocal操作,本身的固定开销比synchronized或ReentrantLock要大。在写操作已经占大头的情况下,读并行带来的收益根本覆盖不了额外开销,反而白白增加了代码复杂度。

所以选择读写锁前一定要先回答三个问题:第一,读操作是不是远多于写操作?第二,读临界区会不会很快,不存在长耗时操作?第三,系统对写操作的延迟是否敏感?如果三个问题答案都是“是”,读写锁基本就是正确选择。如果写操作比例高,或者临界区里有网络调用、磁盘IO这种阻塞操作,那大概率还是老老实实用互斥锁更合适。

还有一点值得注意:可重入能力在这个场景中很重要。比如在持有读锁的方法里又调用了另一个加读锁的方法,ReentrantReadWriteLock可以正常继续执行,不会把自己卡死。如果换成StampedLock,它不支持重入,类似嵌套调用就要小心了。

5. 实战踩坑:常见问题与排查技巧

5.1 写锁饥饿:读锁不停,写锁就没机会

读写锁最遭人诟病的问题就是写锁饥饿。如果读线程源源不断,而且每个读线程持有读锁的时间还比较长,写锁可能长时间抢不到锁,写操作延迟飙升甚至超时。非公平模式下我上面提到,读锁在检测到队列头部有写锁等待时会让路,这能缓解一定程度的饥饿,但并不能完全根治,比如大量读锁通过重入不断延续持有时间,写锁依然可能被无限延后。

面对这种问题,有几个实践方向。第一个直接换公平锁,虽然会牺牲吞吐,但线程FIFO能保证写锁最终一定被调度。第二个是缩短读锁持有时间,把“持锁读数据+持锁做业务计算”改成“持锁读数据,释锁做业务计算”,比如先把HashMap里的引用拷出来,释放锁后再处理对象内容。第三个是在系统层面做改造,把多次写操作合并成一次批量写,减少写锁申请频率。我自己更偏向先优化读锁持有时间,大多数压测场景下,这一步就能解决很多问题。

5.2 锁降级为什么要先拿读锁再放写锁

锁降级看起来只是一行代码顺序的问题,但背后有一个很核心的思路:保证数据的一致性和连续性。假设你直接释放写锁再去拿读锁,写锁释放的一瞬间,另外一个写线程可能成功抢到锁,修改了刚刚才写入的数据。等你拿到读锁去读时,读到的已经不是自己写的那份数据了,这在某些场景会造成逻辑错误。

反过来,先在写锁临界区内获取读锁,再释放写锁,就能保证当前线程从“写者”身份无缝切换为“读者”身份,中间不会有任何其他写线程插入。用一句话总结:降级的目的就是让自己写的数据对后续读取保持可见,并且不让别人在中间篡改。如果你根本不关心这点,只是顺手想用读锁保护后续操作,其实可以考虑直接把读操作放进同一个写锁临界区,没必要绕一圈做降级。

5.3 典型死锁与锁泄漏排查思路

读写锁最常见的死锁类型是锁升级死锁。比如一个线程在读锁保护下执行某个方法,方法内部又要获取写锁,这个请求会一直等在同步队列里,因为它需要等待所有读锁释放,而自己手上就握着一个读锁,形成自锁。排查时最直接的手段是用jstack把线程dump出来,寻找处于WAITING或BLOCKED状态的线程,重点看等待的锁对象是不是同一个。通过线程栈能基本定位到业务代码里的那几行调用,然后顺着调用链检查是否存在“读锁内获取写锁”的逻辑。

另一个常见问题是锁泄漏。读锁或写锁获取后,如果发生异常而没有finally释放,锁就会被永久占用。读锁泄漏会导致所有写锁永久等待,写锁泄漏会导致所有读锁和写锁永久等待,整个系统直接瘫痪。排查这类问题,除了代码review,还可以在压测或生产环境用JFR或者Arthas的thread命令持续观察线程状态,看是否有大量线程长期卡在同一个锁上。最可靠的预防办法有两个:一是强制遵循try/finally模式,二是约定读锁和写锁的加锁解锁必须在同一个方法内成对出现,不要跨方法传递。

5.4 问题速查表

平时排障时我习惯把现象、原因和动作整理成表,方便团队同学直接对照,这里分享一份:

现象可能原因排查思路解决动作
写操作延迟很高读锁持有时间长,写锁饥饿jstack看写线程是否长期等待;监控读锁持有时间缩短读锁临界区;换公平锁;批量写
系统吞吐上不去读写比例没有想象中那么高,或临界区有阻塞操作统计实际读写比例;压测对比互斥锁确认是否适合读写锁;把长耗时操作移出锁
线程卡死,jstack显示等待同一个锁锁升级死锁或锁泄漏分析线程栈,找循环等待链杜绝读锁内获取写锁;检查是否缺finally释放
数据读到旧值用了StampedLock乐观读且校验失败后没升级锁检查乐观读逻辑校验失败后走读锁,或者回到ReentrantReadWriteLock
程序报IllegalMonitorStateException释放锁的线程不是当前持有锁的线程打印线程名,检查代码路径保证加锁解锁线程一致

最后再分享一个我自己的判断标准。用了读写锁这么多年,最大的体会是:它不是银弹,只是一个“在特定读写比例下效率更高的工具”。每次新项目需要加锁时,我都会先做一次极简单的读写比例统计,再决定用哪种锁。如果拿不准,就用上面的Demo跑一轮压测,数据比任何经验都可靠。这个套路帮我避开了很多不必要的性能麻烦,也让你在代码里写的每一把锁都真正值回票价。

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

OpenCV+Qt+YOLO构建人形检测系统:从脚本到桌面应用的完整实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/1 5:39:51

GrayLog接入Windows日志:从Winlogbeat到告警的完整实践指南

把Windows的日志接到GrayLog&#xff0c;这事乍一看像是“装个采集器、建个Input”就能搞定&#xff0c;但真正上手之后&#xff0c;你会发现坑全藏在细节里&#xff1a;版本匹配、时区问题、提取器正则、Sidecar注册、Beats类型映射……每一步都可能让你卡上半天。这篇文章我会…

作者头像 李华
网站建设 2026/10/1 5:38:45

手写多Agent面试官:ReAct循环与角色协作实战

最近在业余时间做了一个叫《码上面试》的Agent项目&#xff0c;简单说&#xff0c;它是一个面向程序员求职场景的模拟面试助手。市面上类似的面试刷题工具太多了&#xff0c;但大多数都是“题库固定题解”的模式&#xff0c;候选人对着标准答案背&#xff0c;实际面试时遇到追问…

作者头像 李华
网站建设 2026/10/1 5:38:45

xinput1_3.dll报错真相:不是丢失,是DirectX运行时环境异常

1. 项目概述&#xff1a;xinput1_3.dll不是“丢失”&#xff0c;而是系统运行时环境缺失的典型症状 你点开《极限竞速&#xff1a;地平线5》图标&#xff0c;黑屏两秒后弹出一行红字&#xff1a;“无法启动此程序&#xff0c;因为计算机中丢失 xinput1_3.dll”&#xff1b;或者…

作者头像 李华
网站建设 2026/10/1 5:37:29

Jev大模型为何“哑巴”?从结构化补全到Codex集成实战指南

最近后台好多人在问Jev这个模型&#xff0c;说法五花八门&#xff0c;最集中的就是“这玩意儿到底怎么用&#xff1f;问它问题怎么一句话都不回&#xff1f;”。先别急着删文件&#xff0c;你大概率是踩中了“哑巴模型”这个坑。Jev的本职工作不是陪你唠嗑&#xff0c;它是那种…

作者头像 李华