深入理解Java虚拟机—Java虚拟机内存

之前的内容:

上一篇:深入理解Java虚拟机—Java历史以及Java虚拟机历史

下一篇:深入理解Java虚拟机—垃圾收集算法


一.运行时数据区分为五个区域

1. 程序计数器

他可以看作是当前线程所执行的字节码的行号指示器,用来记录当前线程执行到哪行字节码的位置,为了线程切换后能恢复到正确的执行位置
特点:

  1. 线程私有
  2. 如果执行的是Java方法,他记录的是正在执行的虚拟机字节码指令的地址
  3. 如果是本地方法,则记录值为空(undefined)

2. Java虚拟机栈

特点:

  1. 线程私有
  2. 每个方法执行的时候,Java虚拟机都会同步创建一个栈帧,用于存储局部变量表,操作数栈,动态链接,,方法出口等信息
  3. 局部变量表中存放了编译器可知的各种Java虚拟机数据基本类型,对象引用,returnAddress(指向了一条字节码指令的地址)类型

3. 本地方法栈

本地方法栈与Java虚拟机栈发货的作用是十分相似的,其区别是虚拟机栈为虚拟机执行Java方法,本地方法栈则是为虚拟机用到的本地(native)方法服务

3. Java堆

堆是Java虚拟机所管理的区域里面最大的一块,几乎所有的对象都在这里分配内存,他是所有线程共享的一块区域
特点:

  1. 堆是虚拟机启动时创建,是虚拟机所管的内存中最大的一块
  2. Java的所有对象和数组,都会在与虚拟机的进程绑定的,所以他是线程共享的
  3. 内存不足时会抛出OutOfMemoryError
  4. jdk1.8中的方法区叫Meta Space(元空间)

4. 方法区

方法区和Java堆一样,是线程共享的区域,它用于存储已被虚拟机加载的类型信息,常量,静态变量,即时编译器编译后的代码缓存等数据,别名叫非堆(non-heap)
特点:

  1. 各个线程共享
  2. 当方法区内存不足时,会抛出OutOfMemoryError
  3. jdk1.7中的方法区叫Perm Space(永久代),占用的是虚拟机内存

4.1 运行时常量池

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

5. 直接内存

直接内存并不是虚拟机运行时数据区的一部分,也不是Java 虚拟机规范中农定义的内存区域。在JDK1.4 中新加入了NIO(New Input/Output)类,引入了一种基于通道(Channel)与缓冲区(Buffer)的I/O 方式,它可以使用native 函数库直接分配堆外内存,然后通脱一个存储在Java堆中的DirectByteBuffer 对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在Java堆和Native堆中来回复制数据。

二. HotSpot对象探秘

1. 对象的创建

在代码方面,对象创建通常仅仅是一个关键字new。
在虚拟机中,当遇到new时,便会进行一系列操作。

  1. 首先检查这个指令的参数是否能在运行常量池中找到一个类的符号引用。
    ——若常量池中没有该类的符号引用,则说明该类没有被定义,抛出异常。
  2. 检查这个符号代表的类是否已被加载、解析、初始化过。
    ——若没有,则先执行相应的类加载过程。
  3. 虚拟机为新生对象分配内存。(对象所需内存大小在类被加载完成后便可完全确定)
    —— 假设Java内存是完全规整的,所有用过的内存放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点指示器,那所分配内存就是把指针向空闲空间那边移动一段与对象大小相等的距离。这种分配方式被称为 “指针碰撞”
    ——如果Java堆中的内存并不规整,已使用的内存和空闲的内存相互交错,虚拟机就必须维护一个列表,记录那些内存块可用,在分配的时候在列表上找到一块足够大的空间划分给对象实例,并更新列表记录。这种分配方式被称为“空闲列表”
  4. 初始化内存空间为零,这步操作保证了对象的实例字段在Java代码中不赋初始值就能直接使用,使程序能访问到这些数据类型所对应的零值
  5. 设置对象头中的信息,例如:对象是哪个类的实例,如何才能找到类的元数据,对象的哈希码,对象的GC分代年龄等信息
  6. 新的对象产生了(即构造函数的执行),但是class文件中的<init>方法还没执行,所有的字段都是默认值
  7. 执行class文件中的<init>方法,根据代码对对象进行初始化赋值,这样一个真正可用的对象才被创建出来

