Java 深入理解 JVM 虚拟机

Java 深入理解 JVM 虚拟机,初识 JVM、JVM 内存模型、JVM 类加载机制、Java 对象的访问方式、Java 内存分配机制、GC 机制&算法

JVM 和 Java

JVM
JVM,全称 Java Virtual Machine,即 Java 虚拟机。虽然自称为虚拟机,但是 JVM 和 VMware 还是有区别的:

  • JVM:只能运行 Java 字节码(Java bytecode)的虚拟机,不能像其它虚拟机一样,模拟真实的硬件环境;
  • VMware:可模拟真实的硬件环境(X86),在该环境中可以无障碍的安装任何(几乎)操作系统,功能强大。

可以这么说,VMware 提供的虚拟化环境对于运行在其上面的操作系统、应用软件都是透明的,我们可以将一个正常运行在实体机上的软件不经修改的运行在 VMware 环境中;但是 JVM 却不行,它只能运行字节码文件,如果要想在 JVM 上运行程序,必须遵循 JVM 的特有指令集规范,否则是无法正常运行的,也就是说,JVM 对于运行在其上的软件来说是不具备透明性的。

因此如果想使用 JVM 来运行你的程序,就必须生成指定的字节码文件(和可执行文件差不多的概念,只不过是相对 JVM 平台来说的)。

Java
Java 只是一个基于 JVM 的编程语言,和 JVM 是不同的两个概念,JVM 是一个平台,除了 Java 外,还有其它的基于 JVM 的编程语言,如 BBj、BeanShell、Ceylon、Clojure、Fantom、Kotlin、Groovy、MIDletPascal、Scala、Xtend。不过,因为 Java 是 JVM 的亲儿子,因此很多时候都对它有特别的照顾。

JRE、JDK
JRE,全称 Java Runtime Environment,即 Java 运行时环境。JRE 主要提供两个东西:一是java可执行文件,即 JVM 虚拟机,用来执行字节码的;二是rt.jar以及其它基础类库,单单有一个 JVM 还是不行的,必须要有一个类库,提供最基本的环境支持(比如 String、System、Runtime 类等等),方便 Java 以及其它基于 JVM 的语言调用。

JDK,全称 Java Development Kit,即 Java 开发工具包。JDK 一般自带一个对应的 JRE 环境,除此之外,它主要提供的就是 Java 语言开发工具,如javacJava 编译器,将源码编译为字节码、jdbJava 调试器,调试 Java 程序用的、javadocJava 文档生成器,比如 Oracle 的 Java 类库文档就是使用 javadoc 生成的。

因此,如果你是开发者,就必须安装 JDK;如果你只是想运行 Java 程序,则只需要安装 JRE。Oracle 官方都有提供相应的下载。

Java 是跨平台的,但 JVM 不是
我们都知道 Java 是跨平台的,可以实现”一次编译,到处运行”的目的。但是,跨平台的仅仅是 Java 程序(准确地说,应该是 Java 字节码文件),但是 JVM 虚拟机并不是跨平台的,每个平台都有其特定的 JVM 程序,比如 Windows 版的 JVM 就不可以在 Linux 环境下运行,反之也是。

其实很好理解,所谓的字节码文件,就和一个普通的文本文件一样,只不过字节码文件是不可阅读的,一个普通的文件当然是跨平台的了,而 JVM 虚拟机其实就是一个普通的可执行文件,在不同的系统之间当然是不兼容的,如果可以的话,那么 C/C++ 编译的程序也是跨平台的了(╮(╯_╰)╭)。

比如,我们要执行一个 Java 程序一般要经过两个步骤:

  1. javac HelloWorld.java,使用 javac 编译器将 Java 源码文件编译成 Java 字节码文件(HelloWorld.class);
  2. java HelloWorld,启动 JVM 虚拟机,然后 JVM 开始解释运行(JIT 即时编译)字节码文件,执行完毕后退出。

JVM 和一个普通的可执行文件没有任何区别,在操作系统的层次上讲,并不会对 JVM 有特殊照顾;JVM 有自己的进程空间,和一般的可执行文件一样,分为textdatabssheapstack,heap 和 stack 区域是运行时存在的。

因此,当我们在 shell 中执行java HelloWorld时,经过的步骤有:

  1. 当前 shell 进程执行 fork() 系统调用,创建一个新的子进程,该新进程拥有属于自己的 PID;
  2. 新进程执行 exec() 系统调用(传入 HelloWorld 参数),载入 java 可执行文件,开始执行它的 main() 函数。

JVM/Java 版本历史

  • 1995年5月23日,Java语言诞生
  • 1996年1月,第一个JDK-JDK1.0诞生
  • 1996年4月,10个最主要的操作系统供应商申明将在其产品中嵌入JAVA技术
  • 1996年9月,约8.3万个网页应用了JAVA技术来制作
  • 1997年2月18日,JDK1.1发布
  • 1997年4月2日,JavaOne会议召开,参与者逾一万人,创当时全球同类会议规模之纪录
  • 1997年9月,JavaDeveloperConnection社区成员超过十万
  • 1998年2月,JDK1.1被下载超过2,000,000次
  • 1998年12月8日,JAVA2企业平台J2EE发布
  • 1999年6月,SUN公司发布Java的三个版本:标准版(J2SE)、企业版(J2EE)和微型版(J2ME)
  • 2000年5月8日,JDK1.3发布
  • 2000年5月29日,JDK1.4发布
  • 2001年6月5日,NOKIA宣布,到2003年将出售1亿部支持Java的手机
  • 2001年9月24日,J2EE1.3发布
  • 2002年2月26日,J2SE1.4发布,自此Java的计算能力有了大幅提升
  • 2004年9月30日18:00PM,J2SE1.5发布,成为Java语言发展史上的又一里程碑。为了表示该版本的重要性,J2SE1.5更名为Java SE 5.0
  • 2005年6月,JavaOne大会召开,SUN公司公开Java SE 6。此时,Java的各种版本已经更名,以取消其中的数字“2”:J2EE更名为Java EE,J2SE更名为Java SE,J2ME更名为Java ME
  • 2006年12月,SUN公司发布JRE6.0
  • 2009年12月,SUN公司发布Java EE 6
  • 2010年11月,由于Oracle公司对于Java社区的不友善,因此Apache扬言将退出JCP[14]
  • 2011年7月28日,Oracle公司发布Java SE 7
  • 2014年3月18日,Oracle公司发表Java SE 8
  • 2017年9月21日,Oracle公司发表Java SE 9

JVM Server/Client 模式区别

注意第三行的输出,Server VM,表示当前 JVM 的默认模式是 Server 模式。
1) Client模式:启动速度快,运行速度慢;
2) Server模式:启动速度慢,运行速度快。

对于 32 位平台,可以通过配置$JAVA_HOME/jre/lib/i386/jvm.cfg文件切换默认模式;
对于 64 位平台,只能使用 Server 模式,Client 模式已经不存在了,不能切换为 Client 模式。

而现在大多数服务器都是 64 位的(不明白现在用 32 位还有什么意义),因此可以不用管什么 Client/Server 模式。

JVM 运行时数据区

JVM 运行时数据区
JVM 运行时数据区

JVM 运行时数据区被划分为 5 个部分:

  1. 虚拟机栈VM Stack:执行 Java 方法的内存区域。每次方法调用都会创建一个新的栈帧结构,栈帧包含局部变量表操作数栈动态链接返回地址等信息,该区域是线程私有的;
  2. 本地方法栈Native Method Stack:执行 Native 方法的内存区域。每次方法调用都会创建一个新的栈帧结构,具体的数据结构并没有强制规定,各厂商可以自由实现,HotSpot 将虚拟机栈和本地方法栈放在一起,不进行区分,该区域是线程私有的;
  3. 程序计数器Program Counter:记录当前线程正在执行的字节码地址的内存区域。当线程正在执行一个本地方法时,该计数器的值为空(Undefined,未定义的),此内存区域是唯一一个在 Java 虚拟机规范中没有规定任何OutOfMemoryError的区域,该区域是线程私有的;
  4. Heap:用于存储绝大多数的对象以及数组(数组也是对象)。是运行时数据区中最大的一块区域,也是 GC(垃圾回收器)的主战场,该区域是线程共享的;
  5. 方法区Method Area:存放方法的代码、已加载的类信息、静态变量、JIT 即时编译器编译后的代码等。同时 JVM 还会为每个已加载的类维护一个运行时常量池,常量池是可以动态扩展的,用于存储final静态常量字面量(整型字面量、浮点型字面量、字符串字面量等)、符号引用(即还未链接的偏移地址,有三类:类和接口的全限定名、字段的名称和修饰符、方法的名称和修饰符),方法区也是线程共享的。

java.lang.Class对象存储在堆区,不是在方法区!一个类的 Class 对象是由加载它的 ClassLoader 创建的!Class 对象提供了访问对应类的方法区数据结构的接口。

详细介绍
JVM 运行时内存区域

程序计数器
程序计数器是当前线程所执行的字节码地址指示器。每个线程都有自己计数器,是私有内存空间,该区域是整个内存中较小的一块。当线程正在执行一个 Java 方法时,PC 计数器记录的是正在执行的虚拟机字节码地址;当线程正在执行的一个 Native 方法时,PC 计数器则为空(Undefined)

此内存区域是唯一一个在 Java 虚拟机规范中没有规定任何OutOfMemoryError情况的区域。

虚拟机栈
虚拟机栈的生命周期与线程相同,是 Java 方法执行的内存模型。每个 Java 方法执行的同时都会创建一个栈帧结构,方法执行过程,对应着虚拟机栈的入栈到出栈的过程。

如果线程请求的空间过大(超过设置的最大值),则抛出StackOverflowError栈溢出错误;如果线程在申请一个新的栈帧时(未超过设置的最大值)没有足够的内存可分配了,则抛出OutOfMemoryError内存溢出错误。

