JVM内存结构详细总结(结合自身使用经历介绍各个知识点使用场景)

一. 概述运行时数据区域

        如图JVM运行时数据区域划分为以下6个主要部分:①程序计数器,②虚拟机栈,③本地方法栈,④虚拟机堆,⑤方法区,⑥直接内存,下面对6个部分详细总结,希望可以对路过的朋友有所帮助。
在这里插入图片描述

二.6大部分

1.程序计数器

        ‘程序技术器’存储的是当前线程所执行字节码文件的行号,换句话说‘程序技术器’就是当前线程所执行字节码文件的行号指示器,用以告诉当前线程下一次需要执行什么指令。这就是他的作用。
‘程序技术器’的鲜明特点是:线程私有内存占用极小,也是唯一不会发生OOM的运行数据区域。
为什么需要程序计数器?
        因为在单核的计算机内部,多线程是通过线程间的来回切换实现的,并不是一个真正的并行状态,‘程序技术器’会记录当前线程所执行的位置,这样在线程切换回来时才可以继续执行,不过即使是单线程程序也是要依赖‘程序技术器’的。
        
为什么不会OOM?
        ‘程序技术器’仅仅存储一个行号,执行java文件时存储的是即将执行的字节码行号,执行本地方法时存储的是null。

2.虚拟机栈

        ‘虚拟机栈’描述的是java方法运行时的内存模型,‘虚拟机栈’的基础结构是栈帧,每个方法的运行到结束便对应着一个栈帧的入栈到出栈,当前正在执行的栈帧称为“当前栈帧”,事实上虚拟机栈只执行“当前栈帧”,每个栈帧又主要由①局部变量表,②操作数栈,③动态连接,④返回地址四部分组成。hotspot不支持栈的动态扩展,栈深度用完时会报StackOverFlowError,若在线程申请栈内存时内存就不够则会报OOM。
        
‘虚拟机栈’的主要特点:线程私有、通过参数**“-Xss:1M”来设定栈大小**,存在StackOverFlowErrorOOM两种异常的可能。
如下图是‘虚拟机栈’的内存模型:
在这里插入图片描述
下面介绍下栈帧的四个主要部分

2.1 局部变量表

        ‘局部变量表’用以存储编译器可知的8中基本数据类型(byte、short、int、long、float、double、char、boolean),引用数据类型referencereturnAddress这三类信息,大小编译期可知,‘局部变量表’的基础单位是‘变量槽’,每个‘变量槽’的大小有32Bit(32位操作系统中)和64Bit(64位操作系统中)两种,但是无论是32位操作系统中还是64位操作系统中,其中除了long、double占两个‘变量槽’其余类型都是占用1个‘变量槽’。
        
32位操作系统与64位操作系统中‘变量槽’的大小不一样为什么存储long、double都是使用两个‘变量槽’呢?
        首先我们需要明确下long、double都是占用64Bit的数据类型,其他比如int是32Bit,按理说一个64位操作系统中的‘变量槽’大小是64Bit,应该是可以直接存储long、double类型的数据的,为什么还是和32位操作系统中一样是占用两个变量槽呢,因为64位中一个‘变量槽’实际使用部分仍是只是用了32Bit,空了32Bit未使用,这样就解释通了,为什么会空着32Bit呢?因为64位操作系统中设计‘变量槽’时刻意为了与32位操作系统的表现保持一致,所以才会有,虽然64位操作系统一个‘变量槽’是64Bit但是存储64Bit的long、double仍是使用两个变量槽。
        
局部变量表的大小编译器可知?
        首先明确:局部变量表的大小指的是‘变量槽’的个数,不是只占用的内存大小。
java文件在编译成class文件后,变量槽的大小就已经确定下来,如下图程序:

public class VarTable {
    
      
    private String testVar = "我是字段";

	public VarTable(){
    
    
		super();
		System.out.println("我是构造器");
	}

    public static void main(String[] args){
    
    
        VarTable vt = new VarTable();
		vt.test("我是字符串");
    }
    public void  test(String str){
    
    
		System.out.println(this.testVar);
		System.out.println(str);
    }
	public static void  testStatic(){
    
    
    }
    
}

