1、运行时数据区概述

运行时数据区,Runtime Data Area。包含方法区、堆、虚拟机栈、本地方法栈和程序计数器五个部分。其中堆和方法区为多个线程共享的区域,其他区域为每个线程私有的。
在Java API中,一个Java虚拟机实例就对应着一个Runtime类,一个Runtime类就对应着一个运行时数据区。
image.png

2、程序计数器(PC寄存器)

程序计数器Program Counter Register,又叫pc寄存器:

  • 程序计数器是线程私有的 ,每个线程有一个独立的程序计数器,互不影响;
  • 它是当前线程所执行的字节码的行号指示器,为了线程切换后能够恢复到正确的位置;用于判断线程的分支、循环、跳转、异常、线程切换和恢复等功能;
  • 如果线程执行的是Java方法,则记录的是正在执行的虚拟机字节码的指令地址;

关于这条,网上也有很多说法,说的是它记录的是下一条要执行的字节码的指令地址;但是我认为记录的是正在执行的字节码指令地址比较正确,原因有二:
①《深入理解Java虚拟机》和《Java虚拟机规范》中,对它的描述都是:如果这个方法不是native的,那么pc寄存器就保存Java虚拟机正在执行的字节码指令的地址;
② 没有看到有关于字节码指令执行时,能够保证原子性的说法,那么如果寄存器保存的是下一条的指令地址,当前指令执行中断再回来执行,却只能拿到下一条指令的地址,感觉有些不对劲。

  • 如果正在执行的是本地方法,则这个计数器的值应为空(undefined);
  • 是运行时数据区中唯一不会出现OOM的区域,没有垃圾回收。

3、虚拟机栈

我们常说的栈就是指虚拟机栈,栈与堆是两个重要的内存区域。很多人会把内存区域粗略的划分为栈和堆,这是由于C/C++的“历史遗留”问题,不过也可以看出栈和堆在内存区域中的重要性。
“栈管运行,堆管存储”:栈是运行时的单位,而堆是存储的单位。栈解决程序如何执行、如何处理数据的问题;而堆解决的是数据存储的问题,即数据放在哪里,怎么放。

3.1 虚拟机栈VM Stack内容概述:

  • 是线程私有的,每个栈的生命周期与其对应的线程相同;
  • 方法在执行过程中,栈的内部会创建一个个栈帧(Stack Frame),每个方法对应一个栈帧;每个方法执行的过程就对应了一个入栈和出栈的过程;
  • 栈帧中存储着局部变量表,包括8中基本数据类型和对象的运用地址、操作数栈、动态链接和方法出口等信息;
  • 它是快速有效的存储方式,访问速度仅次于程序计数器;
  • JVM直接对Java栈的操作只有两个:

① 每个方法执行,伴随着进栈(或者叫入栈、压栈);
② 方法执行结束的出栈;

  • 栈不存在垃圾回收,这是因为栈随着线程创建而创建,随着线程的销毁而释放空间;但是存在OOM问题(OutOfMemory);

3.2 虚拟机栈的大小和相关设置:

  • 栈的大小是动态的或者固定不变的。
    • 如果栈是固定的:当线程请求的栈深度大于虚拟机允许的最大容量,就会抛出StackOverFlowError异常;
    • 如果栈可以动态扩展:当栈无法申请到足够的内存,会抛出OutOfMemory异常;

HotSpot虚拟机不支持栈的动态扩展,所以只有在创建线程申请内存时,因为无法获得足够的内存而导致OutOfMemory异常。

  • 使用-Xss参数,设置每个线程对应的栈的最大空间;HotSpot虚拟机,在macOS和liunx下,默认是1024k,在windows系统下,会受虚拟内存的影响;

3.3 栈中会出现的异常:

  • StackOverFlowError:

栈溢出。在栈是固定的情况下,当某个线程运行计算时,需要占用的栈的大小,大于虚拟机允许的一个栈的最大容量,就会抛出StackOverFlowError异常;
比如我们方法进行递归调用,却没有递归出口,就会出现栈溢出异常。

  • OutOfMemoryError:Java Heap Space:

在栈可以动态扩展的情况下,当栈无法申请到足够的内存,会抛出OutOfMemory异常;
我们一般使用的hotSpot虚拟机,是不允许栈的动态扩展的,但是也是有可能出现栈的OutOfMemory异常的,是这种情况:
操作系统分配个虚拟机的内存是有限制的,比如说是2G。虚拟机提供了参数来控制 Java 堆和方法区的这两部分内存的最大值。剩余的内存为 2GB(操作系统限制)减去 Xmx(最大堆容量),再减去 MaxPermSize(最大方法区容量),程序计数器消耗内存很小,可以忽略掉。如果虚拟机进程本身耗费的内存不计算在内,剩下的内存就由虚拟机栈和本地方法栈“瓜分”了。每个线程分配到的栈容量越大,可以建立的线程数量自然就越少,建立线程时就越容易把剩下的内存耗尽。即:栈创建出的栈太多了,再次创建一个栈时,已经申请不到空间能够满足一个栈的大小了,就会报栈的OOM错误。
所以,栈的大小并不是越大越好,栈占用的内存越大, 能够创建出的线程数越少。

3.4 栈运行原理(一些细节):

  • 不同的线程中包含的栈帧不允许存在相互引用;
  • 当前方法调用了其他方法,方法返回时,当前栈帧会传回此方法的执行结果给前一个栈帧,接着虚拟机会丢弃当前栈帧,使得前一个栈帧重新称为新的栈帧;
  • Java方法有两种返回方式:

① 一种是正常的函数返回,使用return指令;
②另一种是抛出异常,不管哪种方式抛出异常,都会导致栈帧被弹出。

3.5 栈帧:

栈内存里面是由一个个栈帧组成的,栈帧中存储了方法的局部变量表、操作数栈、动态链接、方法返回地址和一些附加信息。
在编译代码的时候,栈帧中需要多大的局部变量表,多深的操作数栈,都已经完全确定,并且写入到了方法表的Code属性中。因此一个栈帧需要分配多少内存,不会受到程序运行期变量数据的影响,仅仅取决于具体到虚拟机实现。
对于执行引擎来说,只有处于栈定的栈帧才是有效的,称为当前栈帧(Current Stack Frame),这个栈帧所关联的方法称为当前方法(Current Method)。执行引擎所运行的所有字节码指令都只针对当前栈帧进行操作。
运行时数据区 - 图3

  • 局部变量表(Local Variable Table):

局部变量表:
局部变量表是一组变量值存储空间,定义为一个数字数组,用于存放方法参数和方法内部定义的局部变量。其存储的数据类型包括:各类基本数据类型、对象引用(reference)、以及returnAddress(returnAddress,可以理解为返回值,是指向了一条字节码指令的地址)类型。
reference表示对一个对象实例的引用,通过它可以得到对象在Java堆中存放的起始地址的索引和该数据所属数据类型在方法区的类型信息。
returnAddress是指向了一条字节码指令的地址。
局部变量表的大小,在编译期就确定下来。在编译Class文件时,就在方法的Code属性的max_locals数据项中,确定了该方法需要分配的局部变量表的最大容量。它是线程私有的,不存在数据安全问题。局部变量表中的变量,只在当前方法调用中有效,虚拟机通过局部变量表完成参数值到参数变量列表的传递过程。
方法调用结束后,随着方法栈帧的销毁,局部变量表也会随之销毁。
slot:
槽,变量槽。是局部变量表的最小单位。局部变量表是一个数组,slot就是数组中的一个存储单元。长度不超过32位的数据类型占用一个slot,64为的数据类型(long和double)占用两个slot。
局部变量表中粗放的是方法参数和局部变量,具体是这么排列的:
① 索引0的位置,每个方法的局部变量表,此位置都是 this。即方法所属对象实例的引用;
② 然后是方法参数列表;
③ 分配完方法参数后,会一次分配方法内部定义的局部变量。
运行时数据区 - 图4
运行时数据区 - 图5
slot复用:
并不是方法中用到了多少个局部变量,就把这些局部变量所占slot之和作为max_locals的值,原因是局部变量表中的slot可以复用。如果一个局部变量过了其作用域,那么其作用域之后声明的新的局部变量就有可能复用过期局部变量的slot,从而达到节省资源的目的。
补充:
① 在栈帧中,与性能调优关系最密切的部分就是局部变量表,方法执行时,虚拟机使用局部变量表完成方法的传递。
② 局部变量表中的变量也是重要的垃圾回收根节点,只要被局部变量表中直接或者间接引用的对象都不会被回收。

  • 操作数栈(Operand Stack):

