【Java】常见锁策略 CAS机制 锁优化策略

在这里插入图片描述

前言

在本文会详细介绍各种锁策略、CAS机制以及锁优化策略
不仅仅局限于Java,任何和锁相关的话题,都可能会涉及到下面的内容。
这些特性主要是给锁的实现者来参考的.
普通的程序猿也需要了解一些, 对于合理的使用锁也是有很大帮助的

文章目录

  • 前言
  • ✍一、常见的锁策略
    • 1.1乐观锁 悲观锁
    • 1.2重量级锁 轻量级锁
    • 1.3自旋锁 挂起等待锁
    • 1.4公平锁 非公平锁
    • 1.5可重入锁 不可重入锁
    • 1.6读写锁
  • ✍二、CAS机制
    • 2.1概念
    • 2.2如何实现?
    • 2.3有何应用?
      • ①实现原子类
      • ②实现自旋锁
    • 2.4锁的ABA问题
  • ✍三、锁优化策略
    • 3.1偏向锁
    • 3.2轻量级锁
    • 3.3自旋锁
    • 3.4其他锁优化
      • ①锁消除
      • ②锁粗化

✍一、常见的锁策略

1.1乐观锁 悲观锁

乐观锁:预测到程序中遇到冲突的可能性较小,从而消耗的资源(时间资源,内存资源)都比较少。
悲观锁:预测到程序中遇到冲突的可能性比较大,从而消耗的资源(时间资源,内存资源)比较多。

可以将悲观锁抽象理解为拥有被害妄想症

我们来画图再次具象的理解一下:

我们站在乐观锁的角度,认为程序中读数据的操作远远多于写数据的操作,所以在一般情况下,不会对程序进行加锁。
在这里插入图片描述

我们站在悲观锁的角度,认为程序中写操作远远多于读数据的操作,所以每次进行操作时就需要加锁。

在这里插入图片描述

那么对于我们熟知的synchronized锁来说,它是什么锁呢?
synchronized既是乐观锁,也是悲观锁,拥有自适应机制
初始为乐观锁,当检测到程序中锁的竞争较大时,就会切换悲观锁。

1.2重量级锁 轻量级锁

我们先来理解一下资源在计算机中时如何操作的
在这里插入图片描述
重量级锁:

重量级锁过度依赖了操作系统提供的mutex(互斥锁),这种锁同步方式的消耗非常大,主要包括系统调度引起的用户态和内核态的切换,线程阻塞造成的线程调度问题

轻量级锁:

轻量级锁的加锁机制尽可能的不使用mutex,从而减少了内核态和用户态之间的切换。也减少了线程的调度问题。

那么对于我们熟知的synchronized锁来说,它是什么锁呢?
synchronized刚开始是一个轻量级锁,它会检测发生冲突的情况,如果冲突比较严重,就会变成重量级锁。

1.3自旋锁 挂起等待锁

自旋锁(Spin Lock)
当程序中出现锁竞争时,锁对象要不断的检测锁是否归还,一旦锁被归还,那么就能第一时间拿到锁。但会消耗更多CPU资源,更多的时候是在判断锁是否归还。

我们写出一段关于自旋锁的伪代码来更深入的理解

  while( 抢锁 == 失败) {}

如果抢锁失败的话,就会一直重复的进行这一步操作,直到抢到锁为止。一次次抢锁之间的间隔时间非常短。

一旦锁被其他线程释放,就能第一时间获取到锁。

自旋锁的优缺点

  • 优点: 一旦锁被释放,能第一时间拿到锁
  • 缺点: 会造成资源的浪费,CPU一昧的在空等。

挂起等待锁
当锁竞争时,锁对象会等待一时间,不着急拿到锁,过一段时间再进行抢锁,消耗的CPU资源比较少。

挂起等待锁的优缺点

  • 优点: 减少了资源的消耗
  • 缺点: 不知道什么时候能拿到锁。

自旋锁和挂起等待锁的区别

  1. 自旋锁消耗的资源要比挂起等待锁多,但自旋锁的效率高于挂起等待锁
  2. 挂起等待锁会放弃CPU资源,自选锁不会放弃,会一直到锁释放为止。
  3. 自旋锁相较于挂起等待锁能更及时的获取到刚释放的锁

