java虚拟机

JAVA内存模型

Java虚拟机规范中试图定义一种Java内存模型(Java Memory Model,JMM)来屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果。

JMM描述了Java程序中各种变量(线程共享变量)的访问规则,以及在JVM中将变量存储到内存中和读取出变量这样的底层细节。所有的变量都存储在主内存中,每个线程都有自己独立的工作内存,里面保存该线程使用到的变量的副本(主内存中变量的一份拷贝)。JMM的两条规定:

  • 线程对共享变量的所有操作都必须在自己的工作内存中进行,不能直接从主内存中读写
  • 不同的线程之间无法直接访问其他线程工作内存中的变量,线程变量值的传递需要通过主内存来完成

Java 内存模型(下文简称 JMM)就是在底层处理器内存模型的基础上,定义自己的多线程语义。它明确指定了一组排序规则,来保证线程间的可见性。这一组规则被称为 Happens-Before, JMM 规定,要想保证 B 操作能够看到 A 操作的结果(无论它们是否在同一个线程),那么 A 和 B 之间必须满足 Happens-Before 关系

  • 单线程规则:一个线程中的每个动作都 happens-before 该线程中后续的每个动作
  • 监视器锁定规则:监听器的解锁动作 happens-before 后续对这个监听器的锁定动作
  • volatile 变量规则:对 volatile 字段的写入动作 happens-before 后续对这个字段的每个读取动作
  • 线程 start 规则:线程 start() 方法的执行 happens-before 一个启动线程内的任意动作
  • 线程 join 规则:一个线程内的所有动作 happens-before 任意其他线程在该线程 join() 成功返回之前
  • 传递性:如果 A happens-before B, 且 B happens-before C, 那么 A happens-before C

Happens-Before

“Happens-Before” 是计算机科学中,尤其是在并发编程领域的一个重要概念,以下是关于它的详细介绍:

定义

  • “Happens-Before” 关系是一种在并发环境中定义操作之间顺序的规则。它用于确定在一个线程中的操作对于另一个线程中的操作是否可见,以及操作的执行顺序是否有保障。简单来说,如果操作 A Happens-Before 操作 B,那么操作 A 的结果对于操作 B 是可见的,并且操作 A 在操作 B 之前执行。

作用

  • 保证内存可见性:在多线程环境下,每个线程都有自己的本地缓存。当一个线程修改了一个共享变量的值时,其他线程可能不会立即看到这个修改。Happens-Before 规则确保了在一个操作对共享变量进行写操作之后,后续的读操作能够看到这个修改。
  • 确定操作顺序:帮助确定不同线程中操作的执行顺序,防止出现数据竞争和其他并发问题。比如,对于一个共享资源的访问,如果存在明确的 Happens-Before 关系,就可以保证先进行的访问操作完成后,后续的访问操作才能进行,从而避免了并发访问导致的不一致性。

常见的 Happens-Before 规则

  • 程序顺序规则:在一个线程内,按照程序的顺序,前面的操作 Happens-Before 后面的操作。例如,在一段 Java 代码中,先对变量进行赋值操作,然后再读取这个变量的值,那么赋值操作 Happens-Before 读取操作。
  • 监视器锁规则:对一个监视器锁的解锁操作 Happens-Before 后续对这个监视器锁的加锁操作。比如在 Java 中,当一个线程通过synchronized关键字获取到锁并执行完临界区代码后释放锁,那么下一个线程获取到这个锁并进入临界区时,前一个线程在临界区内对共享变量的修改对于后一个线程是可见的。
  • volatile 变量规则:对一个volatile变量的写操作 Happens-Before 后续对这个volatile变量的读操作。这是因为volatile关键字保证了变量的修改会立即刷新到主内存,并且其他线程在读取这个变量时会从主内存中获取最新的值。
  • 线程启动规则Thread.start()方法的调用 Happens-Before 这个线程中后续的操作。当一个线程通过start()方法启动后,该线程中的代码才会开始执行,所以在启动线程之前的操作一定是先于线程内部的操作的。
  • 线程终止规则:线程中的所有操作 Happens-Before 对这个线程的join()方法的返回。也就是说,当一个线程执行完毕并退出后,在其他线程中调用join()方法等待该线程结束时,能够看到这个线程在执行过程中对共享变量的所有修改。

