String为什么是不可变的深度解析底层原理(面试官超爱问)

目录

1. String 是不可变的

2. String 不可变的好处

2.1 字符串常量池

2.2 用作 HashMap 的 key

2.3 缓存 HashCode

2.4 线程安全

面试分析

3.分析:

4. String 为什么不可变面试回答:

5. 为什么要设计为不可变面试回答:

参考面试回答:

6. 字符串常量池问题:

7. BigInteger的不可变性


最近刷面经看到很多这道题然后今天是五一最后一天 我们来学习一下吧:

主要讲解为什么 String 被设计为是不可变的?这样设计有什么好处?以及和他有关的面试题及面试回答

1. String 是不可变的

我们先来介绍一下String 是不可变的这件事。在 Java 中,字符串是一个常量、我们一旦创建了一个 String 对象、就无法改变它的值、它的内容也就不可能发生变化(不考虑反射这种特殊行为)。

举个例子比如我们给字符串 s 赋值,然后再尝试给它赋一个新值,正如下面这段代码所示:

String s = "wy";

s = "MM";

看上去好像是改变了字符串的值,但其背后实际上是新建了一个新的字符串 “MM”

并且把 s 的引用指向这个新创建出来的字符串“MM”  原来的字符串对象“wy'”不变

同样如果我们调用 String 的 subString() 或 replace() 等方法,同时把 s 的引用指向这个新创建出来的字符串,这样都没有改变原有字符串对象的内容,因为这些方法只不过是建了一个新的字符串而已

例如下面这个例子:

String lagou = "lagou";

lagou = lagou.subString(0, 4);

代码中利用 lagou.subString(0, 4) 会建立一个新的字符串“lago”这四个字母,比原来少了一个字母,但是这并不会影响到原有的“lagou”这个五个字母的字符串

也就是说:现在内存中同时存在“lagou”和“lago”这两个对象

那这背后是什么原因呢?我们来看下 String 类的部分重要源码:

public final class String

    implements Java.io.Serializable, Comparable<String>, CharSequence {

    /** The value is used for character storage. */

    private final char value[];

	//...

}

首先可以看到这里面有个非常重要的属性、即 private final 的 char 数组、数组名字叫 value。它存储着字符串的每一位字符、同时 value 数组是被 final 修饰的

也就是说:这个 value 一旦被赋值引用就不能修改了

并且在 String 的源码中可以发现:除了构造函数之外、并没有任何其他方法会修改 value 数组里面的内容、而且 value 的权限是 private、外部的类也访问不到、所以最终使得 value 是不可变的。

那么有没有可能存在这种情况:其他类继承了 String 类,然后重写相关的方法,就可以修改 value 的值呢?这样的话它不就是可变的了吗?

这个问题很好

不过这一点也不用担心、因为 String 类是被 final 修饰的、所以这个 String 类是不会被继承的

因此没有任何人可以通过扩展或者覆盖行为来破坏 String 类的不变性

这就是 String 具备不变性的原因

2. String 不可变的好处

那我们就考虑一下,为什么当时的 Java 语言设计者会把它设计成这样?当然我们不是 String 的设计者本人,也无从考究他们当时的真实想法。不过我们可以思考一下,如果把 String 设计为不可变的,会带来哪些好处呢?我经过总结,主要有以下这四个好处

2.1 字符串常量池

String 不可变的第一个好处是可以使用字符串常量池。在 Java 中有字符串常量池的概念,比如两个字符串变量的内容一样,那么就会指向同一个对象,而不需创建第二个同样内容的新对象,例如:

String s1 = "lagou";

String s2 = "lagou";

其实 s1 和 s2 背后指向的都是常量池中的同一个“lagou”,如下图所示:

并发.png

在图中可以看到,左边这两个引用都指向常量池中的同一个“lagou”,正是因为这样的机制,再加上 String 在程序中的应用是如此广泛,我们就可以节省大量的内存空间

如果想利用常量池这个特性,这就要求 String 必须具备不可变的性质,否则的话会出问题,我们来看下面这个例子:

String s1 = "lagou";

