【刨根问底】特殊的类型:String part1(String相关的存储空间底层、运行时常量池、intern方法)

1. String的本质

作为Java最常用的类型,String的特性总是一个热门话题,其本质是一个jdk提供的核心类,因此,和数组和基础类型这些怪物不同,我们可以阅读其底层实现的代码

public final class String(){
	private final char[] value;
}
//重写自Object方法的比较方法
  public boolean equals(Object anObject) {
        if (this == anObject) {
            return true;
        }
        if (anObject instanceof String) {
            String anotherString = (String)anObject;
            int n = value.length;
            if (n == anotherString.value.length) {
                char v1[] = value;
                char v2[] = anotherString.value;
                int i = 0;
                while (n-- != 0) {
                    if (v1[i] != v2[i])
                        return false;
                    i++;
                }
                return true;
            }
        }
        return false;
    }
}

可知String本质为一个final类型,其数值存放在私有的被final修饰的字符串数组之中。
其中继承自Object的equal方法被重写为,除了引用相同的情况,value数组内容相同也返回true
由于String对象经常会出现,同样的值的字符串经常重复使用的情况,因此,若值都是相同的,但是却专门为其创建出两个不同的变量的话,将会造成大量的内存浪费(比如堆里500个对象有400个都是值为"123"的数组)的情况
因而有了一个特殊的概念——常量池

2.1运行时常量池

2.1.1运行时常量池与字符串常量池及JVM存储结构

由于jdk在6、7、8三个版本都对其存放方式有所改动,因而运行时常量池与字符串常量池的关系及所在的位置会有所改变,而要讨论这些改动,我想应该再提及一个概念

2.1.1.1方法区(method area)

在这之前,我们已经讨论过元空间和永久代,若把这两个视作具体的实现方案,而方法区则是概念上的设计方案
其在定义中,希望有一块用于存放类的元数据的空间
继而,在我们常用的hotspot虚拟机中,在jdk6之前使用在堆内存中划分出一个永久代作为方案来实现这个需求
然后,又在jdk8中转化为了使用元空间的方案
而作为方法区的实现的一部分,常量池在几个版本的变化中,其细节具体如下

在jdk6及其之前
1.字符串常量池为运行时常量池一部分
2.运行时常量池处于永久代,永久代在理论上来说是堆内存的一部分,但是又和堆内存实际上是物理性的隔离的

jdk7中
1.字符串常量池脱离永久代,置于堆空间之中,正式作为堆空间的一部分
2.残存的运行时常量池依然留在堆之中

jdk8及之后
1.字符串常量池依然处于堆空间之中
2.永久代取消,其功能替换为堆外的元空间

2.1.2运行时常量池存放的内容

笼统而言,类在加载时,就认为其为不变的常量会被存放于此,具体细节的话,一般可以分为两种类型:
1.字面量
字符串常量即String,以及其他使用final修饰了的常量
1.1字面量的存放:
实际上,关于String等常量如何存放,在不同的jdk版本有不同的处理方式
jdk6及之前: 存放的是字符串对象本身
jdk7及之后: 存放的除了字符串对象本身,还包含对象的引用(何时存放引用,在后面会讨论)

2.符号引用
举个例子,加载类A时,发现A类中引用了B类,这个B类的引用同样被视为一个常量——需要注意的是
2.1符号引用和直接引用的区别:
A类在加载时,其引用的B类很可能根本就没有被引用——因此,我们并不能保障交给A类一个具体的B类在内存中的地址
作为替代,我们会交给A类一个独一无二的,可以用于交给Jvm在运行时翻译为B类的具体内存地址的符号变量
在这里的符号变量就是——符号引用
而这里的具体内存地址就是——直接引用

小结:各空间的存放内容

堆: 对象,具体为:一切new出来的对象,清理全权交给垃圾收集器

栈: 函数运行时的数据,具体为:函数中用到的对象引用即句柄,以及所有的基础类型变量数据本身(你也可以理解为这些东西的数据就放在句柄里),栈通过栈帧管理空间,即每个方法将会有一个独立,私有的空间名为栈帧的空间,方法执行结束时,栈帧就会得到释放

本地方法栈: 在hotspot虚拟机中,是栈的一种用法,区别仅在于其执行的是代码位于本地的native方法——也就是那些使用其他语言实现的,封装为dll库的方法

