内存区域与内存溢出

1. 运行时数据区域

1.1 程序计数器

Program Counter Register 是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器

字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖于这个计数器来完成。

线程的中断以及中断优先级,就是 Java 中线程由 wait 状态到 running 状态恢复过程,程序计数器的线程独有的,它能记住上次执行到哪儿,下次继续执行。

在任何一个确定的时刻,一个处理器都只会执行一条线程中指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间计数器互不影响,独立存储

如果线程执行的是一个 Java 方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;

如果正在执行的是一个 Native 方法,这个计数器的值为 Undefined。

Native method is a java method which implemented by non java code.

这里是 Java 虚拟机中唯一一个没有规定任何 OutOfMemoryError 情况的区域。

1.2 Java 虚拟机栈

Java Virtual Machine Stacks 也是线程私有的,它的生命周期和线程相同。

它描述了 Java 方法执行的内存模型:每个方法在执行的同时都会创建一个栈帧用于存储局部变量表、操作数栈、动态链接、方法出口等信息

每个方法从调用直至执行完成的过程,就对应着一个栈帧从虚拟机栈中入栈到出栈的过程。

人们经常说的堆栈中的栈,在 Java 里就是这里的虚拟机栈,或者更加准确一点,是虚拟机中中的局部变量表

所以说有多少个线程就有多少个栈,所有栈指向同一个堆里的对象,当多个线程想同时改变堆内存中的一个引用时,就会出现线程同步的问题。

局部变量表存放了编译期可知的基本数据类型(八种)、引用类型、returnAddress 类型(指向了一条字节码指令的地址)。其中 64 位长度的 long 和 double 类型的数据占用两个局部变量空间,其余都是一个。

局部变量表所需的内存空间在编译器就分配完成,进入一个方法这个方法需要在栈中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小

编译生成 .class 文件时就会把这个方法所需要的内存空间定义好,所以 Java 是一门静态语言。

该区域有两种异常状况:如果线程请求的栈深度大于与你及所允许的深度,将抛出 StackOverFlowError 异常;如果虚拟机栈可以动态扩展,如果扩展时无法申请到足够的内存,将会抛出 OutOfMemoryError 异常。

局部变量表长度是在编译期确定的,不会发生内存溢出,而虚拟机栈会溢出。

1.3 本地方法栈

Native Method Stack 与虚拟机栈发挥的作用是相似的,它们之间的区别在于虚拟机栈为执行 Java 方法(字节码)服务,而本地方法栈则为虚拟机使用 Native 方法服务。

Native 方法其实现为非 Java 语言,编写的规则遵循 Java 本地接口的规范( JNI )。

虚拟机规范没有强制对该区域规范,具体的虚拟机可以自由实现它,譬如 Sun HotSpot 虚拟机将本地方法栈和虚拟机栈合二为一。

这里也有虚拟机栈栈提及的两种异常。

1.4 Java 堆

在大多数应用中,Java Heap 是虚拟机所管理的内存中最大的一块。内存共享。

Java 堆是所有线程共享的,物理上不一定连续,而逻辑上连续。

此区域的唯一目的是存放对象实例,几乎所有对象实例都在这里分配内存。

随着 JIT 的发展,栈上分配内存,标量替换等优化技术,让对象都在堆上创建变得不再绝对。

所有对象实例以及数据都在堆上分配,堆分为新生代和老年代,新生代又细分为 eden 区和两个 survivor 区。

新生代占有 1/3 ,老年代占有 2/3 。

从内存分配的角度来看,线程共享的 Java 堆中可能划分出多个线程私有的分配缓冲区,Thread Local Allocation Buffer , TLAB 。

Java 堆可以控制内存大小。

Xmx — 堆最大值

Xms — 堆最小值/初始化堆大小

Xmn — 年轻代堆大小

Xss 每个线程的栈大小

-XX:MaxTenuringThreshould — 来设定到达某个年龄可以成为老年代,默认是 15 。

-XX:NewRatio — 指定新生代和老年代的比例,默认是 1:2 。

如果在堆中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出 OutOfMemoryError 异常。

1.5 方法区

