JVM原理

概述

为了使Java开发人员无需关心不同架构上内存模型之间的差异,Java提供了自己的内存模型,并且JVM通过在适当的位置上插入内存栅栏来屏蔽在JMM与底层平台内存模型之间的差异。

JVM Memory Structure

  • 堆:堆是 Java 程序运行时动态分配内存的区域,用于存放对象、数组等数据结构,由垃圾回收器进行管理和回收。
  • 栈:栈则是 Java 程序中方法的执行环境,用于存放局部变量、方法参数等信息。
  • 方法区:用于存储类的信息、常量池等数据
  • 本地方法栈:用于存储本地方法的执行环境
  • 程序计数器:用于记录当前线程执行的字节码指令的位置。

类加载器

JVM的类加载是通过 ClassLoader 及其子类来完成的,站在 Java 虚拟机的角度来讲,只存在两种不同的类加载器:

  • 启动类加载器:Bootstrap ClassLoader不继承自ClassLoader抽象类,因为它不是一个普通的Java类,底层由C++编写,是JVM自身的一部分,已嵌入到了JVM内核当中,当JVM启动后,Bootstrap ClassLoader也随着启动,负责加载完核心类库后,并构造Extension ClassLoaderApp ClassLoader类加载器。启动类加载器是无法被 Java 程序直接引用的。
  • 其他类加载器:这些类加载器都由 Java 语言实现,独立于虚拟机之外,并且全部继承自ClassLoader抽象类,这些类加载器需要由启动类加载器加载到内存中之后才能去加载其他的类。

类的层次关系和加载顺序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
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 {
// BootstrapClassLoader has no parent ClassLoader
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();
// Subclass override
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;
}
}

类加载机制

类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期包括:加载、验证、准备、解析、初始化、对象实例化、垃圾收集、对象终结、卸载类型。

类的生命周期

其中类加载的过程包括了加载、连接、初始化三个阶段:

  1. 加载:查找并且加载类的二进制数据
  2. 连接:
    • 验证:确保被加载类的正确性(安全性校验)
    • 准备:为类的静态变量分配内存并将其初始化为默认值(默认初始化)
    • 解析:把类中的符号引用转换为直接引用
  3. 初始化:为类的静态变量赋予正确的初始值(显式初始化)

加载阶段

类的加载简单来说,就是将class文件中的二进制数据读取到内存中,将其放在方法区中,然后在堆内存中创建一个java.lang.Class对象,用来封装在方法区的数据结构。

加载是类加载过程的第一个阶段,在加载阶段,虚拟机需要完成以下三件事情:

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

相对于类加载的其他阶段而言,加载阶段是可控性最强的阶段,因为开发人员既可以使用系统提供的类加载器来完成加载,也可以自定义自己的类加载器来完成加载。

加载阶段完成后,虚拟机外部的二进制字节流就按照虚拟机所需的格式存储在方法区之中,而且在 Java 堆中也创建一个 java.lang.Class 类的对象,这样便可以通过该对象访问方法区中的这些数据。

连接阶段

验证

Java 是与平台无关的语言,这得益于 Java 源代码编译后生成的存储字节码的文件,即 Class 文件,以及Java虚拟机的实现。不仅使用 Java 编译器可以把 Java 代码编译成存储字节码的 Class 文件,使用 JRuby 等其他语言的编译器也可以把程序代码编译成 Class 文件,虚拟机并不关心 Class 的来源是什么语言,只要它符合一定的结构,就可以在 Java 中运行。Java 语言中的各种变量、关键字和运算符的语义最终都是由多条字节码命令组合而成的,因此字节码命令所能提供的语义描述能力肯定会比 Java 语言本身更强大,这便为其他语言实现一些有别于 Java 的语言特性提供了基础,而且这也正是在类加载时要进行安全验证的原因。

  1. 文件格式验证
    • 魔数因子是否正确(0xCAFEBABE)
    • 主从版本号是否符合当前虚拟机
    • 常量池中的常量类型是不是不支持
    • etc
  2. 元数据验证
    • 是否有父类
    • 父类是不是允许继承
    • 是否覆盖了父类的final字段
    • 其他语义检查
  3. 字节码验证
  4. 符号引用验证
准备

