一、intern与字符串比较
前置知识
字符串常量池
在Java语言中有8种基本数据类型和一种比较特殊的类型String。这些类型为了使它们在运行过程中速度更快、更节省内存,都提供了一种常量池的概念。
常量池就类似一个Java系统级别提供的缓存。8种基本数据类型的常量池都是系统协调的,String类型的常量池比较特殊,它的主要使用方法有两种。
• 直接使用双引号声明出来的String对象会直接存储在常量池中。
• 如果不是用双引号声明的String对象,可以使用String提供的intern()方法。
Java 6及以前,字符串常量池存放在永久代。Java 7及以后将字符串常量池的位置调整到Java堆内。
字符串常量池除了有专门的内存区域存储字符串外,还提供了一个StringTable,主要用于快速检索字符串常量池中是否包含某个字符串,它的结构类似于HashTable,每个元素都是key-value结构,StringTable保存了字符串常量池中的字符串的引用。
intern与字符串比较
注:1.为方便描述,下文中的“字符串常量池”简称“池”;2.对于语句“String s = new String("ab");”,虽然变量名s与new String("ab")对象在内存中的引用并非一回事,但为方便描述,下文中的类似描述“引用x”均为代指字符串对象在内存中的引用。
intern()返回字符串对象在池(StringTable)中的引用,在java文档中这样介绍这个方法:
返回字符串对象的规范表示形式。
最初为空的字符串池由类字符串私下维护。
当调用intern方法时,如果池中已经包含一个字符串,该字符串与equals(object)方法确定的string对象相等,则返回池中的字符串。否则,将此字符串对象添加到池中,并返回对此字符串对象的引用。
面试题中若出现的intern与字符串比较,仅知道intern()返回池中的引用是不够的,关键需要知道返回的引用指向哪里的字符串,与比较的引用是否为同一引用。
如:
String s = new String("ab"); s == s.intern();
对于这道题,大家都知道引用s指向的是堆中的字符串“ab”,而s.intern()返回的引用指向池的字符串“ab”,因此答案是false。
判断intern()返回的引用指向哪里需要准确理解intern在两种情形下的行为:一是池中包含字符串;另外一种就是不包含字符串。下面就以字符串“ab”为例,解释在池中有无该字符串的两种情况下的intern()的行为。
1.池中包含字符串“ab”
1.1.String s = "ab";
在上文的“前置知识”中说到,以双引号的字面量声明的字符串对象会直接存入池中。如下代码1,执行语句1时会将字符串“ab”存入池中,并在StringTable中创建一行记录,其中的引用指向字符串“ab”。执行语句2时,通过StringTable检索(通过hashcode)发现字符串“ab”已经在池中,返回StringTable中的引用即可。执行语句3时,同样的,引用池中已包含了字符串“ab”,且StringTable中已创建了该字符串的引用,因此s1.intern()返回这个引用即可。
代码1
@Test void t1(){ String s1 = "ab"; String s2 = "ab"; System.out.println(s1 == s2); // true System.out.println(s1 == s1.intern()); // true }
图示1
例外:如下面变量s4的赋值形式,字符串相加时若包含变量,JVM编译器在编译期间不会把相加后的结果字符串作为一个字符串常量存入常量池表,这样自然在类加载后的内存分配时不会将结果字符串分配到池中,而是在运行期间通过执行StringBuilder的append及toString方法创建一个新的结果字符串对象,并存入堆中。
@Test void t1_例外(){ String s1 = "ab"; String s2 = "a"; String s3 = "b"; String s4 = s2 + s3; // 字符串相加若包含变量,创建的字符串将处于堆中 System.out.println(s1 == s4); // false System.out.println(s1 == s4.intern()); // true }
1.2.String s = new String("ab");
以这种形式创建的字符串对象,需通过调用intern方法才能返回字符串在池中的引用。如下代码2,执行语句1时,将在堆中创建字符串对象“ab”,引用s1指向该字符串对象,且会在池中存入字符串“ab”。执行语句2时,将在堆中再次创建字符串对象“ab”,引用s2指向该字符串对象,由于池中已经存在字符串“ab”,所以这次不会在池中重复地存入该字符串了。执行语句3中的s1.intern()时,发现池中虽已包含字符串“ab”,但在StringTable中并未包含其记录,则在StringTable中新增一条记录,这条记录中包含池中字符串“ab”的引用,最后返回这个引用。
代码2
@Test void t2(){ String s1 = new String("ab"); String s2 = new String("ab"); System.out.println(s1 == s2); // false System.out.println(s1 == s1.intern()); // false }
图示2
小结:对于池中包含字符串“ab”而言,s1.intern()返回的引用总是指向池中的字符串。
2.池中不包含字符串“ab”
如果池中不包含字符串“ab”,则intern依据jdk版本会有不同的行为。如下代码3,对于语句1,应注意对于这种字符串对象相加的情况,与“代码2中的语句1”不同,JVM编译器在编译期不会将相加的结果放入常量池表,相应地在内存分配时自然也不会将结果放入池中,因此池中不包含字符串对象“ab”。字符串对象“a”、“b”、“ab”会被分配到堆中。执行语句2的s1.intern()时,在jdk6时,池在方法区的永久代中(虽然说彼时方法区在物理上仍属于堆,但从逻辑上的内存分配内容而言,俨然是2个概念),调用s1.intern时,会将字符串对象“ab”复制到池中,并在StringTable中新增一条字符串对象“ab”的记录。引用s1指向堆中的字符串对象“ab”,s1.intern()返回的引用指向池中的字符串对象“ab”,两者比较结果自是false;自jdk7开始,池“搬家”至堆中,调用s1.intern时,会在StringTable中新增一条字符串对象“ab”的记录,这条记录中的引用就是字符串对象“ab”在堆中的引用。所以s1.intern()与s1比较的结果为ture。
执行语句3时,在jdk6时,发现StringTable中已包含字符串对象“ab”的引用,直接返回该引用。因此s1和s2不等;在jdk7时,同样的发现StringTable中已包含字符串对象“ab”的引用,直接返回该引用,但是该引用就是堆中的字符串对象“ab”的引用。因此s1和s2相等。
对于语句4时有点特殊,因为java作为关键字在池创建后就被“优先安排”到池中,执行语句5中的s3.intern()时,会在StringTable中新增一条记录,该记录中的引用执行池中的字符串对象“java”。因为引用s3指向的字符串对象“ab”在堆中,s3.intern()返回的引用指向的字符串“ab”在池中,两者比较结果自是false。经笔者测试,不仅是“java”,还有更多的关键字也在这“优先安排”的名单中,它们包含但不限于:boolean,byte,char,default,double,float,int,long,null,short,true,void,false。
注意:代码3中的语句1替换成String s1 = new StringBuilder("a").append("b").toString();是一样的,实际上对于含有字符串变量(如代码“t1_例外”中的s4的赋值)或字符串对象(如代码3中的语句1)相加的情况,JVM在运行期间也正是利用StringBuilder的append及toString完成字符串相加并返回一个新的结果字符串,这个结果字符串存入堆中。
注:本文测试的jdk6的具体版本为:jdk1.6.0_45,jdk7的具体版本为:jdk1.7.0_79
代码3
@Test void t3(){ String s1 = new String("a") + new String("b"); System.out.println(s1.intern() == s1); // false(6) true(7) String s2 = "ab"; System.out.println(s1 == s2); // false(6) true(7) String s3 = new String("ja") + new String("va"); System.out.println(s3.intern() == s3); // false(6) false(7) }
图示3
小结:对于池中不含字符串“ab”而言,s1.intern()返回的引用在jdk6及以下版本指向池中的字符串,在jdk7及以上版本指向堆中的字符串。对于像“java”关键字这样的例外情况,其intern()返回的引用总是指向池中的字符串。
二、intern的应用
理解intern的目的不仅是应付面试题,更在于在实际开发过程中应用。先了解一下intern的优缺点。
1.intern的优缺点
优点
• 提升比较效率。
• 在创建大量的重复字符串的场景中,有效减少内存消耗。
缺点
• 执行intern本身需要一定的时间成本,降低代码效率。
先看下优点1。如下代码,通过debug可知:使用intern(语句2),在进行 equals 比较时,如果两个对象是同一个的话,在 “==” 比较时就能得出结果,所以可以提高 equals 比较的效率。
@Test void t_equals(){ String s1 = new String("ab"); String s2 = new String("ab"); System.out.println(s1.equals(s2)); // 语句1 System.out.println(s1.intern().equals(s2.intern())); // 语句2 intern好处之一:提高比较效率 }
执行语句1
执行语句2
下面再看下优点2及缺点。下面进行测试,此次测试用于检测intern能否有效节省内存及其执行效率。如下测试代码,在运行前设置JVM参数:-Xmx2g -Xms2g -Xmn1500M。(注意:因为此JVM参数设置的足够大,因此即使对于本测试代码创建了1千万个字符串对象(不使用intern的情况下),也不会发生OMM。经笔者测试,如把JVM参数设置为:-Xms128m -Xmx1024m -Xmn600m -XX:+HeapDumpOnOutOfMemoryError,因为新生代只有600m,则会发生OOM,期间因为垃圾回收会造成长时间的卡顿)
public class intern { // 字符串数组的⻓度 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); // 产⽣10个随机数,放⼊DB_DATA数组中保存 for (int i = 0; i < DB_DATA.length; i++) { DB_DATA[i] = random.nextInt(); } long t = System.currentTimeMillis(); // 存储1000*10000个字符串对象 for (int i = 0; i < MAX; i++) { arr[i] = new String(String.valueOf(DB_DATA[i % DB_DATA.length])); // 语句1 } System.out.println((System.currentTimeMillis() - t) + "ms"); System.gc(); // TimeUnit.SECONDS.sleep(30); } }
获取堆转储文件说明:此次测试用的是jmap命令获取堆转储文件,用mat软件进行分析。由于执行测试类速度很快,为了在测试类执行期间获取pid,所以在测试代码最后一句(System.gc();)后面再加一句TimeUnit.SECONDS.sleep(30);(注意:在测消耗时间时不要加这句,在获取堆转储文件时加上这句),以利用这个睡眠时间获取堆转储文件。以下是获取堆转储文件示例:
执行测试代码,然后立即在cmd上输入:jps -l,查看测试类pid为17096
然后立即在cmd上输入:jmap -dump:format=b,file=D:/Test/intern2/intern.bin 17096,即可创建堆转储文件(注意:这2个步骤需在测试类执行完毕前操作好)。最后用mat打开堆转储文件即可进行分析。
不使用intern测试:
mat分析结果:从结果上看,不使用intern共创建10001789个字符串对象,共占用约610m(640173576/1024/1024,注:640173576的单位为字节),消耗时间:655ms
使用intern测试:
先在语句1上增加intern方法,即:arr[i] = new String(String.valueOf(DB_DATA[i % DB_DATA.length])).intern();,然后进行测试。
mat分析结果:从结果上看,使用intern创建了1784个字符串对象,共占用约168kb(172864/1024),消耗时间:1518ms
测试结果:基于本测试案例,会创建大量的重复字符串,使用intern一方面大量节省了内存,另一方面却又降低了代码效率。
intern节省内存的原理:intern方法本身不能减少JVM去创建对象,而是通过消除引用,促进垃圾回收来实现内存优化的。如代码String s1 = new String("ab"); String s2 = new String("ab");与代码:String s1 = new String("ab").intern(); String s2 = new String("ab").intern();在创建对象数量上其实是一样的,都创建了2个对象,都占用了2块堆内存空间。但是,由于前者的引用s1、s2直接指向堆中的2个字符串对象,所以在这2个引用消失前,垃圾回收器是不会回收这2个对象的;而后者引用s1、s2指向的是池中的引用,所以堆中的2个对象是没有引用指向它们的,它们会很快被回收。
2.intern的应用
根据测试结果可知,当某一场景中创建了大量重复字符串,为了节省内存和避免OOM,就可以考虑使用intern进行优化。
对于与业务逻辑相关的字符串,可以很容易的通过mat分析工具,找到它们并查看它们是否占据了大量的内存。对于其他字符串,可以通过 “Merge shortest Path to GC Root” 选项来找到它们被存储的位置。
参考文章:《使用string的intern优缺点》、《深入理解Java虚拟机》第三版 2.4.3 方法区和运行时常量池溢出