不再怕面试被考字符串---详解Java中的字符串
程序员文章站
2022-04-08 23:08:31
字符串常量池详解 在深入学习字符串类之前, 我们先搞懂JVM是怎样处理新生字符串的. 当你知道字符串的初始化细节后, 再去写 或`String s = new String("hello")`等代码时, 就能做到心中有数. 首先得搞懂字符串常量池的概念. 常量池是Java的一项技术, ......
字符串常量池详解
在深入学习字符串类之前, 我们先搞懂jvm是怎样处理新生字符串的.
当你知道字符串的初始化细节后, 再去写string s = "hello"
或string s = new string("hello")
等代码时, 就能做到心中有数.
- 首先得搞懂字符串常量池的概念.
- 常量池是java的一项技术, 八种基础数据类型除了float和double都实现了常量池技术. 这项技术从字面上是很好理解的: 把经常用到的数据存放在某块内存中, 避免频繁的数据创建与销毁, 实现数据共享, 提高系统性能.
- 字符串常量池是java常量池技术的一种实现, 在近代的jdk版本中(1.7后), 字符串常量池被实现在java堆内存中.
- 下面通过三行代码让大家对字符串常量池建立初步认识:
public static void main(string[] args) { string s1 = "hello"; string s2 = new string("hello"); system.out.println(s1 == s2); //false }
- 我们先来看看第一行代码
string s1 = "hello";
干了什么.
- 对于这种直接通过双引号""声明字符串的方式, 虚拟机首先会到字符串常量池中查找该字符串是否已经存在. 如果存在会直接返回该引用, 如果不存在则会在堆内存中创建该字符串对象, 然后到字符串常量池中注册该字符串.
- 在本案例中虚拟机首先会到字符串常量池中查找是否有存在"hello"字符串对应的引用. 发现没有后会在堆内存创建"hello"字符串对象(内存地址0x0001), 然后到字符串常量池中注册地址为0x0001的"hello"对象, 也就是添加指向0x0001的引用. 最后把字符串对象返回给s1.
- 温馨提示: 图中的字符串常量池中的数据是虚构的, 由于字符串常量池底层是用hashtable实现的, 存储的是键值对, 为了方便大家理解, 示意图简化了字符串常量池对照表, 并采用了一些虚拟的数值.
- 下面看
string s2 = new string("hello");
的示意图
- 当我们使用new关键字创建字符串对象的时候, jvm将不会查询字符串常量池, 它将会直接在堆内存中创建一个字符串对象, 并返回给所属变量.
- 所以s1和s2指向的是两个完全不同的对象, 判断s1 == s2的时候会返回false.
如果上面的知识理解起来没有问题的话, 下面看些难点的.
public static void main(string[] args) { string s1 = new string("hello ") + new string("world"); s1.intern(); string s2 = "hello world"; system.out.println(s1 == s2); //true }
- 第一行代码
string s1 = new string("hello ") + new string("world");
的执行过程是这样子的:
- 依次在堆内存中创建"hello "和"world"两个字符串对象
- 然后把它们拼接起来 (底层使用stringbuilder实现, 后面会带大家读反编译代码)
- 在拼接完成后会产生新的"hello world"对象, 这时变量s1指向新对象"hello world".
- 执行完第一行代码后, 内存是这样子的:
- 第二行代码
s1.intern();
- string类的源码中有对
intern()
方法的详细介绍, 翻译过来的意思是: 当调用intern()
方法时, 首先会去常量池中查找是否有该字符串对应的引用, 如果有就直接返回该字符串; 如果没有, 就会在常量池中注册该字符串的引用, 然后返回该字符串. - 由于第一行代码采用的是new的方式创建字符串, 所以在字符串常量池中没有保存"hello world"对应的引用, 虚拟机会在常量池中进行注册, 注册完后的内存示意图如下:
- 第三行代码
string s2 = "hello world";
- 这种直接通过双引号""声明字符串背后的运行机制我们在第一个案例提到过, 这里正好复习一下.
- 首先虚拟机会去检查字符串常量池, 发现有指向"hello world"的引用. 然后把该引用所指向的字符串直接返回给所属变量.
- 执行完第三行代码后, 内存示意图如下:
- 如图所示, s1和s2指向的是相同的对象, 所以当判断s1 == s2时返回true.
- 最后我们对字符串常量池进行总结: 当用new关键字创建字符串对象时, 不会查询字符串常量池; 当用双引号直接声明字符串对象时, 虚拟机将会查询字符串常量池. 说白了就是: 字符串常量池提供了字符串的复用功能, 除非我们要显式创建新的字符串对象, 否则对同一个字符串虚拟机只会维护一份拷贝.
配合反编译代码验证字符串初始化操作.
- 相信看到这里, 再见到有关的面试题, 你已经无所畏惧了, 因为你已经懂得了背后原理.
- 在结束之前我们不妨再做一道压轴题
public class main { public static void main(string[] args) { string s1 = "hello "; string s2 = "world"; string s3 = s1 + s2; string s4 = "hello world"; system.out.println(s3 == s4); } }
这道压轴题是经过精心设计的, 它不但照应上面所讲的字符串常量池知识, 也引出了后面的话题.
- 如果看这篇文章是你第一次往底层探索字符串的经历, 那我估计你不能立即给出答案. 因为我第一次见这几行代码时也卡壳了.
- 首先第一行和第二行是常规的字符串对象声明, 我们已经很熟悉了, 它们分别会在堆内存创建字符串对象, 并会在字符串常量池中进行注册.
- 影响我们做出判断的是第三行代码
string s3 = s1 + s2;
, 我们不知道s1 + s2
在创建完新字符串"hello world"后是否会在字符串常量池进行注册. 说白了就是我们不知道这行代码是以双引号""形式声明字符串, 还是用new关键字创建字符串. - 这时, 我们应该去读一读这段代码的反编译代码. 如果你没有读过反编译代码, 不妨借此机会入门.
- 在命令行中输入
javap -c 对应.class文件的绝对路径
, 按回车后即可看到反编译文件的代码段.
c:\users\liuyj>javap -c c:\users\liuyj\ideaprojects\test\target\classes\fortest\main.class compiled from "main.java" public class fortest.main { public fortest.main(); code: 0: aload_0 1: invokespecial #1 // method java/lang/object."<init>":()v 4: return public static void main(java.lang.string[]); code: 0: ldc #2 // string hello 2: astore_1 3: ldc #3 // string world 5: astore_2 6: new #4 // class java/lang/stringbuilder 9: dup 10: invokespecial #5 // method java/lang/stringbuilder."<init>":()v 13: aload_1 14: invokevirtual #6 // method java/lang/stringbuilder.append:(ljava/lang/string;)ljava/lang/stringbuilder; 17: aload_2 18: invokevirtual #6 // method java/lang/stringbuilder.append:(ljava/lang/string;)ljava/lang/stringbuilder; 21: invokevirtual #7 // method java/lang/stringbuilder.tostring:()ljava/lang/string; 24: astore_3 25: ldc #8 // string hello world 27: astore 4 29: getstatic #9 // field java/lang/system.out:ljava/io/printstream; 32: aload_3 33: aload 4 35: if_acmpne 42 38: iconst_1 39: goto 43 42: iconst_0 43: invokevirtual #10 // method java/io/printstream.println:(z)v 46: return }
- 首先调用构造器完成main类的初始化
0: ldc #2 // string hello
- 从常量池中获取"hello "字符串并推送至栈顶, 此时拿到了"hello "的引用
2: astore_1
- 将栈顶的字符串引用存入第二个本地变量s1, 也就是s1已经指向了"hello "
3: ldc #3 // string world
5: astore_2
- 重复开始的步骤, 此时变量s2指向"word"
6: new #4 // class java/lang/stringbuilder
- 刺激的东西来了: 这时创建了一个stringbuilder, 并把其引用值压到栈顶
9: dup
- 复制栈顶的值, 并继续压入栈定, 也就意味着栈从上到下有两份stringbuilder的引用, 将来要操作两次stringbuilder.
10: invokespecial #5 // method java/lang/stringbuilder."<init>":()v
- 调用stringbuilder的一些初始化方法, 静态方法或父类方法, 完成初始化.
- 13: aload_1
- 把第二个本地变量也就是s1压入栈顶, 现在栈顶从上往下数两个数据依次是:s1变量和stringbuilder的引用
14: invokevirtual #6 // method java/lang/stringbuilder.append:(ljava/lang/string;)ljava/lang/stringbuilder;
- 调用stringbuilder的append方法, 栈顶的两个数据在这里调用方法时就用上了.
- 接下来又调用了一次append方法(之前stringbuilder的引用拷贝两份就用途在此)
- 完成后, stringbuilder中已经拼接好了"hello world", 看到这里相信大家已经明白虚拟机是如何拼接字符串的了. 接下来就是关键环节
21: invokevirtual #7 // method java/lang/stringbuilder.tostring:()ljava/lang/string;
24: astore_3
- 拼接完字符串后, 虚拟机调用stringbuilder的
tostring()
方法获得字符串hello world
, 并存放至s3. - 激动人心的时刻来了, 我们之所以不知道这道题的答案是因为不知道字符串拼接后是以new的形式还是以双引号""的形式创建字符串对象.
- 下面是我们追踪stringbuilder的
tostring()
方法源码:
@override public string tostring() { // create a copy, don't share the array return new string(value, 0, count); }
- ok, 这道题解了, s3是通过new关键字获得字符串对象的.
- 回到题目, 也就是说字符串常量表中没有存储"hello world"的引用, 当s4以引号的形式声明字符串时, 由于在字符串常量池中查不到相应的引用, 所以会在堆内存中新创建一个字符串对象. 所以s3和s4指向的不是同一个字符串对象, 结果为false.
详解字符串操作类
- 明白了字符串常量池, 我相信关于字符串的创建你已经有十足的把握了. 但是这还不够, 作为一名合格的java工程师, 我们还必须对字符串的操作做到了如指掌. 注意! 不是说你不用查api能熟练操作字符串就了如指掌了, 而是说对string, stringbuilder, stringbuffer三大字符串操作类背后的实现了然于胸, 这样才能在开发的过程中做出正确, 高效的选择.
string, stringbuilder, stringbuffer的底层实现
- 点进string的源码, 我们可以看见string类是通过char类型数组实现的.
public final class string implements java.io.serializable, comparable<string>, charsequence { /** the value is used for character storage. */ private final char value[]; ... }
- 接着查看stringbuilder和stringbuffer的源码, 我们发现这两者都继承自abstractstringbuilder类, 通过查看该类的源码, 得知stringbuilder和stringbuffer两个类也是通过char类型数组实现的
abstract class abstractstringbuilder implements appendable, charsequence { /** * the value is used for character storage. */ char[] value; ... }
而且通过stringbuilder和stringbuffer继承自同一个父类这点, 我们可以推断出它俩的方法都是差不多的. 通过查看源码也发现确实如此, 只不过stringbuffer在方法上添加了
synchronized
关键字, 证明它的方法绝大多数方法都是线程同步方法. 也就是说在多线程的环境下我们应该使用stringbuffer以保证线程安全, 在单线程环境下我们应使用stringbuilder以获得更高的效率.既然如此, 我们的比较也就落到了stringbuilder和string身上了.
关于stringbuilder和string之间的讨论
- 通过查看stringbuilder和string的源码我们会发现两者之间一个关键的区别: 对于string, 凡是涉及到返回参数类型为string类型的方法, 在返回的时候都会通过new关键字创建一个新的字符串对象; 而对于stringbuilder, 大多数方法都会返回stringbuilder对象自身.
/** * 下面截取几个string类的方法 */ public string substring(int beginindex) { if (beginindex < 0) { throw new stringindexoutofboundsexception(beginindex); } int sublen = value.length - beginindex; if (sublen < 0) { throw new stringindexoutofboundsexception(sublen); } return (beginindex == 0) ? this : new string(value, beginindex, sublen); } public string concat(string str) { int otherlen = str.length(); if (otherlen == 0) { return this; } int len = value.length; char buf[] = arrays.copyof(value, len + otherlen); str.getchars(buf, len); return new string(buf, true); } /** * 下面截取几个stringbuilder类的方法 */ @override public stringbuilder append(string str) { super.append(str); return this; } @override public stringbuilder replace(int start, int end, string str) { super.replace(start, end, str); return this; }
- 就因为这点区别, 使得两者在操作字符串时在不同的场景下会体现出不同的效率.
- 下面还是以拼接字符串为例比较一下两者的性能
public class main { public static int time = 50000; public static void main(string[] args) { long start = system.currenttimemillis(); string s = ""; for(int i = 0; i < time; i++){ s += "test"; } long end = system.currenttimemillis(); system.out.println("string类使用时间: " + (end - start) + "毫秒"); } } //string类使用时间: 4781毫秒
public class main { public static int time = 50000; public static void main(string[] args) { long start = system.currenttimemillis(); stringbuilder sb = new stringbuilder(); for(int i = 0; i < time; i++){ sb.append("test"); } long end = system.currenttimemillis(); system.out.println("stringbuilder类使用时间: " + (end - start) + "毫秒"); } } //stringbuilder类使用时间: 5毫秒
- 就拼接5万次字符串而言, stringbuilder的效率是string类的956倍.
- 我们再次通过反编译代码看看造成两者性能差距的原因, 先看string类. (为了方便阅读代码, 我删除了计时部分的代码, 并重新编译, 得到的main方法反编译代码如下)
public static void main(java.lang.string[]); code: 0: ldc #2 // string, 将""空字符串加载到栈顶 2: astore_1 //存放到s变量中 3: iconst_0 //把int型数0压栈 4: istore_2 //存到变量i中 5: iload_2 //把i的值压到栈顶(0) 6: getstatic #3 // field time:i 拿到静态变量time的值, 压到栈顶 9: if_icmpge 38 // 比较栈顶两个int值, for循环中的判定, 如果i比time小就继续执行, 否则跳转 //从这里开始, 就是for循环部分 12: new #4 // class java/lang/stringbuilder 15: dup 16: invokespecial #5 // method java/lang/stringbuilder."<init>":()v 19: aload_1 20: invokevirtual #6 // method java/lang/stringbuilder.append:(ljava/lang/string;)ljava/lang/stringbuilder; 23: ldc #7 // string test 25: invokevirtual #6 // method java/lang/stringbuilder.append:(ljava/lang/string;)ljava/lang/stringbuilder; 28: invokevirtual #8 // method java/lang/stringbuilder.tostring:()ljava/lang/string; 31: astore_1 //每拼接完一次, 就把新的字符串对象引用保存在第二个本地变量中 //到这里一次for循环结束 32: iinc 2, 1 //变量i加1 35: goto 5 //继续循环 38: return
- 从反汇编代码中可以看到, 当用string类拼接字符串时, 每次都会生成一个stringbuilder对象, 然后调用两次append()方法把字符串拼接好, 最后通过stringbuilder的tostring()方法new出一个新的字符串对象.
- 也就是说每次拼接都会new出两个对象, 并进行两次方法调用, 如果拼接的次数过多, 创建对象所带来的时延会降低系统效率, 同时会造成巨大的内存浪费. 而且当内存不够用时, 虚拟机会进行垃圾回收, 这也是一项相当耗时的操作, 会大大降低系统性能.
- 下面是使用stringbuilder拼接字符串得到的反编译代码.
public static void main(java.lang.string[]); code: 0: new #2 // class java/lang/stringbuilder 3: dup 4: invokespecial #3 // method java/lang/stringbuilder."<init>":()v 7: astore_1 8: iconst_0 9: istore_2 10: iload_2 11: getstatic #4 // field time:i 14: if_icmpge 30 //从这里开始执行for循环内的代码 17: aload_1 18: ldc #5 // string test 20: invokevirtual #6 // method java/lang/stringbuilder.append:(ljava/lang/string;)ljava/lang/stringbuilder; 23: pop //到这里一次for循环结束 24: iinc 2, 1 27: goto 10 30: return
- 可以看到stringbuilder拼接字符串就简单多了, 直接把要拼接的字符串放到栈顶进行append就完事了, 除了开始时创建了stringbuilder对象, 运行时期没有创建过其他任何对象, 每次循环只调用一次append方法. 所以从效率上看, 拼接大量字符串时, stringbuilder要比string类给力得多.
- 当然string类也不是没有优势的, 从操作字符串api的丰富度上来讲, string是要多于stringbuilder的, 在日常操作中很多业务都需要用到string类的api.
- 在拼接字符串时, 如果是简单的拼接, 比如说
string s = "hello " + "world";
, string类的效率会更高一点. - 但如果需要拼接大量字符串, stringbuilder无疑是更合适的选择.
- 讲到这里, java中的字符串背后的原理就讲得差不多, 相信在了解虚拟机操作字符串的细节后, 你在使用字符串时会更加得心应手. 字符串是编程中一个重要的话题, 本文围绕java体系讲解的字符串知识只是字符串知识的冰山一角. 字符串操作的背后是数据结构和算法的应用, 如何能够以尽可能低的时间复杂度去操作字符串, 又是一门大学问.
- 最后欢迎关注我的公众号, 我会在公众号中持续更新系统的java后端面试题分析, 将会囊括java基础知识到主流框架原理, 力求深入每个知识点背后的原理. 还会分享关于编程的趣味漫画.