栈帧(Stack Frame)的结构
每次方法调用都会产生一个新的栈帧结构并被压至栈顶。当方法正常返回或者调用过程中抛出未捕获的异常时,栈帧将出栈。每个栈帧包含:

  • 局部变量表:存储方法执行过程中所有的局部变量的数组,包括 this 引用、所有方法参数、其它局部变量。如果是静态方法,则没有 this 引用。局部变量的类型有:
    • boolean:布尔值
    • byte:字节
    • char:字符
    • short:短整型
    • int:整形
    • long:长整型
    • float:单精度浮点型
    • double:双精度浮点型
    • reference:引用类型
      除了longdouble外,其它类型都占有局部变量数组的一个 Slot 空间,long、double 则占有两个连续的 Slot 空间,因为它们是 64 位双精度的。
  • 操作数栈:所谓的操作数栈其实就是对原生CPU 寄存器的抽象,可以理解为方法的工作现场。
  • 动态链接:一个指向当前方法所属类的运行时常量池的指针(引用)。常量池中有大量的符号引用,这些符号引用会在类加载阶段或第一次使用时被转换为直接引用(替换为相对偏移地址),在类加载并校验通过后进行解析的方式称为饥饿方式,在第一次使用时进行解析的方式被称为惰性方式。将符号引用替换为直接引用的过程被称为绑定,在编译期间进行绑定被称为静态绑定,在运行期间进行绑定被称为动态绑定
  • 返回地址:方法每调用一次,就会产生一个新的栈帧结构,当该方法返回时,当前线程是怎么知道下一条要执行的字节码地址的呢?答案是通过方法的返回地址。调用一个函数之前,线程会先在当前栈帧的栈顶压入函数调用的下一条指令地址,然后才会压入新的栈帧,并开始执行被调用函数的第一条指令,当被调用函数返回时,它所在的栈帧被弹出,这时下一条指令地址被暴露在了当前栈帧的顶部,因此线程开始继续执行当前函数的下一条指令。

本地方法栈
本地方法栈是虚拟机为 Native 方法提供的内存空间,具体的栈帧结构并未强制规定,由虚拟机自行发挥,有些虚拟机的实现直接把本地方法栈和虚拟机栈合二为一,比如我们常用的 HotSpot 虚拟机。

如果线程请求的空间过大(超过设置的最大值),则抛出StackOverflowError栈溢出错误;如果线程在申请一个新的栈帧时(未超过设置的最大值)没有足够的内存可分配了,则抛出OutOfMemoryError内存溢出错误。

堆 Heap
堆是 JVM 管理的最大的一块内存,也是 GC 的主战场,里面存放的是几乎所有的对象实例和数组数据。为什么说是几乎所有的对象呢?因为 JIT 编译器有栈上分配、标量替换等优化技术的实现导致部分对象实例数据不存在 Java 堆,而是栈内存。此处不考虑这些优化手段,我们依旧认为对象是存储在堆上的。

  • 内存回收角度,Java 堆被分为新生代年老代;这样划分的好处是为了更快的回收内存,提高 GC 效率;
  • 内存分配角度,Java 堆可以划分出线程私有分配缓冲区(Thread Local Allocation Buffer,TLAB);这样划分的好处是为了更快的分配内存,避免锁的争用(CAS 重试)。

Java 对象的具体结构如图所示:
Java 堆中实例对象的存储结构图

填充数据不一定存在,仅仅是为了字节对齐:HotSpot VM 的自动内存管理要求对象起始地址必须是 8 字节的整数倍;对象头本身是 8 的倍数,当对象的实例数据不是 8 的倍数,便需要填充数据来保证 8 字节的对齐,该功能类似于高速缓存行的对齐。

另外,在堆上的内存分配是并发进行的,虚拟机采用 CAS 加失败重试保证原子操作,或者是采用每个线程预先分配 TLAB 内存。

如果对象占用的内存过大(如超大数组),则 JVM 会抛出OutOfMemoryError内存溢出错误,导致 Java 程序被意外终止。

方法区
方法区存放的是已加载的方法代码类信息静态变量、JIT 编译器编译后的临时代码等数据。GC 在该区域出现的比较少,因为在此区域执行 GC 的效率很低,因此也被称为永久代(HotSpot 专有概念,JDK1.8 中被Metaspace元空间给替代)。

如果加载的类过多而当前堆内存又不足,则 JVM 会抛出OutOfMemoryError内存溢出错误,导致 Java 程序被意外终止。

运行时常量池
运行时常量池是方法区的一部分,用于存放final静态常量字面量符号引用。同时还可以在运行期间,将新的常量加入常量池中,应用比较多的就是 String 类的 intern() 方法和各大整型包装类的 valueOf() 方法。

  • final静态常量:即被 final 修饰的静态成员变量;
  • 字面量:如字符串字面量、整型字面量、浮点型字面量;
  • 符号引用:符号引用在被使用之前都会被替换为直接引用,符号引用有 3 类:类和接口的全限定名字段的名称和描述符方法的名称和描述符

JVM 类加载机制

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

类的加载连接初始化过程都是在程序运行期间完成的,这种策略虽然会令类加载时稍微增加一些性能开销,但是会为 Java 应用程序提供高度的灵活性,Java 里天生可以动态扩展的语言特性就是依赖运行期动态加载和动态连接这个特点来实现的。

类的生命周期
加载验证准备解析初始化使用卸载七个阶段。其中验证准备解析 3 个部分统称为连接。
JVM 类的七个阶段

触发类加载的条件

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

类加载的具体过程
加载

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

验证
验证是连接阶段的第一步,目的是为了确保 class 文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。验证包含 4 个阶段的校验动作:

  1. 文件格式验证:验证字节流是否符合 class 文件格式的规范,并且能被当前版本的虚拟机处理;
  2. 元数据验证:对类的元数据信息进行语义校验,是否不存在不符合 Java 语言规范的元数据信息;
  3. 字节码验证:最复杂的一个阶段,主要目的是通过数据流和控制流分析,确定程序语义是合法的,符合逻辑的。对类的方法体进行校验分析,保证被校验类的方法在运行时不会做出危害虚拟机安全的事件;
  4. 符号引用验证:最后一个阶段的校验发生在虚拟机将符号引用转换为直接引用的时候,这个转换动作将在连接的第三个阶段——解析阶段中发生,符号验证的目的是确保解析动作能正常进行。

准备
准备阶段是正式类变量分配内存并设置类变量初始值的阶段。这些变量所使用的内存都将在方法区中分配。只包括类变量。初始值“通常情况”下是数据类型的零值。“特殊情况”下,如果类字段的字段属性表中存在 ConstantValue 属性(即 final 常量),那么在准备阶段变量的值就会被初始化为 ConstantValue 属性所指定的值。

解析
解析是虚拟机将常量池内的符号引用替换为直接引用的过程。符号引用就是 class 文件中的CONSTANT_Class_infoCONSTANT_Field_infoCONSTANT_Method_info等类型的常量。解析也被称为绑定,像这种在类加载期间进行解析的行为叫做动态绑定,而如果是在编译期间进行解析的则被称为静态绑定;并且,JVM 可以自由选择解析的时机,如果是在类加载并校验通过后进行解析则为饥饿方式,如果是在该符号引用第一次被使用时进行解析则为惰性方式

初始化
类加载过程中的最后一步。初始化阶段是执行类构造器<clinit>()(class init)方法的过程。<clinit>()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块中的语句合并产生的。<clinit>()与类的构造函数不同,它不需要显示地调用父类构造器,虚拟机会保证在子类的<clinit>()方法执行之前,父类的<clinit>()方法已经执行完毕。简单地说,初始化就是对类变量进行赋值及执行静态代码块

以下几种情况不会执行初始化步骤

  • 通过子类引用父类的静态字段,只会触发父类的初始化,而不会触发子类的初始化;
  • 定义对象数组,不会触发该类的初始化;
  • 常量在编译期间会存入类的常量池中,本质上并没有直接引用定义常量的类,不会触发定义常量所在的类;
  • 通过类名获取 Class 对象,不会触发类的初始化;
  • 通过Class.forName()加载指定类时,如果指定参数 initialize 为 false 时,也不会触发类初始化,其实这个参数是告诉虚拟机,是否要对类进行初始化;
  • 通过 ClassLoader 默认的 loadClass 方法,也不会触发初始化动作。

类加载器 ClassLoader
通过上述的了解,我们已经知道了类加载机制的大概流程及各个部分的功能。其中加载部分的功能是将类的 class 文件读入内存,并为之创建一个 java.lang.Class 对象。这部分功能就是由类加载器(ClassLoader)来实现的。

注意这里不一定非得要从一个 class 文件获取,这里既可以从 ZIP 包中读取(比如从 jar 包和 war 包中读取),也可以在运行时计算生成(动态代理),也可以由其它文件生成(比如将 JSP 文件转换成对应的 Class 类),要做到这些可能需要自定义一个类加载器。

类加载器分类
不同的类加载器负责加载不同的类。主要分为两类:

  • 启动类加载器(Bootstrap ClassLoader):由 C++ 语言实现(针对 HotSpot),负责将$JAVA_HOME/jre/lib/默认目录或-XbootclasspathJVM 运行参数指定的路径中的类库加载到内存中,即负责加载 Java 的核心类
  • 其它类加载器:由 Java 语言实现,继承自抽象类 java.lang.ClassLoader。如:
    • 扩展类加载器(Extension ClassLoader):负责加载$JAVA_HOME/jre/lib/ext/默认目录或java.ext.dirsJVM 系统属性指定的路径中的类库,即负责加载 Java 扩展类
    • 应用程序类加载器(Application ClassLoader):负责加载$CLASSPATH-classpath/-cp参数指定的路径中的类库(用户类)。如果我们没有自定义类加载器,那么默认就是用这个加载器(使用ClassLoader.getSystemClassLoader()可获取)。

以上两大类三小类的类加载器基本上负责了所有 Java 类的加载。下面我们来具体了解上述几个类加载器实现类加载过程时相互配合协作的流程。
JVM 类加载器

JVM 中有多个类加载器,分饰不同的角色。每个类加载器由它的父加载器加载。Bootstrap加载器除外,它是所有最顶层的类加载器。

