字符串常量池详解

在深入学习字符串类之前, 我们先搞懂JVM是怎样处理新生字符串的.

当你知道字符串的初始化细节后, 再去写String s = "hello"String s = new String("hello")等代码时, 就能做到心中有数.

 

  • 首先得搞懂字符串常量池的概念.
  • 常量池是Java的一项技术, 八种基础数据类型除了float和double都实现了常量池技术. 这项技术从字面上是很好理解的: 把经常用到的数据存放在某块内存中, 避免频繁的数据创建与销毁, 实现数据共享, 提高系统性能.
  • 字符串常量池是Java常量池技术的一种实现, 在近代的JDK版本中(1.7后), 字符串常量池被实现在Java堆内存中.
  • 下面通过三行代码让大家对字符串常量池建立初步认识:
  1. public static void main(String[] args) {
  2. String s1 = "hello";
  3. String s2 = new String("hello");
  4. System.out.println(s1 == s2); //false
  5. }
  • 我们先来看看第一行代码String s1 = "hello";干了什么.

  • 对于这种直接通过双引号""声明字符串的方式, 虚拟机首先会到字符串常量池中查找该字符串是否已经存在. 如果存在会直接返回该引用, 如果不存在则会在堆内存中创建该字符串对象, 然后到字符串常量池中注册该字符串.
  • 在本案例中虚拟机首先会到字符串常量池中查找是否有存在"hello"字符串对应的引用. 发现没有后会在堆内存创建"hello"字符串对象(内存地址0x0001), 然后到字符串常量池中注册地址为0x0001的"hello"对象, 也就是添加指向0x0001的引用. 最后把字符串对象返回给s1.
  • 温馨提示: 图中的字符串常量池中的数据是虚构的, 由于字符串常量池底层是用HashTable实现的, 存储的是键值对, 为了方便大家理解, 示意图简化了字符串常量池对照表, 并采用了一些虚拟的数值.

 

  • 下面看String s2 = new String("hello");的示意图

  • 当我们使用new关键字创建字符串对象的时候, JVM将不会查询字符串常量池, 它将会直接在堆内存中创建一个字符串对象, 并返回给所属变量.
  • 所以s1和s2指向的是两个完全不同的对象, 判断s1 == s2的时候会返回false.

 

如果上面的知识理解起来没有问题的话, 下面看些难点的.

  1. public static void main(String[] args) {
  2. String s1 = new String("hello ") + new String("world");
  3. s1.intern();
  4. String s2 = "hello world";
  5. System.out.println(s1 == s2); //true
  6. }
  • 第一行代码String s1 = new String("hello ") + new String("world");的执行过程是这样子的:
  1. 依次在堆内存中创建"hello "和"world"两个字符串对象
  2. 然后把它们拼接起来 (底层使用StringBuilder实现, 后面会带大家读反编译代码)
  3. 在拼接完成后会产生新的"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关键字创建字符串对象时, 不会查询字符串常量池; 当用双引号直接声明字符串对象时, 虚拟机将会查询字符串常量池. 说白了就是: 字符串常量池提供了字符串的复用功能, 除非我们要显式创建新的字符串对象, 否则对同一个字符串虚拟机只会维护一份拷贝.

 

配合反编译代码验证字符串初始化操作.

  • 相信看到这里, 再见到有关的面试题, 你已经无所畏惧了, 因为你已经懂得了背后原理.
  • 在结束之前我们不妨再做一道压轴题
  1. public class Main {
  2. public static void main(String[] args) {
  3. String s1 = "hello ";
  4. String s2 = "world";
  5. String s3 = s1 + s2;
  6. String s4 = "hello world";
  7. System.out.println(s3 == s4);
  8. }
  9. }

