深入理解JVM—字符串常量池StringTable

       首先我们来看一道关于字符串的面试题,请大家先不要直接上机运行,自己先在脑子里运行一下这段程序,如果你可以很清晰的得到所有输出,那么恭喜你!这篇文章你就不要在浪费时间再看了;如果你在某一步还有不清楚的,那么这篇文章将会一网打尽所有你对String常量池的疑虑。

package top.easyblog;

/**
 * @author :huangxin
 * @modified By:
 * @Description: 字符串常量池测试
 * @since :2020/02/16 15:57
 */
public class StringTableTest {

    public static void main(String[] args) {
        String s1="a";
        String s2="b";
        String s3="a"+"b";
        String s4=s1+s2;
        String s5=new String("ab");
        String s6="ab";
        String s7=s4.intern();

        //问:
        System.out.println(s3==s4);
        System.out.println(s3==s5);
        System.out.println(s3==s6);
        System.out.println(s3==s7);

        String str1=new String("c")+new String("d");
        String str2="cd";
        str1.intern();
        String str3=str1.intern();

        //问:
        System.out.println(str2==str1);
        System.out.println(str2==str3);
    }
}

运行结果:

1、StringTable概述

       StringTable又可以称为StringPool,字符串常量池,在JDK1.7以前字符串常量池是方法区中的运行时常量池的一部分,JDK1.7及以后JVM为了提高性能和减少对方法区内存的开销把字符串常量池被移到了内存中。字符串常量池的作用大致是:每当我们创建字符串的时候,JVM首先会检查字符串常量池,如果该字符串已经存在于字符串常量池中,那么就直接返回它在常量池中的直接引用;否则,就会实例化该字符串并且将其放到常量池中,由于String字符串的不可变性我们可以十分肯定常量池中一定不存在两个相同的字符串。

2、StringTable的特性概述

  1. 字面量字符串会在编译阶段直接添加到常量池中
  2. 常量池中的字符创建仅仅是符号,只有在第一次用到的时候才会创建对象(字符串的延迟加载性)
  3. 利用串池的机制,可以避免字符串的重复创建
  4. 字符串变量的拼接原理是通过StringBuilder实现的(JDK1.8)
  5. 字符串常量的拼接原理是编译阶段的优化
  6. 可以使用String类的intern()方法,主动将串池中还没有的字符串对象放入串池
  • JDK1.7及以后会将这个字符串对象尝试放入串池,如果串池中有这个字符串则不会放入,如果没有就放入,最后会返回串池中对象的直接引用。
  • JDK1.7以前会将这个字符串对象尝试放入串池,如果串池中有这个字符串则不会放入,如果没有则把此对象复制一份再放入串池,最后会返回串池中对象的直接引用

3、String在JVM中的解析(JDK1.7+)

3.1 字符串的两种创建方式

在Java中我们创建字符串的方式一般有两种形式:直接字面量new String()创建对象,例如:

String str1="abc";      //字面量创建
String str2=new String("abc");     //new 对象形式创建

这两行代码在内存解析的过程如下图:

       从图中可以看出,str1使用字面量创建字符串,在编译期的时候就对常量池进行判断是否存在该字符串,如果存在则不创建直接返回对象的引用;如果不存在,则先在常量池中创建该字符串实例再返回实例的引用给str1。

       再来看看str2,str2使用关键词new创建字符串,JVM会首先检查字符串常量池,如果该字符串已经存在常量池中,那么就不再在字符串常量池创建该字符串对象,而直接堆中复制该对象的副本,然后将堆中对象的地址赋值给引用str2;如果字符串不存在常量池中,就会实例化该字符串并且将其放到常量池中,然后在堆中复制该对象的副本,然后将堆中对象的地址赋值给引用str2。

3.2 字符串拼接

       字符拼接也有两种形式:一种全是字面量(常量)拼接,这种拼接方式形成的字符串会在编译期就被优化直接形成目标字符串并存到字符串常量池中;另一种含有变量的字符串拼接,这种拼接方式的底层原理是利用StringBuilder拼接好目标字符串后在转换为新的字符串对象(并不会主动放进字符串常量池中)。

