第十三章 StringTable

String 的基本特性

  • String:字符串,使用一对 “” 引起来表示
// 两种定义方式
String s1 = "atguigu"; // 字面量的定义方式
String s2 = new String("hello");
  • String 声明为 final 的,不可被继承
  • String 实现了 Serializable 接口:表示字符串支持序列化的。
  • String 实现了 Comparable 接口:表示 String 可以比较大小
  • String 在 JDK 8 及以前内部定义了 final char[] value 用于存储字符串数据。JDK 9 时改为了 byte[]
  • String 代表不可变的字符序列。简称:不可变性
    • 当对字符串重新赋值时,需要重写指定内存区域赋值,不能使用原有的 value 进行复制。
    • 当对现有的字符串进行连接操作时,也需要重新指定内存区域赋值,不能使用原有的 value 进行赋值
    • 当调用 String 的 replace() 方法修改指定字符或字符串时,也需要重新指定内存区域赋值,不能使用原有的 value 进行赋值
  • 通过字面量的方式(区别于 new)给一个字符串复制,此时的字符串值声明在字符串常量池中。
  • 字符串常量池中是不会存储相同内容的字符串的。
    • String 的 String Pool 是一个固定大小的 Hashtable,默认值大小长度是 1009.如果放进 String Pool 的 String 非常多,就会造成 Hash 冲突严重,从而导致链表会很长,而链表长了后直接会造成的影响就是当调用 String.intern 时性能会大幅下降。
    • 使用 -XX:StringTableSize 可设置 StringTable 的长度
    • 在 JDK 6 中 StringTable 是固定的,就是 1009 的长度,所以如果常量池中的字符串过多就会导致效率下降很快。StringTableSize 设置没有要求。
    • 在 JDK 7 中,StringTable 的长度默认值是 60013
    • JDK 8 开始,设置 StringTable 的长度的话,1009 是可设置的最小值

image.png
image.png
String 在 JDK 9 中存储结构的变更
官网说明:JEP 254: Compact Strings
:::warning

Motivation

The current implementation of the String class stores characters in a char array, using two bytes (sixteen bits) for each character. Data gathered from many different applications indicates that strings are a major component of heap usage and, moreover, that most String objects contain only Latin-1 characters. Such characters require only one byte of storage, hence half of the space in the internal char arrays of such String objects is going unused.

动机

String类的当前实现将字符存储在字符数组中,每个字符使用两个字节(十六位)。从许多不同的应用程序收集的数据表明,字符串是堆使用的主要组成部分,此外,大多数String对象仅包含Latin-1 字符。此类字符只需要一个字节的存储空间,因此此类String对象的内部字符数组中一半的空间将未使用。

Description

We propose to change the internal representation of the String class from a UTF-16 char array to a byte array plus an encoding-flag field. The new String class will store characters encoded either as ISO-8859-1/Latin-1 (one byte per character), or as UTF-16 (two bytes per character), based upon the contents of the string. The encoding flag will indicate which encoding is used.
String-related classes such as AbstractStringBuilder, StringBuilder, and StringBuffer will be updated to use the same representation, as will the HotSpot VM’s intrinsic string operations.
This is purely an implementation change, with no changes to existing public interfaces. There are no plans to add any new public APIs or other interfaces.

描述

我们建议将String类的内部表示形式从UTF-16字符数组更改为字节数组加上编码标志字段。新的字符串类将根据字符串的内容存储编码为ISO-8859-1/Latin-1(每个字符一个字节)或UTF-16(每个字符两个字节)的字符。编码标志将指示使用哪种编码。(即可以根据字符串的内容存储编码来使用不同的存储方式)
AbstractStringBuilder、StringBuilder和StringBuffer等字符串相关类将更新为使用相同的表示形式,HotSpot VM的内在字符串操作也是如此。
这纯粹是一种实现更改,对现有的公共接口没有更改。没有计划添加任何新的公共API或其他接口。
:::
结论:
String 再也不用 char[] 来存储了,改成了 byte[] 加上编码标记,节约了一些空间。而StringBuilder和StringBuffer等字符串相关类也更新为使用相同的表示形式。HotSpot VM的内在字符串操作也是如此。

