Java字符串内存分配-字符串常量池

字符串常量池

第一节 相关概念

  首先要了解Java虚拟机内存区域划分,参考Java内存区域和内存溢出异常

1.1 运行时数据区

  • 程序计数器

  • 本地方法栈

  • 虚拟机栈:方法帧,局部变量,基本类型,对象引用等。

  • 堆(Heap):引用类型即对象实例和数组,成员变量等。

  • 方法区(Method Area):字节码,常量,静态变量等。

  类加载完毕,类的信息存在方法区内。和String相关的即运行时常量池字符串常量池,前者在方法区内,后者在JDK 1.7后转移到中(之前是在no-heapPerm Gen中。永久代,HotSpot独有,而Perm Gen在Java 8时被移除,其中的方法区移到了Metaspace(元空间),Metaspace不位于虚拟机内,而是使用本地内存,所以理论上最大可用空间是系统整个内存空间,将元数据剥离出Perm Gen提高GC效率,字符串与类元数据分开提升了独立性。)。


1.2 String相关区域

1.常量池表(constant_pool table):

  存储Class文件中所有常量(包括字符串)的表,是文件中的结构化数据而不是运行时内容,主要存两样:字面量(Literal)和符号引用量(Symbolic References),字面量就是类中定义的常量,而String是不可变的final修饰的,也作为字面量存储在这里。

2.运行时常量池(Runtime Constant pool):

  属于方法区中的某部分,属于运行时内容,受方法区内存限制。

  其数据大部分都是由常量池表转换而来(除了常量池中内容,还可能有动态生成加入的内容,因为运行时常量池有动态性,即除了编译时的字符常量,也可以在运行时将新常量加入池内,比如 String.intern 方法)。

3.字符串常量池(String pool):

  字符串常量池是用来存储驻留字符串(interned string)的全局字符串池,HotSpot VM中实现String pool功能的是 StringTable 类,StringTable是一个哈希表,其存储的是驻留字符串的引用,相当于字符串实例被哈希表引用后就被赋予了驻留字符串的身份。对于类中的运行时常量池中的字符串常量,经过解析之后同样存储的是字符串的引用。解析的过程中会去查询StringTable,以保证运行时常量池所引用的字符串和StirngTable中一致。

  JVM规定进入字符串常量池的实例叫被驻留的字符串(interned string),各个JVM可能有不同的实现,HotSpot是设置一个哈希表来引用堆中的字符串实例,被引用就是被驻留。

  字符串常量池在JDK1.7版本后存于Heap堆内,旧版本存于Perm区域(静态区域,较小)中。所以旧版本时比较字符串地址,字符串常量池中的实例地址和堆中的实例地址必定是不同的,而新版本调用 intern()方法写入常量池时因为调用对象已存于堆中,就无需再存储一份String对象了,所以直接存储其在堆内的引用。但”abc”字符串常量仍会在常量池内创建实例。

字符串常量池和运行时常量池区别:

  1. 字符串常量池每个类都有一个,运行时常量池是JVM共享,全局只有一个。
  2. 字符串常量池只记录字符串对象,运行时常量池记录各种对象。

1.3 享元模式

  字符串常量池涉及到一个设计模式:享元模式,即共享元素模式

  含义是:一个系统若有多处用到了同一元素,那么应该只定义存储一份,然后让所有地方引用此元素。

  字符串String就是依照此模式设计,字符串常量池就是存储元素的地方。


第二节 案例解析

  我们知道字符串赋值方式包括:

1
2
String a = "ori";
String b = new String("ori");

  这两种方式有什么不同吗?我们带着这个疑问逐步看下去。

  我们在main函数中声明了如下变量:

1
String str1 = "Hello";

  字面量”Hello”的字符串实例创建到字符串常量池内,str1引用是字符串常量池内其地址(此时内存状态如下图所示,旧版JDK字符串常量池在永久代方法区中,新版则在堆中)。

  创建String变量时,JVM在字符串常量池内寻找是否有 equals(“Hello”) 的String,若存在就在Stack内的当前栈帧中的局部变量表内创建变量b,并将字符串常量池内存储的引用复制给b。若没找到就在Heap中新建一个对象,先把引用赋值到字符串常量池内,再复制到局部变量表。

  定义多个同字面量字符串呢?

1
2
String str2 = "Hello"; 
String str3 = "Hello";

当定义多个同值的字符串时,只会在堆内引用相同的实例。

  另一种方式创建字符串呢?

1
String str3 = new String("Hello"); 

  如果用new的方式创建对象,情况就不一样了。因为new关键字,会在Heap中新申请一块内存,虽然存储的内容相同,但地址等信息都不一样了。

  String.intern() 方法可以手动检查字符串常量池,把新的字面值(Literal)的字符串地址赋值到字符串常量池内。

  OK,有了以上的了解,就可以分析以下的几个问题了。


第三节 问题分析

1.以下变量在内存中和运行中有什么区别?

1
2
int a = 1;
String b = "ori";

  字面量(Literal) 1”ori” 会经过编译后存入Class文件中的常量池表(constant_pool table)中。

  当程序运行类被加载时,Class文件中的常量池表会被加载到JVM内存中的方法区内,存放在对应Class对象的运行时常量池(Runtime Constant pool)中。

  也就是运行到此类时,其类信息被加载到方法区中,而常量池表内的大部分数据会被加载到运行时常量池中。a为基本数据类型,并不涉及到对象数据,而b则是字符串对象,需要在堆中进行实例分配。

