本文转载自微信公众号《程序新视野》,作者为二哥。转载本文请联系程序新视界公众号。面试的时候经常被问到String的intern方法的调用和内存结构的变化。但是你真的在实际生产中使用过吗?你见过别人怎么用的吗?最近看了Nacos的源码,果然看到代码中用到了String类的intern方法。NamingUtils类中有这样一个方法:publicstaticStringgetGroupedName(finalStringserviceName,finalStringgroupName){//...省略参数校验部分finalStringresultGroupedName=groupName+Constants.SERVICE_INFO_SPLITER+serviceName;returnresultGroupedName.intern();}方法操作很简单,就是拼接一串GrouedName,但是为什么在最后调用了intern方法呢?让我们在本文中对其进行分析。intern方法的基本定义我们先来看String中intern方法的定义:publicnativeStringintern();发现是一个native方法,暂时无法进一步查看其具体实现。很多朋友到现在都刚尝过。其实我们也可以通过文档和一些工具来验证intern方法的作用和运行原理。intern方法有注释介绍其作用,大意是:当调用intern方法时,如果字符串常量池中不存在对应的字符串(equals方法比较),则将该字符串添加到常量池中;如果存在,直接返回对应的地址。我们都知道字符串常量池的作用类似于一个缓存,可以让程序运行的更快,节省内存。上面的代码之所以调用intern方法,肯定是为了这个目的。String与常量池内存结构要理解intern的作用,首先得了解String的内存结构。字符串的创建通常有两种形式,通过new关键字创建的形式和通过引号直接赋值的形式。这两种形式的字符串创建在内存分配上有所不同。直接使用双引号创建字符串时,会先去常量池中查找该字符串是否已经存在。如果不存在,则先在常量池中创建一个常量对象,然后返回引用地址;如果存在,则直接返回。JDK6及之前的内存结构:JDK7及之后的内存结构:PS:JDK8及之后的PermSpace改为元空间,就不画图展示了。使用new关键字创建字符串时,创建的对象分配在堆中,栈中的引用指向该对象。Stringstr2=newString("你好");双引号中的字面值有两种情况。当常量池中不存在字面值“hello”时,会在常量池中生成这样一个常量;如果存在,则将堆中指针的对象直接指向字面值。JDK6及之前的内存结构:JDK7及之后的内存结构:通常在面试题中,会要求通过new关键字创建一个String,在内存中创建几个对象,就是基于上面的原则。显然,如果常量池中已经存在“hello”,那么堆中只会创建一个对象。如果常量池中不存在,则需要将字符串对象存储到常量池中。所以答案可能是1,也可能是2。理解了这两个基本的内存逻辑和分布之后,基本的扩展情况(面试题)就可以回答了。例如:Stringstr1="hello";Stringstr2="hello";System.out.println(str1==str2);//true两个对象都直接存放在常量池中,所以引用地址相同。另一个例子:Strings1=newString("hello");Strings2="hello";Strings3=newString("hello");System.out.println(s1==s2);//falseSystem.out.println(s1.equals(s2));//trueSystem.out.println(s1==s3);//false第一次输出false因为s1指向堆中对象的地址,s2指向常量池的地址;第二个比较是常量池中存放的字符串,他们共用一个,所以为真;第三个s1和s3虽然在常量池中共享“hello”字面值,但在堆中有各自的对象,所以是false。字符串拼接字符串拼接分两种情况,先看直接加号拼接:Strings1="hello"+"word";Strings2="helloword";System.out,println(s1==s2);//Intrue的情况下,对于s1,Java编译器会进行编译时优化,编译器会拼接字符串,然后将它们作为“helloword”存储在常量池中。所以s1和s2都指向了常量池中的同一个地址。另一种情况是非纯字符串常量的拼接:Strings1=newString("he")+newString("llo");在这种情况下,Java编译器也会根据StringBuilder优化字符串拼接。基本流程是先创建一个StringBuilder,然后调用append方法进行拼接,最后调用toString方法生成字符串对象。最后,常量池中不存在toString方法生成的字符串“hello”。最终的内存结构为:开头提到的Nacos中的源码,拼接后调用intern方法的目的是将上述形式拼接的堆中的字符串存入常量池。然后直接访问常量池中的对象,提高性能。那么,当String类调用intern时会发生什么?让我们来看看。String的intern()方法String.intern()方法的作用前面已经讲过,我们来看看在不同的JDK版本中使用intern方法的不同效果。JDK1.6的实现在JDK1.6及之前的版本中,常量池在永久代中分配内存,永久代的内存与Java堆是物理隔离的。当执行intern方法时,如果字符串在常量池中不存在,虚拟机会复制常量池中的字符串并返回引用。如果字符串已经存在,则直接返回这个常量对象在这个常量池中的引用。所以需要谨慎使用intern方法,避免常量池中的字符串过多,导致性能下降,甚至PermGen内存溢出。Stringstr1=newString("abc");Stringstr1Pool=str1.intern();System.out.println(str1Pool==str1);以上代码,在JDK1.6中打印结果为false。我们先来看一下内存结构图:上面代码中,newString创建的时候,会在常量池和堆中创建两个对象,和前面分析的内存结果是一样的。当str1调用intern方法时,发现对应的对象已经存在于常量池中,则该方法返回该对象在常量池中的地址。此时str1指向对象在堆中的地址,str1Pool指向常量池中的地址,所以它们不相等。另一种情况是常量池中没有字符串常量:Stringstr1=newString("a")+newString("bc");Stringstr1Pool=str1.intern();System.out.println(str1Pool==str1);对应的内存结构图如下:上面代码中,字符串str1生成的对象不存在于常量池中,而是完全存在于堆中。当然,在创建对象时,字符串“a”和“bc”会被存储在常量池中。调用intern方法时,会检查常量池中是否有“abc”,发现没有,于是将“abc”复制到常量池中,intern返回的结果就是常量池的地址常量池。至此,很明显str1Pool和str1其中一个指向常量池,另一个指向堆地址,所以不相等。但在JDK1.7及更高版本中,情况发生了变化。JDK1.7JDK1.7实现后,intern方法仍然会先检查常量池中是否已经存在,如果存在则返回常量池中的引用,与之前没有区别。但是如果在常量池中找不到对应的字符串,则不会将该字符串复制到常量池中,而只会在常量池中生成对原字符串的引用。简单的说就是常量池的内容变了。当在常量池中找不到时,复制一份放在常量池中。1.7之后,将堆上的地址引用复制到常量池中,即常量池存放的是字符串在堆中的引用地址。1.7及以后,常量池已经从方法区移到了堆中。已有的场景我们就不演示了,和JDK1.6是一致的。我们来看看常量池中没有对应字符串的情况。Stringstr1=newString("a")+newString("bc");Stringstr1Pool=str1.intern();System.out.println(str1Pool==str1);对应的内存结构变化如下:一开始创建“abc”对象与JDK1.6同时在堆中创建一个对象,常量池中不存在“abc”。调用intern方法时,常量池不会复制“abc”的字面值进行存储,而是直接将“abc”在堆中的地址存储在常量池中,intern方法返回对象的地址在堆中。这时会发现str1和str1Pool中存放的引用地址都是“abc”在堆中的地址。因此,上述方法的执行结果为真。线程池的实现结构Java使用jni调用C++实现的StringTable的intern方法。StringTable的intern方法类似于Java中HashMap的实现,但是不能自动扩容。默认大小为1009,也就是说String的字符串常量池是一个固定大小的Hashtable。如果常量池中的String过多,会导致严重的Hash冲突,导致链表过长。直接后果是调用String.intern时性能会大幅下降。在JDK1.6中,StringTable的长度固定为1009。在JDK1.7中,StringTable的长度可以通过一个参数来指定:-XX:StringTableSize=99991因此,使用intern方法时需要谨慎。那么,哪些场景适合使用intern方法呢?即对应的字符串被大量复用时。比如我们一开始讲的Nacos代码中,服务的名称基本不会变,会重复使用,所以放在常量池中比较合适。同时我们要知道,intern方法虽然可以减少内存占用,但是会因为多了一个操作而增加了程序的耗时。但是与JVM的垃圾回收时间相比,增加的时间可以忽略不计。总结写这篇文章的想法纯粹来自阅读开源框架源码中的一行代码,但是如果你仔细想想为什么要这样使用,发现背后的原理也是很有趣的它和相关的知识点。
