这一定是全网写JVM最好的文章之一 - JVM运行时数据区

2,513 阅读30分钟

根据小伙伴给我的留言,从今天开始,打算写系列文章了,之后我会搞一个脑图,把我具体要写的都加上去

JVM基础知识

一个Java程序到底是如何运行的?

一个Java程序,首先要经过javac编译成.class文件,.class文件是给JVM进行识别的,JVM将.class文件加载到方法区,执行引擎会执行这些字节码,执行时,会翻译成操作系统相关的函数。

过程如下:Java文件->编译器->字节码->JVM->机器码

JVM,JRE,JDK的关系

首先看一下这个图,最表象的,就是JDK>JRE>JVM,也就是JDK包含JRE,JRE包含JVM,那么这三个到底有什么区别呢?先从最小的开始说

JVM:JVM具体可以理解成就是一个平台,一个虚拟机,可以把class翻译成机器识别的代码。但是需要注意,JVM不会自己生成代码,需要大家编写代码,同时需要很多依赖库,这个就需要用到JRE

JRE:JRE除了包含JVM之外,还提供了很多的类库,也就说我们说的jar包(比如:读取和操作文件,连接网络,IO等等),这些东西都是JRE提供的基础类库。JVM标准加上实现了一大堆类库,就组成了Java的运行时环境,也就是我们常说的JRE

JDK:玩过Java的小伙伴应该都用过java -jar,javac等命令吧,如果只有jvm,jre,我们代码是写完了,但是怎么编译呢?或者代码出了问题怎么调试呢?这些都是JDK提供的,所以,jdk其实就是给我们提供了一些工具,一些命令,让我们完成编译代码,调试代码,反编译代码等操作。

为什么说Java是一次编译到处运行

不管是windows,mac,还是linux,unix,oracle官网上都提供了下载对应的jdk版本,我们只需要编写java代码,因为jvm不同操作系统上下载的不同版本的,所以不需要我们管各种操作系统之间的区别,jvm只识别字节码,所以jvm其实跟语言是解耦的,也没有直接关联。

Java内存区域

根据上图可以知道,方法区和堆是一个颜色,虚拟机栈和本地方法栈和程序计数器是一个颜色 所以,方法区和堆是线程共享的,虚拟机栈和本地方法栈和程序计数器是线程私有的 通过上图,可知Java内存区域包括运行时数据区和执行引擎和本地库接口和本地方法库

运行时数据区

运行时数据区包括:方法区,虚拟机栈,本地方法栈,堆和程序计数器

这里说明一下:Java1.7及以前也是跟堆分开的,只不过实现方式和堆类似,这个图稍微有点不太准确

虚拟机栈

虚拟机栈是线程私有的,他的生命周期与线程是一样的。虚拟机栈描述的是Java方法执行的线程内存模型:每个方法执行的时候都会创建一个栈桢用于存放局部变量表,操作数栈,动态链接,方法出口等信息。

看这个方法,main方法中调用a方法,a方法调用b方法,以此调用。这时候我们运行代码,线程就会对应有一个虚拟机栈,同时,执行某个方法的时候,就会产生一个栈桢 首先是执行main方法,就会有一个栈桢,main调用a方法,就会有一个a方法对应的栈桢,以此类推,但是,执行完的时候,是后进先出,栈的数据结构就是这样,最后是调用的c方法,c方法执行完毕之后,c的栈桢会先出去,然后继续b出栈,以此类推

什么是栈桢

栈桢是用于支持虚拟机进行方法调用和方法执行的数据结构,栈桢存储了方法的局部变量表,操作数栈,动态链接和方法返回地址等信息。每一个方法从调用到执行完成的过程,都对应一个栈桢在虚拟机里从入栈到出栈的过程。

局部变量表

局部变量表是一组变量值存储空间,用于存放方法参数和方法内定义的局部变量。包括八种基本数据类型,对象引用(reference类型)和returnAddress类型(指向一条字节码指令的地址)。

