Java String的不可变性

        String是Java中比较特殊的一个类,特殊就在于它具有不可变性。这篇文章主要讲Sring的不可变性的具体体现、实现原理、原因以及与之相关的编程实践。

1.不可变性与可变性

         有不可变性那么肯定就有可变性,这两者有什么的区别呢?我们先不讲Java,而从C语言的字符串讲起更容易理解,因为用C语言可以使用“纯”的代码,而掩盖JVM底层实现的复杂性,我相信大多数理工科毕业的码农都学过C语言。我们先用C语言写两段简单的代码来实现字符串的截取(不考虑过多的代码健壮性),以谈论可变与不可变的区别。

方法1:

#include <stdio.h>
#include <string.h>
#include <stdlib.h>

char *substring0(char *sou_str, unsigned int begin_index) {
    unsigned int index = 0;
    do {
       sou_str[index++] = sou_str[begin_index++];
    } while (sou_str[begin_index]);
    sou_str[index] = '\0';

    return sou_str;
}


int main() {
    char sou_str[] = "ABCDEFG";

    printf("sou_str:%s\n", sou_str);
    printf("-----------------\n");

    char *sub_str = substring0(sou_str, 2);
    printf("sub_str:%s\n", sub_str);
    printf("sou_str:%s\n", sou_str);

    return 0;

}

输出结果:

sou_str:ABCDEFG
-----------------
sub_str:CDEFG
sou_str:CDEFG

在C语言中字符串的实现是很简单的,它内存中存在两个部分:

  1. 一部分是一个字符数组,是在内存中的一段连续空间,里面存放的就是字符串的内容(这里就是字符ASCII码),只是数组的最后的一位会追加一个 ‘\0’ 来表示字符串的结束,它存在与程序的rodata段;
  2. 另一部分就是字符数组变量(sou_str),这个变量实者是一个字符指针常量,指向上面存放字符的内存的首地址,它位于程序运行时栈里面,它是我们去操作字符串的入口。

     (这段代码存在把rodata段的数组拷贝到栈里的过程,但是这个跟我们的问题无关,可以忽略)

上述代码图解如下:

 这个代码很简单,但是这个代码里面我需要注意的是:

  1. 截取字符串的操作是在存放原始字符串的内存中进行的,sou_str所指向的内存区域的内容变成截取后的字符串
  2. 截取后,sou_str与sub_str指向是内存中的同一片区域

 接下来我们把这段代码稍微改一下就能看出可变与不可变的区别了。

方法2:

#include <stdio.h>
#include <string.h>
#include <stdlib.h>

char *substring1(char *sou_str, unsigned int begin_index) {
    unsigned int str_len = strlen(sou_str);
    char *buff = malloc(str_len);

    unsigned int index = 0;
    do {
        buff[index++] = sou_str[begin_index++];
    } while (sou_str[begin_index]);
    buff[index] = '\0';

    return buff;
}


int main() {
    char sou_str[] = "ABCDEFG";

    printf("sou_str:%s\n", sou_str);
    printf("-----------------\n");

    char *sub_str = substring1(sou_str, 2);
    printf("sub_str:%s\n", sub_str);
    printf("sou_str:%s\n", sou_str);

    free(sub_str);

    return 0;
}

输出结果:

sou_str:ABCDEFG
-----------------
sub_str:CDEFG
sou_str:ABCDEFG

        这段代码跟之前的最大的区别就在于它用macllo函数重新申请了一段内存来存在截取后的字符串,这样存储原始字符串的字符数组里面的数据并没有发生变化。

  对于这个代码:

  1. 对于sou_str所指向的字符数组内容没有发生变化
  2. 对于sub_str所指向的是新申请的内存,里面存放的是截取后的字符串

        老铁们看出这两个方法的区别了吗?实际上就是截取时会不会改变原始字符数组里的值。第一种改了就是可变的,第二种没有改变就是不可变的,就是这么简单。        

        扯了这么多这个跟Java有什么关系呢?这两种实现方式最大区别就在于是否去修改原始字符数组的内容,后一种方法没有修改原始的内容,Java String中substring方法也是采用的这种方法。

