ChangeLog

2019-03-23 继续了解了一下字符串常量池…

讲得好啊…

Java 源码分析 — String 的设计

这篇讲的太好了,源码方面的分析直接看这篇吧…

JDK1.8关于运行时常量池, 字符串常量池的要点

Java 永久代去哪儿了

JAVA中String.intern的理解

几张图轻松理解String.intern()

深入解析String#intern

在线查看 jdk8 源码 这里

说明

讨论若未作说明,皆基于 jdk 1.8 +

这一块太抽象了,很多地方要有图才说明得清楚…我只是稍作记录,便于回顾,就不弄那么详细了…

public final class String
    implements java.io.Serializable, Comparable<String>, CharSequence {
}

能看到,String 类由 final 修饰,这代表 String 初始化后就不能改变.这里的改变,指的是创建的对象不可变.

String,StringBuffer 和 StringBuilder 都是 final 类,String 不可变是由于里面真正存储数据的属性 :

/** The value is used for character storage. */
private final char value[];

value 数组为 final…

比如:

String str = "hello";
str = "world";

看起来似乎是改变了,实际上只是引用的指向变了,第一次创建的那个对象 “hello” 并没有变化.

而 String 为 final,效果是 String 不能被继承,避免被其他人继承后破坏.

其次,String 实现了三个接口.

实现

看源码,底层由一个 char 数组实现

private final char value[];

String 不可变,String 类中每一个看起来会修改 String 值的方法,实际上都是新建了一个对象.

以 substring() 举例.

public String substring(int beginIndex, int endIndex) {
  ...   
  int subLen = endIndex - beginIndex;
  ...
  return ((beginIndex == 0) && (endIndex == value.length)) ? this
  : new String(value, beginIndex, subLen);
}

实际上返回了一个新对象.

PS : 本来想用字符串的拼接举例的,比如

String str = "hell" + "o";

记得在 C++ 中可以重载 “+” 运算符,可是 JAVA 怎么做呢…

[java 编程思想]

java 并不允许程序员重载任何操作符,用于 String 的 “+” 和 “+=” 是 java 中仅有的两个重载过的操作符.

jdk 1.8 文档

 字符串连接是通过StringBuilder (或StringBuffer )类及其append方法实现的.

[java 编程思想]

编译期做了一定程度的优化,自动引入了 StringBuilder,调用它的 append 和 toString 方法实现拼接.

不能依赖编译器的优化,频繁拼接应选用 StringBuilder.

equals()

注意,equals() 与 == 在默认情况下都是比较变量指向的首地址.

查看源码可以发现 String 重写了 equals(),使其目的变成比较字符串对象的内容是否一致…

创建对象

String 创建对象有两种方式.

1. String str1 = "hello";
2. String str2 = new String("hello");

区别

先简单说一下

首先第一种,看一段代码.

String s1 = “abc”; 
String s2 = “abc”; 
System.out.println(s1 == s2); 
输出 : true

通过双引号指定的字符串对象,在编译期间确定,通过 equals() 查找常量池有无该字符串,若有,返回.若无,创建再返回.

PS : 用 equals() 来比较,比较的到底是字符串对象还是字符串对象的引用都无妨了…

s1,s2 都指向字符串常量池中的同一个地址.

第二种.

String s1 = new String(“abc”); 
String s2 = new String(“abc”); 
System.out.println(s1 == s2); 
输出 : false

s1,s2 是堆中两个不同对象的引用.

看看 jdk1.8 的源码 :

	 /**
     * Initializes a newly created {@code String} object so that it represents
     * the same sequence of characters as the argument; in other words, the
     * newly created string is a copy of the argument string. Unless an
     * explicit copy of {@code original} is needed, use of this constructor is
     * unnecessary since Strings are immutable.
     *
     */
    public String(String original) {
        this.value = original.value;
        this.hash = original.hash;
    }

这种方式实际上创建了两个对象.

in other words, the newly created string is a copy of the argument string.

参数实际传的是一个 String 对象的引用(也就是上面提到的用 双引号 指定字符串对象,此时会有与字符串常量池中数据的判断).这里生成了一个对象,这个对象在编译期间确定.

第二次又用 new 关键字创建了一个对象.这个对象实际是参数对象的副本.这一块和普通对象的创建一致.

查看 对象的创建

字符串常量池

字符串常量池是干吗用的??

因为字符串作为一个基础的数据类型,使用非常频繁.

而我们已经知道,String 对象是不可变的.所以当我们需要多个 String 对象时,它们实际上指向的同一个对象还是不同的对象,都没什么关系.

基于此,为了减少频繁创建字符串所带来的性能影响,JVM 为字符串开辟了一个类似于缓存区的空间,这个空间就是字符串常量池.在字符串常量池中,每个字符串只保存一份.(通过 equals() 判断 String 的值)

第一种创建方式 中,之所以打印”true”,就是因为对于同一个字符串对象,字符串常量池中只保存一份.实际比较的其实是字符串常量池中的同一个地址.

jdk 1.7 以前,字符串常量池在运行时常量池里,而运行时常量池放在方法区.

不过字符串常量池放在方法区 会导致内存溢出错误.(与 JVM 内存分配联系)

于是在 jdk 1.7 中,把字符串常量池从方法区移动到堆中.(运行时常量池中的其他东西不变动)