Method Area 也是线程共享的。它用于存放已经被虚拟机加载的类信息、常量、静态变量、即时编译器( JIT )编译后的代码等数据。

之所以叫方法区,而不是常量、静态变量、热点代码区,是因为这一块是各个方法共享的区域,不属于某个方法,所有方法都可以访问这个区域。

它还有一个名字, Non-Heap ,与堆进行区分。

HotSpot 虚拟机早期把这里称为 Permanenet Generation ,把 GC 分代扩展到了方法区,或者说使用永久代来实现方法区而已。这样 HotSpot 的 GC 就可以像管理 Java 堆一样管理这部分内存。其它虚拟机不存在永久代概念。

因为 String.intern() 这样的方法存在,会产生内存溢出问题,在 jdk1.7 的时候,HotSpot 已经将原本放在永久代的字符串常量池移出,而这个该方法的实现在 jdk1.7 也改变了。

永久代在 jdk1.8 中完全移除,使用 metaspace 代替,元空间可以在本地内存之外分配,所以其最大可利用空间是整个系统可用的内存空间。

垃圾收集在这个区域很小出现,因为这个区域的回收条件非常苛刻,但是却是必要的。

这里可以回收:废弃常量,无用的类。苛刻的应该说是无用的类:

  1. 该类所有的实例都已经被回收,即 Java 堆中不存在该类的任何实例;
  2. 加载该类的 ClassLoader 已经被回收;
  3. 该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

满足上面三个条件也仅仅是“可以”删除,而不是必然回收。

当方法区无法满足内存分配要求的时候,将抛出 OutOfMemoryError 异常。

1.6 运行时常量池

Runtime Constant Pool 是方法区的一部分,Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池,用于存放编译期间生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。

字面量就比如: int a = 1; 这里的 1 就是字面量。再比如: String a = “abc”; 这里的 abc 就是字面量。

符号引用包括:

  1. 类和接口的全限定名
  2. 字段名字和描述符
  3. 方法名称和描述符

符号引用和直接引用:直接引用存的是地址值,而符号引用存放的是一个路径,包路径。两者替换发生在类解析的时候。

运行时常量池:

  1. 全局常量池在每个 VM 中只有一个,存放常量的引用值。

  2. class 常量池是在编译的时候每个 class 都有的,在编译阶段,存放的是常量的符号引用。

  3. 运行时常量池是在类加载完成后,将每个 class 常量池中的符号引用值转存到运行时常量池中,也就是说,每个 class 都有一个运行时常量池,类在解析后,将符号引用替换成直接引用,与全局常量池中的引用值保持一致。

  4. 运行时也可能将新的常量放入池中。

一般来说,除了保存 Class 文件中描述的符号引用外,还会把翻译出来的直接引用也存储到运行时常量池中。

关于运行时将常量放入池中,书上说 String 的 intern() 方法。

1
2
3
4
5
6
7
String str1 = new String("a") + new String("bc"); // 此时 new 关键字在堆中创建了 abc 字符串对象。
String str = str1.intern(); // 从池中返回字符串对象,如果没有则先添加到池中再返回。
// jdk1.6 的做法是直接在常量池中生成一个 abc 对象。
// jdk1.7 及以上版本不再存储一个对象了,可以直接存储堆中的引用。
String str2 = "abc";
// 因此,str == str2 的结果是 true
// 所以,我们反复调用这个方法也不会出现 OutOfMemoryError 异常!

既然运行时常量池是方法区的一部分,自然受到方法区内存的限制,当常量池无法申请内存时,将抛出 OutOfMemoryError 异常。

1.7 直接内存

Direct Memory 并不是虚拟机运行时数据区的一部分,也不是 Java 虚拟机规范中定义的内存区域。但是这部分的内存也被频繁使用,而且可能导致 OutOfMemoryError 异常,值得探讨。

Jdk1.4 引入的 NIO 类,引入了基于 Channel 和 Buffer 的 I/O 方式,然后通过存储在 Java 堆上的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样可以在一些场景中显著提高性能,避免了在 Java 堆和 Native 堆中来回复制数据。

显然,本地的直接内存的分配不会受 Java 堆的大小而限制,但是会收到本机总内存大小和处理器寻址空间的限制。服务器管理员在配置时,可能忽略直接内存,使得各内存区域综合大于物理内存限制,产生异常