其中64位长度的long和double类型的数据会占用2个局部变量空间,其余的数据类型只占用1个。

操作数栈

操作数栈也称为操作栈,是一个后入先出栈(LIFO)。随着方法执行和字节码指令的执行,会从局部变量表或对象实例的字段中复制或变量写入到操作数栈,再随着计算进行将栈中的元素出栈到局部变量表或者返回给方法调用者,也就是出栈/入栈操作。

这里说明一下,操作数栈,实际上就是缓存,在计算机中,内存是用来存放数据的,cpu是用来计算的,但是在,cpu和内存之间,操作系统增加了一个三级缓存,用来解决,内存和cpu之间速度差距太大,这里操作数栈就相当于是三级缓存,堆和栈(局部变量表)相当于内存,执行引擎相当于cpu

这里有个类:

public class Person {

    public int work(){
        int x = 1;
        int y = 2;
        int z = (x+y)*10;
        return z;
    }

    public static void main(String[] args){
        Person person = new Person();
        person.work();
    }
}

这里,我们可以用javap -c xxx.class命令来进行反汇编

javap -c Person.class

Compiled from "Person.java"
public class com.zzz.Person {
  public com.zzz.Person();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return

  public int work();
    Code:
       0: iconst_1
       1: istore_1
       2: iconst_2
       3: istore_2
       4: iload_1
       5: iload_2
       6: iadd
       7: bipush        10
       9: imul
      10: istore_3
      11: iload_3
      12: ireturn

  public static void main(java.lang.String[]);
    Code:
       0: new           #2                  // class com/zzz/Person
       3: dup
       4: invokespecial #3                  // Method "<init>":()V
       7: astore_1
       8: aload_1
       9: invokevirtual #4                  // Method work:()I
      12: pop
      13: return
}

这里显示的东西就有点像类了,这里我就不用javap命令了,安装一个idea插件jclass Bytecode Viewer 然后单机class文件,点view中的show bytecode with jclasslib 点击方法里面的work的code就可以看到和上面work里面一样的信息了 这里执行work方法的时候,work方法压入栈,开始执行第一行代码int i=1;这段代码在反编译中对应着两行,,这个插件很人性的就是,可以点击这个iconst之类的,就会跳转到官网对应的说明 这里官网给的解释,将int常数i压入操作数栈中。 接着执行istroe_1 再来看一下官网这个意思 这里说明的意思是,将int类型的i从操作数栈弹出,存放在i处的局部变量表中 这里大家也发现了把,操作数栈其实真的很像cpu上的三级缓存,局部变量表相当于内存

之后iconst_2和istore_2和之前对应的也一样,压入操作数栈,从操作数栈弹出,存放到局部变量表

再往后就是执行iload_1和iload_2 这里的意思是将局部变量表中i的位置的int类型的数据压入到操作数栈中,所以这里就是把1,2位置的数据压入操作数栈 之后执行iadd iadd的意思是把1,2这两个数从操作数栈弹出,进行相加,结果是3,再把结果进行入栈 这里,执行引擎运算完毕之后,需要入栈,因为执行引擎类似于cpu是不用来存储数据的,所以需要操作数栈来保存数据

之后执行bipush 10 这里意思是将这个value=10的int类型的数压入到操作数栈中 之后执行imul 从操作数栈弹出两个值,然后进行乘法运算,之后把结果压入操作数栈 之后执行istore_3,就是把30出栈,放入局部变量表3的位置 之后执行iload_3,上面说了,也就是把局部变量表3的位置压入操作数栈,也就是30 之后执行ireturn 因为我们方法需要返回的,看这里解释也可以解释的通为什么需要iload了,这个ireturn是需要从操作数栈的栈顶进行返回,所以需要把30压入操作数栈的栈顶。把30从操作数栈弹出,将它压入调用程序框架的操作数栈,当前方法的操作数栈中的其他值都丢弃

