深入理解java虚拟机-第二章:java内存区域与内存泄露异常

时间:2023-03-08 17:04:00

2.1概述:

  java将内存的管理(主要是回收工作),交由jvm管理,确实很省事,但是一点jvm因内存出现问题,排查起来将会很困难,为了能够成为独当一面的大牛呢,自然要了解vm是怎么去使用内存的。

2.2运行时的数据区域

  vm会将管理的内存划分为不同的区域,不同的区域间有各自的用途,以及创建和销毁时间。具体的区域划分如下图:

深入理解java虚拟机-第二章:java内存区域与内存泄露异常

  注:执行引擎跟本地库接口不是内存数据区,方法区跟堆内存才是共享的内存数据区

2.2.1程序计数器

  是一块较小的内存地址,可以认为是当前线程所执行的字节码的行号指示器。在概念模式中(不同的虚拟机可以选择自己的实现方式),字节码解释器工作时,通过改变这个计数器的值来选择下一条执行的字节码命令。分支、循环、跳转、异常处理、线程恢复等基础功能都是依赖这个计数器完成的。

  在JVM中多线程是通过线程轮流切换并分配处理器执行时间实现的,即在同一个时刻,一个处理器只会执行一个线程的命令,所以为了线程切换能够回到正确的执行位置,每条线程都要有独立的计数器。

  补充:

  如果线程执行的是java方法,那么计数器记录的是字节码指令的地址,如果是Native方法,计数器则为空(Undefined),该区域在jvm规范中也没有OOM。

2.2.2java虚拟机栈

  是线程私有的,生命周期与线程相同。

  虚拟机栈描述的是方法执行的内存模型,方法在执行的时候会创建栈帧(Stack Frame),用于存储局部变量表,操作数栈、动态链接、方法出口等信息。方法从调用到执行完成,对应一个栈帧从入栈到出栈的过程。

  局部变量表中存放的是:编译期可知的各种基本数据类型(八个基本数据类型),对象引用(可能是指向对象地址的引用指针,也可能是执行代表对象的句柄)和returnAddress类型(指向了一条字节码指令的地址)。

  long与double会占据两个局部变量空间(slot),其他占据一个,局部变量表所需内存大小编译期间就已经完成分配, 方法运行期间不会改变局部变量表的大小。

  jvm规范中,对栈规定了两个异常状态,线程请求的栈深度大于虚拟机所允许的深度会抛出*Error异常。

  虚拟机栈可以动态扩展来避免栈溢出,但是当扩展无法申请到足够的内存时,就会抛出OutofMemoryError异常。

2.2.3本地方法栈

  功能作用与虚拟机栈是非常一致的,区别就在于:java虚拟机栈为执行java方法服务,本地方法栈为虚拟机使用的Native方法服务。虚拟机规范并没有对本地方法栈做硬性要求。

  HotSpot直接把本地方法栈跟虚拟机栈合二为一,本地方法栈也会抛出两个异常。栈溢出与内存溢出。

2.2.4java堆

  java堆(java Heap)内存是vm管理的最大的内存。

  java堆被所有线程共享的内存区域。该内存区域存在的目的是存放对象实例。

  规范中:所有的对象实例以及数组都是要求在堆上进行分配,但是随着JIT编译器的发展与逃逸分析技术,出现了栈上分配和标量替换,这会导致有一些微妙的变化。

  java堆是垃圾收集器的主要管理区域,也成为GC堆(Garbage Collected Heap),收集器都选择分代收集算法。

  java堆可以细分为:新生代和老年代:再细致点有Eden空间、From Survivor空间、 To Survivor空间等。

  从内存分配的角度看,java堆内是可以划分出多个线程私有的分配缓冲区的(Thread Local Allocation Buffer TLAB)。

  划分的详细的目的在于方便更好地回收内存。

  补充:java堆可以不处于物理上的连续内存,只要逻辑上连续就可以了,当堆无法继续扩展时,也会抛出OutOfMemoryError。

2.2.5方法区:

  方法区(Method Area)跟java堆一样,是线程共享的内存区域,用于储存VM加载的类信息,常量,静态变量,即时编译器编译后的代码等,还有一个别名(Non-Heap)非堆

  对于HotSpot来说,方法区也可称为永久代(Permanent Generation),本质上两者并不等价,仅仅是因为HotSpot虚拟机把分带收集器扩展到了方法区(用永久代来实现方法区)。

  用永久代实现方法区会容易导致内存溢出问题(永久代有-XX:MaxPermSize的上限)。在jdk1.7中,已经把原来放在永久代的字符串常量池移出永久代了。

  VM规范对于方法区来说,也可以不选择连续的物理内存,还可以选择固定大小或者可扩展,甚至你还可以选择不实现垃圾回收。

  针对方法区的回收主要是针对常量池的回收和对类型的卸载,当方法区无法满足内存分配的时候,就会出现OutOfMemoryError异常。