这道压轴题是经过精心设计的, 它不但照应上面所讲的字符串常量池知识, 也引出了后面的话题.

  • 如果看这篇文章是你第一次往底层探索字符串的经历, 那我估计你不能立即给出答案. 因为我第一次见这几行代码时也卡壳了.
  • 首先第一行和第二行是常规的字符串对象声明, 我们已经很熟悉了, 它们分别会在堆内存创建字符串对象, 并会在字符串常量池中进行注册.
  • 影响我们做出判断的是第三行代码String s3 = s1 + s2;, 我们不知道s1 + s2在创建完新字符串"hello world"后是否会在字符串常量池进行注册. 说白了就是我们不知道这行代码是以双引号""形式声明字符串, 还是用new关键字创建字符串.
  • 这时, 我们应该去读一读这段代码的反编译代码. 如果你没有读过反编译代码, 不妨借此机会入门.
  • 在命令行中输入javap -c 对应.class文件的绝对路径, 按回车后即可看到反编译文件的代码段.
  1. C:\Users\liuyj>javap -c C:\Users\liuyj\IdeaProjects\Test\target\classes\forTest\Main.class
  2. Compiled from "Main.java"
  3. public class forTest.Main {
  4. public forTest.Main();
  5. Code:
  6. 0: aload_0
  7. 1: invokespecial #1 // Method java/lang/Object."<init>":()V
  8. 4: return
  9. public static void main(java.lang.String[]);
  10. Code:
  11. 0: ldc #2 // String hello
  12. 2: astore_1
  13. 3: ldc #3 // String world
  14. 5: astore_2
  15. 6: new #4 // class java/lang/StringBuilder
  16. 9: dup
  17. 10: invokespecial #5 // Method java/lang/StringBuilder."<init>":()V
  18. 13: aload_1
  19. 14: invokevirtual #6 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
  20. 17: aload_2
  21. 18: invokevirtual #6 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
  22. 21: invokevirtual #7 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
  23. 24: astore_3
  24. 25: ldc #8 // String hello world
  25. 27: astore 4
  26. 29: getstatic #9 // Field java/lang/System.out:Ljava/io/PrintStream;
  27. 32: aload_3
  28. 33: aload 4
  29. 35: if_acmpne 42
  30. 38: iconst_1
  31. 39: goto 43
  32. 42: iconst_0
  33. 43: invokevirtual #10 // Method java/io/PrintStream.println:(Z)V
  34. 46: return
  35. }
  • 首先调用构造器完成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()方法源码:
  1. @Override
  2. public String toString() {
  3. // Create a copy, don't share the array
  4. return new String(value, 0, count);
  5. }
  • ok, 这道题解了, s3是通过new关键字获得字符串对象的.
  • 回到题目, 也就是说字符串常量表中没有存储"hello world"的引用, 当s4以引号的形式声明字符串时, 由于在字符串常量池中查不到相应的引用, 所以会在堆内存中新创建一个字符串对象. 所以s3和s4指向的不是同一个字符串对象, 结果为false.

 

详解字符串操作类

  • 明白了字符串常量池, 我相信关于字符串的创建你已经有十足的把握了. 但是这还不够, 作为一名合格的Java工程师, 我们还必须对字符串的操作做到了如指掌. 注意! 不是说你不用查api能熟练操作字符串就了如指掌了, 而是说对String, StringBuilder, StringBuffer三大字符串操作类背后的实现了然于胸, 这样才能在开发的过程中做出正确, 高效的选择.

 

String, StringBuilder, StringBuffer的底层实现

  • 点进String的源码, 我们可以看见String类是通过char类型数组实现的.
  1. public final class String
  2. implements java.io.Serializable, Comparable<String>, CharSequence {
  3. /** The value is used for character storage. */
  4. private final char value[];
  5. ...
  6. }

 

  • 接着查看StringBuilder和StringBuffer的源码, 我们发现这两者都继承自AbstractStringBuilder类, 通过查看该类的源码, 得知StringBuilder和StringBuffer两个类也是通过char类型数组实现的
  1. abstract class AbstractStringBuilder implements Appendable, CharSequence {
  2. /**
  3. * The value is used for character storage.
  4. */
  5. char[] value;
  6. ...
  7. }
  • 而且通过StringBuilder和StringBuffer继承自同一个父类这点, 我们可以推断出它俩的方法都是差不多的. 通过查看源码也发现确实如此, 只不过StringBuffer在方法上添加了synchronized关键字, 证明它的方法绝大多数方法都是线程同步方法. 也就是说在多线程的环境下我们应该使用StringBuffer以保证线程安全, 在单线程环境下我们应使用StringBuilder以获得更高的效率.

  • 既然如此, 我们的比较也就落到了StringBuilder和String身上了.

 

