【Java 并发编程】读写锁 ReentrantReadWriteLock StampLock 详解

news2024/11/26 9:51:51

读写锁 ReentrantReadWriteLock & StampLock 详解

  • 1. 读写锁
    • 1.1 并发场景
    • 1.2 什么是读写锁
    • 1.3 思考如何自己实现一把锁?
  • 2. ReentrantReadWriteLock
    • 2.1 ReentrantReadWriteLock 概述及其基本结构
    • 2.2 ReentrantReadWriteLock 的特点
      • 2.2.1 读写锁的互斥关系
      • 2.2.2 可重入性
      • 2.2.3 锁的升降级
        • 锁升级
        • 锁降级
      • 2.2.4 公平锁和非公平锁
    • 2.3 Sync 类
      • 2.3.1 读写状态的设计
      • 2.3.2 记录首个获得读锁的线程 - firstReader
      • 2.3.3 线程局部计数器 - HoldCounter
      • 2.3.4 缓存 - cachedHoldCounter
      • 2.3.5 获取锁
      • 2.3.6 释放锁
    • 2.4 写锁是怎样获取和释放的?
    • 2.5 读锁是怎样获取和释放的?
  • 3. StampLock
    • 3.1 StampedLock 的使用
      • 3.1.1 StampLock 三种访问模式
      • 3.1.2 演示乐观读
      • 3.1.3 在缓存中的应用
    • 3.2 使用场景和注意事项

1. 读写锁

1.1 并发场景

  • 读读操作不存在线程安全问题。读写写写操作是存在线程安全问题的。

现实中有这样一种场景:对共享资源有读和写的操作,且写操作没有读操作那么频繁(读多写少)。在没有写操作的时候,多个线程同时读一个资源没有任何问题,所以应该允许多个线程同时读取共享资源(读读共享);但是如果一个线程想去写这些共享资源,就不应该允许其他线程对该资源进行读和写操作了(读写,写写互斥)。

思考:针对这种场景,有没有比 ReentrantLock 更好的方案?

答:针对这种读多写少的场景,可以使用读写锁

1.2 什么是读写锁

读写锁 ReadWriteLock,顾名思义一把锁分为读与写两部分,读锁允许多个线程同时获得,因为读操作本身是线程安全的。而写锁是互斥锁,不允许多个线程同时获得写锁。并且读与写操作也是互斥的。读写锁适合多读少写的业务场景

1.3 思考如何自己实现一把锁?

  • 继承自 AQS:CAS + 同步队列,自选一定次数后,线程阻塞,加锁 state = 0 -> 1, 解锁 state 1 -> 0

  • 独占锁:tryAcquired

  • 共享锁:tryAcquireShared

如何设计一把读写锁:

  • 读读共享,写读,读写,写写互斥

  • 读锁:共享锁,悲观读锁,有读锁不能写

  • 写锁:独占锁

  • 判断当前是否存在读锁或者写锁,通过 state:state 如何存储读锁和写锁的状态,通过高低位,int:32位,高16位标识读锁,低16位标识写锁 - 0000 0000 0000 0000 0000 0000 0000 0000

  • 高16位不为0:有读锁;低16位不位0:有写锁。

2. ReentrantReadWriteLock

2.1 ReentrantReadWriteLock 概述及其基本结构

针对读多写少场景,Java 的并发包提供了读写锁 ReentrantReadWriteLock,整体架构和实现都与 ​​ReentrantLock​​​ 类似,不同之处在于​​ ReentrantReadWriteLock ​​​分为读锁和写锁,读锁是共享锁,可多个读线程共享一把锁;写锁是互斥锁,只能有一个线程持有锁。同样​​ ReentrantReadWriteLock​​ 也是基于 AQS 实现的。

ReentrantReadWriteLock 实现了 ReadWriteLock 接口,该接口只有两个方法,分别用于返回读锁和写锁,这两个锁都是 Lock 对象。该接口源码如下:

public interface ReadWriteLock {
    Lock readLock();
    Lock writeLock();
}

ReentrantReadWriteLock 有两个域,分别存放读锁和写锁:

private final ReentrantReadWriteLock.ReadLock readerLock;

