深入浅出String,带你走进String的内心世界

本文深入探讨 Java 中 String 类型的特点,包括其不可变性和不可被继承性,以及如何利用 intern() 方法优化内存使用。通过多个示例代码,帮助读者理解 String 的内部运作机制。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

大家好,今天给大家分享一下String类型的相关知识

 

一、背景介绍

String作为一种最常用的引用类型,我们已经用的滚瓜烂熟了,但是它具体有哪些特点,你真的了解吗?

首先问自己:

什么是String?它有什么特点?

它和int这些数据类型有什么区别?

和其他具体对象又有什么区别?

如果你连这个也答不上来,那可能需要先去熟悉一下基本概念了,如果你知道,俺么继续往下看吧。

二、知识刨析

String实质是字符数组,其具有两个基本特点:

①不可变性

②不可被继承

为什么我们说String是不可变的呢?我们使用时明明记得它是可以随意组合修改啊?

我相信很多人肯定有这个疑问,那么我们首先看一个demo

 String s1="Hello";
 String s2="World";
 s1="Hello"+"World";
 System.out.println(s1);

输出结果大家肯定知道是:HelloWorld

在demo中,看起来好像s1的值改变了,但是实际上,变的并不是String,而是其一个引用的指向

String s1="Hello";这段代码中,s1只是一个地址的引用,我们将其声明成String类型,代表它的指向只能是String类型的数据,不能指向其他,否则就会在编译期报错。但实际上String的具体内容是“Hello"。

所以我们执行String s1=s1+s2这段代码时,实际上是将s1和s2组合起来,创建了一个新字符数组,然后将其内存地址赋值给s1,所以改变的并不是String,而是s1这个引用的地址。

如果你知道String其实是一个引用类型,那你应该很容易理解这个概念,即变的不是String的值而是s1这个引用的值。

要深究其原理我们可以看一下源码。

看源码的guo'cheng我们重点关注的应该是修饰词,构造函数,contact方法

首先我们看一下其修饰词

