JVM学习(一):JVM运行时数据区-堆、栈、方法区

一、概述

  • JVM是Java应用程序的运行环境,每个Java应用程序,通过main方法作为执行入口启动后,在操作系统中都会对应一个JVM进程。
  • Java应用程序在启动时,需要加载实际执行的类的类文件.class,从而获取类的字段,方法,常量等信息,在执行过程中,需要动态创建对象,在主线程或者子线程进行函数调用。
  • 而这就需要在运行时进行动态内存分配:
    • 对于类文件的内容在一块内存存放,从而可以通过指针的方式来访问引用这块内存来获取类的方法,字段,常量等信息,这块内存区域在JVM规范中就是方法区;
    • 对于动态创建的对象,需要一块内存来存放所有这些对象并通过对象引用来访问和引用该内存区域的对象,这块内存区域就是堆。同时在堆内存空间不够用时,JVM会自动进行垃圾对象的销毁回收;
    • 对应函数调用,结合操作系统的知识可知,需要每个函数调用都对应自身独立的函数调用栈,在该函数调用栈中存放被调用方法的参数,局部变量,返回值和返回调用函数的地址等,所以需要一块内存区域来作为函数调用栈,这块内存区间就是栈。每个线程都对应一个独立的栈,线程每次只能执行一个方法,然后在栈中创建一个该方法的栈帧。
  • 对于三个区域,在启动一个Java应用程序时,都支持通过配置相关参数来根据应用程序的特点来自定义各个内存区域的初始、最大大小;通过相关命令如jstack,jmap,jstat等来查看相关区域的运行时状态;对于堆,由于JVM会进行自动垃圾对象销毁回收,故可以指定进行垃圾回收的算法实现,从而满足不同应用程序的响应和吞吐量要求。
  • 在线程的角度来看,堆和方法区是由该JVM进程的所有线程共享的,所有线程都在堆中获取内存用来存放自身在执行方法时创建的对象;从方法区获取该Java应用程序定义的类的信息;而栈是每个线程独立的,每个线程都有一个自身独立的栈和程序计数器PC,一个线程不能访问另外一个线程的栈和程序计数器PC。
  • 以上三个内存区域,即JVM的运行时数据区的结构示意图如下:(图片引自《Hotspot实战》)
    JVM学习(一):JVM运行时数据区-堆、栈、方法区_第1张图片
  • JVM规范定义了方法区,堆,栈的三个概念和各个区域的职责,各种不同的JVM实现可以有不同的实现,以下基于Hotspot这种JVM实现来分析。

二、方法区

  • 方法区主要用于存放JVM加载的类文件的信息,即方法区中的是类文件的静态信息的动态呈现,故可以被执行线程访问。
  • 类信息包括:类的static字段,类的方法(包括实例方法和static静态方法),常量池(主要包括字符串,符号引用等)。在hotspot实现中,字符串常量在JDK8+之后的版本已经移到了堆中,方法区主要用于存放类相关的信息。
  • 在Hotspot实现当中,方法区只是逻辑上独立的区域,在JDK8以前在物理上是和堆一起的,作为堆的分代实现中的永久代PemGen中,故垃圾收集器也会回收该区域的垃圾,回收主要是对常量池的回收和类的卸载。在JDK8以后,方法区在物理上从堆中移到了本地内存中,称为元数据空间MetaSpace,大小受限于本地内存的大小,而之前是受限于堆的大小。如果方法区内存满了不能再进行内存分配来存放更多的常量或者类信息,则会抛出OutOfMemory异常。具体关于方法区的配置信息,可参考:JVM运行时数据区-方法区