那么对于我们熟知的synchronized锁来说,它是什么锁呢?
synchronized既是自旋锁,也是挂起等待锁。
synchronized锁的轻量级锁是基于自旋锁实现的,基于CAS机制
重量级锁部分基于挂起等待锁实现,通过调用内核api实现。

1.4公平锁 非公平锁

这种锁策略代表着当多个锁竞争时,竞争的规则是什么?
公平锁: 按照先来后到的规则来竞争锁。
非公平锁: 竞争锁时每个对象的机会时均等的。

这种规则是不是不符合我们生活所理解的所认为的公平
在这里插入图片描述
生活就是这样,制定规则的人说什么是公平,那什么就是公平。

那么对于我们熟知的synchronized锁来说,它是什么锁呢?
synchronized是非公平锁,也就是机会均等的竞争锁。

1.5可重入锁 不可重入锁

这种锁策略是用来规定 是否允许同一个线程获取同意一把锁
例如递归操作中有锁操作,那么递归过程中这个锁会阻塞自己吗?
如果不会,那就是可重入锁

可重⼊锁的内部, 包含了 线程持有者计数器两个信息.

  • 如果某个线程加锁的时候, 发现锁已经被⼈占用, 但是恰好占⽤的正是自己, 那么仍然可以继续获取到锁, 并让计数器自增.
  • 解锁的时候计数器递减为 0 的时候, 才真正释放锁. (才能被别的线程获取到)

管理是有成本的,可重入锁看起来很理想,但需要考虑是否有必要引入该成本。

那么对于我们熟知的synchronized锁来说,它是什么锁呢?
synchronized是可重入锁
mutex锁是不可重入锁

1.6读写锁

多线程之间,数据的读取不会产生线程安全问题,但数据的写入方相互之间以及和读者之间都需要进行互斥。如果两种场景之间都用同一个锁,就会产生极大的性能消耗。

一个线程对数据的访问,主要哟两种操作:读操作写操作

  • 两个线程都只读一个数据,此时并没有线程安全问题,直接并发的读即可
  • 两个线程都要写一个数据,引发线程安全问题
  • 一个线程读,一个线程写,也会引发线程安全问题
  • 线程安全问题转移我上一篇文章链接: 线程安全问题

读写锁

ReentrantReadWriteLock.ReadLock  //读锁
ReentrantReadWriteLock.WriteLock  //写锁

读锁和写锁都提供了lock/unlock方法进行加锁解锁

  • 读加锁和读加锁之间,不互斥
  • 写加锁和写加锁之间,互斥
  • 读加锁和写加锁之间,互斥

读写锁主要适用于“频繁读,不频繁写”的场景中

那么对于我们熟知的synchronized锁来说,它是什么锁呢?
synchronized不是读写锁

✍二、CAS机制

2.1概念

CAS:全程Compare and swap,字面意思是“比较并交换”
CAS操作就是将赋值这一操作进行原子化了。

CAS涉及的操作

假设内存中的原数据V,旧的预期值A,新的预期值B

  1. 比较A与V是否相等 (比较)

  2. 如果比较相等,将B写入V (交换)

  3. 返回操作是否成功

     CAS可以视为是一种乐观锁(可以理解成CAS是乐观锁的一种实现方式)
     
     当多个线程对某个资源进行CAS操作,只能有一个线程能操作成功。
     但是并不会阻塞其他线程,其他线程只会收到操作失败的信号。
    

2.2如何实现?

CAS是如何实现的?
针对不同的操作系统,JVM用到了不同的CAS实现原理,简单来讲:

  • Java的CAS利用的是unsafe这个类提供的CAS操作;
  • unsafe的CAS依赖的是jvm针对不同的操作系统实现的Atomic::cmpxchg;
  • Atomic::cmpxchg的实现使用了汇编的CAS操作,并使用cpu硬件提供的lock机制保证其原子性。

2.3有何应用?

CAS有哪些应用?

①实现原子类

        AtomicInteger atomicInteger = new AtomicInteger(0);
        //相当于 i++;
        atomicInteger.getAndIncrement();

假设两个线程同时调用gerAndIncrement

1.两个线程都读取value的值到oldValue中,(oldValue是一个局部变量,在栈上,每个线程有自己的栈)
在这里插入图片描述