2.下列代码创建了多少对象?

1
2
String s1 = new String("abc");
String s2 = new String("str") + new String("ing");

  这个问题其实没有标准的答案。

  首先要对创建这个动词定义,是指从编译到执行,还是只是执行这两行代码的过程? 先考虑以下问题?

第一句在运行时涉及到几个String实例?

  回答:2个,一个是编译阶段字符串常量池内驻留intern的实例,一个是执行阶段通过new创建的实例。

第一句涉及到用户声明的几个String类型变量?

  回答:1个,即变量s1。

执行阶段String的创建过程?

  首先String创建时,会根据String字面量(Literal)去字符串常量池中查找是否有相同字符串存在(equals),若不存在就在堆中新建对象,然后在字符串常量池中赋值其引用,调用String构造器进行初始化,将引用地址赋值给变量s1。

  若存在关键字 new ,就会在堆中新建对象,不会再向字符串常量池新建数据了,直接返回对象引用。

  构造器本身是做初始化,new 关键字负责创建实例。

  回到问题本身,如果创建指的是在执行代码阶段创建了几个String实例,则答案是2个。代码在执行前要先通过编译和加载,所以在类加载时会创建并驻留一个String实例,而到了执行此代码阶段,虚拟机执行的字节码如下所示:

1
2
3
4
5
0: new  #2; //class java/lang/String  
3: dup
4: ldc #3; //String xyz
6: invokespecial #4; //Method java/lang/String."<init>":(Ljava/lang/String;)V
9: astore_1

  所以 new 创建了一次对象,ldc是将类加载时创建好的实例引用压到操作数栈顶,并没有新创建对象。

  而后一句在类加载阶段,新建 ”str” 并驻留+1,新建 ”ing” 并驻留+1;执行阶段两个 new 创建两个String实例+2,而 ”+” 会被隐式的处理为 StringBuilder.append() ,所以会创建一个StringBuilder实例+1,然后 StringBuilder.toString() 又调用一次 new String() ,String实例+1。

  以上是在 JDK 1.7 以前字符串常量池内是要创建String实例的,但之后的版本因为迁移到堆内,所以只会拷贝一份引用。

  类加载阶段,第一句创建了 “abc” 字面量对应的String实例,第二句创建了 ”str””ing” 对应的String实例,把引用复制到 String table 中。

  执行阶段,第一句 new 指令创建了1个String实例,第二句两个new创建了两个String实例,创建了一个StringBuilder实例,拼接完后又根据String table是否存在 ”string” 决定是否再创建一个String实例(可能此时其他地方已经实例化过相同的字符串),实际执行中可能就要考虑字符串常量池中是否已经加载过对应字符串字面量了。

  所以算上加载阶段且假设String Table为空,第一句创建了2个对象,都是String对象。而第二句创建了6个对象,5个String对象

3.以下两者内存分配时有区别吗?

1
2
String s1 = "abc";
String s2 = new String("abc");

  JVM编译阶段对字符串字面量(Literal) ”abc” 判断,字符串常量池中若没有则创建一个,s1所指向的字符串引用地址指向该字符串实际内存地址,而s2调用了 new 指令,在运行时创建不同的对象,根据String的源代码可以清楚得知,初始化时会赋值给字符数组value其在字符串常量池中的内存地址,所以 new 只是创建了一个String对象,而字符串常量数据仍在字符串常量池中创建。

4.判断下列代码输出结果?

1
2
3
4
5
6
7
8
9
10
String s1 = "abc";
String s2 = new String("abc");
String s3 = "abc";
String s4 = "a" + "bc";
String s5 = new String("abc");
System.out.println(s1 == s2);
System.out.println(s1 == s3);
System.out.println(s1 == s4);
System.out.println(s2 == s5);
System.out.println(s1 == s1.intern());

  结果:

1
2
3
4
5
false
true
true
false
true

  分别打印其内存地址:

1
2
3
4
5
6
s1: 0x463bfb080
s2: 0x463bfb200
s3: 0x463bfb080
s4: 0x463bfb080
s5: 0x463bfb2c0
s1.intern(): 0x463bfb080

  解析:

  • s1引用指向字符串常量池中的 ”abc” ,s2通过 new 关键字在堆中创建新对象并返回,请记住只要是通过 new 创建对象,其引用肯定是不同的。
  • s3在字符串常量池内找到相同字符串,直接返回 ”abc” 引用,所以是对同一字符串常量池中字符串数据的引用,所以引用地址相同。
  • 是对同一字符串常量池中字符串数据的引用,所以引用地址相同。
  • 因为 new 创建新对象,只要是 new 在堆中创建新对象,引用肯定是不同的。
  • s1.intern() 将String对象在运行期动态加入字符串常量池并返回其引用,所以和s1是相同引用

  更多内容请参考String的intern方法详解


参考博客和文章书籍等:

java 字符串内存分配的分析与总结

rednaxelafx

java用这样的方式生成字符串:String str = “Hello”,到底有没有在堆中创建对象

因博客主未标明不可转载和引用,若内容涉及侵权请及时告知,我会尽快修改和删除相关内容