一文带你详细了解 JVM 运行时内存
1.程序计数器
概念
程序计数器也叫作 PC 寄存器,是一块很小的内存区域,可以看做是当前线程执行的字节码的行号指示器。字节码的解释工作就是通过改变程序计数器里面的值来获得下一条需要执行字节码的指令。
特点
Pc 寄存器表现为一块内存,功能是存放伪指令,确切的说是存放的将要执行指令的地址。
当虚拟机正在执行的是一个 native 方法时,JVM 的 PC 寄存器存储的值是 undefined。
程序计数器是线程私有的,它的生命周期和线程一样,每个线程只有一个。这也是为了保证多线程下,线程切换后能恢复到正确的执行位置,所以每个线程需要独立的程序计数器,相互隔离互不影响。
此区域是唯一一个没有 OOM 情况的区域。
图例
2.虚拟机栈
概念
JAVA 虚拟机栈的生命周期和线程相同,他也是线程私有的,每一个线程有自己独立的虚拟机栈。他用来存储栈帧,程序运行时,每一个方法被调用执行时都会创建一个栈帧,用来存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法从调用到执行完就对应着一个栈帧在虚拟机中从入栈到出栈的过程。
图例演示
栈帧
栈帧是支持虚拟机方法调用和执行的数据结构。栈帧中存储了方法的局部变量表、操作数栈、动态连接和方法返回地址等信息。每一个方法的调用和执行完成都对应着一个栈帧从虚拟机栈中入栈到出栈的过程。
设置虚拟机栈的大小
-Xss 为 JVM 启动时的每个线程分配的内存大小,也就是可以设置线程栈的大小。
局部变量表
局部变量表是一组变量值存储空间,用于存放方法的参数和方法内定义的局部变量。
操作数栈
操作数栈是一个后入先出栈(LIFO)。随着方法执行和字节码指令的执行,会从局部变量表或者对象实例的字段中复制常量或者变量写到操作数栈,再随着计算的进行会将栈中的元素出栈到局部变量表或者返回给方法调用者。
动态链接
java 虚拟机中,每一个栈帧都包含一个指向运行时常量池中该栈所属方法的符号引用,持有这个引用的目的为了支持方法调用过程中的动态链接。 动态链接的作用:将符号引用转换为直接引用。
方法返回地址
方法返回地址存放调用该方法的 PC 寄存器的值。一个方法的结束,有两种方式:正常地执行完成,出现未处理的异常非正常的退出。无论通过哪种方式退出,在方法退出后都返回到该方法被调用的位置。方法正常退出时,调用者的 PC 计数器的值作为返回地址,即调用该方法的指令的下一条指令的地址。而通过异常退出的,返回地址是要通过异常表来确定,栈帧中一般不会保存这部分信息。无论方法是否正常完成,都需要返回到方法被调用的位置,程序才能继续进行。
3.本地方法栈
概念
本地方法栈则是为虚拟机使用到的本地(Native) 方法服务,而虚拟机栈是为使用到的 java 方法服务。
关于 native 方法
native 关键字修饰的 Java 方法是一个原生态方法,方法对应的实现 Java 作用范围达不到,而是在用其他编程语言(如 C 和 C++)文件中实现。Java 语言本身不能直接对操作系统底层进行访问和操作,但可以通过 JNI 接口调用其他编程语言来实现对操作系统底层的访问。 native 方法在异地实现,类似抽象方法,不能有方法体,要以分号结束。例如:
本地方法栈特点
本地方法栈加载 nativef 方法,是为了填补 java 不方便实现的场景产生的。
虚拟机栈为为虚拟机执行 java 服务,而本地方法栈为了执行虚拟机所使用到的 native 服务
本地方法栈也是线程私有的,和线程的生命周期是一致的,每个线程都有一个本地方法栈。
4.堆
4.1 堆的总括
4.1.1 概念
Java 堆(Java Heap) 是虚拟机所管理的内存中最大的一块。 Java 堆是被所 有线程共享的一块内存区域, 在虚拟机启动时创建。 此内存区域的唯一目的就是存放对象实例, Java 世界里“几乎”所有的对象实例都在这里分配内存。
4.1.2 特点
堆是 Java 虚拟机所管理内存中最大的一块区域。
堆是线程共享的。
堆在虚拟机启动的时候创建。
堆存在的目的就是存放对象实例。
堆是垃圾回收管理的主要区域。因此堆又被称作为 GC 堆,JAVA 堆还可以细分为新生代,老年代,永久代(jdk8 以后就取消了),其中新生代又分为 Eden 空间、From survivor、To survivor。
堆在计算机物理上存储是不连续的,但是逻辑上是连续的,它的大小可以调节(-Xmx,-Xms 控制)。
方法结束后,堆对象不会马上的移除,仅仅在垃圾回收的时候才会移除。
如果堆中没有足够的内存完成对实例的分配,且堆的空间无法再扩展时,那么将会报出 OOM 异常。
4.1.3 设置堆内存大小
我们可以通过-Xms 来设置最小堆内存,通过-Xmx 设置最大堆内存。
以上是设置了:-Xms5m -Xmx20m
这里可以看出打印出来的 Xmx 值 18m 和设置的值 20m 之间是有差异的,total Memory 和最大的内存之间也还是存在比较明显的差异,就是说 JVM 一般会尽量保持内存在一个尽可能底的层面,而非贪婪做法按照最大的内存来进行分配。
另外,当我们申请分配内存 10m 时,我们会发现 free Memory 和 total Memory 都上升了,可以看出 JVM 在内存分配时是动态分配的。
4.1.4 堆的分类
JAVA 将虚拟机堆分为三个部分:
新生代 (又分为伊甸园区,幸存者区 s0 和幸存者区 s1)
老年代
永久代(JDK1.8 后没有了,被本地内存的元空间取代了)
图例如下:
4.2 新生代和老年代
4.2.1 对象存储
新生代存放刚创建的实例对象,内存比较小,垃圾回收比较频繁。新生代又分为 Eden 区,survivor To 区 S0 和 survivor From 区 S1,其中 S0 区和 S1 区并不是固定的 from 及 to 的区域,由对象转移的方向决定的,假设对象从 S1 转移到 S0,那么 S1 便是 survivor From,S0 是 survivor To。
老年代主要存放一些生命周期比较长的对象,经过在新生代几次的回收依旧没有清除掉,那这部分实例便会转移到老年代。老年代的垃圾回收相对来讲没有那么频繁。
4.2.2 配置新生代和老年代的堆中占比
默认情况下-XX:NewRatio=2,表示新生代:老年代 = 1:2,新生代占整个堆空间的 1/3
案例:假设我们将-XX:NewRatio 修改为等于 4,那么则表示新生代:老年代 = 1:4,那么新生代占整个堆空间的 1/5
除了我们可以配置新生代和老年代的比例之外,我们还可以配置 eden 和 S0 和 S1 在新生代中的占比情况,默认情况下-XX:SurvivorRatio = 8,表示 Eden:S0:S1=8:1:1,这表示 Eden 占整个新生代的 8/10,而两个 survivor 区域分别占了 1/10,另外,需要补充一点,由于 JVM 在运行时,每次都只会使用 Eden 区和一块 survivor 区进行服务,因此总是会有一个 survivor 区域是空闲着的,所以新生代的最高使用也只能达到 9/10。
4.3 对象分配过程
new 对象时首先会将对象放在 eden 区,该区大小有内存限制。
当 eden 区的数据满了之后,程序还需要创建对象,会触发垃圾回收,将那些不再被引用的对象给销毁掉。
剩余没被回收掉的对象会被转移到 S0 区,而程序新创建的对象又会继续写入 Eden 区。
当再次发生垃圾回收时,如果 S0 中还存在未被销毁的对象,那么这部分剩余的对象会被转移到 S1 中。
之后每次经历垃圾回收,存在 S0 或者 S1 中未被销毁的对象总会相互转移过去。
当这种转移达到 15 次上限后,那么这部分对象将会被转移到老年区。当然这个阈值并不是固定 15,可以通过调节参数 -XX:MaxTenuringThreshold=N 来控制阈值。
当养老区的内存也不足时,会触发 GC 进行养老区的垃圾回收。
如果养老区进行了 GC 垃圾回收后还是没有办法保存新创建的对象,那么将会报 OOM 异常。
4.4 堆 GC
Java 中的堆是虚拟机中 GC 收集垃圾的主要区域。GC 分为两种,一种是部分收集(Partial GC),一种是整堆收集(Full GC).
部分收集
新生代收集(Minor GC/Yong GC):只是新生代的垃圾回收。
老年代收集(Major GC/Old GC):只是老年代的垃圾回收。
混合收集(Mixed) :收集整个新生代和老年的垃圾。(G1 GC 会混合回收, region 区域回收)
整堆收集(Full GC):收集整个 java 堆和方法区的垃圾收集器
年轻代 GC 触发条件
当年轻代内存不足时,会触发 Minor GC,这里的内存不足指的是 Eden 区的内存不足,Survivor 区不会。
Minor GC 会暂停其他用户的线程,等到垃圾回收结束,用户的线程才恢复。
老年代 GC 触发条件
老年代空间不足时,会尝试触发 Minor Gc,如果空间还是不足,则会触发 Major GC
如果 Major GC 结束后,空间还是不足,会报 OOM 异常。
Major GC 的速度比 Minor GC 慢 10 倍以上。
Full GC 触发条件
程序调用 System.gc(),会触发 Full GC,但不会立即去执行。
老年代空间不足。
方法区空间不足。
通过 Minor GC 后仍能进入老年代的对象所占空间大于老年代剩余可用空间。
5.元空间
JDK1.8 后为什么废除永久代,引入元空间
在之前的永久代中,它是堆的一部分,主要是在存储类的元数据、静态变量、常量等,这些数据的大小也不太容易控制和计算,开发人员对永久代进行调优会有很多的难度。永久代会对 GC 带来不必要的复杂度,回收效率偏低。
而用元空间替代永久代,这样的话可以很好的解决这个问题,因为元空间是放在本地内存上的,简而言之,只要你服务器内存还有,元空间基本就不会发生内存溢出等问题。
废除永久代的好处
由于类的元数据分配在本地内存上,这样就说元空间的最大分配内存就是服务器系统剩余可用内存,不会遇到永久代时存在的内存溢出问题。
将运行时常量池从永久代中分离出来,与类的元数据分开,提高了元数据的独立性。
将元数据从永久代剥离出来放到元空间,可以提升对元数据的管理,同时也提升 GC 效率。
元空间相关参数
-XX:MetaspaceSize,初始空间大小,达到该值就会触发垃圾收集进行类型卸载,同时 GC 会对该值进行调整:如果释放了大量的空间,就适当降低该值;如果释放了很少的空间,那么在不超过 MaxMetaspaceSize 时,适当提高该值。
-XX:MaxMetaspaceSize,最大空间,默认是没有限制的。如果没有使用该参数来设置类的元数据的大小,其最大可利用空间是整个系统内存的可用空间。JVM 也可以增加本地内存空间来满足类元数据信息的存储。但是如果没有设置最大值,则可能存在 bug 导致 Metaspace 的空间在不停的扩展,会导致机器的内存不足;进而可能出现 swap 内存被耗尽;最终导致进程直接被系统直接 kill 掉。如果设置了该参数,当 Metaspace 剩余空间不足,会抛出:java.lang.OutOfMemoryError: Metaspace space。
-XX:MinMetaspaceFreeRatio,在 GC 之后,最小的 Metaspace 剩余空间容量的百分比,减少为分配空间所导致的垃圾收集。
-XX:MaxMetaspaceFreeRatio,在 GC 之后,最大的 Metaspace 剩余空间容量的百分比,减少为释放空间所导致的垃圾收集。
6.方法区
6.1 方法区的理解
概念:
元空间、永久代是方法区具体的落地实现。方法区看作是一块独立于 Java 堆的内存空间,它主要是用来存储所加载的类信息的,方法区是线程共享的。
特点:
方法区与堆一样是各个线程共享的内存区域。
方法区在 JVM 启动的时候就会被创建,并且它实际的物理内存空间和 Java 堆一样可以是不连续的。
方法区的大小跟堆一样,可以选择固定的大小或者动态变化。
方法区的大小决定了系统可以保存多少个类,如果系统定义了太多的类,导致方法区溢出,虚拟机仍然会报 OOM 异常。
关闭虚拟机就会释放方法区区域。
6.2 方法区结构
类加载器将 Class 文件加载到内存以后,将类的信息存储到方法区中。
方法区中存储的内容:
类型信息(域信息、方法信息)
运行时常量池
类型信息
这个类型的完整有效名称(全名 = 包名.类名)
这个类型直接父类的完整有效名(对于 interface 或是 java.lang. Object,都没有父类)
这个类型的修饰符( public, abstract,final 的某个子集)
这个类型直接接口的一个有序列表
域信息
域信息,即为类的属性,成员变量 JVM 必须在方法区中保存类所有的成员变量相关信息及声明顺序。域的相关信息包括:域名称、域类型、域修饰符(pυblic、private、protected、static、final、volatile、transient 的某个子集)
方法信息
方法名称方法的返回类型(或 void)
方法参数的数量和类型(按顺序)
方法的修饰符 public、private、protected、static、final、synchronized、native,、abstract 的一个子集
方法的字节码 bytecodes、操作数栈、局部变量表及大小( abstract 和 native 方法除外)
异常表( abstract 和 native 方法除外)。每个异常处理的开始位置、结束位置、代码处理在程序计数器中的偏
移地址、被捕获的异常类的常量池索引
6.3 方法区设置
方法区的大小不必是固定的,可以根据应用的需要动态调整
jdk7 及之前通过-xx:Permsize 来设置永久代初始分配空间。-XX:MaxPermsize 来设定永久代最大可分配空间。64 位的机器默认是 82M。当 JVM 加载的类信息容量超过了这个值,会报 OOM 异常:PermGen space。
jdk8 及以后元数据区大小可以使用参数 -XX:MetaspaceSize 和 -XX:MaxMetaspaceSize 指定。但是元数据区的 -XX:MaxMetaspaceSize 默认是-1 即没有限制,不设置可以使用系统剩余所有内存。如果元数据区发生溢出,虚拟机一样会抛出异常 OutOfMemoryError:Metaspace
7.运行时常量池
字节码文件中,内部包含了常量池。
方法区中,内部包含了运行时常量池。
常量池:存放了编译期间产生的各种字面量和符号引用。
运行时常量池:是常量池表在运行时的一种表现形式。
编译后的字节码文件中包含了类型信息、域信息、方法信息等。通过 ClassLoader 将字节码文件的常量池中的信息加载到内存中,存储在了方法区的运行时常量池中。
常量池,可以看做是一张表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量等类型。
如果感觉本文对你有帮助,点赞关注支持一下,想要了解更多 Java 后端,大数据,算法领域最新资讯可以关注我公众号【架构师老毕】私信 666 还可获取更多 Java 后端,大数据,算法 PDF+大厂最新面试题整理+视频精讲
评论