JVM—认识JVM的内存布局和运行时数据区

1,167 阅读12分钟

1. Java 内存区域

1.1. JVM 内存布局 与 运行时数据区

JVM 内存布局 与 运行时数据区


1.2. Heap 堆

  • 它的唯一目的就是存放对象实例;几乎所有对象实例和数组,分配内存的区域

  • 堆内存区域是线程共享区域,并发编程时需要考虑线程安全问题。

  • 可以通过-Xms256M -Xmx1024M 设置堆内存大小。

    注意: Java程序在运行中,堆空间会不断扩容与减少,会造成系统压力,所以一般设置为同样大小

    -X: 表示运行参数

    ms: 表示memory start,即起始大小

    mx: 表示memory max ,即最大内存

  • 堆分成:新生代老年代两大块,如名字一样,对象初生在新,有一例外是新生代无法接纳的超大对象会在老年代创建

  • 新生代:对象主要分配在新生代的Eden区域

    如果在新生代分配失败且对象是一个不含任何对象引用的大数组,可被直接分配到老年代。

  • 可以设置分配在老年代大对象的阈值:-XX:PretenureSizeThreshold

    默认为0不生效,意味着任何对象都会现在新生代分配内存。

  • 可以通过-Xmn256M 设置新生代区域大小为256M。此处的大小是(eden + 2 survivor space),

  • 可以通过-XX:ServivorRatio=8 决定eden与Survivor的内存空间占比为8:1

  • 长期存活的对象会进入老年代:虚拟机给每个初生对象都设置了一个age,当age>=15时就会晋升到老年代。

    当对象出现在Eden,经过YGC而存活,被移到Servivor区,此时年龄变为1。每次YGC过后,存活的对象age就会+1.直到被回收或者晋升老年代。

    另外如果在YGC中,要移动的对象大于Survivor的容量上限,则直接进入老年代。

  • 可以设置这个age的阈值:-XX:MaxTenuringThreshold,当age达到这个值就会进入到老年代。

    对象的年龄并不是必须达到了MaxTenuringThreshold才晋升老年代,如果在Survivor中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代。

  • 堆的OutOfMemoryRrror(简称OOM)如果一个新生对象或者在晋升的对象,分配的区域放不下了就会抛出OOM。

    当一个新生对象分配给Eden时,如果Eden不够,则会触发Minor GC。

    当一个对象在晋升的时候JVM发现内存空间不够,如果Survivor区中无法放下,或者是超大对象的阈值超过上限,则尝试在老年代分配,如果老年代也无法分配,则触发Full Garbage Collection(FGC),如果依然无法放下,则抛出OOM。

    要分析OOM我们可以使用-XX:+HeapDumpOnOutOfMemory,让JVM打印OOM信息。


1.2. 方法区Method Area(PermGen & Metaspace)

  • 方法区主要用于存放:类元信息、字段、静态属性、方法、常量、JIT编译后的代码等数据。

    永久带(PerGen)和元空间(Metaspace)分别方法区的具体实现。

  • PermGen是Hotspot中(<=JDK1.7)特有的区域,称为永久代。

    在该区域,如果动态加载过多的类,容易产生Perm的OOM。java.lang.OutOfMemory: PermGen space 错误。

    上述错误可以通过设置-XX:PermSize=1024M解决。

    另外还可以设置-XX:MaxPermSize=1024m 最大永久代大小。 默认是64M

    但是JDK8及以后,由于用元空间替换了PermGen所以在JDK8及以后的版本中HotSpot会提示:Java Hotspot 64Bit Server VM warning ignoring option MaxPermSize=1024M; support was removed in 8.0。

  • Metaspace是为了解决永久带的缺陷而优化设计的新实现,它分配内存在本地内存,并且它把以前Perm中的字符串常量全部移到了堆内存。而其他的包括类元信息、字段、静态属性、方法、常量等移到了元空间。其实在1.7的某个版本就已经把字符串常量移到了堆内存中。

    大部分类元数据都在本地内存中分配。用于描述类元数据的“klasses”已经被移除。默认情况下,类元数据只受可用的本地内存限制。可以通过-XX:MaxDirectMemorySize=50m设置直接内存。

    因为是本地内存中存储,所以如果程序存在内存泄露,不停的扩展Metaspace的空间,会导致机器的内存不足,所以还是要有必要的调试和监控。

  • Metaspace可以通过-XX:MetaspaceSize=10m-XX:MaxMetaspaceSize=50m 设置初始空间大小和最大空间


1.3. 虚拟机栈 JVM Stack

