JVM—String字符串(二)

一、JDK 8 版本下 JVM 对象的分配、布局、访问(概述)

1、对象的创建过程

(1)前言

  Java 是一门面向对象的编程语言,程序运行过程中在任意时刻都可能有对象被创建。开发中常用 new 关键字、反射等方式创建对象, JVM 底层是如何处理的呢?

(2)对象的创建的几种常见方式

  • 使用 new 关键字创建(常见比如:单例模式、工厂模式等创建)。
  • 反射机制创建(调用 class 的 newInstance() 方法)。
  • 克隆创建(实现 Cloneable 接口,并重写 clone() 方法)。
  • 反序列化创建。

(3)对象创建步骤

第一步:判断对象对应的类 是否已经被 加载、解析、初始化过。

  虚拟机执行 new 指令时,先去检查该指令的参数 能否在 方法区(元空间)的运行时常量池中 定位到 某个类的符号引用,并检查这个符号引用代表的 类是否 被加载、解析、初始化过。如果没有,则在双亲委派模式下,查找相应的类 并加载。

第二步:为对象分配内存空间。

  类加载完成后,即可确定对象所需的内存大小,在堆中根据适当算法划分内存空间给对象。

划分算法:
  划分算法根据 Java 堆中内存是否 规整进行可划分为:指针碰撞、空闲列表。
  堆内存规整时,采用指针碰撞方式分配内存空间,由于内存规整,即指针只需移动 所需对象内存 大小即可。
  堆内存不规整时,采用空闲列表方式分配内存空间,存在内存碎片,需要维护一个列表用于记录哪些内存块可用,在列表中找到足够大的内存空间分配给对象。

堆内存是否规整:
  堆内存是否规整由 垃圾回收器算法决定。
  使用 Serial、ParNew 等带有 Compact(压缩)过程的垃圾回收器时,堆内存规整,即指针碰撞。
  使用 CMS 等带有 Mark-Sweep(标记清除)算法的垃圾回收器时,堆内存不规整,即空闲列表。

第三步:处理并发安全问题。
  分配内存空间时,指针修改可能会碰到并发问题(比如 对象 A 分配内存后,但指针还没修改,此时 对象 B 仍使用原来指针 进行内存分配,那么 A 与 B 就会出现冲突)。

解决方式一:对分配内存空间的动作进行同步处理(CAS 加上失败重试 保证更新操作的原子性)。

解决方式二:将分配内存空间的动作按照线程划分到不同空间中执行(Thread Local Allocation Buffer,TLAB,每个线程在堆中预先分配一小块内存空间,哪个线程需要分配内存,就在哪个 TLAB 上进行分配)。

第四步:初始化属性值。
  将内存空间中的属性 赋 零值(默认值)。

第五步:设置对象的 对象头。
  将对象所属 类的元数据信息、对象的哈希值、对象 GC 分代年龄 等信息存储在对象的对象头。

第六步:执行 <init> 方法进行初始化。
  执行 <init> 方法,加载 非静态代码块、非静态变量、构造器,且执行顺序为从上到下执行,但构造器最后执行。并将堆内对象的 首地址 赋值给 引用变量。

2、对象内存布局

  java对象的内存布局以及使用ClassLayout查看布局:https://www.cnblogs.com/hhddd-1024/p/16525797.html

  对象在内存中存储布局可以分为:对象头(Header)、实例数据(Instance Data)、对齐填充(Padding)。

(1)对象头(Header)

  对象头用于存储 运行时元数据 以及 类型指针。

  • 运行时元数据:对象的哈希值、GC 分代年龄、锁状态标志、偏向时间戳等。
  • 类型指针:即对象指向 类元数据的 指针(通过该指针确定该对象属于哪个类)。

(2)实例数据(Instance Data)

  其为对象 存储的真实有效信息,即程序中 各类型字段的内容。

(4)对齐填充(Padding)

  不是必然存在的,起着占位符的作用。比如 HotSpot 中对象大小为 8 字节的整数倍,当对象实例数据不是 8 字节的整数倍时,通过对齐填充补全。

3、对象访问定位(句柄访问、直接指针)

(1)问题

  对象 存于堆中,而对象的引用 存放在栈帧中,如何根据 栈帧存放的引用 定位 堆中存储的对象,即为对象访问定位问题。取决于 JVM 的具体实现,常见方式:句柄访问、直接指针。