2. HotSpot 虚拟机对象

2.1 对象的创建

虚拟机遇到一条 new 指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。

为什么要去常量池寻找该指令的参数?因为前面提到过一个类的符号引用将在运行时放入到常量池中。

如果没有加载进来,将使用类加载器,使用双亲委派机制进行类加载。

在类加载检查通过后,接下来虚拟机将为新生对象分配内存,对象需要的内存的大小在类加载完成后便完全确定了,分配内存的方式有两种,选择哪一种分配方式取决于 Java Heap 是否规整,而是否规整,取决于垃圾回收器是否具有压缩整理功能。

  1. 垃圾回收器使用 SerialParNew 等带有 Compact 过程的垃圾回收器将使用指针碰撞的方式分配内存;

    Java Heap 绝对规整,一边是空闲的内存,一边是用过的内存,我们使用一个指针作为分界,挪动指针来分配内存。

  2. 使用 CMS 这种基于 Mark-Sweep 算法的收集器将使用空闲列表的方式分配内存。

    Java Heap 并不是规整的,使用和没有使用的内存交错,我们维护一个列表,记录哪些块可用,实时更新这张表。

除了划分空间,我们还需要考虑的一点是线程安全。为了保证线程安全,我们有两种方案:

  1. 对分配内存空间的操作进行同步处理——实际虚拟机中使用 CAS 配上失败重试的方式来保证更新操作的原子性。

    Compare And Swap 通过版本号,先比较,再提交。假如版本号不是最新的则认定本次 CAS 操作失败。

  2. 前面我们也提起过 TLAB Thread Local Allocation Buffer ,每个线程在 Heap 中预先分配一小块内存,称为本地线程分配缓存。只有在 TLAB 分配完,才进行同步锁定,分配新的 TLAB 。

现在内存分配完成了,虚拟机将分配到的内存空间都初始化为零值,甚至 TLAB 可以将这一步提前到 TLAB 分配时进行,因此保证了 Java 代码可以不赋初始值直接使用。

解释了为什么对象的属性可以不初始化就可以访问,而局部变量需要初始化,因为需要看栈帧的初始化方式。

然后虚拟机对对象进行必要的设置,将一些必要的信息放到对象头中

现在对于虚拟机而言一个对象已经产生了,而对我们而言,才刚刚开始。

虚拟机创建对象:

  1. 检查 new 参数是否在常量池中有一个类的引用,检查类是否被加载解析过。
  2. 没有则加载,通过后,虚拟机为新对象分配内存,在类加载完成就决定了内存大小,根据 GC 方式,看是否具有压缩整理功能,于是有指针碰撞空闲列表两种分配方式。
  3. 分配可能出错,我们需要使用 CAS+错误重试TLAB 两种方式保证线程安全。
  4. 内存分配完成,虚拟机将分配到的内存空间都初始化零值。
  5. 配置对象头,存放类的元数据信息、对象的哈希码、对象的 GC 年龄等信息。
  6. 从虚拟机角度看,现在,一个新的对象已经创建完成。
  7. 从我们的角度看,对象的创建刚刚开始, init 方法还没有执行,执行 new 以后执行 init 方法,这样一个真正的对新创建完成。

2.2 对象的内存布局

对象内存布局包括

  1. 对象头——保存状态信息以及类型信息
  2. 实例数据——保存对象中定义的变量信息
  3. 对齐填充——在对象有用信息不到八字节整数倍的时候进行对其

对象头中包括

  • a. 运行时数据
    • 哈希值
    • GC 信息
    • 锁信息
    • 线程 id
    • 时间戳
  • b. 类型指针

官方称对象头为Mark Word,对象需要存储的运行时数据很多,其实已经超过了32位、64位 Bitmap 结构能记录的限度,但是对象头信息是与对象自身定义的数据无关的额外存储成本

所以一个对象所占有的空间大小,是比这个对象所存储的大小要大一点。

