第二章、Java内存区域与内存溢出异常
2.1 概述
- 介绍Java虚拟机内存的各个区域
- 讲解这些区域的作用、服务对象以及其中可能产生的问题
2.2 运行时数据区域
2.2.1 运行时数据区域
- 程序计数器:当前线程所执行的字节码的行号指示器,每条线程都需要有一个独立的程序计数器(线程私有),不会发生OOM。
- 虚拟机栈:线程私有,每个方法被执行的时候,Java虚拟机都会同步创建一个栈帧用于存储局部变量表、操作数栈、动态连接、方法出口等信息,会抛出StackOverflowError和OutOfMemoryError异常。
- 本地方法栈:与虚拟机栈相似,为本地方法的执行服务,会抛出StackOverflowError和OutOfMemoryError异常。
- 堆:线程共享,存放对象实例,大小可通过参数-Xmx和-Xms设定,会抛出OutOfMemoryError异常。
- 方法区:线程共享,用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。
- 运行时常量池:方法区的一部分,用于存放在类加载后Class文件常量池表中的信息(各种字面量与符号引用)。
- 直接内存:不是虚拟机运行时数据区的一部分,NIO使用Native函数库直接分配堆外内存,然后DirectByteBuffer对象作为这块内存的引用。
2.2.2 额外知识:
-
Class对象是存放在堆区的,不是方法区,这点很多人容易犯错。类的元数据(元数据并不是类的Class对象。Class对象是加载的最终产品,类的方法代码,变量名,方法名,访问权限,返回值等等都是在方法区的)才是存在方法区的。
-
程序计数器(PC) 不存在Error(溢出情况) 不存在GC(垃圾回收)
虚拟机栈(JVM) :存在Error(溢出情况) 不存在GC(垃圾回收)
本地方法栈(NM)::存在Error(溢出情况) 不存在GC(垃圾回收)
堆空间(Heap)::存在Error(溢出情况) 存在GC(垃圾回收)
方法区(Method Area)::存在Error(溢出情况) 存在GC(垃圾回收)总结:
- 不存在Error(溢出情况) :程序计数器(PC)
- 不存在GC(垃圾回收):程序计数器(PC)、虚拟机栈(JVM)、本地方法栈(NM)
-
堆中:Class对象、字符串常量池、静态成员(在Class对象内),在JDK 1.7的HotSpot实现中,原本放在方法区中的静态变量、字符串常量池等被移到了堆内存中
-
java.lang.Class 对象和 static 成员变量在运行时内存的位置。这里先给出结论,JDK 1.8 中,两者都位于堆(Heap),且static 成员变量位于 Class对象内。
-
Class对象是存放在堆区的,不是方法区,这点很多人容易犯错。类的元数据(元数据并不是类的Class对象!Class对象是加载的最终产品,类的方法代码,变量名,方法名,访问权限,返回值等等都是在方法区的)才是存在方法区的。
-
2.2.3 jdk6,7,8三个版本的内存模型
在JDK 1.7的HotSpot实现中,原本放在方法区中的静态变量、字符串常量池等被移到了堆内存中
补充:类的实例对象、类的元数据、类的class对象关系图
Class对象存储在Java堆中_class对象在堆还是方法区-CSDN博客
Person类的实例(Oop)-->Person类的元数据()< === >Person类的class对象
2.2.4 java栈溢出分析
从一道面试题开始学习JVM:Java最大栈深度有多大?_栈的深度-CSDN博客
一文读懂Java虚拟机栈 (baidu.com)
-Xss设置线程栈大小
- 线程栈大小越大,能够支持越多的方法调用,也即是能够存储更多的栈帧,深度就越大
- 局部变量表内容越多,那么栈帧就越大,栈深度就越小。
栈大小(-Xss设置) = 栈帧大小 * 栈深度
测试:
public class StackTest {private int count = 0;public void recursiveCalls(String a){count++;System.out.println("stack depth: " + count);recursiveCalls(a);}public void test(){try {recursiveCalls("a");} catch (Exception e) {System.out.println(e);}}public static void main(String[] args) {new StackTest().test();}
}
我们设置启动参数
-Xms256m -Xmx256m -Xmn128m -Xss256k
输出内容:
stack depth: 1556
Exception in thread "main" java.lang.StackOverflowErrorat sun.nio.cs.UTF_8.updatePositions(UTF_8.java:77)
可以发现,栈深度为1556的时候,就报 StackOverflowError了。
接下来我们调整-Xss线程栈大小为 512k,输出内容:
stack depth: 3249
Exception in thread "main" java.lang.StackOverflowErrorat java.nio.charset.CharsetEncoder.encode(CharsetEncoder.java:579)
发现栈深度变味了3249,说明了:
随着线程栈的大小越大,能够支持越多的方法调用,也即是能够存储更多的栈帧。
局部变量表内容越多,那么栈帧就越大,栈深度就越小。可用相同方法测试
2.2.5 Metaspace (有待完善?感觉不是很清晰)
Java 内存分区之什么是 CCS区 Compressed Class Space 类压缩空间_compressedclassspacesize-CSDN博客
JVM元空间(Metaspace) - Yungyu - 博客园 (cnblogs.com)
Metaspace由两大部分组成:Klass Metaspace和NoKlass Metaspace。
- Klass Metaspace就是用来存klass的,就是class文件在jvm里的运行时数据结构(不过我们看到的类似A.class其实是存在heap里的,是java.lang.Class的对象实例) ,这部分默认放在Compressed Class Pointer Space中,是一块连续的内存区域,紧接着Heap,和之前的perm一样
- NoKlass Metaspace专门来存klass相关的其他的内容,比如method,constantPool等,可以由多块不连续的内存组成。 这块内存是必须的,虽然叫做NoKlass Metaspace,但是也其实可以存klass的内容,上面已经提到了对应场景。 NoKlass Metaspace在本地内存中分配。
Metaspace used 2937K, capacity 4486K, committed 4864K, reserved 1056768Kclass space used 314K, capacity 386K, committed 512K, reserved 1048576K
- reserved:元数据的空间保留(但不一定committed提交)的量。所谓的保留,更加接近一种记账的概念,还没实际分配,承诺给你,但是还没有给你(如果内存不够了,给的时候可能给不了)
- committed: 空间块的数量。操作系统分配真正的内存:正在使用的内存Chunk + GC回收的Free Chunk
- capacity: 当前分配块的元数据的空间。分配下来正在使用的内存Chunk,被GC回收的(Free Chunk)不算了
- 因为有GC的存在,有些Chunk的数据可能会被回收,那么这些Chunk属于committe的一部分,但不属于capacity。
- used:加载的类的空间量。正在使用的内存Chunk之和,不包括碎片内存。
- 这些被分配的Chunk,基本很难被100%用完,存在碎片内存的情况,这些Chunk实际被使用的内存之和即used的大小。
2.2.6 class space (有待确认?不知对错???感觉不是很清晰)
至于class space
,要记住的是,metaspace
并不是全部用来放类对象的。比如说,因为每一个ClassLoader
都被分配了一块内存,这块内存可能并没有被用完,于是就会有一些内存碎片;metaspace
还需要放所谓静态变量。所以,class space
是指实际上被用于放class
的那块内存的和。
2.2.7 可参考博客
JVM内存结构简述(JDK1.8)_jvm1.8内存模型-CSDN博客
通俗易懂,一文彻底理解JVM方法区 (baidu.com)
Class对象存储在Java堆中_class对象在堆还是方法区-CSDN博客
《Java虚拟机规范》阅读(一):简介和Java虚拟机结构 - 朱样年华 - 博客园 (cnblogs.com)
2.3 HotSpot虚拟机对象探秘
2.3.1 对象的创建
【Java】关于Java对象的创建过程_java对象创建的过程-CSDN博客
在 Java 中,创建对象的方式有很多种,比如最常见的通过new xxx()
来创建一个对象,通过反射Class.forName(xxx).newInstance()
来创建对象等。其实无论是哪种创建方式,JVM 底层的执行过程是一样的。
创建对象大致分为 5 个步骤:
- 检查类是否加载(非必然步骤,如果没有就执行类的加载);
- 分配内存;
- 初始化零值;
- 设置头对象;
- 执行
方法(该方法由实例成员变量声明、实例初始化块和构造方法组成)。
(1) 类加载检查
当需要创建一个类的实例对象时,比如通过new xxx()方式,虚拟机首先会去检查这个类是否在常量池中能定位到一个类的符号引用,并且检查这个符号引用代表的类是否已经被加载、解析和初始化,如果没有,那么必须先执行类的加载流程;如果已经加载过了,就不会再次加载。
Q:为什么在对象创建时,需要有这一个检查判断?
A:主要原因在于:类的加载,通常都是懒加载,只有当使用类的时候才会加载,所以先要有这个判断流程。
(2) 分配内存
在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需内存的大小在类加载完成 后便可完全确定。
虚拟机如何在堆中分配内存主要有两种方式:
- 指针碰撞(Bump The Pointer):假设Java堆中内存是绝对规整的,所有被使用过的内存都被放在一 边,空闲的内存被放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那 个指针向空闲空间方向挪动一段与对象大小相等的距离。
- 空闲列表(Free List):如果Java堆中的内存并不是规整的,已被使用的内存和空闲的内存相互交错在一起,那就没有办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录。
选择哪种分配方式由Java堆是否规整决定,而Java堆是否规整又由所采用的垃圾收集器是否带有空间压缩整理(Compact)的能力决定。因此,当使用Serial、ParNew等带压缩整理过程的收集器时,系统采用的分配算法是指针碰撞,既简单又高效;而当使用CMS这种基于清除 (Sweep)算法的收集器时,理论上就只能采用较为复杂的空闲列表来分配内存。
🍳内存分配时的线程安全问题:对象创建在虚拟机中是非常频繁的行为,即使仅仅修改一个指针所指向的位置,在并发情况下也并不是线程安全的,可能出现正在给对象A分配内存,指针还没来得及修改,对象B又同时使用了原来的指针来分配内存的情况。
解决这个问题 有两种可选方案:
- CAS+重试机制:通过 CAS 操作移动指针,只有一个线程可以移动成功,移动失败的线程重试,直到成功为止。
- TLAB (thread local Allocation buffer):也称为本地线程分配缓冲,这个处理方式思想很简单,就是当线程开启时,虚拟机会为每个线程分配一块较大的空间,然后线程内部创建对象的时候,就从自己的空间分配,这样就不会有并发问题了,当线程自己的空间用完了才会从堆中分配内存,之后会转为通过 CAS+重试机制来解决并发问题。
(3)初始化零值
内存分配完成之后,虚拟机必须将分配到的内存空间(但不包括对象头)都初始化为零值(比如 int 类型赋值为 0,引用类型为null等操作)。如果使用了TLAB的话,这一项工作也可以提前至TLAB分配时顺便进行。这步操作保证了对象的实例字段 在Java代码中可以不赋初始值就直接使用,使程序能访问到这些字段的数据类型所对应的零值。
(4)设置头对象
内存结构:在HotSpot虚拟机里,对象在堆内存中的存储布局可以划分为三个部分:
-
对象头(Header)HotSpot虚拟机对象的对象头部分包括两类信息。
-
第一类是用于存储对象自身的运行时数据,如哈 希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等。这部分数据的长度在32位和64位的虚拟机中分别为32个比特和64个比特,官方称它为“Mark Word”。具体各个字段含义去线程加锁那里看!
-
另外一部分是类型指针,即对象指向它的类型元数据(存储在元空间)的指针,Java虚拟机通过这个指针 来确定该对象是哪个类的实例。简单聊一聊UseCompressedOops UseCompressedClassPointers这两个JVM参数_usercompressedclasspointres和oops-CSDN博客
-
此外,如果对象是一个Java数组,那在对象头中还必须有一块用于记录数组长度的数据,因为虚拟机可以通过普通 Java对象的元数据信息确定Java对象的大小,但是如果数组的长度是不确定的,将无法通过元数据中的 信息推断出数组的大小。
-
-
实例数据(Instance Data)
实例数据部分是对象真正存储的有效信息,即我们在程序代码里面所定义的各种类型的字 段内容,无论是从父类继承下来的,还是在子类中定义的字段都必须记录起来。这部分的存储顺序会 受到虚拟机分配策略参数(-XX:FieldsAllocationStyle参数)和字段在Java源码中定义顺序的影响。 HotSpot虚拟机默认的分配顺序为longs/doubles、ints、shorts/chars、bytes/booleans、oops(Ordinary Object Pointers,OOPs),从以上默认的分配策略中可以看到,相同宽度的字段总是被分配到一起存放,在满足这个前提条件的情况下,在父类中定义的变量会出现在子类之前。如果HotSpot虚拟机的 +XX:CompactFields参数值为true(默认就为true),那子类之中较窄的变量也允许插入父类变量的空 隙之中,以节省出一点点空间。
整数类型:byte(1字节), short(2字节), int(4字节), long(8字节)
浮点类型:float(4字节), double(8字节)
字符类型:char(2字节)
布尔类型: boolean(1个字节) -
对齐填充(Padding)
首先解释一下上面的一些术语,在java的对象中,对象的大小都为8byte的倍数。
alignment/padding gap
- alignment 对齐,对齐都是向8byte对齐
- padding 补齐,补齐是向4byte补齐,对象对齐的最小粒度为4byte。
alignment/padding gap会在以下情况下存在:(有待确认,不确定??)
- 当对象包含基本数据类型时,如果这些基本数据类型的字节和不是4的倍数,且字节和大于4,此时会触发alignment/padding gap。
- 当对象包含非基本数据类型(即引用数据类型)时,如果所有基本数据类型的字节和不是4的倍数,此时也会触发alignment/padding gap。
- 缓存行填充:在多线程环境中,为了优化性能,Java可能会对某些需要频繁读取和修改的字段进行缓存行填充。
Q: 为什么要对齐数据?
A: 为了CPU能够高效寻址 。
另外字节对齐的作用不仅是便于cpu快速访问,同时合理的利用字节对齐可以有效地节省存储空间。
- CPU一次访问时,要么读0x01~0x04,要么读0x05~0x08…硬件不支持一次访问就读到0x02~0x05
例:如果0x02~0x05存了一个int,读取这个int就需要先读0x01~0x04,留下0x02~0x04的内容,再读0x05~0x08,留下0x05的内容,两部分拼接起来才能得到那个int的值,这样读一个int就要两次内存访问,效率就低了。
例子:
1.普通对象
Object object = new Object()
占几个字节?
在64位CPU上,对象头中对象标志占8个字节,类型指针(指针压缩开启)占4个字节。实例数据区无数据。对齐区12不是8的倍数了,扩充4个字节变成16。因此一共16个字节。
public static class ArtisanTest {int id; //4BString name; //4Bbyte b; //1BObject o; //4B
}
2.数组对象(多了个length)
public static void main(String[] args) {int[] a = {1};System.out.println(ClassLayout.parseInstance(a).toPrintable());
}
打印的内存布局信息:
打印的内存布局信息:
可以看到SIZE一共是24byte。
对象头:对象标志8字节。类型指针4字节。数组长度4字节。
实例数据:int 1 数据4字节。
对齐:前面一共20字节。填充4字节。使其满足8的倍数。
(5)执行方法
<init>
方法Java在编译的时候生成的,该方法包含这个类中的实例成员变量声明、实例初始化块和构造方法,作用是给对象执行初始化操作。按照程序员的意愿对对象进行初始化,这样一个真正可用的对象才算完全被构造出来。
类中有多少个构造方法就有多少个<init>
方法。创建对象时使用哪个构造方法,就执行对应的<init>
方法。<init>
方法中的语句顺序与实例成员变量初始化顺序一致,下图是实例成员变量的初始化顺序:
当然父类也有<init>
方法,初始化对象时,先执行父类的<init>
方法再执行子类的<init>
方法,如图所示:
到这里初始化操作完成之后,Java对象才算真正意义上创建了,这时候才能够使用这个对象。
顺便扩展一下前面的类加载阶段时的静态成员变量初始化。静态成员变量初始化对应的是
方法,并且也是JVM自动生成的。 方法中的语句顺序与静态成员变量初始化顺序一致,下图是静态成员变量的初始化顺序: 注意
方法不会在创建对象时执行,只有在类加载的初始化阶段时候,才会执行对应的 方法。具体查看Java类的初始化时机。【Java】关于Java类初始化的时机_java类的初始化是什么时候-CSDN博客
打印对象的内存布局
最后,如果我们想看下对象创建后的大小,可以添加第三方jol
包,使用它来打印对象的内存布局情况。
<dependency><groupId>org.openjdk.jol</groupId><artifactId>jol-core</artifactId><version>0.9</version>
</dependency>
测试类
public class ObjectHeaderTest {public static void main(String[] args) {System.out.println("=========打印Object对象的大小========");ClassLayout layout = ClassLayout.parseInstance(new Object());System.out.println(layout.toPrintable());System.out.println("========打印数组对象的大小=========");ClassLayout layout1 = ClassLayout.parseInstance(new int[]{});System.out.println(layout1.toPrintable());System.out.println("========打印有成员变量的对象大小=========");ClassLayout layout2 = ClassLayout.parseInstance(new ArtisanTest());System.out.println(layout2.toPrintable());}/*** ‐XX:+UseCompressedOops 表示开启压缩普通对象指针* ‐XX:+UseCompressedClassPointers 表示开启压缩类指针**/public static class ArtisanTest {int id; //4BString name; //4Bbyte b; //1BObject o; //4B}
}
运行结果:
=========打印Object对象的大小========
java.lang.Object object internals:OFFSET SIZE TYPE DESCRIPTION VALUE0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1)4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)8 4 (object header) e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243)12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total========打印数组对象的大小=========
[I object internals:OFFSET SIZE TYPE DESCRIPTION VALUE0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1)4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)8 4 (object header) 6d 01 00 f8 (01101101 00000001 00000000 11111000) (-134217363)12 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)16 0 int [I.<elements> N/A
Instance size: 16 bytes
Space losses: 0 bytes internal + 0 bytes external = 0 bytes total========打印有成员变量的对象大小=========
com.example.myspringboot001.test.ObjectHeaderTest$ArtisanTest object internals:OFFSET SIZE TYPE DESCRIPTION VALUE0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1)4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)8 4 (object header) 61 e0 00 f8 (01100001 11100000 00000000 11111000) (-134160287)12 4 int ArtisanTest.id 016 1 byte ArtisanTest.b 017 3 (alignment/padding gap) 20 4 java.lang.String ArtisanTest.name null24 4 java.lang.Object ArtisanTest.o null28 4 (loss due to the next object alignment)
Instance size: 32 bytes
Space losses: 3 bytes internal + 4 bytes external = 7 bytes total
2.3.2 对象的访问定位
对象访问方式也是由虚拟机实 现而定的,主流的访问方式主要有使用句柄和直接指针两种
使用句柄访问:Java堆中将可能会划分出一块内存来作为句柄池,reference中存储的就 是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自具体的地址信息。
直接指针访问:Java堆中对象的内存布局就必须考虑如何放置访问类型数据的相关 信息,reference中存储的直接就是对象地址,如果只是访问对象本身的话,就不需要多一次间接访问的开销。
使用直接指针来访问最大的好处就是速度更快,它节省了一次指针定位的时间开销,由于对象访问在Java中非常频繁,因此这类开销积少成多也是一项极为可观的执行成本,就本书讨论的主要虚拟机HotSpot而言,它主要使用第二种方式进行对象访问(有例外情况,如果使用了Shenandoah收集器的 话也会有一次额外的转发,具体可参见第3章),但从整个软件开发的范围来看,在各种语言、框架中 使用句柄来访问的情况也十分常见。
2.3.3 实战:OutOfMemoryError异常
看 2.2.4 java栈溢出分析
看书 第88页
程序计数器(PC) 不存在Error(溢出情况) 不存在GC(垃圾回收)
虚拟机栈(JVM) :存在Error(溢出情况) 不存在GC(垃圾回收)
本地方法栈(NM)::存在Error(溢出情况) 不存在GC(垃圾回收)
堆空间(Heap)::存在Error(溢出情况) 存在GC(垃圾回收)
方法区(Method Area)::存在Error(溢出情况) 存在GC(垃圾回收)
总结:
- 不存在Error(溢出情况) :程序计数器(PC)
- 不存在GC(垃圾回收):程序计数器(PC)、虚拟机栈(JVM)、本地方法栈(NM)