5. 方法区
5.1 定义
是所有java虚拟机线程共享的区域,这个和我们的堆有点类似,它和堆都是线程共享的区域。那么这块区域
它里面存储了什么信息呢?它存储了跟类的结构相关的一些信息,都有哪些呢,有类的成员变量、方法数据、
成员方法以及构造方法它们的代码部分,包括一些特殊方法(这里的特殊方法指的是类的构造器)。从这我们
大概看出来了,哦,方法区里存的就是跟类相关的一些信息,就包括它的方法呀、构造器呀成员方法呀等等,
这些信息都是在方法区里存储的,另外,运行时常量池也是存储在方法区的。
方法区在虚拟机启动时被创建,方法区逻辑上是堆的一个组成部分,或者大家可以这样理解,它在概念上定义
了这个方法区,但是不同的JVM厂商去做这个JVM实现时不一定会遵从JVM这种逻辑上的定义,逻辑上它是堆的
一部分,但具体实现上它究竟是不是堆的一部分,这个不同的JVM厂商实现方式上是不一样的。这个规范并不
强制方法区的位置,比如说Oracle的HotSpot虚拟机,它在JDK8以前它的实现叫做永久代,那这个永久代呢
他就是使用了堆内存的一部分作为这个方法区,但是到了1.8以后它把永久代移除了,换了一个实现,这个实现
呢叫做元空间,那么元空间呢用的就不是堆的内存了,它用的是本地内存,也就是操作系统的内存,所以不同的
实现呢它对这个方法区的位置选择上就有所不同。比如IBM的J9虚拟机的实现就没有把方法区放在堆中。好,这
是关于方法区概念上的一个明晰,所以网络上你见到有人提到永久代,那它只是HotSpot JDK在1.8以前的一个
实现而已,方法区是规范,永久代包括元空间都是它的一种实现而已。最后再看一点,就是对方法区的内存溢出
的一个定义,就是方法区如果申请内存时发现不足了,那么它也会让我们的虚拟机抛一个内存溢出错误。好,这
就是对方法区的定义。
5.2 组成
下面给出的是Oracle的HotSpot虚拟机它在1.6的内存结构中方法区大概的结构以及1.8中方法区的结构,通过下面这张图可能会更好的理解一下
1.6呢我们看,有堆,还有方法区(概念),当然其他的栈之类的就没有画了,避免干扰,那这个Method Area呢,
它只是一个概念上的东西,或者说规范里这么定义了,那1.6里它怎么实现的呢,它是用了一个永久代作为方法区
的实现,这个永久代里包含了这样几部分内容,它可以存储我们的类的信息,就是刚才提到了类元信息,就是类
里面的field、method、构造器呀等等,还有,当然类加载器也是在这里面存着,在永久代里存着,还有一块,
就是我们刚才提到的,叫运行时常量池,运行时常量池里有一个比较重要的,就是咱们通常俗称叫串池的东西,
它正式的名称叫StringTable,字符串表,好,这是1.6的一个结构啊,在1.6里方法区它的实现称之为永久代,
好,那到了1.8以后,永久代这个实现被废弃了,那么方法区当然它还是一个概念上的东西,那它的实现变成了一个
叫Metaspace(元空间),这个Metaspace里当然它还会存储类、类加载器以及常量池的信息,不过呢它已经不占用
我们的堆内存了,换句话说,它已经不是由JVM来管理它的内存结构了,它已经被移出到本地内存当中,所谓的本地内存
就是我们的这个操作系统内存,它里面还会跑一些其他进程,其中有一块是我们的元空间Metaspace,当然有一个不太
一样的地方,后面我们会重点去讲,其中这个串池StringTable它是不再放在这个元空间或者说这个方法区的实现内存
部分了,它被移到了我们的Heap堆里面
5.3 方法区内存溢出
-
1.8以前会导致永久代内存溢出
* 演示永久代内存溢出 java.lang.OutOfMemoryError: PermGen space * -XX:MaxPermSize=8m
-
1.8之后会导致元空间内存溢出
* 演示元空间内存溢出 java.lang.OutOfMemoryError: Metaspace * -XX:MaxMetaspaceSize=8m
方法区就是来存储类的数据的,那类能有多少啊,它怎么会导致方法区产生内存溢出呢,下面我们通过一个实例来演示一下方法区内存溢出的
一个现象
import jdk.internal.org.objectweb.asm.ClassWriter;
import jdk.internal.org.objectweb.asm.Opcodes;
/**
* 演示元空间内存溢出 java.lang.OutOfMemoryError: Metaspace
* -XX:MaxMetaspaceSize=8m 最大元空间大小设置为8m
*/
public class Demo09 extends ClassLoader{ // 可以用来加载类的二进制字节码
public static void main(String[] args) {
int j = 0;
try {
Demo09 test = new Demo09();
for(int i = 0; i < 10000; i++, j++){
// ClassWriter 作用是生成类的二进制字节码
ClassWriter cw = new ClassWriter(0);
// 版本号, public, 类名, 包名, 父类, 接口
cw.visit(Opcodes.V1_8, Opcodes.ACC_PUBLIC, "Class" + i, null, "java/lang/Object", null);
// 返回byte[]
byte[] code = cw.toByteArray();
// 执行了类的加载
test.defineClass("Class" + i, code, 0, code.length); //Class对象
}
} finally {
System.out.println(j);
}
}
}
测试类是ClassLoader的一个子类,也就意味着它是一个类加载器,类加载器能干嘛呢,它能够去加载类的二进制字节码,
这是它的作用。那我们创建这样一个类加载器,我就可以动态的去加载越来越多的字节码,好,我们往下看,for循环里面
其实就是希望去加载10000个新的类,这里用到了一个大家没见过的类,来生成类的二进制字节码,它的名字叫ClassWriter,
ClassWriter顾名思义,它就是用代码的方式来生成我们类的字节码的一个类,那么我们要调用它的visit方法来进行一个
生成,这个visit方法看着参数很多,但实际上比较好理解,它的参数1代表类的版本号,就是你生成的java类它是1.8的还是
1.7的等等,这是一个版本号,参数2呢是你类的一个访问修饰符,ACC_PUBLIC就代表将来生成的这个类是一个公开的类,
参数3代表类的名字,上面的“Class” + i,意味着将来类的名字是"Class0","Class1"······"Class9999",再往后的
参数就是它的包名,再往后的这个参数代表类的一个父类,最后的参数是类要实现的接口的名称。调完了以后,接下来会调一下
ClassWriter的toByteArrray()方法,它就是为生成类并且返回这个类字节码的一个byte数组,因为二进制字节码用byte来
数组表示嘛,之后使用测试类执行类的加载。 在1.8以后方法区的实现换成了一个叫元空间的实现,那么这个元空间呢它默认
情况下是使用的是系统内存而且默认情况下没有设置它的上限,所以你运行这段代码你并不会观察到方法区的内存溢出,
下面这张图是1.8的方法区也就是元空间溢出,显示的是元空间溢出
下面这张图是1.6的方法区也就是永久代溢出,显示的是永久代溢出
场景
- spring
- mybatis
5.4 运行时常量池
-
常量池,就是一张表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量等信息
-
运行时常量池,常量池是*.class文件中的,当该类被加载,它的常量池信息就会放入运行时常量池,并把里面的符号地址变为真实地址。
常量池是存在在*.class 文件中的,那当你这个类被加载到虚拟机中以后,那么它的常量池信息就会放入运行时常量池之中了,
也就是下图的Constant pool它只是一个类的常量池信息,这些信息它要运行时你得把它放在内存里对吧,那它放在内存中的位置
就称之为运行时常量池,并且它还要把里面的符号地址变为真实地址,
注意:常量池和运行时常量池是不一样的。
不管是1.6还是1.8,它们在方法区的组成中,都有一个叫运行时常量池的部分,那这个运行时常量池呢内部还有可能包含一个叫StringTable
的东西, 那它到底是什么呢?我们要讲解运行时常量池,那就得先来聊聊什么叫常量池。常量池的作用就是给指令提供,根据常量符号就以查表
的方式去找到它们,这样虚拟机指令才能成功的去执行。
我们先来看一个程序
package Memory.JVMRAM;
// 二进制字节码(类基本信息,常量池,类方法定义包含了虚拟机指令)
public class HelloWorld {
public static void main(String[] args) {
System.out.println("hello world");
}
}
这个程序要运行,肯定要先编译成二进制字节码,这个字节码它由哪些部分组成呢,
一般来说由这么三部分组成, 第一部分就是这个类的基本信息,第二部分就是类的
常量池,第三部分就是类中的方法定义, 这些类中的方法定义中就包含了我们的
虚拟机指令,
5.5 StringTable
接下来我们来学习运行时常量池中比较重要的部分:StringTable,也就是我们俗称的串池,在讲串池之前我们先来看几道面试题。
常量池和串池的关系
package Memory.JVMRAM;
// StringTable [ "a", "b", "ab"] hashtable 结构,不能扩容
public class HelloWorld {
// 常量池中的信息,都会被加载到运行时常量池中,这时 a b ab 都是常量池中的符号,还没有变成 java 字符串对象
// ldc #2 会把 a 符号变为 "a" 字符串对象
// ldc #3 会把 b 符号变为 "b" 字符串对象
// ldc #4 会把 ab 符号变为 "ab" 字符串对象
public static void main(String[] args) {
String s1 = "a"; // 懒惰的
String s2 = "b";
String s3 = "ab";
}
}
常量池最初存在于字节码文件中,当它加载的时候它就会被加载到运行时常量池,加载完了以后常量池中的信息还没有成为对象,
还没有变为java字符串对象,那什么时候会变成java字符串对象呢,等到你具体执行到引用它的那行代码上,在它变为“a”字符串
对象以后,它还要做一件事情,它会准备好一块空间,这块空间叫做StringTable就是咱们俗称的串池,刚开始呢,里面是空的,
没有内容,那a变为"a"字符串对象以后,我会把"a"作为key到StringTable里去找,看有没有一个取值相同的key,StringTable
它在数据结构上其实是一个哈希表,这个哈希表长度在一开始时长度是固定的,并且是不能扩容的,当然,第一次从这个串池里找
这个"a"是没有的,没有的话它就会把刚刚生成的"a"字符串对象放入串池,再往下执行会把b变为"b"字符串对象,当然,也是到
串池里先找一圈,发现没有,没有的话会把"b"字符串对象放进去,一定要注意,每个字符串对象并不是事先就给它放在串池里,
而是执行到用到它的这行代码才开始创建这个字符串对象,这个行为上其实是一个懒惰的行为,用到了才会创建,用不到不会说
提前创建。
1. 所有的字符串对象遇到时它都是懒惰的,遇不到它(还没有执行到它)它不会把这个相应的字符串对象创建出来,只有遇到了才创建。
2. 创建完这个字符串对象之后会把它放入串池,先到串池找,串池中没有,那就把它放入串池,如果串池中已经有了,那它就会使用
串池中的对象,总之,串池中的字符串对象只会存在一份,或者说,每一个取值不同的字符串对象在串池中是唯一的。
package Memory.JVMRAM;
// StringTable [ "a", "b", "ab"] hashtable 结构,不能扩容
public class HelloWorld {
// 常量池中的信息,都会被加载到运行时常量池中,这时 a b ab 都是常量池中的符号,还没有变成 java 字符串对象
// ldc #2 会把 a 符号变为 "a" 字符串对象
// ldc #3 会把 b 符号变为 "b" 字符串对象
// ldc #4 会把 ab 符号变为 "ab" 字符串对象
public static void main(String[] args) {
String s1 = "a"; // 懒惰的
String s2 = "b";
String s3 = "ab";
String s4 = s1 + s2; // new StringBuilder().append("a").append("b").toString()
// toString方法内部是new String("ab") 这里相当于创建了一个新的值为"ab"的字符串对象,然后把它存入s4
System.out.println(s3 == s4); // false
/*
s1、s2、s3是在串池当中的字符串对象,s4是一个新的字符串对象,虽然它俩的值是一样的,但是s3这个"ab"它是在串池中的,
而s4 new出来的字符串对象它是在堆里面的或者说它俩的位置是不一样的,是两个对象,所以打印false
*/
}
}
package Memory.JVMRAM;
// StringTable [ "a", "b", "ab"] hashtable 结构,不能扩容
public class HelloWorld {
// 常量池中的信息,都会被加载到运行时常量池中,这时 a b ab 都是常量池中的符号,还没有变成 java 字符串对象
// ldc #2 会把 a 符号变为 "a" 字符串对象
// ldc #3 会把 b 符号变为 "b" 字符串对象
// ldc #4 会把 ab 符号变为 "ab" 字符串对象
public static void main(String[] args) {
String s1 = "a"; // 懒惰的
String s2 = "b";
String s3 = "ab";
// 变量用 + 拼接 用StringBuilder这种方式拼接
String s4 = s1 + s2; // new StringBuilder().append("a").append("b").toString()
// 常量用 + 拼接 直接在编译期确定好了,直接拼接好
String s5 = "a" + "b"; // javac 在编译期间的优化,结果已经在编译期确定为ab,直接在串池中找
/*
对于s5 javac在编译期间的优化,它认为你 "a" 和 "b" 大家都是常量,它们的内容不会变了,
所以它俩拼接的结果是确定的,既然是确定的,那在编译期间我就能知道它的结果肯定是"ab"了,
不可能是别的值,而s4中的s1和s2是变量,既然是变量,那将来在运行的时候它引用的值就可能
被修改,那既然有可能发生修改,那它的结果是不能确定的,所以它在运行期间必须用StringBuilder
这种动态的方法去拼接,而对于s5已经能确定的结果,已经在编译期优化好了,所以不需要我们用StringBuilder
这种方法来拼接了
对于s5直接到串池里找"ab",找到了然后直接用串池的
*/
System.out.println(s3 == s5); // true
}
}
字符串延迟加载
不是一下子把很多字符串对象一下子放入串池,而是执行一行代码,遇到一个没见过的字符串对象,才放入串池。
5.6 StringTable 特性
注意:
常量池、运行时常量池、串池(StringTable)三者是不一样的
- 常量池,就是一张表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量等信息
- 运行时常量池,常量池是*.class文件中的,当该类被加载,它的常量池信息就会放入运行时常量池,并把里面的符号地址变为真实地址。
- 串池(StringTable),它在数据结构上其实是一个哈希表
1. 所有的字符串对象遇到时它都是懒惰的,遇不到它(还没有执行到它)它不会把这个相应的字符串对象创建出来,只有遇到了才创建。
2. 创建完这个字符串对象之后会把它放入串池,先到串池找,串池中没有,那就把它放入串池,如果串池中已经有了,那它就会使用
串池中的对象,总之,串池中的字符串对象只会存在一份,或者说,每一个取值不同的字符串对象在串池中是唯一的。
注意:对于字符串常量,会自动把字符串常量放入串池
对于字符串变量,不会自动把字符串变量放入串池,必须调用intern方法才可以把字符串变量放入串池
-
常量池中的字符串仅是符号,第一次用到时才变为对象
-
利用串池的机制,来避免重复创建字符串对象
-
字符串变量拼接的原理是 StringBuilder (1.8)
-
字符串常量拼接的原理是编译器优化
-
可以使用 intern 方法,主动将串池中还没有的字符串对象放入串池
- 1.8 将这个字符串对象尝试放入串池,如果有则并不会放入,如果没有则放入串池,会把串池中的对象返回。(无论串池中有没有这个字符串,都会返回串池中的字符串对象)
- 1.6 将这个字符串对象尝试放入串池,如果有则并不会放入,如果没有会把此对象复制一份放入串池(也就是说它又创建了一个新的对象,这个新的对象才会被放入串池,也就是调用intern的字符串对象和将来真正放入串池的字符串对象是两个对象),会把串池中的对象返回。(无论串池中有没有这个字符串,都会返回串池中的字符串对象)
下面这个是1.8下的分析
package Memory.JVMRAM;
public class Demo12 {
// ["a", "b"]
public static void main(String[] args) {
// 会自动把常量 "a", "b" 放入串池
// 堆 new String("a") new String("b") new String("ab")
String s = new String("a") + new String("b");
String s2 = s.intern(); // 将这个字符串对象尝试放入串池,如果有则不会放入,如果没有则放入串池,会把串池中的对象返回
// 后面这个"ab"常量会首先在串池中找,如果没有则创建并放入串池,如果有则直接返回串池中的
// 这里的"ab"就是用的上一步放入串池的"ab"对象
System.out.println(s2 == "ab"); // true
System.out.println(s == "ab"); // true
}
}
package Memory.JVMRAM;
public class Demo12 {
public static void main(String[] args) {
String x = "ab"; // ["ab"]
// 会自动把常量 "a", "b" 放入串池
// 堆 new String("a") new String("b") new String("ab")
String s = new String("a") + new String("b"); // ["ab", "a", "b"]
String s2 = s.intern(); // 将这个字符串对象尝试放入串池,如果有则不会放入,如果没有则放入串池,会把串池中的对象返回
// 后面这个"ab"常量会首先在串池中找,如果没有则创建并放入串池,如果有则直接返回串池中的
// 这里的"ab"就是用的上一步放入串池的"ab"对象
System.out.println(s2 == x); // true
System.out.println(s == x); // false
}
}
串池中目前只存的是常量的字符串值,动态拼接的字符串刚开始都在堆里,并没有放入串池,那我们能不能把我们动态创建的字符串对象
放入串池呢?答案是可以我们可以调用它的intern方法,intern方法呢是将这个字符串对象尝试放入串池,如果串池中有,则不会放入,
如果没有,就会把引用的对象放入串池,并且会把串池中的对象返回,
下面这个是1.6下的分析
面试题
在1.8下
package Memory.JVMRAM;
public class Demo10 {
public static void main(String[] args) {
String s1 = "a";
String s2 = "b";
String s3 = "a" + "b"; // 在编译期会优化为"ab"
String s4 = s1 + s2;// new StringBuilder().append("a").append("b").toString(),在堆中,因为是变量,所以并不会自动放入串池
String s5 = "ab"; // 直接引用常量池中已有的对象
String s6 = s4.intern();
// 问
System.out.println(s3 == s4); // false 一个是常量池中的"ab",一个是堆中的"ab"
System.out.println(s3 == s5); // true
System.out.println(s3 == s6); // true
String x2 = new String("c") + new String("d"); // new String("ab")
String x1 = "cd";
x2.intern();
// 问,如果调换了【最后两行代码】的位置呢,如果是jdk1.6呢
System.out.println(x1 == x2); // false
}
}
调换最后两行代码位置:
package Memory.JVMRAM;
public class Demo10 {
public static void main(String[] args) {
String s1 = "a";
String s2 = "b";
String s3 = "a" + "b"; // 在编译期会优化为"ab"
String s4 = s1 + s2;// new StringBuilder().append("a").append("b").toString(),在堆中,因为是变量,所以并不会自动放入串池
String s5 = "ab"; // 直接引用常量池中已有的对象
String s6 = s4.intern();
// 问
System.out.println(s3 == s4); // false 一个是常量池中的"ab",一个是堆中的"ab"
System.out.println(s3 == s5); // true
System.out.println(s3 == s6); // true
String x2 = new String("c") + new String("d"); // new String("ab")
x2.intern();
String x1 = "cd";
// 问,如果调换了【最后两行代码】的位置呢,如果是jdk1.6呢
System.out.println(x1 == x2); // true,如果是1.6下最后的结果是false
}
}
在1.6下
package Memory.JVMRAM;
public class Demo10 {
public static void main(String[] args) {
String s1 = "a";
String s2 = "b";
String s3 = "a" + "b"; // 在编译期会优化为"ab"
String s4 = s1 + s2;// new StringBuilder().append("a").append("b").toString(),在堆中,因为是变量,所以并不会自动放入串池
String s5 = "ab"; // 直接引用常量池中已有的对象
String s6 = s4.intern();
// 问
System.out.println(s3 == s4); // false 一个是常量池中的"ab",一个是堆中的"ab"
System.out.println(s3 == s5); // true
System.out.println(s3 == s6); // true
String x2 = new String("c") + new String("d"); // new String("ab")
x2.intern(); // 在1.6下做一个副本,将副本入池,并不会将x2入池
String x1 = "cd";
// 问,如果调换了【最后两行代码】的位置呢,如果是jdk1.6呢
System.out.println(x1 == x2); // false,如果是1.8下,最后的结果是true
}
}
5.7 StringTable 位置
在JVM1.6的时候,StringTable它是运行时常量池的一部分,和运行时常量池一起存储在永久代之中,而在1.7、1.8中,
StringTable就被转移到了堆中,为什么要做这个更改呢?就是因为永久代的内存回收效率很低,永久代需要负GC的时候
才会触发永久代的垃圾回收,但是负GC得等到老年代的空间不足才会触发,触发的时机就有点晚,间接的导致StringTable
它的回收效率并不高,其实StringTable它用的非常的频繁,它里面存的都是字符串常量,我们一个java应用程序中,大量的
字符串常量对象,都会分配到StringTable里,那如果StringTable的回收效率不高,就会占用大量的内存,容易产生永久代的
内存不足,所以基于这些缺点,从1.7开始,JVM的工程师就把StringTable转移到了堆里,堆里面的StringTable只需要
Minor GC就会触发StringTable的垃圾回收,一些串池中用不到的字符串常量对象也可以回收,这样就减轻了字符串对内存的占用。
所以这是1.6到1.7、1.8的一个优化。
那怎么证明 1.8 StringTable 的位置不一样呢?
我们设计这样一个案例,我们不断的往StringTable里存放大量的字符串对象,并且用一个长时间存在的对象来引用这些字符串对象,那是不是就势必造成它们的内存空间不足啊,那如果是在1.6的环境下去运行这个案例,那大家想,它内存空间不足一定是触发永久代的内存空间不足,但如果在1.7或1.8下去做同样的案例实验,它触发的内存不足肯定会报一个堆空间不足。所以我们就从这个角度去看看它出现内存溢出错误现象的不同来间接地推断StringTable存在的位置。
package Memory.JVMRAM;
import java.util.ArrayList;
import java.util.List;
/**
* 演示 StringTable 位置
* 在jdk8下设置 -Xmx10m -XX:-UseGCOverheadLimit
* 在jdk6下设置 -XX:MaxPermSize=10m
*/
public class Demo13 {
public static void main(String[] args) {
List<String> list = new ArrayList<>();
int i = 0;
try {
for(int j = 0; j < 260000; j++){
list.add(String.valueOf(j).intern()); // 新new一个字符串对象并将其放入StringTable中
i++;
}
} catch (Throwable e) {
e.printStackTrace();
} finally {
System.out.println(i);
}
}
}
在1.6下:
在1.8下:
VM options参数为:-Xmx10m
设置VM options参数为:-Xmx10m -XX:-UseGCOverheadLimit
1.8串池用的是堆空间
1.6串池用的是永久代
5.8 StringTable 垃圾回收机制
StringTable也是会受到垃圾回收的管理的,当内存空间不足时,StringTable中那些没有被引用的字符串常量,
仍然会被垃圾回收,这可能和大家想的不太一样,很多人都错误的认为,字符串常量它就是永久的了,也不会被
垃圾回收,事实证明,它是可以被垃圾回收的,下面我们通过一个案例,来给大家演示StringTable的垃圾回收现象。
package Memory.JVMRAM;
/**
* 演示 StringTable 垃圾回收
* 参数1:设置虚拟机堆内存的最大值 参数2:打印字符串表的统计信息,通过它我们可以清晰地看到串池中字符串实例的个数,包括占用的一些大小信息
* 最后两个参数是打印垃圾回收的详细信息,如果发生了垃圾回收,它会把垃圾回收的次数、花费的时间显示出来
* -Xmx10m -XX:+PrintStringTableStatistics -XX:+PrintGCDetails -verbose:gc
*/
public class Demo14 {
public static void main(String[] args) {
int i = 0;
try{
for(int j = 0; j < 100; j++){
String.valueOf(j).intern();
i++;
}
}catch (Throwable e){
e.printStackTrace();
}finally {
System.out.println(i);
}
}
}
加入100个字符串对象:
存10000个字符串对象:
这时发生了垃圾回收
通过上面的案例也确实证明了StringTable也是会发生垃圾回收的。
5.9 StringTable 性能调优
- 调整 -XX:StringTableSize=桶个数
- 考虑将字符串对象是否入池
StringTable底层是一个哈希表,哈希表的性能其实是和它的大小密切相关的,如果哈希表它的桶的个数比较多,
那么元素就比较分散,那么哈希碰撞的几率就会减少,那查找的速度也会变快,反之,如果桶的个数比较少,那么
它的哈希碰撞的几率就会增高,导致链表较长,从而查找的速度也会受到影响。那么我们就通过一个案例来给大家
演示一下StringTable的调优,其实主要就是调整HashTable桶的个数。
设置桶个数为200000:
桶的个数采取默认:
调整桶个数为1009:
所以如果系统里字符串常量非常多的话,建议适当的把StringTable桶的个数调整的比较大,这样让它有一个更好的哈希分布,
减少哈希冲突,就可以让我们的StringTable(串池)的效率得到明显的一个性能提升了。
有些同学可能会问,为什么我们要用StringTable呢,我们什么情况下需要用到StringTable呢。
下面的代码实例会告诉我们之间的差别。
不入串池:
入串池:
如果应用里有大量的字符串,而且这些字符串可能会存在重复的问题,那我们可以让字符串入池,来减少字符串对象个数,节约我们的
堆内存的使用。
6. 直接内存
一见到这个名词,可能有同学会有些疑问,比如说我们之前在介绍JVM的内存结构时,并没有一个区域叫直接内存,都是一些方法区、堆、
栈等,好像并没有一块区域叫直接内存,那么这个直接内存占用的是哪块内存呢,注意,它并不属于java虚拟机的内存管理,而是属于
系统内存,这个直接内存是操作系统的内存,那我们来看一下它到底是怎么回事。既然是操作系统内存,我们java程序能用它吗。
6.1 定义
Direct Memory
-
常见于NIO操作时,用于数据缓冲区。
主要用于NIO操作,在NIO进行数据读写时用作它的一个缓冲区内存,NIO里有一个ByteBuffer,ByteBuffer所分配和使用的 内存就是我们的直接内存,它不属于java虚拟机来管理,它是属于操作系统的内存。
-
分配回收成本较高,但读写性能高
因为它是属于操作系统内存,我Java想要直接用,分配也好、释放也好,就比较慢一些,但是它的读写性能非常的高。
-
不受JVM内存回收管理
java虚拟机做一些垃圾回收时,它不会说直接去释放Direct Memory分配的一些内存。既然这样,那Direct Memory会不会 引发一些内存泄露或者是内存溢出的这样的问题呢。好那我们来看一下
解释Direct Memory为什么读写性能高
使用ByteBuffer或者说使用了直接内存大文件的读写效率就会非常的高。为什么呢?我们需要先了解一下文件的读写过程,
使用传统IO读写:
java本身并不具备磁盘读写的能力,它要调用磁盘读写必须调用操作系统提供的函数,内部会调用本地方法,cpu的运行状态会从java的
用户态切换到内核态,这是cpu的状态改变,其次内存这边也会有一些相关的操作,什么操作呢,当切换到内核态以后,它就可以由
cpu的函数真正读取磁盘文件的内容了,比如说它从磁盘文件读取出来,读取进来以后,内核态的时候它会在操作系统内存中划出一块
缓冲区,这块缓冲区我们称之为系统缓冲区,磁盘的内容就会先读入到系统缓冲区中,它不可能说把全部的大量文件一次性的全读到
内存,那内存太紧张了,所以,利用缓冲区分次进行读取,读到系统缓冲区,但是注意,系统缓冲区我们Java的代码是不能运行的,
所以java这边会在堆内存中去分配一块java的缓冲区,那我们java的代码要能访问到刚才读取的那个流中的数据必须再从系统缓冲区
把这个数据间接地给它再读入到我们的java缓冲区,java到了下一个状态(用户态),再去调用输出流的写入操作,这样反复进行
读写、读写、读写。把整个文件复制到目标位置。但是我们也发现了问题所在,就是现在我们有两块内存,两块缓冲区,系统内存有个
缓冲区,java内存也有个缓冲区,那读取的时候必然涉及到数据得存两份,第一次读到系统缓冲区还不行,因为java代码访问不到它们,
所以你要把系统缓冲区数据再读入到java的缓冲区中,这样其实就造成了一个不必要的数据的复制,就是效率因而不是很高,
使用ByteBuffer(直接内存):
当我们调用ByteBuffer的allocateDirect()方法时,就是分配一块直接内存,这个方法调用了以后意味着我会在操作系统这边划出一块
缓冲区,就是上图的direct memory,这段区域跟我们之前不一样的地方在于这个操作系统划出的这块内存java代码可以直接访问,
换句话说,这块内存系统也可以用它,java代码也可以用它,它对两段代码都是可以共享的一块内存区域,这就叫直接内存,那好,
加入了直接内存以后,大家很明显的可以看出来了,磁盘文件读取的时候它会把它读到直接内存,而我们的java代码也可以访问到直接内存,
其实就比刚才我们那种代码就少了一次缓冲区的复制操作,所以速度就得到了成倍的一个提升,这就是我们直接内存它带来的一个好处。
它也是适合做这种文件的IO操作,
看一下Direct Memory是否会产生内存泄漏或者是内存溢出这样的问题。
演示直接内存内存溢出的代码
package Memory.JVMRAM;
import java.nio.ByteBuffer;
import java.util.ArrayList;
import java.util.List;
/**
* 演示直接内存溢出
*/
public class Demo16 {
static int _100Mb = 1024 * 1024 *1024;
public static void main(String[] args) {
List<ByteBuffer> list = new ArrayList<>();
int i = 0;
try {
while(true){
// 分配100Mb直接内存
ByteBuffer byteBuffer = ByteBuffer.allocateDirect(_100Mb);
list.add(byteBuffer);
i++;
}
} finally {
System.out.println(i);
}
// 方法区是jvm规范,jdk6中对方法区的实现称为永久代
// jdk8中对方法区的实现称为元空间
}
}
6.2 分配和回收原理
- 使用了Unsafe对象完成直接内存的分配回收,并且回收需要主动调用 freeeMemory() 方法
- ByteBuffer 的实现类内部,使用了 Cleaner (虚引用)来监测 ByteBuffer 对象,一旦ByteBuffer对象被垃圾回收,那么就会由 ReferenceHandler 线程通过 Cleaner 的 clean 方法调用 unsafe的freeMemory()方法 来释放直接内存。
既然直接内存不受jvm虚拟机内存管理,那么它所分配的内存会不会被正确回收,那它底层又是怎么实现的,我们来一起看一下。
package Memory.JVMRAM;
import java.io.IOException;
import java.nio.ByteBuffer;
public class Demo17 {
static int _1Gb = 1024 * 1024 * 1024;
public static void main(String[] args) throws IOException {
ByteBuffer byteBuffer = ByteBuffer.allocateDirect(_1Gb);
System.out.println("分配完毕···");
System.in.read();
System.out.println("开始释放···");
byteBuffer = null;
System.gc();
System.in.read();
}
}
package Memory.JVMRAM;
import sun.misc.Unsafe;
import java.io.IOException;
import java.lang.reflect.Field;
public class Demo18 {
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();
}
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);
}
}
}
我们发现在垃圾回收之后,idea占用内存减少了1Gb,那这里可能就有些同学就会有个误区,是不是因为垃圾回收导致了我们直接内存
被释放掉了呢,刚才不是还说垃圾回收不会管理直接内存吗,为什么这里垃圾回收会导致我们直接内存被释放掉,当然这是好事,不会造成
内存泄漏,但是它的原理是怎样的呢。
有一个ByteBuffer底层分配和释放内存的相关的的类型,这个类型是java里一个非常底层的类,叫做Unsafe,这个Unsafe它就可以
干一些分配直接内存、释放直接内存的事,但是一般不建议普通程序员来使用Unsafe类,都是jdk内存自己去使用Unsafe,现在为了向
大家演示它的工作流程,就想办法获得了一个Unsafe对象,Unsafe对象不能直接获取,所以我们必须通过反射的方法拿到Unsafe中
它的一个静态成员变量,这个静态成员变量就是将来我们要用的unsafe对象,通过一些不寻常的手段拿到unsafe对象,
拿到unsafe对象以后我们可以调用它的一些方法来分配和释放内存,
上面的案例验证了直接内存它的分配和释放是通过一个Unsafe对象来管理的,不是我们的垃圾回收,垃圾回收只能释放java的内存,
换句话说,垃圾回收,它对java中无用的对象它的释放是自动的,不需要我们手动来调用任何的方法,但直接内存不同,它必须由
我们来主动调用freeMemory()方法才能完成对直接内存的释放。
接下来我们来分析一下ByteBuffer它的源码,看看它是怎么与刚才我们的Unsafe对象关联在一起的。
// ByteBuffer内部源码
public static ByteBuffer allocateDirect(int capacity) {
return new DirectByteBuffer(capacity);
}
// 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;
}
cleaner = Cleaner.create(this, new Deallocator(base, size, cap));
att = null;
}
// 回调任务对象
private static class Deallocator
implements Runnable
{
private static Unsafe unsafe = Unsafe.getUnsafe();
private long address;
private long size;
private int capacity;
private Deallocator(long address, long size, int capacity) {
assert (address != 0);
this.address = address;
this.size = size;
this.capacity = capacity;
}
public void run() {
if (address == 0) {
// Paranoia
return;
}
unsafe.freeMemory(address);
address = 0;
Bits.unreserveMemory(size, capacity);
}
}
// Cleaner类,它是虚引用的
public class Cleaner extends PhantomReference<Object> {
private static final ReferenceQueue<Object> dummyQueue = new ReferenceQueue();
private static Cleaner first = null;
private Cleaner next = null;
private Cleaner prev = null;
private final Runnable thunk;
private static synchronized Cleaner add(Cleaner var0) {
if (first != null) {
var0.next = first;
first.prev = var0;
}
first = var0;
return var0;
}
private static synchronized boolean remove(Cleaner var0) {
if (var0.next == var0) {
return false;
} else {
if (first == var0) {
if (var0.next != null) {
first = var0.next;
} else {
first = var0.prev;
}
}
if (var0.next != null) {
var0.next.prev = var0.prev;
}
if (var0.prev != null) {
var0.prev.next = var0.next;
}
var0.next = var0;
var0.prev = var0;
return true;
}
}
private Cleaner(Object var1, Runnable var2) {
super(var1, dummyQueue);
this.thunk = var2;
}
public static Cleaner create(Object var0, Runnable var1) {
return var1 == null ? null : add(new Cleaner(var0, var1));
}
public void clean() {
if (remove(this)) {
try {
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;
}
});
}
}
}
}
我们可以看到,在DirectByteBuffer构造器里就调用了unsafe对象的方法,这个unsafe对象调用了它的allocateMemory()、setMemory()这些方法完成了对直接内存的分配,那在什么时候又会发生内存的释放呢,直接内存释放必须主动地调用unsafe的freeMemory()方法,这里的关键在于DirectByteBuffer构造器里面一个叫cleaner的特殊的对象,关于Cleaner我们看后面关联的一个的回调任务对象Deallocator,在它的run()方法里我们看到了unsafe.freeMemory(address),所以发现确实将来还是得主动地调用Unsafe的freeMemory()来释放刚才的直接内存,那关键点就集中到了cleaner,Cleaner在java类库里它是一个特殊的类型,叫做虚引用类型,它的特点是当它所关联的这个对象被回收时,那么cleaner呢就会触发虚引用的一个clean方法,它关联的是this,也就是bytebuffer,所以当我们的bytebuffer被垃圾回收时,bytebuffer还是我们的java对象,还是受垃圾回收的管理,当bytebuffer这个对象自己被垃圾回收掉时,它就会触发虚引用对象cleaner对象中的clean方法,Cleaner是虚引用的,它的clean()方法就会执行刚才我们的任务对象的run方法,Cleaner的clean()不是在我们主线程执行的,后台有一个叫reference handler的线程,它专门去监测虚引用对象,一旦虚引用对象所关联的实际对象,就是directByteBuffer被回收掉以后,它就会调用虚引用对象里的clean()方法,然后去执行任务对象,任务对象刚才我们已经看到了,任务对象里面再调用freeMemory()真正去释放直接内存,所以直接内存的释放是借助了java中虚引用的机制,
禁用显示的垃圾回收
package Memory.JVMRAM;
import java.io.IOException;
import java.nio.ByteBuffer;
public class Demo19 {
static int _1Gb = 1024 * 1024 * 1024;
/**
* 禁用显式的垃圾回收:让代码中的System.gc()无效
* -XX:+DisableExplicitGC 显示的
*/
public static void main(String[] args) 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();
}
}
Full GC,比较影响性能,不光要回收新生代还要回收老年代,所以会造成程序暂停时间比较长,所以为了防止一些程序员不小心在
自己代码里经常写System.gc()触发显式的垃圾回收,我们做JVM调优时经常会加上-XX:+DisableExplicitGC虚拟机参数,
禁用这种显式的垃圾回收,也就是让System.gc()无效,达到这么一个效果。但是加上这个参数可能就会影响到我们刚才说的
这套直接内存的回收机制。一旦禁用System.gc()以后,你就会发现可能别的代码没什么问题,但是对于直接内存的使用还是
有影响的,因为我们不通过代码显式的去回收掉ByteBuffer对象的话,那这个ByteBuffer只能等到真正的垃圾回收发生时
才会被清理,从而它所对应的那块直接内存才会被释放掉,所以这就造成了一个直接内存可能会占用较大,长时间得不到释放的
一个现象,那怎么解决这个问题呢,可以用unsafe对象直接调用freeMemory()的方式来释放直接内存,所以这是一种直接内存
使用的比较多的时候我们对直接内存的一种管理方式,换句话说,还是我们手动的管理这块直接内存,推荐用unsafe的freeMemory()
来手动管理这个直接内存,