深入理解Java虚拟机笔记(一)Java内存区域与内存溢出异常



运行时数据区域

在这里插入图片描述

程序计数器

作用

程序计数器(Program Counter Register)是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。在虚拟机的概念模型里(仅是概念模型,各种虚拟机可能会通过一些更高效的方式去实现),字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。

作用域:线程私有

由于Java虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器(对于多核处理器来说是一个内核)都只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。

记录内容

如果线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是Native方法,这个计数器值则为空(Undefined)。此内存区域是唯一一个在Java虚拟机规范中没有规定任何OutOfMemoryError情况的区域。

Java虚拟机栈

作用

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

听得比较多的说法

经常有人把Java内存区分为堆内存(Heap)和栈内存(Stack),这种分法比较粗糙,Java内存区域的划分实际上远比这复杂。其中所指所指的“栈”就是现在讲的虚拟机栈,或者说是虚拟机栈中局部变量表部分。

局部变量表

局部变量表存放了编译期可知的各种基本数据类型(64位长度的long和double类型的数据会占用2个局部变量空间)、对象引用和returnAddress类型(指向了一条字节码指令的地址)。

内存空间分配

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

两种异常状况

在Java虚拟机规范中,对这个区域规定了两种异常状况:

  1. StackOverflowError异常:如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常;如果虚拟机栈可以动态扩展(当前大部分的Java虚拟机都可动态扩展,只不过Java虚拟机规范中也允许固定长度的虚拟机栈),
  2. OutOfMemoryError异常:如果扩展时无法申请到足够的内存,就会抛出OutOfMemoryError异常。

本地方法栈

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

Java堆

目的

Java堆(所有线程共享)的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存。

发展

随着JIT编译器等发展,所有对象都分配在堆上也不是那么绝对了。

内存回收相关

Java堆是垃圾收集器管理的主要区域(经常被称为’‘GC堆’’)。

分类

  1. 从内存回收的角度上讲,Java堆还可以细分为:新生代和老年代;再细致一点的有Eden空间,From Survivor空间,To Survivor空间等。
  2. 从内存分配的角度上讲,线程共享的Java堆中可能会划分为多个线程私有的分配缓冲区(Thread Local Allocation Buffer,TLAB)。

无论哪个区域存储的都是对象。

Java虚拟机规范规定

Java可以处于物理上不连续的内存空间中,只要逻辑连续即可。不过,如果在堆中没有内存完成分配实例,并且堆无法扩展时会抛出OOM(OutOfMemory)异常。

方法区

概述

方法区和Java堆一样,是各个线程共享的内存区域,用于存储已被虚拟机加载的类信息,常量,静态变量,即时编译后的代码等数据。很多人都把方法区称为"永久代"。

版本变动

jdk1.7的HotSpot中,已经把原本放在永久代的字符串常量池移出。

"永久代"中的垃圾收集

相对而言,垃圾收集行为在这个区域是比较少出现的,但并非输出进入了方法区就如永久代的名字一样"永久"存在了,这个区域的内存回收目标主要是针对常量池的回收和对类型的卸载,一般来说,这个区域的回收率不高,尤其是对类型的卸载,条件相当苛刻,但是这个区域的回收却是必要的。

抛出的异常

当方法区无法满足内存分配需求时,将抛出OOM异常。

运行时常量池

概述

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

存储对象

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

动态性

运行时常量池相对于Class文件常量池的另外一个重要特征是具备动态性,Java语言并不要求常量一定只有编译期才能产生,也就是并非预置入Class文件中常量池的内容才能进入方法区运行时常量池,运行期间也可能将新的常量放入池中,这种特性被开发人员利用得比较多的便是String类的intern()方法。

抛出异常

受方法区内存的限制,当常量池无法申请内存时就会抛出OOM。

直接内存

概述

通过Native函数库直接分配的堆外内存。

抛出异常

各个内存区域总和大于物理内存限制,会导致动态扩展时出现OOM异常。


HotSpot虚拟机对象探秘

对象的创建

类加载

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

分配内存

对象所需内存的大小在类加载完成后便可完全确定,为对象分配空间的任务等同于把一块确定大小的内存从Java堆中划分出来。