JVM Stack

  • Stack 是一个先进后出的数据结构。JVM中的栈是描述Java方法执行的内存区域,它是线程私有的。每个方法从开始调用到结束调用就是栈帧从入栈到出栈的结果。

  • 活动线程中,只有栈顶的栈帧才是有效的,称为当前栈帧。正在执行的方法称为当前方法,栈帧是方法运行的基本结构。在执行引擎运行时,所有指令都只能针对当前栈帧操作。

  • 栈帧(Stack Frame)用于存储局部变量表、操作栈、动态链接、方法返回地址等信息。

    局部变量表:存放方法参数,编译期可知的基本数据类型、对象引用类型(reference)和returnAddress类型(指向一条字节码指令地址)。局部变量表所需的内存空间是在编译期确定,方法在局部变量表中分配多少空间是完全确定的。在运行期间不会改变局部变量表的大小。局部变量没有准备阶段,必须显示初始化。

    操作栈是一个初始状态为空的桶式结构栈。方法执行过程中,会有各种指令往栈写入和提取信息。JVM的执行引擎就是基于操作栈的执行引擎。

    动态连接: 在Class文件中的常量持中存有大量的符号引用。字节码中的方法调用指令就以常量池中指向方法的符号引用作为参数。这些符号引用一部分在类的加载阶段或第一次使用的时候就转化为了直接引用,称为静态链接。而相反的,另一部分在运行期间转化为直接引用,就称为动态链接

    方法返回地址:方法执行时有两种退出情况:一是正常退出,正常执行到方法的返回字节码指令;二是异常退出。两种退出都会返回当前被调用的位置。方法退出相当于弹出当前栈帧,退出的方式有三种:1. 返回值压入上层调用栈帧。2. 异常信息抛给能够处理的栈帧。3. PC计数器指向方法调用后的下一条指令。

  • StackOverflowError:当栈深度超过虚拟机分配给线程的栈大小时就会出现此error。

    最常见的就是递归深度超出了限定,然后抛出这个错误

  • OutOfMemoryError:虚拟机扩展时无法申请到足够的内存空间,多线程下的内存溢出,与栈空间是否足够大并不存在任何联系。

    为每个线程的栈分配的内存越大(参数-Xss),那么可以建立的线程数量就越少,建立线程时就越容易把剩下的内存耗尽,越容易内存溢出。

  • 可以通过-Xss2m设置栈内存大小,设置每个线程的栈内存,默认1M,一般来说是不需要改的。-XX:ThreadStackSize线程堆栈大小

    如果把-Xss或者-XX:ThreadStackSize设为0,就是使用“系统默认值”。而在Linux x64上HotSpot VM给Java栈定义的“系统默认”大小也是1MB。

    JDK1.6以前,谁设置在后面,谁就生效;JDK1.6以后,-Xss设置在后面,则以-Xss为准,-XXThreadStackSize设置在后面,则主线程-Xss为准,其它线程以-XX:ThreadStackSize为准。


1.4. 本地方法栈 Native Method Stacks

  • 本地方法栈为Native方法服务

  • 本地方法通过JNI(Java Native Interface)来访问虚拟机运行时的数据区,甚至是调用寄存器,具有和JVM相同的能力和权限。

  • 本地方法栈也会抛出:OutOfMemoryError和StackOverflowError

  • JNI

    JNI深度使用操作系统的特性功能。复用非Java代码。如果大量使用其他语言来实现JNI,会失去跨平台特性。

    如果对执行效率要求高,偏底层的跨进程的操作等,可以考虑设计为JNI调用方式。


1.5. 程序计数器 Program Counter Register

  • 每个线程创建后都会产生自己的程序计数器和栈帧,程序计数器用来存放执行指令的偏移量和行号指示器等,线程执行或恢复都依赖程序计数器。
  • 程序计数器是线程独占,在各个线程直接互不影响,在此区域也不会有内存溢出异常。
  • 线程如果在执行一个Java方法则记录虚拟机字节码指令的地址,如果代码执行到了Native方法计数器就为undefined。

