JUC并发编程第十三章——读写锁、邮戳锁

本章路线总纲

无锁——>独占锁——>读写锁——>邮戳锁

1 关于锁的面试题

  • 你知道Java里面有那些锁
  • 你说说你用过的锁,锁饥饿问题是什么?
  • 有没有比读写锁更快的锁
  • StampedLock知道吗?(邮戳锁/票据锁)
  • ReentrantReadWriteLock有锁降级机制,你知道吗?

2 简单聊聊ReentrantReadWriteLock

类图:

读写锁的演变情况:

2.1 是什么?

读写锁说明

  • 一个资源能够被多个读线程访问,或者被一个写线程访问,但是不能同时存在读写线程

演变

  • 无锁无序->加锁->读写锁->邮戳锁

读写锁意义和特点

  • 读写锁只允许读读共存,而读写和写写依然是互斥的,恰好大多实际场景是”读/读“线程间不存在互斥关系,只有”读/写“线程或者”写/写“线程间的操作是需要互斥的,因此引入了 ReentrantReadWriteLock
  • 一个ReentrantReadWriteLock同时只能存在一个写锁但是可以存在多个读锁,但是不能同时存在写锁和读锁,也即资源可以被多个读操作访问,或一个写操作访问,但两者不能同时进行。
  • 只有在读多写少情景之下,读写锁才具有较高的性能体现。

2.2 特点

可重入、读写兼顾

结论:一体两面,读写互斥,读读共享,读没有完成的时候其他线程写锁无法获得

ReentrantReadWriteLock的缺点:

1. 锁饥饿问题:

  • ReentrantReadWriteLock实现了读写分离,但是一旦读操作比较多的时候,想要获取写锁就变得比较困难了,因此当前有可能会一直存在读锁,而无法获得写锁。

2. 锁降级:

  • 将写锁降级为读锁------>遵循获取写锁、获取读锁再释放写锁的次序,写锁能够降级为读锁
  • 如果一个线程持有了写锁,在没有释放写锁的情况下,它还可以继续获得读锁。这就是写锁的降级,降级成为了读锁。
  • 如果释放了写锁,那么就完全转换为读锁
  • 如果有线程在读,那么写线程是无法获取写锁的,是悲观锁的策略

2.3 读写锁案例

  • 使用读写锁之前,使用synchronized的情况
public class ReentrantReadWriteLockDemo {
    public static void main(String[] args) {
        MyCache cache = new MyCache();

        //开启10个线程,写入数据
        for (int i = 1; i <= 10; i++) {
            int finalI = i;
            new Thread(() -> {
                cache.write(finalI + "", finalI + "");
            }, String.valueOf(i)).start();
        }

        //开启10个线程,读取数据
        for (int i = 1; i <= 10; i++) {
            int finalI = i;
            new Thread(() -> {
                cache.read(finalI + "");
            }, String.valueOf(i)).start();
        }
    }
}

//模拟一个缓存资源类,有读写两种功能
class MyCache {

    HashMap<String, String> map = new HashMap<>();

     ReentrantLock lock = new ReentrantLock();

    //读写都加锁
    public void write(String key, String value) {
        lock.lock();
        try {
            System.out.println(Thread.currentThread().getName() + "线程开始写入数据...");
            //延迟500ms模拟业务耗时,同时可以看出读写不能共同执行 (因为运行结果是先打印一个线程写入,再打印对应线程写入完成)
            TimeUnit.MILLISECONDS.sleep(500);
            map.put(key, value);
            System.out.println(Thread.currentThread().getName() + "线程完成写入数据!");
        } catch (InterruptedException e) {
            e.printStackTrace();
        }finally {
            lock.unlock();
        }
    }

