字符串常量池,看这篇就够了(三)

779 阅读6分钟

哈喽,我是子牙。十余年技术生涯,一路披荆斩棘从技术小白到技术总监到JVM专家到创业。技术栈如汇编、C语言、C++、Windows内核、Linux内核。特别喜欢研究虚拟机底层实现,对JVM有深入研究。分享的文章偏硬核,很硬的那种。

手撸过JVM、内存池、垃圾回收算法、synchronized、线程池、NIO、三色标记算法…

这篇文章是专栏字符串常量池的第三篇。如果前两篇你还没看,墙裂都建议你回去看一下,再来看本篇

本篇文章就从上篇文章留的问题切入,分享:

  1. 什么情况字符串会写入常量池
  2. 什么情况字符串不会写入常量池
  3. intern底层是如何实现的
  4. 字符串过多导致OOM如何解决

上篇留的问题是这段代码为什么是这个结果

简单分析一下

一、s2与s3不是同一个对象,说明在创建s3这个对象时,字符串常量池StringTable中是没有[子牙真帅]这个字符串的。引出的问题就是什么情况字符串会写入?什么情况不会?

二、执行s3.intern,如果接收返回结果,则s3与s4指向的是同一个对象,如果不接收,还是不相等,这又是为什么呢?

写不写常量池

一般我们用Java代码创建字符串,常用方式有三

String s1 = "ziya";
String s2 = new String("ziya");
String s3 = "zi" + new String("ya");

那不常用的呢?反射方式创建、字节码增强包创建。但是我想,正常写代码,没人这么不正常吧。

如果你的Java代码编译生成的字节码指令中有【ldc】,就会写入常量池,否则就不会。细节上篇文章已经讲了,不赘述。

经过前面的简单分析我们知道:字符串拼接是不会写入常量池的,我们来细究一下

首先我们来看下字符串拼接,经编译器编译后生成的字节码长啥样子

是不是没想到,字符串拼接的底层竟然是通过StringBuild类实现的,通过append进行拼接,调用toString转成字符串。那毫无疑问,字符串拼接不写入常量池的秘密,不是在append中,就是在toString中。

追踪append的调用链,你最终会找到这句代码

public void getChars(int srcBegin, int srcEnd, char[] dst, int dstBegin) {
    ……
    System.arraycopy(this.value, srcBegin, dst, dstBegin, srcEnd - srcBegin);
    ……
}

StringBuild中维护着一个能够自动扩容的char数组,append传入的字符串,都会被蜕去外壳,拿到真正的字符串内容,写入这个char数组。

arraycopy是个native方法,继续跟踪到Hotspot源码,发现也没有操作StringTable的代码。Hotspot源码我就不贴了,敢兴趣的可以自己去看。不会看吗?可以看我之前写的一篇文章《教你如何找到native方法对应的Hotspot源码》

再看看toString调用链,代码很简单,创建一个String对象,将StingBuild中char数组中的内容原封不动的copy过来

public String(char[] value, int offset, int count) {
    ……
    this.value = Arrays.copyOfRange(value, offset, offset + count);
    ……
}

copyOfRange最终调用的也是arraycopy这个native方法

综上:你写的Java代码经编译器编译后生成的字节码指令中有【ldc】,就会写入常量池,字符串拼接不会写入常量池。

intern做了什么

JVM执行到指令【ldc】就会将字符串写入常量池,本质原因是这条调用链上会调用intern,那intern底层是如何实现的呢?我把相关代码贴出来,然后挨个剖析

先上第一段代码

解释这个代码之前,先给大家看个图,不然讲了大家也听不懂。什么图呢?字符串到底是怎么存储的?与运行时常量池之间的关系是怎样的?

前面讲过,在link阶段,字符串会被封装成Symbol对象,存储到SymbolTable中,然后还要存储到运行时常量池中

运行时常量池是一个C++对象,如图所示:蓝色部分表示这个C++对象本身占用的内存,接在对象后面的部分是常量池项。比如index=1的位置是一个字符串,那这个位置存储的就是一个Symbol对象的内存地址

运行时常量池这个C++对象中有一个属性resolved_references,是一个数组结构。看这个属性的名字也能看得出来:解析过的引用类型。即如果这个字符串已经执行过了intern,由Symbol对象转成了String对象,就直接返回。如果还没有转成String对象,就调用intern,调用完intern还有件重要的事情,就是写入resolved_references。

这三段内容看到,上面那段代码应该不用我解释了吧。

接下来第二段代码

这段代码做的事情:从Symbol对象中拿出字符串内容,调用intern。这个方法代码有点长,保留核心逻辑

1、先去字符串常量池StringTable中去找有没有这个字符串,如果有,直接返回,如果没有,往下走

2、第16行代码,基于字符串内容创建Java的String对象。这个方法等下展开讲,讲完第二篇的内容你就恍然大悟了

3、将创建的String对象写入StringTable。这个做的好处:一、下次通用的字符串不需要再次执行创建,提升了程序执行效率;二、由于不需要重复创建,节省了内存,有点缓存的感觉

接下来看下16行代码的细节

189行:创建一个Java的String对象,这里是Hotspot源码,所以创建的是一个oop对象,转成Handle。Hotspot源码中,操作一个对象,有时候是直接操作oop,有时候会转成Handle对象。其实因为做了C++级别的操作符重载,两种对象的写代码风格风格基本差不多

190行:拿到String对象中存储字符串的容器char数组,对应的Hotspot中的C++对象就是typeArrayOop

后面的for循环就是一个字节一个字节的赋值。这块为啥不调用类似memcopy直接整块内存拷贝呢?想不通!

到这里就把intern底层细节讲明白了

字符串导致OOM

这个问题也是小伙伴问我问的比较多的。看到这里你应该清楚这背后的原因及如何解决了吧

背后的原因是大概率你的代码中的字符串都是拼接生成的,不会写入常量池,所以每次都是不断的创建,消耗内存空间

解决办法就是在拼接字符串的代码后面手动调用intern触发写入常量池StringTable。后面出现的G1的字符串去重,本质就是干这事,就是你不用手动调用intern,在GC的时候,G1给你做了。

但是就算存在字符串去重,因为拼接底层实现是通过copy实现的,不会写入常量池,所以字符串去重只是缓解了这个问题,并没有根本解决这个问题。如果从根本上解决,拼接的底层实现需要改!jdk8这块还是之前的代码,后面的不晓得改了没,抽空看下

我是子牙老师,喜欢钻研底层,深入研究Windows、Linux内核、JVM。如果你也喜欢研究底层,欢迎关注我的公众号【硬核子牙】