操作数栈也常称为操作栈,在方法执行的过程中,根据字节码指令,往操作数栈中写入数据或提取数据。在Class文件的Code属性的max_stacks中指定了执行过程中最大的栈深度。Java虚拟机的解释引擎被称为基于栈的执行引擎,这里的栈就是指操作数栈。
方法执行过程中,进行算数运算或者调用其他方法时进行参数传递时,就是通过操作数栈进行的。如果被调用的方法带有返回值,其返回值会被压入当前栈帧的操作数栈中,并更新程序计数器中下一条需要执行的字节码指令。主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间。
当一个方法刚开始执行时,一个新的栈帧也会随之被创建出来,这个方法的操作数栈是空的。
每一个操作数栈会拥有一个明确的栈深度,用于存储数值,最大深度在编译期就定义好了。在操作数栈中,32位数据类型占用一个栈单位深度,64位数据类型占用两个栈单位深度。操作数栈并非采用访问索引方式进行数据访问,而是只能通过标准的入栈、出栈操作完成一次数据访问
运行时数据区 - 图6
栈顶缓存技术:
由于操作数是存储在内存中,频繁的进行内存读写操作影响执行速度。将栈顶元素全部 缓存到物理CPU的寄存器中,以此降低对内存的读写次数,提升执行引擎的执行效率。
JVM对操作数栈的优化:
在概念上,两个栈帧是相互独立的。但是大多数虚拟机的实现都会进行优化,使两个栈帧出现一部分重叠,令下面的部分操作数栈与上面的局部变量表重叠在一块,这样在方法调用时可以共用一部分数据,无需进行额外的参数复制传递。

  • 动态链接(Dynamic Linking):

动态链接是什么:
每个栈帧都包含一个指向运行时常量池中,该栈帧所属方法的引用,目的是为了支持当前方法的代码能够实现动态链接。在Java源文件被编译成字节码文件(.class文件)时,所有的变量、方法引用都作为符号引用,保存在class文件的常量池中。描述一个方法调用了另外的方法时,就是通过常量池中指向方法的符号引用表示的。动态链接的作用就是讲这些符号引用转换为调用方法的直接引用。
常量池中的符号引用一部分会在类加载阶段或者第一次使用时转化为直接引用,这种转化称为静态解析,另一部分将在每一次运行期间转换为直接引用,这部分称为动态链接
关于常量池和运行时常量池:
常量池在字节码文件中,运行时常量池在运行时的方法区中。

  • 方法返回地址:

方法返回地址是什么:
方法返回地址就是存放调用该方法的pc寄存器的值。
方法的结束,或者说退出当前方法的方式只有两种:
① 正常执行完成。当执行遇到返回指令,会将返回值传递给上层的方法调用者,这种方式称为正常完成出口,一般来说,调用者的pc计数器可以作为返回地址。
② 出现未处理异常,非正常退出。当执行遇到异常,并且当前方法体内没有得到处理,就会导致方法退出,此时是没有返回值的,称为异常完成出口,返此时返回地址要通过异常处理器来确定。
无论哪种方式退出,方法退出后,都会返回该方法被调用的位置。本质上,方法的退出就是当前栈帧出栈的过程。此时需要恢复上层方法的局部变量表,操作数栈,将返回值压入调用者栈帧的操作数栈,设置PC寄存器值等,让调用者方法继续执行下去。

  • 一些附加信息:

允许携带与Java虚拟机实现相关的一些附加信息,例如对程序调试提供支持的信息。不是一定有的,属于可选情况。

3.6 方法的调用的一些细节:

  • 静态链接:

当一个字节码文件被装载进JVM内部时,如果被调用的目标方法在编译期可知,且运行时期间保持不变,这种情况下降调用方的符号引用转为直接引用的过程称为静态链接

  • 动态链接:

如果被调用的方法无法再编译期被确定下来,只能在运行期将调用的方法的符号引用转为直接引用,这种引用转换过程具备动态性,因此被称为动态链接

  • 方法的绑定:

绑定是一个字段、方法、或者类在符号引用被替换为直接引用的过程。仅仅发生一次。
早期绑定:被调用的目标方法如果再编译期可知,且运行期保持不变
晚期绑定:被调用的方法在编译期无法被确定,只能够在程序运行期根据实际的类型绑定相关的方法。

  • 虚方法和非虚方法:

如果方法在编译期就确定了具体的调用版本,则这个版本在运行时是不可变的。这样的方法称为非虚方法。静态方法,私有方法,final方法,实例构造器,父类方法都是非虚方法。其他方法称为虚方法。

  • 方法的调用指令:后续学习
  • 方法重写的本质:后续学习
  • 虚方法表:后续学习

4、本地方法栈

  • Java虚拟机栈管理Java方法的调用,而本地方法栈用于管理本地方法的调用。本地方法栈也是线程私有的;
  • 允许被实现成固定或者是可动态扩展的内存大小。内存溢出情况和Java虚拟机栈相同;
  • 使用C语言实现,当某个线程调用一个本地方法时,就会进入一个全新,不受虚拟机限制的世界,它和虚拟机拥有同样的权限;
  • 并不是所有的JVM都支持本地方法,因为Java虚拟机规范并没有明确要求本地方法栈的使用语言,具体实现方式,数据结构等;
  • Hotspot JVM中,直接将本地方法栈和虚拟机栈合二为一;

5、堆

5.1 Java堆核心概述

描述:

  • 一个JVM实例只存在一个堆内存,堆也是Java内存管理的核心区域;
  • Java堆在JVM启动的时候就被创建,其空间大小也就确认了,堆内存的大小是可调节的;
  • JVM虚拟机规范规定,堆可以处在物理上不连续的内存空间中,但是逻辑上它应该被视为连续的;
  • 堆内存是线程共享的,但是堆里还可以划分线程私有的缓冲区(TLAB)
  • “几乎”所有对象实例都在这里分配;
  • 数组和对象可能永远不会存储在栈上,因为栈帧中保存引用,引用指向对象或数组在堆中的位置;
  • 方法结束后,堆中的对象不会马上被移出,要在垃圾收集的时候才会被移除,堆是垃圾回收的重点区域;

    5.2 堆内存划分及大小

    堆内存划分及大小:

  • 在JDK7及以前,堆内存在逻辑上划分为新生代、老年代和永久代;

  • 在JDK8及以后,堆内存在逻辑上划分为新生代、老年代和元空间。

这里强调逻辑上,是因为新生代和老年代,物理上在堆内存中,但是永久代或者元空间,是作为方法区的具体实现,物理上不在堆中。
同时,新生代又分为伊甸园区(Eden),幸存者From区(Survivor From)和幸存者To区(Survivor To)。
这里可以知道,堆内存在物理上,只有新生代和老年代两个部分。
默认情况下,堆内存的初始内存(也是占用的最小内存)为物理内存大小的1/64,最大内存为物理内存的1/4。其中,新生代占用堆内存空间的1/3,老年代占用堆内存空间的2/3。新生代中,伊甸园区,幸存者From区,幸存者To区空间占用的比例为8:1:1(HotSpot虚拟机中)。
堆内存大小设置相关参数:

  • -Xms:表示堆空间的起始内存;
  • -Xmx:表示堆空间的最大内存。

当超过最大内存将抛出OOM错误。
通常把-Xms和-Xmx两个参数设置相同的值,目的是为了能够在垃圾回收清理完堆后,不需要重新分隔计算堆区的大小,从而提高性能。
相关指令:

  • jps命令:查看当前程序运行的进程
  • jstat命令:查看JVM在gc时的统计信息; jstat -gc 进程号;

可以知道,堆中划分了几个“代”,这是一种分代思想。其实不分代也可以,分代的理由是为了优化GC性能。

5.3 新生代与老年代

  • 新生代与老年代的空间默认比例为1:2,即新生代占用堆空间1/3的内存,老年代占用堆空间2/3的内存。