package chapter13;

import org.junit.Test;

public class StringTest1 {

    @Test
    public void test1() {
        String s1 = "abc"; // 字面量定义的方式,"abc"存储在字符串常量池中
        String s2 = "abc";

        System.out.println(s1 == s2); // true

        System.out.println(s1);
        System.out.println(s2);
    }
}

测试结果:
image.png

package chapter13;

import org.junit.Test;

public class StringTest1 {

    @Test
    public void test1() {
        String s1 = "abc"; // 字面量定义的方式,"abc"存储在字符串常量池中
        String s2 = "abc";
        s1 = "hello";

        System.out.println(s1 == s2); // 判断地址:false

        System.out.println(s1);
        System.out.println(s2);
    }

    @Test
    public void test2() {
        String s1 = "abc";
        String s2 = "abc";
        s2 += "def";
        System.out.println(s2); // abc
        System.out.println(s1); // abcdef
    }

    @Test
    public void test3() {
        String s1 = "abc";
        String s2 = s1.replace('a', 'm');
        System.out.println(s1); // abc,体现了 String 的不可变性
        System.out.println(s2); // mbc
    }
}

例题:

package chapter13;

public class StringExer {
    String str = new String("good");
    char[] ch = {'t','e','s','t'};

    public void change(String str, char[] ch){
        str = "test ok";
        ch[0] = 'b';
    }

    public static void main(String[] args) {
        StringExer ex = new StringExer();
        ex.change(ex.str, ex.ch);
        System.out.println(ex.str); // good
        System.out.println(ex.ch); // best
    }
}

结果输出:
image.png
当主线程执行到方法 ex.chang(ex.str, ex.ch) 时,change 方法入栈,其局部变量表中包含两个变量,分别是 str 和 ch,主线程将成员变量 str 的地址值复制了一份传递给了 change 方法的局部变量 str,此时如果输出打印 str,其值将会和成员变量 str 一样,均为 good。但是 change 方法又重新将 “test ok” 赋值给了 str,由于“test ok”之前在字符串常量池中并不存在,所以虚拟机会将“test ok”添加到字符串常量池中,并将地址赋值给 str,此时 str 的值就变成了 “test ok”。随着 change 方法执行结束,虚拟机栈会将其出栈,此时的局部变量 str 也就失去了作用,所以在打印成员变量 str 的值时没有发生改变。要想改变成员变量 str 的值,则 str = “test ok”; 应该改为 this.str = “test ok”;。

String 的内存分配

  • 在 Java 语言中有 8 中基本数据类型和一种比较特殊的类型 String。这些类型为了使它们在运行过程中速度更快、更节省内存,都提供了一种常量池的概念。
  • 常量池就类似于一个 Java 系统级别提供的缓存。8 中基本数据类型的常量池都是系统协调的,String 类型的常量池比较特殊,它的主要使用方法有两种。
    • 直接使用双引号声明出来的 String 对象会直接存储在常量池中。
      • 比如:String info = “atguigu.com”;
    • 如果不是用双引号声明的 String 对象,可以使用 String 提供的 intern() 方法
    • 使用 new String(“hello”) 方式创建的对象,"hello"会被存放在堆中常量池之外的地方。
  • Java 6 及以前,字符串常量池存放在永久代
  • Java 7 中 Oracle 的工程师对字符串池的逻辑做了很大的改变,即将字符串常量池的位置调整到 Java 堆中。
    • 所有的字符串都保存在堆(Heap)中,和其他普通对象一样,这样可以让你在进行调优应用时进需要调整堆大小就可以了。
    • 字符串常量池概念原本使用得比较多,但是这个改动使得我们有足够的理由重新考虑在 Java 7 中使用 String.iintern()。
  • Java 8 元空间,字符串常量在堆。

