目录
一.概述
二.运行时数据区域
2.1程序计数器
2.2 Java虚拟机栈
2.3本地方法栈
2.4 Java堆
2.5 方法区
2.5.1 方法区与永久代的关系
2.5.2 常用参数
2.5.3 为什么要将永久代(PermGen)替换成元空间(MetaSpace)?
2.6 运行时常量池
2.7 直接内存
三.HotSpot虚拟机对象探秘
3.1 对象的创建
3.2 对象的内存布局
3.3 对象的访问定位
四.重点补充内容
4.1String类和常量池
4.2 String s1=new String("abc");这句话创建了几个字符串对象?
4.2 8种基本类型的包装类和常量池
对于Java程序员来说,在虚拟机自动内存管理机制的帮助下,不再需要为每一个new操作去写配对的delete/fress代码,不容易出现内存泄漏和内存溢出问题,由虚拟机管理内存。也是因为Java程序员把内存控制的权利交给了Java虚拟机,一旦出现了内存泄漏和溢出方面的问题,如果不了解虚拟机怎样使用内存,那么排查错误会成为异常艰难的工作。
Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域。这些区域都有各自的用于,以及创建和销毁的时间。JDK. 1.8 和之前的版本略有不同,下面会介绍到。
JDK1.8之前:
JDK1.8:
程序计数器(Program Counter Register)是一块较小的内存空间,可以看作是当前线程所执行的字节码的行号指示器。在虚拟机的概念模型中,字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支,循环,跳转,异常处理,线程恢复等基础功能都需要依赖这个计数器完成。
由于Java虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器(对于多核处理器是一个内核)都只会执行一条线程中的指令。因此,为了线程切换后能够恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,个线程之间计数器互不影响,独立存储,称这类内存区域为“线程私有”的内存。
如果线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是Native方法,这个计数器值为空(Undefined)。此内存区域是唯一一个在Java虚拟机规范中没有规定任何OutOfMemoryError情况的区域。
从上面的介绍中我们知道程序计数器主要有两个作用:
与程序计数器一样,Java虚拟机栈(Java Virtual Machine Stacks)也是线程私有的,它的生命周期与线程相同。虚拟机栈描述的是Java方法执行的内存模型:每个方法在执行的同时会创建一个栈帧(Stack Frame)用于存储局部变量表,操作数栈,动态链接,方法出口等信息。每个方法从调用直至执行完成的过程,就对应一个栈帧在虚拟机栈中入栈到出栈的过程。
Java 虚拟机栈也是线程私有的,每个线程都有各自的 Java 虚拟机栈,而且随着线程的创建而创建,随着线程的死亡而死亡。
经常有人把Java内存区域分为堆内存(Heap)和栈内存(Stack),这种方法比较粗糙。这种划分的方式中指的“栈”就是现在讲的虚拟机栈,或者说是虚拟机栈中局部变量表。
局部变量表存放了编译期可知的各种基本数据类型(boolean,byte,char,short,int,float,long,double),对象引用(reference类型,不同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置)和returnAddress(指向一条字节码指令的地址)。局部变量表所需要的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。
注意:其中64位长度的long和double类型的数据会占用2个局部变量空间(Slot),其余的数据只占用一个。
在Java虚拟机规范,对这个区域规定了两种异常状况:
扩展:那么方法/函数如何调用?
Java 栈可用类比数据结构中栈,Java 栈中保存的主要内容是栈帧,每一次函数调用都会有一个对应的栈帧被压入 Java 栈,每一个函数调用结束后,都会有一个栈帧被弹出。
Java 方法有两种返回方式:
不管哪种返回方式都会导致栈帧被弹出。
本地方法栈(Native Method Stack)与虚拟机栈所发挥的作用是非常相似的,区别:虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则为虚拟机栈使用的是Native方法服务。在虚拟机规范中对本地方法栈中的方法使用的语言,使用方式和数据结构并没有强制规定,因此具体的虚拟机可以自由实现它。甚至有的虚拟机(HotSpot虚拟机)直接把本地方法栈和虚拟机栈合二为一。
本地方法被执行的时候,在本地方法栈也会创建一个栈帧,用于存放该本地方法的局部变量表、操作数栈、动态链接、出口信息。
方法执行完毕后相应的栈帧也会出栈并释放内存空间,也会出现 StackOverFlowError 和 OutOfMemoryError 两种异常。
Java堆(Java Heap)是Java虚拟机所管理的内存中最大的一块。Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存。这一点在Java虚拟机规范中的描述是:所有的对象实例以及数组都要在堆上分配,但是随着技术的发展,所有对象都分配在堆上也渐渐变得不那么“绝对”。
Java堆是垃圾收集器管理的主要区域,因此很多时候也被称为“GC堆(Garbage Collect Heap)”。从内存回收的角度来讲,由于现在收集器基本都采用分代收集算法,所以Java堆还可以细分为:新生代和老年代。再细致一点是:Eden空间,From Survivor空间,To Survivor空间等。从内存分配的角度来讲,线程共享的Java堆可能划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer,TLAB)。进一步划分的目的是为了更好的回收内存,或者更快的分配内存
根据Java虚拟机规范的规定,Java堆可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可,就像磁盘空间一样。在实现时,既可以实现固定大小的,也可以是可扩展的,当前主流的虚拟机是按照可扩展实现(通过-Xmx和-Xms控制)。
如果在堆中没有内存完成实例分配,并且堆也无法再扩展,将会抛出OutOFMemoryError异常。
方法区(Method Area)与Java堆一样,是各个线程共享的内存区域,用于存储已被虚拟机加载的类信息,常量,静态变量,即时编译器编译后的代码等数据。虽然Java虚拟机规范把方法区描述为堆的一个逻辑部分,但是它有一个别名叫做Non-Heap(非堆),目的应该是与Java堆区分开来。
对于习惯在HotSpot虚拟机上开发,部署程序的开发者来说,很多人都更愿意把方法区称为“永久代(Permanent Generation)”,本质上两者并不等价,仅仅是因为HotSpot虚拟机的设计团队选择吧GC分代收集扩展至方法区,或者说使用永久代来实现方法区而已,这样HotSpot的垃圾收集器可以像管理Java堆一样管理这部分内存,能够省去专门为方法区编写内存管理代码的工作。对于其他虚拟机来说是不存在永久代的概念。
总结来说:《Java 虚拟机规范》只是规定了有方法区这么个概念和它的作用,并没有规定如何去实现它。那么,在不同的 JVM 上方法区的实现肯定是不同的了。 方法区和永久代的关系很像 Java 中接口和类的关系,类实现了接口,而永久代就是 HotSpot 虚拟机对虚拟机规范中方法区的一种实现方式。 也就是说,永久代是 HotSpot 的概念,方法区是 Java 虚拟机规范中的定义,是一种规范,而永久代是一种实现,一个是标准一个是实现,其他的虚拟机实现并没有永久代这一说法。
JDK1.8之前永久代还没被彻底移除的时候通常通过下面的参数来调节方法区的大小:
-XX:PermSize=N //方法区(永久代)初始大小
-XX:MaxPermSize=N //方法区(永久代)最大大小,超过这个值会抛出OutOfMemoryError异常
Java虚拟机规范对方法区的限制十分的宽松,除了和Java堆一样不需要连续的内存和可以选择固定大小或者可扩展外,还可以选择不实现垃圾收集。相对而言,垃圾收集行为在这个区域是比较少出现的,但并非这个数据进入了方法区就如永久代的名字一样“永久”存在了。这个区域的内存回收目标主要是针对常量池的回收和对类型的卸载,一般来说,这个区域的回收成绩比较难让人满意,尤其是类型的卸载。但是这个部分区域的回收是必要的。根据Java虚拟机规范的规定,当方法区无法满足内存分配需求时,将抛出OutOfMemoryError异常。
JDK1.8的时候,方法区(HotSpot的永久代被彻底移除了),取而代之是元空间,元空间使用的是直接内存。
下面是一些常用参数:
-XX:MetaspaceSize=N //设置Metaspace的初始大小(和最小大小)
-XX:MaxMetaspaceSize=N //设置最大大小
与永久代很大的不同就是,如果不指定大小的话,随着更多类的创建,虚拟机会耗尽所有可用的系统内存。
整个永久代有一个JVM本身设置固定大小上限,无法进行调整,而元空间使用的是直接内存,受本机可用内存的限制,并且永远不会得到java.lang.OutOfMemoryError异常。你可以使用-XX:MaxMetaspaceSize标志设置最大空间大小,默认值为unlimited,这意味着它只受系统内存的限制。-XX:MetaspaceSize调整标志定义元空间的初始大小如果未指定此标志,则Metaspace将根据运行时应用程序需求动态地重新调整大小。
这只是一个原因,还有很多底层原因。
运行时常量池(Runtime Constant Pool)是方法区的一部分。Class文件除了有类的版本,字段,方法和接口等描述信息外,还有一项信息是常量池(Constant Pool Table),用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行常量池中存放。
Java虚拟机对Class文件每一部分(自然也包括常量池)的格式都有严格规定,每一个字节用于存储哪种数据都必须符合规范上的要求才会被虚拟认可,装载和执行,但是对于运行时常量池,Java虚拟机规范没有做任何细节的要求,不同的提供商实现的虚拟机可以按照自己的需求来实现这个区域,一般来说,除了保存Class文件中描述符号引用外,还会把翻译出来的直接引用也存储在运行常量池中。
方法区存着类的信息,常量和静态变量,即类被编译后的数据。这个说法其实是没问题的,只是太笼统了。更加详细一点的说法是方法区里存放着类的版本,字段,方法,接口和常量池。常量池里存储着字面量和符号引用。下图是class文件信息,class文件常量池和运行时常量池的关系:
Class文件除了有类的版本,字段,方法和接口等描述信息外,还有一项信息是常量池(Constant Pool Table),用于存放编译期生成的各种字面量(Literal)和符号引用(Symbolic Reference)。字面量就是我们常说的 常量概念,如文本字符串,被声明为final的常量等。符号引用是一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要在使用的时候能无歧义定位到目标就行(与直接引用区分一下,直接引用一般是指向方向区的本地指针,位移偏移量或者是一个能间接定位到目标的句柄)。
下面用一张图来表示常量池里存储的内容:
上面的图片来源于:https://blog.csdn.net/wangbiao007/article/details/78545189
静态常量池(class文件常量池)与动态常量池(运行时常量池)的关系与区别:
静态常量池存储的是当class文件被java虚拟机加载进来后存放在方法区的一些字面量和符号引用,字面量包括字符串,基本类型的常量,符号引用其实引用的就是常量池里面的字符串,但符号引用不是直接存储字符串,而是存储字符串在常量池里的索引。
动态常量池是当class文件被加载完成后,java虚拟机会将静态常量池里的内容转移到动态常量池里,在静态常量池的符号引用有一部分是会被转变为直接引用的,比如说类的静态方法或私有方法,实例构造方法,父类方法,这是因为这些方法不能被重写其他版本,所以能在加载的时候就可以将符号引用转变为直接引用,而其他的一些方法是在这个方法被第一次调用的时候才会将符号引用转变为直接引用的。
上面这部分参考了:https://blog.csdn.net/wangbiao007/article/details/78545189
直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域。但是这部分的内存被频繁地使用,而且也可能导致OutOfMemoryError异常出现。
在JDK1.4中新加入了NIO(New Input/Output)类,引入了一种基于通道(Channel)与缓冲区(Buffer)的I/O方式,可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆中的DirectByteBuffer对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在Java堆和Native堆中来回复制数据。
本机直接内存的分配不会受到Java堆大小的限制,但是肯定会受到本机总内存(包括RAM以及SWAP区或者分页文件)大小以及处理器寻址空间的限制。服务器管理员在配置虚拟机参数时,会根据实际内存设置-Xmx等参数信息,但是经常忽略直接内存,使得各个内存区域总和大于物理限制(包括物理和操作系统级的限制),从而导致动态扩展时出现OutOfMemoryError异常。
通过上面的介绍我们大概知道了虚拟机的内存情况,下面我们来详细的了解一下 HotSpot 虚拟机在 Java 堆中对象分配、布局和访问的全过程。
虚拟机遇到一条new指令时,首先将检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否被加载,解析和初始化过。如果没有,那必须先执行相应的类加载过程。(后面会讨论这部分细节)
在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需的内存的大小在类加载完成后便可以确定,为对象分配空间的任务等同于把一块确定大小的内存从Java堆中划分出来,分配方式有“指针碰撞”和“空闲列表”两种。选择哪种分配方式由Java堆是否规整决定,而Java堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。
内存分配的两种方式:(补充内容,需要掌握)
选择以上两种方式中的哪一种,取决于 Java 堆内存是否规整。而 Java 堆内存是否规整,取决于 GC 收集器的算法是"标记-清除",还是"标记-整理"(也称作"标记-压缩"),值得注意的是,复制算法内存也是规整的。
分配方式 | 适用场合 | 原理 | GC收集器 |
指针碰撞 | 堆内存绝对规整(没有内存碎片)的情况下 | 所有的内存都放到一边,空闲的内存放到另一边,中间放着一个指针作为分界点的指示器,分配内存就仅仅是把那个指针向空闲空间那边挪动一段与对象大小相等的距离。 | Serial,ParNew |
空闲列表 | 堆内存并不是规整,已经使用的内存和空闲的内存相互交错 | 虚拟机就必须维护一个列表,记录哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录。 | CMS |
内存分配并发问题:
对象创建在虚拟机是非常频繁的行为,即使是仅仅修改一个指针所执行的位置,在并发情况下也并不是线程安全的,也可能出现正在给对象A分配内存,指针还没来得及修改,对象B又同时使用了原来的指针来分配内存。解决这个问题有两个方法:
内存分配完后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),如果使用TLAB,这一工作过程也可以提前至TLAB分配时进行。这一步操作保证了对象的实例字段在Java代码中可以不赋值就直接使用,程序能访问到这些字段的数据类型所对应的零值。
接下来,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例,如何才能找到类的元数据信息,对象的哈希码,对象的GC分代年龄等信息。这些信息存放在对象的对象头(Object Header)之中。根据虚拟机当前到的运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。
在上面的过程都完成后,从虚拟机的角度来看,一个新的对象已经产生了,但从Java程序的视角来看,对象创建才刚刚开始-
在HotSpot虚拟机中,对象在内存存储的布局可以分为3块区域:对象头(Header),实例数据(Instance Data)和对齐填充(Padding)。
HotSpot虚拟机的对象头包括两部分信息,第一部分用于存储对象自身的运行时数据,如哈希码,GC分代年龄,锁状态标志,线程持有的锁,偏向线程ID等,这部分数据的长度在32位和64位的虚拟机(未开启压缩指针)分别为32bit和64bit,官方称它为“Mark Word”。对象需要存储的运行时数据很多,其实已经超出32位,64位Bitmap结构所能记录的限度,但是对象头信息是与对象自身定义的数据无关的额外存储成本,考虑到虚拟机的空间效率,Mark Word被设计成一个非固定的数据结构以便在极小的空间内存储尽量多的信息,会根据对象得到状态复用自身的存储空间。
对象头的另一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。并不是所有的虚拟机实现都必须在对象数据上保留类型指针,换句话说,查找对象的元数据信息并不一定要经过对象本身。另外,如果对象是一个Java数组,那在对象头中还必须有一块记录数组长度的数据,因为虚拟机可以通过普通Java对象的元数据信息确定Java对象的大小,但是从数组的元数据却无法确定数组的大小。
实例数据部分是对象真正存储的有效信息,也是在程序代码中所定义的各种类型的字段内容。无论是从父类继承下来的,还是在子类中定义的,都需要记录下来。这部分的存储顺序会受到虚拟机分配策略参数(FieldsAllocationStyle)和字段在Java源码中定义顺序的影响。HotSpot虚拟机默认的分配策略为long/doubles,ints,shorts/chars,bytes/booleans,oops(Ordinary Object Pointers),从分配策略来看,相同宽度的字段总是被分配在一起。在满足这个前提条件的情况下,在父类中定义的变量会出现在子类之前。如果CompactFields参数值为true,那么子类较窄的变量也可能会插入到父类变量的空隙之间。
第三部分的对齐填充并不是必然存在的,也没有特别含义,仅仅起着占位符的作用。由于HotSpot VM的自动内存管理系统要求对象的起始地址必须是8字节的整数倍,而对象头正好是8字节的倍数,因此,当对象的实例数据部分没有对齐时,需要通过对齐填充来补全。
建立对象是为了使用对象,Java程序需要通过栈上的reference数据来操作堆上的具体对象。由于reference类型在Java虚拟机规范中只规定了一个指向对象的引用,并没有定义这个引用应该通过何种方式去定位,访问堆中的对象的具体位置,所以对象的访问方式也是取决于虚拟机实现而定的。目前主流的访问方式由两种:
1.使用句柄:如果使用句柄访问的话,那么Java堆中会划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息。通过句柄访问对象如下图所示:
2.直接指针:如果使用直接指针访问,那么Java堆对象的布局就必须考虑如何放置访问类型数据的相关信息,而reference中存储的直接就是对象地址。通过直接指针访问对象如下图所示:
这两种对象访问方式各有优势:
参考:JavaGuide
String对象的两种创建方式:
String str1="abcd" //先检查字符串常量池中有没有“abcd”,如果字符串常量池中没有,则创建一个,然后str1指向字符串常量池中的对象,如果有,则直接将str1指向“abcd”;
String str2=new String("abcd"); //堆中创建一个对象
String str3=new String(""abcd"); //堆中创建一个对象
System.out.println(str1==str2); //false
System.out.println(str2==str3); //false
这两种不同的创建方法是有差别的:
注意:只要使用new方法,便需要创建新的对象。
String类型的常量池比较特殊,它的主要使用方法有两种:
String s1=new String("你好");
String s2=s1.intern();
String s3="你好";
System.out.printlln(s2); //你好
System.out.println(s1==s2); //false,因为一个是堆内存中的String对象,一个是常量池中的String对象
System.out.println(s2==s3); //true 因为两个都是常量池的String对象
字符串拼接:
String str1="do";
String str2="ing";
String str3="do"+"ing"; //常量池中的对象
String str4=str1+str2; //在堆上创建新的对象
String str5="doing"; //常量池中的对象
System.out.println(str3==str4); //false
System.out.println(str3==str5); //true
System.out.println(str4==str5); //false
尽量避免多个字符串拼接,因为这样会重新创建对象。如果需要该表字符串的话,可以使用StringBuilder或者StringBuffer。
将创建1或两个字符串。如果池中已经存在字符串常量"abc",则只会在堆空间创建一个字符串常量“abc”。如果池中没有字符串常量“abc”,那么它将首先在池中创建,然后在堆中创建,因此将创建总共两个字符串对象。
验证:
String s1=new String("abc");
String s2="abc";
System.out.println(s1==s2); //false,因为一个是堆内存,一个是常量池内存,两者是不同的。
System.out.println(s1.equals(s2)); //true
Java基本类型二等包装类大部分都实现了常量池技术,即Byte,Short,Integer,Long,Character,Boolean;这5种包装类默认创建了数值[-128,127]的相应类型的缓存数据,但是超出此范围仍然会去创建新的对象。为什么把缓存设置在[-128,127]区间?参见issue/461 性能和资源之间的权衡。
两种浮点数类型的包装类Float,Double没有实现常量池技术。
Integer i1=33;
Integer i2=33;
System.out.println(i1==i2); //true
Integer i11=333;
Integer i22=333;
System.out.println(i11==i22); //false
Double i3=1.2;
Double i4=1.2;
System.out.println(i3==i4); //false
Integer 缓存源代码:
/**
*此方法将始终缓存-128 到 127(包括端点)范围内的值,并可以缓存此范围之外的其他值。
*/
public static Integer valueOf(int i) {
if (i >= IntegerCache.low && i <= IntegerCache.high)
return IntegerCache.cache[i + (-IntegerCache.low)];
return new Integer(i);
}
应用场景:
Integer i1=40;
Integer i2=new Integer(40);
System.out.println(i1==i2); //false
//比较丰富的一个例子
Integer l1=40;
Integer l2=40;
Integer l3=0;
Integer l4=new Integer(40);
Integer l5=new Integer(40);
Integer l6=new Integer(0);
System.out.println(l1==l2); //true
System.out.println(l1==l2+l3); //true
System.out.println(l1==l4); //false
System.out.println(l4==l5); //false
System.out.println(l4==l5+l6); //true
System.out.println(40==l5+l6); //true
//l4==l5+l6,因为+这个操作符不适合Integer对象,首先l5和l6进行自动拆箱操作,进行数值相加,即i4==40。然后Integer对象无法与数值直接进行比较,所以l4自动拆箱转为int值40,所以最终这条语句转为40==40进行数值比较