10_StringTable

String的基本特性

  • String:字符串,使用一对""引起来表示。
    String sl1=“atguigu”;//字面量的定义方式
    String s2= new String(“hello”);

  • String 被final声明,不可被继承

  • String实现了 Serializable接口:表示字符串是支持序列化的

  • 实现了 Comparable接口:表示 String可以比较大小

  • String final在jdk8及以前内部定义了char[] value用于存储字符串数据。jdk9时改为byte[]

    变更原因:

    image-20200917150221054

    image-20200917150355524

  • String:代表不可变的字符序列。简称:不可变性。

字符串的不可变性

  • 通过字面量的方式(区别于new)给一个字符串赋值,此时的字符串值声明在字符串常量池中,之后再使用intern()方法或者字面量赋值相同的字符串,会直接返回当前字符串分配到的地址引用。

  • 当对已经赋值字符串引用的字符串变量重新赋值时,会新开辟一块空间存储新的字符串,然后将新的引用赋值到该字符串变量

    String s1 = "abc";
    String s2 = "abc";
    System.out.println(s1==s2);//true。定义s1的时候"abc"字面量进入了常量池,定义s2的时候检查到常量池已存在该常量,直接获取其引用
    s1 = "hello";
    System.out.println(s1==s2);//false,"hello"字面量会在常量池另外开辟内存进行存储并重新赋值新引用到使s2,不会覆盖"abc"的空间
    System.out.println(s1);//abc,没有被覆盖
    System.out.println(s2);//hello,新内存地址引用
    
  • 当对现有的字符串进行连接操作时,也需要重新分配内存进行存储连接后的新值,不能对变量已指向的常量池内存空间进行覆盖。

    String s1 = "abc";
    String s2 = "abc";
    s1 += "def";
    System.out.println(s1==s2);//false,还是会在常量池新开辟一块内存空间存储拼接后的"abcdef"
    System.out.println(s1);//abc,没有被覆盖
    System.out.println(s2);//abcdef,新内存地址引用
    
  • 当调用String的replace()方法修改指定字符或字符串时,也需要重新分配内存进行存储连接后的新值,不能对变量已指向的常量池内存空间进行覆盖。

    String s1 = "abc";
    String s2 = s1.replace('a', 'm');
    System.out.println(s1==s2);//false,还是会在常量池新开辟一块内存空间存储替换后得到的"mbc"
    System.out.println(s1);//abc,没有被覆盖
    System.out.println(s2);//mbc,新内存地址引用
    

StringTable

字符串常量池中是不会存储相同内容的字符串的。

  • String的 String Pool是一个固定大小的HashTable,默认值大小长度是1009。如果放进 String Pool的string非常多,就可能造成较多的Hash冲突,从而导致链表会很长,而链表长了后直接会造成的影响就是当调用String.intern()时性能会大幅下降。
  • 使用-XX: StringTableSize可设置StringTable的长度
  • 在jdk6中StringTable是固定的,就是1009的长度,所以如果常量池中的字符串过多就会导致效率下降很快。StringTablesize设置没有要求
  • 在jdk7中, StringTable的长度默认值是60013,1009是可设置的最小值。

使用-XX:+PrintStringTableStatistics可以在退出的时候打印StringTable的信息

String的内存分配

在Java语言中有8种基本数据类型和一种比较特殊的类型String。这些类型为了使它们在运行过程中速度更快、更节省内存,都提供了一种常量池的概念。常量池就类似一个Java系统级别提供的缓存。8种基本数据类型的常量池都是系统协调的, String类型的常量池比较特殊。它的主要使用方法有两种。

  • 直接使用双引号声明出来的 String对象会直接存储在常量池中。
  • 如果不是用双引号声明的String对象可以使用 String提供的 intern()方法。

字符串常量池位置变化

  1. Java6及以前字符串常量池存放在永久代。
  2. Java7中 oracle的工程师对字符串池的逻辑做了很大的改变,即将字符串常量池的位置调整到Java堆内。
    • 所有的字符串都保存在堆(Heap)中,和其他普通对象一样,这样可以让你在进行调优应用时仅需要调整堆大小就可以了。
    • 字符串常量池概念原本使用得比较多,但是这个改动使得我们有足够的理由让我们重新考虑在Java7中使用 String.intern()
  3. Java8元空间,字符串常量在堆。

为什么要变化?

image-20200917154123382

String例子说明

  1. 运行下面代码,通过IDEA下方的memory窗口可以查看当前JVM内存对象情况,其中String对象在运行到第一个"10"之后String对象变为2331个,在之后就一直不变了。

    image-20200917154527425

  2. 以下方法运行过程的引用分析

    image-20200917155005412

    image-20200917155021370

    A string is created in line7. it goes in the String pool in the heap space and a reference is created in the foo() stack space for it.

字符串拼接操作