(2)句柄访问

  在堆中划分出一块内存作为 句柄池,用于保存对象的句柄地址(指针),而栈帧中存放的即为 句柄地址。
  当对象被移动(垃圾回收)时,只需要改变 句柄池中 指向对象实例数据的指针 即可,不需要修改栈帧中的数据。

img

(3)直接访问(HotSpot 使用)

  栈帧中直接存放 对象实例数据的地址,对象移动时,需要修改栈帧中的数据。
  相较于 句柄访问,减少了一次 指针定位的时间开销(积少成多还是很可观的)。

img

二、JDK8 中的 String

1、String 基本概念(JDK9 稍作改变)

(1)基本概念

  • String 指的是字符串,一般使用双引号括起来 "" 表示(比如: "hello")。
  • 使用 final 类型修饰 String 类,表示不可被继承。
  • String 类实现了 Serializable 接口,表示字符串支持序列化。
  • String 类实现了 Comparable 接口,表示可以比较大小。
  • String 类内部使用 final 修饰的数组存储字符。

注:JDK8 及以前 内部使用 final char[] value 用于存储字符串数据,JDK9 时改为 final byte[] 存储数据(内部将 每个字符 与 0xFF 比较,当有一个比 0xFF 大时,使用 2 个字节存储,否则使用 1 个字节存储)。

(2)赋值方式

  • 字面量直接赋值
  • new 关键字通过构造器赋值
【字面量直接赋值:值会存放于 字符串常量池 中】
    String a = "hello";

【new + 构造器赋值:值可能会存放于 字符串常量池 中,并且 new 关键字会在堆中创建一个对象】
    String a = new String("hello");    
注:
    值不一定会存放于 字符串常量池中,可以调用 String 的 intern() 方法将值放于字符串常量池中。
    intern() 方法在不同 JDK 版本中实现不同,后面会举例,此处大概有个印象即可。

2、字符串常量池(String Pool)、String 不可变性

(1)字符串常量池(String Pool)

  JVM 内部维护一个 字符串常量池(String Pool),当 String 以字面量形式赋值时,此时字符串会声明在字符串常量池中(比如:String a = "hello" 赋值时,会生成一个 "hello" 字符串存于 常量池中)

  字符串常量池中不会存储相同内容的字符串,其内部实现是一个固定大小的 Hashtable,如果常量池中存储 String 过多,将会造成 hash 冲突,从而造成性能下降,可以通过 -XX:StringTableSize 设置 StringTable 大小(比如:-XX:StringTableSize=2000)。

注:

  • 常量池 类似于 缓存,使程序运行更快、节省内存。
  • JDK 6 及以前,字符串常量池存放于 永久代中,StringTable 默认长度为 1009。
  • JDK 7 及之后,字符串常量池存放于 堆中,StringTable 默认长度为 60013,其最小值为 1009。
【常用 JVM 参数:】
-XX:StringTableSize    配置字符串常量池中的 StringTable 大小,JDK 8 默认:60013。
-XX:+PrintStringTableStatistics  在JVM 进程退出时,打印出 StringTable 相关统计信息。

(2)String 不可变性

  String 一旦在内存中创建,其值将是不可变的(反射场景除外)。当值改变时,改变的是指向堆内存的引用,而非直接修改内存中的值。

JDK 8 String 不可变:

  JDK8 采用 final 修饰 String 类,表示该类不可被继承。

  String 类内部采用 private final char value[] 存储字符串,使用 private 修饰数组且不对外提供 setter 方法,即 外部不可修改字符串。使用 final 修饰数组,表示 内部不可修改字符串(引用地址不变,内容可变,使用反射可能会改变字符串)。且 String 提供的相关方法中,并没有去修改原有字符串中的值,而是返回一个新的引用指向内存中新的 String 值(比如 replace() 方法返回一个 new String() 对象)。

(3)常见场景(修改引用地址)

  • 对现有字符串重新赋值时。
  • 对现有字符串进行连接操作时。
  • 使用字符串的 replace() 方法修改指定字符串时。
【举例:(给现有字符串重新赋值)】
public static void main(String[] args) {
    String C1 = new String("abc");
    String C2 = C1;
    System.out.println(C1 == C2); // true
    System.out.println(System.identityHashCode(C1));
    System.out.println(System.identityHashCode(C2));
    C2 = "abc";
    System.out.println(C1 == C2); // false
    System.out.println(System.identityHashCode(C1));
    System.out.println(System.identityHashCode(C2));
}