空闲列表

如果Java堆中的内存并不是规整的,已使用的内存和空闲的内存相互交错,那就没有办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式称为“空闲列表”(Free List)。

指针碰撞

假设Java堆中内存是绝对规整的,所有用过的内存都放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空间那边挪动一段与对象大小相等的距离,这种分配方式称为“指针碰撞”(Bump the Pointer)。

创建中的线程安全

除如何划分可用空间之外,还有另外一个需要考虑的问题是对象创建在虚拟机中是非常频繁的行为,即使是仅仅修改一个指针所指向的位置,在并发情况下也并不是线程安全的,可能出现正在给对象A分配内存,指针还没来得及修改,对象B又同时使用了原来的指针来分配内存的情况。

  1. 对分配内存空间的动作进行同步处理。
  2. 把内存分配的动作按照线程划分在不同的空间中进行。:每个线程在Java堆中预先分配一小块内存,称为本地线程分配缓冲(TLAB)。

初始化

内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),如果使用TLAB,这一工作过程也可以提前至TLAB分配时进行。

必要的设置

例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息。这些信息存放在对象的对象头(Object Header)之中。根据虚拟机当前的运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。

init方法执行

执行new指令之后会接着执行<init>方法(目前可以可以简单理解为构造器内的初始化行为),把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来。

对象的内存布局

对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)

对象头

  1. 用于存储对象自身的运行时数据,如哈希码,GC分代年龄,锁状态标准,线程持有的锁,偏向线程ID,偏向时间戳等。官方称其为"Mark Word"。
  2. 类型指针:即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。不过不是所有虚拟机都要保留类型指针,即可以不通过对象本身去查找对象的元数据信息。

实例数据

对象真正存储的有效信息,即程序代码中所定义的各种类型的字段内容。这部分的存储顺序会受到虚拟机分配策略参数和字段在Java源码中定义顺序的影响。相同宽度的字段总是被分配到一起。在满足这个前提条件的情况下,在父类中定义的变量会出现在子类之前。如果CompactFields参数值为true(默认为true),那么子类之中较窄的变量也可能会插入到父类变量的空隙之中。

对齐填充

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

对象的访问定位

通过句柄访问对象

在这里插入图片描述

通过句柄访问实例数据和类型数据

优势

使用句柄来访问的最大好处就是reference中存储的是稳定的句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而reference本身不需要修改。

通过直接指针访问对象

在这里插入图片描述

通过对象的指针访问对象类型数据。

优势

使用直接指针访问方式的最大好处就是速度更快,它节省了一次指针定位的时间开销,由于对象的访问在Java中非常频繁,因此这类开销积少成多后也是一项非常可观的执行成本。就本书讨论的主要虚拟机Sun HotSpot而言,它是使用第二种方式进行对象访问的,但从整个软件开发的范围来看,各种语言和框架使用句柄来访问的情况也十分常见。


实战:OOM异常

Java堆溢出

public class HeapOOM {
    static class OOMObject {
    }
    public static void main(String[] args){
        List<OOMObject> list = new ArrayList<>();
        try {
            while (true) {
                list.add(new OOMObject());
            }
        }finally {
            System.out.println("大小:"+list.size());
        }

    }
}

Java堆内存的OOM异常是实际应用中常见的内存溢出异常情况。当出现Java堆内存溢出时,异常堆栈信息“java.lang.OutOfMemoryError”会跟着进一步提示“Java heap space”。

可以打开内存分析工具判断时内存泄漏还是内存溢出。

  • 如果是内存泄露,可进一步通过工具查看泄露对象到GC Roots的引用链。于是就能找到泄露对象是通过怎样的路径与GC Roots相关联并导致垃圾收集器无法自动回收它们的。掌握了泄露对象的类型信息及GC Roots引用链的信息,就可以比较准确地定位出泄露代码的位置。
  • 如果不存在泄露,换句话说,就是内存中的对象确实都还必须存活着,那就应当检查虚拟机的堆参数(-Xmx与-Xms),与机器物理内存对比看是否还可以调大,从代码上检查是否存在某些对象生命周期过长、持有状态时间过长的情况,尝试减少程序运行期的内存消耗。