双亲委派模型
双亲委派模型的工作流程是:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把请求委托给父加载器去完成,父加载器又将请求委托给它的父加载器,依此类推。因此所有的类加载请求最终都应该被传递到顶层的启动类加载器中,只有当父加载器无法完成该类的加载时,才会将请求传递给它的子加载器,让子加载器去尝试加载该类,如果子加载器也加载不了,则继续传递给它的子加载器,依此类推。如果到了最后也没加载成功,则抛出 ClassNotFoundException 异常。

这样的好处是不同层次的类加载器具有不同优先级,比如所有 Java 对象的超级父类 java.lang.Object,位于 rt.jar,无论哪个类加载器加载该类,最终都是由启动类加载器进行加载,保证安全。即使用户自己编写一个 java.lang.Object 类并放入程序中,虽能正常编译,但不会被加载运行,保证不会出现混乱。

比较两个类是否”相等”,只有在这两个类是由同一个类加载器加载的前提下才有意义,否则即使这两个类来源于同一个 .class 文件,被同一个虚拟机加载,只要加载它们的类加载器不同,这两个类必定不相等。
上面说的”相等”,包括类对应的 Class 对象的 equals() 方法、isAssignableFrom() 方法、isInstance() 方法的返回结果,也包括使用 instanceof 关键字做对象所属关系判定等情况。

ClassLoader 类简介

Bootstrap 加载器

  • Bootstrap 加载器一般由本地代码实现,因为它在 JVM 加载以后的早期阶段就被初始化了;
  • Bootstrap 加载器只负责载入基础的 Java API,默认搜索路径(sun.boot.class.path)为:
    • $JAVA_HOME/jre/lib/rt.jar
    • $JAVA_HOME/jre/lib/resources.jar
    • $JAVA_HOME/jre/lib/charsets.jar
    • $JAVA_HOME/jre/lib/sunrsasign.jar
    • $JAVA_HOME/jre/lib/jce.jar
    • $JAVA_HOME/jre/lib/jfr.jar
    • $JAVA_HOME/jre/lib/jsse.jar
    • $JAVA_HOME/jre/classes
  • Bootstrap 只加载拥有较高信任级别的启动路径下找到的类,因此跳过了很多普通类需要做的校验工作。

Extension 加载器
Extension 加载器只负责载入扩展的 Java API,默认搜索路径(java.ext.dirs)为:$JAVA_HOME/jre/lib/ext/usr/java/packages/lib/ext

System 加载器
System 加载器是应用的默认类加载器,负责载入除基础、扩展 Java API 外的其它类,也就是$CLASSPATH-classpath/-cp命令行参数指定的路径下的类。

自定义加载器
用户自定义类加载器也可以用来加载应用类;通过继承 java.lang.ClassLoader 实现自定义的类加载器(只需重写findClass()方法);使用自定义的类加载器有很多特殊的原因,如运行时重新加载类、把加载的类分隔为不同的组、从加密的 class 文件中加载类、从网络中加载类等。

其中,Bootstrap 类加载器为null,Extension 类加载器为sun.misc.Launcher$ExtClassLoader、System 类加载器为sun.misc.Launcher$AppClassLoader,如下所示:

如何自定义类加载器

  1. 继承java.lang.ClassLoader抽象类;
  2. 重写protected Class<?> findClass(String name) throws ClassNotFoundException方法;
  3. 使用Class<?> defineClass(String name, byte[] b, int off, int len)方法转换字节流为 Class 对象。

例子:使用简单的加密算法(异或)将常规 .class 文件加密为 .cipher 文件,然后使用自定义的类加载器加载它。

1、简单的加密工具,将 .class 加密为 .cipher 文件

2、测试用的类

3、自定义的类加载器

4、测试 & 测试结果

JVM 对象访问方式

在 Java 虚拟机规范中,对于通过 reference 引用类型访问具体对象的方式并未做规定,目前主流的实现方式主要有两种:
1、通过句柄访问:
通过句柄访问 Object
如果使用句柄访问方式,Java 堆中将会划分出一块内存来作为句柄池(两个元素的指针数组),reference 变量中存储的就是对象的句柄地址,而句柄中包含了对象实例数据对象类型数据的地址。使用句柄的优点是 reference 存储的是句柄地址,当对象被移动时,只需改变对象实例数据指针,而 reference 本身不用变动。

2、通过直接指针访问:
通过直接指针访问 Object
如果使用直接指针访问方式,reference 变量中存储的就是对象实例数据的地址,然后再通过实例数据中的对象类型数据指针访问对象类型数据。这种方法的优点是访问对象实例数据比较快,因为只有一次指针定位操作。

默认的 HotSpot 虚拟机就是使用的直接指针访问方式!

JVM 内存分代机制

这里所说的内存分配,主要指的是在堆上的分配,一般的,对象的内存分配都是在堆上进行,但现代技术也支持将对象拆成标量类型(标量类型即原子类型,表示单个值,可以是基本类型或 String 等),然后在栈上分配,在栈上分配的很少见,此处不进行讨论。

Java 虚拟机内存分配内存回收机制概括的说就是:分代分配,分代回收

对象将根据存活的时间被分为:年轻代(Young Generation)年老代(Old Generation)永久代(Permanent Generation,即方法区)

永久代是 HotSpot 特有概念,它采用永久代的方式来实现方法区,其它虚拟机实现没有这一概念;而且 HotSpot 也有取消永久代的趋势,在 JDK1.7 中 HotSpot 已经开始了“去永久化”,但是永久代依然存在,在 JDK1.8 中已经彻底取消了永久代,取而代之的是元空间 Metaspace

JVM 堆结构图 - 分代
JVM 堆结构图 - 分代

年轻代 Young Generation
年轻代(Young Generation):对象被创建时,内存的分配首先发生在年轻代(大对象可以直接被创建在年老代)。大部分的对象在创建后很快就不再使用,因此很快变得不可达,于是被年轻代的 GC 机制清理掉(IBM 的研究表明,98% 的对象都是很快消亡的),这个 GC 机制被称为Minor GC或叫Young GC

年轻代一般分为 3 个区域:
1) Eden 区:伊甸园,亚当和夏娃偷吃禁果生娃娃的地方,表示内存首次分配的区域,默认大小为 80%;
2) Survivor 0:From,存活区 1,默认大小为 10%;
3) Survivor 1:To,存活区 2,默认大小为 10%。

  • 绝大多数刚创建的对象会被分配在 Eden 区,其中的大多数对象很快就会消亡;Eden 区是连续的内存空间,因此在其上分配内存极快;
  • 最初一次,当 Eden 区满的时候,执行 Minor GC,将消亡的对象清理掉,并将剩余的对象复制到一个存活区 Survivor0(此时,Survivor1 是空白的,两个 Survivor 总有一个是空白的);
  • 下次 Eden 区满了,再执行一次 Minor GC,将消亡的对象清理掉,将存活的对象复制到 Survivor1 中,然后清空 Eden 区;接着将 Survivor0 中消亡的对象清理掉,将其中可以晋级的对象晋级到 Old 区,将存活的对象复制到 Survivor1 区,然后清空 Survivor0 区;
  • 当一个对象在两个存活区之间切换了几次(HotSpot 虚拟机默认15次,用-XX:MaxTenuringThreshold控制,大于该值进入老年代,但这只是个最大值,并不代表一定是这个值)之后,仍然存活的对象,将被复制到老年代。

从上面的过程可以看出,Eden 区是连续的空间,且 Survivor 总有一个为空。经过一次 GC 和复制,一个 Survivor 中保存着当前还活着的对象,而 Eden 区和另一个 Survivor 区的内容都不再需要了,可以直接清空,到下一次 GC 时,两个 Survivor 的角色再互换。
因此,这种方式分配内存和清理内存的效率都极高,这种垃圾回收的方式就是著名的停止-复制(stop-and-copy)清理法;这不代表着停止-复制清理法很高效,其实,它也只在这种情况下高效,如果在老年代采用停止复制,则挺悲剧的。

在 Eden 区,HotSpot 虚拟机使用了两种技术来加快内存分配:

  • bump-the-pointer:JVM 内部维护一个指针(allocatedTail),它始终指向先前已分配对象的尾部,当新的对象分配请求到来时,只需检查代中剩余空间(从 allocatedTail 到代尾 geneTail)是否足以容纳该对象,并在“是”的情况下更新 allocatedTail 指针并初始化对象。从而大大加快内存分配速度。
  • TLAB(Thread-Local Allocation Buffers):对于多线程应用,分配操作必须是线程安全的。如果使用全局锁为此提供保证,则分配操作必定成为一个性能瓶颈。因此 HotSpot 采用了一种被称为线程局部分配缓冲区(Thread-Local Allocation Buffers,TLAB)的技术。该项技术为每个线程提供一个独立的分配缓冲区(伊甸区的一小部分),借此来提高分配操作的吞吐量。因为针对每个 TLAB,只有一个线程从中分配对象,故而分配操作可以使用bump-the-pointer技术快速完成,而不必使用任何锁机制;只有当线程将其已有 TLAB 填满并且需要获取一个新的 TLAB 时,同步才是必须的。同时,为了减少 TLAB 所带来的空间消耗,还使用了一些其它技术,例如,分配器能够把 TLAB 的平均大小限制在伊甸区的1% 以下。

bump-the-pointerTLAB技术的组合保证了分配操作的高效性,类似new Object()这样的操作在大部分时间内只需要大约 10 条机器指令即可完成。

年老代 Old/Tenured Generation
一个对象如果在年轻代存活了足够长的时间而没有被清理掉(即在几次 Young GC 后存活了下来),则会被复制到年老代。如果对象比较大(比如长字符串或大数组)而 Young 空间不足,则大对象会直接分配到年老代上(大对象可能触发提前 GC,应少用,更应避免使用短命的大对象),使用-XX:PretenureSizeThreshold可控制直接升入年老代的对象大小,大于这个值的对象会直接分配在年老代上。

