震惊,99.9% 的同学没有真正理解字符串的不可变性

Posted 明明如月学长

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了震惊,99.9% 的同学没有真正理解字符串的不可变性相关的知识,希望对你有一定的参考价值。

一、你以为的常识

1.1 不可变性的理解

稍有些基础的同学都知道 Java 中 String 字符串是“不可变”的,想要使用“可变字符串”可以使用 StringBuilderStringBuffer

大多数讲字符串不可变性的文章大同小异。

不可变的定义:

An immutable object is an object whose internal state remains constant after it has been entirely created. This means that once the object has been assigned to a variable, we can neither update the reference nor mutate the internal state by any means.
《Why String is Immutable in Java?》
所谓不可变对象,即对象创建之后内部状态保持不变。换句话说,一旦对象被赋值给一个变量,将不再允许通过任何方式改变引用、修改内部状态。

1.2 不可变性的实现

String “不可变性”的保障:

  • (1) String 类被 final ,导致不继承;
  • (2) 存储 String 的字符的 char 数组为 final 则引用不可改变。
  • (3) 所有修改方法(如 concat)都会返回一个新的字符串对象。
public final class String
    implements java.io.Serializable, Comparable<String>, CharSequence 
    /** The value is used for character storage. */
    private final char value[];

//省略其他

  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);
    


1.3 不可变性的好处

1.3.1 节省内存

由于字符串的不可变性,不同的字符串变量可以引用同一个示例来实现节省堆内存的目的。

String s1 = "明明如月学长";
String s2 = "明明如月学长");
String s3 = new String("明明如月学长");
assertThat(s1 == s2).isTrue();
assertThat(s1 == s3).isFalse();

1.3.2 更安全

字符串的不可变性保证了安全性。
请看下面的示例代码,先执行参数的合法性检查,然后执行一些次要的认为,最后执行重要的任务:

void criticalMethod(String userName) 
    //1 执行安全检查
    if (!isAlphaNumeric(userName)) 
        throw new SecurityException(); 
    
	
    //2 执行一些次要的任务
    initializeDatabase();
	
    //3 重要的任务
    connection.executeUpdate("UPDATE Customers SET Status = 'Active' " +
      " WHERE UserName = '" + userName + "'");

假如字符串可变,在第一步安全检查通过后字符串发生修改,代码运行可能出现不符合预期的结果,比如造成 SQL 注入等。

字符串的不可变性也保证了多线程访问时的现成安全性。

1.3.3 hashCode 缓存

大家可以看到 String 的 hashCode 的计算和构成字符串的字符有关,由于 String 的不可变性就可以将 hashCode 缓存起来。源码中也可以看出计算过之后,下次调用 hashCode 直接返回。

 /**
     * Returns a hash code for this string. The hash code for a
     * @code String object is computed as
     * <blockquote><pre>
     * s[0]*31^(n-1) + s[1]*31^(n-2) + ... + s[n-1]
     * </pre></blockquote>
     * using @code int arithmetic, where @code s[i] is the
     * <i>i</i>th character of the string, @code n is the length of
     * the string, and @code ^ indicates exponentiation.
     * (The hash value of the empty string is zero.)
     *
     * @return  a hash code value for this object.
     */
    public int hashCode() 
        int h = hash;
        if (h == 0 && value.length > 0) 
            char val[] = value;

            for (int i = 0; i < value.length; i++) 
                h = 31 * h + val[i];
            
            hash = h;
        
        return h;
    

二、怀疑人生

2.1 质疑

不知道你是否真正认真思考过,字符串真的不可变吗?
即使字符串类用 final 修饰,字符串值字符数组也用 final 修饰,所有修改方法都返回新的字符串对象,那么值一定无法修改吗?
答案是否定的!!
我们可以用反射来修改字符串对象的值。

2.2 验证


    public static void main(String[] args) throws NoSuchFieldException, IllegalAccessException 
        String name ="明明如月学长 admin";
        System.out.println("name 修改前:"+name+", hashCode:"+name.hashCode());
        String newName = "明明如月学长";
        System.out.println("newName:"+newName+", hashCode:"+newName.hashCode());
        replace(name,newName);
        System.out.println("name 修改后: "+name+", hashCode:"+name.hashCode());
    


    private static  void replace(String name,String newName) throws NoSuchFieldException, IllegalAccessException 
    // 去掉私有
        Field value = String.class.getDeclaredField("value");
        value.setAccessible(true);

   // 去掉 final
        Field mod = Field.class.getDeclaredField("modifiers");
        mod.setAccessible(true);
        mod.setInt(value, value.getModifiers() & ~Modifier.FINAL);

       // 直接替换 value 字符数组
        value.set(name, newName.toCharArray());
    

