我于杀戮之中绽放,亦如黎明中的花朵
--戏命师·烬
开篇词
有了前面的铺垫,我们知道了所有对象都会依附一个叫做类的东西,不管是类,还是对象,都会存在JVM的内存里,那么, 在内存中又会有怎样的神秘面纱呢,本章将会详细揭晓。
运行时数据区
java虚拟机在执行java程序的过程中,他会把他所管理的内存划分为不同的内存区域。这些内存区域在JVM中有着不同的作用,JAVA虚拟机所管理的内存区域主要包括以下几个运行时数据区。
!
方法区
前面我们有讲类的加载,那么,这些类的信息存放在哪里呢?没错,就是在我们的方法区。方法区主要存放已经被虚拟机加载进来的类信息,常量,静态变量,即时编译器编译后的代码缓存数据等。当然,他是线程共享的,对于堆内存来说,它经常会被称作为“非堆”。
方法区,元空间,永久代的关系
说到方法区,就不得不提一下方法区,永久代,元空间之间的关系了。
方法区:
- 他是JVM的设计规范,属于逻辑上的东西,所有虚拟机都得遵守,其实他跟元空间,永久代是不存在可比性的。
- 是JVM所有线程共享的、用于存储已经被虚拟机加载进来的类信息,常量,静态变量,即时编译器编译后的代码缓存数据等
永久代:
PermGen,就是 PermGen space,全程是 Permanent Generation space ,是指内存的永久保存区域。
- PermGen space 则是 HotSpot 虚拟机基于JVM规范对方法区的一个落地实现,并且只有 HotSpot 才有 PermGen space。
- 而如 JRockit(Oracle)、J9(IBM) 虚拟机有方法区 ,但是就没有 PermGen space。
- PermGen space 是JDK7及之前, HotSpot虚拟机对方法区的一个落地实现。在JDK8被移除。
元空间:
元空间跟永久代的最大区别就是,元空间并不在虚拟机内存中,而是使用的是本地内存。
- 移除PermGen(永久代)从从JDK7 就开始。例如,字符串内部池,已经在JDK7 中从永久代中移除。直到JDK8 的发布将宣告 PermGen(永久代)的终结。
- 其实,移除 PermGen 的工作从 JDK7 就开始,永久代的部分数据(字符串常量池)就已经转移到了 Java Heap 或者是 Native Heap。
- 但永久代仍存在于JDK7 中,并没完全移除,比如:
- 字面量 (interned strings)转移到 Java heap;
- 类的静态变量(class statics)转移到Java heap;
- 符号引用(Symbols) 转移到 Native heap;
JDK版本 方法区的实现 字符串常量池所在的位置 JDK6 PermGen space(永久代) PermGen space(永久代) JDK7 PermGen space(永久代) Heap(堆) JDK8 Metaspace(元空间) Heap(堆)
统一成一句话就是:JDK6、JDK7 时,方法区 就是 PermGen(永久代)。 JDK8 时,方法区就是 Metaspace(元空间)。
思考个问题,为什么要用元空间来替换永久代呢?
表面上看是为了避免OOM异常。
因为通常使用PermSize和MaxPermSize设置永久代的大小就决定了永久代的上限,但是不是总能知道应该设置为多大合适, 如果使用默认值很容易遇到OOM错误。
当使用元空间时,可以加载多少类的元数据就不再由MaxPermSize控制, 而由系统的实际可用空间来控制。
更深层的原因还是要合并HotSpot和JRockit的代码,JRockit从来没有所谓的永久代,也不需要开发运维人员设置永久代的大小,但是运行良好。同时也不用担心运行性能问题了,在覆盖到的测试中, 程序启动和运行速度降低不超过1%,但是这点性能损失换来了更大的安全保障。
- 由于永久代内存经常不够用或者发生内存泄露,报出异常 java.lang.OutOfMemoryError: PermGen 。
- 字符串存在永久代中,容易出现性能问题和内存溢出。
- 类及方法的信息等比较难确定其大小,因此对于永久代的大小指定比较困难,太小容易出现永久代溢出,太大则容易导致老年代溢出。
- 永久代会位GC带来不必要的复杂度,而且回收效率偏低。
- Oracle可能会将HotSpot和JRockit合二为一。
堆(Heap)
对于Java应用程序来说,Java堆是虚拟机所管理的内存中最大的一块,java堆是线程共享的区域,在虚拟机启动的时候创建。此区域唯一的目的就是存放对象的实例,java中几乎所有的实例都会在这个堆内存中分配。
堆内存的划分
在默认情况下,堆内存会按照1:2(并不是绝对的,会有点差异)的比例划分为新生代和老年代,而新生代会按照8:1:1 划分为Eden区:Survior From区: Survior To区。 而这些区域的划分跟垃圾收集器有着密不可分的关系,这些划分标准也都是基于经典分代来进行设计的,而现在有的收集器不是这样的,比如G1收集器。
逃逸分析,栈上分配,标量替换(了解)
当然,如果有些对象占用内存比较小,并且他们根据逃逸分析之后,并没有发生逃逸,这个对象将会进行标量替换,分配在栈内存上。
逃逸分析一般分为两种,一种是方法逃逸,另一种是线程逃逸,例如作为调用参数传递到其他方法中,这种称为方法逃逸;甚至还有可能被外部线程访 问到,譬如赋值给可以在其他线程中访问的实例变量,这种称为线程逃逸;从不逃逸、方法逃逸到线 程逃逸,称为对象由低到高的不同逃逸程度。
当一定的对象满足能够栈上分配的条件的时候,这部分数据会在栈上分配,随着栈帧从栈中弹出而进行销毁。
java虚拟机栈
java虚拟机栈是程序私有的,他的生命周期与线程同步。
java在运行的时候,一定是线程来执行某个方法的,对每一个线程来说,都会有一个虚拟机栈,比如在上面的main()方法里,其实就有一个“replicaManager”局部变量,因此,JVM必须有一块区域是来保存每个方法内的局部变量等数据的,这个区域就是Java虚拟机栈
每个线程都有自己的Java虚拟机栈
线程没调用一个方法,都会形成一个栈帧,调用的时候,就会把这个栈帧压入到这个栈中,方法执行完毕的话,这个栈帧就会出栈。
栈帧里面包含有这个方法的局部变量表,操作数栈,动态链接,方法出口等信息。
局部变量表存放了编译期可知的各种Java虚拟机基本数据类型(boolean、byte、char、short、int、 float、long、double)、对象引用(reference类型,它并不等同于对象本身,可能是一个指向对象起始 地址的引用指针,也可能是指向一个代表对象的句柄或者其他与此对象相关的位置)和returnAddress 类型(指向了一条字节码指令的地址)。
本地方法栈
本地方法栈(Native Method Stacks)与虚拟机栈所发挥的作用是非常相似的,其区别只是虚拟机 栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的本地(Native) 方法服务。
程序计数器
程序计数器(Program Counter Register)是一块较小的内存空间,它可以看作是当前线程所执行的 字节码的行号指示器。在Java虚拟机的概念模型里,字节码解释器工作时就是通过改变这个计数器 的值来选取下一条需要执行的字节码指令,它是程序控制流的指示器,分支、循环、跳转、异常处 理、线程恢复等基础功能都需要依赖这个计数器来完成。
由于Java虚拟机的多线程是通过线程轮流切换、分配处理器执行时间的方式来实现的,在任何一 个确定的时刻,一个处理器(对于多核处理器来说是一个内核)都只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程 之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。
如果线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是本地(Native)方法,这个计数器值则应为空(Undefined)。此内存区域是唯 一一个在《Java虚拟机规范》中没有规定任何OutOfMemoryError情况的区域。
运行时常量池
运行时常量池是存在于方法区的,Class文件除了魔数,版本号,字段,方法,接口等描述信息,还有一项重要信息就是常量池表,用于编译期生成的各种字面量与符号引用,这部分在类加载后存放到方法区的运行时常量池中。
运行时常量池相对于Class文件常量池的另外一个重要特征是具备动态性,Java语言并不要求常量 一定只有编译期才能产生,也就是说,并非预置入Class文件中常量池的内容才能进入方法区运行时常 量池,运行期间也可以将新的常量放入池中,这种特性被开发人员利用得比较多的便是String类的 intern()方法。
既然运行时常量池是方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存 时会抛出OutOfMemoryError异常。
基于JDK8比较 Class文件常量池,运行时常量池,字符串常量池的关系与区别
Class文件常量池:
存在于Class文件中,是运行时常量池未解析之前的状态,而Class文件信息又存放在方法区中,所以Class文件常量池是存放在方法区中的。 每个类都会有这个类文件常量池,等它被解析完之后,就变成了运行时常量池。 它对于运行时常量池来说是静态的,无法动态添加。
运行时常量池:
产生在编译阶段,是存放在方法区的,每个类都有一份运行时常量池,所谓的运行时常量池其实就是将编译后的类信息放入运行时的一个区域中,用来动态获取类信息。
运行时常量池是在类加载完成之后,将每个class常量池中的符号引用值转存到运行时常量池中,也就是说,每个class都有一个运行时常量池,类在解析之后,将符号引用替换成直接引用,与全局常量池中的引用值保持一致。
字符串常量池:
在JDK1.7开始就慢慢把字符串常量池从方法区迁移到堆内存中。在此之前 是存放在永久代(方法区)中。
常量池和字符串常量池的版本变化
在JDK1.7之前运行时常量池逻辑包含字符串常量池存放在方法区, 此时hotspot虚拟机对方法区的实现为永久代
在JDK1.7 字符串常量池被从方法区拿到了堆中, 这里没有提到运行时常量池,也就是说 字符串常量池被单独拿到堆,运行时常量池剩下的东西还在方法区, 也就是hotspot中的永久代
在JDK1.8 hotspot移除了永久代用元空间(Metaspace)取而代之, 这时候字符串常量池还在堆, 运行时常量池还在方法区, 只不过方法区的实现从永久代变成了元空间(Metaspace)
String.intern()在jdk1.6与Jdk1.7之后的区别
详细分析
先来说下这个intern是用来做什么的?JDK1.6以及之前,intern方法会先判断这个常量在常量池中是否存在,如果存在,则返回当前常量的引用,如果不存在,则拷贝一份,放入到常量池中,返回其引用地址。JDK 1.7后,intern方法还是会先去查询常量池中是否有已经存在,如果存在,则返回常量池中的引用,这一点与之前没有区别,区别在于,如果在常量池找不到对应的字符串,则不会再将字符串拷贝到常量池,而只是在常量池中生成一个对原字符串的引用。简单的说,就是往常量池放的东西变了:原来在常量池中找不到时,复制一个副本放到常量池,1.7后则是将在堆上的地址引用复制到常量池。
接下来,主要针对于JDK1.7 进行intern的分析,接下来看看几个已经烂大街的网上的案例以及问题。
**案例 1. **
String a1 = new String("abcd");
String a2 = "abcd";
System.out.println(a1 == a2);
System.out.println(a1 == a1.intern());
System.out.println(a2 == a1.intern());
Q:上述代码中,字符串abcd是否会在常量池中生成?最终输出结果是true还是false?
A:看下面这个截图
JVM会在堆中创建一个String对象,会判断字符串常量池中是否存在abcd这个字符串,如果没有,就将这个字符串保存到字符串常量池中,如果有的话,则吧常量池中abcd这个引用赋值给堆内存中的这个String对象。
最终结果返回的是false ,false ,true,第一个false是因为a1是指向堆内存中的String对象,a2指向的是常量池中的这个引用;
第二个false是因为执行intern的时候,发现字符串常量池中已经存在abcd这个字符串了,所以a1.intern()应该直接等于a2的。
案例2.
String a1 = new String("abcd") + new String("efgh");
String a2 = a1.intern();
String a3 = "abcdefgh";
System.out.println(a1 == a2);
System.out.println(a1 == a3);
System.out.println(a2 == a3);
Q: 内存分布是怎样的,常量池中又存放了哪些数据?输出结果是怎样的?
A: 首先,代码第一行执行完之后,常量池中有abcd和 efgh字符串,并没有abcdefgh这个字符串,堆内存中有abcdefgh这个对象的实例(他目前还不在字符串常量池中);然后执行a1.intern();这个方法的时候,检查字符串常量池中,发现没有abcdefgh这个字符串,由于基于JDK1.8,所以将会看堆内存中,把abcdefgh这个字符串的引用地址放到了常量池中,a3直接拿常量池中的这个值的引用。
通过以上分析,可以得出输出结果分别是true,true,true,这三个值地址其实都是a1的堆内存地址(引用地址)
案例3.
String a1 = new String("abcd") + new String("efgh");
String a3 = "abcdefgh";
String a2 = a1.intern();
System.out.println(a1 == a2);
System.out.println(a1 == a3);
System.out.println(a2 == a3);
注:案例三跟案例2的区别就是将a3的位置跟a2的位置进行调换,同样的问题提问? A: 最后输出结果是false,false,true; 分析如下: 首先呢,代码执行完第一行,在堆内存中有abcdefgh这个字符串,但是这个字符串并不存在于常量池中,a1指向的是堆内存中的这个实例。 然后执行a3,a3直接指向了字符串常量池中的abcdefgh这个字符串,最后执行a2,也就是a1.intern(),发现 abcdefgh已经存在于字符串常量池了,就返回这个字符串,于是a2实际上是跟a3相等的,但是跟a1不相等。
String s=new String(“abc”)到底创建了几个对象?
如果在执行String s=new String(“abc”)这句话之前,常量池中并没有"abc",那么在创建new String()时会先在常量池中 创建字符串常量,然后通过这个字符串常量,在堆中创建一个新的字符串,但是这两个字符串对象(常量池中的"abc"和堆中的"abc")底层保存字符的数组都是一个。
如果在执行之前就有了"abc"这个字符串常量了(例如上面的代码),在执行String s=new String(“abc”)这句话时,也就只在堆中创建一个对象了。
验证常量池中的"abc"和堆中的"abc"底层保存字符的数组都是一个
前面我们查看String的构造器后得出结论,采用new关键字在堆中创建的"abc"和常量池中"abc"虽然对象不是一个,但是它们两个对象底层指向的数组是一个,那我们就通过代码验证这个结论。
整体思路是这样的:我们通过反射,修改常量词中字符串对象底层数组的值,看堆中的String对象的值是否跟着改变:
public static void main(String[] args) throws NoSuchFieldException, IllegalAccessException {
// String s1 = new String("a") ;
// //String s3 = "a";
// System.out.println(s1.intern() == s1);
//通过常量词中的"abc"在堆中创建一个String对象
String str2 = new String("abc");
//str2.intern();
String str = "abc";
//获取String类中的value字段
Field field = String.class.getDeclaredField("value");
//将字段设置为可访问的
field.setAccessible(true);
//获取str对象上的value属性的值
char[] arr = (char[]) field.get(str);
arr[2]='1';
System.out.println(str);//输出:ab1
System.out.println(str2);//输出:ab1
// System.out.println(a3 == a1.intern());//true
// System.out.println(a3.intern() == a1);//false
}
再次分析:
16行是关键,他会导致输出结果不同,如果没有这行代码,则str指向的是堆内存的abcabc,str2指向的是字符串常量池中的abcabc, 如果有了这行代码,str跟str2都是指向str2的引用(因为常量池中存的就是这个引用),也就是同一个东西,改变其中一个也就两个都变了
直接内存:
直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是《Java虚拟机规范》中 定义的内存区域。但是这部分内存也被频繁地使用,而且也可能导致OutOfMemoryError异常出现
结束语
这节篇章主要讲的是java运行时数据区是怎样的,只是大概的抛砖引玉,另外主要在那几个常量池的对比,网上也有很多资料,但是也有好多都是错的,希望大家找些比较靠谱的资料来看吧,我也是看了好久,晕了,不过慢慢理解觉得这个版本还是对的。还有intern这个方法确实有点恶心,因为JDK版本的不同导致实现出现出乎意料的结果,希望大家能够理解。最后,希望各位看官能够手下留情,如果有不对的地方,还请各位能够指出来,共同学习,进步!
很喜欢烬的这句台词,代表经历磨难后新的生命吧,加油 各位看官