Java进阶之JVM内存管理深度剖析

JVM基础知识

JVM与操作系统的关系

Java Virtual Machine

JVM 全称 Java Virtual Machine,也就是我们耳熟能详的 Java 虚拟机。它能识别 .class后缀的文件,并且能够解析它的指令,最终调用操作系统上的函数,完成我们想要的操作。

JVM解析class文件中的字节码,不一定全映射到OS的系统调用,可以是映射到CPU指令集或者是OS的系统调用。

翻译

Java 程序不一样,使用 javac 编译成 .class 文件之后,还需要使用 Java 命令去主动执行它,操作系统并不认识这些 .class 文件。所以JVM就是一个翻译。
在这里插入图片描述

从图中可以看到,有了 JVM 这个抽象层之后,Java 就可以实现跨平台了。JVM 只需要保证能够正确执行 .class 文件,就可以运行在诸如 Linux、Windows、MacOS 等平台上了。

从跨平台到跨语言

跨平台:我们写的这个类Person这个类,在不同的操作系统上(Linux、Windows、MacOS 等平台)执行,效果是一样,这个就是JVM的跨平台性。
为了实现跨平台型,不同操作系统有不同的JDK的版本
https://www.oracle.com/java/technologies/javase/javase-jdk8-downloads.html
在这里插入图片描述

跨语言:JVM只识别字节码,所以JVM其实跟语言是解耦的,也就是没有直接关联,并不是它翻译Java文件,而是识别class文件,这个一般称之为字节码。还有像Groovy 、Kotlin、Jruby等等语言,它们其实也是编译成字节码,所以也可以在JVM上面跑,这个就是JVM的跨语言特征。

JVM、JRE、JDK的关系

在这里插入图片描述

JVM只是一个翻译,把Class翻译成机器识别的代码,但是需要注意,JVM 不会自己生成代码,需要大家编写代码,同时需要很多依赖类库,这个时候就需要用到JRE。

JRE是什么,它除了包含JVM之外,提供了很多的类库(就是我们说的jar包,它可以提供一些即插即用的功能,比如读取或者操作文件,连接网络,使用I/O等等之类的)这些东西就是JRE提供的基础类库。JVM 标准加上实现的一大堆基础类库,就组成了 Java 的运行时环境,也就是我们常说的 JRE(Java Runtime Environment)。

但对于程序员来说,JRE还不够。我写完要编译代码,还需要调试代码,还需要打包代码、有时候还需要反编译代码。所以我们会使用JDK,因为JDK还提供了一些非常好用的小工具,比如 javac(编译代码,即将java源文件编译成.class字节码文件)、java、jar (打包代码)、javap(反编译<反汇编>,即对javac编译生成的class文件进行反编译)等。这个就是JDK。

具体文档可以通过官网去下载:https://www.oracle.com/java/technologies/javase-jdk8-doc-downloads.html

JVM整体

在这里插入图片描述

一个 Java 程序,首先经过 javac 编译成 .class 文件,然后 JVM 将其加载到方法区,执行引擎将会执行这些字节码。执行时,会翻译成操作系统相关的函数。JVM 作为 .class 文件的翻译存在,输入字节码,调用操作系统函数。
过程如下:Java 文件->编译器>字节码->JVM->机器码。

我们所说的 JVM,狭义上指的就 HotSpot(因为JVM有很多版本,但是使用最多的是HotSpot)。如非特殊说明,我们都以 HotSpot 为准。Java 之所以成为跨平台,就是由于 JVM 的存在。Java 的字节码,是沟通 Java 语言与 JVM 的桥梁,同时也是沟通 JVM 与操作系统的桥梁。

解释执行与JIT:
Java程序最初是仅仅通过解释器解释执行的,即对字节码逐条解释执行(节省内存,不需要存储编译后的机器码),这种方式的执行速度相对会比较慢(因为在执行时才编译那段代码),尤其当某个方法或代码块运行的特别频繁时,这种方式的执行效率就显得很低。于是后来在虚拟机中引入了JIT编译器(即时编译器),当虚拟机发现某个方法或代码块运行特别频繁时,就会把这些代码认定为“Hot Spot Code”(热点代码),为了提高热点代码的执行效率,在运行时,虚拟机将会把这些代码编译成与本地平台相关的机器码,并进行各层次的优化,存储在方法区,完成这项任务的正是JIT编译器。

二者各有优势:当程序需要迅速启动和执行时,解释器可以首先发挥作用,省去编译的时间,立即执行;当程序运行后,随着时间的推移,编译器逐渐发挥作用,把越来越多的代码编译成本地代码后存储在方法区,可以获取更高的执行效率。解释执行可以节约内存,而编译执行可以提升执行效率。
总结:
1、解释器
程序启动时首先发挥作用,解释执行Class字节码;
省去编译时间,加快启动速度;
节省内存但执行效率较低;

2、JIT编译器
程序解释运行后,JIT编译器逐渐发挥作用;
编译成本地代码,提高执行效率;
但占用内存等资源;

运行时数据区域