编译后的各个方法的字节码信息如下:

public VarTable();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: aload_0
         5: ldc           #2                  // String 我是字段
         7: putfield      #3                  // Field testVar:Ljava/lang/String;
        10: getstatic     #4                  // Field java/lang/System.out:Ljava/io/PrintStream;
        13: ldc           #5                  // String 我是构造器
        15: invokevirtual #6                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
        18: return
      LineNumberTable:
        line 5: 0
        line 2: 4
        line 6: 10
        line 7: 18

  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           #7                  // class VarTable
         3: dup
         4: invokespecial #8                  // Method "<init>":()V
         7: astore_1
         8: aload_1
         9: ldc           #9                  // String 我是字符串
        11: invokevirtual #10                 // Method test:(Ljava/lang/String;)V
        14: return
      LineNumberTable:
        line 10: 0
        line 11: 8
        line 12: 14

  public void test(java.lang.String);
    descriptor: (Ljava/lang/String;)V
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=2, args_size=2
         0: getstatic     #4                  // Field java/lang/System.out:Ljava/io/PrintStream;
         3: aload_0
         4: getfield      #3                  // Field testVar:Ljava/lang/String;
         7: invokevirtual #6                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
        10: getstatic     #4                  // Field java/lang/System.out:Ljava/io/PrintStream;
        13: aload_1
        14: invokevirtual #6                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
        17: return
      LineNumberTable:
        line 14: 0
        line 15: 10
        line 16: 17

  public static void testStatic();
    descriptor: ()V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=0, locals=0, args_size=0
         0: return
      LineNumberTable:
        line 18: 0
  

从上面信息中可以清楚看到,每个方法对应的字节码信息中都有Code属性Code中的locals的值存储的便是‘变量槽’的个数。
 
附上一张程序计数器的导图:
在这里插入图片描述

这里做个延伸,扩展的会多一些,路过的朋友不想看的可以忽略延伸部分
………………………………局部变量表延伸部分–开始………………………………
 
第一问:为什么test方法只传入了一个String变量,变量槽大小却是2?
因为每个实例方法,都会隐式传入一个当前方法所属对象的实例,方法内用this表示,比如上图中程序可以在test方法中直接使用this.testVar,这便是依赖了隐式传入的this,构造器中也会隐式传入this。
 
第二问:为什么testStatic这个静态方法没有隐式传入this这个参数?
因为只有实例方法才会传入this这个隐式参数,也才能传入this,因为静态方法属于类方法,是和类一起加载的,此时并没有对象产生,所以不能传入this这个隐式参数。
 
第三问:this是隐式传参,那super是隐式传参吗?
根据构造器VarTable()的变量槽的个数为1,结合前面证实的this这个隐式传参,我们可以发现super并不是隐式传参,这个1代表的是this,super只是在构造器中可以调用而已,super具体实现机制并非隐式传参。
 
第四问:为什么main方法中只传入了一个String数组,‘变量槽’大小却是2呢?
根据上面的问题我们已经可以知道,静态方法中肯定不会有this隐式传入,man方法的两个变量槽其实一个是传入的数组参数,一个是main方法内部定义的变量‘vt ’,所以是两个变量槽,
 
第五问:局部变量表的大小和哪些因素有关?
对上面几个问题总结就可以发现有三点影响到了局部变量表的大小。
①是否是静态方法,是的话不会有this隐式传入,否则都会有this隐式传入。
②方法中传入的参数个数。
③方法内部定义的局部变量个数。
 
第六问:如果方法内部循环生成局部变量,栈的内存会爆炸吗,局部变量槽的个数会一直增加吗?
该场景代码如下方代码所示:

public class InnerLoopNewVar {
    
      
    
    public static void main(String[] args){
    
    
		int n =0;
		while(true){
    
    
			String str = new String("我是第"+n+"个字符串");
			System.out.println(str);
		}
    }  
}

下面再看下main方法对应的字节码文件吧,省略了部分该场景无关的信息。

 public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=4, locals=3, args_size=1
         0: iconst_0
         1: istore_1
         2: new           #2                  // class java/lang/String
         5: dup
         6: new           #3                  // class java/lang/StringBuilder
         9: dup