    public void read(String key) {
        lock.lock();
        try {
            System.out.println(Thread.currentThread().getName() + "线程开始读取数据...");
            String val = map.get(key);
            TimeUnit.MILLISECONDS.sleep(200);
            System.out.println(Thread.currentThread().getName() + "线程读取到的数据是:\t" + val);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }finally {
            lock.unlock();
        }
    }
}
运行结果:
1线程开始写入数据...
1线程完成写入数据!
2线程开始写入数据...
2线程完成写入数据!
3线程开始写入数据...
3线程完成写入数据!
4线程开始写入数据...
4线程完成写入数据!
5线程开始写入数据...
5线程完成写入数据!
6线程开始写入数据...
6线程完成写入数据!
7线程开始写入数据...
7线程完成写入数据!
9线程开始写入数据...
9线程完成写入数据!
8线程开始写入数据...
8线程完成写入数据!
10线程开始写入数据...
10线程完成写入数据!
1线程开始读取数据...
1线程读取到的数据是:	1
2线程开始读取数据...
2线程读取到的数据是:	2
3线程开始读取数据...
3线程读取到的数据是:	3
4线程开始读取数据...
4线程读取到的数据是:	4
5线程开始读取数据...
5线程读取到的数据是:	5
6线程开始读取数据...
6线程读取到的数据是:	6
7线程开始读取数据...
7线程读取到的数据是:	7
8线程开始读取数据...
8线程读取到的数据是:	8
9线程开始读取数据...
9线程读取到的数据是:	9
10线程开始读取数据...
10线程读取到的数据是:	10

说明:可以看出,开始写入/读取和完成写入/读取,都是成对出现的。这说明这写入/读取期间,其他线程不能执行写入/读取。读写/读读/写写都互斥了。

问题:我们希望的情况应该是,读写/写写都互斥,但读读可以并发读取。从而引出了读写锁(对写独占,对读共享)

  • 使用读写锁
public class ReentrantReadWriteLockDemo {
    public static void main(String[] args) {
        MyCache cache = new MyCache();

        //开启10个线程,写入数据
        for (int i = 1; i <= 10; i++) {
            int finalI = i;
            new Thread(() -> {
                cache.write(finalI + "", finalI + "");
            }, String.valueOf(i)).start();
        }

        //开启10个线程,读取数据
        for (int i = 1; i <= 10; i++) {
            int finalI = i;
            new Thread(() -> {
                cache.read(finalI + "");
            }, String.valueOf(i)).start();
        }
    }
}

//模拟一个缓存资源类,有读写两种功能
class MyCache {

    HashMap<String, String> map = new HashMap<>();

    ReentrantLock lock = new ReentrantLock();

    ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();

    //读写都加锁
    public void write(String key, String value) {
        rwLock.writeLock().lock();
        try {
            System.out.println(Thread.currentThread().getName() + "线程开始写入数据...");
            //延迟500ms模拟业务耗时,同时可以看出读写不能共同执行 (因为运行结果是先打印一个线程写入,再打印对应线程写入完成)
            TimeUnit.MILLISECONDS.sleep(500);
            map.put(key, value);
            System.out.println(Thread.currentThread().getName() + "线程完成写入数据!");
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            rwLock.writeLock().unlock();
        }
    }

    public void read(String key) {
        rwLock.readLock().lock();
        try {
            System.out.println(Thread.currentThread().getName() + "线程开始读取数据...");
            String val = map.get(key);
            TimeUnit.MILLISECONDS.sleep(200);
            System.out.println(Thread.currentThread().getName() + "线程读取到的数据是:\t" + val);
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            rwLock.readLock().unlock();
        }
    }
}
运行结果:
1线程开始写入数据...
1线程完成写入数据!
2线程开始写入数据...
2线程完成写入数据!
3线程开始写入数据...
3线程完成写入数据!
4线程开始写入数据...
4线程完成写入数据!
5线程开始写入数据...
5线程完成写入数据!
6线程开始写入数据...
6线程完成写入数据!
7线程开始写入数据...
7线程完成写入数据!
8线程开始写入数据...
8线程完成写入数据!
9线程开始写入数据...
9线程完成写入数据!
10线程开始写入数据...
10线程完成写入数据!
1线程开始读取数据...
9线程开始读取数据...
7线程开始读取数据...
6线程开始读取数据...
5线程开始读取数据...
3线程开始读取数据...
4线程开始读取数据...
2线程开始读取数据...
10线程开始读取数据...
8线程开始读取数据...
10线程读取到的数据是:10
4线程读取到的数据是:	4
2线程读取到的数据是:	2
8线程读取到的数据是:	8
3线程读取到的数据是:	3
7线程读取到的数据是:	7
6线程读取到的数据是:	6
5线程读取到的数据是:	5
1线程读取到的数据是:	1
9线程读取到的数据是:	9

说明:可以看出,所有写操作还是跟之前一样,全部互斥。但读操作可以并发读取。

结论