可以使用参数 -XX:NewRatio=2来设置,这个设置就代表新生代占1,老年代占2。
使用 jinfo -flag NewRatio 进程号 命令可以查看参数设定值

  • 在HotSpot虚拟机中,Eden区和两个Survivor空间占比是8:1:1;

可以使用 -XX:SurvivorRatio调整这个空间比例。
默认是8:1:1,但是实际上是6:1:1,因为由自适应机制。

  • 几乎所有的Java对象都是在Eden区被new出来的,Eden放不下的大对象,会直接进入老年代。
  • -Xmn参数可以设置新生代最大内存大小,如果同时设置了新生代比例,并且与此参数冲突,那么以此参数为准。

5.4 对象分配的过程

一般过程:

  1. new的对象会先放在Eden区,这个区域有大小限制;
  2. 当创建新对象,Eden区空间填满时,会触发Minor GC,将Eden区中不再被其他对象引用的对象进行销毁;
  3. 将Eden区中剩余的对象移到幸存者0区;
  4. 再加载新的对象到Eden区;
  5. 再次触发垃圾回收,此时上次幸存者区中(0区)如果没有被回收,就会放到幸存者1区;
  6. 再次经历垃圾回收,又回将幸存者重新放回幸存者0区,以此类推;
  7. 默认15次,超过15次时,幸存者区中的幸存对象会被转道老年代。

对于幸存者0区和1区,复制之后有交换,谁空谁是to区
特殊过程:
运行时数据区 - 图7

5.5 Minor GC、Major GC和Full GC

粗略来说,可以看做Minor GC是收集新生代,Major GC收集老年代,Full GC 收集整个堆,包括方法区。但是不同的虚拟机及不同的垃圾收集器也有所不同。
针对HotSpot虚拟机:
GC按照内存回收区域可分为:

  • 部分收集:
    • 新生代收集:Mainor GC(也叫Young GC)
    • 老年代收集:Major GC;目前只有CMS垃圾收集器会有单独收集老年代的行为;很多时候Major GC与Full GC在谈论的时候混淆使用,具体分辨应看是老年代的回收,还是整堆回收;
    • 混合收集:收集整个新生代以及部分老年代的垃圾收集,目前只有G1垃圾收集器会有这种行为;
  • 整堆收集:Full GC,收集整个Java堆和方法区的垃圾收集。

Minor GC的触发条件:

  • 当新生代空间不足时,就会触发Minor GC,这里的新生代指的是Eden区满,幸存者区满不会触发GC;
  • 每次Minor GC会清理新生代的内存;
  • Java对象大多朝生夕死,Minor GC非常频繁;
  • Minor GC会引发STW(stop the world,中断其他用户线程),后面会详细讲解。

老年代的GC(Major GC和Full GC)的触发条件:

  • 指发生在老年代的GC,对象从老年代消失,我们说“MajorGC”“FullGC”发生了
  • 出现了MajorGC,经常会伴随至少一次MinorGC;
    • 不是绝对的,在Parallel Scavenge收集器的收集策略里就直接进行MajorGC的策略选择过程
    • 也就是老年代空间不足,通常会先尝试触发MinorGC,如果之后空间还不足,则触发MajorGC
  • MajorGC的速度比MinorGC慢10倍以上,STW的时间更长
  • 如果MajorGC后,内存还不足,就报OOM了

Full GC的触发机制:

  • 调用System.gc()时,系统建议执行FullGC,但是不必然执行;
  • 老年代空间不足
  • 方法区空间不足;
  • 通过MinorGC后进入老年代的平均大小,大于老年代的可用内存;
  • 由Eden区,Survivor 0区向Survivor 1区复制时,对象的大小大于ToSpace可用内存,则把改对象转存到老年代,且老年代的可用内存小于该对象的大小
  • FullGC是开发或调优中尽量要避免的,这样暂停时间会短一些。

