非堆,方法区(常量池的生成机制)

非堆,方法区

 

一个不是堆内存的区域,这样理解吗?

其实笔者刚开始也是这样理解的,但是当我们了解了其中的原理,就不会这样认为了,也不能这样认为了。

非堆”、“堆”不能将其认为是两个相对的内存区域,甚至我们可以将其统称为堆。

在Java虚拟机规范中描述:方法区为堆的一个逻辑部分。

但是我们在内存模型构建的时候,就是将堆和方法区分为块区域的。

 

 

那么它们分别有什么共同点呢?方法区又存放的是什么数据内容呢?

共同点:有堆内存的特性,线程共享;那么线程共享,自然而言,里面存放的数据也是线程共享的才可以。方法区内部存放的是虚拟机加载的类型信息、常量,静态变量,即时编译器编译后的代码缓存等数据。那么上面这些数据我们都可以知道都是线程之间都共享的数据;上面类型信息,这些都是类的元数据,常量,基本上都存放于“运行时常量池”区,静态变量,也是静态池中,都是所有线程都可以访问,都需要访问的数据。

 

上篇文章分享到:分代设计,其中堆中存在年轻代、老年代。然后还剩下一个永久代,这就和方法区有关系了。

方法区的实现逻辑就是永久代,为什么是通过永久代实现的呢?因为在这块内存区域,也属于Java字节码产生的数据,那么Java虚拟机就需要对其进行管理维护,我们将其实现方式通过分代方式实现,那么我们就能够使用垃圾回收器进行对其管理。就能够做到通用管理了。

但是在接下来的分代概念的退化,逐渐衍生出的分区概念,渐渐的也就没有永久代了,将产生一个新的名称:元空间。

我们都知道,如果我们将方法区的这部分数据用于分代管理,那么就会受到虚拟机的管理,自然会也会有内存分配的限制,例如,当永久代的内存不够使用了,也会产生内存溢出的情况,如果说对象的实例的内存区域产生溢出,我们可以通过垃圾回收算法进行清理,毕竟对象的实例是有生有死的。但是对于方法区中存放的数据的性质,基本上无法进行垃圾回收的吧?试问,一个常量,一个编译后数据,回收它做什么?那么这就产生了问题了。

然后就在jdk8后出现了元空间的的设计概念了。元空间的内存受操作系统的管理限制,也就是说,当我们一台物理机有32GB内存,jvm虚拟机分配了4GB堆内存,1GB永久代内存,那么我们的元空间消耗上线受32GB的管理,而不是受1GB永久代内存的限制了。这也将极大的解决了永久代也会产生内存溢出的问题了。(此处有个特殊问题,32位操作系统,在系统限制的原因,物理内存的限制还是会有4GB的限制,但是我们基本上不会考虑32位操作系统了,也就那种少量的移动设备会有使用)

将方法区中的数据内容移出永久代,是一个势在必行的事情,在JDK7,就已经将常量池、静态池等数据开始放在本地内存中。在JDK8的时候,就全部移出,也就没有了永久代的概念,全部都存放于元空间了。

 

 

上面我们说到:方法区中存放的数据的性质的原因,基本上是不需要进行回收,那么对于这块区域的垃圾回收机制也就不怎么重要了。举个抽象的例子来说,我们一个快餐店的桌面,服务员对餐桌的清洁整理是很有需要的吧?那么一栋楼的建筑材料的清洁,有没有说,定期我们查下这栋楼中哪块砖头是不需要了的,我们将其回收吧?这两个例子的概念不是很接近,表达的意思就是:方法区中的数据概念基本上是和虚拟机的生命周期是一致的,没有垃圾回收的太多意义了。

但是我们只能说,没有太多意义,不能说完全没有回收的必要,因为对于方法区中的数据,其中常量池的数据我们是需要回收的,当一个对象指向由实例A改为指向了实例B,那么实例A就没有意义了,当我们进行根可达算法分析过会,这个数据就会被回收了。

 

为什么方法区中的数据也需要回收,因为方法区的常量、类型卸载等操作也需要整理内存,因为方法区也会出现OOM的问题了。

 

 

 

上面也说到了“常量池”,我们也需要特别说明一下。(他需要回收)

在我们所编译后的字节码文件中,每个类都会有一个 常量池表(Constant Pool Table),用于存放编译器生成的各种字面量和符号应用,在类开始加载的时候,我们就会将其存放到线程共享区域的方法区中的运行时常量池中。这是一个加载流程。

什么叫字面量?就是String str = "str"; 而String strObj = new String(“str”);是实例化对象

 

在我们运行的时候,就会有新的常量产生,常量并不是一开始就有的,并不是在字节码编译解析阶段,类装载阶段才会进入到从类的常量池表转存到运行时常量池的。也就是说,在代码运行阶段,我们也可以构造常量,例如字符串常量。

