图解java.util.concurrent并发包源码系列——深入理解ReentrantLock,看完可以吊打面试官

news2024/9/21 22:39:07

图解java.util.concurrent并发包源码系列——深入理解ReentrantLock,看完可以吊打面试官

  • ReentrantLock是什么,有什么作用
  • ReentrantLock的使用
  • ReentrantLock源码解析
    • ReentrantLock#lock方法
      • FairSync#tryAcquire方法
      • NonfairSync#tryAcquire方法
    • ReentrantLock#unlock方法
  • ReentrantLock的其他方法简介
    • ReentrantLock#lockInterruptibly
    • ReentrantLock#tryLock
    • ReentrantLock#tryLock(带参数)
    • ReentrantLock#newCondition

往期文章:

  • 人人都能看懂的图解java.util.concurrent并发包源码系列 ThreadPoolExecutor线程池
  • 图解java.util.concurrent并发包源码系列,原子类、CAS、AtomicLong、AtomicStampedReference一套带走
  • 图解java.util.concurrent并发包源码系列——LongAdder
  • 图解java.util.concurrent并发包源码系列——深入理解AQS,看完可以吊打面试官

上一篇文章(图解java.util.concurrent并发包源码系列——深入理解AQS,看完可以吊打面试官 )介绍了AQS的原理和源码,然后使用AQS实现了一个排他锁,这次我们看看Java的并发包里面基于AQS实现的锁工具类——ReentrantLock。本文会介绍ReentrantLock的功能,以及ReentrantLock如何通过AQS实现它的功能,但是不会再介绍AQS的相关原理,关于AQS的原理和源码,可以看上一篇文章。

ReentrantLock是什么,有什么作用

ReentrantLock是可重入锁,当我们有一些资源需要互斥访问,或者有某一块代码需要互斥执行时,可以使用ReentrantLock加锁,对互斥资源和互斥代码块起到一个保护作用,保证同一时刻只会有一个线程访问。

在这里插入图片描述

可重入的意思就是当一个线程获取到锁之后,如果它再次获取同一把锁,是可以获取成功的。而不可重入锁则是当前线程获取到锁以后,如果它再次获取,是获取不成功的,也就是自己把自己给阻塞住了,我们上一篇文章最后面的例子就是一个不可重入锁。

在这里插入图片描述

ReentrantLock的使用

ReentrantLock的用法非常简单,它提供了lock方法用于获取锁,unlock方法用于释放锁。

调用lock方法后,当前线程会去获取锁,获取不到锁会阻塞等待,直到获取到锁,该方法才会返回。如果重复调用多次lock方法,就重复加锁多次,当前线程不会阻塞(可重入),但是它需要多次释放锁。

调用unlock方法,当前线程就会释放锁,然后唤醒其他阻塞等待获取锁的线程。

我们还是用上一篇文章最后的那个例子,去观察ReentrantLock的作用。

    public static void main(String[] args) throws InterruptedException {
        int[] num = {0};
        // 创建一个ReentrantLock对象
        ReentrantLock reentrantLock = new ReentrantLock();
        Thread[] threads = new Thread[100];
        for (int i = 0; i < 100; i++) {
            Thread thread = new Thread(() -> {
                for (int j = 0; j < 10000; j++) {
                    try {
                        // 加锁
                        reentrantLock.lock();
                        num[0]++;
                    } finally {
                        // 释放锁,防止finally块中是因为防止抛异常导致锁得不到释放
                        reentrantLock.unlock();
                    }
                }
            });
            thread.start();
            threads[i] = thread;
        }
        for (int i = 0; i < threads.length; i++) {
            threads[i].join();
        }
        System.out.println(num[0]);
    }

在这里插入图片描述

可以看到输出结果是正确的。

用法跟我们上一篇文章最后面的例子几乎是一模一样的。首先要创建一个ReentrantLock对象,然在线程try代码块中调用ReentrantLock的lock方法进行加锁,在finally代码块中调用ReentrantLock的unlock方法进行解锁操作,解锁操作在finally块中是因为防止线程抛异常导致锁得不到释放。

ReentrantLock源码解析

接下来看看ReentrantLock的lock方法和unlock方法的内部源码,看看它是怎么实现锁的获取和释放的。

ReentrantLock#lock方法