与其他概念的关系

  • 与数据竞争的关系:数据竞争是指多个线程同时访问一个共享资源,并且其中至少有一个是写操作,而这些操作之间没有正确的同步,导致结果不可预测。Happens-Before 关系的存在可以避免数据竞争,因为它明确了操作之间的顺序和可见性,确保了共享资源的访问是有序和正确的。
  • 与内存模型的关系:不同的编程语言和硬件平台都有自己的内存模型,用于规定内存访问的规则和行为。Happens-Before 是内存模型中的一个重要概念,它为内存模型提供了一种定义操作顺序和可见性的方式,使得程序员可以在编写并发程序时遵循这些规则,以确保程序在不同的内存模型下都能正确运行。

JVM内存结构

JVM包含元空间Java虚拟机栈本地方法栈程序计数器等内存区域,其中是占用内存最大的,如下图所示:

JVM常量池

JVM常量池主要分为Class文件常量池、运行时常量池、全局字符串常量池、以及基本类型包装类对象常量池

  • Class文件常量池:class文件是一组以字节为单位的二进制数据流,在java代码的编译期间,我们编写的java文件就被编译为.class文件格式的二进制数据存放在磁盘中,其中就包括class文件常量池。
  • 运行时常量池:运行时常量池相对于class常量池一大特征就是具有动态性,java规范并不要求常量只能在运行时才产生,也就是说运行时常量池的内容并不全部来自class常量池,在运行时可以通过代码生成常量并将其放入运行时常量池中,这种特性被用的最多的就是String.intern()。
  • 全局字符串常量池:字符串常量池是JVM所维护的一个字符串实例的引用表,在HotSpot VM中,它是一个叫做StringTable的全局表。在字符串常量池中维护的是字符串实例的引用,底层C++实现就是一个Hashtable。这些被维护的引用所指的字符串实例,被称作”被驻留的字符串”或”interned string”或通常所说的”进入了字符串常量池的字符串”。
  • 基本类型包装类对象常量池:java中基本类型的包装类的大部分都实现了常量池技术,这些类是Byte,Short,Integer,Long,Character,Boolean,另外两种浮点数类型的包装类则没有实现。另外上面这5种整型的包装类也只是在对应值小于等于127时才可使用对象池,也即对象不负责创建和管理大于127的这些类的对象。

JVM内存模型

JVM试图定义一种统一的内存模型,能将各种底层硬件以及操作系统的内存访问差异进行封装,使Java程序在不同硬件以及操作系统上都能达到相同的并发效果。它分为工作内存和主内存,线程无法对主存储器直接进行操作,如果一个线程要和另外一个线程通信,那么只能通过主存进行交换。如下图所示:

线程隔离数据区:

  • 程序计数器: 一块较小的内存空间,存储当前线程所执行的字节码行号指示器
  • 虚拟机栈: 里面的元素叫栈帧,存储 局部变量表、操作栈、动态链接、方法返回地址 等
  • 本地方法栈: 为虚拟机使用到的本地Native方法服务时的栈帧,和虚拟机栈类似

线程共享数据区:

  • 方法区: 存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据
  • 堆: 唯一目的就是存放对象的实例,是垃圾回收管理器的主要区域
  • 元数据区:常量池、方法元信息

程序计数器

程序计数器(Program Counter Register)也叫PC寄存器。程序计数器是一块较小的内存空间,可以看作是当前线程所执行的字节码的行号指示器。JVM支持多个线程同时运行,每个线程都有自己的程序计数器。倘若当前执行的是 JVM 的方法,则该寄存器中保存当前执行指令的地址;倘若执行的是native 方法,则寄存器中为空(undefined)。

程序计数器特点:

  • 当前线程私有
  • 当前线程所执行的字节码的行号指示器
  • 不会出现OutOfMemoryError情况
  • 以一种数据结构的形式放置于内存中

注意:程序计数器是唯一一个不会出现 OutOfMemoryError 的内存区域,它的生命周期随着线程的创建而创建,随着线程的结束而死亡。

JAVA虚拟机栈

