玩命加载中🤣🤣🤣

JVM-09-StringTable


StringTable

1-String的基本特性

  • String:字符串,使用一对 “” 引起来表示
String s1 = "hello";  // 字面量的定义方式
String s2 =  new String("hello");  // new 对象的方式
  • String 被声明为 final 的,不可被继承
  • String 实现了 Serializable 接口:表示字符串是支持序列化的。实现了 Comparable 接口:表示 String 可以比较大小
  • String 在 jdk8 及以前内部定义了 final char value[] 用于存储字符串数据。JDK9 时改为 byte[]

String存储结构变更

官方文档http://openjdk.java.net/jeps/254

// <=jdk8
private final char value[];
// >=jdk9
private final byte[] value
  • String 类的当前实现将字符存储在 char 数组中,每个字符使用两个字节(16位)。
  • 从许多不同的应用程序收集的数据表明,字符串是堆使用的主要组成部分,而且大多数字符串对象只包含拉丁字符(Latin-1)。这些字符只需要一个字节的存储空间,因此这些字符串对象的内部 char 数组中有一半的空间将不会使用,产生了大量浪费。
  • 之前 String 类使用 UTF-16 的 char[] 数组存储,现在改为 byte[] 数组外加一个编码标识存储。该编码表示如果你的字符是 ISO-8859-1 或者 Latin-1,那么只需要一个字节存。如果你是其它字符集,比如 UTF-8,你仍然用两个字节存

结论:

  • String 再也不用 char[] 来存储了,改成了 byte [] 加上编码标记,节约了一些空间

  • 同时基于 String 的数据结构,例如 StringBuffer 和 StringBuilder 也同样做了修改

基本特性

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

  • 当对字符串重新赋值时,需要重写指定内存区域赋值,不能使用原有的 value 进行赋值。
@Test
public void test1() {
    String s1 = "abc";//字面量定义的方式,"abc"存储在字符串常量池中
    String s2 = "abc";
    s1 = "hello";

    System.out.println(s1 == s2);//判断地址:true  --> false
    System.out.println(s1);//
    System.out.println(s2);//abc
}
  • 当对现有的字符串进行连接操作时,也需要重新指定内存区域赋值,不能使用原有的 value 进行赋值。
@Test
public void test2() {
    String s1 = "abc";
    String s2 = "abc";
    s2 += "def";
    System.out.println(s2);//abcdef
    System.out.println(s1);//abc
}
  • 当调用 String 的 replace() 方法修改指定字符或字符串时,也需要重新指定内存区域赋值,不能使用原有的 value 进行赋值。
@Test
public void test3() {
    String s1 = "abc";
    String s2 = s1.replace('a', 'm');
    System.out.println(s1);//abc
    System.out.println(s2);//mbc
}

面试题

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;
        ex = new StringExer();
        ex.change(ex.str, ex.ch);
        System.out.println(ex.str);//good
        System.out.println(ex.ch);//best
    }
}

字节码

castore操作规范

通过字节码可以看到, str 时仅仅是当前栈帧的局部变量表中, 并没有针对引用数据类型做修改的动作. 而数组则是采用了 castore 操作(具体细节说明结合规范), 切实地对引用数据类做操作.

通过字面量的方式(区别于new)给一个字符串赋值,此时的字符串值声明在字符串常量池中。

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

  • String的String Pool(字符串常量池)是一个固定大小的Hashtable,默认值大小长度是 1009。如果放进 String Pool 的 String 非常多,就会造成Hash冲突严重,从而导致链表会很长,而链表长了后直接会造成的影响就是当调用 String.intern() 方法时性能会大幅下降。
  • 使用 -XX:StringTablesize 可设置 StringTable 的长度
  • 在 JDK6 中 StringTable 是固定的,就是 1009 的长度,所以如果常量池中的字符串过多就会导致效率下降很快,StringTablesize 设置没有要求
  • 在 JDK7中,StringTable 的长度默认值是 60013,StringTablesize 设置没有要求
  • 在 JDK8 中,StringTable 的长度默认值是 60013,StringTable 可以设置的最小值为 1009
jps #查看java进程
jinfo -flag StringTableSize 5432 #查看大小