2.线程1先执行CAS操作,由于oldValue和value的值相同,直接及逆行value赋值

注意 
· CAS是直接读写内存的,而不是操作寄存器
· CAS的读内存,比较,写内存操作是一条硬件指令,是原子的

在这里插入图片描述
3.线程2执行CAS,执行第一次时,发现oldValue和value的值不相等,不能进行赋值。因此需要进入循环。
在循环里重新读取value的值赋给oldValue
在这里插入图片描述
4.线程2接下第二次执行CAS(此时因为第一次没有成功,所以还在while循环中),此时oldValue和value相同,于是直接执行赋值操作。
在这里插入图片描述
5.线程1和线程2返回各自的oldValue的值即可。

形如上述代码就可用实现一个原子类,不需要使用重量级锁,就可以高效的完成自增操作。

②实现自旋锁

自旋锁伪代码

class SpinLock{
    private Thread owner = null;
    private void lock(){
        //通过CAS看当前锁是否被某个线程私有
        //如果这个锁已经被别的线程私有,那么就自旋等待
        //如果这个锁没有被其他线程私有,那么就把owner设为当前尝试加锁的线程。
        while (!CAS(this.owner,null,Thread.currentThread())){
            
        }
    }
    public void unlock(){
        this.owner = null;
    }
}

2.4锁的ABA问题

ABA问题:
假设存在两个线程t1和t2,有一个共享变量num,初始值为A
接下来,线程t1想使用CAS把num值改成Z,那么就需要

  • 先读取num的值,记录到oldNum变量中
  • 是同CAS判定当前num的值是否为A,如果为A,就修改为Z。
线程t1的CAS期望时num不变就修改为,但num的值已经被t2给改了,
只不过又改成A了,这个时候t1究竟是否要更新num的值为Z呢?

到这一步,t1线程无法区分当前这个变量始终是A,还是经历了一个变化过程。
在这里插入图片描述

引起的BUG
在大部分情况下,是没有问题的,但总是存在一些特殊情况

小帅和小美生育有一子,孩子两岁了,需要去打疫苗,医院给小帅和小美发去了短信,要求他们这周末孩子去医院打疫苗,并在社区医院的表格报备;
我们期望 一个人带孩子去打疫苗并在社区医院表格中报备了,另一个人发现已经报备了,不需要再打疫苗了。
如果使用CAS的方式来完成这个打疫苗过程就可能出现问题。

正常的过程

  1. 小帅看见社区医院的表格没有报备,小帅带孩子去打疫苗。小美看见社区医院的表格没有报备,小美带孩子去打疫苗
  2. 小帅带孩子打了疫苗,孩子被小帅带走了,小美在家等待。
  3. 小帅带孩子回来了,并在社区医院表格中报备,小美通过查看表格知道了孩子已经打了疫苗,就不带孩子去医院了。

异常BUG

  1. 小帅看见社区医院的表格没有报备,小帅带孩子去打疫苗。小美看见社区医院的表格没有报备,小美带孩子去打疫苗
  2. 小帅带孩子打了疫苗,孩子被小帅带走了,小美在家等待。
  3. 小帅带孩子打完了疫苗,公司有事,把孩子送回家马上回公司了,并没有在社区医院表格中报备。此时小美拿到孩子,看见表格中没有报备,以为小帅带孩子出去玩了。小美就带孩子去了医院
  4. 小美到医院给医生说,孩子要打疫苗,孩子又被打了一针疫苗
    在这里插入图片描述
这时孩子就被打了两次疫苗!!!都是ABA问题搞的鬼。
孩子过两天就死了!!!孩子才两岁,也不会说话!!!
吃了没文化的亏!!!
所以奉劝大家一定要好好学习哈哈

既然问题这么严重,就给出解决方案来解决这个问题

解决方案
给要修改的值,引入版本号,在CAS比较数据当前值和旧值的同时,也要比较版本号是否符合预期。

  • CAS操作在读取旧值的同时,也要读取版本号。
  • 真正修改的时候,
    – 如果当前版本号和读到的版本号相同,则修改数据,并把版本号+1.
    –如果当前版本号高于读到的版本号,就操作失败(认为数据已经被修改过了)。

对比理解上面打疫苗的例子

