关于java:也谈不可变对象

5次阅读

共计 11391 个字符,预计需要花费 29 分钟才能阅读完成。

前言

很久之前跟敌人聊 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 < len;) {int c = (int)value[firstLower];
                int srcCount;
                if ((c >= Character.MIN_HIGH_SURROGATE)
                        && (c <= Character.MAX_HIGH_SURROGATE)) {c = codePointAt(firstLower);
                    srcCount = Character.charCount(c);
                } else {srcCount = 1;}
                int upperCaseChar = Character.toUpperCaseEx(c);
                if ((upperCaseChar == Character.ERROR)
                        || (c != upperCaseChar)) {break scan;}
                firstLower += srcCount;
            }
            return this;
        }

        /* result may grow, so i+resultOffset is the write location in result */
        int resultOffset = 0;
        char[] result = new char[len]; /* may grow */

        /* Just copy the first few upperCase characters. */
        System.arraycopy(value, 0, result, 0, firstLower);

        String lang = locale.getLanguage();
        boolean localeDependent =
                (lang == "tr" || lang == "az" || lang == "lt");
        char[] upperCharArray;
        int upperChar;
        int srcChar;
        int srcCount;
        for (int i = firstLower; i < len; i += srcCount) {srcChar = (int)value[i];
            if ((char)srcChar >= Character.MIN_HIGH_SURROGATE &&
                (char)srcChar <= Character.MAX_HIGH_SURROGATE) {srcChar = codePointAt(i);
                srcCount = Character.charCount(srcChar);
            } else {srcCount = 1;}
            if (localeDependent) {upperChar = ConditionalSpecialCasing.toUpperCaseEx(this, i, locale);
            } else {upperChar = Character.toUpperCaseEx(srcChar);
            }
            if ((upperChar == Character.ERROR)
                    || (upperChar >= 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 < len;) {int c = (int)value[firstLower];
                int srcCount;
                if ((c >= Character.MIN_HIGH_SURROGATE)
                        && (c <= Character.MAX_HIGH_SURROGATE)) {c = codePointAt(firstLower);
                    srcCount = Character.charCount(c);
                } else {srcCount = 1;}
                int upperCaseChar = Character.toUpperCaseEx(c);
                if ((upperCaseChar == Character.ERROR)
                        || (c != upperCaseChar)) {break scan;}
                firstLower += srcCount;
    }
}

这在 Java 中被称为 labeled loop(标签循环),用于管制跳转到指定地位,如下所示:

Random r = new Random();
System.out.println("start random hash code:" + System.identityHashCode(r));
int index = 0;
label1:
 for (; ;) {if (index == 0){System.out.println("首次执行");
    }else{System.out.println("标签跳转执行到这里执行");
    }
    int random = r.nextInt(2);
    random = random + 1;
    System.out.println("random:" + random);
    label2:
    for (;;) {if (random == 1) {
           // 示意我要跳转到标签 label1
           index = random;
            break label1;
        }
       if (random == 2) {
          // 跳转到 label2
          index = random;
          break label2;
        }
       if (random == 3) {
          // 跳出内层循环
          index = random;
          break;
      }
  }
}
System.out.println("end random hash code:" + System.identityHashCode(r)); // 语句一
System.out.println("整个循环全副进行");

其中的一个输入后果为:

start random hash code: 326549596
首次执行
random: 2
标签跳转执行到这里执行
random: 2
标签跳转执行到这里执行
random: 1
end random hash code: 326549596
整个循环全副停

通过剖析后果咱们能够发现,如果跳转到的指标标签还处于一个循环中,会主动再执行一次跳转指标标签的外层循环,如果跳转标签的指标标签所属的语句块不属于循环语句,则程序执行标签代码块上面的代码,比方咱们在下面的代码中跳转到 label1 之后,label 标签不在循环语句中,就会语句一对应的代码。这让我想到了 C 语言的 goto 语句 , 在 C 语言中 goto 语句用于跳转到标签对应的语句块外面:

#include <stdio.h>
#include <stdlib.h>
#include <time.h>
int main(int argc , char* argv[])
{srand((unsigned)time(NULL));// 初始化随机数
  int result  = rand()/100;
  printf("%d\n", result / 2);
  if(result / 2 == 0)
  {goto gotoUp;}else
  {goto gotoMove;}
  gotoUp:
      printf("往上跳转");
  gotoMove:
      printf("往下跳转");
}

goto 语句在 Java 中是一个保留关键字,在 IDE 中输出这个关键字会变色,然而不具备性能,起因恐怕来自于 Edsger W. Dijkstra 在 1968 年致计算机科学协会通信 (CACM) 的函件,在函件中,Dijkstra 呐喊破除编程语言中的 goto 语句,明天这封信曾经变得闻名遐迩,大多数程序员都听过“永远不应用 goto 语句”的箴言,,但现在只有多数计算机科学业余学生理解 Dijkstra 拥护 goto 的历史背景并从中受害。“goto 语句是邪恶的”这句“传言”进入了古代编程的教条,然而浏览 Dijkstra 原文能够让你明确这句传言齐全不得要领。Dijkstra 写那封信时,公认的形式是用 goto 语句手动编写 循环 if-then 和其余控制结构,因为咱们明天司空见惯的根本控制流语句那时候要么不被大多数编程语言反对,要么模式有限度。Dijkstra 的意思不是 goto 的 所有 用处都不好,而是说正确应用高级的控制结构能毁灭 大多数 过后风行的 goto 的用法。Dijkstra 依然容许将 goto 用于更简单的编程控制结构。这方面能够在 Linux 的源码中一窥端倪,在《从 SocketTimeoutException 到全连贯队列和半连贯队列》中咱们提到 Linux 内核在 2.2 之后,如果 accept 队列满了,一个连贯又须要从 SYN 队列移到 accept 队列时(比方收到了三次握手中的第三个包,客户端发来的 ack),linux 下的该种实现会如何体现呢?具体的源码解决就是应用 goto 语句来做管制的:

child = inet_csk(sk)->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 语句被认为无害 https://www.emon100.com/goto-translation/

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

[3] Java 逃逸剖析 https://xuranus.github.io/2021/04/07/Java%E9%80%83%E9%80%B8%E…

[4] String s = new String(“xyz”). How many objects has been made after this line of code execute? https://stackoverflow.com/questions/19672427/string-s-new-stringxyz-how-many-objects-has-been-made-after-this-line-of

[5] Escape Analysis in the HotSpot JIT Compiler https://blogs.oracle.com/javamagazine/post/escape-analysis-in…

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

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

[8] When can Hotspot allocate objects on the stack? [duplicate] https://stackoverflow.com/questions/43002528/when-can-hotspot-allocate-objects-on-the-stack/43002529#43002529

正文完
 0