(1)常量拼接编译阶段就可以确定
例如:String str1="Hello,"+"java";

       前面说到过,String字符串的不可变性我们可以十分肯定常量池中一定不存在两个相同的字符串,根据这个特点,在编译阶段javac编译器就会把各个待拼接的常量直接组合为目标字符串并放入字符串常量池中,这一过程同样需要判断是否已经存在该字符串。

(2)变量拼接:编译阶段无法确定,只有在运行后才可以知道结果
例如:

String str1="hello,";
String str2="java";    //str1、str2对应的字符串在编译阶段就被放进了字符串常量池找中
String str3=str1+str2;   // 实质:new StringBuilder().append("hello,").append("java").toString();   ==> new String("hello,java");

上面三行代码在JVM中的解析过程如下图所示:

       当使用“+”连接字符串变量时在运行期才能确定的,连接的本质是通过StringBuilder拼接之后再toString()新建一个新的String对象存储在堆中。下面是这三行代码编译后使用javap工具反编译出来的字节码:

       字节码中0~5行的作用就是从常量池中加载字符串常量并将其引用存储在局部变量表中。第6行字节码创建了一个StringBuilder实例,第10行调用了StringBuilder调用了StringBuilder的无参构造器初始化Stringuilder实例,接着13~17行就是从局部变量表中取出刚才存储的两个字符串引用的值调并用StringBuider类的append()方法拼接字符串;最后拼接完成后调用了StringBuilder的toString()方法产生了一个新的String对象存储到堆中,之后在局部变量表中str3指向了堆中这个String对象的地址(24行的astore_3这条指令)。

(3)更一般的字符串拼接—常量和变量混合拼接
将(2)中的例子改为如下

String str1="hello,";
String str2="java";    
String str3="1"+"23"+str1+"4"+"5"+str2;

直接看编译后的字节码:

通过字节码和(2)的规律我们不难得到内存模型如下:

       其本质和(1)(2)还是一样的,只要是常量,在编译期间就会确定下来并被加入常量池中,而且这里还遵循贪心原则,就是编译器会尽可能多的把可以确定下来的常量构成一个新串加入到字符串常量池中,之后对于变量就只能在运行的时候处理了,拼接的时候还是需要借助StringBuilder的append()方法;并且最后依然只会返回在堆中字符串对象的地址。

3.3 String.intern()方法解析

intern()方法在JDK1.7以前和JDK1.7及以后的实现略微有所差别:
● JDK1.7以前会将这个字符串对象尝试放入串池,如果串池中有这个字符串则不会放入,如果没有则把此对象复制一份再放入串池,最后会返回串池中对象的直接引用。
● JDK1.7及以后会将这个字符串对象尝试放入串池,如果串池中有这个字符串则不会放入,如果没有就存储堆中这个字符串的引用,也就是字符串常量池中存储的是指向堆里的对象,最后还是会返回字符串常量池中该字符串的直接引用。

验证
下面我们看一个案例:

public class StringTest {
 
public static void main(String[] args) {
    String s3 = new String("ab") + new String("c");
    System.out.println(s3 == s3.intern());
}

JDK6的执行结果为:false
JDK7和JDK8的执行结果为:true

JDK6-的内存模型如下:

       我们都知道JDK6中的常量池是放在永久代(方法区)的,永久代和Java堆是两个完全分开的区域。当调用str1.intern()后,JVM首先会检查字符串常量池中是否存在该字符串,如果没有就把该字符串复制一份然后添加到字符串常量池中,最后返回指向该常量的引用。如果之后str2在调用intern()方法,那么就会直接返回常量池中“abc”的引用。因此上面代码s3和s3.intern不相等的原因就是因为它们两个任然不是同一个对象。

JDK7/8+的内存模型如下:

       JDK7/8+中,字符串常量池已经被转移至Java堆中,开发人员也对intern 方法做了一些修改。因为字符串常量池和new的对象都存于Java堆中,为了优化性能和减少内存开销,当调用 intern 方法时,如果常量池中已经存在该字符串,则返回池中字符串;否则直接存储堆中的引用,也就是字符串常量池中存储的是指向堆里的对象。所以结果为true。

4、StringTable 垃圾回收

