String的内存和intern()方法

Posted wangshen31

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了String的内存和intern()方法相关的知识,希望对你有一定的参考价值。

一、关于常量池

字符串在Java中用的非常得多,Jvm为了减少内存开销和提高性能,使用字符串常量池来进行优化。

在jdk1.7之前(不包括1.7),Java的常量池是在方法区的地方,方法区是一个运行时JVM管理的内存区域,是一个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态常量等。

运行时常量池是方法区的一部分。

来看一个图:

(图片来自https://www.cnblogs.com/ysocean/p/8571426.html

技术图片

关于其他的内存分布就不在这介绍了。

 

 

而在jdk1.7和它以后,方法区的常量池被移到了堆中,见图:(图片来自https://www.cnblogs.com/ysocean/p/8571426.html)

技术图片

 

 

 

 

 

二、new String("xxx")和 = "xxx"

在了解常量池后,我们再来看这两个创建String对象的方法。

 

先来看使用引号""创建字符串的方式

  • 单独(注意是单独)使用引号来创建字符串的方式,字符串都是常量,在编译期已经确定存储在常量池中了。
  • 用引号创建一个字符串的时候,首先会去常量池中寻找有没有相等的这个常量对象,没有的话就在常量池中创建这个常量对象;有的话就直接返回这个常量对象的引用。

所以看这个例子:

String str1 = "hello";
String str2 = "hello";
System.out.println(str1 == str2);//true

这个例子的结果是true,首先 String str1 = "hello",会先到常量池中检查是否有“hello”的存在,发现是没有的,于是在常量池中创建“hello”对象,并将常量池中的引用赋值给str1;第二个字面量 String str2 = "hello",在常量池中检测到该对象了,直接将引用赋值给str2。

 

然后是new的方式创建字符串

String a = new String("abc");

new这个关键字,毫无疑问会在堆中分配内存,创建一个String类的对象。因此,a这个在栈中的引用指向的是堆中的这个String对象的。

然后,因为"abc"是个常量,所以会去常量池中找,有没有这个常量存在,没的话分配一个空间,放这个"abc"常量,并将这个常量对象的空间地址给到堆中String对象里面;如果常量池中已经有了这个常量,就直接用那个常量池中的常量对象的引用呗,就只需要创建一个堆中的String对象。

看下这个图:(图片来自https://blog.csdn.net/MOU_IT/article/details/78312399)

技术图片

所以这个例子:

 public static void main(String[] args) {
        
    String s1 = new String("hello");
        
    String s2 = "hello";
        
    String s3 = new String("hello");

    System.out.println(s1 == s2);// false
        
    System.out.println(s1.equals(s2));// true
   
    System.out.println(s1 == s3);//false
}

第一个输出为false,因为==比较的是引用的地址,s2指的是常量池中常量对象的地址,而s1指的是堆中String对象的地址,肯定不同。

然后第二个为true,因为jdk重写了equals()方法,比较的是字符串的内容。

第三个输出为false,原因是每个String对象都是不同的,所以引用指向的堆地址肯定也不同,所以false。

 

 

 

三、关于“+”运算符

纯常量相加:

String s1 = "hello" + "word";
String s2 = "helloword";
System.out,println(s1 == s2);//true

这个的输出是true,意味着"helloword"和"hello" + "word"的地址是一样的。

但我们之前在《thinking in Java》中看到的是说JVM为了优化这个字符串相加的过程,在“+”这个操作符的重载中自动引入了StringBuilder类喔。

那s2显然应该是常量池中"helloword"这个常量对象的引用,那这个s1不应该是StringBuilder调用toString方法后产生的堆中的String对象的引用吗?

查了很多文章,这篇告诉了我们原因——https://www.cnblogs.com/vincentl/p/9600093.html

总结下就是:

  两个或者两个以上的字符串常量相加,在预编译的时候“+”会被优化,相当于把两个或者两个以上字符串常量自动合成一个字符串常量.
  字符串常量相加,不会用到StringBuilder对象,有一点要注意的是:字符串常量和字符串是不同的概念,字符串常量储存于方法区(总之就常量池),而字符串储存于堆(heap)。

 

 

而非纯常量的字符串相加的

 

像是字符串相加表达式中带变量的那种的话,就是JVM会自动创建一个StringBuilder然后再调用append()方法最后再调用toString()方法返回的方式了,所以在堆中会有个String对象,引用指向的是堆中的对象的地址。

所以相加出来的结果,是不会被加到常量池中的。

String s1 = new String("he")+new String("llo"); 
  1. 这个代码中,首先,new String("he"),先在常量池中看,发现没有这个"he"常量,于是建一个,然后再在堆中创建一个String的对象(但没引用,很快被gc的)。
  2. 加法,暗中new了StringBuilder,调用append方法。
  3. new String("llo")一样的道理,堆中一个String对象,常量池中"llo"常量对象。
  4. StringBuilder的append方法搞定后,调用toString()方法,具体是new一个String对象,也就是现在是一个堆中的String对象,内容是"hello",但注意这个hello没有在常量池中创建!!其实可以理解因为没有出现过一次"hello",拼接是通过StringBuilder的append方法完成的。

 

总之:对于所有包含new方式新建对象(包括null)和变量形式 的“+”连接表达式,它所产生的新对象都不会被加入字符串池中。

 

再看个例子:

 String s0 = "ab"; 
 final String s1 = "b"; 
 String s2 = "a" + s1;  
 System.out.println((s0 == s2)); //result = true

这个不是带变量的相加吗,不应该是返回一个堆上的引用吗?

这是因为final修饰的s1在编译期就可以识别,它在编译时被解析为常量值的一个本地拷贝存储到自己的常量池中或嵌入到它的字节码流中。所以此时的"a" + s1和"a" + "b"效果是一样的。故上面程序的结果为true。

 

 

 

四、String的intern()方法

看书时的疑惑

在读JVM的时候,在描述方法区和运行时常量池溢出的章节里面提到了String.intern()方法。

这是一个native的方法,书上是这样描述它的作用的:如果字符串常量池中已经包含一个等于此String对象的字符串,则返回代表池中这个字符串的String对象;否则,将此String对象包含的字符添加到常量池中,并返回此String对象的引用。

 

并提到,在JDK1.6及其之前的版本,由于常量池分配在永久代内,我们可以通过-XX:PermSize和-XX:MaxPermSize限制方法区的大小从而间接限制常量池的容量。

 

不仅如此,在intern方法返回的引用上,JDK1.6和JDK1.7也有个地方不一样,来看看书本上给的例子:

public static void main(String[] args) {
    String str1 = new StringBuilder("计算机").append("软件").toString();
    System.out.println(str1.intern() == str1);

    String str2 = new StringBuilder("ja").append("va").toString();
    System.out.println(str2.intern() == str2);
}

这段代码在JDK1.6中,会得到两个false,在JDK1.7中运行,会得到一个true和一个false。

 

书上说,产生差异的原因是:在JDK1.6中,intern()方法会把首次遇到的字符串实例复制到永久代中,返回的也是永久代中这个字符串实例的引用,而由StringBuilder创建的字符串实例在Java堆上,所以必然不是同一个引用,将返回false。

而JDK1.7的intern()不会再复制实例,只是在常量池中记录首次出现的实例的引用,因此intern()返回的引用和StringBuilder创建的那个字符串的实例是同一个。对str2比较返回false是因为"java"这个字符串在执行StringBuilder.toString()之前就已经出现过,字符串常量池中已经有它的引用了,不符合“首次出现”的原则,而“计算机软件”这个字符串则是首次出现的,因此返回true。

 

刚开始看这个我是一脸懵,查了很多资料还有看了很多关于String内存的知识我才搞懂这个。

 

 

stringTable的小说明

这里先再提一下字符串常量池,实际上,为了提高匹配速度,也就是为了更快地查找某个字符串是否在常量池中,Java在设计常量池的时候,还搞了张stringTable,这个有点像我们的hashTable,根据字符串的hashCode定位到对应的桶,然后遍历数组查找该字符串对应的引用。如果找得到字符串,则返回引用,找不到则会把字符串常量放到常量池中,并把引用保存到stringTable了里面。

在JDK7、8中,可以通过-XX:StringTableSize参数StringTable大小

 

 

jdk1.6及其之前的intern()方法

在JDK6中,常量池在永久代分配内存,永久代和Java堆的内存是物理隔离的,执行intern方法时,如果常量池不存在该字符串,虚拟机会在常量池中复制该字符串,并返回引用;如果已经存在该字符串了,则直接返回这个常量池中的这个常量对象的引用。所以需要谨慎使用intern方法,避免常量池中字符串过多,导致性能变慢,甚至发生PermGen内存溢出。

看一个图片来理解下:(图片来自https://blog.csdn.net/soonfly/article/details/70147205)

技术图片

当然,这个常量池和堆是物理隔离的。

总之就是,要抓住“复制”这个字眼,常量池中存的是内容为"abc"的常量对象。

 

看个详细点的例子:

   public static void main(String[] args) {
        String a = new String("haha");
        System.out.println(a.intern() == a);//false
    }

首先,见到"haha",产量池中没有这个常量,所以会在常量池中放下这个常量对象,底层是通过ldc命令,"haha"被添加到字符串常量池,然后在stringTable中添加该常量的引用(引用好像是这个String对象中的char数组的地址),而a这个引用指向的是堆中这个String对象的地址,所以肯定是不同的。(而且一个在堆,一个在方法区中)。

 

 

 

jdk1.7的intern()方法

JDK 1.7后,intern方法还是会先去查询常量池中是否有已经存在,如果存在,则返回常量池中的引用,这一点与之前没有区别,区别在于,如果在常量池找不到对应的字符串,则不会再将字符串拷贝到常量池,而只是在常量池中生成一个对原字符串的引用。简单的说,就是往常量池放的东西变了:原来在常量池中找不到时,复制一个副本放到常量池,1.7后则是将在堆上的地址引用复制到常量池。

当然这个时候,常量池被从方法区中移出来到了堆中。

看个图:

(图片来自https://blog.csdn.net/soonfly/article/details/70147205)

技术图片

所以再看回我们书上的那个例子

public static void main(String[] args) {
    String str1 = new StringBuilder("计算机").append("软件").toString();
    System.out.println(str1.intern() == str1);

    String str2 = new StringBuilder("ja").append("va").toString();
    System.out.println(str2.intern() == str2);
}

这个例子在jdk1.7中的结果是true和false。第一个输出中,因为“计算机软件”这个字符串常量,是没有出现过在常量池中的,所以调用intern()方法的时候,会在常量池中生成一个"计算机软件"的引用,注意是引用哦!

而str1所指向的也是这个堆对象的引用,所以第一个是true。

而第二个,首先查资料发现,由于JVM的 特殊性在JVM启动的时候调用了一些方法,在常量池中已经生成了“java”字符串常量。

所以,str2指向的是堆中的String对象,内容是"java",而这个str2调用intern的时候,常量池中会发现已经有了这个常量对象,所以会返回这个已经存在了的"java"常量对象的引用,那肯定呵str2引用指向的堆地址是不同的,所以false。

 

再看一个例子:

String str2 = new String("str")+new String("01");
str2.intern();
String str1 = "str01";
System.out.println(str2==str1);//true

这个返回true的原因也一样,str2的时候,只有一个堆的String对象,然后调用intern,常量池中没有“str01”这个常量对象,于是常量池中生成了一个对这个堆中string对象的引用。

然后给str1赋值的时候,因为是带引号的,所以去常量池中找,发现有这个常量对象,就返回这个常量对象的引用,也就是str2引用所指向的堆中的String对象的地址。

所以str2和str1指向的是同一个东西,所以为true。

 

 

参考文章:

基本就是图片所引用的博客中的相关内容,在每张图片旁边都有说明复制的来源,这里就不再引述了。




以上是关于String的内存和intern()方法的主要内容,如果未能解决你的问题,请参考以下文章

String中intern方法的作用

JDK方法区元空间以及String.intern()知识要点

调用 intern() 方法后,内存中的 new String() 对象何时被清除

Jackson2.x中内存泄露的风险点—封装的intern逻辑

JDK8中String的intern()方法详细解读内存图解+多种例子+1.1w字长文

深入解析String.intern()方法