了解一下 JVM 基本概念


一、JVM 基本认识

1、虚拟机 与 JVM

(1)虚拟机(Virtual Machine),可以理解为一台虚拟的计算机,其是一款软件,用来执行一系列虚拟的计算机指令。
可以分为:系统(硬件)虚拟机、程序(软件)虚拟机。

(2)系统(硬件)虚拟机
系统虚拟机是一个可以运行完整操作系统的一个平台,其模拟了物理计算机的硬件。即相当于在物理计算机上 模拟出 一台计算机(操作系统)。比如:VMware。

(3)程序(软件)虚拟机
程序虚拟机是一个可以运行某个计算机程序的一个平台,其模拟了物理计算机某些硬件功能(比如:处理器、堆栈、寄存器等)、具备相应的指令系统(字节码指令)。即相当于在操作系统上 模拟出 一个软件运行平台。比如:JVM。

(4)JVM
JVM(Java Virtual Machine),是一台执行字节码指令并运行程序的虚拟机,其字节码并不一定由 Java 语言编译而成,任何一个语言通过 编译器 生成 具备 JVM 规范的字节码文件时,均可以被 JVM 解释并执行(即 JVM 是一个跨语言的平台)。

特点:自动内存管理、自动垃圾回收。字节码一次编译,到处运行。

官方文档地址(自行选择合适的版本):https://docs.oracle.com/javase/specs/index.html

(5)学习 JVM 的目的
一般进行 Java 开发时,不需要关注太底层的东西,专注于业务逻辑层面。这是因为 JVM 已经对底层技术、硬件、操作系统这些方面做了相应的处理(JVM 已经帮我们完成了 硬件平台的兼容以及内存资源管理 等工作)。
但由于 JVM 跨平台的特性,其会牺牲一些硬件相关的性能以达到 统一虚拟平台 的效果。当 程序使用人数 增大、业务逻辑复杂时,程序的性能、稳定性、可靠性会受到影响,往往提升硬件的性能不能成比例的提高程序的性能。
所以有必要了解 JVM 一些底层运行原理,写出适合 JVM 运行、优化 的代码,从而提高程序性能(当然也可以快速定位、解决内存溢出等问题)。

2、JVM 整体结构

(1)Java 运行过程
如下图,Java 源码经过 Java 编译器,将源码编译为字节码,再使用 JVM 解析运行字节码。

(2)JVM 运行过程(图片来源于网络)
如下图,字节码文件被类加载器导入,加载、验证字节码文件的正确性并分配初始化内存。
通过执行引擎解释执行字节码文件,并与 运行时数据区 进行数据交互(当然,其中逻辑实现没那么简单,此处略过)。

(3)JVM 架构分类
虚拟机 内部处理指令流可以分为两种:基于栈的指令集架构、基于寄存器的指令集架构。
基于栈架构特点:
不需要硬件的支持,可移植性好(跨平台方便)、设计与实现简单、指令集小但指令会变多(可能会影响效率)。
一般 JVM 都是基于栈的,比如:HotSpot 虚拟机。

基于寄存器架构特点:
依赖于硬件,可移植性差、但指令少(使用更少的指令执行更多的操作,执行效率稍高)。
比如: Android 的 Dalvik 虚拟机

【举例:(执行如下操作时)】
    int a = 2;
    int b = 3;
    a += b;

【基于栈的指令集架构:(输出字节码如下)】
0: iconst_2    常量 2
1: istore_1    常量 2 入栈
2: iconst_3    常量 3
3: istore_2   常量 3 入栈
4: iload_1    
5: iload_2    
6: iadd       2 + 3 相加
7: istore_1   将相加结果 5 入栈

【基于寄存器的指令集架构:(没有实际操做、大致指令如下)】
mov a, 2    将 2 赋给 a
add a, 3    将 a 加 3 后再将结果 赋给 a

可以看到 使用寄存器时,指令数量明显少于栈。

注:
直接打开 class 字节码文件会乱码,可以通过 javap -c 字节码文件 来反编译,得到可读的字节码文件。(也可以使用 IDEA 插件 bytecode viewer 或者 jclasslib bytecode viewer 去查看字节码,此处不做过多介绍)
javap -c XXX.class 对代码进行反编译。
javap -v XXX.class 对代码进行反编译,并显示额外信息(比如:常量池等信息)

(4)简单了解一下 JVM 生命周期
JVM 生命周期 即 JVM 从创建、使用、销毁的整个过程。
JVM 启动
通过引导类加载器(bootstrap class loader)创建一个初始类(initial class)来启动 JVM。这个初始类由虚拟机的具体实现指定(JVM 种类很多)。

JVM 使用(执行)
JVM 用于运行程序,每个程序启动运行都会存在一个 JVM 进程与之对应。
程序结束后,JVM 也就结束。

JVM 销毁
可以分为:正常销毁、异常销毁。

【正常销毁:】
    程序正常结束。
【异常销毁:】
    程序执行中出现异常,且异常未被处理导致 JVM 终止。
    由于操作系统异常,导致 JVM 进程结束。
    调用 System.exit() 方法,参数为非 0 时 JVM 退出。

3、简单了解几个虚拟机

(1)Sun Classic VM
Sun 公司开发的第一款商用虚拟机(在JDK 1.4 时被淘汰)。
内部只提供解释器(解释器、即时编译器不能配合工作,二选一使用)。
注:
解释器:根据字节码文件,一行一行读取解析并执行(立即执行,响应时间短,效率较低)。
即时编译器:把整个字节码文件编译成 可执行的机器码(需要响应时间、造成卡顿),机器码能直接在平台运行,将一些重复出现的代码(热点代码)缓存起来提高执行效率。

(2)Sun Exact VM
为了解决 Classic VM 的问题,Sun 公司提供了此虚拟机(被 HotSpot 替代)。
解释器、编译器混合工作模式。且具备热点探测功能。
使用 Exact Memory Management(准确式内存管理),可以知道内存中某位置的数据的类型。

(3)HotSpot 虚拟机
一家小公司开发,被 Sun 公司收购。
HotSpot 虚拟机采用 解释器、即时编译器 并存的架构,是 JVM 高性能代表作之一。
HotSpot 即热点(热点探测功能),通过 计数器 找到最具有编译价值的代码,触发即时编译(方法被频繁调用)或者栈上替换(方法中循环次数多)。
通过解释器、即时编译器协同工作,在响应时间与执行性能中取得平衡。
如下图:
Java 8 依旧采用 HotSpot 作为 JVM。

(4)BEA JRockit
专注于服务端应用,代码由 即时编译器 编译执行,不包含解释器(即不关心程序启动速度)。
是 JVM 高性能代表作之一,执行速度最快(大量行业数据测试后得出)。
BEA 被 Sun 公司收购,Sun 公司被 Oracle 收购。Oracle 以 HotSpot 为基础,融合了 JRockit 的优秀特性(垃圾回收器、MissionControl)。

(5) IBM J9
市场定位与 HotSpot 接近。广泛应用于 IBM 各种 Java 产品。也是高性能 JVM 代表作之一。

二、类加载子系统(Class Loader SubSystem)

1、类加载子系统作用、流程

(1)作用:
类加载子系统负责从 文件系统 或者 网络 中加载 class 文件(class 文件头部有特殊标识)。
将 class 文件加载到系统内存中,并对数据进行 校验、解析 以及 初始化操作,最终形成可以被虚拟机使用的 Java 类型。
注:
类加载器只负责 class 文件的加载,由执行引擎决定是否能够运行。
加载的类信息 存放于名为 方法区 的内存空间中,方法区还会存放 运行时常量池等信息。

(2)流程
类的生命周期指的是 类从被加载到内存开始、到从内存中移除结束。
过程如下图所示:

而类加载过程,需要关心的就是前几步(加载 到 初始化)。需要注意的是,解析操作可能会在 初始化之后执行(比如:Java 的运行期绑定)。
流程图如下:

2、加载(Loading)

(1)目的:
加载 class 二进制字节流文件到内存中。

(2)步骤:
Step1:使用类加载器 通过一个类的全限定名 去获取此类的 二进制字节流(获取方式开放)。
Step2:将字节流 对应的静态存储结构 转为 方法区 运行时的数据结构。
Step3:在内存中生成一个代表这个类的 java.lang.Class 对象,作为方法区中这个类的各种数据访问的外部入口(HotSpot 中,该 Class 对象存放于方法区中)。