我们用段代码进行说明一下:(也是一些面试题)

为什么str == strObj.intern() 就是相等的,而直接比较就不相等呢?

这是因为,使用new String()就是在创建一个String对象的实例,那么我们在堆内存中创建的String对象和方法区中创建的常量肯定地址不是同一个的。

那么为什么调用了intern()返回的String对象,就和方法区中的常量就相等了呢?

那是因为在我们调用intern()方法,就是在申请常量的动作,编译器就会将strObj中的值判断在常量池中是否存在,如果不存在,那么就创建,如果存在,那么就直接将其指向方法区中常量池的对应的地址。那么,当我们去比较两个对象的时候,比较的是两个对象的地址值,那么自然就是相等的。

 

 

所以说,将一个字符串对象调用intern()方法就是可以理解为将其申请到常量池中的属性,有就直接指向,没有就创建一个常量对象。那么即使再多的不同的String实例,只要它们的内容一致,那么在申请常量空间的时候,都会做出判断,最后结果都会指向同一个常量池地址,然后比较的时候,都会是相等的。

我们可以看下图,可以看出,对于其中使用了inern()方法返回的对象和字符串比较都是相等的。

并且和我们局部变量中的str2也是相等的,那就说明String对象通过字面量构造出来的都是常量。但是有同学会问,常量不是不可变吗?为什么我们还可以对其进行 + 拼接?

 

 

 

常量确实不可变,但是我们可以创建出新的常量,就是说,我们将两个字符串对象 String str1 = "ja"; String str2 = "va";所构成的String str3 = str1 + str2;就是一个新的对象实例 “java”,然后再进行申请常量空间,在方法区中的常量池中会先判断是否已经有“java”这个常量,没有就创建,有的话,就会直接指向。例如下图中:

也就是说,当前字符串对象拼接完成后,构成了一个新的实例,当这个实例需要比较的一个常量的时候,那么就需要我们使用intern()返回常量地址进行比较。

 

但是如果是 String str4 = "ja" + "va";所构成的对象是什么呢?这其实和上述不一样,str4在编译阶段就会 变成 String str4 = "java"; 然后两者再进行比较,那么就会直接相同了。

 

那么这个时候,如果我们将str1、str2都置为final修饰呢?结果就和上方完全不一样了

我们发现,不管是对象间的拼接和字面量间的拼接,结果都是和str3完全相等,甚至idea都提示,没必要使用intern()方法了。这到底是为什么呢?

 

先说明下为何final会直接相等,因为对于final修饰的对象,就是不可变的,如果我们对str1进行再次赋值,那么就会直接编译不通过,那么str1就和"ja"值绑死了,然后在编译阶段,编译器就直接方法内中的所有调用str1的地方执行了常量替换(相似的还有方法内嵌)。那么产生的结果就会将 String str5 = "ja" + "va";,这个时候,str3和str5就一致了,就会产生新的常量对象。

那么为何变量与变量的拼接,和变量与常量的拼接就会产生新的 堆的 对象实例呢?

这里将会使用到StringBuilder对象,将会使用append()方法,产生一个新的String对象实例,那么就是一个新的堆空间中的一个对象实例了,那么自然地址是不一样的了。

 

那么在jdk7之前,我们在使用intern()方法的时候,就会去运行时常量池中寻找对应字符串常量,如果找不到,就会创建一个新的字符串常量;但是在jdk7后,他不是创建一个新的字符串常量了,而是在常量池中存放一个执行堆内存实例的内存地址,这样就节省了空间。那么有同学就会问了,那么如果我构造出的一个实例String str1 = String("Java")+String("ToBeOne"),String str2 = String("Java")+String("ToBeOne"), str1.intern()的内存地址和str2.intern的内存地址是不是一样的呢?结论是一样的,且都是 方法区中的运行时常量池中的某个地址,而不是 str1实例的堆空间的某个地址,因为,只不过在常量池中的某个地址存放的具体内容是一个堆空间中个某个地址,只是在获取其常量池对象的内容的时候,会使用到这个堆空间的地址。

那么又有同学问了,那么我此时 “JavaToBeOne”的常量地址指向的是堆内存中的首次执行intern()方法的地址吗?

是的。我们可以代码论证;

(有点绕?先示例表明,再画张图)

(1)先回答第一个问题:JDK7过后,执行intern()方法,如果常量池中没有找到对应的字符串对象,那么就会分配一个内存空间地址,此地址指向的是当前实例对象的堆内存的内存地址。为什么呢?我们看看代码