方法区是 JVM 的概念模型.这一块联系 JVM 的内存模型.

永久代和元空间是方法区的两种不同具体实现.

  • MetaSpace(元空间) –> 使用本地内存
  • PermGen(永久代) –> 使用 JVM 的内存

字符串常量池存在永久代中,更容易出现性能问题和内存溢出.

jdk 1.7 中,把字符串常量池从方法区移动到堆中.

  • 类和方法的信息大小难以确定,给永久代的大小指定带来困难.
  • 永久代会为 GC 带来不必要的复杂度.

jdk 1.8 中,HotSpot 虚拟机用元空间取代了永久代.

好处 : java.lang.OutOfMemoryError : PermGen 这个异常不复存在了

理解一下 intern()

String str = "hello";
1. System.out.println(str);
2. System.out.println(str.intern());

从输出结果来看,1,2 没区别…

hello
hello

intern() 返回的是字符串对象的规范表示.

它返回的内容取自具有唯一字符串的值.

其实也就是说它返回的值来自字符串常量池.

看网上的介绍,对于字符串常量池中到底保存的是字符串对象,还是字符串对象的引用,众说纷纭…

看 jdk1.8 对 intern() 的说明.

When the intern method is invoked, if the pool already contains a string equal to this String object as determined by the equals(Object)method, then the string from the pool is returned. Otherwise, this String object is added to the pool and a reference to this String object is returned.

这个说明对于理解原理貌似没有啥参考价值…

jdk6 和 jdk6+ 的描述是一样的,但是 jdk6 和 jdk6+ 的实现并不一致.

尝试跟踪源码.

public native String intern();

是个本地方法,继续深入 :

在线查看

\openjdk7\jdk\src\share\native\java\lang\String.c
Java_java_lang_String_intern(JNIEnv *env, jobject this)
{
    return JVM_InternString(env, this);
}

日…到这步跟不下去了.还是搜索一下看看别人有没有关于这玩意儿的资料吧…

果然找到!!

深入解析String#intern

还是直接给结果吧…

看段代码:

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

why?

  • jdk6

上面已经提到过,String s = new String("1"); 这种方式创建字符串实际生成了两个字符串对象.

首先,构造器中传入了一个字符串对象 “1”,它就被放在字符串常量池中.

然后在堆中创建一个字符串对象,s 指向这个字符串对象的首地址.

调用 s.intern() ,字符串常量池已经存在该对象,直接返回.

s2 指向的就是字符串常量池中 “1” 的首地址.

而 == 判断的是地址是否相同.显然不同.

s3 与 s4 不同之处在于,执行 s3.intern() 时,字符串常量池中还没有字符串对象,所以需要在字符串常量池中创建一个对象.其余的与上相同.

  • jdk 7

s 与 s2 情况与 jdk6 一样.

但是执行 s3.intern() 时,字符串常量池中还没有字符串对象,此时先堆中有没有该字符串对象,如果有,就把堆中字符串对象的引用保存到字符串常量池中.

所以 s4 指向的和 s3 一样,都是堆中 String 对象的引用.

所以判断为 true.

总结 :

jdk6 : 当调用 intern 方法时,如果字符串常量池先前已创建出该字符串对象,则返回池中的该字符串的引用.否则,将该字符串对象添加到字符串常量池中,并且返回该字符串对象的引用.

jdk6+ : 当调用 intern 方法时,如果字符串常量池先前已创建出该字符串对象,则返回池中的该字符串的引用.否则,如果该字符串对象已经存在于 Java堆 中,则将堆中对此对象的引用添加到字符串常量池中,并且返回该引用;如果堆中不存在,则在池中创建该字符串并返回其引用.

jdk6 字符串只存在于 字符串常量池.

jdk6+ 字符串存在于字符串常量池和 Java堆.

那么回到上面那个问题,字符串常量池中到底保存的是字符串对象,还是字符串对象的引用?

答案就是,jdk6 保存的是字符串对象,jdk6+ 既保存了字符串对象,又保存了字符串对象的引用.

JVM 内存模型

https://hqweay.cn/2018/10/09/jvm/#内存管理机制

拼接字符串

[JAVA 编程思想中文版] P284

尝试了一下拼接字符串,然后调用 jdk 自带的反编译工具查看…

拼接字符串被 JVM 优化,操作过于频繁时会使用 StringBuilder.

javap 使用

首先对 java 文件进行编译.如 Test.java

javac Test.java

生成了 Test.class

再执行 javap -c Test.calss

或者 javap -c Test

即可查看反编译的代码.

测试

String str4 = "haha" + "hehe" + 112 + "shsh";

当只有这么一行的时候,反编译效果.

ldc           #5                  // String hahahehe112shsh
astore        4

后面并未注释使用了 StringBuilder.

但是执行

for(int i = 0; i < 15; i++){
  str4 += "llo";
}

反编译发现:

      30: new           #6                  // class java/lang/StringBuilder
      33: dup
      34: invokespecial #7                  // Method java/lang/StringBuilder."<init>":()V
      37: aload         4
      39: invokevirtual #8                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
      42: ldc           #9                  // String llo
      44: invokevirtual #8                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
      47: invokevirtual #10                 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
      50: astore        4

可以大概看出使用了 StringBuilder 的append().