目录
方法区的概念
方法区内存溢出
方法区的组成
运行时常量池
常量池
运行时常量池
StringTable
StringTable 字符串变量的拼接
StringTable 编译期优化
StringTable的延迟加载
StringTable intern() 1.8
StringTable intern() 1.6
回归StringTable的面试题
总结StringTable的特性
方法区的概念
方法区与 Java 堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
也就是说,方法区存储的信息主要是跟类相关的信息。
方法区在虚拟机启动的时候被创建,它逻辑上是堆的一部分。也就是说,它在概念上定义了方法区是堆的一部分,但实际上它只是个规范,不同的虚拟机厂家去实现JVM时,不一定会遵从这个JVM的规范。
例如HotSpot的虚拟机,在JDK8以前,它的对方法区的实现叫做永久代,它就是使用了堆的一部分,作为方法区。而在JDK8以后,移除了永久代的实现,换了一种元空间的实现,元空间使用了操作系统的一部分(一些内存 )作为了方法区,而不再是堆的一部分。
方法区内存溢出
方法区使用不当会有内存溢出的问题(OutOfMemoryError),测试代码可以写个循环制造类加载的伪代码,来模拟内存溢出的情况(需要调小虚拟机方法区内存的参数,更容易演示出来)。这里不做伪代码的演示。
// 1.8以后-XX:MaxmetaspaceSize=8m
1.8以前是永久代的内存溢出,因为使用了堆内存的一部分,以及垃圾回收效率不高,它更容易溢出。1.8以后因为使用了操作系统的内存作为方法区的一部分,垃圾回收效率也高了一些,相对来说没有那么容易溢出。
实际场景,比如Spring,Mybatis框架动态加载类,动态生成类的场景是很多的,使用不当都会导致方法区内存溢出。
方法区的组成
不管是JDK1.8以前,还是JDK1.8以后,方法区都由运行时常量池,Class,ClassLoader组成。
运行时常量池 常量池
要了解运行时常量池,先了解一下什么是常量池。
常量池,就是一张表,虚拟机指令根据这张常量表找到要执行的类名,方法名,参数类型,字母量信息。
拿以下代码来讲,运行这段代码,首先需要把这段代码编译成二进制字节码。
这个二进制的字节码从大方向上来讲,主要由3个方法组成:类的基本信息,常量池,类方法定义,包括虚拟机指令。
public class StringTableTest2 { public static void main(String[] args) { System.out.println("Hello world"); }}
对这段代码进行一个反编译:
javap -v StringTableTest2.class
我们得到一串的字节码, 分了三个部分,第一部分就是类的一些信息,第二部分就是常量池,第三部分就是关于类的方法定义。
第一部分和第二部分:
第三部分:
查看main方法,从有行号0,3,5,8的地方就是虚拟机指令了,也就是说
System.out.println("Hello world");
在虚拟机执行的就是这4行指令,那么这4行指令和常量池有什么关系?
解释器去执行这4行指令的时候要怎么做呢,需要一个查表翻译,看看#2,#3,#4究竟代表什么对应什么。
比如在 #2
它又指向了#21,#22
最终#28,#29,#30
具体的行号代表有更仔细的解释,但是可以明白,常量池的作用给指令提供常量符号,让我们去找到它
运行时常量池运行时常量池,常量池是*.class文件中的,当该类被加载,它的常量池信息就会放入运行时常量池,并把里面的符号变成真实地址。
刚刚我们通过#2这种符号查找指令,但是在运行时,这些符号就是真实的内存地址,通过真实的内存地址去查找指令。
StringTable
引入一道面试题
public class StringTableTest1 { public static void main(String[] args) { 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); System.out.println(s3 == s5); System.out.println(s3 == s6); String x2 = new String("c") + new String("d"); String x1 = "cd"; x2.intern(); // 问题:如果调换x1,x2的位置?如果是jdk 1.6 呢? System.out.println(x1 == x2); }}
为了搞清楚这个面试题,需要从字节码角度和常量池角度来解释,为了解释清楚,用了以下代码举例。
StringTable 字符串变量的拼接
public class StringTableTest3 { public static void main(String[] args) { String s1 = "a"; String s2 = "b"; String s3 = "ab"; }}
反编译这段代码, ldc #2 是在#2去加载了一个字符串对象,astore_1是把这个字符串对象,存入了局部变量里的1位置里去。
常量池中的信息,都会被加载到运行时常量池中。这时 a,b,ab都是常量池中的符号,还没有变为java字符串对象。
ldc #2 会把a符号 变为“a” 字符串对象,并准备好一块空间(StringTable),它实际上是一个Hash表,一开始是空的,在放入之前,它会去看StringTable里有没有这样一个“a”对象。没有的话会把它放入StringTable里。
不是每一个对象都事先放入StringTable里的,而是执行到这行代码的时候,才会放入,这是一种懒加载的方式。
在这段代码里新增一行代码
String s4 = s1 + s2;
再反编译看看:
它创建了一个StringBuilder的对象,然后调用了它的无参构造,然后加载了s1的参数,接下来调用了StringBuilder的append方法,然后加载了s2的参数,继续调用了append方法,然后调用toString方法。
String s4 = s1+s2 ,实际上是 new StringBuilder().append("a").append("b").toString()
然后astore指令把 s4 存储到了局部变量表。
StringBuilder的toString()方法,是创建了一个新的String()对象。
现在思考 ,s3 是否等于s4呢?答案显而易见,s3的变量“ab”存在于常量池,而s4的变量“ab”是一个new String对象,存储在堆里。
所以他们是不一样的内存地址,答案为false
public class StringTableTest3 { public static void main(String[] args) { String s1 = "a"; String s2 = "b"; String s3 = "ab"; String s4 = s1 + s2; System.out.println(s3 == s4); }}
StringTable 编译期优化
再新增一行代码,String s5 = "a" + "b",它是否和s3相等?
public static void main(String[] args) { String s1 = "a"; String s2 = "b"; String s3 = "ab"; String s4 = s1 + s2; System.out.println(s3 == s4); String s5 = "a" + "b"; System.out.println(s3 == s5); }
反编译一下,在执行到 变量 s5的时候,它的字节码指令和执行变量s3的指令一致:去#4找到String ab
在执行s3的时候因为StringTable还没有常量ab,所以将ab存入常量池。
在执行s5的时候 去#4查找常量ab,此时常量池已有常量ab
这是javac在编译期间的优化,因为是两个常量的相加,结果在编译期已经确定是ab,会放入串池。
StringTable的延迟加载
在上面的时候,提到过,StringTable里的常量,不是一开始就放入的,而是执行的时候才放入的。
对以下代码进行debug可以观察到一个延迟加载的现象:
public class StringTableTest4 { public static void main(String[] args) { String s1 = "1";//2282 String s2 = "2"; String s3 = "3"; String s4 = "4"; String s5 = "5"; String s6 = "6"; String s7 = "7"; String s8 = "8"; String s9 = "9"; String s10 = "10";//2292 String s11 = "1"; String s12 = "2"; String s13 = "3"; String s14 = "4"; String s15 = "5"; String s16 = "6"; String s17 = "7"; String s18 = "8"; String s19 = "9"; String s20 = "10"; }}
在执行到s1时,memory里显示的char[]的个数是2282个,往下执行直到s10,个数依次累加到2292。
s1-s10的变量都加载进StringTable以后,s11-s20加载以后count数没有再增加,因为StringTable里已经存在这些值了。
StringTable intern() 1.8
可以手动的把一个变量放入串池里,这个方法是String的intern()方法,当调用 intern 方法时,如果常量池中已经该字符串,则返回池中的字符串;否则将此字符串添加到常量池中,并返回字符串的引用。
第一个例子,一开始串池里没有ab
public static void main(String[] args) { // new String("ab") 存在于堆中 String s1 = new String("a") + new String("b"); // 当调用 intern 方法时,如果常量池中已经该字符串,则返回池中的字符串;否则将此字符串添加到常量池中,并返回字符串的引用。 // 将"ab"放入串池(之前串池没有"ab"),返回了“ab”的引用地址 String s2 = s1.intern(); // 因为 s2 引用了串池的“ab”,所以与“ab”(此时串池已有)比较内存地址值 是true System.out.println(s2 == "ab"); // intern()方法后,串池存入了“ab”的引用 这个引用地址就是s1的“ab”引用地址 因此也为true System.out.println(s1 == "ab"); }
第二个例子,先让ab存在于串池:
public static void main(String[] args) { // "ab" 放入了串池 String x = "ab"; // new String("ab") 在堆中新开辟了一个内存地址存放 String s1 = new String("a") + new String("b"); // 将s1存放入串池 因为串池已有"ab",将返回串池里的“ab”内存地址 String s2 = s1.intern(); // true 因为s2用了串池里的“ab” 与 x 一致 System.out.println(s2 == x); // 堆里的“ab”与串池里的“ab”相比较 所以是false System.out.println(s1 == x); }
StringTable intern() 1.6
1.6 会将字符串对象尝试放入串池,如果有则不会放入,如果没有,会把对象复制一份,放入串池,并把串池对象返回。
也就是又创建了一个新的对象,并把这个对象放入了串池。
于是在1.6中,例1有了不一样的结果。
public static void main(String[] args) { // StringTable["a","b"] "ab"对象存放于堆中 String s1 = new String("a") + new String("b"); // 1.6 jdk 调用了 intern 方法 此时串池没有 “ab”对象 拷贝了一份放入串池(新的一个对象 因此引用地址与s1的“ab”对象内存地址不一样) String s2 = s1.intern(); // StringTable 里已有“ab”,因此s2 与 常量“ab” 相等 System.out.println(s2 == "ab"); // s1的引用地址 和StringTable里ab" 不是一个引用地址 所以是false System.out.println(s1 == "ab"); }
回归StringTable的面试题
通过以上例子,了解了StringTable的特性以后,再看这道面试题,就能知道答案了。
s3==s4?
s3是常量“a”和“b”的拼接,在编译期优化的时候把“ab”放入了串池,s4的本质是new String(“ab”),存放于堆中,因此答案是false
s3==s5?
s5是常量“ab”,串池已存在,因此不会创建新的对象,所以答案为true
s3==s6?
s4是变量s1和s2的拼接,在底层的原理是 new StringBuilder.append("a").append("b"),是new String("ab"),调用intern方法后会尝试将其放入串池,因为串池有了常量ab了,所以放入失败,返回了串池里ab的引用。所以s6的值来自于串池里的ab。
因此s3==s6 是true
x1==x2?
x2 new String("c")+new String("d") 本质也是new String("cd"),调用了intern方法后将cd放入串池。
x1 在串池里放入了cd
x2.intern 尝试把cd放入串池,因为串池已有cd,放入失败。但x2的ab仍然在堆中,x1的ab在串池。所以x1 == x2 的答案是false
public class StringTableTest1 { public static void main(String[] args) { 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); System.out.println(s3 == s5); System.out.println(s3 == s6); String x2 = new String("c") + new String("d"); String x1 = "cd"; x2.intern(); // 问题:如果调换x1,x2的位置?如果是jdk 1.6 呢? System.out.println(x1 == x2); }}
如果调换x1,x2的位置 ,x2的“cd”的引用在堆中,intern后将“cd”的引用入池,x1的“cd”来自串池“cd”,是同一个对象,答案为false。
String x2 = new String("c") + new String("d"); x2.intern(); String x1 = "cd"; System.out.println(x1 == x2);
写到这里的时候还是觉得自己没有理解清楚入串池的究竟是不是引用地址,参考了下这篇文章。
String类和常量池内存分析例子以及8种基本类型_砖业洋__-CSDN博客
总结StringTable的特性
常量池中的字符串仅是符号,第一次用到时才变为对象。
利用串池的机制,来避免重复创建字符串对象
字符串变量拼接的原理是 StringBuilder(1.8)
字符串常量拼接的原理是编译期优化