2.2.6运行时常量池

  运行时常量池(Runtime Constant Pool)是方法区的一部分。

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

  VM规范没有对运行时常量池的细节规范,一般情况下除了class的符号引用外,还会把直接引用也存在运行时常量池。

  运行时常量池相对于Class文件的常量池另一个重要特征是动态性,运行期间也可以放入新的常量进入常量池。比较多的用法是String类的intern()。

  作为方法区的一部分,当然也会OutofMemoryError异常

2.2.7直接内存

  直接内存(Direct Memory)并不是VM运行时数据区的一部分,也不是VM规范中定义的内存区域,但是如果该区域被频繁使用,也会导致OutOfMemoryError异常。

  NIO,引入了基于通道(Channel)与缓冲区(Buffer)的I/O方式,可以直接使用Native函数分配堆外内存,然后通过在堆中的DirectByteBuffer对象作为该内存的引用进行操作。

  虽然该内存不受堆的限制,但是也可能受物理内存的限制,所以也可能因为设置的参数问题,导致动态扩展时出现OutOfMemoryError异常。

简单总结:讲了各个内存区域的一些实现细节跟部分VM规范,除了程序计数器外其他所有的内存区域都存在内存溢出异常,甚至于非jvm的内存区域直接内存,也有可能出现内存溢出异常。


2.3HotSpot虚拟机的对象探秘

2.3.1对象的创建

  创建对象是通过关键字new来创建的。

  虚拟机收到new命令时,会去常量池中检查是否有对应参数的类的符号引用,并检查这个符号引用是否已经被加载、解析和初始化过,如果没有的话,那么要先进行类加载。

  类加载的检查完成后,就要对新生对象进行内存分配了,分配方式有两种,根据堆内存是否规整可以分为两类:指针碰撞(Bump the Pointer)、空闲列表(Free List)

  指针碰撞:堆内存规整,分配内存的过程仅仅是将指针向空闲空间挪动一段与对象大小一致的距离。

  空闲列表:如果内存不规整,那么已使用的内存与空闲内存交互,虚拟机会维护一个记录表,记录内存是否可用,在分配时从列表中找足够内存划分给实例,更新记录表。

  堆是否规整又跟垃圾收集器有关,使用Serial、ParNew等带Compact过程的收集器时,采用指针碰撞;使用CMS这种基于Mark-Sweep算法的收集器时,采用空闲列表的方式。

  除了分配内存外,还需要考虑在并发下的安全问题,虚拟机采用了CAS配上失败重试的方式保证更新操作的原子性;另一种方式是把内存分配的动作按照线程划分在不同的空间中,即每个线程在java堆中预先分配一个内存,称为本地线程分配缓冲(Thread Local Allocation Buffer,TLAB),哪个线程需要分配内存,就在哪个线程的TLAB分配,只有TLAB用完需要进行新增时,才进行同步锁定操作。虚拟机是否使用TLAB,可以通过配置:-XX:+/-UseTLAB参数来设定。

  内存分配完成,VM还需要将分配的内存空间都初始化为零值(对象头除外),如果使用TLAB的话,那么该过程也会提前至TLAB时进行,这一步操作保证了对象实例字段在java代码中可以不赋初始值就直接使用,程序可以直接访问到这些字段数据类型的对应值。

  完成初始化工作后,VM要设置对象的对象头,相关信息:对象是哪个类的实例,如何找到类的元数据信息,对象的哈希值,对象的GC分带年龄等信息。

  完成以上步骤后,VM的视角,新的对象已经产生了。但是java代码角度,对象创建才刚开始,<init>方法没有执行,所有字段都还是0,执行完new指令后执行<init>方法后才算对象创建完毕。

  简单描述一下VM视角与程序视角下对象的创建流程:

    VM  ->  类是否初始化  内存分配  内存空间初始化  对象头赋值

    java程序  ->  类是否初始化  内存分配  内存空间初始化  对象头赋值  <init>方法

