当前位置: 首页 > article >正文

Java中的锁总结

锁的本质是为了实现线程同步。

何为线程同步?就是线程之间接某种机制协调先后次序执行。当有个线程在对内存进行操作时,其他线程都不可以对这个内存地址进行操作,直到该线程完成操作。

也就是说每个线程的修改都是原子操作,所谓原子性是指不可分割的一系列操作指令。在执行完毕前不会被任何其他操作中断。要么全部执行,要么全部不执行。

Java 中常用锁实现的方式有两种:

  • 用并发包中的锁类(例如,ReentrantLock)

  • 利用同步代码块(例如,synchronized)

以上两种有各自的底层实现锁的不同方式。一个是底层是通过Monitor来实现锁,一个是通过AQS实现锁。知道了锁的底层实现原理后,又可以进一步的分析锁的各种特性,比如说如果锁住同步资源失败,是要阻塞还是不要阻塞?多个线程竞争这个锁是要不要排队?这个锁能被一个线程多次获取吗?这个锁能不能被多个线程共享?

接下来,我就以以上两种分类展开分析。

1、synchronized

1.1.synchronized底层是如何实现线程同步的

1.1.1.JDK 6之前

synchronized锁特性由 JVM底层的监视锁负责实现。这个监视锁就是monitor,是每个对象与生俱来的一个隐藏字段。使用 synchronized 时, JVM会根据synchronized所在的同步方法或代码块,获取该方法或代码块所属对象的 monitor。monitor中有一个Owner字段存放拥有该锁的线程的唯一标识,表示该锁被这个线程占用。

当线程想来获取锁时,就根据monitor的状态进行加、解锁的判断。如果成功加锁那么该 monitor 的Owner就是这个线程。monitor 在被释放前,不能再被其他线程获取。

以上过程我们查看字节码就可以证明:

使用 monitorentermonitorexit 两个字节码指令获取和释放 monitor。如果使用 monitorenter 进入时monitor 为0,表示该线程可以持有 monitor 后续代码,并将 monitor +1,如果当前线程已经持有了 monitor,那么 monitor 继续加+1 ;如果monitor 非0,其他线程就会进入阻塞状态。

这种方式就是synchronized最初实现同步的方式,这就是JDK 6之前synchronized效率低的原因。

为什么效率低?因为一旦没获取到锁,就进入阻塞状态,我们知道,线程状态的切换是需要CPU从用户态转为核心态,频繁的阻塞和唤醒对CPU来说是一件负担很重的工作,这种状态转换需要耗费处理器时间。

1.1.2.JDK 6之后

JDK 6中为了减少获得锁和释放锁带来的性能消耗,引入了“偏向锁”和“轻量级锁”。所以目前锁一共有4种状态,级别从低到高依次是:无锁偏向锁轻量级锁重量级锁。锁状态只能升级不能降级。

偏向锁和轻量级锁都是乐观锁,基于CAS操作,不需要条件变量之类的东西,所有不需要Monitor,而重量级锁是悲观锁,则会被monitor机制管理。(CAS具体介绍待会会讲)

在介绍偏向锁和轻量级锁之前,先介绍对象头结构。因为偏向锁、轻量级锁的实现正是依靠对象头中的Mark Word。

对象头中分为两部分,一部分是“Mark Word”(存储对象自身的运行时数据,32bit或64bit,可复用);另一部分是指向它的类的元数据指针

偏向锁

偏向锁就是在运行过程中,对象的锁偏向某个线程。即在开启偏向锁机制的情况下,某个线程获得锁,当该线程下次再想要获得锁时,不需要再获得锁(即忽略synchronized关键词),直接就可以执行同步代码,比较适合竞争较少的情况。

偏向锁的获取流程:

(1)查看Mark Word中偏向锁的标识以及锁标志位,若是否偏向锁为1且锁标志位为01,则该锁为可偏向状态。

(2)若为可偏向状态,则测试Mark Word中的线程ID是否与当前线程相同,若相同,则直接执行同步代码,否则进入下一步。

(3)当前线程通过CAS操作竞争锁成功,说明Mark Word中的线程ID为空,则将Mark Word中线程ID设置为当前线程ID,然后执行同步代码。

(4)当前线程通过CAS竞争锁失败的情况下,说明Mark Word中的线程ID是别人的线程ID,说明有竞争。当到达全局安全点时之前获得偏向锁的线程被挂起,偏向锁升级为轻量级锁,然后被阻塞在安全点的线程继续往下执行同步代码。