StringTable 为什么要调整?
官网:Java SE 7 Features and Enhancements
:::warning
Area: HotSpot
Synopsis: In JDK 7, interned strings are no longer allocated in the permanent generation of the Java heap, but are instead allocated in the main part of the Java heap (known as the young and old generations), along with the other objects created by the application. This change will result in more data residing in the main Java heap, and less data in the permanent generation, and thus may require heap sizes to be adjusted. Most applications will see only relatively small differences in heap usage due to this change, but larger applications that load many classes or make heavy use of the String.intern() method will see more significant differences.
RFE: 6962931
:::

  • PermSize(永久代)比较小,如果大量创建字符串,永久代很容易报 OOM
  • 永久代垃圾回收频率低

String 的基本操作

案例一

package chapter13;

public class StringTest4 {
    public static void main(String[] args) {
        System.out.println();
        System.out.println("1");
        System.out.println("2");
        System.out.println("3");
        System.out.println("4");
        System.out.println("5");
        System.out.println("6");
        System.out.println("7");
        System.out.println("8");
        System.out.println("9");
        System.out.println("10"); //

        System.out.println("1");
        System.out.println("2");
        System.out.println("3");
        System.out.println("4");
        System.out.println("5");
        System.out.println("6");
        System.out.println("7");
        System.out.println("8");
        System.out.println("9");
        System.out.println("10"); //
    }
}

image.png
以 debug 模式启动程序:
第一个断点处:
image.png
第二个断点处:
image.png
继续单步执行
image.png
直接跳到下一个断点:
image.png
继续单步执行
image.png
继续单步执行
image.png
直接跳至最后一个断点
image.png
代码执行结束后,字符串个数仍为 1160,未再发生变化:
image.png

总结:Java 语言规范里要求完全相同的字符串字面量,应该包含同样的 Unicode 字符序列(包含同一份码点序列的常量),并且必须是指向同一个 String 类实例。

案例二

image.png
内存分析:
image.png
image.png
第 7 行代码创建了一个字符串。该字符串被存放在堆空间中字符串常量池中,并且在 foo() 栈空间中创建了一个指向该字符串的一个引用。

字符串拼接操作

1、常量与常量的拼接结果在常量池,原理是编译器优化
2、常量池中不会存在相同内容的常量
3、只要其中有一个是变量,结果就在堆中。变量拼接的原理是 StringBuilder
4、如果拼接的结果是调用 intern() 方法,则主动将常量池中还没有的字符串对象放入池中,并返回此对象地址。

package chapter13;

import org.junit.Test;

public class StringTest5 {

    @Test
    public void test1() {
        String s1 = "a" + "b" + "c";
        String s2 = "abc"; // "abc" 一定是放在字符串常量池中的,将此地址赋值给 s2

        /**
         * 最终 .java 编译成 .class,再执行 .class
         * 编译期就已经确定下来
         * 验证方式:可以直接在idea中查看编译后.class文件,其显示的内容如下:
         * String s1 = "abc";
         * String s2 = "abc";
         * 也可以通过 jclasslib 进行验证 
         */
        System.out.println(s1 == s2); // true
        System.out.println(s1.equals(s2)); // true
    }

    @Test
    public void test2() {
        String s1 = "javaEE";
        String s2 = "hadoop";

        String s3 = "javaEEhadoop";
        String s4 = "javaEE" + "hadoop";
        // 如果拼接符号的前后出现了 new,则需要在堆空间中 new String(),具体的内容为拼接后的结果
        String s5 = s1 + "hadoop";
        String s6 = "javaEE" + s2;
        String s7 = s1 + s2;

        System.out.println(s3 == s4); // true
        System.out.println(s3 == s5); // false
        System.out.println(s3 == s6); // false
        System.out.println(s3 == s7); // false
        System.out.println(s5 == s6); // false
        System.out.println(s5 == s7); // false
        System.out.println(s6 == s7); // false

        // intern():判断字符串常量池中是否存在 javaEEhadoop 值,如果存在,则返回常量池中 javaEEhadoop 的地址值
        // 如果字符串常量池中不存在 javaEEhadoop,则向字符串常量池中添加 javaEEhadoop,并返回 javaEEhadoop 的地址值
        String s8 = s6.intern();
        System.out.println(s3 == s8); // true
    }

    @Test
    public void test3() {
        String s1 = "a";
        String s2 = "b";
        String s3 = "ab";
        String s4 = s1 + s2;
        System.out.println(s3 == s4); // false
    }