准备阶段就是给类变量分配默认初始值。

数据类型 默认值
byte (byte)0
char ‘\u0000’
short (short)0
int 0
long 0L
float 0.0f
double 0.0d
boolean false
reference null
解析
  • 类或接口的解析
  • 字段解析
  • 类方法解析
  • 接口方法解析

类初始化阶段

初始化是类加载过程的最后一步,到了此阶段,才真正开始执行类中定义的 Java 程序代码。在准备阶段,类变量已经被赋过一次系统要求的初始值,而在初始化阶段,则是执行类构造器<clinit>方法的过程。换句话说,其实初始化阶段做的事情就是给static变量赋予用户指定的值以及执行静态代码块。

  • <clinit>方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序所决定的,静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句中可以赋值,但是不能访问。
  • <clinit>方法与类的构造函数不同,它不需要显式地调用父类构造器,虚拟机会保证在子类的<clinit>方法执行之前,父类的<clinit>方法已经执行完毕。因此,在虚拟机中首先被执行的是Object的<clinit>方法。
  • <clinit>方法对于类或接口来说并不是必须的,如果一个类中没有静态语句块,也没有对类变量的赋值操作,那么编译器可以不为这个类生成<clinit>方法。
  • 接口中不能使用静态语句块,但仍然有类变量初始化的赋值操作,因此接口与类一样会生成<clinit>方法。但是接口与类不同的是:执行接口的<clinit>方法不需要先执行父接口的<clinit>方法,只有当父接口中定义的变量被使用时,父接口才会被初始化。另外,接口的实现类在初始化时也一样不会执行接口的<clinit>方法。
  • 虚拟机会保证一个类的<clinit>方法的线程安全性,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的<clinit>方法,其他线程都需要阻塞等待,直到活动线程执行<clinit>方法完毕。如果在一个类的<clinit>方法中有耗时很长的操作,那就可能造成多个线程阻塞,在实际应用中这种阻塞往往是很隐蔽的。

Java虚拟机规范为类的初始化时机做了严格定义:在首次主动使用时初始化。这个规则直接影响着类装载、连接和初始化类的机制,因为在类型被初始化之前它必须已经被连接,然而在连接之前又必须保证它已经被装载了。

说了这么多,类的初始化时机就是在首次主动使用时,那么,哪些情形下才符合首次主动使用的要求呢?

首次主动使用的情形:

  • 遇到 new、getstatic、putstatic、invokestatic 这四条字节码指令时,如果类还没有进行过初始化,则需要先触发其初始化。生成这四条指令最常见的 Java 代码场景是:使用 new 关键字实例化对象时、读取或设置一个类的静态字段时(被static修饰又被final修饰的,已在编译期把结果放入常量池的静态字段除外)、以及调用一个类的静态方法时。
  • 使用 Java.lang.refect 包的方法对类进行反射调用时,如果类还没有进行过初始化,则需要先触发其初始化。
  • 当初始化一个类的时候,如果发现其父类还没有进行初始化,则需要先触发其父类的初始化。
  • 当虚拟机启动时,用户需要指定一个要执行的主类,虚拟机会先执行该主类。

除了以上几种情形以外,所有其它使用Java类型的方式都是被动使用的,它们不会导致类的初始化。

被动使用的几种情形:

  • 对于静态字段,只有直接定义这个字段的类才会被初始化,因此,通过其子类来引用父类中定义的静态字段,只会触发父类的初始化而不会触发子类的初始化。
  • 常量在编译阶段会存入调用它的类的常量池中,本质上没有直接引用到定义该常量的类,因此不会触发定义常量的类的初始化。
  • 通过数组定义来引用类,不会触发类的初始化。

<clinit><init>方法的区别:

  • <clinit>是类构造器方法。Java编译器把所有的类变量初始化语句和静态语句块中的语句通通收集到 <clinit> 方法内,该方法只能被 JVM 调用,专门承担初始化工作。
  • <init>是对象构造器方法。一旦一个类被装载、连接和初始化,它就随时可以使用了。对象实例化和初始化时就是对象生命的起始阶段的活动,Java编译器在编译每个类时都会为该类至少生成一个实例初始化方法即 <init> 方法。

