『一篇就够了系列』关于JVM,这里有你想知道的一切

本文全面解析JVM架构,涵盖类加载、对象创建、内存布局、垃圾回收机制等核心技术,揭示JVM内部工作原理。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

我的新书《Android App开发入门与实战》已于2020年8月由人民邮电出版社出版,欢迎购买。点击进入详情


欢迎加入Android开发交流QQ群:
Android开发技术交流

一、导言

Windows下,双击jdk-8u161-windows-x64.exe,安装后在Java文件夹下有如下两个文件:
在这里插入图片描述

1. JRE

JRE stands for “Java Runtime Environment” and may also be written as “Java RTE.” The Java Runtime Environment provides the minimum requirements for executing a Java application; it consists of the Java Virtual Machine (JVM), core classes, and supporting files.

还是看英文原版的解释的比较清晰。
JRE表示Java运行环境,也就是包含执行Java程序最基本的条件。它由JVM和一些核心类库组成。

使用场景:JRE一般是给想要运行Java程序的人员使用。

2. JDK

The Java Development Kit (JDK) is a software development environment used for developing Java applications and applets. It includes the Java Runtime Environment (JRE), an interpreter/loader (Java), a compiler (javac), an archiver (jar), a documentation generator (Javadoc) and other tools needed in Java development.

JDK是一个用来开发Java Application和Applets的开发环境。它包含了JRE、解释器、编译器、打包工具、文档生成器等。

使用场景:JDK一般是给开发人员使用。

3. JVM

  1. A specification where working of Java Virtual Machine is specified. But implementation provider is independent to choose the algorithm. Its implementation has been provided by Sun and other companies.
  2. An implementation is a computer program that meets the requirements of the JVM specification
  3. Runtime Instance Whenever you write java command on the command prompt to run the java class, an instance of JVM is created.

JVM,Java Virtual Machine的简称,简单来说就是用来执行字节码的,它并不认识Java代码,它将字节码翻译成对应平台的机器指令,再由对应的平台执行机器指令。这也就是我们所说的写一次Java代码,能够在各种平台上运行:WORA(Write Once Run Anywhere)。所以每个平台需要有一个对应的JVM。

另外JVM本身也是一种规范(specification),不同厂商可以有不同的实现。目前比较出名的JVM是Oracle的HotSpot,它就是JVM规范的一种实现。

最后说到编程语言,不管是用Java还是其它的什么语言,只要编译器生成的字节码能够符合JVM规范,JVM能够读懂就行。简单理解这样的编程就是面向JVM规范编程, JVM成为了一个能容纳其它语言运行的平台,如Scala、Kotlin、Groovy、Clojure等。

使用场景:不管是用JRE还是JDK,最后都要用到JVM,因为JVM会一行一行地解释代码(字节码),因此JVM也可以认为是一个解释器。

4. 三者逻辑关系

在这里插入图片描述
JDK = JRE + Development/debugging tools
JRE = JVM + Java Packages Classes(like util, math, lang, awt,swing etc)+runtime libraries.
JVM = Class loader system + runtime data area + Execution Engine.

JVM是JRE的子集,JRE是JDK的子集,JDK是JRE的超集。

再来个详细的图:
在这里插入图片描述

5. 三者运行关系

Runtime是指JRE执行环节;
Compile是指JDK中的编译环节;
在这里插入图片描述
运行Java应用时,三者的关系图:
在这里插入图片描述
上图同样展示了Java程序的运行流程有如下步骤:

  1. 可以使用你熟悉的IDE进行开发,保存为一个.java的源文件;
  2. 通过JDK中的编译器,将源文件编译成字节码格式的文件,后缀是.class;
  3. .class文件通过各种可能的渠道,交由这个平台对应的JVM处理;
  4. JVM将字节码翻译成机器码,然后由本机执行这个机器码;

二、JVM架构

要深入了解JVM,先从JVM的架构说起。
从网上找到一张图,清晰地展示了JVM的架构:
在这里插入图片描述
可以看到,JVM架构分五块,其中前三个较为重要:
1. Class Loader SubSystem:类加载子系统;
2. Runtime Data Areas:运行时数据区域;
3. Execution Engine:执行引擎;

4. Java Native Interface:JNI接口;
5. Native Method Libraries:本地方法库;

1. 类加载子系统

(1) 类加载定义

类加载的过程要做的事情,简单来说就是将.class中的字节码文件读入内存, 并将这个字节流所代表的静态存储结构转化为Method Area的运行时数据结构,最后还会在Heap中生成一个Class类对象( java.lang包内的Class,区别于类的对象),作为方法区数据的访问入口。
注:
查看Class类源码可以看到,Class类的构造函数是私有的,也就是不能直接通过new创建,而是只有JVM才能创建Class类对象。
在这里插入图片描述
通常可以通过class<?> clazz = Class.forName("com.java.Test");进行类加载。

(2) 类加载时机

只有在 第一次 主动调用 这个类的时候才会对这个类进行加载。
也就是说类只有一次加载机会,一旦加载了后续就不会重新加载。
关于主动调用,有以下六种情况,其它情况都属于被动调用:

  1. 一个类的实例被创建(new操作、反射、cloning,反序列化)
  2. 调用类的static方法
  3. 使用或对类/接口的static属性进行赋值时(这不包括final的与在编译期确定的常量表达式)
  4. 当调用 API 中的某些反射方法时
  5. 子类被初始化
  6. 被设定为 JVM 启动时的启动类(具有main方法的类)

(3) 类加载过程

JVM将字节码转换成运行时对象分为三个流程:Loading、Linking、Initialization。

1) Loading(加载)

