JVM系列1_内存与垃圾回收篇

248 阅读44分钟

本文已参与「新人创作礼」活动,一起开启掘金创作之路。


  • 一个能够运行字节码的虚拟机。
  • 屏蔽了具体的操作系统的信息。
  • 正是以上两点,使得Java程序具有一次编译,到处执行的特性。

JVM结构

image.png

1.JVM与Java体系结构

1.1 前言

是否遇到过这些问题?

  • 运行着的线上系统突然卡死,系统无法访问,甚至直接 OOM
  • 想解决线上JVM GC问题,但却无从下手
  • 新项目上线,对各种 JVM 参数设置一脸茫然,直接默认吧然后就JJ了
  • 每次面试之前都要重新背一遍JVM的一些原理概念性的东西,然而面试官却经常问你在实际项目中如何调优 JVM 参数,如何解决 GCOOM 等问题,一脸懵逼

image.png

大部分Java开发人员,除会在项目中使用到与Java平台相关的各种高精尖技术,对于Java技术的核心Java虚拟机了解甚少。

🍒开发人员如何看待上层框架

部分人认为SSM、微服务等上层技术才是重点,基础技术并不重要,这其实是一种本末倒置的“病态”。

如果我们把核心类库的 API 比做数学公式的话,那么Java虚拟机的知识就好比公式的推导过程。

🍒我们为什么要学习JVM?

  • 面试的需要(BATJ、TMD,PKQ等面试都爱问)
  • 中高级程序员必备技能
    • 项目管理、调优的需要
  • 追求极客的精神
    • 比如:垃圾回收算法、JIT、底层原理

🍒Java vs C++

垃圾收集机制为我们打理了很多繁琐的工作,大大提高了开发的效率,但是,垃圾收集也不是万能的。

懂得JVM内部的内存结构、工作机制,是设计高扩展性应用和诊断运行时问题的基础,也是Java工程师进阶的必备能力。

1.2 面向人群及参考书目

《深入理解Java虚拟机》周志明

1.3 Java及JVM简介

TIOBE语言热度排行榜: index | TIOBE - The Software Quality Company

Programming Language20212016201120062001199619911986
C12221111
Java2111326--
Python35682719--
C++43332228
C#545713---
Visual Basic613------
JavaScript781091032--
PHP864411---
SQL9-------
R101731-----
Lisp3427131417742
Ada3628171620853
(Visual) Basic--764335

世界上没有最好的编程语言,只有最适用于具体应用场景的编程语言。

🍒JVM:跨语言的平台

每个语言都需要转换成字节码文件,最后转换的字节码文件都能通过Java虚拟机进行运行和处理。

🍒字节码

  • 我们平时说的java字节码,指的是用java语言编译成的字节码。准确的说任何能在jvm平台上执行的字节码格式都是一样的。所以应该统称为:jvm字节码

🍒如何真正搞懂JVM?

  • Java虚拟机非常复杂,要想真正理解它的工作原理,最好的方式就是自己动手编写一个!
  • 天下事有难易乎? 为之,则难者亦易矣;不为,则易者亦难矣
  • 推荐书籍《自己动手写Java虚拟机》

1.4 Java发展的重大事件

  • 2000年,JDK1.3发布,Java HotSpot Virtual Machine正式发布,成为Java的默认虚拟机。

  • 2006年,JDK6发布。同年,Java开源并建立了OpenJDK。顺理成章,Hotspot虚拟机也成为了openJDK中的默认虚拟机。

  • 2010年,Oracle收购了Sun,获得Java商标和最真价值的HotSpot虚拟机

  • 2011年,JDK7发布。在JDK1.7u4中,正式启用了新的垃圾回收器G1。

  • 2017年,JDK9发布。将G1设置为默认Gc,替代CMS

在JDK11之前,OracleJDK 中还会存在一些 OpenJDK 中没有的、闭源的功能。但在 JDK11 中,我们可以认为OpenJDK和OracleJDK代码实质上已经完全一致的程度。

1.5 虚拟机与Java虚拟机

🍒虚拟机

  • 程序虚拟机的典型代表就是Java虚拟机,它专门为执行单个计算机程序而设计,在Java虚拟机中执行的指令我们称为Java字节码指令。

🍒Java虚拟机

  • Java虚拟机是一台执行Java字节码的虚拟计算机,它拥有独立的运行机制,其运行的Java字节码也未必由Java语言编译而成。
  • Java技术的核心就是Java虚拟机(JVM,Java Virtual Machine),因为所有的Java程序都运行在Java虚拟机内部。

特点

  • 一次编译,到处运行
  • 自动内存管理
  • 自动垃圾回收功能

🍒JVM的位置

JVM是运行在操作系统之上的,它与硬件没有直接的交互。

image.png

1.6 JVM的整体结构

  • HotSpot VM 是目前市面上高性能虚拟机的代表作之一。

  • 它采用解释器与即时编译器并存的架构。

  • 在今天,Java程序的运行性能早已脱胎换骨,已经达到了可以和 C/C++ 程序一较高下的地步。

1.7 Java代码执行流程 ⭐️

xxx.java(Java编译器)

xxx.class

Java虚拟机

操作系统

1.8 JVM的架构模型

Java编译器输入的指令流基本上是一种基于栈的指令集架构,另外一种指令集架构则是基于寄存器的指令集架构

public int calc(){
    int a=100;
    int b=200;
    int c=300;
    return (a + b) * c;
}
> javap -c Test.class
...
public int calc();
    Code:
    Stack=2,Locals=4,Args_size=1
       0: bipush        100
       2: istore_1
       3: sipush        200
       6: istore_2
       7: sipush        300
      10: istore_3
      11: iload_1
      12: iload_2
      13: iadd
      14: iload_3
      15: imul
      16: ireturn
}

由于跨平台性的设计,Java的指令都是根据栈来设计的。

1.9 JVM的生命周期

🍒虚拟机的启动

Java虚拟机的启动是通过引导类加载器(bootstrap class loader)创建一个初始类(initial class)来完成的,这个类是由虚拟机的具体实现指定的。