private static void method0() {
        //1.先判断常量池中是否有“Java”这个对象,不存在则创建;然后再创建堆内存中这个实例对象,再返回堆内存中的内存地址
        //"JustDoIt"同理
        //然后两个对象进行拼接,使用StringBuilder,此时只创建了堆内存中的实例对象,然后返回堆内存中的内存地址
        String str1 = new String("Java") + new String("JustDoIt");
        System.out.println(System.identityHashCode(str1));
        //切记不可使用下述方式创建字符串对象,因为会先去常量池中创建对象,会影响我们这次测试
        //String str1 = new String("JavaJustDoIt") ;
 
        //2.将堆内存中的实例“JavaJustDoIt”向运行时常量池申请内存空间
        //在常量池中没有找到“JavaJustDoIt”字符串,然后就创建一个常量空间
        // 但是常量空间的值设定的是堆内存中“JavaJustDoIt”的内存地址
        str1.intern();//注意,此方法有返回对象,但是此操作不想改变堆内存中的结果,只是想提前在常量池中生成一个“值”
 
        //4.比较两个对象的地址值,由于现在str1.intern()指向的是堆内存中的地址,也就是str1的地址,两者就直接相等。
        System.out.println(str1 == str1.intern());
        System.out.println(System.identityHashCode(str1));
        System.out.println(System.identityHashCode(str1.intern()));
}

打印结果

1940030785
true
1940030785
1940030785

此结果打印true,且str1.intern()返回的地址值和str1一致;并且地址值和一开始的str1对象的堆内存地址就是一样的。

 

(2)那么第二个问题,执行上述操作后,再定义一个常量,所指向的内存地址,是常量池中的一个地址呢?还是对应被指向的String实例的堆内存的内存地址呢?

private static void method1() {
        //1.先判断常量池中是否有“Java”这个对象,不存在则创建;然后再创建堆内存中这个实例对象,再返回堆内存中的内存地址
        //"JustDoIt"同理
        //然后两个对象进行拼接,使用StringBuilder,此时只创建了堆内存中的实例对象,然后返回堆内存中的内存地址
        String str1 = new String("Java") + new String("JustDoIt");
        System.out.println(System.identityHashCode(str1));
 
        //切记不可使用下述方式创建字符串对象,因为会先去常量池中创建对象,会影响我们这次测试
        //String str1 = new String("JavaJustDoIt") ;
 
        //2.将堆内存中的实例“JavaJustDoIt”向运行时常量池申请内存空间
        //在常量池中没有找到“JavaJustDoIt”字符串,然后就创建一个常量空间
        // 但是常量空间的值设定的是堆内存中“JavaJustDoIt”的内存地址
        str1.intern();//注意,此方法有返回对象,但是此操作不想改变堆内存中的结果,只是想提前在常量池中生成一个“值”
 
        //3.在常量池中先寻找常量“JavaJustDoIt”字符串
        //由于第2步中已经生成了一个常量对象,且返回地址是指向堆内存中的地址
        String str2 = "JavaJustDoIt";
        System.out.println(System.identityHashCode(str2));
 
        //4.比较两个对象的地址值,由于现在str2指向的是堆内存中的地址,也就是str1的地址,两者就直接相等。
        System.out.println(str1 == str2);
        System.out.println(System.identityHashCode(str1));
        System.out.println(System.identityHashCode(str2));
} 

控制台打印

1940030785
1940030785
true
1940030785
1940030785

结果很清晰啊,所有的对象的地址都是一样的,那我们的推论就是正确的。

 

(3)那么有同学就会问了,那是不是本身就是一个样的啊?你这个1940030785内存地址又没写清是堆内存还是常量池内存。好的,那么我们就举一个反例论证一下。我们先进行常量池对象声明,再去执行intern()方法。

private static void method2() {
        //1.先判断常量池中是否有“Java”这个对象,不存在则创建;然后再创建堆内存中这个实例对象,再返回堆内存中的内存地址
        //"JustDoIt"同理
        //然后两个对象进行拼接,使用StringBuilder,此时只创建了堆内存中的实例对象,然后返回堆内存中的内存地址
        String str1 = new String("Java") + new String("JustDoIt");
        System.out.println(System.identityHashCode(str1));
        //切记不可使用下述方式创建字符串对象,因为会先去常量池中创建对象,会影响我们这次测试
        //String str1 = new String("JavaJustDoIt") ;
 
        //2.在常量池中先寻找常量“JavaJustDoIt”字符串
        //首次创建,没找到,然后就创建一个“JavaJustDoIt”的字符串常量,分配一块常量池空间
        String str2 = "JavaJustDoIt";
        System.out.println(System.identityHashCode(str2));
 
        //3.将堆内存中的实例“JavaJustDoIt”向运行时常量池申请内存空间
        //在常量池中找到了“JavaJustDoIt”字符串,然后返回常量池中的内存地址
        str1.intern();//注意,此方法有返回对象,但是此对象是常量池中的对象。
 
        //4.比较两个对象的地址值,str2指向的是常量池中的内存地址,而str1指向的是堆内存中的地址,自然不等
        System.out.println(str1 == str2);
        System.out.println(System.identityHashCode(str1));
        System.out.println(System.identityHashCode(str2));
}