finalstaticstatic final修饰的字段赋值的区别:

  • static修饰的字段在类加载过程中的准备阶段被初始化为 0 或 null 等默认值,而后在初始化阶段(触发类构造器)才会被赋予代码中设定的值,如果没有设定值,那么它的值就为默认值。
  • final修饰的字段在运行时被初始化(可以直接赋值,也可以在对象构造器中赋值),一旦赋值便不可更改;
  • static final修饰的字段在 Javac 时生成 ConstantValue 属性,在类加载的准备阶段根据ConstantValue的值为该字段赋值,它没有默认值,必须显式地赋值,否则 Javac 时会报错。可以理解为在编译期即把结果放入了常量池中。

方法区

方法区是各个线程共享的内存区域,它用于存储已经被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据,垃圾收集行为在这个区域比较少出现。该区域的内存回收目标主要针是对废弃常量的和无用类的回收。方法区域又被称为“永久代”。

堆内存

堆内存用来存放由new创建的对象和数组,在堆中分配的内存,由Java虚拟机的自动垃圾回收器来管理。

在堆中产生了一个数组或对象后,还可以在栈中定义一个特殊的变量,让栈中这个变量的取值等于数组或对象在堆内存中的首地址,栈中的这个变量就成了数组或对象的引用变量。引用变量就相当于是为数组或对象起的一个名称,以后就可以在程序中使用栈中的引用变量来访问堆中的数组或对象。

引用变量是普通的变量,定义时在栈中分配,引用变量在程序运行到其作用域之外后被释放。而数组和对象本身在堆中分配,即使程序运行到使用new产生数组或者对象的语句所在的代码块之外,数组和对象本身占据的内存不会被释放,数组和对象在没有引用变量指向它的时候,才变为垃圾,不能再被使用,但仍然占据内存空间不放,在随后的一个不确定的时间被垃圾回收器收走释放掉,这也是Java比较占内存的原因。

虚拟机栈

存放基本数据类型的数据和对象的引用,但对象本身不存在栈中,而是存放在堆中,实际上,栈中的引用变量指向堆内存中的数组和对象,这就是Java中的指针。

在函数中定义的一些基本类型的变量数据和对象的引用变量都是在函数的栈内存中分配的,当在一段代码块定义一个变量时,Java就会在栈中为这个变量分配内存空间,当该变量退出该作用域后,Java会自动释放掉为该变量所分配的内存空间,该内存空间可以立即被另作他用。

本地方法栈

本地方法栈与Java虚拟机栈作用非常类似,其区别是:Java虚拟机栈是为虚拟机执行Java方法服务,而本地方法栈是为虚拟机调用的操作系统本地方法服务。

程序计数器

JVM是基于栈的体系结构来执行class字节码的。线程创建后,都会产生程序计数器(PC)和栈(Stack),程序计数器存放下一条要执行的指令在方法内的偏移量,栈中存放一个个栈帧,每个栈帧对应着每个方法的每次调用。当线程在执行一个 Java 方法时,该计数器记录的是正在执行的虚拟机字节码指令的地址,当线程在执行的是Native方法时,该计数器的值为空。

GC收集器

垃圾对象的判定

Java中的垃圾回收一般是在堆中进行,因为堆中存放着几乎所有的对象实例,垃圾收集器对堆中的对象进行回收前,要先确定这些对象是否还有用,判定对象是否为垃圾对象有如下算法:引用计数算法、引用可达性分析算法。

对象引用:如果 reference 类型的数据中存储的数值代表的是另外一块内存的起始地址,就称这块内存代表着一个引用。

引用计数算法

给对象添加一个引用计数器,每当有一个地方引用它时,计数器值就加 1,当引用失效时,计数器值就减1,任何时刻计数器都为 0 的对象就是不可能再被使用的。

引用计数算法的实现简单,判定效率也很高,在大部分情况下它都是一个不错的选择,但 Java 语言并没有选择这种算法来进行垃圾回收,主要原因是它很难解决对象之间的相互循环引用问题。

引用可达性分析算法

Java是采用引用可达性分析算法来判定对象是否存活的。这种算法的基本思路是通过一系列名为GC Roots的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链,当一个对象到GC Roots没有任何引用链相连时,就证明此对象是不可用的。GC会收集那些不是GC Roots且没有被GC Roots引用的对象。

在 Java 语言里,可作为GC Roots的对象包括下面几种:

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象
  • 方法区中的类静态属性引用的对象
  • 方法区中的常量引用的对象
  • 本地方法栈中 JNI(Native 方法)的引用对象

垃圾收集算法

标记-清除算法

首先标记出所需回收的对象,在标记完成后统一回收掉所有被标记的对象,它的标记过程其实就是前面的引用可达性分析算法中判定垃圾对象的标记过程。

  • 优点:不需要进行对象的移动,并且仅对不存活的对象进行处理,在存活对象比较多的情况下极为高效。
  • 缺点:标记和清除过程的效率都不高,并且标记清除后会产生大量不连续的内存碎片。

标记-整理算法

该算法标记的过程与标记—清除算法中的标记过程一样,但对标记后出的垃圾对象的处理情况有所不同,它不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。

  • 优点:经过整理之后,新对象的分配只需要通过指针碰撞便能完成,相当简单;使用这种方法空闲区域的位置是始终可知的,也不会再有碎片的问题了。
  • 缺点:GC暂停的时间会增长,因为需要将所有的对象都拷贝到一个新的地方,还得更新它们的引用地址。

标记-复制算法

将可用内存按容量划分为大小相等的两块,每次只是用其中一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。

  • 优点:内存分配时不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。
  • 缺点:需要一块能容纳下所有存活对象的额外的内存空间,因此,可一次性分配的最大内存缩小了一半。

分代收集

当前商业虚拟机的垃圾收集都采用分代收集,它根据对象的存活周期的不同将内存划分为几块,一般是把 Java 堆分为新生代和老年代。在新生代中,每次垃圾收集时都会发现有大量对象死去,只有少量存活,因此可选用标记-复制算法来完成收集,而老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用标记—清除算法或标记—整理算法来进行回收。

垃圾收集器

垃圾收集器是内存回收算法的具体实现,Java 虚拟机规范中对垃圾收集器应该如何实现并没有任何规定,因此不同厂商、不同版本的虚拟机所提供的垃圾收集器都可能会有很大的差别。Sun HotSpot 虚拟机 1.6 版包含了如下收集器:Serial、ParNew、Parallel Scavenge、CMS、Serial Old、Parallel Old。这些收集器以不同的组合形式配合工作来完成不同分代区的垃圾收集工作。

Java 中的堆是 JVM 所管理的最大的一块内存空间,主要用于存放各种类的实例对象。在 Java 中,堆被划分成两个不同的区域:新生代(Young)、老年代(Old)。新生代(Young)又被划分为三个区域:Eden、From Survivor、To Survivor。这样划分的目的是为了使 JVM 能够更好的管理堆内存中的对象,包括内存的分配以及回收。

JVM Heap Structure

默认的,新生代(Young)与老年代(Old)的比例的值为1:2(该值可以通过参数 –XX:NewRatio 来指定),新生代(Young)三个区域内存大小比例默认为8:1:1(可以通过参数 –XX:SurvivorRatio 来设定)。

  • 新生代:大多数情况下,对象在Eden中分配,当Eden没有足够空间时,会触发一次Minor GC
  • 老年代:用于存放经过几次Minor GC之后依旧存活的对象,当老年代的空间不足时,会触发Major GCFull GC

内存的分配策略

  • 对象优先在 Eden 分配
  • 大对象直接进入老年代
  • 长期存活的对象将进入老年代

垃圾回收策略

  • 新生代GC(Minor GC):发生在新生代的垃圾收集动作,因为 Java 对象大多都具有朝生夕灭的特性,因此Minor GC 非常频繁,一般回收速度也比较快。
  • 老年代GC(Major GC/Full GC):发生在老年代的 GC,出现了 Major GC,经常会伴随至少一次 Minor GC。由于老年代中的对象生命周期比较长,因此 Major GC 并不频繁,一般都是等待老年代满了后才进行Full GC,而且其速度一般会比 Minor GC 慢 10 倍以上。另外,如果分配了 Direct Memory,在老年代中进行 Full GC时,会顺便清理掉 Direct Memory 中的废弃对象。

参考链接