Java 引以为豪的就是它的自动内存管理机制。相比于 C++的手动内存管理、复杂难以理解的指针等,Java 程序写起来就方便的多。

Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域。

在 Java 中,JVM 内存主要分为堆、程序计数器、方法区(包含运行时常量池)、虚拟机栈和本地方法栈。
在这里插入图片描述

线程私有的区域

程序计数器

较小的内存空间,指向当前线程正在执行的字节码指令的地址,可以看作是当前线程执行的字节码的行号指示器;各线程之间独立存储,互不影响。

程序计数器是一块很小的内存空间,主要用来记录各个线程执行的字节码的地址,例如,分支、循环、跳转、异常、线程恢复等都依赖于计数器。

由于 Java 是多线程语言,当执行的线程数量超过 CPU 核数时,线程之间会根据时间片轮询争夺 CPU 资源。如果一个线程的时间片用完了,或者是其它原因导致这个线程的 CPU 资源被提前抢夺,那么这个退出的线程就需要单独的一个程序计数器,来记录下一条运行的指令。

如果线程正在执行的是一个java方法,程序计数器记录的是正在执行的虚拟机字节码指令地址;
若线程执行的是Native方法,程序计数器则为Undefined。

程序计数器也是JVM中唯一不会OOM(OutOfMemory)的内存区域

虚拟机栈

在这里插入图片描述
上图描述了线程私有的虚拟机栈和程序计数器

栈是什么样的数据结构?先进后出(FILO)的数据结构,

虚拟机栈在JVM运行过程中存储当前线程运行方法所需的数据,指令、返回地址。

Java 虚拟机栈是基于线程的。哪怕你只有一个 main() 方法,也是以线程的方式运行的。在线程的生命周期中,参与计算的数据会频繁地入栈和出栈,栈的生命周期是和线程一样的。
栈里的每条数据,就是栈帧。在每个 Java 方法被调用的时候,都会创建一个栈帧,并入栈。一旦完成相应的调用,则出栈。所有的栈帧都出栈后,线程也就结束了。

栈的大小缺省为1M,可用参数 –Xss调整大小,例如-Xss256k

每个栈帧,都包含四个区域:局部变量表、操作数栈、动态连接、返回地址(完成出口)
局部变量表:顾名思义就是局部变量的表,用于存放我们的局部变量的。首先它是一个32位的长度,主要存放我们的Java的八大基础数据类型,一般32位就可以存放下,如果是64位的就使用高低位占用两个也可以存放下,如果是局部的一些对象,比如我们的Object对象,我们只需要存放它的一个引用地址即可。
操作数栈:存放我们方法执行的操作数的,它就是一个栈,先进后出的栈结构,操作数栈,就是用来操作的,操作的的元素可以是任意的java数据类型,所以我们知道一个方法刚刚开始的时候,这个方法的操作数栈就是空的,操作数栈运行方法就是JVM一直运行入栈/出栈的操作
动态连接:Java语言特性多态(需要类运行时才能确定具体的方法)。
返回地址:正常返回(调用程序计数器中的地址作为返回)、异常的话(通过异常处理器表<非栈帧中的>来确定)

栈帧执行对内存区域的影响

字节码助记码解释地址:https://cloud.tencent.com/developer/article/1333540

在这里插入图片描述
在这里插入图片描述

测试代码:

/**
 * @author King老师
 * 栈帧执行对内存区域的影响
 */
public class Person {
    
    

    public int work() throws Exception {
    
    //运行过程中,打包一个栈帧
        int x = 1;//x是一个局部变量
        int y = 2;
        int z = (x + y) * 10;
        return z;
    }

    public static void main(String[] args) throws Exception {
    
    
        Person person = new Person();//person是一个引用,存放在栈中,new Person()是创建一个对象,存放在堆中
        person.work();//执行完了,出栈
        person.hashCode();
        int i = 12;
    }
}

执行:javap -c Person.class

javap -c Person.class
Compiled from "Person.java"
public class com.example.javaadvanced.jvm.ex1.Person {
    
    
  public com.example.javaadvanced.jvm.ex1.Person();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return

  public int work() throws java.lang.Exception;
    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[]) throws java.lang.Exception;
    Code:
       0: new           #2                  // class com/example/javaadvanced/jvm/ex1/Person
       3: dup
       4: invokespecial #3                  // Method "<init>":()V
       7: astore_1
       8: aload_1
       9: invokevirtual #4                  // Method work:()I
      12: pop
      13: aload_1
      14: invokevirtual #5                  // Method java/lang/Object.hashCode:()I
      17: pop
      18: bipush        12
      20: istore_2
      21: return
}

重点看work()方法的字节码:

  public int work() throws java.lang.Exception;
    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

注意,字节码前面都有一个数字,可以认为是相对于work()方法体的偏移量。
可以认为程序计数器记录的就是这个行号,细心的同学可能发现 7 怎么就跳到了9 了,这是因为不同的指令偏移量不一样,一般的指令偏移量是1,bipush这个指令偏移量是2,所以直接到了9。