     @Test
    public void test4() {
        final String s1 = "a";
        final String s2 = "b";
        String s3 = "ab";
        String s4 = s1 + s2;
        System.out.println(s3 == s4); // true
    }
}

test1() 方法分析:
直接在idea中查看编译后的 SpringTest5.class文件
image.png
使用 jclsslib,可以看到两组命令完全一致。
image.png
test3() 分析:
image.png
s1 + s2 的执行细节如下:
:::warning
StringBuilder s = new StringBuilder(); (这里变量 s 是为了方便理解而临时定义的)
s.append(“a”);
s.append(“b”);
s.toString() --> 约等于 new String(“ab”);
:::
补充:在 JDK5.0 以后使用的 StringBuilder,在 JDK5.0 之前使用的 StringBuffer
test4() 分析:
字符串拼接操作不一定使用的是 StringBuilder !
如果拼接符号左右两边都是字符串常量或常量引用,则仍然使用编译期优化,即非 StringBuilder 的方式。
在定义类、方法、基本数据类型、引用数据类型时,能使用 final 建议使用。
变量 s1、s2 被 final 修饰后则变成了常量, final 在编译的时候就会分配了,准备阶段会显示初始化。

拼接操作与 append 操作的效率对比

package chapter13;

import org.junit.Test;

public class StringTest5 {
    
    @Test
    public void test6() {
        long start = System.currentTimeMillis();
//        method1(100000); //1804
        method2(100000); // 3
        long end = System.currentTimeMillis();
        System.out.println("花费的时间为:" + (end - start));
    }

    private void method2(int highLevel) {
        StringBuilder src = new StringBuilder();
        for (int i = 0; i < highLevel; i++) {
            src.append("a");// 每次循环都会创建一个 StringBuilder、String
        }
    }

    private void method1(int highLevel) {
        String src = "";
        for (int i = 0; i < highLevel; i++) {
            src = src + "a";
        }
    }
}

测试 method1 :将 test6() 方法中的 method2() 注释掉
image.png
测试 method12:将 test6() 方法中的 method1() 注释掉
image.png
总结:
通过 StringBuilder 的 append() 的方式添加字符串的效率要远高于使用 String 的字符串拼接方式。
原因:

  • StringBuilder 的 append() 的方式,自始至终只创建了一个 StringBuilder 的对象。使用 String 的字符串拼接方式,创建了多个 StringBuilder 和 String 对象
  • 使用 String 的字符串拼接方式,内存中由于创建了较多的 StringBuilder 和 String 的对象,内存占用更大;如果进行 GC,需要花费额外的时间。

使用 StringBuilder 的 append() 的方式可继续改进:
在实际开发中,如果基本确定需要添加所有的字符串长度不高于某个限定值 highLevel 的情况下,建议使用构造器 StringBuilder s = new StringBuilder(highLevel); // new char[highLevel]。
StringBuilder 底层是使用得 char 型数组存储字符串的,如果使用如果使用空参构造器,append() 方法会确认 append 进来的字符串是否超出了当前字符数组的容量,如果超出了就需要进行扩容。扩容次数过多了,效率就会下降。所以在一开始就设置好字符数组的大小,不用每次进行扩容。即使设置的容量不够,append 也会自动进行扩容的。

intern() 的使用

概述

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

  • 比如:String myInfo = new String(“I love atguigu”).intern();

也就是说,如果在任意字符上调用 String.intern 方法,那么其返回结果所指向的那个类实例,必须和直接以常量形式出现的字符串实例完全相同。因此,下列表达式的值必定是 true:
:::warning
(“a” + “b” + “c”).intern() == “abc”
:::
通俗点讲,Interned String 就是确保字符串在内存里只有一份拷贝,这样可以节约内存空间,加快字符串操作任务的执行速度。注意,这个值会被存放在字符串内部池(String Intern Pool)。
如何保证变量 s 指向的是字符串常量池中的数据呢?
有两种方式:

  • 方式一:字面量定义的方式
    • String s = “hello”;
  • 方式二:调用 intern 方法
    • String s = new String(“hello”).intern();
    • String s = new StringBuilder(“hello”).toString().intern();

