由“String不可变”引发的一些思考

字符串常量池

​ JVM为了字符串的服用,减少字符串对象的重复创建,特别维护了一个常量池。

​ jdk1.7之前的版本,常量池存放在方法区,方法区和JAVA堆一样,是各个线程共享的内存区域,用于存储已经被虚拟机加载的类信息、常量、静态常量、JIT编译后的代码等。在java虚拟机规范中将方法区描述为堆的一个逻辑部分,也被叫做Non-Heap(非堆)。经常遇到的一个错误:java.lang.OutOfMemoryError: PermGen space,这里的PermGenspace也就是我们经常提到的永久代,方法区可以理解为java虚拟机的规范,而永久代则看作是规范的一种实现。而在jdk1.7中,常量池从永久代移到到了堆区,为移除永久代工作做准备。于是我们看到的常量池内存溢出变成了java.lang.OutOfMemoryError: Java heap space。

​ 到了jdk1.8,永久代彻底移除,元空间(Meta space)出现,可以看作是对JVM规范中方法区的另一种实现。不过元空间与永久代之间最大的区别在于:元空间并不在JVM中,而是使用本地内存。因此,默认情况下,元空间的大小仅受本地内存限制,把类的元数据扔到了元空间,而常量池仍然放在java heap中。

​ 元空间取代永久代可能是因为,字符串存于永久代,占用堆区内存,太小可能导致永久代溢出,太大导致老年代溢出,为GC也带来不必要的复杂度,并且回收效率也偏低。当然以上内容仅针对HotSpot 。

String

        String str1 = "123";
        String str2 = new String("123");
        System.out.println(str1 == str2);
        System.out.println(str1.equals(str2));

​ 首先是String的两种不同的初始化写法。第一种字面量形式的写法,会直接在字符串常量池中查找是否存在值 123,若存在直接返回这个值的引用,若不存在创建一个字符串123存入字符串常量池中。而使用 new 关键字,则会直接在堆上生成一个新的 String 对象,并不会理会常量池中是否有这个值。所以本质上 str1 和 str2 指向的内存地址是不一样的,str1 指向常量池,str2指向堆,而对于“==”比较的是地址,所以第一句是返回false,而第二句equals比较值(对于没有复写equals的对象,equals仍然比较的是地址),当然是返回true。

​ String对象不可变是因为它用了final修饰。在同一个方法内,当final修饰一个基本数据类型时,表示该基本数据类型的值在初始化后便不能发生变化;如果final修饰一个引用类型,则对其初始化之后便不能再改变值,但栈上的引用可以改变。

​ 上栗子1:

        String s = "123";
        System.out.println("s = " + s);
        s = "456";
        System.out.println("s = " + s);

​ 打印结果为

​ s = 123
​ s = 456

​ s的值变了,那123这个对象改变了吗,其实“123”这个对象还是存在,只不过生成了一个新的”456“对象,并把这个s指向了“456”,如下图所示。

1

​ 在进一步讨论讨论String的不可变性之前,看下面的一个栗子:

    public static void main(String args[]) {
        String str = "hello";
        change(str);
        System.out.println(str);      
    }
    private static void change(String str) {
        str = "word";
    }

    输出结果:
        hello

​ 为什么这个执行结果是hello,而不像上一个栗子,str指向了新的对象“word”呢?这个涉及到java函数调用时的参数传递策略,java到底是值传递还是引用传递。

​ 先理解几个基础概念:

形参:用来接收调用该方法时传递的参数。只有在被调用的时候才分配内存空间,一旦调用结束,就释放内存空间。因此仅仅在方法内有效,也就是change函数里面的这个str。

实参:传递给被调用方法的值,预先创建并赋予确定值,对应main函数里面的str。

值传递:方法调用时,实参把它的副本传递给对应的形参。

引用传递:方法调用时,将实参的引用地址直接传给形参,函数接收到原参数的内存地址。

值传递和引用传递都是属于函数调用时参数的求值策略(Evaluation Strategy),这是对调用函数时,求值和传值的方式的描述,而非传递的内容的类型,引用类型和引用传递是不同方法的概念,一个描述内存分配方式,一个描述参数求值策略。