......
......

根据上面的字节码文件可以发现即使在方法内部无限生成对象,其实局部变量槽的大小是在运行之前编译期间就已经确定,并不会因为程序运行时陷入死循环而去不停创建对象,那为什么会在编译器就能确定使用多少个变量槽呢,我再运行期间创建的对象没有变量槽要去存储到什么地方呢?这里就要引出一个关于变量槽的非常非常重要的特性:变量槽是可以复用的。java开发者应该都知道,方法内部创建的变量只会在当前方法内有效,且方法退出后大部分对象都会被销毁,因为方法就是这些局部变量的作用域,但其实方法内部的参数作用域可能并不是整个方法内,就比如for循环内的变量他的作用域就只是一个for循环而已(循环的实现其实是语法糖,可以看成一个个方法),退出这个for循环后变量也就会被销毁,失去了意义,同时他占用的变量槽自然也就空了,那下一次有其他变量需要使用变量槽,就可以用这个空出来的变量槽,从而节省了内存占用。这也就能解释为什么上面的例子中局部变量表的大小是3了。一个被传入参数占用,一个被变量n占用,一个是被循环内部的变量str占用这个变量槽是可以复用的。
 
第七问:为什么使用这么大的篇幅探索局部变量表?
看了这部分内容的人可能会有这么个想法,因为曾经由于这个点入过坑,所以这块介绍的详细了些,感兴趣的话可以点击这里。死循环为什么不会OOM?

…………………………………局部变量表延伸部分–结束…………………………………
 

2.2 操作数栈

操作数栈又被称为操作栈(有人说又叫操作树栈,这种叫法是错误的)主要用以存储程序运行期间产生的中间变量。java虚拟机的解释执行引擎被称为“基于栈的执行引擎”这个栈指的就是操作数栈。他的大小和局部变量表一样都是编译器可知,方法的字节码文件中的Code属性中的statck指的便是操作数栈的深度。
如下所示:

  public void test(java.lang.String);
    descriptor: (Ljava/lang/String;)V
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=2, args_size=2
         0: getstatic     #4                  // Field java/lang/System.out:Ljava/io/PrintStream;
         3: aload_0
         4: getfield      #3                  // Field testVar:Ljava/lang/String;
         7: invokevirtual #6                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
        10: getstatic     #4                  // Field java/lang/System.out:Ljava/io/PrintStream;
        13: aload_1
        14: invokevirtual #6                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
        17: return
      LineNumberTable:
        line 14: 0
        line 15: 10
        line 16: 17

操作数栈是如何在程序中起作用的?
java的解释执行被称为“基于栈的执行引擎”,这个栈说的就是操作数栈,从这句话就可以看出操作数栈的重要性已经不言而喻了,在方法执行时,操作数栈与局部变量表配合完成了方法的大部分操作。举个简单的例子,方法中有下面这段代码:

int a = 1;
int b = 2;
int c = a + b;

在jvm中这段代码是这么执行的:
第一步将局部变量表中的a、b两个变量进行入栈操作,
第二步将操作数栈顶的a、b出栈,然后将他们相加,
第三步将相加的结果从新入栈,再将值赋给局部变量表中的c。
其实程序中大部分操作都是需要依赖操作数栈来完成操作的,它和局部变量表中的变量一起构成了jvm的操作指令。
 
为什么说操作数栈叫“操作树栈”是错误的?
如果明白操作数栈的由来就不会有这样的想法了,计算机的操作指令包含两部分①操作数,②操作码。
这两部分构成了计算机的操作指令,jvm同样也是这样,jvm中的字节码指令也是这样操作数便是存储在局部变量表中的变量,操作码就是load、add、等等这些操作码,操作数栈之所以叫操作数栈就是因为他是用来存储从局部变量表中拿出的操作数而已,就是这样。
 

2.3 动态连接(Dynamic Linking)

动态连接存储的是当前方法在运行时常量池中的符号引用。都知道class常量池中存储了很多的符号引用,一部分在编译期间就转化为了直接引用,另外一部分会在运行期间转化为直接引用,这部分就称为动态连接,比较常见的例子就是面向父类编程,面向接口编程。
 