iconst 和 bipush: 这两个指令都是将常量压入操作数栈,如果取值范围是 -1~5 则采用 iconst 指令,如果取值是-128~127 之间(除了-1~5的范围),则使用 bipush 入栈指令。还有 sipush、ldc 等指令,具体看数字。

istore:将操作数栈栈顶元素存放如局部变量表的某索引位置。
比如上面: istore_1 则代表将操作数栈栈顶元素存放在局部变量表中索引为 1 的位置。

iload:将局部变量表中某索引位置的值取出并压入到操作数栈栈顶位置。
比如上面:iload_1 将局部变量表中索引为 1 的值取出,并压入到操作数栈的栈顶位置。

iadd: 将操作数栈栈顶的两个元素弹出进行相加,并将结果重新压入操作数栈的栈顶。

imul: 将操作数栈栈顶的两个元素弹出进行相乘,并将结果重新压入操作数栈的栈顶。

下面分析执行work()方法对应的字节码指令时,栈中work()方法的栈帧的数据存储情况:

执行到work()方法时

在这里插入图片描述
局部表量表中下标为 0 位置处的 this 引用变量是栈帧创建时默认存放的,指向当前对象,静态方法没有这个引用变量。

iconst_1 ,将int型1压入操作数栈
在这里插入图片描述

istore_1 ,将操作数栈的栈顶的int型数据存储到局部变量中(下标为1的位置)
在这里插入图片描述
iconst_2, 将int型2压入操作数栈
istore_2, 将操作数栈的栈顶的int型数据存储到局部变量中(下标为2的位置)
在这里插入图片描述
iload_1 ,将局部变量表中下标为1的int型数据压入操作数栈
iload_2 ,将局部变量表中下标为2的int型数据压入操作数栈
在这里插入图片描述

iadd,将操作数栈的栈顶2个int型数据出栈,相加,并将结果入栈

在这里插入图片描述
bipush 10,将10压入操作数栈
在这里插入图片描述
imul 将操作数栈的栈顶2个int数据出栈,相乘,并将结果压入操作数栈
在这里插入图片描述
istore_3 , 将操作数栈的栈顶的int型数据存储到局部变量中(下标为3的位置)
在这里插入图片描述

iload_3 将局部变量表中下标为3的int型数据压入操作数栈
在这里插入图片描述

ireturn 将操作数栈的栈顶int型数据出栈,并返回给方法的调用者

在这里插入图片描述

work方法执行完成后,虚拟机栈中的work方法的栈帧出栈,虚拟机栈中只有一个main方法的栈帧。随着main方法的执行完成,main方法的栈帧也出栈,虚拟机栈中没有栈帧。

为什么说JVM是基于栈的解释执行
在JVM中,基于解释执行的这种方式是基于栈的引擎,这个说的栈,就是操作数栈。jvm执行字节码的过程就是在操作数栈中不断进行入栈出栈的过程。

JAVA的执行是基于栈(栈帧中的操作数栈)的。优点:兼容性好;缺点:执行速度偏慢。
C/C++的执行是基于寄存器(硬件)的。优点:因为是在寄存器中执行指令的,所以执行速度快;缺点:移植性差,每次修改代码都要花费大量时间进行make install操作,将代码编译为对应的机器指令。

本地方法栈

本地方法栈保存的是native方法的信息。

本地方法栈跟 Java 虚拟机栈的功能类似,Java 虚拟机栈用于管理 Java 函数的调用,而本地方法栈则用于管理本地方法的调用。但本地方法并不是用 Java 实现的,而是由 C 语言实现的。

本地方法栈是和虚拟机栈非常相似的一个区域,它服务的对象是 native 方法。你甚至可以认为虚拟机栈和本地方法栈是同一个区域。

虚拟机规范无强制规定,各版本虚拟机自由实现 ,HotSpot直接把本地方法栈和虚拟机栈合二为一 。

当一个JVM创建的线程调用native方法后,JVM不再为其在虚拟机栈中创建栈帧,JVM只是简单地动态链接并直接调用native方法。

线程共享的区域

方法区(永久代)

存储已被虚拟机加载的类的信息,包括:

  • 类型信息
  • 字段信息
  • 方法信息
  • 常量池(直接常量和相应类型所用到的所有类型、字段和方法的符号引用)
  • 静态变量
  • 即时编译器编译后的代码等

比如:

	//obj1是静态变量,存储在方法区,new Object()创建出的对象存储在堆中
    public static Object obj1 = new Object(); 
    //obj2是常量,存储在方法区,new Object()创建出的对象存储在堆中
    public static final Object obj2 = new Object();
    //str是静态变量,存储在方法区,"abc"是字面量,存储在方法区的运行时常量池中。
    public static String str = "abc";

很多开发者都习惯将方法区称为“永久代”,其实这两者并不是等价的。
HotSpot 虚拟机使用永久代来实现方法区,但在其它虚拟机中,例如,Oracle 的 JRockit、IBM 的 J9 就不存在永久代一说。因此,方法区只是 JVM 中规范的一部分,可以说,在 HotSpot 虚拟机中,设计人员使用了永久代来实现了 JVM 规范的方法区。