Loading即类加载,JVM提供了三种类加载器(Class Loader):Bootstrap、Extension、Application。

先找到所加载类的依赖,首先从Bootstrap类加载器开始,它会在$JAVA_HOME/jre/lib目录下的rt.jar包中查找;如果没有找到,再通过Extension类加载器在$JAVA_HOME/jre/lib/ext目录下的类文件中查找;如果还没找到,再通过Application类加载器查找CLASSPATH环境变量下的所有类文件和jar包文件。如果通过以上步骤仍然未找到.class文件,那么直接抛出异常ClassNotFoundException

需要注意的是,Java中类加载器的加载方式采用了双亲委托机制(Delegation-Hierarchy principle)
在这里插入图片描述
流程就是我们上面提到的从Bootstrap到Extension再到Application,依次递归。
双亲委托的好处:

  1. 强制从最高一级开始加载,保证核心类能够加载完毕;
  2. 避免重复加载已有的类;
  3. 从安全角度考虑,避免不安全的类被加载进来(比如已经由最高一级加载进来了String类,其它第三方的String类就无法被加载);

注意:

  1. 不同的classloader加载的同一个class文件,也是不同的类;
  2. 实现自己的类加载器,只需要继承ClassLoader,并覆盖findClass方法。
2) Linking(链接:Verify、Prepare、Resolve)

Linking即链接,分为三个阶段:Verify、Prepare、Resolve。

  1. Verify:校验
    校验.class文件的正确性,比如.class文件是否适当地格式化了,又或者.class文件是不是由有效的编译器编译出来的。如果校验不通过,则会抛出异常java.lang.VerifyError
    注:此步骤不是必须,可通过配置跳过。
  2. Preparation:准备
    为类中的静态变量(static variables)分配内存空间, 不进行任何初始化动作,也就是0或null;
    注:类中的静态变量可以理解为是类成员变量,而不是实例成员变量。
  3. Resolve
    将符号引用替换成直接引用。
3) Initialization(初始化)

类加载最后一个流程,初始化所有静态变量,并执行静态代码块。
加载的顺序是在这个类中从上到下,从这个类的父类再到这个类本身。
这个阶段会去真正执行代码,具体包括:静态变量、静态代码块、构造函数、变量显式赋值。

这些代码执行的顺序遵循以下两个原则:

  1. 有static先初始化static,然后是非static的
  2. 显式初始化,构造块初始化,最后调用构造函数进行初始化

2. 运行时数据区

运行时数据区,即Runtime Data Areas,也叫也叫JVM Memory(JVM内存区)。它由五大块组成:

  1. Method Area:方法区;
  2. Heap Area:堆区;
  3. Stack Area:栈区;
  4. PC Registers:程序计数器;
  5. Native Method Stack:本地方法栈;
    其中Method Area、Heap Area都只有一个,跟随JVM创建而创建,而Stack Area、PC Registers、Native Method Stack是随线程创建而创建,随线程销毁而销毁的,所以可能会有多个。
    在这里插入图片描述

(1) Method Area(线程共享)

  1. 永久代(Permanent Generation), 用于存储被JVM加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。需要注意的是,在HotSpot中,HotSpot VM把GC分代收集扩展至方法区, 即使用Java堆的永久代来实现方法区, 这样 HotSpot 的垃圾收集器就可以像管理 Java 堆一样管理这部分内存, 而不必为方法区开发专门的内存管理器(永久带的内存回收的主要目标是针对常量池的回收和类型的卸载, 因此收益一般很小)。
  2. 运行时常量池(Runtime Constant Pool)是方法区的一部分。Class 文件中除了有类的版本、字段、方法、接口等描述等信息外,还有一项信息是常量池 (Constant Pool Table),用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。 Java 虚拟机对 Class 文件的每一部分(自然也包括常量池)的格式都有严格的规定,每一个字节用于存储哪种数据都必须符合规范上的要求,这样才会被虚拟机认可、装载和执行。

(2) Heap Area(线程共享)

  1. 创建的对象和数组都保存在 Java 堆内存中,
  2. 也是垃圾收集器进行垃圾收集的最重要的内存区域。
  3. 由于现代 VM 采用分代收集算法, 因此 Java 堆从 GC 的角度还可以细分为: 新生代(Eden区、From Survivor区和To Survivor区)和老年代。

(3) Stack Area(线程私有)

也叫虚拟机栈,它有如下特点:

  1. Stack Area是描述 java 方法执行的内存模型,每个方法在执行的同时都会创建一个栈帧(Stack Frame),用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。由此可知,线程当前执行的方法所对应的栈帧必定位于Java栈的顶部,以及使用递归方法的时候容易导致栈内存溢出的现象。
  2. 栈帧( Frame)是用来存储数据和部分过程结果的数据结构,同时也被用来处理动态链接(Dynamic Linking)、 方法返回值和异常分派( Dispatch Exception)。栈帧随着方法调用而创建,随着方法结束而销毁——无论方法是正常完成还是异常完成(抛出了在方法内未被捕获的异常)都算作方法结束。
  3. 栈上分配:对于小对象(一般几十个bytes),在没有逃逸的情况下,可以直接分配在栈上(直接分配在栈上,可以自动回收,减轻GC压力);大对象或者逃逸对象无法栈上分配。

(4) PC Registers(线程私有)

PC Registers也叫程序计数器,由于是线程私有的,所以它存储的是这个线程当前执行指令的物理地址。唯一一块不会出现OutOfMemoryError的区域。

(5) Native Method Stack(线程私有)

