嘘~ 正在从服务器偷取页面 . . .

JVM 八股文


1. 内存区域

JVM 整体结构

Java 代码执行流程

1.1 类加载子系统

1.1.1 类加载过程

  • 类加载器子系统负责从文件系统或者网络中加载 class 文件,class 文件在文件开头有特定的文件标识;

  • ClassLoader 只负责 class 文件的加载,运行则由 Execution Engine 决定;

  • 加载的类信息存放于一块称为方法区的内存空间。除了类的信息外,方法区中还会存放运行时常量池信息,可能还包括字符串字面量和数字常量(这部分常量信息是 class 文件中常量池部分的内存映射)。

类加载子系统结构

/**
 *示例代码
 */
public class HelloLoader {
    public static void main(String[] args) {
        System.out.println("Hello World!");
    }
}

示例代码执行过程

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

加载 class 文件的方式:

  • 本地系统中直接加载;

  • 通过网络获取,典型场景:Web Applet;

  • 从 zip 压缩包中读取,成为日后 jar、war 格式的基础;

  • 运行时计算生成,使用最多的是:动态代理技术(利用反射实现动态类加载);

  • 由其他文件生成,典型场景:JSP 应用;

  • 从专有数据库中提取 .class 文件,比较少见;

  • 从加密文件中获取,典型的防 Class 文件被反编译的保护措施。

链接
  • 验证(Verify)

    • 确保 Class 文件的字节流中包含信息符合当前虚拟机要求,保证被加载类的正确性,不会危害虚拟机自身安全;
    • 主要包括四种验证,文件格式验证,元数据验证,字节码验证,符号引用验证。验证方法的具体内容

    一个类方法的字节码没有通过字节码验证,那肯定是有问题;如果一个方法体通过字节码验证,也不能表示一定就是安全的。程序无法校验程序员的代码逻辑问题。

  • 准备(Prepare)

    • 为类变量分配内存并且设置该类变量的默认初始值,即零值;
    • 这里不包含用 final 修饰的 static,因为 final 在编译的时候就会分配了,准备阶段会显式初始化
    • 这里不会为实例变量分配初始化,类变量会分配在方法区中,而实例变量是会随着对象一起分配到 Java 堆中。
      深入理解 Java 虚拟机第三版 规定零值
  • 解析(Resolve)

    • 将常量池内的符号引用( 以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可)转换为直接引用(可以直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄。区别于符号引用就是直接引用必须引用的目标已经在内存中存在)的过程;
    • 解析操作往往会伴随着 JVM 在执行完初始化之后再执行;
    • 解析动作主要针对类或接口、字段、类方法、接口方法、方法类型等。对应常量池中的 CONSTANT_Class_info,CONSTANT_Fieldref_info、CONSTANT_Methodref_info 等。
初始化
  • 初始化阶段就是执行类构造器方法<clinit>()的过程。

  • 此方法不需定义,是javac编译器自动收集类中的所有类变量的赋值动作和静态代码块中的语句合并而来。

  • 构造器方法中指令按语句在源文件中出现的顺序执行。

  • <clinit>()不同于类的构造器。(关联:构造器是虚拟机视角下的<init>()

  • 若该类具有父类,JVM会保证子类的<clinit>()执行前,父类的<clinit>()已经执行完毕。

  • 虚拟机必须保证一个类的<clinit>()方法在多线程下被同步加锁。

类加载子系统三步骤

1.1.2 类加载器分类

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

从概念上来讲,自定义类加载器一般指的是程序中由开发人员自定义的类加载器,但是 Java 虚拟机规范将所有派生于抽象类 ClassLoader 的类加载器都划分为自定义类加载器

类加载器继承顺序

类加载器加载图示

虚拟机自带类加载器
启动类加载器(引导类加载器,Bootstrap ClassLoader)
  • 这个类加载使用 C/C++ 语言实现的,嵌套在 JVM 内部;

  • 用来加载 Java 的核心库(JAVA_HOME/jre/lib/rt.jar、resources.jar 或 sun.boot.class.path 路径下的内容),用于提供 JVM 自身需要的类;

  • 并不继承自 java.lang.ClassLoader,没有父加载器;

  • 加载扩展类和应用程序类加载器,并指定为他们的父类加载器;

  • 出于安全考虑,Bootstrap 启动类加载器只加载包名为 java、javax、sun 等开头的类。

扩展类加载器(Extension ClassLoader)
  • Java 语言编写,由 sun.misc.Launcher$ExtClassLoader 实现;

  • 派生于 ClassLoader 类;

  • 父类加载器为启动类加载器;

  • 从 java.ext.dirs 系统属性所指定的目录中加载类库,或从 JDK 的安装目录的 jre/1ib/ext 子目录(扩展目录)下加载类库。如果用户创建的 JAR 放在此目录下,也会自动由扩展类加载器加载。主要负责加载 Java 的扩展类库。

应用程序类加载器(系统类加载器,AppClassLoader)
  • Java 语言编写,由 sun.misc.Launcher$AppClassLoader 实现;

  • 派生于 ClassLoader 类;

  • 父类加载器为扩展类加载器;

  • 它负责加载环境变量 classpath 或系统属性 java.class.path 指定路径下的类库,该类加载是程序中默认的类加载器,一般来说,Java 应用的类都是由它来完成加载。通过 ClassLoader.getSystemclassLoader() 方法可以获取到该类加载器。

用户自定义类加载器

在 Java 的日常应用程序开发中,类的加载几乎是由上述3种类加载器相互配合执行的,在必要时,我们还可以自定义类加载器,来定制类的加载方式。 为什么要自定义类加载器?

  • 隔离加载类;

  • 修改类加载的方式;

  • 扩展加载源;

  • 防止源码泄漏。

1.1.3 双亲委派机制

Java 虚拟机对 class 文件采用的是按需加载的方式,也就是说当需要使用该类时才会将它的 class 文件加载到内存生成 class 对象。而且加载某个类的 class 文件时,Java 虚拟机采用的是双亲委派模式,即把请求交由父类处理,它是一种任务委派模式。

  1. 如果一个类加载器收到了类加载请求,它并不会自己先去加载,而是把这个请求委托给父类的加载器去执行;
  2. 如果父类加载器还存在其父类加载器,则进一步向上委托,依次递归,请求最终将到达顶层的启动类加载器;
  3. 如果父类加载器可以完成类加载任务,就成功返回,倘若父类加载器无法完成此加载任务,子加载器才会尝试自己去加载,这就是双亲委派模式。

双亲委派机制的实现图示

优势:

  • 避免类的重复加载;
  • 保护程序安全,防止核心API被随意篡改:
    • 自定义类:java.lang.String;
    • 自定义类:java.lang.ShkStart(报错:阻止创建 java.lang 开头的类)。
沙箱安全机制

自定义 String 类,但是在加载自定义 String 类的时候会率先使用引导类加载器加载,而引导类加载器在加载的过程中会先加载 jdk 自带的文件(rt.jar 包中 java\lang\String.class),报错信息说没有 main 方法,就是因为加载的是 rt.jar 包中的 String 类。这样可以保证对 Java 核心源代码的保护。

1.1.4 判断两个类相等

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

  • 类的完整类名必须一致,包括包名;

  • 加载这个类的 ClassLoader(指 ClassLoader 实例对象)必须相同。

在 JVM 中,即使这两个类对象(class 对象)来源同一个 Class 文件,被同一个虚拟机所加载,但只要加载它们的 ClassLoader 实例对象不同,那么这两个类对象也是不相等的。

1.1.5 类的主动/被动使用

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

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

  • 创建类的实例;

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

  • 调用类的静态方法;

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

  • 初始化一个类的子类;

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

  • JDK 7 开始提供的动态语言支持:java.lang.invoke.MethodHandle实例的解析结果 REF_getStatic、REF_putStatic、REF_invokeStatic 句柄对应的类没有初始化,则初始化 。

除了以上七种情况,其他使用 Java 类的方式都被看作是对类的被动使用,都不会导致类的初始化(依然会有加载和连接的过程)。

1.2 运行时数据区

1.2.1 程序计数器

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

如果学习过汇编,JVM 的程序计数器就是对 CPU 寄存器的一种模拟(比如 IP、SP)。

每个线程都有它自己的程序计数器,是线程私有的,生命周期与线程的生命周期保持一致。程序计数器可以忽略不计,运行速度极快。为了能够准确地记录各个线程正在执行的当前字节码指令地址,每一个线程都被分配了一个PC寄存器。

简单程序字节码演示

每一次 JVM 的操作都会有计数器,记录每一步的执行。

CPU 时间片

CPU 时间片即 CPU 分配给各个程序的时间,每个线程被分配一个时间段,称作它的时间片。

  • 宏观上:可以同时打开多个应用程序,每个程序并行不悖,同时运行;

  • 微观上:由于单个CPU的每个核心一次只能处理程序要求的一部分,为处理公平,引入时间片,每个程序轮流执行。

1.2.2 虚拟机栈

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

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

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

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

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

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

每一次操作都会压入一个栈帧,在接收到return指令或者抛出异常时就会弹出一个栈帧

每个栈帧中存储着:

  • 局部变量表(Local Variables);

  • 操作数栈(Operand Stack)(或表达式栈);

  • 动态链接(Dynamic Linking)(或指向运行时常量池的方法引用);

  • 方法返回地址(Return Address)(或方法正常退出或者异常退出的定义);

  • 一些附加信息。

栈帧的内部结构

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

  • 由于局部变量表是建立在线程的栈上,是线程的私有数据,因此不存在数据安全问题

  • 局部变量表所需的容量大小是在编译期确定下来的,并保存在方法的 Code 属性的 maximum local variables 数据项中。在方法运行期间是不会改变局部变量表的大小的;

  • 方法嵌套调用的次数由栈的大小决定。一般来说,栈越大,方法嵌套调用次数越多。对一个函数而言,它的参数和局部变量越多,使得局部变量表膨胀,它的栈帧就越大,以满足方法调用所需传递的信息增大的需求。进而函数调用就会占用更多的栈空间,导致其嵌套调用次数就会减少;

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

局部变量表,最基本的存储单元是 Slot(变量槽)。Slot 如同数组或者汇编中的寄存器存储,参数值的存放总是在局部变量数组的index 0 开始,到数组长度-1的索引结束。

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

在局部变量表里,32位以内的类型只占用一个 slot(包括 returnAddress 类型),64位的类型(long和double)占用两个 slot。byte、short、char 在存储前被转换为 int,boolean 也被转换为 int,0表示 false,非0表示 true。如果需要访问局部变量表中一个64bit的局部变量值时,只需要使用前一个索引即可

slot 分配内存

栈帧中的局部变量表中的槽位是可以重用的,如果一个局部变量过了其作用域,那么在其作用域之后申明的新的局部变就很有可能会复用过期局部变量的槽位,从而达到节省资源的目的。

和类变量初始化不同,局部变量表不存在系统初始化的过程,这意味着一旦定义了局部变量则必须人为的初始化,否则无法使用。

// 直接调用 test 没有初始化,所以会报错
public void test(){
    int i;
    System.out.println(i);
}
操作数栈

对于赋值、运算等操作,都会在一个栈中实现操作。主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间。方法的返回值也会压入栈帧的操作数栈中。

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

public class ClassTest {
    public static void main(String[] args) {
        int a = 1;
        int b = 2;
        int c = a + b;
        System.out.println(c);
    }
}

class 文件中出现 push pop 操作

操作栈存储在内存中,频繁的读/写会影响执行速度,HotSpot JVM 的开发者们提出栈顶缓存。将栈顶元素,全部缓存在物理 CPU 的寄存器中,以此降低对内存的读/写次数。

动态链接

动态链接的作用是为了将符号引用转换为调用方法的直接引用。

  • 静态链接:字节码文件装载进 JVM 文件中,目标方法在编译期内可知,在运行期保持不变(早期绑定);
  • 动态链接:被调用的方法在编译期无法被确定下来,只能够在程序运行期将调用的方法的符号转换为直接引用(晚期绑定)。

对于编译时就能确定的方法,可以确定的方法被称为非虚方法。静态方法、私有方法、final方法、实例构造器、父类方法都是非虚方法,其他方法称为虚方法。

class Father {
    public static void print(String str) {
        System.out.println("father " + str);
    }

    private void show(String str) {
        System.out.println("father" + str);
    }
}

class Son extends Father {
    public static class VirtualMethodTest {
        public static void main(String[] args) {
            Son.print("coder");
        }
    }
}

虚拟机提供了几条方法调用指令:

  • 普通调用指令:

    • invokestatic:调用静态方法,解析阶段确定唯一方法版本;

    • invokespecial:调用方法、私有及父类方法,解析阶段确定唯一方法版本;

    • invokevirtual:调用所有虚方法;

    • invokeinterface:调用接口方法。

  • 动态调用指令:

    • invokedynamic:动态解析出需要调用的方法,然后执行。动态调用指令是为了实现动态类型语言在 JDK 7 引入,在 JDK 8 Lambda 表达式出现后,在 Java 中才有直接的生成方式。

为了提高执行虚方法性能,JVM 在类的方法区创建一个虚方法表,使用索引表来代替查找。

class Dog {
    public void sayHello() {
    }

    public String tostring() {
        return "Dog";
    }
}

class Cat implements Friendly {
    public void eat() {
    }

    public void sayHello() {
    }

    public void sayGoodbye() {
    }

    protected void finalize() {
    }
}

class Chihuahua extends Dog implements Friendly {
    public void sayHello() {
        super.sayHello();
    }

    public void sayGoodbye() {
    }
}

虚方法执行

方法返回地址

无论通过哪种方式退出,在方法退出后都返回到该方法被调用的位置。方法正常退出时,调用者的程序计数器的值作为返回地址,即调用该方法的指令的下一条指令的地址。而通过异常退出的,返回地址是要通过异常表来确定,栈帧中一般不会保存这部分信息。

当一个方法开始执行后,只有两种方式可以退出这个方法:

  1. 执行引擎遇到任意一个方法返回的字节码指令(return),会有返回值传递给上层的方法调用者,简称正常完成出口;
    • 一个方法在正常调用完成之后,究竟需要使用哪一个返回指令,还需要根据方法返回值的实际数据类型而定。
    • 在字节码指令中,返回指令包含ireturn(当返回值是boolean,byte,char,short和int类型时使用),lreturn(Long类型),freturn(Float类型),dreturn(Double类型),areturn。另外还有一个return指令声明为void的方法,实例初始化方法,类和接口的初始化方法使用。
  1. 在方法执行过程中遇到异常(Exception),并且这个异常没有在方法内进行处理,也就是只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,简称异常完成出口。

正常完成出口和异常完成出口的区别在于:通过异常完成出口退出的不会给他的上层调用者产生任何的返回值。

1.2.3 本地方法栈

本地方法

一个 Native Method 是一个 Java 调用非 Java 代码的接囗。本地接口的作用是融合不同的编程语言为 Java 所用,它的初衷是融合 C/C++ 程序。

如果对于效率有要求,单纯使用 Java 就不能满足需求了。

  • 与 Java 环境的交互:有时Java应用需要与Java外面的环境交互,这是本地方法存在的主要原因;
  • 与操作系统的交互:通过使用本地方法,我们得以用 Java 实现了 jre 的与底层系统的交互,甚至 JVM 的一些部分是用 C 写的;
  • Sun’s Java:Sun 的解释器是用 C 实现的,这使得它能像一些普通的 C 一样与外部交互。

目前本地方法使用越来越少,除非涉及到硬件层面。

本地方法栈

Java 虚拟机栈于管理 Java 方法的调用,而本地方法栈用于管理本地方法的调用,也是线程私有。当某个线程调用一个本地方法时,它就进入了一个全新的并且不再受虚拟机限制的世界。它和虚拟机拥有同样的权限。

并不是所有的 JVM 都支持本地方法。因为 Java 虚拟机规范并没有明确要求本地方法栈的使用语言、具体实现方式、数据结构等。

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

本地方法栈

直接内存

直接内存不是虚拟机运行时数据区的一部分,也不是《Java 虚拟机规范》中定义的内存区域。直接内存是在 Java 堆外的、直接向系统申请的内存区间。来源于 NIO,通过存在堆中的 DirectByteBuffer 操作 Native 内存。通常,访问直接内存的速度会优于 Java 堆,即读写性能高。

1.2.4 堆

堆对一个 JVM 进程来说是唯一的,进程中的多个线程共享同一堆空间。

《Java 虚拟机规范》规定,堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为连续的。

堆内存

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。

堆内存结构变化

年轻代、老年代

对于70%-99%的临时对象,分代思想能优化 GC 效率,不需要对堆进行全局扫描。

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

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

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

分类划分

默认情况下,新生代和老年代内存占比为1:2,新生代中 Eden 区和两个 Survivor 区(生存者区)的占比为8:1:1。

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

对象分配过程
  1. 新创建的对象放在 Eden 区,有大小限制;
  2. 当 Eden 的空间填满时,JVM 的垃圾回收器将进行垃圾回收(Minor GC),将不再被其他对象所引用的对象进行销毁,新的对象进去进入 Eden 区。Eden 区中的剩余对象移动到幸存者0区;
  3. 如果再次触发垃圾回收,上次幸存下来的放到幸存者0区的对象,如果没有被回收,会被放到幸存者1区;
  4. 在幸存者区的对象在多次 GC 也没有被消除之后,就会进入老年代(默认为15次,可以设置参数:进行设置-Xx:MaxTenuringThreshold= N);
  5. 当老年代内存不足时,触发 Major GC,进行老年代的内存清理。若老年代执行了 Major GC之后,发现依然无法进行对象的保存,就会产生 OOM 异常(表示 Heap Space 不够)。

从新生代逐渐到老年代

垃圾收集策略

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

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

  • 部分收集:不是完整收集整个 Java 堆的垃圾收集。其中又分为:

    • 新生代收集(Minor GC / Young GC):只是新生代的垃圾收集;

    • 老年代收集(Major GC / Old GC):只是老年代的垃圾收集;

      • 目前,只有 CMS GC 会有单独收集老年代的行为;
      • 注意,很多时候 Major GC 会和 Full GC 混淆使用,需要具体分辨是老年代回收还是整堆回收。
    • 混合收集(MixedGC):收集整个新生代以及部分老年代的垃圾收集。

      • 目前,只有G1 GC会有这种行为。
  • 整堆收集(Full GC):收集整个java堆和方法区的垃圾收集。

Minor GC

在 Eden 区满时,会触发 Minor GC(Survivor 区不会),因为触发及其频繁,所以回收速度一般比较快。

Minor GC 会引发 STW(Stop The World),暂停其它用户的线程,等垃圾回收结束,用户线程才恢复运行。

Major GC/Full GC

出现了 Major GC,经常会伴随至少一次的 Minor GC(但非绝对的,在 Paralle1 Scavenge 收集器的收集策略里就有直接进行 MajorGC 的策略选择过程) 。在老年代空间不足时,会先尝试触发 Minor GC。如果之后空间还不足,则触发 Major GC。

Major GC 的执行时间更长,STW 时间更长。

TLAB

TLAB,全名 Thread Local Allocation Buffer。

JVM 为每个线程分配了一个私有缓存区域,它包含在 Eden 空间内,避免多个线程操作同一地址,需要使用加锁等机制,进而影响分配速度。

使用 TLAB 可以避免一系列的非线程安全问题,同时还能够提升内存分配的吞吐量,将这种内存分配方式称之为快速分配策略

TLAB 结构

逃逸分析

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

逃逸分析的基本行为就是分析对象动态作用域:

  • 当一个对象在方法中被定义后,对象只在方法内部使用,则认为没有发生逃逸;

  • 当一个对象在方法中被定义后,它被外部方法所引用,则认为发生逃逸。例如作为调用参数传递到其他地方中。

// 逃逸对象
public static StringBuffer createStringBuffer(String s1, String s2) {
    StringBuffer sb = new StringBuffer();
    sb.append(s1);
    sb.append(s2);
    return sb;
}

// 未逃逸对象
public static String createStringBuffer(String s1, String s2) {
    StringBuffer sb = new StringBuffer();
    sb.append(s1);
    sb.append(s2);
    return sb.toString();
}

使用逃逸分析,编译器可以对代码做如下优化:

  1. 栈上分配:将堆分配转化为栈分配。如果一个对象在子程序中被分配,要使指向该对象的指针永远不会发生逃逸,对象可能是栈上分配的候选,而不是堆上分配;
  2. 同步省略:如果一个对象被发现只有一个线程被访问到,那么对于这个对象的操作可以不考虑同步。极大提高并发性和性能;
  3. 分离对象或标量替换:Java 的原始数据类型就是标量。有的对象可能不需要作为一个连续的内存结构存在也可以被访问到,那么对象的部分(或全部)可以不存储在内存,而是存储在 CPU 寄存器中。

所以对于变量或者其他方法等,尽可能在局部内进行定义,全局变量就会被逃逸分析判定为逃逸。

逃逸分析不成熟

其根本原因就是无法保证逃逸分析的性能消耗一定能高于消耗。经过逃逸分析可以做标量替换、栈上分配、和锁消除。但逃逸分析自身需要进行一系列复杂的分析,过程相对耗时。

一个极端的例子,就是经过逃逸分析之后,发现没有一个对象是不逃逸的。那这个逃逸分析的过程就白白浪费掉了。

虽然这项技术并不十分成熟,但是它也是即时编译器优化技术中一个十分重要的手段。

1.2.5 方法区

栈、堆、方法区交互关系

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

在 JDK 8 中取消永久代,使用元空间

方法区内部结构

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

方法区存储信息

类型信息
  • 这个类型的完整有效名称(全名=包名.类名);
  • 这个类型直接父类的完整有效名(对于 interface 或是 java.lang.object,都没有父类);
  • 这个类型的修饰符(public,abstract,final 的某个子集);
  • 这个类型直接接口的一个有序列表。
域/变量(Field)信息

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

每个全局常量(static final)在编译时就会被分配。

方法(Method)信息

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

  • 方法名称;
  • 方法的返回类型(或 void);
  • 方法参数的数量和类型(按顺序);
  • 方法的修饰符(public,private,protected,static,final,synchronized,native,abstract的一个子集);
  • 方法的字节码(bytecodes)、操作数栈、局部变量表及大小(abstract和native方法除外);
  • 异常表(abstract和native方法除外) 。每个异常处理的开始位置、结束位置、代码处理在程序计数器中的偏移地址、被捕获的异常类的常量池索引
non-final 的类变量
  • 静态变量和类关联在一起,随着类的加载而加载,他们成为类数据在逻辑上的一部分;

  • 类变量被类的所有实例共享,即使没有类实例时,你也可以访问它。

public class MethodAreaTest {
    public static void main(String[] args) {
        Order order = new Order();
        order.hello();
        System.out.println(order.count);
    }
}

class Order {
    public static int count = 1;
    public static void hello() {
        System.out.println("hello!");
    }
}

方法区内结构

常量池

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

  • 运行时常量池(Runtime Constant Pool)是方法区的一部分;
  • 常量池表(Constant Pool Table)是 Class 文件的一部分,用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中;
  • 运行时常量池,在加载类和接口到虚拟机后,就会创建对应的运行时常量池;
  • JVM 为每个已加载的类型(类或接口)都维护一个常量池。池中的数据项像数组项一样,是通过索引访问的;
  • 运行时常量池中包含多种不同的常量,包括编译期就已经明确的数值字面量,也包括到运行期解析后才能够获得的方法或者字段引用。此时不再是常量池中的符号地址了,这里换为真实地址;
  • 运行时常量池,相对于 Class 文件常量池的另一重要特征是:具备动态性;
  • 运行时常量池类似于传统编程语言中的符号表(symboltable),但是它所包含的数据却比符号表要更加丰富一些;
  • 当创建类或接口的运行时常量池时,如果构造运行时常量池所需的内存空间超过了方法区所能提供的最大值,则 JVM 会抛 OutOfMemoryError 异常。
StringTable
  • 常量与常量的拼接结果在常量池,原理是编译期优化;

  • 常量池中不会存在相同内容的变量;

  • 只要其中有一个是变量,结果就在堆中。变量拼接的原理是 StringBuilder;

  • 如果拼接的结果调用 intern() 方法,则主动将常量池中还没有的字符串对象放入池中,并返回此对象地址。

public static void test1() {
    // 都是常量,前端编译期会进行代码优化
    // 通过idea直接看对应的反编译的class文件,会显示 String s1 = "abc"; 说明做了代码优化
    String s1 = "a" + "b" + "c";  
    String s2 = "abc"; 

    // true,由上述可知,s1和s2实际上指向字符串常量池中的同一个值
    System.out.println(s1 == s2); 
}

编译优化演示

字符串拼接

对于字符串中的+,本质是个语法糖。如果是多个变量相加,相当于调用 StringBuilder 进行拼接。

public static void test5() {
    String s1 = "javaEE";
    String s2 = "hadoop";

    String s3 = "javaEEhadoop";
    String s4 = "javaEE" + "hadoop";    
    String s5 = s1 + "hadoop";
    String s6 = "javaEE" + s2;
    String s7 = s1 + s2;

    System.out.println(s3 == s4); // true 编译期优化
    System.out.println(s3 == s5); // false s1是变量,不能编译期优化
    System.out.println(s3 == s6); // false s2是变量,不能编译期优化
    System.out.println(s3 == s7); // false s1、s2都是变量
    System.out.println(s5 == s6); // false s5、s6 不同的对象实例
    System.out.println(s5 == s7); // false s5、s7 不同的对象实例
    System.out.println(s6 == s7); // false s6、s7 不同的对象实例

    String s8 = s6.intern();
    System.out.println(s3 == s8); // true intern之后,s8和s3一样,指向字符串常量池中的"javaEEhadoop"
}
  • 不使用 final 修饰,即为变量。如 s3 行的 s1 和 s2,会通过 new StringBuilder 进行拼接;

  • 使用 final 修饰,即为常量。会在编译器进行代码优化。在实际开发中,能够使用 final 的,尽量使用。

public void test6(){
    String s0 = "beijing";
    String s1 = "bei";
    String s2 = "jing";
    String s3 = s1 + s2;
    System.out.println(s0 == s3); // false s3指向对象实例,s0指向字符串常量池中的"beijing"
    String s7 = "shanxi";
    final String s4 = "shan";
    final String s5 = "xi";
    String s6 = s4 + s5;
    System.out.println(s6 == s7); // true s4和s5是final修饰的,编译期就能确定s6的值了
}
intern()

当调用 intern 方法时,如果池子里已经包含了一个与这个 String 对象相等的字符串,正如 equals(Object) 方法所确定的,那么池子里的字符串会被返回。否则,这个 String 对象被添加到池中,并返回这个 String 对象的引用。

("a"+"b"+"c").intern() == "abc"
// true

String intern()

JDK1.6 中,将这个字符串对象尝试放入串池:

  • 如果串池中有,则并不会放入。返回已有的串池中的对象的地址;
  • 如果没有,会把此对象复制一份,放入串池,并返回串池中的对象地址。

JDK1.7 起,将这个字符串对象尝试放入串池:

  • 如果串池中有,则并不会放入。返回已有的串池中的对象的地址;
  • 如果没有,则会把对象的引用地址复制一份,放入串池,并返回串池中的引用地址。
class TestB {
    public static void main(String[] args) {
        /*
         * ① String s = new String("1")
         * 创建了两个对象
         * 		堆空间中一个new对象
         * 		字符串常量池中一个字符串常量"1"(注意:此时字符串常量池中已有"1")
         * ② s.intern()由于字符串常量池中已存在"1"
         *
         * s  指向的是堆空间中的对象地址
         * s2 指向的是堆空间中常量池中"1"的地址
         * 所以不相等
         */
        String s = new String("1");
        s.intern();
        String s2 = "1";
        System.out.println(s == s2); // jdk1.6 false jdk7/8 false

        /*
         * ① String s3 = new String("1") + new String("1")
         * 等价于 new String("11"),但是,常量池中并不生成字符串"11";
         *
         * ② s3.intern()
         * 由于此时常量池中并无"11",所以把 s3 中记录的对象的地址存入常量池
         * 所以 s3 和 s4 指向的都是一个地址
         */
        String s3 = new String("1") + new String("1");
        s3.intern();
        String s4 = "11";
        System.out.println(s3 == s4); //jdk1.6 false jdk7/8 true
    }
}

new String(“1”) 常量池已经有1

去除永久代

原来的数据被移到了一个与堆不相连的本地内存区域,称为元空间(Metaspace)。

永久代设置空间大小是很难确定的。在某些场景下,如果动态加载类过多,容易产生 Perm 区的 oom(占用内存过大)。比如某个实际 Web 工程中,因为功能点比较多,在运行过程中,要不断动态加载很多类,经常出现致命错误。

《Java 虚拟机规范》对方法区的约束是非常宽松的,提到过可以不要求虚拟机在方法区中实现垃圾收集。事实上也确实有未实现或未能完整实现方法区类型卸载的收集器存在。

垃圾回收

这个区域的回收效果比较难令人满意,尤其是类型的卸载,条件相当苛刻。

方法区的垃圾收集主要回收两部分内容:常量池中废弃的常量不再使用的类型

判定一个常量是否“废弃”还是相对简单,而要判定一个类型是否属于“不再被使用的类”,需要同时满足下面三个条件:

  • 该类所有的实例都已经被回收,也就是 Java 堆中不存在该类及其任何派生子类的实例;
  • 加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如 OSGi、JSP 的重加载等,否则通常是很难达成的;
  • 该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

1.2.6 执行引擎

Java 多平台的关键在于使用 JVM 中的执行引擎能够突破“物理机”的硬件约束,执行不被硬件支持的指令集格式。

方法在执行的过程中,执行引擎可能会通过存储在局部变量表中的对象引用准确定位到存储在 Java 堆区中的对象实例信息,以及通过对象头中的元数据指针定位到目标对象的类型信息。每一项指令都依赖于程序计数器。

Java 执行过程

解释执行
  • 解释器:当 Java 虚拟机启动时会根据预定义的规范对字节码采用逐行解释的方式执行,将每条字节码文件中的内容“翻译”为对应平台的本地机器指令执行;
  • JIT(Just In Time Compiler)编译器:就是虚拟机将源代码直接编译成和本地机器平台相关的机器语言。

编译可以细分为两个过程,一个是编译,一个是汇编。编译将高级语言替换成等效的汇编源码,汇编则是将汇编代码变成计算机可以识别的机器码。

编译型语言执行过程

Java 语言的“编译期”其实是一段“不确定”的操作过程:

  • 因为它可能是指一个前端编译器(其实叫“编译器的前端”更准确一些)把 .java 文件转变成 .class 文件的过程;
  • 也可能是指虚拟机的后端运行期编译器(JIT 编译器,Just In Time Compiler)把字节码转变成机器码的过程;
  • 还可能是指使用静态提前编译器(AOT 编译器,Ahead of Time Compiler)直接把 .java 文件编译成本地机器代码的过程。
解释器
  • 字节码解释器在执行时通过纯软件代码模拟字节码的执行,效率非常低下;

  • 而模板解释器将每一条字节码和一个模板函数相关联,模板函数中直接产生这条字节码执行时的机器码,从而很大程度上提高了解释器的性能。

JIT 编译器

对于效率至上而言,解释器显然不能满足需求。JVM 支持一种叫作即时编译的技术,将整个函数体编译成为机器码,每次函数执行时,只执行编译后的机器码

HotSpot VM 采用解释器与即时编译器并存的架构。当 Java 虚拟器启动时,解释器可以首先发挥作用,而不必等待即时编译器全部编译完成后再执行,这样可以省去许多不必要的编译时间。随着时间推移,编译器发挥作用,把越来越多的代码编译成本地代码,获得更高的执行效率。

热点代码

机器在热机状态可以承受的负载要大于冷机状态。如果以热机状态时的流量进行切流,可能使处于冷机状态的服务器因无法承载流量而假死。

一个被多次调用的方法,或者是一个方法体内部循环次数较多的循环体都可以被称之为“热点代码”,可以通过 JIT 编译器编译为本地机器指令。由于这种编译方式发生在方法的执行过程中,因此被称之为栈上替换,或称为 OSR(On Stack Replacement)编译。

基于计数器的热点探测,HotSpot VM 为每一个方法都建立2个不同类型的计数器,分别为方法调用计数器(Invocation Counter)和回边计数器(Back Edge Counter)。

方法调用计数器

当一个方法被调用时,会先检查该方法是否存在被 JIT 编译过的版本,如果存在,则优先使用编译后的本地代码来执行。如果不存在已被编译过的版本,则将此方法的调用计数器值加1,然后判断方法调用计数器与回边计数器值之和是否超过方法调用计数器的阀值。如果已超过阈值,那么将会向即时编译器提交一个该方法的代码编译请求。

方法调用计数器

热点衰减

如果不做任何设置,方法调用计数器统计的并不是方法被调用的绝对次数,而是一个相对的执行频率。

当超过一定的时间限度,如果方法的调用次数仍然不足以让它提交给即时编译器编译,那这个方法的调用计数器就会被减少一半,这个过程称为方法调用计数器热度的衰减(Counter Decay),而这段时间就称为此方法统计的半衰周期(Counter Half Life Time)。

回边计数器

统计一个方法中循环体代码执行的次数,在字节码中遇到控制流向后跳转的指令称为“回边”(Back Edge)。建立回边计数器统计的目的就是为了触发 OSR 编译

1.3 对象实例化

1.3.1 创建对象的方式

  • new:最常见的方式、Xxx 的静态方法,XxxBuilder/XxxFactory 的静态方法;

  • Class 的 newInstance 方法:反射的方式,只能调用空参的构造器,权限必须是 public;

  • Constructor 的 newInstance(XXX):反射的方式,可以调用空参、带参的构造器,权限没有要求;

  • 使用 clone():不调用任何的构造器,要求当前的类需要实现 Cloneable 接口,实现 clone();

  • 使用序列化:从文件中、从网络中获取一个对象的二进制流;

  • 第三方库 Objenesis。

1.3.2 创建对象的步骤

创建对象步骤

1. 判断对象对应的类是否加载、链接、初始化

虚拟机遇到一条new指令,首先去检查这个指令的参数能否在 Metaspace 的常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已经被加载,解析和初始化(即判断类元信息是否存在)。

如果没有,那么在双亲委派模式下,使用当前类加载器以 ClassLoader + 包名 + 类名为key进行查找对应的 .class 文件;

  • 如果没有找到文件,则抛出 ClassNotFoundException 异常;

  • 如果找到,则进行类加载,并生成对应的 Class 对象。

2. 为对象分配内存

首先计算对象占用空间的大小,接着在堆中划分一块内存给新对象。如果实例成员变量是引用变量,仅分配引用变量空间即可,即4个字节大小。

  • 如果内存规整:虚拟机将采用的是指针碰撞法(Bump The Point)来为对象分配内存;
    所有用过的内存在一边,空闲的内存放另外一边,中间放着一个指针作为分界点的指示器,分配内存就仅仅是把指针指向空闲那边挪动一段与对象大小相等的距离罢了。如果垃圾收集器选择的是 Serial ,ParNew 这种基于压缩算法的,虚拟机采用这种分配方式。一般使用带 Compact(整理)过程的收集器时,使用指针碰撞。

  • 如果内存不规整:虚拟机需要维护一个空闲列表(Free List)来为对象分配内存。
    已使用的内存和未使用的内存相互交错,那么虚拟机将采用的是空闲列表来为对象分配内存。意思是虚拟机维护了一个列表,记录上那些内存块是可用的,再分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的内容。

选择哪种分配方式由 Java 堆是否规整所决定,而 Java 堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。

3. 处理并发问题
  • 采用 CAS 失败重试、区域加锁保证更新的原子性;

  • 每个线程预先分配一块 TLAB:通过设置 -XX:+UseTLAB参数来设定。

4. 初始化分配到的内存

所有属性设置默认值,保证对象实例字段在不赋值时可以直接使用。

5. 设置对象的对象头

将对象的所属类(即类的元数据信息)、对象的 HashCode 和对象的 GC 信息、锁信息等数据存储在对象的对象头中。这个过程的具体设置方式取决于 JVM 实现。

6. 执行init方法进行初始化

在 Java 程序的视角看来,初始化才正式开始。初始化成员变量,执行实例化代码块,调用类的构造方法,并把堆内对象的首地址赋值给引用变量。

因此一般来说(由字节码中跟随 invokespecial 指令所决定),new 指令之后会接着就是执行方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完成创建出来。

1.3.3 对象内存布局

public class Customer {
    int id = 1001;
    String name;
    Account acct;

    {
        name = "匿名客户";
    }

    public Customer() {
        acct = new Account();
    }
}

public class CustomerTest {
    public static void main(string[] args){
        Customer cust=new Customer();
    }
}

创建对象图示

内存分配图示

对象头(Header)

对象头包含了两部分,分别是运行时元数据(Mark Word)和类型指针。如果是数组,还需要记录数组的长度

运行时元数据
  • 哈希值(HashCode);

  • GC 分代年龄;

  • 锁状态标志;

  • 线程持有的锁;

  • 偏向线程ID;

  • 翩向时间戳。

类型指针

指向类元数据 InstanceKlass,确定该对象所属的类型。

实例数据(Instance Data)

它是对象真正存储的有效信息,包括程序代码中定义的各种类型的字段(包括从父类继承下来的和本身拥有的字段)

  • 相同宽度的字段总是被分配在一起;

  • 父类中定义的变量会出现在子类之前;

  • 如果 CompactFields 参数为 true(默认为 true):子类的窄变量可能插入到父类变量的空隙。

对齐填充(Padding)

不是必须的,也没有特别的含义,仅仅起到占位符的作用

1.3.4 对象的访问定位

对象定位图示

句柄访问

reference 中存储稳定句柄地址,对象被移动(垃圾收集时移动对象很普遍)时只会改变句柄中实例数据指针即可,reference 本身不需要被修改。

句柄访问图示

直接指针(HotSpot采用)

直接指针是局部变量表中的引用,直接指向堆中的实例,在对象实例中有类型指针,指向的是方法区中的对象类型数据。

直接指针图示

2. 垃圾回收

在默认情况下,通过 system.gc() 或者 Runtime.getRuntime().gc() 的调用,会显式触发 Full GC,同时对老年代和新生代进行回收,尝试释放被丢弃对象占用的内存。

一般情况下,垃圾回收应该是自动进行的,无须手动触发,否则就太过于麻烦了。

2.1 垃圾回收算法

对于垃圾回收,需要明确两点:

  • 哪些是垃圾(标记阶段);
  • 怎么清除(清除阶段)。

2.1.1 标记阶段

方法一:引用计数算法

对每个对象保存一个整型的引用计数器属性,用于记录对象被引用的情况。

对于一个对象A,只要有任何一个对象引用了A,则A的引用计数器就加1;当引用失效时,引用计数器就减1。只要对象A的引用计数器的值为0,即表示对象A不可能再被使用,可进行回收。

缺点:

  • 它需要单独的字段存储计数器,这样的做法增加存储空间的开销

  • 每次赋值都需要更新计数器,伴随着加法和减法操作,这增加时间开销

  • 无法处理循环引用。这是一条致命缺陷,导致在 Java 的垃圾回收器中没有使用这类算法。

方法二:可达性分析算法

解决在引用计数算法中循环引用的问题,防止内存泄漏的发生,也叫作追踪性垃圾收集(Tracing Garbage Collection)。

  • 以根对象集合(GCRoots)为起始点,按照从上至下的方式搜索被根对象集合所连接的目标对象是否可达;

  • 使用可达性分析算法后,内存中的存活对象都会被根对象集合直接或间接连接着,搜索所走过的路径称为引用链;

  • 如果目标对象没有任何引用链相连,则是不可达的,就意味着该对象己经死亡,可以标记为垃圾对象;

  • 在可达性分析算法中,只有能够被根对象集合直接或者间接连接的对象才是存活对象。

可达性分析算法图示

包含元素

在Java语言中,GC Roots包括以下几类元素:

  • 虚拟机栈中引用的对象:

    • 比如:各个线程被调用的方法中使用到的参数、局部变量等。
  • 本地方法栈内JNI(通常说的本地方法)引用的对象;

  • 方法区中类静态属性引用的对象:

    • 比如:Java 类的引用类型静态变量。
  • 方法区中常量引用的对象:

    • 比如:字符串常量池(String Table)里的引用。
  • 所有被同步锁 synchronized 持有的对象;

  • Java 虚拟机内部的引用:

    • 基本数据类型对应的 Class 对象,一些常驻的异常对象(如:NullPointerException、OutOfMemoryError),系统类加载器。
  • 反映 Java 虚拟机内部情况的 JMXBean、JVMTI 中注册的回调、本地代码缓存等。

2.1.2 finalization

Java 语言提供了对象终止(finalization)机制来允许开发人员提供对象被销毁之前的自定义处理逻辑(已于 JDK 18 中废弃)

当垃圾回收器发现没有引用指向一个对象,即:垃圾回收此对象之前,总会先调用这个对象的 finalize() 方法。

finalize() 方法允许在子类中被重写,用于在对象被回收时进行资源释放。通常在这个方法中进行一些资源释放和清理的工作,比如关闭文件、套接字和数据库连接等。在新 JDK 18 中,官方推荐使用 try-resource 或清洁器进行代替。

正确使用

永远不要主动调用某个对象的 finalize() 方法,应该交给垃圾回收机制调用。理由包括下面三点:

  • 在 finalize() 时可能会导致对象复活

  • finalize() 方法的执行时间是没有保障的,它完全由GC线程决定,极端情况下,若不发生 GC,则 finalize() 方法将没有执行机会;

  • 一个糟糕的 finalize() 会严重影响 GC 的性能。

状态

如果从所有的根节点都无法访问到某个对象,说明对象己经不再使用了。一般来说,此对象需要被回收。但事实上,也并非是“非死不可”的,这时候它们暂时处于“缓刑”阶段。一个无法触及的对象有可能在某一个条件下“复活”自己,如果这样,那么对它的回收就是不合理的,为此,定义虚拟机中的对象可能的三种状态。如下:

  • 可触及的:从根节点开始,可以到达这个对象;

  • 可复活的:对象的所有引用都被释放,但是对象有可能在 finalize() 中复活;

  • 不可触及的:对象的 finalize() 被调用,并且没有复活,那么就会进入不可触及状态。不可触及的对象不可能被复活,因为 finalize() 只会被调用一次。

以上3种状态中,是由于 finalize() 方法的存在,进行的区分。只有在对象不可触及时才可以被回收

具体过程

判定一个对象 objA 是否可回收,至少要经历两次标记过程:

  1. 如果对象 objA 到 GC Roots 没有引用链,则进行第一次标记
  2. 进行筛选,判断此对象是否有必要执行 finalize() 方法
  3. 如果对象 objA 没有重写 finalize() 方法,或者 finalize() 方法已经被虚拟机调用过,则虚拟机视为“没有必要执行”,objA 被判定为不可触及的;
    如果对象 objA 重写了finalize()方法,且还未执行过,那么 objA 会被插入到 F-Queue 队列中,由一个虚拟机自动创建的、低优先级的 Finalizer 线程触发其 finalize() 方法执行。
  4. finalize() 方法是对象逃脱死亡的最后机会,稍后 GC 会对 F-Queue 队列中的对象进行第二次标记。如果 objA 在 finalize() 方法中与引用链上的任何一个对象建立了联系,那么在第二次标记时,objA 会被移出“即将回收”集合。之后,对象会再次出现没有引用存在的情况。在这个情况下,finalize() 方法不会被再次调用,对象会直接变成不可触及的状态,一个对象的finalize方法只会被调用一次
public class GCTest {
    // 类变量,属于 GC Roots 的一部分
    public static GCTest canReliveObj;

    @Override
    protected void finalize() throws Throwable {
        super.finalize();
        System.out.println("调用当前类重写的finalize()方法");
        canReliveObj = this;
    }

    public static void main(String[] args) throws InterruptedException {
        // 创建一个新的对象
        canReliveObj = new GCTest();
        // 模拟销毁的情况
        canReliveObj = null;
        System.gc();
        System.out.println("-----------------第一次gc操作------------");
        // 因为 Finalizer 线程的优先级比较低,暂停2秒,以等待它
        Thread.sleep(2000);
        if (canReliveObj == null) {
            System.out.println("obj is dead");
        } else {
            System.out.println("obj is still alive");
        }

        System.out.println("-----------------第二次gc操作------------");
        canReliveObj = null;
        System.gc();
        // 下面代码和上面代码是一样的,但是 canReliveObj 却自救失败了
        Thread.sleep(2000);
        if (canReliveObj == null) {
            System.out.println("obj is dead");
        } else {
            System.out.println("obj is still alive");
        }
    }
}

/* 执行结果为
-----------------第一次gc操作------------
调用当前类重写的finalize()方法
obj is still alive
-----------------第二次gc操作------------
obj is dead
*/

2.1.3 清除阶段

Mark-Sweep Mark-Compact Copying
速率 中等 最慢 最快
空间开销 少(但会堆积碎片) 少(不堆积碎片) 通常需要活对象的2倍空间(不堆积碎片)
移动对象
标记清除算法

当堆中的有效内存空间(available memory)被耗尽的时候,就会停止整个程序 STW(也被称为 stop the world),然后进行两项工作,第一项则是标记,第二项则是清除。

  • 标记:Collector从引用根节点开始遍历,标记所有被引用的对象。一般是在对象的 Header 中记录为可达对象;

  • 清除:Collector对堆内存从头到尾进行线性的遍历,如果发现某个对象在其 Header 中没有标记为可达对象,则将其回收。

标记清除算法图示

  • 标记清除算法的效率不算高;

  • 在进行GC的时候,需要停止整个应用程序,用户体验较差;

  • 这种方式清理出来的空闲内存是不连续的,产生内碎片,需要维护一个空闲列表。

复制算法

将活着的内存空间分为两块,每次只使用其中一块,在垃圾回收时将正在使用的内存中的存活对象复制到未被使用的内存块中,之后清除正在使用的内存块中的所有对象,交换两个内存的角色,最后完成垃圾回收。

  • 需要两倍的内存空间;

  • 对于 G1 这种分拆成为大量 region 的 GC,复制而不是移动,意味着 GC 需要维护 region 之间对象引用关系,不管是内存占用或者时间开销也不小;

  • 多半应用于新生代的垃圾回收(需要回收之后对象存活数量不大)。

标记-压缩(整理)算法

标记一清除算法的确可以应用在老年代中,但是该算法不仅执行效率低下,而且在执行完内存回收后还会产生内存碎片,所以 JVM 的设计者需要在此基础之上进行改进。标记-压缩(Mark-Compact)算法由此诞生。

  1. 第一阶段和标记清除算法一样,从根节点开始标记所有被引用对象
  2. 第二阶段将所有的存活对象压缩到内存的一端,按顺序排放。
  3. 之后,清理边界外所有的空间。

标记-压缩(整理)算法

标记-清除算法是一种非移动式的回收算法,标记-压缩是移动式的。是否移动回收后的存活对象是一项优缺点并存的风险决策。可以看到,标记的存活对象将会被整理,按照内存地址依次排列,而未被标记的内存会被清理掉。如此一来,当我们需要给新对象分配内存时,JVM 只需要持有一个内存的起始地址即可,这比维护一个空闲列表显然少了许多开销。

指针碰撞

如果内存空间以规整和有序的方式分布,即已用和未用的内存都各自一边,彼此之间维系着一个记录下一次分配起始点的标记指针,当为新对象分配内存时,只需要通过修改指针的偏移量将新对象分配在第一个空闲内存位置上,这种分配方式就叫做指针碰撞(Bump the Pointer)。

2.2 内存溢出与内存泄露

内存溢出(OOM)

java doc 中对 OutOfMemoryError 的解释是,没有空闲内存,并且垃圾收集器也无法提供更多内存。

  1. Java 虚拟机的堆内存设置不够。
    比如:可能存在内存泄漏问题;也很有可能就是堆的大小不合理,比如我们要处理比较可观的数据量,但是没有显式指定 JVM 堆大小或者指定数值偏小。我们可以通过参数-Xms-Xmx来调整。
  2. 代码中创建了大量大对象,并且长时间不能被垃圾收集器收集(存在被引用)对于老版本的 Oracle JDK,因为永久代的大小是有限的,并且 JVM 对永久代垃圾回收(如,常量池回收、卸载不再需要的类型)非常不积极,所以当我们不断添加新类型的时候,永久代出现 OutOfMemoryError 也非常多见,尤其是在运行时存在大量动态类型生成的场合;类似intern字符串缓存占用太多空间,也会导致 OOM 问题。对应的异常信息,会标记出来和永久代相关:“java.lang.OutOfMemoryError: PermGen space“。
    随着元数据区的引入,方法区内存已经不再那么窘迫,所以相应的 OOM 有所改观,出现 OOM,异常信息则变成了:“java.lang.OutofMemoryError:Metaspace“。直接内存不足,也会导致 OOM。

内存泄漏(Memory Leak)

对象不会再被程序用到了,但是GC又不能回收他们的情况,称为内存泄漏。

尽管内存泄漏并不会立刻引起程序崩溃,但是一旦发生内存泄漏,程序中的可用内存就会被逐步蚕食,直至耗尽所有内存,最终出现 OutOfMemory 异常,导致程序崩溃。(这里的存储空间并不是指物理内存,而是指虚拟内存大小,这个虚拟内存大小取决于磁盘交换区设定的大小)

  1. 单例模式:
    单例的生命周期和应用程序是一样长的,所以单例程序中,如果持有对外部对象的引用的话,那么这个外部对象是不能被回收的,则会导致内存泄漏的产生。
  2. 一些提供close的资源未关闭导致内存泄漏:
    数据库连接(dataSourse.getConnection() ),网络连接(socket)和io连接必须手动close,否则是不能被回收的。

2.3 STW

Stop-the-World,简称 STW,指的是 GC 事件发生过程中,会产生应用程序的停顿。停顿产生时整个应用程序线程都会被暂停,没有任何响应,有点像卡死的感觉,这个停顿称为 STW。STW 是 JVM 在后台自动发起和自动完成的。

可达性分析算法中枚举根节点(GC Roots)会导致所有 Java 执行线程停顿。

  • 分析工作必须在一个能确保一致性的快照中进行;

  • 一致性指整个分析期间整个执行系统看起来像被冻结在某个时间点上;

  • 如果出现分析过程中对象引用关系还在不断变化,则分析结果的准确性无法保证。

2.4 安全点

程序执行时并非在所有地方都能停顿下来开始 GC,只有在特定的位置才能停顿下来开始 GC,这些位置称为安全点(Safepoint)。

Safe Point 的选择很重要,如果太少可能导致 GC 等待的时间太长,如果太多可能导致运行时的性能问题。大部分指令的执行时间都非常短暂,通常会根据“是否具有让程序长时间执行的特征”为标准。比如:选择一些执行时间较长的指令作为 Safe Point,如方法调用、循环跳转和异常跳转等。

如何在GC发生时,检查所有线程都跑到最近的安全点停顿下来呢?有两种方式。

抢先式中断:首先中断所有线程。如果还有线程不在安全点,就恢复线程,让线程跑到安全点。

主动式中断:设置一个中断标志,各个线程运行到Safe Point的时候主动轮询这个标志,如果中断标志为真,则将自己进行中断挂起。(有轮询的机制)

安全区域

Safepoint 机制保证了程序执行时,在不太长的时间内就会遇到可进入 GC 的 Safepoint。但是,程序“不执行”的时候呢?例如线程处于 Sleep 状态或 Blocked 状态,这时候线程无法响应 JVM 的中断请求,“走”到安全点去中断挂起,JVM 也不太可能等待线程被唤醒。对于这种情况,就需要安全区域(Safe Region)来解决。

安全区域是指在一段代码片段中,对象的引用关系不会发生变化,在这个区域中的任何位置开始 Gc 都是安全的。我们也可以把 Safe Region 看做是被扩展了的 Safe point。

参考文章

  1. JVM 学习笔记
  2. JVM 的类加载器及类的加载过程
  3. JVM 类的主动使用与被动使用
  4. 成员内部类里面为什么不能有静态成员和方法
  5. Java 新生代、老生代和永久代详解
  6. Java 虚拟机中 STW
  7. TLAB 到底是干什么的
  8. JVM 之逃逸分析
  9. JVM 中的 StringTable
  10. Java 18 拥有 9 个新特性

文章作者: 陈鑫扬
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 陈鑫扬 !
评论
  目录