在之前在看jvm虚拟机的书,结果看到常量池的时候,看得一脸懵逼,去网上查也是云里雾里.所以这里自己花几天摸清楚后,在这里做个笔记
因为字符串常量池现在网上争议颇多,官方文档也说得很含糊,以下几点并不是很明确:
比如jdk1.7后的字符串常量池所存储的是否都是引用?还是对象和引用都有?
jdk1.7后intern方法将字符串放到常量池,到底是在堆中创建对象,然后放的堆中的对象的引用,还是在直接常量池新建一个对象?
以下若有说得不对之处,欢迎大佬们指出赐教
1. 几种常量区
Java中的常量池分为三种类型:
- 类文件中常量池(The Constant Pool)
- 运行时常量池(The Run-Time Constant Pool)
- String常量池
1.1 类文件中的常量池
我们写的程序源码经过javac的编译会转变成class类型的文件,也被称为字节码文件,该文件记录了整个程序或者说当前这个类的所有相关信息,其中有一个很重要的部分被称为常量池。
常量池存放编译器生成的各种字面量(Literal)和符号引用(Symbolic References);
- 字面量:
- 1.文本字符串
- 2.八种基本类型的值
- 3.被声明为final的常量等;
- 符号引用:
- 1.类和接口的全限定名(Fully Qualified Name)
- 2.字段的名称和描述符(Descriptor)
- 3.方法的名称和描述符
这里写一个Test.java
public class Test {
private String str = "hello";
private int nn = 233;
private Integer mm = 332;
void aa(){
System.out.println(65535);
}
}
在cmd中, 用javac Test.java
编译成class文件,然后再用javap -v Test.class
查看,就可以看到有一个常量池,里面正是上面所说的字面量和符号引用
1.2 运行时常量池
相较于Class文件常量池,运行时常量池更具动态性,在运行期间也可以将新的变量放入常量池中,而不是一定要在编译时确定的常量才能放入。最主要的运用便是String类的intern()方法
- jdk1.6及以下版本:它位于永久代-方法区中
- jdk1.7,逐步开始抛弃方法区,将字符串常量池移至堆区.这里jdk文档并没有说运行时常量池是否也跟着移到堆区,也就是说运行时常量依然在方法区,永久代仍存在于JDK1.7中
绿色是线程私有,蓝色是线程共享。
jdk文档:
https://www.oracle.com/technetwork/java/javase/jdk7-relnotes-418459.html#jdk7changes
区域: HotSpot
概要:在JDK 7中,实现的字符串不再分配在Java堆的永久生成中,而是分配在Java堆的主要部分(称为年轻和老一代),以及另一个应用程序创建的对象。此更改将导致更多数据驻留在主Java堆中,并且永久生成中的数据更少,因此可能需要调整堆大小。由于此更改,大多数应用程序只会看到堆使用中的相对较小的差异,但是加载许多类或大量使用该String.intern()方法的较大应用程序将看到更显着的差异。
- jdk1.8,JVM移除了永久区,取而代之的是元空间(Metaspace) ,也就是将本地内存用来存储.容量取决于是32位或是64位操作系统的可用虚拟内存大小).这时候字符串常量池还在堆, 运行时常量池还在方法区, 只不过方法区的实现从永久代变成了元空间.
1.2.1 运行时常量的包装类
我们知道,Integer是int的包装类,而包装类是对象,创建对象就需要消耗资源.
java中的基本类型的包装类基本都实现了常量池技术.
即Byte,Short,Integer,Long,Character,Boolean。这5种包装类默认创建了数值[-128,127]的相应类型的缓存数据
但是超出此范围仍然会去创建新的对象。 两种浮点数类型的包装类Float,Double并没有实现常量池技术。
比如我们运行下面的代码:
@Test
public void fun07(){
Integer a = 10;
Integer b = 10;
System.out.println(a == b);
Integer c = 200;
Integer d = 200;
System.out.println(c == d);
Long e = 200L;
Long f = 200L;
System.out.println(e == f);
Long g = 20L;
Long h = 20L;
System.out.println(g == h);
Double i = 20.0;
Double j = 20.0;
System.out.println(i == j);
}
得到的是:
true
false
false
true
false
需要注意的是:
- 使用new,仍然会创建新对象. 比如 Integer i1 = new Integer(40);
- Integer a = 40;Java在编译的时候会直接将代码封装成Integer a =Integer.valueOf(40);,从而使用常量池中的对象。
1.3 字符串常量池的移动
在jdk1.6及之前,字符串常量池是属于运行时常量池中的
jdk1.7 , 也就是上面说的,字符串常量池从方法区中被单独拿到堆中了
2. 字符串常量池
- 字符串常量池,即为了避免多次创建字符串对象,而将字符串在jvm中开辟一块空间,储存不重复的字符串.
- 在直接使用双引号""声明字符串的时候, java都会去常量池找有没有这个相同的字符串,如果有,则将常量池的引用返回给变量. 如果没有,会在字符串常量池中创建一个对象,然后返回这个对象的引用
- 使用new关键字创建,比如
String a = new String("hello");
, 这里可能创建两个对象. 一个是用双引号括起来的hello
,按照上面的逻辑, 如果常量池没有,创建一个对象. 另一个是必须会创建的,new 关键字必然会在堆中创建一个新对象. 最终返回的是new 关键词创建的对象的地址
2.1 字符串怎样加入到常量池中?
这里先说一说String的intern
方法,这个方法,能动态的将字符串加入到常量池中.
/**
* Returns a canonical representation for the string object.
* <p>
* A pool of strings, initially empty, is maintained privately by the
* class {@code String}.
* <p>
* When the intern method is invoked, if the pool already contains a
* string equal to this {@code String} object as determined by
* the {@link #equals(Object)} method, then the string from the pool is
* returned. Otherwise, this {@code String} object is added to the
* pool and a reference to this {@code String} object is returned.
* <p>
* It follows that for any two strings {@code s} and {@code t},
* {@code s.intern() == t.intern()} is {@code true}
* if and only if {@code s.equals(t)} is {@code true}.
* <p>
* All literal strings and string-valued constant expressions are
* interned. String literals are defined in section 3.10.5 of the
* <cite>The Java™ Language Specification</cite>.
*
* @return a string that has the same contents as this string, but is
* guaranteed to be from a pool of unique strings.
*/
public native String intern();
简单来说就是intern用来返回常量池中的某字符串,如果常量池中已经存在该字符串,则直接返回常量池中该对象的引用。否则,在常量池中加入该对象,然后 返回引用。(这里的加入该对象对于java1.7前后的处理方式不同, 往后看)
看下面这个例子
@Test
public void fun06() {
String str = "aa";
String str2 = new String("aa");
System.out.println(str == str2);
String str3 = str2.intern();
System.out.println(str == str3);
}
结果是:
false
true
这个下面再解释.
2.2 不同的字符串创建方式
下面说的常量池都是字符串常量池
- 情况一: 直接用双引号声明
String s1= "ram";
String s2= "ram";
String s2= "ram";
当我们第一次执行String s1 =“ram”时,JVM将在常量池中创建一个新对象,s1将引用该对象,即“ram”。
当我们第二次执行String s2 =“ram”时,JVM将检查字符串常量池中是否存在任何值为“ram”的对象。截至现在是的,我们已经在“字符串常量”池中存在“ram”,因此它不会创建新对象,只是s2引用变量将指向现有的“ram”对象。对于String s3 =“ram”,将发生相同的过程。
- 情况二: 使用new关键字
String str1 = new String(“mohan”);
jvm会第一步检查常量池是否有"mohan", 发现没有,创建一个新对象在常量池中.然后因为有new关键词,所以会在堆中创建对象,然后将这个对象的地址引用返回
- 情况三: 组合
String st1 =“rakesh”;
String st2 = new String(“rakesh”);
当我们执行String st1 =“rakesh”时,JVM将在常量池中创建一个对象,st1将引用它
执行第二步的时候,JVM将检查字符串常量池中是否有任何可用的名称为“rakesh”的对象,现在是的,我们已经在字符串常量池中使用了“rakesh”,因此JVM不会在字符串常量池中创建任何对象。
因为有new关键词,它将在堆中创建一个对象,st2将指向该对象。
当我们使用new运算符创建String对象时,JVM将首先在SCP(字符串常量池)中检查,该对象是否可用。如果SCP内部没有该对象,JVM将创建两个对象,一个在SCP内部,另一个在SCP外部。但是如果JVM在SCP中找到相同的对象,它只会在SCP外部创建一个对象。
再看下面的代码
public class StringExample1 {
public static void main(String[] args) {
String s1 = "india";
String s2 = s1 + "is";
s1.concat("great");
s2.concat(s1);
s1 += "country";
System.out.println(s1 + " " + s2);
}
}
将总共创建8个对象
-
"india"会直接在scp(字符串常量池)中创建
-
s1 + “is”, 此时因为编译器无法知道s1是什么,而字符串String是一个final不可修改的类, 所以这里的 " + " 会被编译成(等同于):
String s2 = new StringBuilder("india").append("is").toString();
我们看看StringBuilder的toString方法:
也就是会堆中重新new一个字符串对象 -
s1.concat(“great”)也一样,会重新new一个String对象,但是因为"great"是双引号声明的,所以同样在scp中创建一个"great"对象
-
s2.concat,同理,会重新new一个String对象
-
s1 += “country” 也就是 s1 = s1 + “country”,和2一样,创建两个对象
总结
- 对于双引号""直接声明的字符串, 比如
String a = "aa"
,会直接在scp中创建对象 - 对于两个声明的字符串使用 " + " 拼接, 因为jvm的优化,会将拼接后的结果放入常量池.但是两个声明的字符串不会,(String s = “abc”+ “def”, 会直接生成“abcdef"字符串常量 而不把 “abc” "def"放进常量池)
- 对于其中有一个不是声明的字符串,用变量相加的,编译器无法得知结果,会用StringBuilder进行创建新对象,不会将结果放到scp中
2.3 通过intern来验证
1.例子一
@Test
public void fun06() {
String str = "aa";
String str2 = new String("aa");
System.out.println(str == str2);
String str3 = str2.intern();
System.out.println(str == str3);
}
上面2.1的代码,跑的结果是
false
true
第一个相信大家也知道了,String str = “aa”;
在scp创建了一个对象"aa",
而String str2 = new String(“aa”);这里其实有两步
第一步java去scp找"aa",发现scp有"aa"
第二步,在堆中创建对象.所以两者地址不一样,str == str2 为false.
第二个,在经过 String str3 = str2.intern();后,intern发现scp已经有"aa"了, 所以直接将scp的地址返回给str3, 所以str == str3都是scp的地址,所以为true.
2.例子二 两个声明字符串相加,两个值不放到scp中,最终结果放到scp
jdk1.8环境
@Test
public void fun06() {
String s = new String("a") + new String("b");
String s1 = "ab" + "cd";
String s2 = s.intern();
String s3 = "ab";
System.out.println(s == s2);
System.out.println(s3 == s2);
}
结果为true,true
这是因为第一行代码, 据上面所知,不管"a"和"b",只会在堆中创建"ab"对象
第二行代码,我们用两个声明字符串相加,可知jvm会优化,直接在scp中创建"abcd"
第三行代码,s调用intern方法,发现scp没有"ab",将s在堆中的引用地址给s2
第四行代码,java先去scp找"ab",发现有,直接将其地址返回给s3
所以s指向堆中的地址,s2也是这个地址,所以相同. s3==s2同理,相同
- 这里如果将String s3 = "ab"放到前面:
@Test
public void fun06() {
String s = new String("a") + new String("b");
String s1 = "ab" + "cd";
String s3 = "ab";
String s2 = s.intern();
System.out.println(s == s2);
System.out.println(s3 == s2);
}
false
true
这是为什么呢?
第一行代码和第二行代码如上
第三行代码,java发现scp没有"ab",在scp创建新对象,然后返回地址给s3
第四行代码,s调用intern方法,发现scp有"ab",将s在scp中的引用地址给s2
所以s是堆中的地址,s2和s3是scp的地址,所以结果是false和true
- 如果再回到第一次的true,true的代码,这次不调整代码顺序,而是将"ab" + “cd”,两个声明式相加,变成其中一个是变量
@Test
public void fun06() {
String s = new String("a") + new String("b");
String s1 = "ab" + s;
String s2 = s.intern();
String s3 = "ab";
System.out.println(s == s2);
System.out.println(s3 == s2);
}
false
true
这是因为s1不再是两个声明式相加,编译器无法得知结果,所以将String s1 = "ab" + s
变成:
String var = "ab";
String s1 = var + s;
所以"ab"会在scp中,s2拿到的也会是var的地址,所以s == s2 为false
2.例子二 jdk的变化
上面说到,jdk1.7以后, scp从方法区移至了堆区,其实改变的不仅仅是位置
我们看最上面的intern方法的官方注释:
如果常量池中已经存在该字符串,则直接返回常量池中该对象的引用。否则,在常量池中加入该对象,然后 返回引用
这是jdk1.6及之前的,因为当时scp在方法区,也叫非堆区,所以intern的方法,是将该对象加入到常量区中
但是jdk1.7及以后,scp移至了堆区,字符串的创建也在堆区,为了节省开支,intern方法,不再是把该字符串直接加入scp,而是将其地址引用放到scp
public static void main(String[] args) {
String s = new String("1");
s.intern();
String s2 = "1";
System.out.println(s == s2);
String s3 = new String("1") + new String("1");
s3.intern();
String s4 = "11";
System.out.println(s3 == s4);
}
jdk6 下false false
jdk7 下false true
---------------a.jdk6的情况---------------
看图:
下面的图长方形表示变量存储的对象引用地址, 圆圈表示真实对象
注:图中绿色线条代表 string 对象的内容指向。 黑色线条代表地址指向。
如上图所示。首先说一下 jdk6中的情况,在 jdk6中上述的所有打印都是 false 的,因为 jdk6中的常量池是放在 Perm 区中的,Perm 区和正常的 JAVA Heap 区域是完全分开的。上面说过如果是使用引号声明的字符串都是会直接在字符串常量池中生成,而 new 出来的 String 对象是放在 JAVA Heap 区域。所以拿一个 JAVA Heap 区域的对象地址和字符串常量池的对象地址进行比较肯定是不相同的,即使调用String.intern方法也是没有任何关系的。
---------------b.jdk7以上的情况---------------
-
在第一段代码中,先看 s3和s4字符串。
String s3 = new String("1") + new String("1");
,这句代码中现在生成了2最终个对象,是字符串常量池中的“1” 和 JAVA Heap 中的 s3引用指向的对象。中间还有2个匿名的new String("1")
我们不去讨论它们。此时s3引用对象内容是”11”,但此时常量池中是没有 “11”对象的。 -
接下来
s3.intern();
这一句代码,是将 s3中的“11”字符串放入 String 常量池中,因为此时常量池中不存在“11”字符串,因此常规做法是跟 jdk6 图中表示的那样,在常量池中生成一个 “11” 的对象,关键点是 jdk7 中常量池不在 Perm 区域了,这块做了调整。常量池中不需要再存储一份对象了,可以直接存储堆中的引用。这份引用指向 s3 引用的对象。 也就是说引用地址是相同的。 -
最后
String s4 = "11";
这句代码中”11”是显示声明的,因此会直接去常量池中创建,创建的时候发现已经有这个对象了,此时也就是指向 s3 引用对象的一个引用。所以 s4 引用就指向和 s3 一样了。因此最后的比较 s3 == s4 是 true。 -
再看 s 和 s2 对象。
String s = new String("1");
第一句代码,生成了2个对象。常量池中的“1” 和 JAVA Heap 中的字符串对象。s.intern();
这一句是 s 对象去常量池中寻找后发现 “1” 已经在常量池里了。 -
接下来
String s2 = "1";
这句代码是生成一个 s2的引用指向常量池中的“1”对象。 结果就是 s 和 s2 的引用地址明显不同。图中画的很清晰。
-
来看第二段代码,从上边第二幅图中观察。第一段代码和第二段代码的改变就是
s3.intern();
的顺序是放在String s4 = "11";
后了。这样,首先执行String s4 = "11";
声明 s4 的时候常量池中是不存在“11”对象的,执行完毕后,“11“对象是 s4 声明产生的新对象。然后再执行s3.intern();时,常量池中“11”对象已经存在了,因此 s3 和 s4 的引用是不同的。 -
第二段代码中的 s 和 s2 代码中,
s.intern();
,这一句往后放也不会有什么影响了,因为对象池中在执行第一句代码String s = new String("1");
的时候已经生成“1”对象了。下边的s2声明都是直接从常量池中取地址引用的。 s 和 s2 的引用地址是不会相等的。