(3)获取 class 二进制字节流方式(简单列举几个)
Type1:从本地系统直接加载。
Type2:从网络中加载(比如: Applet)。
Type3:从 zip 压缩包中读取(jar、war 包等)。
Type4:运行时计算生成(动态代理技术)。
Type5:从数据库中读取(比较少见)。
Type6:从其他文件中读取(JSP 文件生成对应的 Class 类)。

3、连接(Linking)-- 验证(Verification)

(1)目的:
确保 class 文件的二进制字节流中包含的信息符合当前虚拟机的要求,保证数据的正确性 而不会影响虚拟机自身的安全(比如:通过某种方式修改了 class 文件,若不去验证字节流是否符合格式,则可能导致虚拟机载入错误字节流而崩溃)。
注:
验证阶段非常重要但不一定必要,如果代码是经过反复使用、验证过后并没有出现问题,可以考虑将验证关闭(-Xverify:none),从而缩短类加载时间。

(2)验证方式:
具体细节自行查阅相关文档、书籍,此处来源于 “深入理解 JAVA 虚拟机 第二版 周志明 著”。
Step1:文件格式验证
验证字节流是否符合 class 文件格式规范,并能够被当前虚拟机处理。
比如:class 文件要以 CAFEBABE 开头

Step2:元数据验证
对类的 元数据 信息进行语义校验,验证当前数据是否符合 Java 语言规范。
比如:类是否存在父类、是否继承了 final 修饰的类等。

Step3:字节码验证
对类的方法体进行语义校验。
比如:方法体中类型的转换是否有效。

Step4:符号引用验证。
对常量池中各符号引用进行匹配性校验(一般发生在 解析阶段)。
比如:符号引用中通过字符串描述的全限定名能否找到对应的类。
注:
文件格式验证 是 基于 二进制字节流 进行的,通过验证后,会将数据存入 内存的方法区。后续三种验证均是对方法区数据进行操作。

4、连接(Linking)-- 准备(Preparation)

(1)目的:
为类变量分配内存并设置类变量的默认初始值为 零值(比如:int 为 0, boolean 为 false)。
注:
此处的类变量是 static 修饰的变量,但不包含 final static 修饰的变量。
final static 修饰的即为常量,在编译时就已经设置好了,在 准备阶段(preparation)会赋值。
static 修饰的变量在 准备阶段赋零值,在 初始化阶段(Initialization)执行真正赋值操作。
非 static 修饰的变量为 实例变量,随着对象分配到 堆中,并非存在于方法区中。

【举例:】
public static int value = 123;
此时 value 属于类变量,准备阶段 value = 0,初始化阶段 value = 123.

public static final int value = 123;  
此时 value 属于常量,准备阶段 value = 123.

(2)零值

数据类型           零值
int                0
long               0L
short              (short)0
byte               (byte)0
char               '\u0000' 
float              0.0f
double             0.0d
boolean            false
reference          null

5、连接(Linking)-- 解析(Resolution)

(1)目的:
将常量池中的 符号引用 转换为 直接引用。
注:
符号引用(Symbolic References):指用一组符号(字面量)来描述所引用的目标,但引用目标并不一定加载到了内存中。字面量形式明确定义在 Java 虚拟机规范的 Class 文件格式中。
直接引用(Direct References):指直接指向目标的指针 或 能间接定位到目标的句柄,引用目标一定存在于内存中。

(2)解析动作
解析动作主要针对 类或接口、字段、类方法、接口方法、方法类型、方法句柄、调用点限定符 这 7 类符号引用(具体解析过程此处略过,自行查阅文档、书籍)。

6、初始化(Initialization)

(1)目的:
在准备阶段是为 类变量赋零值,而初始化阶段就是真正执行类中相关代码操作,是执行类构造器 () 方法的过程。

(2)值得注意的点

() 方法是 编译器自动收集类中所有 类变量的赋值操作、静态语句块(static{}) 等语句合并而成的。且其顺序是由 语句在源文件中出现的顺序而定的。

() 方法不同于 类的构造函数(实例构造器 ()),若当前类具有父类,则当前类执行 () 之前 父类的

() 方法就已经执行完毕了。对于父接口,当前类执行时不会执行父类接口的 () 方法,只有使用到类变量时才会去实例化(接口中不能定义 静态语句块,可以存在类变量,即常量)。
若一个类中没有类变量以及静态语句块,则不会生成 ()。
在多线程下,虚拟机会保证一个类的 () 方法被加锁、同步,即一个线程执行 () 后,其余执行到此处的线程均会阻塞,直至当前线程执行完毕。其他线程不会再次执行 ()。

(3)初始化的方式
当类被主动使用时,会导致类的初始化。而被动使用时,不会导致类的初始化。
主动使用:
使用 new 关键字实例化对象时。
读取、设置某个类、接口的静态变量时(非 final static 修饰的常量)。
调用某个类的静态方法时。
初始化一个类的子类时(先触发父类初始化)。
JVM 启动时被标明为启动类的类(main 方法所在的类)。
反射调用某类时。
java.lang.invoke.MethodHandle 实例(JDK 7 之后提供的动态语言支持)的解析结果REF_static、REF_putStatic、REF_invokeStatic 句柄对应的类没有初始化,则初始化。

被动使用:
除了上面 7 种情况之外的情况都是被动使用,不会导致类的初始化。

7、类加载器

(1)目的:
前面加载过程的第一步:使用类加载器 通过一个类的全限定名 去获取此类的 二进制字节流。这个类加载器可以由用户自定义实现(在 JVM 外部去实现),使程序可以自定义以何种方式去获取需要的类(当然一般使用 JVM 提供的即可)。
注:
每一个类加载器,都有一个独立的类名称空间,对于任何一个类,该类与加载它的类加载器共同确定它在 JVM 中的唯一性(即判断两个类是否相同,需要保证两个类由同一个 JVM 且同一个类加载器加载时才有可能相等)。

(2)类加载器分类
从 JVM 角度,可以分为两种:
引导类加载器(Bootstrap ClassLoader)、其他所有类的类加载器。
注:
引导类加载器,由 C/C++ 语言编写,是 JVM 的一部分,其实例对象无法被获取。
其他所有类的类加载器,由 Java 语言开发,独立于 JVM,且派生于 java.lang.ClassLoader。

从 开发人员 角度,可以细分为四种:
引导类加载器(Bootstrap ClassLoader)、扩展类加载器(Extension ClassLoader)、应用程序类加载器(Application ClassLoader)、用户自定义类加载器(User-Defined ClassLoader)
注:
引导类加载器,用来加载 Java 的核心类库(JAVA_HOME/jre/lib 或者 sun.boot.class.path 下的内容),且出于安全考虑,其只加载包名为 java、javax、sun 等开头的类。
扩展类加载器,由 Java 语言编写,派生于 ClassLoader(sun.misc.Launcher$ExtClassLoader),其父类为引导类加载器(但是代码中获取不到),用来加载 Java 的扩展类(加载系统属性 java.ext.dirs 或者 jre/lib/ext 下的内容)。
应用程序类加载器,由 Java 语言编写,派生于 ClassLoader(sun.misc.Launcher$AppClassLoader),其父类为扩展类加载器。是程序中默认的类加载器(一般类均由其完成加载),负责加载环境变量(classpath) 或者系统属性 java.class.path 指定的路径下的内容。
用户自定义类加载器,自定义类的加载方式,可以用于拓展加载源、修改类的加载方式。

(3)ClassLoader
ClassLoader 是一个抽象类,除了引导类加载器,其余所有类加载器均由其派生而来。

常见获取 ClassLoader 的方式:

【获取 ClassLoader 的方式:】
【方式一:获取当前类的 ClassLoader(调用当前类的 getClassLoader() 方法))】
ClassLoader classLoader = String.class.getClassLoader();

【方式二:获取当前系统的 ClassLoader(即 sun.misc.Launcher$AppClassLoader)】
ClassLoader classLoader = ClassLoader.getSystemClassLoader();

【方式三:获取当前线程上下文的 ClassLoader】
ClassLoader classLoader = Thread.currentThread().getContextClassLoader();