年老代的空间一般比年轻代大,能存放更多的对象,在年老代上发生的 GC 次数也比年轻代少;当年老代内存不足时,将执行Major GC,也叫Full GC(个人觉得这种说法不太准确,我的个人理解是:Minor GC针对年轻代,Major GC针对年老代,Minor GCMajor GC统称为Partial GC,而Full GC则针对整个堆,即年轻代、年老代、永久代(如果有的话))。

可以使用-XX:+UseAdaptiveSizePolicy开关来控制是否采用动态控制策略,如果启用动态控制,则动态调整 Java 堆中各个区域的大小以及进入老年代的年龄。

可能存在年老代对象引用新生代对象的情况,如果需要执行 Young GC,则可能需要查询整个老年代以确定是否可以清理回收,这显然是低效的;解决的方法是,年老代中维护一个 512 byte 的块card table,所有老年代对象引用新生代对象的记录都记录在这里;Young GC 时,只要查这里即可,不用再去查全部老年代,因此性能大大提高。

永久代 Permanent Generation
永久代的回收有两种:常量池中的常量无用的类;常量的回收很简单,没有引用了就可以被回收;对于无用的类进行回收,必须保证 3 点:

  1. 该类的所有实例都已经被回收;
  2. 加载该类的 ClassLoader 已经被回收;
  3. 该类对应的 Class 对象没有被引用(即没有通过反射引用该类的地方)。

因此,在永久代进行 GC 的效率较低,永久代的回收也不是必须的,可以通过参数来设置是否对类进行回收;HotSpot 提供-Xnoclassgc进行控制。

JDK1.8 元空间 Metaspace
为什么移除永久代?

  • 永久代的大小是固定的,并且很难进行调优。-XX:MaxPermSize究竟设置成多少好呢?
  • 简化 Full GC,因为 HotSpot 每个垃圾收集器都有专门的代码来处理永久代中的元数据。
  • 不想再出现恼人的 java.lang.OutOfMemoryError: PermGen,希望可以更灵活的管理元数据。

因此,JDK1.8 使用元空间 Metaspace代替了之前的永久代,而-XX:PermSize-XX:MaxPermSize选项则被忽略并给出警告。

PermGen 和 Metaspace 的区别
PermGen 是 Java Heap 的一部分,而 Metaspace 不是。元空间是在 Native Memory(本地内存)分配的,如果不显式限制元空间最大大小(-XX:MaxMetaspaceSize),则只受到操作系统可用内存的限制。如下图所示:
永久代与元空间的区别

Metaspace 垃圾回收
对于僵死的类及类加载器的垃圾回收将在元数据使用达到MaxMetaspaceSize参数的设定值时进行。适时地监控和调整元空间对于减小垃圾回收频率和减少延时是很有必要的。持续的元空间垃圾回收说明,可能存在类、类加载器导致的内存泄漏或是大小设置不合适。

Metaspace 相关参数
-XX:MetaspaceSize:设置元空间初始大小,默认值为 16M
-XX:MaxMetaspaceSize:设置元空间最大大小,默认不限制
-XX:MinMetaspaceFreeRatio:设置空闲元空间容量的最小占比
-XX:MaxMetaspaceFreeRatio:设置空闲元空间容量的最大占比
-XX:MinMetaspaceExpansion:设置元空间每次扩展的最小大小
-XX:MaxMetaspaceExpansion:设置元空间每次扩展的最大大小(不进行 Full GC)

JVM 垃圾回收机制

年轻代
前面我们说了,在年轻代中,使用停止-复制算法进行垃圾清理,JVM 将年轻代内存分为 2 部分,Eden 区较大,Survivor 比较小,并被划分为两个等量的部分(Survivor From、Survivor To)。

每次进行清理时,将 Eden 区和一个 Survivor 中仍然存活的对象拷贝到另一个 Survivor 中(如果达到成年的年龄,则被复制到年老代中),然后清理掉 Eden 和刚才的 Survivor;由于绝大部分的对象都是短命的,甚至存活不到 Survivor 中,所以,Eden 区与 Survivor 的比例较大;HotSpot 默认是 8:1,即分别占年轻代的 80%,10%,10%。

如果某次回收中,Survivor + Eden 中存活下来的内存超过了 10%(即一个 Survivor 占有的空间),则需要将一部分对象分配到老年代;用-XX:SurvivorRatio参数来配置 Eden 区域、Survivor 区域的容量比值,默认是 8,代表 Eden:Survivor1:Survivor2 = 8:1:1。

年老代
年老代存储的对象比年轻代多得多,而且不乏大对象,对年老代进行内存清理时,如果使用停止-复制算法,则相当低效;一般,年老代用的算法是标记-整理算法,即:标记出仍然存活的对象(存在引用的),将所有存活的对象向一端移动,以保证内存的连续

在发生Minor GC时,虚拟机会检查晋升进入老年代的大小是否大于年老代的剩余空间大小,如果大于,则直接触发一次Full GC

永久代
永久代的回收前面也说了,主要是回收无用的常量和无用的类。不过因为在永久代执行 GC 的效率不高,因此 GC 在永久代中的活动较少。不过,当 JVM 加载的类过多而导致永久代空间不足时,会触发 Full GC,如果 Full GC 后内存空间仍然不足,则抛出 OutOfMemoryError 错误。

Full GC 触发条件

  • 调用 System.gc() 时,可能会执行 Full GC;
  • OldGen 年老代的空间不足时;
  • PermGen 永久代的空间不足时;
  • 年老代的剩余空间不足以容纳经过 Minor GC 晋升的对象时;
  • 从 Eden、Survivor 区拷贝存活对象至另一 Survivor,该 Survivor 空间不足,则把一些对象转至年老代,但是年老代的空间也不足时。

总而言之,只要年老代永久代的空间不足,就会触发 Full GC。

判断对象是否存活
GC(Garbage Collection,垃圾回收)是通过对象是否存活来决定是否进行回收的。判断对象是否存活主要有两种算法:引用计数算法可达性分析算法

1) 引用计数算法
引用计数的算法原理是给对象添加一个引用计数器,每被引用一次计数器加 1,引用失效时减 1,当计数器 0 后表示对象不在被引用,可以被回收了。引用计数法简单高效,但是存在对象之间循环引用问题,可能导致无法被 GC 回收,需要花很大精力去解决循环引用问题。

Java 没有采用引用计数算法,最主要的原因就是很难解决对象之间循环引用的问题。

2) 可达性分析算法
可达性分析的算法原理是从对象根引用(GC Roots)开始遍历搜索所有可到达对象,形成一个引用链,遍历的同时标记出可达对象和不可达对象,不可达对象表示没有任何引用存在,可以被 GC 回收。如下图所示:
可达性分析算法
当一个对象到 GC Roots 没有任何引用链相连时,则证明这个对象为可回收的对象

GC Roots 是可以从堆外部访问的对象(可以有多个),如果一个对象可以被以下途径访问,则可以作为 GC Roots:

  • 虚拟机栈的本地变量表中所引用的对象可以作为 GC Roots;
  • 本地方法栈的局部变量所引用的对象也可以作为 GC Roots;
  • 方法区中已加载类的静态字段引用的对象可以作为 GC Roots。

四种引用类型
1) 强引用(StrongReference)
Object obj = new Object(),这里的 obj 便是一个强引用,强引用不会被 GC 回收;即使抛出 OutOfMemoryError 错误,使程序异常终止;

2) 软引用(SoftReference)
如果内存空间足够,垃圾回收器就不会回收它,如果内存空间不足了,就会回收这些对象的内存;软引用可用来实现内存敏感的高速缓存;

3) 弱引用(WeakReference)
垃圾回收器一旦发现了弱引用的对象,不管当前内存空间足够与否,都会回收它的内存;不过由于垃圾回收器是一个优先级很低的线程,因此不一定能很快发现那些弱引用的对象;

4) 虚引用(PhantomReference)
虚引用必须和引用队列(ReferenceQueue)联合使用;

总结:
1) 强引用:不会被 GC 回收,用于对象的一般状态;
2) 软引用:在内存不足时被 GC 回收,用于对象缓存;
3) 弱引用:只要被 GC 发现就会回收,用于对象缓存。

内存回收算法
内存回收算法主要有停止-复制标记-整理标记-清除;不同算法使用不同的场景,总体来说停止-复制算法适合对象存活时间短,存活率低的年轻代标记-清除标记-整理算法适合对象存活时间长,存活率高的年老代

  • 停止-复制(Stop-Copy)
    停止复制算法对于存活率较低的对象回收有着非常高的效率,而且不会形成内存碎片,但是会浪费一定的内存空间,适合对象存活率较低的年轻代使用,如果在对象存活率较高的年老代采用这种算法,那将会是一场灾难。
  • 标记-整理(Mark-Compact)
    通过可达性分析算法标记所有不可达对象,然后将存活对象都向一个方向移动,然后清理掉边界外的内存;这种算法是将存活对象向着一个方向聚集,然后将剩余区域清空,这种算法适合对象存活率较高的年老代,该算法不会产生内存碎片。
  • 标记-清除(Mark-Sweep)
    通过可达性分析算法标记所有不可达对象,然后清理不可达对象;这种算法会形成大量的内存碎片。

一般我们所说的 GC 都是发生在年轻代年老代
年轻代的对象存活时间短,存活率低,一般采用停止-复制算法;
年老代的对象存活时间长,存活率高,一般采用标记-整理标记-清除算法,具体采用何种算法和具体采用的垃圾收集器有关。

GC 收集器
在 GC 机制中,起重要作用的是垃圾收集器,垃圾收集器是 GC 的具体实现,Java 虚拟机规范中对于垃圾收集器没有任何规定,所以不同厂商实现的垃圾收集器各不相同。

GC 收集器分为年轻代收集器年老代收集器,不同的收集器使用不同的收集算法,有着不同的特点。

目前的收集器在内存回收时均无法消除stop-the-world(即在回收内存时不可避免的停止用户线程);新出现的收集器只能使停顿时间越来越短,但是无法彻底消除。