img

3、String 拼接操作 -- 笔试题

(1)拼接操作可能存在的情况

  • 常量与常量(字面量或者 final 修饰的变量)的拼接结果会存放于常量池中,由编译期优化导致。
  • 拼接数据中若有一个是变量,则拼接结果 会存放于 堆中。由 StringBuilder 拼接。
  • 如果拼接结果调用 intern() 方法,且常量池中不存在该字符串对象,则将拼接结果 存放于 常量池中。

(2)常量(字面量)拼接 -- 拼接结果存于常量池

  对于两个及以上字面量拼接操作,在编译时会进行优化,若该拼接结果不存在于常量池中,则直接将其拼接结果存于常量池,并返回其引用地址。否则,返回常量池中该结果所在的引用地址。

【举例:】
public static void main(String[] args) {
    String c1 = "a" + "b" + "c";	    // 编译期优化,等同于 "abc",并存放于常量池中
    String c2 = "abc";					// "abc" 已存在于常量池,此时直接将常量池中的地址 赋给 c2
    System.out.println(c1 == c2); 		// true
    System.out.println(System.identityHashCode(c1));
    System.out.println(System.identityHashCode(c2));
}

img

(3)final 修饰的变量拼接(可以理解为常量) -- 拼接结果存于常量池

  由于 final 修饰的变量不可被修改,在编译期优化等同于 常量进行拼接操作,所以结果存放于常量池中。

【举例:】
public static void main(String[] args) {
    final String c1 = "hello";
    final String c2 = "world";
    String c3 = "helloworld";			// "helloworld" 存放于常量池中
    String c4 = c1 + c2;			    // 发生编译器优化,此处等价于 helloworld
    System.out.println(c3 == c4);		// true
    System.out.println(System.identityHashCode(c3));
    System.out.println(System.identityHashCode(c4));
}

img

(4)一般变量拼接 -- 拼接结果存于 堆

  拼接操作中出现变量时,会触发 new StringBuilder 操作,并使用 StringBuilder 的 append 方法进行字符串拼接,最终调用其 toString 方法转为字符串,并返回引用地址。

注:StringBuilder 的 toString 方法内部调用 new String(),其最终拼接结果存放于 堆 中(不会将拼接结果存放于常量池,可以手动调用 intern() 方法将结果放入常量池,后面介绍,往下看)。

  使用 StringBuilder 进行字符串拼接操作效率要远高于使用 String 进行字符串拼接操作。

  使用 String 直接进行拼接操作时,若出现变量,则会先创建 StringBuilder 对象,最终输出结果还得转为 String 对象,即使用 String 进行字符串拼接过程中 可能出现多个 StringBuilder 和 String 对象(比如在 循环中 进行字符串拼接操作),且创建对象过多会占用更多的内存。
  而使用 StringBuilder 进行拼接操作时,只需要创建一个 StringBuilder 对象即可,可以节省内存空间以及提高效率执行。

// 源文件内容
public static void main(String[] args) {
    String c1 = "hello";
    String c2 = "world";
    String c3 = "hello" + "world";		    // 发生编译优化,等价于 "helloworld",存于常量池
    String c4 = "helloworld";				// 常量池中已存在,直接赋值常量池引用
    String c5 = "hello" + c2;			    // 拼接结果存于 堆
    String c6 = c1 + "world";				// 拼接结果存于 堆
    String c7 = c1 + c2; 					// 拼接结果存于 堆
    System.out.println(System.identityHashCode(c3));
    System.out.println(System.identityHashCode(c4));
    System.out.println(System.identityHashCode(c5));
    System.out.println(System.identityHashCode(c6));
    System.out.println(System.identityHashCode(c7));
}
// 字节码内容
public static void main(String[] args) {
    String c1 = "hello";
    String c2 = "world";
    String c3 = "helloworld";
    String c4 = "helloworld";
    (new StringBuilder()).append("hello").append(c2).toString();
    String c6 = c1 + "world";
    (new StringBuilder()).append(c1).append(c2).toString();
}

img