运行时常量池:
jdk6及之前:类加载时判定的常用的不变的常量,具体为:String及其他的final修饰的常量、类的符号引用
jdk6之后:去除字符串常量池以外的全部

元空间&永久代:
永久代: 是运行时常量池的父级,存放类的一切元数据,具体为:类的所有信息——成员对象、方法、常量等等,其中常量的部分会专门存放于常量池之中。永久代由jvm调配清理事件、也会根据配置的参数自动进行扩容,清理条件为类的数据被判定为僵死
元空间: 和常量池没有关系,本质就是失去了常量池的永久代,内存空间改为置于堆外

程序计数器: 存放的内容为多线程服务,本质为并发过程中线程的“书签”,具体为:上一个线程执行至的class文件行数,由于记录的是文件行数,是必定可以预知最大的大小的,所以永远不会发生OOM,也没有进行垃圾回收的必要

堆外空间: 某些特殊对象可以通过unsafe和bufferByte对象将对象存放于堆外之中、具体可以降低程序的GC压力,清除交给和此类对象引用伴生的cleaner清除器执行——当引用僵死被清除,则cleaner就会调用方法进行堆外空间的回收

2.2String对象的创造

和一般的对象不同String对象有多种创造方式

//一型
String a = "123";
形式1:直接使用""创建

将会检测字符串"123"对象是否已经在常量池中存在
若存在,则将池中的引用交给句柄a
若不存在,则在池中创建"123"字符对象后,再将常量池中的该对象的引用交给句柄a
,因此有代码

String a = "123";
String b = "123";
System.out.print(a==b);
//输出为true,两者引用的对象相同
形式2:使用new创建
//二型
String b = new String("123");
/**
     * Initializes a newly created {@code String} object so that it represents
     * the same sequence of characters as the argument; in other words, the
     * newly created string is a copy of the argument string. Unless an
     * explicit copy of {@code original} is needed, use of this constructor is
     * unnecessary since Strings are immutable.
     *
     * @param  original
     *         A {@code String}
     */
    public String(String original) {
        this.value = original.value;
        this.hash = original.hash;
    }

首先,我们很清楚的知道这里的String使用了构造器来创建对象,自然,我们可以直接进入源码查看他的构造器代码

很明显,在进行代码时

String str1 = new String("123");

这里作为参数的"123"是一个独立的对象
因此,其底层逻辑是,先执行类型1的逻辑,在常量池中创建一个字符串对象。再在堆空间中创建一个String对象,并将常量池中的对象的值和hash值复制给这个字符串对象
最终,将堆中对象的引用交给当前句柄,因此有

String a = new String("123");
String b = "123";
System.out.print(a==b);
//输出为false,两者引用的对象不是相同的对象
形式3:intern
//三型
String c = "123".intern();
//
String b = new String("123");
String c = b.intern();

与其说是一种形式,实际上intern()是一种String使用的native函数
其底层逻辑网上众说纷纭,但是总而言之可以归纳为两种观点
1.在jdk6和jdk7后分为两种类型——我认为较为合理的观点

2.jdk7及之后并没有进行改变——使用官方文档做论点支撑,但是有个别现象无法解释的观点
在翻阅资料时,我找到了一份详细的通过反编译Class文件而做出断定的博客:
通过反编译深入理解Java String及intern + JDK1.8关于运行时常量池, 字符串常量池的要点

其讲述的非常的清楚,再次我会简单复述其验证思路,证明观点一

2.2.1常见字符串问题

首先,先说结论:intern方法的底层实现共有jdk6及之前与jdk7及之后两种版本

问题一:以下输出是什么

String a = "123";
String b = new String("123");
String c = b.intern();

System.out.println(a==b);
System.out.println(a==c);

将会输出false和true,第一个输出true不难理解,之前已经讲过原因
而第二个输出呢
因为实际上intern()方法的底层将会做的是(此处直接贴jdk文档的原话):
在这里插入图片描述
总体而言,核心句意是:
1.前往常量池,寻找和调用方法的字符串对象值相等的——即,能够通过a.equals(b);方法的对象
2.若有,则该方法会返回常量池中的字符串对象的引用
3.若无,则该字符串对象将会被加入常量池中,且返回该字符串对象的引用
这里的第三句是网上争议最大的一条,至于为什么,我们先要懂得下一个问题的解