1.6. 直接内存 Direct Memory

  • 直接内存,即本机使用的堆外的系统内存。该部分内存可被JVM使用,不会被JVM堆内存限制,但是动态拓展时也会出现OutOfMemory,可用-XX:MaxDirectMemorySize=50m来限制使用内存空间的最大值最大值

  • DirectByteBuffer可以直接操作DirectMemory,它通过JNI调用native方法直接分配堆外内存,通过DirectByteBuffer对象对这块内存对象进行操作

    这个调用,实际上是从系统的用户态切换到了内核态使用系统调用来完成这个操作。

    为什么要切换到内核态?用户态没有权限去操作内核态的资源,它只能通过系统调用外完成用户态到内核态的切换,然后在完成相关操作后再有内核态切换回用户态。

    DirectByteBuffer该类本身还是位于Java内存模型的堆中。堆内内存是JVM可以直接管控、操纵。

    由于DirectByteBuffer的权限修饰符是空的也就是默认的,所以在我们编程中是无法直接new,只允许同包创建,我们可以通过ByteBuffer中的静态方法allocateDirect(int)方法来创建对象。

    public static ByteBuffer allocateDirect(int capacity) {
            return new DirectByteBuffer(capacity);
    }
    

    而 DirectByteBuffer 类中调用了native的unsafe.allocateMemory(size)来分配空间,实际上是使用了c语言的malloc方法。

    // Primary constructor
    //
    DirectByteBuffer(int cap) {                   // package-private
    
        super(-1, 0, cap, cap);
        boolean pa = VM.isDirectMemoryPageAligned();
        int ps = Bits.pageSize();
        long size = Math.max(1L, (long)cap + (pa ? ps : 0));
        Bits.reserveMemory(size, cap);
    
        long base = 0;
        try { // 这里是重点!!!掉黑板
            base = unsafe.allocateMemory(size);
        } catch (OutOfMemoryError x) {
            Bits.unreserveMemory(size, cap);
            throw x;
        }
        unsafe.setMemory(base, size, (byte) 0);
        if (pa && (base % ps != 0)) {
            // Round up to page boundary
            address = base + ps - (base & (ps - 1));
        } else {
            address = base;
        } // 这里记录分配空间的信息。
        cleaner = Cleaner.create(this, new Deallocator(base, size, cap));
        att = null;
    }
    
        // 记录分分配空间信息的类
        private Deallocator(long address, long size, int capacity) {
            assert (address != 0);
            this.address = address;
            this.size = size;
            this.capacity = capacity;
        }
    

2. 对象创建与内存分配

2.1 对象创建

  • 对象使用new创建的简单过程

创建对象过程

  • 指针碰撞:

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

  • 空闲列表

    如果Java堆中的内存并不是规整的,已使用的和空闲的内存相互交错,就无法进行指针碰撞了,JVM就必须维护一个列表,记录可用内存区域,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式称为“空闲列表”(Free List) 【CMS这种基于Mark-Sweep算法的使用空闲列表】

  • 不难想到,分配内存时如果多个线程同时创建对象,就会出现并发问题。JVM实际采用:

    一种是CAS(Compare And Swap)加上失败重试机制来保证更新操作的原子性;

    另一种是本地线程缓冲(TLAB,Thread Local Allocation Buffer.),即把内存分配的动作按照线程划分在不同的空间之中进行,每个线程都预先分配一小块内存。线程在自己的TLAB中分配,只有TLAB用完才需要同步加锁。虚拟机是否用TLAB,可以通过-XX:+/-UseTLAB参数设定。


2.2 对象内存

  • 对象头(Header)包含两部分:一是自身运行时数据;二是类型指针

    运行时数据: 32位和64位JVM分别对应32位和64位长度(未开启指正压缩),存储包括:哈希码、GC分带年龄、锁状态标志、线程池持有锁、偏向锁ID、偏向时间戳等。(Mark Word)。

    类型指针: 即对象指向它的类元数据的指针,虚拟机通过这个指针确定是哪个对象的实例。查找对象的元数据信息不一定要经过对象本身。对象是Java数组,则对象头中则会有一块记录数组长度的数据;普通Java类可以通过元数据信息确定Java类大小,但数组还需要需要对象头中的长度数据才能确定。

  • 实例数据(Instance Data)

    就是对象存储的真正的有效信息,也就是程序代码中定义的所有字段内容。

  • 对齐填充(Padding)

    因为HotSpot VM的自动内存管理系统要求对象起始地址必须是8字节的整数倍,对象头部分正好是8字节的倍数,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。


2.3 对象访问

  • Java通过栈上的reference数据来操作堆上的具体对象,而reference是一个指向对象的引用,通过reference去定位和访问对象,目前主流的使用两种方式:一是使用句柄,二是使用直接指针

    句柄: JVM堆会专门划分内存作为句柄池,而reference中存的就是对象的句柄地址;句柄中包含了对象实例数据与类型数据各自的具体地址。

    句柄

    直接指针: 如果是直接指针,Java堆中就会防止访问类型数据相关的信息。而reference中存储的直接就是对象地址。

    直接指针