JVM 内存结构
内存简介
物理内存和虚拟内存
所谓物理内存就是通常所说的 RAM(随机存储器)。
虚拟内存使得多个进程在同时运行时可以共享物理内存,这里的共享只是空间上共享,在逻辑上彼此仍然是隔离的。
内核空间和用户空间
一个计算通常有固定大小的内存空间,但是程序并不能使用全部的空间。因为这些空间被划分为内核空间和用户空间,而程序只能使用用户空间的内存。
使用内存的 Java 组件
Java 启动后,作为一个进程运行在操作系统中。
有哪些 Java 组件需要占用内存呢?
- 堆内存:Java 堆、类和类加载器
- 栈内存:线程
- 本地内存:NIO、JNI
Java 源代码文件经过编译器编译后生成字节码文件,然后交给 JVM 的类加载器,加载完毕后,交给执行引擎执行。在整个执行的过程中,JVM 会用一块空间来存储程序执行期间需要用到的数据,这块空间一般被称为运行时数据区,也就是常说的 JVM 内存。
运行时数据区域
JVM 在执行 Java 程序的过程中会把它所管理的内存划分为若干个不同的数据区域。这些区域都有各自的用途,以及创建和销毁的时间,有的区域随着虚拟机进程的启动而存在,有些区域则依赖用户线程的启动和结束而建立和销毁。如下图所示:
程序计数器
程序计数器(Program Counter Register)
是一块较小的内存空间,它可以看做是当前线程所执行的字节码的行号指示器。例如,分支、循环、跳转、异常、线程恢复等都依赖于计数器。
当执行的线程数量超过 CPU 数量时,线程之间会根据时间片轮询争夺 CPU 资源。如果一个线程的时间片用完了,或者是其它原因导致这个线程的 CPU 资源被提前抢夺,那么这个退出的线程就需要单独的一个程序计数器,来记录下一条运行的指令,从而在线程切换后能恢复到正确的执行位置。各条线程间的计数器互不影响,独立存储,我们称这类内存区域为 “线程私有” 的内存。
- 如果线程正在执行的是一个 Java 方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;
- 如果正在执行的是 Native 方法,这个计数器值则为空(Undefined)。
🔔 注意:此内存区域是唯一一个在 JVM 中没有规定任何
OutOfMemoryError
情况的区域。
为什么本地方法在程序计数器中的值是 undefined 的?因为本地方法大多是通过 C/C++ 实现的,并未编译成需要执行的字节码指令。
由于程序计数器中存储的数据所占的空间不会随程序的执行而发生大小上的改变,因此,程序计数器是不会发生内存溢出现象(OutOfMemory)的。
Java 虚拟机栈
Java 虚拟机栈(Java Virtual Machine Stacks)
也是线程私有的,它的生命周期与线程相同。
每个 Java 方法在执行的同时都会创建一个栈帧(Stack Frame)用于存储 局部变量表、操作数栈、常量池引用 等信息。每一个方法从调用直至执行完成的过程,就对应着一个栈帧在 Java 虚拟机栈中入栈和出栈的过程。
- 局部变量表 - 32 位变量槽,存放了编译期可知的各种基本数据类型、对象引用、
ReturnAddress
类型。 - 操作数栈 - 基于栈的执行引擎,虚拟机把操作数栈作为它的工作区,大多数指令都要从这里弹出数据、执行运算,然后把结果压回操作数栈。
- 指向运行时常量池的引用 - 当前方法所属的类的运行时常量池的引用,引用其他的常量类或者使用字符串常量池中的字符串。
- 动态链接 - 每个栈帧都包含一个指向运行时常量池(方法区的一部分)中该栈帧所属方法的引用。持有这个引用是为了支持方法调用过程中的动态连接。Class 文件的常量池中有大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用为参数。这些符号引用一部分会在类加载阶段或第一次使用的时候转化为直接引用,这种转化称为静态解析。另一部分将在每一次的运行期间转化为直接应用,这部分称为动态链接。
- 方法出口 - 返回方法被调用的位置,恢复上层方法的局部变量和操作数栈,如果无返回值,则把它压入调用者的操作数栈。
🔔 注意:
该区域可能抛出以下异常:
- 如果线程请求的栈深度超过最大值,就会抛出
StackOverflowError
异常;- 如果虚拟机栈进行动态扩展时,无法申请到足够内存,就会抛出
OutOfMemoryError
异常。💡 提示:
可以通过
-Xss
这个虚拟机参数来指定一个程序的 Java 虚拟机栈内存大小:
1
java -Xss=512M HackTheJava
本地方法栈
本地方法栈(Native Method Stack)
与虚拟机栈的作用相似。
二者的区别在于:虚拟机栈为 Java 方法服务;本地方法栈为 Native 方法服务。本地方法并不是用 Java 实现的,而是由 C 语言实现的。
🔔 注意:本地方法栈也会抛出
StackOverflowError
异常和OutOfMemoryError
异常。
Java 堆
Java 堆(Java Heap)
的作用就是存放对象实例,几乎所有的对象实例都是在这里分配内存。
Java 堆是垃圾收集的主要区域(因此也被叫做"GC 堆")。现代的垃圾收集器基本都是采用分代收集算法,该算法的思想是针对不同的对象采取不同的垃圾回收算法。
因此虚拟机把 Java 堆分成以下三块:
新生代(Young Generation)
Eden
- Eden 和 Survivor 的比例为 8:1From Survivor
To Survivor
老年代(Old Generation)
永久代(Permanent Generation)
当一个对象被创建时,它首先进入新生代,之后有可能被转移到老年代中。新生代存放着大量的生命很短的对象,因此新生代在三个区域中垃圾回收的频率最高。
🔔 注意:Java 堆不需要连续内存,并且可以动态扩展其内存,扩展失败会抛出
OutOfMemoryError
异常。💡 提示:可以通过
-Xms
和-Xmx
两个虚拟机参数来指定一个程序的 Java 堆内存大小,第一个参数设置初始值,第二个参数设置最大值。
1
java -Xms=1M -Xmx=2M HackTheJava
|
|
- -XX:InitalSurvivorRatio 新生代 Eden/Survivor 空间初始比例 默认 8
- -XX:NewRatio Old区/Young区的内存比例 默认2
方法区
方法区(Method Area)也被称为永久代。方法区用于存放已被加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
对这块区域进行垃圾回收的主要目标是对常量池的回收和对类的卸载,但是一般比较难实现。
🔔 注意:和 Java 堆一样不需要连续的内存,并且可以动态扩展,动态扩展失败一样会抛出
OutOfMemoryError
异常。💡 提示:
- JDK 1.7 之前,HotSpot 虚拟机把它当成永久代来进行垃圾回收。可通过参数
-XX:PermSize
和-XX:MaxPermSize
设置。- JDK 1.8 之后,取消了永久代,用 **
metaspace(元数据)
**区替代。可通过参数-XX:MaxMetaspaceSize
设置。
运行时常量池
运行时常量池(Runtime Constant Pool)
是方法区的一部分,Class 文件中除了有类的版本、字段、方法、接口等描述信息,还有一项信息是常量池(Constant Pool Table),用于存放编译器生成的各种字面量和符号引用,这部分内容会在类加载后被放入这个区域。
- 字面量 - 文本字符串、声明为
final
的常量值等。 - 符号引用 - 类和接口的完全限定名(Fully Qualified Name)、字段的名称和描述符(Descriptor)、方法的名称和描述符。
除了在编译期生成的常量,还允许动态生成,例如 String
类的 intern()
。这部分常量也会被放入运行时常量池。
🔔 注意:当常量池无法再申请到内存时会抛出
OutOfMemoryError
异常。
元空间
JDK 8 的时候,原有的方法区(更准确的说应该是永久代)被彻底移除,取而代之的是元空间。
我们来说说方法区吧。方法区和堆一样,是线程共享的区域,它用来存储已经被 Java 虚拟机加载的类信息、常量、静态变量,以及便器编译后的代码等。
在有些地方,方法区也被称为永久代。但其实不能这么理解。
《Java 虚拟机规范》中只规定了有方法区这么一个概念和它的作用,并没有规定如何去实现它。那么不同的 Java 虚拟机可能就会有不同的实现。永久代是 HotSpot 对方法区的一种实现形式。也就是说,永久代只是 HotSpot 中的一个概念,而方法区则是 Java 虚拟机规范中的一个定义,一种规范。
换句话说,方法区和永久代的关系就像是 Java 中接口和类的关系,类实现了接口。
在方法区中,还有一块非常重要的部分,也就是运行时常量池。在讲 class 文件的时候,提到了每个 class 文件都会有个常量池,用来存放字符串常量、类和接口的名字、字段名、常量等等。运行时常量池和 class 文件的常量池是一一对应的,它就是通过 class 文件中的常量池来构建的。
JDK 7 之前,运行时常量池中包含着字符串常量池,都在方法区。
JDK 7 的时候,字符串常量池从方法区中拿出来放到了堆中,运行时常量池中的其他东西还在方法区中。
JDK 8 的时候,HotSpot 移除了永久代,也就是说方法区不存在了,取而代之的是元空间。也就意味着字符串常量池在堆中,运行时常量池跑到了元空间。
再来说说为什么要将永久代 (PermGen) 或者说方法区替换为元空间 (MetaSpace) 。
第一,永久代放在 Java 虚拟机中,就会受到 Java 虚拟机内存大小的限制,而元空间使用的是本地内存,也就脱离了 Java 虚拟机内存的限制。
第二,JDK 8 的时候,在 HotSpot 中融合了 JRockit 虚拟机,而 JRockit 中并没有永久代的概念,因此新的 HotSpot 就没有必要再开辟一块空间来作为永久代了。
直接内存
直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是 JVM 规范中定义的内存区域。
在 JDK 1.4 中新加入了 NIO 类,它可以使用 Native 函数库直接分配堆外内存,然后通过一个存储在 Java 堆里的 DirectByteBuffer
对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在 Java 堆和 Native 堆中来回复制数据。
🔔 注意:直接内存这部分也被频繁的使用,且也可能导致
OutOfMemoryError
异常。💡 提示:直接内存容量可通过
-XX:MaxDirectMemorySize
指定,如果不指定,则默认与 Java 堆最大值(-Xmx
指定)一样。
Java 内存区域对比
内存区域 | 内存作用范围 | 异常 |
---|---|---|
程序计数器 | 线程私有 | 无 |
Java 虚拟机栈 | 线程私有 | StackOverflowError 和 OutOfMemoryError |
本地方法栈 | 线程私有 | StackOverflowError 和 OutOfMemoryError |
Java 堆 | 线程共享 | OutOfMemoryError |
方法区 | 线程共享 | OutOfMemoryError |
运行时常量池 | 线程共享 | OutOfMemoryError |
直接内存 | 非运行时数据区 | OutOfMemoryError |
JVM 运行原理
|
|
运行以上代码时,JVM 处理过程如下:
(1)JVM 向操作系统申请内存,JVM 第一步就是通过配置参数或者默认配置参数向操作系统申请内存空间,根据内存大小找到具体的内存分配表,然后把内存段的起始地址和终止地址分配给 JVM,接下来 JVM 就进行内部分配。
(2)JVM 获得内存空间后,会根据配置参数分配堆、栈以及方法区的内存大小。
(3)class 文件加载、验证、准备以及解析,其中准备阶段会为类的静态变量分配内存,初始化为系统的初始值(这部分我在第 21 讲还会详细介绍)。
(4)完成上一个步骤后,将会进行最后一个初始化阶段。在这个阶段中,JVM 首先会执行构造器 <clinit>
方法,编译器会在 .java
文件被编译成 .class
文件时,收集所有类的初始化代码,包括静态变量赋值语句、静态代码块、静态方法,收集在一起成为 <clinit>()
方法。
(5)执行方法。启动 main 线程,执行 main 方法,开始执行第一行代码。此时堆内存中会创建一个 student 对象,对象引用 student 就存放在栈中。
(6)此时再次创建一个 JVMCase 对象,调用 sayHello 非静态方法,sayHello 方法属于对象 JVMCase,此时 sayHello 方法入栈,并通过栈中的 student 引用调用堆中的 Student 对象;之后,调用静态方法 print,print 静态方法属于 JVMCase 类,是从静态方法中获取,之后放入到栈中,也是通过 student 引用调用堆中的 student 对象。
OutOfMemoryError
什么是 OutOfMemoryError
OutOfMemoryError
简称为 OOM。Java 中对 OOM 的解释是,没有空闲内存,并且垃圾收集器也无法提供更多内存。通俗的解释是:JVM 内存不足了。
在 JVM 规范中,除了程序计数器区域外,其他运行时区域都可能发生 OutOfMemoryError
异常(简称 OOM)。
下面逐一介绍 OOM 发生场景。
堆空间溢出
java.lang.OutOfMemoryError: Java heap space
这个错误意味着:堆空间溢出。
更细致的说法是:Java 堆内存已经达到 -Xmx
设置的最大值。Java 堆用于存储对象实例,只要不断地创建对象,并且保证 GC Roots 到对象之间有可达路径来避免垃圾收集器回收这些对象,那么当堆空间到达最大容量限制后就会产生 OOM。
堆空间溢出有可能是**内存泄漏(Memory Leak)
** 或 内存溢出(Memory Overflow)
。需要使用 jstack 和 jmap 生成 threaddump 和 heapdump,然后用内存分析工具(如:MAT)进行分析。
Java heap space 分析步骤
- 使用
jmap
或-XX:+HeapDumpOnOutOfMemoryError
获取堆快照。 - 使用内存分析工具(visualvm、mat、jProfile 等)对堆快照文件进行分析。
- 根据分析图,重点是确认内存中的对象是否是必要的,分清究竟是是内存泄漏(Memory Leak)还是内存溢出(Memory Overflow)。
内存泄漏
内存泄漏是指由于疏忽或错误造成程序未能释放已经不再使用的内存的情况。
内存泄漏并非指内存在物理上的消失,而是应用程序分配某段内存后,由于设计错误,失去了对该段内存的控制,因而造成了内存的浪费。内存泄漏随着被执行的次数不断增加,最终会导致内存溢出。
内存泄漏常见场景:
- 静态容器
- 声明为静态(
static
)的HashMap
、Vector
等集合 - 通俗来讲 A 中有 B,当前只把 B 设置为空,A 没有设置为空,回收时 B 无法回收。因为被 A 引用。
- 声明为静态(
- 监听器
- 监听器被注册后释放对象时没有删除监听器
- 物理连接
- 各种连接池建立了连接,必须通过
close()
关闭链接
- 各种连接池建立了连接,必须通过
- 内部类和外部模块等的引用
- 发现它的方式同内存溢出,可再加个实时观察
jstat -gcutil 7362 2500 70
重点关注:
FGC
— 从应用程序启动到采样时发生 Full GC 的次数。FGCT
— 从应用程序启动到采样时 Full GC 所用的时间(单位秒)。FGC
次数越多,FGCT
所需时间越多,越有可能存在内存泄漏。
如果是内存泄漏,可以进一步查看泄漏对象到 GC Roots 的对象引用链。这样就能找到泄漏对象是怎样与 GC Roots 关联并导致 GC 无法回收它们的。掌握了这些原因,就可以较准确的定位出引起内存泄漏的代码。
导致内存泄漏的常见原因是使用容器,且不断向容器中添加元素,但没有清理,导致容器内存不断膨胀。
【示例】
|
|
内存溢出
如果不存在内存泄漏,即内存中的对象确实都必须存活着,则应当检查虚拟机的堆参数(-Xmx
和 -Xms
),与机器物理内存进行对比,看看是否可以调大。并从代码上检查是否存在某些对象生命周期过长、持有时间过长的情况,尝试减少程序运行期的内存消耗。
【示例】
|
|
执行 java -verbose:gc -Xms10M -Xmx10M -XX:+HeapDumpOnOutOfMemoryError io.github.dunwu.javacore.jvm.memory.HeapMemoryLeakMemoryErrorDemo
上面的例子是一个极端的例子,试图创建一个维度很大的数组,堆内存无法分配这么大的内存,从而报错:Java heap space
。
但如果在现实中,代码并没有问题,仅仅是因为堆内存不足,可以通过 -Xms
和 -Xmx
适当调整堆内存大小。
GC 开销超过限制
java.lang.OutOfMemoryError: GC overhead limit exceeded
这个错误,官方给出的定义是:超过 98%
的时间用来做 GC 并且回收了不到 2%
的堆内存时会抛出此异常。这意味着,发生在 GC 占用大量时间为释放很小空间的时候发生的,是一种保护机制。导致异常的原因:一般是因为堆太小,没有足够的内存。
【示例】
|
|
【处理】
与 Java heap space 错误处理方法类似,先判断是否存在内存泄漏。如果有,则修正代码;如果没有,则通过 -Xms
和 -Xmx
适当调整堆内存大小。
永久代空间不足
【错误】
|
|
【原因】
Perm (永久代)空间主要用于存放 Class
和 Meta 信息,包括类的名称和字段,带有方法字节码的方法,常量池信息,与类关联的对象数组和类型数组以及即时编译器优化。GC 在主程序运行期间不会对永久代空间进行清理,默认是 64M 大小。
根据上面的定义,可以得出 PermGen 大小要求取决于加载的类的数量以及此类声明的大小。因此,可以说造成该错误的主要原因是永久代中装入了太多的类或太大的类。
在 JDK8 之前的版本中,可以通过 -XX:PermSize
和 -XX:MaxPermSize
设置永久代空间大小,从而限制方法区大小,并间接限制其中常量池的容量。
初始化时永久代空间不足
【示例】
|
|
在此示例中,源代码遍历循环并在运行时生成类。javassist 库正在处理类生成的复杂性。
重部署时永久代空间不足
对于更复杂,更实际的示例,让我们逐步介绍一下在应用程序重新部署期间发生的 Permgen 空间错误。重新部署应用程序时,你希望垃圾回收会摆脱引用所有先前加载的类的加载器,并被加载新类的类加载器取代。
不幸的是,许多第三方库以及对线程,JDBC 驱动程序或文件系统句柄等资源的不良处理使得无法卸载以前使用的类加载器。反过来,这意味着在每次重新部署期间,所有先前版本的类仍将驻留在 PermGen 中,从而在每次重新部署期间生成数十兆的垃圾。
让我们想象一个使用 JDBC 驱动程序连接到关系数据库的示例应用程序。启动应用程序时,初始化代码将加载 JDBC 驱动程序以连接到数据库。对应于规范,JDBC 驱动程序向 java.sql.DriverManager 进行注册。该注册包括将对驱动程序实例的引用存储在 DriverManager 的静态字段中。
现在,当从应用程序服务器取消部署应用程序时,java.sql.DriverManager 仍将保留该引用。我们最终获得了对驱动程序类的实时引用,而驱动程序类又保留了用于加载应用程序的 java.lang.Classloader 实例的引用。反过来,这意味着垃圾回收算法无法回收空间。
而且该 java.lang.ClassLoader 实例仍引用应用程序的所有类,通常在 PermGen 中占据数十兆字节。这意味着只需少量重新部署即可填充通常大小的 PermGen。
PermGen space 解决方案
(1)解决初始化时的 OutOfMemoryError
在应用程序启动期间触发由于 PermGen 耗尽导致的 OutOfMemoryError
时,解决方案很简单。该应用程序仅需要更多空间才能将所有类加载到 PermGen 区域,因此我们只需要增加其大小即可。为此,更改你的应用程序启动配置并添加(或增加,如果存在)-XX:MaxPermSize
参数,类似于以下示例:
|
|
上面的配置将告诉 JVM,PermGen 可以增长到 512MB。
清理应用程序中 WEB-INF/lib
下的 jar,用不上的 jar 删除掉,多个应用公共的 jar 移动到 Tomcat 的 lib 目录,减少重复加载。
🔔 注意:-XX:PermSize
一般设为 64M
(2)解决重新部署时的 OutOfMemoryError
重新部署应用程序后立即发生 OutOfMemoryError 时,应用程序会遭受类加载器泄漏的困扰。在这种情况下,解决问题的最简单,继续进行堆转储分析–使用类似于以下命令的重新部署后进行堆转储:
|
|
然后使用你最喜欢的堆转储分析器打开转储(Eclipse MAT 是一个很好的工具)。在分析器中可以查找重复的类,尤其是那些正在加载应用程序类的类。从那里,你需要进行所有类加载器的查找,以找到当前活动的类加载器。
对于非活动类加载器,你需要通过从非活动类加载器收集到 GC 根的最短路径来确定阻止它们被垃圾收集的引用。有了此信息,你将找到根本原因。如果根本原因是在第三方库中,则可以进入 Google/StackOverflow 查看是否是已知问题以获取补丁/解决方法。
(3)解决运行时 OutOfMemoryError
第一步是检查是否允许 GC 从 PermGen 卸载类。在这方面,标准的 JVM 相当保守-类是天生的。因此,一旦加载,即使没有代码在使用它们,类也会保留在内存中。当应用程序动态创建许多类并且长时间不需要生成的类时,这可能会成为问题。在这种情况下,允许 JVM 卸载类定义可能会有所帮助。这可以通过在启动脚本中仅添加一个配置参数来实现:
|
|
默认情况下,此选项设置为 false,因此要启用此功能,你需要在 Java 选项中显式设置。如果启用 CMSClassUnloadingEnabled,GC 也会扫描 PermGen 并删除不再使用的类。请记住,只有同时使用 UseConcMarkSweepGC 时此选项才起作用。
|
|
在确保可以卸载类并且问题仍然存在之后,你应该继续进行堆转储分析–使用类似于以下命令的方法进行堆转储:
|
|
然后,使用你最喜欢的堆转储分析器(例如 Eclipse MAT)打开转储,然后根据已加载的类数查找最昂贵的类加载器。从此类加载器中,你可以继续提取已加载的类,并按实例对此类进行排序,以使可疑对象排在首位。
然后,对于每个可疑者,就需要你手动将根本原因追溯到生成此类的应用程序代码。
元数据区空间不足
【错误】
|
|
【原因】
Java8 以后,JVM 内存空间发生了很大的变化。取消了永久代,转而变为元数据区。
元数据区的内存不足,即方法区和运行时常量池的空间不足。
方法区用于存放 Class 的相关信息,如类名、访问修饰符、常量池、字段描述、方法描述等。
一个类要被垃圾收集器回收,判定条件是比较苛刻的。在经常动态生成大量 Class 的应用中,需要特别注意类的回收状况。这类常见除了 CGLib 字节码增强和动态语言以外,常见的还有:大量 JSP 或动态产生 JSP 文件的应用(JSP 第一次运行时需要编译为 Java 类)、基于 OSGi 的应用(即使是同一个类文件,被不同的加载器加载也会视为不同的类)等。
【示例】方法区出现 OutOfMemoryError
|
|
【解决】
当由于元空间而面临 OutOfMemoryError
时,第一个解决方案应该是显而易见的。如果应用程序耗尽了内存中的 Metaspace 区域,则应增加 Metaspace 的大小。更改应用程序启动配置并增加以下内容:
|
|
上面的配置示例告诉 JVM,允许 Metaspace 增长到 512 MB。
另一种解决方案甚至更简单。你可以通过删除此参数来完全解除对 Metaspace 大小的限制,JVM 默认对 Metaspace 的大小没有限制。但是请注意以下事实:这样做可能会导致大量交换或达到本机物理内存而分配失败。
无法新建本地线程
java.lang.OutOfMemoryError: Unable to create new native thread
这个错误意味着:Java 应用程序已达到其可以启动线程数的限制。
【原因】
当发起一个线程的创建时,虚拟机会在 JVM 内存创建一个 Thread
对象同时创建一个操作系统线程,而这个系统线程的内存用的不是 JVM 内存,而是系统中剩下的内存。
那么,究竟能创建多少线程呢?这里有一个公式:
|
|
【参数】
MaxProcessMemory
- 一个进程的最大内存JVMMemory
- JVM 内存ReservedOsMemory
- 保留的操作系统内存ThreadStackSize
- 线程栈的大小
给 JVM 分配的内存越多,那么能用来创建系统线程的内存就会越少,越容易发生 unable to create new native thread
。所以,JVM 内存不是分配的越大越好。
但是,通常导致 java.lang.OutOfMemoryError
的情况:无法创建新的本机线程需要经历以下阶段:
- JVM 内部运行的应用程序请求新的 Java 线程
- JVM 本机代码代理为操作系统创建新本机线程的请求
- 操作系统尝试创建一个新的本机线程,该线程需要将内存分配给该线程
- 操作系统将拒绝本机内存分配,原因是 32 位 Java 进程大小已耗尽其内存地址空间(例如,已达到(2-4)GB 进程大小限制)或操作系统的虚拟内存已完全耗尽
- 引发
java.lang.OutOfMemoryError: Unable to create new native thread
错误。
【示例】
|
|
【处理】
可以通过增加操作系统级别的限制来绕过无法创建新的本机线程问题。例如,如果限制了 JVM 可在用户空间中产生的进程数,则应检查出并可能增加该限制:
|
|
通常,OutOfMemoryError
对新的本机线程的限制表示编程错误。当应用程序产生数千个线程时,很可能出了一些问题—很少有应用程序可以从如此大量的线程中受益。
解决问题的一种方法是开始进行线程转储以了解情况。
直接内存溢出
由直接内存导致的内存溢出,一个明显的特征是在 Head Dump 文件中不会看见明显的异常,如果发现 OOM 之后 Dump 文件很小,而程序中又直接或间接使用了 NIO,就可以考虑检查一下是不是这方面的原因。
【示例】直接内存 OutOfMemoryError
|
|
StackOverflowError
对于 HotSpot 虚拟机来说,栈容量只由 -Xss
参数来决定如果线程请求的栈深度大于虚拟机所允许的最大深度,将抛出 StackOverflowError
异常。
从实战来说,栈溢出的常见原因:
- 递归函数调用层数太深
- 大量循环或死循环
【示例】递归函数调用层数太深导致 StackOverflowError
|
|
参考资料
- 《深入理解 Java 虚拟机》(opens new window)
- 《Java 性能调优实战》(opens new window)
- 从表到里学习 JVM 实现(opens new window)
- 作为测试你应该知道的 JAVA OOM 及定位分析(opens new window)
- 异常、堆内存溢出、OOM 的几种情况(opens new window)
- 介绍 JVM 中 OOM 的 8 种类型
JVM 内存结构,由Java虚拟机规范定义。描述的是Java程序执行过程中,由JVM管理的不同数据区域。各个区域有其特定的功能。
Java代码是要运行在虚拟机上的,而虚拟机在执行Java程序的过程中会把所管理的内存划分为若干个不同的数据区域,这些区域都有各自的用途。其中有些区域随着虚拟机进程的启动而存在,而有些区域则依赖用户线程的启动和结束而建立和销毁。
JVM运行时内存区域结构
JVM 内存结构
Java 堆(Heap)
Java虚拟机所管理的内存中最大的一块。Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存。
Java堆是垃圾收集器管理的主要区域,因此很多时候也被称做“GC堆”。如果从内存回收的角度看,由于现在收集器基本都是采用的分代收集算法,所以Java堆中还可以细分为:新生代和老年代;而年轻代内存又被分成三部分,Eden空间、From Survivor空间、To Survivor空间,默认情况下年轻代按照8:1:1的比例来分配。
在JDK7以及其前期的JDK版本中,堆内存通常被分为三块区域Nursery内存(young generation)、长时内存(old generation)、永久内存(Permanent Generation for VM Matedata)。
JDK8 HotSpot JVM 将移除永久区,使用本地内存来存储类元数据信息并称之为:元空间(Metaspace)。这意味着不会再有java.lang.OutOfMemoryError: PermGen问题
PermGen空间状况:这部分内存空间将全部移除。JVM的参数:PermSize 和 MaxPermSize 会被忽略并给出警告
Metaspace 容量:默认情况下,类元数据只受可用的本地内存限制(容量取决于是32位或是64位操作系统的可用虚拟内存大小)。新参数(MaxMetaspaceSize)用于限制本地内存分配给类元数据的大小。如果没有指定这个参数,元空间会在运行时根据需要动态调整。
根据Java虚拟机规范的规定,Java堆可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可,就像我们的磁盘空间一样。在实现时,既可以实现成固定大小的,也可以是可扩展的,不过当前主流的虚拟机都是按照可扩展来实现的(通过-Xmx和-Xms控制)。
如果在堆中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出OutOfMemoryError异常。
- 可通过参数 -Xms 和-Xmx设置
- Java堆是被所有线程共享,是Java虚拟机所管理的内存中最大的一块 Java堆在虚拟机启动时创建。
- Java堆唯一的目的是存放对象实例,几乎所有的对象实例和数组都在这里。
- Java堆为了便于更好的回收和分配内存,可以细分为:新生代和老年代;再细致一点的有Eden空间、From Survivor空间、To Survivor区。
- 新生代:包括Eden区、From Survivor区、To Survivor区,系统默认大小Eden:Survivor=8:1。
- 老年代:在年轻代中经历了N次垃圾回收后仍然存活的对象,就会被放到年老代中。因此,可以认为年老代中存放的都是一些生命周期较长的对象。
- Survivor空间等Java堆可以处在物理上不连续的内存空间中,只要逻辑上是连续的即可(就像我们的磁盘空间一样。在实现时,既可以实现成固定大小的,也可以是可扩展的)。
方法区(Method Area)
Java堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。虽然Java虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做Non-Heap(非堆),目的应该是与Java堆区分开来。
对于习惯在HotSpot虚拟机上开发和部署程序的开发者来说,很多人愿意把方法区称为“永久代”(Permanent Generation),本质上两者并不等价,仅仅是因为HotSpot虚拟机的设计团队选择把GC分代收集扩展至方法区,或者说使用永久代来实现方法区而已。
Java虚拟机规范对这个区域的限制非常宽松,除了和Java堆一样不需要连续的内存和可以选择固定大小或者可扩展外,还可以选择不实现垃圾收集。相对而言,垃圾收集行为在这个区域是比较少出现的,但并非数据进入了方法区就如永久代的名字一样“永久”存在了。这个区域的内存回收目标主要是针对常量池的回收和对类型的卸载,一般来说这个区域的回收“成绩”比较难以令人满意,尤其是类型的卸载,条件相当苛刻,但是这部分区域的回收确实是有必要的。
根据Java虚拟机规范的规定,当方法区无法满足内存分配需求时,将抛出OutOfMemoryError异常。
存储类信息、常量、静态变量等数据,是线程共享的区域,为与Java堆区分,方法区还有一个别名Non-Heap(非堆);
可通过参数-XX:MaxPermSize设置
- 线程共享内存区域,用于储存已被虚拟机加载的类信息、常量、静态变量,即编译器编译后的代码,方法区也称持久代(Permanent Generation)。
- 虽然Java虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做Non-Heap(非堆),目的应该是与Java堆区分开来。
- 如何实现方法区,属于虚拟机的实现细节,不受虚拟机规范约束。
- 方法区主要存放java类定义信息,与垃圾回收关系不大,方法区可以选择不实现垃圾回收,但不是没有垃圾回收。
- 方法区域的内存回收目标主要是针对常量池的回收和对类型的卸载。
- 运行时常量池,也是方法区的一部分,虚拟机加载Class后把常量池中的数据放入运行时常量池。
运行时常量池
JDK1.6 之前字符串常量池位于方法区之中。 JDK1.7 字符串常量池已经被挪到堆之中。
可通过参数-XX:PermSize和-XX:MaxPermSize设置
- 常量池(Constant Pool):常量池数据编译期被确定,是Class文件中的一部分。存储了类、方法、接口等中的常量,当然也包括字符串常量。
- 字符串池/字符串常量池(String Pool/String Constant Pool):是常量池中的一部分,存储编译期类中产生的字符串类型数据。
- 运行时常量池(Runtime Constant Pool):方法区的一部分,所有线程共享。虚拟机加载Class后把常量池中的数据放入到运行时常量池。常量池:可以理解为Class文件之中的资源仓库,它是Class文件结构中与其他项目资源关联最多的数据类型。
常量池中主要存放两大类常量:字面量(Literal)和符号引用(Symbolic Reference)。
- 字面量:文本字符串、声明为final的常量值等。
- 符号引用:类和接口的完全限定名(Fully Qualified Name)、字段的名称和描述符(Descriptor)、方法的名称和描述符。
程序计数器(Program Counter Register)
程序计数器(Program Counter Register)是一块较小的内存空间,它的作用可以看做是当前线程所执行的字节码的行号指示器。在虚拟机的概念模型里(仅是概念模型,各种虚拟机可能会通过一些更高效的方式去实现),字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。 由于Java虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器(对于多核处理器来说是一个内核)只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间的计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。 如果线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是Natvie方法,这个计数器值则为空(Undefined)。
此内存区域是唯一一个在Java虚拟机规范中没有规定任何OutOfMemoryError情况的区域。
JVM栈(JVM Stacks)
与程序计数器一样,Java虚拟机栈(Java Virtual Machine Stacks)也是线程私有的,它的生命周期与线程相同。虚拟机栈描述的是Java方法执行的内存模型:每个方法被执行的时候都会同时创建一个栈帧(Stack Frame)用于存储局部变量表、操作栈、动态链接、方法出口等信息。每一个方法被调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。
-
局部变量表 存放了编译期可知的各种基本数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference类型,它不等同于对象本身,根据不同的虚拟机实现,它可能是一个指向对象起始地址的引用指针,也可能指向一个代表对象的句柄或者其他与此对象相关的位置)和returnAddress类型(指向了一条字节码指令的地址)。其中64位长度的long和double类型的数据会占用2个局部变量空间(Slot),其余的数据类型只占用1个。局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。
-
操作数栈 基于栈的执行引擎,虚拟机把操作数栈作为它的工作区,大多数指令都要从这里弹出数据、执行运算,然后把结果压回操作数栈。
-
动态连接 每个栈帧都包含一个指向运行时常量池(方法区的一部分)中该栈帧所属方法的引用**。持有这个引用是为了支持方法调用过程中的动态连接。Class文件的常量池中有大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用为参数。这些符号引用一部分会在类加载阶段或第一次使用的时候转化为直接引用,这种转化称为静态解析。另一部分将在每一次的运行期间转化为直接应用,这部分称为动态连接
-
方法出口 返回方法被调用的位置,恢复上层方法的局部变量和操作数栈,如果无返回值,则把它压入调用者的操作数栈。
在Java虚拟机规范中,对这个区域规定了两种异常状况:
-
如果线程请求的栈深度大于虚拟机所允许的深度,将抛出
StackOverflowError
异常; -
如果虚拟机栈可以动态扩展(当前大部分的Java虚拟机都可动态扩展,只不过Java虚拟机规范中也允许固定长度的虚拟机栈),当扩展时无法申请到足够的内存时会抛出
OutOfMemoryError
异常。
本地方法栈(Native Method Stacks)
本地方法栈(Native Method Stacks)与虚拟机栈所发挥的作用是非常相似的,其区别不过是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的Native方法服务。
虚拟机规范中对本地方法栈中的方法使用的语言、使用方式与数据结构并没有强制规定,因此具体的虚拟机可以自由实现它。甚至有的虚拟机(譬如Sun HotSpot虚拟机)直接就把本地方法栈和虚拟机栈合二为一。与虚拟机栈一样,本地方法栈区域也会抛出StackOverflowError和OutOfMemoryError异常。
- 虚拟机栈为虚拟机执行Java方法(也就是字节码)服务。
- 本地方法栈则是为虚拟机使用到的Native方法服务。有的虚拟机(譬如Sun HotSpot虚拟机)直接就把本地方法栈和虚拟机栈合二为一
对象分配规则
- 对象优先分配在Eden区,如果Eden区没有足够的空间时,虚拟机执行一次Minor GC。
- 大对象直接进入老年代(大对象是指需要大量连续内存空间的对象)。这样做的目的是避免在Eden区和两个Survivor区之间发生大量的内存拷贝(新生代采用复制算法收集内存)。
- 长期存活的对象进入老年代。虚拟机为每个对象定义了一个年龄计数器,如果对象经过了1次Minor GC那么对象会进入Survivor区,之后每经过一次Minor GC那么对象的年龄加1,直到达到阀值对象进入老年区。
- 动态判断对象的年龄。如果Survivor区中相同年龄的所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象可以直接进入老年代。
- 空间分配担保。每次进行Minor GC时,JVM会计算Survivor区移至老年区的对象的平均大小,如果这个值大于老年区的剩余值大小则进行一次Full GC,如果小于检查HandlePromotionFailure设置,如果true则只进行Monitor GC,如果false则进行Full GC。
栈是运行时的单位,而堆是存储的单位。
栈解决程序的运行问题,即程序如何执行,或者说如何处理数据;堆解决的是数据存储的问题,即数据怎么放、放在哪儿。
在Java中一个线程就会相应有一个线程栈与之对应,这点很容易理解,因为不同的线程执行逻辑有所不同,因此需要一个独立的线程栈。而堆则是所有线程共享的。栈因为是运行单位,因此里面存储的信息都是跟当前线程(或程序)相关信息的。包括局部变量、程序运行状态、方法返回值等等;而堆只负责存储对象信息。
为什么要把堆和栈区分出来呢?
第一,从软件设计的角度看,栈代表了处理逻辑,而堆代表了数据。这样分开,使得处理逻辑更为清晰。分而治之的思想。这种隔离、模块化的思想在软件设计的方方面面都有体现。
第二,堆与栈的分离,使得堆中的内容可以被多个栈共享(也可以理解为多个线程访问同一个对象)。这种共享的收益是很多的。一方面这种共享提供了一种有效的数据交互方式(如:共享内存),另一方面,堆中的共享常量和缓存可以被所有栈访问,节省了空间。
第三,栈因为运行时的需要,比如保存系统运行的上下文,需要进行地址段的划分。由于栈只能向上增长,因此就会限制住栈存储内容的能力。而堆不同,堆中的对象是可以根据需要动态增长的,因此栈和堆的拆分,使得动态增长成为可能,相应栈中只需记录堆中的一个地址即可。
第四,面向对象就是堆和栈的完美结合。其实,面向对象方式的程序与以前结构化的程序在执行上没有任何区别。但是,面向对象的引入,使得对待问题的思考方式发生了改变,而更接近于自然方式的思考。当我们把对象拆开,你会发现,对象的属性其实就是数据,存放在堆中;而对象的行为(方法),就是运行逻辑,放在栈中。我们在编写对象的时候,其实即编写了数据结构,也编写的处理数据的逻辑。不得不承认,面向对象的设计,确实很美。
堆中存什么?栈中存什么?
堆中存的是对象。栈中存的是基本数据类型和堆中对象的引用。一个对象的大小是不可估计的,或者说是可以动态变化的,但是在栈中,一个对象只对应了一个4btye的引用(堆栈分离的好处:))。
为什么不把基本类型放堆中呢?因为其占用的空间一般是1~8个字节——需要空间比较少,而且因为是基本类型,所以不会出现动态增长的情况——长度固定,因此栈中存储就够了,如果把他存在堆中是没有什么意义的(还会浪费空间,后面说明)。可以这么说,基本类型和对象的引用都是存放在栈中,而且都是几个字节的一个数,因此在程序运行时,他们的处理方式是统一的。但是基本类型、对象引用和对象本身就有所区别了,因为一个是栈中的数据一个是堆中的数据。最常见的一个问题就是,Java中参数传递时的问题。
Java中的参数传递时传值呢?还是传引用?
要说明这个问题,先要明确两点:
-
不要试图与C进行类比,Java中没有指针的概念
-
程序运行永远都是在栈中进行的,因而参数传递时,只存在传递基本类型和对象引用的问题。不会直接传对象本身。
明确以上两点后。Java在方法调用传递参数时,因为没有指针,所以它都是进行传值调用(这点可以参考C的传值调用)。因此,很多书里面都说Java是进行传值调用,这点没有问题,而且也简化的C中复杂性。
异常
堆内存不够最常见的错误就是OOM(OutOfMemoryError)
栈内存溢出最常见的错误就是StackOverflowError。程序有递归调用时候最easy发生
Java中,栈的大小通过-Xss来设置,当栈中存储数据比较多时,需要适当调大这个值,否则会出现java.lang.StackOverflowError异常。常见的出现这个异常的是无法返回的递归,因为此时栈中保存的信息都是方法返回的记录点。
Java对象的大小
基本数据的类型的大小是固定的,这里就不多说了。对于非基本类型的Java对象,其大小就值得商榷。
在Java中,一个空Object对象的大小是8byte,这个大小只是保存堆中一个没有任何属性的对象的大小。看下面语句:
这样在程序中完成了一个Java对象的生命,但是它所占的空间为:4byte+8byte。4byte是上面部分所说的Java栈中保存引用的所需要的空间。而那8byte则是Java堆中对象的信息。因为所有的Java非基本类型的对象都需要默认继承Object对象,因此不论什么样的Java对象,其大小都必须是大于8byte。
有了Object对象的大小,我们就可以计算其他对象的大小了。
其大小为:空对象大小(8byte)+int大小(4byte)+Boolean大小(1byte)+空Object引用的大小(4byte)=17byte。但是因为Java在对对象内存分配时都是以8的整数倍来分,因此大于17byte的最接近8的整数倍的是24,因此此对象的大小为24byte。
这里需要注意一下基本类型的包装类型的大小。因为这种包装类型已经成为对象了,因此需要把他们作为对象来看待。包装类型的大小至少是12byte(声明一个空Object至少需要的空间),而且12byte没有包含任何有效信息,同时,因为Java对象大小是8的整数倍,因此一个基本类型包装类的大小至少是16byte。这个内存占用是很恐怖的,它是使用基本类型的N倍(N>2),有些类型的内存占用更是夸张(随便想下就知道了)。因此,可能的话应尽量少使用包装类。在JDK5.0以后,因为加入了自动类型装换,因此,Java虚拟机会在存储方面进行相应的优化。
引用类型
对象引用类型分为强引用、软引用、弱引用和虚引用。
**强引用:**就是我们一般声明对象是时虚拟机生成的引用,强引用环境下,垃圾回收时需要严格判断当前对象是否被强引用,如果被强引用,则不会被垃圾回收
**软引用:**软引用一般被做为缓存来使用。与强引用的区别是,软引用在垃圾回收时,虚拟机会根据当前系统的剩余内存来决定是否对软引用进行回收。如果剩余内存比较紧张,则虚拟机会回收软引用所引用的空间;如果剩余内存相对富裕,则不会进行回收。换句话说,虚拟机在发生OutOfMemory时,肯定是没有软引用存在的。
**弱引用:**弱引用与软引用类似,都是作为缓存来使用。但与软引用不同,弱引用在进行垃圾回收时,是一定会被回收掉的,因此其生命周期只存在于一个垃圾回收周期内。
强引用不用说,我们系统一般在使用时都是用的强引用。而“软引用”和“弱引用”比较少见。他们一般被作为缓存使用,而且一般是在内存大小比较受限的情况下做为缓存。因为如果内存足够大的话,可以直接使用强引用作为缓存即可,同时可控性更高。因而,他们常见的是被使用在桌面应用系统的缓存。
直接内存
想想还是把这块加上。直接内存并不是虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域。但是这部分内存也被频繁地使用,而且也可能导致内存溢出问题。JDK1.4中新增加了NIO,引入了一种基于通道与缓冲区的I/O方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆中的DirectByteBuffer对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在Java堆和Native堆中来回复制数据。显然,本机直接内存的分配不会受到Java堆大小的限制,但是,既然是内存,肯定还是会受到本机总内存(包括RAM、SWAP区)大小以及处理器寻址空间的限制。
可通过-XX:MaxDirectMemorySize指定,如果不指定,则默认与Java堆的最大值(-Xmx指定)一样。
- 直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域,但是这部分内存也被频繁地使用,而且也可能导致OutOfMemoryError异常出现。
1、计算机存储单位
从小到大依次为位Bit、字节Byte、千字节KB、兆M、千兆GB、TB,相邻单位之间都是1024倍,1024为2的10次方,即:
- 1Byte = 8bit
- 1K = 1024Byte
- 1M = 1024K
- 1G = 1024M
- 1T = 1024G
2、计算机存储元件
寄存器:中央处理器CPU的一部分,是计算机中读写速度最快的存储元件,但是容量很少
内存:属于独立的一个部件,是和CPU沟通的桥梁,用于存放CPU中的运算数据以及与外部存储器交换的数据。尽管在今天,对内存的读写速度已经很快了,但是由于寄存器是在CPU上的,所以对于内存的读写速度和对于寄存器的读写速度上还是有几个数量级的差距。但是没办法,对于内存的读写I/O操作是很难消除的,寄存器数量有限,不可能通过寄存器来完成所有的运算任务
3、内核空间和用户空间
连接内存和寄存器的是地址总线,地址总线的宽度影响了物理地址的索引范围,因为总线宽度决定了处理器一次可以从寄存器或内存中获取多少个Bit,同时也决定了处理器最大可以寻址的地址空间。比如32位CPU的系统,可寻址范围为0x00000000~0xFFFFFFFF,即232=4294967296个内存位置,每个内存位置1个字节,即32位CPU系统可以有4GB的内存空间。不过应用程序是不可以完全使用这些地址空间的,因为这些地址空间被划分为了内核空间和用户空间,程序只能使用用户空间的内存。内核空间主要是指操作系统运行时所使用的用于程序调度、虚拟内存的使用或者链接硬件资源的程序逻辑。区分内核空间和用户空间的目的主要是从系统的稳定性的角度考虑的。Windows 32操作系统默认内核空间和用户空间的比例是1:1,即2G内核空间、2G内存空间,32位Linux系统中默认比例则是1:3,即1G内核空间,3G内存空间。
4、字长
CPU的主要技术指标之一,指的是CPU一次能并行处理二进制的位数(Bit)。通常称处理字长为8位数据的CPU为8位CPU,32位CPU就是在同一时间内处理字长为32位的二进制数据。不过目前虽然CPU大多是64位的,但还是以32位字长运行
JVM 参数
控制参数 -Xms设置堆的最小空间大小。
-Xmx设置堆的最大空间大小。
-XX:NewSize设置新生代最小空间大小。
-XX:MaxNewSize设置新生代最大空间大小。
-XX:PermSize设置永久代最小空间大小。
-XX:MaxPermSize设置永久代最大空间大小。
-Xss设置每个线程的堆栈大小。
没有直接设置老年代的参数,但是可以设置堆空间大小和新生代空间大小两个参数来间接控制。
老年代空间大小=堆空间大小-年轻代大空间大小
从更高的一个维度再次来看JVM和系统调用之间的关系
方法区和堆是所有线程共享的内存区域;而Java栈、本地方法栈和程序计数器是运行是线程私有的内存区域。