JAVA虚拟机栈(Java Virtual Machine Stacks)是每个线程的一个私有的栈,随着线程的创建而创建,其生命周期与线程同进同退。栈里面存着的是一种叫“栈帧”的东西,每个Java方法在被调用的时候都会创建一个栈帧,一旦完成调用,则出栈。所有的栈帧都出栈后,线程也就完成了使命。栈帧中存放了局部变量表(基本数据类型和对象引用)、操作数栈、动态链接(指向当前方法所属的类的运行时常量池的引用等)、方法出口(方法返回地址)和一些额外的附加信息。栈的大小可以固定也可以动态扩展。当栈调用深度大于JVM所允许的范围,会抛出StackOverflowError的错误,不过这个深度范围不是一个恒定的值。

java虚拟机栈的特点:

  • 线程私有,生命周期与线程相同
  • java方法执行的内存模型,每个方法执行的同时都会创建一个栈帧,存储局部变量表(基本类型、对象引用)、操作数栈、动态链接、方法出口等信息
  • StackOverflowError:当线程请求的栈深度大于虚拟机所允许的深度
  • OutOfMemoryError:如果栈的扩展时无法申请到足够的内存

相关参数:

-Xss:设置方法栈的最大值

本地方法栈

本地方法栈(Native Method Stacks)与Java栈的作用和原理非常相似。区别只不过是Java栈是为执行Java方法服务的,而本地方法栈则是为执行本地方法(Native Method)服务的。在JVM规范中,并没有对本地方法栈的具体实现方法以及数据结构作强制规定,虚拟机可以自由实现它。在HotSopt虚拟机中直接就把本地方法栈和Java栈合二为一。

方法区

方法区(Method Area)用于存放虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。

  • 又称之为:非堆(Non-Heap)或 永久区
  • 线程共享
  • 主要存储:类的类型信息、常量池(Runtime Constant Pool)、字段信息、方法信息、类变量和Class类的引用等
  • Java虚拟机规范规定:当方法区无法满足内存分配需求时,将抛出OutOfMemoryError异常

相关参数:

-XX:PermSize:设置Perm区的初始大小

-XX:MaxPermSize:设置Perm区的最大值

堆内存

堆内存(JAVA Heap)是被线程共享的一块内存区域,创建的对象和数组都保存在 Java 堆内存中,也是垃圾收集器进行垃圾收集的最重要的内存区域。由于现代 VM 采用分代收集算法, 因此 Java 堆从 GC 的角度还可以细分为: 新生代(Eden区、From Survivor 区和 To Survivor 区)和老年代

堆内存特点:

  • 线程共享
  • 主要用于存储JAVA实例或对象
  • GC发生的主要区域
  • 是Java虚拟机所管理的内存中最大的一块
  • 当堆中没有内存能完成实例分配,且堆也无法再扩展,则会抛出OutOfMemoryError异常

相关参数:

-Xms:设置堆内存初始大小

-Xmx:设置堆内存最大值

-XX:MaxTenuringThreshold:设置对象在新生代中存活的次数

-XX:PretenureSizeThreshold:设置超过指定大小的大对象直接分配在旧生代中

新生代相关参数(注意:当新生代设置得太小时,也可能引发大对象直接分配到旧生代):

-Xmn:设置新生代内存大小

-XX:SurvivorRatio:设置Eden与Survivor空间的大小比例

JVM运行时内存

JVM运行时内存又称堆内存(Heap)。Java 堆从 GC 的角度还可以细分为: 新生代(Eden 区、From Survivor 区和 To Survivor 区)和老年代。

当代主流虚拟机(Hotspot VM)的垃圾回收都采用“分代回收”的算法。“分代回收”是基于这样一个事实:对象的生命周期不同,所以针对不同生命周期的对象可以采取不同的回收方式,以便提高回收效率。Hotspot VM将内存划分为不同的物理区,就是“分代”思想的体现。

一个对象从出生到消亡

一个对象产生之后首先进行栈上分配,栈上如果分配不下会进入Eden区,Eden区经过一次垃圾回收之后进入survivor区,survivor区在经过一次垃圾回收之后又进入另外一个survivor,与此同时Eden区的某些对象也跟着进入另外一个survivor,什么时候年龄够了就会进入old区,这是整个对象的一个逻辑上的移动过程。

新生代(Young Generation)

