一、String 的基本特性
1、基本介绍
(1)String:字符串,使用一对" "引起来表示。
声明方式:
String s1 = "Java"; //字面量的定义方式 String s2 = new String("JVM"); //构造器方式
(2)String声明为final的,不可被继承;
(3)String 实现了 Serializable接口:表示字符串是支持序列化的。
(4)实现了Comparable接口:表示String可以比较大小;
(5)String在 JDK8 及以前内部定义了 final char[] value 用于存储字符串数据。JDK9时改为byte[]
2、String 存储结构变更
String在 JDK8 及以前内部定义了 final char[] value 用于存储字符串数据。JDK9时改为byte[]
String 在 JDK9 中存储结构变更:http://openjdk.java.net/jeps/254
结论: String再也不用char[]来存储啦,改成了byte[]加上编码标记,节约了一些空间。
那StringBuffer和 StringBuilder是否仍无动于衷呢?
String-related classes such as AbstractStringBuilder, StringBuilder,and StringBuffer will be updated to use the same representation, as will theHotSpot VM's intrinsic(固有的、内置的) string operations.
字符串相关的类,如AbstractStringBuilder StringBuilder, StringBuffer将更新使用相同的表示,就像theHotSpot VM的内在(固有的,内置的)字符串操作。
3、String的基本特性
String:代表不可变的字符序列。简称:不可变性。
(1)当对字符串重新赋值时,需要重写指定内存区域赋值,不能使用原有的value进行赋值。
(2)当对现有的字符串进行连接操作时,也需要重新指定内存区域赋值,不能使用原有的value进行赋值。
(3)当调用string的replace ()方法修改指定字符或字符串时,也需要重新指定内存区域赋值,不能使用原有的value进行赋值。
通过字面量的方式(区别于new)给一个字符串赋值,此时的字符串值声明在字符串常量池中。
4、String 的底层结构
字符串常量池中是不会存储相同内容的字符串的。
String的 String Pool是一个固定大小的 Hashtable,默认值大小长度是1009。如果放进string Pool的 String非常多,就会造成Hash冲突严重,从而导致链表会很长,而链表长了后直接会造成的影响就是当调用 String.intern时性能会大幅下降。
使用-XX: StringTablesize可设置StringTable的长度
(1)在JDK6中StringTable是固定的,就是1009的长度,所以如果常量池中的字符串过多就会导致效率下降很快。StringTableSize设置没有要求。
(2)在JDK7中,StringTable 的长度默认值是60013,设置没有要求。
(3)JDK8 开始,设置 StringTable 的长度1009是可设置的最小值。
二、String 的内存分配
1、String内存分配
在Java语言中有 8 种基本数据类型和一种比较特殊的类型String。这些类型为了使它们在运行过程中速度更快、更节省内存,都提供了一种常量池的概念。
常量池就类似一个Java系统级别提供的缓存。8种基本数据类型的常量池都是系统协调的,String类型的常量池比较特殊。它的主要使用方法有两种。
(1)直接使用双引号声明出来的string对象会直接存储在常量池中:
比如:String info = "Hello World" ;
(2)如果不是用双引号声明的String对象,可以使用String提供的 intern() 方法。这个后面重点谈
2、不同版本中的常量池
(1)Java6及以前,字符串常量池存放在永久代。
(2)Java 7 中 Oracle 的工程师对字符串池的逻辑做了很大的改变,即将字符串常量池的位置调整到Java堆内。
① 所有的字符串都保存在堆(Heap)中,和其他普通对象一样,这样可以让你在进行调优应用时仅需要调整堆大小就可以了。
② 字符串常量池概念原本使用得比较多,但是这个改动使得我们有足够的理由让我们重新考虑在Java 7 中使用 String.intern ()。
(3)Java8 由永久代替换为元空间,字符串常量仍然在堆。
3、StringTable为什么要调整?
① 永久代 PermSize 默认比较小,如果放大量的字符串,永久代可以OOM;
② 永久代垃圾回收频率低,个别版本JVM没有对永久代垃圾回收,不能及时回收,容易报 OOM;
官网解释:https://www.oracle.com/java/technologies/javase/jdk7-relnotes.html#jdk7changes
测试案例:
public class StringTest3 { public static void main(String[] args) { //使用Set保持着常量池引用,避免full gc回收常量池行为 Set<String> set = new HashSet<String>(); //在short可以取值的范围内足以让6MB的PermSize或heap产生OOM了。 short i = 0; while(true){ set.add(String.valueOf(i++).intern()); } } }
设置相关的 JVM 参数:
jdk6中: -XX:PermSize=6m -XX:MaxPermSize=6m -Xms6m -Xmx6m jdk8中: -XX:MetaspaceSize=6m -XX:MaxMetaspaceSize=6m -Xms6m -Xmx6m
三、String 的基本操作
1、案例一
Java语言规范里要求完全相同的字符串字面量,应该包含同样的Unicode字符序列(包含同一份码点序列的常量),并且必须是指向同一个String类实例。
测试代码:
public class StringTest { public static void main(String[] args) { System.out.println();//2293 System.out.println("1");//2294 System.out.println("2"); System.out.println("3"); System.out.println("4"); System.out.println("5"); System.out.println("6"); System.out.println("7"); System.out.println("8"); System.out.println("9"); System.out.println("10");//2303 //如下的字符串"1" 到 "10"不会再次加载 System.out.println("1");//2304 System.out.println("2");//2304 System.out.println("3"); System.out.println("4"); System.out.println("5"); System.out.println("6"); System.out.println("7"); System.out.println("8"); System.out.println("9"); System.out.println("10");//2304 } }
2、案例二
String 的分配情况:
public class Memory { public static void main(String[] args) {//line 1 int i = 1;//line 2 Object obj = new Object();//line 3 Memory mem = new Memory();//line 4 mem.foo(obj);//line 5 }//line 9 private void foo(Object param) {//line 6 String str = param.toString();//line 7 System.out.println(str); }//line 8 }
A string is created in line 7. it goes in the String Pool in the heap space and a reference is created in the foo() stack space for it.
四、字符串拼接操作
1、拼接操作
(1)常量与常量的拼接结果在常量池,原理是编译期优化。
(2)常量池中不会存在相同内容的常量。
(3)只要其中有一个是变量,结果就在堆中。变量拼接的原理是StringBuilder。
(4)如果拼接的结果调用 intern() 方法,则主动将常量池中还没有的字符串对象放入池中,并返回此对象地址。
2、案例一
@Test public void test1(){ String s1 = "a" + "b" + "c";//编译期优化:等同于"abc" String s2 = "abc"; //"abc"一定是放在字符串常量池中,将此地址赋给s2 /* * 最终.java编译成.class,再执行.class * String s1 = "abc"; * String s2 = "abc" */ System.out.println(s1 == s2); //true System.out.println(s1.equals(s2)); //true }
3、案例二
@Test public void test2(){ String s1 = "javaEE"; String s2 = "hadoop"; String s3 = "javaEEhadoop"; String s4 = "javaEE" + "hadoop";//编译期优化 //如果拼接符号的前后出现了变量,则相当于在堆空间中new String(),具体的内容为拼接的结果:javaEEhadoop String s5 = s1 + "hadoop"; String s6 = "javaEE" + s2; String s7 = s1 + s2; System.out.println(s3 == s4);//true System.out.println(s3 == s5);//false System.out.println(s3 == s6);//false System.out.println(s3 == s7);//false System.out.println(s5 == s6);//false System.out.println(s5 == s7);//false System.out.println(s6 == s7);//false //intern():判断字符串常量池中是否存在javaEEhadoop值,如果存在,则返回常量池中javaEEhadoop的地址; //如果字符串常量池中不存在javaEEhadoop,则在常量池中加载一份javaEEhadoop,并返回次对象的地址。 String s8 = s6.intern(); System.out.println(s3 == s8);//true }
4、案例三
@Test public void test3(){ String s1 = "a"; String s2 = "b"; String s3 = "ab"; /* 如下的s1 + s2 的执行细节:(变量s是临时定义的) ① StringBuilder s = new StringBuilder(); ② s.append("a") ③ s.append("b") ④ s.toString() --> 约等于 new String("ab") 补充:在jdk5.0之后使用的是StringBuilder,在jdk5.0之前使用的是StringBuffer */ String s4 = s1 + s2;// System.out.println(s3 == s4);//false }
5、案例四
@Test public void test4(){ final String s1 = "a"; final String s2 = "b"; String s3 = "ab"; String s4 = s1 + s2; System.out.println(s3 == s4);//true }
字符串拼接总结:
(1)字符串拼接操作不一定使用的是StringBuilder!
(2)如果拼接符号左右两边都是字符串常量或常量引用,则仍然使用编译期优化,即非StringBuilder的方式。
(3)针对于final修饰类、方法、基本数据类型、引用数据类型的量的结构时,能使用上final的时候建议使用上。
6、拼接与 append 操作的比较
测试代码:
@Test public void test6(){ long start = System.currentTimeMillis(); // method1(100000);//4014 method2(100000);//7 long end = System.currentTimeMillis(); System.out.println("花费的时间为:" + (end - start)); } public void method1(int highLevel){ String src = ""; for(int i = 0;i < highLevel;i++){ src = src + "a";//每次循环都会创建一个StringBuilder、String } // System.out.println(src); } public void method2(int highLevel){ //只需要创建一个StringBuilder StringBuilder src = new StringBuilder(); for (int i = 0; i < highLevel; i++) { src.append("a"); } // System.out.println(src); } }
总结:
(1)体会执行效率:通过StringBuilder的append()的方式添加字符串的效率要远高于使用String的字符串拼接方式!
(2)详情:① StringBuilder的append()的方式:自始至终中只创建过一个StringBuilder的对象使用String的字符串拼接方式:创建过多个StringBuilder和String的对象;
② 使用String的字符串拼接方式:内存中由于创建了较多的StringBuilder和String的对象,内存占用更大;如果进行GC,需要花费额外的时间。
(3)改进的空间:在实际开发中,如果基本确定要前前后后添加的字符串长度不高于某个限定值highLevel的情况下,建议使用构造器实例化:
StringBuilder s = new StringBuilder(highLevel);//new char[highLevel]
五、new String("abc") 到底创建了几个对象?
1、对于下面的代码到底创建了几个对象?
public static void main(String[] args) { String str = new String("abc"); }
对应的字节码指令
① 首先使用 new 关键字在堆空间创建了 String 对象。
② 然后使用 ldc 从字符串常量池中获取字符串 "abc"
③ 接着执行 String的 <init>() 方法
④ 把 str 保存到局部变量索引为1的位置
⑤ 方法返回
可以发现,在这里创建了两个对象。
① 一个对象是:new关键字在堆空间创建的 new String 新对象;
② 另一个对象是:字符串常量池中的对象"abc",常量“abc”对象的内容创建出的一个新的String对象。 字节码指令:ldc
ldc 的含义是:将常量值从常量池中取出来并且压入栈中。
String s1 = new String("abc"); 的内存结构图:
注意:没有直接的赋值操作(str="abc"),并不代表常量池中没有“abc”这个字符串。也就是说衡量创建几个对象、常量池中是否有对应的字符串,不仅仅由你是否创建决定,还要看程序启动时其他类中是否包含该字符串。
2、变形1:对于下面的代码到底创建了几个对象?
public static void main(String[] args) { String str1 = "abc"; String str2 = new String("abc"); }
字节码指令:
从上图中,我们可以看到第0行和第7行中的字符串引用是同一个,这说明了,在编译期间,该字符串变量的值已经确定了下来。
所以使用字面量方式创建对象,是在常量池中创建了对应的数据:"abc";
通过字面量的方式(区别于new给一个字符串赋值,此时的字符串值声明在字符串常量池中)
并且将该字符串值缓存在缓冲区中,同时让该变量指向该字符串值,后面如果有使用相同的字符串值,则继续指向同一个字符串值。
字符串常量池是不会存储相同内容(使用String类的 equals()比较(重写过),返回true)的字符串的;
所以String str2 = new String("abc"); 此时就创建一个对象,而 abc 则是从字符串常量缓冲区中取出来的。
String s1 = "abc" 与 String s2 = new String("abc");的内存结构图:
区别:
String str1 = "abc" 直接 str1 存储的是常量池中的地址值;
String str2 = new String("abc"); str2 存储的是在堆中的 new String() 的地址值;
new String() 存放的是跟 str1 相同指向的常量池的地址值;
s1 指向在常量池中的, s2 指向堆上的,而 s2 内部的 char value[] 则指向常量池中的 char value[]。
对于:
String str = new String("abc");
首先要看常量池里是否有“abc” 这个字符串,如果有,则在堆中创建一个new String 对象,如果没有,则创建两个String对象,一个在堆中,一个在常量池中。
我们可以把上面这行代码分成String str、=、"abc" 和 new String() 四部分来看待。
(1)String str只是定义了一个名为 str 的String类型的变量,因此它并没有创建对象;
(2)= 是对变量 str 进行初始化,将某个对象的引用(或者叫句柄)赋值给它,显然也没有创建对象;
(3)现在只剩下new String("abc")了。
那么,new String("abc")为什么又能被看成"abc"和new String()呢?
我们来看一下被我们调用了的String的构造器:
public String(String original) { this.value = original.value; this.hash = original.hash; }
常用的创建一个类的实例(对象)的方法有以下两种:
① 使用 new 创建对象。
② 调用 Class 类的 newInstance 方法,利用反射机制创建对象。
我们正是使用new调用了String类的上面那个构造器方法创建了一个对象,并将它的引用赋值给了str变量。
同时我们注意到,被调用的构造器方法接受的参数也是一个String对象,这个对象正是 "abc"。由此我们又要引入另外一种创建String对象的方式的讨论——引号内包含文本(字面量方式)。
3、变形2:下面会创建几个对象?
String str = "abc" + "def";
上面的问题涉及到字符串常量重载“+”的问题,当一个字符串由多个字符串常量拼接成一个字符串时,它自己也肯定是字符串常量。字符串常量的 “+” 号连接Java虚拟机会在程序编译期将其优化为连接后的值。
就上面的示例而言,在编译时已经被合并成 “abcdef” 字符串,因此,只会创建1个对象。并没有创建临时字符串对象 abc 和 def ,这样减轻了垃圾收集器的压力。
我们通过 javap 查看 class 文件可以看到如下内容(idea工具)。
很明显,字节码中只有拼接好的abcdef。
4、思考:下面的代码又创建了几个对象?
public static void main(String[] args) { String str = new String("a") + new String("b"); }
对应的字节码信息:
对象1:new StringBuilder()
对象2: new String("a")
对象3: 常量池中的"a"
对象4: new String("b")
对象5: 常量池中的"b"
可以看到紧接着是调用了 StringBuilder的 toString() 方法:
源码:
@Override public String toString() { // Create a copy, don't share the array return new String(value, 0, count); }
看一下 StringBuilder 的 toString() 的字节码
对于 StringBuilder的toString()有产生了一个对象:
对象6 :new String("ab")
而在这里要注意:toString()的调用是获取 StringBuilder 的字段 value 值,所以在字符串常量池中,没有生成"ab"。
很显然,在toString方法中又新创建了一个String对象,而该String对象传递数组的构造方法来创建的:
public String(char value[], int offset, int count)
也就是说,String 对象的 value 值直接指向了一个已经存在的数组,而并没有指向常量池中的字符串。
对于上面的代码,可以理解为创建了 6 个对象。
六、String的创建过程到底做了什么
1、String str = "abc"创建对象的过程
(1)创建过程:
①首先在常量池中查找是否存在内容为 "abc" 字符串对象;
②如果不存在则在常量池中创建 "abc",并让 str 引用该对象;
③如果存在则直接让 str 引用该对象;
(2)"abc"是怎么保存,保存在哪?
当直接赋值时,字符串 “abc” 会被存储在常量池中,只有1份,此时的赋值操作等于是创建 0 个或 1 个对象。
如果常量池中已经存在了 “abc”,那么不会再创建对象,直接将引用赋值给 str;
如果常量池中没有“abc”,那么创建一个对象,并将引用赋值给str。
常量池属于类信息的一部分,而类信息反映到JVM内存模型中是对应存在于JVM内存模型的方法区,也就是说这个类信息中的常量池概念是存在于在方法区中。
而方法区是在JVM内存模型中的堆中由JVM来分配的,所以"abc"可以说存在于堆中。一般这种情况下,"abc" 在编译时就被写入字节码中,所以 Class 被加载时,JVM就为 "abc" 在常量池中分配内存,所以和静态区差不多。
2、String str = new String("abc")创建实例的过程
(1)创建过程
① 首先在堆中(不是常量池)创建一个指定的对象"abc",并让str引用指向该对象;
② 在字符串常量池中查看,是否存在内容为"abc"字符串对象;
③ 若存在,则将new出来的字符串对象与字符串常量池中的对象联系起来;
④ 若不存在,则在字符串常量池中创建一个内容为"abc"的字符串对象,并将堆中的对象与之联系起来;
当JVM遇到上述代码时,会先检索常量池中是否存在“abc”,如果不存在“abc”这个字符串,则会先在常量池中创建这个一个字符串。
然后再执行new操作,会在堆内存中创建一个存储“abc”的String对象,对象的引用赋值给str2。此过程创建了2个对象。
当然,如果检索常量池时发现已经存在了对应的字符串,那么只会在堆内创建一个新的String对象,此过程只创建了1个对象。
在上述过程中检查常量池是否有相同Unicode的字符串常量时,使用的方法便是String中的intern()方法。
public native String intern();
下面通过一个简单的示意图看一下String在内存中的两种存储模式。
String str1 = "abc"; // 在常量池中 String str2 = new String("abc"); // 在堆上
上面的示意图我们可以看到在堆内创建的String对象的char value[]属性指向了常量池中的char value[]。
还是上面的示例,如果我们通过debug模式也能够看到String的char value[]的引用地址。
图中两个String对象的value值的引用均为{char[3]@820},也就是说,虽然是两个对象,但它们的value值均指向常量池中的同一个地址。
当然,大家还可以拿一个复杂对象(Person)的字符串属性(name)相同时的debug结果进行比对,结果是一样的。
3、案例一
String str1 = "abc"; String str2 = "ab" + "c"; str1==str2是true吗?
答案:是。因为String str2 = "ab" + "c"会查找常量池中时候存在内容为"abc"字符串对象,如存在则直接让str2引用该对象,显然String str1 = "abc"的时候,上面说了,会在常量池中创建"abc"对象,所以str1引用该对象,str2也引用该对象,所以str1==str2。
4、案例二
String str1 = "abc"; String str2 = "ab"; String str3 = str2 + "c"; str1==str3是false吗?
答案:是。因为String str3 = str2 + "c" 涉及到变量(不全是常量)的相加,所以会生成新的对象,其内部实现是先new一个StringBuilder,然后 append(str2),append("c");然后让str3引用toString()返回的对象。
八、Java 中new String("字面量") 中 "字面量" 是何时进入字符串常量池的?
1、字面量是何时进入字符串常量池的呢?
字面量进入字符串常量池的时机
代码:
class NewTest0 { public static String s1="static"; // 第一句 public static void main(String[] args) { String s1=new String("he")+new String("llo"); //第二句 String s2="hello"; //第三句 } }
下述仅讨论字符串。
(1) NewTest.class 的 class文件常量池 中 是含有 "static" ,"he","llo","hello的。
这个可以通过 javap查看Constant pool明白。
(2)在类加载阶段, JVM会在堆中创建 对应这些 class文件常量池中的 字符串对象实例 并在字符串常量池中驻留其引用。具体在 resolve 阶段执行。这些常量全局共享。
那么问题来了:
class NewTest1{ public static String s1="static"; // 第一句 public static void main(String[] args) { String s1=new String("he")+new String("llo"); //第二句 s1.intern(); //将 堆中新建的对象"hello" 存入字符串常量池 String s2="hello"; //第三句 System.out.println(s1==s2);//输出是true。 } }
根据上面的 (1)(2).
class常量池中本来就有 "he" "llo" "hello"字面量。
类加载以后,"he" "llo" "hello" 对象实例也在堆上创建,并且引用在 字符串常量池中驻留。 我们将这个 "hello"对象实例叫做 hello 1.
第二句,s1是在堆上新创建的 "hello"对象实例,叫做 hello2.
当执行到 intern() 语句时, hello2 equals hello1 都是 "hello" 。
但是hello1 和 hello2在堆中的地址是不一样的,那么字符串常量池中原本存储的是hello1的地址,现在是会依旧使用hello1的地址还是 更新 为 hello2的地址?这是问题1
为了描述的方便。我们将intern()执行后,字符串常量池中的"hello"引用假设为 hello3 .
hello3指向 hello1 或者hello2. 这取决于问题1的答案.
第三句,直接将s2赋值为 常量池中的 hello3所指向的字符串.
但是最终输出结果 是true 。
这是说明了 hello3所指向的是hello2.
通过结果推测:问题1的答案就是,字符串常量池更新了。
那么继续。我们举个反例:
class NewTest2{ public static void main(String[] args) { String s1=new String("he")+new String("llo"); String s2=new String("h")+new String("ello"); String s3=s1.intern(); String s4=s2.intern(); System.out.println(s1==s3); System.out.println(s1==s4); } }
如果问题1的答案是,字符串常量池更新了。
为了叙述方便 s1 的 "hello" 是 hello1 . s2 的 "hello" 是 hello2 .
hello2和hello1是不同对象具有不同地址,但是内容都是"hello"。
s3就等于 hello1, s4=hello2
因此 s1==s3 是肯定的。指向同一个"hello"对象hello1.
但是 s1==s4 应该是错的。因为 一个指向hello1 一个指向hello2.
但是结果却是,都是正确的。
这说明了,问题1的答案是错误的。 字符串常量池并没有更新。
那么到底是怎么回事的呢?
精简版的问题:
JVM执行代码时,字面量是何时进入字符串常量池?
(1)是在类加载过程中就已经被更新了。
(2)是在真正执行代码时,遇到字面量或者intern()语句,就将其尝试加入字符串常量池。
(3)两者都具备, 类加载过程是初始化常量池。 执行代码时是新增常量池的内容。并且对于常量池中已有的字面量,不采取更新措施。(通过上面的反例可以得出这个结论)
2、关于常量池知识
Java中的常量池(字符串常量池、class常量池和运行时常量池)
3、String的 intern 方法干了什么?
JDK7/8中,如果常量池中已经有了这个字符串,那么直接返回常量池中它的引用,如果没有,那就将它的引用保存一份到字符串常量池,然后直接返回这个引用。敲黑板,这个方法是有返回值的,是返回引用。
4、s1.intern(); 和 s1 = s1.intern();一样吗?
不一样。一个引用a,指向了一个具体的对象,然后调用了一个方法func,请问这个方法会对a本身产生什么影响吗?没有吧,换句话说,a.func(..)执行完之后,a原来指向谁还是指向谁吧,对不对?所以s1.intern();对s1有什么影响吗?一点影响都没有,原来指向哪现在还指向哪。s1 = s1.intern();就不一样了,你把intern方法的返回值给了s1,s1是可以重新指向的对吧。
5、字面量进入字符串常量池的时机
根据上面得出的结论:
在类加载阶段, JVM会在堆中创建对应这些 class 文件常量池中的字符串对象实例 ,并在字符串常量池中驻留其引用。具体在resolve阶段执行。这些常量全局共享。
这里说的比较笼统,没错,是resolve阶段,但是并不是大家想的那样,立即就创建对象并且在字符串常量池中驻留了引用。 JVM规范里明确指定resolve阶段可以是lazy的。
JVM规范里Class文件的常量池项的类型,有两种东西:
(1)CONSTANT_Utf8
(2)CONSTANT_String
后者是String常量的类型,但它并不直接持有String常量的内容,而是只持有一个index,这个index所指定的另一个常量池项必须是一个CONSTANT_Utf8类型的常量,这里才真正持有字符串的内容。
在HotSpot VM中,运行时常量池里,
CONSTANT_Utf8 -> Symbol*(一个指针,指向一个Symbol类型的C++对象,内容是跟Class文件同样格式的UTF-8编码的字符串)
CONSTANT_String -> java.lang.String(一个实际的Java对象的引用,C++类型是oop)
CONSTANT_Utf8会在类加载的过程中就全部创建出来,而CONSTANT_String则是lazy resolve的,例如说在第一次引用该项的ldc指令被第一次执行到的时候才会resolve。
那么在尚未resolve的时候,HotSpot VM把它的类型叫做JVM_CONSTANT_UnresolvedString,内容跟Class文件里一样只是一个index;等到resolve过后这个项的常量类型就会变成最终的JVM_CONSTANT_String,而内容则变成实际的那个oop。
看到这里想必也就明白了, 就HotSpot VM的实现来说,加载类的时候,那些字符串字面量会进入到当前类的运行时常量池,不会进入全局的字符串常量池(即在StringTable中并没有相应的引用,在堆中也没有对应的对象产生)
6、ldc指令是什么东西?
简单地说,它用于将int、float或String型常量值从常量池中推送至栈顶
以下面代码为例:
public class Abc { public static void main(String[] args) { String a = "AA"; } }
查看其编译后的Class文件如下:
使用ldc将"AA"送到栈顶,然后用astore_1把它赋值给我们定义的局部变量a,然后就没什么事了return了。
根据上面说的,在类加载阶段,这个 resolve 阶段( constant pool resolution )是lazy的。换句话说并没有真正的对象,字符串常量池里自然也没有,那么ldc指令还怎么把人推送至栈顶?或者换一个角度想,既然resolve 阶段是lazy的,那总有一个时候它要真正的执行吧,是什么时候?
执行ldc指令就是触发这个lazy resolution动作的条件
ldc字节码在这里的执行语义是:到当前类的运行时常量池(runtime constant pool,HotSpot VM里是ConstantPool + ConstantPoolCache)去查找该index对应的项,如果该项尚未resolve则resolve之,并返回resolve后的内容。
在遇到String类型常量时,resolve的过程如果发现StringTable已经有了内容匹配的java.lang.String的引用,则直接返回这个引用,反之,如果StringTable里尚未有内容匹配的String实例的引用,则会在Java堆里创建一个对应内容的String对象,然后在StringTable记录下这个引用,并返回这个引用出去。
可见,ldc指令是否需要创建新的String实例,全看在第一次执行这一条ldc指令时,StringTable是否已经记录了一个对应内容的String的引用。
7、上面的案例
对于上面的第一段代码:
// JDK1.7 class NewTest1{ public static String s1="static"; // 第一句 public static void main(String[] args) { String s1=new String("he")+new String("llo"); //第二句 s1.intern(); // 第三句 String s2="hello"; //第四句 System.out.println(s1==s2);//第五句,输出是true。 } }
"static" "he" "llo" "hello"都会进入Class的常量池, 按照上面说的,类加载阶段由于resolve 阶段是lazy的,所以是不会创建实例,更不会驻留字符串常量池了。但是要注意这个"static"和其他三个不一样,它是静态的,在类加载阶段中的初始化阶段,会为静态变量指定初始值,也就是要把"static"赋值给s1(main方法里面怎么还有个s1,这里说的是外面那个静态的)。那么这个赋值操作要怎么搞啊?显然需要先ldc指令把它放到栈顶,然后用putstatic指令完成赋值。注意,ldc指令,根据上面说的,会创建"static"字符串对象,并且会保存一个指向它的引用到字符串常量池。
运行main方法后,首先是第二句,一样的,要先用ldc把"he"和"llo"送到栈顶,换句话说,会创建他俩的对象,并且会保存引用到字符串常量池中;然后有个+号对吧,内部是创建了一个StringBuilder对象,一路append,最后调用StringBuilder对象的toString方法得到一个String对象(内容是hello,注意这个toString方法会new一个String对象),并把它赋值给s1。注意啊,没有把hello的引用放入字符串常量池。
然后是第三句,intern方法一看,字符串常量池里面没有"hello",它会把上面的这个hello对象的引用保存到字符串常量池,然后返回这个引用,但是这个返回值我们并没有使用变量去接收,所以没用。
第四句,字符串常量池里面已经有了,直接用嘛
第五句,已经很明显了。
8、代码片段二
// JDK1.7 class NewTest2 { public static void main(String[] args) { String s1 = new String("he") + new String("llo"); // ① String s2 = new String("h") + new String("ello"); // ② String s3 = s1.intern(); // ③ String s4 = s2.intern(); // ④ System.out.println(s1 == s3); System.out.println(s1 == s4); } }
类加载阶段,什么都没干。
然后运行main方法,先看第一句,会创建"he"和"llo"对象,并放入字符串常量池,然后会创建一个"hello"对象,没有放入字符串常量池,s1指向这个"hello"对象。
第二句,创建"h"和"ello"对象,并放入字符串常量池,然后会创建一个"hello"对象,没有放入字符串常量池,s2指向这个"hello"对象。
第三句,字符串常量池里面还没有"hello",于是会把s1指向的String对象的引用放入字符串常量池(换句话说,放入池中的引用和s1指向了同一个对象),然后会把这个引用返回给了s3,所以s3==s1是true。
第四句,字符串常量池里面已经有了,直接将它返回给了s4,所以s4==s1是true。
9、演示
用了一个很巧妙的办法验证了字符串是懒加载进入字符串池的,我修改简化了一下他的代码
public class Test { public static void main(String[] args) { test('h', 'e', 'l', 'l', 'o'); } static void test(char... arg) { String s1 = new String(arg), s2 = s1.intern(); System.out.println('"'+s1+'"' +(s1!=s2? " existed": " did not exist")+" in the pool before"); System.out.println("hello"); } }
首先,test方法接收一个char[]作为参数,我们传入了{'h', 'e', 'l', 'l', 'o'}作为实参, 这样我们就能够避免提前使用到“hello”这个字面量。
然后,我们在test方法的最后一行输出了“hello”,因此在这个类中我们还是直接使用到了“hello”这个字面量
最后,在test方法的第一行,我们使用传入的char[]创建了一个字符串对象,并使用s1来引用它,然后我们调用s1的intern方法,将返回结果付给s2,再比较s1和s2是否引用了同一个对象。
根 intern 方法的行为:
JDK7中,如果常量池中已经有了这个字符串,那么直接返回常量池中它的引用,如果没有,那就将它的引用保存一份到字符串常量池,然后直接返回这个引用。
当s1和s2相等时,说明当程序执行到test方法的第二行时,字符串池中不存在“hello”这个字符串,即使我们在test方法的最后一行显示的声明了“hello”这个字符串,这就证明了“hello”是懒加载的,直到使用到它的时候才会被加载,而并非在Test类被加载进入虚拟机时就进入了字符串池。
如果不相等,则反之。
九、字符串常见问题
1、什么是字符串常量池?
字符串常量池:即String Constant Pool,又叫做String Pool,String Table。顾名思义,即用于存放字符串常量的运行时内存结构,其底层实现为一种Hashtable。其中所指的字符串常量,可以是编译期在源码中显式的字符串字面量,也可以是之后在程序运行时创建的字符串String对象。
在 JDK1.6 及之前,字符串常量池中只会存放具体的String实例,在使用 String.intern 方法时,若字符串常量池中有满足String.equals方法的String对象,则返回其引用;若字符串常量池中没有相同的String对象,则当前String对象为堆上对象,故在字符串常量池中创建一个相同的String对象,并返回其引用。
在 JDK1.7 及之后,字符串常量池中不仅可以存放String实例,同时还能存放指向Java堆中某个String实例的引用。在使用String.intern方法时,若字符串常量池中有满足String.equals方法的String对象,则返回其引用,这一点和JDK1.6相同;若字符串常量池中没有相同的String对象,则当前String对象为堆上对象,故在字符串常量池中存放一个指向堆上此String对象的引用,并返回此引用。
参考资料:
stackoverflow: String Constant Pool vs String pool
stackoverflow: String pool vs Constant pool
在IDK1.6中,intern() 方法会把首次遇到的字符串实例复制到永久代中,返回的也是永久代中这个字符串实例的引用。而JDK1.7中(以及部分其他虚拟机,例如 JRockit)的 intern() 实现不会再复制实例,只是在常量池中记录首次出现的实例引用。
——《深入理解 Java 虚拟机(第2版)》2.4.3 方法区和运行时常量池溢出
JDK 7(以及部分其他虚拟机,例如 JRockit)的 intern() 方法实现就不需要再拷贝字符串的实例到永久代了,既然字符串常量池已经移到 Java 堆中,那只需要在常量池里记录一下首次出现的实例引用即可。
——《深入理解 Java 虚拟机(第3版)》2.4.3 方法区和运行时常量池溢出
在方法区中常量引用的对象,譬如字符串常量池(String Table)里的引用
——《深入理解 Java 虚拟机(第3版)》3.2.2 可达性分析
2、字符串常量池在JVM中的分布
在 JDK1.6 及之前,字符串常量和其他的基本类型的常量一样,存放在 运行时常量池(Run-Time Constant Pool) 中,即在方法区(HotSpot中为永久代PermGen)中。
在 JDK1.7 及之后,字符串常量的存放位置已经从运行时常量池中分离到了 Java堆(Heap) 中,形成了独立的字符串常量池(String Pool),其中一方面也是因为在永久代中创建String对象,容易耗尽永久代内存空间。
参考资料
在IDK1.6中,intern() 方法会把首次遇到的字符串实例复制到永久代中,返回的也是永久代中这个字符串实例的引用。而JDK1.7中(以及部分其他虚拟机,例如 JRockit)的 intern() 实现不会再复制实例,只是在常量池中记录首次出现的实例引用。
——《深入理解 Java 虚拟机(第2版)》2.4.3 方法区和运行时常量池溢出
JDK 7(以及部分其他虚拟机,例如 JRockit)的 intern() 方法实现就不需要再拷贝字符串的实例到永久代了,既然字符串常量池已经移到 Java 堆中,那只需要在常量池里记录一下首次出现的实例引用即可。
——《深入理解 Java 虚拟机(第3版)》2.4.3 方法区和运行时常量池溢出
3、字符串字面量在class文件中的位置
源码经过Java编译器编译后,其中的字符串字面量以CONSTANT_String_info的形式存放在class文件的常量池(Constant Pool) 中。class文件的常量池,可以通过javac -verbose命令显式查看。
示例代码(JDK1.8)
public class Test { public static String s1 = "He"; public String s2 = "llo"; public static void main(String[] args) { System.out.println("Hello"); } }
javap -verbose编译结果
Constant pool: #1 = Methodref #10.#28 // java/lang/Object."<init>":()V #2 = String #29 // llo #3 = Fieldref #9.#30 // com/njf/exer/StringTest1.s2:Ljava/lang/String; #4 = Fieldref #31.#32 // java/lang/System.out:Ljava/io/PrintStream; #5 = String #33 // Hello #6 = Methodref #34.#35 // java/io/PrintStream.println:(Ljava/lang/String;)V #7 = String #36 // He #8 = Fieldref #9.#37 // com/njf/exer/StringTest1.s1:Ljava/lang/String; #9 = Class #38 // com/njf/exer/StringTest1 #10 = Class #39 // java/lang/Object #11 = Utf8 s1 #12 = Utf8 Ljava/lang/String; #13 = Utf8 s2 #14 = Utf8 <init> #15 = Utf8 ()V #16 = Utf8 Code #17 = Utf8 LineNumberTable #18 = Utf8 LocalVariableTable #19 = Utf8 this #20 = Utf8 Lcom/njf/exer/StringTest1; #21 = Utf8 main #22 = Utf8 ([Ljava/lang/String;)V #23 = Utf8 args #24 = Utf8 [Ljava/lang/String; #25 = Utf8 <clinit> #26 = Utf8 SourceFile #27 = Utf8 StringTest1.java #28 = NameAndType #14:#15 // "<init>":()V #29 = Utf8 llo #30 = NameAndType #13:#12 // s2:Ljava/lang/String; #31 = Class #40 // java/lang/System #32 = NameAndType #41:#42 // out:Ljava/io/PrintStream; #33 = Utf8 Hello #34 = Class #43 // java/io/PrintStream #35 = NameAndType #44:#45 // println:(Ljava/lang/String;)V #36 = Utf8 He #37 = NameAndType #11:#12 // s1:Ljava/lang/String; #38 = Utf8 com/njf/exer/StringTest1 #39 = Utf8 java/lang/Object #40 = Utf8 java/lang/System #41 = Utf8 out #42 = Utf8 Ljava/io/PrintStream; #43 = Utf8 java/io/PrintStream #44 = Utf8 println #45 = Utf8 (Ljava/lang/String;)V
从javap工具的编译结果来看,可以发现 class文件的常量池(Constant Pool)中保存有源码中出现的所有字符串字面量。
参考资料
-
- 常量池中主要存放两大类常量:字面量(Literal)和符号引用(Symbolic References)。字面量比较接近于Java语言层面的常量概念,如文本字符串、被声明为final的常量值等。
——《深入理解 Java 虚拟机(第3版)》6.3.2 常量池
- 常量池中主要存放两大类常量:字面量(Literal)和符号引用(Symbolic References)。字面量比较接近于Java语言层面的常量概念,如文本字符串、被声明为final的常量值等。
4、字符串字面量何时进入到字符串常量池中
字符串字面量,和其他基本类型的字面量或常量不同,并不会在类加载中的解析(resolve) 阶段填充并驻留在字符串常量池中,而是以特殊的形式存储在 运行时常量池(Run-Time Constant Pool) 中。而是只有当此字符串字面量被调用时(如对其执行ldc字节码指令,将其添加到栈顶),HotSpot VM才会对其进行resolve,为其在字符串常量池中创建对应的String实例。
在JDK1.7的 HotSpot VM中,这种还未真正解析(resolve)的String字面量,以JVM_CONSTANT_UnresolvedString 的形式存放在运行时常量池中,此时并未为其创建String实例;
在JDK1.8的HotSpot VM中,这种未真正解析(resolve)的String字面量,被称为 pseudo-string,以JVM_CONSTANT_String 的形式存放在运行时常量池中,此时并未为其创建String实例。
在编译期,字符串字面量以"CONSTANT_String_info"+"CONSTANT_Utf8_info"的形式存放在class文件的 常量池(Constant Pool) 中;
在类加载之后,字符串字面量以"JVM_CONSTANT_UnresolvedString(JDK1.7)"或者"JVM_CONSTANT_String(JDK1.8)"的形式存放在 运行时常量池(Run-time Constant Pool) 中;
在首次使用某个字符串字面量时,字符串字面量以真正的String对象的方式存放在 字符串常量池(String Pool) 中。
示例代码(JDK1.8)
public class StringTest2 { public static void main(String[] args) { String s1 = new String("He") + new String("llo");// 堆上创建"Hello","He","llo"实例,String Pool中创建"He"和"llo"实例 s1.intern();// 将堆上"Hello"的引用存入String Pool String s2 = "Hello";// 获取String Pool中的"Hello"的引用 System.out.println(s1 == s2);// true } }
参考资料
知乎: Java 中new String(“字面量”) 中 “字面量” 是何时进入字符串常量池的?
OpenJDK1.7 HotSpot: src/share/vm/oops/constantPoolOop.cpp
OpenJDK1.8 HotSpot: src/share/vm/oops/constantPool.cpp
5、new String("Hello");到底创建了几个对象
基于对之前问题的解答,这个问题就比较好解答了:
(1)若此代码运行之前没有显示使用过"Hello"字面量,也没有调用某个值为"Hello"字符串对象的 intern方法,那么new String("Hello")运行时会创建两个对象,一个在堆上,一个在字符串常量池中
(2)若此代码运行之前已经使用过"Hello"字面量,或者调用了intern方法,在字符串常量池中创建了相同的实例或者保存了堆上相同对象的引用,那么new String("Hello")运行时只会创建一个对象,在堆上
注意
若在某个类的静态变量中使用了某个字符串字面量,如"Hello",则在类加载的初始化(initialize)阶段,便会在字符串常量池中创建对应的String实例,并将其赋值给对应的静态变量
示例代码(JDK1.8)
public class Test { public static String s = "Hello"; public static void main(String[] args) { String s1 = new String("He") + new String("llo"); // 堆上创建"Hello","He","llo"实例,String Pool中创建"He"和"llo"实例 s1.intern(); // String Pool中已有"Hello",故没有将s1的引用添加到String Pool中,返回的是String Pool中已有的"Hello"的引用 String s2 = "Hello"; // 获取String Pool中的"Hello"的引用 System.out.println(s1 == s2);// false System.out.println(s == s2);// true } }
七、intern() 的使用
1、intern() 方法说明
2、intern() 方法使用
如果不是用双引号声明的 String对象,可以使用 String 提供的 intern() 方法: intern()方法会从字符串常量池中查询当字符串是否存在,若不存在就会将当前字符串放入常量池中。
比如:String myInfo = new String ("Hello World" ) .intern() ;
也就是说,如果在任意字符串上调用String.intern() 方法,那么其返回结果所指向的那个类实例,必须和直接以常量形式出现的字符串实例完全相同。因此,下列表达式的值必定是true:
比如:( "a" +"b" + "c" ) .intern () == " abc"
通俗点讲,Interned String就是确保字符串在内存里只有一份拷贝,这样可以节约内存空间,加快字符串操作任务的执行速度。注意,这个值会被存放在字符串内部池(String Intern Pool)。
如何保证变量 s 指向的是字符串常量池中的数据呢?
方式一:
String s = "java";//字面量定义的方式
方式二:
调用intern() String s = new String("java").intern(); String s = new StringBuilder("java").toString().intern();
4、intern() 的面试难题
测试代码:
public static void main(String[] args) { //intern() 的使用:jdk6 vs jdk7/8 String s = new String("1"); s.intern(); String s2 = "1"; System.out.println(s == s2);//jdk6:false jdk7/8:false String s3 = new String("1") + new String("1"); String s5 = s3.intern(); String s4 = "11"; System.out.println(s3 == s4);//jdk6:false jdk7/8:true System.out.println(s5 == s4);//jdk7/8:true }
解析说明:
public static void main(String[] args) { //intern() 的使用:jdk6 vs jdk7/8 String s = new String("1"); //此时字符串常量池已经存在 "1",堆中也有一个字符串对象"1" s.intern(); //调用此方法之前,字符串常量池中已经存在了"1" String s2 = "1"; //指向字符串常量池中的 "1" //jdk6:false jdk7/8:false System.out.println(s == s2); //s是指向堆中的对象,s2是指向字符串常量池中的对象 String s3 = new String("1") + new String("1"); //s3变量记录的地址为:new String("11") //执行完上一行代码以后,字符串常量池中,是否存在"11"呢?答案:不存在!! String s5 = s3.intern(); //在字符串常量池中生成"11"。如何理解? //jdk6:创建了一个新的对象"11",也就有新的地址。 //jdk7:此时常量中并没有创建"11",而是创建一个指向堆空间中new String("11")的地址 String s4 = "11"; //s4变量记录的地址:使用的是上一行代码代码执行时,在常量池中生成的"11"的地址 System.out.println(s3 == s4);//jdk6:false jdk7/8:true System.out.println(s5 == s4);//jdk7/8:true }
图示:
JDK6:
JDK7:
5、intern()的拓展问题
代码:
public static void main(String[] args) { //StringIntern.java中练习的拓展: String s3 = new String("1") + new String("1"); // s3 指向堆中的得对象:new String("11") //执行完上一行代码以后,字符串常量池中,是否存在"11"呢?答案:不存在!! String s4 = "11"; //在字符串常量池中生成新的字符串对象"11" //检查字符串常量是否有 "11",此时已经有了,返回是的字符串常量池中 "11" 对象 String s5 = s3.intern(); System.out.println(s3 == s4);//false System.out.println(s5 == s4);//true }
6、intern() 的练习题
(1)练习题1
代码:
public static void main(String[] args) { String s = new String("a") + new String("b");//new String("ab") //在上一行代码执行完以后,字符串常量池中并没有"ab" String s2 = s.intern(); //jdk6中:在串池中创建一个新的字符串对象"ab" //jdk7/8中:串池中没有创建字符串"ab",而是创建一个引用,指向堆中的new String("ab"),将此引用返回 System.out.println(s2 == "ab");//jdk6:true jdk7/8:true System.out.println(s == "ab");//jdk6:false jdk7/8:true }
图示:
JDK6:
JDK7/8
(2)练习题2
代码:
public static void main(String[] args) { String x = "ab"; //常量池中存在 "ab" String s = new String("a") + new String("b");//new String("ab") //在上一行代码执行完以后,字符串常量池中有"ab" String s2 = s.intern(); //因为常量池已经有ab,不会放入 //jdk6中:在串池中创建一个新的字符串对象"ab" //jdk7/8中:串池中没有创建字符串"ab",而是创建一个引用,指向堆中的new String("ab"),将此引用返回 System.out.println(s2 == "ab");//jdk6:true jdk8:true System.out.println(s == "ab");//jdk6:true jdk8:true }
图示:
(3)练习题3
代码:
public static void main(String[] args) { //String s1 = new String("ab");//执行完以后,会在字符串常量池中会生成"ab" String s1 = new String("a") + new String("b"); //执行完以后,不会在字符串常量池中会生成"ab" s1.intern(); String s2 = "ab"; System.out.println(s1 == s2); }
7、intern() 的空间效率测试
测试代码:
public class StringIntern2 { static final int MAX_COUNT = 1000 * 10000; static final String[] arr = new String[MAX_COUNT]; public static void main(String[] args) { Integer[] data = new Integer[]{1,2,3,4,5,6,7,8,9,10}; long start = System.currentTimeMillis(); for (int i = 0; i < MAX_COUNT; i++) { // arr[i] = new String(String.valueOf(data[i % data.length])); arr[i] = new String(String.valueOf(data[i % data.length])).intern(); } long end = System.currentTimeMillis(); System.out.println("花费的时间为:" + (end - start)); try { Thread.sleep(1000000); } catch (InterruptedException e) { e.printStackTrace(); } System.gc(); } }
测试结论:对于程序中大量存在存在的字符串,尤其其中存在很多重复字符串时,使用intern()可以节省内存空间。
例如一些大的网站平台,需要内存中存储大量的字符串。比如社交网站,很多人都存储:北京市、海淀区等信息。这时候如果字符串都调用 intern() 方法,就会明显降低内存的大小。
8、小节
总结String的intern ()的使用:
jdk1.6中,将这个字符串对象尝试放入串池。
如果串池中有,则并不会放入。返回已有的串池中的对象的地址
如果没有,会把此对象复制一份(创建一个新对象),放入串池,并返回串池中的对象地址
jdk1.7起,将这个字符串对象尝试放入串池。
如果串池中有,则并不会放入。返回已有的串池中的对象的地址;
如果没有,则会把对象的引用地址复制一份(复制对象引用),放入串池,并返回串池中的引用地址
六、StringTable 的垃圾回收
StringTable 存在垃圾回收策略,使用intern() 方法会使用字符串常量池中的字符串,从而回收堆中的垃圾对象,可以从空间上节省内存空间。
案例:
public class StringGCTest { public static void main(String[] args) { for (int j = 0; j < 100000; j++) { String.valueOf(j).intern(); } } }
可以使用下面的 JVM 参数来查看 StringTable 的垃圾回收:
-XX:+PrintStringTableStatistics:打印字符串常量池的统计信息
String的垃圾回收:
-Xms15m -Xmx15m -XX:+PrintStringTableStatistics -XX:+PrintGCDetails
运行结果:在新生代发生了 YGC,使得空间进行回收。
七、G1 中的String去重操作
1、官方文档说明
官方文档地址:http://openjdk.java.net/jeps/192
2、String的重复
背景:对许多Java应用(有大的也有小的)做的测试得出以下结果:
① 堆存活数据集合里面 String对象占了25%;
② 堆存活数据集合里面重复的 String对象有13.5%;
③ String 对象的平均长度是 45;
许多大规模的Java应用的瓶颈在于内存,测试表明,在这些类型的应用里面,Java堆中存活的数据集合差不多25%是String对象。
更进一步,这里面差不多一半 String 对象是重复的,重复的意思是说: string1.equals (string2) =true。
堆上存在重复的 String对象必然是一种内存的浪费。这个项目将在G1垃圾收集器中实现自动持续对重复的 String 对象进行去重,这样就能避免浪费内存。
String 的去重是指在堆中会有多个 String 对象的 value 值是一样的,这时就让他们只想同一份数据,如果当其中有一个发生了改变,还需要新创建一个自己的 value。
3、String去重操作
实现:
① 当垃圾收集器工作的时候,会访问堆上存活的对象。对每一个访问的对象都会检查是否是候选的要去重的 String 对象。
② 如果是,把这个对象的一个引用插入到队列中等待后续的处理。一个去重的线程在后台运行,处理这个队列。处理队列的一个元素意味着从队列删除这个元素,然后尝试去重它引用的 String 对象。
③ 使用一个 hashtable 来记录所有的被 String对象使用的不重复的 char 数组。当去重的时候,会查这个hashtable,来看堆上是否已经存在一个一模一样的 char 数组。
④ 如果存在,String 对象会被调整引用那个数组,释放对原来的数组的引用,最终会被垃圾收集器回收掉。
⑤ 如果查找失败,char数组会被插入到 hashtable,这样以后的时候就可以共享这个数组了。
命令行选项
UsestringDeduplication (bool):开启 String 去重,默认是不开启的,需要手动开启。
PrintstringDeduplicationstatistics (bool):打印详细的去重统计信息
StringDeduplicationAgeThreshold (uintx):达到这个年龄的 String 对象被认为是去重的候选对象
参考:
Java 中new String("字面量") 中 "字面量" 是何时进入字符串常量池的?
文章评论