☕️【Java 技术之旅】带你一起攻克 String 类创建的难点分析
字符串常量池引入
String 是一个引用类型,这意味着 String 类型的实例化与其它对象一样,相较于基本数据类型,时间和空间的消耗都是较大的,但是由于 String 的使用频率非常高,JVM 为了提高性能和减少内存的开销,在实例化字符串的时候进行了一些优化,引入了字符串常量池。。
字符串创建过程
每当我们创建字符串常量时,JVM 会首先检查字符串常量池,如果该字符串已经存在常量池中,那么就直接返回常量池中的实例引用。
如果字符串不存在常量池中,就会实例化该字符串并且将其放到常量池中。由于 String 字符串的不可变性我们可以十分肯定常量池中一定不存在两个相同的字符串
.class 文件中的常量池将包含 String 字面量,在 jvm 进行类装载过程中,class 文件中的常量池将被载入内存,此时便形成了所谓的字符串常量池。
整体来说 String 对象的初始化分为两种
初始化方式将会影响对象内存分配的方式,
字面量初始化的形式创建字符串
执行完上面的第一句代码之后,会在堆上创建一个 String 对象,并把 String 对象的引用存放到字符串常量池中,并把引用返回给 one,那当第二句代码执行时,字符串常量池已经有对应内容的引用了,直接返回对象引用给 two。one.equals(two) / one == two 都为 true。 图形化如下所示:
字符串拼接的初始化场景
应该思考为什么会输出 true,通过反编译可知 jvm 直接将上面的"a"+"a"在编译阶段直接变成了"aa"。
上面这一段输出 false,同样通过反编译
可以看出汇编指令明显比上面的长了许多,然后我们逐个分析 s1 的产生过程
首先 jvm 会先生成一个 StringBuilder 对象
然后会添加 s 和"a",这里我们可以看出第一次添加的时候需要通过 ldc 出栈解析了字符串 s 的值,然后添加到 StringBuilder 对象中。
最后调用 StringBuilder 对象的 toString 方法返回一个新的字符串对象。
StringBuilder 的 toString 方法如下,所以上面 s1==s2 为 false。
通过上面的分析
我们可以知道当 String s1 = "a" + "a"时在编译阶段由于可以直接确定 s1 的值,所以在编译阶段直接将 s1 的值赋值为 aa
String s1 = s+ "a"在编译阶段中由于不知道 s 的内容(在编译阶段 jvm 不会知道一个对象的内容),所以需要运行期间来解析 s 并且通过 StringBuilder 进行优化来将它们相加。
所以我们在平时写代码的时候对于字符串拼接用 StringBuilder 来拼接,因为 String 类型相加底层用的 StringBuilder,而每一次 String 相加都会生成一个对象,使用 StringBuilder 可以节约内存,避免内存溢出
new 创建字符串
众所周知 String s = new String("a")将会在生成一个 String 对象,字符串 a 会不会加入到常量池中呢?我们对 String s = new String("a")也进行反编译如下:
通过对上面进行反编译可以看到使用 new 创建对象的时候执行了 ldc 这个指令,ldc 指令的意思是操作字符串常量池,如果有直接拉取下来,如果没有就创建一个对象在常量池中。
通过反编译我们可以看出使用 new String()创建对象的时候我们访问了字符串常量池的,那么是不是创建 s 对象的时候在常量池也创建了一个"a"呢?
可以理解为
也就是说在第二种初始化中是包含了第一种初始化的,首先进行的是以字面量的形式创建匿名变量,具体流程与第一种方式初始化一致,然后 new 操作会在堆上创建 s 指向的 String 对象,也就是说第二种方式初始化实际上会创建两个 String 对象,一个存放在字符串常量池,一个存放在堆中;
实际案例分析
带着这个疑问我们看看 String 的构造函数
这个构造函数只是将"a"的 value 和 hash 赋值给了新创建的对象,而 value 是 char[]类型的数组,hash 也是 int 类型,这两个都不可能对字符串常量池访问,所以真正的原因只可能是传入的"a"是从字符串常量池中获取的,所以我们在 new String()的时候有可能会生成两个对象。
所以对于 new String("a")可能会生成两个对象,一个是字符串类型对象存放在堆中,另外一个就是字符串常量池对象,当然如果 a 以前在字符串常量池中存在那么将不会创建字符串常量池对象。
注意:new String()本身不会在字符串常量池中创建相应的对象,new String("a")会生成两个对象的原因是因为"a"在加载 new String("a");这行代码所存在的类的时候将其放入字符串常量池中的。比如"int"就是 java 在 Integer 类的初始化阶段时候在解析 public static final Class<Integer> TYPE = (Class<Integer>) Class.getPrimitiveClass("int");这行代码的时候放入到字符串常量池中的,具体可以根据工具进行查看 =》工具可以使用 GDB。
可以看出当使用 char[]数组创建对象的时候并没有访问常量池,通过上面我们可以得出只要在代码中出现"a","ab"这种直接告诉我们这是什么的字符串才会在常量池中创建相应的对象
可以得出以下结论:
字面量形式的 String 对象初始化都会被加入字符串常量池;此时当内容一致时,多个引用会同时指向同一对象,这也是为什么 String 会被设计成 immutability(不变性),防止当一个引用更改对象的内容时,其它引用被迫更改。
使用 new 操作创建的 String 对象,一定会在堆上创建对象,但是如果涉及到字面量初始化,则会创建两个对象,分别存放在字符串常量池与堆中。
在直接使用双引号"" 声明字符串的时候,java 都会去常量池通过 equal 找有没有相同的字符串,如果有,则将常量池的引用返回给变量,如果没有 回在常量池中创建一个对象,然后返回这个对象的引用
使用 new 关键字创建,例如 String a = new String("a"),这里首先会去常量池对比有没有"a",没有则会创建,其次 new 一定会在堆里面创建一个新对象 并返回该对象的引用
使用+ 运算符,此处有大致有三种情况,
String str = "ab"+"cd"; 在常量池上创建常量 ab, cd ,abcd 返回 abcd【着重了解 abcd 在常量池上】
String str = new String("ab") + new String("cd"); 在堆上创建对象 ab、cd 和 abcd,在常量池上创建常量 ab 和 cd ,常量池上不会创建 abcd【着重了解 abcd 不会在常量池上】
还有混合使用的就不在一一说明 如 String str = "ab" + new String("cd"); 或者 String strAb = "ab"; String str = strAb + new String("cd") 等情况
验证运算符
验证运算符与字符串混用
区别仅仅就是多定义了一个 String s1 ="ab",
来一个特殊字符串
与代码 3 一样仅仅是字符串替换成了"java" 返回结果为 false 其实此处就是因为 jvm 虚拟机在其他类【Version.class】先定义了放入了常量池,其实原理就和代码 4 一样 先把 ab 放入了常量池 了解了原理就可以举一反三
运行时常量的包装类
8 种基本数据类型都有自己的包装类,在包装类对象创建的实话就会消耗资源,因此 java 对 其中 5 种(Byte,Short,Integer,Long,Character,Boolean)包装类实现了常量池技术,默认创建了数值(-128 ,127)的相应类型的缓存数据,但是超出了此范围依然会去创建新的对象。两种浮点数类型的包装类 (Float,Double) 并没有实现常量池技术
版权声明: 本文为 InfoQ 作者【李浩宇/Alex】的原创文章。
原文链接:【http://xie.infoq.cn/article/cb7b86d2c02fbf8cac31fa71e】。文章转载请联系作者。
评论