虚拟机栈为执行 Java 方法服务, 而本地方法栈则为本地方法服务。什么是本地方法?简单的说Native Method就是Java能调用非Java方法的一个接口。

3. 执行引擎

执行引擎就是用来运行.class文件的,它一行一行地读取运行数据区中的字节码。执行引擎分为三个部分:Interpreter、JIT、Garbage Collector。

  1. 解释器(Interpreter):
    JVM解释器根据预先定义好的JVM指令到机器指令的映射,将每一条字节码指令都转换成对应的本地指令(native instruction),并且在没有对代码进行任何优化的情况下,直接执行。
  2. JIT 编译器(JIT compiler):
    为了提高性能,JIT 编译器会将合适的字节码序列(如上面提到的重复方法调用代码,以及其他的重复代码)编译成本地机器码,这些本地机器码可以被重复使用,以此来提高系统的性能。

JIT编译器由四个部分组成:

  • 中间代码生成器(Intermediate Code Generator): 用于生成中间代码;
  • 代码优化器(Code Optimizer): 用于优化上面生成的中间代码;
  • 目标代码生成器(Target Code Generator): 生成本地机器码;
  • 配置器(Profiler): 一个特殊的部分,用于查找hotspot中的方法是否多次执行。

[补充:]
解释执行:
将高级语言编写的代码一条一条读取,解释成等价的低级语言代码并在对应的低级虚拟机上执行,在读取解释下一条代码,直到全部代码解释执行完毕。
编译执行:
将所有的由高级语言编写的程序进行编译(转换成能够实现等价功能的低级语言程序),并在低级虚拟机上执行。
(JIT, Just-in-time)及时编译:
结合解释执行和编译执行的特点,它编译一部分代码,执行,再继续编译执行(不是一次性编译)。

计算机体系结构中将计算机系统按功能划分层次结构:

第6级(虚拟机) -> 应用语言机器
第5级(虚拟机) -> 高级语言机器
第4级(虚拟机) -> 汇编语言机器
第3级(虚拟机) -> 操作系统机器
第2级(物理机) -> 传统机器语言机器
第1级(物理机) -> 微程序机器

  1. 翻译(Translation): 先用转换程序把高一级机器上的程序转换为低一级机器上的等效程序,然后再在该低级机器上运行,实现程序的功能。
  2. 解释(Interpretation): 是对于高一级机器上的程序的每一条语句或指令,都转成去执行低一级机器的一段等效程序.
    执行完之后,再去高一级机器取下一条语句或指令,再进行解释执行,如此反复,知道解释执行完整个程序。
    在6级层次中,一般下面三层是用解释实现的,而上面三层是经常使用翻译的方式。
  1. 垃圾收集器(Garbage Collector): 收集和清除那些不可达对象(unreferenced objects or dead objects)。

4. Java Native Interface

Java Native Interface,简称JNI,它是和Native Method Libraries交互的一个接口,它使得执行本地库(一般是C或C++写的)成为可能。

5. Native Method Libraries

本地方法库,一般给执行引擎使用。

三、对象创建

1. 创建对象的六步

在这里插入图片描述

  1. 类加载检查
    JVM遇到一条new指令时,首先检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。
    如果没有,那必须先执行相应的类的加载过程。

  2. 对象分配内存
    对象所需内存的大小在类加载完成后便完全确定(对象内存布局),为对象分配空间的任务等同于把一块确定大小的内存从Java堆中划分出来。
    根据Java堆中是否规整有两种内存的分配方式:(Java堆是否规整由所采用的垃圾收集器是否带有压缩整理功能决定)。

    • 指针碰撞(Bump the pointer)
      Java堆中的内存是规整的,所有用过的内存都放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,分配内存也就是把指针向空闲空间那边移动一段与内存大小相等的距离。例如:Serial、ParNew等收集器。
    • 空闲列表(Free List)
      Java堆中的内存不是规整的,已使用的内存和空闲的内存相互交错,就没有办法简单的进行指针碰撞了。虚拟机必须维护一张列表,记录哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录。例如:CMS这种基于Mark-Sweep算法的收集器。
  3. 并发处理
    对象创建在虚拟机中是非常频繁的行为,即使是仅仅修改一个指针指向的位置,在并发情况下也并不是线程安全的,可能出现正在给对象A分配内存,指针还没来得及修改,对象B又同时使用了原来的指针来分配内存的情况。解决这个问题有两种方案:

    • 同步
      虚拟机采用CAS配上失败重试的方式保证更新操作的原子性

    • 本地线程分配缓冲(Thread Local Allocation Buffer, TLAB)
      把内存分配的动作按照线程划分为在不同的空间之中进行,即每个线程在Java堆中预先分配一小块内存(TLAB)。哪个线程要分配内存,就在哪个线程的TLAB上分配。只有TLAB用完并分配新的TLAB时,才需要同步锁定。

      虚拟机是否使用TLAB,可以通过-XX:+/-UseTLAB参数来设定。

  4. 内存空间初始化
    虚拟机将分配到的内存空间都初始化为零值(不包括对象头),如果使用了TLAB,这一工作过程也可以提前至TLAB分配时进行。
    内存空间初始化保证了对象的实例字段在Java代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。
    注意:类的成员变量可以不显示地初始化(Java虚拟机都会先自动给它初始化为默认值)。方法中的局部变量如果只负责接收一个表达式的值,可以不初始化,但是参与运算和直接输出等其它情况的局部变量需要初始化。

  5. 对象设置
    虚拟机对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息。这些信息存放在对象的对象头之中。

  6. 执行init()
    在上面的工作都完成之后,从虚拟机的角度看,一个新的对象已经产生了。但是从Java程序的角度看,对象的创建才刚刚开始init()方法还没有执行,所有的字段都还是零。
    所以,一般来说(由字节码中是否跟随invokespecial指令所决定),执行new指令之后会接着执行init()方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算产生出来。