2.不可变实现原理

        String是怎样实现不可变性的呢?这个问题引申一下就是如何实现一个不可变类,不可变类就是类被实例化后,它所有字段的值是不能被改变的。最简单的不可变类就是没得字段的类,它一定是不可变的,但是对于有字段的类怎么实现不可变性呢,我们就用String为例子来讲一讲。String的不可变主要靠字段value来实现的,字符串就是存在这个字符数组里面,这里与C语言是类似的。        

        对象的字段在内存中存在两部分,一部分是引用(reference),一部分是对象(object),诸如有一个类有一个person字段:

Person person = new Person();

这种代码,前面的person是引用,它位于栈内存中;而后面的new PerSon()是一个类实例后的对象,它位于堆内存中。Java中的引用与对象就好比C语言中指针与指针所指向的内存区域,比如上面的C语言中,sou_str相当于引用,而存放字符串的数组就是对象(其实用结构体指针与结构体变量来类比更贴切,因为C语言的结构体是复合数据类型,跟Java类更贴近)。要保证字段不被改变就要保证字段的这两部分都不被改变。首先要保证引用变量不被改变使用 final 关键字就可以了,比如String中的value:

private final char value[];

        而要实现对象不被改变的一种方法就是使用CopyOnWrite,就是当要去修改内存的内容时,不要直接去修改原始的内存,而是申请一片新内存,把原始的内容复制后,再在新申请的内存里面修改,而保持原始内存不变。就像上面C语言的第二种方法,Java String也是使用这个方法,在String中所有要改变字符串内容的方法都是使用的这种思路。比如substring的方法主要实现代码如下:

new String(value, beginIndex, subLen);
.
.
this.value = Arrays.copyOfRange(value, offset, offset+count);
.
.
char[] copy = new char[newLength];
System.arraycopy(original, from, copy, 0,
                         Math.min(original.length - from, newLength));

虽然最后的java.lang.System#arraycopy方法是native的,但是我们知道这个是用来实现内存复制的。我们可以看到Java实现字符截取的方式跟上面C语言第二种思路是一样的,新建了一个char数组来存储截取后的字符串,使用内存复制来实现具体的截取。这样原始的字符串数组内容就没有被改变。

        为了保证不变性还需要注意一点就是当需要把字段引用暴露出去的时候也需要使用copy,而不是直接暴露原始的引用,这样可以避免通过暴露的引用改变对应的对象内容。比如String类的toCharArray方法是返回String对象的字符数组,它实现如下:

    public char[] toCharArray() {
   
        char result[] = new char[value.length];
        System.arraycopy(value, 0, result, 0, value.length);
        return result;
    }

      String的不可变性具体讲就是每个String的实例会包含一个名为value的字符数组实例,一旦一个String类被实例后,这个value是不会被改变的,即具有不可变性(immutability)。String还包含一个hash字段,它的值取决于value的内容,value不变hash也不会改变的,所以String是一个不可变类。String还有其他几个格外的字段如下:

private static final long serialVersionUID = -6849794470754667710L;

private static final ObjectStreamField[] serialPersistentFields =
        new ObjectStreamField[0];

public static final Comparator<String> CASE_INSENSITIVE_ORDER
                                         = new CaseInsensitiveComparator();

他们都是 static  final修饰的常量,都是不可变的。

3.不可变的意义

        为什么String需要设计成不可变的呢?在Java中为节省内存以及内存分配回收的时间,设计一个特殊内存区域即字符串常量池。比如如下代码:

 String str0 = "aa";
 String str1 = "aa";
 System.out.println(str0 == str1);

这段代码的执行结果为true,说明str0和str1这两个引用指向的是同一个String对象。在Java中对于像“aa”这种直接使用双引号声明出来的字符串字面量,JVM为其实例化一个String对象,并且把它放到字符串常量池里面,当同样的字符串字面量出现时就不会生成新的String对象了,而是直接使用字符串常量池里面的对象。所以上面的代码str0与str1是引用的同一个对象。新建String还有一种方法如下:

 String str0 = "aa";
 String str2 = new String("aa");
 System.out.println(str0 == str2);