使用ReadWriteLock实现读写操作,一体两面,读写互斥,读读共享,但是读没有完成时候其它线程写锁无法获取


2.4 锁降级

ReentrantReadwriteLock锁降级:

  • 将写入锁降级为读锁(类似Linux文件读写权限理解,就像写权限要高于读权限一样),锁的严苛程度变强叫做升级,反之叫做降级。

ReentrantReadwriteLock的特性:

写锁降级成为读锁

  1. 如果同一个线程持有了写锁,在没有释放写锁的情况下,它还可以继续获得读锁。这就是写锁的降级,降级成为了读锁。
  2. 规则惯例,先获取写锁,然后获取读锁,再释放写锁的次序。
  3. 如果释放了写锁,那么就完全转换为读锁。

总之:

  • 如果一个线程先获取写锁,在获取写锁和释放写锁之间可以再获取读锁,如果获取了读锁,之前获取的写锁且被释放了。那么之前的写锁,就降级为现在的读锁了。

why?要有这么个特性?

----后面解释,大概目的:锁降级是为了让当前线程感知到数据的变化,目的是保证数据的可见性


2.5 写锁可以降级为读锁,但读锁不可升级为写锁

重入还允许通过获取写入锁定,然后读取锁然后释放写锁从写锁到读取锁,但是从读锁升级到写锁是不可能的

锁降级的目的:锁降级是为了让当前线程感知到数据的变化,目的是保证数据可见性

样例1

锁降级:获取写锁 ——> 获取读锁 ——> 释放写锁 ——> 释放读锁      ✔ 可以完成

import java.util.concurrent.locks.ReentrantReadWriteLock;

public class LockDownGradingDemo {

    public static void main(String[] args) {
        ReentrantReadWriteLock readWriteLock = new ReentrantReadWriteLock();

        ReentrantReadWriteLock.ReadLock readLock = readWriteLock.readLock();
        ReentrantReadWriteLock.WriteLock writeLock = readWriteLock.writeLock();

        // 例一:正常两个A、B线程
//        new Thread(() -> {
//            readLock.lock();
//            System.out.println("---A线程读取---");
//            readLock.unlock();
//        }, "A").start();
//
//        new Thread(() -> {
//            writeLock.lock();
//            System.out.println("---B线程写入---");
//            writeLock.unlock();
//        }, "B").start();


        // 例二:only one 同一个线程
        writeLock.lock();
        System.out.println("---写入---");
        // 一些其它的业务操作...

        readLock.lock();
        System.out.println("---读取---");
        // 一些其它的业务操作...

        writeLock.unlock();
        readLock.unlock();
    }

}

输出结果:
---写入---
---读取---

说明: 

  • 同一个线程的写后立刻读是可以的,即将写入锁降级为读锁是支持的,这种就是锁降级

样例2

锁降级:获取读锁 ——> 获取写锁 ——> 释放读锁 ——> 释放写锁      X 不可以完成

import java.util.concurrent.locks.ReentrantReadWriteLock;

public class LockDownGradingDemo2 {

    public static void main(String[] args) {
        ReentrantReadWriteLock readWriteLock = new ReentrantReadWriteLock();

        ReentrantReadWriteLock.ReadLock readLock = readWriteLock.readLock();
        ReentrantReadWriteLock.WriteLock writeLock = readWriteLock.writeLock();

        // 例二:only one 同一个线程
        readLock.lock();
        System.out.println("---读取---");
        // 一些其它的业务操作...

        writeLock.lock();
        System.out.println("---写入---");
        // 一些其它的业务操作...

        readLock.unlock(); // 这个位置和下面那个位置效果一样

        writeLock.unlock();
//        readLock.unlock();
    }

}


输出结果:
---读取---
// ...程序未结束

说明:

  • 如果有线程读没有完成的时候,写线程无法获取锁,必须要等着读锁释放所锁后才有机会写,这是悲观锁的策略

1、2例子对比小结:

  • 其实想想很容易理解:同一个线程,先读,还没有读完(读锁readLock没有unlock),我又去写。那么我之前的不就是脏数据了?因此应该先全部读完,才能执行写操作。
  • 而例子1中,先写,就算没写完(写锁没有释放),我立马去读,由于读操作不会导致数据不一致。因此,这是合理的。