2. 创建对象的四种方式

  1. new
  2. 反射
    1. Class类的newInstance方法
    2. Constructor类的newInstance方法
  3. clone
  4. 反序列化

四、『虚拟机栈』栈帧

JVM虚拟机栈中存放的是Stack Frame:栈帧。
一个线程对应一个虚拟机栈,虚拟机栈中存放着一组栈帧。
线程每调用一个方法就对应着 JVM Stack 中 Stack Frame 的入栈,方法执行完毕或者异常终止对应着出栈(销毁)。
由于先进后出的特点,位于栈顶的栈帧才是当前栈。
我们看一下爱栈帧所包含的内容:
在这里插入图片描述

1. 局部变量表(Local Variable Table)

在编译程序代码的时候就可以确定栈帧中需要多大的局部变量表,具体大小可在编译后的 Class 文件中看到。
局部变量表的容量以 Variable Slot(变量槽)为最小单位,每个变量槽都可以存储 32 位长度的内存空间。
在方法执行时,虚拟机使用局部变量表完成参数值到参数变量列表的传递过程的,如果执行的是实例方法,那局部变量表中第 0 位索引的 Slot 默认是用于传递方法所属对象实例的引用(在方法中可以通过关键字 this 来访问到这个隐含的参数)。
其余参数则按照参数表顺序排列,占用从 1 开始的局部变量 Slot。
基本类型数据以及引用和 returnAddress(返回地址)占用一个变量槽,long 和 double 需要两个。

2. 操作数栈(Operand Stack)

同样也可以在编译期确定大小。
Frame 被创建时,操作栈是空的。操作栈的每个项可以存放 JVM 的各种类型数据,其中 long 和 double 类型(64位数据)占用两个栈深。
方法执行的过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是出栈和入栈操作(与 Java 栈中栈帧操作类似)。
操作栈调用其它有返回结果的方法时,会把结果 push 到栈上(通过操作数栈来进行参数传递)。

3. 动态链接(Dynamic Linking)

每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态链接。
在类加载阶段中的解析阶段会将符号引用转为直接引用,这种转化也称为静态解析。另外的一部分将在运行时转化为直接引用,这部分称为动态链接。

4. 返回地址(Return Address)

方法开始执行后,只有 2 种方式可以退出 :方法返回指令,异常退出。

5. 帧数据区(Stack Data)

帧数据区的大小依赖于 JVM 的具体实现。

关于栈帧的具体分析,我们可以自己编写一个java文件,然后编译再反编译,查看代码的内容。
/**

  • 编译:javac com\jvm\StackFrame.java
  • 反编译:javap -p -v com\jvm\StackFrame.class
    */
    在这里插入图片描述

在这里插入图片描述

五、『堆』内存布局

一个Java对象在内存中包括对象头、实例数据和补齐填充3个部分:
在这里插入图片描述

1. 对象头

  • Mark Word:包含一系列的标记位,比如轻量级锁的标记位,偏向锁标记位等等。在32位系统占4字节,在64位系统中占8字节;
  • Class Pointer:用来指向对象对应的Class对象(其对应的元数据对象)的内存地址。在32位系统占4字节,在64位系统中占8字节;
  • Length:如果是数组对象,还有一个保存数组长度的空间,占4个字节;

2. 对象实际数据

对象实际数据包括了对象的所有成员变量,其大小由各个成员变量的大小决定,比如:byte和boolean是1个字节,short和char是2个字节,int和float是4个字节,long和double是8个字节,reference是4个字节(64位系统中是8个字节)。
在这里插入图片描述
对于reference类型来说,在32位系统上占用4bytes, 在64位系统上占用8bytes。

3. 对齐填充

Java对象占用空间是8字节对齐的,即所有Java对象占用bytes数必须是8的倍数。例如,一个包含两个属性的对象:int和byte,这个对象需要占用8+4+1=13个字节,这时就需要加上大小为3字节的padding进行8字节对齐,最终占用大小为16个字节。

注意:以上对64位操作系统的描述是未开启指针压缩的情况,关于指针压缩会在下文中介绍。

4. 对象头占用空间大小

这里说明一下32位系统和64位系统中对象所占用内存空间的大小:

  • 在32位系统下,存放Class Pointer的空间大小是4字节,MarkWord是4字节,对象头为8字节;
  • 在64位系统下,存放Class Pointer的空间大小是8字节,MarkWord是8字节,对象头为16字节;
  • 64位开启指针压缩的情况下,存放Class Pointer的空间大小是4字节,MarkWord是8字节,对象头为12字节;
  • 如果是数组对象,对象头的大小为:数组对象头8字节+数组长度4字节+对齐4字节=16字节。其中对象引用占4字节(未开启指针压缩的64位为8字节),数组MarkWord为4字节(64位未开启指针压缩的为8字节);
  • 静态属性不算在对象大小内。

5. 指针压缩

从上文的分析中可以看到,64位JVM消耗的内存会比32位的要多大约1.5倍,这是因为对象指针在64位JVM下有更宽的寻址。对于那些将要从32位平台移植到64位的应用来说,平白无辜多了1/2的内存占用,这是开发者不愿意看到的。

从JDK 1.6 update14开始,64位的JVM正式支持了 -XX:+UseCompressedOops 这个可以压缩指针,起到节约内存占用的新参数。

6. 什么是OOP?