String s2 = "lagou";

s1 = "LAGOU";

System.out.println(s2);

我们想一下,假设 String 对象是可变的,那么把 s1 指向的对象从小写的“lagou”修改为大写的“LAGOU”之后,s2 理应跟着变化,那么此时打印出来的 s2 也会是大写的:

LAGOU

这就和我们预期不符了、同样也就没办法实现字符串常量池的功能了

因为对象内容可能会不停变化、没办法再实现复用了。

假设这个小写的“lagou”对象已经被许多变量引用了、如果使用其中任何一个引用更改了对象值、那么其他的引用指向的内容是不应该受到影响的。

实际上由于 String 具备不可变的性质、所以上面的程序依然会打印出小写的“lagou”、不变性使得不同的字符串之间不会相互影响、符合我们预期。

2.2 用作 HashMap 的 key

String 不可变的第二个好处就是它可以很方便地用作 HashMap (或者 HashSet) 的 key。通常建议把不可变对象作为 HashMap的 key,比如 String 就很合适作为 HashMap 的 key。

对于 key 来说、最重要的要求就是它是不可变的、这样我们才能利用它去检索存储在 HashMap 里面的 value。

由于 HashMap 的工作原理是 Hash、也就是散列、所以需要对象始终拥有相同的 Hash 值才能正常运行。

如果 String 是可变的:这会带来很大的风险、因为一旦 String 对象里面的内容变了、那么 Hash 码自然就应该跟着变了、若再用这个 key 去查找的话、就找不回之前那个 value 了。

2.3 缓存 HashCode

String 不可变的第三个好处就是缓存 HashCode

在 Java 中经常会用到字符串的 HashCode,在 String 类中有一个 hash 属性,代码如下:

/** Cache the hash code for the String */

private int hash;

这是一个成员变量、保存的是 String 对象的 HashCode。

因为 String 是不可变的、所以对象一旦被创建之后、HashCode 的值也就不可能变化了、我们就可以把 HashCode 缓存起来。

这样的话以后每次想要用到 HashCode 的时候不需要重新计算,直接返回缓存过的 hash 的值就可以了、因为它不会变、这样可以提高效率、所以这就使得字符串非常适合用作 HashMap 的 key。

而对于其他的不具备不变性的普通类的对象而言、如果想要去获取它的 HashCode 、就必须每次都重新算一遍 相比之下效率就低了。

2.4 线程安全

String 不可变的第四个好处就是线程安全、因为具备不变性的对象一定是线程安全的。我们不需要对其采取任何额外的措施,就可以天然保证线程安全。

由于 String 是不可变的、所以它就可以非常安全地被多个线程所共享。这对于多线程编程而言非常重要、避免了很多不必要的同步操作。

面试分析

3.分析:

首先介绍一下Final

  • final关键字修饰的类不能被继承
  • final修饰的方法不能被重写
  • final修饰的变量是基本数据类型则值不能改变
  • final修饰的变量是引用类型则不能再指向其他对象。保证了引用的不可变性。数组是引用类型
  • final修饰引用类型变量的内容是可以修改的

String字符串为什么不可变分析:

1.首先char[] 数组被 private 修饰

因此final关键字修饰的数组保存字符串并不是 String 不可变的根本原因

因为这个数组保存的字符串是可变的(final修饰引用类型变量的情况)。

  • final: final 关键字修饰 value 数组 这意味着 value 数组的引用一旦被初始化 其引用就不能再指向其他数组

  • 但是final 并不意味着数组的内容不能被修改。

  • final 只是保证了 value 始终指向同一个数组对象。

  • 虽然数组本身的内容在技术上是可以修改的(通过反射等机制或者内部有别的方法可以修改)

  • 而且 char[] 数组被 final 修饰

2.private final char value[]

  • 也就是说这个数组只能被内部访问 并且你无法在当前作用域内访问它。 private 保证了外部无法直接访问 value 数组

  • private 成员(变量、方法等)只能在声明它的类内部访问。也就是说要修改只能在本地修改