问题二:以下将会输出什么
String baseStr = "hime";
final String baseFinalStr = "hime";

String str1 = "himehina";
String str2 = "hime"+"hina";
String str3 = baseStr+"hina";
String str4 = baseFinalStr+"hina";
String str5 = new String("himehina");
String str6 = new String("himehina").intern();

System.out.println(str1 == str2);
System.out.println(str1 == str3);
System.out.println(str1 == str4);
System.out.println(str1 == str5);
System.out.println(str1 == str6);

上面引用的那篇博客甚至对这些代码的底层class文件进行分析,在这里,我们直接说结论,关于字符串对象相加:
1.当是两个字面量相加时:
编译器中会直接合并相加的两个字面量字符串
即,对于编译器而言,看到"hime"+"hina"和看到"himehina"是完全一样的

2.当是一个对象引用和一个字符串相加:
底层其实是, 创建一个StringBuild对象,将两者作为参数相加后,再转换为String类型

3.当一个被final修饰的对象和一个字符串相加:
编译器会将final修饰的字符串,直接翻译为字面量,并作为字面量看待
即,对于编译器来说,baseFinalStr 等价于"hime"

因此,以上的输出答案为:true false true false true

接下来,接入正题,请看问题三

问题三,以下输出的内容为?
String a = "123";
String b = a+"456";
b.intern();
String c = "123456";
System.out.println(b==c);

根据问题二得出的结论,此处:
b由StringBuilder创造并转换而来,因而,"123456"只在堆中存在,常量池中并没有该对象
之后,b使用intern()方法,应该去常量池中寻找字符串对象"123456",因为不存在,因此,应该会在常量池新建该对象
c为使用字面格式创造的字符串对象,按其规则,取值应当来自常量池,也就是上一行中intern方法创造出的字符对象。所以和b指向的堆中的对象应该是两个不同的对象,所以输出结果应当是false

然而事实却是:
jdk6时将会输出false
jdk7时将会输出true

对于该种情况,死磕官方文档的一类人的论断将会无法解释,即jdk7以及之后的intern方法确实修改了底层逻辑
jdk6及之前
在这里插入图片描述
jdk7及之后
在这里插入图片描述
在jdk7及以后,intern()方法的实际逻辑根据实验结果,以及内存中存放的数据类型(从jdk7开始的字符串常量池除了字符串对象以外,还会存放对象引用)分析,可以确定的是,intern的底层已经优化为——若池中没有调用该方法的对象的值相同的字符串对象存在时,将会把该对象的引用存入常量池中。

反对方的说法总结

观点1:调用intern向常量池中不应放入的是引用,而是对象
When the intern method is invoked, if the pool already contains a string equal to this String object as determined by the equals(Object) method, then the string from the pool is returned.
Otherwise, this String object is added to the pool and a reference to this String object is returned.
此处第二句,“this String object is added to the pool”,因此咬定存入常量池中的肯定为对象,但是实际上这句英文原本就可翻译为把字符串对象加入常量池

观点2:调用intern应该是在堆内存全局获取最早创建的字符串对象,并将最早的那一个字符串对象的引用放入常量池
非常明显,这样做一点都不高效,新的算法做出的改变是:原本需要在常量池中创建一个对象,改为了,只需要存放一个引用;而常量池原本是需要增加一个对象的空间,而变得只需要存放一条引用,完全没有额外新增在堆范围内进行搜索这一条的必要

代码实验
在这里插入图片描述
在这里看到,b在经过intern()之后打印的地址依然没变,且c的地址与a和b相同,因此,第一种说法不成立
然后b1与后续的b,c都不相同,因此第二种说法也不成立

2.3String,StringBuilder,StringBuffer

在之前的"+"字符串加法中,我们就已经提到了StringBuilder对象,其与String的关系如下:
在这里插入图片描述
我们知道,String在进行字符串操作时,底层是进行了多次转换的,因此,效率实际很低,自然,直接使用为String服务的StringBuilder当然能够获得更高的效率,另外,我们认识了Stringbuilder的兄弟StringBufffer,实际上,StringBuilder的所有的方法都添加了synchronized关键字,而StringBuffer没有,因此StringBuffer将会有更高的运行速度


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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值