文章目录
- 一、运行时数据区域
- 1、程序计数器
- 2、Java虚拟机栈
- 3、本地方法栈
- 4、Java堆
- 5、方法区
- 6、运行时常量池
- 7、直接内存
- 二、HotSpot虚拟机对象
- 1、对象的创建
- 2、对象的内存布局
- 3、对象的访问定位
- 三、OutOfMemoryError和StackOverflowError异常
- 1、Java堆溢出(最常见)
- 2、虚拟机栈和本地方法栈溢出
- 1、通过设置-Xss参数减少栈内存容量使栈溢出
- 2、定义大量本地变量,增大此方法帧中本地变量表的长度使栈溢出
- 3、方法区和运行时常量池溢出
- 3.1 运行时常量池溢出
- 3.2 方法区溢出
- 4、本机直接内存溢出
- 总结
一、运行时数据区域
运行时数据区域有1、Method Area方法区 2、VM Stack虚拟机栈 3、Native Method Stack本地方法栈 4、Heap堆 5、Program Counter Register程序计数器。
Method Area方法区、Heap堆是由所有线程共享的数据区。其余3个是线程隔离的数据区。
1、程序计数器
- Program Counter Register程序计数器是一块较小的内存空间,它可以看做是当前线程所执行的字节码的行号指示器。在Java虚拟机概念模型中,字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。
即Program Counter Register是一个“数值”,字节码解释器可以改变它,线程需要使用它才能继续向下执行。 - Java虚拟机的多线程是通过线程轮流切换,分配处理器执行时间的方式来实现的。为了线程切换后能恢复到正确的执行位置,每个线程都需要有一个独立的程序计数器,各条线程之间计数器互不影响,独立存储,这类内存区域为“线程私有”的内存。
- 如果线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;
如果正在执行的是本地方法,这个计数器值为空(Undefined)。
2、Java虚拟机栈
Java虚拟机栈也是线程私有的,它的生命周期和线程相同。虚拟机栈描述的是Java方法执行的内存模型:每个方法被执行时,Java虚拟机都会同步创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态连接、方法出口等信息。每一个方法被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。
虚拟机的局部变量表存放了编译期可知的各种Java虚拟机基本数据类型(byte、short、int、long、boolean、char、float、double)、对象引用(reference类型,它并不等同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或者其他与此对象相关的位置)和returnAddress类型(指向了一条字节码指令的地址)。
这些数据类型在局部变量表中的存储空间以局部变量槽(slot)来表示,其中64位长度的long和double类型的数据会占用两个变量槽,其余的数据类型只占一个。局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要的栈帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。
内存区域规定的两类异常状况:1、线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverFlowError异常。2、Java虚拟机栈容量可以动态扩展,当栈扩展时,无法申请到足够的内存会抛出OutOfMemoryError异常。
3、本地方法栈
本地方法栈和虚拟机栈的区别只是虚拟机栈为虚拟机执行Java方法服务,而本地方法栈则是为虚拟机使用到的本地方法服务。
本地方法栈也会抛出StackOverFlowError和OutOfMemoryError异常。
HotSpot虚拟机直接就把本地方法栈和虚拟机栈合二为一。
4、Java堆
对于Java应用程序而言,Java堆(Java Heap)是虚拟机所管理的内存中最大的一块。Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,Java中几乎所有对象实例都在这里分配内存。
Java堆是垃圾收集器管理的内存区域,也称作GC堆。
Java堆既可以实现成固定大小,也可以是可扩展的,当前主流的Java虚拟机都是按照可扩展来实现的(通过参数-Xmx和-Xms设定)。如果Java堆中没有内存完成实例分配,并且堆也无法再扩展时,Java虚拟机将会抛出OutOfMemoryError异常。
5、方法区
方法区也是各个线程共享的内存区域,它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。
JDK8后,方法区将在本地内存中实现的元空间来代替永久代。
6、运行时常量池
运行时常量池(Runtime Constant Pool)是方法区的一部分。
Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池表,用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。
并非预置入Class文件后常量池的内容才能进入方法区运行时常量池,运行期间也可以将新的常量放入池中,这种特性被利用得比较多的就是String类的intern()方法。
7、直接内存
直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,但是也被频繁使用,也可以导致OutOfMemoryError异常。
JDK1.4中加入NIO(New Input/Output)类,引入了一种基于通道(Channel)与缓冲区(Buffer)的I/O方法,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆里面的DirectByteBuffer对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在Java堆和Native堆中来回复制数据。
二、HotSpot虚拟机对象
1、对象的创建
当虚拟机遇到一条字节码new指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,则必须先执行相应的类加载过程。
虚拟机为新生对象分配内存,有指针碰撞(Bump The Pointer)和空闲列表(Free List)两种方式。
1)指针碰撞,当Java堆中内存是绝对规整的,所有被使用过的内存都被放在一边,空闲的内存被放在另一边,中间放着一个指针作为分界点的指示器,分配内存时就仅仅是把那个指针向空闲空间方向挪动一段与对象大小相等的距离。
2)空闲列表,当Java堆中内存不是规整的,已使用的内存和空闲内存相互交错在一起,那就没有办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记录上哪些内存块是可用的,在分配的时候回从列表中找到一块足够大的空间划分给对象实例,并更新表上的记录。
这两种划分可用空间,当遇到并发情况时,不是线程安全的。
需要在划分可用空间的基础上,解决并发,有对分配内存空间的操作进行同步处理和本地线程分配缓冲两种解决方案。
1)对分配内存空间的操作进行同步处理,也就是虚拟机采用的CAS配上失败重试的方式来保证更新操作的原子性。
2)本地线程分配缓冲,即每个线程在Java堆中预先分配一小块内存。当本地缓冲区用完了,分配新的缓冲区时才需要同步锁定。可以通过-XX:+/-UseTLAB参数来设定。
此时从虚拟机的视角,一个新的对象已经产生;程序中遇到new之后会继续执行init()方法,此时一个真正可用的对象才算完全被构造出来。
2、对象的内存布局
对象在堆内存中的存储布局可以划分为3个部分:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。
1)对象头部分包括用于存储对象自身的运行时数据和类型指针这两类信息。
- 运行时数据,如哈希码、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,称为“Mark Word”。
- 类型指针,即对象指向它的类型元数据的指针,Java虚拟机通过这个指针来确定该对象是哪个类的实例。
2)实例数据是对象真正存储的有效信息,即在程序代码里面所定义的各种类型的字段内容,无论是从父类中继承下来的,还是在子类中定义的字段都必须记录起来。
3)对齐填充,仅仅起着占位符的作用。任何对象的大小必须是8个字节的正整数倍,如果对象实例数据部分没有对齐,就需要对齐填充来补全。
3、对象的访问定位
Java程序会通过栈上的reference数据来操作堆上的具体对象。
主流的访问方式主要有使用句柄和直接指针两种:
- 使用句柄访问的话,Java堆中将可能会划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,句柄中包含了对象实例数据与类型数据各自具体的地址信息
- 使用直接指针访问的话,Java堆中对象的内存布局就必须考虑如何放置访问类型数据的相关信息,reference中存储的直接就是对象地址,只是访问对象本身的话,就不需要像句柄访问那样多一次间接访问的开销。
这两种访问方式各有优势,使用句柄来访问的最大好处就是reference中存储的是稳定句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而reference本身不需要被修改。
使用直接指针来访问最大的好处就是速度更快,它节省了一次指针定位的时间开销,由于对象访问在Java中非常频繁,节省的时间开销也是极为可观的。
即句柄访问是属于间接访问对象实例数据和对象类型数据的;而直接指针访问是属于直接访问对象实例数据和间接访问对象类型数据的。
HotSpot虚拟机使用直接指针访问的。
三、OutOfMemoryError和StackOverflowError异常
1、Java堆溢出(最常见)
限制堆大小为20M,不可扩展(将堆的最小值-Xms参数与最大值-Xmx参数设置为一样即可避免堆自动扩展),再通过设置参数 -XX:+HeapDumpOnOutOfMemoryError可以让虚拟机在出现内存溢出异常时,Dump出当前的内存堆转储快照以便事后分析。
-
创建包outofmemoryerror,再创建类HeapOOM
package outofmemoryerror; import java.util.ArrayList; public class HeapOOM { static class OOMObject { } public static void main(String[] args) { ArrayList<OOMObject> oomObjects = new ArrayList<>(); while(true) { oomObjects.add(new OOMObject()); } } }
因为创建的对象实例会存放在堆中,一直创建可以让堆内存溢出。
-
IDEA中,先通过编辑参数配置
-
再修改选项
-
最后添加VM选项即可
-
在VM Arguments框中输入:
-Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError
,再运行即可。
2、虚拟机栈和本地方法栈溢出
HotSpot虚拟机中并不区分虚拟机栈和本地方法栈,因此对于HotSpot来说,-Xoss参数(设置本地方法栈大小)虽然存在,但实际上是没有效果的,栈容量只能由-Xss参数来设定。
1)线程请求的栈深度大于虚拟机所允许的最大深度,将抛出StackOverflowError异常。
2)虚拟机的栈内存允许动态扩展,当扩展栈容量无法申请到足够的内存时,将抛出OutOfMemoryError异常。但是HotSpot虚拟机时不支持栈扩展的。
所以在运行时,线程只会因为栈容量无法容纳新的栈帧而导致StackOverflowError异常。
1、通过设置-Xss参数减少栈内存容量使栈溢出
-
创建包stackoverflowerror,再创建类JavaVMStackSOF
package stackoverflowerror; public class JavaVMStackSOF { private int stackLength = 1; public void stackLeak() { stackLength++; stackLeak(); } public static void main(String[] args) { JavaVMStackSOF javaVMStackSOF = new JavaVMStackSOF(); try{ javaVMStackSOF.stackLeak(); }catch (Throwable e){ System.out.println("stackLength: "+javaVMStackSOF.stackLength); throw e; } } }
因为JVM运行时,关于栈只能抛出StackOverflowError异常,将stackLeak()方法调用后,它会进行无限递归调用,直到栈溢出。
-
将VM Arguments参数设置为
-Xss180k
,因为64位windows下的JDK11创建JVM时,栈的最小内存为180k。然后运行即可。
设置栈内存为最小内存容量,是因为可以在栈最小的深度时输出异常。从而减少输出栈深度。
2、定义大量本地变量,增大此方法帧中本地变量表的长度使栈溢出
-
创建包stackoverflowerror,创建类JavaVMStackSOF2
package stackoverflowerror; public class JavaVMStackSOF2 { private static int stackLength = 1; public static void stack() { long user0,user1,user2,user3,user4,user5,user6,user7,user8,user9, user10,user11,user12,user13,user14,user15,user16,user17,user18,user19, user20,user21,user22,user23,user24,user25,user26,user27,user28,user29, user30, user31,user32,user33,user34,user35,user36,user37,user38,user39, user40,user41,user42,user43,user44,user45,user46,user47,user48,user49,user50; stackLength++; stack(); user0=user1=user2=user3=user4=user5=user6=user7=user8=user9= user10=user11=user12=user13=user14=user15=user16=user17=user18=user19= user20=user21=user22=user23=user24=user25=user26=user27=user28=user29= user30=user31=user32=user33=user34=user35=user36=user37=user38=user39= user40=user41=user42=user43=user44=user45=user46=user47=user48=user49=user50=0; } public static void main(String[] args) { JavaVMStackSOF javaVMStackSOF = new JavaVMStackSOF(); try{ javaVMStackSOF.stack(); }catch (Throwable e){ System.out.println("stackLength: "+javaVMStackSOF.stackLength); throw e; } } }
跟前面方法一样,并且多了很多本地变量,但是不设置栈的内存容量,直接运行即可。此时会因为本地变量太多,并且递归,使得栈溢出。
3、方法区和运行时常量池溢出
运行时常量池是方法区的一部分。
自JDK7开始,原本存放在永久代的字符串常量池被移至Java堆中。
String::intern()是一个本地方法,作用是如果常量池中已经包含一个等于此String对象的字符串,则返回该String对象的引用;否则则将其添加到常量池中,再返回该String对象的引用。
3.1 运行时常量池溢出
-
创建包outofmemoryerror,再创建类RuntimeConstantPoolOOM
package outofmemoryerror; import java.util.HashSet; import java.util.Set; public class RuntimeConstantPoolOOM { public static void main(String[] args) { //使用Set保持着常量池引用,避免Full GC回收常量池行为 Set<String> set = new HashSet<>(); //在short范围内足以让6MB的PermSize产生OutOfMemoryError了 short i = 0; while(true) { set.add(String.valueOf(i++).intern()); } } }
-
设置VM Arguments为-Xmx6M,即Java堆最大内存为6M
此时由于反复调用intern()方法(将其添加到常量池中),又因为运行时常量池在Java堆中,后面又设置Java堆的大小为6m,从而出现OutOfMemoryError异常。
3.2 方法区溢出
方法区的主要职责是用于存放类型的相关信息,如类名、访问修饰符、常量池、字段描述、方法描述等。
JDK8后,元空间代替了永久代。并且HotSpot虚拟机提供了一些参数作为元空间的防御措施:
1)-XX:MaxMetaspaceSize:设置元空间最大值,默认值为-1,即不限制,或者说只受于本地内存大小。
2)-XX:MetaspaceSize:指定元空间的初始空间大小,以字节为单位,达到该值就会出发垃圾手机进行类型卸载,同时收集器会对该值进行调整:如果释放了大量的空间,就适当降低该值;如果释放了很少的空间,那么在不超过-XX:MaxMetaspaceSize的情况下,适当提高该值。
3)-XX:MinMetaspaceFreeRatio:作用是在垃圾收集后控制最小的元空间剩余容量的百分比,可减少因为元空间不足导致的垃圾收集的频率。
4、本机直接内存溢出
直接内存(Direct Memory)的容量大小可通过-XX:MaxDirectMemorySize参数指定,不指定则默认跟Java堆最大值一致。
总结
- 运行时数据区域有1、Method Area方法区 2、VM Stack虚拟机栈 3、Native Method Stack本地方法栈 4、Heap堆 5、Program Counter Register程序计数器。
1)程序计数器是线程所执行的字节码的行号指示器,即Program Counter Register是一个“数值”,字节码解释器可以改变它的数值,从而实现选取下一条指令。线程需要使用它才能继续向下执行。
2)Java虚拟机栈描述的是Java方法执行的内存模型。
3)本地方法栈是为虚拟机使用到的本地方法服务,也Java虚拟机栈相似。
4)Java堆这内存区域的唯一目的就是存放对象实例,是被所有线程共享的一块内存区域,主流虚拟机一般是将Java堆按照可扩展来实现的。
5)方法区用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。
6)运行时常量池,即在类加载后,将Class文件的常量池表存放到方法区的运行时常量池中。
7)直接内存,并不是虚拟机运行时数据区的一部分,但是也被频繁使用。
OutOfMemoryError异常,在虚拟机栈、本地方法栈、Java堆、方法区、运行时常量池、直接内存中都有可能抛出。
StackOverFlowErrory异常,在虚拟机栈和本地方法栈中可能抛出。 - 当虚拟机遇到New指令时,会进行检查这个符号引用。没有被加载、初始化等操作,则必须先执行相应的类加载过程。
虚拟机为新生对象分配内存,有指针碰撞(Bump The Pointer)和空闲列表(Free List)两种方式划分可用空间。
这两种划分可用空间,当遇到并发情况时,不是线程安全的。
需要在划分可用空间的基础上,解决并发,有对分配内存空间的操作进行同步处理和本地线程分配缓冲两种解决方案。此时从虚拟机的视角,一个新的对象已经产生;程序中遇到new之后会继续执行init()方法,此时一个真正可用的对象才算完全被构造出来。
对象在堆内存中的存储布局可以划分为对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)3个部分。对象头存的是对象运行时数据,而实例数据存的是对象本身,对齐填充则是当对象头+实例数据的大小不是8个字节的正整数倍时,进行一个填充。
对象的访问定位,Java程序会通过栈上的reference数据来操作堆上的具体对象,主要有使用句柄和直接指针两种。 - Java堆溢出异常是实际应用中最常见的内存溢出异常。-Xms与-Xmx之间的差值为可扩展的空间。
线程请求的栈深度大于虚拟机所允许的最大深度,将抛出StackOverflowError异常。可以通过-Xss参数减少栈容量或者通过定义大量本地变量,从而更快的使栈溢出。