深入理解Java虚拟机(一)——运行时数据区

引言

JVMJava虚拟机,是一种规范,它遵循着冯诺依曼体系结构的设计原理。在冯诺依曼体系结构中指出,计算机处理的数据和指令都是二进制数,采用存储程序方式,不加区分的存储在同一个存储器里,并且顺序执行。

指令由操作码和地址码组成,操作码决定了操作类型和所操作数的数字类型,地址码则指出地址码和操作数。不同的操作系统指令集以及数据结构都有着差异,而 JVM通过在操作系统上建立虚拟机,自己定义出来的一套统一的数据结构和操作指令,把同一套语言翻译给各大主流的操作系统,实现了跨平台运行,可以说 JVMJava的核心,是 Java可以一次编译到处运行的本质所在。

一、JVM组成

JVM有多种实现,例如 OracleJVMHPJVMIBMJVM等,本文使用广泛的HotSpot JVM

1. JVM在JDK中的位置

众所周知,JDKJava开发的必备工具箱,JDK其中有一部分是 JREJREJAVA运行环境,JVM则是 JRE最核心的部分,下面是一张关于JDK Standard Edtion的组成图。
这里写图片描述

从最底层的位置可以看出JVM有多重要,而实际项目中JAVA应用的性能优化,OOM等异常处理最终都得从JVM这儿来解决。在命令行,我们可以通过java -version命令可以查看关于当前机器JVM的信息,下面是在本人Win7系统上执行命令的截图。

这里写图片描述

2. JVM的组成

JVM由4大部分组成:ClassLoaderRuntime Data AreaExecution EngineNative InterfaceJVM组成结构图如下:
这里写图片描述

  • ClassLoader是类加载器,是负责加载class文件,class文件在文件开头有特定的文件标识,ClassLoader只负责class文件的加载,至于它是否可以运行,则由Execution Engine决定

  • Native Interface是负责调用本地接口。它的作用是调用不同语言的接口给JAVA程序用,它会在Native Method Stack中记录对应的本地方法,然后调用该方法时就通过Execution Engine加载对应的本地lib

    现在很少使用,因为即使要和底层C或C++接口进行交互,可以用WebService进行,完全没必要用JNI

  • Execution Engine是执行引擎,Class文件被加载后,会把指令和数据信息放入内存中,Execution Engine则负责把这些命令解释给操作系统。

  • Runtime Data Area是运行时数据区,用于存放数据,分为五部分:Stack(栈),Heap(堆),Method Area(方法区),PC Register(寄存器),Native Method Stack。几乎所有的关于Java内存方面的问题,都是集中在这块。下图是关于运行时数据区的描述:
    这里写图片描述

上半部分的框表示线程私有、下半部分的框表示线程共享

三、Runtime Data Area

这里写图片描述

内存中的栈与堆:

栈是运行时的单位,而堆是存储的单位

栈解决程序的运行问题,即程序如何执行,或者说如何处理数据。堆解决的是数据的存储问题,即数据怎么放,放在哪儿。

1. 栈(线程私有)

1. 栈的特点

  • 栈是一种快速有效的分配存储方式,访问速度仅次于程序计数器

  • JVM直接对Java栈的操作只有两个:

    • 每个方法执行,伴随着进栈(入栈、压栈)
    • 执行结束后的出战操作
  • 对于栈来说不存在垃圾回收问题:GC、OOM

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-wpNxgaAp-1640077389752)在这里插入图片描述

2. 基本内容

  • Stack表示Java栈内存,在Java中,每个线程都拥有自己的栈,即栈为线程私有,其生命周期与线程相同

  • 栈里面存储的是栈帧StackFrame,结构见下图

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

  • 局部变量表存放了编译期可知的各种基本数据类型(booleanbytecharshortintfloatlongdouble)、对象引用(reference类型)

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

3. 栈的异常

  • Java虚拟机规范允许Java栈的大小是动态的或者是固定不变的
  • 如果采用固定大小的Java虚拟机栈,那每一个线程的Java虚拟机栈容量可以在线程创建的时候独立选定。如果线程请求分配的栈容量超过Java虚拟机栈允许的最大容量,Java虚拟机将会抛出一个 StackOverflowError异常
  • 如果Java虚拟机栈可以动态扩展,并且在尝试扩展的时候无法申请到足够的内存,或者在创建新的线程时没有足够的内存去创建对应的虚拟机核那Java虚拟机将会抛出一个OutofMemoryError异常

4. 设置大小

可以使用参数-Xss选项来设置线程的最大栈空间,栈的大小直接决定了函数调用的最大可达深度

