5.JUC(java.util.concurrent) 的常见类
5.1ReentrantLock
可重入互斥锁. 和 synchronized 定位类似, 都是用来实现互斥效果, 保证线程安全.
ReentrantLock 也是可重入锁. “Reentrant” 这个单词的原意就是 “可重入”
ReentrantLock [ri:'entrəntlɒk] 的用法:
- lock(): 加锁, 如果获取不到锁就死等.
- trylock(超时时间): 加锁, 如果获取不到锁, 等待一定的时间之后就放弃加锁.
- unlock(): 解锁
ReentrantLock lock = new ReentrantLock(); ----------------------------------------- lock.lock(); try { // working } finally { lock.unlock()
ReentrantLock 和 synchronized 的区别:
- synchronized 是一个关键字, 是 JVM 内部实现的(大概率是基于 C++ 实现). ReentrantLock 是标准库的一个类, 在 JVM 外实现的(基于 Java 实现).
- synchronized 使用时不需要手动释放锁. ReentrantLock 使用时需要手动释放. 使用起来更灵活, 但是也容易遗漏 unlock.
- synchronized 在申请锁失败时, 会死等. ReentrantLock 可以通过 trylock 的方式等待一段时间就放弃.
- synchronized 是非公平锁, ReentrantLock 默认是非公平锁. 可以通过构造方法传入一个 true 开启公平锁模式.
// ReentrantLock 的构造方法 public ReentrantLock(boolean fair) { sync = fair ? new FairSync() : new NonfairSync(); }
- 更强大的唤醒机制. synchronized 是通过 Object 的 wait / notify 实现等待-唤醒. 每次唤醒的是一个随机等待的线程. ReentrantLock 搭配 Condition 类实现等待-唤醒, 可以更精确控制唤醒某个指定的线程.
如何选择使用哪个锁?
- 锁竞争不激烈的时候, 使用 synchronized, 效率更高, 自动释放更方便.
- 锁竞争激烈的时候, 使用 ReentrantLock, 搭配 trylock 更灵活控制加锁的行为, 而不是死等.
- 如果需要使用公平锁, 使用 ReentrantLock.
5.2原子类
原子类内部用的是 CAS 实现,所以性能要比加锁实现 i++ 高很多。原子类有以下几个
- AtomicBoolean
- AtomicInteger
- AtomicIntegerArray
- AtomicLong
- AtomicReference
- AtomicStampedReference
以 AtomicInteger 举例,常见方法有
addAndGet(int delta); i += delta; decrementAndGet(); --i; getAndDecrement(); i--; incrementAndGet(); ++i; getAndIncrement(); i++;
5.3线程池
虽然创建销毁线程比创建销毁进程更轻量, 但是在频繁创建销毁线程的时候还是会比较低效.
线程池就是为了解决这个问题. 如果某个线程不再使用了, 并不是真正把线程释放, 而是放到一个 “池子” 中, 下次如果需要用到线程就直接从池子中取, 不必通过系统来创建了.
5.3.1ExecutorService 和 Executors
代码示例:
- ExecutorService 表示一个线程池实例.
- Executors 是一个工厂类, 能够创建出几种不同风格的线程池.
- ExecutorService 的 submit 方法能够向线程池中提交若干个任务.
ExecutorService pool = Executors.newFixedThreadPool(10); pool.submit(new Runnable() { @Override public void run() { System.out.println("hello"); } });
Executors 创建线程池的几种方式
- newFixedThreadPool: 创建固定线程数的线程池
- newCachedThreadPool: 创建线程数目动态增长的线程池.
- newSingleThreadExecutor: 创建只包含单个线程的线程池.
- newScheduledThreadPool: 设定 延迟时间后执行命令,或者定期执行命令. 是进阶版的 Timer.
Executors 本质上是 ThreadPoolExecutor 类的封装.
5.3.2ThreadPoolExecutor
ThreadPoolExecutor 提供了更多的可选参数, 可以进一步细化线程池行为的设定.
ThreadPoolExecutor 的构造方法
理解 ThreadPoolExecutor 构造方法的参数
把创建一个线程池想象成开个公司. 每个员工相当于一个线程.
- corePoolSize: 正式员工的数量. (正式员工, 一旦录用, 永不辞退)
- maximumPoolSize: 正式员工 + 临时工的数目. (临时工: 一段时间不干活, 就被辞退).
- keepAliveTime: 临时工允许的空闲时间.
- unit: keepaliveTime 的时间单位, 是秒, 分钟, 还是其他值.
- workQueue: 传递任务的阻塞队列
- threadFactory: 创建线程的工厂, 参与具体的创建线程工作.
- RejectedExecutionHandler: 拒绝策略, 如果任务量超出公司的负荷了接下来怎么处理.
- AbortPolicy(): 超过负荷, 直接抛出异常.
- CallerRunsPolicy(): 调用者负责处理
- DiscardOldestPolicy(): 丢弃队列中最老的任务.
- DiscardPolicy(): 丢弃新来的任务.
代码示例:
ExecutorService pool = new ThreadPoolExecutor(1, 2, 1000, TimeUnit.MILLISECONDS, new SynchronousQueue<Runnable>(), Executors.defaultThreadFactory(), new ThreadPoolExecutor.AbortPolicy()); for(int i=0;i<3;i++) { pool.submit(new Runnable() { @Override void run() { System.out.println("hello"); } }); }
5.3.3线程池的工作流程
5.4信号量 Semaphore[ˈseməfɔː®]
信号量, 用来表示 “可用资源的个数”. 本质上就是一个计数器.
此处的信号量是 Java 把操作系统原生的信号量封装了一下
理解信号量
可以把信号量想象成是停车场的展示牌: 当前有车位 100 个. 表示有 100 个可用资源.
当有车开进去的时候, 就相当于申请一个可用资源, 可用车位就 -1 (这个称为信号量的 P 操作)
当有车开出来的时候, 就相当于释放一个可用资源, 可用车位就 +1 (这个称为信号量的 V 操作)
如果计数器的值已经为 0 了, 还尝试申请资源, 就会阻塞等待, 直到有其他线程释放资源.
Semaphore 的 PV 操作中的加减计数器操作都是原子的, 可以在多线程环境下直接使用.
代码示例一
- 创建 Semaphore 示例, 初始化为 4, 表示有 4 个可用资源.
- acquire 方法表示申请资源(P操作), release 方法表示释放资源(V操作)
- 创建 20 个线程, 每个线程都尝试申请资源, sleep 1秒之后, 释放资源. 观察程序的执行效果.
Semaphore semaphore = new Semaphore(4); Runnable runnable = new Runnable() { @Override public void run() { try { System.out.println("申请资源"); semaphore.acquire(); System.out.println("我获取到资源了"); Thread.sleep(1000); System.out.println("我释放资源了"); semaphore.release(); } catch (InterruptedException e) { e.printStackTrace(); } } }; for (int i = 0; i < 20; i++) { Thread t = new Thread(runnable); t.start(); }
代码示例二:
import java.util.concurrent.Semaphore; public class ThreadDemo31 { public static void main(String[] args) throws InterruptedException { Semaphore semaphore = new Semaphore(3); semaphore.acquire(); System.out.println("执行一次 P 操作"); semaphore.acquire(); System.out.println("执行一次 P 操作"); semaphore.acquire(); System.out.println("执行一次 P 操作"); semaphore.acquire(); System.out.println("执行一次 2P 操作"); } }
因为semaphore只申请了3个资源,因此在我们第四次semaphore.acquire();申请资源时,我们线程就回处于阻塞状态。
在我们修改代码,在第四次semaphore.acquire();前加上semaphore.release();来释放我们的一个资源后,此时我们就相当于从3-3=0个资源到0+1=1个资源,那么我们再次调用semaphore.acquire();来申请资源打印执行一次 2P 操作即可打印成功。
5.5CountDownLatch
同时等待 N 个任务执行结束.
好像跑步比赛,10个选手依次就位,哨声响才同时出发;所有选手都通过终点,才能公布成绩。
- 构造 CountDownLatch 实例, 初始化 10 表示有 10 个任务需要完成.
- 每个任务执行完毕, 都调用 latch.countDown() . 在 CountDownLatch 内部的计数器同时自减.
- 主线程中使用 latch.await(); 阻塞等待所有任务执行完毕. 相当于计数器为 0 了.
public class Demo { public static void main(String[] args) throws Exception { CountDownLatch latch = new CountDownLatch(10); Runnable r = new Runable() { @Override public void run() { try { Thread.sleep(Math.random() * 10000); latch.countDown(); } catch (Exception e) { e.printStackTrace(); } } }; for (int i = 0; i < 10; i++) { new Thread(r).start(); } // 必须等到 10 人全部回来 latch.await(); System.out.println("比赛结束"); } }
异步和同步是相对的。
同步:发送请求方,自己主动的等待响应结果。
异步:发送方发完请求就不管了。等有了结果之后,对方把结果主动推送过来。
5.6相关面试题
- 线程同步的方式有哪些?
synchronized, ReentrantLock, Semaphore 等都可以用于线程同步.
- 为什么有了 synchronized 还需要 juc 下的 lock?
以 juc 的 ReentrantLock 为例,
- synchronized 使用时不需要手动释放锁. ReentrantLock 使用时需要手动释放. 使用起来更 灵活,
- synchronized 在申请锁失败时, 会死等. ReentrantLock 可以通过 trylock 的方式等待一段时 间就放弃.
- synchronized 是非公平锁, ReentrantLock 默认是非公平锁. 可以通过构造方法传入一个 true 开启公平锁模式.
- synchronized 是通过 Object 的 wait / notify 实现等待-唤醒. 每次唤醒的是一个随机等待的 线程. ReentrantLock 搭配 Condition 类实现等待-唤醒, 可以更精确控制唤醒某个指定的线 程.
- AtomicInteger 的实现原理是什么?
基于 CAS 机制. 伪代码如下:
class AtomicInteger { private int value; public int getAndIncrement() { int oldValue = value; while ( CAS(value, oldValue, oldValue+1) != true) { oldValue = value; } return oldValue; }
执行过程参考 2.3"CAS 有哪些应用" 章节.
- 信号量听说过么?之前都用在过哪些场景下?
信号量, 用来表示 “可用资源的个数”. 本质上就是一个计数器.
使用信号量可以实现 “共享锁”, 比如某个资源允许 3 个线程同时使用, 那么就可以使用 P 操作作为 加锁, V 操作作为解锁, 前三个线程的 P 操作都能顺利返回, 后续线程再进行 P 操作就会阻塞等待, 直到前面的线程执行了 V 操作.
- 解释一下 ThreadPoolExecutor 构造方法的参数的含义
- corePoolSize 核心线程数,在线程池中要保证的最小线程数
- mainumPoolSize 最大线程数,线程池中能运行的最大线程数
- keepAliveTime 保证存活时间,当线程空闲时间,多久会回收线程。
- unit 和 keepAliveTime配合使用,时间单位
- workQueue 工作队列,用于存储任务在任务被执行之前
- threadFactory 线程创建工厂,用于创建线程
- RejectedExecutionHandler 当workQueue工作队列达到容量上限时,对任务进行的拒绝策略。
6.线程安全的集合类
原来的集合类, 大部分都不是线程安全的.
Vector, Stack, HashTable, 是线程安全的(不建议用), 其他的集合类不是线程安全的.
6.1多线程环境使用 ArrayList
- 自己使用同步机制 (synchronized 或者 ReentrantLock)
即自己加锁,自己使用 synchronized 或者 ReentrantLock 来是我们多线程从不安全变为安全
- Collections.synchronizedList(new ArrayList);
synchronizedList 是标准库提供的一个基于 synchronized 进行线程同步的 List.
synchronizedList 的关键操作上都带有 synchronized
- 使用 CopyOnWriteArrayList
CopyOnWrite容器即写时复制的容器。
- 当我们往一个容器添加元素的时候,不直接往当前容器添加,而是先将当前容器进行Copy, 复制出一个新的容器,然后新的容器里添加元素,
- 添加完元素之后,再将原容器的引用指向新的容器。
这样做的好处是我们可以对CopyOnWrite容器进行并发的读,而不需要加锁,因为当前容器不会 添加任何元素。
所以CopyOnWrite容器也是一种读写分离的思想,读和写不同的容器。
优点:
在读多写少的场景下, 性能很高, 不需要加锁竞争.
缺点:
- 占用内存较多.
- 新写的数据不能被第一时间读取到.
6.2多线程环境使用队列
- ArrayBlockingQueue
基于数组实现的阻塞队列
- LinkedBlockingQueue
基于链表实现的阻塞队列
- PriorityBlockingQueue
基于堆实现的带优先级的阻塞队列
- TransferQueue
最多只包含一个元素的阻塞队列
6.3多线程环境使用哈希表
HashMap 本身不是线程安全的.
在多线程环境下使用哈希表可以使用:
- Hashtable
- ConcurrentHashMap
6.3.1Hashtable
只是简单的把关键方法加上了 synchronized 关键字.
这相当于直接针对 Hashtable 对象本身加锁.
- 如果多线程访问同一个 Hashtable 就会直接造成锁冲突.
- size 属性也是通过 synchronized 来控制同步, 也是比较慢的.
- 一旦触发扩容, 就由该线程完成整个扩容过程. 这个过程会涉及到大量的元素拷贝, 效率会非常低.
6.3.2ConcurrentHashMap
相比于 Hashtable 做出了一系列的改进和优化. 以 Java1.8 为例
- 读操作没有加锁(但是使用了 volatile 保证从内存读取结果), 只对写操作进行加锁. (避免脏读)加锁的方式仍然 是是用 synchronized, 但是不是锁整个对象, 而是 “锁桶” (用每个链表的头结点作为锁对象), 大大降低了锁冲突的概率.
- 充分利用 CAS 特性. 比如 size 属性通过 CAS 来更新. 避免出现重量级锁的情况.
- 优化了扩容方式: 化整为零
- 发现需要扩容的线程, 只需要创建一个新的数组, 同时只搬几个元素过去.
- 扩容期间, 新老数组同时存在.
- 后续每个来操作 ConcurrentHashMap 的线程, 都会参与搬家的过程. 每个操作负责搬运一小部分元素.
- 搬完最后一个元素再把老数组删掉.
- 这个期间, 插入只往新数组加.
- 这个期间, 查找需要同时查新数组和老数组
简单来讲,我们ConcurrentHashMap不是像HashMap一样直接创建一个新的更大的数组,一次性全部搬运过去。我们ConcurrentHashMap是创建一个新的更大的数组每次只搬运一部分(不是全部!),这样做的目的就是为了我们用户的体验感,我们一点一点搬运的话程序不会等待很长时间,但是也完成了我们需要扩容的目的。
参考资料:https://blog.csdn.net/u010723709/article/details/48007881
6.3.3ConcurrentHashMap和HashMap区别
他们两个最大的区别就是ConcurrentHashMap是加一把大锁,把整个HashMap全部锁起来了,而ConcurrentHashMap是加一把一把的小锁,在不同线程上进行的操作不会相互的影响,不会导致因为只有一把锁使得整个程序效率低下。
eg:HashMap:
在对1和2这个情况下,是针对一把锁进行加锁,会有锁竞争,保证线程安全。(ConcurrentHashMap和HashMap是一样的 - 加一把大锁和加多把小锁作用相同都保证了线程安全)
但是在我们HashMap的场景下我们使用两个线程分别对3和4进行插入操作,但是因为只有一把大锁(锁的粒度太大了),所以我们只能一个一个插入,此时锁竞争就很激烈,导致我们插入效率低下。
ConcurrentHashMap:
在对1和2这个情况下,是针对一把锁进行加锁,会有锁竞争,保证线程安全。(ConcurrentHashMap和HashMap是一样的 - 加一把大锁和加多把小锁作用相同都保证了线程安全)
而在我们ConcurrentHashMap的场景下我们是给每一个链表下都加上了一把小锁(锁的粒度变小了),此时我们使用两个线程分别对3和4进行插入操作是不会有任何问题的,因为我们3和4所在的链表分别有各自对应的锁,就不会有锁竞争了,没有阻塞等待,程序就回更快。(快是相对的,不管咋样,不会比不加锁快)
上述谈到的情况,是针对 JDK1.8及其以后的情况,在1.7和之前,ConcurrentHashMap使用的是 “分段锁”
分段锁,本质上也是缩小锁的范围,从而降低锁冲突的概率,但是这种做法不够彻底。一方面粒度切分的还不够细。另一方面代码实现也更繁琐。
小知识:
HashMap存的是键值对(key-value),通过key可以唯一获取value。
HashMap 的 key 允许为 null,ConcurrentHashMap 的 key 值不允许为null。
6.4相关面试题
- ConcurrentHashMap的读是否要加锁,为什么?
读操作没有加锁. 目的是为了进一步降低锁冲突的概率. 为了保证读到刚修改的数据, 搭配了 volatile 关键字.
- 介绍下 ConcurrentHashMap的锁分段技术?
这个是 Java1.7 中采取的技术. Java1.8 中已经不再使用了. 简单的说就是把若干个哈希桶分成一个 “段” (Segment), 针对每个段分别加锁.
目的也是为了降低锁竞争的概率. 当两个线程访问的数据恰好在同一个段上的时候, 才触发锁竞争.
- ConcurrentHashMap在jdk1.8做了哪些优化?
取消了分段锁, 直接给每个哈希桶(每个链表)分配了一个锁(就是以每个链表的头结点对象作为锁对象).
将原来 数组 + 链表 的实现方式改进成 数组 + 链表 / 红黑树 的方式. 当链表较长的时候(大于等于 8 个元素)就转换成红黑树.
- Hashtable和HashMap、ConcurrentHashMap 之间的区别?
HashMap: 线程不安全. key 允许为 null
Hashtable: 线程安全. 使用 synchronized 锁 Hashtable 对象, 效率较低. key 不允许为 null.
ConcurrentHashMap: 线程安全. 使用 synchronized 锁每个链表头结点, 锁冲突概率低, 充分利用 CAS 机制. 优化了扩容方式. key 不允许为 null
7.死锁
7.1死锁是什么
死锁是这样一种情形:多个线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放。由于线 程被无限期地阻塞,因此程序不可能正常终止。
举个栗子理解死锁
滑稽老哥和女神一起去饺子馆吃饺子. 吃饺子需要酱油和醋.
滑稽老哥抄起了酱油瓶, 女神抄起了醋瓶.
滑稽: 你先把醋瓶给我, 我用完了就把酱油瓶给你.
女神: 你先把酱油瓶给我, 我用完了就把醋瓶给你.
如果这俩人彼此之间互不相让, 就构成了死锁.
酱油和醋相当于是两把锁, 这两个人就是两个线程.
为了进一步阐述死锁的形成, 很多资料上也会谈论到 “哲学家就餐问题”.
- 有个桌子, 围着一圈 哲 ♂ 家, 桌子中间放着一盘意大利面. 每个哲学家两两之间, 放着一根筷子.
- 每个 哲 ♂ 家 只做两件事: 思考人生 或者 吃面条. 思考人生的时候就会放下筷子. 吃面条就会拿起左 右两边的筷子(先拿起左边, 再拿起右边).
- 如果 哲 ♂ 家 发现筷子拿不起来了(被别人占用了), 就会阻塞等待.
- [关键点在这] 假设同一时刻, 五个 哲 ♂ 家 同时拿起左手边的筷子, 然后再尝试拿右手的筷子, 就会 发现右手的筷子都被占用了. 由于 哲 ♂ 家 们互不相让, 这个时候就形成了 死锁
死锁是一种严重的 BUG!! 导致一个程序的线程 “卡死”, 无法正常工作!
7.2如何避免死锁
死锁产生的四个必要条件:
- 互斥使用,即当资源被一个线程使用(占有)时,别的线程不能使用
- 不可抢占,资源请求者不能强制从资源占有者手中夺取资源,资源只能由资源占有者主动释放。
- 请求和保持,即当资源请求者在请求其他的资源的同时保持对原有资源的占有。
- 循环等待,即存在一个等待队列:P1占有P2的资源,P2占有P3的资源,P3占有P1的资源。这样 就形成了一个等待环路。
当上述四个条件都成立的时候,便形成死锁。当然,死锁的情况下如果打破上述任何一个条件,便可让 死锁消失。
其中最容易破坏的就是 “循环等待”.
破坏循环等待
最常用的一种死锁阻止技术就是锁排序. 假设有 N 个线程尝试获取 M 把锁, 就可以针对 M 把锁进行编号 (1, 2, 3…M).
N 个线程尝试获取锁的时候, 都按照固定的按编号由小到大顺序来获取锁. 这样就可以避免环路等待.
可能产生环路等待的代码:
两个线程对于加锁的顺序没有约定, 就容易产生环路等待.
Object lock1 = new Object(); Object lock2 = new Object(); Thread t1 = new Thread() { @Override public void run() { synchronized (lock1) { synchronized (lock2) { // do something... } } } }; t1.start(); Thread t2 = new Thread() { @Override public void run() { synchronized (lock2) { synchronized (lock1) { // do something... } } } }; t2.start();
不会产生环路等待的代码:
约定好先获取 lock1, 再获取 lock2 , 就不会环路等待.
Object lock1 = new Object(); Object lock2 = new Object(); Thread t1 = new Thread() { @Override public void run() { synchronized (lock1) { synchronized (lock2) { // do something... } } } }; t1.start(); Thread t2 = new Thread() { @Override public void run() { synchronized (lock1) { synchronized (lock2) { // do something... } } } }; t2.start();
7.3相关面试题
谈谈死锁是什么,如何避免死锁,避免算法? 实际解决过没有?
使用可重入锁
8.其他常见问题
面试题:
- 谈谈 volatile关键字的用法?
volatile 能够保证内存可见性. 强制从主内存中读取数据. 此时如果有其他线程修改被 volatile 修饰 的变量, 可以第一时间读取到最新的值.
- Java多线程是如何实现数据共享的?
JVM 把内存分成了这几个区域:
方法区, 堆区, 栈区, 程序计数器.
其中堆区这个内存区域是多个线程之间共享的.
只要把某个数据放到堆内存中, 就可以让多个线程都能访问到
- Java创建线程池的接口是什么?参数 LinkedBlockingQueue 的作用是什么?
创建线程池主要有两种方式:
- 通过 Executors 工厂类创建. 创建方式比较简单, 但是定制能力有限.
- 通过 ThreadPoolExecutor 创建. 创建方式比较复杂, 但是定制能力强.
LinkedBlockingQueue 表示线程池的任务队列. 用户通过 submit / execute 向这个任务队列中添 加任务, 再由线程池中的工作线程来执行任务.
- Java线程共有几种状态?状态之间怎么切换的?
NEW: 安排了工作, 还未开始行动. 新创建的线程, 还没有调用 start 方法时处在这个状态.
RUNNABLE: 可工作的. 又可以分成正在工作中和即将开始工作. 调用 start 方法之后, 并正在 CPU 上运行/在即将准备运行 的状态.
BLOCKED: 使用 synchronized 的时候, 如果锁被其他线程占用, 就会阻塞等待, 从而进入该状 态.
WAITING: 调用 wait 方法会进入该状态.
TIMED_WAITING: 调用 sleep 方法或者 wait(超时时间) 会进入该状态.
TERMINATED: 工作完成了. 当线程 run 方法执行完毕后, 会处于这个状态.
- 在多线程下,如果对一个数进行叠加,该怎么做?
- 使用 synchronized / ReentrantLock 加锁
- 使用 AtomInteger 原子操作.
- Servlet是否是线程安全的?
Servlet 本身是工作在多线程环境下.
如果在 Servlet 中创建了某个成员变量, 此时如果有多个请求到达服务器, 服务器就会多线程进行 操作, 是可能出现线程不安全的情况的.
- Thread和Runnable的区别和联系?
Thread 类描述了一个线程.
Runnable 描述了一个任务.
在创建线程的时候需要指定线程完成的任务, 可以直接重写 Thread 的 run 方法, 也可以使用 Runnable 来描述这个任务.
- 多次start一个线程会怎么样
第一次调用 start 可以成功调用.
后续再调用 start 会抛出 java.lang.IllegalThreadStateException 异常
- 有synchronized两个方法,两个线程分别同时用这个方法,请问会发生什么?
synchronized 加在非静态方法上, 相当于针对当前对象加锁.
如果这两个方法属于同一个实例:
线程1 能够获取到锁, 并执行方法. 线程2 会阻塞等待, 直到线程1 执行完毕, 释放锁, 线程2 获取到 锁之后才能执行方法内容.
如果这两个方法属于不同实例:
两者能并发执行, 互不干扰.
- 进程和线程的区别?
进程是包含线程的. 每个进程至少有一个线程存在,即主线程。
进程和进程之间不共享内存空间. 同一个进程的线程之间共享同一个内存空间.
进程是系统分配资源的最小单位,线程是系统调度的最小单位。
感谢各位读者的阅读,本文章有任何错误都可以在评论区发表你们的意见,我会对文章进行改正的。如果本文章对你有帮助请动一动你们敏捷的小手点一点赞,你的每一次鼓励都是作者创作的动力哦!😘