偏向锁的释放:

偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程不会主动释放偏向锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有字节码正在执行),它会首先暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态。撤销偏向锁后恢复到无锁(标志位为“01”)或轻量级锁(标志位为“00”)的状态。

当一个线程访问同步代码块并获取锁时,会在Mark Word里存储锁偏向的线程ID。在线程进入和退出同步块时不再通过CAS操作来加锁和解锁,而是检测Mark Word里是否存储着指向当前线程的偏向锁。引入偏向锁是为了在无多线程竞争的情况下尽量减少不必要的轻量级锁执行路径,因为轻量级锁的获取及释放依赖多次CAS原子指令,而偏向锁只需要在置换ThreadID的时候依赖一次CAS原子指令即可。

轻量级锁

轻量级锁的获取流程:

(1)当线程执行代码进入同步块时,若Mark Word为无锁状态,虚拟机先在当前线程的栈帧中建立一个名为Lock Record(锁记录)的空间,用于存储当前对象的Mark Word的拷贝,此时状态如下图:

(2)复制对象头中的Mark Word到锁记录中。

(3)复制成功后,虚拟机将用CAS操作将对象的Mark Word更新为执行Lock Record的指针,并将Lock Record里的owner指针指向对象的Mark Word。如果更新成功,则执行4,否则执行5。;

(4)如果更新成功,则这个线程拥有了这个锁,并将锁标志设为00,表示处于轻量级锁状态,此时状态图:

(5)如果更新失败,虚拟机会检查对象的Mark Word是否指向当前线程的栈帧,如果是则说明当前线程已经拥有这个锁,可进入执行同步代码。否则说明多个线程竞争,若当前只有一个等待线程,则该线程通过自旋进行等待。但是当自旋超过一定的次数,或者一个线程在持有锁,一个在自旋,又有第三个来访时,轻量级锁升级为重量级锁

综上,偏向锁通过对比Mark Word解决加锁问题,避免执行CAS操作。而轻量级锁是通过用CAS操作和自旋来解决加锁问题,避免线程阻塞和唤醒而影响性能。重量级锁是将除了拥有锁的线程以外的线程都阻塞。

1.2.总结

synchronized底层是通过monitor和CAS实现的。

1.3.CAS

CAS全称 Compare And Swap(比较与交换),是一种无锁算法。在不使用锁(没有线程被阻塞)的情况下实现多线程之间的变量同步。java.util.concurrent包中的原子类就是通过CAS来实现了乐观锁。

CAS算法涉及到三个操作数:

  • 需要读写的内存值 V。

  • 进行比较的值 A。

  • 要写入的新值 B。

当且仅当 V 的值等于 A 时,CAS通过原子方式用新值B来更新V的值(“比较+更新”整体是一个原子操作),否则不会执行任何操作。一般情况下,“更新”是一个不断重试的操作。

CAS虽然很高效,但是它也存在三大问题,这里也简单说一下:

  1. ABA问题

    • CAS需要在操作值的时候检查内存值是否发生变化,没有发生变化才会更新内存值。但是如果内存值原来是A,后来变成了B,然后又变成了A,那么CAS进行检查时会发现值没有发生变化,但是实际上是有变化的。ABA问题的解决思路就是在变量前面添加版本号,每次变量更新的时候都把版本号加一,这样变化过程就从“A-B-A”变成了“1A-2B-3A”。

    • JDK从1.5开始提供了AtomicStampedReference类来解决ABA问题,具体操作封装在compareAndSet()中。compareAndSet()首先检查当前引用和当前标志与预期引用和预期标志是否相等,如果都相等,则以原子方式将引用值和标志的值设置为给定的更新值。

  2. 循环时间长开销大。CAS操作如果长时间不成功,会导致其一直自旋,给CPU带来非常大的开销。

  3. 只能保证一个共享变量的原子操作

    • 对一个共享变量执行操作时,CAS能够保证原子操作,但是对多个共享变量操作时,CAS是无法保证操作的原子性的。

    • Java从1.5开始JDK提供了AtomicReference类来保证引用对象之间的原子性,可以把多个变量放在一个对象里来进行CAS操作。

2、并发包中的锁类

2.1.AQS