ReentrantLock#lock

    public void lock() {
        sync.lock();
    }

可以看到和我们上一篇文章的例子一样,都是有一个内部类,这个内部类肯定继承了AQS。但是它这里没有直接调用AQS的acquire方法,而是调用了一个lock方法,我们进去lock方法看一看。

abstract static class Sync extends AbstractQueuedSynchronizer {
        private static final long serialVersionUID = -5179523762034025860L;

        abstract void lock();

		// ...省略了下面的代码
}

可以看到Sync继承了AQS,但是Sync本身还是一个抽象类,而lock方法也是一个抽象方法。

在这里插入图片描述

可以看到Sync有两个实现类,FairSync和NonfairSync。

在这里插入图片描述

也就是说ReentrantLock内部的sync有可能是FairSync,也有可能是NonfairSync。那什么时候是FairSync、什么时候是NonFairSync呢?我们看看ReentrantLock的构造方法就知道了。

    /**
     * 默认非公平锁
     */
    public ReentrantLock() {
        sync = new NonfairSync();
    }

    /**
     * fair为true时,是公平锁,为false则是非公平锁
     */
    public ReentrantLock(boolean fair) {
        sync = fair ? new FairSync() : new NonfairSync();
    }

ReentrantLock可以实现公平锁和非公平锁的功能,默认是非公平锁,如果我们调用有参构造方法,传递的fair参数是true就是公平锁,使用的sync就是FairSync,否则就是非公平锁,使用的就是NonfairSync。

在这里插入图片描述

公平锁就是当一个线程获取锁时,会先看一下队列中是否有线程等待获取锁,如果有,那么它会入队列。非公平锁则相反,如果一个线程要获取锁,那么不管三七二十一,先尝试获取一下,如果获取成功,则不用进队列。非公平锁的效率是比公平锁高的,所有默认的就是非公平锁。

我看一下lock方法的具体实现。

    static final class FairSync extends Sync {
        private static final long serialVersionUID = -3000897897090466540L;

        final void lock() {
            acquire(1);
        }

		// ......省略下面的代码
	}

FairSync的lock方法直接调用了AQS的acquire方法,与我们上一篇文章的例子一致。

    static final class NonfairSync extends Sync {
        private static final long serialVersionUID = 7316153563782823691L;

        final void lock() {
            if (compareAndSetState(0, 1))
                setExclusiveOwnerThread(Thread.currentThread());
            else
                acquire(1);
        }
		// ......省略下面的代码
	}

NonfairSync的lock方法,会使用AQS的compareAndSetState方法尝试获取锁,如果CAS成功那么就不需要走acquire方法的逻辑了,如果CAS失败,才会调用AQS的acquire方法。

在这里插入图片描述

FairSync#tryAcquire方法

FairSync的lock方法会调用AQS的acquire方法,acquire方法会调用tryAcquire方法,然后会进入到FairSync的tryAcquire方法。

        protected final boolean tryAcquire(int acquires) {
            final Thread current = Thread.currentThread();
            int c = getState(); // 获取AQS中的state变量
            if (c == 0) { // state等于0表示锁没有被获取
                if (!hasQueuedPredecessors() && // 查看队列中是否有等待获取锁的线程
                    compareAndSetState(0, acquires)) { // CAS尝试获取锁
                    setExclusiveOwnerThread(current); // 获取锁成功,设置当前线程为占有锁的线程
                    return true; // 返回true,表示告诉AQS获取锁成功
                }
            }
            else if (current == getExclusiveOwnerThread()) { // 锁已被获取,但是获取锁的线程是当前线程
                int nextc = c + acquires; // state加acquires
                if (nextc < 0)
                    throw new Error("Maximum lock count exceeded");
                setState(nextc); // 设置state的值
                return true; // 返回true,表示告诉AQS获取锁成功
            }
            return false; // 返回true,表示告诉AQS获取锁不成功
        }
    }

FairSync的tryAcquire方法首先会调用AQS的getState方法获取state变量,这里是当state为0才表示锁没有被获取,与我们上一篇文章的例子不一样。

如果state为0,那么锁没有被获取,此时会调用hasQueuedPredecessors()方法检查队列中是否有等待获取锁的线程,如果有,那么当前线程不会获取锁,而是返回false直接进队列排队,如果队列中没有等待获取锁的线程,那么会调用AQS的compareAndSetState方法尝试修改state变量,修改成功表示获取锁成功,此时就会设置当前线程为占有锁的线程,然后方法返回true,表示获取锁成功。