5.6 堆内存分配策略

  • 对象优先分配到新生代的Eden区,如果对象在Eden出生,并经过第一次Minor GC后仍然存活,并且能够被Survivor容纳,则会被移动到survivor区中,并将对象年龄设置为1。对象在Survivor区每经过一次Minor GC而不被回收,年龄就加1,当年龄增加到一定程度(默认是15,不同的JVM和垃圾收集器有所不同,可以通过-XX:MaxTenuringThreshold参数设置)时,就会被晋升道老年代;
  • 大对象(Eden区放不下的对象)直接分配到老年代;(尽量避免程序中出现过多大对象)
  • 长期存活的对象分配到老年代;
  • 动态对象年龄分配:如果Survivor区中相同年龄的所有对象大小的总和,大于Suarvivor空间的一半,那么年龄大于等于该年龄的对象可以直接进入老年代,无需等到MaxTenuringThreshold中要求的年龄;
  • 空间分配担保:

在发生Minor GC之前,虚拟机会检查老年代最大可用的连续空间,是否大于新生代所有对象的总空间。如果大于,则此次Minor GC是安全的,如果小于,则查看-XX:HandlePromotionFailure设置的参数,是否允许担保失败。
如果设置为true,则会继续检查老年代最大可用连续空间是否大于历次晋升到老年代的对象的平均大小,如果大于,则尝试进行以此Minor GC,但这次Minor GC仍然是由风险的;如果小于,则改为进行一次Full GC;
如果设置为false,则改为进行一次Full GC;
在jdk6update24,也可以直接记为jdk7之后,这个参数不会再影响到虚拟机的空间分配担保策略,规则改为:只要老年代的连续空间大于新生代对象总大小,或者历次晋升到老年代的对象的平均大小,就会进行Minor GC,否则进行Full GC。

5.7 TLAB

TLAB:Thread Local Allocation Buffer,线程本地分配缓冲区。我们知道,堆区是线程共享的区域,任何线程都可以访问到堆区的共享数据。由于对象实例的创建在JVM中非常频繁,因此在并发环境下,从堆区划分内存空间是线程不安全的。为了避免多个线程操作同一地址,需要使用加锁等机制,进而影响分配速度。由此引出了TLAB。
TLAB:

  • 从内存模型而不是垃圾回收的角度,对Eden区进行划分,JVM为每个线程分配了一个私有缓存区域,包含在Eden区的空间中;
  • 多线程同时分配内存时,使用TLAB可以避免一系列的线程安全问题,同时还能够提升内存分配的吞吐量,因此我们将这种内存分配方式称为快速分配策略;
  • openjdk衍生出来的jvm都提供了TLAB的设计;
  • 尽管不是所有的对象实例都能在TLAB中成功分配内存,但JVM确实是将TLAB作为内存分配的首选;
  • 开发人员通过-XX:UserTLAB参数来设置是否开启TLAB空间;
  • 默认情况下,TLAB空间内存非常小,仅占用整个Eden空间的1%,可以通过-XX:TLABWasteTargetPercent设置TLAB空间所占Eden空间的百分比;
  • 一旦对象在TLAB空间分配内存失败,JVM就会尝试通过加锁机制确保数据操作的原子性,从而直接在Eden空间中分配内存。

image.png
image.png
TLAB涉及到东西也很多,这里先简单了解。

5.8 堆空间相关的参数设置

运行时数据区 - 图10

5.9 堆是分配对象的唯一选择吗

先说结论,不是,还有可能在栈上分配

  • 随着JIT编译器的发展与逃逸分析技术逐渐成熟,栈上分配,标量替换优化技术,会导致一些微妙的变化,所以对象是分配到堆上这个结论,变得不那么绝对了;
  • 特殊情况下,如果经过逃逸分析后发现,一个对象并没有逃逸出方法,那么就有可能被优化成栈上分配,这样堆上分配,也不需要垃圾回收了,这时最常见的堆外存储技术;
  • TaoBaoVM,其中创新的GCIH(GC invisible heap)技术实现了off-heap,实现了将生命周期较长的Java对象从heap中移动heap外,并且GC不能管理GCIH内部的Java对象,以此达到降低GC的回收频率和提升GC的回收效率的目的。

