✅作者简介:热爱Java后端开发的一名学习者,大家可以跟我一起讨论各种问题喔。
🍎个人主页:Hhzzy99
🍊个人信条:坚持就是胜利!
💞当前专栏:JVM
🥭本文内容:JVM的内存结构!
文章目录
- 内存结构
- 程序计数器(Program Counter)
- 虚拟机栈(Virtual Machine Stack)
- 栈内存溢出
- 线程运行诊断
- 本地方法栈(Native Method Stack)
- 堆(Heap)
- 堆内存溢出
- 堆内存诊断
- 方法区(Method Area)
- 方法区内存溢出
- 二进制字节码
- 常量池
- 运行时常量池
- 串池
- StringTable特性
- StringTable垃圾回收
- StringTable调优
- 直接内存
- 结语
内存结构
程序计数器(Program Counter)
JVM(Java虚拟机)中的程序计数器(Program Counter)是一种比较简单的数据结构,它可以看作是当前线程所执行的字节码的行号指示器或者下一条指令的地址。
每个线程都有一个独立的程序计数器,它是线程私有的,不会被线程之间共享。在任何给定时间点,一个线程只会执行一个方法,即当前线程的程序计数器指向正在执行的方法的字节码指令。
程序计数器在JVM中的作用主要有以下两个方面:
- 字节码解释器工作:程序计数器指示了字节码解释器下一条要执行的指令。字节码解释器通过不断地改变程序计数器的值来获取下一条指令,并解释执行。(
记住下一条jvm指令的执行地址
) - 线程切换恢复:由于线程切换时,需要记录当前线程的执行位置以便于恢复执行,程序计数器也起到了这个作用。当线程被切换回来时,JVM会根据程序计数器的值来恢复线程的执行位置,从上一次中断的地方继续执行。
需要注意的是,程序计数器是一个较小的内存区域,它是线程私有
的,且没有OutOfMemoryError
的风险。而且,由于程序计数器只记录了线程执行的位置信息,不会执行任何垃圾回收和内存分配等操作,所以它不会对垃圾回收和内存管理产生影响。
总结一下:
程序计数器在JVM中用于记住下一条jvm执行的执行地址
,它在字节码解释器工作和线程切换恢复中发挥着重要的作用。
虚拟机栈(Virtual Machine Stack)
虚拟机栈(Virtual Machine Stack)是Java虚拟机(JVM)在运行时创建的线程私有的内存区域,用于存储方法的局部变量、操作数栈、动态链接、方法出口等信息。下面是对虚拟机栈的详细解释:
- 概念:每个线程在执行Java程序时,都会在虚拟机栈上创建一个对应的栈帧(Stack Frame)。栈帧用于存储方法的运行时数据,每个方法对应一个栈帧。
- 栈帧结构:栈帧由三部分组成:局部变量表(Local Variable Table)、操作数栈(Operand Stack)和帧数据区(Frame Data)。局部变量表用于存储方法中的局部变量和参数,操作数栈用于执行方法的操作数和中间结果,帧数据区包含方法返回地址和异常处理相关信息。
- 栈的特点:虚拟机栈是一个后进先出(LIFO)的数据结构,它的大小是固定的,栈的大小在JVM启动时就可以设置。当线程调用一个方法时,JVM会为该方法创建一个栈帧并压入虚拟机栈,方法执行完毕后,栈帧会出栈。
- 栈的作用:虚拟机栈的主要作用是支持方法的调用和执行。每个线程独立拥有一个虚拟机栈,线程之间的方法调用和数据传递通过虚拟机栈来完成。
- 栈的异常:虚拟机栈的大小是有限的,当方法调用的栈帧数量超过了虚拟机栈的最大限制时,就会抛出
"StackOverflowError"
异常。另外,如果虚拟机栈无法动态扩展,或者申请扩展时无法分配到足够的内存,就会抛出"OutOfMemoryError"
异常。
总而言之,虚拟机栈是Java虚拟机在运行时为每个线程创建的一个内存区域,用于支持方法的调用和执行。它存储了方法的局部变量、操作数栈和方法出口等信息,是Java程序运行时的重要组成部分。
栈内存溢出
- 栈帧过多导致栈内存溢出
设置栈内存参数:-Xss256k
private static int count;
public static void main(String[] args) {
try{
method1();
}catch (Throwable e){
e.printStackTrace();
System.out.println(count);
}
}
private static void method1(){
count++;
method1();
}
java.lang.StackOverflowError
- 栈帧过大导致栈内存溢出
public class StackOverflowExample {
public static void main(String[] args) {
try {
recursiveMethod(1);
} catch (StackOverflowError e) {
System.out.println("StackOverflowError: " + e.getMessage());
}
}
public static void recursiveMethod(int value) {
byte[] data = new byte[1 * 1024 * 1024]; // 创建一个1MB大小的数组
recursiveMethod(value + 1); // 递归调用自身
}
}
在上述代码中,recursiveMethod()
方法在每次递归调用时都会创建一个1MB大小的数组。随着递归的深入,栈帧中的局部变量 data
占用的内存也会不断增加,当栈帧中的数组大小超过了虚拟机栈的容量限制时,就会导致栈内存溢出。
当我们运行上述代码时,会抛出 StackOverflowError
异常,提示栈溢出。这是因为每次递归调用都会占用大量的栈内存,栈帧中的数组越来越大,最终超过了虚拟机栈的容量限制。
线程运行诊断
cpu占用过多
在linux下,用top命令监测定位哪个应用程序对cpu占用高
ps H -eo pid,tid,%cpu | grep 进程id
(用ps命令进一步定位是哪个线程引起的cpu占用高)
jstack 进程id
可以根据线程id(linux查找到的是十进制,jstack显示出的是十六进制,可以转换一下再对比)找到有问题的线程,进一步定位到问题代码的源码行号。
程序运行很长时间没有结果
jstack 进程id
发现发生了死锁
本地方法栈(Native Method Stack)
本地方法栈(Native Method Stack) 是Java虚拟机为执行本地方法(Native Method)
而提供的一个栈空间。本地方法是使用其他编程语言(如C、C++)编写的方法,通过Java的本地接口(JNI)
与Java代码进行交互。
本地方法栈的作用类似于虚拟机栈,但是它不存储Java方法的调用和执行信息,而是用于执行本地方法的相关操作。本地方法栈的大小是由虚拟机在启动时设定的,可以通过命令行参数进行调整。
与虚拟机栈类似,本地方法栈也是线程私有的,每个线程都有自己的本地方法栈。在执行本地方法时,虚拟机会将本地方法的参数和局部变量等信息存储在本地方法栈中,并提供给本地方法使用。
本地方法栈的溢出会导致本地方法栈溢出异常(StackOverflowError)
。与虚拟机栈溢出异常类似,本地方法栈溢出异常通常是由于递归调用或本地方法内部存在过多的栈帧导致的。
需要注意的是,本地方法栈与虚拟机栈在实现上可能是相互交叉的,也可能是独立的,具体取决于虚拟机的实现。在一些虚拟机实现中,虚拟机栈和本地方法栈可能合二为一,共用同一块内存空间。而在另一些实现中,虚拟机栈和本地方法栈是独立的,分别使用不同的内存空间。
堆(Heap)
堆(Heap)是Java虚拟机管理的内存区域之一,用于存储对象实例和数组
。在Java程序运行时,所有动态分配的对象
都存储在堆中。
堆是一个运行时数据区,被 所有线程共享
。它在虚拟机启动时被创建,并且是Java虚拟机管理的最大的一块内存区域。堆被划分为更小的区域,称为堆块(Heap Chunk)或堆页(Heap Page)。每个堆块可以被分配给一个对象,如果对象太大无法放入单个堆块中,那么它将被划分为多个连续的堆块。
堆的大小可以通过命令行参数进行调整,其中包括初始堆大小(-Xms)
和最大堆大小(-Xmx)
。堆的大小直接影响到应用程序可以创建的对象数量和程序的性能。
Java堆的特点包括:
- 对象的动态分配:所有在Java程序中创建的对象都存储在堆中,并由垃圾收集器进行管理。
- 对象的自动释放:Java虚拟机的垃圾收集器会自动回收不再使用的对象内存,释放给堆重新分配。
- 对象的可扩展性:堆可以根据应用程序的需要进行动态扩展,以容纳更多的对象实例。
需要注意的是,堆的内存分配是线程共享的,因此在多线程环境下需要考虑线程安全性。此外,堆内存的回收是通过垃圾收集器来完成的,垃圾收集过程可能会导致一定的停顿时间,需要合理配置堆大小和选择适当的垃圾收集器以平衡内存使用和应用程序的响应性能。
堆内存溢出
public static void main(String[] args) {
int i = 0;
try{
List<String> list = new ArrayList<>();
String a = "hello";
while (true){
list.add(a);
a = a + a;
i++;
}
}catch (Throwable e){
e.printStackTrace();
System.out.println(i);
}
}
java.lang.OutOfMemoryError: Java heap space
堆内存诊断
//用到的代码
public static void main(String[] args) throws InterruptedException {
System.out.println("1...");
Thread.sleep(30000);
byte[] array = new byte[1024 * 1024 * 10];//10M
System.out.println("2...");
Thread.sleep(20000);
array = null;
System.gc();
System.out.println("3...");
Thread.sleep(1000000000L);
}
-
jps工具
查看当前系统中有哪些java进程
-
jmap工具
查看堆内存占用情况
-
jconsole工具(控制台输入jconsole打开)
图形界面,多功能的监测工具,可以连续监测
- jvisualvm工具(好用)(控制台输入jvisualvm打开)
方法区(Method Area)
方法区(Method Area)是Java虚拟机管理的内存区域之一,用于存储类的结构信息、静态变量、常量以及编译器编译后的代码等数据。
方法区属于共享区域,被所有线程共享。它在虚拟机启动时被创建,用于存储加载的类信息、常量、静态变量、即时编译器编译后的代码等。方法区的大小可以通过命令行参数进行调整,如设置最大方法区大小(-XX:MaxMetaspaceSize
)。
官方文档(JDK1.8版本的)Chapter 2. The Structure of the Java Virtual Machine (oracle.com)
方法区的特点包括:
- 存储类的结构信息:方法区存储了类的完整结构信息,包括类的名称、父类的名称、字段、方法、接口等。
- 存储静态变量和常量:方法区存储了类的静态变量和常量,这些变量在类的生命周期内始终存在,并且可以被所有实例共享。
- 存储编译后的代码:方法区存储了编译器编译后的代码,包括方法的字节码指令等。
- 执行运行时常量池:方法区中的运行时常量池存储了类、接口、方法中使用的常量数据。
需要注意的是,方法区的内存分配是线程共享的,因此在多线程环境下需要考虑线程安全性。在早期的Java虚拟机版本中,方法区的实现是永久代(Permanent Generation),但在JDK 8及之后的版本中,方法区的实现已经改为元空间(Metaspace)。元空间使用的是本地内存而不是虚拟机内存,因此避免了传统方法区的一些限制和问题。
需要注意的是,方法区的内存分配是线程共享的,因此在多线程环境下需要考虑线程安全性。此外,方法区的内存回收主要是通过垃圾收集器来回收无用的类和常量数据,具体的回收策略和机制因虚拟机而异。
方法区内存溢出
代码(JDK1.8版本的)
运行前添加参数-XX:MaxMetaspaceSize=64m
将元空间内存设置小一点便于观察报错
public class MetaSpaceOverflow extends ClassLoader{//可以用来加载类的二进制字节码
public static void main(String[] args) {
int i = 0;
try{
MetaSpaceOverflow test = new MetaSpaceOverflow();
for (int j = 0;; j++,i++) {
//ClassWriter 作用是生成类的二进制字节码
ClassWriter cw = new ClassWriter(0);
//参数意思:版本号,public,类名,包名,父类,接口
cw.visit(Opcodes.V1_8,Opcodes.ACC_PUBLIC,"Class"+j,null,"java/lang/Object",null);
//返回生成类的二进制字节码byte[]
byte[] code = cw.toByteArray();
//执行类的加载
test.defineClass("Class" + j,code,0,code.length);
}
}finally {
System.out.println(i);
}
}
}
运行这段代码,当超出我们预定的64m
元空间限制,就会抛出java.lang.OutOfMemoryError: Metaspace
异常。
1.8以前的版本会抛出java.lang.OutOfMemoryError: PermGen space
异常
二进制字节码
//二进制字节码包含(类基本信息,常量池,类方法定义,包含了虚拟机指令)
public class HelloWorld {
public static void main(String[] args) {
System.out.println("hello world");
}
}
通过javac -g 类名.java
将Java代码编译成字节码文件
再通过javap -v 类名.class
反编译字节码文件
类基本信息:
常量池:
类方法的定义:
虚拟机指令:
常量池
常量池是Java中的一种数据结构,用于存储在编译时期生成的字面量和符号引用。它在类加载过程中被创建,并与每个类相关联。
常量池包含了各种常量,例如字符串常量、数值常量、类和接口的全限定名、字段和方法的符号引用等。它的主要目的是减少重复的数据,提高内存利用率和运行效率。
在Java中,常量池分为两部分:编译时常量池和运行时常量池。
- 编译时常量池:在编译阶段,Java编译器会将源代码中的字面量和符号引用存储在编译时常量池中。它是每个类文件中的一部分,用于存储类的常量信息。
- 运行时常量池:在类加载过程中,编译时常量池的内容会被复制到运行时常量池中。运行时常量池是方法区的一部分,用于存储类加载后的常量数据。
常量池具有以下特点:
- 常量池中的常量是唯一的,不会重复存储相同的字面量或符号引用。
- 常量池中的常量可以被类的方法直接引用,以及在运行时进行动态链接、解析和初始化。
- 常量池中的常量可以在运行时被修改(例如使用反射机制修改常量值),但不推荐这样做。
常量池在Java中扮演了重要的角色,它为类的加载、链接和初始化过程提供了必要的数据支持,同时也为字符串常量的共享和节省内存提供了机制。
通俗来说,常量池就是一张常量表,虚拟机根据这张常量表找到要执行的类名、方法名、参数类型、字面量(像上面的"hello world"、整数、boolean类型,都称之为字面量)等信息。
运行时常量池
运行时常量池是Java虚拟机在类加载过程中所创建的一块内存区域,用于存储每个类或接口的常量数据。
运行时常量池是方法区的一部分,它在类加载时被创建,并且与每个类相关联。它的主要作用是存储编译时期生成的字面量和符号引用,以及在运行时进行动态链接、解析和初始化所需要的各种常量信息。
运行时常量池与编译时常量池有一定的关联关系,编译时常量池中的内容在类加载过程中会被复制到运行时常量池中。但是运行时常量池相比于编译时常量池具有更大的灵活性,可以进行动态修改。
运行时常量池中存储的常量类型包括:
- 字符串常量:包括直接使用双引号括起来的字符串字面量。
- 类和接口的全限定名:用于符号引用。
- 字段和方法的符号引用:用于动态链接和解析。
- 数值常量:包括整数、浮点数和布尔值等。
运行时常量池具有以下特点:
- 常量池中的常量是唯一的,不会重复存储相同的字面量或符号引用。
- 运行时常量池可以通过Java的反射机制进行访问和修改。
- 运行时常量池是方法区的一部分,与每个类或接口相关联,它在内存中的位置是固定的。
需要注意的是,运行时常量池的容量是有限的,当常量池中的常量数量超过容量限制时,会抛出java.lang.OutOfMemoryError
异常。在Java 7之前,运行时常量池的容量受到方法区大小的限制;而在Java 7及以后的版本中,运行时常量池被移至堆内存中,受到堆大小的限制。
通俗来讲,常量池是存在于 *.class 文件中的,当该类被加载,它的常量池信息就会放入运行时常量池,并把里面的符号地址变为真实地址
串池
串池(String Pool)是Java中的字符串常量池,它是一个位于堆内存中的特殊存储区域,用于存储字符串对象。
在Java中,字符串是不可变的,即创建后不能被修改。为了提高字符串的重用性和效率,Java使用了字符串常量池的机制。字符串常量池中存储了所有字符串字面量(直接使用双引号括起来的字符串)的引用,相同的字符串字面量在常量池中只会保存一份,这样可以节省内存空间。
当创建一个字符串对象时,首先会检查字符串常量池中是否已经存在相同内容的字符串,如果存在,则直接返回常量池中的引用;如果不存在,则在常量池中创建一个新的字符串对象,并返回该引用。
例如:
String s1 = "Hello"; // 字符串常量池中创建了一个"Hello"对象,并将引用赋给s1
String s2 = "Hello"; // 直接使用常量池中的引用,无需创建新的对象
String s3 = new String("Hello"); // 创建了一个新的字符串对象,并在堆内存中存储,不在常量池中
串池的特点包括:
- 字符串常量池中的字符串对象是不可变的,一旦创建就不能被修改。
- 字符串常量池中的字符串对象是唯一的,相同内容的字符串只会在常量池中保存一份。
- 字符串常量池的对象可以通过字符串字面量直接访问,不需要显式调用构造函数。
- 字符串常量池位于堆内存中,与堆中的其他对象一起进行垃圾回收。
需要注意的是,通过new String()
方式创建的字符串对象不会被放入字符串常量池,而是在堆内存中单独创建一个新的对象。如果需要将这样的字符串对象放入字符串常量池中,可以使用intern()
方法手动将其加入常量池。
String s4 = new String("Hello").intern(); // 将字符串对象放入字符串常量池
StringTable特性
- 常量池中的字符串仅是符号,第一次用到时才变为对象
- 利用串池的机制,来避免重复创建字符串对象
- 字符串变量拼接的原理是 StringBuilder (1.8)
- 字符串常量拼接的原理是编译期优化
- 可以使用 intern 方法,主动将串池中还没有的字符串对象放入串池
- 1.8 将这个字符串对象尝试放入串池,如果有则并不会放入,如果没有则放入串池, 会把串池中的对象返回
- 1.6 将这个字符串对象尝试放入串池,如果有则并不会放入,如果没有会把此对象复制一份,放入串池, 会把串池中的对象返回
StringTable位置
在1.8之前它就随着常量池存储在永久代(PermGen)里面
在1.8之后它存在于堆里面
原因: 永久代的内存回收效率太低,永久代是需要FullGC的时候才会触发,而FullGC只有在整个老年代空间不足才会触发,触发时机太晚,间接的导致StringTable回收效率不高。但是StringTable使用的非常的频繁,大量的字符串常量对象都会分配到StringTable中,如果他的回收效率不高,就会占用大量的内存,容易造成永久代的内存不足。所以在1.7开始,就将StringTable转移到了堆里,堆里面的StringTable只需要minorGC就可以触发垃圾回收。
StringTable垃圾回收
StringTable(字符串常量池)在垃圾回收中有一些特殊的处理方式。在早期的JVM版本中,StringTable是位于永久代(PermGen)中的,而在JDK 7及以后的版本中,StringTable被移至堆内存的一部分,也就是元空间(Metaspace)。
在垃圾回收过程中,StringTable的垃圾回收机制与其他对象的垃圾回收机制略有不同。在经典的垃圾回收算法中,只有不再被任何对象引用的对象才会被回收。但是,StringTable中的字符串对象是具有特殊性的,它们通常是常量或者被大量使用的字符串。由于这些字符串在运行时频繁地被引用,所以垃圾回收器并不会过早地回收这些字符串对象。
然而,当一个类被加载器卸载或者被垃圾回收时,相关的字符串常量也会被移除。这意味着在某些情况下,StringTable中的字符串对象可能会被回收,但通常情况下,它们会一直存在于整个应用程序的生命周期中。
需要注意的是,随着JDK的不同版本和垃圾回收机制的演进,StringTable的实现细节也可能会有所变化。因此,在具体的JVM实现中,StringTable的垃圾回收策略可能有所不同。但总体而言,StringTable中的字符串对象通常具有一定的持久性,并不会被过早地回收。
StringTable调优
设置桶个数
StringTable底层是一个Hash表,可以用参数-XX:StringTableSize
设置桶个数
一般可以调大一点,降低Hash碰撞,降低链表的长度,对速度的提升很明显。
测试
/***
*@Description: StringTable调优
*@author: Hhzzy99
*@date:2023/5/13
* -XX:StringTableSize=200000 设置桶个数
* -XX:+PrintStringTableStatistics 打印字符串常量池的统计信息
**/
public class StringTableBatter {
public static void main(String[] args) throws IOException {
try(BufferedReader reader = new BufferedReader(new InputStreamReader(new FileInputStream("linux.words"), StandardCharsets.UTF_8))){
String line = null;
long start = System.nanoTime();
while (true){
line = reader.readLine();
if (line == null){
break;
}
line.intern();
}
System.out.println("cost:" + (System.nanoTime() - start)/1000000);
}
}
}
将桶的数量设置为480000个-XX:StringTableSize=480000
480000个单词只用了0.144秒
考虑入池
修改一下代码
public class StringTableBatter {
public static void main(String[] args) throws IOException {
List<String> list = new ArrayList<>();
System.in.read();
for (int i = 0; i < 10; i++) {
try(BufferedReader reader = new BufferedReader(new InputStreamReader(new FileInputStream("linux.words"), StandardCharsets.UTF_8))){
String line = null;
long start = System.nanoTime();
while (true){
line = reader.readLine();
if (line == null){
break;
}
list.add(line);
}
System.out.println("cost:" + (System.nanoTime() - start)/1000000);
}
}
System.in.read();
}
}
用Jvisualvm工具
发现大约4800000个单词,一共占用了300M左右的内存(char[]和String)
将上面代码中的list.add(line)
改为list.add(line.intern())
发现占用的内存就30多M不到40M
如果你的
应用里有大量的字符串,而且还可能存在重复,那我们可以让字符串入池,来减少字符串个数,节约内存的使用。
直接内存
- 常见于NIO操作时,用于数据缓冲区
- 分配回收成本较高,但读写性能高
- 不受JVM内存回收管理
JVM直接内存(Direct Memory)是Java虚拟机在堆外(Off-Heap)分配的一块内存区域,它不受Java堆大小的限制,通常用于存储大量的数据或进行高性能的IO操作。
JVM直接内存与Java堆内存的区别在于分配和管理方式。Java堆内存通过垃圾回收器自动管理,而JVM直接内存则是由开发人员手动分配和释放。
JVM直接内存的优点包括:
- 直接访问:JVM直接内存可以直接在操作系统的内存空间中分配,避免了Java堆内存与操作系统之间的数据拷贝,提高了IO操作的性能。
- 较少的垃圾回收压力:JVM直接内存不受垃圾回收器的管理,因此不会影响垃圾回收的效率和延迟。
- 大内存支持:由于JVM直接内存不受Java堆大小的限制,可以分配更大的内存空间,适用于处理大规模数据和高并发场景。
然而,使用JVM直接内存也存在一些注意事项:
- 手动管理:开发人员需要手动分配和释放JVM直接内存,需要确保正确的内存使用和释放,避免内存泄漏或越界访问的问题。
- 内存溢出:JVM直接内存的分配不受Java堆大小限制,可能导致系统整体内存不足而产生OutOfMemoryError。
- 调优难度:由于JVM直接内存不受垃圾回收器的管理,需要开发人员自行进行性能调优和内存管理,包括合理控制内存分配和及时释放。
总之,JVM直接内存是Java虚拟机提供的一种可选的内存分配方式,适用于对性能和内存控制要求较高的场景。在使用JVM直接内存时,开发人员需要仔细考虑内存的分配和释放,并进行性能优化和监控,以确保系统的稳定性和性能表现。
用反射的方法获得一个Unsafe类
public static Unsafe getUnsafe(){
try{
Field f = Unsafe.class.getDeclaredField("theUnsafe");
f.setAccessible(true);
Unsafe unsafe = (Unsafe)f.get(null);
return unsafe;
}catch (NoSuchFieldException | IllegalAccessException e){
throw new RuntimeException(e);
}
}
public class MemoryManager {
static int _1GB = 1024 * 1024 * 1024;
public static void main(String[] args) throws IOException {
//直接内存
Unsafe unsafe = getUnsafe();
long base = unsafe.allocateMemory(_1GB);
unsafe.setMemory(base,_1GB,(byte)0);
System.in.read();
unsafe.freeMemory(base);
System.in.read();
}
}
分配内存前的内存占用
分配1G内存
ByteBuffer byteBuffer = ByteBuffer.allocateDirect(_1GB);
System.out.println("分配完毕....");
System.in.read();
System.out.println("开始释放");
byteBuffer = null;
System.gc();
System.in.read();
此方法类似。
分配和回收原理
使用了Unsafe 对象完成直接内存的分配回收,并且回收需要主动调用 freeMemory 方法
ByteBuffer 的实现类内部,使用了 Cleaner (虚引用)来监测 ByteBuffer 对象,一旦ByteBuffer 对象被垃圾回收,那么就会由 ReferenceHandler 线程通过 Cleaner 的 clean 方法调用 freeMemory 来释放直接内存
结语
本文展示了JVM中的内存结构,希望对大家有所帮助!大家如果感兴趣可以点点赞,关注一下,你们的支持是我最强大的动力,非常感谢您的阅读(❁´◡`❁)