OOP的全称为:Ordinary Object Pointer,就是普通对象指针。启用CompressOops后,会压缩的对象:

  • 每个Class的属性指针(静态成员变量);
  • 每个对象的属性指针;
  • 普通对象数组的每个元素指针。

当然,压缩也不是所有的指针都会压缩,对一些特殊类型的指针,JVM是不会优化的,例如指向PermGen的Class对象指针、本地变量、堆栈元素、入参、返回值和NULL指针不会被压缩。

7. 实例计算对象占用内存

static class B {
        int a;
        int b;
    }
static class C {
        int ba;
        B[] as = new B[3];

        C() {
            for (int i = 0; i < as.length; i++) {
                as[i] = new B();
            }
        }
    }
  1. 对象本身大小
    直接计算当前对象占用空间大小,包括当前类及超类的基本类型实例字段大小、引用类型实例字段引用大小、实例基本类型数组总占用空间、实例引用类型数组引用本身占用空间大小; 但是不包括超类继承下来的和当前类声明的实例引用字段的对象本身的大小、实例引用数组引用的对象本身的大小。

现在我们来看一下C对象本身自己所占用的内存空间:

未开启压缩:
16(对象头)+4(ba)+8(as引用的大小)+padding/4=32
开启压缩:
12+4+4+padding/4=24

  1. 当前对象占用的空间总大小
    递归计算当前对象占用空间总大小,包括当前类和超类的实例字段大小以及实例字段引用对象大小。

递归计算复合对象占用的内存的时候需要注意的是:对齐填充是以每个对象为单位进行的,看下面这个图就很容易明白。

现在我们来手动计算下C对象占用的全部内存是多少,主要是三部分构成:C对象本身的大小+数组对象的大小+B对象的大小。

未开启压缩:
(16 + 4 + 8+4(padding)) + (24+ 8*3) +(16+8)*3 = 152bytes

开启压缩:
(12 + 4 + 4 +4(padding)) + (16 + 4*3 +4(数组对象padding)) + (12+8+4(B对象padding))*3= 128bytes

六、『堆』垃圾回收(GC)

垃圾回收(Garbage Collection,GC),顾名思义就是释放垃圾占用的空间,防止内存泄露。有效的使用可以使用的内存,对内存堆中已经死亡的或者长时间没有使用的对象进行清除和回收。

1. 垃圾判断算法

(1) 引用计数法

给每个对象添加一个计数器,当有地方引用该对象时计数器加1,当引用失效时计数器减1。用对象计数器是否为0来判断对象是否可被回收。缺点:无法解决循环引用的问题。

先创建一个字符串,String m = new String(“jack”);,这时候 “jack” 有一个引用,就是m。然后将m设置为null,这时候 “jack” 的引用次数就等于 0 了,在引用计数算法中,意味着这块内容就需要被回收了。

引用计数算法是将垃圾回收分摊到整个应用程序的运行当中了,而不是在进行垃圾收集时,要挂起整个应用的运行,直到对堆中所有对象的处理都结束。因此,采用引用计数的垃圾收集不属于严格意义上的Stop-The-World的垃圾收集机制。

看似很美好,但我们知道JVM的垃圾回收就是Stop-The-World的,那是什么原因导致我们最终放弃了引用计数算法呢?看下面的例子。

public class ReferenceCountingGC {

  public Object instance;

  public ReferenceCountingGC(String name) {
  }

  public static void testGC(){

    ReferenceCountingGC a = new ReferenceCountingGC("objA");
    ReferenceCountingGC b = new ReferenceCountingGC("objB");

    a.instance = b;
    b.instance = a;

    a = null;
    b = null;
  }
}

我们可以看到,最后这2个对象已经不可能再被访问了,但由于他们相互引用着对方,导致它们的引用计数永远都不会为0,通过引用计数算法,也就永远无法通知GC收集器回收它们。

(2) 可达性分析算法

通过GC ROOT的对象作为搜索起始点,通过引用向下搜索,所走过的路径称为引用链。通过对象是否有到达引用链的路径来判断对象是否可被回收(可作为GC ROOT的对象:虚拟机栈中引用的对象,方法区中类静态属性引用的对象,方法区中常量引用的对象,本地方法栈中JNI引用的对象)
在这里插入图片描述
通过可达性算法,成功解决了引用计数所无法解决的循环依赖问题,只要你无法与GC Root建立直接或间接的连接,系统就会判定你为可回收对象。那这样就引申出了另一个问题,哪些属于GC Root。

Java内存区域中可以作为GC ROOT的对象:

  • 虚拟机栈中引用的对象
public class StackLocalParameter {

  public StackLocalParameter(String name) {}

  public static void testGC() {
    StackLocalParameter s = new StackLocalParameter("localParameter");
    s = null;
  }
}

此时的s,即为GC Root,当s置空时,localParameter对象也断掉了与GC Root的引用链,将被回收。

  • 方法区中类静态属性引用的对象
public class MethodAreaStaicProperties {

  public static MethodAreaStaicProperties m;

  public MethodAreaStaicProperties(String name) {}

  public static void testGC(){
    MethodAreaStaicProperties s = new MethodAreaStaicProperties("properties");
    s.m = new MethodAreaStaicProperties("parameter");
    s = null;
  }
}

此时的s,即为GC Root,s置为null,经过GC后,s所指向的properties对象由于无法与GC Root建立关系被回收。而m作为类的静态属性,也属于GC Root,parameter 对象依然与GC root建立着连接,所以此时parameter对象并不会被回收。

  • 方法区中常量引用的对象
public class MethodAreaStaicProperties {

  public static final MethodAreaStaicProperties m = MethodAreaStaicProperties("final");