如在 32bit 的虚拟机中,对象未被锁定,Mark Word 的 32bit 中有 25bit 用于存储对象哈希码,4bit 用于存储对象分代年龄,2bit 用于存储锁标志位,1bit 固定为0。其他状态(轻量级锁、重量级锁、GC 标记、可偏向)下的对象的内容见表:

对象的另外一部分信息是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。

所谓元数据类型,即方法区中加载的类信息。

查找对象的元数据信息并不一定要通过对象本身,因为我们可以用直接指针句柄,直接指针需要根据对象头的类型指针来定位该对象的具体类型,但是通过句柄的时候,句柄池中存储了实例指针和类型指针,就不需要对象头中设置类型指针了。

值得注意的是,如果对象是 Java 数组,那么对象头中还必须有一块专门记录数组长度的数据,因为普通的 Java 对象的元数据信息中确定了 Java 对象的大小,而数组的元数据中却无法确定数组的大小。

我们可以通过 .length 属性来直接获取长度,其他对象则是 length() 方法。

实例数据是对象真正存储的有效信息,也是对象代码中所定义的各种类型的字段内容,无论是父类继承的还是子类中定义的,都要记录,HotSpot 虚拟机默认分配策略为——longs/doubles, ints, shorts/chars, bytes/booleans, oops(普通对象指针 Ordinary Object Pointers),可以看出相同宽度的字段总被分配到一起,满足这个前提下,父类中定义的变量出现在子类之前,如果 CompactFields 参数值为 true(默认为 true),子类中较窄的遍历也可能插入到父类变量的空隙中。

对齐填充并不是必然存在的,也没有特别的含义,它仅仅起着占位符的作用。由于 Hotspot VM的自动内存管理系统要求对象起始地址必须是 8 字节的整数倍,换句话说,就是对象的大小必须是8字节的整数倍。而对象头部分正好是8字节的倍数(1倍或者2倍),因此,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。

2.3 对象的访问定位

建立对象是为了使用对象,我们的程序需要通过栈上的 reference 数据来操作操作栈上的具体对象。由于 reference 类型在虚拟机中只规定了一个指向对象的引用,并没有定义这个引用应该通过什么方式去定位,访问堆中对象的具体位置,访问方式由 JVM 实现而决定。

我们可以使用句柄直接指针两种方式。

句柄是一个指向对象实例数据的指针,Reference 是指向句柄的指针。

如果我们使用句柄访问,那么 Heap 中将有一块内存来作为句柄池,Reference 将存储的是对象的句柄地址,句柄中包含了对象的实例数据类型数据各自具体的地址信息。

如果使用直接指针访问,那么 Heap 堆对象的布局就必须考虑如何防止访问类型数据的相关信息,而 Reference 中存储的直接就是对象地址。

这两种方式各有优势,Reference 存储就是稳定的句柄地址,对象对移动时改变句柄中的实例数据指针,而 Reference 本身不需要修改。
使用直接指针最大的好处就是速度更快,它节省了一次指针定位的时间开销,Sun HotSpot 使用的是第二种方式。

3. OOM 实战

确定内存对象很有必要,分析是内存泄露还是内存溢出:

  1. 内存泄露:本该释放内存的实例没有释放,根据引用链来确定位置。内存泄露不一定会内存溢出,但是极少成多,达到上限就会出现内存溢出。
  2. 内存溢出:超出了 Heap 限制,堆内存增大或者分析对象声明周期是否过长。

3.1 Java 堆溢出

Java Heap 用于存储对象实例,只要不断创建对象,并且保证 GC Roots 到对象之间有可达路径来避免垃圾回收机制清楚这些对象,那么对象在到达最大堆容量的时候就会产生内存溢出。

可以将 -Xms(堆最小值)和 -Xmx(堆最大值)设为一样,来避免堆自动扩展。

通过参数 -XX:+HeapDumpOnOutputMemroyError 可在出现内存溢出异常时 Dump 当前的内存堆转存储快照以便事后分析。

如果是内存泄露,找到泄露对象是通过怎样的路径与 GC Roots 关联导致无法回收,掌握泄露对象的类型信息以及引用链的信息,就可以找到泄露位置。

如果不存在泄露,就是对象都需要存活,那么就查看虚拟机的堆参数,查看是否可以调大,并检查某些对象生命周期是否过长,尝试减少程序运行期的内存消耗。