虚拟机和本地方法栈溢出

public class JavaVMStackSOF {
    private int stackLength=1;
    private void stackLeak(){
        stackLength++;
        stackLeak();
    }
    public static void main(String[] args)throws Throwable{
        JavaVMStackSOF oom=new JavaVMStackSOF();
        try {
            oom.stackLeak();
        }catch (Throwable e){
            System.out.println("stack length"+oom.stackLength);
            throw e;
        }

    }
}

在这里插入图片描述

两种异常

  • 如果线程请求的栈深度大于虚拟机所允许的最大深度,将抛出StackOverflowError异常。
  • 如果虚拟机在扩展栈时无法申请到足够的内存空间,则抛出OutOfMemoryError异常。

实验结果

实验结果表明:

  • 在单个线程下,无论是由于栈帧太大还是虚拟机栈容量太小,当内存无法分配的时候,虚拟机抛出的都是StackOverflowError异常。单线程有限制虚拟机栈大小。

  • 多线程情况下,通过不断地建立线程的方式倒是可以产生内存溢出异常。但是这样产生的内存溢出异常与栈空间是否足够大并不存在任何联系,或者准确地说,在这种情况下,为每个线程的栈分配的内存越大(内存一定,单个线程的内存越大,线程可创建数量越少),反而越容易产生内存溢出异常。

分析

操作系统分配给每个进程的内存是有限制的,譬如32位的Windows限制为2GB。虚拟机提供了参数来控制Java堆和方法区的这两部分内存的最大值。剩余的内存为2GB(操作系统限制)减去Xmx(最大堆容量),再减去MaxPermSize(最大方法区容量),程序计数器消耗内存很小,可以忽略掉。如果虚拟机进程本身耗费的内存不计算在内,剩下的内存就是虚拟机栈和本地方法栈的了。每个线程分配到的栈容量越大,可以建立的线程数量自然就越少,建立线程时就越容易把剩下的内存耗尽。

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

jdk1.6会发生常量池溢出,之后则不会。

String.intern测试

/**
*jdk1.7
**/
public class RuntimeConstantPoolOOM {
    public static void main(String[] args){
        //StringBuilder创建在堆上,intern方法在常量池中记录首次出现的示例引用
        String strl=new StringBuilder("计算机").append("软件").toString();
        System.out.println(strl.intern()==strl);
        //java已经出现过,因此常量池中的引用与堆中的不同
        String strl2=new StringBuilder("ja").append("va").toString();
        System.out.println(strl2.intern()==strl2);
    }
}

jdk1.6则返回两个false,因为常量池复制的是string的值而不是引用。

方法区用于存放Class的相关信息,对于这些区域的测试,基本的思路是运行时产生大量的类去填满方法区,直到溢出。

注意

jdk8移除了PermGen,取而代之的是MetaSpace元空间(Metaspace)意味着不会再有java.lang.OutOfMemoryError:PermGen问题,也不再需要你进行调优及监控内存空间的使用,但是新特性不能消除类加载器导致的内存泄漏。你需要使用不同的方法以及遵守新的命名约定来追踪这些问题。

本机直接溢出

/**
 * 本机直接内存溢出
 */
public class DirectMemoryOOM {
    private static final int _1MB=1024*1024;
    public static void main(String[] args) throws IllegalAccessException {
        Field unsafeField=Unsafe.class.getDeclaredFields()[0];
        unsafeField.setAccessible(true);
        Unsafe unsafe= (Unsafe) unsafeField.get(null);
        while (true){
            unsafe.allocateMemory(_1MB);
        }
    }
}

在这里插入图片描述

注意

  • DirectMemory容量可通过-XX:MaxDirectMemorySize指定,如果不指定,则默认与Java
    堆最大值(-Xmx指定)一样,虽然使用DirectByteBuffer分配内存也会抛出内存溢出异常,但它抛出异常时并没有真正向操作系统申请分配内存,而是通过计算得知内存无法分配,于是手动抛出异常,真正申请分配内存的方法是unsafe.allocateMemory()。

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

猜你喜欢

转载自blog.csdn.net/weixin_43958969/article/details/90413891
今日推荐