JVM之StringTable

1. String的基本特性

  • 使用一对""引起来表示。

  • 声明为final的,不可被继承。也不可变,对String重新赋值,会重新分配内存,重新指向。原本的value不变。

  • 实现了Serializable接口,表示字符串是支持序列化的。实现了 Comparable接口,表示String可以比较大小。

  • 在jdk8及以前内部定义了final char[] value用于存储字符串数据。jdk9时改为final byte[] value

    节省空间。String类的当前实现在一个char数组中存储字符,每个字符使用两个字节(16位)。从许多不同应用程序收集的数据表明,字符串占的内存是堆的“大头”,而且大多数String对象只包含Latin-1字符。这种字符只需要一个字节的存储空间,因此这些String对象内部的char数组中的一半空间未被使用。来源:https://openjdk.org/jeps/254

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

    • 常量池是一个固定大小的Hashtable。jdk6中表长是固定为1009,元素个数没有限制。jdk7中默认长度是60013,可以任意调整。jdk8中1009是可设置的最小值。可以通过-XX:StringTableSize设置长度。

      测试了一下,如果小于1009会出现,下面的错误提示:
      Error: Could not create the Java Virtual Machine.
      Error: A fatal exception has occurred. Program will exit.
      StringTable size of 1008 is invalid; must be between 1009 and 2305843009213693951

2. String的内存分配

常量池

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

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

  • 直接使用双引号声明出来的 string对象会直接存储在常量池中。比如:String info="hello, my friend;"
  • 如果不是用双引号声明的String对象,可以使用String提供的intern()方法。

在JVM中的位置

  • Java 6及以前,字符串常量池存放在永久代。
  • Java7中Oracle的工程师对字符串池的逻辑做了很大的改变,即将字符串常量池的位置调整到Java堆内。
    • 所有的字符串都保存在堆(Heap)中,和其他普通对象一样,这样可以让你在进行调优应用时仅需要调整堆大小就可以了。
    • 字符串常量池概念原本使用得比较多,但是这个改动使得我们有足够的理由让我们重新考虑在Java 7中使用String.intern()。
  • Java8元空间,字符串常量也在堆中。

StringTable为什么要调整?官方:https://www.oracle.com/java/technologies/javase/jdk7-relnotes.html

在JDK 7中,经过intern()方法处理的字符串对象不再分配在Java堆的永久代(permanent generation),而是分配在Java堆的主要部分(年轻代和老年代),与应用程序创建的其他对象一起。这个变化将导致主要Java堆中存储更多的数据,而永久代中的数据减少,因此可能需要调整堆的大小。大多数应用程序的堆内存不会因为这个变化而产生太大的差异,只有加载许多类或者大量使用String.intern()方法才会看到明显的差异。

通过一段代码来证明一下 Java 8中,字符串常量在堆中。

/**
 *  jdk6中:
 *  -XX:PermSize=6m -XX:MaxPermSize=6m -Xms6m -Xmx6m
 *  jdk8中
 *  -XX:MetaspaceSize=20m -XX:MaxMetaspaceSize=20m -Xms20m -Xmx20m
 **/
public class Main {
    public static void main(String[] args) {
        Set<String> set=new HashSet<>();
        int i=0;
        while (true){
            set.add(String.valueOf(i++).intern());
        }
    }
}
//输出
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
	at java.util.HashMap.resize(HashMap.java:704)
	at java.util.HashMap.putVal(HashMap.java:663)
	at java.util.HashMap.put(HashMap.java:612)
	at java.util.HashSet.add(HashSet.java:220)
	at cn.lucas.test.day28.Main.main(Main.java:17)

3. String的基本操作

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

public class Main2 {
    public static void main(String[] args) {
        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");
    }
}

在IDEA中执行上面这段代码,用debug方式查看Memeory可以看到:从最刚开始的110java.lang.String的count(数量)在不断增加,也即会在字符串常量池中创建字符串,但是下一次到1以后,由于常量池中已经有了这些字符串,因此count不再增加。

4. 字符串拼接操作

结论先行:

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

首先我们来看第一个示例:

public class Main4 {
    public static void main(String[] args) {
        String s1 = "a" + "b" + "c";
        String s2 = "abc";
        System.out.println(s1 == s2);//true
        System.out.println(s1.equals(s2));//true
    }
}

反编译后字节码文件:

public static void main(String[] args) {
        String s1 = "abc";
        String s2 = "abc";
        System.out.println(s1 == s2);
        System.out.println(s1.equals(s2));
}

因此可以,在编译阶段"a" + "b" + "c"直接变成了"abc"。因此我们得到结论1:常量与常量的拼接结果在常量池中,原理是编译器优化。

我们再看第二个示例:

public class Main5 {
    public static void main(String[] args) {
        String s1 = "javaEE";
        String s2 = "hadoop";

        String s3 = "javaEEhadoop";
        String s4 = "javaEE" + "hadoop";
        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

        String s8 = s6.intern();

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

原因如下:

在Java中,使用"=="运算符比较字符串时,它比较的是字符串对象的引用地址,即比较它们是否指向同一个对象。

在代码中,对于s3、s4、s5、s6、s7这些字符串变量的比较:

  • s3和s4都是通过字符串字面值直接赋值的,因此它们指向的是字符串池中的同一个字符串对象,所以s3 == s4为true。
  • s3与s5、s3与s6、s3与s7不是直接使用字符串字面值赋值的,它们是通过字符串拼接操作生成的新字符串对象,所以它们指向的是不同的对象,所以s3 == s5、s3 == s6、s3 == s7都为false。

另外,s5、s6、s7之间的比较:

  • s5是将s1与"hadoop"拼接而成的,s6是将"javaEE"与s2拼接而成的,s7是将s1与s2拼接而成的。
  • 因为字符串拼接操作会生成新的字符串对象,所以s5、s6、s7都指向不同的对象,它们之间的比较都为false。

最后,s8是通过调用s6.intern()方法得到的字符串对象,它会在字符串池中查找是否存在相同值的字符串对象,如果存在则返回该对象的引用,如果不存在则将当前字符串对象添加到字符串池中并返回该引用。

  • 在代码中,s8和s3的值相同,并且s3已经存在于字符串池中,因此s8会直接返回s3的引用,所以s3 == s8为true。

只要拼接过程中出现了变量,那么对象就会在堆中生成,因此通过“==”比较得到的答案就是false(比较地址,一个在堆中,一个在常量池中,内存地址不同)。

通过jclasslib(IDEA插件),可以从字节码文件中看到变量拼接的原理是StringBuilder。

//...  
 17 invokespecial #6 <java/lang/StringBuilder.<init>>
 20 aload_1
 21 invokevirtual #7 <java/lang/StringBuilder.append>
//..

因此我们得出了结论3:只要其中有一个是变量,结果就在堆中。变量拼接的原理是StringBuilder。

我们看第三个示例

public class Main6 {
    public static void main(String[] args) {
        final String s1 = "a";
        final String s2 = "b";
        String s3 = "ab";
        String s4 = s1 + s2;
        System.out.println(s3 == s4);//true
    }
}

被final修饰的字符串变量,会被编译器优化,直接变为字面量。下面为被反编译后的字节码文件。

public class Main6 {
    public Main6() {
    }

    public static void main(String[] args) {
        String s1 = "a";
        String s2 = "b";
        String s3 = "ab";
        String s4 = "ab";
        System.out.println(s3 == s4);
    }
}

我们来看第四个示例:字符串拼接的原理就是利用StringBuilder的append()方法,但每一次都会创建一个StringBuilder对象,再通过toString()方法产生一个String对象,这会产生极大的资源浪费。我们做个对比:

public class Main7 {
    public static void main(String[] args) {
        Main7 main7 = new Main7();
        long start = System.currentTimeMillis();
        //main7.method1(100000); //5466
        main7.method2(100000); //16
        long end = System.currentTimeMillis();

        System.out.println(end - start);
    }

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

    void method2(int highLevel) {
        StringBuilder src = new StringBuilder();
        for (int i = 0; i < highLevel; i++) {
            src.append("a");
        }
    }
}

可以看到效率的差距还是非常大的。因此StringBuilder进行字符串拼接操作的效率更高。

5. intern()的使用

intern()方法会在字符串常量池中与这个字符串对象字面量相同的字符串,如果有就返回这个字符串的地址,如果没有就加入字符串常量池,同样返回地址。因此只要是字面量相同的字符串对象,调用intern()方法进行比较都必定为true。

一个例子:

public class StringIntern1 {
    public static void main(String[] args) {
        String s = new String("1");
        s.intern();
        String s2 = "1";
        System.out.println(s == s2);

        String s3 = new String("1") + new String("1");
        s3.intern();
        String s4 = "11";
        System.out.println(s3 == s4);
    }
}

在jdk6中,执行结果为false false,在jdk7/8中,执行结果为false true。原因在于6中的常量池在永久代。而7以后,常量池就放到了堆中,为了减少内存的占用,常量池中会存放对象的引用,因此执行String s4="11"时,返回的其实是s3的引用,因此结果相等。

故总结一下:

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

6. intern()空间效率测试

public class StringIntern2 {
    static final int MAX = 1000 * 10000;
    static final String[] arr = new String[MAX];

    public static void main(String[] args) throws Exception {
        Integer[] DB_DATA = new Integer[10];
        Random random = new Random(10 * 10000);
        for (int i = 0; i < DB_DATA.length; i++) {
            DB_DATA[i] = random.nextInt();
        }
        long t = System.currentTimeMillis();
        for (int i = 0; i < MAX; i++) {
            //arr[i] = new String(String.valueOf(DB_DATA[i % DB_DATA.length]));
            arr[i] = new String(String.valueOf(DB_DATA[i % DB_DATA.length])).intern();
        }
        Thread.sleep(1000000);
        System.out.println((System.currentTimeMillis() - t) + "ms");
        System.gc();
    }
}

执行上面这段代码,并通过jvisualvm进行观察,可以得出,第一种会创建出一千万个String对象,然后将引用赋值给数组,虽然第二种方式也会同样多的对象,但通过intern方法,返回的是字符串1到10的引用,即数组只引用了10个String对象,剩下没有引用的会被垃圾回收掉。因此,第二种方法占用的空间更少。

参考资料

  1. Java基础常见面试题总结(中)之String
  2. 尚硅谷宋红康JVM全套教程(详解java虚拟机)
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值