【举例:】
public class JVMDemo {
    public static void main(String[] args) {
        // 自定义类(JVMDemo),使用默认类加载器加载(系统类加载器)
        ClassLoader jvmDemoClassLoader = JVMDemo.class.getClassLoader();

        // 获取自定义类 的类加载器
        System.out.println(jvmDemoClassLoader); // 为默认类加载器 sun.misc.Launcher$AppClassLoader@18b4aac2
        // 获取自定义类 的父类加载器(拓展类加载器)
        System.out.println(jvmDemoClassLoader.getParent()); // 为拓展类加载器 sun.misc.Launcher$ExtClassLoader@4554617c
        // 获取拓展 类加载器 的父类加载器(引导类加载器)
        System.out.println(jvmDemoClassLoader.getParent().getParent()); // 为引导类加载器,获取不到,为 null

        // 核心类(String),使用引导类加载器加载
        ClassLoader stringClassLoader = String.class.getClassLoader();
        // 获取核心类 的类加载器
        System.out.println(stringClassLoader); // 为引导类加载器,获取不到,为 null

        // 获取系统类加载器
        System.out.println(jvmDemoClassLoader.getSystemClassLoader()); // 为 sun.misc.Launcher$AppClassLoader@18b4aac2
        System.out.println(stringClassLoader.getSystemClassLoader()); // 为 sun.misc.Launcher$AppClassLoader@18b4aac2

        // 获取当前线程的 类加载器
        System.out.println(Thread.currentThread().getContextClassLoader()); // 为 sun.misc.Launcher$AppClassLoader@18b4aac2
    }
}

常见 ClassLoader 方法:

【常见 ClassLoader 方法:】
ClassLoader getParent();             返回该类加载器的 父类加载器
Class<?> loadClass(String name);     加载名为 name 的类,返回 Class 对象。
Class<?> findClass(String name);     查找名为 name 的类,返回 Class 对象。
Class<?> findLoadedClass(String name); 查找名为 name 被加载过的类,返回 Class 对象。
void resolveClass(Class<?> c);       连接指定的 Java 类。

【自定义类加载器步骤:(一般格式)】
Step1:继承 java.lang.ClassLoader,实现自定义类加载器。
Step2:重写 fingClass() 逻辑。

【自定义类加载器步骤:(简单版)】
Step1:继承 java.net.URLClassLoader,该类已编写 findClass() 方法以及获取字节码流的方式。

8、双亲委派机制(Parents Delegation Model)

(1)目的:
使类加载器间具备层级结构。
防止类被重复加载。
保护程序安全,防止核心 API 被篡改。

(2)双亲委派机制原理
JVM 按需加载 class 文件,即使用到该类时,才会去加载其 class 文件到内存生成 class 对象。且采用双亲委派机制去加载。

双亲委派机制原理:
除了顶层的 引导类加载器外,其余的类加载器应该存在其 父类加载器。
如果一个类加载器 收到了 类加载 请求,其并不会立即去加载,而是把这个请求委托给 父类加载器 进行加载,若父类加载器 仍有 父类加载器,则继续向上委托,直至到达 引导类加载器。
如果父类加载器可以完成 类加载 请求,则成功返回,否则子类加载器才会去尝试加载。

如下为 ClassLoader 中的双亲委派实现:
先检查类是否被加载过,若该类没有被加载过,则调用父类加载器的 loadClass() 方法去加载。
若父类加载器不存在,则默认使用 引导类加载器为 父类加载器。如果父类加载失败后,则抛出异常,并执行子类的 findClass() 方法进行加载。

protected Class<?> loadClass(String name, boolean resolve)
    throws ClassNotFoundException
{
    synchronized (getClassLoadingLock(name)) {
        // First, check if the class has already been loaded
        Class<?> c = findLoadedClass(name);
        if (c == null) {
            long t0 = System.nanoTime();
            try {
                if (parent != null) {
                    c = parent.loadClass(name, false);
                } else {
                    c = findBootstrapClassOrNull(name);
                }
            } catch (ClassNotFoundException e) {
                // ClassNotFoundException thrown if class not found
                // from the non-null parent class loader
            }

            if (c == null) {
                // If still not found, then invoke findClass in order
                // to find the class.
                long t1 = System.nanoTime();
                c = findClass(name);

                // this is the defining class loader; record the stats
                sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
                sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
                sun.misc.PerfCounter.getFindClasses().increment();
            }
        }
        if (resolve) {
            resolveClass(c);
        }
        return c;
    }
}

(3)沙箱安全机制
沙箱即限制一个程序的运行环境。
而 JVM 中沙箱安全机制 指将 Java代码限定在 JVM 运行范围内,限制代码对本地资源的访问,从而对代码隔离,防止核心 API 被修改。

如下图所示:
自定义一个 java.lang.String.class,由于 JVM 的机制会使用 引导类加载器 对其加载,而 JVM 会先去加载 /jre/lib/rt.jar 下的 java.lang.String.class,但是其并没有 main 方法,所以报错。

再如下图所示:
自定义一个 java.lang.StringTe.class,同样 JVM 会使用 引导类加载器 加载,但是并没有加载到此类,所以会报错(SecurityException)。

三、运行时数据区

1、了解下 JVM 内存布局

(1)内存布局
内存是非常重要的系统资源,为了保证 JVM 高效稳定的运行,JVM 内存布局规定了 Java 在运行过程中内存 申请、分配、管理 的策略。不同 JVM 对于内存的划分方式以及管理机制存在部分差异。

(2)基本 JVM 内存布局
JVM 在执行 Java 程序过程中,会将其管理的内存 分为 若干个不同的数据区域,每个区域均有各自的用途(堆、方法区等)。有些区域随着 JVM 启动、退出 而创建、销毁,有的区域随着 用户线程的开始、结束 而创建、销毁。
如下图所示:
多个线程共享 堆、以及方法区(永久代、元空间)。
每个线程独有 程序计数器、虚拟机栈、本地方法栈。

2、运行时数据区各内存空间区别

(1)按线程是否共享划分
堆、方法区(元空间 或 永久代)线程共享。
虚拟机栈、本地方法栈、 程序计数器 线程私有。

(2)按抛出异常划分
堆、方法区 会发生 GC 以及 抛出 OOM(OutOfMemoryError)。
虚拟机栈、本地方法栈 会抛出 OOM 或者 StackOverflowError,不会发生 GC。
程序计数器 不会发生 GC 以及抛出 OOM 异常。

四、运行时数据区 -- 程序计数器(Program Counter Register)

1、什么是程序计数器?

程序计数器是一块很小的内存空间,用于存放 下一条字节码指令 所在地址(即 即将执行的指令,由执行引擎读取下一条指令)。
是线程私有的(每个线程创建时均会创建)。
是 JVM 中唯一一个不会出现 OOM(OutOfMemory,内存溢出) 的区域。也不会存在 GC(Garbage Collection,垃圾回收)。
注:
字节码解释器工作时,通过改变程序计数器的值来获取下一条需要执行的字节码指令(比如:分支、循环、跳转、异常处理、线程恢复等操作)。

2、每个线程独有程序计数器。

JVM 多线程通过线程轮流切换并分配处理器执行时间的方式实现的。在任意一个时间点,一个处理器只会处理一个线程的指令,而为了使线程切换后能回到正确的位置(执行正确的指令),每个线程均会有个独立的程序计数器,各个线程间互不影响,通过各自的程序计数器执行正确的指令。
注:
若线程执行的是 Java 方法,程序计数器保存的是 即将执行的字节码指令的地址。
若线程执行的是 Native 方法,程序计数器保存的是 Undefined。

五、运行时数据区 -- 虚拟机栈(Virtual Machine Stacks)

1、栈与堆?虚拟机栈?

(1)栈与堆?
可以理解 栈是运行时的单位、堆时存储时的单位。
栈解决的是程序运行问题,即 程序怎么执行、处理数据。
堆解决的是数据存储问题,即 数据怎么存储、放在何处。

(2)什么是虚拟机栈?
每个线程创建时均会创建一个虚拟机栈(线程私有),其内部保存着一个一个栈帧(Stack Frame),用于存储局部变量、操作结果,参与方法调用和返回。
注:
一个栈帧对应一个方法调用。即 一个栈帧从入栈 到 出栈 的过程,即为 一个方法从调用到完成的过程。
栈帧是一个内存区块,内部维护着方法执行过程中的各种数据信息(局部变量表、操作数栈、动态链接、方法出口、以及附加信息)。

2、虚拟机栈的常见异常?基本运行原理?基本内部结构?