并发包的类族中, Lock 是 JUC 包的顶层接口,它的实现逻辑是利用了 volatile 的可见性。这体现在AQS使用一个int成员变量来表示同步状态:

private volatile int state;

ReentrangLock、Semaphor、CountDownLatch,它们的实现都用到了一个共同的基类--AbstractQueuedSynchronizer,简称AQS。AQS是一个同步框架。AQS定义两种资源共享方式:独占式共享式。具体争用共享资源的方式通过自定义实现。

2.1.1.利用AQS自定义同步方法

这里直接给出自定义同步工具的例子:(来源于:从ReentrantLock的实现看AQS的原理及应用 - 美团技术团队 (meituan.com))

public class LeeLock  {
​
    private static class Sync extends AbstractQueuedSynchronizer { 
        @Override
        protected boolean tryAcquire (int arg) {
            return compareAndSetState(0, 1);
        }
​
        @Override
        protected boolean tryRelease (int arg) {
            setState(0);
            return true;
        }
​
        @Override
        protected boolean isHeldExclusively () {
            return getState() == 1;
        }
    }
    
    private Sync sync = new Sync();
    
    public void lock () {//加锁操作,代理到acquire(模板方法)上,acquire会调用我们重写的tryAcquire方法
        sync.acquire(1);
    }
    
    public void unlock () {//释放锁,代理到release(模板方法)上就行,release会调用我们重写的tryRelease方法。
        sync.release(1);
    }
}

测试LeeLock锁:

public class LeeMain {
​
    static int count = 0;
    static LeeLock leeLock = new LeeLock();
​
    public static void main (String[] args) throws InterruptedException {
​
        Runnable runnable = new Runnable() {
            @Override
            public void run () {
                try {
                    leeLock.lock();
                    for (int i = 0; i < 10000; i++) {
                        count++;
                    }
                } catch (Exception e) {
                    e.printStackTrace();
                } finally {
                    leeLock.unlock();
                }
​
            }
        };
        Thread thread1 = new Thread(runnable);
        Thread thread2 = new Thread(runnable);
        thread1.start();
        thread2.start();
        thread1.join();
        thread2.join();
        System.out.println(count);
    }
}

如果没有加锁,那么count最后的值一定是小于20000;而如果加上LeeLock锁,最后的count值就为20000。

以上,我们可以知道,我们自定义同步工具主要重写的就是tryAcquire方法。还有以下方法是可以被重写的:

protected boolean tryAcquire(int arg) : 独占式获取同步状态,试着获取,成功返回true,反之为false

protected boolean tryRelease(int arg) :独占式释放同步状态,等待中的其他线程此时将有机会获取到同步状态;

protected int tryAcquireShared(int arg) :共享式获取同步状态,返回值大于等于0,代表获取成功;反之获取失败;

protected boolean tryReleaseShared(int arg) :共享式释放同步状态,成功为true,失败为false

protected boolean isHeldExclusively() : 是否在独占模式下被线程占用。

要实现一个独占锁,那就去重写tryAcquire,tryRelease方法,要实现共享锁,就去重写tryAcquireShared,tryReleaseShared。

总结:我们自定义同步方法时,只需要自定义什么情况下能成功获取锁。获取锁成功后就开始执行线程方法,如果没获取锁的线程就交于AQS管理,线程排队、阻塞、唤醒都通通让AQS定义好方法实现,我们无需关心。

首先,我们需要知道AQS维护了一个volatile int state(代表共享资源)和一个FIFO线程等待队列名字叫"CLH"队列(多线程争用资源被阻塞时会进入此队列)。该队列由一个一个的Node结点组成,每个Node结点维护一个prev引用和next引用,分别指向自己的前驱和后继结点。AQS维护两个指针,分别指向队列头部head和尾部tail。

Node类的属性:

/** waitStatus值,表示线程已被取消(等待超时或者被中断)*/
static final int CANCELLED =  1;
/** waitStatus值,表示后继线程需要被唤醒(unpaking)*/
static final int SIGNAL = -1;
/**waitStatus值,表示结点线程等待在condition上,当被signal后,会从等待队列转移到同步到队列中 */
static final int CONDITION = -2;
/** waitStatus值,表示下一次共享式同步状态会被无条件地传播下去*/
static final int PROPAGATE = -3;
​
​
volatile int waitStatus;  // Node里,记录状态用的。初始为0
​
volatile Thread thread;  // Node里,标识哪个线程
​
volatile Node prev;  // 前驱节点(这个Node的上一个是谁)
​
volatile Node next; // 后继节点(这个Node的个一个是谁)