如果state不为0,那么会检查当前线程是否是占有锁的线程,如果是,那么会执行重入的逻辑,然后方法返回true。如果当前线程不是占有锁的线程,那么方法返回false,表示获取锁失败。

在这里插入图片描述

下面看一下hasQueuedPredecessors方法是怎么判断队列中是否有线程的。

    public final boolean hasQueuedPredecessors() {
        Node t = tail; // 尾节点
        Node h = head; // 头节点
        Node s;
        return h != t &&
            ((s = h.next) == null || s.thread != Thread.currentThread());
    }

h != t 判断头节点和尾节点是否不相等,不相等才有可能有线程在队列中,相等的话表示队列为空,那么肯定是没有线程在队列中的。

如果头节点和尾节点不相等了,那么看h.next头节点的后继节点是否为空,如果为空,那么就表示当前队列已经有等待获取锁的线程。那么为什么头尾节点不相等,并且头节点的next指针为空,就表示有线程已经入队列了呢?还有,什么情况下头节点指针和尾节点指针不相等,但是头节点的next指针为null呢?答案就在AQS初始化队列以及线程节点入队的方法中,也就是AQS的enq方法。

    private Node enq(final Node node) {
        for (;;) {
            Node t = tail;
            if (t == null) { // Must initialize
                if (compareAndSetHead(new Node())) // A:使用CAS的方式设置头节点指针指向一个空节点
                    tail = head; // B:设置尾指针指向和头指针指向的同一节点
            } else {
                node.prev = t; // C:设置node的prev指针指向当前队列的尾节点
                if (compareAndSetTail(t, node)) { // D:使用CAS的方式设置尾节点指针指向当前node
                    t.next = node; // E:设置原来的尾节点的next指针指向当前node
                    return t;
                }
            }
        }
    }

可以看到AQS的enq方法,分成了5个步骤。注意!这里的前提是AQS的队列还未初始化!

假设现在AQS队列还未初始化,在当前线程获取锁之前,有一个线程执行到了enq方法,然后这个线程执行完A、B、C、D四步,也就是它成功初始化了AQS的队列,然后又用CAS的方式修改尾节点的指针指向它自己的node节点,但是就是没有执行代码E,此时是不是头节点指针和尾节点指针不相等(h != t),并且头节点的next指针是null((s = h.next) == null)?此时这个线程已经算是入了队列的,如果此时有其他线程来获取锁(公平锁),那么是应该要排队的。所以当前线程获取锁的时候,执行到hasQueuedPredecessors()方法,就会返回true,表示已经有线程在队列中等待获取锁了。

那么假设现在AQS队列还未初始化,如果在当前线程获取锁之前,有一个线程执行到了enq方法,然这个线程执行完A、B,没有执行后面三步呢?那么此时头节点指针和尾节点指针还是相等的(h != t 不成立),此时这个线程也不算入队列,所以此时如果有别的线程来调用hasQueuedPredecessors()方法,hasQueuedPredecessors()方法放回false(队列中没有等待获取锁的线程),也是正确的。

如果头节点指针和尾节点指针不相等(h != t 为true),然后头节点的next指针不为null((s = h.next) == null 为false),但是头节点的next指针指向的节点对应的线程等于当前线程(s.thread != Thread.currentThread() 为false),那么hasQueuedPredecessors()方法返回false,当前线程可以尝试获取锁,也是正确的,因为很明显这种情况就是我已经在排队了,并且此时轮到我了。如果头节点的next指针指向的节点对应的线程不是当前线程(s.thread != Thread.currentThread() 为true),hasQueuedPredecessors()方法返回true,那么当前线程就只能去排队。

在这里插入图片描述

FairSync的tryAcquire方法就介绍到这里。

在这里插入图片描述

NonfairSync#tryAcquire方法

NonfairSync的tryAcquire方法里面直接调用了nonfairTryAcquire方法。

        protected final boolean tryAcquire(int acquires) {
            return nonfairTryAcquire(acquires);
        }