但是String类内部没有提供修改 value 数组内容的方法 (保证内容不被修改)所有的改变内容的方法的源代码都是new 一个新对象

  • 以及 String 类本身的设计 有final修饰 被final修饰的类不能被继承 方法不能被重写 也就是说即不提供任何修改 value 数组内容的方法。

  • 所以这就导致了String是不可变的

4. String 为什么不可变面试回答:

String 不可变性的原因有以下几点 我来说一下我的看法

首先就是:

  • String 类内部包含一个 private final char[] value 数组。这是关键的声明。

  • final 修饰符确保了 value 数组的引用一旦指向某个数组、就不能再指向其他数组对象。这意味着value 数组的引用是不可修改的、从而保证了字符串对象在整个生命周期内始终引用同一个字符数组。

  • 但它本身并不能阻止数组内容的修改。理论上可以通过反射等方式修改其内容。但是我们这分析不考虑这个情况先

然后就是:private 关键字进行了访问控制private char value[]

  • private:这意味着 value 数组只能在 String 类内部访问。外部无法直接修改这个数组的内容。private 成员(变量、方法等)只能在声明它的类内部访问。
  • 所以说如果我们想修改value数组那就只能依靠String 类的内部方法

但是:String 类内部没有提供修改 value 数组内容的方法:

  • String 类的设计至关重要:它没有提供任何公共的或受保护的方法来修改 value 数组的内容。所有看似修改字符串的方法(如 substring()replace()toUpperCase() 等)实际上都是创建并返回一个新的 String 对象、而原始的 String 对象保持不变。

最后就是:

  String 类本身被声明为 final 也就是final class String:防止继承和修改行为

  • String 类本身被声明为 final。这意味着:

    • String 类不能被继承、这防止了子类通过重写方法来修改 value 数组的内容。

    • String 类中的方法不能被重写。这确保了 String 类的行为是可预测的、并且不会被子类意外地修改。

综上:String 类的不可变性是通过多个层面设计实现的、包括 final 修饰的 value 数组、private 访问控制、没有提供修改内容的方法以及将 String 类声明为 final。这些设计共同确保了 String 对象一旦创建、其内容就无法被改变

5. 为什么要设计为不可变面试回答:

分析:

  1. 用于字符串常量池复用

  2. 用于hashmap 的key是不可变的 如果key可变 那就可能找不到原来的value了

  3. 同时可以用作缓存 哈希码不可变 如果哈希码可变每次都要重新计算

  4. 不可变的对象是线程安全的

为什么String 设计为不可变性:

参考面试回答:

从安全性来说:不可变性的作用: 如果 String 对象是可变的 那么当一个变量修改了字符串内容时 其他指向相同字符串的变量也会受到影响,这会导致混乱和错误。 不可变性保证了字符串常量池中的字符串对象不会被修改 从而可以安全地被多个变量共享。(防止恶意修改): 除了防止意外修改、不可变性还可以防止恶意修改。例如如果一个方法接收一个 String 对象作为参数、并且这个 String 对象是可变的、那么方法可能会修改这个 String 对象、从而影响到调用方的代码。如果 String 对象是不可变的、那么方法就无法修改它、从而保证了安全性。

从缓存上来说: 由于 String 对象是不可变的、所以可以安全地进行缓存。例如,String Pool(字符串常量池)就是利用了 String 的不可变性、避免了重复创建相同的字符串对象、节省了内存空间。

从缓存哈希码唯一性: 不可变对象的哈希码在创建后就固定不变。这使得 String 对象非常适合作为 HashMap 的 key 或用作缓存的键。如果 String 对象是可变的、那么每次访问缓存时都需要重新计算哈希码、这会降低性能、甚至导致缓存失效。

从HashMap 的 key 的要求: HashMap 使用 key 的哈希码来确定 value 的存储位置。如果 key 是可变的、那么当 key 的内容发生改变时、其哈希码也会发生改变、导致无法找到之前存储的 value。因此HashMap 要求 key 必须是不可变的。

从线程安全性: 不可变对象是线程安全的、因为它们的状态不能被修改。多个线程可以安全地访问同一个不可变对象、而不需要额外的同步措施。这简化了并发编程避免了潜在的线程安全问题。