方法区主要是用来存放已被虚拟机加载的类相关信息,包括类信息、静态变量、常量、运行时常量池、字符串常量池。

JVM 在执行某个类的时候,必须先加载该类。在加载类(加载、验证、准备、解析、初始化)的时候,JVM 会先加载 class 文件,而在 class 文件中除了有类的版本、字段、方法和接口等描述信息外,还有一项信息是常量池 (Constant Pool Table),用于存放编译期间生成的各种字面量和符号引用。

字面量包括字符串(String a=“b”)、基本类型的常量(final 修饰的变量),符号引用则包括类和方法的全限定名(例如 String 这个类,它的全限定名就是 Java/lang/String)、字段的名称和描述符以及方法的名称和描述符。

而当类加载到内存中后,JVM 就会将 class 文件常量池中的内容存放到运行时的常量池中;在解析阶段,JVM 会把符号引用替换为直接引用(对象的索引值)。
例如,类中的一个字符串常量在 class 文件中时,存放在 class 文件常量池中的;在 JVM 加载完类之后,JVM 会将这个字符串常量放到运行时常量池中,并在解析阶段,指定该字符串对象的索引值。运行时常量池是全局共享的,多个类共用一个运行时常量池,class 文件中常量池的多个相同的字符串在运行时常量池中只会存在一份。

方法区与堆空间类似,也是一个共享内存区,所以方法区是线程共享的。假如两个线程都试图访问方法区中的同一个类信息,而这个类还没有装入 JVM,那么此时就只允许一个线程去加载它,另一个线程必须等待。在 HotSpot 虚拟机、Java7 版本中已经将永久代的静态变量和运行时常量池转移到了堆中,其余部分则存储在 JVM 的非堆内存中,而 Java8 版本已经将方法区中实现的永久代去掉了,并用元空间(Metaspace)代替了之前的永久代(PermGen),永久代中的 class metadata 数据转移到了Metaspace,并且元空间的存储位置是本地内存,而不是虚拟机中。元空间的本质和永久代类似,都是对JVM规范中方法区的实现,不过元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,是使用本地内存。

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

JVM参数参考:https://docs.oracle.com/javase/8/docs/technotes/tools/unix/java.html

Java8 为什么使用元空间替代永久代,这样做有什么好处呢?
官方给出的解释是:
移除永久代是为了融合 HotSpot JVM 与 JRockit VM 而做出的努力,因为 JRockit 没有永久代,所以不需要配置永久代。
永久代内存经常不够用或发生内存溢出,抛出异常 java.lang.OutOfMemoryError: PermGen。这是因为在 JDK1.7 版本中,指定的 PermGen 区大小为 8M,由于 PermGen 中类的元数据信息(class metadata)在每次 FullGC 的时候都可能被收集,回收率都偏低,成绩很难令人满意;还有,为 PermGen 分配多大的空间很难确定,PermSize 的大小依赖于很多因素,比如,JVM 加载的 class 总数、常量池的大小和方法的大小等。

对于方法区和永久代的区别,简单总结一下就是:

  • 方法区是JVM规范层面的东西,规定了这一个区域要存放哪些数据。
  • 永久代或者元空间是实现层面的东西,是对方法区的不同的具体实现。

JVM的方法区,为什么叫做方法区?
方法区主要用于存储.class文件加载进JVM的数据,包括类型信息,字段信息,方法信息(包括方法的字节码),常量池等,而一个class文件的主体是方法的字节码,也就是说相比上面提到的所有的数据,方法的字节码的是占大多数的,也可以说方法区中绝大多数数据都是方法的字节码,所以这块内存区域叫做方法区,即存储方法的字节码。

堆中存储的数据包括:

  • 对象实例(几乎所有)
  • 数组(数组也是对象,即数组也是Object的实例)

堆是 JVM 上最大的内存区域,我们申请的几乎所有的对象,都是在这里存储的。我们常说的垃圾回收,操作的对象就是堆。
堆空间一般是程序启动时,就申请了,但是并不一定会全部使用。
随着对象的频繁创建,堆空间占用的越来越多,就需要不定期的对不再使用的对象进行回收。这个在 Java 中,就叫作 GC(Garbage Collection)。
那一个对象创建的时候,到底是在堆上分配,还是在栈上分配呢?这和两个方面有关:对象的类型和在 Java 类中存在的位置。
Java 的对象可以分为基本数据类型和普通对象。
对于普通对象来说,JVM 会首先在堆上创建对象,然后在其他地方使用的其实是它的引用。比如,把这个引用保存在虚拟机栈的局部变量表中。
对于基本数据类型来说(byte、short、int、long、float、double、char),有两种情况。当你在方法体内声明了基本数据类型的对象,它就会在栈上直接分配。其他情况,都是在堆上分配。