private final ReentrantReadWriteLock.WriteLock writerLock;

ReentrantReadWriteLock 的核心原理主要在于两点:

  • 内部类 Sync:实现了的 AQS 大部分方法。Sync 类有两个子类 FairSync 和 NonfairSync,分别实现了公平读写锁和非公平读写锁。

  • 内部类 ReadLockWriteLock:分别是读锁和写锁的具体实现,它们都和 ReentrantLock 一样实现了 Lock 接口,因此实现的手段也和 ReentrantLock 一样,都是委托给内部的 Sync 类对象来实现。

下面一起看一下 ReentrantReadWriteLock 和这些内部类的继承、聚合关系图:

在这里插入图片描述

2.2 ReentrantReadWriteLock 的特点

2.2.1 读写锁的互斥关系

线程进入读锁的前提条件:

  • 没有其他线程的写锁

  • 没有写请求或者有写请求,但调用线程和持有锁的线程是同一个

线程进入写锁的前提条件:

  • 没有其他线程的读锁

  • 没有其他线程的写锁

2.2.2 可重入性

ReentrantReadWriteLockReadWriteLock接口之上,添加了可重入的特性,且读锁和写锁都支持可重入。可重入的含义是:

  • 如果一个线程获取了读锁,那么它可以再次获取读锁(但直接获取写锁会失败,原因见下方的“锁的升降级”)

  • 如果一个线程获取了写锁,那么它可以再次获取写锁或读锁

2.2.3 锁的升降级

锁升级

**ReentrantReadWriteLock** 不支持锁升级(把持读锁、获取写锁,最后释放读锁的过程),即同一个线程获取读锁后,直接申请写锁是不能获取成功的。写一段代码来测试一下:

public class ReentrantReadWriteLockTest {

    public static void main(String[] args) {
        ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();
        rwLock.readLock().lock();
        System.out.println("get readLock");

        rwLock.writeLock().lock();
        System.out.println("blocking");
    }
}

输出结果为:

get readLock

目的是:保证数据可见性,如果读锁已被多个线程获取,其中任意线程成功获取了写锁并更新了数据,则其更新对其他获取到读锁的线程是不可见的。

锁降级

锁降级指的是写锁降级成为读锁。如果当前线程拥有写锁,然后将其释放,最后再获取读锁,这种分段完成的过程不能称之为锁降级。锁降级是指把持住(当前拥有的)写锁,再获取到读锁,随后释放(先前拥有的)写锁的过程。锁降级可以帮助我们拿到当前线程修改后的结果而不被其他线程所破坏,防止更新丢失。

锁降级的使用示例:

因为数据不常变化,所以多个线程可以并发地进行数据处理,当数据变更后,如果当前线程感知到数据变化,则进行数据的准备工作,同时其他处理线程被阻塞,直到当前线程完成数据的准备工作。

private final ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
private final Lock r = rwl.readLock();
private final Lock w = rwl.writeLock();
private volatile boolean update = false;

public void processData() {
    readLock.lock();
    if (!update) {
        // 必须先释放读锁
        readLock.unlock();
        // 锁降级从写锁获取到开始
        writeLock.lock();
        try {
            if (!update) {
                // TODO 准备数据的流程(略)  
                update = true;
            }
            readLock.lock();
        } finally {
            writeLock.unlock();
        }
        // 锁降级完成,写锁降级为读锁
    }
    try {
        //TODO  使用数据的流程(略)
    } finally {
        readLock.unlock();
    }
}

锁降级中读锁的获取是否必要呢?答案是必要的。主要是为了保证数据的可见性,如果当前线程不获取读锁而是直接释放写锁,假设此刻另一个线程(记作线程T)获取了写锁并修改了数据,那么当前线程无法感知线程T的数据更新。如果当前线程获取读锁,即遵循锁降级的步骤,则线程T将会被阻塞,直到当前线程使用数据并释放读锁之后,线程T才能获取写锁进行数据更新。

2.2.4 公平锁和非公平锁

ReentrantReadWriteLock 支持公平模式和非公平模式(默认)获取锁。从性能上来看,非公平模式更好。