补充内容:

  • char (基本数据类型): 基本数据类型直接存储值。

  • char[] (数组 引用类型): 表示一个字符数组即一个字符序列。 数组是对象 因此是引用类型。

  • 引用类型存储的是对象的内存地址 而不是对象本身的值。

6. 字符串常量池问题:

当使用字符串字面量创建 String 对象时、JVM 会首先检查字符串常量池中是否存在相同的字符串。如果存在则直接返回常量池中字符串的引用、如果不存在则在常量池中创建一个新的字符串对象。这使得 s1s2 指向同一个对象 从而提高了性能并节省了内存。

String 变量存储的是对象的引用 而不是对象本身。

当我们修改 String 变量的值时 实际上是改变了变量指向的对象 而不是修改对象本身的内容。

public class Main {
    public static void main(String[] args) {
        String s = "hello";
        System.out.println("s = " + s); // 输出: s = hello

        s = "world";
        System.out.println("s = " + s); // 输出: s = world

        String s2 = "hello";
        System.out.println("s2 = " + s2); // 输出: s2 = hello

        System.out.println("s == s2: " + (s == s2)); // 输出: s == s2: false
    }
}

  • s 最初指向字符串常量池中的 hello字符串对象。

  • 然后s 被重新赋值 指向字符串常量池中的 world 字符串对象。

  • hello”字符串对象仍然存在于字符串常量池中 但不再被 s 引用。

  • 字符串常量池 字符串常量池是 JV 中一块特殊的存储区域 用于存储字符串字面量。

  • String 对象的不可变性: String 对象一旦创建、其内容就不能被修改。

  • 引用改变: 当我们修改 String 变量的引用时(例如 s = "world";) 实际上是让变量指向了字符串常量池中的另一个 String 对象。

  • 内容保留: 即使 String 变量的引用改变了 原始 String 对象的内容仍然会留在字符串常量池中 直到 JVM 垃圾回收器认为它不再被需要并回收它。

7. BigInteger的不可变性

BigInteger 也是不可变的。这意味着对 BigInteger 对象的任何操作、如加法、减法等、都会返回一个新的 BigInteger 对象、而不会修改原始对象。

9.String、StringBuffer 和 StringBuilder区别

String、StringBufferStringBuilder 区别

这是我个人的理解 面试的时候从这个点出发就行了

  • 可变性:

    • String 类是不可变的,一旦创建后字符串的内容不可修改。任何对字符串的修改操作(如 substring()、replace())都会生成一个新的 String 对象、而原始的 String 不会改变

    • StringBufferStringBuilder: 可变。 可以在原有对象上修改字符串内容。不会创建新的对象。它们的修改操作直接作用于原对象

  • 线程安全

    • String: 因为是不可变的 所以是线程安全。

    • StringBuffer: 线程安全。 每一个操作方法都加了同步锁 (synchronized)。

    • StringBuilder: 线程不安全。

  • 性能:

    • String: 性能最低、因为每次修改都会创建新对象。这在频繁修改字符串的场景下会带来性能问题、尤其是在字符串拼接操作较多时。

    • StringBuffer: 性能中等 加了同步锁

    • StringBuilder: 性能最高。在没有线程安全需求的情况下、StringBuilder 性能最佳。它没有锁机制、因此在频繁修改字符串时、StringBuilder 是最优选择。

  • 存储位置:

    • String: 字符串常量池。

    • StringBufferStringBuilder: 堆内存。

  • 继承关系:

    • StringBufferStringBuilder 都继承自 AbstractStringBuilder 抽象类。

    • AbstractStringBuilder 内部使用可变的 byte[]char[] 数组来存储字符串内容。

  • 适用场景:

    • String: 适用于不需要修改内容的字符串场景,尤其是在常量字符串的处理上

    • StringBuffer: 多线程环境下频繁修改字符串如拼接字符串、使用它能够保证线程安全

    • StringBuilder: 单线程环境下频繁修改字符串。如字符串拼接、它在性能上优于 StringBuffer。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值