关于StringBuilder和String之间的讨论

  • 通过查看StringBuilder和String的源码我们会发现两者之间一个关键的区别: 对于String, 凡是涉及到返回参数类型为String类型的方法, 在返回的时候都会通过new关键字创建一个新的字符串对象; 而对于StringBuilder, 大多数方法都会返回StringBuilder对象自身.
  1. /**
  2. * 下面截取几个String类的方法
  3. */
  4. public String substring(int beginIndex) {
  5. if (beginIndex < 0) {
  6. throw new StringIndexOutOfBoundsException(beginIndex);
  7. }
  8. int subLen = value.length - beginIndex;
  9. if (subLen < 0) {
  10. throw new StringIndexOutOfBoundsException(subLen);
  11. }
  12. return (beginIndex == 0) ? this : new String(value, beginIndex, subLen);
  13. }
  14. public String concat(String str) {
  15. int otherLen = str.length();
  16. if (otherLen == 0) {
  17. return this;
  18. }
  19. int len = value.length;
  20. char buf[] = Arrays.copyOf(value, len + otherLen);
  21. str.getChars(buf, len);
  22. return new String(buf, true);
  23. }
  24. /**
  25. * 下面截取几个StringBuilder类的方法
  26. */
  27. @Override
  28. public StringBuilder append(String str) {
  29. super.append(str);
  30. return this;
  31. }
  32. @Override
  33. public StringBuilder replace(int start, int end, String str) {
  34. super.replace(start, end, str);
  35. return this;
  36. }

 

  • 就因为这点区别, 使得两者在操作字符串时在不同的场景下会体现出不同的效率.
  • 下面还是以拼接字符串为例比较一下两者的性能
  1. public class Main {
  2. public static int time = 50000;
  3. public static void main(String[] args) {
  4. long start = System.currentTimeMillis();
  5. String s = "";
  6. for(int i = 0; i < time; i++){
  7. s += "test";
  8. }
  9. long end = System.currentTimeMillis();
  10. System.out.println("String类使用时间: " + (end - start) + "毫秒");
  11. }
  12. }
  13. //String类使用时间: 4781毫秒
  1. public class Main {
  2. public static int time = 50000;
  3. public static void main(String[] args) {
  4. long start = System.currentTimeMillis();
  5. StringBuilder sb = new StringBuilder();
  6. for(int i = 0; i < time; i++){
  7. sb.append("test");
  8. }
  9. long end = System.currentTimeMillis();
  10. System.out.println("StringBuilder类使用时间: " + (end - start) + "毫秒");
  11. }
  12. }
  13. //StringBuilder类使用时间: 5毫秒
  • 就拼接5万次字符串而言, StringBuilder的效率是String类的956倍.
  • 我们再次通过反编译代码看看造成两者性能差距的原因, 先看String类. (为了方便阅读代码, 我删除了计时部分的代码, 并重新编译, 得到的main方法反编译代码如下)
  1. public static void main(java.lang.String[]);
  2. Code:
  3. 0: ldc #2 // String, 将""空字符串加载到栈顶
  4. 2: astore_1 //存放到s变量中
  5. 3: iconst_0 //把int型数0压栈
  6. 4: istore_2 //存到变量i中
  7. 5: iload_2 //把i的值压到栈顶(0)
  8. 6: getstatic #3 // Field time:I 拿到静态变量time的值, 压到栈顶
  9. 9: if_icmpge 38 // 比较栈顶两个int值, for循环中的判定, 如果i比time小就继续执行, 否则跳转
  10. //从这里开始, 就是for循环部分
  11. 12: new #4 // class java/lang/StringBuilder
  12. 15: dup
  13. 16: invokespecial #5 // Method java/lang/StringBuilder."<init>":()V
  14. 19: aload_1
  15. 20: invokevirtual #6 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
  16. 23: ldc #7 // String test
  17. 25: invokevirtual #6 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
  18. 28: invokevirtual #8 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
  19. 31: astore_1 //每拼接完一次, 就把新的字符串对象引用保存在第二个本地变量中
  20. //到这里一次for循环结束
  21. 32: iinc 2, 1 //变量i加1
  22. 35: goto 5 //继续循环
  23. 38: return
  • 从反汇编代码中可以看到, 当用String类拼接字符串时, 每次都会生成一个StringBuilder对象, 然后调用两次append()方法把字符串拼接好, 最后通过StringBuilder的toString()方法new出一个新的字符串对象.
  • 也就是说每次拼接都会new出两个对象, 并进行两次方法调用, 如果拼接的次数过多, 创建对象所带来的时延会降低系统效率, 同时会造成巨大的内存浪费. 而且当内存不够用时, 虚拟机会进行垃圾回收, 这也是一项相当耗时的操作, 会大大降低系统性能.

 

  • 下面是使用StringBuilder拼接字符串得到的反编译代码.
  1. public static void main(java.lang.String[]);
  2. Code:
  3. 0: new #2 // class java/lang/StringBuilder
  4. 3: dup
  5. 4: invokespecial #3 // Method java/lang/StringBuilder."<init>":()V
  6. 7: astore_1
  7. 8: iconst_0
  8. 9: istore_2
  9. 10: iload_2
  10. 11: getstatic #4 // Field time:I
  11. 14: if_icmpge 30
  12. //从这里开始执行for循环内的代码
  13. 17: aload_1
  14. 18: ldc #5 // String test
  15. 20: invokevirtual #6 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
  16. 23: pop
  17. //到这里一次for循环结束
  18. 24: iinc 2, 1
  19. 27: goto 10
  20. 30: return
  • 可以看到StringBuilder拼接字符串就简单多了, 直接把要拼接的字符串放到栈顶进行append就完事了, 除了开始时创建了StringBuilder对象, 运行时期没有创建过其他任何对象, 每次循环只调用一次append方法. 所以从效率上看, 拼接大量字符串时, StringBuilder要比String类给力得多.

 

  • 当然String类也不是没有优势的, 从操作字符串api的丰富度上来讲, String是要多于StringBuilder的, 在日常操作中很多业务都需要用到String类的api.
  • 在拼接字符串时, 如果是简单的拼接, 比如说String s = "hello " + "world";, String类的效率会更高一点.
  • 但如果需要拼接大量字符串, StringBuilder无疑是更合适的选择.

 

  • 讲到这里, Java中的字符串背后的原理就讲得差不多, 相信在了解虚拟机操作字符串的细节后, 你在使用字符串时会更加得心应手. 字符串是编程中一个重要的话题, 本文围绕Java体系讲解的字符串知识只是字符串知识的冰山一角. 字符串操作的背后是数据结构和算法的应用, 如何能够以尽可能低的时间复杂度去操作字符串, 又是一门大学问.