  public MethodAreaStaicProperties(String name) {}

  public static void testGC() {
    MethodAreaStaicProperties s = new MethodAreaStaicProperties("staticProperties");
    s = null;
  }
}

m即为方法区中的常量引用,也为GC Root,s置为null后,final对象也不会因没有与GC Root建立联系而被回收。

  • 本地方法栈中引用的对象
    在这里插入图片描述
    任何native接口都会使用某种本地方法栈,实现的本地方法接口是使用C连接模型的话,那么它的本地方法栈就是C栈。当线程调用Java方法时,虚拟机会创建一个新的栈帧并压入Java栈。然而当它调用的是本地方法时,虚拟机会保持Java栈不变,不再在线程的Java栈中压入新的帧,虚拟机只是简单地动态连接并直接调用指定的本地方法。

2. 垃圾回收算法

(1) 标记-清除算法

在这里插入图片描述
标记清除算法(Mark-Sweep)是最基础的一种垃圾回收算法,它分为2部分,先把内存区域中的这些对象进行标记,哪些属于可回收标记出来,然后把这些垃圾拎出来清理掉。就像上图一样,清理掉的垃圾就变成未使用的内存区域,等待被再次使用。但它存在一个很大的问题,那就是内存碎片。

上图中等方块的假设是2M,小一些的是1M,大一些的是4M。等我们回收完,内存就会切成了很多段。我们知道开辟内存空间时,需要的是连续的内存区域,这时候我们需要一个2M的内存区域,其中有2个1M是没法用的。这样就导致,其实我们本身还有这么多的内存的,但却用不了。

(2) 复制算法

在这里插入图片描述
复制算法(Copying)是在标记清除算法基础上演化而来,解决标记清除算法的内存碎片问题。它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。保证了内存的连续可用,内存分配时也就不用考虑内存碎片等复杂情况。复制算法暴露了另一个问题,例如硬盘本来有500G,但却只能用200G,代价实在太高。

(3) 标记-整理算法

在这里插入图片描述

标记-整理算法标记过程仍然与标记-清除算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,再清理掉端边界以外的内存区域。

标记整理算法解决了内存碎片的问题,也规避了复制算法只能利用一半内存区域的弊端。标记整理算法对内存变动更频繁,需要整理所有存活对象的引用地址,在效率上比复制算法要差很多。一般是把Java堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。

(4) 分代收集算法

分代收集算法分代收集算法严格来说并不是一种思想或理论,而是融合上述3种基础的算法思想,而产生的针对不同情况所采用不同算法的一套组合拳,根据对象存活周期的不同将内存划分为几块。

  • 在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。

  • 在老年代中,因为对象存活率高、没有额外空间对它进行分配担保,就必须使用标记-清理算法或者标记-整理算法来进行回收。

3. 内存区域与回收策略

对象的内存分配,往大方向讲,就是在堆上分配(但也可能经过JIT编译后被拆散为标量类型并间接地栈上分配),对象主要分配在新生代的Eden区上,如果启动了本地线程分配缓冲,将按线程优先在TLAB上分配。少数情况下也可能会直接分配在老年代中(大对象直接分到老年代),分配的规则并不是百分百固定的,其细节取决于当前使用的是哪一种垃圾收集器组合,还有虚拟机中与内存相关的参数的设置。

  1. 对象优先在Eden分配
    大多数情况下,对象会在新生代Eden区中分配。当Eden区没有足够空间进行分配时,虚拟机会发起一次 Minor GC。Minor GC相比Major GC更频繁,回收速度也更快。通过Minor GC之后,Eden区中绝大部分对象会被回收,而那些存活对象,将会送到Survivor的From区(若From区空间不够,则直接进入Old区) 。

  2. Survivor区
    Survivor区相当于是Eden区和Old区的一个缓冲,类似于我们交通灯中的黄灯。Survivor又分为2个区,一个是From区,一个是To区。每次执行Minor GC,会将Eden区中存活的对象放到Survivor的From区,而在From区中,仍存活的对象会根据他们的年龄值来决定去向。(From Survivor和To Survivor的逻辑关系会发生颠倒: From变To , To变From,目的是保证有连续的空间存放对方,避免碎片化的发生)

  • Survivor区存在的意义
    如果没有Survivor区,Eden区每进行一次Minor GC,存活的对象就会被送到老年代,老年代很快就会被填满。而有很多对象虽然一次Minor GC没有消灭,但其实也并不会蹦跶多久,或许第二次,第三次就需要被清除。这时候移入老年区,很明显不是一个明智的决定。所以,Survivor的存在意义就是减少被送到老年代的对象,进而减少Major GC的发生。Survivor的预筛选保证,只有经历16次Minor GC还能在新生代中存活的对象,才会被送到老年代。
  1. 大对象直接进入老年代
    所谓大对象是指,需要大量连续内存空间的Java对象,最典型的大对象就是那种很长的字符串以及数组。大对象对虚拟机的内存分配来说就是一个坏消息,经常出现大对象容易导致内存还有不少空间时就提前触发垃圾收集以获取足够的连续空间来 “安置” 它们。
    虚拟机提供了一个XX:PretenureSizeThreshold参数,令大于这个设置值的对象直接在老年代分配,这样做的目的是避免在Eden区及两个Survivor区之间发生大量的内存复制(新生代采用的是复制算法)。

  2. 长期存活的对象将进入老年代
    虚拟机给每个对象定义了一个对象年龄(Age)计数器,如果对象在Eden出生并经过第一次Minor GC后仍然存活,并且能被Survivor容纳的话,将被移动到Survivor空间中(正常情况下对象会不断的在Survivor的From与To区之间移动),并且对象年龄设为1。对象在Survivor区中每经历一次Minor GC,年龄就增加1岁,当它的年龄增加到一定程度(默认15岁),就将会晋升到老年代中。对象晋升老年代的年龄阈值,可以通过参数 XX:MaxPretenuringThreshold 设置。

  3. 动态对象年龄判定
    为了能更好地适应不同程度的内存状况,虚拟机并不是永远地要求对象的年龄必须达到 MaxPretenuringThreshold才能晋升老年代,如果Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于改年龄的对象就可以直接进入老年代,无需等到MaxPretenuringThreshold中要求的年龄。
    这其实有点类似于负载均衡,轮询是负载均衡的一种,保证每台机器都分得同样的请求。看似很均衡,但每台机的硬件不通,健康状况不同,我们还可以基于每台机接受的请求数,或每台机的响应时间等,来调整我们的负载均衡算法。