(1)虚拟机栈常见异常?
JVM 规范中允许 虚拟机栈 的大小 是动态的 或者 是固定不变的。
如果采用固定大小的 Java 虚拟机栈,那每一个线程的虚拟机栈大小可以在 线程创建时指定,若线程请求分配的栈容量(深度)超过了虚拟机栈的最大容量(深度),将会导致 JVM 抛出 StackOverflowError 异常。
如果采用动态扩展容量的 虚拟机栈,若在尝试拓展的过程中无法申请到足够的内存(或者创建线程时没有足够的内存去创建对应的虚拟机栈),将会导致 JVM 抛出 OutOfMemoryError 异常。

如下图:
main 方法内存递归调用 main 方法,形成一个死循环(导致栈空间耗尽),最终导致 StackOverflowError。可以通过 -Xss 参数去设置 栈的大小。

(2)基本运行原理
虚拟机栈的操作只有两个:每个方法执行触发入栈操作,方法执行结束触发出栈操作。即栈帧的入栈、出栈操作(遵循 先进后出 FILO、后进先出 原则 LIFO)。
一个线程运行时,在一个时间点上只会存在一个活动的栈帧(方法),即当前栈顶栈帧 是有效的,如果当前方法中调用了其他方法,则会创建新的栈帧并入栈成为 新的栈顶栈帧。当新的栈帧执行结束后,会将执行结果返回给上一个栈帧,丢弃当前栈帧并将上一个栈帧重新作为新的栈顶栈帧。

(3)栈帧的内部结构分类
局部变量表(Local Variables)或者 局部变量数组。
操作数栈(Operand Stack)或者 表达式栈。
动态链接(Dynamic Linking)或者 指向运行时常量池(Constant pool)的方法引用。
方法返回地址(Return Address)。
附加信息。

3、栈帧结构 -- 局部变量表(Local Variables)

(1)什么是局部变量表?
一组变量值存储空间(可以理解为 数组),用于存储方法参数以及定义在方法体内部的局部变量。其包括的数据类型为:基本数据类型(int、long、double 等)对象引用(reference)以及 方法返回地址(returnAddress)。
局部变量表建立在 虚拟机栈 上,属于线程独有数据,即不会出现线程安全问题。
被局部变量表直接或间接引用的对象不会被 GC(垃圾回收)。
局部变量表所需容量大小是在编译期就确定下来的,方法运行期间不会改变其大小(即编译期就可以知道该方法需要几个局部变量 以及 其所占用的 slot 空间)。
注:
32 位以内长度类型只会占用一个 局部变量表空间(slot),比如:short、byte、boolean 等。
64 位类型会占用两个 局部变量表空间,比如:long、double。

(2)举例
如下图:
静态方法没有 this 变量,若为 构造器方法或者 实例方法,会存在一个 this 变量。
此处 main() 方法中存在 4 个变量,其中 b 为 double 型,占用两个 slot 空间,args 为引用类型,占用 1 个空间,也即总空间为 5。
start 表示变量开始生效的 字节码指令 行数。

如下图:
slot 可以被重用,当某个局部变量作用域结束后,其后续定义的新的局部变量可以占用 过期的 slot,可用于节省资源(但可能会影响 垃圾回收)。

4、栈帧结构 -- 操作数栈(Operand Stack)

(1)什么是操作数栈?
每一个栈帧中包含一个 后进先出的 操作数栈,在方法执行过程中,根据字节码指令,往栈中写入数据(入栈, push)或者提取数据(出栈,pop)。操作数栈主要用于保存计算过程中的中间结果、并作为计算过程中 变量 临时的存储空间。
如果被调用的方法(栈帧)存在返回值,则将其返回值压入操作数栈中,并更新程序计数器中下一条需要执行的字节码指令。
注:
JVM 基于栈的架构,其中栈 指的即为 操作数栈。基于栈时 为了完成一项操作,会存在更多的入栈、出栈操作,而操作数存储在内存中,频繁入栈、出栈操作(内存写、读操作)必然会影响执行速度。HotSpot 设计者提出 栈顶缓存技术(TOS,Top-of-Stack Cashing) 解决这个问题,将栈顶元素全部缓存到 物理 CPU 的寄存器中,以降低内存的 读、写次数,提高执行引擎的执行效率。

5、栈帧结构 -- 方法返回地址(return address)

(1)什么是方法返回地址?
方法结束的方式有两种:正常执行完成(Normal Method Invocation Completion)、出现异常退出(Abort Method Invocation Completion)。无论哪种方式退出,均需要回到方法被调用的位置。
方法正常退出时,即当前栈帧出栈,并恢复上一次栈帧(可能涉及操作:恢复上一次栈帧的局部变量表以及操作数栈、存在返回值时会将返回值压入操作数栈、调整 程序计数器 使其指向下一条指令)。
方法异常退出时,通过异常表(保存返回地址)查找是否有匹配的异常处理器,若没有对应的异常处理器,则会导致方法退出(栈帧一般不会保存返回地址,且一般不会产生返回值给 上一个栈帧)。
注:
方法正常退出时,使用哪个返回指令由 方法返回值 的实际数据类型决定。

ireturn       返回值为 boolean、byte、char、short、int 时的返回指令
lreturn       返回值为 long 时的返回指令
freturn       返回值为 float 时的返回指令
dreturn       返回值为 double 时的返回指令
areturn       返回值为 引用类型 时的返回指令
return        返回值为 void、构造器方法等 无返回值时的返回指令

6、栈帧结构 -- 动态链接(Dynamic Linking)

Java 源文件编译成字节码文件时,所有的 变量 以及 方法 都作为符号引用保存在 class 文件的运行时常量池中。每一个栈帧内部都包含一个指向 运行时常量池中 该栈帧对应的 方法的引用(即符号引用),使用符号引用的目的 是为了使 当前方法的代码 支持 动态链接(详见下面的方法调用)。

7、方法调用(方法重载、方法重写)

Java 常用方法操作 有方法重载、方法重写。那编译器如何去识别 真实调用的方法呢?

(1)先熟悉基本概念
静态链接:
类加载字节码文件时,若被调用的目标方法 在编译期可知且运行期不变时,此时将调用方法的符号引用转为直接引用的过程叫 静态链接(发生在 类加载的 连接 的 解析阶段)。
注:
类加载的连接的解析(Resolution)阶段,会将常量池中一部分符号引用 转为 直接引用。而解析的前提就是:方法在程序运行之前(编译时就已确定)能够确定下来,不会发生改变。

动态链接:
若被调用的目标方法在 编译期无法被确定下来,即需要在程序运行时将 符号引用转为 直接引用 的过程 叫做动态链接。

方法绑定:
绑定是一个字段、方法或者 类 的符号引用 被替换到 直接引用的过程,仅发生一次。可以分为早期绑定、晚期绑定。早期绑定是 方法编译期可知且运行期不变时进行绑定,也即通过静态链接的方式绑定。晚期绑定是 方法运行期根据实际类型绑定,即通过动态链接的方式绑定。

非虚方法:
非虚方法指的是 编译期就确定且 运行期不可变的方法。在类加载阶段就会将 符号引用 解析为 直接引用。
常见类型为:静态方法、私有方法、final 方法、实例构造器、父类方法(即不可被重写的方法)。

虚方法:
非虚方法之外的方法(即需要运行期确定的方法)。

(2)方法调用相关虚拟机指令:

【普通指令:】
    invokestatic       调用静态方法
    invokespecial      调用实例构造器 <init> 方法、私有方法、父类方法
    invokevirtual      调用虚方法(final 方法除外)
    invokeinterface    调用接口方法(运行期确定实现此接口的对象)
注:
    这四条指令固化在虚拟机内部,方法调用执行不可被人为干预。
    invokestatic、invokespecial 指令调用的方法为 非虚方法,
    invokevirtual(除 final 方法)、invokeinterface 指令调用的方法为 虚方法。
   final 修饰的方法也由 invokevirtual 指令调用,但其为 非虚方法。

【动态调用指令:】
    invokedynamic      动态解析出需要调用的方法并执行
注:
    支持人为干预。
    Java 为了支持 动态类型语言,在 JDK 7 中增加了 invokedynamic 指令,
    但 JDK 7 中并没有直接提供该指令,需要借助 ASM 等底层字节码工具实现。
    直至 JDK 8 中 Lambda 表达式出现才有直接生成 invokedynamic 指令的方式。