🍒虚拟机的运行

  • 一个运行中的Java虚拟机有着一个清晰的任务:执行Java程序。

  • 程序开始执行时他才运行,程序结束时他就停止。

  • 执行一个所谓的Java程序的时候,真真正正在执行的是一个叫做Java虚拟机的进程。

🍒虚拟机的退出

有如下的几种情况:

  • 程序正常执行结束
  • 程序在执行过程中遇到了异常或错误而异常终止
  • 由于操作系统出现错误而导致Java虚拟机进程终止
  • 某线程调用Runtime类或system类的 exit 方法,或Runtime类的halt方法,并且Java安全管理器也允许这次exit或halt操作。
  • 除此之外,JNI(Java Native Interface)规范描述了用JNI Invocation API来加载或卸载 Java虚拟机时,Java虚拟机的退出情况。

1.10 JVM的发展历程

具体JVM的内存结构,其实取决于其实现,不同厂商的JVM,或者同一厂商发布的不同版本,都有可能存在一定差异。主要以 Oracle HotSpot VM 为默认虚拟机。

2.类加载子系统

类加载器是JVM执行类加载机制的前提。

2.1 内存结构概述

  • Class文件
  • 类加载子系统
  • 运行时数据区 (五大部分)
    • 方法区
    • 程序计数器
    • 虚拟机栈
    • 本地方法栈
  • 执行引擎
  • 本地方法接口
  • 本地方法库

如果自己想手写一个Java虚拟机的话,主要考虑哪些结构呢?

  • 类加载器
  • 执行引擎

image.png

2.2 类加载器与类的加载过程

🍒类加载器子系统作用

image.png

  • (1)类加载器子系统负责从文件系统或者网络中加载 Class 文件,class 文件在文件开头有特定的文件标识
  • (2)ClassLoader 只负责 class 文件的加载,至于它是否可以运行,则由 Execution Engine 决定。
  • (3)加载的类信息存放于一块称为方法区的内存空间
    • 除了类的信息外,方法区中还会存放运行时常量池信息,可能还包括字符串字面量和数字常量(这部分常量信息是Class文件中常量池部分的内存映射)

🍒类加载器ClasLoader角色

image.png

  • class file加载到JVM中,被称为DNA元数据模板,放在方法区。
  • .class文件->JVM->最终成为元数据模板,此过程就要一个运输工具(类装载器Class Loader),扮演一个快递员的角色。

🍒加载阶段

  1. 通过一个类的全限定名获取定义此类的二进制字节流
  2. 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
  3. 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口

🍒链接阶段

  • 验证
  • 准备
  • 解析

🍒初始化阶段

  • 初始化阶段就是执行类构造器方法()的过程。

2.3 类加载器分类

JVM支持两种类型的类加载器 。分别为引导类加载器(Bootstrap ClassLoader)和自定义类加载器(User-Defined ClassLoader)。

无论类加载器的类型如何划分,在程序中我们最常见的类加载器始终只有3个,如下所示:

🍒虚拟机自带的加载器 ⭐️

启动类加载器(引导类加载器,Bootstrap ClassLoader)
  • 并不继承自ava.lang.ClassLoader,没有父加载器。
  • 出于安全考虑,Bootstrap启动类加载器只加载包名为 javajavaxsun等开头的类
扩展类加载器(Extension ClassLoader)
  • 父类加载器为启动类加载器
应用程序类加载器(系统类加载器,AppClassLoader)
  • 父类加载器为扩展类加载器
  • 该类加载器是程序中默认的类加载器,一般来说,Java应用的类都是由它来完成加载
  • 通过ClassLoader#getSystemclassLoader() 方法可以获取到该类加载器

🍒用户自定义类加载器

ClassLoader 类是一个抽象类,其后所有的类加载器都继承自ClassLoader(不包括启动类加载器)。

2.4 ClassLoader 使用说明

ClassLoader 类是一个抽象类,其后所有的类加载器都继承自ClassLoader(不包括启动类加载器)

🍒获取ClassLoader的途径

  • 方式一:获取当前ClassLoader

    clazz.getClassLoader()
    
  • 方式二:获取当前线程上下文的ClassLoader

    Thread.currentThread().getContextClassLoader()
    
  • 方式三:获取系统的ClassLoader

    ClassLoader.getSystemClassLoader()
    
  • 方式四:获取调用者的ClassLoader

    DriverManager.getCallerClassLoader()
    

2.5 双亲委派机制

Java虚拟机对 class 文件采用的是按需加载的方式,也就是说当需要使用该类时才会将它的 class 文件加载到内存生成 class 对象。

而且加载某个类的 class 文件时,Java虚拟机采用的是双亲委派模式,即把请求交由父类处理,它是一种任务委派模式

🍒工作原理

  • 1)如果一个类加载器收到了类加载请求,它并不会自己先去加载,而是把这个请求委托给父类的加载器去执行;

  • 2)如果父类加载器还存在其父类加载器,则进一步向上委托,依次递归,请求最终将到达顶层的启动类加载器;

  • 3)如果父类加载器可以完成类加载任务,就成功返回,倘若父类加载器无法完成此加载任务,子加载器才会尝试自己去加载,这就是双亲委派模式。

    • 父类加载器加载完成了,就不会向下委托

2.6 其它

🍒如何判断两个class对象是否相同

在JVM中表示两个class对象是否为同一个类存在两个必要条件:

  • 类的完整类名必须一致,包括包名。
  • 加载这个类的ClassLoader(指ClassLoader实例对象)必须相同。

🍒类的主动使用和被动使用

Java程序对类的使用方式分为:主动使用和被动使用。

主动使用,又分为七种情况:

  • 创建类的实例

  • 访问某个类或接口的静态变量,或者对该静态变量赋值

  • 调用类的静态方法

  • 反射(比如:Class.forName("com.atguigu.Test"))

  • 初始化一个类的子类

  • Java虚拟机启动时被标明为启动类的类

  • JDK 7 开始提供的动态语言支持:

    java.lang.invoke.MethodHandle实例的解析结果

    REF_getStatic、REF_putStatic、REF_invokeStatic句柄对应的类没有初始化,则初始化