2.6 写锁和读锁是互斥的

        写锁和读锁是互斥的(这里的互斥是指线程间的互斥,当前线程可以获取到写锁又获取到读锁,但是获取到了读锁不能继续获取写锁),这是因为读写锁要保持写操作的可见性。因为,如果允许读锁在被获取的情况下对写锁的获取,那么正在运行的其他读线程无法感知到当前写线程的操作

因此,分析读写锁ReentrantReadWriteLock,会发现它有个潜在的问题:

  • 即ReentrantReadWriteLock读的过程中不允许写,只有等待线程都释放了读锁,当前线程才能获取写锁也就是写入必须等待,这是一种悲观的读锁,人家还在读着那,你先别去写,省的数据乱。

2.7 Oracle公司ReentrantReadWriteLock使用样例

* <p><b>Sample usages</b>. Here is a code sketch showing how to perform
* lock downgrading after updating a cache (exception handling is
* particularly tricky when handling multiple locks in a non-nested
* fashion):
*
* <pre> {@code
* class CachedData {
*   Object data;
*   volatile boolean cacheValid;
*   final ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
*
*   void processCachedData() {
*     rwl.readLock().lock();// 1
*     if (!cacheValid) {
*       // Must release read lock before acquiring write lock
*       rwl.readLock().unlock();// 2
*       rwl.writeLock().lock();// 3
*       try {
*         // Recheck state because another thread might have
*         // acquired write lock and changed state before we did.
*         if (!cacheValid) {
*           data = ...//在此做一些写操作
*           cacheValid = true;
*         }
*         // Downgrade by acquiring read lock before releasing write lock
*         rwl.readLock().lock();// 4
*       } finally {
*         rwl.writeLock().unlock(); // 5 Unlock write, still hold read
*       }
*     }
*
*     try {
*       use(data);
*     } finally {
*       rwl.readLock().unlock();// 6
*     }
*   }
* }}</pre>

代码解读:

  • 1-6 六个加锁/释放锁的操作。1-2对应读锁、3-5对应写锁、4-6对应读锁。volatile类型的cacheValid变量,保证其可见性
  • 首先,线程第一次进来,资源类CacheData是没有被修改过的。先加读锁1,if判断 ( !cacheValid ) 的值为true。在2的位置释放读锁。
  • 接着准备写操作,先获取写锁3。并进行双端检索 (防止其它线程恰好修改了)。做完写操作后,把cacheValid改为true。为了立刻读取到我刚刚修改的数据data,必须发生锁降级,在释放写锁5之前获取读锁4。原因:如果我先把写锁释放了,再获取读锁,出现了没有锁的空档期。在此期间锁可能被其他线程获取并修改数据,无法保证读锁立马能被同一个线程获取,可能在我使用data数据的期间,data数据又被修改了!
  • 在4的位置已经获取了读锁,代码运行到5的位置释放写锁。发生锁降级。之后在use(data)这行使用刚刚修改的data数据,最后在6位置释放读锁。让其他线程继续抢锁。

        这里只有锁降级才能保证,同一个线程我先执行写操作,再继续读我刚刚写的数据。在整个线程执行业务的过程中,一直是加锁(不是写锁就是读锁)状态,没有出现空档期,因此整个操作保证了原子性。

如果违背锁降级的步骤,如果违背锁降级的步骤, 如果违背锁降级的步骤

  • 如果当前的线程C在修改完cache中的数据后,没有获取读锁而是直接释放了写锁,那么假设此时另一个线程D获取了写锁并修改了数据,那么C线程无法感知到数据已被修改,则数据出现错误。

4 邮戳锁StampedLock

4.1 是什么?

StampedLock是JDK1.8中新增的一个读写锁,也是对JDK1.5中的读写锁ReentrantReadWriteLock的优化

stamp 代表了锁的状态。当stamp返回零时,表示线程获取锁失败,并且当释放锁或者转换锁的时候,都要传入最初获取的stamp值。

4.2 它是由饥饿问题引出

锁饥饿问题:

  • ReentrantReadWriteLock实现了读写分离,但是一旦读操作比较多的时候,想要获取写锁就变得比较困难了,因此当前有可能会一直存在读锁,而无法获得写锁。

如何解决锁饥饿问题:

  • 使用”公平“策略可以一定程度上缓解这个问题
  • 使用”公平“策略是以牺牲系统吞吐量为代价的
  • StampedLock类的乐观读锁方式--->采取乐观获取锁,其他线程尝试获取写锁时不会被阻塞,在获取乐观读锁后,还需要对结果进行校验

