一、intern()定义及使用

相信绝大多数的人不会去用String类的intern方法,打开String类的源码发现这是一个本地方法,定义如下: 


public native String intern();



Java代码命名空间注释 java命名空间的方式_Java代码命名空间注释

文档告诉我们该方法返回一个字符串对象的内部化引用。关于native方法详解见native关键字(本地方法)、 java调用so动态链接库

java.lang.String.intern():返回一个保留池字符串,就是一个在全局字符串池中有了一个入口。如果以前没有在全局字符串池中,那么它就会被添加到里面。

例如:

一个初始时为空的字符串池,它由类 String 私有的维护。 当调用 intern 方法时,如果池已经包含一个等于此 String 对象的字符串(该对象由 equals(Object) 方法确定),则返回池中的字符串。否则,将此 String 对象添加到池中,并且返回此 String 对象的引用。 它遵循对于任何两个字符串 s 和 t,当且仅当 s.equals(t) 为 true 时,s.intern() == t.intern() 才为 true。

总结出来其意思如下:如果:s.intern()方法的时候,会将共享池中的字符串与外部的字符串(s)进行比较,如果共享池中有与之相等的字符串,则不会将外部的字符串放到共享池中的,返回的只是共享池中的字符串,如果不同则将外部字符串放入共享池中,并返回其字符串的句柄(引用)-- 这样做的好处就是能够节约空间

众所周知:String类维护一个初始为空的字符串的对象池,当intern方法被调用时,如果对象池中已经包含这一个相等的字符串对象则返回对象池中的实例,否则添加字符串到对象池并返回该字符串的引用。 
   
   从程序的角度上怎么来看这个方法呢,我们假设有两个字符串s1,s2,当s1.equals(s2)时,s1.intern()==s2.intern(),也就是说这两个字符串在内存中使用的是同一个实例。 
   
   Java语言规范中定义了字符串文字以及更一般的常量表达式的值的字符串是被内部化的,以便它们共享同一个实例。我们试验一下下面代码:



String s1="你好,Java"; 
   String s2="你好,"+"Java"; 
   System.out.println(s1==s2); 
   System.out.println(s1.intern()==s2.intern());



   这段代码将打印两个true,也就是说字符串s1和s2是共享同一个实例。不过前提是尽管使用了表达式,但是表达式中必须都是常量。 
   
  了解这个处理机制也可以让我们在用到字符串常量的时候了解如何节省这些字符串所占用的内存。 

下面两个例子可以帮你:



public void inTest1() {

        System.out.println("begin inTest1()==========");
        String a = "b";
        String b = "b";

        System.out.println(a == b); //true

        String c = "d";
        String d = new String("d").intern();
        System.out.println(c == d); //true
        System.out.println("end inTest1()==========");
    }

    public void inTest2() {
        System.out.println("begin inTest2()==========");
        String a = new String("abc");
        String b = new String("ab");
        b = b + "c";
        System.out.println(a == b);//false
        System.out.println(a == b.intern());//false
        System.out.println(a.intern() == b);//false
        System.out.println(a.intern() == b.intern()); //true
        System.out.println("end inTest2()==========");
    }



 二、字符串常量池

2.1、 字面量和常量池初探

字符串对象内部是用字符数组存储的,那么看下面的例子:

String m = "hello,world";
    String n = "hello,world";
    String u = new String(m); String v = new String("hello,world");
  1. 会分配一个11长度的char数组,并在常量池分配一个由这个char数组组成的字符串,然后由m去引用这个字符串
  2. 用n去引用常量池里边的字符串,所以和m引用的是同一个对象
  3. 生成一个新的字符串,但内部的字符数组引用着m内部的字符数组
  4. 同样会生成一个新的字符串,但内部的字符数组引用常量池里边的字符串内部的字符数组,意思是和u是同样的字符数组

使用图来表示的话,情况就大概是这样的(使用虚线只是表示两者其实没什么特别的关系):

Java代码命名空间注释 java命名空间的方式_运维_02

测试demo:



String m = "hello,world";
            String n = "hello,world";
            String u = new String(m);
            String v = new String("hello,world");
            
            System.out.println(m == n); //true 
            System.out.println(m == u); //false
            System.out.println(m == v); //false
            System.out.println(u == v); //false



 