nonfairTryAcquire方法位于Sync抽象类内部。

        final boolean nonfairTryAcquire(int acquires) {
            final Thread current = Thread.currentThread();
            int c = getState();
            if (c == 0) {
            	// 锁没有被获取,尝试获取锁
                if (compareAndSetState(0, acquires)) {
                    setExclusiveOwnerThread(current);
                    return true;
                }
            }
            // 锁已被获取,查看是否是当前线程获取的,如果是,那么执行重入逻辑
            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;
        }

进入到Sync的nonfairTryAcquire方法内部,如果判断锁没有被获取(state == 0)就直接先CAS一下,如果CAS成功,该方法就返回true,表示获取锁成功。如果锁已被获取,那么判断当前线程是否是持有锁的线程 (current == getExclusiveOwnerThread()),如果当前线程是持有锁的线程,那么执行锁重入的逻辑,然后方法返回true,表示获取锁成功。否则返回false,表示获取锁失败。

在这里插入图片描述

ReentrantLock的lock方法到这里就介绍完了。

在这里插入图片描述

ReentrantLock#unlock方法

接下来看一下ReentrantLock的unlock方法的具体逻辑。

    public void unlock() {
        sync.release(1);
    }

ReentrantLock的unlock方法调用了sync的release方法,这个和我们上一篇文章的例子是一样的。

AQS的release方法会调用tryRelease方法。因为释放锁的操作不需要区分公平还是非公平,都是一样的释放锁的逻辑,所以tryRelease方法就放到了Sync抽象类的内部。也就是说,FairSync和NonfairSync的tryRelease是同一个方法,都是Sync的tryRelease方法。

        protected final boolean tryRelease(int releases) {
            int c = getState() - releases;
            // 如果当前线程不是持有锁的线程,抛异常
            if (Thread.currentThread() != getExclusiveOwnerThread())
                throw new IllegalMonitorStateException();
            boolean free = false;
            // c == 0,表示锁被释放,free设为true表示锁被释放,设置持有锁的线程为null
            if (c == 0) {
                free = true;
                setExclusiveOwnerThread(null);
            }
            // 更新state
            setState(c);
            return free;
        }

Sync的tryRelease方法首先会调用AQS的getState方法获取state变量,然后计算state变量减去参数releases之后的值c。然后判断如果c等于0,表示锁被释放了,那么设置返回值free为true表示锁被释放,然后setExclusiveOwnerThread(null)设置持有锁的线程为null。然后不管c是否等于0,最后都会调用AQS的setState方法,更新state变量,最后返回free。

如果返回的free为true,表示锁被释放了,如果返回的free为false,表示锁还没有被释放。比如当前线程重复获取锁5次,现在调用了unlock方法3次,那么state等于2,锁还没有被释放,它需要再调用两次,state才等于0,此时锁才被真正释放。

在这里插入图片描述

到这里ReentrantLock的unlock方法也介绍完了。

ReentrantLock的其他方法简介

ReentrantLock不仅仅只有lock和unlock两个方法,还有其他获取和释放锁的方法,下面做一个简单介绍,不做深入的描述。

ReentrantLock#lockInterruptibly

    public void lockInterruptibly() throws InterruptedException {
        sync.acquireInterruptibly(1);
    }

ReentrantLock的lockInterruptibly是以响应中断的方式获取锁,就是说在获取锁的过程中如果其他线程调用了thread.interrupt()方法打断了当前线程,那么当前线程会响应中断,不再继续获取锁。而我们上面介绍的ReentrantLock#lock方法,是不响应中断的,也就是说其他线程调用了当前线程的thread.interrupt()方法,当前线程也不做任何响应。

ReentrantLock#tryLock

    public boolean tryLock() {
        return sync.nonfairTryAcquire(1);
    }

ReentrantLock的tryLock方法是尝试获取锁,也就是尝试获取一下,成就成,不成就走,也不会阻塞。

ReentrantLock#tryLock(带参数)

    public boolean tryLock(long timeout, TimeUnit unit)
            throws InterruptedException {
        return sync.tryAcquireNanos(1, unit.toNanos(timeout));
    }

ReentrantLock还有一个带参数的tryLock方法 boolean tryLock(long timeout, TimeUnit unit),该方法和无参的tryLock方法一样,也是尝试获取锁,成就成,不成不会马上走,而是阻塞等待一段时间。timeout参数就是指定阻塞等待的时间,unit则是时间单位。