JDK6 VS JDK7/8 中的intern

示例一

问题:

  • new String(“ab”) 会创建几个对象?
package test.chapater13;

public class StringNewTest {
    public static void main(String[] args) {
        String str = new String("ab");
    }
}

两个。一个对象是 new 关键字在堆空间中创建的。另一个对象是字符串常量池中的对象,字节码指令 ldc。 image.png

  • new String(“a”) + new String(“b”) 会创建几个对象?
package chapter13;

public class StringNewTest {
    public static void main(String[] args) {
        String str = new String("a") + new String("b");
    }
}

image.png
根据字节码文件分析:
对象 1 : new StringBuilder()
对象 2 :new String(“a”)
对象 3 :字符串常量池中的 “a”
对象 4 :new Stirng(“b”)
对象 5 :字符串常量池中的 “b”
对象 6 :StringBuilder 的 toString() 方法–> new String(“ab”)
StringBuilder 的 toString() 方法的调用并没有在字符串常量池中生成 “ab”
image.png

示例二

package test.chapater13;

public class StringIntern1 {
    public static void main(String[] args) {
        String s = new String("1");
        s.intern(); // 调用此方法之前,字符串常量池中已经存在”1“
        String s2 = "1";
        System.out.println(s == s2); // jdk6: false  jdk8: false

        String s3 = new String("1") + new String("1");// s3变量记录的地址为:new String("11")
        // 执行完上一行代码以后,字符串常量池中,并不存在"11"
        s3.intern(); // 在字符串常量池中生成"11"。
        // jdk6 : 创建了一个新的对象"11",也就有了新的地址
        // jdk7 : 此时常量池中并没有创建"11",而是创建了一个指向那个堆空间中new String("11")的地址
        String s4 = "11"; // s4 变量记录的地址:使用的是上一行代码执行时,在常量池中生成的"11"的地址
        System.out.println(s3 == s4); // jdk6: false jdk8: true
    }
}

JDK6 环境下测试结果:
image.png
变量 s 指向的是堆空间中的 String 对象,由于字符串常量池中并没有对象 “1”,new 在执行时,同时向字符串常量池中添加了对象"1",变量 s2 指向的是字符串常量池中的对象"1"。所以 s == s2 的结果为 false。s.intern() 方法加不加无所谓, new String() 操作会将创建的字符串添加到字符串常量池中。
变量
s3 最终指向了堆中对象"11",根据示例一可以得知,new String(“1”) + new String(“1”); 代码最终并没有把对象"11" 添加进字符串常量池中。s3.intern() 则会将 “11” 添加进字符串常量池。s4 指向的是字符串常量池中的对象 “11”。所以 s3 == s4 的结果为false。
image.png
JDK8 环境下测试结果:
image.png
jdk8 中 s3.intern() 会将堆中 “11” 对象的地址拷贝一份到字符串常量池中。s4 = “11” 检测到字符串常量池中已经存在 “11”,会把字符串常量池中的 “11” 的地址赋给变量 s4,即 s4 最终会指向堆中的 “11”。
image.png

示例三

package chapter13;

public class StringIntern1 {
    public static void main(String[] args) {
        String s3 = new String("1") + new String("1");
        // 执行完上一行代码以后,字符串常量池中,不存在"11"
        String s4 = "11"; // 在字符串常量池中生成对象"11"
        String s5 = s3.intern();
        System.out.println(s3 == s4);
        System.out.println(s5 == s4);
    }
}

执行结果:
image.png

总结 String 的 intern() 的使用

  • JDK 1.6 中,将这个字符串对象尝试放入字符串常量池。
    • 如果字符串常量池中有,则并不会放入,返回已有的字符串常量池中的对象的地址
    • 如果没有,会把此对象复制一份,放入字符串常量池,并返回字符串常量池中的对象地址。
  • JDK 1.7 起,将这个字符串对象尝试放入字符串常量池
    • 如果字符串常量池中有,则并不会放入,返回已有的字符串常量池中的对象的地址
    • 如果没有,则会把对象的引用地址复制一份,放入字符串常量池,并返回字符串常量池中的引用地址