【动态类型语言、静态类型语言:】
    二者区别在于 类型检查 发生的时期。
    动态类型语言 对类型检查 是在运行期,即变量没有类型信息、变量值才有类型信息(比如: JavaScript)。
    静态类型语言 对类型检查 是在编译期,即变量有类型信息(比如:Java)。
比如:
    Java:  String hello = "hello"; hello = 10;  // 编译报错
    JS:     var hello = "hello";  hello = 10;  // 可以运行成功

(3)方法重载
接下来再看看 方法重载 与 方法重写。涉及到多个方法(多态),虚拟机如何去确定真实调用的是哪个方法呢(分派)?

如下代码(方法重载),最终输出结果是什么?

【代码:】
public class JVMDemo {
    static abstract class Human {}

    static class Man extends Human {}

    static class Woman extends Human {}

    public void sayHello(Human human) {
        System.out.println("Human");
    }

    public void sayHello(Man man) {
        System.out.println("Man");
    }

    public void sayHello(Woman woman) {
        System.out.println("Woman");
    }

    public static void main(String[] args) {
        Human man = new Man();
        Human woman = new Woman();
        JVMDemo jvmDemo = new JVMDemo();
        jvmDemo.sayHello(man);
        jvmDemo.sayHello(woman);
    }
}

【输出结果:】
Human
Human

对于上述代码中:
Human man = new Man();
Human 为父类,Man 为子类,将 Human 称为变量 man 的静态类型(Static Type)或者 外观类型(Apparent Type),将 Man 称为变量的实际类型(Actual Type)。

静态类型 在编译期是可知的,而实际类型只有在 运行期才可以确定。

在编译期根据 静态类型 去定位方法执行 的(分派)动作称为 静态分派,而静态分派的典型代表就是 方法重载。静态分派发生在编译阶段,其动作不需要 JVM 去执行。

在运行期根据 实际类型 去定位方法执行 的(分派)动作称为 动态分派,而动态分派的典型代表就是方法重写。动态分派发生在运行阶段,其动作需要 JVM 去执行。

上述代码中,man 与 woman 的静态类型实际都是 Human,方法重载时,编译器根据静态类型去决定重载方法,也即在编译期就能确定到是 sayHello(Human human) 最终执行,故输出结果均为 Human。

(4)方法重写
如下代码(方法重写),最终输出结果是什么?

【代码:】
public class JVMDemo2 {
    static class Human {
        public void sayHello() {
            System.out.println("Human");
        }
    }

    static class Man extends Human {
        public void sayHello() {
            System.out.println("Man");
        }
    }

    static class Woman extends Human {
        public void sayHello() {
            System.out.println("Woman");
        }
    }

    public static void main(String[] args) {
        Human man = new Man();
        Human woman = new Woman();
        man.sayHello();
        woman.sayHello();
    }
}

【输出结果:】
Man
Woman

方法重写的过程:
Step1:找到操作数栈顶的 第一个元素 所指向对象的实际类型,记为 C。

Step2:如果在类型 C 中查找到与常量池中 符号引用 所代表的描述符、简单名称都相符的方法,则进行访问权限校验,如果通过校验则返回该方法的直接引用,结束查找。若校验失败,则抛出异常 java.lang.IllegalAccessError。

Step3:若在类型 C 中未查找到相关方法,则根据继承关系从下到上 以及对 C 的父类执行 Step2 的查找与验证过程。

Step4:如果始终没有合适的方法,则抛出异常 java.lang.AbstractMethodError。

注:
invokevirtual 指令执行第一步就是在运行期 确定 参数的实际类型,所以尽管两次执行的是 Human 的 sayHello() 方法,但最终执行的是 man 与 woman 的 sayHello() 方法。

8、虚方法表

平常开发中,方法重写是非常常见的,也即 动态分派 会频繁操作,如果每次动态分配都去 执行一遍 查找逻辑(在类的方法元数据中查找合适的目标方法),那么将有可能影响执行效率。为了提高性能, JVM 在类的方法区中 建立了一个 虚方法表(virtual method table,vtable)实现,使用虚方法表的索引来替代元数据 以提高性能。类似的,在 invokeinterface 指令执行时会用到接口方法表(interface method table,itable)。

虚方法表会在 类加载的链接阶段被创建并初始化,准备阶段 给类变量 赋初始值后,JVM 会把该类的方法表也进行初始化。

虚方法表中存放着每个方法的实际入口地址,如果某个方法在子类中没有被重写,那么子类的虚方法表里面的地址 与 父类方法的地址一致(均指向父类的 方法入口)。如果子类重写了某方法,则子类的虚方法表的地址将 为指向子类的 方法入口。

如下图(图片来源于网络):
Father、Son 均没有重写 Object 的方法,所以虚方法表中均指向 Object。
而 Son 重写了 Father 的两个方法,所以 Son 的两个方法均指向自己,没有指向其父类 Father。

六、运行时数据区 -- 本地方法栈(Native Method Stack)

1、本地方法接口(Native Method Interface)

(1)什么是本地方法?
本地方法(Native Method)是非 Java 语言编写的方法,比如 C、C++ 语言编写,而 Java 可以通过调用 本地方法接口 去使用 本地方法。

(2)为什么使用本地方法?
可以与 Java 外面的环境进行交互,简化逻辑。比如涉及一些底层操作时,使用 Java 较难实现,但使用 C 或者 C++ 可以很方便的实现,而本地方法采用 C 或者 C++ 很好的实现了功能,我们只需要调用这个本地方法接口 就可以很方便的使用其功能(不需要关心其实现逻辑)。
与操作系统交互。JVM 与底层操作系统还是有区别的,若想实现与 操作系统的交互,还是需要通过本地方法实现的。

2、本地方法栈(Native Method Stack)

本地方法栈与 Java 虚拟机栈类似。但是 Java 虚拟机栈用来管理 Java 方法的调用。而 本地方法栈用来管理 本地方法的调用。
本地方法栈是线程私有的,在异常方面与 Java 虚拟机栈相同。
当线程调用 Java 方法时,JVM 会创建一个栈帧 并压入 虚拟机栈,但是调用 native 方法时,JVM 直接动态连接并指向 native 方法。
本地方法栈可以由 JVM 自由实现,比如:在 HotSpot 中,本地方法栈 与 虚拟机栈 合二为一。

七、运行时数据区 -- 堆(Heap)

1、什么是堆?

Java 堆是 JVM 所管理内存中最大的一块区域,被所有线程共享(存在线程私有的分配缓冲区 Thread Local Allocation Buffer,TLAB)。
在 JVM 启动时创建(空间大小确定,可通过 -Xms、-Xmx调节)。
其目的是用于 存放 实例对象(对象实例、数组等)。
注:
-Xms 用于设置堆的初始内存,等价于 -XX:InitialHeapSize。默认初始内存 = 物理内存 / 64。
-Xmx 用于设置堆的最大内存,等价于 -XX:MaxHeapSize。默认最大内存 = 物理内存 / 4。
如果 堆 中内存大小超过 Xmx 所指定的最大内存时,将会抛出 OutOfMemoryError 异常。
一般将 -Xms 与 -Xmx 两个参数设置成相同的值,防止 GC 垃圾回收完 堆区 对象后重新计算堆区的大小,从而提高性能。

2、堆内存细分

现代垃圾收集器 大部分 基于分代收集理论,可以将堆空间 细分为如下几个区:
(1)JDK7 及 以前对 堆内存 划分:
新生区(年轻代、新生代、Young Generation Space)
养老区(老年代、老年区、Old Generation Space)
永久区(Permanent Space)

(2)JDK8 及 之后对 堆内存 划分:
新生区(年轻代、新生代、Young Generation Space)
养老区(老年代、老年区、Old Generation Space)
元空间(Meta Space)

一般讲堆空间,讲的是 新生代 与 老年代。永久区、元空间 属于方法区的实现。
JVM 规范中指出 方法区 逻辑上属于堆,但并没有规定方法区具体实现方式,由 JVM 自行实现。

使用 -XX:+PrintGCDetails 可以打印 GC 详细信息(可以看到堆相关信息)。

使用 JDK 自带的 jvisualvm 工具,可以分析 JVM 运行时的 JVM 参数、堆栈、CPU 等信息。

3、年轻代、老年代

无论年轻代 还是 老年代 都是用来存储 对象的,其不同的是 存储对象的 生命周期。
(1)什么是 年轻代、老年代?
堆中 对象按照生命周期 可以划分为两类:
生命周期较短的对象,这类对象的创建、消亡很快。
生命周期较长的对象,某些极端情况下 可能与 JVM 生命周期保持一致。
年轻代一般用于存储生命周期较短的对象,老年代一般用于存储生命周期较长的对象。

