运行时数据区域
Java 虚拟机在执行Java 程序的过程中会把它所管理的内存划分为若干个不同的数据区域
。这些区域有各自的用途,以及创建和销毁的时间,有的区域随着虚拟机进程的启动一直存在,
有些区域则是依赖于用户线程的启动和结束而建立和销毁,根据《Java虚拟机规范
》的规定,Java 虚拟机所管理的内存将会包括以下几个运行时区域。
如下图所示:
程序计数器
程序计数器(Program Counter Register)
是线程私有
的,它可以看作是当前线程所执行的字节码的行号指示器
。
主要工作就是通过改变这个计数器的值来选举下一条需要执行的字节码指令,
它是程序控制流的指示器、分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。
- 如果线程正在执行一个Java方法,这个计数器记录的是正在执行的虚拟机
字节码指令的地址
- 如果正在执行的是本地方法(Native)方法,这个计数器值则为
空(Undefined)。
- 在Java虚拟机规范中,是唯一一个
不会
发生OOME情况的区域。
以代码和字节码为例子:
public void test(){
int a = 0;
System.out.println("123");
}
//对应的字节码
public void test();
Code:
0: iconst_0
1: istore_1
2: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
5: ldc #3 // String 123
7: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
10: return
在字节码中 0,1,2,5,7,10
这些数字就是由程序计数器来保存的,代表着节码指令的行号
,旁边的则是字节码的具体操作
。
Java虚拟机栈
和程序计数器一样,Java虚拟机栈(Java Virtual Machine Stack)
也是线程私有
的,它的生命周期与线程相同。
虚拟机栈描述的是Java方法执行的线程模型:每个方法被执行的时候,Java虚拟机都会创建一个栈帧(Stack Frame) 用于存储局部变量表、操作数栈、动态连接、方法出口等信息。
每一个方法的调用直至执行完毕的过程,就对应着一个栈帧在虚拟机中从入栈到出栈的过程。
LocalVariableTable:
Start Length Slot Name Signature
0 11 0 this Lcn/sirann/aotuMemoryManager/memoryArea/MemoryAreaTest;
2 9 1 a I
局部变量表
:
- 对应
程序计数器
中的test()
方法,可以发现除了有基本类型a
以外还有一个this
也就是说this
会被隐式的传入方法中所以我们可以在方法里直接使用this
- 可以存放基本
数据类型
(boolean、byte、char、short、int、float、long、double)、对象引用
(reference 类型,指向对象起始地址的引用指针,也可能是代表对象的句柄)。 - 这些数据类型在局部变量表中以
槽(slot)
来表示,其中64位的long
和double
类型的数据会占用两个
变量槽,其余的只占用一个。 - 局部变量表在
编译期间
就完全分配,当进入一个方法时,这个方法需要在栈帧中分配多少局部变量空间都完全确定。运行期
间不会改变局部变量表的大小。 - 在虚拟机栈中有两类
异常情况
- 如果线程请求的栈深度大于虚拟机所允许的深度,抛出
StackOverflowError
异常。 - 如果Java虚拟机栈容量可以
动态扩展
(Hotspot是不可以动态扩展的,所以只要线程申请栈空间成功了就不会有OOME,但是如果申请失败就会出现OOME),当栈扩展时无法申请到足够的内存会抛出OOME异常
- 如果线程请求的栈深度大于虚拟机所允许的深度,抛出
本地方法栈
本地方法栈(Native Method Stacks) 与 虚拟机栈基本一样,主要的区别在于:
- 虚拟机栈是为虚拟机执行
Java方法
(字节码)服务。 - 本地方法栈则是为
本地
(Native)方法服务。
Java堆
Java堆是共享区域
,也是在内存区域中最重要的一块,Java几乎所有的对象实例都会在这里分配内存。所以堆也是垃圾回收器
重点照顾对象。
- Java堆可以处于物理上不连续的内存空间中,但是逻辑上它应该被视为连续的,就像我们用磁盘空间去存储文件一样,并不要求每个文件都连续存放的,但是取得时候是一个完整的文件。
- Java堆即可以被实现成固定大小,也可以通过参数
-Xmx和-Xms
来设定。 - 如果Java堆中没有内存完成实例分配,并且堆也无法在扩展,则会抛出
OOME
方法区
方法区(Method Area)
与 Java堆一样是共享区域
,它用于存放已经被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。
- 在Jdk8以前,使用
永久代
来实现方法区。这样使得HotSpot 的垃圾收集器能够像管理Java堆一样来管理这片内存。 - Jdk8废弃了永久代的概念,把原本放在永久代的字符串常量池、静态变量、类型数据全部移到
元空间中(Meta-space)
。 - 如果方法区无法满足新的内存分配需求时,将抛出OOME
运行时常量池
运行时常量池(Runtime Constant Pool)
是方法区
的一部分。
-
Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是
常量池表(Constant Pool Table)
,用于存放编译期生成的各种字面量
和符号引用
,这部分内存将在类加载后存放到方法区的运行时常量池中 -
运行时常量池具有
动态性
,并非在编译器期间生成的常量能进入运行时常量池,运行期间也可以把新的常量放入池中。比如String.intern()
方法
intern方法
会判断常量池是否有相同的常量如果有,那么直接引用,如果没有则创建。
直接内存
直接内存(Direct Memory)
并不是虚拟机运行时数据区的一部分,它是堆外内存
。
- NIO 引入了一种基于通道(Channel) 与缓冲区(Buffer) 的I/O方法,它可以直接使用Native方法直接分配堆外内存,然后通过存储在Java堆中的
DirectByteBuffer
对象作为这块内存的引用进行操作。 - 可以避免在Java堆与Native堆之间
来回复制
数据。 - 不受Java堆大小的限制,受限于本机总内存,超过本机总内存也会发生
OOME
HotSpot 虚拟机对象探秘
对象的创建
我们创建对象基本都是使用new
关键字,比如下面的代码:
Test test = new Test();
当虚拟机遇到一条字节码new指令
时,首先讲去检查
这个指令的参数是否在常量池中定位到一个类的符号引用,
并检查这个符号因引用代表的类是否已经被加载了、解析和初始化过。如果没有,那就必须执行相应的类加载过程。
在类加载检查通过后,虚拟机将为新生成的对象分配内存(加载后可以确定所需的内存大小)。
对象的内存分配实际上就是在Java堆上找到一块足够大的空间给对象实例,如果发现不够那就进行GC,如果GC后还是不够那就抛出OOME。
Java堆内存的分配方式
有两种:
指针碰撞
:Java堆中的内存是绝对规整的,所有使用过的都被放在一边,空闲的内存放在另一边。中间放着一个指针作为分界点的指示器,分配内存仅仅是把那个指针向空闲空间方向挪动一段与对象大小相等的距离空闲列表
:维护一个列表记录哪些内存是可用的,分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录。
使用哪种分配方式取决于垃圾回收器是否带有空间压缩整理
的能力比如Serial、ParNew
是拥有此能力的;而CMS
这种是基于清除(Sweep)
无法使用指针碰撞。
思考一个问题:对象创建在虚拟机中是非常频繁的行为,即时仅仅修改一个指针所指向的位置,在并发情况下也并不是线程安全的。可能出现在给对象A分配内存,指针还没来得及修改, 对象B又同时使用了原来的指针来分配内存的情况。
可以通过以下两种方式来解决:
- 对分配内存空间的动作进行同步处理(虚拟机采用CAS来保证更新操作的原子性)
- 每个线程在Java堆中预先分配一小块内存,称为
本地线程分配缓冲(Thread Local Allocation Buffer,TLAB)
,哪个线程需要分配内存,就在那个线程的本地缓冲区中分配。只有在本地缓冲区用完了,分配新的缓存区才需要同步锁定。虚拟机是否使用TLAB,可以通过-XX:+/-UseTLAB
参数设定。
内存分配完成之后,虚拟机必须将分配到的内存空间(但不包括对象头)都初始化为
零值
,如果使用了TLAB
的话,这一项工作也可以提前至TLAB分配
时顺便进行。这步操作保证了对象的实例字段在Java代码中可以不赋初始值就直接使用,使程序能访问到这些字段的数据类型所对应的零值。Java虚拟机还要对对象进行
必要的设置
,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码(实际上对象的哈希码会延后到真正调用Object::hashCode()方法时才计算)、对象的GC分代年龄等信息。这些信息存放在对象的对象头(ObjectHeader)之中,根据虚拟机当前运行状态的不同,如是否启用偏向锁
等,对象头会有不同的设置方式。在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了。但是从Java程序的视角看来,对象创建才刚刚开始——
构造函数
,即Class文件中的<init>()
方法还没有执行,所有的字段都为默认的零值
,对象需要的其他资源和状态信息也还没有按照预定的意图构造好。一般来说(由字节码流中new指令后面是否跟随invokespecial指令所决定,Java编译器会在遇到new关键字的地方同时生成这两条字节码指令,但如果直接通过其他方式产生的则不一定如此),new指令之后会接着执行<init>()
方法,按照程序员的意愿对对象进行初始化,这样一个真正可用的对象才算完全被构造出来。
对象的内存布局
在HotSpot虚拟机里,对象在堆内存中的存储布局可以划分为三个部分:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。
-
HotSpot虚拟机对象的
对象头
部分包括两类信息:- 第一类是
用于存储对象自身的运行时数据
,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,这部分数据的长度在32位和64位的虚拟机(未开启压缩指针)中分别为32个比特和64个比特,官方称它为“Mark Word
”。 - 另一类是
类型指针
,即对象指向它的类型元数据的指针,Java虚拟机通过这个指针来确定该对象是哪个类的实例。
- 第一类是
-
实例
数据部分
是对象真正存储的有效信息:- 即我们在程序代码里面所定义的
各种类型的字段内容
,无论是从父类继承下来的,还是在子类中定义的字段都必须记录起来。 - 这部分的
存储顺
序会受到虚拟机分配策略参数(-XX:FieldsAllocationStyle参数
)和字段在Java源码中定义顺序
的影响。HotSpot虚拟机默认的分配顺序为longs/doubles、ints、shorts/chars、bytes/booleans、oops(Ordinary Object Pointers,OOPs)。 - 从以上默认的
分配策略
中可以看到,相同宽度
的字段总是被分配到一起存放,在满足这个前提条件的情况下,在父类中定义的变量会出现在子类之前。如果HotSpot虚拟机的+XX:CompactFields参数值为true(默认就为true)
,那子类之中较窄的变量也允许插入父类变量的空隙之中,以节省出一点点空间。
- 即我们在程序代码里面所定义的
-
对象的第三部分是
对齐填充
:- 它仅仅起着
占位符
的作用。由于HotSpot虚拟机的自动内存管理系统
要求对象起始地址必须是8字节的整数倍
,换句话说就是任何对象的大小都必须是8字节的整数倍。对象头部分已经被精心设计成正好是8字节的倍数(1倍或者2倍),因此,如果对象实例数据部分没有对齐的话,就需要通过对齐填充来补全。
- 它仅仅起着
对象的访问定位
创建对象自然是为了后续使用该对象,我们的Java程序会通过栈上的reference数据
来操作堆上的具体对象
。由于reference类型在《Java虚拟机规范》
里面只规定了它是一个指向对象的引用,并没有定义这个引用应该通过什么方式去定位、访问到堆中对象的具体位置,所以对象访问方式也是由虚拟机实现而定的
主流的访问方式主要有使用句柄
和直接指针
两种:
句柄访问
:Java堆中将可能会划分出一块内存来作为句柄池
,reference中存储的就是对象的句柄地址
,而句柄中包含了对象实例数据与类型数据各自具体的地址信息直接指针访问
:Java堆中对象的内存布局就必须考虑如何放置访问类型数据的相关信息,reference中存储的直接就是对象地址
,如果只是访问对象本身的话,就不需要多一次间接访问的开销。
这两种对象访问方式各有优势:
使用
句柄
来访问的最大好处就是reference中存储的是稳定句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而reference本身不需要被修改。使用
直接指针
来访问最大的好处就是速度更快
,它节省了一次指针定位的时间开销,由于对象访问在Java中非常频繁,因此这类开销积少成多也是一项极为可观的执行成本,就HotSpot而言,它主要使用第二种方式进行对象访问
OutOfMemoryError 异常
在《Java虚拟机规范》
的规定里,除了程序计数器
外,虚拟机内存的其他几个运行时区域都有发生OOM
异常的可能
Java堆溢出
Java堆用于储存对象实例
,我们只要不断地创建对象,并且保证GC Roots
到对象之间有可达路径
来避免垃圾回收机制清除这些对象,那么随着对象数量的增加,总容量触及最大堆的容量限制后就会产生内存溢出异常。
//-Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError
public class HeapOOM {
static class OOMObject{
}
public static void main(String[] args) {
List<OOMObject> list = new ArrayList<OOMObject>();
while (true){
list.add(new OOMObject());
}
}
}
运行结果:
java.lang.OutOfMemoryError: Java heap space
Dumping heap to java_pid17035.hprof ...
Heap dump file created [27845055 bytes in 0.098 secs]
Java堆内存的OutOfMemoryError
异常是实际应用中最常见的内存溢出异常情况。出现Java堆内存溢出时,异常堆栈信息“java.lang.OutOfMemoryError
”会跟随进一步提示“Java heap space
”。
虚拟机栈和本地方法栈溢出
由于HotSpot虚拟机中并不区分虚拟机栈和本对象的内存布局 地方法栈
,因此对于HotSpot来说,-Xoss参数
(设置本地方法栈大小)虽然存在,但实际上是没有任何效果的,栈容量只能由-Xss参数
来设定。
关于虚拟机栈和本地方法栈,在《Java虚拟机规范》
中描述了两种异常:
-
如果线程请求的栈深度大于虚拟机所允许的最大深度,将抛出
StackOverflowError
异常。 -
如果虚拟机的栈内存允许动态扩展,当扩展栈容量无法申请到足够的内存时,将抛出
OutOfMemoryError
异常。
《Java虚拟机规范》
明确允许Java虚拟机实现自行选择是否支持栈的动态扩展,而HotSpot虚拟机的选择是不支持扩展
,所以除非在创建线程申请内存时就因无法获得足够内存而出现OutOfMemoryError异常,否则在线程运行时是不会因为扩展而导致内存溢出的,只会因为栈容量无法容纳新的栈帧而导致StackOverflowError异常。
- 使用-Xss参数减少栈内存容量。结果:抛出
StackOverflowError
异常,异常出现时输出的堆栈深度相应缩小。
//-Xss160k
public class JavaVMStackSOF {
private int stackLength = 1;
public void stackLeak(){
stackLength++;
stackLeak();
}
public static void main(String[] args) throws Throwable {
JavaVMStackSOF oom = new JavaVMStackSOF();
try {
oom.stackLeak();
} catch (Throwable e) {
System.out.println("stack length:" + oom.stackLength);
throw e;
}
}
}
输出结果:
stack length:771
Exception in thread "main" java.lang.StackOverflowError
at cn.sirann.aotuMemoryManager.oom.JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:12)
- 创建线程导致
OOM
//-Xss2M
public class JavaVMStackOOM {
private void dontStop(){
while (true){
}
}
public void stackLeakByThread(){
while (true){
new Thread(()->{
dontStop();
}).start();
}
}
public static void main(String[] args) throws Throwable {
JavaVMStackOOM oom = new JavaVMStackOOM();
oom.stackLeakByThread();
}
}
输出结果:
Exception in thread "main" java.lang.OutOfMemoryError: unable to create native thread
出现StackOverflowError
异常时,会有明确错误堆栈可供分析,相对而言比较容易定位到问题所在。
如果使用HotSpot虚拟机默认参数
,栈深度在大多数情况下(因为每个方法压入栈的帧大小并不是一样的,所以只能说大多数情况下)
到达1000~2000
是完全没有问题,对于正常的方法调用(包括不能做尾递归优化的递归调用),这个深度应该完全够用了。
但是,如果是建立过多线程导致的内存溢出,在不能减少线程数量或者更换64位虚拟机的情况下,就只能通过减少最大堆和减少栈容量
来换取更多的线程。这种通过“减少内存
”的手段来解决内存溢出的方式,如果没有这方面处理经验,一般比较难以想到。也是由于这种问题较为隐蔽,从JDK 7起,以上提示信息中“unable to create nativethread”后面,虚拟机会特别注明原因可能是“possibly out of memory or process/resourcelimits reached”。
方法区和运行时常亮池溢出
由于运行时常量池
是方法区的一部分,所以这两个区域的溢出测试可以放到一起进行。前面曾经提到HotSpot从JDK 7开始逐步“去永久代
”的计划,
并在JDK 8中完全使用元空间来代替永久代
,在此我们就以测试代码来观察一下,使用“永久代”还是“元空间”来实现方法区,对程序有什么实际的影响。
String::intern()
是一个本地方法,它的作用是如果字符串常量池中已经包含一个等于此String对象的字符串,则返回代表池中这个字符串的String对象的引用;否则,会将此String对象包含的字符串添加到常量池中,并且返回此String对象的引用。
在JDK 6或更早之前的HotSpot虚拟机中,常量池都是分配在永久代
中,我们可以通过-XX:PermSize
和-XX:MaxPermSize
限制永久代的大小,即可间接限制其中常量池的容量,具体实现如代码清单2-7所示,请读者测试时首先以JDK 6来运行代码。
从运行结果中可以看到,运行时常量池溢出时,在OutOfMemoryError异常后面跟随的提示信息是“PermGen space
”,说明运行时常量池的确是属于方法区(即JDK 6的HotSpot虚拟机中的永久代)的一部分。
而使用JDK 7或更高版本的JDK
来运行这段程序并不会得到相同的结果,无论是在JDK 7中继续使用-XX:MaxPermSize参数
或者在JDK 8及以上版本使用-XX:MaxMeta-spaceSize参数
把方法区容量同样限制在6MB
,也都不会重现JDK 6中的溢出异常,循环将一直进行下去,永不停歇。
出现这种变化,是因为自JDK 7起,原本存放在永久代的字符串常量池被移至Java堆之中
,所以在JDK 7及以上版本,限制方法区的容量对该测试用例来说是毫无意义的。这时候使用-Xmx参数
限制最大堆到6MB就能够看到以下两种运行结果之一,具体取决于哪里的对象分配时产生了溢出:
关于这个字符串常量池的实现在哪里出现问题,还可以引申出一些更有意思的影响,看如下的代码:
public class RuntimeConstantPoolOOM {
public static void main(String[] args) {
String str1 = new StringBuilder("计算机").append("软件").toString();
System.out.println(str1.intern() == str1);
String str2 = new StringBuilder("ja").append("va").toString();
System.out.println(str2.intern() == str2);
}
}
-
这段代码在
JDK 6
中运行,会得到两个false
,而在JDK 7中运行,会得到一个true
和一个false
。产生差异的原因是,在JDK 6中,intern()
方法会把首次遇到的字符串实例复制到永久代的字符串常量池中存储,返回的也是永久代里面这个字符串实例的引用,而由StringBuilder创建的字符串对象实例在Java堆上,所以必然不可能是同一个引用,结果将返回false。 -
而
JDK 7
(以及部分其他虚拟机,例如JRockit)的intern()
方法实现就不需要再拷贝字符串的实例到永久代了,既然字符串常量池已经移到Java堆中,那只需要在常量池里记录一下首次出现的实例引用即可,因此intern()返回的引用和由StringBuilder创建的那个字符串实例就是同一个。而对str2比较返回false,这是因为“java”这个字符串在执行String-Builder.toString()之前就已经出现过了,字符串常量池中已经有它的引用,不符合intern()方法要求“首次遇到”的原则,“计算机软件”这个字符串则是首次出现的,因此结果返回true。
我们再来看看方法区的其他部分的内容,方法区
的主要职责是用于存放类型的相关信息,如类名、访问修饰符、常量池、字段描述、方法描述等
。
对于这部分区域的测试,基本的思路是运行时产生大量的类去填满方法区,直到溢出为止。
虽然直接使用Java SE API
也可以动态产生类(如反射时的GeneratedConstructorAccessor和动态代理等
),但在本次实验中操作起来比较麻烦。
在下面的代码中借助了CGLib
直接操作字节码运行时生成了大量的动态类
。
类似这样的代码在当前的很多主流框架,如Spring、Hibernate
对类进行增强时,都会使用到CGLib
这类字节码技术,当增强的类越多,就需要越大的方法区以保证动态生成的新类型可以载入内存。
另外,很多运行于Java虚拟机上的动态语言(例如Groovy等)
通常都会持续创建新类型来支撑语言的动态性
借助CGLib使得方法区出现内存溢出异常:
//-XX:PermSize=10M -XX:MaxPermSize=10M
public class JavaMethodAreaOOM {
static class OOMObject{
}
public static void main(String[] args) {
while (true){
Enhancer enhancer = new Enhancer();
enhancer.setSuperClass(OOMObject.class);
enhancer.setUseCache(false);
enhancer.setCallback(new MethodInterceptor(){
public Object intercept(Object obj, Method method,Object[] args,MethodProxy proxy) throws Throwable{
return proxy.invokeSuper(obj,args);
}
});
enhancer.create();
}
}
}
在JDK 7中的运行结果:
Caused by: java.lang.OutOfMemoryError: PermGen space
方法区溢出也是一种常见的内存溢出异常,一个类如果要被垃圾收集器回收,要达成的条件是比较苛刻的。
在经常运行时生成大量动态类的应用场景里,就应该特别关注这些类的回收状况。这类场景除了之前提到的程序使用了CGLib字节码增强
和动态语言外,
常见的还有:大量JSP或动态产生JSP文件的应用(JSP第一次运行时需要编译为Java类)、基于OSGi的应用(即使是同一个类文件,被不同的加载器加载也会视为不同的类)等。
在JDK 8以后,永久代便完全退出了历史舞台,元空间作为其替代者登场。在默认设置下,前面列举的那些正常的动态创建新类型的测试用例已经很难再迫使虚拟机产生方法区的溢出异常了。
HotSpot还提供了一些参数作为元空间的防御措施
,主要包括:
-
-XX:MaxMetaspaceSize
:设置元空间最大值,默认是-1,即不限制,或者说只受限于本地内存大小。 -
-XX:MetaspaceSize
:指定元空间的初始空间大小,以字节为单位,达到该值就会触发垃圾收集进行类型卸载,同时收集器会对该值进行调整:如果释放了大量的空间,就适当降低该值;如果释放了很少的空间,那么在不超过-XX:MaxMetaspaceSize(如果设置了的话)的情况下,适当提高该值。 -
-XX:MinMetaspaceFreeRatio
:作用是在垃圾收集之后控制最小的元空间剩余容量的百分比,可减少因为元空间不足导致的垃圾收集的频率。 -
-XX:Max-MetaspaceFreeRatio
,用于控制最大的元空间剩余容量的百分比。
本机直接内存溢出
直接内存(Direct Memory)
的容量大小可通过-XX:MaxDirectMemorySize
参数来指定,如果不去指定,则默认与Java堆最大值(由-Xmx指定)一致。
//-Xmx20M -XX:MaxDirectMemorySize=10M
public class DirectMemoryOOM {
private static final int _1MB = 1024 * 1024;
public static void main(String[] args) throws IllegalAccessException {
Field declaredField = Unsafe.class.getDeclaredFields()[0];
declaredField.setAccessible(true);
Unsafe unsafe = (Unsafe) declaredField.get(null);
while (true){
unsafe.allocateMemory(_1MB);
}
}
}
输出结果:
Exception in thread "main" java.lang.OutOfMemoryError
at sun.misc.Unsfe.allocateMemory(Native Method)
at org.fenixsoft.oom.DMOOM.main(DMOOM.java:20)
参考
The Java® Virtual Machine Specification
《深入理解Java虚拟机 第三版》