二者的规则如下:

  • 公平锁:无论是读线程还是写线程,在申请锁时都会检查是否有其他线程在同步队列中等待。如果有,则让步。

  • 非公平锁:如果是读线程,在申请锁时会判断是否有写线程在同步队列中等待。如果有,则让步。不过这是为了防止写线程饿死,与公平策略无关;如果是写线程,则直接竞争锁资源,不会关心有无别的线程正在等待

2.3 Sync 类

Sync 类是一个抽象类,有两个具体子类 NonfairSyncFairSync,分别对应非公平读写锁、公平读写锁。Sync 类的主要作用就是为这两个子类提供绝大部分的方法实现。

只定义了两个抽象方法 writerShouldBlockreaderShouldBlocker 交给两个子类去实现。

2.3.1 读写状态的设计

设计的精髓:用一个变量如何维护多种状态

在 ReentrantLock 中,使用 Sync(实际是 AQS)的 int 类型的 state 来表示同步状态,表示锁被一个线程重复获取的次数。但是,读写锁 ReentrantReadWriteLock 内部维护着一对读写锁,如果要用一个变量维护多种状态,需要采用 “按位切割使用” 的方式来维护这个变量,将其切分为两部分:高16为表示读,低16为表示写

abstract static class Sync extends AbstractQueuedSynchronizer {
    private static final long serialVersionUID = 6317671515068378041L;
	
    /*
     * 锁状态在逻辑上分为两个无符号短路,用一个 int 值(32 位)表示:
     *
     * 低 16 位表示 写锁的保持计数(包括重入)
     * 高 16 位表示 读锁的保持计数(包括重入)
     */
    static final int SHARED_SHIFT   = 16;
    // 每多获取一次读锁,state = state + SHARED_UNIT,相当于左移一位
    static final int SHARED_UNIT    = (1 << SHARED_SHIFT);
    // 写锁可重入的最大次数、读锁最大可获取次数(不等于获取读锁的线程数,一个线程可能获取多次)
    static final int MAX_COUNT      = (1 << SHARED_SHIFT) - 1;
    /*
     * 写锁的掩码,用于计算写锁重入次数时,将state的高16全部置为0, 这样的int值EXCLUSIVE_MASK 的高16位都是0,低16位都是1
     * 00000000 00000000 11111111 11111111
   	 */
    static final int EXCLUSIVE_MASK = (1 << SHARED_SHIFT) - 1;

    /** 获得持有读状态的锁的数量 */
    static int sharedCount(int c)    { return c >>> SHARED_SHIFT; }
    /** 获得持有写状态的锁的次数 */
    static int exclusiveCount(int c) { return c & EXCLUSIVE_MASK; }

	...
	...
}
  • exclusiveCount(int c) 静态方法:传入state的值c,通过与EXCLUSIVE_MASK做&操作,能快速得到低16位的值是多少,也就是写锁/排它锁的数量。

  • sharedCount(int c) 静态方法:传入的c是state值,通过无符号右移16位,快速得到读锁/共享锁的数量;不同于写锁,读锁可以同时被多个线程持有。而每个线程持有的读锁支持重入的特性,所以需要对每个线程持有的读锁的数量单独计数,这就需要用到 HoldCounter 计数器

state划分为读、写状态的示意图如下,其中读锁持有1个,写锁持有3个:

在这里插入图片描述

2.3.2 记录首个获得读锁的线程 - firstReader

private transient Thread firstReader;
private transient int firstReaderHoldCount;

firstReader 记录首个获得读锁的线程;firstReaderHoldCount 记录 firstReader 持有的读锁数

2.3.3 线程局部计数器 - HoldCounter

Sync 类定义了一个线程局部变量 readHolds,用于保存当前线程重入读锁的次数。如果该线程的读锁数减为 0,则将该变量从线程局部域中移除。相关源码如下:

// 内部类,用于记录当前线程重入读锁的次数
static final class HoldCounter {
    int count = 0;
    // 这里使用线程的id而非直接引用,是为了方便 GC
    final long tid = getThreadId(Thread.currentThread());
}
 