默认 年轻代 与 老年代 的比例为 1:2,即 年轻代 占堆空间 的 1/3。可以通过 -XX:NewRatio 来设置。比如: -XX:NewRatio=4,此时年轻代 : 老年代 = 1/4,即 年轻代占堆空间 1/5(但一般不会修改)。

(2)年轻代内部结构
年轻代内部又可以分为 Eden Space、Survivor0 Space、Survivor1 Space。其中 Survivor 又可以称为 from、to。from、to 大小相同,用于保存经过垃圾回收 幸存下来的 对象,且总有一个为空。

在 HotSpot 中,默认 Eden : Survivor0 : Survivor1 = 8:1:1(但是经过自适应后,显示出来的是 6:1:1,可以通过 -XX:SurvivorRatio=8 设置)。
几乎所有的对象 均创建在 Eden(80%,大于 Eden 内存的对象可直接进入 老年代),可以通过 -Xmn 设置新生代最大内存。

(3)为什么给堆分代?不分代就不能正常工作吗?
分代的唯一理由是 优化 GC 性能。
堆中存储对象的生命周期不同,且大部分生命周期非常短暂,如果不加管理(不分代)全部放在一起,则每次 GC 都需要全局扫描一次才可以知道哪些是需要被 回收的对象,每次都会扫描到很多不需要被回收的对象(生命周期长的对象),这样会极大影响效率。
而使用分代后(年轻代、老年代),将生命周期短的对象保存在年轻代,GC 多回收此处的对象,这样可以减少扫描数据,从而提高效率。

4、Minor GC、Major GC、Full GC

JVM 进行 GC 时,根据不同的内存空间 会有不同的 GC 算法与之对应。
(1)HotSpot 根据回收区域划分:
部分收集(Partial GC):
Minor GC 针对 年轻代 进行 GC
Major GC 针对 老年代 进行 GC
Mixed GC 针对 整个新生代以及部分老年代 进行 GC
整堆收集(Full GC):
Full GC 针对 整个堆以及方法区 进行 GC

(2)Minor GC 触发时机:
年轻代空间(Eden)不足时,会触发 Minor GC。而 Java 对象生命周期一般较短,所以 Minor GC 非常频繁且回收速度也较快。Minor GC 执行会引发 STW(Stop The World),会暂停其他线程直至 GC 结束(可能造成 程序卡顿)。

(3)Major GC 触发时机:
老年代空间不足时,会触发 Major GC。Major GC 速度一般比 Minor GC 慢 10 倍以上(STW 时间更长),若经过一次 Major GC 后内存仍不足,则会抛出 OOM 异常。

(4)Full GC 触发时机:
调用 System.gc() 时,系统会建议执行 Full GC,但是不一定执行(应尽量避免此操作)。
大对象(占用大量连续内存空间的 java 对象)直接进入老年代,但老年代没有连续的空间存储,此时会触发 Full GC。
通过 Minor GC 进入老年代的平均大小 大于老年代的 可用内存,会触发 Full GC。
方法区空间不足时,会触发 Full GC。

5、内存分配策略(对象提升 Promotion 规则)、对象分配过程

(1)内存分配策略
对象在内存中 存在不同的生命周期,而对于不同生命周期的对象在内存中分配规则如下:
Rule1:

对象优先分配到年轻代。
对象优先分配到年轻代中的 Eden 区。
Rule2:

大对象直接存入老年代。
大对象(占用大量连续空间的对象)直接分配到老年代(应尽量避免出现过多的大对象)。
Rule3:

长期存活对象存入老年代
在年轻代经过多次 GC 后仍存活的对象(对象年龄足够),将其移入老年代。
Rule4:

对象动态年龄判断
如果 Survivor 区中相同年龄的所有对象大小的总和大于 Survivor 空间的一半,则这些对象直接进入老年代(无需考虑阈值)。
Rule5:

空间分配担保
在发生 Minor GC 之前,虚拟机会检查 老年代最大可用的连续空间是否大于新生代所有对象的总空间。如果大于,则此次 Minor GC 是安全的,如果小于,则会继续检查 老年代最大可用的连续空间是否大于 历次晋升到老年代的对象的平均大小。若大于,则尝试进行一次 Minor GC,若小于,则会进行一次 Full GC。

注:
对象存入 Eden,经过 Minor GC 后,存活的对象存入 Survivor 并将其对象年龄设为 1,每经过一次 Minor GC,对象年龄加 1,当增加到一定年龄(默认 15,不同 JVM 不同),该对象将移入 老年代。可以通过 -XX:MaxTenuringThreshold 设置年龄阈值。

(2)对象分配过程
给对象分配内存 是一件非常严谨、复杂的任务,需要考虑内存分配、回收、回收是否产生内存碎片等一系列问题,涉及到 内存分配算法、内存回收算法。

对象分配简单流程如下:
Step1:
对象申请内存,先经过 Eden 区,若 Eden 内存足够,则给对象分配内存。若 Eden 已满 或者 对象超过 Eden 内存,则会触发 Minor GC 进行垃圾回收。
Step2:
进行 Minor GC 回收,会将 Eden 区不再被其他对象引用的对象销毁,并将幸存下来的对象存入 Survivor 区。此时 Eden 又可存入对象。
Step3:
当再次触发 Minor GC 时,会将幸存下来的对象存入另一个 Survivor 中,两个 Survivor 总有一个为空(多次 GC,幸存的对象会在 两个 Survivor 中相互交换)。
Step4:
当 Survivor 中的对象交换次数到达某一个值(对象年龄达到阈值),该对象进入老年代。
Step5:
老年代内存不足时,会触发 Major GC 进行内存清理,若清理后仍无法保存对象,则会抛出 OutOfMemoryError 异常。

第一次 Minor GC:
对象从 Eden 区进入 Survivor 区。

第二次 Minor GC:
对象从 Eden 区、Survivor 区进入另一个 Survivor 区。

第 N 次 Minor GC:
对象从 Survivor 进入 老年代。

详细流程如下:

6、TLAB(Thread Local Allocation Buffer)

(1)并发问题:
堆区是线程共享区域,任何线程均可以访问到堆区中的共享数据。但是由于对象实例的创建在 JVM 中频繁,并发环境下从堆区中划分 内存空间 是线程不安全的。为了避免多个线程操作同一个地址,需要给对象加锁或者 CAS 等机制实现线程安全,进而影响分配速度。

(2)为什么使用 TLAB?
TLAB 指的是Thread Local Allocation Buffer(线程本地分配缓存),属于线程私有的堆空间,TLAB 作为内存分配的首选(即对象分配首先经过此处),但是其空间较小,所以不是所有的对象实例都能在 TLAB 中成功分配内存。
默认情况下 TLAB 是开启的(通过 -XX:UseTLAB 可以设置是否开启 TLAB)。

7、逃逸分析(Escape Analysis)-- 栈上分配、标量替换、同步消除

JVM 中,对象一般都存储在 堆中,但是随着 逃逸技术的 发展,栈上分配、标量替换等优化技术的产生使 对象分配在堆中不那么绝对了(栈上分配会将 对象直接存入 栈中,无需存入堆)。

(1)逃逸分析(Escape Analysis):
逃逸分析 是分析对象动态作用域,当一个对象在方法中定义后,若该对象在方法外被引用(比如:作为参数传递到其他方法中),则称为方法逃逸。若未被引用,则没有发生逃逸。
其并不是直接优化代码的手段,而是为其他优化手段提供依据的分析技术。
JDK 8 中,HotSpot 默认已开启了逃逸分析,可以通过 -XX:DoEscapeAnalysis 手动开启或者关闭逃逸分析。使用逃逸分析后,编译器可以对代码做一些优化:栈上分配、同步消除、标量替换。
注:
由于无法保证逃逸分析的性能消耗 与 其效果成正比(可能存在经过逃逸分析后发现没有一个不逃逸的对象,那么这分析的过程就白白浪费了),所以不同的 JVM 可能对逃逸技术相关优化有不同的实现,比如 HotSpot 就没有进行 栈上分配 这个优化。

【不会发生逃逸:(对象只存在于当前方法内部)】
public static void test(){
    StringBuilder sb = new StringBuilder();
}