4.3 StampedLock的特点

  • 所有获取锁的方法,都返回一个邮戳,stamp为零表示失败,其余都表示成功
  • 所有释放锁的方法,都需要一个邮戳,这个stamp必须是和成功获取锁时得到的stamp一致
  • StampedLock是不可重入的危险(如果一个线程已经持有了写锁,在去获取写锁的话会造成死锁)
  • StampedLock有三种访问模式:
    • Reading(悲观读模式):功能和ReentrantReadWriteLock的读锁类似
    • Writing(写模式):功能和ReentrantReadWriteLock的写锁类似
    • Optimistic reading(乐观读模式):无锁机制,类似与数据库中的乐观锁,支持读写并发,很乐观认为读时没人修改,假如被修改在实现升级为悲观读模式
  • 一句话:读的过程中也允许写锁介入

4.4 邮戳锁StampedLock代码演示

4.4.1 传统的读写锁模式——读的时候写锁不能获取

import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.StampedLock;

public class StampedLockDemo {

    static int number = 37;
    static StampedLock stampedLock = new StampedLock();

    public void write() {
        long stamp = stampedLock.writeLock();
        System.out.println(Thread.currentThread().getName() + "\t" + "写线程准备修改");
        try {
            number = number + 13;
        } finally {
            stampedLock.unlockWrite(stamp);
        }
        System.out.println(Thread.currentThread().getName() + "\t" + "写线程结束修改");
    }