(5)拼接结果调用 intern 方法 -- 结果存放于常量池

  由于不同版本 JDK 的 intern() 方法执行结果不同,此处暂时略过,接着往下看。

4、String 使用 new 关键字创建对象问题 -- 笔试题

(1)new String("hello") 会创建几个对象?

  可能会创建 1 个或 2 个对象。

  new 关键字会在堆中创建一个对象,而当字符串常量池中不存在 "hello" 时,会创建一个对象存入字符串常量池。若常量池中存在对象,则不会创建、会直接引用。

public static void main(String[] args) {
    String c1 = new String("hello");
    String c2 = new String("hello");
}

img

(2)new String("hello") + new String("world") 创建了几个对象?

  创建了 6 个对象(不考虑常量池是否存在数据)。

对象创建:

  由于涉及到变量的拼接,所以会触发 new StringBuilder() 操作。此处创建 1 个对象。

  new String("hello") 通过上例分析,可以知道会创建 2 个对象(堆 1 个,字符串常量池 1 个)。

  同理 new String("world") 也会创建 2 个对象。

  最终拼接结果 会触发 StringBuilder 的 toString() 方法,内部调用 new String() 在堆中创建一个对象(此处不会在字符串常量池中创建对象)。

注:
  StringBuilder 的 toString() 内部的 new String() 并不会在 字符串常量池 中创建对象。

  String str = new String("hello"); 这种形式创建的字符串 可以在字符串常量池中创建对象。

此处我是根据 字节码文件 中是否有 ldc 指令来判断的(后续根据 intern() 方法同样也可以证明这点),有不对的地方,还望不吝赐教。

public static void main(String[] args) {
	String a = new String("hello") + new String("world");
}

img

5、String 中的 intern() 相关问题 -- 笔试题

(1)intern() 作用

  对于非字面量直接声明的 String 对象(通过 new 创建的对象),可以使用 String 提供的 intern 方法获取字符串常量池中的数据。

该方法作用:

  从字符串常量池中查询当前字符串是否存在(通过 equals 方法比较),如果不存在,则会将当前字符串放入常量池中并返回该引用地址(此处不同版本的 JDK 有不同的实现)。若存在则直接返回引用地址。

(2)不同 JDK 版本中 intern() 使用的区别

  JDK 6:尝试将该字符串对象 放入 字符串常量池中(字符串常量池位于 方法区中),
    若字符串常量池中已经存在 该对象,则返回字符串常量池 当前对象的引用地址。
    若没有该对象,则将 当前对象值 复制一份放入字符串常量池,并返回此时对象的引用地址。

  JDK 7 之后:尝试将该字符串对象 放入 字符串常量池中(字符串常量池位于 堆中),
    若字符串常量池中已经存在 该对象,则返回字符串常量池 当前对象的引用地址。
    若没有该对象,则将 当前对象的 引用地址 复制一份放入字符串常量池,并返回引用地址。

(3)使用 JDK8 演示 intern()

  此处使用 JDK8 演示 intern() 方法,有兴趣可以自行研究 JDK6 的操作。

【例一:】
public static void main(String[] args) {
    String a = new String("hello"); // 此时常量池存在 "hello"
    String b = "hello"; // 直接引用常量池中 "hello"
    String c = a.intern(); // 直接引用常量池中 "hello"
    System.out.println(System.identityHashCode(a));
    System.out.println(System.identityHashCode(b));
    System.out.println(System.identityHashCode(c));
    System.out.println(b == a); // false,a 指向 堆 内对象,b 指向 字符串常量池
    System.out.println(b == c); // true,b,c 均指向字符串常量池
}

【例二:(b,c 互换位置)】
public static void main(String[] args) {
    String a = new String("hello"); // 此时常量池存在 "hello"
    String c = a.intern(); // 直接引用常量池中 "hello"
    String b = "hello"; // 直接引用常量池中 "hello"
    System.out.println(System.identityHashCode(a));
    System.out.println(System.identityHashCode(b));
    System.out.println(System.identityHashCode(c));
    System.out.println(b == a); // false,a 指向 堆 内对象,b 指向 字符串常量池
    System.out.println(b == c); // true,b,c 均指向字符串常量池
}