4. 垃圾收集器

HotSpot)7种垃圾收集器
在这里插入图片描述
7种垃圾收集器作用于不同的分代,如果两个收集器之间存在连续,就说明他们可以搭配使用。
从JDK1.3到现在,从Serial收集器-》Parallel收集器-》CMS-》G1,用户线程停顿时间不断缩短,但仍然无法完全消除。
1、Serial收集器(串行收集器)
在这里插入图片描述
  Serial收集器是最基本、发展历史最悠久的收集器,曾是(JDK1.3.1之前)虚拟机新生代收集的唯一选择。
  Serial收集器是一个单线程的收集器。“单线程”的意义不仅仅是它只会使用一个CPU或一条收集器线程去完成垃圾收集工作,更重要的是它在垃圾收集的时候,必须暂停其他所有工作的线程,直到它收集结束。
  Serial收集器是HotSpot虚拟机运行在Client模式下的默认新生代收集器。
  Serial收集器具有简单而高效,由于没有线程交互的开销,可以获得最高的单线程收集效率(在单个CPU环境中)。
  “-XX:+UseSerialGC”:添加该参数来显式的使用Serial垃圾收集器。
2、ParNew收集器
  ParNew收集器是Serial收集器的多线程版本,除了使用多条线程进行垃圾收集之外,其余行为包括Serial收集器可用的所有控制参数、收集算法、Stop The Word、对象分配规则、回收策略等都与Serial收集器一样。
  ParNew收集器是许多运行在Server模式下的虚拟机首选的新生代收集器,其中一个原因是,除了Serial收集器之外,目前只有ParNew收集器能与CMS收集器配合工作。 
  “-XX:+UseConcMarkSweepGC”:指定使用CMS后,会默认使用ParNew作为新生代收集器。
“-XX:+UseParNewGC”:强制指定使用ParNew。
“-XX:ParallelGCThreads”:指定垃圾收集的线程数量,ParNew默认开启的收集线程与CPU的数量相同。
  并行(Parallel):指多条垃圾收集线程并行工作,但此时用户线程仍然处于等待状态。
  并发(Concurrent):指用户线程与垃圾收集线程同时执行(但不一定是并行,可能是交替执行),用户线程继续工作,而垃圾收集程序运行在另一个CPU上。
3、Parallel Scavenge收集器
  Parallel Scavenge收集器是一个新生代收集器,使用复制算法,且是并行的多线程收集器。
  Parallel Scavenge收集器关注点是达到一个可控制的吞吐量(吞吐量 = 运行用户代码时间 / (运行用户代码时间 + 垃圾收集时间)),而其他收集器关注点在尽可能的缩短垃圾收集时用户线程的停顿时间。
  Parallel Scavenge收集器提供了两个参数来用于精确控制吞吐量,一是控制最大垃圾收集停顿时间的 -XX:MaxGCPauseMillis参数,二是控制吞吐量大小的 -XX:GCTimeRatio参数;
  “ -XX:MaxGCPauseMillis” 参数允许的值是一个大于0的毫秒数,收集器将尽可能的保证内存垃圾回收花费的时间不超过设定的值(但是,并不是越小越好,GC停顿时间缩短是以牺牲吞吐量和新生代空间来换取的,如果设置的值太小,将会导致频繁GC,这样虽然GC停顿时间下来了,但是吞吐量也下来了)。
  “ -XX:GCTimeRatio”参数的值是一个大于0且小于100的整数,也就是垃圾收集时间占总时间的比率,默认值是99,就是允许最大1%(即1/(1+99))的垃圾收集时间。
  “-XX:UseAdaptiveSizePolicy”参数是一个开发,如果这个参数打开之后,虚拟机会根据当前系统运行情况收集监控信息,动态调整新生代的比例、老年大大小等细节参数,以提供最合适的停顿时间或最大的吞吐量,这种调节方式称为GC自适应的调节策略。
4、Serial Old收集器
  Serial Old收集器是Seria收集器的老年代版本,他同样是一个单线程收集器,使用" 标记-整理" 算法。
  Serial Old收集器主要用于Client模式下的虚拟机使用。
  Server模式下的两大用途:一、在JDK1.5及之前的版本与Parallel Scavenge收集器搭配使用;二、作为CMS收集器的后备方案,在并发收集发生Conturrent Mode Failure时使用。
5、Paraller Old收集器
  Parallel Old收集器是Parallel Scavenge收集器的老年代版本,使用多线程和“标记-整理”算法。
  在JDK1.6中才出现。