对于引用传递,在函数中可以改变原始对象,这里的改变是指将一个变量指向另一个对象,就像第一个栗子中呈现的那样,而不是仅仅是改变属性或者成员变量之类的,所以Java是值传递。

​ 而这些行为与参数类型是基本类型还是引用类型无关。对于值传递来说,无论参数是基本类型还是引用类型,都会在栈上创建一个副本,但有一点不同,对于值类型,这个副本是整个原始值的复制,而对于引用类型来说,由于对象实例在堆上,复制的只是在栈上的它的一个引用值。

​ 上例代码中,str传入change()函数时,形参str只是原引用str的一个引用副本,它们同时指向了堆中的“hello”对象,也就是同时存在两个不同的str。当执行形参str = “word”;时,在内存再创建了一个“word”对象,并把形参str引用指向了这个新创建的对象,但是main函数里面的str的地址并没有改变。

2

​ 那么再来看这段代码:

    public static void main(String args[]) {
        final char[] ch = {'h', 'e', 'l', 'l', 'o'};
        change(ch);
        System.out.println(ch);
    }
    private static void change(char[] ch) {
        ch[1] = 'a';
    }

	输出结果:
		hallo

​ 因为是值传递,所以引用不可变,但是可以改变对象的属性或者成员变量。那怎么改变对象的属性或者成员变量呢?比如上述的数组,直接改变ch[i]的值,要么对象自身提供改变的方法,比如StringBuilder的append方法:

    public static void main(String args[]) {
        StringBuilder sb = new StringBuilder("hello");
        change(sb);
        System.out.println(sb.toString());
    }
    private static void change(StringBuilder sb) {
        sb.append(" world");
    }

    输出结果:
        hello world

String对象不可变

​ 在Effective Java中对不可变类的设计提出了以下五点建议:

  1. 不要提供任何会修改对象状态的方法。
  2. 保证类不会被扩展。
  3. 使所有的域都是 final 的。
  4. 使所有的域都成为私有的。
  5. 确保对于任何可变性组件的互斥访问。

​ 我们再结合jdk源码(1.8)就可以知道为什么String被称为一个不可变类了,除了在类上标识final保证类不会被扩展,其所有的域都是final且私有的,也不提供改变对象状态的方法(比如setter等)。

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

    /** Cache the hash code for the string */
    private int hash; // Default to 0

    /** use serialVersionUID from JDK 1.0.2 for interoperability */
    private static final long serialVersionUID = -6849794470754667710L;

    /**
     * Class String is special cased within the Serialization Stream Protocol.
     *
     * A String instance is written into an ObjectOutputStream according to
     * <a href="{@docRoot}/../platform/serialization/spec/output.html">
     * Object Serialization Specification, Section 6.2, "Stream Elements"</a>
     */
    private static final ObjectStreamField[] serialPersistentFields =
        new ObjectStreamField[0];

​ 那么String真的一定不可改变,发现String的值是用一个char[]数组存储的,结合我们上面提到的栗子是不是可以做到改变呢?

改变String

​ 计算机科学领域的任何问题都可以通过增加一个中间层来解决,那么对于java,通过反射往往可以做到一些普通方法做不到的事。通过反射获取这个value[]数组,然后改变它不就可以了吗。

    public static void main(String args[]) throws Exception {
        String str = "hello";
        change(str);
        System.out.println(str);
    }
    private static void change(String str) throws Exception {
        // 1.获取域
        Field field = String.class.getDeclaredField("value");
        // 2.value是私有域,设置为可访问
        field.setAccessible(true);
        // 3.找到对应值
        char[] value = (char[]) field.get(str);
        value[1] = 'a';
    }

	输出结果:
		hallo

​ 很显然,通过反射可以破坏 String 的不可变性。

总结

​ Java的传参方式是值传递,这个是函数调用时参数的求值策略,与参数是基本类型还是引用类型无关。另外,不可变类的设计在使用过程中不容易出错,天生的线程安全,当然也应该避免不可变设计的滥用。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值