2. 对象的内存布局

在这里插入图片描述

对象在堆内存里面分为三个部分 : 对象头,实例数据,对其填充

2.1 对象头

——第一部分是“Mark Word”,用于存储对象自身的运行时数据, 如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等等,这部分数据的长度在32位和64位的虚拟机(暂 不考虑开启压缩指针的场景)中分别为32个和64个Bits,官方称它为“Mark Word”。对象需要存储的运行时数据很多,其实已经超出了32、64位Bitmap结构所能记录的限度,但是对象头信息是与对象自身定义的数据无关的额 外存储成本,考虑到虚拟机的空间效率,Mark Word被设计成一个非固定的数据结构以便在极小的空间内存储尽量多的信息,它会根据对象的状态复用自己的存储空间。例如在32位的HotSpot虚拟机 中对象未被锁定的状态下,Mark Word的32个Bits空间中的25Bits用于存储对象哈希码(HashCode),4Bits用于存储对象分代年龄,2Bits用于存储锁标志 位,1Bit固定为0,在其他状态(轻量级锁定、重量级锁定、GC标记、可偏向)下对象的存储内容如下表所示:
在这里插入图片描述
但是如果对象是数组类型,则需要三个机器码,因为JVM虚拟机可以通过Java对象的元数据信息确定Java对象的大小,但是无法从数组的元数据来确认数组的大小,所以用一块来记录数组长度。

对象头信息是与对象自身定义的数据无关的额外存储成本,但是考虑到虚拟机的空间效率,Mark Word被设计成一个非固定的数据结构以便在极小的空间内存存储尽量多的数据,它会根据对象的状态复用自己的存储空间,也就是说,Mark Word会随着程序的运行发生变化,变化状态如下(32位虚拟机):
在这里插入图片描述

存储内容 标志位 状态
对象哈希码、对象分代年龄 01 未锁定
指向锁记录的指针 00 轻量级锁定
指向重量级锁的指针 10 膨胀(重量级锁定)
空,不需要记录信息 11 GC标记
偏向线程ID、偏向时间戳、对象分代年龄 01 可偏向

注意偏向锁、轻量级锁、重量级锁等都是jdk 1.6以后引入的。

在这里插入图片描述
其中轻量级锁和偏向锁是Java 6 对 synchronized 锁进行优化后新增加的,稍后我们会简要分析。这里我们主要分析一下重量级锁也就是通常说synchronized的对象锁,锁标识位为10,其中指针指向的是monitor对象(也称为管程或监视器锁)的起始地址。每个对象都存在着一个 monitor 与之关联,对象与其 monitor 之间的关系有存在多种实现方式,如monitor可以与对象一起创建销毁或当线程试图获取对象锁时自动生成,但当一个 monitor 被某个线程持有后,它便处于锁定状态。在Java虚拟机(HotSpot)中,monitor是由ObjectMonitor实现的,其主要数据结构如下(位于HotSpot虚拟机源码ObjectMonitor.hpp文件,C++实现的)

ObjectMonitor() {
    _header       = NULL;
    _count        = 0; //记录个数
    _waiters      = 0,
    _recursions   = 0;
    _object       = NULL;
    _owner        = NULL;
    _WaitSet      = NULL; //处于wait状态的线程,会被加入到_WaitSet
    _WaitSetLock  = 0 ;
    _Responsible  = NULL ;
    _succ         = NULL ;
    _cxq          = NULL ;
    FreeNext      = NULL ;
    _EntryList    = NULL ; //处于等待锁block状态的线程,会被加入到该列表
    _SpinFreq     = 0 ;
    _SpinClock    = 0 ;
    OwnerIsThread = 0 ;
  }