2.4 返回地址(Return Address)

返回地址中存储的是当前方法执行结束后应该返回的上层调用处的地址。每个方法正常执行结束后都应该将执行结果返回到上层调用该方法的地方(异常时也会返回,只是返回的不是正常执行结果)。
 
附上一张栈的总结导图:
在这里插入图片描述

3. 本地方法栈(Native Stack)

HotSpot虚拟机将本地方法栈与虚拟机栈已经合二为一,两者已经区别不大,唯一区别就是虚拟机栈为java方法服务,本地方法栈为本地方法服务。两者都是通过‘-Xss:1M’来设置大小,都会有StackOverFlowErrorOOM产生,都是线程私有的空间。

4. 虚拟机堆(Heap)

java虚拟机规范中这么描述:所有的对象实例及数组都应该在堆上分配,所以堆的唯一作用就是为了存储创建出来的对象。其他所有的功能都是为了更好的存储对象来服务的。
堆的特点:堆是运行时数据区域中内存占用最大的一块区域,是所有线程共享的内存区域,可以通过参数’-Xms:250M’来设置堆的最小内存,通过‘-Xmx:300M’设置堆的最大内存。堆支持设置最大最小内存设置,所以堆空间是支持动态扩展的,当内存使用达到最小内存临界值时会触发一次GC回收堆空间,会根据回收到的内存大小动态改变参数‘-Xms:250M’的值(在小于最大值的范围内改变),如果动态扩展到的内存不足以支撑新产生对象的分配,便会有OutOfMemoryError产生。
 
堆既然是运行时数据区域内存占用最大的一块那堆的内存结构又是什么样的呢?
在JDK8及其之前可以认为堆是由新生代(Young Generation)和老年代(Old Generation)组成的,首先需要明确JDK8及其之前为什么把堆划分为新生代和老年代,因为JDK8及其之前采用的垃圾收集器都是基于分代理念设计的,也就是基于年轻代和老年代设计的,所有的垃圾收集器要么是只回收新生代(如:Serial、ParNew、Parallel Scavenge),要么是只回收老年代(如:CMS、Serial Old、Parallel Old),所以堆中的对象要么属于老年代的对象、要么属于新生代的对象。我们把堆划分为新生代、老年代是毫无问题的。但是JDK9引入了G1收集器以后,这种划分就不太准确了,具体原因在文末进行说明

4.1 新生代

新生代,顾名思义用来存储堆中新产生的对象的地方,大部分对象被创建出来以后都会进入新生代,同时对象创建时会在对象的头部信息中存储一个GC分代年龄,用以表示该对象年龄大小,一个对象每熬过一次垃圾收集他的年龄就会加1,当年龄到达16时,该对象就会进入老年代。新生代中又划分为了Eden、From Survivor、To Survivor三个区域。
新生代特点:通常新生代中90%对象熬不过一次垃圾收集,大部分对象都是朝生夕灭的,新生代中Eden默认占用新生代内存80%From Survivor和To Survior都是默认占用10%。Eden和From Survivor用以存储新产生的对象,在触发Minor GC时会将存活的对象移入To Survivor中。新生代占用内存大小可以通过参数‘-Xmn:50M’来设定。

4.2 老年代

新生代中存储的对象的GC分代年龄达到16时,该对象就会进入老年代中,且熬过月多次垃圾收集的对象就会越难以被回收,老年代中的垃圾回收相对新生代是高昂的。老年代占用的内存大小无需手动设置可以根据堆内存大小减去新生代大小来获取。
 
附上堆的导图:
在这里插入图片描述

5. 方法区