主要是用来存放新生的对象。一般占据堆的1/3空间。由于频繁创建对象,所以新生代会频繁触发MinorGC进行垃圾回收。新生代又分为 Eden区、SurvivorFrom、SurvivorTo三个区。

  • Eden区:Java新对象的出生地(如果新创建的对象占用内存很大,则直接分配到老年代)。当Eden区内存不够的时候就会触发MinorGC,对新生代区进行一次垃圾回收
  • SurvivorTo:保留了一次MinorGC过程中的幸存者
  • SurvivorFrom:上一次GC的幸存者,作为这一次GC的被扫描者

MinorGC流程

  • MinorGC采用复制算法
  • 首先把Eden和SurvivorFrom区域中存活的对象复制到SurvicorTo区域(如果有对象的年龄达到了老年的标准,则复制到老年代区),同时把这些对象的年龄+1(如果SurvicorTo不够位置了就放到老年区)
  • 然后清空Eden和SurvicorFrom中的对象
  • 最后SurvicorTo和SurvicorFrom互换,原SurvicorTo成为下一次GC时的SurvicorFrom区

为什么 Survivor 分区不能是 0 个?

如果 Survivor 是 0 的话,也就是说新生代只有一个 Eden 分区,每次垃圾回收之后,存活的对象都会进入老生代,这样老生代的内存空间很快就被占满了,从而触发最耗时的 Full GC ,显然这样的收集器的效率是我们完全不能接受的。

为什么 Survivor 分区不能是 1 个?

如果 Survivor 分区是 1 个的话,假设我们把两个区域分为 1:1,那么任何时候都有一半的内存空间是闲置的,显然空间利用率太低不是最佳的方案。

但如果设置内存空间的比例是 8:2 ,只是看起来似乎“很好”,假设新生代的内存为 100 MB( Survivor 大小为 20 MB ),现在有 70 MB 对象进行垃圾回收之后,剩余活跃的对象为 15 MB 进入 Survivor 区,这个时候新生代可用的内存空间只剩了 5 MB,这样很快又要进行垃圾回收操作,显然这种垃圾回收器最大的问题就在于,需要频繁进行垃圾回收。

为什么 Survivor 分区是 2 个?

如果Survivor分区有2个分区,我们就可以把 Eden、From Survivor、To Survivor 分区内存比例设置为 8:1:1 ,那么任何时候新生代内存的利用率都 90% ,这样空间利用率基本是符合预期的。再者就是虚拟机的大部分对象都符合“朝生夕死”的特性,所以每次新对象的产生都在空间占比比较大的Eden区,垃圾回收之后再把存活的对象存入Survivor区,如果是 Survivor区存活的对象,那么“年龄”就+1,当年龄增长到15(可通过 -XX:+MaxTenuringThreshold 设定)对象就升级到老生代。

总结

根据上面的分析可以得知,当新生代的 Survivor 分区为 2 个的时候,不论是空间利用率还是程序运行的效率都是最优的,所以这也是为什么 Survivor 分区是 2 个的原因了。

老年代(Old Generation)

主要存放应用程序中生命周期长的内存对象。老年代的对象比较稳定,所以MajorGC不会频繁执行。在进行MajorGC前一般都先进行了一次MinorGC,使得有新生代的对象晋身入老年代,导致空间不够用时才触发。当无法找到足够大的连续空间分配给新创建的较大对象时也会提前触发一次MajorGC进行垃圾回收腾出空间。

MajorGC流程

MajorGC采用标记—清除算法。首先扫描一次所有老年代,标记出存活的对象,然后回收没有标记的对象。MajorGC的耗时比较长,因为要扫描再回收。MajorGC会产生内存碎片,为了减少内存损耗,我们一般需要进行合并或者标记出来方便下次直接分配。当老年代也满了装不下的时候,就会抛出OOM(Out of Memory)异常。

永久区(Perm Generation)

指内存的永久保存区域,主要存放元数据,例如Class、Method的元信息,与垃圾回收要回收的Java对象关系不大。相对于新生代和年老代来说,该区域的划分对垃圾回收影响比较小。GC不会在主程序运行期对永久区域进行清理,所以这也导致了永久代的区域会随着加载的Class的增多而胀满,最终抛出OOM异常。

JAVA8与元数据

在Java8中,永久代已经被移除,被一个称为“元数据区”(元空间)的区域所取代。元空间的本质和永久代类似,都是对JVM规范中方法区的实现。不过元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。因此,默认情况下,元空间的大小仅受本地内存限制。类的元数据放入Native Memory,字符串池和类的静态变量放入java堆中,这样可以加载多少类的元数据就不再由MaxPermSize控制,而由系统的实际可用空间来控制。