3.2 虚拟机栈和本地方法栈溢出

由于在 HotSpot 虚拟机中并不区分虚拟机栈和本地方法栈,因此对于 HotSpot 而言,虽然 Xoss 参数存在,用以设置方法栈大小,但是是无效的,栈容量只和 -Xss 有关。

我们还记得 JVM 中描述的两种异常:

  1. 如果线程请求的栈深度大于虚拟机允许的最大深度,就会有 StackOverFlowError 异常;
  2. 如果虚拟机在扩展栈时无法申请到足够的内存空间,将会抛出 OutOfMemoryError 异常。

第一种异常,在一个线程中不断调用新的方法压栈帧进去;
第二种异常,线程分配 jvm 栈,不断 new Thread();

这里有两种异常,看似严谨,但是有重叠的地方:当栈空间无法分配时,到底是内存太小还是已使用的栈空间太大?

在单线程下,无论栈帧太大还是虚拟机容量太小都是抛出 StackOverFlowError

如果不限于单线程,不断简历线程可以产生 OOM ,但是这样产生内存溢出和栈空间没有任何联系,为每个线程的栈分配的内存越大,反而越容易产生 OOM 。

在多线程的情况下,每个线程分配到的栈容量越大,可以建立的线程数量自然越少,建立线程时就越容易把剩下的内存耗尽。

JVM 提供了参数来控制 Java Heap 和方法区这两部分的最大值,剩余的内存是进程内存上限减去线程共有的 Xmx (最大堆容量),再减去 MaxPermSize (最大方法区容量),然后是线程私有的,程序计数器内存消耗很小,可以忽略,剩下的内存就是虚拟机栈和本地方法栈瓜分了。

如果是建立过多线程导致的内存溢出,在不能减少线程数或替换64位虚拟机的情况下,只能通过减少最大堆和减少栈容量来换取更多的线程。

3.3 方法区和运行时常量池溢出

线程公有的方法区,String.intern() 是个 Native 方法:如果字符串常量池中包括一个等于此 String 对象的字符串,则返回代表这个字符串的 String 对象,否则,将此 String 对象包含的字符添加到常量池中,并返回此 String 对象的引用。

在 Jdk1.7 前, String.intern() 方法单纯将首次遇到的字符串实例复制到永久代中,返回的也是永久带中这个字符串实例的引用。

在 Jdk1.6 前,由于常量池在永久代里,现在已经不是这样了,现在在元空间里,如果是以前,我们可以通过 -XX:PermSize 和 -XX:MaxPermSize 限制方法区方法,间接控制常量池。

方法区用于存放 Class 的相关信息,如类名、访问修饰符、常量池、字段描述、方法描述等。对于这些区域的测试,基本的思路是运行时产生大量的类去填满方法区,直到溢出。

我们可以借助 CGLib 直接操作字节码运行时生成大量的动态类,当前很多主流的框架,比如 Spring 、 Hibernate 在对类进行增强的时候,都会用到这个技术。

方法区溢出也是一种常见的内存溢出异常,一个类要被垃圾收集器回收掉,判定条件是比较苛刻的。在经常动态生成大量 Class 的应用中,需要特别注意类的回收状况。这类场景除了上面提到的程序使用了 CGLib 字节码增强和动态语言之外,常见的还有:大量 JSP 或动态产生 JSP 文件的应用( JSP 第一次运行时需要编译为 Java 类)、基于 OSGi 的应用(即使是同一个类文件,被不同的加载器加载也会视为不同的类)等。

在 Jdk1.8 前,本地部署 Tomcat 经常会 OOM ,可能就是因为项目中存在大量的 JSP 文件编译后加载的 JVM 中方法区内存溢出了。

3.4 本机直接内存溢出

DirectMemory 容量可以通过 -XX:MaxDirectMemorySize 指定,默认和 Java Heap 最大值一样。

由 DirectMemory 导致的内存溢出,一个明显的特征是 Heap Dump 文件不会看见明显的异常,如果我们发现 OOM 之后 Dump 文件很小,而且程序中又直接或间接使用了 NIO ,那么可以考虑这方面的问题。