方法区是《java虚拟机规范》中的一种规范,在JDK6之前HotSpot使用永久代去实现方法区,JDK8已经完全使用本地内存元空间来实现方法区,无论使用何种方式去实现方法区,方法区的本质作用仍是用以存储被虚拟机加载的类型信息、常量、静态变量(JDK8移到堆中)、即时编译器编译后的代码缓存等信息。
方法区的特点:方法区的大小在64Bit的操作系统中默认是21M,使用参数‘-XX:MetaSpaceSize:21M’来设定方法区的最小值,同时可以设置方法区的最大值‘-XX:MaxMetaSpaceSize:50M’,该值默认是-1,意思是没有只要需要方法区就可以一直扩展,直到受限于物理机的内存。当方法区的内存使用达到设置的最小值时,便会触发Full GC(只有Full GC会回收方法区),进行回收方法区的类型信心和常量,虚拟机会根据回收到的内存大小动态调整‘-XX:MetaSpaceSize:21M’方法区最小值的值,以免重复触发GC,当动态扩展到的内存不足以为新加载的类型信息分配内存时就会报OOM。方法区是线程共享区域
 
附上一张方法区的导图:
在这里插入图片描述

5.1 运行时常量池

运行时常量池是方法区的一部分,Class文件中除了有魔数、次版本、主版本等等信息外,还有一项是常量池(这里是Class常量池,不是字符串常量池),常量池中存储的是字面量与符号引用。这块常量池中的内容在Class文件被加载后信息会被放入运行时常量池。这也就是运行时常量池中信息的由来了。他的作用已经不言而喻了,存储的是字面量与符号引用,就相当于一个仓库,字面量的确切值是存储在这里,类型信息的获取是需要依赖符号引用的,这些都需要依赖运行时常量池来完成。
运行时常量池的特点:大小受限于方法区大小的限制,因为受限于方法区大小限制,所以也会有OOM产生,下面展示下字节码文件中的常量池的信息,#1,#2,#3,#4存储的就是符号引用。

Constant pool:
   #1 = Methodref          #5.#15         // java/lang/Object."<init>":()V
   #2 = Class              #16            // Test
   #3 = Methodref          #2.#15         // Test."<init>":()V
   #4 = Methodref          #2.#17         // Test.test1:()V
   #5 = Class              #18            // java/lang/Object
   #6 = Utf8               <init>
   #7 = Utf8               ()V
   #8 = Utf8               Code
   #9 = Utf8               LineNumberTable
  #10 = Utf8               main
  #11 = Utf8               ([Ljava/lang/String;)V
  #12 = Utf8               test1
  #13 = Utf8               SourceFile
  #14 = Utf8               Test.java
  #15 = NameAndType        #6:#7          // "<init>":()V
  #16 = Utf8               Test
  #17 = NameAndType        #12:#7         // test1:()V
  #18 = Utf8               java/lang/Object

 
附上运行时常量池的导图:
在这里插入图片描述

6. 直接内存(Direct Memory)

直接内存并不是运行时数据区的一部分也不是《JVM虚拟机规范》中定义的一部分,但是这块内存也会经常被使用,且频率也不低,在JVM参数配置时,也是必须考虑的一部分,JDK1.4引入NIO以后,可以通过Native函数库操作堆外内存,然后堆中的DirectByteBuffer对象作为这块内存的yinyon来操作这块内存,这样避免了在java堆与Native堆中来回复制,使用参数‘-XX:MaxDirectMemorySize:21M’来设定大小。
 
 
写在最后:为什么JDK9之后堆按新生代、老年代划分是不准确的呢?
因为JDK9开始服务端的默认垃圾收集器由Parallel Scavenge+Parallel Old 变成了G1.细心的可能看到前面一句话就已经有所发现,Parallel Scavenge 是新生代的垃圾收集器,Parallel Old 是老年代的收集器,而G1自己就替代了他们俩。无需为新生代和老年代都设置对应的垃圾收集器,很显然不可能G1是只回收堆中部分内存的,一个就代替了两个自然是G1的工作范围更全面。JDK9开始G1正式进入历史的舞台,他是一款划时代的垃圾收集器,他开创了基于Region的内存布局,不再是面向单一的新生代或者老年代进行垃圾回收,而是面向整堆进行回收,回收的基础单位就是Region,而每个Region都可以去充当新生代、老年代、或者新生代中的Eden、Survivor等区域。这也是第一个混合型的垃圾收集器。所以再单纯的将堆中的内存布局简单划分为新生代、老年代是不准确的了,G1关注的不再单纯是分代了。

猜你喜欢

转载自blog.csdn.net/m0_46897923/article/details/113740839