public final class String
    implements java.io.Serializable, Comparable<String>, CharSequence {
    /** The value is used for character storage. */
    private final char value[];

我们可以看到这个类是被修饰为final的,我们知道被final修饰的类是无法被继承的,这也是我们之前为什么说String无法被继承的原因,而其值是一个char数组,修饰词也是final,代表这个值不能被改变

 public String(char value[]) {
        this.value = Arrays.copyOf(value, value.length);
    }

然后我们再来看一下构造函数

 public String(char value[]) {
        this.value = Arrays.copyOf(value, value.length);
    }

我们可以看到其构造函数是将一个字符数组的值copy进一个新的数组,然后将其赋值给String的value,并不是将其地址直接给value,这会产生什么区别呢?我们可以看下面的demo

//从构造器角度来解释复制字符集
        char a0='H';
        char a1='e';
        char a2='l';
        char a3='l';
        char a4='o';
        char a5='y';
       char s[]={a0,a1,a2,a3,a4};

       String s3=new String(s);
        System.out.println(s3);

        s[4]=a5;
        System.out.println(s3);

输出结果:Hello ;Hello

这就是因为String的不可变性,如果是将char[]的地址直接给value,那么当s[]改变的时候,s3的值也会跟着改变,因为他们共用一个地址所以一个改变另外一个也会改变。从这里我们就能看出直接赋值指向同一空间和复制指向不同空间的区别就是前者一个改变两个都变,后者一个改变另外一个不受影响

但是从构造方法我们可以知道,并不会这样,因为其并不是使用同一内存地址而是使用不同地址,这也是String不可变性的一大保证。

然后是contact方法,这个方法其实我们一直都在使用,就是我们使用的"+"连接符

        String s1="Hello";
        String s2="World";
        String s3="Hello"+"World";
        System.out.println(s3);
        //s1拼接s2,s1.contact(s2)等价于s1+s2,我们可以看contact源码
        //从源码中可以看到其实s1原来的值并没有变,而是创建了一个新的String,
        // 将原来的String扩容,把原来的s1复制进新数组前面,然后将新的s2复制进新数组后面
        String s4=s1.concat(s2);
        System.out.println(s4);

其实这时候输出的结果都是:HelloWorld ;HelloWorld

因为其作用其实是一样的,我们看其源码

 public String concat(String str) {
        int otherLen = str.length();
        if (otherLen == 0) {
            return this;
        }
        int len = value.length;
        char buf[] = Arrays.copyOf(value, len + otherLen);
        str.getChars(buf, len);
        return new String(buf, true);
    }

我们看到它是先创建一个新的字符串组buf[],然后将s1的值放进去,其长度就是s1和s2的长度之和,然后再将s2放在buf[]中s1后面,最后,再new一个String,将buf放进去返回。

所以,String并没有改变,因为现在的s1指向的并不是之前的s1指向,而是新建了一个String,指向了新的String。这也是String不可变性的保证之一。

三、常见问题

String的内存储存是怎么样的?

String里面的intern()方法有什么用?

四、解决方案

为了解决第一个问题我们首先看demo

        String s="HelloWorld";
        String s0="HelloWorld";
        String s1="Hello";
        String s2="World";
        String s3=s1+s2;
        String s4="Hello"+"World";
        System.out.println(s==s0);
        //有什么办法可以让s==s3输出结果为true呢
        System.out.println(s==s3);
        System.out.println(s.equals(s3));
        System.out.println(s==s4);
   

输出答案:true false true true

那么问题来了,为什么答案会是true,false,true,true呢?

首先我们需要知道==和equals()的区别:

==比较的是其左右两个变量的字面值,如果是引用就比较引用地址的值,如果是主数据类型就比较其具体值;

equals()则是比较其变量最终指向的内容,

弄清楚这个,那么第三个为什么是true我们也就知道了,因为其最终指向的值都是一样的。

要弄清楚第一个为什么是true,我们得知道:

JVM为了提高性能和减少内存开销,在实例化字符串常量的时候进行了一些优化。为了减少在JVM中创建的字符串的数量,字符串类维护了一个字符串常量池,其主要用来存储编译器生成的各种字面量和符号引用。

常量池中的“对象”是在编译期就确定好了的,在类被加载的时候创建的,如果类加载时,该字符串常量在常量池中已经有了,那这一步就省略了,不用创建,直接指向同一个地址就可以,所以第一个是true。因为s0会指向s已经创建的内存地址。

需要注意的是,其在类加载时,会加载进常量池的都是确定的常量,即我一眼就知道答案是什么的才会被加载进去,所以String s3=s1+s2;这个式子,只有在运行时,进行到这一步才会得到结果。因为JVM并不知道你s1在加载时到底是指啥,s1被指向内存常量池的”Hello“这一步操作是在运行时才会产生,所以也就不会本末倒置将s3=s1+s2在加载时就确定值,它是在运行时创建一个对象,所以也就不存在去常量池中寻找有没有这个常量的存在,自然,s==s3答案就是false了。

同理,我一眼就能看出”Hello“+”World"的值,因此JVM也能一眼看出来,所以s4就会被加载进常量池,因为常量池中已经存在“HelloWorld"了,所以自然第四个答案就是true,因为它们是指向同一个内存空间的。

至于注释里面怎么讲第2个变为true大家可以自己思考下,写在评论区里。

这时有人就会问了,那么这个字符串常量池是不是在过了编译期之后里面就不能新增值了呢?注意我前面的概念中说的是”主要“,有主要自然就有次要,就有其他方法。

而这个intern()方法就是其他!

在这之前,我们先看demo

       //首先需要知道下面这两句话各定义了几个对象
        String s1=new String("HelloWord");
        String s2=new String("HelloWord");
        String s3="HelloWord";
        String s4=new String("HelloWord").intern();
        System.out.println(s1==s2);
        System.out.println(s1==s3);
        System.out.println(s3==s4);

输出结果:false  false true

同样是new String ,为什么s3==s4是true,s1==s3却是false呢?

这是因为new操作是在运行时在堆上建立一个对象,将s1指向这个对象,然后这个对象指向常量池中一开始就加载好的”HelloWorld"。

所以其实s1和s2存储的是值是堆中对象的地址,因此第一个操作实际上创建了两个对象,虽然并不是同时创建的,而第二个操作则只创建了一个对象,因为第一个操作已经在常量池创建了对象,它拿来用就好。

所以此时实际上s1和s2指向的是堆中对象的值,因此s1==s2是false。

而intern()被String实例调用时则会去常量池中寻找是否有一样的,如果有,就直接返回地址,如果没有就创建一个然后返回,所以它是会直接返回常量池中的地址,并将其赋值给s4,而且,其可以做到在运行期扩充常量池.

虽然new String也是去常量池中寻找,如果有就返回,但是这个返回是给堆中对象,这就是二者的最大区别。

因此s3==s4是true,但是s1==s4即s1==s3就是false

为了加深印象,可以看一下这个demo

        String s1 = "Hello";
        String s2 = "World";
        String s3 = new String(s1 + s2).intern();
        System.out.println(s3);
//        String s4 = new String("HelloWorld").intern();
        String s5=s1+s2;
        System.out.println(s5);
        String s6=new String(s1+s2).intern();
//        System.out.println(s3==s4);
        System.out.println(s3==s5);
        System.out.println(s3==s6);

输出结果:HelloWorld    HelloWorld   false   true

此例第二个true就说明了调用intern方法后,其指向地址就是常量区

因为如果是指向堆区,其结果应该是false才对。

五、拓展思考

如果说String是不可变的,是不是我们不应该对其经常修改?

是的,每一次就该都会新建一个String出来,频繁操作不仅开销大,还会造成内存溢出。

那如果要经常修改应该怎么办呢?

我们可以使用StringBuffer和StringBuilder.

StringBuffer和StringBuilder有什么区别?

 

其都是继承AbstractStringBuilder类,区别就是前者的方法上都加了synchronized,而后者没有,所以前者是线程安全的,后者不是。在速度上多少也会有点区别,可以看一个demo

 long t1=System.currentTimeMillis();
        for(int i=0;i<100000;i++){
            String s1="string"+i;
        }
        long t2=System.currentTimeMillis();
        for (int i=0;i<100000;i++){
            StringBuffer s=new StringBuffer("buffer");
            StringBuffer s2=s.append(i);
        }
        long t3=System.currentTimeMillis();
        for (int i=0;i<100000;i++){
            StringBuilder s=new StringBuilder("builder");
            StringBuilder s3=s.append(i);
        }
        long t4=System.currentTimeMillis();
        System.out.println(t2-t1);
        System.out.println(t3-t2);
        System.out.println(t4-t3);

输出结果如下

可以看到,String是最慢的,StringBuffer因为线程安全所以比StringBuilder要慢一些,但是会比String快一些。

所以根据你项目的具体要求,可以选择不同的引用类型。

六、参考文献

https://blog.youkuaiyun.com/xiabing082/article/details/49759071

https://www.cnblogs.com/think-in-java/p/6127804.html

https://blog.youkuaiyun.com/u010001192/article/details/45460971

 

今天的分享就到这了,大家有什么问题可以评论里留言,觉得不错也可以点个赞哦~

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值