种一棵树最好的时间是十年前,其次是现在
本文整理自BiliBili黑马的JVM视频:https://www.bilibili.com/video/BV1yE411Z7AP
引言
什么是JVM
定义:
Java Virtual Machine - java 程序的运行环境(Java二进制字节码的运行环境)
好处:
- 一次编写,到处运行
- 自动内存管理,垃圾回收功能(因为当时对标的是C和C++)
- 数组下标越界检查
- 多态(使用虚拟方法调用的机制实现了多态)
比较:
jvm
、jre
、jdk
是一个逐级向上,包含的关系
学习JVM有什么用
- 面试(提高面试竞争力)
- 理解底层的实现原理
- 中高级程序员的必备技能
常见的JVM
参考来源:维基百科
本文JVM都是以
HotSpot
为基准
学习路线
内存结构
1. 程序计数器
1.1 定义
ProGram Counter Register 程序计数器(寄存器)
- 作用,记住下一条jvm指令的执行地址
- 特点
- 是线程私有的
- 不会存在内存溢出
1.2 作用
0: getstatic #20 // PrintStream out = System.out;
3: astore_1 // --
4: aload_1 // out.println(1);
5: iconst_1 // --
6: invokevirtual #26 // --
9: aload_1 // out.println(2);
10: iconst_2 // --
11: invokevirtual #26 // --
14: aload_1 // out.println(3);
15: iconst_3 // --
16: invokevirtual #26 // --
19: aload_1 // out.println(4);
20: iconst_4 // --
21: invokevirtual #26 // --
24: aload_1 // out.println(5);
25: iconst_5 // --
26: invokevirtual #26 // --
29: return
- 解释器会解释指令为机器码交给cpu执行,程序计数器会记录下一条指令的地址行号,这样下一次解释器会从程序计数器拿到指令然后进行解释执行
- 多线程的环境下,如果两个线程发生了上下文切换,那么程序计数器会记录线程下一行指令的地址行号,以便于接着往下执行
2. 虚拟机栈
2.1 定义
Java Virtual Machine Stacks (Java 虚拟机栈)
- 每个线程运行时所需要的内存,称为虚拟机栈
- 每个栈由多个栈帧(Frame)组成,对应着每次方法调用时所占用的内存
- 每个线程只能有一个活动栈帧,对应着当前正在执行的那个方法
问题辨析
- 垃圾回收是否涉及栈内存?
- 不涉及,当方法执行完自动会释放内存
- 栈内存分配越大越好吗?
- 方法内的局部变量是否线程安全?
- 如果方法内局部变量没有逃离方法的作用访问,它是线程安全的
- 如果是局部变量引用了对象,并逃离方法的作用范围,需要考虑线程安全
2.2 栈内存溢出
栈帧过大、过多、或者第三方类库操作,都有可能造成栈内存溢出 **java.lang.stackOverflowError**
-Xss256k
指定栈内存大小!
栈帧过多导致栈内存溢出
栈帧过大导致栈内存溢出
2.3 线程运行诊断
案例1: cpu 占用过多
定位:
- 用top定位哪个进程对cpu的占用过高
**ps H -eo pid,tid,%cpu | grep**
进程id (用ps命令进一步定位是哪个线程引起的cpu占用过高)- jstack 进程id
- 可以根据线程id 找到有问题的线程,进一步定位到问题代码的源码行号
案例2:程序运行很长时间没有结果
3. 本地方法栈
一些带有native
关键字的方法就是需要JAVA去调用本地的C或者C++方法,因为Java有时候没法直接和操作系统底层交互,所以需要用到本地方法栈,服务带native
关键字的方法
4.堆
4.1 定义
Heap 堆
- 通过
**new 关键字**
,创建对象都会使用堆内存
特点
- 它是线程共享的,堆中对象都需要考虑线程安全的问题
- 有垃圾回收机制
4.2 堆内存溢出
**java.lang.OutofMemoryError :java heap space**
:堆内存溢出
可以使用 -Xmx8m
来指定堆内存大小。
可以将堆内存调小一点,这样可以过早的去发现堆内存的溢出
4.3 堆内存诊断
- jps 工具
- 查看当前系统中有哪些 java 进程
- jmap 工具
- 查看堆内存占用情况 jmap - heap 进程id
- jconsole 工具
- 图形界面的,多功能的监测工具,可以连续监测
- jvisualvm 工具
5. 方法区
5.1 定义
Java 虚拟机有一个在所有 Java 虚拟机线程之间共享的方法区域。方法区域类似于用于传统语言的编译代码的存储区域,或者类似于操作系统进程中的“文本”段。它存储每个类的结构,例如运行时常量池、字段和方法数据,以及方法和构造函数的代码,包括特殊方法,用于类和实例初始化以及接口初始化方法区域是在虚拟机启动时创建的。尽管方法区域在逻辑上是堆的一部分,但简单的实现可能不会选择垃圾收集或压缩它。此规范不强制指定方法区的位置或用于管理已编译代码的策略。方法区域可以具有固定的大小,或者可以根据计算的需要进行扩展,并且如果不需要更大的方法区域,则可以收缩。方法区域的内存不需要是连续的!
5.2 组成
在1.6时候方法区的实现叫做永久代
在1.8时候方法区的实现叫做元空间
当然,不管是在哪个(1.7或1.8)版本,方法区都是一个概念,在1.8时,方法区已经不占用JVM内存了,而是移到 本地内存,同时串池(StringTable)移到了堆(Heap)中
5.3 方法区内存溢出
1.8 之前会导致永久代内存溢出
- 使用
-XX:MaxPermSize=8m
指定永久代内存大小
1.8 之后会导致元空间内存溢出
- 使用
-XX:MaxMetaspaceSize=8m
指定元空间大小
在1.8的时候,如果不去指定元空间的大小,那么很难看到元空间溢出,因为1.8的元空间是用的是系统物理内存
场景:
- spring
- mybatis
在这样框架中,都是使用了字节码动态生成技术,cglib
,spring中是动态代理,
**asm**
:字节码操纵框架
5.4 运行时常量池
public class Test {
public static void main(String[] args) {
System.out.println("Hello World!");
}
}
然后使用 **javap -v Test.class**
命令反编译查看结果
每条指令都会对应常量池表中一个地址,常量池表中的地址可能对应着一个类名、方法名、参数类型等信息
- 常量池,就是一张表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量等信息
- 运行时常量池,常量池是 *.class 文件中的,当该类被加载,它的常量池信息就会放入运行时常量池,并把里面的符号地址变为真实地址
5.5 StringTable
先看一道面试题:
String s1 = "a";
String s2 = "b";
String s3 = "a" + "b";
String s4 = s1 + s2;
String s5 = "ab";
String s6 = s4.intern();
// 问
System.out.println(s3 == s4); // false
System.out.println(s3 == s5); // true
System.out.println(s3 == s6); // true
String x2 = new String("c") + new String("d");
String x1 = "cd";
x2.intern();
// 问,如果调换了【最后两行代码】的位置呢,如果是jdk1.6呢
System.out.println(x1 == x2); // false
// 如果调换了 true
在1.8,串池(StringTable)是在堆中
5.5 StringTable特性
- 常量池中的字符串仅是符号,只有在被用到时才会转化为对象
- 利用串池的机制,来避免重复创建字符串对象
- 字符串变量拼接的原理是
StringBuilder
- 字符串常量拼接的原理是编译器优化
- 可以使用
intern
方法,主动将串池中还没有的字符串对象放入串池中
**intern**
方法 1.8
调用字符串对象的 intern
方法,会将该字符串对象尝试放入到串池中
- 如果串池中没有该字符串对象,则放入成功
- 如果有该字符串对象,则放入失败,无论放入是否成功,都会返回串池中的字符串对象
注意:此时如果调用 intern
方法成功,堆内存与串池中的字符串对象是同一个对象;如果失败,则不是同一个对象
例1:
public class Main {
public static void main(String[] args) {
// "a" "b" 被放入串池中,str 则存在于堆内存之中
String str = new String("a") + new String("b");
// 调用 str 的 intern 方法,这时串池中没有 "ab" ,则会将该字符串对象放入到串池中,此时堆内存与串池中的 "ab" 是同一个对象
String st2 = str.intern();
// 给 str3 赋值,因为此时串池中已有 "ab" ,则直接将串池中的内容返回
String str3 = "ab";
// 因为堆内存与串池中的 "ab" 是同一个对象,所以以下两条语句打印的都为 true
System.out.println(str == st2);
System.out.println(str == str3);
}
}
例2:
public class Main {
// ["ab","a","b"]
public static void main(String[] args) {
// 此处创建字符串对象 "ab" ,因为串池中还没有 "ab" ,所以将其放入串池中
String x = "ab";
// "a" "b" 被放入串池中,s 则存在于堆内存之中
String s = new String("a") + new String("b");
// 将字符传s放入常量池,若果有则不会放入,如果没有则放入常量池,会把池中的对象返回
String s2 = s.intern();
System.out.println(s2 == x); // true
System.out.println(s == x); // false
}
}
种一棵树最好的时间是十年前,其次是现在
本文整理自BiliBili黑马的JVM视频:https://www.bilibili.com/video/BV1yE411Z7AP
引言
什么是JVM
定义:
Java Virtual Machine - java 程序的运行环境(Java二进制字节码的运行环境)
好处:
- 一次编写,到处运行
- 自动内存管理,垃圾回收功能(因为当时对标的是C和C++)
- 数组下标越界检查
- 多态(使用虚拟方法调用的机制实现了多态)
比较:
jvm
、jre
、jdk
是一个逐级向上,包含的关系
学习JVM有什么用
- 面试(提高面试竞争力)
- 理解底层的实现原理
- 中高级程序员的必备技能
常见的JVM
参考来源:维基百科
本文JVM都是以
HotSpot
为基准
学习路线
内存结构
1. 程序计数器
1.1 定义
ProGram Counter Register 程序计数器(寄存器)
- 作用,记住下一条jvm指令的执行地址
- 特点
- 是线程私有的
- 不会存在内存溢出
1.2 作用
0: getstatic #20 // PrintStream out = System.out;
3: astore_1 // --
4: aload_1 // out.println(1);
5: iconst_1 // --
6: invokevirtual #26 // --
9: aload_1 // out.println(2);
10: iconst_2 // --
11: invokevirtual #26 // --
14: aload_1 // out.println(3);
15: iconst_3 // --
16: invokevirtual #26 // --
19: aload_1 // out.println(4);
20: iconst_4 // --
21: invokevirtual #26 // --
24: aload_1 // out.println(5);
25: iconst_5 // --
26: invokevirtual #26 // --
29: return
- 解释器会解释指令为机器码交给cpu执行,程序计数器会记录下一条指令的地址行号,这样下一次解释器会从程序计数器拿到指令然后进行解释执行
- 多线程的环境下,如果两个线程发生了上下文切换,那么程序计数器会记录线程下一行指令的地址行号,以便于接着往下执行
2. 虚拟机栈
2.1 定义
Java Virtual Machine Stacks (Java 虚拟机栈)
- 每个线程运行时所需要的内存,称为虚拟机栈
- 每个栈由多个栈帧(Frame)组成,对应着每次方法调用时所占用的内存
- 每个线程只能有一个活动栈帧,对应着当前正在执行的那个方法
问题辨析
- 垃圾回收是否涉及栈内存?
- 不涉及,当方法执行完自动会释放内存
- 栈内存分配越大越好吗?
- 方法内的局部变量是否线程安全?
- 如果方法内局部变量没有逃离方法的作用访问,它是线程安全的
- 如果是局部变量引用了对象,并逃离方法的作用范围,需要考虑线程安全
2.2 栈内存溢出
栈帧过大、过多、或者第三方类库操作,都有可能造成栈内存溢出 **java.lang.stackOverflowError**
-Xss256k
指定栈内存大小!
栈帧过多导致栈内存溢出
栈帧过大导致栈内存溢出
2.3 线程运行诊断
案例1: cpu 占用过多
定位:
- 用top定位哪个进程对cpu的占用过高
**ps H -eo pid,tid,%cpu | grep**
进程id (用ps命令进一步定位是哪个线程引起的cpu占用过高)- jstack 进程id
- 可以根据线程id 找到有问题的线程,进一步定位到问题代码的源码行号
案例2:程序运行很长时间没有结果
3. 本地方法栈
一些带有native
关键字的方法就是需要JAVA去调用本地的C或者C++方法,因为Java有时候没法直接和操作系统底层交互,所以需要用到本地方法栈,服务带native
关键字的方法
4.堆
4.1 定义
Heap 堆
- 通过
**new 关键字**
,创建对象都会使用堆内存
特点
- 它是线程共享的,堆中对象都需要考虑线程安全的问题
- 有垃圾回收机制
4.2 堆内存溢出
**java.lang.OutofMemoryError :java heap space**
:堆内存溢出
可以使用 -Xmx8m
来指定堆内存大小。
可以将堆内存调小一点,这样可以过早的去发现堆内存的溢出
4.3 堆内存诊断
- jps 工具
- 查看当前系统中有哪些 java 进程
- jmap 工具
- 查看堆内存占用情况 jmap - heap 进程id
- jconsole 工具
- 图形界面的,多功能的监测工具,可以连续监测
- jvisualvm 工具
5. 方法区
5.1 定义
Java 虚拟机有一个在所有 Java 虚拟机线程之间共享的方法区域。方法区域类似于用于传统语言的编译代码的存储区域,或者类似于操作系统进程中的“文本”段。它存储每个类的结构,例如运行时常量池、字段和方法数据,以及方法和构造函数的代码,包括特殊方法,用于类和实例初始化以及接口初始化方法区域是在虚拟机启动时创建的。尽管方法区域在逻辑上是堆的一部分,但简单的实现可能不会选择垃圾收集或压缩它。此规范不强制指定方法区的位置或用于管理已编译代码的策略。方法区域可以具有固定的大小,或者可以根据计算的需要进行扩展,并且如果不需要更大的方法区域,则可以收缩。方法区域的内存不需要是连续的!
5.2 组成
在1.6时候方法区的实现叫做永久代
在1.8时候方法区的实现叫做元空间
当然,不管是在哪个(1.7或1.8)版本,方法区都是一个概念,在1.8时,方法区已经不占用JVM内存了,而是移到 本地内存,同时串池(StringTable)移到了堆(Heap)中
5.3 方法区内存溢出
1.8 之前会导致永久代内存溢出
- 使用
-XX:MaxPermSize=8m
指定永久代内存大小
1.8 之后会导致元空间内存溢出
- 使用
-XX:MaxMetaspaceSize=8m
指定元空间大小
在1.8的时候,如果不去指定元空间的大小,那么很难看到元空间溢出,因为1.8的元空间是用的是系统物理内存
场景:
- spring
- mybatis
在这样框架中,都是使用了字节码动态生成技术,cglib
,spring中是动态代理,
**asm**
:字节码操纵框架
5.4 运行时常量池
public class Test {
public static void main(String[] args) {
System.out.println("Hello World!");
}
}
然后使用 **javap -v Test.class**
命令反编译查看结果
每条指令都会对应常量池表中一个地址,常量池表中的地址可能对应着一个类名、方法名、参数类型等信息
- 常量池,就是一张表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量等信息
- 运行时常量池,常量池是 *.class 文件中的,当该类被加载,它的常量池信息就会放入运行时常量池,并把里面的符号地址变为真实地址
5.5 StringTable
先看一道面试题:
String s1 = "a";
String s2 = "b";
String s3 = "a" + "b";
String s4 = s1 + s2;
String s5 = "ab";
String s6 = s4.intern();
// 问
System.out.println(s3 == s4); // false
System.out.println(s3 == s5); // true
System.out.println(s3 == s6); // true
String x2 = new String("c") + new String("d");
String x1 = "cd";
x2.intern();
// 问,如果调换了【最后两行代码】的位置呢,如果是jdk1.6呢
System.out.println(x1 == x2); // false
// 如果调换了 true
在1.8,串池(StringTable)是在堆中
5.5 StringTable特性
- 常量池中的字符串仅是符号,只有在被用到时才会转化为对象
- 利用串池的机制,来避免重复创建字符串对象
- 字符串变量拼接的原理是
StringBuilder
- 字符串常量拼接的原理是编译器优化
- 可以使用
intern
方法,主动将串池中还没有的字符串对象放入串池中
**intern**
方法 1.8
调用字符串对象的 intern
方法,会将该字符串对象尝试放入到串池中
- 如果串池中没有该字符串对象,则放入成功
- 如果有该字符串对象,则放入失败,无论放入是否成功,都会返回串池中的字符串对象
注意:此时如果调用 intern
方法成功,堆内存与串池中的字符串对象是同一个对象;如果失败,则不是同一个对象
例1:
public class Main {
public static void main(String[] args) {
// "a" "b" 被放入串池中,str 则存在于堆内存之中
String str = new String("a") + new String("b");
// 调用 str 的 intern 方法,这时串池中没有 "ab" ,则会将该字符串对象放入到串池中,此时堆内存与串池中的 "ab" 是同一个对象
String st2 = str.intern();
// 给 str3 赋值,因为此时串池中已有 "ab" ,则直接将串池中的内容返回
String str3 = "ab";
// 因为堆内存与串池中的 "ab" 是同一个对象,所以以下两条语句打印的都为 true
System.out.println(str == st2);
System.out.println(str == str3);
}
}
例2:
public class Main {
// ["ab","a","b"]
public static void main(String[] args) {
// 此处创建字符串对象 "ab" ,因为串池中还没有 "ab" ,所以将其放入串池中
String x = "ab";
// "a" "b" 被放入串池中,s 则存在于堆内存之中
String s = new String("a") + new String("b");
// 将字符传s放入常量池,若果有则不会放入,如果没有则放入常量池,会把池中的对象返回
String s2 = s.intern();
System.out.println(s2 == x); // true
System.out.println(s == x); // false
}
}
常量池维护的是引用,所有的对象都在堆中
5.6 StringTable的位置
**jdk1.6**
StringTable 位置是在永久代中
**jdk1.8**
StringTable 位置是在堆中
因为垃圾回收不会发生在永久代
5.7 StringTable 垃圾回收
PermGen space
:1.6串池使用永久代的空间
# jdk8
-Xmx10m #指定堆内存大小
-XX: - UseGCOverheadLimit # + Use..打开开关,-Use..关闭开关
# jdk6
-XX:MaxPermSize = 10m
-XX:+PrintStringTableStatistics #打印字符串常量池信息
-XX:+PrintGCDetails
-verbose:gc #打印 gc 的次数,耗费时间等信息
/**
* 演示 StringTable 垃圾回收
* -Xmx10m -XX:+PrintStringTableStatistics -XX:+PrintGCDetails -verbose:gc
*/
public class Code_05_StringTableTest {
public static void main(String[] args) {
int i = 0;
try {
for(int j = 0; j < 10000; j++) { // j = 100, j = 10000
String.valueOf(j).intern();
i++;
}
}catch (Exception e) {
e.printStackTrace();
}finally {
System.out.println(i);
}
}
}
5.8 StringTable性能调优
- 因为StringTable是由HashTable实现的,所以可以适当增加HashTable桶的个数,来减少字符串放入串池所需要的时间
-XX:StringTableSize=桶个数(最少设置为 1009 以上)
这个地方因为当桶个数变少,导致哈希碰撞变多,哈希查找变慢,底层的红黑树不断在添加元素
- 考虑是否需要将字符串对象入池,可以通过
intern
方法减少重复入池
6. 直接内存
不属于Java虚拟机内存,属于系统内存
6.1 定义
**Direct Memory**
- 常见于 NIO 操作时,用于数据缓冲区
- 分配回收成本较高,但读写性能高
- 不受 JVM 内存回收管理
6.2 使用直接内存的好处
文件读写流程:
因为 java 不能直接操作文件管理,需要切换到内核态,使用本地方法进行操作,然后读取磁盘文件,会在系统内存中创建一个缓冲区,将数据读到系统缓冲区, 然后在将系统缓冲区数据,复制到 java 堆内存中。缺点是数据存储了两份,在系统内存中有一份,java 堆中有一份,造成了不必要的复制。
使用了 DirectBuffer 文件读取流程
直接内存是操作系统和 Java 代码都可以访问的一块区域,无需将代码从系统内存复制到 Java 堆内存,从而提高了效率
6.3 直接内存回收原理
总结:
- 使用了 Unsafe 对象完成直接内存的分配回收,并且回收需要主动调用
freeMemory
方法 ByteBuffer
的实现类内部,使用了Cleaner
(虚引用)来监测ByteBuffer
对象,一旦ByteBuffer
对象被垃圾回收,那么就会由ReferenceHandler
线程通过Cleaner
的clean
方法调用freeMemory
来释放直接内存
不建议普通程序员使用
unSafe
public class Code_06_DirectMemoryTest {
public static int _1GB = 1024 * 1024 * 1024;
public static void main(String[] args) throws IOException, NoSuchFieldException, IllegalAccessException {
// method();
method1();
}
// 演示 直接内存 是被 unsafe 创建与回收
// 推荐使用 unsafe去释放时间内存
private static void method1() throws IOException, NoSuchFieldException, IllegalAccessException {
// 获取unsafe对象
Field field = Unsafe.class.getDeclaredField("theUnsafe");
field.setAccessible(true);
Unsafe unsafe = (Unsafe)field.get(Unsafe.class);
// 分配内存
long base = unsafe.allocateMemory(_1GB); // 直接内存地址
unsafe.setMemory(base,_1GB, (byte)0);
System.in.read();
// 释放内存
unsafe.freeMemory(base); // 通过地址去释放
System.in.read();
}
// 演示 直接内存被 释放
private static void method() throws IOException {
ByteBuffer byteBuffer = ByteBuffer.allocateDirect(_1GB);
System.out.println("分配完毕");
System.in.read();
System.out.println("开始释放");
byteBuffer = null;
System.gc(); // 手动 gc
System.in.read();
}
}
直接内存的回收不是通过 JVM 的垃圾回收来释放的,而是通过**unsafe.freeMemory**
来手动释放
第一步:allocateDirect
的实现
public static ByteBuffer allocateDirect(int capacity) {
return new DirectByteBuffer(capacity);
}
底层是创建了一个 DirectByteBuffer
对象
第二步:DirectByteBuffer
类
DirectByteBuffer(int cap) { // package-private
super(-1, 0, cap, cap);
boolean pa = VM.isDirectMemoryPageAligned();
int ps = Bits.pageSize();
long size = Math.max(1L, (long)cap + (pa ? ps : 0));
Bits.reserveMemory(size, cap);
long base = 0;
try {
// 申请内存
base = unsafe.allocateMemory(size);
} catch (OutOfMemoryError x) {
Bits.unreserveMemory(size, cap);
throw x;
}
unsafe.setMemory(base, size, (byte) 0);
if (pa && (base % ps != 0)) {
// Round up to page boundary
address = base + ps - (base & (ps - 1));
} else {
address = base;
}
// 通过虚引用,来实现直接内存的释放,this为虚引用的实际对象, 第二个参数是一个回调,实现了 runnable 接口,run 方法中通过 unsafe 释放内存
cleaner = Cleaner.create(this, new Deallocator(base, size, cap)); // Deallocator是一个任务对象,因为他实现了runnable接口
att = null;
}
这里调用了一个 Cleaner
的 create
方法,且后台线程还会对虚引用的对象监测,如果虚引用的实际对象(这里是 DirectByteBuffer
)被回收以后,就会调用 Cleaner
的 clean
方法,来清除直接内存中占用的内存
public void clean() {
if (remove(this)) {
try {
// 都用函数的 run 方法, 释放内存
this.thunk.run();
} catch (final Throwable var2) {
AccessController.doPrivileged(new PrivilegedAction<Void>() {
public Void run() {
if (System.err != null) {
(new Error("Cleaner terminated abnormally", var2)).printStackTrace();
}
System.exit(1);
return null;
}
});
}
}
}
可以看到关键的一行代码, this.thunk.run()
,thunk
是 Runnable
对象。run
方法就是回调 Deallocator
中的 run
方法,
public void run() {
if (address == 0) {
// Paranoia
return;
}
// 释放内存
unsafe.freeMemory(address);
address = 0;
Bits.unreserveMemory(size, capacity);
}
注意:
/**
* -XX:+DisableExplicitGC 显示的
*/
private static void method() throws IOException {
ByteBuffer byteBuffer = ByteBuffer.allocateDirect(_1GB);
System.out.println("分配完毕");
System.in.read();
System.out.println("开始释放");
byteBuffer = null;
System.gc(); // 显示的垃圾回收,Full GC
System.in.read();
}
一般用 jvm 调优时,会加上下面的参数:
-XX:+DisableExplicitGC // 禁止显示的 GC
意思就是禁止我们手动的 GC,比如手动 System.gc()
无效,它是一种 full gc
,会回收新生代、老年代,会造成程序执行的时间比较长
不过还是建议使用