2.3.2对象的内存布局

  对象在内存中的布局分为三个区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)

  HotSpot的对象头包含两部分信息,第一部分:存储对象自身的运行时数据、第二部分类型指针。

  存储运行时数据有:哈希吗,GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,根据虚拟机位数不同分别为32/64bit,称为Mark Word,实际上是一个可动态的数据结构,以便以小空间存储更多的信息。

  类型指针:即对象指向它的类元数据的指针,VM通过指针确定对象属于哪个类。

  注:如果对象是一个数组,那么对象头中还必须有一块用于记录数组长度的数据。

  实例数据:对象真正存储的有效信息。

  父类定义在前,子类灾后,存储的顺序还受VM分配策略参数(FieldsAllocationStyle)和java源码中定义顺序影响。

  HotSpot的顺序是:从长到短,且字段相同的放在一起。

  第三部分对齐填充不是必然存在的,仅仅是占位符的作用。由于HotSpot的VM自动内存管理系统要求对象起始地址必须是8字节的整数倍,那么对象就必须是8字节的整数倍了,因为对象头部分是8字节的倍数,所有当实例数据没有对齐时,对齐填充就用来补齐。

2.3.3对象的访问定位

  虚拟机规范只规定了一个指向对象的引用,但是没有规定具体的方式。所以还是根据虚拟机的具体实现来表述对对象的访问。常规的是两种句柄式与直接指针式:

  如果是采取:句柄访问的话,那么java堆会划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了实例数据地址跟数据类型地址

深入理解java虚拟机-第二章:java内存区域与内存泄露异常

  如果采用直接指针访问,那么java堆对象的布局就必须考虑如何放置访问类型数据的相关信息,reference中存储的是对象地址。

   深入理解java虚拟机-第二章:java内存区域与内存泄露异常

两种对象的访问方式各有优劣,句柄的好处时,reference中存储的是稳定的句柄地址,对象被移动时,只改变句柄中的实例数据指针,而reference不改变。

  直接指针访问最大的好处是速度快,节省了一次指针定位的时间开销。

  HotSpot采用的就是直接指针访问的方式。


2.4实战OOM异常

  本节的目的:1、通过代码验证java虚拟机规范中描述的各个运行时区域存储的内容。

        2、帮助判断实际工作中是什么问题导致哪些区域内存溢出,什么原因导致该区域内存溢出,出现问题该怎么办。

  使用如下jvm参数:

  

-verbose:gc -Xms20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8

2.4.1java堆溢出

  堆中存放对象实例,只要不停创造对象,且保证GC Roots到对象之间有可达路径来避免垃圾回收机制清楚对象(将在垃圾回收机制处讲),那么对象达到阈值后自然会产生内存溢出。

  使用参数:-XX:+HeapDumpOnOutOfMemoryError可让虚拟机在出现内存溢出时Dump当前的内存堆转储快照,便于事后分析。

示例代码:

public class HeapOOM {
static class OOMObject {}
public static void main(String[] args) {
List<OOMObject> list = new ArrayList<>();
while (true) {
list.add(new OOMObject());
} }
}

  解决这个区域的异常,一般通过内存映像分析工具,对Dump出来的堆转储快照进行分析,重点是确认内存中的对象是否是必要的,也就是确认是内存泄漏(Memory Leak)还是内存溢出(Memory Overflow)。

  注:上面这句也简洁直观地表达了内存泄漏与内存溢出的区别。

  内存泄漏:可以通过工具看泄漏对象到GC Roots的引用链,可以找到泄漏对象时通过怎样的路径与GC Roots相关联并导致垃圾回收器无法自动回收它们。只要掌握了泄漏对象的类型信息及GC Roots引用连的信息,就可以准确地定位出泄漏代码的位置。

  内存溢出:如果对象都必须存活,那么虚拟机的堆参数(-Xmx与-Xms)与机器物理内存对比看是否能够调大;从代码角度看,是否有对象的生命周期过长、持有状态时间过长,以期减少程序运行期间内存的消耗。

2.4.2虚拟机栈以及本地方法栈溢出

  HotSpot中不区分虚拟机栈与本地方法栈,所有-Xoss实际无效,只设置-xss即可。

  对于虚拟机栈和本地方法栈来说,会出现两种异常:

    线程请求的栈深度大于虚拟机所允许的最大深度,即抛出:*Error。

    虚拟机扩展栈时,无法申请到足够的内存空间,即抛出:OutOfMemoryError

  其实,当栈空间无法继续分配时,到底是内存太小,还是已用栈空间太大,本质都是同一件事情的两种描述。

  注明:在单线程条件下,无论是栈帧过大还是虚拟机容量太小,都会抛出异常*Error。

  在多线程条件下,通过不断创建线程的方式是会产生内存溢出的,但是产生内存溢出与栈空间是否足够大无关,在这种情况下,为每个线程分配的内存越大,越容易栈溢出(总量一定,单次消耗越大,越容易满)。

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

  String.intern()是一个Native方法,它的作用是:如果字符串常量池中已经包含了一个等价次string对象的字符串,返回常量池中的该对象,否则将string对象包含的字符串添加到常量池中,返回此string对象的引用。