除了以上七种情况,其他使用Java类的方式都被看作是对类的被动使用,都不会导致类的初始化

3.运行时数据区之程序计数器(PC寄存器)

PC寄存器用来存储指向下一条指令的地址。

PC寄存器.png

3.1 运行时数据区

🍒线程

JVM允许一个应用有多个线程并行的执行。 在Hotspot JVM里,每个线程都与操作系统的本地线程直接映射。

当一个Java线程准备好执行以后,此时一个操作系统的本地线程也同时创建。Java线程执行终止后,本地线程也会回收。

操作系统负责所有线程的安排调度,到任何一个可用的CPU上。一旦本地线程初始化成功,它就会调用Java线程中的run()方法。

🍒JVM系统线程

如果你使用console或者是任何一个调试工具,都能看到在后台有许多线程在运行。

这些后台线程不包括调用public static void main(String[] args)的main线程以及所有这个main线程自己创建的线程。

这些主要的后台系统线程在Hotspot JVM里主要是以下几个:

  • 虚拟机线程:这种线程的操作是需要JVM达到安全点才会出现。这些操作必须在不同的线程中发生的原因是他们都需要JVM达到安全点,这样堆才不会变化。这种线程的执行类型包括"stop-the-world"的垃圾收集,线程栈收集,线程挂起以及偏向锁撤销。

  • 周期任务线程:这种线程是时间周期事件的体现(比如中断),他们一般用于周期性操作的调度执行。

  • GC线程:这种线程对在JVM里不同种类的垃圾收集行为提供了支持。

  • 编译线程:这种线程在运行时会将字节码编译成到本地代码。

  • 信号调度线程:这种线程接收信号并发送给JVM,在它内部通过调用适当的方法进行处理。

3.2 程序计数器(PC寄存器)

PC寄存器用来存储指向下一条指令的地址,也即将要执行的指令代码。

由执行引擎读取下一条指令。

在JVM规范中,每个线程都有它自己的程序计数器,是线程私有的,生命周期与线程的生命周期保持一致

任何时间一个线程都只有一个方法在执行,也就是所谓的当前方法。程序计数器会存储当前线程正在执行的Java方法的JVM指令地址;或者,如果是在执行native方法,则是未指定值(undefined)。

它是唯一一个在Java虚拟机规范中没有规定任何OutofMemoryError情况的区域。

4.虚拟机栈 ⭐️⭐️⭐️⭐️⭐️

4.1 虚拟机栈概述

🍒虚拟机栈出现的背景

由于跨平台性的设计,Java的指令都是根据栈来设计的。不同平台CPU架构不同,所以不能设计为基于寄存器的。

优点是跨平台,指令集小,编译器容易实现,缺点是性能下降,实现同样的功能需要更多的指令

🍒初步印象

有不少Java开发人员一提到Java内存结构,就会非常粗粒度地将JVM中的内存区理解为仅有Java堆(heap)和Java栈(stack)?为什么?

🍒内存中的栈与堆

栈是运行时的单位,而堆是存储的单位。

  • 栈解决程序的运行问题,即程序如何执行,或者说如何处理数据。
  • 堆解决的是数据存储的问题,即数据怎么放,放哪里。

🍒虚拟机栈基本内容

(1)Java虚拟机栈是什么?

  • Java虚拟机栈(Java Virtual Machine Stack),早期也叫 Java 栈。

  • 每个线程在创建时都会创建一个虚拟机栈,其内部保存一个个的栈帧(Stack Frame),对应着一次次的Java方法调用,是线程私有的。

(2)生命周期

  • 生命周期和线程一致

(3)作用

  • 主管Java程序的运行,它保存方法的局部变量、部分结果,并参与方法的调用和返回。

(4)栈的特点

  • JVM直接对Java栈的操作只有两个:

    • 每个方法执行,伴随着进栈(入栈、压栈)

    • 执行结束后的出栈工作

  • 对于栈来说不存在垃圾回收问题(栈存在溢出的情况)

(5)面试题:开发中遇到哪些异常?

🌸 栈中可能出现的异常

Java 虚拟机规范允许Java栈的大小是动态的或者是固定不变的

  • 如果采用固定大小的Java虚拟机栈,那每一个线程的Java虚拟机栈容量可以在线程创建的时候独立选定。如果线程请求分配的栈容量超过Java虚拟机栈允许的最大容量,Java虚拟机将会抛出一个StackOverflowError 异常。

  • 如果Java虚拟机栈可以动态扩展,并且在尝试扩展的时候无法申请到足够的内存,或者在创建新的线程时没有足够的内存去创建对应的虚拟机栈,那Java虚拟机将会抛出一个 OutOfMemoryError 异常。

public static void main(String[] args) {
    test();
}
public static void test() {
    test();
}
//抛出异常:Exception in thread"main"java.lang.StackoverflowError
//程序不断的进行递归调用,而且没有退出条件,就会导致不断地进行压栈。

🌸 设置栈内存大小

我们可以使用参数 -Xss选项来设置线程的最大栈空间,栈的大小直接决定了函数调用的最大可达深度

public class StackDeepTest{ 
    private static int count=0; 
    public static void recursion(){
        count++; 
        recursion(); 
    }
    public static void main(String args[]){
        try{
            recursion();
        } catch (Throwable e){
            System.out.println("deep of calling="+count); 
            e.printstackTrace();
        }
    }
}

4.2 栈的存储单位

🍒栈中存储什么?

每个线程都有自己的栈,栈中的数据都是以栈帧(Stack Frame)的格式存在

在这个线程上正在执行的每个方法都各自对应一个栈帧(Stack Frame)。

栈帧是一个内存区块,是一个数据集,维系着方法执行过程中的各种数据信息。

🍒栈运行原理

JVM直接对Java栈的操作只有两个,就是对栈帧的压栈和出栈,遵循“先进后出”/“后进先出”原则

