JVM学习笔记之StringTable

目录

背景

String基本特性

不可变性

值传递

String的内存分配

String的基本操作

字符串拼接操作

intern()的使用

StringTable的垃圾回收

G1中的String去重操作

结语

背景

学了半天JVM,是时候复习一下String了

String基本特性

String是字符串final类,不可被继承;实现了Serializable接口和Comparable接口,表示可序列化和可比较大小

jdk8及以前内部定义了final char[]来存储字符串,jdk9改用final byte[];改变的原因是堆中的String对象主要是拉丁字符,这些使用1个字节就足够了,所以String内部改用byte[],同时加入了编码标记,以针对中文等复杂文字进行适配。同样的改变也在StringBuffer、StringBuilder等字符串类中发生

不可变性

String s1 = "szc";
String s2 = "szc";


System.out.println(s1 == s2); // true,
// 因为String内部覆写了compare()方法,实现了逐字符比较;而且s1和s2都指向的是同一个常量池对象


System.out.println(s1.hashCode()); // 114396
s1 += "is"; // 同样的还有重新赋值、replace()
System.out.println(s1.hashCode()); // 109937926

从拼接前后的s1哈希码不一样,可见对象不一样。

值传递

以下代码说明String传参是值传递

public class StringTest1 {
    private String str = "szc";
    private char[] ch = {'1', '2', '3'};

    public void change(String str, char[] ch) {
        str = "sss"; // str的哈希和this.str的哈希不一样
        ch[0] = 'a';
    }

    public static void main(String[] args) {
        StringTest1 test = new StringTest1();
        test.change(test.str, test.ch);

        System.out.println(test.str); // szc
        System.out.println(test.ch); // a23
    }
}

字符串字面量存储在常量池中,是不会存储相同的字符串的。

String Pool是一个固定大小的哈希表,即数组+链表,默认大小为60013(jdk7及以后),长度变大的原因是减少链表长度,提高效率,jdk8中可设置最小值为1009,可通过-XX:StringTableSize来设置。

String的内存分配

字符串常量池和字符串对象都在堆中

1)、直接用双引号声明出来的String对象会直接存在常量池中,比如String info = "szc";

2)、intern()方法

StringTable放在堆中的原因:

1)、jdk6中字符串常量池是在永久代里,这块区域比较小

2)、永久代回收频率很低,不利于释放内存

String的基本操作

用下面的例子证明字符串常量池里不会添加重复的字符串

public class StringTest2 {
    public static void main(String[] args) {
        System.out.println("1");
        System.out.println("2");
        System.out.println("3");


        System.out.println("1");
        System.out.println("2");
        System.out.println("3");
    }
}

在执行第一行System.out.println("1");前,常量池里总共有2513个字面量

执行完第一行System.out.println("1");后,最增加两个:换行和"1"

执行完第一个System.out.println("2");后,最增加一个:"2"

执行完第一个System.out.println("3");后,最增加一个:"3"

再执行下面的分别输出123,就不会有新的字符串存储了

以下例子说明栈对象、堆对象和常量池对象之间的关系

public 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);
    }
}

结构图如下

可见toString()方法在字符串池中创建了个字符串对象,然后foo()方法中的str指向这个对象

字符串拼接操作

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

常量池中不会存在相同内容的常量

只要拼接时其中有一个是变量,结果就在堆中,其拼接原理是StringBuilder

如果拼接结果调用inter()方法,并且此字符串内容还不在常量池中,则主动将其放入池中,并返回此对象地址

案例1:

public class StringTest3 {
    public static void main(String[] args) {
        String s1 = "a" + "b" + "c"; // a + b + c在编译期就被优化为abc
        String s2 = "abc";


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

案例2:

public class StringTest3 {
    public static void main(String[] args) {
        String s2 = "abc";


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


        String s3 = "a";
        String s4 = "b";
        String s5 = "c";


        String s6 = s3 + "bc"; // 拼接有一个变量,结果就在堆中新建一个String对象,内容为拼接后的结果
        String s7 = "a" + s4 + "c";
        String s8 = "ab" + s5;


        System.out.println(s1 == s6); // false
        System.out.println(s1 == s7); // false
        System.out.println(s1 == s8); // false


        System.out.println(s6 == s8); // false


        String s9 = s8.intern(); // intern()方法在常量池创建新的字面量对象,或者复用已有的,然后返回对象地址
        System.out.println(s2 == s9); // 由于s8的字面量abc已经有了,所以返回的s9的地址就是s2的地址,故而输出为true
    }
}

案例3:

public static void f() {
    String s1 = "a";
    String s2 = "b";
    String s3 = "ab";
    String s4 = s1 + s2;
    System.out.println(s3 == s4);
}

对应字节码

0 ldc #5 <a>
2 astore_0
3 ldc #6 <b>
5 astore_1
6 ldc #13 <ab>
8 astore_2
9 new #8 <java/lang/StringBuilder>
12 dup
13 invokespecial #9 <java/lang/StringBuilder.<init>>
16 aload_0
17 invokevirtual #10 <java/lang/StringBuilder.append>
20 aload_1
21 invokevirtual #10 <java/lang/StringBuilder.append>
24 invokevirtual #12 <java/lang/StringBuilder.toString>
27 astore_3
28 getstatic #3 <java/lang/System.out>
31 aload_2
32 aload_3
33 if_acmpne 40 (+7)
36 iconst_1
37 goto 41 (+4)
40 iconst_0
41 invokevirtual #4 <java/io/PrintStream.println>
44 return

当字符串拼接里出现变量时,都会先创建一个StringBuilder(字节码第9行),然后拼接操作实际是调用StringBuilder的append()方法(字节码第17行、21行),这里是分别append了个a,append了个b,最后调用StringBuilder的toString(字节码第24行),≈ new String("ab"),所以最后的输出为false。最后jdk5之后用的是StringBuilder,jdk5及之前用的是StringBuffer

拼接操作的效率比append()方法要低很多,因为每拼接一次都要创建新的StringBuilder和新的String,而append()方法不会,可以通过在构造StringBuilder对象时传入字符串长度的上限值来进一步优化,以免对字符数组的多次扩容。

注意,这里的常量包括final对象

案例4:

public static void g() {
    final String s1 = "a";
    final String s2 = "b";
    String s3 = "ab";
    String s4 = s1 + s2;


    System.out.println(s3 == s4); // true
}

对应字节码

0 ldc #5 <a>
2 astore_0
3 ldc #6 <b>
5 astore_1
6 ldc #13 <ab>
8 astore_2
9 ldc #13 <ab>
11 astore_3
12 getstatic #3 <java/lang/System.out>
15 aload_2
16 aload_3
17 if_acmpne 24 (+7)
20 iconst_1
21 goto 25 (+4)
24 iconst_0
25 invokevirtual #4 <java/io/PrintStream.println>
28 return

从字节码第6、8、9行可见程序对String s4 = s1 + s2也使用了编译期优化

intern()的使用

此方法在jdk8中的核心描述如下所述

A pool of strings, initially empty, is maintained privately by the class String.

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.

It follows that for any two strings s and t, s.intern() == t.intern() is true if and only if s.equals(t) is true.

 大意就是此方法被调用时,如果常量池包含此字符串对象的字面值,就会把池中的字面值对象返回;如果不包含,就把新的字面值加入池中,返回新的字面量对象的引用。对于两个字符串s和t,当且仅当两者字面值相等,两者的intern()方法的返回值才会相等

保证变量s指向字符串常量池中数据的两种方法:字面量赋值、调用intern()

new String()到底创建了几个对象?2个,java代码如下

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

对应字节码

0 new #2 <java/lang/String>
3 dup
4 ldc #3 <szc>
6 invokespecial #4 <java/lang/String.<init>>
9 astore_1
10 return

根据第0行和第4行字节码,显然是新建了2个,一个是new的对象,一个szc

同理new String("a") + new String("b"),创建了6个,对应字节码

0 new #2 <java/lang/StringBuilder>
3 dup
4 invokespecial #3 <java/lang/StringBuilder.<init>>
7 new #4 <java/lang/String>
10 dup
11 ldc #5 <a>
13 invokespecial #6 <java/lang/String.<init>>
16 invokevirtual #7 <java/lang/StringBuilder.append>
19 new #4 <java/lang/String>
22 dup
23 ldc #8 <b>
25 invokespecial #6 <java/lang/String.<init>>
28 invokevirtual #7 <java/lang/StringBuilder.append>
31 invokevirtual #9 <java/lang/StringBuilder.toString>
34 astore_1
35 return

根据第0行、第7行、第11行、第19行、第23行可知,构造了两个new的String对象、a和b字面量对象、一个StringBuilder对象,然后第31行调用StringBuilder的toString()方法,可知新建了一个字符串对象

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

而new String(value, 0, count)对应的源码如下

public String(char value[], int offset, int count) {
    if (offset < 0) {
        throw new StringIndexOutOfBoundsException(offset);
    }
    if (count <= 0) {
        if (count < 0) {
            throw new StringIndexOutOfBoundsException(count);
        }
        if (offset <= value.length) {
            this.value = "".value;
            return;
        }
    }
    // Note: offset or count might be near -1>>>1.
    if (offset > value.length - count) {
        throw new StringIndexOutOfBoundsException(offset + count);
    }
    this.value = Arrays.copyOfRange(value, offset, offset+count);
}

由最后一行可知没有在常量池中新建ab对象(分析字节码指令也可以得到同样的结论,没有类似ldc <ab>的指令),所以总共有6个对象被新建。

分析以下代码执行结果(jdk7及以上)

public class StringNewTest {
    public static void main(String[] args) {
        String s = new String("a");
        s.intern();
        String s1 = "a";
        System.out.println(s == s1); // false

        String s2 = new String("a") + new String("b");
        s2.intern();
        String s3 = "ab";
        System.out.println(s2 == s3); // true
    }
}

执行s.intern()时,由于常量池中已经有a字面量(new String时创建),所以这一行代码其实这里没啥用。s1直接指向常量池,s指向的是堆空间中被创建的字符串对象,所以结果为false

执行s2.intern()时,由于创建s2时,字符串常量池里没有ab,所以执行intern()方法后,会在字符串常量池中创建ab。而jdk7及以后,如果intern()方法传入的字符串值没有在常量池中,那么JVM只会在堆中新建字符串对象,然后常量池中创建的是指向堆中新字符串对象地址的引用,而s3指向的是常量池中对象的地址,实际也是堆中字符串对象,所以s2 == s3为true

jdk6及以前,intern()新字符串值时,会实打实在永久代常量池和堆中新建两个对象,所以那时s2 == s3为false

对于以下代码

String s4 = "cd";
String s5 = new String("c") + new String("d");
s4.intern();
System.out.println(s4 == s5); // false

由于这里intern()的是s4,s4指向的"cd"已经在创建s4时在常量池中新建了,所以这个intern()没什么用。而s5指向的是堆中的String对象,因此s4不等于s5

对于以下代码

String s6 = new String("e") + new String("f");
String s7 = s6.intern();


System.out.println(s6 == "ef"); // true
System.out.println(s7 == "ef"); // true

s6在intern时,常量池里没有ef,所以常量池中保存的是堆中字符串对象ef的地址(自然也是s6的地址),返回给了s7。所以s6、"ef"、s7两两相等

对于以下代码

String s6 = new String("ef");
s6.intern();
String s7 = "ef";

System.out.println(s6 == s7); // false

由于创建s6时,ef是通过new String()出来的,所以常量池中保存的是字面量ef,而s6指向的是堆中的字符串对象,所以s6 == s7为false

当构造大量重复字符串对象时,intern()方法会大大提高时空效率

StringTable的垃圾回收

可以使用-XX:+PrintStringTableStatistics打印字符串常量池的统计信息

测试代码

public class StringGcTest {
    public static void main(String[] args) {
        for (int i = 0; i < 100000; i++) {
            String.valueOf(i).intern();
        }
    }
}

参数:-Xms10m -Xmx10m -XX:+PrintStringTableStatistics -XX:+PrintGCDetails。输出结果

[GC (Allocation Failure) [PSYoungGen: 2048K->504K(2560K)] 2048K->889K(9728K), 0.0797190 secs] [Times: user=0.00 sys=0.00, real=0.08 secs]
[GC (Allocation Failure) [PSYoungGen: 2552K->504K(2560K)] 2937K->1009K(9728K), 0.0012426 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[GC (Allocation Failure) [PSYoungGen: 2552K->488K(2560K)] 3057K->1057K(9728K), 0.0012180 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
Heap
PSYoungGen      total 2560K, used 2086K [0x00000000ffd00000, 0x0000000100000000, 0x0000000100000000)
  eden space 2048K, 78% used [0x00000000ffd00000,0x00000000ffe8f980,0x00000000fff00000)
  from space 512K, 95% used [0x00000000fff00000,0x00000000fff7a020,0x00000000fff80000)
  to   space 512K, 0% used [0x00000000fff80000,0x00000000fff80000,0x0000000100000000)
ParOldGen       total 7168K, used 569K [0x00000000ff600000, 0x00000000ffd00000, 0x00000000ffd00000)
  object space 7168K, 7% used [0x00000000ff600000,0x00000000ff68e4b8,0x00000000ffd00000)
Metaspace       used 3241K, capacity 4496K, committed 4864K, reserved 1056768K
  class space    used 350K, capacity 388K, committed 512K, reserved 1048576K
SymbolTable statistics:
Number of buckets       :     20011 =    160088 bytes, avg   8.000
Number of entries       :     13291 =    318984 bytes, avg  24.000
Number of literals      :     13291 =    568080 bytes, avg  42.742
Total footprint         :           =   1047152 bytes
Average bucket size     :     0.664
Variance of bucket size :     0.664
Std. dev. of bucket size:     0.815
Maximum bucket size     :         6
StringTable statistics:
Number of buckets       :     60013 =    480104 bytes, avg   8.000
Number of entries       :     30235 =    725640 bytes, avg  24.000
Number of literals      :     30235 =   1752696 bytes, avg  57.969
Total footprint         :           =   2958440 bytes
Average bucket size     :     0.504
Variance of bucket size :     0.464
Std. dev. of bucket size:     0.681
Maximum bucket size     :         4

由StringTable statistics信息中的Number of entries       :     30235和Number of literals      :     30235两个值可见,发生了常量池的GC

不启用时,把+改成-即可

G1中的String去重操作

对于每一个访问的对象都要检查是否是候选要去重的String对象,如果是,把这个对象的一个引用插入到队列中。

一个后台线程专门用来去重,对这个队列的处理意味着从队列中删除这个元素,再对此元素引用的对象进行去重 。

对String对象去重的方法是,使用一个哈希表来记录所有被字符串对象使用的且不重复的char数组,去重时,根据这个哈希表查看堆中是否存在一个一模一样的char数组。

如果存在,String对象会引用表中已经存在的数组,释放对堆中数组的引用,堆中数组从而被GC掉;如果不存在,char数组会把插入到哈希表中,以便后来者共用之。

结语

行文至此,结合JVM的String覆写就结束了,后面我会整理垃圾回收器的学习笔记,与君共享

猜你喜欢

转载自blog.csdn.net/qq_37475168/article/details/106599844
今日推荐