详解Java中的字符串的更多相关文章

  1. 详解Java中的clone方法

    详解Java中的clone方法 参考:http://blog.csdn.net/zhangjg_blog/article/details/18369201/ 所谓的复制对象,首先要分配一个和源对象同样 ...

  2. 【Java学习笔记之三十三】详解Java中try,catch,finally的用法及分析

    这一篇我们将会介绍java中try,catch,finally的用法 以下先给出try,catch用法: try { //需要被检测的异常代码 } catch(Exception e) { //异常处 ...

  3. 详解Java中的Object.getClass()方法

    详解Object.getClass()方法,这个方法的返回值是Class类型,Class c = obj.getClass(); 通过对象c,我们可以获取该对象的所有成员方法,每个成员方法都是一个Me ...

  4. 详解Java中的clone方法:原型模式

    转:http://developer.51cto.com/art/201506/478985.htm clone顾名思义就是复制, 在Java语言中, clone方法被对象调用,所以会复制对象.所谓的 ...

  5. java中String是对象还是类?详解java中的String

    有很多人搞不懂对象和类的定义.比如说java中String到底是对象还是类呢? 有人说String 既可以说是类,也可以说是对象. 其实他这么说也没问题, 类和对象其实都是一个抽象的概念. 我们可以把 ...

  6. 详解Java中对象的软、弱和虚引用的区别

    对于大部分的对象而言,程序里会有一个引用变量来引用该对象,这是最常见的引用方法.除此之外,java.lang.ref包下还提供了3个类:SoftReference.WeakReference和Phan ...

  7. 详解Java中的final关键字

    本文原文地址:https://jiang-hao.com/articles/2019/coding-java-final-keyword.html1 final 简介2 final关键字可用于多个场景 ...

  8. 详解Java中的clone方法 -- 原型模式

    转自: http://blog.csdn.net/zhangjg_blog/article/details/18369201 Java中对象的创建   clone顾名思义就是复制, 在Java语言中, ...

  9. 详解Java中格式化日期的DateFormat与SimpleDateFormat类

    DateFormat其本身是一个抽象类,SimpleDateFormat 类是DateFormat类的子类,一般情况下来讲DateFormat类很少会直接使用,而都使用SimpleDateFormat ...

随机推荐

  1. 公用表表达式CTE简单递归使用-简单树形结构

    1.建表脚本 CREATE TABLE [dbo].[tb_tree]( ,) NOT NULL, [ParentId] [int] NULL, ) NULL, CONSTRAINT [PK_tb_t ...

  2. 收集的免费API接口

    1.IP地址调用接口 这是淘宝的IP调用API http://ip.taobao.com/service/getIpInfo.php?ip=$ip 返回值:{"code":0,&q ...

  3. oop中 限制文件类型和大小

    <?php /** * Created by IntelliJ IDEA. * User: jiabinwang * Date: 7/5/18 * Time: 8:46 PM */ namesp ...

  4. cf 1006E

    #include <iostream> #include <cstdio> #include <cstring> #include <string> # ...

  5. HDU 5396 区间DP 数学 Expression

    题意:有n个数字,n-1个运算符,每个运算符的顺序可以任意,因此一共有 (n - 1)! 种运算顺序,得到 (n - 1)! 个运算结果,然后求这些运算结果之和 MOD 1e9+7. 分析: 类比最优 ...

  6. CentOS-文件操作

    centos彻底删除文件夹.文件命令(centos 新建.删除.移动.复制等命令: 1.新建文件夹 mkdir 文件名 新建一个名为test的文件夹在home下 view source1 mkdir ...

  7. [转] NGINX宏观手记

    前言 任何一个工具都有它的灵魂所在,作为一个PHP程序员,我们可能仅仅使用了它的一小部分,这篇文章让你更加了解Nginx,本章大多都是总结.翻译.整理 ,希望你可以知道nginx不仅仅是PHP的附属品 ...

  8. VC6.0与Office2007~2010不兼容问题及解决方法

    一.问题描述 启动打开文件对话框中,在 Visual C++ 使用的键盘快捷键或从文件菜单上将导致以下错误: 在 DEVSHL 中的访问冲突 (0xC0000005).在 0x5003eaed 的 D ...

  9. C++ STL 的初步认知

    学无止境!!!    尊重他人劳动,尊重出处:http://www.cnblogs.com/shiyangxt/archive/2008/09/11/1289493.html 我已经做了4年的MFC ...

  10. 正在创建模型,此时不可使用上下文“的解决办法。 正在创建模型,此时不可使用上下文。如果在 OnModelCreating 方法内使用上下文或如果多个线程同时访问同一上下文实例,可能引发此异常。请注意不

    //默认为: Database.SetInitializer<testContext>(null);//这里报错, 检查原因:catch(Exception ex) 错误提示: 基础连接未 ...