       字符串常量池是有垃圾回收的,无论是在JDK1.6以前还是在JDK1.7以后对于字符串常量池都是由垃圾回收机制的。接下来我们就在JDK1.8环境下通过一段代码验证一下StringTable的垃圾回收。

package top.easyblog;

/**
 * StringTable GC测试
 * VM Options:-Xmx10m -XX:+PrintStringTableStatistics -XX:+PrintGCDetails -verbose:gc(打印垃圾回收详细信息)
 *
 * @author :huangxin
 * @modified :
 * @since :2020/02/17 15:05
 */
public class StringTableGCTest {


    public static void main(String[] args) {
        int i=0;
        try{

        }catch (Exception e){
            e.printStackTrace();
        }finally {
            System.out.println(i);
        }

    }
}

运行后我们来看看控制台打印信息

       我们看到没有发生GC,并且当前键值的数量是1773个。之后我们往常量池中不断添加字符串,主要就观察有没有发生GC以及StringTable statistics就好了

package top.easyblog;

/**
 * StringTable GC测试
 * VM Options:-Xmx10m -XX:+PrintStringTableStatistics -XX:+PrintGCDetails -verbose:gc(打印垃圾回收详细信息)
 *
 * @author :huangxin
 * @modified :
 * @since :2020/02/17 15:05
 */
public class StringTableGCTest {


    public static void main(String[] args) {
        int i=0;
        try{
           for(int j=0;j<100000;j++){
               String.valueOf(j).intern();
               i++;
           }
        }catch (Exception e){
            e.printStackTrace();
        }finally {
            System.out.println(i);
        }
    }
}

运行后我们再来看看控制台打印信息

       这一次我们看到总共进行了3次GC,并且打印的当前字符串常量池的键值对数量远远小于添加的字符串数量10,0000个,说明对字符串常量池进行了垃圾回收。

5、StringTable性能调优

5.1 调节-XX:StringTableSize=size

       StringTable的底层数据结构是hash表,hash表的基本组成就是数组和链表,hash表中的每个数组单元我们叫做桶(bucket),桶的数量越多,hash冲突的几率就越低,但同时耗费的空间就越多。因此对于StringTable的性能调优额基本原理就是在减少hash碰撞(调整StringTable桶的个数)以及和内存消耗之间找到系统运行的平衡点。下面以一个例子来说明:

package top.easyblog;

import java.io.*;

/**
 * StringTable调优
 * VM Options:-XX:StringTableSize=size(设置StringTable桶的个数) -XX:+PrintStringTableStatistics
 * @author :huangxin
 * @modified :
 * @since :2020/02/17 16:33
 */
public class StringTableOptimize {

    public static void main(String[] args) {
       try(BufferedReader reader=new BufferedReader(new InputStreamReader(new FileInputStream(new File("d://dictionary.txt")))) ) {
           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+"ms");
       } catch (FileNotFoundException e) {
           e.printStackTrace();
       } catch (IOException e) {
           e.printStackTrace();
       }
    }
}

在使用默认值的情况下上面的代码的运行结果如下:

       通过上图可以看到使用JVM默认对StringTable设置的桶的个数(60013)时,执行向字符串常量池中添加40万条字符串花费了442ms,接下来我尝试把StringTable的桶的个数调大了些(200000),运行结果如下:

       运行结果和我的预期相符合,执行同样的代码花费的时间减少了约0.1s,并且多次运行的数值都是在这个值附近,说明增加StringTable桶的个数对那种系统中有大量字符串时的性能提高是有帮助的。为了进一步验证,我又把StringTable桶的个数调节的很小(2000),运行结果(如下图)再次验证了通过调节StringTable桶的个数是有助于当系统中有大量字符串时的性能提升的。

5.2 使用intern()方法

当系统中需要处理大量字符串并且这些字符串可能会存在重复的问题,那么这时可以使用intern()方法将这些字符串添加到常量池中,以减少对堆内存的消耗。

留言区

还能输入500个字符