打印结果:

1940030785
1869997857
false
1940030785
1869997857

我们可以对照对应的打印顺序,会发现,str1和str2的内存地址就是不一样的,此刻str1指向的是堆内存的地址,而str2指向的是常量池中的对应字符串的地址。

 

(4)那么是不是到底如此呢?我们再假设,如果str1先执行intern()方法,那么这个时候,如果又有一个str2的实例对象,那么这个str2实例对象执行intern()方法所返回的对象应当就是和str1对象是一模一样的,因为都是指向的是str1的堆内存中的实例对象内存地址。

private static void method3() {
        //1.在堆内存中构建2个实例“JavaJustDoIt”,而常量池中没有
        String str1 = new String("Java") + new String("JustDoIt");
        String str2 = new String("Java") + new String("JustDoIt");
        str1.intern();
 
        System.out.println(str1.intern() == str1);
        System.out.println(str2.intern() == str1);
        System.out.println(str2.intern() == str1.intern());
}

结论到底是怎么样呢?我想各位同学最好直接执行一下,看看结果,亲力亲为。

 

 

说了这么多,我们画几张图片进行描述一下吧:

(1)当我们在运行过程中实例化了几个String对象,方式如下;

 

(2)当我们通过字面量方式创建了一个对象 str,将会直接指向一个常量池中的内存地址

 

(3)当我们使用的方法.intern(),就会大致如下所示:

我们可以看到“hello”对象将会创建出一个常量对象;当执行intern()方法后,就会将其指向到运行时常量池中的某个内存地址。

 

(4)我们在实现字符串拼接的时候,就会出现如下方式:

对象的拼接,将会产生新对象实例;字面量拼接,在编译时期就会将其合并。

 

 

(5)字符串对象的拼接,会产生新的对象实例,且不会先创建字符串常量实例,但是直接new String()就会先构造常量,再构造堆实例。且其中intern()在jdk7以后,不再拷贝副本,而是指针引用地址。具体的实现,我们上述讲了很多,此处不赘述。

 

 

 

拓展:

其实对于方法区中,更多的我们考究是是常量池的应用,而常量池呢,最特殊的又是字符串常量,jvm虚拟机为了减少相同字符串常量的空间的浪费,做了很多地址引用的特殊操作,我们需要学习这些内容,且很多面试题中都会出一些稀奇古怪的问题,这些问题本质考的都是对象的内存分配,以及分配的顺序。

String str1 = "j";
String str2 = "j";

两者一致,因为两者都是指向同一个常量池地址。

 

String str1 = new String("j");
String str2 = new String("j");

两者不一致,因为两者指向了不同的堆内存的实例地址。但是在构建str1的时候,就提前判断了常量池中是否存在字符串j,存在则跳过,不存在则创建;而构建str2的时候,也做了相同的判断。

String str1 = "ja";
String str2 = "va";
String str3 = str1 + str2;
String str4 = "java";

其中str3和str4对象是不相等的,因为对于变量的字符串拼接,本质是使用的StringBuilder进行拼接,实例化出一个新的对象实例了。返回的也是一个新的堆内存的实例地址。

final String str1 = "ja";
final String str2 = "va";
String str3 = str1 + str2;
String str4 = "java";

但是加上了final修饰就不一样了,会在编译时期出现常量拷贝,也就是直接将所有调用str1的地方改为“ja”,str2改为“va”,那么对于str3 = “ja” + "va" 和 str4 = “Java”就是相等的。

 

然后就是关于intern()方法的使用了,主要就是String实例主动向常量池进行申请空间,存在则指向,不存在创建,且改为指向当前堆实例的内存地址。

//1.在堆内存中构建2个实例“JavaJustDoIt”,而常量池中没有
String str1 = new String("Java") + new String("JustDoIt");
String str2 = new String("Java") + new String("JustDoIt");
str1.intern();
 
System.out.println(str1.intern() == str1);
System.out.println(str2.intern() == str1);
System.out.println(str2.intern() == str1.intern());

题目就是上方,结论就是 都是相等的;主要是 str1.intern() == str1;

因为我们意识里都是认为intern()所返回的对象都是常量池中的某个地址。

但是此处却返回的str1堆实例的内存地址。

变着法的绕。

 

 

总结:

就是关于方法区中数据的存储,我们要知道和堆内存中是分开的,并且有着非堆的别名,其中的运行常量池不一定是编译时才构造好的,也有运行时期临时组成的。我们需要了解其加载顺序,才能知道其规则。那么最后,由于元空间的出现,降低了方法区中的内存空间产生内存溢出的概率,但是不是解决了,是降低了。因为内存永远是有上限的。且方法区中的数据消耗是可变的。自然也会有OOM

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值