java内存区域与内存溢出异常
1.概述
对于Java程序员来说,在虚拟机自动内存管理机制的帮助下,不再需要手动释放内存,不容易出现内存泄露和内存溢出问题。一旦出现内存泄露和溢出方面的问题,如果不了解虚拟机是怎样使用内存的,排查错误将会异常艰难。
2.运行时数据区域
Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域。这些区域都有各自的用途,以及创建和销毁的时间,有的区域随着虚拟机进程的启动而存在,有些区域则依赖用户线程的启动和结束而建立和销毁。
如图所示:
2.1 程序计数器(PC计数器)
程序计数器(Program Counter Register)是一块较小的内存空间,它可以看作是当前线程所执行的Java字节码的行号指示器。在虚拟机的概念模型里,字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。
由于Java虚拟机的多线程是通过线程轮流切换、分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器(对于多核处理器来说是一个内核)都只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。
如果线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是本地(Native)方法,这个计数器值则为空(Undefined)。此内存区域是唯一 一个在Java虚拟机规范中没有规定OutOfMemoryError情况的区域。
2.2 Java虚拟机栈
与程序计数器一样,Java虚拟机栈(Java Virtual Machine Stacks)也是线程私有的,它的生命周期与线程相同。虚拟机栈描述的是Java方法执行的内存模型:每个方法在执行的时候,Java虚拟机都会创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态连接、方法出口等信息。每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。
经常有人把Java内存区分为堆内存(Heap)和栈内存(Stack),这种划分方式直接继承自传统的C、C++程序的内存布局结构,但实际的内存区域划分要比这更复杂。其中所指的“堆”就是Java堆,而所指的“栈”通常就是指这里所讲的虚拟机栈,或者更多的情况下只是指虚拟机栈中局部变量表部分。
局部变量表存放了编译期可知的各种基本数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference类型,它不等同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关位置)和returnAddress类型(指向了一条字节码指令的地址:调用者PC计数器中的值,指向的是返回调用者的跳转处)。
这些数据类型在局部变量表中的存储空间一局部变量槽(Slot)来表示,其中64为长度的long和double类型的数据会占用2个变量槽,其余的数据类型只占用1个。局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在栈帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。(这里的“大小”是指变量槽的数量,一个变量槽所占用多大的内存空间,这完全取决于具体的虚拟机的实现)
在《Java虚拟机规范》中,对这个区域规定了两种异常状况:如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常;如果虚拟机栈可以动态扩展(当前大部分的Java虚拟机都可动态扩展,只不过Java虚拟机规范中也允许固定长度的虚拟机栈),如果扩展时无法申请到足够的内存,就会抛出OutOfMemoryError异常。
2.3 本地方法栈
本地方法栈(Native Method Stack)与虚拟机栈所发挥的作用是非常相似的,其区别只是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则为虚拟机使用到的本地(Native)方法服务。
《Java虚拟机规范》对本地方法栈中方法使用的语言、使用方式与数据结构并没有强制规定。HotSpot虚拟机直接把本地方法栈和虚拟机栈合二为一。与虚拟机栈一样,本地方法栈区域也会抛出StackOverflowError和OutOfMemoryError异常。
参数设置:
- -Xss 设置每个线程的栈大小。JDK1.5+ 每个线程栈大小为1M,一般来说如果栈不是很深的话,1M是绝对够用的啦。
参数含义解析:
- 以-X开头的参数是和实现有关的,第一个s表示stack,第二个s表示size;
注意:
在相同物理内存下,减小这个值能生成更多的线程。但是操作系统对一个进程内的线程数还是有限制的,不能无限生成,经验值在3000~5000左右。
2.4 Java堆
对于Java应用程序来说,Java堆(Java Heap)是Java虚拟机所管理的内存中最大的一块。Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存。在《Java虚拟机规范》中堆Java堆的描述是:所有的对象实例以及数组都要在堆上分配,但是随着java语言的发展,由于及时编译技术的进步,尤其是逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化发生,所有的对象都分配在堆上也渐渐变得不是那么“绝对”了。
Java堆是垃圾收集器管理的内存区域,因此很多时候也被称做“GC堆”(Garbage Collected Heap)。
从回收内存的角度来看,由于现代收集器大部分都是基于分代收集理论设计的,所以Java堆中经常出新生代、老年代、永久代、Eden空间、From Survivor空间、To Survivor空间等名词,但这只是一部分垃圾收集器的共同特性或者说设计风格而已,而非某个JVM具体实现的固有内存布局。(现已出现不采用分代设计的新垃圾收集器)
从分配内存的角度来看,所有线程共享的Java堆中可以划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer,TLAB),以提升对象分配时的效率。
不过无论从什么角度,无论如何划分,都与存放内容无关,无论哪个区域,存储的都仍然是对象实例,进一步划分的目的是为了更好地回收内存,或者更快地分配内存。
根据Java虚拟机规范的规定,Java堆可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可,就像我们的磁盘空间一样。但对于大对象(如数组对象),多数虚拟机处于实现简单、存储高效的考虑,很可能会要求连续的内存空间。在实现时,既可以实现成固定大小的,也可以是扩展的,不过当前主流的虚拟机都是按照可扩展来实现的(通过-Xmx和-Xms控制)。如果在堆中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出OutOfMemoryError异常。
参数设置:
- -Xms 设置堆的最小空间大小;通常为操作系统可用内存的1/64大小即可。
- -Xmx 设置堆的最大空间大小;通常为操作系统可用内存的1/4大小。
- -Xmn 设置新生代大小,是对-XX:newSize、-XX:MaxnewSize两个参数的同时配置,这个参数是在JDK1.4版本以后出现的;通常为Xmx的1/3或1/4。新生代 = Eden + 2个Survivor空间。实际可用空间 = Eden + 1个Survivor,即90%。
- -XX:NewSize 设置新生代最小空间大小;
- -XX:MaxNewSize 设置新生代最大空间大小;
- -XX:NewRatio 新生代与老年代的比例,如-XX:NewRatio=2,则新生代占整个堆空间的1/3,老年代占2/3。
- -XX:SurvivorRatio 新生代中 Eden 与 Survivor的比值。默认值为 8 。即Eden占新生代空间的8/10,另外两个Survivor各占1/10。
- -XX:+HeapDumpOnOutOfMemoryError:在出现内存溢出时Dump出当前的内存堆转储快照
参数含义解析:
- 以-X开头的参数是和实现有关的,并不是适用于所有的参数;
- 最开始只有 -Xms的参数,表示‘初始’ memory size,m表示memory,s表示size;
- 紧接是参数 -Xmx,为了对齐三字符,压缩了其表示形式,采用计算机中约定表示方式:用 x 表示“”大“ (可以联想到衣服的号码大小,S、M、L、XL、XXL),因此 -Xmx中的m应当还是memory。既然有了最大内存的概念,那么一开始的 -Xms所表示的”初始“内存也就有了一个”最小“内存的概念(其实常用的做法中初始内存采用的也就是最小内存)。如果不对齐参数长度的话,其表示应当是-Xmsx。
注意:
开发过程中,通常会将-Xms与-Xmx两个参数的配置相同的值,其目的是为了能够在Java垃圾回收机制清理完堆区后不需要重新分隔计算堆区的大小而浪费资源。(在《深入理解java虚拟机》说明当-Xms与-Xmx两个参数的配置相同时可避免堆自动扩展)
2.5 方法区
方法区(Method Area)与Java堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。虽然《Java虚拟机规范》把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做Non-Heap(非堆),目的应该是与Java堆区分开来。
在JDK/以前,对于习惯在HotSpot虚拟机上开发、部署程序的开发者来说,很多人都更愿意把方法区称为“永久代”(Permanent Generation),本质上两者并不等价,仅仅是因为当时的HotSpot虚拟机的设计团队选择把分代收集设计扩展至方法区,或者说使用永久代来实现方法区而已,这样HotSpot的垃圾收集器可以像管理Java堆一样管理这部分内存、能够省去专门为方法区编写内存管理代码的工作。
根据Java虚拟机规范的规定,当方法区无法满足内存分配需求时,将抛出OutOfMemoryError异常。(元空间后不存在此问题?)
JDK1.7的HotSpot,把原本放在永久代的字符串常量池、静态变量等移到了Java堆中
JDK8版本中则把永久代给完全删除了,取而代之的是与JRockit、J9一样在本地内存中实现的元空间(Meta-space),把JDK7中永久代还剩下的内容(主要是类型信息)全部移动到元空间中
JDK7以前版本:
JDK8:
参数设置:
- -XX:PermSize设置永久代最小空间大小;
- -XX:MaxPermSize设置永久代最大空间大小;
参数含义解析:
- PermSize,表示永久代初始设置大小,这里初始大小表示最小大小,Perm是permanent永久的意思;
注意:
- JDK8没有这个参数设置。
- 非堆内存不会被Java垃圾回收机制进行处理,在配置之前一定要慎重考虑下自身软件所需要的非堆区内存大小。
3. -XX:MaxMetaspaceSize:设置元空间最大值,默认是-1,即不限制,或者说只受限于本地内存大小
4. -XX:MetaspaceSize:设置元空间的初始空间大小,以字节为单位,达到该值就会触发垃圾收集进行类型卸载,同事收集器会对该值进行调整:如果释放了大量的空间,就适当降低该值;如果释放了很少的空间,在不超过XX:-MaxMetaspaceSize的情况下,适当提高该值
5. -XX:MinMetaspaceFreeRatio:作用是在垃圾收集之后控制最小的元空间剩余容量的百分比,可减少因为元空间不足导致的垃圾收集的频率
6.-XX:MaxMetaspaceFreeRatio:作用是在垃圾收集之后控制最大的元空间剩余容量的百分比
2.6 运行时常量池(是否也随着常量池移到堆中?)
运行时常量池(Runtime Constant Pool)是方法区的一部分。Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池(Constant Pool Table),用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。
Java虚拟机对Class文件每一部分(自然也包括常量池)的格式都有严格规定,每一个字节用于存储哪种数据都必须符合规范上的要求才会被虚拟机认可、装载和执行,但对于运行时常量池,Java虚拟机规范没有做任何细节的要求。不过,一般来说,除了保存Class文件中描述的符号引用外,还会把由符号引用翻译出来的直接引用也存储在运行时常量池中。
运行时常量池相对于Class文件常量池的另外一个重要特征是具备动态性,Java语言并不要求常量一定只有编译期才能产生,也就是并非预置入Class文件中常量池的内容才能进入方法区运行时常量池,运行期间也可能将新的常量放入池中,这种特性被开发人员利用得比较多的便是String类的intern()方法。
既然运行时常量池是方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存时会抛出OutOfMemoryError异常。
2.7 直接内存(狭义的堆外内存,广义的堆外内存指非堆内存,需要去除永久代,包括JVM本身运行分配的内存、codecache、jni等)
直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域。但是这部分内存也被频繁地使用,而且也可能导致OutOfMemoryError异常出现。
在JDK1.4中新加入了NIO(New Input/Output)类,引入了一种基于通道(Channel)与缓冲区(Buffer)的I/O方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆中的DirectByteBuffer对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在Java堆和Native堆中来回复制数据。
显然,本机直接内存的分配不会受到Java堆大小的限制,但是,既然是内存,肯定还是会受到本机总内存(包括物理内存、SWAP分区或者分页文件)大小以及处理寻址空间的限制。服务器管理员在配置虚拟机参数时,会根据实际内存设置-Xmx等参数信息,但经常忽略直接内存,使得各个内存区域总和大于物理内存限制(包括物理的和操作系统级的限制),从而导致动态扩展时出现OutOfMemoryError异常。
参数设置:可以通过 -XX:MaxDirectMemorySize参数来设置最大可用直接内存,如果不去指定,则默认与Java堆最大值,即与 -Xmx相同。即假如最大堆内存为1G,则默认直接内存也为1G,那么JVM最大需要的内存大小为2G多一些。当直接内存达到最大限制时就会触发GC,如果回收失败则会引起OutOfMemoryError。
3.HotSpot虚拟机对象探秘
3.1 对象的创建
当java虚拟机遇到一条字节码new指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已经被加载、解析和初始化过、如果没有,那么必须先执行相应的类加载过程。
在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需要的内存大小在类加载完成后便可以完全确认(3.2介绍),为对象分配空间的任务实际上便等同于把一块确认大小的内存块从Java堆中划分出来。
内存分配方式:
1.指针碰撞(Bump The Pointer):Java堆中的内存绝对规整的,使用过的与空闲的内存各自放到一边,中间放着一个指针作为分界指示器,那么所分配内存就会仅仅把那个指针向空闲空间方向移动一段与对象大小相等的距离
2.空闲列表(Free List):虚拟机维护一个列表,记录上哪些内存是可用的,在分配时从列表中找到一块足够大的空间给对象,并更新列表上的记录
选择哪个分配方式取决Java堆是否规整,而Java堆是否规整又由采用的垃圾收集器是否带有空间压缩整理的能力决定(Serial、parNew有,CMS没有,但会设计一个叫做Linear Allocation Buffer的分配缓冲区,通过空闲列表拿到一大块分配缓冲区后,在它里面可以使用“指针碰撞”的方式分配)
在并发的情况下这些分配操作并不是线程安全的,有两种解决方案:一种是对分配内存空间的动作进行同步处理——实际上虚拟机是采用CAS配上失败重试的方式保证更新操作的原子性;另外一种就是把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在Java堆中预先分配一小块内存(本地线程分配缓冲,Thread Local Allocation Buffer,TLAB),本地使用完了才需要同步锁定,是否使用TLAB,通过-XX:+/-UseTLAB参数设置。
内存分配完成之后,将分配到的内存空间(但不包括对象头)都初始化为零值,如果使用TLAB,也可以提前到TLAB分配时顺便进行
接下来,对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码(实际上会延后到调用Object::hashCode()方法时计算)、对象的GC分代信息等,这些信息存放在对象的对象头之中。
从虚拟机的角度来看,一个新的对象已经生成了。但从Java程序的角度看来,对象创建才刚刚开始,构造函数还没执行。new指令之后会接着执行<init>()方法(new关键字会同时生成invokespecial指令),这样一个真正可用对象才算是完全被构造出来。
3.2 对象的内存布局
在HotSpot虚拟机里,对象在堆内存中的存储布局可以划分为三个部分:对象头、实例数据和对齐填充。
对象头包括两类信息:第一类是用于存储对象自身的运行时的数据,如哈希码、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,又称为“Mark Word”;另一类是类型指针,即对象指向它的类型元数据的指针,如果对象是一个Java数组,那么还要有一块用于记录数组长度的数据。
实例数据是对象真正存储的有效信息,即我们在程序代码里面定义的各种类型的字段内容(包括父类继承下来的,并且父类定义的变量出现在子类之前)
对齐填充并不是必然存在的(占位符),HotSpot的自动内存管理系统要求对象出事地址必须是8字节的整数倍
3.3 对象的访问定位
通过栈上的reference数据来操作堆上的具体对象,那么通过什么方式去定位?
主流的访问方式:
1.句柄访问:Java堆中划分一块内存作为句柄池,refreence中存储的就是对象的句柄地址,句柄中包含对象实例数据与类型数据各自的地址(好处:对象被移动时只会改变句柄中的实例数据指针,而reference本身不需要被修改)
2.直接指针访问:refreence中存储的直接就是对象地址(好处:速度更快,节省一次指针定位的时间开销),HotSpot主要使用直接指针访
4.内存溢出
根据上面的参数设置堆、方法区、直接内存的大小,循环生成对象、常量、DirectByteBuffer对象
参考资料:
《深入理解java虚拟机》
https://www.cnblogs.com/swordfall/p/10723938.html
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· .NET Core 中如何实现缓存的预热?
· 从 HTTP 原因短语缺失研究 HTTP/2 和 HTTP/3 的设计差异
· AI与.NET技术实操系列:向量存储与相似性搜索在 .NET 中的实现
· 基于Microsoft.Extensions.AI核心库实现RAG应用
· Linux系列:如何用heaptrack跟踪.NET程序的非托管内存泄露
· TypeScript + Deepseek 打造卜卦网站:技术与玄学的结合
· Manus的开源复刻OpenManus初探
· AI 智能体引爆开源社区「GitHub 热点速览」
· 三行代码完成国际化适配,妙~啊~
· .NET Core 中如何实现缓存的预热?