版本号就相当于医院的疫苗接种单
打了疫苗,医院就会在疫苗接种单上盖章。
我们搭配一个版本号,初始为1
  1. 小帅看见社区医院的表格没有报备,版本号为1,小帅带孩子去打疫苗。小美看见社区医院的表格没有报备,版本号为1,小美带孩子去打疫苗
  2. 小帅带孩子打了疫苗,孩子被小帅带走了,打了疫苗,版本号为2,小美在家等待。
  3. 小帅带孩子打完了疫苗,公司有事,把孩子送回家马上回公司了,并没有在社区医院表格中报备,但版本号为2。
  4. 此时小美拿到孩子,看见表格中没有报备,以为小帅带孩子出去玩了。小美就准备带孩子去医院,但是发现此时版本号为2,和之前读到的版本号1不相同,版本小于当前版本,认为操作失败。

✍三、锁优化策略

JVM将synchronized锁分为 无锁、偏向锁、轻量级锁、重量级锁。
会根据情况,进行一次升级。
在这里插入图片描述

3.1偏向锁

第⼀个尝试加锁的线程, 优先进⼊偏向锁状态.
偏向锁不是真的 "加锁", 只是给对象头中做⼀个 "偏向锁的标记", 记录这个锁属于哪个线程.
如果后续没有其他线程来竞争该锁, 那么就不⽤进⾏其他同步操作了(避免了加锁解锁的开销)
如果后续有其他线程来竞争该锁(刚才已经在锁对象中记录了当前锁属于哪个线程了, 很容易识别当前
申请锁的线程是不是之前记录的线程), 那就取消原来的偏向锁状态, 进⼊⼀般的轻量级锁状态.
偏向锁本质上相当于 "延迟加锁" . 能不加锁就不加锁, 尽量来避免不必要的加锁开销.
但是该做的标记还是得做的, 否则⽆法区分何时需要真正加锁.

3.2轻量级锁

随着其他线程进⼊竞争, 偏向锁状态被消除, 进⼊轻量级锁状态(⾃适应的⾃旋锁).
此处的轻量级锁就是通过 CAS 来实现.

  • 通过 CAS 检查并更新⼀块内存 (⽐如 null => 该线程引⽤)
  • 如果更新成功, 则认为加锁成功
  • 如果更新失败, 则认为锁被占⽤, 继续⾃旋式的等待(并不放弃 CPU).

⾃旋操作是⼀直让 CPU 空转, ⽐较浪费 CPU 资源.
因此此处的⾃旋不会⼀直持续进⾏, ⽽是达到⼀定的时间/重试次数, 就不再⾃旋了.
也就是所谓的 “⾃适应”

3.3自旋锁

如果竞争进⼀步激烈, ⾃旋不能快速获取到锁状态, 就会膨胀为重量级锁
此处的重量级锁就是指⽤到内核提供的 mutex .

  • 执⾏加锁操作, 先进⼊内核态.
  • 在内核态判定当前锁是否已经被占⽤
  • 如果该锁没有占⽤, 则加锁成功, 并切换回⽤⼾态.
  • 如果该锁被占⽤, 则加锁失败. 此时线程进⼊锁的等待队列, 挂起. 等待被操作系统唤醒.
  • 经历了⼀系列的沧海桑⽥, 这个锁被其他线程释放了, 操作系统也想起了这个挂起的线程, 于是唤醒
    这个线程, 尝试重新获取锁.

3.4其他锁优化

①锁消除

编译器+JVM 判断锁是否可消除. 如果可以, 就直接消除.

②锁粗化

⼀段逻辑中如果出现多次加锁解锁, 编译器 + JVM 会⾃动进⾏锁的粗化

锁的颗粒: 锁的粒度: 粗和细
实际开发过程中, 使⽤细粒度锁, 是期望释放锁的时候其他线程能使⽤锁.
但是实际上可能并没有其他线程来抢占这个锁. 这种情况 JVM 就会⾃动把锁粗化, 避免频繁申请释放
锁.

举个例子来理解锁粗化

小明妈妈让小明去超市买东西

第一种方式

  • 打电话,让小明买酱油,挂电话
  • 打电话,让小明买白醋,挂电话
  • 打电话,让小明买抹布,挂电话

第二种方式

  • 打电话,让小明买酱油,白醋,抹布,挂电话

