JVM虚拟机的内存管理

程序计数器(Program Counter Register)

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

注意点

  • 程序计数器是线程私有的,它的生命周期与线程相同,每个线程都有一个。

  • 当虚拟机正在执行的方法是一个本地( native )方法的时候,jvm 的 pc 寄存器存储的值是 undefined

  • 此内存区域是唯一一个在 Java 虚拟机规范中没有规定任何 OutOfMemoryError 情况的区域

虚拟机栈(Java Virtual Machine Stacks)

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

相关概念

  • 栈帧(stack frame):用于支持虚拟机进行方法调用和方法执行的数据结构。栈帧存储了方法的局部变量表、操作数栈、动态连接和方法返回地址等信息。
  • 局部变量表(Local Variable Table):一组变量值存储空间,用于存放方法参数方法内定义的局部变量。包括8种基本数据类型、对象引用(reference类型)和returnAddress类型(指向一条字节码指令的地址)
  • 操作数栈(Operand Stack):随着方法执行和字节码指令的执行,会从局部变量表或对象实例的字段中复制常量或变量写入到操作数栈,再随着计算的进行将栈中元素出栈到局部变量表或者返回给方法调用者。
  • 动态链接(Dynamic Linking):每个栈帧都包含一个指向运行时常量池中所属方法的符号引用,持有这个引用的目的是为了支持方法调用过程中的动态链接(Dynamic Linking),动态链接的作用就是将符号引用转换成直接引用。
  • 方法返回地址:方法返回地址存放调用该方法的PC寄存器的值。正常退出时,根据PC寄存器的值来返回到调用他的地方;异常退出的,返回地址是要通过异常表来确定,栈帧中一般不会保存这部分信息。

注意点

  • 虚拟机栈是线程私有的,它和线程同时创建,其生命周期和线程相同。
  • 虚拟机栈需要使用连续的内存空间

  • 分配虚拟机栈大小:-Xss (例:-Xss1m),为jvm启动的每个线程分配内存大小,默认JDK1.4中是256K,JDK1.5+中是1M

相关异常

  • StackOverFlowError :线程请求的栈深度 > 所允许的深度

  • OutOfMemoryError :虚拟机栈扩展时无法申请到足够的内存

本地方法栈(Native Method Stacks)

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

注意点

  • 本地方法栈是线程私有的,它和线程同时创建,其生命周期和线程相同。

相关异常

  • StackOverFlowError :线程请求的栈深度 > 所允许的深度
  • OutOfMemoryError :本地方法栈扩展时无法申请到足够的内存

堆(Java Heap)

Java堆(Java Heap)的唯一目的就是存放对象实例以及数组, Java 世界里大部分对象实例都在这里分配内存,另有小部分由于即时编译等技术的进步,让这件事情变得不绝对。此外,堆中也包含私有的线程缓冲区 Thread Local Allocation Buffer (TLAB)。