AQS本身的属性:

private transient Thread exclusiveOwnerThread; // 标识拿到锁的是哪个线程
​
private transient volatile Node head; // 标识头节点
​
private transient volatile Node tail; // 标识尾节点
​
private volatile int state; // 同步状态,为0时,说明可以抢锁

整体流程就是:

当线程获取资源失败,会被构造成一个结点加入CLH队列中,同时当前线程会被阻塞在队列中(通过LockSupport.park实现,其实是等待态)。当持有同步状态的线程释放同步状态时,会唤醒后继结点,然后此结点线程继续加入到对同步状态的争夺中。

单纯分析AQS不太生动,我们拿ReentrantLock来分析AQS。

2.1.2.ReentrantLock

ReentrantLock这个类,并没有直接继承AQS,而在ReentrantLock有一个内部类,sync,它继承了AQS。这与我们上面LeeLock的实现方式一样。

1、我们从以下代码开始分析:

ReentrantLock lock = new ReentrantLock();

ReentrantLock有两个重载构造器,空参的构造器默认用的是非公平锁,而带参的构造器通过传true或false来指定是否使用公平锁。

public ReentrantLock() {
    sync = new NonfairSync();  // 默认是非公平锁
}
​
​
public ReentrantLock(boolean fair) { // 传入一个 boolean 值,true 时为公平锁,false 时为非公平锁
    sync = fair ? new FairSync() : new NonfairSync();
}
线程加锁

2、接着我们来分析这句代码:

lock.lock(); // 加锁

进入lock方法,又要区别公平锁与非公平锁:

非公平锁是这样的:

final void lock() {
     if (compareAndSetState(0, 1)) // 成功将state 由0改为1的线程,直接就可以拿到锁
         setExclusiveOwnerThread(Thread.currentThread());
     else
         acquire(1); // 没拿到锁,将这个线程交给AQS处理。!!!我们分析AQS主要就是从这个方法开始进入分析。
 }

公平锁是这样的:

final void lock() { 
    acquire(1); // 直接调用acquire(), 非公平锁是先抢锁,失败用调用acquire().
}

对比公平锁与非公平锁,非公平锁是如果有线程要加锁,直接尝试获取锁,获取不到才会到等待队列的队尾等待;而公平锁就是多个线程按照申请锁的顺序来获取锁,线程直接进入队列中排队,队列中的第一个线程才能获得锁。所以我们可以猜测acquire方法就是来处理获取锁失败的线程。

3、以上lock方法中都调用了acquire方法,我们进入此方法分析:

public final void acquire(int arg) {
    if (!tryAcquire(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE), arg)){
         selfInterrupt();
    }
}

可以看出,没获得锁的线程会再一次尝试获取锁,对应tryAcquire方法。刚进入lock 没抢到,现在可能锁已释放,有可能这次就抢成功了。如果仍然获取不到锁,那么就放到队列的队尾。对应addWaiter方法。线程在队列里要么被阻塞等待被唤醒、要么不断自旋尝试获取锁。直到获取锁成功或者不再需要获取(中断)。对应acquireQueued方法。 

4、分析tryAcquire方法。(此方法就是自定义同步器需要自己定义的那部分,这个方法作用就是定义抢锁是否成功的逻辑)。ReentrantLock抢锁成功的逻辑就是:定义 state为 0 时可以获取资源并置为 1。若已获得资源, state 不断加 1。在释放资源时 state 减1,直至为 0 。

非公平锁抢锁方式:

// tryAcquire 这个方法,非公平锁调用这个方法,传入参数是1,这个与可重入相关。
protected final boolean tryAcquire(int acquires) {
    return nonfairTryAcquire(acquires);
}
​
final boolean nonfairTryAcquire(int acquires) {
    final Thread current = Thread.currentThread();
    int c = getState(); // 获取 state状态,
    if (c == 0) { // state是0,继续抢锁。刚进入lock 没抢到,现在可能锁已释放,有可能这次就抢成功了。
        if (compareAndSetState(0, acquires)) {
            setExclusiveOwnerThread(current);
            return true; // 拿到了锁,lock 方法就结束了。
        }
    }
    // 来抢锁的线程,本身就执有锁,说明是再次加锁,state 再加 1
    else if (current == getExclusiveOwnerThread()) {
        int nextc = c + acquires;
        if (nextc < 0) // overflow 内在溢出了,抛出异常
            throw new Error("Maximum lock count exceeded");
        setState(nextc); 
        return true;
    }
    return false; // 抢锁失败
}