堆大小参数
-Xms:堆的最小值,堆区内存初始内存分配的大小;
-Xmx:堆的最大值,堆区内存可被分配的最大上限;
-Xmn:新生代的大小;
-XX:NewSize;新生代最小值;
-XX:MaxNewSize:新生代最大值;
例如- Xmx256m

直接内存(本地内存,堆外内存)

在这里插入图片描述

不是虚拟机运行时数据区的一部分,也不是java虚拟机规范中定义的内存区域;

如果使用了NIO,这块区域会被频繁使用,在java堆内可以用DirectByteBuffer对象直接引用并操作;

这块内存不受java堆大小限制,但受本机总内存的限制,可以通过-XX:MaxDirectMemorySize来设置(默认与堆内存最大值一样),所以也会出现OOM异常。

从底层深入理解运行时数据区

查看下java版本:java -version

java version "14.0.2" 2020-07-14
Java(TM) SE Runtime Environment (build 14.0.2+12-46)
Java HotSpot(TM) 64-Bit Server VM (build 14.0.2+12-46, mixed mode, sharing)

运行测试代码:

/**
 * @author King老师
 * 从底层深入理解运行时数据区
 * -Xms30m -Xmx30m -XX:+UseConcMarkSweepGC -XX:-UseCompressedOops
 * -Xss1m
 */
public class JVMObject {
    
    
    public final static String MAN_TYPE = "man"; // 常量
    public static String WOMAN_TYPE = "woman";  // 静态变量

    public static void  main(String[] args)throws Exception {
    
    //栈帧
        Teacher T1 = new Teacher();//Teacher对象存在堆中,  T1引用变量,存在虚拟机栈的栈帧的局部变量表中
        T1.setName("Mark");
        T1.setSexType(MAN_TYPE);
        T1.setAge(36);
        for (int i=0;i<15;i++){
    
    //进行15次垃圾回收
            System.gc();//垃圾回收
        }
        Teacher T2 = new Teacher();
        T2.setName("King");
        T2.setSexType(MAN_TYPE);
        T2.setAge(18);
        Thread.sleep(Integer.MAX_VALUE);//线程休眠很久很久
    }
}

class Teacher{
    
    
    String name;
    String sexType;
    int age;

    public String getName() {
    
    
        return name;
    }
    public void setName(String name) {
    
    
        this.name = name;
    }

    public String getSexType() {
    
    
        return sexType;
    }
    public void setSexType(String sexType) {
    
    
        this.sexType = sexType;
    }
    public int getAge() {
    
    
        return age;
    }
    public void setAge(int age) {
    
    
        this.age = age;
    }
}

当我们通过 Java 运行以上代码时,JVM 的整个处理过程如下:
1.JVM 向操作系统申请内存。JVM 第一步就是通过配置参数或者默认配置参数向操作系统申请内存空间。
2.JVM 获得内存空间后,会根据配置参数分配堆、栈以及方法区的内存大小。
3. class 文件加载、验证、准备以及解析,其中准备阶段会为类的静态变量分配内存,初始化为系统的初始值。(系统的初始值,对于变量或静态变量赋值默认值,即比如int就是0,String就是null;而常量则是赋值初始值)。
4.完成上一个步骤后,将会进行最后一个初始化阶段。在这个阶段中,JVM 首先会执行构造器<clinit>方法,编译器会在.java 文件被编译成.class 文件时,收集所有类的初始化代码,包括静态变量赋值语句、静态代码块、静态方法,收集在一起成为<clinit>方法。
5.执行方法。启动 main 线程,执行 main 方法,开始执行第一行代码。此时堆内存中会创建一个 Teacher 对象,对象引用 T1 就存放在栈中。
执行其他方法时,具体的操作参看上面的:栈帧执行对内存区域的影响

这里先执行: javap -c JVMObject.class
查看下JVMObject的字节码(后面分析会用到JVMObject的字节码):

Compiled from "JVMObject.java"
public class com.example.javaadvanced.jvm.ex1.JVMObject {
    
    
  public static final java.lang.String MAN_TYPE;

  public static java.lang.String WOMAN_TYPE;

  public com.example.javaadvanced.jvm.ex1.JVMObject();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return

