也谈不可变对象

2023年 8月 18日 47.2k 0

前言

很久之前跟朋友聊String的不可变性,那个时候对这个问题不感兴趣,觉得这个问题的价值不高,这段时间写DDD感觉有点卡文,索性就来探索这个问题。所谓不可变性也就是指我们不可以修改这个对象,如下代码:

String  s = "hello world";
String upperCase = s.toUpperCase();
System.out.println(System.identityHashCode(s));
System.out.println(System.identityHashCode(upperCase));

输出值是不同的。我们来解释一下上述代码做了什么,首先我们String类型的引用变量s,并将其存储到栈里面,然后我们用=,表示让s存储字符串hello world的地址,我们通过引用变量s就能调用字符串对象hello world的toUpperCase方法,将字符串转为大写。然后声明一个String类型的引用变量,接收这个方法的返回值。然后我们调用输出语句打印出s和upperCase对应变量的hashCode。输出值不同说明s和upperCase指向的是不同的对象,一般我们普通的对象修改成员变量,修改的还是原本的对象,所以我们可以这么做:

Card card = new Card("test card");
System.out.println(System.identityHashCode(card));
card.setCardNumber("4444");
System.out.println(System.identityHashCode(card));

你会发现打印的都是一样的,这就代表我们修改的是引用变量card指向的对象。但是对字符串引用变量每一次调用修改的方法,我们再使用identityHashCode方法获得对象的hashCode值会发现,每次都不一样,这就是在说明对于String对象的每一次产生修改动作的时候,都会产生一个新的对象。这也就是不可变对象(immutable object)的真义。我们可以toUpperCase方法来验证我们的论断:

public String toUpperCase() {
  return toUpperCase(Locale.getDefault());
}

public String toUpperCase(Locale locale) {
        if (locale == null) {
            throw new NullPointerException();
        }

        int firstLower;
        final int len = value.length;

        /* Now check if there are any characters that need to be changed. */
        scan: {
            for (firstLower = 0 ; firstLower = Character.MIN_HIGH_SURROGATE)
                        && (c = Character.MIN_HIGH_SURROGATE &&
                (char)srcChar = Character.MIN_SUPPLEMENTARY_CODE_POINT)) {
                if (upperChar == Character.ERROR) {
                    if (localeDependent) {
                        upperCharArray =
                                ConditionalSpecialCasing.toUpperCaseCharArray(this, i, locale);
                    } else {
                        upperCharArray = Character.toUpperCaseCharArray(srcChar);
                    }
                } else if (srcCount == 2) {
                    resultOffset += Character.toChars(upperChar, result, i + resultOffset) - srcCount;
                    continue;
                } else {
                    upperCharArray = Character.toChars(upperChar);
                }

                /* Grow result if needed */
                int mapLen = upperCharArray.length;
                if (mapLen > srcCount) {
                    char[] result2 = new char[result.length + mapLen - srcCount];
                    System.arraycopy(result, 0, result2, 0, i + resultOffset);
                    result = result2;
                }
                for (int x = 0; x < mapLen; ++x) {
                    result[i + resultOffset + x] = upperCharArray[x];
                }
                resultOffset += (mapLen - srcCount);
            } else {
                result[i + resultOffset] = (char)upperChar;
            }
        }
       return new String(result, 0, len + resultOffset);
}

验证我们的结论其实只用看toUpperCase的返回语句就可以,我们在toUpperCase的return语句之中看到,又new了一个 String返回给我们。在这个方法中有个语法是我们不曾看到过的:

scan: {
     for (firstLower = 0 ; firstLower = Character.MIN_HIGH_SURROGATE)
                        && (c icsk_af_ops->syn_recv_sock(sk, skb, req, NULL);
        if (child == NULL)
                goto listen_overflow;

具体代码位置在net/ipv4/tcp_minisocks.c 中的 tcp_check_req函数,所以我们也可以说goto如果用不好是有害的,但他也可以帮我们控制一些复杂结构。这方面Go语言还是启用了goto关键字的语义,我们可以使用goto来跳转到指定的标签上,也许在将来的不久Java也会启用goto,这并不困难。 那回到最初的问题, String是如何实现不可变的,我们依然简单粗浅的看String的源码, 在源码中我们可以看到String在1.8中采取是用char数组存储的,而且加上了final修饰,这意味着char数组是固定的,但只是地址固定我们仍然可以修改字符数组中的值,但是String 没有暴露给我们修改char数组的方法,或许我们可以借助子类,重写String的修改方法,直接改底层存储的char数组,但是String又是final的,杜绝了我们继承String类,重写内部的方法来修改直接存储char数组的可能。所以String 是如何实现不可变呢,第一暴露出去的所有修改对象的方法在修改的时候,将就有的成员变量复制到新对象的成员变量最后将新的对象返回出去,第二个用final修饰class否,防止子类重写父类方法来实现对原对象的修改。String在Java中是一个相当重要的类,JVM启动以后,String对象会占用越来越多的内存,我们为每一个String对象都分配内存,这会造成一定的冗余:

String s1 = "hello world";
String s2 = "hello world";
System.out.println(s1 == s2);

对于String对象来说,我们很少用new 来创建,我们通常都是字符串字面量(String Literal)来直接创建,也就是双引号里面直接写字符,然后将String类型的引用指向字符串字面量,对于通过字符串字面量创建的String对象,JVM会首先从字符串常量池中去寻找,里面是否已经创建过了,如果已经创建过了 则不会分配内存创建这样的String对象,而是会将引用变量指向已有的String对象:

也就是说一个字符串对象在Java里面可能存在多个引用,如果我们允许String对象修改它本身,而不是不可变的,那么这些引用都会被修改,那就糟糕了。

String相关的面试题

这里又顺带想起String的几道面试题:

public static void main(String[] args) {
    String s = "hello world";
    modifyString(s);
    System.out.println(s);
}

private static void modifyString(String s) {
    s = "hello";
}

输出是hello world,在Java中大部分对象都可变的,我们可以修改这个对象本身,但是上面这个现象确是不能用不可变对象来解释,他的答案叫形式参数与实际参数。那什么是形式参数:

  • 形式参数: 如果函数要求接收参数,那么必须进行详细声明,在Java中要声明希望接收的参数类型,对应参数类型的变量,像下面这样:
private static void modifyString(String s) {}

形式参数和在函数体内声明变量相似,现在我在方法上声明了一个叫类型为String,名称为s的形式参数,那么我在函数体内的变量名就不能叫s,编译器会提示你:Variable 's' is already defined in the scope。形参在进入函数时创建,退出函数时销毁。

  • 实际参数: 调用函数时传入的实际值。

所以我们在调用modifyString只是创建了一个局部变量指向了我们传入的实际参数,在函数体中我们修改了变量s的指向。我记得我在什么时候是通过不可变来解释的,想来那个时候没有仔细研究过不可变性,又忘记了基础知识点,但是这又是个面试题,错误的概念扭曲了认知,我在错误的套用概念,想来令人哑然失笑。下面一道面试题也很经典:

String s = new String("xyz"); 

上面的语句被执行之后会创建几个实例,之前有个看的答案是两个,一个是通过new方式创建的String实例,另一个实例是引用变量s,s指向String实例,但s是变量诶,这个引用类型的变量用于存储实例的地址,但它本身并不是String的实例。那换一种问法创建了几个String实例,我们上面说不通过new方式创建的String对象,通过"hello world" 这种形式来声明的,JVM会先去字符串常量池中查找,如果未查找到会在字符串常量池里面创建,然后new指令也会创建一个,所以是两个String实例。那么换一个问题呢? 上面的代码用户声明了几个String类型的变量,答案很简单也就是一个,也就是s,我们将语句换成下面这样也是一样的:

String s = null;

也是一个,变量是变量,引用类型的变量只是对某个对象实例或者null的引用,不是实例本身。声明变量的个数跟创建实例的个数没有必然的关系:

String s1 = "a";  
String s2 = s1.concat("");  
String s3 = null;  
new String(s1);

上面涉及了3个String类型的变量,String的contact方法如果接收的参数是空字符串会返回this,这里不会创建额外的实例。

逃逸分析与标量替换

但是上面的代码如果直接出现main函数中出现一次,就会只创建两个对象,但如果像下面这样呢? 等newString方法执行完,语句二创建的String实例可能已经被回收。

public static void main(String[] args) {
   Foo  foo  = new Foo();
   while (true){
      foo.newString();
   }
}
class Foo{
    public void newString(){
        String s1 = new String("xyz");
        String s = new String("xyz");// 语句二
        String s2 = new String("xyz");
    }
}

我们的JVM参数为: -Xms6M -Xmx6M -XX:+PrintGCDetails -verbose:gc。但是运行这个程序你会发现刚开始在控制台会输出垃圾回收器的回收信息,但是随后就没有了,但是执行newString方法,我们确实是不断的创建对象,那为什么不OOM呢,既然不断的创建对象堆上的内存应该不够用了才对,那为什么后面不再输出垃圾回收器的动作呢, 原因在于JVM采用了一种逃逸分析(Escape Analysis)的技术:

However, if an object is created in one method and used exclusively inside that method—that is, if it is not passed to another method or used as the return value—the runtime can potentially do something smarter.

然而,如果一个对象在一个方法内被创建,并且只在该方法内使用,也就是说他没有传递给另外一个方法或者被作为返回值,那么在运行时就能做一些更聪明的事情。

You can say that the object does not escape and the analysis that the runtime (really, the JIT compiler) does is called escape analysis.

在这种情况下,我们就可以说,对象没有逃逸,运行时(JIT编译器)会对此类情况进行分析,这种分析技术被称为逃逸分析。

《Escape Analysis in the HotSpot JIT Compiler》

如果对象没有被逃逸,那么JIT编译器会做什么呢? 在HotSpot 中的实现是

The first option suggests that the object can be replaced by a scalar substitute. This elimination is called scalar replacement.

第一个方案建议用标量替代物来替换对象,这种消除技术被称为标量替换。

This means that the object is broken up into its component fields, which are turned into the equivalent of extra local variables in the method that allocates the object. Once this has been done, another HotSpot VM JIT technique can kick in, which enables these object fields (and the actual local variables) to be stored in CPU registers (or on the stack if necessary).

这意味着对象会被分解为组成字段,在创建对象的方法中,这些字段相当于额外的局部变量。一旦进行了标量替换,另一种HotSpot VM JIT 技术就可以启动,它可以将这些对象字段(和实际的局部变量)存储在CPU寄存器中(或必要时存储在栈里面)。

将局部变量存储到CPU寄存器里,想到了C语言的register关键字,这个关键字就是请求将编译器将变量尽可能的存储在编译器里面,而不是通过内存寻址访问,以提高效率,注意是请求编译器,编译器也可能回绝你的请求, 因为大多数编译器都能够自动做到这一点,而且比人类更擅长选择变量存储的位置,所以这个关键字也不被推荐使用,不要相信你自己,你不会比编译器更聪明。 所以对象是被分解为组成字段,然后在栈上分配,没有产生对象,所以也就无从说对象在栈上分配这个说法,一个完整的对象有对象头和对象体,但是在标量替换技术下,没有产生对象,它将当前语句替换为对应的成员变量,如果你认为这也是对象的另一种形式的话,那么的确可以认为对象在栈上分配,但是没有任何对象的产生,它将创建对象的语句转换为了若干成员变量,我们只从对象创建的角度来说,实际产生的并没有使用创建对象指令,实际产生的临时变量,并不具备对象的一切,他不具备对象头,我记得之前看过一些文章, 类似都是说对象也会在栈里面分配,说的也是逃逸分析技术下的标量替换,我以为是这种技术下创建的对象和在堆里面一样呢,但细究之下发现并非如此,对象在栈上创建可能是一个伪命题,这并不是很好的问题,原因在于你说他创建对象了嘛,它没有创建对象,我说的对象指的事存储形式和堆一样的对象, 我们可以认为对象有两种基本存储的形式,一种是对象有对象头和对象体,对象是一种组织数据的形式,一种是基本类型的变量,当我们说起创建对象的时候,大致上就说的是我们的数据被存储为对象形式。我们也可以换一个角度来理解这个问题,即在进行标量替换的时候,从Java程序员的角度来说,我确实拿到了一个对象,我可以调用对象的方法, 但是组成对象的数据在进行标量替换上以后在栈上,从这种角度来说对象分配到栈上也有一定的道理。你可以添加JVM参数: -XX:+DoEscapeAnalysis, 来关闭逃逸分析,就会观察到垃圾回收动作相对于开启逃逸分析活跃了很多。

写在最后

我尝试用一种发散性思维去写作,也是看了雷军的年度演讲:

知识不全是线性的,大部分是网状的,知识点之间不一定有绝对的先后关系;前面内容看不懂,跳过去,并不影响学后面的;后面的学会了,有时候更容易看懂前面的。

所以发散了几条线,关于逃逸分析主要参考了参考文档[5] , Oracle发布的文章,具备很好的参考性和权威性,我也认同,其实关于逃逸分析这块,在探索的时候想到了之前看过的一篇文章,好像是别再说Java对象都是在堆内存上分配空间的了! ,本篇起名的时候也在想,要不题目改为《是谁告诉你对象可以在栈上分配的》但是想来是我们看待问题的角度不一样,我认为对象如果在栈上分配空间,那么也应当具备和堆中一样的存储结构,从这个角度来看,那经过标量替换之后,存储在栈上的确实不是这种结构,我们也可以说对象没有在栈上创建,只是对象创建语句被拆分为创建临时变量语句,只是在程序员的角度来说,我们用对象这种形式存储的数据被分配到了栈上存储,但是对于Java程序员来说,看起来仍然是对象,但是实际的存储确实在栈上的。这方面详细的讨论是参考文档[5], 这篇文章来自oracle,是官方权威的文章,在这篇文章中细致的讨论了逃逸分析,标量替换,但是标量替换才是最终的手段,JIT编译器换了一种存储对象的方式,所以我个人是不认可对象在栈上分配这个说法的。我倾向于称之为标量替换。

参考资料

[1] GOTO 语句被认为有害 www.emon100.com/goto-transl…

[2] 请别再拿“String s = new String("xyz");创建了多少个String实例”来面试了吧 zhuanlan.zhihu.com/p/315475889

[3] Java逃逸分析 xuranus.github.io/2021/04/07/…

[4] String s = new String("xyz"). How many objects has been made after this line of code execute? stackoverflow.com/questions/1…

[5] Escape Analysis in the HotSpot JIT Compiler blogs.oracle.com/javamagazin…

[6] C语言丨一文带你了解关键字register(又名闪电飞刀 ) zhuanlan.zhihu.com/p/346439177

[7] Why is C not deprecating the register keyword as C++ did? www.quora.com/Why-is-C-no…

[8] When can Hotspot allocate objects on the stack? [duplicate] stackoverflow.com/questions/4…

相关文章

JavaScript2024新功能:Object.groupBy、正则表达式v标志
PHP trim 函数对多字节字符的使用和限制
新函数 json_validate() 、randomizer 类扩展…20 个PHP 8.3 新特性全面解析
使用HTMX为WordPress增效:如何在不使用复杂框架的情况下增强平台功能
为React 19做准备:WordPress 6.6用户指南
如何删除WordPress中的所有评论

发布评论