年轻代收集器:SerialParNewParallel Scavenge
年老代收集器:Serial OldParallel OldCMS(Concurrent Mark-Sweep);
整堆收集器:G1(Garbage-First Garbage Collector),计划替代 CMS,Java9 默认收集器。

7 种 GC 收集器
(JDK1.7)上图展示了 7 种作用于不同分代的收集器,如果两个收集器之间存在连线,就说明它们可以搭配使用。HotSpot 实现了如此多的收集器,正是因为目前并无完美的收集器出现,只是选择对具体应用最适合的收集器。

  • Serial + Serial Old:年轻代-单线程收集器、年老代-单线程收集器(Client 模式默认值);
  • Serial + CMS:年轻代-单线程收集器、年老代-并发收集器;
  • ParNew + Serial Old:年轻代-多线程收集器、年老代-单线程收集器;
  • ParNew + CMS:年轻代-多线程收集器、年老代-并发收集器;
  • Parallel + Serial Old:年轻代-并行收集器、年老代-单线程收集器;
  • Parallel + Parallel Old:年轻代-并行收集器、年老代-并行收集器(Server 模式默认值);
  • G1:整堆收集器(年轻代、年老代、永久代),JDK1.7 开始提供,JDK1.9 成为默认垃圾收集器。

比较推荐的组合:Parallel + Parallel OldParNew + CMSG1(JDK1.7 起)

相关概念
stop-the-world
不管选择什么收集器,stop-the-world都是不可避免的。也就是说,执行 GC 时(不一定是整个 GC 过程),用户线程会被暂停,直到 GC 任务结束。GC 调优通常就是为了减少stop-the-world的时间

串行、并行、并发
串行(Serial):只有一个线程在执行任务;
并行(Parallel):多个线程同时执行任务;
并发(Concurrent):多个线程交替执行任务。

串行垃圾收集、并行垃圾收集、并发垃圾收集
串行(Serial)收集:只有一个 GC 线程在工作,此时用户线程处于等待状态;如SerialSerial Old
并行(Parallel)收集:有多个 GC 线程在工作(不一定并行,可能是交替执行的),此时用户线程处于等待状态;如ParNewParallel ScavengeParallel Old
并发(Concurrent)收集:用户线程与 GC 线程一起工作(不一定同时,可能是交替执行的);如CMSG1(也有并行)。

吞吐量(Throughput)
吞吐量即 CPU 用于运行用户代码的时间与 CPU 总共消耗的时间的比值,即吞吐量 = 运行用户代码时间 / (运行用户代码时间 + 垃圾收集时间)。假设虚拟机总共运行了 100 分钟,其中垃圾收集花掉 1 分钟,那吞吐量就是 99%。

垃圾收集器的关注点
1、停顿时间
停顿时间越短就适合需要与用户交互的程序;良好的响应速度能提升用户体验。
2、吞吐量
高吞吐量则可以高效率地利用 CPU 时间,尽快完成运算的任务;适合后台计算而不需要太多交互的任务。

年轻代收集器
1) Serial
采用停止-复制算法,因为只有一个 GC 线程,因此被命名为 Serial 串行收集器。
-XX:+UseSerialGC:启用 Serial 收集器

2) ParNew
采用停止-复制算法,Serial 收集器的多线程版。关注用户线程停顿时间
-XX:+UseParNewGC:启用 ParNew 收集器
-XX:ParallelGCThreads:设置 GC 线程数量,默认与可用 CPU 核数相同
-XX:+UseConcMarkSweepGC:启用 CMS 收集器,默认方案 ParNew + CMS,备用方案 ParNew + Serial Old

3) Parallel Scavenge
采用停止-复制算法,关注 CPU 吞吐量,即运行用户代码的时间与总时间的比值。其它收集器的关注点一般是尽量缩短 GC 时用户线程的停顿时间;而 Parallel 的目标则是达到一个可控的吞吐量。适合对暂停时间无高要求、与用户无过多交互的应用,也即计算密集型任务。
-XX:+UseParallelGC:启用 Parallel Scavenge 收集器
-XX:MaxGCPauseMillis:设置用户线程最大停顿时间(毫秒),设置过小可能导致 GC 发生更频繁
-XX:GCTimeRatio:设置吞吐量大小,即用户线程执行时间与总时间的占比,默认为 99,即只有 %1 的时间用于 GC
-XX:+UseAdaptiveSizePolicy:自适应调节策略,如自动调整新生代大小、Eden 和 Survivor 比例、晋升年老代的年龄。这是一种值得推荐的方式:
1、只需设置好 Heap 堆内存大小(如-Xmx堆空间最大大小);
2、然后使用-XX:MaxGCPauseMillis-XX:GCTimeRatio给 JVM 设置一个优化目标;
3、最后启用-XX:+UseAdaptiveSizePolicy自适应调节策略,让 JVM 自动调整相关参数。

年老代收集器
1) Serial Old
采用标记-整理算法,只有一个 GC 线程,是 Serial 收集器的年老代版本。

2) Parallel Old
采用标记-整理算法,有多个 GC 线程,是 Parallel Scavenge 的年老代版本。Parallel Old 出现后(JDK 1.6),与 Parallel Scavenge 配合有很好的效果,充分体现 Parallel Scavenge 收集器吞吐量优先的效果。
-XX:+UseParallelOldGC:启用 Parallel Old 收集器

3) CMS(Concurrent Mark Sweep)
采用标记-清除算法(存在内存碎片),致力于获取最短停顿时间,优点是并发收集(用户线程可以和 GC 线程(基本上)同时工作),缺点是需要更多的内存,是 JDK1.5 推出的第一款真正意义上的并发(Concurrent)收集器。适用于与用户交互较多的应用,比如常见的 WEB、B/S 应用。
-XX:+UseConcMarkSweepGC:启用 CMS 收集器,默认方案 ParNew + CMS,备用方案 ParNew + Serial Old(内存不足时)

CMS 收集器运作过程比前面几种收集器更复杂,可以分为 4 个步骤:
CMS 收集器的四个过程

  • 初始标记(CMS initial mark):仅标记一下与 GC Roots 直接关联的对象;速度很快;但需要”Stop The World”。
  • 并发标记(CMS concurrent mark):进行 GC Roots Tracing 的过程(遍历 GC Roots);标记出存活对象,用户线程不需要暂停,但不能保证可以标记出所有存活对象。
  • 重新标记(CMS remark):为了修正并发标记期间因用户程序继续运作而导致标记变动的那一部分对象的标记记录;需要”Stop The World”,且停顿时间比初始标记稍长,但远比并发标记短;采用多线程并行执行来提升效率。
  • 并发清除(CMS concurrent sweep):回收所有的可回收对象(单个 GC 线程)。

整个过程中耗时最长并发标记并发清除都可以与用户线程一起工作;所以总体上说,CMS 收集器的内存回收过程与用户线程一起并发执行。

CMS 收集器 3 个明显缺点
1、对 CPU 资源非常敏感
并发收集虽然不会暂停用户线程,但因为占用一部分 CPU 资源,还是会导致应用程序变慢,总吞吐量降低。

CMS 的默认收集线程数量是:(CPU 数量 + 3) / 4。当 CPU 数量多于 4 个,收集线程占用的 CPU 资源多于 25%,对用户程序影响可能较大;不足 4 个时,影响更大,可能无法接受。针对这种情况,曾出现了增量式并发收集器(Incremental Concurrent Mark Sweep/i-CMS);类似使用抢占式来模拟多任务机制的思想,让收集线程和用户线程交替运行,减少收集线程运行时间;但效果并不理想,JDK1.6 后官方就不再提倡用户使用。

2、无法处理浮动垃圾,可能出现Concurrent Mode Failure失败
1)浮动垃圾(Floating Garbage)
在并发清除时,用户线程新产生的垃圾,称为浮动垃圾;这使得并发清除时需要预留一定的内存空间,不能像其它收集器在老年代几乎填满再进行收集;也可以认为 CMS 所需要的空间比其它垃圾收集器大;使用-XX:CMSInitiatingOccupancyFraction设置当年老代内存使用率达到此值时,触发 CMS 收集器(可以理解为 CMS 预留内存)。JDK1.5 默认值为 68%;JDK1.6 变为大约 92%。
2)Concurrent Mode Failure 失败
如果 CMS 预留内存空间无法满足程序需要,就会出现一次Concurrent Mode Failure失败;这时 JVM 启用后备预案:临时启用 Serail Old 收集器,而导致另一次 Full GC 的产生;这样的代价是很大的,所以 CMSInitiatingOccupancyFraction 不能设置得太大。

3、产生大量内存碎片
由于 CMS 基于”标记-清除”算法,清除后不进行压缩操作; 产生大量不连续的内存碎片会导致分配大内存对象时,无法找到足够的连续内存,从而需要提前触发另一次 Full GC 动作。解决方法:

  • -XX:+UseCMSCompactAtFullCollection
    使得 CMS 出现上面这种情况时不进行 Full GC,而开启内存碎片的合并整理过程;但合并整理过程无法并发,停顿时间会变长;此选项默认开启(但不会进行,结合下面的 CMSFullGCsBeforeCompaction);
  • -XX:CMSFullGCsBeforeCompaction
    设置执行多少次不压缩的 Full GC 后,来一次压缩整理;为减少合并整理过程的停顿时间;默认为 0,也就是说每次都执行 Full GC,不会进行压缩整理;由于空间不再连续,CMS 需要使用可用”空闲列表”内存分配方式,这比简单实用”碰撞指针”分配内存消耗大。

以上两个 JVM 启动选项已被标记为 Deprecated,不建议使用

总体来看,与 Parallel Old 垃圾收集器相比,CMS 减少了执行老年代垃圾收集时应用暂停的时间;但却增加了新生代垃圾收集时应用暂停的时间、降低了吞吐量而且需要占用更大的堆空间

G1收集器
G1(Garbage-First)是 JDK1.7-u4 才推出商用的收集器,在 JDK1.9 中,被提议作为默认的 GC 收集器。