ObjectMonitor中有两个队列,_WaitSet 和 _EntryList,用来保存ObjectWaiter对象列表( 每个等待锁的线程都会被封装成ObjectWaiter对象),_owner指向持有ObjectMonitor对象的线程,当多个线程同时访问一段同步代码时,首先会进入 _EntryList 集合,当线程获取到对象的monitor 后进入 _Owner 区域并把monitor中的owner变量设置为当前线程同时monitor中的计数器count加1,若线程调用 wait() 方法,将释放当前持有的monitor,owner变量恢复为null,count自减1,同时该线程进入 WaitSe t集合中等待被唤醒。若当前线程执行完毕也将释放monitor(锁)并复位变量的值,以便其他线程进入获取monitor(锁)。如下图所示
在这里插入图片描述
由此看来,monitor对象存在于每个Java对象的对象头中(存储的指针的指向),synchronized锁便是通过这种方式获取锁的,也是为什么Java中任意对象可以作为锁的原因,同时也是notify/notifyAll/wait等方法存在于顶级对象Object中的原因



——对象头的另外一部分是类型指针,即是对象指向它的类的元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。并不是所有的虚拟机实现都必须在对象数据上保留类型指针,换句话说查找对象的元数据信息并不一定要经过对象本身。另外,如果对象是一个Java数组,那在对象头中还必须有一块用于记录数组长度的数据,因为虚拟机可以通过普通Java对象的元数据信息确定Java对象的大小,但是从数组的元数据中无法确定数组的大小。
以下是HotSpot虚拟机markOop.cpp中的C++代码(注释)片段,它描述了32bits下MarkWord的存储状态:

// Bit-format of an object header (most significant first, big endian layout below):  
//  
//  32 bits:  
//  --------  
//  hash:25 ------------>| age:4    biased_lock:1 lock:2 (normal object)  
//  JavaThread*:23 epoch:2 age:4    biased_lock:1 lock:2 (biased object)  
//  size:32 ------------------------------------------>| (CMS free block)  
//  PromotedObject*:29 ---------->| promo_bits:3 ----->| (CMS promoted object)
2.2 实例数据

接下来实例数据部分是对象真正存储的有效信息,也既是我们在程序代码里面所定义的各种类型的字段内容,无论是从父类继承下来的,还是在子类中定义的都需要记录下来。 这部分的存储顺序会受到虚拟机分配策略参数(FieldsAllocationStyle)和字段在Java源码中定义顺序的影响。HotSpot虚拟机 默认的分配策略为longs/doubles、ints、shorts/chars、bytes/booleans、oops(Ordinary Object Pointers),从分配策略中可以看出,相同宽度的字段总是被分配到一起。在满足这个前提条件的情况下,在父类中定义的变量会出现在子类之前。如果 CompactFields参数值为true(默认为true),那子类之中较窄的变量也可能会插入到父类变量的空隙之中

2.3 对其填充

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

3. 对象的访问定位

建立对象是为了使用对象,我们的Java程序需要通过栈上的reference数据来操作堆上的具体对象。由于reference类型在Java虚拟机规范里面只规定了是一个指向对象的引用,并没有定义这个引用应该通过什么种方式去定位、访问到堆中的对象的具体位置,对象访问方式也是取决于虚拟机实现而定的。主流的访问方式有使用句柄和直接指针两种。

——如果使用句柄访问的话,Java堆中将会划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据的具体各自的地址信息。如下图所示
  在这里插入图片描述
——如果使用直接指针访问的话,Java堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,reference中存储的直接就是对象地址,下如图所示
在这里插入图片描述

这两种对象访问方式各有优势,使用句柄来访问的最大好处就是reference中存储的是稳定句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而reference本身不需要被修改。 使用直接指针来访问最大的好处就是速度更快,它节省了一次指针定位的时间开销,由于对象访问的在Java中非常频繁,因此这类开销积小成多也是一项非常可观的执行成本。从上一部分讲解的对象内存布局可以看出,就虚拟机HotSpot而言,它是使用第二种方式进行对象访问,但在整个软件开发的范围来看,各种语言、框架中使用句柄来访问的情况也十分常见。

三. OutOfMemoryError异常(简称OOM异常)

1. 堆溢出

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

执行代码前提:Java堆大小为20MB,不可扩展(将堆的最小值-Xms参数与最大值-Xmx参数设置为一样即可避免堆自动扩展),通过参数-XX:+HeapDumpOnOutOfMemoryError可以让虚拟机在出现内存溢出异常是Dump出当前的内存堆转储快照以便事后进行分析。

   public static void main(String[] args) {
        List<OOMObject> list = new ArrayList<OOMObject>();
        while(true) {
            // list保留引用,避免Full GC 回收 
            list.add(new OOMObject());
        }
    }
    static class OOMObject {
    }

