JVM——String字符串

发布时间 2023-04-12 17:45:31作者: 黄河大道东

一、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)常量(字面量)拼接 -- 拼接结果存于常量池

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