前面提到被作用右值引用的字符串字面量都会进入到字符串常量值中,下面介绍当出现字符串拼接(使用"+"或者通过StringBuilder等工具类进行拼接得到的结果)的时候拼接得到的结果会不会出现在常量池中:

  1. 字符串拼接结果直接进入常量池的情况:

    • 常量与常量的拼接结果在常量池, 原理是编译期优化(即编译期会完成字符串常量池初始化,将所有在编译期识别的字符串值在字符串常量池进行创建),这里的常量就是指字符串字面量(右值)以及使用final修饰的字符串变量(左值)

      String s1 = "a" + "b" + "c"; //前端编译直接合成"abc"
      String s2 = "abc";
      System.out.println(s1 == s2); //true
      
    • 常量池中不会存在相同内容的常量。

  2. 字符串拼接结果不进入常量池的情况:

    • 只要参与拼接的字符串中有一个是变量(即非上面描述的常量范围,这样的符号/symbol在编译期无法确定其值), 字符串拼接将在运行时进行,此时拼接结果将会产生一个String对象放在堆中(不进入常量池)。变量拼接的原理是 StringBuilder

       string s1 ="javaEE";
       string s2 ="hadoop";
       string s3 ="javaEEhadoop";
       string s4 ="javaEE"+ "hadoop";
       string s5=s1+"hadoop";
       string s6="javaEE"+s2;
       string s7=s1+s2;
       System.out.printIn(s3 ==s4);//true
       System.out.printIn(s3 ==s5);//false
       System.out.printIn(s3 = s6);//false
       System.out.printIn(s3 =s7);//false
       System.out.printIn(s5 ==s6);//false
       System.out.printIn(s5 ==s7);//false
       System.out.printIn(s6==s7);//false
       string s8=s6.intern();
       System.out.println(s3 ==s8);//true
      
    • 如果拼接的结果调用intern()方法,则主动将常量池中还没有的字符串对象放入池中,并返回此对象地址;如果已经存在,则返回已存在字符串地址。

字符串拼接"+"解析

可以看到下面的字节码解析中字符串的拼接通过StringBuilder来实现的,在没有StringBuilder(5.0)之前都是使用StringBuffer

image-20200917161645127

StringBuilder#toString()方法的底层如下:

    @Override
    public String toString() {
        // Create a copy, don't share the array
        return new String(value, 0, count);
    }

可以看到是直接new了一个String对象,注意这种方式与new String("我是字符串")是有区别的,前者对字符串常量池没有任何影响;后者会将"我是字符串"字面量放入常量池,然后返回分配到的内存地址赋值到变量(左值)中。即前者会在堆产生一个String对象,后者会在字符串常量池建立一个字符串常量。

使用"+"和StringBuilder#append()拼接的效率

由上面可以得知每一个"+“都会编译为创建一个StringBuildertoStringnew一个String对象,所以如果在一个循环次数很高的代码中使用”+"就会涉及常见对象的消耗以及内存占用(以及带来GC)的消耗。所以相较于我们手动使用StringBuilder#append来说,效率会差别非常大。

另外如果我们使用StringBuilder的时候,如果可以提前可以预知字符串长度则可以调用有参构造传入capacity创建指定长度的Builder,防止多次扩容。

final

final可以修饰一个左值为一个常量:

    public void test1() {
        String s1 = "javaEEhadoop";
        String s2 = "javaEE";
        String s3 = s2 + "hoodp";
        System.out.println(s1 == s3); //false
        final String s4 = "javaEE"; //s4常量
        String s5 = s4 + "hadoop";
        System.out.println(s1 == s5);//true
    }

intern()的使用

intern()方法主动将常量池中还没有的字符串对象放入池中,并返回此对象地址;如果已经存在,则返回已存在字符串地址。。

如果不是用双引号声明的 string对象,可以使用 string提供的intern方法: intern方法会从字符串常量池中查询当前字符串是否存在,若不存在就会将当前字符串放入常量池中。

  • 比如: String myInfo= new String(aStringVariable).intern();

当且仅当两个字符串变量equals的时候它们调用intern()方法得到的结果是=的。因此,下列表达式的值必定是true:

("a"+"b"+"c").intern() == "abc"

通俗点讲, Interned String就是确保字符串在内存里只有一份拷贝,这样可以节约内存空间,加快字符串操作任务的执行速度。注意,这个值会被存放在字符串内部池(String Intern Pool)。

JDK6和JDK7变化例子

例1:

求以下输出:

image-20200917164159401