栈上分配基于逃逸分析和标量替换。栈上分配的好处是方法结束后自动释放对应的内存,无需垃圾回收。
逃逸分析:
逃逸分析的基本行为,就是分析对象动态作用域。或者说,逃逸分析的目的,就是判断对象的作用域是否有可能逃出作用域。

  • 当一个对象在方法中定义后,对象只在方法内部使用,则认为没有发生逃逸;
  • 当一个对象在方法中定义后,它被外部方法引用,则认为发生逃逸,比如作为调用参数传递到其他地方或者作为方法返回值返回出去;

标量替换:
标量是指一个无法再分解的更小的数据的数据,Java中原始数据类型就是标量。
可以分解的数据叫聚合量,Java中的对象就是聚合量,因为他可以分解成其他聚合量和标量。
标量替换参数:-XX:EliminateAllocations,默认打开

6、 方法区

6.1 栈、堆、方法区的交互关系

运行时数据区 - 图11
运行时数据区 - 图12

6.2 方法区概述

  • 方法区是各个线程共享的内存区域,用于存储已被虚拟机加载的类信息(包括类名、方法信息、字段信息)、常量、静态变量、即时编译器编译后的代码等数据;
  • ava虚拟机规范中明确说明:尽管所有的方法区在逻辑上是属于堆的一部分,但是一些简单的实现,可能不会选择去进行垃圾收集或者进行压缩。对于HotSpot而言,方法区还有一个别名叫Non-Heap(非堆),目的就是要和堆分开;
  • 方法区在JVM启动的时候被创建,并且它的实际的物理内存空间和Java堆区一样,都是可以不连续的;可以选择固定大小或者可扩展;
  • 方法区的大小决定了系统可以保存多少个类,如果定义太多类,加载大量的第三方的Jar包,Tomcat部署过多工程,导致方法区溢出,虚拟机同样会抛出内存溢出OOM:PermGen space或者Metaspace ;
  • 关闭JVM就会释放这个区域的内存。

6.3 HotSpot中方法区的演进

  • 在jdk7及以前,习惯上把方法区,称为永久代,jdk8开始,使用元空间取代了永久代;
    • 元空间的本质和永久带类似,都是对JVM规范中方法区的实现。不过元空间与永久代最大的区别在于:元空间不在虚拟机设置的内存中,而是使用本地内存;
    • 根据Jvm规范,如果方法区无法满足新的内存分配需求,将抛出OOM异常;
  • 本质上,方法区和永久代并不等价,仅是对HostSpot而言的;
  • Java虚拟机规范,对如何实现方法区,不做统一要求,例如BEA JRockit/IBM J9中不存在永久代的概念;
  • 现在来看,当年使用永久代,不是好的点子,导致Java程序更容易OOM;

6.4 设置方法区大小与OOM

方法区的大小不是固定的,JVM可以根据应用动态调整。
方法区在JDK7及以前使用永久代实现,JDK8及以后使用元空间实现。

  • JDK7及以前:
    • 通过-XX:PermSize 来设置永久代初始分配空间,默认值是20.75M;
    • -XX:MaxPermSize 来设置永久代最大可分配空间,32位及其默认是64M,64位及其默认是82M;
    • 如果JVM加载的类信息容量超过了最大值,会报OOM:PermGenspace错误;
  • JDK8及以后:
    • -XX:MetaspaceSize来设置元空间初始分配空间;
    • -XX:MaxMetaspaceSize来设置元空间最大可分配空间;
    • 元空间的默认值跟系统平台有关:
      • windows下初始值为21M,最大值为-1,即没有限制;
      • 如果不指定大小,虚拟机耗用所有可用系统内存,元数据区发生溢出,一样OOM:Metaspace;
    • 对于一个64位服务端JVM来说,默认的初始元数据区空间为21M,这就是初始的高水位线。一旦触及这个水位线,FULLGC会触发并卸载没有用的类,然后高水位线会被重置。新的高水位线的值取决于GC后释放了多少元空间。如果释放空间不足,在不超过最大设定值时,适当提高该值。如果释放空间过多,则适当降低该值;
    • 如果初始化的高水位线设置过低,上述高水位线调整情况会发生很多次,fullGC多次调用。为了避免频繁FullGC,建议将-XX:MetaspaceSize设置为一个相对较高的值

6.5 方法区的内部结构(方法区中存储什么)