结论:

  • m和n是同一个对象
  • m,u,v都是不同的对象
  • m,u,v,n但都使用了同样的字符数组,并且用equal判断的话也会返回true
2.2、常量池实现及优化

保留字符串不能随意进行,但是如果有大量重复的字符串,占据了很大一部分堆,这时就很有效果了。保留字符串的表是保存在原生内存中的,它是一个大小固定的Hashtable。在java7u40之前的版本中,这个表默认有1009个桶,平均而言,在因为链接而出现冲突之前,预计可以保存500个字符串。在64位版本的java7u40及更新的版本中,默认大小为60013。

从java7开始,这个表(保留字符串表)大小可以在JVM启动时使用-XX:StringTableSize=N(默认1009或60013)。如果某个应用会保留大量字符串,就应该增加这个值。如果这个值是个素数,字符串保留表的效率最高。

intern()方法的性能是由表大小的调优程度所决定的。在《java性能权威指南》中有个示例:表7-3列出了在不同场景下创建和保留1千万个随机创建的字符串的总时间:

Java代码命名空间注释 java命名空间的方式_运维_03

注意,如果字符串保留表的大小设置不当,性能损失会相当严重。一旦根据预期数据设置了该表的大小,性能会极大改善。

某个应用中已经分配的保留字符串个数(及其总大小),可以使用如下的jmap命令获得(这也需要JDK 7u6或者更新版本):



[ciadmin@2-103test_app ~]$ jmap -heap 26964
Attaching to process ID 26964, please wait...
Debugger attached successfully.
Server compiler detected.
JVM version is 25.131-b11
...
Heap Usage:
PS Young Generation
Eden Space:
   capacity = 64487424 (61.5MB)
   used     = 44652520 (42.583961486816406MB)
   free     = 19834904 (18.916038513183594MB)
   69.24221379970147% used
From Space:
   capacity = 1572864 (1.5MB)
   used     = 1048928 (1.000335693359375MB)
   free     = 523936 (0.499664306640625MB)
   66.68904622395833% used
To Space:
   capacity = 1572864 (1.5MB)
   used     = 0 (0.0MB)
   free     = 1572864 (1.5MB)
   0.0% used
PS Old Generation
   capacity = 456130560 (435.0MB)
   used     = 74748280 (71.28551483154297MB)
   free     = 381382280 (363.71448516845703MB)
   16.387474673917925% used

29515 interned Strings occupying 3619152 bytes.
[ciadmin@2-103test_app ~]$



 

如果将字符串表设得特别大,其损失是非常小的:每个桶只需要4字节或8字节(取决于使用的是32位还是64位的JVM),所以比最优的情况多几千,只是一次性消耗一些原生内存(不是堆内存)。

另外,如果想看看字符串标的执行过程,可以使用-XX:+PrintStringTableStatistics参数,在JVM退出时,它会打印一个这样的列表:

Java代码命名空间注释 java命名空间的方式_java_04

 

这个例子有3035072个保留字符串(因为有1009个桶,每个桶平均有3008个字符串,1009*3008=3035072)。理想情况下,桶的平均大小应该是0或1.这个大小实际上不会为0,可能会小于0.5,但是因为技术时用的是整形运行,所以报告中会向下取整。(不理解)

2.3、常量池存放区域变化

总结:jdk1,6常量池放在方法区(也即是Perm空间),jdk1.7,jdk1.8常量池放在堆内存。所以导致string的intern方法因为以上变化在不同版本会有不同表现。

下面,我们通过测试程序来窥探字符串常量池在Java6,Java7两个不同版本底下的内存分配情况。



package com.dxz.metaspace;

public class StringPoolTest {
    public void testStringPoolWithLongString() {
        long i = 0;
        while (true) {
            String longString = "This is a very long string, very very long string to test the gc behavior of the string constant pool"
                    + i;
            longString.intern();
            i++;
        }
    }

    public static void main(String[] args) {
        StringPoolTest stringPoolTest = new StringPoolTest();
        stringPoolTest.testStringPoolWithLongString();
    }
}



测试程序很简单,一个死循环,循环里面通过递增变量i制造唯一的字符串,然后用main函数启动程序。

Java 6