  public static void main(java.lang.String[]) throws java.lang.Exception;
    Code:
       0: new           #2                  // class com/example/javaadvanced/jvm/ex1/Teacher
       3: dup
       4: invokespecial #3                  // Method com/example/javaadvanced/jvm/ex1/Teacher."<init>":()V
       7: astore_1
       8: aload_1
       9: ldc           #4                  // String Mark
      11: invokevirtual #5                  // Method com/example/javaadvanced/jvm/ex1/Teacher.setName:(Ljava/lang/String;)V
      14: aload_1
      15: ldc           #7                  // String man
      17: invokevirtual #8                  // Method com/example/javaadvanced/jvm/ex1/Teacher.setSexType:(Ljava/lang/String;)V
      20: aload_1
      21: bipush        36
      23: invokevirtual #9                  // Method com/example/javaadvanced/jvm/ex1/Teacher.setAge:(I)V
      26: iconst_0
      27: istore_2
      28: iload_2
      29: bipush        15
      31: if_icmpge     43
      34: invokestatic  #10                 // Method java/lang/System.gc:()V
      37: iinc          2, 1
      40: goto          28
      43: new           #2                  // class com/example/javaadvanced/jvm/ex1/Teacher
      46: dup
      47: invokespecial #3                  // Method com/example/javaadvanced/jvm/ex1/Teacher."<init>":()V
      50: astore_2
      51: aload_2
      52: ldc           #11                 // String King
      54: invokevirtual #5                  // Method com/example/javaadvanced/jvm/ex1/Teacher.setName:(Ljava/lang/String;)V
      57: aload_2
      58: ldc           #7                  // String man
      60: invokevirtual #8                  // Method com/example/javaadvanced/jvm/ex1/Teacher.setSexType:(Ljava/lang/String;)V
      63: aload_2
      64: bipush        18
      66: invokevirtual #9                  // Method com/example/javaadvanced/jvm/ex1/Teacher.setAge:(I)V
      69: ldc2_w        #13                 // long 2147483647l
      72: invokestatic  #15                 // Method java/lang/Thread.sleep:(J)V
      75: return

  static {
    
    };
    Code:
       0: ldc           #16                 // String woman
       2: putstatic     #17                 // Field WOMAN_TYPE:Ljava/lang/String;
       5: return
}

执行JVMObject.main()方法前,内存中还没有数据:
在这里插入图片描述
JVMObject.main()方法执行后,内存中的数据分布情况:
在这里插入图片描述

如何验证上述所说的内存数据分布情况呢?使用HSDB工具分析。
JDK1.8之后版本启动JHSDB的时候必须将sawindbg.dll复制到对应的目录下(一台计算机中可能既装了jdk,也可能还装了其他的jre,比如Android Studio自带的jre,在jdk和jre目录下搜索sawindbg.dll,复制到报错提示的那个目录下)

否则运行时会报类似下面的错误:

C:\Program Files\Android\Android Studio\jre\lib>java -cp .\sa-jdi.jar sun.jvm.hotspot.HSDB
Exception in thread "Thread-1" java.lang.UnsatisfiedLinkError: Can't load library: C:\Program Files\Java\jdk-14.0.2\bin\sawindbg.dll
        at java.base/java.lang.ClassLoader.loadLibrary(ClassLoader.java:2640)
        at java.base/java.lang.Runtime.load0(Runtime.java:745)
        at java.base/java.lang.System.load(System.java:1871)
        at sun.jvm.hotspot.debugger.windbg.WindbgDebuggerLocal.<clinit>(WindbgDebuggerLocal.java:661)
        at sun.jvm.hotspot.HotSpotAgent.setupDebuggerWin32(HotSpotAgent.java:567)
        at sun.jvm.hotspot.HotSpotAgent.setupDebugger(HotSpotAgent.java:335)
        at sun.jvm.hotspot.HotSpotAgent.go(HotSpotAgent.java:304)
        at sun.jvm.hotspot.HotSpotAgent.attach(HotSpotAgent.java:140)
        at sun.jvm.hotspot.HSDB.attach(HSDB.java:1191)
        at sun.jvm.hotspot.HSDB.access$2000(HSDB.java:53)
        at sun.jvm.hotspot.HSDB$25$1.run(HSDB.java:463)
        at sun.jvm.hotspot.utilities.WorkerThread$MainLoop.run(WorkerThread.java:66)
        at java.base/java.lang.Thread.run(Thread.java:832)

(遇到类似上述报错时,将sawindbg.dll复制到报错提示中的C:\Program Files\Java\jdk-14.0.2\bin\目录下即可。)

然后进入sa-jdi.jar所在的目录下(因为sun.jvm.hotspot.HSDB这个类在sa-jdi.jar里面),测试时sa-jdi.jar是在C:\Program Files\Android\Android Studio\jre\lib目录下。
执行 java -cp .\sa-jdi.jar sun.jvm.hotspot.HSDB
出现界面:
在这里插入图片描述
重新打开一个新的cmd命令行,执行: jps,查看运行JVMObject.main()方法的进程

在这里插入图片描述
可以看到是22536
然后在HSDB里面点击File->Attach to HotSpot process,显示:
在这里插入图片描述
输入22536,点击OK,显示:
在这里插入图片描述

查看虚拟机栈的栈帧信息:
选择main线程,然后点击左上角第二个按钮(Stack Memory),显示:
在这里插入图片描述

可以看到有两个栈帧,一个是main方法的,一个是Thread.sleep()方法的,因为Thread.sleep()会调用一个native的sleep方法,所以上面可以看到(kind=native),由于在HotSpot虚拟机中并不区分虚拟机栈和本地方法栈(即把虚拟机栈和本地方法栈合二为一),所以可以看到这个native方法的栈帧。

然后查看堆中对象的信息:
点击Tools->Object Histogram
在这里插入图片描述