在一条活动线程中,一个时间点上,只会有一个活动的栈帧。即只有当前正在执行的方法的栈帧(栈顶栈帧)是有效的,这个栈帧被称为当前栈帧(Current Frame),与当前栈帧相对应的方法就是当前方法(Current Method),定义这个方法的类就是当前类(Current Class)

如果在该方法中调用了其他方法,对应的新的栈帧会被创建出来,放在栈的顶端,成为新的当前帧。

如果当前方法调用了其他方法,方法返回之际,当前栈帧会传回此方法的执行结果给前一个栈帧,接着,虚拟机会丢弃当前栈帧,使得前一个栈帧重新成为当前栈帧。

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

🍒栈帧的内部结构

每个栈帧中存储着:

  • 局部变量表(Local Variables)
  • 操作数栈(operand Stack)(或表达式栈)
  • 动态链接(DynamicLinking)(或指向运行时常量池的方法引用)
  • 方法返回地址(Return Address)(或方法正常退出或者异常退出的定义)
  • 一些附加信息

栈帧的大小主要由局部变量表 和 操作数栈决定的。

4.3 局部变量表 (Local Variables)

局部变量表也被称之为局部变量数组或本地变量表。

  • 定义为一个数字数组,主要用于存储方法参数和定义在方法体内的局部变量,这些数据类型包括各类基本数据类型、对象引用(reference),以及returnAddress类型。

  • 方法嵌套调用的次数由栈的大小决定。一般来说,栈越大,方法嵌套调用次数越多。

  • 局部变量表中的变量只在当前方法调用中有效。在方法执行时,虚拟机通过使用局部变量表完成参数值到参数变量列表的传递过程。当方法调用结束后,随着方法栈帧的销毁,局部变量表也会随之销毁。

🍒关于Slot的理解

  • 局部变量表,最基本的存储单元是Slot(变量槽)

  • 局部变量表中存放编译期可知的各种基本数据类型(8种),引用类型(reference),returnAddress类型的变量。

  • 在局部变量表里,32位以内的类型只占用一个slot(包括returnAddress类型),64位的类型(long和double)占用两个slot。

  • byte、short、char 在存储前被转换为int,boolean也被转换为int,0表示false,非0表示true。

🍒补充说明

在栈帧中,与性能调优关系最为密切的部分就是前面提到的局部变量表。

在方法执行时,虚拟机使用局部变量表完成方法的传递。

局部变量表中的变量也是重要的垃圾回收根节点,只要被局部变量表中直接或间接引用的对象都不会被回收

4.4 操作数栈(Operand Stack)

每一个独立的栈帧除了包含局部变量表以外,还包含一个后进先出(Last-In-First-Out)的 操作数栈,也可以称之为表达式栈(Expression Stack)

操作数栈在方法执行过程中,根据字节码指令,往栈中写入数据或提取数据,即入栈(push)和 出栈(pop)

  • 某些字节码指令将值压入操作数栈,其余的字节码指令将操作数取出栈。使用它们后再把结果压入栈
  • 比如:执行复制、交换、求和等操作

代码举例

public void testAddOperation(){
    byte i = 15; 
    int j = 8; 
    int k = i + j;
}

字节码指令信息

public void testAddOperation(); 
    Code:
    0: bipush 15
    2: istore_1 
    3: bipush 8
    5: istore_2 
    6:iload_1 
    7:iload_2 
    8:iadd
    9:istore_3 
    10:return

操作数栈,主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间

如果被调用的方法带有返回值的话,其返回值将会被压入当前栈帧的操作数栈中,并更新PC寄存器中下一条需要执行的字节码指令。

另外,我们说Java虚拟机的解释引擎是基于栈的执行引擎,其中的栈指的就是操作数栈。

4.5 代码追踪

public void testAddOperation() {
    byte i = 15;
    int j = 8;
    int k = i + j;
}

使用javap 命令反编译class文件: javap -v 类名.class

1

程序员面试过程中,常见的i++和++i的区别,放到字节码篇章时再介绍。

4.6 栈顶缓存技术

4.7 动态链接

4.11 栈的相关面试题

🌸分配的栈内存越大越好么?

  • 不是,一定时间内降低了OOM概率,但是会挤占其它的线程空间,因为整个空间是有限的。

🌸垃圾回收是否涉及到虚拟机栈?

  • 不会

🌸方法中定义的局部变量是否线程安全?

  • 具体问题具体分析。如果对象是在内部产生,并在内部消亡,没有返回到外部,那么它就是线程安全的,反之则是线程不安全的。
运行时数据区是否存在Error是否存在GC
程序计数器
虚拟机栈是(SOE)
本地方法栈
方法区是(OOM)

5.本地方法接口和本地方法栈

本地接口的作用是融合不同的编程语言为Java所用,它的初衷是融合C/C++程序。

5.1 什么是本地方法?

简单地讲,一个Native Method是一个Java调用非Java代码的接囗

一个Native Method是这样一个Java方法:该方法的实现由非Java语言实现,比如C。

A native method is a Java method whose implementation is provided by non-java code.

在定义一个native method时,并不提供实现体(有些像定义一个Java interface),因为其实现体是由非java语言在外面实现的。

5.2 为什么使用 Native Method?

与Java环境的交互

有时Java应用需要与Java外面的环境交互,这是本地方法存在的主要原因。

与操作系统的交互

通过使用本地方法,我们得以用Java实现了jre的与底层系统的交互,甚至JVM的一些部分就是用c写的。

Sun's Java

Sun的解释器是用C实现的,这使得它能像一些普通的C一样与外部交互。

现状

目前该方法使用的越来越少了,除非是与硬件有关的应用

5.3 本地方法栈

Java虚拟机栈于管理Java方法的调用,而本地方法栈用于管理本地方法的调用。

本地方法栈,也是线程私有的。