// ThreadLocalHoldCounter是ThreadLocal变量,用来存放不是第一个获取读锁的线程的其他线程的读锁重入数对象
static final class ThreadLocalHoldCounter
    extends ThreadLocal<HoldCounter> {
    public HoldCounter initialValue() {
        return new HoldCounter();
    }
}
 
private transient ThreadLocalHoldCounter readHolds;

由于 readHolds 变量是线程局部变量(继承 ThreadLocal 类),每个线程都会保存一份副本,不同线程调用其 get 方法返回的 HoldCounter 对象不同。

readHolds 中的 HoldCounter 变量保存了每个读线程的重入次数,即其持有的读锁数量。这么做的目的是便于线程释放读锁时进行合法性判断:线程在不持有读锁的情况下释放锁是不合法的,需要抛出 IllegalMonitorStateException 异常。

2.3.4 缓存 - cachedHoldCounter

Sync 类定义了一个 HoldCounter 变量 cachedHoldCounter,用于保存最近获取到读锁的线程的重入次数。源码如下:

private transient HoldCounter cachedHoldCounter;

设计该变量的目的是:将其作为一个缓存,加快代码执行速度。因为获取、释放读锁的线程往往都是最近获取读锁的那个线程,虽然每个线程的重入次数都会使用 readHolds 来保存,但使用 readHolds 变量会涉及到 ThreadLocal 内部的查找(lookup),这是存在一定开销的。有了 cachedHoldCounter 这个缓存后,就不用每次都在 ThreadLocal 内部查找,加快了代码执行速度,相当于用空间换时间。

2.3.5 获取锁

无论是公平锁还是非公平锁,它们获取锁的逻辑都是相同的,因此 Sync 类在这一层就提供了统一的实现

但是,获取写锁和获取读锁的逻辑不相同:

  • 写锁是互斥资源,获取写锁的逻辑主要在 tryAcquire 方法

  • 读锁是共享资源,获取读锁的逻辑主要在 tryAcquireShared 方法

2.3.6 释放锁

无论是公平锁还是非公平锁,它们释放锁的逻辑都是相同的,因此 Sync 类在这一层就提供了统一的实现

但是,释放写锁和释放读锁的逻辑不相同:

  • 写锁是互斥资源,释放写锁的逻辑主要在 tryRelease 方法

  • 读锁是共享资源,释放读锁的逻辑主要在 tryReleaseShared 方法

2.4 写锁是怎样获取和释放的?

2.5 读锁是怎样获取和释放的?

美团面试三连

面试官:了解锁吗?
小明:了解,还经常用过。
面试官:说说 synchronized 和 lock 的区别吧
小明:synchronized 是可重入锁,由于 lock 是一个接口,重入性取决于实现,synchronized 不支持中断,而 lock 可以…
面试官:好了,那有没有比这两种锁更快的锁呢?
小明:在读多写少的情况下,读写锁比他们的效率更高。
面试官:那有没有比读写锁更快的锁呢?
小明:…

3. StampLock

如果我们深入分析 ReentrantReadWriteLock,会发现它有个潜在的问题:如果有线程正在读,写线程需要等待读线程释放锁后才能获取写锁,即读的过程中不允许写,这是一种悲观的读锁。

为了进一步提升并发执行效率,Java 8 引入了新的读写锁:StampedLock

StampedLockReentrantReadWriteLock 相比,改进之处在于:读的过程中也允许获取写锁后写入。在原先读写锁的基础上新增了一种叫乐观读(Optimistic Reading)的模式。该模式并不会加锁,所以不会阻塞线程,会有更高的吞吐量和更高的性能。

它的设计初衷是作为一个内部工具类,用于开发其他线程安全的组件,提升系统性能,并且编程模型也比 ReentrantReadWriteLock 复杂,所以用不好就很容易出现死锁或者线程安全等莫名其妙的问题。

3.1 StampedLock 的使用