显然第二种方式更高效


以上就是本文所有内容,如果对你有帮助的话,点赞收藏支持一下吧!💞💞💞

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

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

户外旅行摄影手册,旅游摄影完全攻略

一、资料前言 本套旅游摄影资料,大小295.47M,共有9个文件。 二、资料目录 《川藏线旅游摄影》杨桦.彩印版.pdf 《户外摄影指南》(Essential.Guide.to.Outdoor.photography.amateur)影印版.pdf 《旅行摄影大师班》(英)科尼什.扫描版.PDF 《旅行摄影…

vue快速入门(三十三)scoped解决组件样式冲突

注释很详细&#xff0c;直接上代码 上一篇 新增内容 scoped解决样式冲突的用法 源码 MyHeader.vue <!-- 用于测试全局注册组件 --> <template><div id"myHeader"><h1>又可以愉快的学习啦</h1></div> </template><scri…

C++初阶学习第一弹——C++入门(上)

前言&#xff1a; 很高兴&#xff0c;从今天开始&#xff0c;我们就要步入C的学习了&#xff0c;在这之前我们已经对C语言有了不错的了解&#xff0c;对数据结构也有了一些自己的认识&#xff0c;今天开始&#xff0c;我们就进入这个新的主题的学习——C 目录 一、C的发展即其特…

机房布线管理解决方案——预标记线缆+移动扫码终端

数据中心布线管理已经是运维管理人员最不愿触碰的问题&#xff0c;线缆连接关系杂乱&#xff0c;线标错乱设备间端口连接查找费时费力&#xff0c;下架业务线缆不能资源释放造成浪费&#xff0c;链路故障排查困难无从下手。 试一下nVisual数据中心布线运维管理解决方案采用物联…

Altair SimLab安装教程

写在前面&#xff1a; Altair simlab是一款简单的前处理软件&#xff0c;笔者在网上搜罗了以下&#xff0c;也下载了免费的资源&#xff0c;但是感觉网上的 教程和资源良莠不齐&#xff0c;借此机会&#xff0c;把自己的经验分享出来。 首先要下载以下的文件&#xff0c;文件有…

PostgreSQL中的索引类型有哪些,以及何时应选择不同类型的索引?

文章目录 索引 解决方案和示例代码 PostgreSQL提供了多种索引类型&#xff0c;每种类型都有其特定的应用场景和优势。选择合适的索引类型可以显著提高查询性能&#xff0c;减少数据库负载。 索引 以下是PostgreSQL中常见的索引类型及其适用场景&#xff1a; 1. B-tree 索引 …

NVIDIA智算中心“产品”上市,AI工业革命的iPhone时刻

GTC 2024落下帷幕了&#xff0c;但这个大会的信息仍在AI产业和经济中发酵。咨询机构WIKIBON认为&#xff0c;GTC 2024在整个科技史中的意义超过了当年史蒂夫乔布斯的iPod和iPhone发布。在AI将永久改变人类的共识下&#xff0c;GTC 2024在广度、愿景、生态系统等方面都有着深远影…

链表。。.

文章目录 单链表头结点合并01 21. 合并两个有序链表02 23. 合并 K 个升序链表 拆分01 86. 分隔链表 快慢指针链表倒数第k个结点链表是否成环链表的中间结点01 19. 删除链表的倒数第 N 个结点02 142. 环形链表 II03 876. 链表的中间结点 相交01 160. 相交链表 反转反转链表反转前…

每三人拥有一辆车!车载工业平板电脑五大硬性要求

在今年7月初&#xff0c;公安部发布2022年上半年全国机动车和驾驶人统计数据&#xff0c;数据显示&#xff0c;截至2022年6月底&#xff0c;全国机动车保有量达4.06亿辆&#xff0c;其中汽车3.10亿辆。此外&#xff0c;目前全国拥有驾驶证的人数高达4.92亿人&#xff0c;其中汽…

Windows安装ChatGLM3

Git clone GitHub - THUDM/ChatGLM3: ChatGLM3 series: Open Bilingual Chat LLMs | 开源双语对话语言模型 查看cuda版本 CUDA&#xff08;Compute Unified Device Architecture&#xff09;是NVIDIA公司开发的一个平行计算平台和编程模型&#xff0c;它允许开发者利用NVIDI…

