Java内存区域与内存溢出
1、Java运行时数据区域
Java虚拟机在执行java程序时,会将自己管理的内存划分为不同的区域。每个区域都有自己的内存大小、创建以及销毁时间,有的区域会随着java进程的启动而创建,随着java进程的销毁而销毁。有的区域是随着用户线程的启动而创建,随着线程的结束而销毁。java运行时数据区域分为以下几个部分,如下图所示。
1.1、程序计数器
程序计数器是较小的一块内存区域,可以看作是当前线程执行字节码的行号指示器。字节码解释器就是根据程序计数器的行号来决定下一步要执行哪个字节码指令。它是控制流的指示器,循环、分支、跳转、异常处理以及线程恢复等功能都要依赖程序计数器。
java虚拟机的多线程是由多个线程轮换、分配处理器执行的时间来执行的。为了让线程切换后保留恢复到之前执行的位置,每个线程内部都有一个程序计数器。线程之间相互不影响。所以程序计数器是线程私有的数据区域。
1.2、Java虚拟机栈
虚拟机栈和程序计数器一样,都是线程私有的,java虚拟机栈描述的是方法执行的内存模型,每个方法在执行时,虚拟机都会创建一个栈帧,主要用来存储局部变量表、动态链接、操作数栈、方法的出口等信息。每一个方法执行开始到结束,都对应着栈帧的入栈和出栈。
局部变量表存储了编译器已知基本数据类型、对象引用、returnAddress类型。
java虚拟机规范中,对这个内存区域有两种异常,
-
一种是线程请求的栈深度超过虚拟机栈深度,就会报StackOverflowError异常。
-
一种是虚拟机栈容量可以动态扩展,当内存不足时就会抛出OutOfMemoryError异常。
SotSpot虚拟机栈容量不可以动态扩展,所以也不会因为动态扩展而出现OutOfMemoryError异常,但是如果在申请虚拟机栈容量时内存已经不足,则还是会出现OOM异常。
1.3、本地方法栈
本地方法栈与虚拟机栈类似,只不过本地方法栈是对程序中的本地方法服务。
与虚拟机栈相同,也会因为线程请求栈深度超过栈深度而抛出StackOverflowError异常,以及因为动态扩展或者扩展栈容量不足时抛出OOM异常。
1.4、Java堆
java堆(java Heap)是内存区域中最大的一块,是所有线程共享的内存区域,主要用来存放对象的实例,几乎所有的对象实例以及数组都在堆内分配内存。java堆也是垃圾收集器管理的内存区域,所以java 堆有时候也被叫做GC堆(Garbage Collected Heap)。
java堆内存在某些垃圾收集器中,是被分为年轻代、老年代这些只是垃圾收集器的设计风格而已,并不是java堆内存固有的内存布局。
从分配内存的角度看,所有线程共享的java堆中可以分配出多个线程私有的缓冲区域(TLAB,Thread Local Allocation Buffer),来提升对象分配内存的效率。将java堆细分只是为了更好的回收内存以及更好的分配内存。
java堆内存的大小可以用 -Xmx和-Xms来指定。当堆内存无法为对象分配内存并且无法扩展时,就会产生OutOfMemoryError异常。
1.5、方法区
方法区和java堆都是线程共享的内存区域,方法区主要存储类型信息、常量、静态变量、即时编译器产生的缓存数据等。
永久代:是HotSpot开发团队将分代设计扩展至方法区,所以使用永久代来实现方法区。
在JDK6的时候,HotSpot团队就放弃了永久代,使用本地内存来实现方法区。
JDK7,HotSpot团队已经将字符串常量池和静态变量移出了永久代。
JDK8,完全废弃了永久代,改用本地内存的元空间来代替,把永久代中的剩余的部分全都移动到了元空间。
当方法区无法分配新的内存区域时,也会产生OutOfMemoryError异常。
1.6、运行时常量池
运行时常量池是方法区的一部分,它是用来存储类编译时的字面量以及符号引用,这部分会在类加载后被存放在运行时常量池中。
运行时常量池具备了动态性,并不是只有在编译期间产生的字面量和符号引用会被放入运行时常量池,在运行期间产生的字面量也会存储到常量池中,典型的应用就是,String类的intern方法。
运行时常量池是方法区的一部分,当然也受到方法区内存的限制,当无法分配内存时也会产生OutOfMemoryError异常。
1.7、直接内存
直接内存并不是虚拟机运行时数据区的一部分。
在JDK1.4时引入了NIO,它是基于通道和缓冲区的IO方式,直接使用native函数库直接分配堆外内存,使用存储在堆内的DriectByteBuffer对象来作为这块内存的引用进行操作。这样可以显著提高性能,避免了数据在java堆和native堆来回复制。
直接内存大小并不受java堆内存大小的限制,但是会受到本机总内存的限制。当-Xmx分配了太大的内存时,忽略了直接内存,动态扩展时也会产生OutOfMemoryError异常。
内存 | 存储 | 线程 |
---|---|---|
程序计数器 | 字节码行号 | 私有 |
虚拟机栈 | java方法:局部变量表、动态链接、操作数栈、方法出口等 | 私有 |
本地方法栈 | 本地方法:局部变量表、动态链接、操作数栈、方法出口等 | 私有 |
堆 | 对象实例、数组 | 共享 |
方法区 | 类型信息(类名、访问修饰符等)、常量、静态变量、即时编译缓存等 | 共享 |
2、虚拟机中的对象
2.1、对象创建
在new对象时,
-
首先检查常量池中是否有这个类的符号引用,并且检查类是否已经被加载、解析和初始化过,如果没有先执行类加载过程。
-
为对象分配内存:在类加载完成后已经确定了对象分配内存的大小。在java堆上分配一块足够的内存。分配内存需要根据java堆内存是否规整,
- 如果是规整的,所有被使用的内存在一边,未被使用的内存在另一边,中间使用一个指针,分配内存就是将指针向空闲内存这边挪动一段与对象内存大小相等的空间。这种分配方式叫指针碰撞。
- 如果是不规整的,使用过的内存和未使用的内存交错在一起,虚拟机必须维护一个列表,哪块内存是可用的,哪块不可用,然后再列表中找到内存足够的空间,并更新列表。这种分配方式叫空闲列表
在多线程下,分配内存空间是非常繁重的,仅仅依靠挪动指针的方法并不是线程安全的,一个线程正在分配内存,指针还没来得及修改,另一个线程也使用了原来的指针分配。这种情况两种解决方式:
(1)虚拟机采用的是CAS加失败重试机制来保证操作的原子性
(2)每个线程内部分配一块私有的缓冲区域,分配内存时,就在线程已经分配的缓冲区域(Thread Local Allocation Buffer)分配对象内存,只有本地缓冲区用完之后才会同步锁定重新分配。虚拟机是否使用TLAB,可以通过-XX:+/-UseTLAB参数来设定。
-
初始化0值:为对象分配完内存后,开始初始化对象内的0值
-
设置对象头:属于哪个类、对象的元数据、对象的GC分代年龄等信息。
-
初始化构造函数
2.2、对象的使用
对象创建完成后就是用来使用的。在堆内存中怎么找到对象,有两种方式,一种是通过对象句柄,一种是通过直接内存。
对象句柄方式:需要在堆中维护一个句柄池,保存对象实例句柄,句柄内包含了对象实例数据以及对象类型各自地址,在java虚拟机栈的本地变量表中存储的是对象句柄的地址。在查找时先通过变量表中的句柄地址找到句柄池中的句柄,然后再根据句柄中的地址信息找到对象。
直接指针方式:再虚拟机栈的本地变量表中存储的就是对象的地址。直接可以通过变量表的对象地址找到对象的实例。
这两种方式:
句柄方式的优点,当对象因GC而移动时只会改变对象实例数据的指针,变量表中的句柄地址不需要变化。而直接内存方式,当对象移动时,需要改动变量表中的对象实例数据地址。
直接指针方式的优点:可以直接通过变量表的内存找到对象实例数据,速度更快,节省了一次指针定位的时间开销。句柄方式,要先根据句柄地址找到对象实例句柄,然后根据句柄中的地址再去找对象实例数据。
HotSpot虚拟机中使用的是直接指针方式。
3、OutOfMemoryError异常
在java运行时数据区域中,除了程序计数器,其他的区域都会产生内存溢出。基于JDK1.8测试
3.1、堆内存溢出测试
java堆用于存储对象实例,只要不断创建对象,保证GC Roots与对象之间可达,确保GC不会回收这些对象。
限制-Xmx20m、-Xms20m,避免堆内存动态扩展,通过参数-XX:+HeapDumpOnOutOf-MemoryError可以让虚拟机在出现内存溢出异常的时候Dump出当前的内存堆转储快照以便进行事后分析。
public class HeapDemo {
static class OomObject{
}
public static void main(String[] args) {
List<OomObject> list = new ArrayList<>();
while(true){
list.add(new OomObject());
}
}
}
- 在IDEA中配置JVM启动参数:
- 执行完成后,抛出异常:
第一个框描述异常信息,OutOfMemoryError: Java heap space(java堆空间)
第二个框产生dump文件,堆转储快照文件。
第三个框抛出OutOfMemoryError异常
-
解决问题,对产生的dump文件进行分析,可以使用内存映像分析工具(例如:Eclipse Memory
Analyzer),首先确认导致OOM的对象是否是必须的,也就是先确定是内存溢出(Memory Overflow)还是内存泄漏(Memory Leak)?
如果是内存泄露,看泄露的对象到GC Roots的路径上,引用了哪些对象,导致无法被GC回收。根据泄漏对象的类型信息以及它到GC Roots引用链的信息,一般可以比较准确地定位到这些对象创建的位置,进而找出产生内存泄漏的代码的具体位置。
如果不是内存泄漏,也就是内存中的对象确实都是必须存活的,那就应当检查Java虚拟机的堆参数(-Xmx与-Xms)设置,与机器的内存对比,看看是否还有向上调整的空间。再从代码上检查是否存在某些对象生命周期过长、持有状态时间过长、存储结构设计不合理等情况,尽量减少程序运行期的内存消耗。
3.2、虚拟机栈、本地方法栈溢出测试
虚拟机栈出现溢出的情况有两种:
(1)线程请求的栈深度超过虚拟机栈的深度就会产生StackOverflowError
(2)虚拟机栈容量可以动态扩展,当内存无法扩展时,就会产生OutOfMemoryError
由于HotSpot虚拟机不允许虚拟机栈容量动态扩展,所以测试使用两种方式:
- 使用-Xss降低虚拟机栈的容量
-
代码实现
public class JavaHeapDemo {
private int stackLength = 1;
public void stackLeak() {
stackLength++;
stackLeak();
}
public static void main(String[] args) {
JavaHeapDemo oom = new JavaHeapDemo();
try {
oom.stackLeak();
} catch (Throwable e) {
System.out.println("stack length:" + oom.stackLength);
throw e;
}
}
}
- 执行结果
栈深度可以达到988.
-
创建更多的局部变量,增大栈帧中的局部变量表的长度
- 代码,创建了很多局部变量,导致栈帧中的局部表增大。
public class JavaHeapDemo { private int stackLength = 1; public void stackLeak() { long a1,a2,a3,a4,a5,a6,a7,a8,a9,a10, a11,a12,a13,a14,a15,a16,a17,a18,a19,a20, a21,a22,a23,a24,a25,a26,a27,a28,a29,a30, a31,a32,a33,a34,a35,a36,a37,a38,a39,a40, a41,a42,a43,a44,a45,a46,a47,a48,a49,a50, a51,a52,a53,a54,a55,a56,a57,a58,a59,a60, a61,a62,a63,a64,a65,a66,a67,a68,a69,a70, a71,a72,a73,a74,a75,a76,a77,a78,a79,a80, a81,a82,a83,a84,a85,a86,a87,a88,a89,a90, a91,a92,a93,a94,a95,a96,a97,a98,a99,a100; stackLength++; stackLeak(); a1=a2=a3=a4=a5=a6=a7=a8=a9=a10= a11=a12=a13=a14=a15=a16=a17=a18=a19=a20= a21=a22=a23=a24=a25=a26=a27=a28=a29=a30= a31=a32=a33=a34=a35=a36=a37=a38=a39=a40= a41=a42=a43=a44=a45=a46=a47=a48=a49=a50= a51=a52=a53=a54=a55=a56=a57=a58=a59=a60= a61=a62=a63=a64=a65=a66=a67=a68=a69=a70= a71=a72=a73=a74=a75=a76=a77=a78=a79=a80= a81=a82=a83=a84=a85=a86=a87=a88=a89=a90= a91=a92=a93=a94=a95=a96=a97=a98=a99=a100 = 0; } public static void main(String[] args) { JavaHeapDemo oom = new JavaHeapDemo(); try { oom.stackLeak(); } catch (Throwable e) { System.out.println("stack length:" + oom.stackLength); throw e; } } }
- 结果
栈的深度只到53,是因为局部变量表变大,栈深度就减小了
3.3、方法区和运行时常量池溢出
3.3.1、运行时常量池
运行时常量池如果溢出,在JDK7以前,常量池都是分配在永久代中,我们可以通过-XX:PermSize和-XX:MaxPermSize限制永久代的大小,即可间接限制其中常量池的容量。
String::intern()是一个本地方法,它的作用是如果字符串常量池中已经包含一个等于此String对象的
字符串,则返回代表池中这个字符串的String对象的引用;否则,会将此String对象包含的字符串添加
到常量池中,并且返回此String对象的引用。
所以在JDK7以前,可以使用一直循环调用首次出现的字符串的intern方法,就会产生内存溢出。
JDK7将字符串常量池移动到了java堆中,所以在JDK7以后运行时常量池的溢出基本上不会发生。
在JDK7以上,常量池中记录的是首次出现的实例引用,由于JDK6和7在常量池的这个区别,也产生了一些现象。如下:
public class ConstantDemo {
public static void main(String[] args) {
String str = new StringBuilder("计算机").append("软件").toString();
System.out.println(str.intern() == str);
String str1 = new StringBuilder("ja").append("va").toString();
System.out.println(str1.intern() == str1);
}
}
在上述类中:JDK6会输出两个false,JDK7以上会输出true、false
- 在JDK6中,第一个str创建后,内存如下:
执行str.intern()后,会将首次遇到的字符串实例复制到常量池中,并且返回常量池中的字符串引用。
JDK6中,通过StringBuilder创建的对象在堆中,常量池中返回的也是复制后的字符串引用。所以JDK6中必然不可能相同。
- 在JDK7以上,字符串常量池移动到了java堆中,常量池中记录的是首次出现的实例引用。第一个str创建后,如下图。
- 当调用了str.intern()后, 在堆中存在而不在常量池中存在的字符串,在常量池中存放其引用
因为指向的是同一个实例,所以str.intern()==str为true。
在JDK7以上,str1为false,是因为“java”这个字符串已经在常量池中了(在加载sun.misc.Version这个类的时候进入常量池的)。
在调用了str1.intern()后,发现常量池中已经有了“java”这个字符串,直接返回该字符串的引用。
所以str1.intern()==str1为false。
3.3.2、方法区
在JDK7将常量池移动到堆以后。方法区剩下的就是类名、访问修饰符、常量池、字段描述、方法描述等信息,造成方法区溢出,可以使用反射在运行时产生大量的类去填满方法区,直到溢出为止。
在JDK 8以后,永久代便完全退出了历史舞台,元空间作为其替代者登场,元空间是在本地内存中,受本地内存大小的限制。
元空间提供了一些参数,防止溢出(一般情况下不会产生)。
-XX:MaxMetaspaceSize:设置元空间最大值,默认是-1,即不限制,或者说只受限于本地内存大小。
-XX:MetaspaceSize:指定元空间的初始空间大小,以字节为单位,达到该值就会触发垃圾收集进行类型卸载,同时收集器会对该值进行调整:如果释放了大量的空间,就适当降低该值;如果释放了很少的空间,那么在不超过-XX:MaxMetaspaceSize(如果设置了的话)的情况下,适当提高该值。
3.4、直接内存溢出
直接内存(Direct Memory)的容量大小可通过-XX:MaxDirectMemorySize参数来指定,如果不去指定,则默认与Java堆最大值(由-Xmx指定一致。
由直接内存导致的内存溢出,一个明显的特征是在Heap Dump文件中不会看见有什么明显的异常情况,如果发现内存溢出之后产生的Dump文件很小,而程序中又直接或间接使用了DirectMemory(典型的间接使用就是NIO),那就可以考虑重点检查一下直接内存方面的原因了。
本文来源:<<深入理解Java虚拟机:JVM高级特性与最佳实践(第3版)周志明.pdf>>