动态连接

Java虚拟机中,每个栈桢都包含一个指向运行时常量池中该栈所属的方法的符号引用,持有这个引用的目的是为了支持方法调用过程中的动态连接

动态连接的作用:将符号引用转换成直接引用(这里后面会讲到)

动态连接后面会讲

方法返回地址

方法返回地址存放调用该方法的PC寄存器的值。一个方法的结束,有两种方法:正常的执行完成,出现未处理的异常非正常的退出。无论通过那种方法退出,在方法退出后都返回该方法被调用的位置。方法正常退出时,调用者的PC计数器的值作为返回地址,即调用该方法的指令的下一条指令的地址。而通过异常退出的,返回地址是要通过异常表来确定,栈桢中一般不会保存这部分信息。

本地方法栈

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

特点:

  1. 本地方法栈加载native方法,native的存在是用来补全Java中缺陷(早期Java不完善,有些地方需要调用C++)
  2. 虚拟机栈为虚拟机执行Java方法服务,而本地方法栈则是虚拟机使用native方法服务。
  3. 是线程私有的,它的生命周期与线程相同,每个线程都有一个。
在Java虚拟机规范中,对本地方法栈这块区域,与Java虚拟机栈一样,规定了两种类型的异常:
1. StackOverFlowError:线程请求的栈深度>所允许的深度
2. OutOfMemoryError:本地方法栈扩展时无法申请到足够的内存

为什么一定需要本地方法栈

因为虚拟机中,虚拟机栈是用来处理内部的一些操作,但是调用native方法和java中方法是不一样的,所以为了规范,就出现了本地方法栈

PC程序计数器

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

为什么要有程序计数器

操作系统中,CPU的逻辑处理器的核心数是有限的。为什么可以同时跑100个线程呢? CPU用了一种优化手段叫做时间片

什么是CPU时间片轮转

时间片轮转法(Round-Robin,RR)主要用于分时系统中的进程调度。为了实现轮转调度,系统把所有就绪进程按先入先出的原则排成一个队列。新来的进程加到就绪队列末尾。每当执行进程调度时,进程调度程序总是选出就绪队列的队首进程,让它在CPU上运行一个时间片的时间。时间片是一个小的时间单位,通常为10-100ms数量级。当进程用完分给他们的时间片后,系统的计时器发出时钟中断,调度程序便停止该进程的运行,把它放入就绪队列的末尾,然后把cpu分给就绪队列的队首进程,同样也让他运行一个时间片,如此往复

通俗点来说,就是,假如你上班摸鱼,不好好写码,你又打游戏又逛淘宝的,假如你淘宝相中一个东西,游戏是打吃鸡,老板过来了,你淘宝和游戏就逛不了了,赶紧切屏到idea中敲代码,老板走了,你打开游戏,找个草丛趴着确保不会有人看到你打死你,然后切到淘宝聊天页跟卖家咨询商品,然后老板又过来了,你又切屏到idea了,老板走了,你又切到淘宝或者吃鸡了,如此反复,老板固定时间路过,游戏,写码,逛淘宝,时间是有限的,需要分配

所以在JVM中,就需要这么一个程序计数器,用来记录代码执行到哪里了,否则时间片执行完,或者方法内部调用方法执行完毕之后,你都不知道你执行到哪里了

PC计数器的特点

  1. 区别于计算机硬件的PC寄存器,两者略有不同。计算机用PC寄存器来存放"伪指令"或地址,而相对于虚拟机,PC寄存器它表现为一块内存,虚拟机的PC寄存器的功能也是存放伪指令,更确切的说存放的是将要执行指令的地址
  2. 当虚拟机正在执行的方法是一个本地方法的时候,JVM的PC寄存器存储的值是undefined。
  3. 程序计数器是线程私有的,它的生命周期与线程一样,每个线程都有一个
  4. 此内存区域是唯一一个在Java虚拟机规范中没有规定任何OutOfMemoryError情况的区域