5. 运行原理

  • 不同线程中所包含的栈帧是不允许存在相互可用的,即不可能在一个栈帧之中引用另外一个线程的栈帧。
  • 如果当前方法调用了其他方法,方法返回之际,当前栈帧会传回此方法的执行结果给前一个栈帧,接着,虚拟机会丢弃当前栈帧,使得前一个栈帧重新成为当前栈帧。
  • Java方法有两种返回函数的方式,一种是正常的函数返回,使用return指令,另外一种是抛出异常。不管使用哪种方式,都会导致栈帧被弹出

6. 栈的内部结构

栈里面存储的是栈帧StackFrame,每个栈帧中存储着:

  • 局部变量表Local variables
  • 操作数栈operand stack
  • 动态链接DynamicLinking或指向运行时常量池的方法引用
  • 方法返回地址ReturnAddress或方法正常退出或者异常退出的定义
  • 一些附加信息

在这里插入图片描述

7. 栈的代码追踪

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

2. 堆(线程共享,GC最频繁)

1. 堆的细分

  • 在Java7及以前,堆内存逻辑上分为三个部分:新生区+养老区+永久代

    新生区:Young Generation Space Young/New

    新生区又被划分为Eden区和Survivor区

    养老区:Tenure Generation Space Old/Tenure

    永久代:Permanent Space Perm

  • 在Java8及之后,堆内存逻辑上分为三个部分:新生区+养老区+元空间

    新生区:Young Generation Space Young/New

    新生区又被划分为Eden区和Survivor区

    养老区:Tenure Generation Space Old/Tenure

    元空间:Meta Space Meta

无论在什么地方看到,下面的命名是等价的:

新生区=新生代=年轻代

养老区=老年区=老年代

永久区=永久代

这里写图片描述

2. 基本内容

  • Java堆是Java虚拟机管理内存中最大的一块,是所有线程共享的内存区域,随虚拟机的启动而创建。该区域唯一目的是存放对象实例,几乎所有对象的实例都在堆里面分配。Java堆是垃圾收集器管理的主要区域,被称GC堆

  • Java虚拟机规范规定,Java堆可以处于物理上不连续的内存空间中,只要逻辑上连续即可。如同磁盘空间一样,既可以实现成固定大小,也可以是扩展的,当前主流虚拟机都是按照扩展来实现的,通过 -Xms 和 -Xmx 控制

  • Java虚拟机规范中对该区域规定了 OutOfMemoryError异常:如果堆中没有内存完成实例分配,并且堆无法再扩展则抛出OutOfMemoryError异常

一个JVM实例只存在一个堆内存,堆内存的大小是可以调节的

类加载器读取了类文件后,需要把类、方法、常变量放到堆内存中,保存所有引用类型的真实信息,以方便执行器执行