antd3.x Tree组件遇到的坑

在使用antd 3.x低版本组件的过程中&#xff0c;使用Tree组件&#xff0c;加载树形数据时候&#xff0c;第一次始终无法加载数据&#xff0c;在查阅antd文档后发现Tree组件会缓存数据&#xff0c;需要进行判断数据是否已加载 {microFolderList.length ?<TreedefaultExpanded…

OpenHarmony图像解码库—stb-image [GN编译]

简介 stb_image主要是C/C实现的图像解码库。 下载安装 直接在OpenHarmony-SIG仓中搜索stb-image并下载。 使用说明 以OpenHarmony 3.1 Beta的rk3568版本为例 库代码存放路径&#xff1a;./third_party/stb-image 修改添加依赖的编译脚本&#xff0c;路径&#xff1a;/devel…

前端三剑客 HTML+CSS+JavaScript ① 基础入门

光永远会照亮你 —— 24.4.18 一、C/S架构和B/S架构 C:Client&#xff08;客户端&#xff09; B:Browser&#xff08;浏览器&#xff09; S:Server&#xff08;服务器&#xff09; C/S 架构&#xff1a; B/S 架构&#xff1a; 大型专业应用、安全性要求较高的应用&#xff0c;还…

Docker使用教程及docker部署Vue项目

什么是Docker及其工作原理 虚拟化技术Docker是什么&#xff1f;三大基本术语核心算法原理和具体操作步骤 Docker和传统虚拟化技术区别为什么使用Docker&#xff1f;Docker有什么作用&#xff1f;1.解决应用部署的环境问题遇到问题达到效果 2.容器化 docker的各种命令解释运行机…

nodejs版本过高导致vue-cli无法启动的解决方案

目录 前言异常现象解决方案总结 前言 之前使用软件管家升级了Nodejs&#xff0c;今天在运行Vue项目的时候老是报错&#xff0c;查了很多资料&#xff0c;最后确定是Nodejs版本过高导致的。 异常现象 E:\project\ry\RuoYi-Cloud\ruoyi-ui>npm run dev> ruoyi3.6.4 dev …

ubuntu上安装调试SVN服务

刚成立团队需要临时搭建一台SVN服务器&#xff0c;所以对照网上的一些提示做了下&#xff0c;操作起来不复杂&#xff0c;还是踩了不少坑&#xff0c;顺便原理性了解了下。 主要操作步骤如下&#xff1a; 1&#xff1a;安装svn sudo apt-get install subversion 2: 创建svn版…

C++入门之类和对象(中)

C入门之类和对象(中) 文章目录 C入门之类和对象(中)1. 类的6个默认对象2. 构造函数2.1 概念2.2 特性2.3 补丁 3. 析构函数3.1 概念3.2 特性3.3 总结 4. 拷贝构造函数4.1 概念4.2 特性4.3 总结 1. 类的6个默认对象 如果一个类中什么都没有&#xff0c;那么这个类就是一个空类。…

NLP任务全览:涵盖各类NLP自然语言处理任务及其面临的挑战

自然语言处理(Natural Language Processing, 简称NLP&#xff09;是计算机科学与语言学中关注于计算机与人类语言间转换的领域。NLP将非结构化文本数据转换为有意义的见解&#xff0c;促进人与机器之间的无缝通信&#xff0c;使计算机能够理解、解释和生成人类语言。人类等主要…

chatgpt免费使用网站

在人工智能的浪潮中&#xff0c;OpenAI的ChatGPT作为一款前沿的语言处理工具&#xff0c;已经引起了广泛的关注和讨论。 ChatGPT以其卓越的语言理解和生成能力&#xff0c;为用户提供了多样化的应用场景&#xff0c;从日常对话、编程辅助到内容创作等。然而&#xff0c;对于许…

FL Studio21.2.4重磅发布更新发布功能介绍2024最新

FL Studio21是一款功能强大的数字音频工作站&#xff08;DAW&#xff09;&#xff0c;它在音乐制作领域占据着重要的地位。以下是对FL Studio 21的详细介绍&#xff1a; 一、功能与特点 音频编辑&#xff1a;FL Studio 21提供了强大的音频编辑功能&#xff0c;包括波形编辑&a…