输入JVMObject所在的包名,点击搜索,显示:
在这里插入图片描述
双击Teacher这一行,可以看到main方法中创建的两个Teacher对象的信息
在这里插入图片描述
第一列是对象的内存地址,分别是0x00000006c2a01998和0x000000076b900000
点击Inspect可以查看这个对象的具体信息:
在这里插入图片描述

再点击Tools->Heap Parameters 查看堆的内存地址
在这里插入图片描述
可以看到新生代(包括eden,from,to) 和老年代的内存地址区间。

上面查看对象地址可以看到其中一个对象的地址是0x00000006c2a01998,刚好在老年代的地址区间内,另一个对象的地址是0x000000076b900000,刚好在eden的起始地址。

查看方法区的数据:
java的类的方法在编译后会生成字节码指令,在运行时,字节码指令会被加载到jvm的内存的方法区中,使用HSDB可以查看运行时内存中的字节码指令。

点击Tools -> Class Browser,可以看到所有的.class文件加载到JVM内存后的信息:
在这里插入图片描述
这些不是类的实例对象,也不是类的java.lang.Class对象,而是类的InstanceKlass对象。
如何验证?
可以打开HSDB的左上角的 Windows -> Console 命令行输入whatis指令查看:
在这里插入图片描述
显示 pointer to InstanceKlass ,即 0x00000007c0060828 这个地址指向一个InstanceKlass对象,0x00000007c0060828即是JVMObject的InstanceKlass对象的地址。

点击public class com.example.javaadvanced.jvm.ex1.JVMObject @0x00000007c0060828
在这里插入图片描述
可以看到JVMObject的InstanceKlass对象的部分信息,包括类型信息,字段信息,方法信息,常量池信息。

可以进一步使用 Tools -> Inspector 查看对应地址处的对象的全部数据:
在这里插入图片描述
其中:oop Klass::_java_mirror: Oop for java/lang/Class @ 0x00000006c2a08670 描述的就是InstanceKlass对象所持有的java.lang.Class对象的引用,该java.lang.Class对象的内存地址是 0x00000006c2a08670

点击main方法:
在这里插入图片描述
可以看到main方法的签名(public static void main(java.lang.String[]) )和main方法的地址(@0x000000001cae2c88),以及main方法的字节码,下面是main方法的完整的字节码:
在这里插入图片描述
可以和用 javap -c JVMObject.class 得到的字节码对比看,与编译得到的字节码相比,JVM运行时内存中的字节码把其中的符号引用都换成了直接引用。

点击Constant Pool,查看常量池的数据:
在这里插入图片描述
查看JVMObject的class对象地址与堆内存地址的关系:

在这里插入图片描述
java.lang.Class对象的内存地址是 0x00000006c2a08670,老年代地址是:PSOldGen [ [0x00000006c2a00000,0x00000006c2ab4818,0x00000006cd380000] ] ]
java.lang.Class对象的内存地址是 0x00000006c2a08670 刚好在老年代地址区间内,即java.lang.Class对象存储在堆中。

深入辨析堆和栈

功能
以栈帧的方式存储方法调用的过程,并存储方法调用过程中基本数据类型的变量(int、short、long、byte、float、double、boolean、char等)以及对象的引用变量,其内存分配在栈上,变量出了作用域就会自动释放;

而堆内存用来存储Java中的对象。无论是成员变量,局部变量,还是类变量,它们指向的对象都存储在堆内存中;

线程独享还是共享
栈内存归属于单个线程,每个线程都会有一个栈内存,其存储的变量只能在其所属线程中可见,即栈内存可以理解成线程的私有内存。
堆内存中的对象对所有线程可见。堆内存中的对象可以被所有线程访问。

空间大小
栈的内存要远远小于堆内存

内存溢出

栈溢出

参数:-Xss1m, 具体默认值需要查看官网:https://docs.oracle.com/javase/8/docs/technotes/tools/unix/java.html#BABHDABI
在这里插入图片描述

HotSpot版本中栈的大小是固定的,是不支持拓展的。
java.lang.StackOverflowError 一般的方法调用是很难出现的,如果出现了可能会是无限递归。
虚拟机栈带给我们的启示:方法的执行因为要打包成栈桢,所以天生要比实现同样功能的循环慢,所以树的遍历算法中:递归和非递归(循环来实现)都有存在的意义。递归代码简洁,非递归代码复杂但是速度较快。
java.lang.OutOfMemoryError:不断建立线程,JVM申请栈内存,机器没有足够的内存。(一般演示不出,演示出来机器也死了)

堆溢出

内存溢出:申请内存空间,超出最大堆内存空间。
如果是内存溢出,则通过 调大 -Xms,-Xmx参数。
如果不是内存泄漏,就是说内存中的对象却是都是必须存活的,那么久应该检查JVM的堆参数设置,与机器的内存对比,看是否还有可以调整的空间,再从代码上检查是否存在某些对象生命周期过长、持有状态时间过长、存储结构设计不合理等情况,尽量减少程序运行时的内存消耗。

方法区溢出

(1) 运行时常量池溢出
(2)方法区中保存的Class对象没有被及时回收掉或者Class信息占用的内存超过了我们配置。