6、CMS(Conturrent Mark Sweep)收集器
在这里插入图片描述
  CMS收集器是一种以获取最短回收停顿时间为目标的收集器。
  目前很大一部分的Java应用集中在互联网或者B/S系统的服务端上。
  CMS收集器是基于“标记-清除”算法实现,它的整个运行过程可以分为:初始登记(标记一下GC Roots能直接关联到的对象,这个过程速度很快)、并发标记(进行GCRoots Tracing的过程)、重新标记(修正并发标记期间因用户线程继续运作而导致标记产生变动的那一部分对象的标记记录,速度稍慢)、并发清除(清除死亡的对象)4个步骤;其中,初始标记和重新标记仍然需要“Stop The World”。
  CMS收集器运行的整个过程中,最耗费时间的并发标记和并发清楚过程收集器线程和用户线程是一起工作的,所以总体来说,CMS收集器的内存回收过程是与用户线程一起并发执行的。
  优点:并发收集、低停顿。
  缺点:
    一:CMS收集器对CPU资源非常敏感。虽然在两个并发阶段不会导致用户线程停顿,但是会因为占用了一部分线程而导致应用程序变慢,总吞吐量下降。CMS默认启动的回收线程数是(CPU数量+3)/4。
    二:CMS收集器无法处理浮动垃圾,可能出现“Conturrent Mode Failure”失败而导致另一次Full GC产生。由于CMS并发清除阶段用户线程还在运行,伴随着程序还在产生新的垃圾,这一部分垃圾出现在标记之后,CMS无法在当次收集中处理掉它们,只能留到下次再清理,这一部分垃圾称为“浮动垃圾”。也正是由于在垃圾收集阶段用户线程还在运行,那么也就需要预留有足够的内存空间给用户线程使用,因此CMS收集器不能像其他收集器那样等待老年代填满之后再进行收集,需要预留一部分空间给并发收集时用户程序使用。可以通过“-XX:CMSInitiatingOccupancyFraction”参数设置老年代内存使用达到多少时启动收集。
    三:由于CMS收集器是一个基于“标记-清除”算法的收集器,那么意味着收集结束会产生大量碎片,有时候往往还有很多内存未使用,可是没有一块连续的空间来分配一个对象,导致不得不提前触发一次Full GC。CMS收集器提供了一个“-XX:UseCMSCompactAtFullCollection”参数(默认是开启的)用于在CMS收集器顶不住要FullGC时开启内存碎片整理(内存碎片整理意味着无法并发执行不得不停顿用户线程)。参数“-XX:CMSFullGCsBeforeCompaction”来设置执行多少次不压缩的Full GC后,跟着来一次带压缩的(默认值是0,意味着每次进入Full GC时都进行碎片整理)。
7、G1(Garbage-First)收集器
  G1收集器是当今收集器技术发展的最前沿成果之一;
  相比其它收集器,具有如下特点:
    1、并行与并发:G1能够重发利用多CPU、多核环境下的优势,使用多个CPU来缩短Stop-The-World停顿时间。
    2、分代收集:与其他收集器一样,分代概念在G1中依然存在。
    3、空间整合:与CMS的“标记-清理”算法不同,G1从整体来看是基于“标记-整理”来实现的收集器,从局部(两个Region之间)上来看是基于“复制”算法实现的,这两种算法都意味着G1运作期间不会产生内存空间碎片,收集后能够提供整体的可用内存。
    4、可预测停顿:G1除了追求低停顿之外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒。
    
  使用G1收集器时,Java堆的内存布局与其他收集器有很大的区别,它将整个Java堆划分为多个大小相等的独立区域(Region),虽然还保留着新生代和老年代的概念,但新生代和老年代不再是物理隔离的了,他们都是一部分Region(不需要连续)的集合。
  G1收集器之所以能够建立可预测的停顿时间模型,是因为它可以有计划地避免在整个Java对中进行全区域的垃圾收集。G1跟踪各个Region里面垃圾堆积的价值大小(回收所获得的空间大小以及回收所需要时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region(这也是Garbage-First名称的由来)。
  G1收集器的运作大致可分为:
    1、初始标记:需要停顿,耗时短;
    2、并发标记:
    3、最终标记:需要停顿,可并发执行;
    4、筛选标记:

七、内存模型

https://www.jianshu.com/p/15106e9c4bf3

八、调试工具:Arthas

Arthas 是Alibaba开源的Java诊断工具,深受开发者喜爱。

当你遇到以下类似问题而束手无策时,Arthas可以帮助你解决:

这个类从哪个 jar 包加载的?为什么会报各种类相关的 Exception?
我改的代码为什么没有执行到?难道是我没 commit?分支搞错了?
遇到问题无法在线上 debug,难道只能通过加日志再重新发布吗?
线上遇到某个用户的数据处理有问题,但线上同样无法 debug,线下无法重现!
是否有一个全局视角来查看系统的运行状况?
有什么办法可以监控到JVM的实时运行状态?
怎么快速定位应用的热点,生成火焰图?
Arthas支持JDK 6+,支持Linux/Mac/Windows,采用命令行交互模式,同时提供丰富的 Tab 自动补全功能,进一步方便进行问题的定位和诊断。
在这里插入图片描述

欢迎关注我的技术公众号:国民程序员,我们的目标:输出干货

  1. 每天分享原创技术文章
  2. 海量免费技术资料和视频学习资源
  3. 分享赚钱门道,带领程序员走向财务自由
图片名称
参考资料:

How JVM Works – JVM Architecture?
JDK, JRE, JVM | 深入了解
JVM 的类初始化机制
Java类加载及对象创建过程详解
Java类的加载和对象创建过程简述
Java-JVM 栈帧(Stack Frame)
Java对象内存布局
一个Java对象到底占用多大内存?
JVM垃圾回收机制
深入理解Java内存模型

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值