运行结果:

java.lang.OutOfMemoryError: Java heap space
Dumping heap to java_pid7768.hprof ...
Heap dump file created [27987840 bytes in 0.142 secs]

2.虚拟机栈和本地方法栈溢出

HotSpot虚拟机中并不区分虚拟机栈和本地方法栈,因此,对于HotSpot来说,虽然-Xoss参数(设置本地方法栈大小)存在,但实际上是无效的,栈容量只由-Xss参数设定。

关于虚拟机栈和本地方法栈,在Java虚拟机规范中描述了两个异常:

1.如果线程请求的栈深度大于虚拟机所允许的最大深度,将抛出StackOuerflowError异常 (递归)

 private int stackLength = 1;
    // 递归调用方法,定义大量的本地变量,增大此方法帧中本地变量表的长度
    public void stackLeak() {
        stackLength++;
        stackLeak();
    }
    public static void main(String[] args) {
        JavaVMStackSOF oom = new JavaVMStackSOF();
        try {
            oom.stackLeak();
        } catch (Throwable e) {
            System.out.println("stack length: " + oom.stackLength);
            throw e;
        }
    }

2.如果虚拟机在扩展栈时无法申请到足够的内存空间,则抛出OutOfMemoryError异常

OutOfMemoryError:多线程下的内存溢出,与栈空间是否足够大并不存在任何联系。为每个线程的栈分配的内存越大(参数-Xss),那么可以建立的线程数量就越少,建立线程时就越容易把剩下的内存耗尽,越容易内存溢出。在这种情况下,如果不能减少线程数目或者更换64位虚拟机时,减少最大堆和减少栈容量能够换区更多的线程。

注意:Windows平台虚拟机中,Java的线程映射到操作系统的内核线程上执行,下面代码执行可能造成系统假死!

 private void dontStop() {
        while(true) {

        }
    }

    // 多线程方式造成栈内存溢出 OutOfMemoryError
    public void stackLeakByThread() {
        while(true) {
            Thread thread = new Thread(new Runnable() {
                @Override
                public void run() {
                    dontStop();
                }
            });
            thread.start();
        }
    }

    public static void main(String[] args) {
        JavaVMStackOOM oom = new JavaVMStackOOM();
        oom.stackLeakByThread();
    }

3. 运行时常量池溢出

String.intern()是一个Native方法,JDK1.6时它的作用是:如果字符串常量池中已经包含一个等于此String对象的字符串,则返回代表池中这个字符串的String对象;否则,将此String对象包含的字符串添加到常量池中,并且返回此String对象的引用。
在JDK1.6及之前版本中,由于常量池分配在永久代内,我们可以通过-XX:PermSize和-XX:MaxPermSize限制方法区大小,从而间接限制其中常量池的容量

   public static void main(String[] args) {
        List<String> list = new ArrayList<String>();
        int i = 0;
        while(true) {
            // list保留引用,避免Full GC 回收 
            list.add(String.valueOf(i++).intern());
        }
    }

运行结果:

Exception in thread "main" java.lang.OutOfMemoryError: PermGen space
    at java.lang.String.intern(Native Method)
    ......

4. 方法区异常

方法区用于存放Class的相关信息,如果运行时产生大量的类去填满方法区,就可能发生方法区的内存溢出。 例如主流框架Spring、Hibernate对大量的类进行增强时,利用CGLib字节码生成动态类;大量JSP或动态JSP(JSP第一次运行时需要编译为Java类)。

下面为CGLib动态生成类导致的方法区溢出:

 public static void main(String[] args) {
        while(true) {
            Enhancer enhancer = new Enhancer();
            enhancer.setSuperclass(OOMObject.class);
            enhancer.setUseCache(false);
            enhancer.setCallback(new MethodInterceptor() {

                @Override
                public Object intercept(Object obj, Method m, Object[] objs, MethodProxy proxy) throws Throwable {
                    // TODO Auto-generated method stub
                    return proxy.invokeSuper(obj, objs);
                }
            });
            enhancer.create();
        }
    }

猜你喜欢

转载自blog.csdn.net/haiyanghan/article/details/108589458