注意Class要被回收,条件比较苛刻(仅仅是可以,不代表必然,因为还有一些参数可以进行控制)
1、该类所有的实例都已经被回收,也就是堆中不存在该类的任何实例。
2、加载该类的ClassLoader已经被回收。
3、该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
在这里插入图片描述

代码示例
cglib是一个强大的,高性能,高质量的Code生成类库,它可以在运行期扩展Java类与实现Java接口。
CGLIB包的底层是通过使用一个小而快的字节码处理框架ASM,来转换字节码并生成新的类。除了CGLIB包,脚本语言例如Groovy和BeanShell,也是使用ASM来生成java的字节码。当然不鼓励直接使用ASM,因为它要求你必须对JVM内部结构包括class文件的格式和指令集都很熟悉。

本机直接内存溢出

直接内存的容量可以通过MaxDirectMemorySize来设置(默认与堆内存最大值一样),所以也会出现OOM异常;
由直接内存导致的内存溢出,一个比较明显的特征是在HeapDump文件中不会看见有什么明显的异常情况,如果发生了OOM,同时Dump文件很小,可以考虑重点排查下直接内存方面的原因。

虚拟机优化技术

编译优化技术——方法内联

方法内联的优化行为,就是把目标方法的代码原封不动的“复制”到调用的方法中,避免真实的方法调用而已。
在这里插入图片描述

栈的优化技术——栈帧之间数据的共享

在一般的模型中,两个不同的栈帧的内存区域是独立的,但是大部分的JVM在实现中会进行一些优化,使得两个栈帧出现一部分重叠。(主要体现在方法中有参数传递的情况),让下面栈帧的操作数栈和上面栈帧的部分局部变量重叠在一起,这样做不但节约了一部分空间,更加重要的是在进行方法调用时就可以直接公用一部分数据,无需进行额外的参数复制传递了。
在这里插入图片描述

图中的栈帧信息就是指动态连接、返回地址等栈帧数据。

使用HSDB工具查看栈空间一样可以看到。

在这里插入图片描述

常见面试题

1. java中的类什么时候被回收? 或者说方法区的类什么时候被卸载?

当一个类满足下面所有条件时:
1.该类的所有实例都被回收
2.加载该类的classloader已经被回收
3.该类的Class对象没有在任何地方被引用,且没有在任何地方通过反射访问该类。
4.JVM没有设置-Xnoclassgc参数

2. JVM 有哪些内存区域?(JVM 的内存布局是什么?)

虚拟机栈,堆,方法区,程序计数器,本地方法栈

3. StackOverFlow与OOM的区别?分别发生在什么时候,JVM栈中存储的是什么?堆中存储是什么?

  1. StackOverFlow:
    每当启动一个新的线程时,jvm会为他分配一个栈,当线程每调用一个方法时,jvm向栈中压入一个新的栈帧,如果方法的嵌套调用层次太多(如递归调用),随着栈中的栈帧的数量增多,最终导致这个线程的栈中的所有栈帧的大小的总和大于-Xss设置的值,就会产生StackOverflowError溢出异常。

  2. OutOfMemory(OOM)
    2.1 堆内存溢出
    java堆用于存放对象的实例,当需要为对象的实例分配内存时,而堆的占用已经达到了设置的最大值(通过-Xmx)设置最大值,则抛出OutOfMemoryError异常。
    2.2 栈内存溢出
    java程序启动一个新线程时,没有足够的空间为该线程分配java栈,一个线程java栈的大小由-Xss设置决定;JVM则抛出OutOfMemoryError异常。
    2.3 方法区内存溢出
    方法区用于存放java类的相关信息,如类的全限定名、访问修饰符、常量池、字段描述、方法描述等。在类加载器加载class文件到内存中的时候,JVM会提取其中的类信息,并将这些类信息放到方法区中。当方法区的内存占用又已经达到最大值(通过-XX:MaxPermSize),将会抛出OutOfMemoryError异常。
    对于这种情况的测试,基本的思路是运行时产生大量的类去填满方法区,直到溢出。这里需要借助CGLib直接操作字节码运行时,生成了大量的动态类

栈中存储:
当前线程运行方法所需的数据(一个方法对应一个栈帧),比如基本数据类型,引用变量,返回地址。

堆中存储的数据包括:

  • 对象实例(几乎所有)
  • 数组(数组也是对象,即数组也是Object的实例)

https://docs.oracle.com/javase/specs/jvms/se7/html/jvms-6.html

[三] java虚拟机 JVM字节码 指令集 bytecode 操作码 指令分类用法 助记符

JVM内存模型
【JVM之内存与垃圾回收篇】方法区
不会吧不会吧,不会还有人不知道JVM 方法区吧
Java方法内联
JVM之栈与栈帧
int a = 1,到底存在于JVM的哪里

JVM栈帧内部结构-动态链接
栈帧中动态连接的理解

查看jvm运行时字节码指令
第一次使用HSDB

猜你喜欢

转载自blog.csdn.net/yzpbright/article/details/107888056