新生区介绍

  • 新生区是类的诞生、成长、消亡的区域,一个类在这里产生、应用,最后被垃圾回收器收集,结束生命

  • 新生区又分为两部分:伊甸区(Eden space)和幸存者区(Survivor space),所有的类都是在伊甸区被new出来的

  • 幸存区有两个:0区(Survivor 0 space)和1区(Survivor 1 space

  • EdenSurvivor0Survivor1的比例是:8:1:1

  • 新生区的大小站堆内存的三分之一

解释:

当Eden区的空间用完时,程序又需要创建对象,JVM的垃圾回收器将对Eden区进行垃圾回收(Minor GC),将伊甸园区中不再被其他对象所引用的对象进行销毁。

然后将伊甸园中的剩余对象移动到幸存0区(也叫From区),若幸存0区也满了,再对该区进行垃圾回收,然后移动到1区(也叫To区)。那如果1区也满了呢?

再移动到养老区,若养老区也满了,那么这个时候将产生MajorGC(Full GC),进行养老区的内存清理, 若养老区执行了Full GC之后发现依然无法进行对象的保存,就会产生OOM异常“OutOfMemoryError”

3. 设置大小

  • 堆的大小在JVM启动时就已经设定好了,可以通过选项-Xms-Xmx来进行设置

    • -Xms用于表示堆区的起始内存,等价-XX:InitialHeapSize

    • -Xmx则用于表示堆区的最大内存,等价于-XX:MaxHeapSize

  • 一旦堆区中的内存大小超过-Xmx所指定的最大内存时,将会抛出OutOfMemoryError异常

  • 通常会将-Xms-Xmx两个参数配置相同的值,其目的是为了能够在Java垃圾回收机制清理完堆区后不需要重新分隔计算堆区的大小,从而提高性能

  • 默认情况下,初始内存大小:物理电脑内存大小1/64
    最大内存大小:物理电脑内存大小1/4

3. PC寄存器(线程私有)

PC Register是程序计数寄存器,每个JAVA线程都有一个单独的PC Register,它表示一个指针,由Execution Engine读取下一条指令。如果该线程正在执行Java方法,则PC Register存储的是正在被执行的指令的地址,如果是本地方法,PC Register的值没有定义。PC寄存器非常小,只占用一个字宽,可以持有一个returnAdress或者特定平台的一个指针。

关于PC寄存器,有两个问题,其实就是一个问题:

  • 使用PC寄存器存储字节码指令地址有什么用呢?为什么使用PC寄存器记录当前线程的执行地址呢?

答:因为CPU需要不停的切换各个线程,这时候切换回来以后,就得知道接着从哪开始继续执行。JVM的字节码解释器就需要通过改变PC寄存器的值来明确下一条应该执行什么样的字节码指令。

  • PC寄存器为什么会被设定为线程私有?

答:我们都知道所谓的多线程在一个特定的时间段内只会执行其中某一个线程的方法,CPU会不停地做任务切换,这样必然导致经常中断或恢复,如何保证分毫无差呢?为了能准确地记录各个线程正在执行的当前字节码指令地址,最好的办法自然是为每一个线都分配一个PC寄存器,这样一来各个线程之间便可以进行独立计算,从而不会出现相干扰的情况。

由于CPU时间片轮限制,众多线程在并发执行过程中,任何一个确定的时刻,一个处理器或者多核处理器中的一个内核,只会执行某个线程中的一条指令。

这样必然导致经常中断或恢复,如何保证分毫无差呢?每个线程在创建后,都会产生己的程序计数器和栈帧,程序计数器在各个线程之间互不影响。

4. 方法区:(Non-heap,所谓的永久代,线程共享)

1. 栈、堆、方法区交互关系

在这里插入图片描述

2. 基本介绍

  • 方法区看作是一块独立于Java堆的内存空间

  • 方法区是各个线程共享的内存区域,用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据Java虚拟机对这个区域的限制非常宽松,处理和Java堆一样不需要连续的内存和可以选择固定大小或者可扩展外,还可以选择不实现垃圾收集。相对而言,垃圾收集在这个区域较少出现

  • Java虚拟机规范中对该区域规定了OutOfMemoryError异常: 如果方法区的内存空间不能满足内存分配请求,那Java虚拟机将抛出一个OutOfMemoryError异常

  • 运行时常量池是方法区的一部分

  • 方法区保存装载的类信息:类型的常量池、字段、方法、方法字节码、通常和永久区(Perm)关联在一起

  • 运行时常量池

  • Class文件中除了有类的版本、字段、方法、接口等信息外,还有一项信息是常量池,用于存放编译期生成的各种字面常量和符号引用,这部分内容在类加载后存放到方法区的常量池中
  • Java虚拟机规范中对该区域规定了OutOfMemoryError异常:当常量池无法申请到内存时抛出OutOfMemoryError异常

3. JDK7和JDK8中的变化

在这里插入图片描述

  • 元空间的本质和永久代类似,都是对JVM规范中方法区的实现。不过元空间与永久代最大的区别在于:元空间不在虚拟机设置的内存中,而是使用本地内存

  • 永久代、元空间二者并不只是名字变了,内部结构做了方法区的大小不必是固定的,jvm可以根据应用的需要动态调整。

  • 设置方法区内存大小,Jdk7及以前:

  • -XX:PermSize来设置永久代初始分配空间,默认值是20.75M

  • -XX:MaxPermSize来设定永久代最大可分配空间。32位机器默认是64M64位机器模式是82M

  • Jdk8及以后,元数据区大小设置:

    • -XX:MetaspaceSize

    • -XX:MaxMetaspaceSize

      默认值依赖于平台。windows下,-XX:MetaspaceSize是21M,- XX:MaxMetaspaceSize 的值是-1,即没有限制。

5. 本地方法栈

Native Method Stack是供本地方法(非java)使用的栈,每个线程持有一个Native Method Stack。

三、JVM运行原理

1. 执行流程

Java 程序被 javac 工具编译为 .class 字节码文件之后,我们执行 java 命令,该 class 文件便被 JVM 的 Class Loader 加载,可以看出JVM的启动是通过JAVA Path下的 java.exe 进行的。

JVM的初始化、运行到结束大概包括这么几步:

  • 调用操作系统API判断系统的CPU架构,根据对应CPU类型寻找位于JRE目录下的/lib/jvm.cfg文件
  • 然后通过该配置文件找到对应的 jvm.dll 文件(如果我们参数中有-server或者-client, 则加载对应参数所指定的 jvm.dll,启动指定类型的JVM),初始化jvm.dll并且挂接到 JNIENV 结构的实例上,之后就可以通过JNIENV实例装载并且处理class文件了
  • class文件是字节码文件,它按照JVM的规范,定义了变量,方法等的详细信息,JVM管理并且分配对应的内存来执行程序,同时管理垃圾回收,直到程序结束,一种情况是JVM的所有非守护线程停止,一种情况是程序调用 System.exit(),JVM的生命周期也结束。

2. 栈的管理

JVM允许栈的大小是固定的或者是动态变化的。在Oracle的关于参数设置的官方文档中有关于Stack的设置,是通过 -Xss 来设置其大小。关于Stack的默认大小对于不同机器有不同的大小,并且不同厂商或者版本号的 jvm 的实现其大小也不同,如下表是HotSpot的默认大小:

TablesAre
Windows IA3264 KB
Linux IA32128 KB
Windows x86_64128 KB
Linux x86_64256 KB
Windows IA64320 KB
Linux IA641024 KB
Solaris Sparc512 KB

一般通过减少常量,参数的个数来减少栈的增长。在程序设计时,我们把一些常量定义到一个对象中,然后来引用它们可以体现这一点,另外,少用递归调用也可以减少栈的占用。
栈是不需要垃圾回收的(线程私有),尽管说垃圾回收是Java内存管理的一个很热的话题,栈中的对象如果用垃圾回收的观点来看,它永远是live状态,是可以reachable的,所以也不需要回收,它占有的空间随着Thread的结束而释放。

关于栈一般会发生以下两种异常:

  • 当线程中的计算所需要的栈超过所允许大小时,会抛出StackOverflowError
  • 当Java栈试图扩展时,没有足够的存储器来实现扩展,JVM会报OutOfMemoryError

3. 堆的管理

1. 堆的基本介绍

堆的管理要比栈管理复杂的多
这里写图片描述

上图是 Heap堆和 PermanentSapce的组合图。其中 Eden区里面存着是新生的对象,S0S1中存放着是每次垃圾回收后存活下来的对象 所以每次垃圾回收后,Eden区会被清空。存活下来的对象先是放到 S0,当 S0满了之后移动到 S1。当S1满了之后移动到 Old Space

Survivor的两个区是对称的,没先后关系,所以同一个区中可能同时存在从 Eden复制过来对象,和从前一个Survivor复制过来的对象,而复制到老年区的只有从第一个Survivor复制过来的对象。而且,Survivor区总有一个是空的。同时,根据程序需要,Survivor区是可以配置为多个的(多于两个),这样可以增加对象在年轻代中的存在时间,减少被放到老年代的可能。

Old Space 中则存放生命周期比较长的对象,而且有些比较大的新生对象也放在Old Space中。

新生代与老年代:

  • 通过 -Xmn 来指定Young Generation的大小,一些老版本也用-XX:NewSize指定,即上图中的 EdenS0S1的总大小
  • 通过-XX:NewRatio来指定Eden区的大小,在 XmsXmx相等的情况下,该参数不需要设置。通过-XX:SurvivorRatio 来设置 Eden和 一个Survivor区的比值
  • 配置新生代与老年代在堆结构的占比:
    • 默认-XX:NewRatio=2,表示新生代占1,老年代占2,新生代占整个堆的1/3
    • 可以修改-XX:NewRatio=4,表示新生代占1,老年代占4,新生代占整个堆的1/5
  • HotSpot中,Eden空间和另外两个Survivor的空间缺省默认所占的比列是8:1:1,可以通过手动配置选项-XX:SurvivorRatio调整整个空间比列

在这里插入图片描述

2. 对象分配过程

  • new的对象先放Eden区,此区有大小限制
  • Eden的空间填满时,程序又需要创建对象,JVM的垃圾回收器将对Eden区进行垃圾回收MinorGC,将Eden区中的不再被其他对象所引用的对象进行销毁,再加载新的对象放到Eden
  • 然后将Eden中的剩余对象移动到S0
  • 如果再次触发垃圾回收,此时上次幸存下来的放到S0区的,如果没有回收,就会放到S1
  • 如果再次经历垃圾回收,此时会重新放回S0区,接着再去S1区。
  • 啥时候能去养老区呢?可以设置次数,默认是15次。可以设置参数:-XX:MaxTenuringThreshold=10进行设置
  • 在养老区,相对悠闲。当养老区内存不足时,再次触发GC:Major GC,进行养老区的内存清理
  • 若养老区执行了Major GCGC之后发现依然无法进行对象的保存,就会产生OOM异常

3. 堆的异常

堆异常分为两种,

  • 一种是Out of Memory(OOM)
  • 一种是Memory Leak(ML)Memory Leak最终将导致OOM

实际应用中表现为:从Console看,内存监控曲线一直在顶部,程序响应慢;从线程看,大部分的线程在进行GC,占用比较多的CPU,最终程序异常终止,报OOMOOM发生的时间不定,有短的一个小时,有长的10天一个月的。关于异常的处理,确定OOM/ML异常后,一定要注意保护现场,可以dump heap,如果没有现场则开启GCFlag收集垃圾回收日志,然后进行分析,确定问题所在。如果问题不是ML的话,一般通过增加Heap,增加物理内存来解决问题,是的话,就修改程序逻辑。

4. MinorGC、MajorGC和FullGC

JVM在进行GC时,并非每次都对上面三个内存(新生代、老年代、方法区)区域一起回收的,大部分时候回收的都是指新生代。
针对HotSpotVM的实现,它里面的GC按照回收区域又分为两大种类型:一种是部分收集PartialGC,一种是整堆收集FullGC

  • 部分收集:不是完整收集整个Java堆的垃圾收集。其中又分为:

    • 新生代收集(MinorGC/YoungGC):只是新生代(Eden\S0,S1)的垃圾收集

    • 老年代收集(MajorGC/OldGC):只是老年代的垃圾收集,目前,只有CMSGC会有单独收集老年代行为

      注意,很多时候Maior GC会和FullGC混淆使用,需要具体分辨是老年代回收还是整堆回收。

    • 混合收集(MixedGC):收集整个新生代以及部分老年代的垃圾收集。目前,只有G1GC会有这种行为

  • 整堆收集(FullGC):收集整个Java堆和方法区的垃圾收集

5. 年轻代GC(MinorGC)触发机制

  • 当年轻代空间不足时,就会触发MinorGC,这里的年轻代满指的 Eden代满,Survivor满不会引发GC。每次MinorGC会清理年轻代的内存
  • 因为Java对象大多都具备朝生夕灭的特性,所以MinorGC非常频繁,一般回收速度也比较快,这一定义既清晰又易于理解
  • MinorGC会引发STW,暂停其它用户的线程,等垃圾回收结束,用户线程才恢复运行

6. 老年代GC(MajorGC/FullGC)触发机制

  • 指发生在老年代的GC,对象从老年代消失时,我们说MajorGCFu11 GC发生了

  • 出现了MajorGC,经常会伴随至少一次的MinorGC,但非绝对的,在Parallel Scavenge收集器的收集策略里就有直接进行MajorGC的策略选择过程

    也就是在老年代空间不足时,会先尝试触发MinorGC。如果之后空间 还不足, 则触发MajorGC

  • MajorGC的速度一般会比MinorGC10倍以上,STW的时间更长

  • 如果MajorGC后,内存还不足,就报OOM

7. FullGC触发机制

触发FullGC执行的情况有如下五种:

  • 调用System.gc()时,系统建议执行FullGC,但是不必然执行

  • 老年代空间不足

  • 方法区空间不足

  • 通过MinorGC后进入老年代的平均大小大于老年代的可用内存

  • Eden区、survivor space0(From Space)区向survivor space1( To Space)区复制时,对象大小大于ToSpace可用内存,则把该对象转存到老年代老年代的可用内存小于该对象大小

    说明:Full gc是开发或调优中尽量要避免的

8. 堆空间参数设置

  • -XX:+PrintFlagsInitial:查看所有的参数的默认初始值

  • -XX:+PrintFlagsFinal:查看所有的参数的最终值(可能会存在修改,不再是初始值) I

  • -Xms:初始堆空间内存(默认为物理内存的1/64)

  • -Xmx:最大堆空间内存(默认为物理内存的1/4)

  • -Xmn:设置新生代的大小。(初始值及最大值)

  • -XX:NewRatio:配置新生代与老年代在堆结构的占比

  • xx:SurvivorRatio:设置新生代中EdenS0/S1空间的比例

  • -XX:MaxTenuringThreshold:设置新生代垃圾的最大年龄

  • XX:+PrintGCDetails:输出详细的GC处理日志

    打印gc简要信息:-xx:+PrintGC -verbose:gc

  • XX:HandlePromotionFailure:是否设置空间分配担保

  • 6
    点赞
  • 20
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

止步前行

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值