2-String的内存分配

  • 在 Java 语言中有 8 种基本数据类型和一种比较特殊的类型String。这些类型为了使它们在运行过程中速度更快、更节省内存,都提供了一种常量池的概念。

  • 常量池就类似一个 Java 系统级别提供的缓存。8 种基本数据类型的常量池都是系统协调的,String 类型的常量池比较特殊。它的主要使用方法有两种

    • 直接使用双引号声明出来的 String 对象会直接存储在常量池中。

      • 比如:String info="hello";
    • 如果不是用双引号声明的 String 对象,可以使用 String 提供的 intern() 方法。这个后面重点谈

  • Java 6 及以前,字符串常量池存放在永久代

  • Java 7 中 Oracle的工程师对字符串池的逻辑做了很大的改变,即将字符串常量池的位置调整到 Java 堆内

    • 所有的字符串都保存在堆(Heap)中,和其他普通对象一样,这样可以让你在进行调优应用时仅需要调整堆大小就可以了。
    • 字符串常量池概念原本使用得比较多,但是这个改动使得我们有足够的理由让我们重新考虑在 Java 7 中使用 String.intern()。
  • Java8 元空间,字符串常量在堆

JDK6 StringTable位置

JDK7 StringTable位置

StringTable为什么要调整?

官方文档:https://www.oracle.com/java/technologies/javase/jdk7-relnotes.html#jdk7changes

  1. permSize 默认比较小

  2. 永久代垃圾回收频率低, 大量的字符串无法及时回收容易进行 Full GC 产生 STW 或者容易产生 OOM

实例demo

/**
 * jdk6中:
 * -XX:PermSize=6m -XX:MaxPermSize=6m -Xms6m -Xmx6m
 *
 * jdk8中:
 * -XX:MetaspaceSize=6m -XX:MaxMetaspaceSize=6m -Xms6m -Xmx6m
 */
public class StringTest3 {
    public static void main(String[] args) {
        //使用Set保持着常量池引用,避免full gc回收常量池行为
        Set<String> set = new HashSet<String>();
        //在short可以取值的范围内足以让6MB的PermSize或heap产生OOM了。
        short i = 0;
        while(true){
            set.add(String.valueOf(i++).intern());
        }
    }
}

JKD8 报错(由此也可见 Java8 元空间,字符串常量在堆)

Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
	at java.util.HashMap.resize(HashMap.java:703)
	at java.util.HashMap.putVal(HashMap.java:662)
	at java.util.HashMap.put(HashMap.java:611)
	at java.util.HashSet.add(HashSet.java:219)
	at com.atguigu.java.StringTest3.main(StringTest3.java:22)

Process finished with exit code 1

3-String的基本操作

demo1

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

public class StringTest4 {
    public static void main(String[] args) {
        System.out.println();//此处断点, 观察内存中String数量
        System.out.println("1");//此处断点, 观察内存中String数量, 加了一个, 有一个换行符的增加
        System.out.println("2");// 此处断点, 观察内存中String数量, 加了一个
        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");// 此处断点, 观察内存中String数量, 与"1"相比增加了9个, 说明增加了1-9的字符串
        //如下的字符串"1" 到 "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");//
    }
}

demo2

class Memory {
    public static void main(String[] args) {
        int i = 1;
        Object obj = new Object();
        Memory mem = new Memory();
        mem.foo(obj);
    }

    private void foo(Object param) {
        String str = param.toString();
        System.out.println(str);
    }
}

引用图解

4-字符串拼接操作

  1. 常量与常量的拼接结果在常量池,原理是编译期优化
  2. 常量池中不会存在相同内容的变量
  3. 拼接前后,只要其中有一个是变量,结果就在堆中。变量拼接的原理是 StringBuilder
  4. 如果拼接的结果调用 intern() 方法,根据该字符串是否在常量池中存在,分为:
    • 如果存在,则返回字符串在常量池中的地址
    • 如果字符串常量池中不存在该字符串,则在常量池中创建一份,并返回此对象的地址

demo1

常量与常量的拼接结果在常量池,原理是编译期优化