这段代码的执行结果为false,使用new创建String实例时会新建一个实例,而不会使用字符串常量池里面的实例,但是str0与str2的value引用指向的是同一个字符数组对象。观察如下的代码:

 Field field = String.class.getDeclaredField("value");
 field.setAccessible(true);

 String str0 = "aa";
 String str2 = new String("aa");

 System.out.println(str0 == str2);

 char[] value0 = (char[]) field.get(str0);
 char[] value2 = (char[]) field.get(str2);
 System.out.println(value0 == value2);

这个代码的结果是false true,说明str0和str2是两个独立的对象,但是value字段是公用的。这段代码在内存图解如下:

 

        使用公用内存能降低内存使用以及内存申请与释放时间,但是会引来新的问题。一方面是引用间的相互干扰,通过一个String引用修改了String对象的内容,那么与之共享对象的引用变量所指向的内容也会变化,这样会相互冲突。另一方就是线程安全问题,字符串常量池可能会导致多个线程共享同一个String对象,而线程间共享内存会导致潜在线程安全问题。诸如如下代码:

        Thread thread0 = new Thread(() -> {
            String str = "aa";
            System.out.println(str);
        });

        Thread thread1 = new Thread(() -> {
            String str = "aa";
            System.out.println(str);
        });        

这两个线程之间看起来没有任何联系,但是由于字符串常量池的存在,两个线程里面的str引用其实是指向的同一个String对象,这两个线程就存在共享内存,线程间共享内容就会存在潜在的线程安全问题。对于这个问题可以引用《Java.Concurrency.in.Practice》一书的原文:

If multiple threads access the same mutable state variable without appropriate synchronization, 
your program is broken. There are three ways to fix it:
• Don't share the state variable across threads;
Make the state variable immutable; or
• Use synchronization whenever accessing the state variable.

从第一句话中,我们可以看出线程安全问题主要产生的因素有:

  • 多线程(multiple threads)
  • 共享变量(the same )
  • 可变性(mutable state variable)

总结起来也就是说当多个线程同时去访问(读写)可变的共享变量的时候,就会存在线程安全风险,具体就是原子性,有序性与可见性问题。要规避线程安全问题也应该从这三个方面着手,所以它又说了要规避线程安全风险有三个方法:

  • 不要在线程间共享变量
  • 让变量不可变
  • 使用同步让线程序列化地访问变量

这里我们要着重讨论的就是第二点:不可变性。 这里的状态变量(state variable)从面向对象编程的角度来理解就是一个对象里面所包装的数据,也就是它所包含的字段(实例字段和静态字段)。一个类要能够被多个线程安全访问,可以把这个类的状态设计成不可变的。对于一段数据而言或者说一段内存,不可变就等效于是只读的,多个线程同时去读一段内存,肯定是没有问题的,也就是线程安全的(Immutable objects are always thread‐safe.)。而String正是采用这种设计原则,使共享变量不可变来达到线程安全。不可变类不会改变共享状态的初始值,也就一定是线程安全的。

        但是每一种技术都有利弊,使用字符串常量池可以减少内存使用,但是为了规避线程安全问题就要使用不可变性,但是每次去修改String都要申请新的内存来存储新的String对象,这里其实又增加了内存的使用,所以如果代码要大量的地修改字符串就不要使用String,而是应该使用StringBuffer或者StringBuilder。

3.intern

        String类还有一个有用且值得讨论的问题就是intern()方法,这个方法可以用来节省内存。对于这个问题详细讨论可以参考这篇文章 :深入解析String#intern - 美团技术团队

4.后记

        最后我想说一点,以前学习String不可变性的时候在网上也看了很多文章,很多文章都以这样的代码来说明String的不可变性:

        String s0 = "123456";
        String s1 = s0;
        System.out.println(s0 == s1);
        System.out.println("s0:" + s0);
        System.out.println("s1:" + s1);

        s0 = "abcdef";
        System.out.println(s0 == s1);
        System.out.println("s0:" + s0);
        System.out.println("s1:" + s1);
        

我曾经还遇到过这样的面试题。但是我觉得这段代码只能体现字符串常量池的存在,而不能体现String的不变性。因为String类的不变性是String实例化后的对象是不可变的,而不是String引用不可变,而这段代码本来就没有去改变String对象的内容,并不能体现String不可变的特殊性。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值