输出结果:

name 修改前:明明如月学长 admin, hashCode:557981902
newName:明明如月学长, hashCode:-292262689
name 修改后: 明明如月学长, hashCode:557981902

2.3 带来的问题

如果字符串的值可以修改,程序就可能出现不符合预期的行为,

import java.lang.reflect.Field;
import java.lang.reflect.Modifier;
import java.util.concurrent.TimeUnit;

public class StringDemo 

    public static void main(String[] args) throws InterruptedException 

        String name ="明明如月学长 admin";

        new Thread(()->
            try 
                mockExecute(name);
             catch (InterruptedException e) 
                e.printStackTrace();
            
        ).start();
        TimeUnit.SECONDS.sleep(2);
        new Thread(()->
            try 
                mockInject(name);
             catch (Exception e) 
                e.printStackTrace();
            
        ).start();
        TimeUnit.SECONDS.sleep(10);


    

private  static synchronized   void mockInject(String name) throws InterruptedException, NoSuchFieldException, IllegalAccessException 
        // sleep 让 子线程执行到 wait
        TimeUnit.SECONDS.sleep(1);

        // 检查前使用合规的名称
        System.out.println("mockInject: 去掉 admin,绕过校验");
        replace(name,"明明如月学长");
        StringDemo.class.notifyAll();
        System.out.println("mockInject: 恢复 admin");
        // 检查后换成不合规的名称
        replace(name,"明明如月学长 admin");
        System.out.println("mockInject: 恢复 admin 完毕");
        StringDemo.class.notifyAll();
    

private static  void replace(String name,String newName) throws NoSuchFieldException, IllegalAccessException 
        Field value = String.class.getDeclaredField("value");
        value.setAccessible(true);

        Field mod = Field.class.getDeclaredField("modifiers");
        mod.setAccessible(true);
        mod.setInt(value, value.getModifiers() & ~Modifier.FINAL);
        value.set(name, newName.toCharArray());

    

private static synchronized  void mockExecute(String name) throws InterruptedException 
        System.out.println("mockExecute: [1] name.contains(\\"admin\\"):"+name.contains("admin"));
        StringDemo.class.wait();
        //1 参数检查
        if(name.contains("admin"))
            throw new IllegalArgumentException("参数检查失败");
        
        System.out.println("mockExecute: 不含 admin 关键字,参数检查通过");
        System.out.println("mockExecute: [2] name.contains(\\"admin\\"):"+name.contains("admin"));
        //2 执行次要任务
        System.out.println("mockExecute: 执行次要任务");

        //3 执行重要人物
        System.out.println("mockExecute: 执行重要任务");
    





这里简单使用 wait/notify 来模拟多线程情况下字符串修改带来的问题。

输出的结果:

mockExecute: [1] name.contains(“admin”):true
mockInject: 去掉 admin,绕过校验
mockInject: 恢复 admin
mockInject: 恢复 admin 完毕
mockExecute: 不含 admin 关键字,参数检查通过
mockExecute: [2] name.contains(“admin”):false
mockExecute: 执行次要任务
mockExecute: 执行重要任务

三、总结

字符串的不可变性是指通过 String 的方法来修改字符串都会产出新的字符串对象。但并非指字符串的字符一定无法被修改,我们可以通过反射一样可以对字符串的“状态/值” 进行修改。

正常情况下不会有人去这么做,否则会产出很多不出乎意料的 BUG。

通过本文想提醒大家,尽信书不如无书,对于看到的知识要有自己的思考。


创作不易,如果本文对你有帮助,欢迎点赞、收藏加关注,你的支持和鼓励,是我创作的最大动力。

以上是关于震惊,99.9% 的同学没有真正理解字符串的不可变性的主要内容,如果未能解决你的问题,请参考以下文章

如何理解“字符串是一组由16位组成的不可变的有序序列”

从字节码和JVM的角度解析Java核心类String的不可变特性

对String值不可变的理解以及String类型的引用传递问题

当我们创建空的不可变列表/集合/映射时,是否有任何实际的应用程序/用例

Google 作恶!99.9% 的 Android 手机 App 都在窃取隐私

编写字符串列表的不可变 F# 映射