关于java中的内存布局

首先java是一个万物都是类的语言,相较于C++,引入了很多内存繁杂的设计,譬如包装器,将一个基本的数据类型包装成了一个对象,虽然这样会给一个基本的数据类型带来很多附加的函数,但是对于内存的消耗也是不容忽视的。在java中的类都实际保存在堆中,而指针的索引又会导致速度的下降。为了减少以上影响引入了池的概念,例如对象常量池等等。这一篇文章是为了探讨由于池的引入导致的地址问题以及“==”判断问题

永久代、方法区、元空间

  • 方法区 (Method Area):这是一个 逻辑规范。JVM规范中定义了方法区,它是一种概念上的区域,用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器(JIT)编译后的代码等数据。它被所有线程共享。你可以把它理解为一个接口或抽象类。

  • 永久代 (Permanent Generation, PermGen):这是 方法区的具体实现。在HotSpot虚拟机(我们最常用的JVM)的Java 7及以前版本中,开发者们选择将方法区实现在一个被称为“永久代”的内存区域里。关键在于,永久代是JVM堆内存的一部分,和其他对象一样受垃圾回收器(GC)管理,但回收效率很低。

  • 元空间 (Metaspace):这也是 方法区的具体实现。从Java 8开始,HotSpot虚拟机移除了永久代,取而代之的是元空间。最大的变化是,元空间不再位于JVM堆内存中,而是直接使用本地内存(Native Memory)。

string对象的存储

Java的String是一个不可变的对象。这意味着一旦一个String对象被创建,它的内容就不能被修改。字符串相加等一系列操作都是拷贝基础上做的操作。

  • String引用:这是一个指向String对象的指针,如果它是局部变量,那么这个引用本身在栈上。

  • String对象本身:它永远在堆上。这个对象包含了字符数据的引用、长度、缓存的哈希码等元数据。

  • 字符串常量池区域:这也是一个永远在堆上的数组(在Java 8及以前是char[],Java 9及以后为了节省空间,改成了byte[]并增加了编码标记)。

所以,创建一个Java String通常涉及至少两个堆上对象:String对象本身和字符串常量池中的char[]/byte[]数组。(JVM实现可能会优化,使它们在内存上是连续的,但逻辑上是两个部分)。例如以下示例,就是创建了两个对象,并返回一个堆区地址的引用。同时new操作会强制在堆的普通区域创建一个新的对象,而不在乎常量池是否已有存在。

1
String s = new String("二哥");

以下这种方法会优先使用字符串常量值

1
String s1 = "abc";

intern()方法

要注意字符串对象的引用可以指向一个堆区不位于字符串常量池的对象或字符串常量池的对象。当使用intern方法时:它会去字符串常量池检查是否存在内容相同的字符串。如果存在,就返回池中那个对象的引用。如果不存在,就把当前这个字符串对象(或其引用,取决于JVM实现)放入池中,并返回其引用。要注意它返回的时字符串常量池的引用。

1
2
3
String s1 = new String("abc"); // s3指向堆中新对象
String s2 = "abc"; // s1指向常量池对象
String s3 = s1.intern(); // s5会获取到常量池中"abc"的引用

判断示例

首先要说明“==”表示的是对象地址严格相同,同样的还有equal方法,但是这个方法应该被重写成判断内容相同

1
2
3
4
5
s1 = new String("二哥三妹");//普通堆区

String s2 = s1.intern();//常量池

System.out.println(s1 == s2);//false

String.intern() 方法在执行时的策略,Java 7 之前,执行 String.intern() 方法的时候,不管对象在堆中是否已经创建,字符串常量池中仍然会创建一个内容完全相同的新对象; Java 7 之后呢,由于字符串常量池放在了堆中,执行 String.intern() 方法的时候,如果对象在堆中已经创建了,字符串常量池中就不需要再创建新的对象了,而是直接保存堆中对象的引用,也就节省了一部分的内存空间。

执行 new String(“二哥”) + new String(“三妹”),会创建一个 StringBuilder 对象,并将 “二哥” 和 “三妹” 追加到其中,然后调用 StringBuilder 对象的 toString() 方法,将其转换为一个新的字符串对象,内容为 “二哥三妹”。这个新的字符串对象存储在堆上。也就是说new String(二哥) + new String(三妹) = new StringBuilder().append(二哥).append(三妹).toString();

1
2
3
4
5
String s1 = new String("二哥") + new String("三妹");//常量池中->"二哥"、“三妹”。普通堆区->"二哥三妹"

String s2 = s1.intern();//当堆区存在时,字符串常量池保存堆区引用,intern返回这个常量池中保存的堆区的引用

System.out.println(s1 == s2);

在下面这个例子中,首先不会发生的是:”小萝莉”返回一个位于字符串常量池的区域,”小” + “萝莉”返回一个位于堆区的区域。会发生的是:由于编译器优化,会直接将”小” + “萝莉”两个字符串拼接,得到”小萝莉”,最终变成同一个字面值,自然返回同一个池中的引用。

所以这里引出一个问题,要判断是否发生了StringBuilder方法,返回一个普通堆区引用。如果一个字符串拼接表达式 + 的所有参与者都是编译期常量(Compile-time Constants),那么这个拼接操作就会在编译期完成,不会在运行时使用 StringBuilder。反之,只要表达式中有任何一个参与者不是编译期常量,编译器就必须生成在运行时执行拼接的代码,这通常就是通过 StringBuilder 来实现的。

  1. 字符串字面量 (String Literals):
  • 例如 “小”, “萝莉”, “abc”。这是最常见的编译期常量。
  1. 基本类型的常量字面量:
  • 例如 123, true, 3.14。当它们和字符串拼接时,也会被当作常量处理。”age:” + 25 会在编译期变成 “age:25”。
  1. 用 final 修饰的、且在声明时就用常量赋值的变量:
  • final String A = “hello”; // A是编译期常量

  • final int AGE = 20; // AGE是编译期常量

  • 注意:final String B = new String(“hello”); // B 不是编译期常量,因为它的值需要new这个运行时动作来确定

1
"小萝莉" == "小" + "萝莉"//true

本站由 Edison.Chen 创建。
本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处。

undefined