公平锁抢锁方式:

// 公平锁tryAcquire()逻辑,与非公平锁区别是多了!hasQueuedPredecessors() 这个判断
protected final boolean tryAcquire(int acquires) {
    final Thread current = Thread.currentThread();
    int c = getState();
    if (c == 0) {
        if (!hasQueuedPredecessors() &&
            compareAndSetState(0, acquires)) {
            setExclusiveOwnerThread(current);
            return true;
        }
    }
    else if (current == getExclusiveOwnerThread()) {
        int nextc = c + acquires;
        if (nextc < 0)
            throw new Error("Maximum lock count exceeded");
        setState(nextc);
        return true;
    }
    return false;
}

hasQueuedPredecessors():

如果返回false,表示等待队列为空,即未初始化 或者 等待队列已初始化,哨兵结点没有后继结点 或者 若哨兵结点有后继结点,后继结点的线程是当前线程。如果属于以上情况的任意一种,就可以抢锁。

总结一句:非公平锁是一旦有线程想要加锁,那么就会立即尝试获取锁,如果获取锁不成功,才加入队列。而公平锁是如果有线程想加锁,不是立即去尝试获取锁,而是先判断队列是不是为空或者队列是不是只有它自己,只有符号这个条件才能尝试获取锁。否则加入队列。

5、addWaiter方法:

private Node addWaiter(Node mode) {
    Node node = new Node(Thread.currentThread(), mode); // 线程与Node绑定
    Node pred = tail;
    if (pred != null) { // 队列已经初始化,直接将new 出来的node 放到队尾
        node.prev = pred;
        if (compareAndSetTail(pred, node)) { // CAS 设置队尾
            pred.next = node;
            return node;
        }
    }
    // 走到这里,说明队列未初始化,或者上面并发入队,入队失败了。
    enq(node); 
    return node;
}
// 自旋方式入队
private Node enq(final Node node) {
    for (;;) { // 死循环,保证入队一定成功
        Node t = tail;
        if (t == null) { // 队尾是null,说明得初始化队列
            if (compareAndSetHead(new Node()))
                tail = head;
        } else {
            node.prev = t;
            if (compareAndSetTail(t, node)) {
                t.next = node;
                return t;
            }
        }
    }
}

补充:队列用了哨兵,初始化就是new一个node不与任何线程绑定,如下图所示,这个就是头节点,thread==null,之后入队的,thread一定有值。图源:彻底弄懂ReentrantLock —— 超详细的原码分析-CSDN博客

6、acquireQueued方法:

final boolean acquireQueued(final Node node, int arg) {
    boolean failed = true;
    try {
        boolean interrupted = false;
        for (;;) {//死循环
            final Node p = node.predecessor();//找到当前结点的前驱结点
            if (p == head && tryAcquire(arg)) {//如果前驱结点是头结点,才tryAcquire,其他结点是没有机会tryAcquire。
                setHead(node);//获取同步状态成功,将当前结点设置为头结点。
                p.next = null; // 方便GC
                failed = false;
                return interrupted;
            }
            // 如果没有获取到同步状态,通过shouldParkAfterFailedAcquire判断是否应该阻塞,parkAndCheckInterrupt用来阻塞线程。如果没进入if条件句,即没被阻塞,于是继续for循环。
            if (shouldParkAfterFailedAcquire(p, node) &&
                parkAndCheckInterrupt())
                interrupted = true;
        }
    } finally {
        if (failed)
            cancelAcquire(node);
    }
}

7、shouldParkAfterFailedAcquire方法:用来判断当前结点线程是否能休息。

判断是否能休息的条件就是这个线程结点的前驱结点的waitStatus值是不是SIGNAL状态。如果是,返回true。那么代表可以放心休息(因为如果前驱节点的状态是SIGNAL,那么表示后继线程需要被唤醒),直到被前驱节点唤醒。其他情况,返回false。如果是CANCEL状态,也就是此结点线程已经无效,从后往前遍历,找到一个非CANCEL状态的结点,将自己设置为它的后继结点。返回false。如果是其他状态,那么就设置为SIGNAL状态,依然返回false。