ReentrantLock#newCondition

    public Condition newCondition() {
        return sync.newCondition();
    }

ReentrantLock的newCondition可以返回一个Condition对象,Condition 可以实现类似于和synchronized加Object#wait的功能,这个我们后面的文章再介绍吧。

在这里插入图片描述

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

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

相关文章

SpringBoot笔记:SpringBoot 集成 Dataway

文章目录 1、什么是 Dataway?2、主打场景3、技术架构4、整合SpringBoot4.1、maven 依赖4.2、初始化脚本4.3、整合 SpringBoot 5、Dataway 接口管理6、Mybatis 语法支持7、小结 1、什么是 Dataway? 官网地址&#xff1a;https://www.hasor.net/docs/guides/quickstart Da…

连通块是什么

刷题的时候遇到一个名词概念&#xff0c;连通块是什么&#xff1f; 在图论中&#xff0c;无向图中的连通块&#xff08;也叫作连通分量&#xff09;是指原图的一个子图&#xff08;即该子图只包含原图中的部分或全部顶点及边&#xff09;&#xff0c;该子图任意两个顶点都能通…

Swift 环境搭建

Swift是一门开源的编程语言&#xff0c;该语言用于开发OS X和iOS应用程序。 在正式开发应用程序前&#xff0c;我们需要搭建Swift开发环境&#xff0c;以便更好友好的使用各种开发工具和语言进行快速应用开发。由于Swift开发环境需要在OS X系统中运行&#xff0c;因此其环境的…

智能指针shared_ptr:自定义删除器

重点&#xff1a; 1.普通指针转化成智能指针。 2.智能指针创建的时候&#xff0c;第二个参数是自定义删除器&#xff0c;默认情况下&#xff0c;shared_ptr调用delete()函数。 class A { public:void Get() { cout << b << endl; }; private:int b{ 10 }; };clas…

Java 与其他编程语言:比较分析

Java 擅长可移植性和可靠性&#xff0c;Python 擅长通用性和简单性&#xff0c;JavaScript 擅长 Web 开发&#xff0c;C 擅长性能&#xff0c;Go 擅长效率。 在广阔的软件开发世界中&#xff0c;选择正确的编程语言对于任何项目的成功都至关重要。Java 是一种以其多功能性和可移…

交换机Vlan实验

介绍 Vlan表示虚拟局域网。 常见的网络安全技术 VlanACL Vlan的作用 Vlan隔离了广播域&#xff0c;增加了网络的安全性。 知识点 默认vlan vlan1 是默认vlan&#xff0c;主要机器开机了&#xff0c;默认所有的接口都属于Vlan1 交换机的接口模式 Access : 这个模式用来…

RK3588平台开发系列讲解(文件系统篇)什么是 VFS

文章目录 一、什么是 VFS二、VFS 数据结构2.1、超级块结构2.2、目录结构2.3、文件索引结点2.4、打开的文件2.5、四大对象结构的关系沉淀、分享、成长,让自己和他人都能有所收获!😄 📢 今天我们一起来瞧一瞧 Linux 是如何管理文件,也验证一下 Linux 那句口号:一切皆为文…

卡尔曼滤波 | Matlab实现无迹kalman滤波仿真

文章目录 效果一览文章概述研究内容程序设计参考资料效果一览 文章概述 卡尔曼滤波 | Matlab实现无迹kalman滤波仿真 研究内容 无迹kalman滤波(UKF)不是采用的将非线性函数线性化的做法。无迹kalman仍然采用的是线性kalman滤波的架构,对于一步预测方程,使用无迹变换(UT)来…

Baumer工业相机堡盟工业相机如何通过BGAPISDK获取相机接口数据吞吐量(C++)

Baumer工业相机堡盟工业相机如何通过BGAPISDK里函数来获取相机当前数据吞吐量&#xff08;C&#xff09; Baumer工业相机Baumer工业相机的数据吞吐量的技术背景CameraExplorer如何查看相机吞吐量信息在BGAPI SDK里通过函数获取相机接口吞吐量 Baumer工业相机通过BGAPI SDK获取数…

【技能实训】DMS数据挖掘项目(完整程序)