内存分配策略

堆内存常见的分配策略如下:

  • 对象优先在Eden区分配
  • 大对象直接进入老年代
  • 长期存活的对象将进入老年代
参数说明信息
-Xms初始堆大小。如:-Xms256m
-Xmx最大堆大小。如:-Xmx512m
-Xmn新生代大小。通常为Xmx的1/3或1/4。新生代=Eden+2个Survivor空间。实际可用空间为=Eden+1个Survivor,即 90%
-XssJDK1.5+每个线程堆栈大小为 1M,一般来说如果栈不是很深的话, 1M 是绝对够用了的
-XX:NewRatio新生代与老年代的比例。如–XX:NewRatio=2,则新生代占整个堆空间的1/3,老年代占2/3
-XX:SurvivorRatio新生代中Eden与Survivor的比值。默认值为 8,即Eden占新生代空间的8/10,另外两个Survivor各占1/10
-XX:PermSize永久代(方法区)的初始大小
-XX:MaxPermSize永久代(方法区)的最大值
-XX:+PrintGCDetails打印GC信息
-XX:+HeapDumpOnOutOfMemoryError让虚拟机在发生内存溢出时Dump出当前的内存堆转储快照,以便分析用

参数基本策略

各分区的大小对GC的性能影响很大。如何将各分区调整到合适的大小,分析活跃数据的大小是很好的切入点。

活跃数据的大小:指应用程序稳定运行时长期存活对象在堆中占用的空间大小,即Full GC后堆中老年代占用空间的大小。

可以通过GC日志中Full GC之后老年代数据大小得出,比较准确的方法是在程序稳定后,多次获取GC数据,通过取平均值的方式计算活跃数据的大小。活跃数据和各分区之间的比例关系如下:

空间倍数
总大小3-4 倍活跃数据的大小
新生代1-1.5 活跃数据的大小
老年代2-3 倍活跃数据的大小
永久代1.2-1.5 倍Full GC后的永久代空间占用

例如,根据GC日志获得老年代的活跃数据大小为300M,那么各分区大小可以设为:

总堆:1200MB = 300MB × 4

新生代:450MB = 300MB × 1.5

老年代: 750MB = 1200MB - 450MB

这部分设置仅仅是堆大小的初始值,后面的优化中,可能会调整这些值,具体情况取决于应用程序的特性和需求。

引用级别

Java中4种引用的级别和强度由高到低依次为:强引用→软引用→弱引用→虚引用

垃圾回收器回收时,某些对象会被回收,某些不会被回收。垃圾回收器会从根对象Object标记存活的对象,然后将某些不可达的对象和一些引用的对象进行回收。如下所示:

引用类型被垃圾回收时间用途生存时间
强引用从来不会对象的一般状态JVM停止运行时终止
软引用当内存不足时对象缓存内存不足时终止
弱引用正常垃圾回收时对象缓存垃圾回收后终止
虚引用正常垃圾回收时跟踪对象的垃圾回收垃圾回收后终止

引用级别

强引用(StrongReference)

强引用是我们最常见的对象,它属于不可回收资源,垃圾回收器(后面简称GC)绝对不会回收它,即使是内存不足,JVM宁愿抛出 OutOfMemoryError 异常,使程序终止,也不会来回收强引用对象。如果一个对象具有强引用,那垃圾回收器绝不会回收它。如下:

Object strongReference = new Object();

内存空间不足时,Java虚拟机宁愿抛出OutOfMemoryError错误,使程序异常终止,也不会靠随意回收具有强引用对象来解决内存不足的问题。 如果强引用对象不使用时,需要弱化从而使GC能够回收,如下:

strongReference = null;

显式地设置strongReference对象为null,或让其超出对象的生命周期范围,则gc认为该对象不存在引用,这时就可以回收这个对象。具体什么时候收集这要取决于GC算法。

public void test() {
	Object strongReference = new Object();
	// 省略其他操作
}

