前言

这篇文章是我读《深入理解Java虚拟机》后的笔记总结,对每一个Java开发者来说这是一本值得多看几遍的好书,文章主要内容包括:

  • 自动内存管理
  • 类加载与执行引擎
  • 程序编译与代码优化
  • 并发与线程安全

自动内存管理

内存模型

Java虚拟机运行时数据区域:

  • 线程共享的数据区域,随虚拟机启动而存在,GC工作区域
    • 方法区:存储已被虚拟机加载的类信息、常量、静态变量、JIT后的代码等数据,通常称为“永久代(Permanent Generation)”。GC回收的目标主要是针对常量池的回收和对类型的卸载
      • 运行时常量池:方法区的一部分,存放编译期生成的字面量和符号引用,在运行期也可以将新的常量放入池中(比如String类的intern()方法)
      • 直接内存:不是虚拟机运行时数据区的一部分。JDK1.4中引入的NIO类,引入基于通道(Chanel)和缓冲区(Buffer)的I/O方式,可以使用Native函数直接分配堆外内存,通过Java堆的DirectByteBuffer对象引用,避免Java堆与Native堆数据复制带来的性能消耗
    • Java堆:内存中最大的一块区域,专门用于存放对象实例(包括数组)和成员变量(包括基本类型),GC主要工作区域。根据GC特性分为老年代和新生代,其中新生代又可以细分为Eden空间、From Survivor空间、To Survivor空间
  • 线程隔离的数据区,随线程启动而创建,随线程结束而销毁
    • 程序计数器:当前线程执行代码的行号指示器,唯一没有规定OutOfMemoryError的区域。执行Java代码时,记录正在执行字节码指令的地址;执行Native方法时,计数器值为空。
    • 虚拟机栈:描述Java方法执行的内存模型,每个方法从调用到执行完成对应一个栈帧从虚拟机栈入栈到出栈
      • 栈帧:存储局部变量、操作数栈、动态链接、方法出口等信息
      • 局部变量表:存放编译器可知的各种基本数据类型数据、对象引用reference(对象实例本身还是存放到堆)、returnAddress类型(字节码指令地址),以局部变量空间Slot为基本分配单位,所需内存空间在编译期间完成分配
    • 本地方法栈:与虚拟机栈负责执行Java方法类似,本地方法栈负责执行Native方法,具体虚拟机可以自由实现它
  • 小结 Java的内存模型除了程序计数器区域没有定义OutOfMemoryError异常,其他区域都存在OutOfMemoryError异常,虚拟机栈与本地方法栈中存在StackOverFlowError异常

对象在内存中存储的布局可以分为三块区域:

  • Header(对象头):存储对象自身运行时数据,比如HashCode、GC分代年龄等信息,类型指针
  • Instance Data(实例数据):对象真正存储的有效信息
  • Padding(对齐填充):对象大小必须是8字节的整数倍,当大小不符合时需要通过对齐填充补全

对象访问定位的两种方式:

  • 句柄访问:保证reference存放的是稳定的句柄地址,访问对象时从句柄池中取句柄,读取句柄中对象实例数据(存放在堆区)和类型数据(存放在方法区)地址信息
  • 直接指针访问:节省了到对象实例数据指针定位开销,速度快,HotSpot目前使用的方式

Garage Collection

内存资源并非取之不尽用之不竭的,对于无用对象我们需要回收其所占用的内存。首先我们来思考三个问题:如何判断对象可被回收,何时执行回收,如何执行回收?现在我带领大家来揭开垃圾收集技术的神秘面纱。

判断对象可被回收的方法:

  • 引用计数法:给对象添加一个引用计数器,对象被引用时计数器值加一,引用失效时计数器值减一,值为0时表示对象不再使用可被回收。由于引用计数法无法解决循环引用的问题,因此虚拟机没有采用这种方案
  • 可达性分析法:主流商用虚拟机采用的方案,以一系列作为GC Root对象作为起点,以引用作边,进行图的遍历,走过的路径称为引用链,未被遍历的对象为不可用对象需要进行回收。Java中可作为GC Roots的对象:
    • 虚拟机栈区的栈帧中的本地变量表引用的对象
    • 方法区中静态属性引用的对象
    • 方法区中常量引用的对象
    • 本地方法栈区Native方法引用的对象

四种引用类型:

  • 强引用:new创建的对象产生的引用,只要强引用存在,即使OOM也不能被回收
  • 软引用:OOM前会回收该引用指向的对象,JDK1.2之后提供SoftReference类实现软引用
  • 弱引用:下一次GC时会回收该引用指向的对象,JDK1.2之后提供WeakReference类实现弱引用
  • 虚引用:无法通过虚引用来获得对象实例,关联的对象仅在被回收时收到一个系统通知。可以用于监视缓存,对象回收后释放资源的工作。JDK1.2之后提供PhantomReference类实现虚引用

垃圾回收算法:

  • 标记-清除算法:最基础的回收算法,通过可达性分析遍历后标记出所有可回收的对象,然后回收所有被标记过的对象。效率不高,容易产生内存碎片
  • 复制算法:将可用内存划分为大小相等的两块,每次使用其中一块,当空间占满后将所有存活对象复制到另一块并清理当前这一块的空间。这样提高了效率,解决了内存碎片的问题,代价是每次只能使用一半的内存空间。这种方案适合对象存活率低的场景,可以减少复制对象带来的开销。根据新生代对象“朝生夕死”的特点,用于回收新生代对象。
  • 标记-整理算法:标记过程类似标记-清除算法,但是之后不是直接回收,而是将所有存活对象移至一端,然后清除端边界外的内存。这种方法解决了内存碎片化问题,也提高了内存空间利用率,适合对象存活率高的场景,每次只需要标记少量对象,适合老年代。
  • 分代收集算法:根据对象存活周期的不同将内存划分为新生代和老年代,根据各自特点采用合适的回收算法。

GC类型:

  • Minor GC:Eden区内存不足以分配给对象时触发的新生代GC,频率高
  • Major GC:通常等价于Full GC,会Stop-The-World。老年代/永久代的GC,频率低速度慢,伴随至少一次的Minor GC

了解以上内容后,最后完整梳理一下分代收集算法具体过程以及采用的GC策略:

  • 在新生代区域根据SurvivorRatio参数划分Eden,Survivor A,Survivor B三块区域比例
  • 每次创建实例时会优先在Eden分配内存,Eden空间不足时执行Minor GC,采用复制算法将所有存活对象复制到Survivor B中,然后将Eden清空后继续向外提供内存空间,Eden空间再次不足时将Eden和Survivor A中存活的对象复制到Survivor B中,同时清空Eden和Survivor A,每次Eden对象复制时使用一块Survivor,如此反复。
  • 虚拟机给每个对象都分配了一个年龄计数器,对象在Survivor区中每“熬过”一次Minor GC后年龄加一,超过MaxTenuringThreshold参数值时会晋升到老年代
  • 当老年代空间不足或永久代空间不足时会执行Major GC,采用的GC算法是标记-整理算法

以上是分代收集算法的完整过程,除了上面描述的常规过程以外,实际过程中还存在一些额外策略:

  • 大对象直接进入老年代:大量连续内存的Java对象,比如很长的字符串以及数组
  • 动态对象年龄判定:如果在Survivor空间中所有年龄相同的对象大小总和大于Survivor空间的一半,则年龄大于或等于该年龄的对象就可以忽略MaxTenuringThreshold参数值直接进入老年代。
  • 空间分配担保:在Minor GC发生之前会先检查老年代最大连续空间是否大于新生代所有对象总空间,这是为防止内存回收后新生代对象全部存活状态,导致老年代无法提供足够空间,导致Minor GC后紧接着又执行Full GC的情况。而虚拟机在完成内存回收前并不知道会存在新生代全部是存活状态这种极端情况,因此虚拟机会查看HandlePromotionFailure值是否允许担保失败。如果不允许担保失败(HandlePromotionFailure == false)则直接进行Full GC;如果允许担保失败(HandlePromotionFailure == true)则虚拟机会将老年代最大连续可用空间与之前每一次回收晋升到老年代对象容量的平均值作比较,大于则相当于担保成功尝试Minor GC,小于则相当于担保失败直接执行Full GC。

虚拟机支持参数配置,我们可以根据应用场景设置相应参数达到性能优化的效果:

示例:-Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8

Params Description
Xms 最小堆容量
Xmx 最大堆容量
Xmn 新生代容量 <==> Eden + Survivor A + Survivor B
PrintGCDetail 记录GC日志
SurvivorRatio 新生代中Eden与Survivor区域的比值,默认值为8
PretenureSizeThreshold 对象大小大于该值,直接分配至老年代
MaxTenuringThreshold 大于该阈值时晋升至老年代,默认值为15
HandlePromotionFailure 是否允许担保失败
ParalleGCThreads 设置并行GC时进行内存回收的线程数

类加载与执行引擎

类加载

虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型。

Class文件:一串二进制字节流,无论以何种形式存在。

类加载生命周期:

  • Loading(加载):通过一个类的全限定名获取类的二进制字节流,将字节流所代表的静态存储结构转化为方法区运行时数据结构,最后在内存中生成一个代表该类的java.lang.Class对象作为访问类数据信息的方法区入口。
  • Verification(验证):保证Class文件字节流合法性,防止恶意代码危害虚拟机安全,验证动作包括文件格式验证、元数据验证、字节码验证、符号引用验证。
  • Preparation(准备):正式在方法区为类变量分配内存并设置类变量初始值,除ConstantValue属性外通常为零值。
  • Resolution(解析):将常量池中的符号引用转换为直接引用,JVM根据16个字节码指令判断是在类加载时就对常量池进行符号引用解析,还是等到使用时进行解析。
    • 符号引用:以一组符号描述所引用的目标,符号可以是任意形式的字面量。
    • 直接引用:可以是直接指向目标的指针、相对偏移量或间接定位到目标的句柄。
    • 运行时绑定使得程序实际执行到invokedynamic指令时才进行解析。
  • Initialization(初始化):真正执行Java代码,父类<clinit>()方法优先执行,
    • <clinit>()方法由类变量赋值动作和静态语句块组成,编译器收集的顺序由语句在源文件中出现的顺序决定。
    • 父类<clinit>()方法优于子类<linit>()方法先执行。
    • <clinit>()方法对于class或interface非必需。
    • VM保证一个类的<linit>()方法在多线程中能被正确加锁、同步,只会有一个线程去执行<linit>f()方法。
  • Using(使用)
  • Unloading(卸载)

类加载场景:

  • 遇到new,getstatic,putstatic,invokestatic 4条指令时
  • 使用java.lang.reflect包方法对类进行反射调用时
  • 子类初始化时首先初始化其父类
  • 虚拟机启动时,首先初始化包含main方法的主类
  • JDK1.7 java.lang.invoke.MethodHandle解析出REF_getStatic,REF_putStatic,REF_invokeStatic的方法句柄时,初始化句柄类

四种类型的加载器:

  • BootstrapClassLoader:由JVM内部实现,负责加载$JAVA_HOME/lib-Xbootclasspath并且是虚拟机识别的核心类库至内存。
  • ExtClassLoader:继承java.lang.ClassLoader,负责加载$JAVA_HOME/lib/extjava.ext.dirs指定的类库。
  • AppClassLoader:继承java.lang.ClassLoader,负责加载classpath路径的类库,默认类加载器。
  • CustomClassLoader:如果我们想动态加载class文件(本地文件/网络下载),需要自定义类加载器,继承java.lang.ClassLoader,并重写findClass方法。

对于任何一个类,由加载它的类加载器和这个类一同确立其在JVM中的唯一性,每个加载器类都有一个独立的类名称空间。同一个Class文件,被同一个虚拟机加载,但是使用不同的类加载器时,会得到不同的两个类,而影响equal,isAssignableFrom,isInstance方法的返回结果和instanceof的判定。那么全限定类名一致的情况下如何保证唯一确定一个类呢?双亲委派模型类加载机制可以解决这个问题。

双亲委派模型:类加载器收到类加载请求时,优先将请求交给parent加载器加载,无法完成时才自己加载,类似与责任链设计模式。加载器之间使用组合Composition关系复用parent加载器的代码,加载请求的传递路径为:CustomClassLoader -> AppClassLoader -> ExtClassLoader -> BootstrapClassLoader。下面是ClassLoader的loadClass方法源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
protected synchronized Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
// 首先检查请求的类是否已经被加载过了
Class c = findLoadedClass(name);
if (c == null) {
try {
if (parent != null) {
c = parent.loadClass(name, false);
} else {
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// 父类无法完成加载
}

if (c == null) {
c = findClass(name);
}
}

if (resolve) {
resolveClass(name);
}
return c;
}

加载流程梳理:

  • 首先检查类是否被加载过,如果已加载直接返回加载过的Class对象
  • 没有加载过,递归父类加载器的loadClass方法处理加载请求
  • parent加载器加载成功,则直接返回
  • parent加载器加载失败,调用本身的findClass方法加载类

以类java.lang.Object为例,无论是哪一个类加载器要加载该类,双亲委派模型可以实现最终交给模型最顶端的BootstrapClassLoader加载,保证Object类在程序运行中的唯一性。现在我们来分析另一种场景,如果用户在项目中编写了java.lang.Object类,双亲委派机制使得AppClassLoader最终会委托BootstrapLoader调用loadClass方法加载,而java.lang.Object已加载过$JAVA_HOME/lib中的java.lang.Object类了,会通过BootstrapClassLoader的findLoadedClass方法直接返回该类,解决了加载多个java.lang.Object的问题,保证了类的唯一性。

根据上面的源码,我们得到自定义ClassLoader的规则,继承ClassLoader类,重写findClass方法而无需重写loadClass方法,确保双亲委派机制的逻辑正确执行。

执行引擎

运行时栈帧(Stack Frame):栈帧是用于虚拟机进行方法调用和方法执行的数据结构,包括局部变量表、操作栈、动态链接、返回地址和附加信息。每一个方法从调用开始到执行完成的过程,对应一个栈帧虚拟机栈中从入栈到出栈的过程。下面我们通过分析栈帧结构了解字节码是如何被执行的。

  • 局部变量表:一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量。除了上面内存模型中对局部变量表的介绍,虚拟机是如何描述Java方法执行时是参数值到参数列表的传递过程呢?首先,如果是调用实例方法则Slot 0存放调用该方法的对象的引用,因此可以使用this访问到该对象;如果调用的是类方法则Slot 0存放0。接下来的Slot按照方法参数列表 ==> 局部变量列表的顺序存放变量。需要注意的是局部变量中的Slot可重用,当方法体内定义的变量超出其作用域时,会被后面的变量重用前面变量的Slot

我们观察下面简短的代码对GC的影响可以说明局部变量表Slot复用

1
2
3
4
5
6
7
public static void main(String[] args) {
{
byte[] placeholder = new byte[64 * 1024 * 1024];
}
int a = 0; // 代码A
System.gc();
}

(1)直接执行程序,placeholder内存会被回收。
(2)将代码A注释掉,执行程序后内存并没有被回收。

自然地我们需要知道placeholder能否被回收的根本原因:局部变量表中的Slot是否存在有关于placeholder数组对象的引用。情形(1)虽然代码已经离开了placeholder作用域,后面没有任何对局部变量表的读写操作,placeholder原本占用的Slot仍旧保持着对placeholder的引用,所以GC roots仍会保持着对placeholder的关联导致placeholder不能被回收。情形(2)利用了Slot可重用性,int a = 0中的变量a会重用placeholder占用的Slot,导致placeholder解引用可以被GC回收。

  • 操作数栈:具有存放执行运算的参数、栈帧数据共享的功能,Java虚拟机的解释执行引擎称为“基于栈的执行引擎”中的“栈”就是操作数栈
  • 动态连接:字节码中的方法调用指令以常量池中指向方法的符号引用做参数。符号引用在类加载阶段或第一次使用时转为直接引用称为静态解析,每次在运行期转化为直接引用称为动态连接
  • 方法返回地址:方法遇return指令正常退出时会将调用者的PC计数器值作为返回地址,异常退出时通过异常处理表确定返回地址

下面是一段Java程序的执行过程

1
2
3
4
5
void foo(){
int a = 1;
int b = 2;
int c = (a + b) * 5;
}

方法调用

方法调用阶段的唯一任务是确定被调用方法的版本,不涉及方法内部的具体执行过程,通常会经过解析和分派的过程:

  • 解析:运行前可唯一确定一个可调用版本,并且该方法调用版本在运行期不可变的方法,能被invokestatic和invokespecial指令调用的方法例如静态方法、私有方法、实例构造器、父类方法以及使用invokevirtual调用的final方法,类加载解析阶段会将该方法的符号引用解析为直接引用,即编译期确定
    • invokestatic:调用静态方法
    • invokespecial:调用实例构造器<init>方法、private方法和父类方法
    • invokevirtual:调用所有虚方法
    • invokeinterface:调用接口方法
    • invokedynamic:先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法
  • 分派
    • 静态分派:根据静态类型来定位方法执行版本的分派,编译器完成,经典应用是函数的重载
    • 动态分派:依赖实际类型来定位方法执行版本的分派,运行期完成,经典应用是函数的重写

举个例子:

1
2
3
4
class Man extends Human {
}

Human man = new Man();

在这个例子里,man这个实例的静态类型是Human,实际类型是Man

未完待续

参考文章

《深入理解Java虚拟机》

声明:本站所有文章均为原创或翻译,遵循署名 - 非商业性使用 - 禁止演绎 4.0 国际许可协议,如需转载请确保您对该协议有足够了解,并附上作者名 (Tsukasa) 及原文地址