JVM 02:StringTable

129 阅读5分钟

一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第8天,点击查看活动详情

StringTable 简介

  • 也叫做串池,它是一个不能扩容的哈希表,可以避免重复创建字符串对象。
  • JDK 1.6 的时候,StringTable 位于运行时常量池中。
  • JDK 1.8 的时候,StringTable 位于堆中。

StringTable 特性

  • 运行时常量池中的字符串仅是符号,第一次用到时才变为对象(延迟加载
  • 字符串变量拼接的原理是 StringBuilderJDK 1.8
  • 字符串常量拼接的原理是编译期优化
  • 可以使用 intern() 方法,尝试将字符串放入串池

代码示例

public class Demo {
    public static void main(String[] args) {
        String s1 = "a";
        String s2 = "b";
        String s3 = "ab";
        String s4 = s1 + s2;
		String s5 = "a" + "b";
    }
}

上述代码解释如下:

  • 常量池中的信息,都会被加载到运行时常量池中,加载后 abab 都是运行时常量池中的符号,还没有变为 Java 字符串对象。
  • 执行到引用 a 的这行 ldc #2 字节码的时候,它会将 a 符号变为 "a" 字符串对象。然后查看 StringTable 中寻找是否存在 "a" 这样的字符串,如果不存在就放进去。
  • String s2 = "b";String s3 = "ab"; 这两句代码同理。
  • 执行到 String s4 = s1 + s2; 的时候,实际上调用的是 new StringBuilder().append("a").append("b").toString()。查看 StringBuildertoString(),相当于 new String("ab"),它位于堆中,所以 s3 == s4 结果为 false

编译期优化

  • String s3 = "ab";String s5 = "a" + "b"; 的效果一致,都是直接查看 StringTable 中是否有 "ab",没有则存放进去。所以 s3 == s5 的结果为 true
  • String s5 = "a" + "b"; 其实是 javac 在编译期的优化,因为 "a""b" 都是常量,不会发生改变,所以在编译期间就可以确定它们的拼接结果,所以不需要使用 StringBuilder 的方式进行拼接。

字符串延迟加载

  • 运行时常量池中的字符串仅是符号,第一次用到时才变为对象。

字符串的 intern 方法

JDK 1.8intern() 方法

  • s.intern() 尝试将字符串对象 s 放入串池,如果已有则不放入,如果没有则放入,最终返回串池中的对象。

代码片段 1

// "a" 放入串池,堆中出现 new String("a")
// "b" 放入串池,堆中出现 new String("b")
// 拼接字符串变量,堆中出现 new String("ab")
String s = new String("a") + new String("b");
// 尝试将字符串对象 s 放入串池
// 如果有则放入,如果没有则不放,最终返回串池中的对象
// 所以串池中出现 "ab",并返回它
String s2 = s.intern();
System.out.println(s2 == "ab");  // true
// s 就是被放入串池的对象,所以为 true
System.out.println(s == "ab");  // true

代码片段 2

// 串池中出现 "ab"
String x = "ab";
// 串池中出现 "a",堆中出现 new String("a")
// 串池中出现 "b",堆中出现 new String("b")
// 堆中出现 new String("ab")
String s = new String("a") + new String("b");
// 尝试将字符串对象 s 放入串池
// 如果有则放入,如果没有则不放,最终返回串池中的对象
// 串池中已有 "ab",直接返回它
String s2 = s.intern();
// true:s2 和 x 都是串池中的,所以为 true
System.out.println(s2 == x);
// false:因为 x 是串池中的,s 是堆中的
System.out.println(s == x);

代码片段 3

// 串池中放入 "a",堆区出现 new String("a")
// 串池中放入 "b",堆区出现 new String("b")
// 堆区出现 new String("ab")
String s = new String("a") + new String("b");
// 堆区的 "ab" 放入串池,返回给 s2
String s2 = s.intern();
// 串池中的 "ab"
String x = "ab";
// true
System.out.println(s2 == x);
// true
System.out.println(s == x);

JDK 1.6intern() 方法

  • s.intern() 尝试将字符串对象 s 放入串池,如果已有则不放入,如果没有则将此对象复制一份,放入串池,最终返回串池中的对象。

团子注:关键区别就在于,1.8 不会发生复制的行为,而 1.6 串池中没有会复制一份。

代码片段 1

// 串池中出现 "ab"
String x = "ab";
// 串池中出现 "a",堆中出现 new String("a")
// 串池中出现 "b",堆中出现 new String("b")
// 堆中出现 new String("ab")
String s = new String("a") + new String("b");
// 尝试将 "ab" 放入串池,发现已有,返回串池的 "ab"
String s2 = s.intern();
// 都是串池的 "ab",所以 true
System.out.println(s2 == x);
// s 是堆的,x 是串池的,所以 false
System.out.println(s == x);

代码片段 2

// 串池中放入 "a",堆区出现 new String("a")
// 串池中放入 "b",堆区出现 new String("b")
// 堆区出现 new String("ab")
String s = new String("a") + new String("b");
// 复制 "ab",放入串池,返回给 s2
String s2 = s.intern();
// 串池中的 "ab"
String x = "ab";
// true
System.out.println(s2 == x);
// false
System.out.println(s == x);

面试题

String s1 = "a";
String s2 = "b";
String s3 = "a" + "b";
String s4 = s1 + s2;
String s5 = "ab";
String s6 = s4.intern();

// 问:以下代码执行结果
System.out.println(s3 == s4);
System.out.println(s3 == s5);
System.out.println(s3 == s6);

String x2 = new String("c") + new String("d");
String x1 = "cd";
x2.intern();

// 问:以下代码执行结果
System.out.println(x1 == x2);
// 如果调换最后两行代码的位置,结果如何
// 如果是 JDK 1.6,结果如何

  • falsetruetruefalse
  • 如果调换最后两行代码位置,最后一个结果为 true
  • 如果是 JDK 1.6,最后一个结果为 false

StringTable 位置

202204082110 JVM 01:基本概念与内存结构 01.png

202204082110 JVM 01:基本概念与内存结构 02.png

  • JDK 1.6 时,StringTable 位于永久代
  • JDK 1.7JDK 1.8StringTable 位于堆中
  • 这样的改动提高了垃圾回收效率,位于永久代的对象在 Full GC 时进行垃圾回收,触发时机比较晚,间接导致 StringTable 回收效率不高。而在堆中的 StringTable 会在 Minor GC 时进行垃圾回收。

代码验证:

import java.util.ArrayList;
import java.util.List;

/**
 * 演示 StringTable 位置
 * 在jdk8下设置 -Xmx10m -XX:-UseGCOverheadLimit
 * 在jdk6下设置 -XX:MaxPermSize=10m
 */
public class StringTablePos {

    public static void main(String[] args) throws InterruptedException {
        List<String> list = new ArrayList<String>();
        int i = 0;
        try {
            for (int j = 0; j < 260000; j++) {
                list.add(String.valueOf(j).intern());
                i++;
            }
        } catch (Throwable e) {
            e.printStackTrace();
        } finally {
            System.out.println(i);
        }
    }
}

结论:

  • 对于 JDK 1.6,会抛异常 java.lang.OutOfMemoryError: PermGen space,也就是永久代内存溢出。
  • 对于 JDK 1.8,设置了 -Xmx10m,表示堆最大内存为 10m,会报异常 java.lang.OutOfMemoryError: GC overhead limit exceeded。这是因为默认情况下会开启一个 JVM 选项 -XX:+UseGCOverheadLimit,也就是 GC 开销限制。它是一项策略,用于限制在抛出 OutOfMemoryError 异常前 JVMGC 上花费的时间。该策略默认启用,如果大于 98% 的总时间花在了 GC 上,而只有少于 2% 的堆内存被回收时,就会抛出 OutOfMemoryError。当堆内存较小时,此功能可以用来防止应用程序长时间运行,却没有任何进展。可以使用 -XX:-UseGCOverheadLimit 来禁用这一选项。
  • 对于 JDK 1.8,同时设置 -Xmx10m -XX:-UseGCOverheadLimit,会抛异常 java.lang.OutOfMemoryError: Java heap space,也就是堆空间内存溢出。

垃圾回收

StringTable 中的字符串,也是会被垃圾回收的。下面的代码通过设置不同的 j,发现 j < 10000 时的 Number of literals 数目并不符合,说明发生了垃圾回收。

import java.util.ArrayList;
import java.util.List;

/**
 * 演示 StringTable 垃圾回收
 * -Xmx10m -XX:+PrintStringTableStatistics -XX:+PrintGCDetails -verbose:gc
 */
public class StringTableGC {
    public static void main(String[] args) throws InterruptedException {
        int i = 0;
        try {
            for (int j = 0; j < 100000; j++) { // j=100, j=10000
                String.valueOf(j).intern();
                i++;
            }
        } catch (Throwable e) {
            e.printStackTrace();
        } finally {
            System.out.println(i);
        }

    }
}

虚拟机参数:

  • -Xmx10m 用于设置堆内存为 10mb
  • -XX:+PrintStringTableStatistics 用于打印 StringTable 的统计信息
  • -XX:+PrintGCDetails -verbose:gc 用于打印垃圾回收的详细信息

性能调优

调整 -XX:StringTableSize=桶个数

  • 如果系统中字符串常量比较多,可以适当增加 StringTable 的桶个数,避免哈希冲突。
  • -XX:StringTableSize=200000,用于设置 StringTable 的桶个数为 200000,默认大小为 60013
  • StringTable 的大小应该在 10092305843009213693951
import java.io.BufferedReader;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStreamReader;

/**
 * 演示串池大小对性能的影响
 * -Xms500m -Xmx500m -XX:+PrintStringTableStatistics -XX:StringTableSize=200000
 */
public class StringTableSize {

    public static void main(String[] args) throws IOException {
        try (BufferedReader reader = new BufferedReader(new InputStreamReader(new FileInputStream("linux.words"), "utf-8"))) {
            String line = null;
            long start = System.nanoTime();
            while (true) {
                line = reader.readLine();
                if (line == null) {
                    break;
                }
                line.intern();
            }
            System.out.println("cost:" + (System.nanoTime() - start) / 1000000);
        }
    }
}

考虑是否将字符串对象入池

网上流传的段子:Twitter 为了存储用户地址,需要占用 30G 的内存空间,但是如果将这些字符串调用 intern 入池后,内存占用下降到数百兆。

import java.io.BufferedReader;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.util.ArrayList;
import java.util.List;

/**
 * 演示 intern 减少内存占用
 * -XX:StringTableSize=200000 -XX:+PrintStringTableStatistics
 * -Xsx500m -Xmx500m -XX:+PrintStringTableStatistics -XX:StringTableSize=200000
 */
public class InternDemo {

    public static void main(String[] args) throws IOException {

        List<String> address = new ArrayList<>();
        System.in.read();
        for (int i = 0; i < 10; i++) {
            try (BufferedReader reader = new BufferedReader(new InputStreamReader(new FileInputStream("linux.words"), "utf-8"))) {
                String line = null;
                long start = System.nanoTime();
                while (true) {
                    line = reader.readLine();
                    if(line == null) {
                        break;
                    }
                    address.add(line.intern());
                }
                System.out.println("cost:" +(System.nanoTime()-start)/1000000);
            }
        }
        System.in.read();
    }
}

参考资料