image.png
s2 == “ab” ,最终比较的是地址值,而"ab"的地址值则是指向了字符串常量池中的"ab"。

Intern 的空间效率测试

package chapter13;

public class StringIntern2 {
    static final int MAX_COUNT = 1000 * 1000;
    static final String[] arr = new String[MAX_COUNT];
    public static void main(String[] args) {
        Integer[] data = new Integer[]{1,2,3,4,5,6,7,8,9,10};

        long start = System.currentTimeMillis();
        for (int i = 0; i < MAX_COUNT; i++) {
//            arr[i] = new String(String.valueOf(data[i % data.length]));
            arr[i] = new String(String.valueOf(data[i % data.length])).intern();
        }
        long end = System.currentTimeMillis();
        System.out.println("花费的时间为:" + (end - start));

        try {
            Thread.sleep(1000000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

未使用 intern 方法:
image.png
使用 intern 方法:
image.png
可以看到在未使用 intern 方法的情况,内存的占用率要远大于使用 intern 方法的。这是因为数组 data 中的值是固定的,for 循环体是不断向字符数组中赋重复值,只不过数组索引位置不同而已。也就是说,字符串常量池中早就已经生成了"1"~"10"的对象。

arr[i] = new String(String.valueOf(data[i % data.length])).intern();

这段代码的执行逻辑是这样的,首先在堆中生成 new String(String.valueOf(data[i % data.length]) 的对象,生成后判断字符串常量池中是否存在,如果存在,则将字符串常量池中的对象地址返回给 arr[i]。此时, new String(String.valueOf(data[i % data.length]) 这个对象便没有了引用,于是垃圾回收器便可以将其回收。这就是为什么使用 intern 方法后,内存空间占用较小的原因。
String str = new String(“abc”):str直接用new String(“abc”)创建,"abc"这字符串在一出现就自动创建成对象存放到常量池中,所以常量池里面存放的是"abc"字符串的引用,并不是str创建的对象的引用。

结论:对于程序中大量存在的字符串,尤其其中存在很多复杂字符串时,使用 intern() 可以节省内存空间。

.StringTable 的垃圾回收

参数设置:
:::warning
-Xms15m -Xmx15m -XX:+PrintStringTableStatistics -XX:+PrintGCDetails
:::

G1 中的 String 去重操作

  • 背景:对许多 Java 应用(有大的也有小的)做的测试得出以下结果:
    • 堆存活数据集合里面 String 对象占了 25%
    • 堆存活数据集合里面重复的 String 对象有 13.5%
    • String 对象的平均长度是 45
  • 许多大规模的 Java 应用的瓶颈在于内存,测试表明,在这些类型的应用里面,Java 堆中存活的数据集合差不多 25% 是 String 对象。更进一步,这里面差不多一半 String 对象是重复的,重复的意思是说:string1.equals(string2)=true。堆上存在重复的 String 对象必然是一种内存的浪费。这个项目将在 G1 垃圾收集器中实现自动持续对重复的 String 对象进行去重,这样就能避免浪费内存。
  • 实现
    • 当垃圾收集器工作的时候,会访问堆上存活的对象。对每一个访问的对象都会检查是否是候选的要去重的 String 对象
    • 如果是,把这个对象的一个引用插入到队列中等待后续的处理。一个驱虫的线程在后台运行,处理这个队列。处理队列的一个元素意味着从队列删除这个元素,然后尝试去重它引用的 String 对象。
    • 使用一个 hashtable 来记录所有的被 String 对象使用的不重复的 char 数组。当去重的时候,会查这个 hashtable,来看堆上是否已经存在一个一模一样的 char 数组。
    • 如果存在,String 对象会被调整引用那个数组,释放对原来的数组的引用,最终会被垃圾收集器回收掉。
    • 如果查找失败,char 数组会被插入到 hashtable,这样以后的时候就可以共享这个数组了。
  • 命令行选项
    • UseStringDeduplication(bool):开启 String 去重,默认是不开启的,需要手动开启。
    • PrintStringDeduplicationStatistics(bool):打印详细的去重统计信息
    • StringDeduplicationAgeThreshold(uintx);达到这个年龄的 String 对象被认为是去重的候选对象
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值