3.1.1 StampLock 三种访问模式

  • Writing(独占写锁):writeLock 方法会使线程阻塞等待独占访问,可类比ReentrantReadWriteLock 的写锁模式,同一时刻有且只有一个写线程获取锁资源;

  • Reading(悲观读锁):readLock 方法,允许多个线程同时获取悲观读锁,悲观读锁与独占写锁互斥,与乐观读共享。

  • Optimistic Reading(乐观读):这里需要注意了,乐观读并没有加锁,也就是不会有 CAS 机制并且没有阻塞线程。仅当当前未处于 Writing 模式 tryOptimisticRead 才会返回非 0 的邮戳(Stamp),如果在获取乐观读之后没有出现写模式线程获取锁,则在方法 validate 返回 true ,允许多个线程获取乐观读以及读锁,同时允许一个写线程获取写锁

在使用乐观读的时候一定要按照固定模板编写,否则很容易出 bug,我们总结下乐观读编程模型的模板:

public void optimisticRead() {
    // 1. 非阻塞乐观读模式获取版本信息
    long stamp = lock.tryOptimisticRead();
    // 2. 拷贝共享数据到线程本地栈中
    copyVaraibale2ThreadMemory();
    // 3. 校验乐观读模式读取的数据是否被修改过
    if (!lock.validate(stamp)) {
        // 3.1 校验未通过,上读锁
        stamp = lock.readLock();
        try {
            // 3.2 拷贝共享变量数据到局部变量
            copyVaraibale2ThreadMemory();
        } finally {
            // 释放读锁
            lock.unlockRead(stamp);
        }
    }
    // 3.3 校验通过,使用线程本地栈的数据进行逻辑操作
    useThreadMemoryVarables();
}

思考:为何 StampedLock 性比 ReentrantReadWriteLock 好?

关键在于 StampedLock 提供的乐观读。ReentrantReadWriteLock 支持多个线程同时获取读锁,但是当多个线程同时读的时候,所有的写线程都是阻塞的。StampedLock 的乐观读允许一个写线程获取写锁,所以不会导致所有写线程阻塞,也就是当读多写少的时候,写线程有机会获取写锁,减少了线程饥饿的问题,吞吐量大大提高

思考:允许多个乐观读和一个写线程同时进入临界资源操作,那读取的数据可能是错的怎么办?

乐观读不能保证读取到的数据是最新的,所以将数据读取到局部变量的时候需要通过 lock.validate(stamp) 校验是否被写线程修改过,若是修改过则需要上悲观读锁,再重新读取数据到局部变量。

3.1.2 演示乐观读

public class StampedLockTest{

    public static void main(String[] args) throws InterruptedException {
        Point point = new Point();

        //第一次移动x,y
        new Thread(()-> point.move(100,200)).start();
        Thread.sleep(100);
        new Thread(()-> point.distanceFromOrigin()).start();
        Thread.sleep(500);
        //第二次移动x,y
        new Thread(()-> point.move(300,400)).start();

    }
}

@Slf4j
class Point {
    private final StampedLock stampedLock = new StampedLock();

    private double x;
    private double y;

    public void move(double deltaX, double deltaY) {
        // 获取写锁
        long stamp = stampedLock.writeLock();
        log.debug("获取到writeLock");
        try {
            x += deltaX;
            y += deltaY;
        } finally {
            // 释放写锁
            stampedLock.unlockWrite(stamp);
            log.debug("释放writeLock");
        }
    }