@Test
public void test1() {
    String s1 = "a" + "b" + "c";//编译期优化:等同于"abc"
    String s2 = "abc"; //"abc"一定是放在字符串常量池中,将此地址赋给s2
    /*
         * 最终.java编译成.class,再执行.class
         * String s1 = "abc";
         * String s2 = "abc"
         */
    System.out.println(s1 == s2); //true
    System.out.println(s1.equals(s2)); //true
}

字节码

demo2

拼接前后,只要其中有一个是变量,结果就在堆中

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

    String s3 = "javaEEhadoop";
    String s4 = "javaEE" + "hadoop";//编译期优化
    //如果拼接符号的前后出现了变量,则相当于在堆空间中new String(),具体的内容为拼接的结果:javaEEhadoop
    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,并返回次对象的地址。
    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
}

字节码分析

细节二

字符串拼接不一定使用的都是 StringBuilder

  • 如果拼接符号左右两边都是字符串常量或常量引用, 则仍然使用编译器优化, 即非 StringBuilder 的方式
  • 针对于 final 修饰类、方法、基本数据类型、引用数据类型的量的结构时, 能使用上 final 的时候建议使用上
@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
}

字节码分析

针对上述练习题

@Test
public void test5() {
    String s1 = "javaEEhadoop";
    String s2 = "javaEE";
    String s3 = s2 + "hadoop";
    System.out.println(s1 == s3);

    final String s4 = "javaEE";
    String s5 = s4 + "hadoop";
    System.out.println(s1 == s5);
}

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

@Test
public void test6(){
    long start = System.currentTimeMillis();
    method1(100000);//6150
    long end = System.currentTimeMillis();
    System.out.println("+拼接花费的时间为:" + (end - start));

    long s2 = System.currentTimeMillis();
    method2(100000);//3
    long e2 = System.currentTimeMillis();
    System.out.println("append拼接花费的时间为:" + (e2 - s2));
}

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

public void method2(int highLevel){
    //只需要创建一个StringBuilder
    StringBuilder src = new StringBuilder();
    for (int i = 0; i < highLevel; i++) {
        src.append("a");
    }
}
  • 相差了 2000 多倍
  • StringBuilder 的 append() 方式: 自始至终只创建过一个StringBuilder 对象; String 字符串拼接方式: 创建过多个StringBuilder 和 String 的对象
  • String 字符串拼接方式: 内存中由于创建了较多的 StringBuilder 和 String 对象, 内存占用更大; 如果进行 GC, 需要花费额外的时间
  • 在实际开发中, 如果基本确定前前后后添加的字符串长度不高于某个限定值 highLevel 的情况下, 建议使用构造器实例化StringBuilder s = new StringBuilder(highLevel); 尽量避免底层不断进行扩容的情况

5-intern()的使用

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

    • 比如:String myInfo = new string("hello").intern();
  • 也就是说,如果在任意字符串上调用 String.intern() 方法,那么其返回结果所指向的那个类实例,必须和直接以常量形式出现的字符串实例完全相同。因此,下列表达式的值必定是 true

demo1

new String(“ab”) 会创建几个对象?

@Test
public void test1() {
    String str = new String("ab");
}

字节码分析

demo2

new String(“a”) + new String(“b”); 创建几个对象

字节码分析

StringBuilder.toString() 字节码

demo3

@Test
public void test3() {
    String s = new String("1");
    s.intern(); // 此时字符串常量池中已经有 "1" 了
    String s2 = "1";
    System.out.println(s == s2);

    String s3 = new String("1") + new String("1"); // 相当于new String("11")
    // 此时常量池中不存在 "11"
    s3.intern(); // 在常量池中生成"11", 如何理解: jdk6:在字符串常量池创建了一个新的对象"11", 也就有新的地址, 但是jdk7之后中字符串常量池记录的是new出来的地址值, 并没有创建"11"
    String s4 = "11"; // s4变量记录的地址: 使用的是上一行代码执行时, 在常量池中生成的 "11" 的地址
    System.out.println(s3 == s4);
}

jdk7下的流程

拓展问题

@Test
public void test4() {
    String s3 = new String("1") + new String("1");
    String s4 = "11"; // 在字符串常量池中生成对象"11"
    s3.intern(); // 实际没干啥
    System.out.println(s3 == s4);
}