在一个方法的内部有一个强引用,这个引用保存在Java中,而真正的引用内容(Object)保存在Java中。 当这个方法运行完成后,就会退出方法栈,则引用对象的引用数0,这个对象会被回收。但是如果这个strongReference全局变量时,就需要在不用这个对象时赋值为null,因为强引用不会被垃圾回收。

软引用(SoftReference)

如果一个对象只具有软引用,则内存空间充足时,垃圾回收器不会回收它;如果内存空间不足了,就会回收这些对象的内存。只要垃圾回收器没有回收它,该对象就可以被程序使用。软引用可用来实现内存敏感的高速缓存。

// 强引用
String strongReference = new String("abc");
// 软引用
String str = new String("abc");
SoftReference<String> softReference = new SoftReference<String>(str);

软引用可以和一个引用队列(ReferenceQueue)联合使用。如果软引用所引用对象被垃圾回收JAVA虚拟机就会把这个软引用加入到与之关联的引用队列中。

ReferenceQueue<String> referenceQueue = new ReferenceQueue<>();
String str = new String("abc");
SoftReference<String> softReference = new SoftReference<>(str, referenceQueue);

注意:软引用对象是在jvm内存不够时才会被回收,我们调用System.gc()方法只是起到通知作用,JVM什么时候扫描回收对象是JVM自己的状态决定的。就算扫描到软引用对象也不一定会回收它,只有内存不够的时候才会回收。

垃圾收集线程会在虚拟机抛出OutOfMemoryError之前回收软引用对象,而虚拟机会尽可能优先回收长时间闲置不用软引用对象。对那些刚构建的或刚使用过的较新的软对象会被虚拟机尽可能保留,这就是引入引用队列ReferenceQueue的原因。

弱引用(WeakReference)

弱引用对象相对软引用对象具有更短暂的生命周期,只要 GC 发现它仅有弱引用,不管内存空间是否充足,都会回收它,不过 GC 是一个优先级很低的线程,因此不一定会很快发现那些仅有弱引用的对象。

弱引用软引用的区别在于:只具有弱引用的对象拥有更短暂生命周期。在垃圾回收器线程扫描它所管辖的内存区域的过程中,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。不过,由于垃圾回收器是一个优先级很低的线程,因此不一定很快发现那些只具有弱引用的对象。

String str = new String("abc");
WeakReference<String> weakReference = new WeakReference<>(str);
str = null;

JVM首先将弱引用中的对象引用置为null,然后通知垃圾回收器进行回收:

str = null;
System.gc();

注意:如果一个对象是偶尔(很少)的使用,并且希望在使用时随时就能获取到,但又不想影响此对象的垃圾收集,那么你应该用Weak Reference来记住此对象。

下面的代码会让一个弱引用再次变为一个强引用

String str = new String("abc");
WeakReference<String> weakReference = new WeakReference<>(str);
// 弱引用转强引用
String strongReference = weakReference.get();

同样,弱引用可以和一个引用队列(ReferenceQueue)联合使用,如果弱引用所引用的对象垃圾回收Java虚拟机就会把这个弱引用加入到与之关联的引用队列中。

虚引用(PhantomReference)

虚引用顾名思义,就是形同虚设。与其他几种引用都不同,虚引用不会决定对象的生命周期。如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收器回收。

应用场景:

虚引用主要用来跟踪对象被垃圾回收器回收的活动。 虚引用软引用弱引用的一个区别在于:

虚引用必须和引用队列(ReferenceQueue)联合使用。当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用加入到与之关联的引用队列中。

String str = new String("abc");
ReferenceQueue queue = new ReferenceQueue();
// 创建虚引用,要求必须与一个引用队列关联
PhantomReference pr = new PhantomReference(str, queue);

程序可以通过判断引用队列中是否已经加入了虚引用,来了解被引用的对象是否将要进行垃圾回收。如果程序发现某个虚引用已经被加入到引用队列,那么就可以在所引用的对象的内存被回收之前采取必要的行动。

OOM

JVM发生OOM的九种场景如下:

场景一:Java heap space

当堆内存(Heap Space)没有足够空间存放新创建的对象时,就会抛出 java.lang.OutOfMemoryError:Javaheap space 错误(根据实际生产经验,可以对程序日志中的 OutOfMemoryError 配置关键字告警,一经发现,立即处理)。

原因分析