相关概念

  • 青年代Young Generation:年轻代主要存放新创建的对象,内存大小相对会比较小,垃圾回收会比较频繁。年轻代分 成1个Eden Space和2个Suvivor Space(from 和to),Eden空间和另外两个Survivor空间占比分别为8:1:1。
  • 老年代Old Generation :年老代主要存放JVM认为生命周期比较长的对象(经过几次的Young Gen的垃圾回收后年龄到达阈值),内存大小相对会比较大,垃圾回收也相对没有那么频繁,新生代 ( Young ) 与老年代 ( Old ) 的默认比例的值为 1:2。
  • 永久代Permanent Generation:(JAVA7的概念,在Java8以后,由于方法区的内存不在分配在Java堆上,而是存储于本地内存元空间Metaspace中,所以永久代就不存在了。

对象的分配

  1. new的对象先放在伊甸园区。该区域有大小限制

  2. 当伊甸园区域填满时,程序又需要创建对象,JVM的垃圾回收器将对伊甸园预期进行垃圾回收(Minor GC),将伊 甸园区域中不再被其他对象引用的额对象进行销毁,再加载新的对象放到伊甸园区

  3. 然后将伊甸园区中的剩余对象移动到幸存者0区

  4. 如果再次触发垃圾回收,此时上次幸存下来的放在幸存者0区的,如果没有回收,就会放到幸存者1区

  5. 如果再次经历垃圾回收,此时会重新返回幸存者0区,接着再去幸存者1区。

  6. 如果累计次数到达默认的15次,这会进入养老区。可以通过设置参数,调整阈值 -XX:MaxTenuringThreshold=N

  7. 养老区内存不足是,会再次出发GC,首先,会尝试触发MinorGC. 如果空间还是不足,则触发 Major GC 进行养老区的内存清理,Major GC的速度比Minor GC慢10倍以上。

  8. 如果养老区执行了Major GC后仍然没有办法进行对象的保存,就会报OOM异常.

堆GC

Java 中的堆也是 GC 收集垃圾的主要区域。

GC 分为两种:一种是部分收集器(Partial GC)另一类是整堆收集器 (Fu’ll GC)

  • 部分收集器: 不是完整收集java堆的的收集器,它又分为:
    • 新生代收集(Minor GC / Young GC): 只是新生代的垃圾收集,Eden代满会触发,Survivor满不会引发GC。YGC会引发STW(stop the world) ,暂停其他用户的线程,等垃圾回收结束,用户的线程才恢复。
    • 老年代收集 (Major GC / Old GC): 只是老年代的垃圾收集 (CMS GC 单独回收老年代)。
    • 混合收集(Mixed GC):收集整个新生代及老年代的垃圾收集 (G1 GC会混合回收, region区域回收)
  • 整堆收集(Full GC):收集整个java堆和方法区的垃圾收集器,调用System.gc() 可以触发, 不是立即执行,老年代空间不足、方法区空间不足、通过Minor GC进入老年代平均大小大于老年代可用内存等情况都会出发。

注意点

  • 堆是Java虚拟机所管理的内存中最大的一块。
  • 堆是jvm所有线程共享的,在虚拟机启动的时候创建。
  • Java堆是垃圾收集器管理的主要区域,从内存回收的角度来看,由于现在收集器基本都采用分代收集算法,所以Java堆还可以细分为:新生代和老年代,新生代又可以分为:Eden 空间、From Survivor空间、To Survivor空间。
  • 分配堆内存大小:通过-Xms和-Xmx控制,当下Java应用最大可用内存为20M, 最小内存为5M,堆大小 = 新生代 + 老年代。
  • 分配新生代和老年代的内存比例:-XX:NewRatio=2 , 标识新生代占1 , 老年代占2 ,新生代占整个堆的1/3
  • 分配 Eden 空间和 Survivor 空间:–XX:SurvivorRatio 来设定

相关异常

  • OutOfMemoryError :堆扩展时无法申请到足够的内存

元空间(MetaSpace)

在JDK1.7之前,HotSpot 虚拟机把方法区当成永久代来进行垃圾回收。从 JDK 1.8 开始,移除永久代,并把方法区移至元空间,它位于本地内存中,而不是虚拟机内存中。HotSpots取消了永久代,那么是不是也就没有方法区了 呢?当然不是,方法区是一个规范,规范没变,它就一直在,只不过取代永久代的是元空间(Metaspace)而已。

永久代与元空间的不同

  • 存储位置不同:永久代在物理上是堆的一部分,和新生代、老年代的地址是连续的,而元空间属于本地内存。

  • 存储内容不同:在原来的永久代划分中,永久代用来存放类的元数据信息、静态变量以及常量池等。 现在类的元信息存储在元空间中,静态变量和常量池等并入堆中,相当于原来的永久代中的数据,被元空间和堆内存给瓜分了。

为什么要废弃永久代,引入元空间?

  • 原因
    • 在原来的永久代划分中,永久代需要存放类的元数据、静态变量和常量等。 它的大小不容易确定,因为这其中有很多影响因素,比如类的总数,常量池的大小和方法数量等,-XX:MaxPermSize 指定太小很容易造成永久代内存溢出。
    • 移除永久代是为融合HotSpot VM与 JRockit VM而做出的努力,因为JRockit没有永久代,不需要配置永久代
    • 永久代会为GC带来不必要的复杂度,并且回收效率偏低。
  • 好处
    • 将运行时常量池从PermGen分离出来,与类的元数据分开,提升类元数据的独立性,提升对元数据的管理同时提升GC效率。
    • 由于类的元数据分配在本地内存中,元空间的最大可分配空间就是系统可用内存空间。不会遇到永久代存在时的内存溢出错误。

参数配置

  • -XX:MetaspaceSize,初始空间大小,达到该值就会触发垃圾收集进行类型卸载。同时GC会对该值进行调整:如果释放了大量的空间,就适当降低该值;如果释放了很少的空间则适当提高(不超过 MaxMetaspaceSize )。

  • -XX:MaxMetaspaceSize,最大空间,默认是没有限制的。如果没有使用该参数来设置类的元数据的大小, 其最大可利用空间是整个系统内存的可用空间。JVM也可以增加本地内存空间来满足类元数据信息的存储。

    但是如果没有设置最大值,则可能存在bug导致Metaspace的空间在不停的扩展,会导致机器的内存不足;进而可能出现swap内存被耗尽;最终导致进程直接被系统直接kill掉。 如果设置了该参数,当Metaspace剩余空间不足,会抛出异常。

  • -XX:MinMetaspaceFreeRatio,在GC之后,最小的Metaspace剩余空间容量的百分比,减少为分配空间所导致的垃圾收集。

  • -XX:MaxMetaspaceFreeRatio,在GC之后,最大的Metaspace剩余空间容量的百分比,减少为释放空间所导致的垃圾收集。

相关异常

  • OutOfMemoryError :设置了元空间的最大空间,Metaspace剩余空间不足时抛出。

方法区(Method Area)

方法区(Method Area) 与Java堆一样, 是各个线程共享的内存区域。方法区中包括类型信息(类信息、域信息、方法信息) 、运行时常量池(运行时常量池、即时编译(JIT)器编译后的代码缓存等数据)

相关概念

  • 类型信息

    对每个加载的类型(类Class、接口 interface、枚举enum、注解 annotation),JVM必须在方法区中存储以下类型信息

    • 这个类型的完整有效名称(全名 = 包名.类名)
    • 这个类型直接父类的完整有效名(对于 interface或是java.lang. Object,都没有父类)
    • 这个类型的修饰符( public, abstract,final的某个子集)
    • 这个类型直接接口的一个有序列表 域信息 域信息,即为类的属性,成员变量 JVM必须在方法区中保存类所有的成员变量相关信息及声明顺序。
  • 域信息

    • 域名称
    • 域类型
    • 域修饰符(pυblic、private、protected、static、final、volatile、transient的 某个子集)
  • 方法

    JVM必须保存所有方法的以下信息,同域信息一样包括声明顺序:

    1. 方法名称方法的返回类型(或void)

    2. 方法参数的数量和类型(按顺序)

    3. 方法的修饰符public、private、protected、static、final、synchronized、native,、abstract的一个子集

    4. 方法的字节码bytecodes、操作数栈、局部变量表及大小( abstract和native方法除外)

    5. 异常表( abstract和 native方法除外)。每个异常处理的开始位置、结束位置、代码处理在程序计数器中的偏 移地址、被捕获的异常类的常量池索引

常量池 VS 运行时常量池

  • 常量池:存放编译期间生成的各种字面量与符号引用;字节码文件中,内部包含了常量池

    一个有效的字节码文件中除了包含类的版本信息、字段、方法以及接口等描述信息外,还包含一项信息那就是常量池表( Constant pool table),包括各种字面量和对类型、域和方法的符号引用。

    常量池,可以看做是一张表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量等类型。

  • 运行时常量池:常量池表在运行时的表现形式;方法区中,内部包含了运行时常量池

  • 两者关系:编译后的字节码文件中包含了类型信息、域信息、方法信息等。通过 ClassLoader 将字节码文件的常量池中的信息加载到内存中,存储在了方法区的运行时常量池中。

直接内存(Direct Memory)

直接内存(Direct Memory) 并不是虚拟机运行时数据区的一部分。

在JDK 1.4中新加入了NIO(New Input/Output) 类, 引入了一种基于通道(Channel) 与缓冲区 (Buffer) 的I/O方 式,它可以使用Native函数库直接分配堆外内存, 然后通过一个存储在Java堆里面的 DirectByteBuffer 对象作为这块 内存的引用进行操作。 这样能在一些场景中显著提高性能, 因为避免了 在Java堆和Native堆中来回复制数据。DirectBuffer直接分配在物理内存中,并不占用堆空间。在访问普通的ByteBuffer时,系统总是会使用一个“内核缓冲区”进行操作。 而DirectBuffer所处的位置,就相当于这个“内核缓冲区”。因此,使用DirectBuffer是一种更加接近内存底层的方法, 所以它的速度比普通的ByteBuffer更快。

通过使用堆外内存,可以带来以下好处

  • 改善堆过大时垃圾回收的效率,减少STW。Full GC时会扫描堆内存,回收效率和堆大小成正比。Native的内存,由OS负责管理和回收。

  • 减少内存在Native堆和JVM堆拷贝过程,避免拷贝损耗,降低内存使用。

  • 可突破JVM内存大小限制。

注意点

  • 直接内存(Direct Memory) 的容量大小可通过-XX: MaxDirectMemorySize 参数来指定, 如果不去指定, 则默认与 Java堆最大值(由-Xmx指定) 一致,

  • 因为虽然使用 DirectByteBuffer 分配内存也会抛出内存溢出异常, 但它抛出异常时并没有真正向操作系统申请分配内存, 而是通过计算得知内存无法分配就会在代码里手动抛出溢出异常, 真正申请分配内存的方法是Unsafe::allocateMemory()

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