特点

  • 并行与并发
    能充分利用多 CPU、多核环境下的硬件优势;可以并行来缩短”Stop The World”停顿时间;也可以并发让垃圾收集与用户程序同时进行。
  • 整堆收集
    能独立管理整个 GC 堆(年轻代和年老代),而不需要与其它收集器搭配;能够采用不同方式处理不同时期的对象;
    虽然保留分代概念,但 Java 堆的内存布局有很大差别;G1 将整个堆划分为多个大小相等的独立区域(Region);新生代和老年代不再是物理隔离,它们都是一部分 Region(不需要连续)的集合。
  • 空间整合
    从整体看,是基于标记-整理算法;从局部(两个 Region 间)看,是基于停止-复制算法。这是一种类似火车算法的实现;不会产生内存碎片,有利于长时间运行。
  • 可预测的停顿
    在低停顿的同时实现高吞吐量;G1 除了追求低停顿外,还能建立可预测的停顿时间模型;可以明确指定 M 毫秒时间片内,垃圾收集消耗的时间不超过 N 毫秒。

适用场景
面向服务端应用,针对具有大内存多处理器的机器。最主要的应用是为需要低 GC 延迟,并具有大堆的应用程序提供解决方案。比如:在堆大小约 6GB 或更大时,可预测的暂停时间可以低于 0.5 秒。HotSpot 打算用来替换掉 JDK1.5 中的 CMS 收集器。

在下面的情况时,使用 G1 可能比 CMS 好:

  • 超过 50% 的 Java 堆被活动数据占用;
  • 对象分配频率或年代提升频率变化很大;
  • GC 停顿时间过长(长于 0.5 至 1 秒)。

是否一定采用 G1 呢?也未必。如果现在采用的收集器没有出现问题,不用急着去选择 G1;如果应用程序追求低停顿,可以尝试选择 G1;是否代替 CMS 需要实际场景测试才知道。

启动参数
-XX:+UseG1GC:启用 G1 收集器
-XX:InitiatingHeapOccupancyPercent:当整个 Java 堆的占用率达到参数值时,开始并发标记阶段;默认为 45
-XX:MaxGCPauseMillis:为 G1 设置暂停时间目标,默认值为 200 毫秒
-XX:G1HeapRegionSize:设置每个 Region 大小,范围 1MB 到 32MB;目标是在最小 Java 堆时可以拥有约 2048 个 Region

为什么 G1 可实现可预测的停顿

  • G1 可以有计划地避免在 Java 堆的进行全区域的垃圾收集;
  • G1 跟踪各个 Region 获得其收集价值大小,在后台维护一个优先列表;
  • G1 每次根据允许的收集时间,优先回收价值最大的 Region(名称 Garbage-First 的由来)。

这就保证了在有限的时间内可以获取尽可能高的收集效率。

一个对象被不同 Region 引用的问题
一个 Region 不可能是孤立的,一个 Region 中的对象可能被其它任意 Region 中对象引用,判断对象存活时,是否需要扫描整个 Java 堆才能保证准确?在其它的分代收集器,也存在这样的问题(而 G1 更突出):回收新生代也不得不同时扫描老年代?这样的话会降低 Minor GC 的效率。

解决方法:无论 G1 还是其它分代收集器,JVM 都是使用 Remembered Set 来避免全局扫描;即每个 Region 都有一个对应的 Remembered Set;每次 Reference 类型数据写操作时,都会产生一个 Write Barrier 暂时中断操作;然后检查将要写入的引用指向的对象是否和该 Reference 类型数据在不同的 Region(其它收集器:检查老年代对象是否引用了新生代对象);如果不同,则通过 CardTable 把相关引用信息记录到引用指向对象的所在 Region 对应的 Remembered Set 中;当进行垃圾收集时,在 GC Roots 枚举范围加入 Remembered Set;就可以保证不进行全局扫描,也不会有遗漏。

G1 收集器运作过程
如果不计算维护 Remembered Set 的操作,可以分为 4 个步骤(与 CMS 较为相似)
G1 收集器运作过程

  • 初始标记(Initial Marking)
    仅标记一下 GC Roots 能直接关联到的对象;且修改 TAMS(Next Top at Mark Start),让下一阶段并发运行时,用户程序能在正确可用的 Region 中创建新对象。需要”Stop The World”,但速度很快。
  • 并发标记(Concurrent Marking)
    进行 GC Roots Tracing 的过程;标记出存活对象;耗时较长,但应用程序也在运行;不过不能保证可以标记出所有存活对象。
  • 最终标记(Final Marking)
    为了修正并发标记期间因用户程序继续运作而导致标记变动的那一部分对象的标记记录;上一阶段对象的变化记录在线程的 Remembered Set Log;这里把 Remembered Set Log 合并到 Remembered Set 中;需要”Stop The World”,且停顿时间比初始标记稍长,但远比并发标记短。G1 采用多线程并行执行来提升效率。
  • 筛选回收(Live Data Counting and Evacuation)
    首先排序各个 Region 的回收价值和成本;然后根据用户期望的 GC 停顿时间来制定回收计划;最后按计划回收一些价值高的 Region 中垃圾对象;回收时采用”停止-复制”算法,从一个或多个 Region 复制存活对象到堆上的另一个空的 Region,并且在此过程中压缩和释放内存;可以并发进行,降低停顿时间,并增加吞吐量。

各版本的默认收集器

  • Java 7:Parallel + Parallel Old;
  • Java 8:Parallel + Parallel Old;
  • Java 9:Garbage-First GC(G1)。

javac/java 常用参数

javac 常用参数

-classpath/cp <paths>:import 搜索路径

-deprecation:输出使用过时 API 警告
-Xlint:输出所有警告信息
-nowarn:不输出任何警告信息
-Werror:出现警告时终止编译

-g:生成调试信息
-g:none:不生成调试信息

-d <目录>:指定存放类文件的目录
-encoding <encoding>:指定源文件编码

-source <jdk版本>:指定源文件 jdk 版本
-target <jdk版本>:指定类文件 jdk 版本

-bootclasspath <paths>:指定 bootstrap 核心类的路径
-extdirs <dirs>:指定 extension 扩展类的路径

@<filename>:从给定文件中读取选项和源文件参数

java 标准参数

java [-options] class [args...]:执行 .class 文件
java [-options] -jar jarfile [args...]:执行 jar 文件

-classpath/cp <paths>:指定 ClassPath 搜索路径

-d32:使用 32 位数据模型(如果可用)
-d64:使用 64 位数据模型(如果可用)

-client:使用 Client VM 虚拟机(64-bit 无效)
-server:使用 Server VM 虚拟机(64-bit 默认)

-ea/enableassertions:启用 assert 断言
-da/disableassertions:禁用 assert 断言(默认)

-D<name>=<value>:设置系统属性(Java 属性)

-verbose:[class|gc|jni]:启用 class|gc|jni 详细输出

-version:打印 JVM 版本信息然后退出
-showversion:打印 JVM 版本信息然后继续

java 非标参数

运行模式
-Xint:纯解释模式
-Xcomp:纯编译模式
-Xmixed:混合模式(默认)

查看设置
-XshowSettings:打印所有设置并继续
-XshowSettings:all:打印所有设置并继续
-XshowSettings:vm:打印 VM 设置并继续
-XshowSettings:properties:打印属性设置并继续
-XshowSettings:locale:打印本地化设置并继续

查看选项
-XX:+PrintFlagsInitial:打印 JVM 默认运行参数后退出
-XX:+PrintFlagsFinal:打印 JVM 当前运行参数后继续运行

核心类路径
-Xbootclasspath:<dirs/zip/jar>:覆盖 bootstrap 核心类搜索路径
-Xbootclasspath/a:<dirs/zip/jar>:追加 bootstrap 核心类搜索路径
-Xbootclasspath/p:<dirs/zip/jar>:插入 bootstrap 核心类搜索路径

内存相关
-Xss<size>:线程栈大小
-Xms<size>:初始堆大小
-Xmx<size>:最大堆大小
-Xmn<size>:年轻代大小,参考值:堆大小的 3/8
-XX:SurvivorRatio=<int>:年轻代各区大小比例,默认为 8,即 Eden 80%、Survivor0 10%、Survivor1 10%
-XX:MaxTenuringThreshold=<age>:进入年老代的对象最大年龄,default 15、CMS 6
-XX:+UseAdaptiveSizePolicy:动态控制 Java 堆中各个区域的大小以及进入年老代的年龄
-XX:PretenureSizeThreshold=<size>:如果一个对象的大小大于该值,那么直接进入年老代

永久代相关
-Xnoclassgc:不对无用的类进行回收
-XX:Permsize=<size>:初始永久代大小
-XX:MaxPermsize=<size>:最大永久代大小

元空间相关
-XX:MetaspaceSize=<size>:设置元空间初始大小,默认值为 16 M
-XX:MaxMetaspaceSize=<size>:设置元空间最大大小,默认不限制
-XX:MinMetaspaceFreeRatio=<int>:设置空闲元空间容量的最小占比
-XX:MaxMetaspaceFreeRatio=<int>:设置空闲元空间容量的最大占比
-XX:MinMetaspaceExpansion=<size>:设置元空间每次扩展的最小大小
-XX:MaxMetaspaceExpansion=<size>:设置元空间每次扩展的最大大小(不进行 Full GC)

致命错误
-XX:OnError=<command>:发生不可恢复错误时运行 command 指定的命令,命令中使用%p可引用 PID 信息
-XX:ErrorFile=<filename>:发生不可恢复错误时将错误信息写入指定文件,文件名使用%p可引用 PID 信息
-XX:OnOutOfMemoryError=<command>:发生 OOM 错误时运行 command 指定的命令,使用%p可引用 PID 信息
-XX:+HeapDumpOnOutOfMemoryError:在发生 OOM 时生成堆转储文件
-XX:HeapDumpPath=<path>:设置堆转储文件的名称,使用%p可引用 PID 信息,默认为./java_pid%p.hprof

类加载跟踪
-XX:+TraceClassLoading:跟踪类加载信息
-XX:+TraceClassLoadingPreorder:跟踪类加载信息(按顺序)
-XX:+TraceClassResolution:跟踪类解析信息
-XX:+TraceClassUnloading:跟踪类卸载信息