Javaheap space 错误产生的常见原因可以分为以下几类:

  • 请求创建一个超大对象,通常是一个大数组
  • 超出预期的访问量/数据量,通常是上游系统请求流量飙升,常见于各类促销/秒杀活动,可以结合业务流量指标排查是否有尖状峰值
  • 过度使用终结器(Finalizer),该对象没有立即被 GC
  • 内存泄漏(Memory Leak),大量对象引用没有释放,JVM 无法对其自动回收,常见于使用了 File 等资源没有回收

解决方案

针对大部分情况,通常只需通过 -Xmx 参数调高 JVM 堆内存空间即可。如果仍然没有解决,可参考以下情况做进一步处理:

  • 如果是超大对象,可以检查其合理性,比如是否一次性查询了数据库全部结果,而没有做结果数限制
  • 如果是业务峰值压力,可以考虑添加机器资源,或者做限流降级
  • 如果是内存泄漏,需要找到持有的对象,修改代码设计,比如关闭没有释放的连接

场景二:GC overhead limit exceeded

当 Java 进程花费 98% 以上的时间执行 GC,但只恢复了不到 2% 的内存,且该动作连续重复了 5 次,就会抛出 java.lang.OutOfMemoryError:GC overhead limit exceeded 错误。简单地说,就是应用程序已经基本耗尽了所有可用内存, GC 也无法回收。

此类问题的原因与解决方案跟 Javaheap space 非常类似,可以参考上文。

场景三:Permgen space

该错误表示永久代(Permanent Generation)已用满,通常是因为加载的 class 数目太多或体积太大。

原因分析

永久代存储对象主要包括以下几类:

  • 加载/缓存到内存中的 class 定义,包括类的名称,字段,方法和字节码
  • 常量池
  • 对象数组/类型数组所关联的 class
  • JIT 编译器优化后的 class 信息

PermGen 的使用量与加载到内存的 class 的数量/大小正相关。

解决方案

根据 Permgen space 报错的时机,可以采用不同的解决方案,如下所示:

  • 程序启动报错,修改 -XX:MaxPermSize 启动参数,调大永久代空间
  • 应用重新部署时报错,很可能是没有应用没有重启,导致加载了多份 class 信息,只需重启 JVM 即可解决
  • 运行时报错,应用程序可能会动态创建大量 class,而这些 class 的生命周期很短暂,但是 JVM 默认不会卸载 class,可以设置 -XX:+CMSClassUnloadingEnabled 和 -XX:+UseConcMarkSweepGC 这两个参数允许 JVM 卸载 class。

如果上述方法无法解决,可以通过 jmap 命令 dump 内存对象 jmap-dump:format=b,file=dump.hprof<process-id> ,然后利用 Eclipse MAT https://www.eclipse.org/mat 功能逐一分析开销最大的 classloader 和重复 class。

场景四:Metaspace

JDK 1.8 使用 Metaspace 替换了永久代(Permanent Generation),该错误表示 Metaspace 已被用满,通常是因为加载的 class 数目太多或体积太大。

此类问题的原因与解决方法跟 Permgenspace 非常类似,可以参考上文。需要特别注意的是调整 Metaspace 空间大小的启动参数为 -XX:MaxMetaspaceSize

场景五:Unable to create new native thread

每个 Java 线程都需要占用一定的内存空间,当 JVM 向底层操作系统请求创建一个新的 native 线程时,如果没有足够的资源分配就会报此类错误。

原因分析

JVM 向 OS 请求创建 native 线程失败,就会抛出 Unableto createnewnativethread,常见的原因包括以下几类:

  • 线程数超过操作系统最大线程数 ulimit 限制
  • 线程数超过 kernel.pid_max(只能重启)
  • native 内存不足

该问题发生的常见过程主要包括以下几步:

  • JVM 内部的应用程序请求创建一个新的 Java 线程
  • JVM native 方法代理了该次请求,并向操作系统请求创建一个 native 线程
  • 操作系统尝试创建一个新的 native 线程,并为其分配内存
  • 如果操作系统的虚拟内存已耗尽,或是受到 32 位进程的地址空间限制,操作系统就会拒绝本次 native 内存分配
  • JVM 将抛出 java.lang.OutOfMemoryError:Unableto createnewnativethread错误