对于Java应用程序来说,Java堆是虚拟机管理的内存中最大的一块。Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,Java世界里"几乎"所有的对象实例都在这里分配内存。"几乎"是指从实现角度来看,随着Java语言的发展,现在已经能看到些许迹象表名日后出现值类型的支持,即使只考虑现在,由于即时编译技术的进步,尤其是逃逸分析技术的日渐强大,栈上分配,标量替换优化手段已经导致一些微妙的变化悄然发生,所以说Java对象实例都分配在堆上也渐渐变得不那么绝对了。

你一定要知道的JVM逃逸分析:juejin.cn/post/690562… 如果想了解栈上分配,请看我掘金上发的这篇文章,通俗易懂

堆的特点

  1. 是Java虚拟机所管理的内存中最大的一块
  2. 堆是JVM所有线程共享的
堆中也包含私有的线程缓冲区 Thread Local Allocation Buffer(TLAB)
  1. 在虚拟机启动的时候创建
  2. 唯一目的就是存放对象实例,几乎所有的对象实例以及数组都要在这里分配内存
  3. Java堆是垃圾收集器管理的主要区域
  4. 因此很多时候Java堆也被称为"GC堆"。从内存回收的角度来看,由于现在收集器基本采用分代收集算法,所以Java堆还可以细分为:新生代,老年代;新生代又可以分为Eden空间,From Survivor空间,To Survivor空间
  5. Java堆是计算机物理存储上不连续的,逻辑上是连续的,也是大小可调节的(通过-Xms和-Xmx控制)。
  6. 方法结束后,堆中对象不会马上移除仅仅在垃圾回收的时候才移除。
  7. 如果在堆中没有内存完成实例的分配,并且堆也无法再扩展时,将会抛出OutOfMemoryError异常。

堆的分类

现在垃圾回收器都使用分代理论,堆空间也分类如下:

在Java7 Hotspot虚拟机中将Java堆内存分为2个部分:

  • 年轻代
  • 老年代

Java7及以前,永久代实现方法跟堆类似,但是不在堆中,可以把下面这幅图堆空间后面的永久代看做单独的模块 在Java8之后,由于方法区的内存不在分配在Java堆上,而是存储于本地内存元空间Metaspace中,所以永久代就不存在了

年轻代和老年代

  1. 年轻代(Young Gen):年轻代主要存放新创建的对象,内存大小相对会比较小,垃圾回收会比较频繁。年轻代分成1个Eden Space和2个Suvivor Space(from和to)
  2. 老年代(Tenrued Gen):老年代主要存放JVM认为生命周期比较长的对象(经过几次Young Gen的垃圾回收后仍然存在),内存大小相对会比较大,垃圾回收也相对没有那么频繁。

新生代和老年代堆结构占比

默认 -XX:NewRatio=2,标识新生代占1,老年代占2,新生代占整个堆的1/3

Eden空间和另外两个Survivor空间占比分别是8:1:1

可以通过操作选项-XX:SurvivorRatio调整这个空间比例。比如:-XX:SurvivorRatio=8

几乎所有的java对象都在Eden区创建,但80%的对象生命周期都很短,创建出来就会销毁

从图中可以看出:堆大小=新生代+老年代。其中,堆的大小可以通过参数-Xms,-Xmx来指定