允许被实现成固定或者是可动态扩展的内存大小。(在内存溢出方面是相同的)

  • 如果线程请求分配的栈容量超过本地方法栈允许的最大容量,Java虚拟机将会抛出一个StackOverflowError 异常。

  • 如果本地方法栈可以动态扩展,并且在尝试扩展的时候无法申请到足够的内存,或者在创建新的线程时没有足够的内存去创建对应的本地方法栈,那么Java虚拟机将会抛出一个OutOfMemoryError异常。

本地方法是使用 C 语言实现的。

它的具体做法是Native Method Stack中登记native方法,在 Execution Engine 执行时加载本地方法库。

Hotspot JVM 中,直接将本地方法栈和虚拟机栈合二为一。

6.堆 Heap⭐️⭐️⭐️⭐️⭐️

堆针对一个JVM进程来说是唯一的,也就是一个进程只有一个JVM,但是进程包含多个线程,他们是共享同一堆空间的。

6.1 堆的核心概述

一个 JVM 实例只存在一个堆内存,堆也是Java内存管理的核心区域。

Java堆区在JVM启动的时候即被创建,其空间大小也就确定了。是JVM管理的最大一块内存空间。

《Java虚拟机规范》中对Java堆的描述是:所有的对象实例以及数组都应当在运行时分配在堆上。(The heap is the run-time data area from which memory for all class instances and arrays is allocated

在方法结束后,堆中的对象不会马上被移除,仅仅在垃圾收集的时候才会被移除。

堆,是GC(Garbage Collection,垃圾收集器)执行垃圾回收的重点区域。

image.png

🍒堆内存细分

Java 7及之前堆内存逻辑上分为三部分:新生区 + 养老区 + 永久区

  • Young Generation Space 新生区 Young/New

    • 又被划分为Eden区和Survivor区
  • Tenure generation space 养老区 Old/Tenure

  • Permanent Space 永久区 Perm

Java 8及之后堆内存逻辑上分为三部分:新生区 + 养老区 + 元空间

  • Young Generation Space 新生区 Young/New

    • 又被划分为Eden区和Survivor区
  • Tenure generation space 养老区 Old/Tenure

  • Meta Space 元空间 Meta

约定:新生区(代)<=>年轻代 、 养老区<=>老年区(代)、 永久区<=>永久代

🍒堆空间内部结构(JDK7&8)

6.2 设置堆内存大小与 OOM

🍒堆空间大小设置

Java堆区用于存储Java对象实例,那么堆的大小在JVM启动时就已经设定好了,大家可以通过选项"-Xmx"和"-Xms"来进行设置。

  • “-Xms"用于表示堆区的起始内存,等价于-XX:InitialHeapSize
  • “-Xmx"则用于表示堆区的最大内存,等价于-XX:MaxHeapSize

一旦堆区中的内存大小超过“-Xmx"所指定的最大内存时,将会抛出 OutOfMemoryError 异常。

通常会将-Xms和-Xmx两个参数配置相同的值,其目的是为了能够在Java垃圾回收机制清理完堆区后不需要重新分隔计算堆区的大小,从而提高性能。

默认情况下

  • 初始内存大小:物理电脑内存大小 / 64
  • 最大内存大小:物理电脑内存大小 / 4

🍒OutOfMemory举例

public class OOMTest {
    public static void main(String[]args){
        ArrayList<Picture> list = new ArrayList<>();
        while(true){
            try {
                Thread.sleep(20);
            } catch (InterruptedException e){
                e.printStackTrace();
            }
            list.add(new Picture(new Random().nextInt(1024*1024)));
        }
    }
}
Exception in thread "main" java.lang.OutofMemoryError: Java heap space
    at com.atguigu. java.Picture.<init>(OOMTest. java:25)
    at com.atguigu.java.O0MTest.main(OOMTest.java:16)

6.3 年轻代与老年代

存储在JVM中的Java对象可以被划分为两类:

  • 一类是生命周期较短的瞬时对象,这类对象的创建和消亡都非常迅速

  • 另外一类对象的生命周期却非常长,在某些极端的情况下还能够与JVM的生命周期保持一致

Java堆区进一步细分的话,可以划分为年轻代(YoungGen)和老年代(oldGen)

其中年轻代又可以划分为 Eden 空间、Survivor0 空间和 Survivor1 空间(有时也叫做 from 区、to 区)

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

几乎所有的 Java对象都是在Eden区被new出来的。绝大部分的Java对象的销毁都在新生代进行了。

6.4 图解对象分配过程

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

1.new的对象先放伊甸园区。此区有大小限制。

2.当伊甸园的空间填满时,程序又需要创建对象,JVM的垃圾回收器将对伊甸园区进行垃圾回收(MinorGC),将伊甸园区中的不再被其他对象所引用的对象进行销毁。再加载新的对象放到伊甸园区。

3.然后将伊甸园中的剩余对象移动到幸存者0区。

4.如果再次触发垃圾回收,此时上次幸存下来的放到幸存者0区的,如果没有回收,就会放到幸存者1区。

5.如果再次经历垃圾回收,此时会重新放回幸存者0区,接着再去幸存者1区。

6.啥时候能去养老区呢?可以设置次数。默认是15次。

  • 可以设置参数:-Xx:MaxTenuringThreshold= N 进行设置

7.在养老区,相对悠闲。当养老区内存不足时,再次触发GC:Major GC,进行养老区的内存清理

8.若养老区执行了Major GC之后,发现依然无法进行对象的保存,就会产生OOM异常。

java.lang.OutofMemoryError: Java heap space

GC.png

流程图

  • 总结

    • 针对幸存者s0,s1区的总结:复制之后有交换,谁空谁是to
    • 关于垃圾回收:频繁在新生区收集,很少在老年代收集,几乎不再永久代和元空间进行收集。

常用调优工具(在JVM下篇:性能监控与调优篇会详细介绍)

  • JDK命令行
  • Eclipse:Memory Analyzer Tool
  • Jconsole
  • VisualVM
  • Jprofiler
  • Java Flight Recorder
  • GCViewer
  • GC Easy

6.5 Minor GC,MajorGC、Full GC

JVM在进行GC时,并非每次都对上面三个内存区域一起回收的,大部分时候回收的都是指新生代。

针对Hotspot VM的实现,它里面的GC按照回收区域又分为两大种类型:一种是部分收集(Partial GC),一种是整堆收集(FullGC)

  • 部分收集:不是完整收集整个Java堆的垃圾收集。其中又分为:
    • 新生代收集(Minor GC / Young GC):只是新生代的垃圾收集
    • 老年代收集(Major GC / Old GC):只是老年代的圾收集。
      • 目前,只有CMSGC会有单独收集老年代的行为。
      • 注意,很多时候Major GC会和Full GC混淆使用,需要具体分辨是老年代回收还是整堆回收。
    • 混合收集(MixedGC):收集整个新生代以及部分老年代的垃圾收集。
      • 目前,只有G1 GC会有这种行为
  • 整堆收集(Full GC):收集整个java堆和方法区的垃圾收集。

🍒年轻代GC(Minor GC)触发机制

  • 当年轻代空间不足时,就会触发MinorGC,这里的年轻代满指的是Eden代满,Survivor满不会引发GC。(每次Minor GC会清理年轻代的内存。)
  • 因为Java对象大多都具备朝生夕灭的特性,所以Minor GC非常频繁,一般回收速度也比较快。这一定义既清晰又易于理解。
  • Minor GC会引发STW,暂停其它用户的线程,等垃圾回收结束,用户线程才恢复运行

🍒老年代GC(Major GC / Full GC)触发机制

  • 指发生在老年代的GC,对象从老年代消失时,我们说 “Major GC” 或 “Full GC” 发生了

  • 出现了Major Gc,经常会伴随至少一次的Minor GC(但非绝对的,在Paralle1 Scavenge收集器的收集策略里就有直接进行MajorGC的策略选择过程)

    • 也就是在老年代空间不足时,会先尝试触发Minor Gc。如果之后空间还不足,则触发Major GC
  • Major GC的速度一般会比Minor GC慢10倍以上,STW的时间更长

  • 如果Major GC后,内存还不足,就报OOM了

🍒Full GC触发机制(后面细讲):

触发Full GC执行的情况有如下五种:

  1. 调用System.gc()时,系统建议执行Full GC,但是不必然执行
  2. 老年代空间不足
  3. 方法区空间不足
  4. 通过Minor GC后进入老年代的平均大小大于老年代的可用内存
  5. 由Eden区、survivor space0(From Space)区向survivor space1(To Space)区复制时,对象大小大于To Space可用内存,则把该对象转存到老年代,且老年代的可用内存小于该对象大小

说明:Full GC 是开发或调优中尽量要避免的。这样暂时时间会短一些

6.6 堆空间分代思想

为什么要把Java堆分代?不分代就不能正常工作了吗?

经研究,不同对象的生命周期不同。70%-99%的对象是临时对象。

  • 新生代:有Eden、两块大小相同的survivor(又称为from/to,s0/s1)构成,to总为空。
  • 老年代:存放新生代中经历多次GC仍然存活的对象。

其实不分代完全可以,分代的唯一理由就是优化GC性能。

如果没有分代,那所有的对象都在一块,就如同把一个学校的人都关在一个教室。GC的时候要找到哪些对象没用,这样就会对堆的所有区域进行扫描。

而很多对象都是朝生夕死的,如果分代的话,把新创建的对象放到某一地方,当GC的时候先把这块存储“朝生夕死”对象的区域进行回收,这样就会腾出很大的空间出来。

堆78.png

6.7 内存分配策略

如果对象在Eden出生并经过第一次Minor GC后仍然存活,并且能被Survivor容纳的话,将被移动到survivor空间中,并将对象年龄设为1。

对象在survivor区中每熬过一次MinorGC,年龄就增加1岁,当它的年龄增加到一定程度(默认为15岁,其实每个JVM、每个GC都有所不同)时,就会被晋升到老年代。对象晋升老年代的年龄阀值,可以通过选项-XX:MaxTenuringThreshold来设置。

针对不同年龄段的对象分配原则如下所示:

  • 优先分配到Eden
  • 大对象直接分配到老年代(尽量避免程序中出现过多的大对象)
  • 长期存活的对象分配到老年代
  • 动态对象年龄判断:如果survivor区中相同年龄的所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象可以直接进入老年代,无须等到MaxTenuringThreshold中要求的年龄。
  • 空间分配担保: -XX:HandlePromotionFailure

6.8 为对象分配内存:TLAB

🍒为什么有TLAB(Thread Local Allocation Buffer)?

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

🍒什么是TLAB

  • 从内存模型而不是垃圾收集的角度,对Eden区域继续进行划分,JVM为每个线程分配了一个私有缓存区域,它包含在Eden空间内。
  • 多线程同时分配内存时,使用TLAB可以避免一系列的非线程安全问题,同时还能够提升内存分配的吞吐量,因此我们可以将这种内存分配方式称之为快速分配策略
  • 据我所知所有OpenJDK衍生出来的JVM都提供了TLAB的设计。

🍒TLAB的再说明

  • 尽管不是所有的对象实例都能够在TLAB中成功分配内存,但JVM确实是将TLAB作为内存分配的首选

  • 在程序中,开发人员可以通过选项“-XX:UseTLAB”设置是否开启TLAB空间。

  • 默认情况下,TLAB空间的内存非常小,仅占有整个Eden空间的1%,当然我们可以通过选项 “-XX:TLABWasteTargetPercent” 设置TLAB空间所占用Eden空间的百分比大小。

  • 一旦对象在TLAB空间分配内存失败时,JVM就会尝试着通过使用加锁机制确保数据操作的原子性,从而直接在Eden空间中分配内存。

6.9 总结:堆空间的参数设置

官网地址:docs.oracle.com/javase/8/do…

// 详细的参数内容会在JVM下篇:性能监控与调优篇中进行详细介绍,这里先熟悉下
-XX:+PrintFlagsInitial  //查看所有的参数的默认初始值
-XX:+PrintFlagsFinal  //查看所有的参数的最终值(可能会存在修改,不再是初始值)
-Xms  //初始堆空间内存(默认为物理内存的1/64)
-Xmx  //最大堆空间内存(默认为物理内存的1/4)
-Xmn  //设置新生代的大小。(初始值及最大值)
-XX:NewRatio  //配置新生代与老年代在堆结构的占比
-XX:SurvivorRatio  //设置新生代中Eden和S0/S1空间的比例
-XX:MaxTenuringThreshold  //设置新生代垃圾的最大年龄
-XX:+PrintGCDetails //输出详细的GC处理日志
//打印gc简要信息:①-Xx:+PrintGC ② - verbose:gc
-XX:HandlePromotionFalilure://是否设置空间分配担保

在发生Minor GC之前,虚拟机会检查老年代最大可用的连续空间是否大于新生代所有对象的总空间

  • 如果大于,则此次Minor GC是安全的

  • 如果小于,则虚拟机会查看-XX:HandlePromotionFailure设置值是否允担保失败。

    • 如果HandlePromotionFailure=true,那么会继续检查老年代最大可用连续空间是否大于历次晋升到老年代的对象的平均大小。

      • 如果大于,则尝试进行一次Minor GC,但这次Minor GC依然是有风险的;
      • 如果小于,则改为进行一次Full GC。
    • 如果HandlePromotionFailure=false,则改为进行一次Full Gc。

在JDK6 Update24之后,HandlePromotionFailure参数不会再影响到虚拟机的空间分配担保策略,观察openJDK中的源码变化,虽然源码中还定义了HandlePromotionFailure参数,但是在代码中已经不会再使用它。JDK6 Update 24之后的规则变为只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小就会进行Minor GC,否则将进行FullGC。

6.10 堆是分配对象的唯一选择么?

随着JIT编译期的发展与逃逸分析技术逐渐成熟,栈上分配标量替换优化技术将会导致一些微妙的变化,所有的对象都分配到堆上也渐渐变得不那么“绝对”了。

小结

年轻代是对象的诞生、成长、消亡的区域,一个对象在这里产生、应用,最后被垃圾回收器收集、结束生命。

老年代放置长生命周期的对象,通常都是从survivor区域筛选拷贝过来的Java对象。当然,也有特殊情况,我们知道普通的对象会被分配在TLAB上;如果对象较大,JVM会试图直接分配在Eden其他位置上;如果对象太大,完全无法在新生代找到足够长的连续空闲空间,JVM就会直接分配到老年代。当GC只发生在年轻代中,回收年轻代对象的行为被称为MinorGc。

当GC发生在老年代时则被称为MajorGc或者FullGC。一般的,MinorGc的发生频率要比MajorGC高很多,即老年代中垃圾回收发生的频率将大大低于年轻代。

7.方法区⭐️⭐️⭐️⭐️⭐️

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

举个例子🌰

栈堆方法区关系.png

7.2 方法区的理解

官方文档:Chapter 2. The Structure of the Java Virtual Machine (oracle.com)

🍒方法区在哪里?

《Java虚拟机规范》中明确说明:“尽管所有的方法区在逻辑上是属于堆的一部分,但一些简单的实现可能不会选择去进行垃圾收集或者进行压缩。”但对于HotSpotJVM而言,方法区还有一个别名叫做Non-Heap(非堆),目的就是要和堆分开。

所以,方法区看作是一块独立于Java堆的内存空间

🍒方法区的基本理解

  • 方法区(Method Area)与Java堆一样,是各个线程共享的内存区域。

  • 方法区在JVM启动的时候被创建,并且它的实际的物理内存空间中和Java堆区一样都可以是不连续的。

  • 方法区的大小,跟堆空间一样,可以选择固定大小或者可扩展。

  • 方法区的大小决定了系统可以保存多少个类,如果系统定义了太多的类,导致方法区溢出,虚拟机同样会抛出内存溢出错误:java.lang.OutOfMemoryError: PermGen space 或者java.lang.OutOfMemoryError: Metaspace

    • 加载大量的第三方的jar包;Tomcat部署的工程过多(30~50个);大量动态的生成反射类
  • 关闭JVM就会释放这个区域的内存。

🍒HotSpot中方法区的演进

在jdk7及以前,习惯上把方法区,称为永久代。jdk8开始,使用元空间取代了永久代。

现在来看,当年使用永久代,不是好的idea。导致Java程序更容易OOM(超过-XX:MaxPermsize上限)

而到了JDK8,终于完全废弃了永久代的概念,改用与JRockit、J9一样在本地内存中实现的元空间(Metaspace)来代替。

元空间的本质和永久代类似,都是对JVM规范中方法区的实现。不过元空间与永久代最大的区别在于:元空间不在虚拟机设置的内存中,而是使用本地内存

永久代、元空间二者并不只是名字变了,内部结构也调整了

根据《Java虚拟机规范》的规定,如果方法区无法满足新的内存分配需求时,将抛出OOM异常

7.3 设置方法区大小与OOM

方法区的大小不必是固定的,JVM可以根据应用的需要动态调整。

  • 元数据区大小可以使用参数 -XX:MetaspaceSize-XX:MaxMetaspaceSize指定

7.4 方法区的内部结构

🍒方法区(Method Area)存储什么?

《深入理解Java虚拟机》书中对方法区(Method Area)存储内容描述如下:

它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等。

🍒方法区的内部结构

(1)类型信息

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

1.这个类型的完整有效名称(全名=包名.类名)

2.这个类型直接父类的完整有效名(对于interface或是java.lang.object,都没有父类)

3.这个类型的修饰符(public,abstract,final的某个子集)

4.这个类型直接接口的一个有序列表

(2)域信息

JVM必须在方法区中保存类型的所有域的相关信息以及域的声明顺序。

域的相关信息包括:域名称、域类型、域修饰符(public,private,protected,static,final,volatile,transient的某个子集)

(3)方法信息

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

1.方法名称

2.方法的返回类型(或void)

3.方法参数的数量和类型(按顺序)

4.方法的修饰符(public,private,protected,static,final,synchronized,native,abstract的一个子集)

5.方法的字节码(bytecodes)、操作数栈、局部变量表及大小(abstract和native方法除外)

6.异常表(abstract和native方法除外)

  • 每个异常处理的开始位置、结束位置、代码处理在程序计数器中的偏移地址、被捕获的异常类的常量池索引

🍒运行时常量池 VS 常量池

  • 方法区,内部包含了运行时常量池
  • 字节码文件,内部包含了常量池
  • 要弄清楚方法区,需要理解清楚ClassFile,因为加载类的信息都在方法区。
  • 要弄清楚方法区的运行时常量池,需要理解清楚ClassFile中的常量池。

一个java源文件中的类、接口,编译后产生一个字节码文件。而Java中的字节码需要数据支持,通常这种数据会很大以至于不能直接存到字节码里,换另一种方式,可以存到常量池,这个字节码包含了指向常量池的引用。在动态链接的时候会用到运行时常量池,之前有介绍。

如下代码:

public class SimpleClass {
    public void sayHello() {
        System.out.println("hello");
    }
}

虽然只有194字节,但是里面却使用了String、System、PrintStream及Object等结构。

这里的代码量其实很少了,如果代码多的话,引用的结构将会更多,这里就需要用到常量池了。

几种常量池内存储的数据类型包括:

  • 数量值
  • 字符串值
  • 类引用
  • 字段引用
  • 方法引用

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

🍒运行时常量池

  • 运行时常量池(Runtime Constant Pool)是方法区的一部分。
  • 常量池表(Constant Pool Table)是Class文件的一部分,用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。
  • JVM为每个已加载的类型(类或接口)都维护一个常量池。池中的数据项像数组项一样,是通过索引访问的。
  • 运行时常量池,相对于Class文件常量池的另一重要特征是:具备动态性

7.5 方法区使用举例

public class MethodAreaDemo {
    public static void main(String args[]) {
        int x = 500;
        int y = 100;
        int a = x / y;
        int b = 50;
        System.out.println(a+b);
    }
}

7.6 方法区的演进细节

7.7 方法区的垃圾回收

7.8 总结

常见面试题

百度:

说一下JVM内存模型吧,有哪些区?分别干什么的?

蚂蚁金服:

Java8的内存分代改进 JVM内存分哪几个区,每个区的作用是什么?

一面:JVM内存分布/内存结构?栈和堆的区别?堆的结构?为什么两个survivor区?

二面:Eden和survior的比例分配

小米:

jvm内存分区,为什么要有新生代和老年代

字节跳动:

二面:Java的内存分区

二面:讲讲Jvm运行时数据库区 什么时候对象会进入老年代?

京东:

JVM的内存结构,Eden和Survivor比例。

JVM内存为什么要分成新生代,老年代,持久代。

新生代中为什么要分为Eden和survivor。

天猫:

一面:Jvm内存模型以及分区,需要详细到每个区放什么。

一面:JVM的内存模型,Java8做了什么改

拼多多:

JVM内存分哪几个区,每个区的作用是什么?

美团:

java内存分配 jvm的永久代中会发生垃圾回收吗?

一面:jvm内存分区,为什么要有新生代和老年代?

8.对象实例化及直接内存

8.1 对象实例化

🍒创建对象的方式

  • new:最常见的方式、Xxx的静态方法,XxxBuilder/XxxFactory的静态方法
  • Class的newInstance方法:反射的方式,只能调用空参的构造器,权限必须是public
  • Constructor的newInstance(XXX):反射的方式,可以调用空参、带参的构造器,权限没有要求
  • 使用clone():不调用任何的构造器,要求当前的类需要实现Cloneable接口,实现clone()
  • 使用序列化:从文件中、从网络中获取一个对象的二进制流
  • 第三方库 Objenesis

🍒创建对象的步骤

8.2 对象内存布局

🍒对象头(Header)

🍒实例数据(Instance Data)

🍒对齐填充(Padding)

🍒小结

8.3 对象的访问定位

JVM是如何通过栈帧中的对象引用访问到其内部的对象实例呢?

🍒句柄访问

🍒直接指针(HotSpot采用)

8.4 直接内存(Direct Memory)

🍒概述

🍒非直接缓冲区

🍒直接缓冲区

9 执行引擎

执行引擎属于JVM的下层,里面包括解释器、及时编译器、垃圾回收器

执行引擎是Java虚拟机核心的组成部分之一。

9.1 概述

9.2 Java代码编译和执行过程

9.3 机器码、指令、汇编语言

9.4 解释器

9.5. JIT编译器

10 StringTable

11 垃圾回收概述及算法

部分面试

什么是 JVM

你是怎么理解Java是一门「跨平台」的语言,也就是「一次编译,到处运行的」?

回答1:

Java源代码会被编译为 class 文件,class文件是运行在 JVM 之上的。

JDK是分「不同的操作系统」,JDK里是包含JVM的,所以Java依赖着JVM实现了『跨平台』。

JVM是面向操作系统的,它负责把Class字节码解释成系统所能识别的指令并执行,同时也负责程序运行时内存的管理。

image.png

回答2:

Java 是一个和平台无关的编程语言,之所以这么说,就是因为有 Java 虚拟机的存在。

因为 Java 虚拟机知道底层硬件平台的指令长度和其他特性,同时 Java 源文件可以被编译成能被 Java 虚拟机执行的字节码文件(.class文件)。 所以无论是什么平台,只要平台上装有对应本平台的 Java 虚拟机,由 Java 源文件编译成的字节码文件(.class 文件)就可以在该平台上运行,这就是“一次编译,多次运行”

那要不你来聊聊从源码文件(.java)到代码执行的过程呗? 编译->加载->解释->执行

(1)编译:将源码文件编译成JVM可以解释的class文件。

image.png

(2)加载:将编译后的class文件加载到JVM中。

image.png

(3)解释:把字节码转换为操作系统识别的指令

推荐

juejin.cn/post/705846…