private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
    //获取前驱结点的wait值 
    int ws = pred.waitStatus;
    if (ws == Node.SIGNAL)//若前驱结点的状态是SIGNAL,意味着当前结点可以被安全地park
        return true;
    if (ws > 0) {
    // ws>0,只有CANCEL状态ws才大于0。若前驱结点处于CANCEL状态,也就是此结点线程已经无效,从后往前遍历,找到一个非CANCEL状态的结点,将自己设置为它的后继结点
        do {
            node.prev = pred = pred.prev;
        } while (pred.waitStatus > 0);
        pred.next = node;
    } else {  
        // 若前驱结点为其他状态,将其设置为SIGNAL状态
        compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
    }
    return false;
}

8、parkAncCheckInterrupt方法:阻塞线程并处理中断

private final boolean parkAndCheckInterrupt() {
    LockSupport.park(this);//使用LockSupport使线程进入阻塞状态
    return Thread.interrupted();// 线程是否被中断过
}
​
这个线程被阻塞后,等待前驱节点唤醒它。当前驱节点唤醒它后,就执行return Thread.interrupted();如果返回的是true,那么
    if (shouldParkAfterFailedAcquire(p, node) &&parkAndCheckInterrupt())
        interrupted = true;//设置interrupted = true
在acquireQueued方法中继续for循环,如果抢到锁,return interrupted;
如果acquireQueued方法返回true,那么在acquire方法中,执行selfInterrupt();
static void selfInterrupt() {
    Thread.currentThread().interrupt();
}
线程释放锁
public void unlock() {
    sync.release(1);
}
​
public final boolean release(int arg) {
    if (tryRelease(arg)) {
        Node h = head;
        if (h != null && h.waitStatus != 0)
            unparkSuccessor(h); //唤醒结点
        return true;
    }
    return false;
}
protected final boolean tryRelease(int releases) {
    int c = getState() - releases;
    if (Thread.currentThread() != getExclusiveOwnerThread())
        throw new IllegalMonitorStateException();
    boolean free = false;
    if (c == 0) {
        free = true;
        setExclusiveOwnerThread(null);
    }
    setState(c);
    return free;
}

参考

《码出高效:Java开发手册》

彻底弄懂ReentrantLock —— 超详细的原码分析-CSDN博客

从ReentrantLock的实现看AQS的原理及应用 - 美团技术团队 (meituan.com)

Java并发包基石-AQS详解 - dreamcatcher-cx - 博客园 (cnblogs.com) 

Java并发之AQS详解 - waterystone - 博客园 (cnblogs.com) 


http://www.kler.cn/news/321260.html

相关文章:

  • Qt信号说明
  • 【Linux】项目自动化构建工具-make/Makefile 详解
  • Linux系统之部署web-resume静态个人简历网页
  • 时序,这很Transformer!颠覆传统,实现了性能的全面超越!
  • Vue3+Element-UI Plus登录静态页
  • vite ts vue中配置@路径别名报错标红
  • 机械设备产品资料方案介绍小程序系统开发制作
  • 【数据结构】排序算法---桶排序
  • SVM原理
  • docker-compose.yml entrypoint 和command 关系
  • 利用 Flink CDC 实现实时数据同步与分析
  • 使用vite+react+ts+Ant Design开发后台管理项目(一)
  • 以数赋能实景三维创新“科技+文旅”
  • 数据结构-3.1.栈的基本概念
  • Redis常用命令笔记
  • Leetcode - 139双周赛
  • Snap 发布新一代 AR 眼镜,有什么特别之处?
  • sentinel-dashboard数据 redis 持久化
  • 甘蔗茎节检测系统源码分享
  • Elasticsearch——介绍、安装与初步使用
  • C语言指针系列1——初识指针
  • CSDN文章导出md并迁移至博客园
  • 数据结构——初始树和二叉树
  • Spring AOP - 配置文件方式实现
  • 【IEEE 独立出版,快速EI检索】第四届人工智能、虚拟现实与可视化国际学术会议(AIVRV 2024)
  • 【编程基础知识】Cookie、Session和JWT(JSON Web Token)
  • Linux 学习 awk 和sed 命令使用
  • 欧洲欧盟药品数据库:EMA、HMA、EDQM-一键查询
  • WEB 编程:富文本编辑器 Quill 配合 Pico.css 样式被影响的问题之Shadow DOM
  • PostgreSQL 向量数据存储指南