总结

  • JDK1.6 中, 将这个字符对象尝试放入串池
    • 如果串池有, 则并不会放入. 返回已有的串池中的对象的地址
    • 如果没有, 则会把此对象复制一份, 放入串池, 并返回串池中的对象地址
  • JDK1.7 起, 将这个字符串对象尝试放入串池
    • 如果串池中有, 则并不会放入. 返回已有的串池中的对象的地址
    • 如果没有, 则会把对象的引用地址复制一份, 放入串池, 并返回传池中的引用地址

补充练习

demo1

@Test
public void test5() {
    String s = new String("a") + new String("b");
    String s2 = s.intern();

    System.out.println(s2 == "ab");
    System.out.println(s == "ab");
}

土味解析(JDK8)

line3: s(new堆地址值,@h1, 注意!!此处不会在串池生成)
line4: s2(串池没有, 返回@st1)

demo2

@Test
public void test6() {
    String s1 = new String("ab");
    //        String s1 = new String("a") + new String("b");
    s1.intern();
    String s2 = "ab";
    System.out.println(s1 == s2);
}

土味解析(JDK8)

====情况1
line3: s1(串池生成@st1, 返回@h1)
line5: 无效
line6: s2(返回@st1)
line7: false

====情况2
line4: s1(返回@h1)
line5: 串池无,则生成 @st1 指向 @h, 即@h
line6: s2(返回@h)
line7: true

demo3(自测)

@Test
public void test5() {
    String x = "ab";
    String s = new String("a") + new String("b");
    String s2 = s.intern();

    System.out.println(s2 == "ab");
    System.out.println(s == "ab");
}

intern() 的效率测试(空间角度)

static final int MAX_COUNT = 1000 * 10000;
static final String[] arr = new String[MAX_COUNT];
@Test 
public void test8() {
    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])); // 写法1
//            arr[i] = new String(String.valueOf(data[i % data.length])).intern(); // 写法2
    }
    long end = System.currentTimeMillis();
    System.out.println("花费的时间为:" + (end - start));

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

写法1的内存情况

写法2的内存情况

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

第一种方式

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

在堆中确实创建了 1000W 个对象, 并且存放在数组中, 也不会被回收

第二种方式

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

返回给数组的是串池中的引用地址, 因此即使new的对象也可以即使被销毁

大的网站平台, 需要内存中存储大量的字符串. 比如社交网站, 很多人都存储: 北京市、海淀区等信息. 这时候如果字符串都调用 intern() 方法, 就会明显降低内存的大小

6-StringTable的垃圾回收

/**
 * String的垃圾回收:
 * -Xms15m -Xmx15m -XX:+PrintStringTableStatistics -XX:+PrintGCDetails
 */
public class StringGCTest {
    public static void main(String[] args) {
        for (int j = 0; j < 100000; j++) {
            String.valueOf(j).intern();
        }
    }
}

不断调整j大小, 观察GC情况

7-G1中的String去重操作

官方文档http://openjdk.java.net/jeps/192

  • 背景:对许多 Java 应用(有大的也有小的)做的测试得出以下结果:
    • 堆存活数据集合里面 String 对象占了25%
    • 堆存活数据集合里面重复的 String 对象有13.5%
    • String 对象的平均长度是45
  • 许多大规模的 Java 应用的瓶颈在于内存,测试表明,在这些类型的应用里面,Java 堆中存活的数据集合差不多25%是 String 对象。更进一步,这里面差不多一半 String 对象是重复的,重复的意思是说:str1.equals(str2)= true。堆上存在重复的 String 对象必然是一种内存的浪费。这个项目将在 G1 垃圾收集器中实现自动持续对重复的 String 对象进行去重,这样就能避免浪费内存。

实现

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

命令行选项

  • UseStringDeduplication(bool) :开启 String 去重,默认是不开启的,需要手动开启。
  • PrintStringDeduplicationStatistics(bool) :打印详细的去重统计信息
  • stringDeduplicationAgeThreshold(uintx) :达到这个年龄的 String 对象被认为是去重的候选对象

文章作者: 👑Dee👑
版权声明: 本博客所有文章除特別声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来源 👑Dee👑 !
  目录