文章目录 1. 系统需求分析1.1 需求概述1.2 需求说明 2. 系统总体设计2.1 编写目的2.2 总体设计2.2.1 功能划分2.2.2 数据库及表2.2.3 主要业务流程 3. 详细设计与实现3.1 表设计3.2 数据库访问工具类设计3.3 配置文件3.4 实体类及设计3.5 业务类及设计3.6 异常处理3.7 界面设计…

优雅记录与保留:探秘Spring Boot与Logback的高级日志输出与存储

&#x1f60a; 作者&#xff1a; 一恍过去 &#x1f496; 主页&#xff1a; https://blog.csdn.net/zhuocailing3390 &#x1f38a; 社区&#xff1a; Java技术栈交流 &#x1f389; 主题&#xff1a; 优雅记录与保留&#xff1a;探秘Spring Boot与Logback的高级日志输出与…

Java中运算符要注意的一些点

目录 1. 算术运算符 1. 1 基本四则运算符&#xff1a;加减乘除模( - * / %) 1.2. 增量运算符 - * % 2. 关系运算符 3. 逻辑运算符 3.1. 逻辑与 && 3.2. 逻辑 || 3.3. 逻辑非 ! 3.4. 短路求值 4. 位运算符 4.1. 按位与 &: 如果两个二进制位都是 …

WebGL: 几个入门小例子

本文罗列几个WebGL入门例子&#xff0c;用于帮助WebGL学习。 一、概述 WebGL (Web Graphics Library)是一组基于Open ES、在Web内渲染3D图形的Javascript APIs。 Ref. from Khronos Group: WebGL WebGL™ is a cross-platform, royalty-free open web standard for a low-lev…

代码随想录算法训练营之JAVA|第二十一天| 77. 组合

今天是第21天刷leetcode&#xff0c;立个flag&#xff0c;打卡60天。 算法挑战链接 77. 组合https://leetcode.cn/problems/combinations/description/ 第一想法 需要从N个数中选取K个数&#xff0c;那么第一想法肯定是k个for循环&#xff0c;每个for循环选取一个数&#x…

K8S系列文章之 kubeasz部署K8S环境

自动化安装方式&#xff08;kubeasz&#xff09;* 生产环境推荐&#xff08;首次安装下载相关配置和安装包&#xff09;是基于Ansible实现的部署工具 简单介绍 每一具体k8s集群的详细配置参数文件 Ansible 任务配置文件 镜像安装包 安装部署步骤 前提 &#xff1a; 保证Ansib…

4个顶级的支持消费级硬件的NeRF软件平台

似乎每天都有大量的创新发布&#xff0c;人们很容易感到不知所措。因此&#xff0c;让我们放慢脚步&#xff0c;看看4个主流的支持消费级硬件的NeRF 平台。 推荐&#xff1a;用 NSDT设计器 快速搭建可编程3D场景。 1、Instant-NGP&#xff08;Instant-NeRF&#xff09; 2022 年…

使用nativephp开发桌面应用测试

2023年7月21日10:29:03 官网&#xff1a;https://nativephp.com/ 源码&#xff1a;https://github.com/NativePHP/laravel 看起像laravel团队的作品 安装&#xff1a;注意需要php8.1以上&#xff0c;laravel10以上 composer create-project laravel/laravel example-app或者&am…

SpringBoot集成Redisson实现延迟队列

一、场景 1、下单未支付&#xff0c;超过10分钟取消订单 2、货到后7天未评价&#xff0c;自动好评 二、实现方案 1、使用xxl-job 定时任务按时检测&#xff0c;实时性不高 2、使用RabitMQ的插件rabbitmq_delayed_message_exchange插件 3、 redis的过期检测 redis.conf 中…

【雕爷学编程】Arduino动手做(186)---WeMos ESP32开发板7

37款传感器与模块的提法&#xff0c;在网络上广泛流传&#xff0c;其实Arduino能够兼容的传感器模块肯定是不止37种的。鉴于本人手头积累了一些传感器和执行器模块&#xff0c;依照实践出真知&#xff08;一定要动手做&#xff09;的理念&#xff0c;以学习和交流为目的&#x…

Vue如何定义router-link的颜色样式

今天在使用Vue的时候碰到了想改样式却改不了的情况 <ul> <li>xxxxx</li> <li><router-link to"/wisdom" active-class"ative">xxxxx</router-link></li> <li><router-link to"/MyLogin" act…