我们使用版本Jdk1.6.0_29来跑该程序,打开Java VisualVM监控,可以看到,Perm区不断发生GC,由此的出结论,虽然字符串常量池放在Perm空间,但当Perm空间接近满的时候,JVM会将字符串常量池中的无用字符串回收掉。

Java代码命名空间注释 java命名空间的方式_java_05

Java 7

下面,我们切换到Jdk1.7.0_67重跑该程序,可以看到Perm区内存分配曲线很平滑,没有出现内存分配的现象。

Java代码命名空间注释 java命名空间的方式_java_06

但在Heap空间,新的对象不断产生,然后不断触发GC

Java代码命名空间注释 java命名空间的方式_java_07

示例2

  其实,移除永久代的工作从JDK1.7就开始了。JDK1.7中,存储在永久代的部分数据就已经转移到了Java Heap或者是 Native Heap。但永久代仍存在于JDK1.7中,并没完全移除,譬如符号引用(Symbols)转移到了native heap;字面量(interned strings)转移到了java heap;类的静态变量(class statics)转移到了java heap。我们可以通过一段程序来比较 JDK 1.6 与 JDK 1.7及 JDK 1.8 的区别,以字符串常量为例:



Java代码命名空间注释 java命名空间的方式_运维_08



package com.dxz.jvm; import java.util.ArrayList; import java.util.List; public class StringOomMock { static String base = "String"; public static void main(String[] args) { List<String> list = new ArrayList<String>(); for (int i=0;i< Integer.MAX_VALUE;i++){ String str = base + i; list.add(str.intern()); } } }



Java代码命名空间注释 java命名空间的方式_运维_08



这段程序以2的指数级不断的生成新的字符串,这样可以比较快速的消耗内存。我们通过 JDK 1.6、JDK 1.7 和 JDK 1.8 分别运行:

JDK 1.6 的运行结果:

JVM参数设置如下:

-XX:MaxPermSize=4M
-Xmx512M
-verbose -verbose:gc

Java代码命名空间注释 java命名空间的方式_Java代码命名空间注释_10

JDK 1.7的运行结果:

-XX:MaxPermSize=4M
-Xmx42M
-verbose -verbose:gc

Java代码命名空间注释 java命名空间的方式_java_11

JDK 1.8的运行结果:

-XX:MaxMetaspaceSize=8M
-XX:PermSize=8m
-XX:MaxPermSize=8m

Java代码命名空间注释 java命名空间的方式_System_12

  从上述结果可以看出,JDK 1.6下,会出现“PermGen Space”的内存溢出,而在 JDK 1.7和 JDK 1.8 中,会出现堆内存溢出,并且 JDK 1.8中 PermSize 和 MaxPermGen 已经无效。(上面日志中的最后两行)因此,可以大致验证 JDK 1.7 和 1.8 将字符串常量由永久代转移到堆中,并且 JDK 1.8 中已经不存在永久代的结论。现在我们看看元空间到底是一个什么东西?

  元空间的本质和永久代类似,都是对JVM规范中方法区的实现。不过元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。因此,默认情况下,元空间的大小仅受本地内存限制,但可以通过以下参数来指定元空间的大小:

  -XX:MetaspaceSize,初始空间大小,达到该值就会触发垃圾收集进行类型卸载,同时GC会对该值进行调整:如果释放了大量的空间,就适当降低该值;如果释放了很少的空间,那么在不超过MaxMetaspaceSize时,适当提高该值。
  -XX:MaxMetaspaceSize,最大空间,默认是没有限制的。

  除了上面两个指定大小的选项以外,还有两个与 GC 相关的属性:
  -XX:MinMetaspaceFreeRatio,在GC之后,最小的Metaspace剩余空间容量的百分比,减少为分配空间所导致的垃圾收集
  -XX:MaxMetaspaceFreeRatio,在GC之后,最大的Metaspace剩余空间容量的百分比,减少为释放空间所导致的垃圾收集

现在我们在 JDK 8下重新运行一下代码段 4,不过这次不再指定 PermSize 和 MaxPermSize。而是指定 MetaSpaceSize 和 MaxMetaSpaceSize的大小。输出结果如下:

Java代码命名空间注释 java命名空间的方式_字符串_13

从输出结果,我们可以看出,这次不再出现永久代溢出,而是出现了元空间的溢出。