intern()方法变化说明

  • JDK6及之前,intern()方法会在常量池中寻找该字符串常量是否存在,如果存在,则方法返回常量池中该地址;如果不存在,则在常量池中为该常量开辟空间存储后返回该新地址。

    • 第一个输出:false
      1. 第一行"1"是一个字面量,编译期初始化进入字符串常量池,然后new String("1")会在堆中创建一个String对象,本地变量s存储的是该String对象的引用,而该String对象中保存了"1"字符串常量的引用
      2. s.intern()相当于没做任何事情,因为没有将返回结果进行赋值
      3. String s2 = "1"将"1"在字符串常量池地址返回该s2.
      4. 很自然s != s2.
    • 第二个输出:false
      1. String s3 = new String("1") + new String("1")会在堆中创建两个String对象,分别保存了"1"的引用
      2. 此时intern()方法就有作用了,它会将s3指向的新拼接好的第三个保存在堆中的String对象中保存的字符串保存到字符串常量池。因为代码中没有任何地方定义了字面量"11"或者由常量拼接成的"11",所以在调用intern()方法之前,它都是以String对象的一个字节成员字段保存在堆中的。但是即使是这样,依然没有将返回值覆盖到s3
      3. 此时String s4 = "11"就会从常量池加载得到"11",返回该引用到s4
      4. 所以很自然,s3是一个堆中地址,s4是常量池地址,s3 != s4
  • JDK7及之后,intern()方法的语义发生了变化,它会在常量池中寻找该字符串常量是否存在,如果存在,则方法返回常量池中该地址;如果不存在,将不会在常量池中开辟新空间,而是直接将当前字符串地址插入到常量池表中。

    即JDK6其实发生了字符串复制,而JDK7没有,因为在JDK7及之后,字符串常量池被移到了堆中(老年代),在JDK6之前是在永久代的,所以在JDK7可以这样做。所以以下输出也产生了变化

    • 第一个输出:false
      1. 这里其实没有变化,"1"已经存在常量池
      2. 结果一样
    • 第二个输出:true
      1. 这里的变化就大了,在s3.intern()会将s3的引用直接更新到常量池表中
      2. 此时String s4 = "11";将会得到s3的引用,故s3==s4

另外学到的是,我们可以从字节码角度对一些代码进行分析,以下是JDK8的字节码分析:image-20200917165013913image-20200917165217870

例2:

# JDK7及之后
String s3 = new String("1") + new String("1");
String s4 = "11"; //常量"11"已经存在,所以"11" == s4 != s3
String s5 = s3.intern(); //"11" == s5 
System.out.println(s3 == s4);//false
System.out.println(s4 == s5);//true

# JDK6及之前
String s = new String("b") + new String("b");
String s2 = s.intern();//发生了字符串复制,s2 == "ab" != s
System.out.println(s2 == "ab");//true
System.out.println(s == "ab");//false

# 无论是任何版本
String ss = new String("ab"); //"ab"编译期存在常量池,先从常量池加载该常量引用并利用它在池外创建一个String对象
ss.intern();
String ss2 = "ab";
System.out.println(ss == ss2);//false

intern()的用处

如果在需要动态生成大量字符串(非字面量)的情况下,推荐使用intern()方法可以大大降低堆的空间使用(复用常量池内存)。

StringTable的垃圾回收

和堆无区别,本身就在堆中,由垃圾收集器决定如何回收。

G1中的String去重操作

背景:对许多Java应用(有大的也有小的)做的测试得出以下结果:

  • 堆存活数据集合里面string对象占了25%
  • 堆存活数据集合里面重复的string对象有13.5%
  • String对象的平均长度是45

许多大规模的Java应用的瓶颈在于内存,测试表明,在这些类型的应用里面,Java堆中存活的数据集合差不多25%是String对象。更进一步,
这里面差不多一半 String对象是重复的,重复的意思是说:
stringl.equals(string2)=true
堆上存在重复的 String对象必然是一种内存的浪费。这个项目将在G1垃圾收集器中实现自动持续对重复的 String对象进行去重,这样就能避免浪费内存。

实现

  1. 当垃圾收集器工作的时候,会访问堆上存活的对象。对每一个访问的对象都会检查是否是候选的要去重的 String对象。
  2. 如果是,把这个对象的一个引用插入到队列中等待后续的处理。一个去重的线程在后台运行,处理这个队列。处理队列的一个元素意味着从队列删除这个元素,然后尝试去重它引用的String对象。
  3. 使用一个hashtable来记录所有的被string对象使用的不重复的char数组。当去重的时候,会查这个hashtable,来看堆上是否已经存在一个一模一样的char数组。
  4. 如果存在, String对象会被调整引用那个数组,释放对原来的数组的引用,最终会被垃圾收集器回收掉。
  5. 如果查找失败,char数组会被 hasht插入到,这样以后的时候就可以共享这个数组了。

配置使用

# 开启G1 string去重,默认是不开启的,需要手动开启。
-XX:+/-UseStringDeduplication
# 打印详细的去重统计信息
-XX:+/-PrintStringDeduplicationStatistics
# 达到这个年龄的 string对象被认为是去重的候选对象
-XX:StringDeduplicationAgeThreshold=

   转载规则


《10_StringTable》 阿钟 采用 知识共享署名 4.0 国际许可协议 进行许可。
  目录