偏向锁相关
-XX:+UseBiasedLocking:启用偏向锁(默认启用,但有 4000ms 延迟)
-XX:BiasedLockingStartupDelay=<int>:设置偏向锁启动延迟(毫秒)
-XX:-UseBiasedLocking:禁用偏向锁

压缩指针
-XX:+UseCompressedOops:启用压缩指针(64-bit 默认)
-XX:-UseCompressedOops:禁用压缩指针

GC 收集器
-XX:+UseSerialGC:启用 Serial 收集器

-XX:+UseParNewGC:启用 ParNew 收集器
-XX:ParallelGCThreads=<int>:设置 GC 线程数量,默认与可用 CPU 核数相同

-XX:+UseParallelGC:启用 Parallel Scavenge 收集器
-XX:+UseParallelOldGC:启用 Parallel Old 收集器
-XX:MaxGCPauseMillis=<int>:设置用户线程最大停顿时间(毫秒),设置过小可能导致 GC 发生更频繁
-XX:GCTimeRatio=<int>:设置吞吐量大小,即用户线程执行时间与总时间的占比,默认为 99,即只有 %1 的时间用于 GC

-XX:+UseConcMarkSweepGC:启用 CMS 收集器,默认方案 ParNew + CMS,备用方案 ParNew + Serial Old(内存不足时)
-XX:CMSInitiatingOccupancyFraction=<int>:当年老代的内存使用率达到此值时,触发 CMS 垃圾收集器

-XX:+UseG1GC:启用 G1 收集器
-XX:InitiatingHeapOccupancyPercent=<int>:当整个堆的使用率达到此值时,开始并发标记阶段;默认为 45
-XX:MaxGCPauseMillis=<int>:为 G1 设置暂停时间目标,默认值为 200 毫秒
-XX:G1HeapRegionSize=<int>:设置每个 Region 大小(1~32 MB);目标是在最小 Java 堆时可拥有约 2048 个 Region

GC 调试相关
-verbose:gc:打印 GC 详细日志
-Xloggc:<file>:记录 gc 详细日志(带时间戳)

-XX:+PrintGC:打印 GC 基本日志
-XX:+PrintGCDetails:打印 GC 详细日志
-XX:+PrintGCTimeStamps:打印 GC 时间戳信息

-XX:+PrintGCApplicationStoppedTime:打印 GC 期间程序暂停的时间
-XX:+PrintGCApplicationConcurrentTime:打印每次垃圾回收前,程序未中断的执行时间

JDK 常用自带工具

jar:jar 打包工具,用法与 tar 命令基本一致
javadoc:Java 文档生成工具 - Java 文档生成
javah:Java 头文件生成工具 - Java JNI 入门
javap:Java 自带反编译工具,仅用于调试,如需完整反编译请尝试其它第三方工具
jdb:Java 自带 Debug 工具,主要用于 Java 程序的断点调试
jconsole:图形化用户界面的监测工具,主要用于监测并显示运行于 Java 平台上的应用程序的性能和资源占用等信息
jdeps:JDK1.8 提供的依赖分析工具,用于查看 .class/.jar/dir 的依赖关系
jhat:Java 堆分析工具(Java Heap Analysis Tool),用于分析 Java 堆内存中的对象信息
jinfo:Java 配置信息工具(Java Configuration Information),用于打印指定 Java 进程、核心文件或远程调试服务器的配置信息
jmap:Java 内存映射工具(Java Memory Map),主要用于打印指定 Java 进程、核心文件或远程调试服务器的共享对象内存映射或堆内存细节
jmc:Java 任务控制工具(Java Mission Control),主要用于 HotSpot JVM 的生产时间监测、分析、诊断
jps:JVM 进程状态工具(JVM Process Status Tool),用于显示当前系统上的 HotSpot JVM 的 Java 进程信息
jstack:Java 堆栈跟踪工具,主要用于打印指定 Java 进程、核心文件或远程调试服务器的 Java 线程的堆栈跟踪信息
jstat:JVM 统计监测工具(JVM Statistics Monitoring Tool),主要用于监测并显示 JVM 的性能统计信息
jstatd:RMI 服务器应用,用于监测 HotSpot JVM 的创建和终止,并提供一个接口,允许远程监测工具附加到运行于本地主机的 JVM 上
jvisualvm:JVM 监测、故障排除、分析工具,主要以图形化界面的方式提供运行于指定虚拟机的Java应用程序的详细信息
keytool:Java 密钥和证书管理工具,主要用于密钥和证书的创建、修改、删除等
serialver:Java 序列化 ID 生成工具,用于生成并返回 serialVersionUID 值
native2ascii:Native 编码与 ASCII 码的转换器(将 Native 编码转换为 UTF-16 编码)

jdb 调试器

相信很多人都听过 gdb,这可以说是调试界的鼻祖,在 Linux 中,通常都是使用 gdb 来进行 C/C++ 程序的调试。如果一个程序需要使用 gdb 来调试,必须先使用 gcc/g++ 的 -g 选项,生成 debug 信息。

同样的,在 Java 中,JDK 也提供了类似的命令行调试工具 - jdb(Java Debugger),不过,jdb 没有 gdb 这么多的玩法,比如 gdb 支持条件断点,jdb 就不行。同时,在调试 java 程序之前,我们通常会先使用javac -g编译源文件,来生成完整的 debug 信息(sourcelinesvars),默认情况下只会生成sourcelines两种 debug 信息。

gdb 工作原理

在介绍 jdb 之前,我们先来看一下传统的 gdb 是怎么调试程序的。

gdb 主要功能的实现依赖于ptrace系统调用,通过 man 手册可以了解到,ptrace 可以让父进程观察和控制其子进程的检查、执行,改变其寄存器和内存的内容,主要应用于打断点(也是 gdb 的主要功能)和打印系统调用轨迹。

建立跟踪关系
用 gdb 调试程序有 2 种模式,包括使用 gdb 启动程序,以及 attach 到现有进程。分别对应下面 2 种建立调试关系的方法:

  1. fork:利用 fork+execve 执行被测试的程序,子进程在执行 execve 之前调用 ptrace(PTRACE_TRACEME),建立了与父进程(debugger)的跟踪关系。
  2. attach:debugger 可以调用 ptrace(PTRACE_ATTACH, pid, …),建立自己与进程号为 pid 的进程间的跟踪关系。即利用 PTRACE_ATTACH,使自己变成被调试程序的父进程。用 attach 建立起来的跟踪关系,可以调用 ptrace(PTRACE_DETACH, pid, …) 来解除。注意 attach 进程时的权限问题,如一个非 root 权限的进程是不能 attach 到一个 root 进程上的。

在使用 ptrace 系统调用建立调试关系之后,发送给被调试程序的任何信号(SIGKILL 除外)都将被 gdb 先行截获,因此 gdb 有机会对信号进行相应的处理。

gdb 断点功能的实现原理
断点功能是通过内核信号实现的,以 x86 为例,内核向某个地址打入断点,实际上就是往该地址写入断点指令 INT 3,即 0xCC。被调试程序运行到这条指令之后就会触发 SIGTRAP 信号,gdb 捕获到这个信号,根据被调试程序当前停止位置查询 gdb 维护的断点链表,若发现在该地址确实存在断点,则可判定为断点命中。

可以看出,gdb 只能调试本机上的进程,对于运行在不同主机上的进程是无法进行调试的。但是 jdb 却可以,那么 jdb 的调试原理是什么呢?

JPDA 体系
无论是 IDE 自带的 debug 工具还是 JDK 自带的 jdb 工具,都支持本地远程的程序调试,那么它们是如何被开发的?它们之间存在着什么样的联系呢?我们不得不提及 Java 的调试体系 —— JPDA(Java Platform Debugger Architecture)。

我们知道,Java 程序都是运行在 JVM 上的,我们要调试 Java 程序,事实上就是向 JVM 请求当前运行态的状态,并对 JVM 发送一定的指令,设置一些回调等等,那么 Java 的调试体系,就是 JVM 的一整套用于调试的工具和接口

对于 JVM 接口熟悉的人来说,您一定还记得 Java 提供了两个接口体系,JVMPI(Java Virtual Machine Profiler Interface)JVMDI(Java Virtual Machine Debug Interface),以及在 Java SE 5 中准备代替它们的JVMTI(Java Virtual Machine Tool Interface),都是Java 平台调试体系(Java Platform Debugger Architecture,JPDA)的重要组成部分。Java SE 自 1.2.2 版就开始推出 Java 平台调试体系结构(JPDA)工具集,而从 JDK 1.3.x 开始,Java SDK 就提供了对 Java 平台调试体系结构的直接支持。顾名思义,这个体系为开发人员提供了一整套用于调试 Java 程序的 API,是一套用于开发 Java 调试工具的接口和协议。本质上说,它是我们通向 JVM,考察 JVM 运行态的一个通道,一套工具。理解这一点对于学习 JPDA 非常重要。

换句话说,通过 JPDA 这套接口,我们就可以开发自己的调试工具。通过这些 JPDA 提供的接口和协议,调试器开发人员就能根据特定开发者的需求,扩展定制 Java 调试应用程序,开发出吸引开发人员使用的调试工具。前面我们提到的 IDE 调试工具都是基于 JPDA 体系开发的,区别仅仅在于它们可能提供了不同的图形界面、具有一些不同的自定义功能。另外,我们要注意的是,JPDA 是一套标准,任何的 JDK 实现都必须完成这个标准,因此,通过 JPDA 开发出来的调试工具先天具有跨平台、不依赖虚拟机实现、JDK 版本无关等移植优点,因此大部分的调试工具都是基于这个体系的。

JPDA 组成模块
JPDA 定义了一个完整独立的体系,它由三个相对独立的层次共同组成,而且规定了它们三者之间的交互方式,或者说定义了它们通信的接口。这三个层次由低到高分别是:

  • Java 虚拟机工具接口(JVMTI):被调试者(debuggee)
  • Java 调试线协议(JDWP):通信协议,主要方式为 socket
  • Java 调试接口(JDI):调试者(debugger)