三、堆

  • 堆空间是JVM三个内存区域中最大的一个区域,该JVM进程的所有线程进行方法调用时动态创建的对象都在堆中存放,而在对应的线程的栈中通过对象引用来访问该对象。
  • JVM实现了垃圾对象的自动回收,在应用程序中,程序只需要根据需要进行对象创建即可,JVM会自动回收不再需要的对象,释放对应的内存空间。
  • 所以为了便于对对象的管理和进行垃圾回收,JVM对于堆在整体上根据对象生命周期的长短不同,基于分代的思路来细分为:新生代,老年代。
  • 其中新生代用于存放新创建的对象,老年代用于存放生命周期较长的对象,即在进行垃圾回收时,如果在此次垃圾回收不能销毁的对象,会从新生代移到老年代。同时对于需要占用太大内存空间的对象的内存分配,如果新生代没有足够的内存空间来存放,则也会直接在老年代分配内存来存放这种“大”对象。
  • 为了方便基于分代来实现垃圾回收的算法实现,新生代进一步分为:Eden区,两个Survivor区,即From Survivor区,To Survivor区,其中Eden区用于存放新创建的对象,如果Eden满了,不能存放新创建的对象了,则会触发新生代垃圾回收,将新生代中所有不再需要的对象销毁,将还需要的对象移到From,并递增对象的年龄。在之后的新生代垃圾回收中,将Eden和From存储的对象移到的To,清空Eden和From,此时From变成了下次GC的To,To变成了From。在对象年龄达到一定大小,则从From直接移动到老年代了。
  • 关于堆更多的分析和相关配置,请参考:JVM运行时数据区-堆

四、栈与程序计数器PC

  • 栈是每个线程私有的一个内存区域,主要用于存放线程在进行方法调用时的方法参数,方法局部变量(如果是对象类型或者常量,则是对堆的对象的对象引用指针,常量池引用指针,如果是是基本数据类型,如double,long等,则是数据自身),方法正常返回的地址(一般为方法A调用方法B,则方法B执行完之后需要返回到方法A继续执行)以及方法异常表的指针。

  • 每个方法都对应栈中的一个栈帧,即线程每调用一个方法,都会在栈中为该方法的执行创建一个栈帧。如果方法存在无法退出的递归调用,则会导致栈填满,即不断创建栈帧导致栈被用完,则会抛StackOverFlow的异常。如果Java应用程序创建了太大的线程,由于每个线程需要一个栈,则如果没有可用的物理内存来继续用于栈的创建,则会抛OutOfMemory的异常。

  • JVM提供了JVM参数:-Xss来设置每个线程的堆栈大小,默认大小为1M,如-Xss1024k表示每个栈的大小为1M,具体可以通过以下命令来查看:

    xyzdeMacBook-Pro:easy-web xyz$ java -XX:+PrintFlagsFinal -version | grep ThreadStackSize
         intx CompilerThreadStackSize                   = 0                                   {pd product}
         intx ThreadStackSize                           = 1024                                {pd product}
         intx VMThreadStackSize                         = 1024                                {pd product}
    java version "1.8.0_181"
    Java(TM) SE Runtime Environment (build 1.8.0_181-b13)
    Java HotSpot(TM) 64-Bit Server VM (build 25.181-b13, mixed mode)
    
  • 程序计数器PC:由于Java应用程序是编译为字节码而不是直接的二进制机器码,依赖JVM进程来执行,由JVM进程来解析这些字节码为二进制机器码,从而进行实际的执行。故JVM实现了一套指令来组织这些字节码为一个完整的程序。在线程进行方法调用过程中,通过程序计数器PC来存放该线程当前正在执行的字节码指令,故程序计数器PC与栈一样也是每个线程独立的。

五、方法区、堆、栈的联系

  • Java应用程序是功能是由一个个方法来实现的,在Java应用程序执行过程中,JVM进程在主线程或者创建子线程来调用这些方法。
  • 在JVM层面关于方法的执行过程包括:
    1. 首先Java应用程序启动加载Java应用程序的类文件.class的字节码,然后解析类文件的字节码并根据类文件的格式生成方法区中的动态数据结构,包括类的字段,方法,常量池,符号引用;
    2. 然后创建执行线程并为该线程创建栈,在调用某个方法时,首先需要使用方法指针从方法区获取该方法的信息;对于方法引用的其他类的方法,则需要将符号引用转为直接引用,即物理内存地址,从而进行对其他方法进行调用。然后由于已经获取了该方法的信息,故在栈中为该方法创建对应的栈帧,在栈帧中存放方法的参数,局部变量,执行过程中产生的中间结果,执行结束产生的执行结果,在方法执行结束之后,根据返回地址,返回调用主方法进行执行。
    3. 在方法执行过程中,需要动态创建对象,在堆中分配内存来存放该对象,并在该方法对应的栈帧中包括对象引用指针,从而在方法执行过程中,可以通过该对象指针来引用堆中的该对象,获取该对象的信息。

你可能感兴趣的:(JVM)