    // 悲观锁,读没有完成时候写锁无法获得锁
    public void read() {
        long stamp = stampedLock.readLock();
        System.out.println(Thread.currentThread().getName() + "\t" + "come in readLock code Block, 4 second continue...");
        for (int i = 0; i < 4; i++) {
            try {
                TimeUnit.SECONDS.sleep(1);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println(Thread.currentThread().getName() + "\t" + "正在读取中");
        }

        try {
            int result = number;
            System.out.println(Thread.currentThread().getName() + "\t" + "获得成员变量值result: " + result);
            System.out.println("写线程没有修改成功,读锁时候写锁无法介入,传统的读写互斥");
        } finally {
            stampedLock.unlockRead(stamp);
        }

    }

    public static void main(String[] args) {
        StampedLockDemo resource = new StampedLockDemo();
        new Thread(() -> resource.read(), "readThread").start();

        try {
            TimeUnit.SECONDS.sleep(1);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        new Thread(() -> {
            System.out.println(Thread.currentThread().getName() + "\t" + "come in");
            resource.write();
        }, "writeThread").start();
        
        try {
            TimeUnit.SECONDS.sleep(4);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        System.out.println(Thread.currentThread().getName() + "\t" + "number:" + number);
    }

}

输出结果:
readThread	come in readLock code Block, 4 second continue...
readThread	正在读取中
writeThread	come in
readThread	正在读取中
readThread	正在读取中
readThread	正在读取中
readThread	获得成员变量值result: 37
写线程没有修改成功,读锁时候写锁无法介入,传统的读写互斥
writeThread	写线程准备修改
writeThread	写线程结束修改
main	number:50

这和之前的读写锁ReentrantLock使用类似,但邮戳锁不可重入


4.4.2 乐观读模式——读的过程中也允许写锁介入

import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.StampedLock;

public class StampedLockDemo2 {

    static int number = 37;
    static StampedLock stampedLock = new StampedLock();

    public void write() {
        long stamp = stampedLock.writeLock();
        System.out.println(Thread.currentThread().getName() + "\t" + "写线程准备修改");
        try {
            number = number + 13;
        } finally {
            stampedLock.unlockWrite(stamp);
        }
        System.out.println(Thread.currentThread().getName() + "\t" + "写线程结束修改");
    }

    // 乐观读,读的过程中也允许写锁介入
    public void read() {
        long stamp = stampedLock.tryOptimisticRead();

        int result = number;

        // public boolean validate(long stamp)方法官方解释
        // 如果自发出给定标记后未完全获取锁,则返回true。如果标记为零,则始终返回false。如果图章代表当前持有的锁,则始终返回true。 使用未从tryOptimisticRead()获取的值或此锁定的锁定方法调用此方法没有定义的效果或结果
        System.out.println("4秒前 stampedLock.validate方法值(true 无修改 false有修改)" + "\t" + stampedLock.validate(stamp));

        // 故意间隔4秒钟,很乐观认为读取中没有其它线程修改过number值,具体靠判断
        for (int i = 0; i < 4; i++) {
            try {
                TimeUnit.SECONDS.sleep(1);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println(Thread.currentThread().getName() + "\t" + " 正在读取...." + i + "秒后,stampedLock.validate方法值(true 无修改 false有修改)" + "\t" + stampedLock.validate(stamp));
        }

        if (!stampedLock.validate(stamp)) {
            System.out.println("有人修改----------有写操作");
            stamp = stampedLock.readLock();
            try {
                System.out.println("从乐观读升级为悲观读");
                result = number;
                System.out.println("重新悲观读后result:" + result);
            } finally {
                stampedLock.unlockRead(stamp);
            }
        }
        System.out.println(Thread.currentThread().getName() + "\t" + "finally value: " + result);

    }


    public static void main(String[] args) {
        StampedLockDemo2 resource = new StampedLockDemo2();
        new Thread(resource::read, "readThread").start();

        // 暂停2秒线程,演示读过程可以写介入
        // 如果暂停>4秒线程,这样读过程没有写介入,此时输出finally value:37
        try {
            TimeUnit.SECONDS.sleep(2);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        new Thread(() -> {
            System.out.println(Thread.currentThread().getName() + "\t" + " come in");
            resource.write();
        }, "writeThread").start();
    }

}

输出结果:
4秒前 stampedLock.validate方法值(true 无修改 false有修改)	true
readThread	 正在读取....0秒后,stampedLock.validate方法值(true 无修改 false有修改)	true
writeThread	 come in
writeThread	写线程准备修改
writeThread	写线程结束修改
readThread	 正在读取....1秒后,stampedLock.validate方法值(true 无修改 false有修改)	false
readThread	 正在读取....2秒后,stampedLock.validate方法值(true 无修改 false有修改)	false
readThread	 正在读取....3秒后,stampedLock.validate方法值(true 无修改 false有修改)	false
有人修改----------有写操作
从乐观读升级为悲观读
重新悲观读后result:50
readThread	finally value: 50

4.5 StampedLock的缺点

  • StampedLock不支持重入,没有Re开头
  • StampedLock的悲观读锁和写锁都不支持条件变量,这个也需要主要
  • 使用StampedLock一定不要调用中断操作,即不要调用interrupt()方法

相关推荐

  1. C++

    2024-06-17 01:28:01       22 阅读
  2. 并发编程

    2024-06-17 01:28:01       36 阅读
  3. C++ 并发编程 |

    2024-06-17 01:28:01       26 阅读

最近更新

  1. TCP协议是安全的吗?

    2024-06-17 01:28:01       16 阅读
  2. 阿里云服务器执行yum,一直下载docker-ce-stable失败

    2024-06-17 01:28:01       16 阅读
  3. 【Python教程】压缩PDF文件大小

    2024-06-17 01:28:01       15 阅读
  4. 通过文章id递归查询所有评论(xml)

    2024-06-17 01:28:01       18 阅读

热门阅读

  1. 搜索引擎Lucene(Solr和Elasticsearch)2

    2024-06-17 01:28:01       8 阅读
  2. Python:哈希查找法

    2024-06-17 01:28:01       7 阅读
  3. 简单剖析tRPC-Go中使用的第三方协程池ants

    2024-06-17 01:28:01       8 阅读
  4. Opencv无法自动补全

    2024-06-17 01:28:01       6 阅读
  5. 15分钟面试被5连CALL,你扛得住么?

    2024-06-17 01:28:01       7 阅读
  6. SSH error : no kex alg message

    2024-06-17 01:28:01       7 阅读
  7. Spring (60)Spring WebFlux

    2024-06-17 01:28:01       8 阅读
  8. 数据结构之B树的原理与业务场景

    2024-06-17 01:28:01       8 阅读
  9. Autosar实践——诊断配置(DaVinci Configuration)

    2024-06-17 01:28:01       6 阅读
  10. 2024.06.16 刷题日记

    2024-06-17 01:28:01       4 阅读
  11. linux发展历程

    2024-06-17 01:28:01       6 阅读
  12. atcoder ABC 358-B题详解

    2024-06-17 01:28:01       7 阅读