JVMTI、JDWP、JDI 三者之间的关系:
JPDA 各模块之间的关系

在 JDB 中的实现方式:
JPDA 各模块之间的关系 - JDB

  1. 被调试者运行于我们想调试的 JVM 之上,它可以通过 JVMTI 这个标准接口,监控当前 JVM 的信息;
  2. 调试者定义了用户可使用的调试接口,通过这些接口,用户可以对被调试者发送调试命令,同时调试者接受并显示调试结果;
  3. 调试者被调试者之间,调试命令和调试结果,都是通过 JDWP 协议传输的。
    • 调试者发送的命令被封装成 JDWP 命令包,通过传输层发送给被调试者被调试者接收到 JDWP 命令包后,解析这个命令并转化为 JVMTI 的调用,在被调试者上运行;
    • 类似的,JVMTI 的运行结果,被格式化成 JDWP 数据包,发送给调试者并返回给 JDI 调用。而调试器开发人员就是通过 JDI 得到数据,发出指令的。

当然,开发人员完全可以不使用完整的三个层次,而是基于其中的某一个层次开发自己的应用。比如您完全可以仅仅依靠通过 JVMTI 函数开发一个调试工具,而不使用 JDWP 和 JDI,只使用自己的通讯和命令接口。当然,除非是有特殊的需求,利用已有的实现会使您事半功倍,避免重复发明轮子。

Java 虚拟机工具接口(JVMTI)
JVMTI(Java Virtual Machine Tool Interface)即指 Java 虚拟机工具接口,它是一套由 JVM 直接提供的 native 接口,它处于整个 JPDA 体系的最底层所有调试功能本质上都需要通过 JVMTI 来提供。通过这些接口,开发人员不仅调试在该虚拟机上运行的 Java 程序,还能查看它们运行的状态,设置回调函数,控制某些环境变量,从而优化程序性能。

我们知道,JVMTI 的前身是 JVMDI 和 JVMPI,它们原来分别被用于提供调试 Java 程序和调节 Java 程序的性能。在 J2SE 5.0 之后,JDK 取代了 JVMDI 和 JVMPI 这两套接口,JVMDI 在最新的 Java SE 6 中已经不提供支持,而 JVMPI 也计划在 Java SE 7 后被彻底取代。

Java 调试线协议(JDWP)
JDWP(Java Debug Wire Protocol)是一个为 Java 调试而设计的一个通讯交互协议,它定义了调试器被调试程序之间传递的信息的格式。在 JPDA 体系中,调试者与被调试者进程之间的交互数据的格式就是由 JDWP 来描述的。它详细完整地定义了请求命令回应数据错误代码,保证了 JVMTI 和 JDI 的通信通畅。

比如在 Sun 公司提供的实现中,它提供了一个名为jdwp.dll(jdwp.so)的动态链接库文件,这个动态库文件实现了一个 Agent(代理人),它会负责解析接收到的请求或者命令,并将其转化为 JVMTI 调用,然后将 JVMTI 函数的返回信息封装成 JDWP 数据返回给发送者。

另外,这里需要注意的是 JDWP 本身并不包括传输层的实现,传输层需要独立实现,但是 JDWP 包括了和传输层交互的严格的定义,就是说,JDWP 协议虽然不规定我们是通过 EMS 还是快递运送货物的,但是它规定了我们传送的货物的摆放的方式。

在 Sun 公司提供的 JDK 中,在传输层上,它提供了 socket 方式,以及在 Windows 上的 shared memory 方式。当然,传输层本身无非就是本机内进程间通信方式和远端通信方式,用户有兴趣也可以按 JDWP 的标准自己实现。

Java 调试接口(JDI)
JDI(Java Debug Interface)是三个模块中最高层的接口,在多数的 JDK 中,它是由 Java 语言实现的。通过 JDI,调试工具开发人员就能通过前端 JVM 上的调试器来远程操控后端 JVM 上被调试程序的运行。JDI 不仅能帮助开发人员格式化 JDWP 数据,而且还能为 JDWP 数据传输提供队列、缓存等优化服务。从理论上说,开发人员只需使用 JDWP 和 JVMTI 即可支持跨平台的远程调试,但是直接编写 JDWP 程序费时费力,而且效率不高。因此基于 Java 的 JDI 层的引入,简化了操作,提高了开发人员开发调试程序的效率。

JVMTI、JDWP、JDI 比较

模块 层次 语言 作用
JVMTI 底层 C 获取及控制当前虚拟机状态
JDWP 中介层 C 定义 JVMTI 和 JDI 交互的数据格式
JDI 高层 Java 提供 Java API 来远程控制被调试虚拟机

Java 调试接口的特点
Java 语言是第一个使用虚拟机概念的流行的编程语言,正是因为虚拟机的存在,使很多事情变得简单而轻松,掌握了虚拟机,就掌握了内存分配、线程管理、即时优化等等运行态。同样的,Java 调试的本质,就是和虚拟机打交道,通过操作虚拟机来达到观察调试我们自己代码的目的。这个特点决定了 Java 调试接口和以前其他编程语言的巨大区别。

以 C/C++ 的调试为例,目前比较流行的调试工具是 GDB 和微软的 Visual Studio 自带的 debugger,在这种 debugger 中,首先,我们必须编译一个“debug”模式的程序,这个会比实际的 release 模式程序大很多。其次,在调试过程中,debugger 将会深层接入程序的运行,掌握和控制运行态的一些信息,并将这些信息及时返回。这种介入对运行的效率和内存占用都有一定的需求。基于这些需求,这些 Debugger 本身事实上是提供了,或者说,创建和管理了一个运行态,因此他们的程序算法比较复杂,个头都比较大。对于远端的调试,GDB 也没有很好的默认实现,当然,C/C++ 在这方面也没有特别大的需求。

而 Java 则不同,由于 Java 的运行态已经被 JVM 很好地管理,因此作为 Java 的 Debugger 无需再自己创造一个可控的运行态,而仅仅需要去操作虚拟机就可以了。Java 的 JPDA 就是一套为调试和优化服务的虚拟机的操作工具,其中,JVMTI 是整合在虚拟机中的接口JDWP 是一个通讯层,而JDI 是为开发人员准备好的工具和运行库

从构架上说,我们可以把 JPDA 看作成是一个 C/S 体系结构的应用,在这个构架下,我们可以方便地通过网络,在任意的地点调试另外一个 JVM 上的程序,这个就很好地解决了部署和测试的问题,尤其满足解决了很多网络时代中的开发应用的需求。前端和后端的分离,也方便用户开发适合于自己的调试工具。

从效率上看,由于 Java 程序本身就是编译成字节码,运行在 JVM 上的,因此调试前后的程序、内存占用都不会有大变化(仅仅是启动一个 JDWP 所需要的内存),任意程度都可以很好地调试,非常方便。而 JPDA 构架下的几个组成部分,JDWP 和 JDI 都比较小,主要的工作可以让虚拟机自己完成。

从灵活性上,Java 调试工具是建立在强大的 JVM 上的,因此,很多前沿的应用,比如动态编译运行,字节码的实时替换等等,都可以通过对 JVM 的改进而得到实现。随着虚拟机技术的逐步发展和深入,各种不同种类,不同应用领域中虚拟机的出现,各种强大的功能的加入,给我们的调试工具也带来很多新的应用。

总而言之,一个先天的,可控的运行态给 Java 的调试工作,给 Java 调试接口带来了极大的优势和便利。通过 JPDA 这个标准,我们可以从 JVM 中得到我们所需要的信息,完成我们所希望的操作,更好地开发我们的程序。

如何使用 JDB 调试 Java 程序
首先我们知道,被调试者(debuggee)就是运行被调试程序的 JVM。不过 JVM 并没有在内部集成 JDWP 中间层,而是将 JDWP 的具体实现打包为jdwp.so/jdwp.dll动态链接库,在要用到的时候动态的挂接到 JVM 中。因此,我们要给 JVM 传递一个启动参数,让它先加载 JDWP 动态链接库,使用java -agentlib:<libname>[=<options>]选项。

因为 JPDA 是 C/S 体系结构,因此,我们有两种连接方式可选择:

  1. JDB 作为服务端,被调试者作为客户端
    • JDB 先启动,监听一个 socket 地址,假设为 127.0.0.1:8080,然后等待被调试者 attach 到 JDB 上;
    • 被调试者挂接 JDWP,然后在本地随机选择一个可用端口,attach 到 JDB 的 socket 地址上,开始调试。
  2. 被调试者作为服务端,JDB 作为客户端
    • 被调试者挂接 JDWP,监听一个 socket 地址,假设为 127.0.0.1:8080,然后等待 JDB attach;
    • 启动 JDB,在本地随机选择一个可用端口,然后 attach 到被调试者的 socket 地址上,开始调试。

第一种方式的具体操作:
服务端:jdb -connect com.sun.jdi.SocketListen:localAddress=127.0.0.1,port=8080
客户端:java -agentlib:jdwp=transport=dt_socket,address=127.0.0.1:8080 Test

第二种方式的具体操作:
服务端:java -agentlib:jdwp=transport=dt_socket,address=127.0.0.1:8080,server=y Test
客户端:jdb -connect com.sun.jdi.SocketAttach:hostname=127.0.0.1,port=8080

不过,JDB 提供了更简便的方法,直接使用jdb Test即可,执行完毕后,JDB 首先监听某个端口,作为服务端,然后 fork 一个新进程,运行 JVM,然后让它 attach 到这个监听的端口,开始进行 JDB 调试。这其实就是我们说的第一种方式。

启动 JDB
jdb:启动 JDB 调试器,启动后,可以通过run [class [args]]内置命令运行给定的类并传递命令行参数,推荐此方式
jdb Test:启动 JDB 调试器,与 Test.class 关联,只有无参run才会使用关联的类,使用run [class [args]]会覆盖它

调试命令

JDB 调试例子
一个简单的测试类:

使用 JDB 进行调试: