Java 虚拟机简介

2018-11-23
44 min read

本文是阅读《深入理解 Java 虚拟机:JVM 高级特性与最佳实践》的笔记

推荐学习资料:

  • 《The Java Virtual Machine Specification, Java SE 7 Edition》
  • http:// hllvm. group. iteye. com/,高级语言虚拟机圈子

概述

Java 技术体系 4 平台

  • Java card
  • Java ME(Micro Edition),手机、PDA 等,对 Java API 有所精简
  • Java SE(Standard Edition),桌面级应用
  • Java EE(Enterprise Edition),除桌面功能外提供企业级功能,如 ERP、CRM等,Java EE 以前称为 J2EE

从 JDK 1.5 以后,官方就不再使用这种命名方式,而是使用 JDK 5、JDK 6 这种形式

Java 虚拟机实现

  • Sun HotSpot VM
    • Hot 指的是热点代码探测,例如一段被频繁调用的函数将会触发编译器优化以提高这段代码的执行速度
  • Sun Mobile-Embedded VM/Meta-Circular VM,用于嵌入式平台
  • BEA JRockit/IBM J9 VMAzul VM/BEA Liquid VMMicrosoft JVM(因侵权而放弃),等等

混合语言

Clojure、Jruby、Groovy 等语言都基于 Java 虚拟机,且各有特点,分别适用于不同的场景

其他 Java 特性

  • Fork/Join 多线程模型
  • 函数式编程,Java 8,lambda
  • OpenJDK 是 sun 2006 年把 Java 开源而形成的项目。OpenJDK 对 windows 的支持并不友好

JVM 内存

graph TB
	A[JVM memory]
	A --> B[线程间共享<br/><执行引擎>]
	style B fill:#f9f
	A --> C[线程私有<br/><本地库接口>]
	style C fill:#f90
	B --> D[方法区<br/>常量池<br><小字符串等>]
	B --> E[堆]
	C --> F[虚拟机栈]
	C --> G[本地方法栈]
	C --> H[程序计数器]
	A --> I[直接内存区<br/>非JVM管理]
	style I fill:#690
	J[部分 JVM 实现不区分]
	F --> J
	G --> J
	E --> K[指针碰撞<br/>空闲列表<br/>TLAB]
	E --> L[堆溢出<br/>栈溢出]
	D --> M[溢出<br/>CGLib&SJP]
  • 线程私有
    • 程序计数器
      • 与 CPU 的程序计数器类似,不过在 Java 中这个计数器可能指向的是字节码位置(不同实现可能不同)
      • 不同线程都有自己的程序计数器
      • 不会抛出 OutOfMemoryError 异常
    • Java 虚拟机栈
      • 与 OS 中进程的栈概念相似,每个 Java 方法都有自己的栈帧用于保存局部变量、动态链接等信息,例如基本数据类型(boolean、byte、char等)、对象引用和 returnAddress
      • 编译时已知的各种基本类型数据保存在栈中的局部变量表中,局部变量表的大小在编译时确定
      • StackOverflowError or OutOfMemoryError,Java 允许固定长度的虚拟机栈
    • 本地方法栈
      • 与 Java 虚拟机栈的功能类似,不过这个栈由 Java 中所调用的 Native 方法使用
      • 有些 JVM 将本地方法栈和虚拟机栈合并,Java 方法和 Native 方法都使用相同的栈空间
      • A native method is a Java method whose implementation is provided by non-java code
  • 线程共享
    • Java 堆(GC 堆)
      • 虚拟机启动时创建,保存绝大部分对象实例与数组。随着 JIT 和逃逸分析等技术的发展对象未必一定在堆上创建
      • JIT 在运行时编译字节码时可以获得比 C/C++ 静态编译时更多的运行时信息,故经过 JIT 编译的可执行代码,其效率可能会比 C/C++ 要高
      • Java 堆是垃圾搜集器(GC)管理的主要区域
      • Java 虚拟机规范中规定堆可以位于物理上不连续的内存空间,不过主流的实现都按照可扩展来实现
    • 方法区
      • 保存已经被虚拟机加载的类信息、常量、静态变量、JIT 编译后的代码等数据
      • 方法区的内存回收是必要的,虽然很少使用,有些 BUG (如内存泄漏)是因为方法区没有回收内存
      • HotSpot 虚拟机称之为永久代,因为此虚拟机将 GC 扩展到了方法区,其他虚拟机不存在这个概念
      • 运行时常量池
        • 存储编译时生成的字面值和符号引用,Java 中常量不一定非要是编译期才能产生,所以这个区域是动态的
        • Java String 中的 intern (native 方法)方法会尝试将字符串放到常量池中并返回对常量池的引用,JDK 1.6 和 JDK 1.7 之间的实现有区别,前者在方法区保持字符串,后者将字符串引用保存在方法区
    • 直接内存
      • 为了避免拷贝数据,Java 提供直接操作非虚拟机管理的内存区域,这些内存被称为直接内存

JVM 内存 & OS 内存

  • 32位 OS 给每个进程可用的内存空间是有限的, windows/linux 为 2GB(1.9GB)
  • JVM 提供了设定 Java 堆(-Xms/-Xmx)、方法区、每个线程栈帧(-Xss)内存大小的机制

对象的创建(HotSpot 为例)

graph LR
A(new obj) --> B[方法区检查]
B -->|YES| C[calloc]
B -->|NO| D[load]
D --> C
C --> E[构造句柄]
E --> F[构造对象]

虚拟机内存概念

指针碰撞

假设 Java 堆中内存是绝对规整的,所有用过的内存都放在一边, 空闲的内存放在另一边, 中间放着一个指针作为分界点的指示器, 那所分配内存就仅仅是把那个指针向空闲空间那边挪动 一段与对象大小相等的距离,这种分配方式称为“ 指针碰撞”( Bump the Pointer)

指针碰撞和后面将要接触到的垃圾回收算法(“标记-整理”)相关

空闲列表

如果 Java 堆中的内存并不是规整的,已使用的内存和空闲的内存相互交错,那就没有办法简单地进行指针碰撞 了,虚拟机就必须 维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的录, 这种分配方式称为“ 空闲列表”( Free List)

空闲列表和后面所说的“标记-清除”垃圾回收算法相关

TLAB

用于多线程对象的创建,多线程同时在堆上创建对象时无论使用指针碰撞还是空闲列表,都会涉及同步问题

每个线程在 Java 堆中预先分配一小块内存,称为本地线程分配缓冲( Thread Local Allocation Buffer, TLAB)。哪个线程要分配内存, 就在哪个线程的 TLAB 上分配, 只有 TLAB 用完并分配新的 TLAB 时,才需要同步锁定

可以使用 -XX:+/-UseTLAB来指定虚拟机是否使用TLAB

对象存储

对象头

HotSpot 对象头由两部分组成:

  • 运行时数据,例如哈希码( HashCode)、 GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等,这些数据一般保存在固定的 32bits 或者 64bits 中
  • 对象类型指针,对应于 C++ 中的 vptr
实例数据 & 对齐填充

实例数据就是 Java 对象的有效信息,例如类中的成员变量等

HotSpot 规定对象的起始地址必须是 8 字节的整数倍,这个和 C/C++ 中内存对齐的概念是相似的

对象访问

对象访问分两类,使用句柄直接指针,如下图所示

使用句柄的好处是便于垃圾回收与内存整理,只要修改句柄中对象的指针就可以移动对象,但效率低,故较少使用

使用直接指针的好处是速度快,其比句柄方法少了一次指针定位的开销,但垃圾回收时就需要修改引用对象的指针

graph LR
	A[<b>stack</b><br>obj handle refer]
	A --> B[<b>handle in heap</b><br/># pointer to obj data<br/># pointer to obj type infos]
	B --> C[<b>heap</b><br/>obj data]
	B --> D[<b>methods area</b><br/>obj type infos]
	
	E[<b>stack</b><br>obj refer]
	E --> F[<b>heap</b><br/>obj data<br/>pointer to type infos]
	F --> G[<b>methods area</b><br/>obj type infos]
	

溢出实例

堆溢出

编译并执行下面代码将会造成堆内存溢出,注释处的 args 是执行 Java 程序时指定的命令,用于限定堆大小

import java.util.*;
// args: Args:-Xms20m-Xmx20m-XX:+HeapDumpOnOutOfMemoryError
public class test {
    static class OOMObject {
    }
    
    public static void main(String[] args) {
        List<OOMObject> list = new ArrayList<OOMObject>();
        while (true) {
            list.add(new OOMObject());
        }
    }
}

使用工具(Eclipse memory analyzer 分析 dump 文件)判断是内存泄漏还是内存溢出,也可以使用工具查看对象到 GC Roots 的引用链,从而定位对象的来源

栈溢出

两种异常

  • StackOverflowError,栈溢出,需要提前设置最大栈空间,命令为:Args:-Xss128k,设定单个线程最大栈空间为 128K
  • OutOfMemoryError,JVM 在扩展栈时无法申请到足够的内存空间
// VM Args:-Xss128k
public class JavaVMStackSOF {
    private int stackLength = 1;
    public void stackLeak() {
        stackLength++; // 递归时记录溢出时 栈帧个数
        stackLeak(); // 每个函数调用都有自己的栈帧,用于保存返回地址等信息
    }

    public static void main(String[] args) throws Throwable {
        JavaVMStackSOF oom = new JavaVMStackSOF();
        try {
            oom.stackLeak();
        } catch (Throwable e) {
            System.out.println(" stack length:" + oom.stackLength);
            System.out.println(e);
            System.exit(0);
        }
    }
}

方法区溢出
String.intern() 简介

String.intern() 是一个 Native 方法,它的作用是:如果字符串常量池中已经包含一个等于此 String 对象的字符串,则返回代表池中这个字符串的 String 对象;否则,将此 String 对象包含的字符串添加到常量池中,并且返回此 String 对象的引用

因为HotSpot JDK 7 中开始去除方法区的“永久代”实现,故 JDK 7 和 JDK 6 中 intern 的实现有一定区别。JDK 6 将字符串对象保存在常量池中;JDK 7 将字符串对象保存在堆中而将字符串引用保存在常量池中

CGlib & JSP

可修改字节码以生成新的类,Spring、Hibernate 等框架都基于此类字节码技术

运行时使用 CGLIB 可以创建大量的类从而出现方法区溢出的异常

方法区溢出的另一种场景就是包含大量 JSP 文件的场景,JSP 需要被编译为 Java 类才能运行

Java 垃圾回收与内存分配

Java 与 C/C++ 不同,Java 不需要显式的删除已分配内存,所以 Java 需要一定的机制检查并回收无用内存

graph TB
	A[垃圾回收]
	
	B[探活]
	C[引用计数]
	D[可达性分析]
	E[四类引用]
	F[GC Roots]
	
	G[垃圾收集]
	H[两次标记]
	I[方法回收3]
	J[finalize]
	K[<b>分代</b>]
	L[<b>老年代</b><br/>标记-整理<br/>标记-清除]
	M[<b>新生代</b><br/>复制 118]
	N[HotSpot<br/>OopMap<br/>见下]
	
	A --> B
	B --> C
	B --> D
	D --> E
	D --> F
	A --> G
	G --> H
	G --> I
	H --> J
	G --> K
	K --> L
	K --> M
	G --> N

对象探活

对象探活的目的在于找到那些需要清理的对象

对象探活常见方法有引用计数可达性分析等。使用引用计数法(类似于 C++ 中的智能指针 shared_ptr)实现对象探活相对容易,但无法解决对象之间相互循环引用的问题,例如

class A{
	public A element;
	...
}

A a, b;
a.element = b;
b.element = a;

可达性分析 & GC Roots

Java 中可达性分析与 GC Roots 是息息相关的。Java 是面向对象的语言,所有对象之间都使用引用进行关联(底层实现一般是 C/C++ 中的指针)。从软件启动开始,所有的对象都会有一个“父对象”,所有的对象都是由父对象创建的,可以认为 JVM 是始祖对象。Java 启动且未进入 main 函数前初始化的对象可以认为其父对象是 JVM;进入 main 函数后创建的所有对象可以认为其父对象是 main 函数所在的对象。由此可见 Java 中对象之间的关系可以使用多叉树进行表示,这些树的根节点就是垃圾回收扫描的起点(不考虑 JVM),也就是 GC Roots

所有对象的父对象都可以追溯到 JVM,但垃圾回收时不将 JVM 当做起点

GC Roots 一般有以下 4 类:

  1. 虚拟机栈中引用的对象
  2. 方法区静态属性引用的对象
  3. 方法区常量引用的对象
  4. 本地方法栈中 JNI(Java Native Interface,原生方法)引用的对象

引用分类

Java 1.2 后引用被分为以下 4 类,“引用强度”由强到弱分别为:

  1. 强引用

强引用就是指在程序代码之中普遍存在的,类似Object obj= new Object()这类的引用,只要强引用还存在,垃圾收集器就不会回收掉被引用的对象

  1. 软引用

软引用是用来描述一些还有用但并非必需的对象。对于软引用关联着的对象,在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围之中进行第二次回收(可以使用 SoftReference 函数创建)

  1. 弱引用

弱引用也是用来描述非必需对象的,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生之前(WeakReference)

  1. 虚引用

为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知(PhantomReference)

垃圾回收

垃圾回收会涉及到对象探活与内存整理。因为 GC 过程很难保证正在移动的对象没有被其他执行中的线程所引用与修改,所以这个过程一般需要整个 JVM 停止执行工作线程,Sun 将这件事称为 Stop The World。虚拟机暂停执行工作线程会影响业务性能,所以要尽可能减少垃圾回收造成的系统停顿时间

使用句柄来访问对象时可以只停顿与将要 GC 的对象相关联的线程,其他线程照样执行,但使用句柄访问对象效率不高,这类方法将 GC 时间分摊到了工作进程执行过程

垃圾回收常见算法

分代收集算法

根据对象存活周期的不同将内存划分为几块。一般把 Java 堆分为新生代(刚创建没多久的对象)和老年代(已经存在很久的对象,一些比较大的对象也默认是老年代),这样就可以根据各个年代的特点采用最适当的收集算法。例如新生代对象在每次垃圾回收时只有少量对象存活,可以使用下面提及的复制算法;对于存活时间长的老年代对象可以使用下面提及的“标记-清理”或者“标记-整理”算法

标记-清除

具体过程和下面的两次标记过程类似,缺点是效率低且会出现内存碎片问题

两次标记

如果对象在进行可达性分析后发现没有与 GC Roots 相连接的引用链,那它将会被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行 finalize() 方法。当对象没有覆盖 finalize() 方法,或者 finalize() 方法已经被虚拟机调用过时,虚拟机将对象视为“没有必要执行”,否则是“有必要执行”

如果这个对象被判定为有必要执行 finalize() 方法,那么这个对象将会放置在一个叫做 F-Queue 的队列之中,并在稍后由一个由虚拟机自动建立的、低优先级的 Finalizer 线程去执行它。这里所谓的“执行” 是指虚拟机会触发这个方法,但并不承诺会等待它运行结束

finalize() 方法是对象逃脱死亡命运的最后一次机会,稍后 GC 将对 F-Queue 中的对象进行第二次小规模的标记,如果对象要在 finalize() 中成功拯救自己——只要重新与引用链上的任何一个对象建立关联,那么对象将被移出 F-Queue,并被认为“活着”

对象的 finalize() 方法只会被调用一次,在 Java 中尽量不要使用 finalize 方法

标记-整理

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

复制

基础的复制算法将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。这样使得每次都是对整个半区进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。只是这种算法的代价是将内存缩小为了原来的一半,代价比较大

从统计上来讲,在进行一次垃圾回收前,有 98%新生代对象都是可回收的,所以没必要把内存分为大小相等的两份

大部分商用虚拟机使用下述方法回收新生代对象

HotSpot 虚拟机将新生代内存分为三个部分:80% 的 Eden 和两个 10% 的 Survivor,创建对象时优先在 Eden 中创建。每次垃圾回收时因为 98% 的对象都可以删除,所以大部分情况下 10% 的内存即可存储所有存活的对象。垃圾回收前虚拟机只会使用 Eden 和其中一个 Survivor,回收时将 Eden 和被使用的 Survivor 中的对象移到另一个未被使用的 Survivor 中

当一个 Survivor 保存不了余下的对象时,会触发分配担保(下面有介绍)

方法回收

类(方法,位于内存方法区)需要同时满足三个条件才算可回收类

  1. 该类所有实例均被回收
  2. 加载该类的 ClassLoader 已经被回收
  3. 该类对应的 java.lang.Class 对象没有在任何地方被引用,即无法在任何地方通过反射访问该类的方法

对方法是否进行回收,虚拟机提供了配置选项。在大量使用反射、动态代理、CGLibByteCode 的框架、动态生成 JSP 以及 OSGi 这类频繁自定义 ClassLoader 的场景都需要虚拟机具备类卸载的功能,以保证静态区不会溢出

HotSpot & OopMap 垃圾回收实现

graph LR
	A[HotSpot&OopMap]
	
	B[准确&保守]
	C[安全点<br/>安全区域]
	D[抢占&主动]
	E[回收器实现]
	F[Serial,ParNew,Parallel<br/>CMS,Serial old,Parallel Old<br/>G1]
	I[其他]
	J[GC日志]
	K[GC 原则]
	L[分配担保]
	
	A --> B
	B --> C
	C --> D
	A --> E
	E --> F
	A --> I
	I --> J
	I --> K
	K --> L
保守 & 准确

JVM 的垃圾回收分为保守式 GC 和准确式 GC,这里的保守和准确指的是 JVM 在垃圾回收时是否确定当前字段为引用类型。下面做详细的介绍,参考

  • 保守式 GC

    保守式 GC, JVM 不记录变量类型信息。每次进行垃圾回收时扫描所有 GC Roots 区域(本地方法栈、JVM 栈、静态方法区等),如果发现一个疑似指针变量(如真实的指针、整数等),JVM 都会检查堆中是否存在对象,如果存在就将对象信息保存在内存分配表中以标识某一段内存已被对象占用,否则就从内存分配表中删除这段内存以供下次分配。如果恰巧一个整数类型和一个指针所指向的地址相同,无论这个对象是否还“存活”,保守式 GC 都会保留这块内存,因为 JVM 不能确定这个变量不是指针。因为同一个对象可能在不同的栈帧中被使用且 JVM 又无法区分变量类型,所以 JVM 不能修改栈中“地址”的值(万一一个整型值和指针的值刚好相同),故保守式 GC 不能移动对象,只能使用类似标记-清除的算法法回收内存。如果 JVM 使用句柄(另一种为直接内存访问)的方式访问对象,则保守式 GC 也可以移动对象,但本该回收的对象依旧存在的现象无法消除

  • 半保守式 GC

    JVM 中栈上的变量一般不包含类型信息,但堆中的对象可以包含类型信息(例如反射等,这个和 C++ 中的虚函数表概念类似),所以垃圾回收时堆对象中的指针类型是可以确定的,此时就不存在上面整型和指针类型不分的情况,堆上的指针可以实现准确式回收,且可以移动对象

  • 准确式 GC

    准确式 GC 指 JVM 进行内存回收时可以确定指针的类型与位置,这一般需要辅助的数据结构与存储空间,HotSpot 中这些数据存储在 OopMap 中

OopMap

OopMap 是 HotSpot 实现准确式垃圾回收的基础,HotSpot 中的 GC Roots 一般保存在 OopMap 中,这样 JVM 在 GC 时就不用扫描所有静态区和栈帧,栈帧在运行时动态变化,所以 OopMap 的内容也在不断的变化

在类文件载入和 JIT(Just-In-Time Compiler)编译过程中 OopMap 都有发生变化的可能

何时更新 OopMap ?

什么时候或者说代码有什么特点时应该更新 OopMap?总不能编译器每执行一条指令就判断一下是否需要更新 OopMap吧,这样效率太低了

安全点(区域)
安全点

GC 需要在更新 OopMap 后才能进行, GC 时的 OopMap 已经包含了所有 GC Roots,且对象之间的引用关系不会在 GC 过程中发生变化,所以 JVM 并不能随意在任何位置进行垃圾回收,JVM 进行垃圾回收的位置需要满足一定的条件,这些满足条件的位置被称为安全点。常见的安全点有:方法调用循环跳转异常跳转等,这些点都不会改变对象之间的引用关系

垃圾回收需要所有线程都运行到安全点,一般有两种方式

  • 抢先式中断(少见)

    JVM 停止所有线程,如果一个线程没有运行到安全点就恢复其运行,直至到达安全点

  • 主动式中断(常见)

    JVM 设置一个标志位,当线程到达安全点时检查这个标志并自动停止运行

安全区域

如果线程阻塞了,或者 sleep,线程将无法执行到安全点,则 JVM 无法进行 GC,此时就需要安全区域的概念。所谓的安全区域,是指引用关系不会发生变化的指令段。线程进入安全区域时会给出一个标识,以供 JVM 查询

常见垃圾回收器

从垃圾回收器出现至今并没有出现一款通用的、在任何场景下性能都非常出众的实现,所以大部分请求下需要在不同的场景下选择不同的垃圾回收器,具体场景具体分析

新生代收集器

Serial

单线程垃圾收集器,收集时需要停止所有工作线程

ParNew

Serial 的多线程版本,可以配合 CMS 使用

Parallel Scavenge

使用复制算法的多线程收集器。其他算法关注如何减少垃圾回收时间,当前算法控制垃圾回收时间和工作线程工作时间的比值。你可以设置一个小的比值,则新生代会占用更多的内存;你可以设置一个大的比值,新生代会占用更少的内存,当然回收时间会变长

老年代收集器

CMS(基于标记清除)

CMS(Concurrent Mark Sweep)是一种追求最短停顿为目标的收集器,特点是并发低停顿收集器,老年代推荐使用

CMS 可以在用户进程运行时进行垃圾回收,此时用户进程产生的垃圾被称为浮动垃圾,CMS 只能等待下次回收时回收这些垃圾

一般在老年代内存被占用超过一定比例时才会触发 CMS 垃圾回收,提高比例可以减少垃圾回收的次数

Serial old(MSC)

Serial 的老年代版本

Parallel Old

Parallel Scavenge 的老年代版本

混合代垃圾回收

G1

未来可能替代 CMS,G1 可以同时用于新生代与老年代

GC 日志

不同垃圾回收器的日志格式不同,但有一定的共性

33.125:[ GC[ DefNew: 3324K- > 152K( 3712K), 0. 0025925 secs] 3324K- > 152K( 11904K), 0. 0031680 secs]   
 
100.667:[ Full GC[ Tenured: 0 K- > 210K( 10240K), 0. 0149142secs] 4603K- > 210K( 19456K),[ Perm: 2999K- > 2999K( 21248K)], 0. 0150007 secs][ Times: user= 0. 01 sys= 0. 00, real= 0. 02 secs]

以第一行为例:

  • 33.125 表示 GC 发生事件,从 JVM 启动到当前的秒数
  • DefNew 表示新生代垃圾回收,不同垃圾回收器使用的关键字不同
  • 3324k->152k(3712k),表示 GC 前该区域已使用容量,和垃圾回收后该区域所使用容量,括号内为当前区域总容量
  • 0.0025925 secs 表示本次新生代垃圾回收所用时间
  • 3324k -> 152k(11904k),GC 前 JVM 使用的堆容量,和 GC 后 JVM 使用的堆容量,圆括号内为总容量
  • 0.0031680,为本次垃圾回收总耗时

部分 GC 原则

  • 对象优先在 Eden 分配
    • 如果 Eden 空间不足,则发起一次 minor GC
    • 老年代 GC (Major GC/Full GC),速度比 Minor GC 要慢 10 倍以上
  • 大对象直接进入老年代
    • 避免新生代大量内存复制,新生代一般使用复制算法进行垃圾回收
  • 长期存活的对象将直接进入老年代,可以设置一个时间,当对象存活时间超过这个值就扔进老年代
  • 动态对象年龄判断
    • Survivor 空间中先相同年龄所有对象大小总合大于 Survivor 空间的一半时,年龄大于或等于该年龄的对象将进入老年代
  • 空间分配担保
    • 新生代使用复制算法,当一个 Survivor 无法保存所有新生代对象时,需要将部分对象保存到老年代内存中,所以在进行 minor GC 前一般需要查看老年代可用空间是否大于新生代所有对象所用总空间,如果是就可以确保 minor GC 可以成功;否则一般会触发 Full GC 腾出空间或者其他机制,保证 Minor GC 不会出现大问题
    • 具体介绍可参考周志明《深入理解 Java 虚拟机》

Java Class 文件简介

作为 一个通用的、机器无关的执行平台,任何其他语言的实现者都可以将 Java 虚拟机作为语言的产品交付媒介。Java 虚拟机不和包括 Java 在内的任何语言绑定,它只与“Class 文件”这种特定的二进制文件格式所关联,Class 文件中包含了 Java 虚拟机指令集和符号表以及若干其他辅助信息

当前使用 JVM 的语言有 Java、JRuby、Groovy 等等

graph TB
	A[Java Class]
	
	B[JVM<br/>独立平台]
	C[u1/u2/u3/u4<br/>表]
	D[大端法]
	F[魔数与版本<br/>常量池<br/>访问标志<br/>继承索引<br/>字段<br/>方法<br/>属性表]
	G[变量类型描述符]
	H[字节码指令<br/><b>256</b>]
	I[<b>面向栈</b>]
	
	A --> B
	A --> C
	C --> D
	A --> F
	F --> G
	A --> H
    H --> I

Class 文件结构

class 文件可能由类加载器生成,所以并不是所有类或者接口都定义在已有的 class 文件中

为了解决大端小端的问题,class 文件规定高位字节放在文件靠前的位置

class 文件中主要包含两种数据:

  • 无符号整型,u1、u2、u3 和 u4,一般用于后续内容的计数。u 后面的数字表示整型所占用的字节数,例如:u1 即为 C 中的 unsigned char
  • 表,表用来存储实际的数据和信息

大致结构

class 文件的大致结构如下,后面详细介绍:

  • class 文件起始四字节使用 16 进制表示为 0xcafebabe
  • 第 5 和第 6 字节是次版本号
  • 第 7 和第 8 字节是主版本号
  • 版本号之后即为常量池入口,常量池中的每一项都是表,常量池主要保存两大类常量
    • 字面值
    • 符号引用
      • 类和接口的全限定名
      • 字段的名称和描述符
      • 方法的名称和描述符
  • 常量池之后有两个字节:访问标志,用于表示这个 class 是类还是接口;是否定义为 public 类型等等
  • 父类索引(u2)、类索引(u2),接口索引数组,用于指明类或接口的继承关系
  • 索引数组之后一般是字段表、方法表、属性表等等,这些区段用于保存实际的代码信息

示例

使用 javac 命令编译下面的代码(我所使用的 javac 版本:javac 1.8.0_65,win7x64)

package xyz.yearn;

public class TestClass {
	    private int m;
	    public int inc(){ return m+1;}
}

class 文件的二进制内容为(可以使用 Free Hex Editor Neo 查看,为了方便阅读,讲解的部分使用双下划线标出)

class 文件的二进制内容和下面的 表1 是对应的

// 主版本号 0x34;常量池中有 0x13-1=18 个表项;第一个表项为 0xa,代表 `methodref_info`
ca fe ba be 00 00 __00 34 00 13 0a__ 00 04 00 0f 09 
00 03 00 10 07 00 11 07 00 12 01 00 01 6d 01 00
01 49 01 00 06 3c 69 6e 69 74 3e 01 00 03 28 29
56 01 00 04 43 6f 64 65 01 00 0f 4c 69 6e 65 4e
75 6d 62 65 72 54 61 62 6c 65 01 00 03 69 6e 63
01 00 03 28 29 49 01 00 0a 53 6f 75 72 63 65 46
69 6c 65 01 00 0e 54 65 73 74 43 6c 61 73 73 2e
6a 61 76 61 0c 00 07 00 08 0c 00 05 00 06 01 00
13 78 79 7a 2f 79 65 61 72 6e 2f 54 65 73 74 43
6c 61 73 73 01 00 10 6a 61 76 61 2f 6c 61 6e 67
// 常量池之后为访问标志位,即下面的 0x0021
// 将上面类前的 public 删除(或者改为 private),0x0021 将变为 0x0020(private)
// 访问标志后为类索引(u2,即 0x0003)、父类索引(u2,即 0x0004)和接口索引
// 接口索引第一个 u2 为实现接口的个数,0x0000,表示当前类未实现任何接口
2f 4f 62 6a 65 63 74 __00 21 00 03 00 04 00 00__ 
// 接口索引后为字段表,第一个 u2 用于计数;余下的 3 个 u2 表示变量 m 的访问标志和两个索引
// 每一个字段都可以有额外的描述信息,m第三个u2后为这些信息的计数,不过 m 没有,所以值为 0x0000
__00 01 00 02 00 05 00 06 00 00__ 
// 字段表后为方法表集合,第一个 u2(0x0002) 为方法计数,一个为构造函数 init,一个为 inc
// 0x0001 表示 public;0x0007 为函数名索引;0x0008 为函数类型描述
__00 02 00 01 00 07 00 08 00 01 00 09__ 00 00 00 1d 00 01 00 01 00 00 00
05 2a b7 00 01 b1 00 00 00 01 00 0a 00 00 00 06
00 01 00 00 00 03 00 01 00 0b 00 0c 00 01 00 09
00 00 00 1f 00 02 00 01 00 00 00 07 2a b4 00 02
04 60 ac 00 00 00 01 00 0a 00 00 00 06 00 01 00
00 00 05 00 01 00 0d 00 00 00 02 00 0e

获得 class 文件后执行命令:javap -verbose TestClass,可以得到下面的输出(有删减,全文使用表 1引用下面输出)

原始的 class 文件是二进制的,使用上面的命令可以将其解析为便于阅读的文本格式(不同版本的 Java,解析出来的格式不同)

// class 文件前 4 个字节为魔数 0xcafebabe,用于标识当前文件为 class 文件
public class xyz.yearn.TestClass
  minor version: 0    // 第 5 和第 6 个字节用于标识次版本号,class 使用大端法,故靠左的字节为高位字节
  major version: 52   // 第 7 和第 8 字节用于标识主版本号
  flags: ACC_PUBLIC, ACC_SUPER // 这是解析“访问标志位”获得的信息
Constant pool:        // 主版本号之后即为常量池入口
   #1 = Methodref          #4.#15         // java/lang/Object."<init>":()V
   #2 = Fieldref           #3.#16         // xyz/yearn/TestClass.m:I
   #3 = Class              #17            // xyz/yearn/TestClass
   #4 = Class              #18            // java/lang/Object
   #5 = Utf8               m              // 变量 m 的变量名
   #6 = Utf8               I              // 变量类型,I 表示 int,数组使用[、[[等描述
   #7 = Utf8               <init>
   #8 = Utf8               ()V
   #9 = Utf8               Code
  #10 = Utf8               LineNumberTable
  #11 = Utf8               inc
  #12 = Utf8               ()I
  #13 = Utf8               SourceFile
  #14 = Utf8               TestClass.java // 在二进制文件中,这句后面为访问标志
  #15 = NameAndType        #7:#8          // "<init>":()V
  #16 = NameAndType        #5:#6          // m:I
  #17 = Utf8               xyz/yearn/TestClass
  #18 = Utf8               java/lang/Object
{
  public xyz.yearn.TestClass();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      // Slots 等概念后面会涉及,这里引用 Slots 以说明下面部分参数的作用
      // 栈帧所需的局部变量表(Slots)大小、操作数个数在编译时已经确定并写在 code 属性中(locals)
      // Java 的指令面向操作数栈而非寄存器,所以需要一块内存(stack) 保存指令的操作数
      // 操作数栈最大深度编译时已确定,因为 Java 指令操作数的个数是确定的 
      stack=1, locals=1, args_size=1  // init 没有参数,args_size 和 locals 中的 1 表示 this 指针
         0: aload_0
         1: invokespecial #1          // Method java/lang/Object."<init>":()V
         4: return
      LineNumberTable:
        line 5: 0

  public int inc();
    descriptor: ()I
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=1, args_size=1 
         0: aload_0
         1: getfield      #2         // Field m:I
         4: iconst_1
         5: iadd
         6: ireturn
      LineNumberTable:
        line 7: 0
} 

class 文件常量池

常量池中保存两大类数据:字面值与表,表的类型个数是有限的,JDK 7 中有 14 种,常量池的索引值从 1 开始

表的第一个字节(u1)标识表的类型,下面简要介绍几种表

CONSTANT_Methodref_info

用于保存类中方法的符号引用

类型 名称 数量 备注
u1 tag 1 标识类型,当前值为 10
u2 index 1 索引,指向声明方法的类描述符 Class_info
u2 index 1 索引,指向名称及类型描述符
CONSTANT_Class_info

此类型的常量代表一个类或者接口的符号引用,其结构如下

类型 名称 数量 备注
u1 tag 1 标识类型,当前值为 7
u2 name_index 1 索引,执行一个 utf8 表,指向的表保存类或接口的全限定名

表 1 中的 #3 即为一个 class_info 表,其中的 name_index 索引指向了 #17#17 即为一个 utf8_info

CONSTANT_Utf8_info

此类型用于保存 utf8 编码的字符串,其结构如下。Java 中变量名由当前表保存,故Java中符号名最长为 64K

类型 名称 数量 备注
u1 tag 1 当前值为 1
u2 length 1 表示后续占用字节数,length 最大值为64K
u1 bytes length 实际的字符串内容
描述符

Java 使用字符串描述变量与方法的类型

Java 使用指定字符表示变量的基本类型,例如 B 表示 byte、C 表示 char:

B:byte, C:char, D:double, F:float, I:int, J:long, S:short, Z:boolean, V:void, L:object

对于数组,每一维使用一个前置 [ 来描述,例如:java.lang.String[][] ,其描述为:[[Ljava/lang/String;

用描述符来描述方法时,按照先参数列表,后返回值的顺序描述,参数列表按照参数的严格顺序放在一组小括号“()” 之内。如方法 void inc() 的描述符为()V,方法 java.lang.String toString() 的描述符为()Ljava/lang/String;,方法 int indexOf(char[] source, int sourceOffset, int sourceCount, char[] target, int targetOffset, int targetCount, int fromIndex)的描述符为([CII[CIII)I

class 文件访问标志

在常量池结束之后,紧接着的两个字节代表访问标志(access_flags),这个标志用于识别一些类或者接口层次的访问信息,包括:这个 Class 是类还是接口;是否定义为 public 类型;是否定义为 abstract 类型;如果是类的话,是否被声明为 final 等

class 文件(父)类索引和接口索引

父类索引、类索引与接口索引用于指明当前类或接口的继承关系

由于 Java 语言不允许多重继承,所以父类索引只有一个,除了 java.lang.Object 之外,所有的 Java 类都有父类,因此除了 java.lang.Object 外,所有 Java 类的父类索引都不为 0

接口索引集合就用来描述这个类实现了哪些接口,这些被实现的接口将按 implements 语句(如果这个类本身是一个接口,则应当是 extends 语句)后的接口顺序从左到右排列在接口索引集合中

字段表集合

字段表(field_info)用于描述接口或者类中声明的变量。字段(field)包括类级变量以及实例级变量,但不包括在方法内部 声明的局部变量

字段表集合中不会列出从超类或者父接口中继承而来的字段,但有可能列出原本 Java 代码之中不存在的字段,譬如在内部类中为 了保持对外部类的访问性,会自动添加指向外部类实例的字段

类型 名称 数量 备注
u2 access_flags 1 当前字段的访问标志
u2 name_index 1 字段名称
u2 descriptor_index 1 字段和方法的描述

方法表集合

class 文件存储格式中对方法的描述与对字段的描述几乎采用了完全一致的方式,方法表的结构如同字段表一样,依次包括了访问 标志(access_flags)、名称索引(name_index)、描述符索引(descriptor_index)、 属性表集合(attributes)几项

方法的具体代码保存在下面的属性表集合中

属性表集合

属性表用于保存其他字段中的一些属性,例如 Code 属性用于保存方法的代码

字节码指令简介

Java 虚拟机采用面向操作数栈而不是寄存器的架构,所以大多数的指令都不包含操作数,只有一个操作码

Java 虚拟机指令操作码长度只有一个字节,所以指令集的操作码总数不超过 256 条

因为 Java 操作码个数有限,故不可能每一种类型的数据都对应独立的操作码,所以很多不同类型的数据所使用的操作码是相同的

JVM 中已有指令详细介绍可参看其他资料,本文只介绍部分后续会用到的指令

与常见的 CPU 指令相比, JVM 指令有一个异常抛出命令:athrow

对象创建与访问

虽然类实例和数组都是对象,但 Java 虚拟机对类实例和数组的创建与操作使用了不同的字节码指令

  • 类创建指令:new

  • 数组创建指令:newarray、anewarray、multianewarray

  • 访问字段:getfield、putfield、getstatic、putstatic

    • getstatic: Get static field from class ,参考。静态初始化块只会在第一次初始化类时执行

方法调用与返回

  • invokestatic,调用类中 static 方法

Java 类加载与运行

类从加载到内存直至被卸载,整个生命周期包括:加载、验证、准备、解析(绑定)、初始化、使用和卸载7个阶段,对于不同的实现这 7 个阶段可能各有重叠,但大致过程相同

graph TB
	A[Class 加载]
	style A fill:#f90
	B[Files<br/>network]
	C[<b>准备</b><br/>方法区<br/>static 0值]
	D[<b>类首次</b>初始化<br/><b>clinit</b><br/>5个初始化时机]
	E[加载器名字空间]
	
	O[运行时栈]
	P[<b>Slots</b><br/>0 this<br/>args<br/>操作数栈]
	Q[Slot 复用]
	S[动态链接<br/>栈帧]
	T[动态特性]
	U[invoke<br/>反射]
	
	A --> B
	A --> C
	C --> D
	B --> E

	O --> P
	P --> Q
	O --> S
	O --> T
	T --> U
	

从类到对象

粗略来看,类的加载一般需要经过下面三个过程

  1. 通过一个类的全限定名来获取定义此类的二进制字节流 ,例如使用文件(Jar、war)、网络(Applet) 等方式
  2. 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
  3. 生成一个代表这个类的 java.lang.Class 对象(方法区或者堆),作为方法区这个类的各种数据的访问入口

下面介绍部分细节

验证

class 文件不一定由 Java 编写,也可能由 C 或其他语言生成,所以为了安全,载入 class 文件时 JVM 会对 class 文件进行验证。验证 阶段大致上会完成下面 4 个阶段的检验动作:

  • 文件格式验证
  • 元数据验证
  • 字节码验证
  • 符号引用验证

准备

准备阶段主要初始化方法区内存

准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些变量所使用的内存都将在方法区中进行分配。这个阶段中有 两个容易产生混淆的概念需要强调一下,首先,这时候进行内存分配的仅包括类变量(被 static 修饰的变量),而不包括实例变量, 实例变量将会在对象实例化时随着对象一起分配在 Java 堆中。其次,这里所说的初始值“ 通常情况” 下是数据类型的零值,因为这时候尚未开始执行任何 Java 方法,成员变量的赋值编译后常置于构造器 <clinit>() 中。但是,如果类字段为 const 类型,那么在编译与准备时变量的值就已经确定了,不需要写进构造器中

public static int value       = 123; // 变量 value 在准备阶段过后的初始值为 0 而不是 123
public static final int value = 123; // 变量 value 在准备阶段的初始值为 123

解析

解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程

初始化

注意,这里的初始化指的不是类对象的初始化,而是类或接口首次使用(创建对象)前所做的准备工作

类初始化阶段是类加载过程的最后一步,前面的类加载过程中,除了在加载阶段用户应用程序可以通过自定义类加载器参与之外, 其余动作完全由虚拟机主导和控制。到了初始化阶段,才真正开始执行类中定义的 Java 程序代码(或者说是字节码)。从另外一个角度来表达:初始化阶段是执行类构造器 <clinit>() 方法的过程,调用<clinit>()是一个类或接口被首次使用前的最后一项工作

<clinit>() 方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{} 块)中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序所决定的,静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量, 在前面的静态语句块可以赋值,但是不能访问

public class Test{ 
    static
    { 
        i= 0;// 给变量赋值可以正常编译通过
    	System.out.print(i)// 这句编译器会提示"非法向前引用" 
    } 
    static int i = 1; 
}

<clinit>() 方法对于类或接口来说并不是必需的,如果一个类中没有静态语句块,也没有对变量的赋值操作,那么编译器可以不为这个类生成 <clinit>() 方法

<clinit>() 方法与类的构造函数(或者说实例构造器 <init>() 方法)不同,它不需要显式地调用父类构造器,虚拟机会 保证在子类的 <clinit>() 方法执行之前,父类的 <clinit>() 方法已经执行完毕。虚拟机会保证一个类的 <clinit>() 方法在多线程环境中被正确地加锁、同步,如果多个线程同时去初始化一个类,那么只会 有一个线程去执行这个类的<clinit>()

初始化时机

JVM 规范对类初始化的时机做了明确的规定,有且仅有下面 5 种情况必须对类进行初始化,且下面 5 种方式被称为主动引用

  1. 遇见 new、getstatic、putstatic 和 invokestatic 这 4 条字节码命令时,如果类未初始化过,则需要初始化
    • new,调用构造函数初始化对象堆内存,创建对象需要知道对象的类型,而 java.lang.Class是在类初始化时载入的
    • 类中静态初始化块只会在类第一次初始化时调用,后三个静态成员访问指令需要触发首次类初始化
  2. 使用 java.lang.reflect 包的方法对类进行反射调用时,如果类未进行过初始化,则需先触发初始化
    • 参考这里,反射需要维护对象的一个 Class 对象,而 Class 对象由类初始化载入
  3. 初始化一些类时,如果发现其父类未被初始化,则需先触发父类的初始化
    • 对于接口而言,只有用到的时候才会初始化父接口
  4. 程序的入口类(包含 main 函数的类),一定会被初始化
  5. JDK 7 中如果一个 java.lang.invoke.MethodHandle 最后解析结果 REF_getstatic 等方法句柄,且这个方法句柄对应的类没有进行过初始化,则需要先触发初始化

除上述 5 种方式(主动引用)外其他对类的应用方式均不会触发类的初始化,这些引用被称为被动引用。下面有几个被动引用的示例

  1. 通过子类引用父类的静态字段,不会导致子类的初始化
  2. 通过数组定义引用类,不会触发此类的初始化:classA a = new classA[10],不会触发 classA的初始化
  3. 常量在编译阶段会存入调用类的常量池中,本质上并没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化

类加载器

虚拟机设计团队把类加载阶段中的“通过一个类的全限定名来获取描述此类的二进制字节流” 这个动作放到 Java 虚拟机外部去实现,以便让应用程序自己决定如何去获取所需要的类

类加载器在类层次划分、OSGi、热部署、代码加密等领域十分重要,是 Java 技术体系中一块重要的基石

如果两个类由不同的类加载器加载,那么即使类源自同一个 class 文件,这两个类也是不同的(包括 equals、isAssignableFrom、isInstance ),因为每一个类加载器都有自己的名字空间

双亲委派模型

开发中常用的 Java 类加载器有三类,这三类加载器分层,可以由底层加载器委派上层加载器去载入数据,后续再详细看

Tomcat & OSGi

若想详细学习类加载技术,可以看一看 tomcat 和 OSGi(Open Service Gateway Initiative) 的实现

运行时栈

一个栈帧中包含的内容如下:局部变量表(包括参数)、操作栈、动态连接、返回地址等数据,下面一一介绍

C/C++ 的栈是动态变化的,变量只有执行到指定位置才会在栈中为之分配内存,且内存大小和变量类型息息相关

局部变量表(Slots)

存储方法内部定义的局部变量和方法参数

JVM 规定局部变量的容量以变量槽(Variable Slot)作为最小单位,一个槽可以存放一个 32 位以内的数据类型。JVM 使用索引定位的方式使用局部变量表,如果是 64 位数据就同时使用相邻的两个 Slot,JVM 不允许访问 64 位数据其中一个 Slot

局部变量槽第 0 个Slot 保存 this 指针,其余变量从 1 开始依次占用槽位,先是函数的参数,再是方法内部的局部变量

JVM 并不会像初始化类变量那样初始化局部变量,所以需要手动初始化

为失效变量赋 null

JVM 栈帧中的变量有时是复用的,下面两段代码,前者触发了垃圾回收,后者未触发,就是因为 Slot 的复用

这条原则没必要体现在代码中,JIT 会自动优化这类问题,此处提出这个概念是为了加深对 Slot 的理解

// 即使调用强制系统进行 GC,p 对应的内存也没有被回收
// 因为在调用 gc 时 p 依旧保持在栈中,作为 GC Root 指向堆内存
public static void main(String[] args)
{
    {
		byte[] p = new byte[64*1024*1024];
        // 部分书籍推荐对不使用的引用赋 null 值,避免回收失效
        // 部分 JIT 会优化掉下面的语句,所以具体情况具体对待
        // p = null; 
    }
	System.gc();
}

// 很多实现复用了不用的 Slot,所以下面这段代码可以触发垃圾回收
public static void main(String[] args)
{
    {
		byte[] p = new byte[64*1024*1024];
    }
    int a = 0; // a 复用了 p 的 Slot,p 对应的内存失去了引用,可被回收
	System.gc();
}

操作数栈

Java 中的指令面向操作数栈而非寄存器,故 Java 指令要操作的数据都需要保存在操作数栈中,与之相对应的是 x86 等 CPU 架构,指令的操作数一般都保存在 CPU 寄存器中

使用操作数栈的架构便于移植,不同 CPU 有不同的寄存器结构,移植时需要重新编译代码;当然使用操作数栈结构会降低性能

操作数栈的最大深度在编译时已写入 Code 属性的 max_stacks

动态链接

和 C/C++ 这类语言生成的可执行文件概念类似,有些代码在编译时即已写入可执行文件中(如静态链接),有些代码需要在运行时动态决断(如系统库的调用)

运行时栈都包含一个指向运行时常量池中当前栈帧所属方法的引用,这个引用的存在是为了支持方法调用过程的动态链接

返回地址

Java 中从一个函数返回有两种情况:正常 return 和异常返回

附加信息

例如调试信息等

方法调用

分派

  • 静态分派(类似 C++ 中的静态绑定)

    所有依赖静态类型来定位方法执行版本的分派动作称为静态分派。静态分派的典型应用是方法重载

  • 动态分派(类似 C++ 的动态绑定)

    • C++ 的动态绑定常见的实现方式是虚函数表和虚函数指针,虚函数指针保存在对象中,C++ 想实现多态,只能用指针(或引用)。Java 和 C++ 实现类似,不过 Java 的“虚函数指针”,在栈帧中有明确的存储位置
    • Java 在解析虚函数时会使用 Slots 槽中第 0 个位置的 this 指针获取对象的类型信息,然后查找对应的方法地址
字节码指令
  • invokestatic
  • invokespecial,调用示例构造<init>方法、私有方法和父类方法
  • invokevirtual
  • invokeinterface
  • invokedynamic,前 4 条语句函数的解析由 JVM 控制,当前指令函数解析由用户决定
Java 的动态语言特性

动态语言的特点是它的类型检测主体过程在运行期而不是编译期。以 C++ 和 Java 为例,假设 obj 为一个对象实例,则在 C++ 和 Java 中使用 obj.method() 的前提是 obj 的静态类型中声明的有 method 这个方法,否则无法通过编译。考虑语言的多态性,非动态语言的特点是:可以修改对象方法的行为,但不能调用对象中不存在的方法

动态语言就不一样了,例如 JavaScript,你甚至可以在运行时为一个对象赋予全新的方法,简单来说,动态语言在运行时查询对象信息,如果有对应方法就调用,没有就报运行时错误

Java 可以使用反射和 invoke 包实现动态方法调用,示例如下

import java.lang.invoke.MethodHandles.lookup;
import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodType;

// 假设 ClassA 中实现了 println 方法
Object obj = System.currentTimeMillis()%2 == 0?System.out:new ClassA(); 
// 在 Java 中,下面用法是不合法的,编译器会提示找不到 println
// 编译器只能查询 obj 本身所包含的信息,不能查询 obj 所引用的对象的类型
// obj 指向的对象是包含 println 方法的,但 obj 的静态类型种没有 println 方法
// obj.println("Hello"); 

// JDK 7之后可以使用 invoke 类库实现方法的动态调用,方法如下
getPrintlnMH(obj).invokeExact("Hello");

// 使用下面的方法获得方法并和指定的对象绑定
MethodHandle getPrintlnMH(Object receiver)
{
    // 获得方法类型,第一个参数未方法的返回值类型,后面为方法的参数类型
    MethodType mt = MethodType.methodType(void.class, String.class);
    // 从 receiver 中查找方面名为 println,形参类型为 mt 的方法,并与 receiver 绑定
    // 执行下面语句前,JVM 是不知道 receiver 有 println 这个成员函数的
    return lookup().findVirtual(receiver.getClass(), "println", mt).bindTo(receiver);
}

和反射相比,反射比 invoke 中的方法更重量级。反射模拟的是代码层次的调用,MethodHandle 模拟字节码层次的调用;反射比 MethodHandle 包含了更多额外的信息,例如方法签名、属性等,后者仅仅包含方法调用信息

MethodHandle 面向所有语言,是 JVM 的特性;反射一般只用于 Java

invokedynamic

JVM 的 invokedynamic 指令和上面的 getPrintlnMH 函数所展示的功能类似,具体实现请参看其他资料

字节码执行引擎

JVM 在执行 Java 代码时有两种选择:解释执行编译执行,有时候这两种方法是同时存在的,例如包含JIT 的 JVM

使用 javac 命令编译下面代码,并使用 javap 查看 cal 函数的字节码:

class TestClass {
    public int cal()
    {
        int a = 100;
        int b = 200;
        int c = 300;
        return (a+b)*c;
    }
}

上面代码对应的字节码如下,编译器可能会对字节码进行优化,所以下面过程只用于说明字节码执行过程

public int cal();
    descriptor: ()I
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=4, args_size=1
         0: bipush        100 // 将 100 放入操作数栈
         2: istore_1          // 将栈顶数据写入局部变量第一个 Slot 中
         3: sipush        200 // 后面 4 条语句与前两条功能相同,即初始化变量 a,b,c 
         6: istore_2
         7: sipush        300
        10: istore_3
        11: iload_1       // 这两行把 Slot 中的值写进操作数栈
        12: iload_2
        13: iadd          // 弹出操作数栈栈顶的两个值求和并将结果入栈,此时栈顶为求和结果
        14: iload_3       // 将第三个 Slot 中的值,即 c 压入操作数栈
        15: imul          // 弹出操作数栈顶两个值求积并将结果入栈,此时栈顶为最终结果
        16: ireturn
      LineNumberTable:
        line 8: 0
        line 9: 3
        line 10: 7
        line 12: 11
}
字节码生成与动态代理

动态代理的简单示例, 动态代理可以实现适配器(Adapter)或修饰器(Decorator) 等模式

import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;

public class TestClass {
    interface IHello {
        void sayHello();
        void sayGoodbye();
    }

    static class Hello implements IHello {
        @Override
        public void sayHello() { System.out.println(" hello world"); }
        @Override
        public void sayGoodbye() { System.out.println(" bye bye!!!"); }
    }

    static class DynamicProxy implements InvocationHandler {
        Object originalObj;

        Object bind(Object originalObj) {
            this.originalObj = originalObj;
            // 三个参数,Class Loader;需要实现的接口数组;
            return Proxy.newProxyInstance(originalObj.getClass().getClassLoader(),\ 
                                          originalObj.getClass().getInterfaces(), \
                                          this);
        }

        // 所有原始成员函数都以下面的方式调用
        @Override
        public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
            System.out.println(" welcome");
            return method.invoke(originalObj, args);
        }
    }

    public static void main(String[] args) {
        IHello hello = (IHello) new DynamicProxy().bind(new Hello());
        hello.sayHello();
        hello.sayGoodbye();
        /* // 上面两行代码编译后的结果类似于下面代码
        public final void sayHello() throws { 
            try { // m3 是绑定在 hello 上的实际方法,即原始的 sayHello 或 sayGoodbye
        		this.h.invoke(this, m3, null); return; 
        	} 
        	catch...
        	}
        */
    }
}

/* 执行结果
welcome
hello world
welcome
bye bye!!!
*/
Retrotranslator

同时执行不同版本的 Java 代码,Retrotranslator 可以将 jdk5 的代码编译为 jdk 5以前的 class 文件

Java 编译与优化

Java 的编译器有三类

  1. 前端编译器,将 .java 文件编译为 .class 文件
    • Javac、ECJ
  2. 运行时编译器,JIT
    • HotSpot 的 C1、C2 编译器
  3. AOT(Ahead Of Time Compiler),直接将 Java 代码编译为本地可执行文件
    • GCJ(GNU Compiler for the Java)、Excelsior JET

下面简要介绍下 Java 的编译与优化

graph TB
	A[Java 编译与优化]
	B[javac JIT AOT]
	C[Java 特性]
	D[伪泛型<br/>陷阱]
	E[装箱陷阱]
	F[-128 127]
	G[运行时优化 C1 C2]
	I[栈顶采样<br/>执行计数]
	J[Java<br/>vs<br/>C++]
	K[条件编译]
	L[调用&回边]
	M[逃逸分析]
	N[分支频率<br/>剪枝]
	
	A --> B
	A --> C
	C --> D
	C --> E
	C --> F
	A --> G
	G --> I
	G --> J
	E --> F
	C --> K
	I --> L
	G --> M
	J --> N

编译与优化

词法 & 语法分析

几乎是所有编译器的第一步,经过这一步,编译器可以生成初始语法树,因为注解的存在,最终语法树的生成在注解处理之后

语法糖

泛型

Java 的泛型是伪泛型

C++ 中的模板是泛型的一种形式,编译时不同类型的泛型参数会促使模板生成新的代码,这种通过类型膨胀的形式实现的泛型是真实泛型

Java 的泛型只展现在代码中,编译成字节码后泛型信息就已经丢失了(底层使用 Object 引用实际对象,实际使用时会有强制类型转换的过程),下面两行代码在相同的 java 文件中时是无法通过编译的,因为 class 文件中不允许出现签名相同的函数(此时泛型信息已经丢失)

public void method(List<String>  list) { ... }
public void method(List<Integer> list) { ... }

在 C++ 中,上面的两个函数在编译时的签名是不同的,但在 Java 的 class 文件中,这两个函数的签名相同,故编译器报错

泛型陷阱

class 格式规定,只要描述符不完全一致的两个方法就可以共存

Java 中类型的描述符是包含返回值的,也就是说只要修改上面那两个方法中的返回值,这两个方法就可以共存,这是设计中的 缺陷,虽然对程序的正常运行没有影响,但违反了返回值不参与重载的规定

Java 新标准中提出了一些新的规范来减少这种情况对语言的影响,如 Signature 等

自动装箱 & 循环遍历

拆箱与装箱的陷阱

public static void main(String[] args) {
    Integer a = 1;
    Integer b = 2;
    Integer c = 3;
    Integer d = 3;
    Integer e = 321;
    Integer f = 321;
    Long g = 3L;
    System.out.println(c == d); // true,Java的==比的是引用地址,小整数有缓存故地址相同
    System.out.println(e == f); // false,默认情况下 Java 缓存 -128~127 之间的所有整数
    System.out.println(c == (a + b)); // true,有运算符,故先拆箱后比较
    System.out.println(c.equals(a + b)); // true,同类比较
    System.out.println(g == (a + b)); // true,包装类的 == 只会在遇到算术运算符时拆箱
    // Java API 中对 Long 中equals的定义为:
    // The result is true if and only if the argument is not null and is a Long object 
    // that contains the same long value as this object.
    System.out.println(g.equals(a + b)); // false,异类比较,equals不处理类型转换,故long!=int 
}
条件编译

Java 的条件编译很简单,只能使用 if 且 if 的条件必须为常量,编译后 class 文件中只会留下满足需求的语句块

注解处理

JDK 5 之后 Java 提供了对注解的支持,Java 支持编译时注解运行时注解,使用编译时注解我们可以干涉编译器的行为

运行时优化

部分商用虚拟机中 Java 程序最开始执行时使用解释器进行解释执行,如果发现一块代码执行频繁,JVM 会使用 JIT (即时编译)对其进行编译,转化为平台相关的机器码并进行各种优化以提高执行效率

graph LR
	A[解释器<br/>Interperter]
	B[编译器<br/>C1 & C2]
	
	A -->|即时编译 JIT|B
	B -->|<b>逆优化</b> 尝试优化失败时回退| A

HotSpot 有两个即时编译器,分别称为 Client Compiler 和 Server Compiler,或者简称 C1 和 C2 编译器,JVM 会根据运行模式选择不同的编译器,用户可以通过 -client 或者 -server 指定编译器。当然也可以强制禁止 JVM 使用运行时编译器,JVM 全程使用解释方式运行

栈上替换

OSR(On Stack Replacement),JIT 编译并优化某个方法的形象说法,优化的方法都位于栈,方法优化即替换已有的解释执行方法,即栈上替换

判断一个函数是不是热点代码片段有两种方式:基于采样的热点探测和基于计数器的热点探测。前者 JVM 周期性的检查栈顶函数类型,后者 JVM 会为每一个函数维护一个计算器,后者更精确严谨些

HotSpot 使用第二种方式并使用了两类计数器:

  1. 方法调用计数器
    • C1 下函数默认被调用 1500 则执行优化,C2 为 10000 次
    • 方法计数器在一个时间间隔内会被减半,称之为计数器热度衰减,所以在一段时间内未被优化的函数依旧不会被优化
  2. 回边计数器
    • 统计一个方法中循环体代码执行的次数

当前大部分 JVM 设计都把代码的优化重心放在了 JIT 上,除非特殊情况,不要关闭 JIT

常见编译优化技术

JIT 编译使用了大量编译技术,本文不做过多介绍,下面仅给出几个便于理解的方法

  1. 公共子表达式消除,如果一个表达式 E 已经计算过了,后面就不再重复计算
  2. 数组边界检查消除
  3. 方法内联
  4. 逃逸分析,如果能证明一个方法内的对象不会被对象之外的实体(其他对象或者线程)访问到,即对象没有逃逸当前方法,则可以对这个对象进行深度的优化,例如将对象分配到栈上,避免垃圾回收等机制造成的消耗
    • 这项技术暂时好像还不稳定,尽量不要在生产环境开启
    • C++ 不存在这个问题,因为 C++ 需要程序员自己管理内存空间

编译性能对比

C++ 为静态编译语言而 Java 为动态编译,二者编译器各有优劣。C++ 可以在编译时进行比较耗时的优化而Java却不行,因为这样会影响服务性能;Java 可以搜集大量运行时信息(调用频率、分支频率预测、裁剪未选中分支等等)来优化代码但 C++ 不行。其他对比可以查阅周志明《深入理解 Java 虚拟机》

Java 高效并发

为了便于移植,Java 多线程内存模型不与硬件关联,不同硬件平台可以使用不同的实现手段

和 CPU 内存与高速缓存做对比 Java 内存模型被分为两大部分:主内存(对应 PC 内存)和工作内存(对应 CPU 高速缓存)

主内存与工作内存之间数据的交互 Java 定义了以下 8 种原子操作(最新的 Java 标准已经采用了新的内存访问协议,但下面 8 种操作也应该了解一下)。从主存读写、工作内存读写、执行引擎三个实体和 lock/unlock 这几个角度很容易记忆下面 8 个操作

  1. lock,标识主内存变量为线程独占
    • 同一个变量可以被一条线程多次 lock,但也需要同样次数的 unlock 才能解锁
    • lock 一个变量时会清空工作内存中此变量的值,在使用这个变量前需要执行 load 和 assign 初始化变量
  2. unlock,释放主内存被锁变量
    • 一个变量实现没有被 lock,那也不允许执行 unlock
    • 对一个变量执行 unlock 之前,必须先使用 store 和 write 把变量同步到主内存中
  3. read,从主内存中读取变量到工作内存中,以便后续的 load 操作
  4. load,作用于工作内存,把 read 操作从主内存中得到的变量值放入工作内存的变量副本中
  5. use,把工作内存中的变量传递给执行引擎
    • 只有对一个变量前一个操作是 load 时,当前线程才可以使用 use;只有对一个变量后一个操作是 use 时才可以对变量执行 load 操作
  6. assign,把一个从执行引擎接收到的值赋予工作内存中的变量
    • 变量在工作内存中改变之后必须把变化同步回主内存
  7. store,把工作内存中的变量传递到主存中
    • 只有对变量执行 assign 操作后才能执行 store;之后后一个操作是 store 才可以执行 assign 操作
  8. write,作用于主内存,把 store 操作从工资内存中得到的变量值放入主内存中

结合上面 8 中操作如果把一个变量从主内存复制到工作内存,那就要顺序执行 read 和 load 操作,Java 内存模型只要求上述两个操作必须按顺序执行(且不能单独出现 read 或 load,同理 write 和 store),没有要求连续执行,即这两个操作之间可以插入其他指令

graph LR
	A[Java 并发]
	B[volatile 2特点]
	C[锁]
	D[悲观锁]
	E[乐观锁]
	F[CAS<br/>ABA 问题]
	H[轻量级锁<br/>偏向锁]
	I[自旋锁 自适应]
	
	A --> B
	A --> C
	C --> D
	C --> E
	E --> F
	C --> H
	D --> I

volatile

volatile 变量和 C/C++ 中的概念是一致的,有以下两个特点

  1. 对所有线程可见,使用 volatile 变量时所有内存会从主存中刷新这个变量,因为线程对 volatile 变量的使用不是互斥的所以 volatile 变量无法保证线程安全,若要保持 volatile 变量的原子性,需要使用同步手段
  2. 禁止指令重排优化

Java 内存模型要求 lock 等 8 个变量操作都具有原子性,但对 64 位数据类型却定义了比较宽松的规定:允许虚拟机将没有被 volatile 修饰的数据读写操作分为两次 32 位的操作来运行。这就导致了多线程非同步情况下读到半个变量的可能性 ,不过大部分商用虚拟机实现都将 64 位数据的操作也实现为原子操作

Java 与线程

JDK 1.2 前 Java 的线程使用协程实现,之后使用系统原生线程

线程安全与锁优化

互斥是方法,同步是目的。Java 中最基本的互斥同步手段是关键字 synchronized

悲观锁

常见的互斥锁是悲观锁,认为只要使用变量就要上锁,无论变量是否出现了竞争条件。随着 CPU 指令的发展我们可以使用基于冲突检测的并发策略,也就是乐观锁。通俗的讲,先进行操作,如果没有发现竞争就认为操作成功,否则就采取其他补偿措施,比如不断的重试,直到成功为止

自旋锁

线程的挂起与恢复是非常耗时的,如果上锁时间很短,使用自旋锁是非常好的优化手段

自适应自旋锁,自旋的次数按一定的策略动态变化

乐观锁

乐观锁与悲观锁最大的区别是前者会先尝试去修改变量,失败后进行补偿,乐观锁避免了线程的阻塞

CAS

乐观锁示例:CAS(Compare And Swap),下面这段代码是 Java 使用 CAS 实现的变量自增,可以用来说明 CAS 的使用方法

public final int incrementAndGet()
{
	for(;;)
	{
		int current = get();
		int next = current+1;
		if(compareAndSet(current, next)) // 非互斥,设置失败则不断尝试,CAS 一般对应 CPU 硬件指令
		{
			return next;
		}
	}
}
CAS 的 ABA 漏洞

两个线程分别使用 CAS 实现变量 val 的修改,假设 val 初始值为 val0,线程 A 将 val 修改为 val1 后又修改为 val0,如果在这个过程中线程 B 先读到的是 val0,在修改时 A 已经完成了 val 从 val1 到 val0 的修改过程,val 的状态其实已经发生了变化,但 B 却没有感知到,这个漏洞被称为 ABA。大部分情况下 ABA 对程序的正常运行没有影响

Java 轻量级锁

Java 中的轻量级锁是和系统提供的锁相对应的,本意是在没有多线程竞争的前提下减少传统重量级锁的使用,以减少互斥带来的损耗

轻量级锁所依据的前提是:对于绝大部分的锁,在整个同步周期内都是不存在竞争的,所以没必要一定要使用重量级锁

轻量级锁会先尝试使用 CAS 给对象打标记,如果成功就不用调用重量级锁并标记对象已被其他线程占用;而其他线程在使用对象时也会确认对象是否已被占用

在存在竞争的情况下,轻量级锁会比重量级锁耗时

以 HotSpot 实现为例,HotSpot 对象头中包含 “Mark Word” 结构,Mark Word 一般是 32bits 或者 64bits,其中有两个比特用于标识对象锁标志位

在进入同步代码块(synchronized)时如果对象没有被锁定(01),则 JVM 首先在当前线程栈帧中创建一个名为锁记录(Lock Record)的空间,用于保存锁对象当前的 Mark Word。随后 JVM 尝试使用 CAS 将对象的 Mark Word 更新为指向 Lock Record 的指针,随后当前线程就拥有了此对象的锁。在没有竞争的情况下使用轻量级锁会减少对重量级锁的使用以提高性能,但存在竞争时依旧需要使用重量级锁。因为锁标志位的检查,存在轻量级锁的 JVM 会比纯重量级锁系统更耗时(差别非常小)

Java 偏向锁

偏向锁是轻量级锁的进一步优化,如果 Mark Word 中锁标志为偏向(01),则当前线程在同步块中使用对象时不需要再进行任何的同步操作。偏向锁和轻量级锁一样,可以提高同步但无竞争程序的性能,但存在竞争时偏向锁存在的意义就不大,此时使用参数-XX:-UseBiasedLocking 来禁止偏向锁反而可以提高性能