【分析:】
    例一 与 例二 的区别在于 intern() 执行时机不同,且两者输出结果相同。
    JDK 8 中 intern() 执行时,若字符串常量池中 equals 未比较出相同数据,则将当前对象的引用地址 复制一份并放入常量池。
    若存在数据,则返回常量池中数据的引用地址。
    
    即 new String() 操作后,若常量池中不存在 数据,则调用 intern() 后,会复制 堆的地址 并存入 常量池中。后续获得的均为 堆的地址。也即上述 例一、例二 中 a、b、c 操作后,均相同且指向 堆。
  若常量池存在数据,则调用 intern() 后,返回常量池引用,后续获得的均为 常量池引用。也即上述 例一、例二 中 a 为指向堆 的引用地址,b,c 均为指向常量池的引用地址。

    通过输出结果可以看到,上述 例一、例二 中 a、b、c 操作后,b, c 相同且不同于 a(即 b、c 指向常量池),从侧面也反映出 new String("hello") 执行后 常量池中存在 "hello"。

img

img

【例四:】
public static void main(String[] args) {
    char[] a = new char[]{'h', 'e', 'l', 'l', 'o'};
    String b = new String(a, 0 , a.length); // 此时常量池中不存在 "hello"
    String c = "hello"; // 此时常量池中存在 "hello"
    String d = b.intern(); // 直接引用常量池中的 "hello"
    System.out.println(System.identityHashCode(b));
    System.out.println(System.identityHashCode(c));
    System.out.println(System.identityHashCode(d));
    System.out.println(c == b); // false, b 指向堆对象, c 指向常量池
    System.out.println(c == d); // true,c,d 均指向常量池
}

【例五:】
public static void main(String[] args) {
    char[] a = new char[]{'h', 'e', 'l', 'l', 'o'};
    String b = new String(a, 0 , a.length); // 此时常量池中不存在 "hello"
    String d = b.intern(); // 常量池不存在 "hello",复制 b 在堆中的引用到常量池中
    String c = "hello"; // 直接获取常量池中的引用
    System.out.println(System.identityHashCode(b));
    System.out.println(System.identityHashCode(c));
    System.out.println(System.identityHashCode(d));
    System.out.println(c == b); // true, c, b 均指向堆
    System.out.println(c == d); // true, c, d 均指向堆
}

【分析:】
    例四 与 例五 的区别在于 intern() 执行时机不同,且两者输出结果相同。
    JDK 8 中 intern() 执行时,若字符串常量池中 equals 未比较出相同数据,则将当前对象的引用地址 复制一份并放入常量池。
    若存在数据,则返回常量池中数据的引用地址。
    
    例四中,new String() 执行后,常量池中不存在 "hello",
    但 String c = "hello" 执行后,常量池中存在 "hello",从而 intern() 获取的是常量池中的引用地址。
    也即 b 为指向 堆的引用,c,d 均为指向常量池的引用。
    
    例五中,new String() 执行后,常量池中不存在 "hello",
    intern() 执行后会将当前对象地址(指向堆的引用)复制并放入常量池,从而 String c = "hello" 获取的是常量池的引用地址。
    也即 b,c,d 获取的均是指向 堆 的引用。

对例四、例五进行一下延伸。

【例六:】
public static void main(String[] args) {
    String a = new String("hello") + new String("world");
    String b = "helloworld";
    String c = a.intern();
    System.out.println(System.identityHashCode(a));
    System.out.println(System.identityHashCode(b));
    System.out.println(System.identityHashCode(c));
    System.out.println(b == a); // false, a 指向堆, b 指向常量池
    System.out.println(b == c); // true, b、c 均指向常量池
}

【例七:】
public static void main(String[] args) {
    String a = new String("hello") + new String("world");
    String c = a.intern();
    String b = "helloworld";
    System.out.println(System.identityHashCode(a));
    System.out.println(System.identityHashCode(b));
    System.out.println(System.identityHashCode(c));
    System.out.println(b == a); // true, a、b 均指向堆,
    System.out.println(b == c); // true, b、c 均指向堆
}

【分析:】
    涉及到变量字符串拼接,会触发 StringBuilder 进行相关操作。
    最终触发 toString() 转为 String,其内部调用的是 String(char value[], int offset, int count) 构造方法,
    此方法在堆中创建 字符串 但不会向常量池中添加数据(与 例四、例五 是同样的场景)。

img

img

posted @ 2023-04-12 17:39  黄河大道东  阅读(25)  评论(0编辑  收藏  举报