【会发生逃逸:(对象可能被其他方法调用)】
public static StringBuilder test(){
    return new StringBuilder();
}

(2)栈上分配(Stack Allocation):
GC 回收堆内存不再使用的对象时,无论筛选对象还是整理对象都是对内存操作,会消耗时间。而使用栈分配后,当一个对象不会逃逸出方法时,对象可能被优化成栈上分配,即对象随着栈帧出栈而销毁(不存于堆),那对 GC 的压力会小很多(HotSpot 默认不支持)。

(3)同步消除(Synchronization Elimination):
线程同步是一个耗时的操作,如果一个对象不会逃逸出线程(无法被其他线程访问),那么对这个对象的同步操作可以省略(消除),从而提高并发性能。可以通过 -XX:EliminateLocks 开启或关闭(HotSpot 默认开启)。

【未进行同步消除:】
public static void test(){
    StringBuilder sb = new StringBuilder("hello");
    synchronized (sb) {
        System.out.println(sb);
    }
}

【进行同步消除后:】
public static void test(){
    StringBuilder sb = new StringBuilder("hello");
    System.out.println(sb);
}

(4)标量替换(Scalar Replacement):
经过逃逸分析,发现一个对象不能被外界访问时,经过优化,可以将这个对象拆解成若干个成员变量来替换。可以通过 -XX:-EliminateAllocations 开启或关闭(HotSpot 默认开启)。
注:
标量(Scalar)指的是不能再被分解的数据。比如:基本数据类型(int、long 等)。
聚合量(Aggregate)指的是还可以被分解的数据。比如:自定义对象(其成员变量可以分解为其他聚合量 或者 标量)。

【未进行标量替换:】
public static void test(){
    People people = new People();
}

class People {
    String name;
    int age;
}

【进行标量替换:】
public static void test(){
    String name;
    int age;
}

8、常用 堆 相关参数设置

【命令参考:】
https://docs.oracle.com/javase/8/docs/technotes/tools/unix/java.html

【常用命令:】
-XX:+PrintFlagsInitial        查看所有参数的默认初始值
-XX:+PrintFlagsFinal          查看所有参数的最终值(某些值可能被修改)
-XX:+PrintGCDetails           查看 GC 详细处理信息
-XX:-UseTLAB                  关闭 TLAB
-Xms20m                       设置初始堆内存空间,等价于 -XX:InitialHeapSize=20m
-Xmx20m                      设置最大堆内存空间,等价于 -XX:MaxHeapSize=20m
-Xmn10m                      设置年轻代大小
-XX:NewRatio=4               配置年轻代与老年代的占比(默认 1:2)
-XX:SurvivorRatio=4          配置年轻代中 Eden 与 Survivor 的占比(默认 8:1:1)。
-XX:MaxTenuringThreshold=10  设置年轻代 GC 处理对象最大年龄
-XX:-DoEscapeAnalysis        关闭逃逸分析
-XX:-EliminateLocks          关闭同步消除     
-XX:-EliminateAllocations    关闭标量替换

八、运行时数据区 -- 方法区(Method Area)

1、什么是方法区?

方法区随 JVM 启动、关闭而创建、销毁,属于 各线程 共享的内存区域,大小可以固定或拓展。
JVM 规范中指出 方法区逻辑上属于 堆的一部分,但是不要求具体的实现方式。
比如:
HotSpot 中方法区可以看成一个独立于 堆 的空间。

2、设置方法区内存大小

方法区大小可以根据应用进行动态调整。

【JDK 7 及以前:】
    -XX:PermSize    设置永久代初始分配内存,默认为 20.75M
    -XX:MaxPerSize  设置永久代最大分配内存,默认 82M(64 位机器)或者 64M(32 位机器)
注:
    当 JVM 加载的类信息 超过 MaxPerSize 时,会抛出 OOM:PermGenSpace

【JDK 8 及之后:】
    -XX:MetaspaceSize      设置元空间初始分配内存,默认为 20.75M
    -XX:MaxMetaspaceSize   设置元空间最大分配内存,默认为本地内存(系统可用内存)
注:
    当 JVM 耗尽系统可用内存后,会抛出 OOM:MetaspaceSize    

当超过初始内存后,会触发 Full GC,为了避免频繁 GC,MetaspaceSize 值应该设置为一个相对较高的值。

3、方法区内部结构

【经典版内部结构:】
    虚拟机加载的 类信息(类型、属性、方法)
    静态变量
    运行时常量池
    即时编译器编译后的代码缓存(JIT 代码缓存)
注:
    JDK 8 后 静态变量以及运行时常量池 存于堆中。

【类信息 -- 类型】
    包含了加载的 类 的类型信息(全类名(包名.类名)、修饰符、继承的父类、实现的接口等信息)。

【类信息 -- 属性(域 Field)】
    包含了加载的 类 的变量信息(变量名称、变量类型、变量修饰符等)。
注:
    静态变量(static 修饰的变量)随类加载而加载,被类的所有实例共享(即使没有类实例也可访问)。
    全局常量(static final 修饰的变量)在编译时就被分配了。

【类信息 -- 方法】
    包含了加载的 类 的方法信息(方法名称、方法返回类型、方法参数与类型、方法修饰符、操作数栈、局部变量表、异常表等)。

【常量池 与 运行时常量池】
    常量池中存放 Java 源码编译期生成的数据, 包含 各种字面量、类型、属性、方法等 的符号引用(可以理解成一个常量表,虚拟机指令通过 符号引用 在常量表中找到需要执行的类、方法、属性等)。
    运行时常量池 为常量池中被 类加载后 存放到方法区中的数据,包含编译期就确定的常量 以及 运行期解析后才能获得的方法、字段引用。

4、HotSpot 方法区演变

JDK 7 及之前,方法区称为 永久代(Permanent Generation,属于虚拟机内存)。
JDK 8 及之后,方法区称为 元空间(MetaSpace,属于本地内存)。

注:
不同虚拟机 实现方法区的方式不同,永久代 仅针对于 HotSpot,JRockit 以及 J9 不存在永久代概念。

HotSpot 方法区演进细节:
JDK 1.6 及之前,使用永久代实现方法区,静态变量 存放于永久代上。
JDK 1.7,使用永久代实现方法区,字符串常量池、静态变量 存放于堆。
JDK 1.8 及之后,使用元空间实现方法区,类信息、属性、方法、常量等存放于本地内存,但字符串常量池、静态变量 仍存放于 堆中。

5、为何使用 元空间 替代 永久代?

为永久代设置空间大小 是不好确定的,如果动态加载类过多,则容易导致 OOM。永久代使用的是虚拟机内存,而元空间使用的是 本地内存(与系统可用内存有关)。
永久代的调优 也是挺困难的。

6、字符串常量池 为什么移到堆中?

字符串常量池 存放于 永久代时,由于 永久代 垃圾回收(Full GC)效率很低,而开发过程中容易创建大量字符串,若一直存放于 永久代(内存空间小),则永久代内存不足导致 OOM,而存于堆中,可以即时回收内存。
注:
不同 JVM 对方法区垃圾回收的实现可能不同(有的甚至都不去实现方法区垃圾回收)。
方法区垃圾回收主要回收:常量池中废弃的常量、不再使用的类。

回收废弃常量:
常量未被引用即可被回收。

回收不使用的类:
首先得判断该类的所有实例是否 已经被回收(即 堆中不存在该类以及其子类的实例对象)。
其次得判断该类的类加载器是否 已经被回收(一般都不会回收)。
最后得判断该类是否 没有在任意地方引用。
满足上述三个条件,则该类允许被回收。
通过 -XX:+TraceClassLoading、-XX:+TraceClassUnloading 可以看到类加载、卸载信息。

九、执行引擎(Execution Engine)

1、基本概念了解一下

(1)机器码:
使用二进制编码方式表示指令,即机器码,比如: 1001、0001 等。CPU 可以直接读取并运行,但是机器码不易记忆,且容易出错。不同硬件的机器码可能不同。

(2)汇编指令:
汇编指令指的是 将机器码中特定的 0、1 组合的序列,简化成 对应的指令,比如:mov,inc 等。统一指令在不同的硬件中可能对应不同的机器码。可读性比机器码 稍好。

(3)汇编指令集:
不同的平台,支持不同的指令,每个平台所支持的指令,即指令集,比如: x86 指令集。