    public double distanceFromOrigin() {
        // 获得一个乐观读锁
        long stamp = stampedLock.tryOptimisticRead();
        // 注意下面两行代码不是原子操作
        // 假设x,y = (100,200)
        // 此处已读取到x=100,但x,y可能被写线程修改为(300,400)
        double currentX = x;
        log.debug("第1次读,x:{},y:{},currentX:{}",
                x,y,currentX);
        try {
            Thread.sleep(2000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        // 此处已读取到y,如果没有写入,读取是正确的(100,200)
        // 如果有写入,读取是错误的(100,400)
        double currentY = y;
        log.debug("第2次读,x:{},y:{},currentX:{},currentY:{}",
                x,y,currentX,currentY);

        // 检查乐观读锁后是否有其他写锁发生
        if (!stampedLock.validate(stamp)) {
            // 获取一个悲观读锁
            stamp = stampedLock.readLock();
            try {
                currentX = x;
                currentY = y;

                log.debug("最终结果,x:{},y:{},currentX:{},currentY:{}",
                        x,y,currentX,currentY);
            } finally {
                // 释放悲观读锁
                stampedLock.unlockRead(stamp);
            }
        }
        return Math.sqrt(currentX * currentX + currentY * currentY);
    }

}

在这里插入图片描述

3.1.3 在缓存中的应用

将用户 id 与用户名数据保存在共享变量 idMap 中,并且提供 put 方法添加数据、get 方法获取数据、以及 getIfNotExist 先从 map 中获取数据,若没有则模拟从数据库查询数据并放到 map 中。

public class CacheStampedLock {
    /**
     * 共享变量数据
     */
    private final Map<Integer, String> idMap = new HashMap<>();
    private final StampedLock lock = new StampedLock();


    /**
     * 添加数据,独占模式
     */
    public void put(Integer key, String value) {
        long stamp = lock.writeLock();
        try {
            idMap.put(key, value);
        } finally {
            lock.unlockWrite(stamp);
        }
    }

    /**
     * 读取数据,只读方法
     */
    public String get(Integer key) {
        // 1. 尝试通过乐观读模式读取数据,非阻塞
        long stamp = lock.tryOptimisticRead();
        // 2. 读取数据到当前线程栈
        String currentValue = idMap.get(key);
        // 3. 校验是否被其他线程修改过,true 表示未修改,否则需要加悲观读锁
        if (!lock.validate(stamp)) {
            // 4. 上悲观读锁,并重新读取数据到当前线程局部变量
            stamp = lock.readLock();
            try {
                currentValue = idMap.get(key);
            } finally {
                lock.unlockRead(stamp);
            }
        }
        // 5. 若校验通过,则直接返回数据
        return currentValue;
    }

    /**
     * 如果数据不存在则从数据库读取添加到 map 中,锁升级运用
     * @param key
     * @return
     */
    public String getIfNotExist(Integer key) {
        // 获取读锁,也可以直接调用 get 方法使用乐观读
        long stamp = lock.readLock();
        String currentValue = idMap.get(key);
        // 缓存为空则尝试上写锁从数据库读取数据并写入缓存
        try {
            while (Objects.isNull(currentValue)) {
                // 尝试升级写锁
                long wl = lock.tryConvertToWriteLock(stamp);
                // 不为 0 升级写锁成功
                if (wl != 0L) {
                    stamp = wl;
                    // 模拟从数据库读取数据, 写入缓存中
                    currentValue = "query db";
                    idMap.put(key, currentValue);
                    break;
                } else {
                    // 升级失败,释放之前加的读锁并上写锁,通过循环再试
                    lock.unlockRead(stamp);
                    stamp = lock.writeLock();
                }
            }
        } finally {
            // 释放最后加的锁
            lock.unlock(stamp);
        }
        return currentValue;
    }

}

上面的使用例子中,需要引起注意的是 get() 和 getIfNotExist() 方法,第一个使用了乐观读,使得读写可以并发执行,第二个则是使用了读锁转换成写锁的编程模型,先查询缓存,当不存在的时候从数据库读取数据并添加到缓存中。

3.2 使用场景和注意事项

对于读多写少的高并发场景 StampedLock 的性能很好,通过乐观读模式很好的解决了写线程 “饥饿” 的问题,我们可以使用 StampedLock 来代替 ReentrantReadWriteLock ,但是需要注意的是 StampedLock 的功能仅仅是 ReadWriteLock 的子集,在使用的时候,还是有几个地方需要注意一下。

  • StampedLock 写锁是不可重入的,如果当前线程已经获取了写锁,再次重复获取的话就会死锁,使用过程中一定要注意;

  • 悲观读、写锁都不支持条件变量 Conditon ,当需要这个特性的时候需要注意;

  • 如果线程阻塞在 StampedLock 的 readLock() 或者 writeLock() 上时,此时调用该阻塞线程的 interrupt() 方法,会导致 CPU 飙升。所以,使用 StampedLock 一定不要调用中断操作,如果需要支持中断功能,一定使用可中断的悲观读锁 readLockInterruptibly() 和写锁 writeLockInterruptibly()。

以上为阅读笔记,具体见:
https://www.cnblogs.com/frankiedyz/p/15655865.html
https://www.cnblogs.com/coolyang/p/17106869.html

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/782245.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

springboot实现qq邮箱发送邮件或者验证码

首先我先去qq邮箱或者网易邮箱开通POP3/IMAP/SMTP/Exchange/CardDAV 服务 它在左上角的设置——账户——往下滑就可以找到——然后点击开通 开通后就会得到一串授权码。如下图 接下来直接编写代码 首先我没导入依赖 <!-- 这个是邮箱验证--> <dependency> <group…

IDEA+java+spring+hibernate+jquery+mysql后台管理系统

IDEA javaspringhibernatejquerymysql后台管理系统 一、系统介绍1.环境配置 二、系统展示1. 管理员登录2.修改密码3.我的内容4.我的操作日志5. 我的登陆日志6. 我的登陆授权7. 内容管理8.标签管理9. 搜索词管理10.分类管理11.分类类型12.标签分类13.用户管理14.部门管理15.角色…

elasticsearch在统计存储用量方面的应用

存储介绍 对象存储在现在的项目中应用非常广泛&#xff0c;主要用来存储图片、视频、音频、文件等静态资源&#xff0c;所有云服务厂商基本上都有对象存储&#xff0c;对象存储收费一般 按每月每GB收费&#xff0c;如七牛的0.098 元/GB/月&#xff0c;阿里的0.12元/GB/月。比如…

k8s网络之(一)如何调试容器网络 nsenter

① 背景 ② 三种解决策略 最常见&#xff1a; 镜像为了保持精简化,没有安装网络命令,缺少必要的基础网络软件 ③ pod_id和container_id关系 细节&#xff1a; 一个pod内可以有多个容器,意味者可以有多个容器id说明&#xff1a; docker ps 默认容器id只有前12位案例&#x…

如何理解自动化

目录 1.如何定义自动化 2.自动化给人类带来的福利 3.如何学习自动化 4.自动化潜在的危害 1.如何定义自动化 自动化是指利用计算机、机械、电子技术和控制系统等现代科学技术手段&#xff0c;对各种工业、商业、农业和日常生活中的操作和过程进行自动控制和执行的过程。它旨在…

《博客继续更新说明》

目录 1.博客停更原因2.考研和比赛3.工作和博客题外话 1.博客停更原因 自2022.6.20起&#xff0c;本人CSDN博客断更了&#xff0c;故很多粉丝的私信没有看到&#xff0c;非常抱歉哈&#xff0c;简单叙说下这一年经历&#xff0c;当时大三后期原本想法是直接找嵌入式实习的&#…

怎样用IDEA社区版以及企业版创建spring boot项目?

Spring 的诞生是为了简化 Java 程序的开发的&#xff0c;而 Spring Boot 的诞生是为了简化 Spring 程序开发的 。 Spring Boot 翻译一下就是 Spring 脚手架&#xff0c;什么是脚手架呢&#xff1f;如下图所示&#xff1a; 盖房子的这个架子就是脚手架&#xff0c;脚手架…

脑电信号处理与特征提取——三. 脑电实验设计的原理与实例(古若雷)

三、脑电实验设计的原理与实例 被试间设计的实验结果也有可能是人员不同造成的&#xff0c;所以建议被试内设计。

【力扣每日一题】2023.7.22 柠檬水找零

目录 题目&#xff1a; 示例&#xff1a; 分析&#xff1a; 代码运行结果&#xff1a; 题目&#xff1a; 示例&#xff1a; 分析&#xff1a; 这是一道模拟题&#xff0c;我们贩卖五美元&#xff08;忒贵了&#xff09;一杯的柠檬水&#xff0c;然后只收取5,10,20面值的钞票…

API自动化测试总结

目录 Jmeter是怎么做API自动化测试的&#xff1f; Jmeter中动态参数的处理&#xff1f; 怎么判断前端问题还是后端问题&#xff1f; 详细描述下使用postman是怎么做API的测试的&#xff1f; 资料获取方法 Jmeter是怎么做API自动化测试的&#xff1f; 1、首先在JMeter里面…

57. 插入区间

题目链接&#xff1a;&#xff1a;力扣 解题思路&#xff0c;因为只插入一个新的区间&#xff0c;所以只需要找到插入的新区见的位置即可&#xff0c;如果与来的区间重叠&#xff0c;则进行合并&#xff0c; 两个区间重叠需要判断的情况比较多&#xff0c;可以判断两个区间不重…

已解决-使用Adobe Acrobat去除PDF水印

这个方法只能去带颜色的水印&#xff0c;灰色的去不了&#xff0c;等我有空时候&#xff0c;再写一篇使用PS去除水印的方法&#xff0c;也非常简单。敬请期待 安装软件 网上下载安装Adobe Acrobat Pro DC 安装Adobe PDF打印机 点击“添加打印机” 选择“我的打印机未列出”…

VCL界面组件DevExpress VCL v23.1 - 全新的Windows 11主题

DevExpress VCL是Devexpress公司旗下最老牌的用户界面套包&#xff0c;所包含的控件有&#xff1a;数据录入、图表、数据分析、导航、布局等。该控件能帮助您创建优异的用户体验&#xff0c;提供高影响力的业务解决方案&#xff0c;并利用您现有的VCL技能为未来构建下一代应用程…

RT-Thread qemu mps2-an385 bsp 移植制作 :环境搭建篇

前言 最近打算系统地研究一下 RT-Thread&#xff0c;包括 RT-Thread BSP 的移植&#xff0c;由于一直在使用 QEMU 进行一些软件功能的验证&#xff0c;qemu 支持很多的CPU 与 开发板&#xff0c;所以想移植一个新的 qemu board 到 RT-Thread&#xff0c;掌握BSP 移植的方法 当…

pytest配置文件:pytest.ini

1. 开始 pytest配置文件可以改变pytest的默认运行方式&#xff0c;它是一个固定的文件名称pytest.ini。 pytest.ini存放路径为项目的根路径。 2. 使用addopts-追加默认参数 我们每次在命令行中执行pytest命令时&#xff0c;如果参数是固定的&#xff0c;可以在pytest.ini进…

OpenCV:图像直方图计算

图像直方图为图像中像素强度的分布提供了有价值的见解。通过了解直方图&#xff0c;你可以获得有关图像对比度、亮度和整体色调分布的信息。这些知识对于图像增强、图像分割和特征提取等任务非常有用。 本文旨在为学习如何使用 OpenCV 执行图像直方图计算提供清晰且全面的指南。…

【Java基础教程】(四十三)多线程篇 · 下:深入剖析Java多线程编程:同步、死锁及经典案例——生产者与消费者,探究sleep()与wait()的差异

Java基础教程之多线程 下 &#x1f539;本节学习目标1️⃣ 线程的同步与死锁1.1 同步问题的引出2.2 synchronized 同步操作2.3 死锁 2️⃣ 多线程经典案例——生产者与消费者&#x1f50d;分析sleep()和wait()的区别&#xff1f; &#x1f33e; 总结 &#x1f539;本节学习目标…

SpringBoot Redis 配置多数据源

Redis 从入门到精通【应用篇】之SpringBoot Redis 配置多数据源 文章目录 Redis 从入门到精通【应用篇】之SpringBoot Redis 配置多数据源1.教程0. 添加依赖1. 配置多个 Redis 连接信息我们将上面的配置改造一下&#xff0c;支持Redis多数据源 2. 配置3. 创建 RedisTemplate 实…

Compose中常用的一些Modifier的扩展ui方法记录

Compose中常用的一些Modifier的扩展ui方法记录 关于防快速点击虚实分割线虚线边框阴影 关于 本篇主要记录一些开发中可能用到的常用方法的扩展记录&#xff0c;包括防快速带点击&#xff0c;画虚实线divider&#xff0c;画虚线边框&#xff0c;绘制阴影等。 防快速点击 inlin…

每天五分钟机器学习:线性回归和非线性回归之间的区别?

本文重点 在前面的课程中,我们学习了单变量线性回归模型以及多变量的线性回归模型,无论是单变量线性回归还是多变量线性回归,这二者都是一样的,都是线性的。本文我们将学习一下线性回归模型和非线性回归之间的区别和联系。 关于模型的基本区别 线性回归:线性回归就是每…