方法区用于存储已被虚拟机加载的类型信息,常量,静态变量(可以,网上阅读的资料,JDK7之后,静态变量都在堆中),即时编译器编译后的代码缓存。

  • 类元信息:
    • 类型信息:

对每个加载到类型(包括类class,接口interface,枚举enum,注解annotation),JVM都必须在方法区中存储下面的类型信息:

  1. - 这个类的完整有效名称(全名=包名.类名);
  2. - 这个类的直接父类的完整有效名,对于interface或者Object则没有父类;
  3. - 这个类的修饰符,publicabstractfinal等;
  4. - 这个类型直接接口的一个有序列表。
  • 域信息:

域信息,即字段、属性、成员变量的意思,这几个词语表达的是同一个意思。JVM必须在方法区中保存类型的所有域的相关信息以及域的声明顺序:

  - 域的声明顺序;
  - 域名称;
  - 域类型;
  - 域修饰符public,private,protected,static,final,volatile,transient的某个子集)。
  • 方法信息:

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

  - 方法名称;
  - 方法的返回类型(或void);
  - 方法的参数和数量及类型(包括顺序);
  - 方法的修饰符(public,private,protected,static,final,synchronized,native,abstract的一个子集);
  - 方法的字节码bytecodes,操作数栈,局部变量表及大小;
  - 异常表(abstract和native方法除外):每个异常处理的开始位置,结束位置,代码处理在程序计数器中的偏移地址,被捕获的异常类的常量池索引。
  • 方法表:
  • 类加载器的引用:

jvm必须知道一个类型是由启动加载器加载的还是由用户类加载器加载的。如果一个类型是由用户类加载器加载的,那么jvm会将这个类加载器的一个引用作为类型信息的一部分保存在方法区中。jvm在动态链接的时候需要这个信息。当解析一个类型到另一个类型的引用的时候,jvm需要保证这两个类型的类加载器是相同的。这对jvm区分名字空间的方式是至关重要的。

  • Class实例的引用:

jvm为每个加载的类都创建一个java.lang.Class的实例(存储在堆上)。而jvm必须以某种方式把Class的这个实例和存储在方法区中的类型数据(类的元数据)联系起来, 因此,类的元数据里面保存了一个Class对象的引用。

  • non-final的类变量:
    • 静态变量和类关联在一起,随着类的加载而加载,他们成为类数据在逻辑上的一部分;
    • 类变量被类的所有实例共享,即使没有类实例时,你也可以访问他;
      • 运行时常量池:

首先,明确一点,常量池,是在字节码文件中,运行时常量池,是在方法区中;JVM在完成类装载操作后,(也即运行时)将常量池加载到方法区,这就是运行时常量池。

  • JIT代码缓存(JIT即:即时编译器):

字面意思上,这是代码缓存区。它缓存的是JIT(Just In Time)即时编译器编译的代码。简而言之,这里存放的是JIT生成的机器码,当然Java本地接口的及其码也会放在这里。
JVM会对频繁使用的代码,即热点代码(Hot Spot Code),达到一定的阈值后会变异成本地平台相关的机器码,并进行各层次的优化,提升执行效率。
热点代码:

  - 被多次调用的方法;
  - 被多次执行的循环体;

6.5 为什么用元空间替换永久代

首先,在JDK1.6及之前,静态变量、字符串常量池存放在永久代上:
运行时数据区 - 图13
在JDK1.7的时候,还是有永久代,但是已经将字符串常量池和静态变量保存在堆上:
运行时数据区 - 图14
到了JDK8及之后,去除了永久代,将类型信息,字段,方法,常量保存在使用本地内存(即操作系统内存)的元空间,但是静态变量、字符串常量池仍然在堆中:
运行时数据区 - 图15
整体变化:
运行时数据区 - 图16

那么,为什么永久代被替换为元空间?

  • 永久代有JVM本身设置的固定大小上限,无法进行调整,而元空间使用本地内存,受本地内存限制,虽然元空间仍可能溢出,但是比原来的几率小;
  • 元空间中存放的是类的元数据,这样加载多少类的元数据就不受原本的永久代最大值控制了,而是由操作系统的实际可用空间控制,这样能加载更多的类,方法区也不容易OOM;