(4)高级语言:
为了使编程更轻松、可读,出现了高级语言。计算机执行高级语言编写的程序时,需要先把程序解释、编译成机器指令(高级语言 -》汇编指令 -》机器码),然后执行。

(5)字节码:
是一种中间状态的二进制文件,比机器码抽象,需直译器转译后才能成为机器码。
源代码 编译成 字节码,字节码再通过 指令平台的 JVM 转译为可执行的 指令。

2、执行引擎

(1)执行引擎概述
执行引擎是 JVM 核心之一。其将 字节码指令 解释/编译 为对应平台的本地机器指令并执行。
执行代码时常分为两种:解释执行(解释器)、编译执行(即时编译器)。
注:
解释器(Interpreter):JVM 启动时根据预定义的规范对 字节码 采用逐行解释的方式执行(逐行翻译 字节码文件 为对应的 本地机器指令执行)。直接解释并执行。
即使编译器(Just in time compiler):JVM 直接将 字节码 编译成 对应的 本地机器指令(一般用于编译 热点代码,再次访问热点代码时 直接返回 机器指令,从而提高代码执行效率)。需要消耗程序运行时间 进行编译操作。

(2)HotSpot 采用 解释器、即时编译器 并存架构
早期 Java 由解释器进行 解释运行,为了提高热点代码的执行效率,引入即时编译器,将热点代码直接编译成 机器指令,提高执行效率。
当程序启动、执行时,解释器首先发挥作用,省去编译时间,立即执行。随着程序运行时间增长,即时编译器开始发挥作用,将代码编译成 本地机器指令后,提高代码的执行效率。

注:
热点代码 指的是 某个方法、代码块 执行频率高,将其标记为 热点代码。
JVM 规范中并未规定 JVM 必须使用即时编译器,但是即时编译器的 性能是 衡量一款 JVM 是否优秀的标准。比如 J9 中只存在 即时编译器(没有解释器)。

(3)HotSpot 设置程序执行方式
默认 HotSpot 采用解释器、即时编译器 并存的架构,但可以根据实际情况执行 JVM 运行时是完全采用解释器执行,还是完全采用即时编译器执行。

【命令:】
    -Xint        完全采用解释器模式运行。
    -Xcomp       完全采用即时编译器模式运行。
    -Xmixed      采用解释器 + 即时编译器混合模式运行。
注:
    可以通过 java -version 查看当前模式。

(4)HotSpot 中 即时编译器分类
HotSpot 中内嵌两个 JIT 编译器,分别为 Client Compiler(C1)、Server Compiler(C2)。
C1 编译器会对字节码进行简单、可靠的优化,耗时较短,编译速度较快。
C2 编译器会对字节码进行耗时较长的优化、激进优化,但优化后的代码执行效率更高。
分层编译(Tiered Compilation)策略:
为了使程序在启动响应速度以及运行效率上达到平衡状态,在 JDK7 的 Server 模式下,采用 分层编译策略 作为默认编译策略。
分层编译根据编译器编译、优化规模与耗时划分出不同的编译层次。
其中:
第 0 层,程序解释执行,仅使用解释器,不开启性能监控,可触发第 1 层编译。
第 1 层,C1 编译,将字节码编译为本地代码,进行简答、可靠优化(可以加上性能监控)。
第 2 层,C2 编译,将字节码编译为本地代码,启动耗时较长的优化(可以根据性能监控信息进行一些不可靠的激进优化)。
注:
分层编译开启后,C1、C2 可能会同时工作,一些代码可能被多次编译。
C1 可以提高编译速度,C2 可以提高执行效率。

C1 优化策略:
方法内联:将引用的函数代码编译到引用点处,减少栈帧生成、参数传递以及跳转过程。
去虚拟化:对唯一的实现类进行内联。
冗余消除:将运行期间一些不会执行的代码消除掉。

C2 优化策略(基于逃逸分析):
标量替换:用标量值替换聚合对象的属性值。
栈上分配:对于未逃逸的对象 将其分配在栈 而非堆。
同步消除:消除未逃逸的对象的同步操作。

3、解释器、即时编译器、热点探测

(1)解释器
由于不同平台底层的 机器码 不同,而为了实现 Java 程序跨平台的特性,不能直接将源码编译成 本地机器指令(可以直接编译成机器指令,不同平台对应不同的 机器指令,可移植性差),所以出现字节码文件,不同 JVM 逐行解释字节码文件 来执行程序。
解释器 逐行读取 字节码文件并解释成 相应的机器指令,当一条字节码解释完成后,从 PC 计数器中 获取下一条 被执行的字节码 指令进行解释执行操作。

解释器分类:
字节码解释器:纯软件代码模拟字节码执行,效率低下。
模板解释器:将每一条字节码和一个模板函数关联,模板函数直接产生字节码执行的机器码,从而提高解释器性能。

(2)编译期相关概念
前端编译器:
将 .java 文件转为 .class 文件的过程。比如:javac。

即使编译器(JIT 编译器、Just In Time Compiler):
将 .class 文件转为 机器码 的过程。比如:HotSpot 的 C1、C2。

静态提前编译器(AOT 编译器、Ahead Of Time Compiler):
直接将 .java 文件转为 机器码 的过程。

(3)即时编译器
即时编译器 将热点代码 直接编译成 机器指令,从而提高执行性能。通过热点探测 判断 某个方法、代码块 是否为热点代码。

热点代码常指:
多次被调用的方法。将整个方法 作为编译对象(标准 JIT 编译方式)。
方法中多次被调用的循环体。将整个方法 作为编译对象(栈上替换,发生在方法执行过程中,即方法栈帧还存在于 栈中,但是方法被替换了)。

热点探测分类:
基于采样的热点检测(Sample Based Hot Spot Detection):使用该方法的虚拟机会周期性的检查各个线程的栈顶,若某个方法经常出现在栈顶达到阈值,则为热点方法。

基于计数器的热点探测(Counter Based Hot Spot Detection):使用该方法的虚拟机会为每个方法建立计数器,统计方法执行次数,若执行次数达到某个阈值,则为热点方法。

(4)HotSpot 采用基于热点计数器的热点探测
HotSpot 采用基于热点计数器的热点探测,其内部维护了两个计数器。
计数器:
方法调用计数器(Invocation Counter),用于统计方法的执行次数。
回边计数器(Back Edge Counter),用于统计循环体执行的次数。

方法调用计数器:
用于统计方法的执行次数。默认阈值在 client 模式下为 1500 次,在 Server 模式下为 10000 次,超过阈值触发 JIT 编译。这个阈值可以通过 -XX:CompileThreshold 来设置。一般 HotSpot 会根据自身版本以及机器硬件性能自动选择运行模式,可以使用 -client 或者 -server 手动指定模式。
当一个方法被调用时,首先会检查该方法是否被 JIT 编译过,如果存在则使用编译后的代码执行,如果不存在,则将该方法调用计数器值加 1,然后判断方法调用计数器 以及 回边计数器之和 是否超过方法调用的阈值。若超过阈值则触发 JIT 编译(只是提交请求,执行还是解释执行,当编译完成后,下一次调用才会是已编译的版本)。
注:
方法调用计数器记录的并非方法被调用的绝对次数,而是一个相对的执行频率(某段时间内方法被调用的次数)。当超过一定时间且方法调用次数 并未达到阈值,则次数减少一半,此过程称为 方法计数器热度的衰减,此时间称为 方法统计的半衰周期。热度衰减是执行垃圾回收顺便执行的,可以使用 -XX:-UseCounterDecay 关闭热度衰减(此时方法调用计数器记录的为方法调用的绝对次数,只要系统运行时间够长,大部分方法均会执行 JIT 编译)。

回边计数器:
用于统计循环体执行次数。回边指的是 字节码中 控制流向后跳转的指令。建立回边计数器目的是触发 OSR(On Stack Replacement,栈上替换),执行 JIT 编译。
当一个方法被调用时,首先会检查该方法是否被 JIT 编译过,如果存在则使用编译后的代码执行,如果不存在,则将回边计数器值加 1,然后判断方法调用计数器 以及 回边计数器之和 是否超过回边调用的阈值。若超过阈值则触发 JIT 编译(提交 OSR 编译请求,并降低回边计数器的值,并解释执行,等待编译完成)。
注:
回边计数器没有热度衰减的过程,其统计的是循环体执行的绝对次数。

未完待续。。。


原文链接:https://www.cnblogs.com/l-y-h/p/13496969.html