解决方案

  • 升级配置,为机器提供更多的内存
  • 降低 Java Heap Space 大小
  • 修复应用程序的线程泄漏问题
  • 限制线程池大小
  • 使用 -Xss 参数减少线程栈的大小
  • 调高 OS 层面的线程最大数:执行 ulimia-a 查看最大线程数限制,使用 ulimit-u xxx 调整最大线程数限制

场景六:Out of swap space?

该错误表示所有可用的虚拟内存已被耗尽。虚拟内存(Virtual Memory)由物理内存(Physical Memory)和交换空间(Swap Space)两部分组成。当运行时程序请求的虚拟内存溢出时就会报 Outof swap space? 错误。

原因分析

该错误出现的常见原因包括以下几类:

  • 地址空间不足
  • 物理内存已耗光
  • 应用程序的本地内存泄漏(native leak),例如不断申请本地内存,却不释放
  • 执行 jmap-histo:live<pid> 命令,强制执行 Full GC;如果几次执行后内存明显下降,则基本确认为 Direct ByteBuffer 问题

解决方案

根据错误原因可以采取如下解决方案:

  • 升级地址空间为 64 bit
  • 使用 Arthas 检查是否为 Inflater/Deflater 解压缩问题,如果是,则显式调用 end 方法
  • Direct ByteBuffer 问题可以通过启动参数 -XX:MaxDirectMemorySize 调低阈值
  • 升级服务器配置/隔离部署,避免争用

场景七:Kill process or sacrifice child

有一种内核作业(Kernel Job)名为 Out of Memory Killer,它会在可用内存极低的情况下“杀死”(kill)某些进程。OOM Killer 会对所有进程进行打分,然后将评分较低的进程“杀死”,具体的评分规则可以参考 Surviving the Linux OOM Killer。不同于其它OOM错误, Killprocessorsacrifice child 错误不是由 JVM 层面触发的,而是由操作系统层面触发的。

原因分析

默认情况下,Linux 内核允许进程申请的内存总量大于系统可用内存,通过这种“错峰复用”的方式可以更有效的利用系统资源。然而,这种方式也会无可避免地带来一定的“超卖”风险。例如某些进程持续占用系统内存,然后导致其他进程没有可用内存。此时,系统将自动激活 OOM Killer,寻找评分低的进程,并将其“杀死”,释放内存资源。

解决方案

  • 升级服务器配置/隔离部署,避免争用
  • OOM Killer 调优

场景八:Requested array size exceeds VM limit

JVM 限制了数组的最大长度,该错误表示程序请求创建的数组超过最大长度限制。JVM 在为数组分配内存前,会检查要分配的数据结构在系统中是否可寻址,通常为 Integer.MAX_VALUE-2

此类问题比较罕见,通常需要检查代码,确认业务是否需要创建如此大的数组,是否可以拆分为多个块,分批执行。

场景九:Direct buffer memory

Java 允许应用程序通过 Direct ByteBuffer 直接访问堆外内存,许多高性能程序通过 Direct ByteBuffer 结合内存映射文件(Memory Mapped File)实现高速 IO。

原因分析

Direct ByteBuffer 的默认大小为 64 MB,一旦使用超出限制,就会抛出 Directbuffer memory 错误。

解决方案

  • Java 只能通过 ByteBuffer.allocateDirect 方法使用 Direct ByteBuffer,因此,可以通过 Arthas 等在线诊断工具拦截该方法进行排查
  • 检查是否直接或间接使用了 NIO,如 netty,jetty 等
  • 通过启动参数 -XX:MaxDirectMemorySize 调整 Direct ByteBuffer 的上限值
  • 检查 JVM 参数是否有 -XX:+DisableExplicitGC 选项,如果有就去掉,因为该参数会使 System.gc() 失效
  • 检查堆外内存使用代码,确认是否存在内存泄漏;或者通过反射调用 sun.misc.Cleaner 的 clean() 方法来主动释放被 Direct ByteBuffer 持有的内存空间
  • 内存容量确实不足,升级配置

最佳实践

① OOM发生时输出堆dump:

-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=$CATALINA_HOME/logs

② OOM发生后的执行动作:

-XX:OnOutOfMemoryError=$CATALINA_HOME/bin/stop.sh

-XX:OnOutOfMemoryError=$CATALINA_HOME/bin/restart.sh

OOM之后除了保留堆dump外,根据管理策略选择合适的运行脚本。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值