聊聊并发编程——多线程之AQS
目录
队列同步器(AQS)
独占锁示例
AQS之同步队列结构
解析AQS实现
队列同步器(AQS)
队列同步器AbstractQueuedSynchronizer(以下简称同步器),是用来构建锁或者其他同步组 件的基础框架,它使用了一个int成员变量表示同步状态,通过内置的FIFO队列来完成资源获 取线程的排队工作,并发包的作者(Doug Lea)期望它能够成为实现大部分同步需求的基础。
-
同步器的主要使用方式是继承,子类通过继承同步器并实现它的抽象方法来管理同步状态。
-
使用同步器提供的3 个方法(getState()、setState(int newState)和compareAndSetState(int expect,int update))对同步状态进行更改,因为它们能够保证状态的改变是安全的。
同步器是实现锁(也可以是任意同步组件)的关键,在锁的实现中聚合同步器,利用同步器实现锁的语义。可以这样理解二者之间的关系:锁是面向使用者的,它定义了使用者与锁交 互的接口(比如可以允许两个线程并行访问),隐藏了实现细节;同步器面向的是锁的实现者, 它简化了锁的实现方式,屏蔽了同步状态管理、线程的排队、等待与唤醒等底层操作。锁和同步器很好地隔离了使用者和实现者所需关注的领域。
独占锁示例
Lock接口的实现基本都是通过聚合了一个同步器的子类来完成线程访问控制的,通过独占锁了解下队列同步器(AQS)。
public class Mutex implements Lock { // 子类推荐被定义为自定义同步组件的静态内部类// 同步器自身没有实现任何同步接口,它仅仅是定义了若干同步状态获取和释放的方法来供自定义同步组件使用private static class Sync extends AbstractQueuedSynchronizer {// 是否处于独占状态protected boolean isHeldExclusively() {return getState() == 1;}// 当状态为0的时候获取锁public boolean tryAcquire(int acquire) {if (compareAndSetState(0, 1)) {return true;}return false;}// 释放锁,将状态设置为0protected boolean tryRelease(int release) {if (getState() == 0) {throw new IllegalMonitorStateException();}setExclusiveOwnerThread(null);setState(0);return true;}// 返回一个Condition,每一个condition都包含了一个condition队列Condition newCondition() {return new ConditionObject();}} // 通过Sync进行代理操作,实现Lock接口的APIprivate final Sync sync = new Sync(); // 获取锁@Overridepublic void lock() {sync.acquire(1);} // 可中断地获取锁@Overridepublic void lockInterruptibly() throws InterruptedException {sync.acquireInterruptibly(1);} // 尝试非阻塞的获取锁@Overridepublic boolean tryLock() {return sync.tryAcquire(1);} /*超时的获取锁,当前线程在以下3种情况下会返回:1.当线程在超时时间获得了锁2.当线程在超时时间被中断3.超时时间结束,返回false*/@Overridepublic boolean tryLock(long time, TimeUnit unit) throws InterruptedException {return sync.tryAcquireNanos(1, unit.toNanos(time));} // 释放锁@Overridepublic void unlock() {sync.release(1);} /*获取等待通知组件,该组件和当前的锁绑定,当前线程只有获得了锁,才能调用该组件的wait()方法,而调用用,当前线程将释放锁*/@Overridepublic Condition newCondition() {return sync.newCondition();} }
AQS之同步队列结构
同步器依赖内部的同步队列(一个FIFO双向队列)来完成同步状态的管理,当前线程获取同步状态失败时,同步器会将当前线程以及等待状态等信息构造成为一个节点(Node)并将其 加入同步队列,同时会阻塞当前线程,当同步状态释放时,会把首节点中的线程唤醒,使其再次尝试获取同步状态。
节点属性:
属性类型与名称 | 描述 |
---|---|
int waitStatus | 等待状态,包含如下状态: CANCELLED:值为1,表示节点已取消,通常是因为线程被中断或者等待超时而被取消。 SIGNAL:值为-1,表示后继节点需要被唤醒,即当前节点的释放(signal)会通知后继节点继续尝试获取锁或资源。 CONDITION:值为-2,表示节点在等待队列中,节点线程等待在Condition上,当其他线程对Condition调用了signal()方法后,该节点将会从等待队列中转移到同步队列中,加入到对同步状态的获取中 PROPAGATE:值为-3,表示释放共享锁时需要向后继节点传播共享性质,以确保后继节点可以被唤醒。这在CountDownLatch等场景中会使用到。 INITIAL:值为0,初始状态。 |
Node prev | 前驱节点,当节点加入同步队列时被设置(尾部添加) |
Node next | 后继节点 |
Node nextWaiter | 等待队列中的后继节点。如果当前节点是共享的,那么这个字段将是一个SHARED常量,也就是说节点类型(独占和共享)和等待队列中的后继节点共用同一个字段 |
Thread thread | 获取同步状态的线程 |
节点是构成同步队列的基础,同步器拥有首节点(head)和尾结点(tail),没有成功获取同步状态的线程会成为节点加入该队列的尾部。
-
当一个线程成功地获取了同步状态(或者锁),其他线程将无法获取到同步状态,转而被构造成为节点并加入到同步队列中,而这个加入队列的过程必须要保证线程安全,因此同步器提供了一个基于CAS的设置尾节点的方法:compareAndSetTail(Node expect,Node update),它需要传递当前线程“认为”的尾节点和当前节点,只有设置成功后,当前节点才正式与之前的尾节点建立关联。
-
首节点是获取同步状态成功的节点,首节点的线程在释放同步状态时,将会唤醒后继节点,而后继节点将会在获取同步状态成功时将自己设置为首节点,设置首节点是通过获取同步状态成功的线程来完成的,由于只有一个线程能够成功获取到同步状态,因此设置头节点的方法并不需要使用CAS来保证,它只需要将首节点设置成为原首节点的后继节点并断开原首节点的next引用即可
解析AQS实现
以ReentrantLock的非公平锁为例,看看lock的实现。
-
ReentrantLock.lock()—获取锁的入口
public void lock() {sync.lock();}
sync 实际上是一个抽象的静态内部类,它继承了 AQS 来实现重入锁的逻辑。
Sync 有两个具体的实现类,分别是: NofairSync:表示可以存在抢占锁的功能,也就是说不管当前队列上是否存在其他 线程等待,新线程都有机会抢占锁 FailSync: 表示所有线程严格按照 FIFO 来获取锁。
ReentrantLock的无参构造函数默认创建的是非公平锁。
public ReentrantLock() {sync = new NonfairSync();}
-
NonfairSync.lock()—获取同步状态/锁。
static final class NonfairSync extends Sync {private static final long serialVersionUID = 7316153563782823691L; /*** Performs lock. Try immediate barge, backing up to normal* acquire on failure.*/final void lock() {if (compareAndSetState(0, 1))setExclusiveOwnerThread(Thread.currentThread());elseacquire(1);} protected final boolean tryAcquire(int acquires) {return nonfairTryAcquire(acquires);}}
-
非公平锁的特点:抢占锁的逻辑,不管有没有线程排队,上来先CAS抢占一下。
-
CAS成功,表示成功获得锁。
-
CAS失败,调用获取独占锁acquire()走锁竞争逻辑。
-
-
AQS.acquire(1)—尝试获取独占锁or加入同步队列自旋获取锁。
public final void acquire(int arg) {if (!tryAcquire(arg) &&acquireQueued(addWaiter(Node.EXCLUSIVE), arg))selfInterrupt();}
-
通过 tryAcquire 尝试获取独占锁,如果成功返回 true,失败返回 false
-
如果 tryAcquire 失败,则会通过 addWaiter 方法将当前线程封装成 Node 添加 到 AQS 队列尾部。
-
acquireQueued(),将 Node 作为参数,通过自旋去尝试获取锁。
-
-
NonfairSync.tryAcquire(1)—尝试获取独占锁
protected final boolean tryAcquire(int acquires) {return nonfairTryAcquire(acquires);}
它是重写 AQS 类中的 tryAcquire 方法
-
ReentrantLock.nofairTryAcquire(1)—尝试获取独占锁
final boolean nonfairTryAcquire(int acquires) {final Thread current = Thread.currentThread(); // 获取当前执行的线程int c = getState(); // 获取state的值if (c == 0) { // 表示无锁状态if (compareAndSetState(0, acquires)) { // CAS替换state的值,case成功表示获取锁成功setExclusiveOwnerThread(current); // 保存当前获得锁的线程,下次再来的时候不用尝试竞争锁return true;}}else if (current == getExclusiveOwnerThread()) { // 如果同一线程竞争锁,直接增加重入次数int nextc = c + acquires;if (nextc < 0) // overflowthrow new Error("Maximum lock count exceeded");setState(nextc);return true;}return false;}
-
获取当前线程,判断当前的锁的状态
-
如果 state=0 表示当前是无锁状态,通过 cas 更新 state 状态的值
-
当前线程是属于重入,则增加重入次数
-
-
AQS.addWaiter(Node.EXCLUSIVE) —线程构造成节点加入同步队列 (static final Node EXCLUSIVE = null;)
private Node addWaiter(Node mode) {Node node = new Node(Thread.currentThread(), mode);// Try the fast path of enq; backup to full enq on failureNode pred = tail;if (pred != null) {node.prev = pred;if (compareAndSetTail(pred, node)) {pred.next = node;return node;}}enq(node);return node;}
当 tryAcquire 方法获取锁失败以后,则会先调用 addWaiter 将当前线程封装成 Node.
入参 mode 表示当前节点的状态,传递的参数是 Node.EXCLUSIVE,表示独占状 态。意味着重入锁用到了 AQS 的独占锁功能
-
将当前线程封装成 Node
-
当前链表中的 tail 节点是否为空,如果不为空,则通过 cas 操作把当前线程的 node 添加到 AQS 队列
-
如果为空或者 cas 失败,调用 enq 将节点添加到 AQS 队列
-
-
enq(node)—通过自旋把当前节点加入到队列中
private Node enq(final Node node) {for (;;) {Node t = tail;if (t == null) { // Must initializeif (compareAndSetHead(new Node()))tail = head;} else {node.prev = t;if (compareAndSetTail(t, node)) {t.next = node;return t;}}}}
图解分析:
8.AQS.acquireQueued(node, 1)—把node加入到链表去争抢锁
-
获取当前节点的 prev 节点
-
如果 prev 节点为 head 节点,那么它就有资格去争抢锁,调用 tryAcquire 抢占锁
-
抢占锁成功以后,把获得锁的节点设置为 head,并且移除原来的初始化 head 节点
-
如果获得锁失败,则根据 waitStatus 决定是否需要挂起线程
-
最后,通过 cancelAcquire 取消获得锁的操作
final boolean acquireQueued(final Node node, int arg) {boolean failed = true;try {boolean interrupted = false;for (;;) {final Node p = node.predecessor(); // 获取当前节点的prev节点if (p == head && tryAcquire(arg)) { // 如果是head节点,说明有资格去争抢锁setHead(node); // 获取锁成功,也就是ThreadA已经释放了锁,然后设置head为ThreadB获得执行权限p.next = null; // help GCfailed = false;return interrupted;}// ThreadA可能还没释放锁,使得ThreadB在执行tryAcquire返回falseif (shouldParkAfterFailedAcquire(p, node) &&parkAndCheckInterrupt())interrupted = true;}} finally {if (failed)cancelAcquire(node);}}
9.shouldParkAfterFailedAcquire—竞争锁失败后应该挂起
这个方法的主要作用是,通过 Node 的状态来判断,ThreadA 竞争锁失败以后是 否应该被挂起。
-
如果 ThreadA 的 pred 节点状态为 SIGNAL,那就表示可以放心挂起当前线程
-
通过循环扫描链表把 CANCELLED 状态的节点移除
-
修改 pred 节点的状态为 SIGNAL,返回 false.
-
返回 false 时,也就是不需要挂起,返回 true,则需要调用 parkAndCheckInterrupt 挂起当前线程
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) { // pred是前置节点int ws = pred.waitStatus; // 前置节点的waitStatusif (ws == Node.SIGNAL) // 如果前置节点为 SIGNAL,意味着只需要等待其前置节点的线程被释放return true;if (ws > 0) { // ws大于 0,意味着prev节点取消了排队,直接移除这个节点就行do {node.prev = pred = pred.prev;} while (pred.waitStatus > 0);pred.next = node;} else {compareAndSetWaitStatus(pred, ws, Node.SIGNAL); // 利用cas设置prev节点的状态为SIGNAL(-1)}return false;}
图解分析:
waitStatus = -1(SIGNAL:值为-1,表示后继节点需要被唤醒,即当前节点的释放会通知后继节点继续尝试获取锁或资源。)
10.parkAndCheckInterrupt
Thread.interrupted,返回当前线程是否被其他线程触发过中断请求,也就是thread.interrupt(); 如果有触发过中断请求,那么这个方法会返回当前的中断标识 true,并且对中断标识进行复位标识已经响应过了中断请求。如果返回 true,意味着在acquire方法中会执行 selfInterrupt()。
private final boolean parkAndCheckInterrupt() {LockSupport.park(this);return Thread.interrupted(); // 1.中断 2.复位}
selfInterrupt: 标识如果当前线程在 acquireQueued 中被中断过,则需要产生一 个中断请求,原因是线程在调用 acquireQueued 方法的时候是不会响应中断请求的。
11.ReentrantLock.unlock()—锁释放
public void unlock() {sync.release(1);}
public final boolean release(int arg) {if (tryRelease(arg)) { // 释放锁成功Node h = head; // 获取aqs中的head节点if (h != null && h.waitStatus != 0) // 如果head节点不为空且状态!=0.调用unparkSuccessor(h)唤醒后续节点unparkSuccessor(h);return true;}return false;}
12.ReentrantLock.tryRelease()—设置锁状态
这个方法可以认为是一个设置锁状态的操作,通过将 state 状态减掉传入的参数值 (参数是 1),如果结果状态为 0,就将排它锁的 Owner 设置为 null,以使得其它 的线程有机会进行执行。 在排它锁中,加锁的时候状态会增加 1(当然可以自己修改这个值),在解锁的时 候减掉 1,同一个锁,在可以重入后,可能会被叠加为 2、3、4 这些值,只有 unlock() 的次数与 lock()的次数对应才会将 Owner 线程设置为空,而且也只有这种情况下才会返回 true.
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;}
13.AQS.unparkSuccessor()—唤醒后续节点
private void unparkSuccessor(Node node) {int ws = node.waitStatus; // 获取head节点的状态if (ws < 0)compareAndSetWaitStatus(node, ws, 0); // 设置head节点状态为0Node s = node.next; // 得到head节点的下一个节点//如果下一个节点为 null 或者 status>0 表示 cancelled 状态.if (s == null || s.waitStatus > 0) { s = null;//通过从尾部节点开始扫描,找到距离head最近的一个waitStatus<=0 的节点for (Node t = tail; t != null && t != node; t = t.prev)if (t.waitStatus <= 0)s = t;}if (s != null) // next节点不为空,直接唤醒这个线程即可LockSupport.unpark(s.thread);}
-
相关文章:

聊聊并发编程——多线程之AQS
目录 队列同步器(AQS) 独占锁示例 AQS之同步队列结构 解析AQS实现 队列同步器(AQS) 队列同步器AbstractQueuedSynchronizer(以下简称同步器),是用来构建锁或者其他同步组 件的基础框架&…...

DE0开发板交通灯十字路口红绿灯VHDL
名称:基于DE0开发板的交通灯十字路口红绿灯 软件:Quartus 语言:VHDL 要求: 设计一个十字路口交通信号灯的控制电路。分为两种情况,正常状态和报警状态。 1.正常状态:要求红、绿灯按一定的规律亮和灭&a…...

华为云云耀云服务器L实例评测使用 | 通过程序实现直播流自动分段录制
华为云云耀云服务器L实例评测使用 | 通过程序实现直播流自动分段录制 1. 准备工作2. 环境搭建3. 心得总结 1. 准备工作 随着云计算时代的进一步深入,越来越多的中小企业企业与开发者需要一款简单易用、高能高效的云计算基础设施产品来支撑自身业务运营和创新开发。基…...
前端教程-webpack
官网 webpack webpack基础 视频教程 尚硅谷Webpack5入门到原理(面试开发一条龙)...
white-space几种属性的用法(处理空格)
white-space:normal 文首的空格忽略,文本内部的换行符自动转成了空格。 white-space:nowrap 不换行,即使超出容器宽度 white-space:pre 与原文本一致,空格和换行符保留 white-space:pre-…...
Linux的历史
Linux的历史 前言: 关于Linux,你可能只是听说过它是一款操作系统,也许你还知道它是开源的,但在日常生活中,你更熟悉的是Windows。 那么我们为什么要了解、学习Linux,看完这一篇,你也许可以从…...
软考高级系统架构设计师系列论文真题八:论企业集成平台的技术与应用
软考高级系统架构设计师系列论文真题八:论企业集成平台的技术与应用 一、论企业集成平台的技术与应用二、找准核心论点三、理论素材准备四、精品范文赏析1.摘要2.正文3.总结软考高级系统架构设计师系列论文之:百篇软考高级架构设计师论文范文软考高级系统架构设计师系列之:论…...
[H5动画制作系列] 路径引导动画 Demo
代码参考1: <!DOCTYPE html> <html lang="en"><head><meta charset="UTF-8" /><meta name="viewport" content="width=device-width, initial-scale=1.0" /><title>路径引导动画 Demo1</tit…...

[React] Context上下文的使用
文章目录 1.Context的介绍2.为什么需要Context3.Context的使用 1.Context的介绍 Context旨在为React复杂嵌套的各个组件提供一个生命周期内的统一属性访问对象,从而避免我们出现当出现复杂嵌套结构的组件需要一层层通过属性传递值得问题。 Context是为了提供一个组…...
高云FPGA系列教程(9):cmd-parser串口命令解析器移植
文章目录 @[toc]cmd-parser库简介cmd-parser库源码获取GW1NSR-4C移植cmd-parser实际测试cmd-parse命令解析器优化本文是高云FPGA系列教程的第9篇文章。 上一篇文章介绍片上ARM Cortex-M3硬核处理器串口外设的使用,演示轮询方式和中断方式接收串口数据,并进行回环测试。 本文…...

PHP8的静态变量和方法-PHP8知识详解
我们在上一课程讲到了public、private、protected这3个关键字,今天我们来讲解static关键字,明天再讲解final关键字。 如果不想通过创建对象来调用变量或方法,则可以将该变量或方法创建为静态变量或方法,也就是在变量或方法的前面…...

用AI写文章被百家号封禁
我是卢松松,点点上面的头像,欢迎关注我哦! 千万不要用AI创作,尤其是原文照搬!不要用ai,不要用,不要用!重要的事情说三遍。 近日ID名为“爸爸在家赚钱”用AI写了4-5篇文章投稿在百家号,随后百度就把他帐号…...
JVM--Java类加载器笔记
Java类加载器 代码经过编译变成了字节码打包成 Jar 文件。让 JVM 去加载需要的字节码,变成持久代/元数据区上的 Class 对象,接着执行程序逻辑。 类声明周期和加载过程 步骤:加载->链接(校验->准备->解析)-…...

【在Ubuntu部署Docker项目】— PROJECT#1
一、说明 让我们深入了解 Docker。用docker构建web服务器。我们正在计划开发JavaScript API,建立MySQL数据库,并创建一个 PHP 网站使用 API 服务。Php Node.js Mysql — DockerSeries — Episode#1 二、系统架构概述 我们要构建的容器,是三…...
【学习笔记】LOJ #6240. 仙人掌
毒瘤题😅 简单版本 CF235D Graph Game 首先,考虑建立圆方树,然后对于一个点双(简单环)上的两个点,有两条路径可以到达 和简单版本类似,考虑容斥。即枚举点对 i , j i,j i,j之间 哪些路径是联…...
java通过接口转发文件(上传下载)
java接口转发上传的文件 RequestMapping(value "/XXXX/fileUpload", method RequestMethod.POST) public String getFileUpload2(RequestParam("file") MultipartFile file, HttpServletRequest request) public static String hotMapPost3(String ur…...
Docker-部署docker-compose以及管理服务
部署docker-compose以及管理服务 文章目录 部署docker-compose以及管理服务[TOC] 前言一、docker-compose是什么?1、介绍2、 功能 二、安装docker-compose1.yum直接安装2.二进制安装3.pip安装 三、docker-compose部署服务1.编写docker-compose.yml文件 总结 前言 D…...
Android - Monkey 测试应用出现Crash报错IllegalStateException
问题描述 平时使用Lottie动画都是正常的,没出过这个crash问题,看下的报错信息,代码中文件夹也设置了,没看出来问题。 AndroidRuntime: java.lang.IllegalStateException: You must set an images folder before loading an imag…...

Spring源码分析 事务 实现原理
文章目录 什么是事务Spring事务管理Spring事务实现原理事务管理器事务定义事务的开启事务核心方法业务代码使用事务TransactionInterceptor 什么是事务 一般所指的事务是数据库事务,是指一批不可分割的数据库操作序列,也是数据库并发控制的基本单位。其…...

ADS-B及雷达显示终端8.3
新版本功能升级主要有如下: 1、地图更新 在上一版本8.2中使用的高程地图为由SRTM经过地形晕渲后,生成地形图片,然后对图片进行贴图,一一按规定位置、大小将地形图贴至底图上,而后在底图上进行二维矢量地图的绘制,包括…...

网络编程(Modbus进阶)
思维导图 Modbus RTU(先学一点理论) 概念 Modbus RTU 是工业自动化领域 最广泛应用的串行通信协议,由 Modicon 公司(现施耐德电气)于 1979 年推出。它以 高效率、强健性、易实现的特点成为工业控制系统的通信标准。 包…...
模型参数、模型存储精度、参数与显存
模型参数量衡量单位 M:百万(Million) B:十亿(Billion) 1 B 1000 M 1B 1000M 1B1000M 参数存储精度 模型参数是固定的,但是一个参数所表示多少字节不一定,需要看这个参数以什么…...
在HarmonyOS ArkTS ArkUI-X 5.0及以上版本中,手势开发全攻略:
在 HarmonyOS 应用开发中,手势交互是连接用户与设备的核心纽带。ArkTS 框架提供了丰富的手势处理能力,既支持点击、长按、拖拽等基础单一手势的精细控制,也能通过多种绑定策略解决父子组件的手势竞争问题。本文将结合官方开发文档,…...

Linux相关概念和易错知识点(42)(TCP的连接管理、可靠性、面临复杂网络的处理)
目录 1.TCP的连接管理机制(1)三次握手①握手过程②对握手过程的理解 (2)四次挥手(3)握手和挥手的触发(4)状态切换①挥手过程中状态的切换②握手过程中状态的切换 2.TCP的可靠性&…...
解锁数据库简洁之道:FastAPI与SQLModel实战指南
在构建现代Web应用程序时,与数据库的交互无疑是核心环节。虽然传统的数据库操作方式(如直接编写SQL语句与psycopg2交互)赋予了我们精细的控制权,但在面对日益复杂的业务逻辑和快速迭代的需求时,这种方式的开发效率和可…...
JVM垃圾回收机制全解析
Java虚拟机(JVM)中的垃圾收集器(Garbage Collector,简称GC)是用于自动管理内存的机制。它负责识别和清除不再被程序使用的对象,从而释放内存空间,避免内存泄漏和内存溢出等问题。垃圾收集器在Ja…...
Java 加密常用的各种算法及其选择
在数字化时代,数据安全至关重要,Java 作为广泛应用的编程语言,提供了丰富的加密算法来保障数据的保密性、完整性和真实性。了解这些常用加密算法及其适用场景,有助于开发者在不同的业务需求中做出正确的选择。 一、对称加密算法…...
鸿蒙中用HarmonyOS SDK应用服务 HarmonyOS5开发一个生活电费的缴纳和查询小程序
一、项目初始化与配置 1. 创建项目 ohpm init harmony/utility-payment-app 2. 配置权限 // module.json5 {"requestPermissions": [{"name": "ohos.permission.INTERNET"},{"name": "ohos.permission.GET_NETWORK_INFO"…...

Java面试专项一-准备篇
一、企业简历筛选规则 一般企业的简历筛选流程:首先由HR先筛选一部分简历后,在将简历给到对应的项目负责人后再进行下一步的操作。 HR如何筛选简历 例如:Boss直聘(招聘方平台) 直接按照条件进行筛选 例如:…...

学习STC51单片机32(芯片为STC89C52RCRC)OLED显示屏2
每日一言 今天的每一份坚持,都是在为未来积攒底气。 案例:OLED显示一个A 这边观察到一个点,怎么雪花了就是都是乱七八糟的占满了屏幕。。 解释 : 如果代码里信号切换太快(比如 SDA 刚变,SCL 立刻变&#…...