默认的,新生代(Young)与老年代(Old)的比例的值为1:2(该值可以通过参数-XX:NewRatio来指定),即,新生代(Young)=1/3的堆空间大小。老年代(Old)=2/3的堆空间大小。其中,新生代(Young)被细分为Eden和两个Survivor区域,这两个Survivor区域分别被命名为from和to,以示 区分。默认的,Eden:from:to=8:1:1(可以通过JVM每次只会使用Eden和其中一块Survivor区域来为对象服务,所以无论什么时候,总是有一块Survivor区域是空闲的。因此,新生代市实际可用的内存空间为9/10(即90%)的新生代空间。

对象分配过程

JVM设计者不仅需要考虑内存如何分配,在哪里分配等问题,并且由于内存分配算法与内存回收算法密切相关,因此还需要考虑GC执行完内存回收后是否存在空间中间产生内存碎片。

分配过程:

  1. new的对象先放到伊甸园区。该区域大小限制
  2. 当伊甸园区填满时,程序又需要创建对象,JVM的垃圾回收器将堆伊甸园区进行垃圾回收(Minor GC),将伊甸园区中不再被其他对象引用的对象进行销毁,再加载新的对象放到伊甸园区
  3. 然后再将伊甸园区的剩余对象移动到Survivor0区
  4. 如果再次触发垃圾回收,此时上次幸存下来的放在Survivor0区的,如果没有回收,就放在Survivor1区
  5. 如果再次经历垃圾回收,此时会重新返回Survivor0区,接着再去Survivor1区。
  6. 如果累计次数到达默认的15次,就会进入老年代
  7. 老年代内存不足时,会再次触发GC:Major GC进行老年代的内存清理
  8. 如果老年代执行Major GC后仍然没有办法进行对象的保存,就会报OOM异常

分配对象的流程:

堆GC

Java中的堆也是GC垃圾收集的主要区域,GC分为两种:一种是部分收集器(Partial GC),另一种是整堆收集器(Full GC)

部分收集器:不是完整收集Java堆的收集器,它又分为:

  • 新生代收集(Minor GC/Young GC):只是新生代的垃圾收集
  • 老年代收集(Major GC/Old GC):只是老年代的垃圾收集(CMS GC单独回收老年代)
  • 混合收集(Mixed GC):收集整个新生代以及老年代的垃圾收集(G1 GC会混合回收,region区域回收) 整堆收集(Full GC):收集整个Java堆和方法区的垃圾收集器

年轻代GC触发条件:

  • 年轻代空间不足,就会触发Minor GC, 这里年轻代指的是Eden代满,Survivor不满不会引发GC
  • Minor GC会引发STW(stop the world) ,暂停其他用户的线程,等垃圾回收接收,用户的线程才恢复 老年代GC (Major GC)触发机制
  • 老年代空间不足时,会尝试触发MinorGC. 如果空间还是不足,则触发Major GC
  • 如果Major GC , 内存仍然不足,则报错OOM
  • Major GC的速度比Minor GC慢10倍以上. FullGC 触发机制:
  • 调用System.gc() , 系统会执行Full GC ,不是立即执行. 老年代空间不足
  • 方法区空间不足
  • 通过Minor GC进入老年代平均大小大于老年代可用内存

通过打印GC信息来看新生代老年代情况和是否GC

在idea VM options中配置-XX:+PrintGCDetails用来打印GC

public class GCTest {

    public static void main(String[] args) {
        //byte[] allocation1 = new byte[60000*1024];
        //byte[] allocation2 = new byte[10000*1024];
        //byte[] allocation3 = new byte[10000*1024];
        //byte[] allocation4 = new byte[10000*1024];
       // byte[] allocation5 = new byte[10000*1024];
    }
}

执行上面代码,结果: 可以看到eden占了8%,from和to占0%,老年代也是0%,这里eden为什么执行就占8%相信大家应该也知道,我就不用多说了,继续

public class GCTest {

    public static void main(String[] args) {
        byte[] allocation1 = new byte[60000*1024];
        //byte[] allocation2 = new byte[10000*1024];
        //byte[] allocation3 = new byte[10000*1024];
        //byte[] allocation4 = new byte[10000*1024];
       // byte[] allocation5 = new byte[10000*1024];
    }
}

这里看的话,eden区直接从8%升到99%,那么我们继续把6改成7

public class GCTest {

    public static void main(String[] args) {
        byte[] allocation1 = new byte[70000*1024];
        //byte[] allocation2 = new byte[10000*1024];
        //byte[] allocation3 = new byte[10000*1024];
        //byte[] allocation4 = new byte[10000*1024];
       // byte[] allocation5 = new byte[10000*1024];
    }
}

这里就是大对象,直接进入老年代了,新生代还是和main方法中没代码运行的时候一样了,都是0%

这个时候,我们把7改回6,6000*1024的时候是eden区马上满的时候,我们把allocation2解开注释,来看一下打印信息

public class GCTest {

    public static void main(String[] args) {
        byte[] allocation1 = new byte[60000*1024];
        byte[] allocation2 = new byte[10000*1024];
        //byte[] allocation3 = new byte[10000*1024];
        //byte[] allocation4 = new byte[10000*1024];
       // byte[] allocation5 = new byte[10000*1024];
    }
}

这里发生Young GC是因为给allocation2分配内存的时候,eden区内存几乎被分配完了,上面也说到了Eden没有足够的空间进行分配的时候,虚拟机就会发起一次Young GC(Minor GC),GC期间虚拟机又发现allocation1无法存入Survior空间,所以只能把新生代的对象提前转交给老年代中去,老年代的空间足够存放allocation1,所以不会出现Full GC

什么样子的对象直接进入老年代

大对象直接进入老年代

大对象就是需要大量连续空间的对象(比如:字符串,数组)。JVM参-XX:PretenureSizeThreshold 可以设置大对象的大小,如果对象超过设置大小会直接进入老年代,不会进入年轻代,这个参数只在 Serial 和ParNew两个收集器下有效。

比如设置JVM参数:-XX:PretenureSizeThreshold=1000000 (单位是字节) -XX:+UseSerialGC ,再执行下上面的第一个程序会发现大对象直接进了老年代

为什么要这样?

为了避免为大对象分配内存时的复制操作而降低效率

长期存活的对象进入老年代

既然虚拟机采用了分代收集的思想来管理内存,那么内存回收时就必须能识别哪些对象应放在新生代,哪些对象应放在老年代中。为了做到这一点,虚拟机给每个对象一个对象年龄(Age)计数器。

如果对象在 Eden 出生并经过第一次 Minor GC 后仍然能够存活,并且能被 Survivor 容纳的话,将被移动到 Survivor空间中,并将对象年龄设为1。对象在 Survivor 中每熬过一次 MinorGC,年龄就增加1岁,当它的年龄增加到一定程度(默认为15岁,CMS收集器默认6岁,不同的垃圾收集器会略微有点不同),就会被晋升到老年代中。对象晋升到老年代的年龄阈值,可以通过参数-XX:MaxTenuringThreshold 来设置。

对象动态年龄判断

当前放对象的Survivor区域里(其中一块区域,放对象的那块s区),一批对象的总大小大于这块Survivor区域内存大小的50%(-XX:TargetSurvivorRatio可以指定),那么此时大于等于这批对象年龄最大值的对象,就可以直接进入老年代了, 例如Survivor区域里现在有一批对象,年龄1+年龄2+年龄n的多个年龄对象总和超过了Survivor区域的50%,此时就会 把年龄n(含)以上的对象都放入老年代。这个规则其实是希望那些可能是长期存活的对象,尽早进入老年代。对象动态年 龄判断机制一般是在minor gc之后触发的。

老年代空间分配担保机制

年轻代每次minor gc之前JVM都会计算下老年代剩余可用空间 如果这个可用空间小于年轻代里现有的所有对象大小之和(包括垃圾对象) 就会看一个“-XX:-HandlePromotionFailure”(jdk1.8默认就设置了)的参数是否设置了 如果有这个参数,就会看看老年代的可用内存大小,是否大于之前每一次minor gc后进入老年代的对象的平均大小。 如果上一步结果是小于或者之前说的参数没有设置,那么就会触发一次Full gc,对老年代和年轻代一起回收一次垃圾, 如果回收完还是没有足够空间存放新的对象就会发生"OOM" 当然,如果minor gc之后剩余存活的需要挪动到老年代的对象大小还是大于老年代可用空间,那么也会触发full gc,full gc完之后如果还是没有空间放minor gc之后的存活对象,则也会发生“OOM”

方法区

方法区和堆一样,是线程共享的内存区域,它用于存储已被虚拟机加载的类信息,常量,静态变量,即时编译器编译后的代码缓存等数据。

元空间,永久代是方法区具体的落地实现,方法区看作是一块独立于Java堆的内存空间,它主要是用来存储所加载的类信息的

方法区是对JVM的"逻辑划分",在jdk1.7之前很多开发者习惯将方法区称为"永久代",是因为在HotSpot虚拟机中,设计人员使用永久代来实现JVM规范的方法区。在jdk1.8之后使用了元空间来实现方法区

JVM在执行某个类的时候,必须先加载。在加载类(加载,验证,准备,解析,初始化)的时候,JVM会先创建class文件,而在class文件中除了有类的版本,字段,方法和接口等描述信息外,还有一项信息是常量池(final修饰的变量),符号引用则包括类和方法的全限定名(例如String这个类,它的全限定名就是Java/lang/String),字段的名称和描述符以及方法的名称和描述符

创建对象各数据区域的声明:

方法区的特点:

  • 方法区与堆一样是各个线程共享的内存区域
  • 方法区在JVM启动的时候就会被创建并且它实例的物理内存空间和Java堆一样都可以不连续
  • 方法区的大小跟堆空间一样可以选择固定大小或者动态变化
  • 方法区的对象决定了系统可以保存多少个类,如果系统定义了太多的类,导致方法区溢出虚拟机同样会跑出(OOM)异常
  • 关闭JVM就会释放这个区域的内存

方法区内部结构:

类加载器将Class文件加载到内存之后,将类的信息存储到方法区中。

方法区中存储的内容:

  • 类型信息(域信息,方法信息)
  • 运行时常量池和常量池

什么是常量池

这里我们用javap -v xxx.class

javap -v Person.class 

Classfile /Users/xxx/Desktop/coupon/target/classes/com/zzz/Person.class
  Last modified 2020-12-17; size 572 bytes
  MD5 checksum c295a3afb5d289652e0225d17f86483f
  Compiled from "Person.java"
public class com.zzz.Person
  minor version: 0
  major version: 52
  flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
   #1 = Methodref          #5.#26         // java/lang/Object."<init>":()V
   #2 = Class              #27            // com/zzz/Person
   #3 = Methodref          #2.#26         // com/zzz/Person."<init>":()V
   #4 = Methodref          #2.#28         // com/zzz/Person.work:()I
   #5 = Class              #29            // java/lang/Object
   #6 = Utf8               <init>
   #7 = Utf8               ()V
   #8 = Utf8               Code
   #9 = Utf8               LineNumberTable
  #10 = Utf8               LocalVariableTable
  #11 = Utf8               this
  #12 = Utf8               Lcom/zzz/Person;
  #13 = Utf8               work
  #14 = Utf8               ()I
  #15 = Utf8               x
  #16 = Utf8               I
  #17 = Utf8               y
  #18 = Utf8               z
  #19 = Utf8               main
  #20 = Utf8               ([Ljava/lang/String;)V
  #21 = Utf8               args
  #22 = Utf8               [Ljava/lang/String;
  #23 = Utf8               person
  #24 = Utf8               SourceFile
  #25 = Utf8               Person.java
  #26 = NameAndType        #6:#7          // "<init>":()V
  #27 = Utf8               com/zzz/Person
  #28 = NameAndType        #13:#14        // work:()I
  #29 = Utf8               java/lang/Object
{
  public com.zzz.Person();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: return
      LineNumberTable:
        line 3: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       5     0  this   Lcom/zzz/Person;

  public int work();
    descriptor: ()I
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=4, args_size=1
         0: iconst_1
         1: istore_1
         2: iconst_2
         3: istore_2
         4: iload_1
         5: iload_2
         6: iadd
         7: bipush        10
         9: imul
        10: istore_3
        11: iload_3
        12: ireturn
      LineNumberTable:
        line 6: 0
        line 7: 2
        line 8: 4
        line 9: 11
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      13     0  this   Lcom/zzz/Person;
            2      11     1     x   I
            4       9     2     y   I
           11       2     3     z   I

  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=2, args_size=1
         0: new           #2                  // class com/zzz/Person
         3: dup
         4: invokespecial #3                  // Method "<init>":()V
         7: astore_1
         8: aload_1
         9: invokevirtual #4                  // Method work:()I
        12: pop
        13: return
      LineNumberTable:
        line 13: 0
        line 14: 8
        line 15: 13
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      14     0  args   [Ljava/lang/String;
            8       6     1 person   Lcom/zzz/Person;
}
SourceFile: "Person.java"

看打印的这些信息,里面有一段开头是Constant pool,这个就是常量池也可以叫做静态常量池

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

运行时常量池

常量池是存放编译期间生成的各种字面量与符号引用

运行时常量池:常量池在运行时的表现形式,编译后的字节码文件中包含了类型信息,域信息,方法信息等,通过ClassLoader将字节码文件的常量池中的信息加载到内存中,存储在了方法区的运行时常量池当中。 运行时常量池存放的是运行时一些直接引用。

运行时常量池在类加载完成之后,将静态常量池中的符号引用值转换成运行时常量池中,类在解析之后,将符号引用替换成直接引用。

运行时常量池在jdk1.7版本之后,就移到堆内存中了,这里指的是物理空间,而逻辑空间还是属于方法区(方法区是逻辑分区)。

public class Person {

    public int work(){
        int x = 1;
        int y = 2;
        int z = (x+y)*10;
        return z;
    }

    public static void main(String[] args){
        Person person = new Person();
        person.work();
    }
}

还是这段代码为例,其实上面的work()这个是个方法,也可以当做是一个符号,work是符合,()是符号,public修饰符也可以当成符号

运行main方法,person调用work()方法,在JVM眼中,这个work()就是一个符号

元空间

方法区与堆空间相似,也是一个共享内存区,所以方法区是线程共享的。假如两个线程都试图访问方法区中的同一个类信息,而这个类还没有装入JVM,那么此时只允许一个线程去加载它,另一个线程必须等待

在HotSpot虚拟机,Java7版本已经将永久代的静态变量和运行时常量池转移到了堆中,其余部分则存储在JVM的非堆内存中,而Java8版本已经将方法区实现的永久代去掉了,并用元空间代替了之前的永久代,并且在元空间的存储位置是本地内存。

元空间大小参数:

  • jdk1.7 及以前(初始和最大值):-XX:PermSize;-XX:MaxPermSize;
  • jdk1.8 以后(初始和最大值):-XX:MetaspaceSize; -XX:MaxMetaspaceSize
  • jdk1.8 以后大小就只受本机总内存的限制(如果不设置参数的话)

Java8为什么使用元空间代替永久代,这样做有什么好处?

官方给出的解释是:

移除永久代是为了融合 HotSpot JVM 与 JRockit VM 而做出的努力,因为 JRockit 没有永久代,所以不需要配置永久代。

永久代内存经常不够用或发生内存溢出,抛出异常 java.lang.OutOfMemoryError: PermGen。这是因为在 JDK1.7 版本中,指定的 PermGen 区大小为 8M,由于 PermGen 中类的元数据信息在每次 FullGC 的时候都可能被收集,回收率都偏低,成绩很难令人满意;还有为 PermGen 分配多大的空间很难 确定,PermSize 的大小依赖于很多因素,比如,JVM 加载的 class 总数、常量池的大小和方法的大小等。