Java虚拟机:内存管理与执行引擎

一、Java技术体系

Sun官方所定义的Java技术体系包括以下几个组成部分:

  • Java程序设计语言
  • 各种硬件平台上的Java虚拟机
  • Class文件格式
  • Java API类库
  • 来自商业机构和开源社区的第三方类库

JDK(Java Development Kit) —— 包括Java程序设计语言、Java虚拟机、Java API类库。JDK是用于支持Java程序开发的最小环境。

JRE(Jave Runtime Environment) —— 包括Java API类库中的Java SE API子集、Java虚拟机。JRE是支持Java程序运行的标准环境。

下图展示了Java技术体系所包含的内容,以及JDK和JRE所涵盖的范围:

按照技术所服务的领域来分,Java技术体系可以分为四个平台,分别是:

  • Java Card:支持一些Java小程序(Applet)运行在小内存设备(如智能卡)上的平台。
  • Java ME(Micro Edition):支持Java程序运行在移动终端(手机、PDA)上的平台,对Java API有所精简,并加入了针对移动终端的支持。
  • Java SE(Standard Edition):支持面向桌面级应用(如Windows下的应用程序)的Java平台,提供了完整的Java核心API。
  • Java EE(Enterprise Edition):支持使用多层架构的企业应用的Java平台,除了提供Java SE API之外,还对其做了大量的扩充并提供了相关的部署支持。

二、Java技术未来

1. 模块化

模块化是解决应用系统与技术平台越来越复杂、越来越庞大问题的一个重要途径。站在整个软件工业化的高度来看,模块化是建立各种功能的标准件的前提。最近几年的OSGi技术的迅速发展、各个厂商在JCP中对模块化规范的激烈斗争,都能充分说明模块化技术的迫切和重要。

2. 混合语言

当单一的Java开发已经无法满足当前软件的复杂需求时,越来越多基于Java虚拟机的语言开发被应用到软件项目中,Java平台上的多语言混合编程正成为主流,每种语言都可以针对自己擅长的方面更好地解决问题。试想一下,在一个项目之中,并行处理用Clojure语言编写,展示层使用JRuby/Rails,中间层使用Java,每个应用层都将使用不同的编程语言来完成,而且,接口对每一层的开发者都是透明的,各种语言之间的交互不存在任何困难,就像使用自己语言的原生API一样方便,因为它们最终都运行在一个虚拟机之上。因此,整个JVM项目开始推动Java虚拟机从“Java语言的虚拟机”向“多语言虚拟机”的方向发展。

3. 多核并行

如今,CPU硬件的发展方向已经从高频率转变为多核心,随着多核时代的来临,软件开发越来越关注并行编程的领域。Fork/Join模式是处理并行编程的一个经典方法,通过利用Fork/Join模式,我们能够更加顺畅地过渡到多核时代。

在Java8中,将会提供Lambda支持,这将会极大地改善目前Java语言不适合函数式编程的现状。另外,在并行计算中必须提及的还有Sumatra项目,其主要关注为Java提供使用GPU和APU运算能力的工具。在JDK外围,也出现了专为满足并行计算需求的计算框架,如Apache的Hadoop Map/Reduce等。

4. 进一步丰富语法

Java 5曾经对Java语法进行了一次扩充,这次扩充加入了自动装箱、泛型、动态注解、枚举、可变长参数、遍历循环等语法,而后,每一次Java版本的发布,都会进一步丰富Java语言的语法特性,包括Java 8中的Lambda表达式。

5. 64位虚拟机

随着硬件的进一步发展,计算机终究会完全过渡到64位的时代,这是一件毫无疑问的事情,主流的虚拟机应用也终究会从32位发展到64位,而Java虚拟机对64位的支持也将会进一步完善。

二、Java内存管理机制

Java与C++之间有一堵由内存动态分配和垃圾收集技术所围成的高墙,墙外面的人想进去,墙里面的人却想出来。

1. 运行时数据区域

Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干不同的数据区域。这些区域都有各自的用途,以及创建和销毁的时间,有的区域随着虚拟机进程的启动而存在,有些区域则依赖于用户线程的启动和结束而建立和销毁。

Java虚拟机所管理的内存包括以下几个运行时数据区域:

1.1 程序计数器

程序计数器(Program Counter Register)是一块较小的内存空间,它可以看做是当前线程所执行的字节码的行号指示器

在虚拟机的概念模型里,字节码解释器工作时就是通过改变程序计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖于程序计数器来完成。

由于Java虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器(对于多核处理器来说是一个核心)都只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间计数器互不影响,独立存储,我们称这类内存区域为线程私有的内存

如果线程正在执行的是一个Java方法,程序计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是Native方法,则程序计数器的值为空(Undefined)。

程序计数器是唯一一个在Java虚拟机规范中没有规定任何OutOfMemoryError情况的区域。

1.2 Java虚拟机栈

与程序计数器一样,Java虚拟机栈(Java Virtual Machine Stack)也是线程私有的,它的生命周期与线程相同。虚拟机栈描述的是Java方法执行的内存模型:每个方法在执行的同时都会创建一个栈帧(Stack Frame,栈帧是方法运行时的基础数据结构)用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。

对于C/C++等程序来说,其内存管理常常分为栈、堆等。对于Java,栈即指代虚拟机栈,或者说是虚拟机栈中局部变量表部分。

局部变量表存放了编译期可知的各种基本数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference类型,它不等同于对象本身,可能是一个指向对象起始地址的引用地址,也可能是指向一个代表对象的句柄或其他与此对象相关的位置)和returnAddress类型(指向了一条字节码指令的地址)。

局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。

可以通过 -Xss 这个虚拟机参数来指定一个程序的 Java 虚拟机栈内存大小:

java -Xss=512M HackTheJava

该区域可能抛出以下异常:

  • 当线程请求的栈深度超过最大值,会抛出StackOverflowError 异常;
  • 栈进行动态扩展时如果无法申请到足够内存,会抛出OutOfMemoryError 异常。

在Java虚拟机规范中,对这个区域规定了两种异常状况:如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常;如果虚拟机栈可以动态扩展,如果扩展时无法申请到足够的内存,就会抛出OutOfMemoryError异常

1.3 本地方法栈

本地方法栈(Native Method Stack)与虚拟机栈所发挥的作用是非常相似的,它们的区别是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则为虚拟机使用到的Native方法服务。与虚拟机栈一样,本地方法栈也会抛出StackOverflowError异常和OutOfMemoryError异常。

1.4 Java堆

对于大多数应用来说,Java堆(Java Heap)是Java虚拟机所管理的内存中最大的一块。Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例。在JVM中,几乎所有的对象实例都在这里分配内存。Java虚拟机规范中的描述是:所有的对象实例以及数组都要在堆上分配,但是随着JIT编译器的发展和逃逸技术逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化发生,所有的对象都分配在堆上也变得不是那么绝对了

Java堆是垃圾收集器管理的主要区域,因此,Java堆也被称为“GC堆”(Garbage Collected Heap)。

现代的垃圾收集器基本都是采用分代收集算法,该算法的思想是针对不同的对象采取不同的垃圾回收算法,因此虚拟机把Java堆分成以下三块:

  • 新生代(Young Generation)
  • 老年代(Old Generation)
  • 永久代(Permanent Generation)

当一个对象被创建时,它首先进入新生代,之后有可能被转移到老年代中。新生代存放着大量的生命很短的对象,因此新生代在三个区域中垃圾回收的频率最高。为了更高效的进行垃圾回收,把新生代继续划分为以下三个空间:

  • Eden
  • From Survivor
  • To Survivor

从内存分配的角度来看,线程共享的Java堆中可能划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer, TLAB)。根据Java虚拟机规范的规定,Java堆可以处于物理上不连续的内存空间,只要逻辑上是连续的即可。在实现时,既可以实现成固定大小的,也可以是可扩展的,如果在堆中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出OutOfMemoryError异常。

可以通过 -Xms 和 -Xmx 两个虚拟机参数来指定一个程序的 Java 堆内存大小,第一个参数设置最小值,第二个参数设置最大值。

java -Xms=1M -XmX=2M HackTheJava

1.5 方法区

方法区(Method Area)与Java堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。虽然Java虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做Non-Heap(非堆),目的应该与Java堆区分开来。

Java虚拟机规范对方法区的限制非常宽松,除了和Java堆一样不需要连续的内存和可以选择固定大小或者可扩展外,还可以选择不实现垃圾回收。相对而言,垃圾收集行为在这个区域是比较少出现的,但并非数据进入了方法区就如同永久代名字一样永久存在。该区域的内存回收目标主要是针对常量池的回收和对类型的卸载。

根据Java虚拟机规范的规定,当方法区无法满足内存分配需求时,将抛出OutOfMemoryError异常。

运行时常量池(Runtime Costant Pool)是方法区的一部分。Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池(Constant Pool),用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。

运行时常量池相对于Class文件常量池的另外一个重要特征是具备动态性。Java语言并不要求常量一定只有编译期才能产生,也就是并非预置入Class文件常量池的内容才能进入方法区运行时常量池,运行期间也可能将新的常量放入池中,如String类的intern()方法。

既然运行时常量区是方法区的一部分,当常量池无法申请到内存时会抛出OutOfMemoryError异常。

1.6 直接内存

直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域。

在Java4中新加入的NIO类,其引入了一种基于通道与缓冲区的IO方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆中的DirectByteBuffer对象作为这块内存的应用进行操作。这样能在一些场景中显著提高性能,在一定程度上能避免在Java堆和Native堆中来回复制数据。

2. JVM对象探秘

在本部分,我们将深入探讨HotSpot虚拟机在Java堆中对象分配、布局和访问的全过程。

2.1 对象的创建

在HotSpot虚拟机中,对象的创建过程分为五个步骤:

步骤一

当虚拟机遇到一条new指令时,首先会去检查new指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,那必须先执行相应的类加载过程

步骤二

在类加载检查通过后,虚拟机将为新生对象分配内存。对象所需内存的大小在类加载完成后便可完全确定,为对象分配内存空间的任务等同于把一块确定大小的内存从Java堆中划分出来。分配方式有:

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

  2. 空闲列表(Free List):如果Java堆中的内存不是规整的,即已使用的内存和空闲的内存相互交错,那么就无法使用指针碰撞了。此时,虚拟机就必须维护一个列表,记录哪些内存块是可用的,于是,空闲列表的内存分配方式就是从该列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录

选择哪种内存分配方式由Java堆是否规整决定,而Java堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。因此,在使用Serial、ParNew等带Compact过程的收集器时,系统采用的分配算法是指针碰撞,而使用CMS这种基于Mark-Sweep算法的收集器时,通常采用空闲列表。

另外,在并发环境下,内存分配方式面临线程安全问题。解决这个问题有两种方案:

  1. 对分配内存空间的动作进行同步处理——实际上虚拟机采用CAS结合失败重试的方法来保证更新操作的原子性。

  2. 把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在Java堆中预先分配一小块内存,称为本地线程分配缓冲区(Thread Local Allocation Buffer,TLAB)

步骤三

内存分配完成后,虚拟机需要将分配的内存空间初始化为零值(不包括对象头)。这一步操作保证了对象的实例字段在Java代码中可以不赋初值就直接使用,程序能访问到这些字段的数据类型所对应的零值

步骤四

接下来,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息。这些信息存放在对象的对象头(Object Header)之中。

步骤五

在上面的工作都完成之后,从虚拟机的视角看,一个新的对象已经产生了。但是,从Java程序员的视角来看,对象创建才刚刚开始——方法还没有执行,所有字段都还为零。

因此,一般来说,执行new命令之后,会接着执行方法,把对象按照程序员的意愿进行初始化,这样,一个真正可用的对象才算完全产生出来。

2.2 对象内存布局

在HotSpot虚拟机中,对象在内存中存储的布局可以划分为3个区域:

  • 对象头(Object Header)
  • 实例数据(Instance Data)
  • 对齐填充(Padding)
对象头信息

HotSpot虚拟机的对象头包括两部分信息:

  • 第一部分:用于存储对象自身的运行时数据,如哈希码、GC分代年龄、锁状态标志、线程持有的锁等,官方称之为“Mark Word”。该部分数据长度在32位和64位虚拟机中分别为32位和64位。Mark Word被设计成一个非固定的数据结构,以便在极小的空间内存储尽量多的信息,它会根据对象的状态复用自己的存储空间。下表是32位HotSpot虚拟机对象头Mark Word。
存储内容 标志位 状态
对象哈希码、对象分代年龄 01 未锁定
指向锁记录的指针 00 轻量级锁定
指向重量级锁的指针 10 膨胀(重量级锁定)
空,不需要记录信息 11 GC标记
偏向线程ID、偏向时间戳、对象分代年龄 01 可偏向
  • 第二部分:类型指针,即对象指向它的类元数据的指针,虚拟机通过该指针来确定这个对象是哪个类的实例。另外,如果对象是一个Java数组,那在对象头中还必须有一块用于记录数组长度的数据。
实例数据

实例数据部分是对象真正存储的有效信息,也是在程序代码中所定义的各种类型的字段内容。无论是从父类继承下来的,还是在子类中定义的,都需要记录下来。

该部分的存储顺序会受到虚拟机分配策略参数(FieldsAllocationStyle)和字段在Java源码中定义顺序影响。

  • HotSpot虚拟机默认的分配策略为longs/doubles、ints、shorts/chars、bytes/booleans、oops(Ordinary Object Pointers)。从分配策略可以看出,相同宽度的字段总是分配在一起。在满足该前提条件下,在父类中定义的变量会出现在子类之前。
对齐填充

对齐填充并不是必然存在的,也没有特别的含义,它仅仅起着占位符的作用。当对象实例数据部分没有对齐时,需要通过对齐填充来补全。

3. OutOfMemoryError异常

3.1 Java堆溢出

Java堆配置参数:-Xmx-Xms

Java堆用于存储对象实例,只要不断地创建对象,并且保证GC Roots到对象之间有可达路径来避免垃圾回收机制清除这些对象,那么在对象数量到达最大堆的容量限制后就会产生内存溢出异常。

测试代码:

public class HeapOOM {
    static classn OOMObject {
    }

    public static void main(String[] args) {
        List list = new ArrayList();

        while (true) {
            list.add(new OOMObject);
         }
    }
}

3.2 虚拟机栈和本地方法栈溢出

Java虚拟机栈配置参数:-Xss

关于虚拟机栈和本地方法栈,在Java虚拟机规范中描述了两种异常:

  • 如果线程请求的栈深度大于虚拟机所允许的最大深度,将抛出StackOverflowError异常;
  • 如果虚拟机在扩展栈时无法申请到足够的内存空间,将抛出OutOfMemoryError异常。

测试代码:

public class JavaVMStackSOF {
    private int stackLength = 1;

    public void stackLeak() {
        stackLength++;
        stackLeak();
    }

    public static void main(String[] args) {
        JavaVMStackSOF oom = new JavaVMStackSOF();

        try{
            oom.stackLeak();
        } catch(Throwable e) {
            System.out.println("stack length = " + oom.stackLength);
            throw e;
        }
    }
}

3.3 方法区和运行时常量池溢出

方法区配置参数:-XX:PermSize-XX:MaxPermSize

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

测试代码:

public class RuntimeConstantPoolOOM {
    public static void main(String[] args) {
        List list = new ArrayList();
        int i = 0;
        while (true) {
            list.add(String.valueOf(i++).intern());
        }
    }
}

3.4 本机直接内存溢出

直接内存配置参数:-XX:MaxDirectMemorySize

三、Java垃圾回收机制

1. 对象存活判断及垃圾回收概述

GC需要完成三件事情:

  • 哪些内存需要回收?
  • 什么时候回收?
  • 如何回收?

为什么需要去了解GC和内存分配呢?答案很简单:当需要排查各种内存溢出、内存泄露问题时,当垃圾收集成为系统达到更高并发量的瓶颈时,我们就需要对这些“自动化”的技术实施必要的监控和调节。

Java堆和方法区的内存的分配和回收是动态的,垃圾收集器主要关注的就是这部分内存。

对象存亡问题:在堆里面存放着Java世界中几乎所有的对象实例,垃圾收集器在对堆进行回收前,第一件事情就是要确定这些对象之中哪些还“存活”着,哪些已经“死去”(即不可能再被任何途径使用的对象)。

1.1 引用计数算法

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

客观的说,引用计数算法(Reference Counting)实现简单,判定效率也很高。但Java虚拟机并没有选用引用计数算法来管理内存,最主要的原因是引用计数算法很难解决对象间相互循环引用的问题

1.2 可达性分析算法

在主流的商用程序语言中(Java、C#)的主流实现中,都是通过可达性分析(Reachability Analysis)来判定对象是否存活的

可达性分析算法:通过一系列的称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链接相连(用图论的话来说,就是从GC Roots到这个对象不可达)时,则证明该对象是不可用的。

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

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

1.3 引用类型

无论是通过引用计算算法判断对象的引用数量,还是通过可达性分析算法判断对象的引用链是否可达,判定对象是否存活都与“引用”有关。

Java 对引用的概念进行了扩充,引入四种强度不同的引用类型。

强引用

只要强引用存在,垃圾回收器永远不会回收调掉被引用的对象。

使用 new 一个新对象的方式来创建强引用。

Object object = new Object();
软引用

用来描述一些还有用但是并非必需的对象。

在系统将要发生内存溢出异常之前,将会对这些对象列进回收范围之中进行第二次回收。如果这次回收还没有足够的内存,才会抛出溢出异常。

软引用主要用来实现类似缓存的功能,在内存足够的情况下直接通过软引用取值,无需从繁忙的真实来源获取数据,提升速度;当内存不足时,自动删除这部分缓存数据,从真正的来源获取这些数据。

使用 SoftReference 类来实现软引用。

Object obj = new Object();
SoftReference sf = new SoftReference(obj); 
  
弱引用

只能生存到下一次垃圾收集发生之前,当垃圾收集器工作时,无论当前内存是否足够,都会被回收。

使用 WeakReference 类来实现弱引用。

Object obj = new Object();
WeakReference wf = new WeakReference(obj); 
  
虚引用

一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用取得一个对象实例。

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

使用 PhantomReference 来实现虚引用。

Object obj = new Object();
PhantomReference pf = new PhantomReference(obj); 
  

虚引用与软引用和弱引用的一个区别在于:虚引用必须和引用队列 (ReferenceQueue)联合使用。当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用加入到与之关联的引用队列中。

关于Java中的软引用、弱引用和虚引用,可以参见博客:java中的弱引用、软引用和虚引用

1.4 两次标记清除

即使在可达性分析算法中不可达的对象,也并非是“非死不可”的。

要真正宣告一个对象死亡,至少要经历两次标记过程

  • 如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,那它将会被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法

  • 如果这个对象被判定为有必要执行finalize()方法,那么这个对象将会放置在一个叫F-Queue的队列之中,并在稍后由一个由虚拟机自动建立的、低优先级的Finalizer线程去执行它。finalize()方法是对象逃脱死亡命运的最后一次机会。在finalize()函数中,如果对象重新与引用链上的任何一个对象建立关联,那么第二次标记是就将其移除出“即将回收”的集合;否则,第二次标记时,对象将会被宣告真正死亡

注意,任何一个对象的finalize()方法都只会被系统自动调用一次,不鼓励使用该方法来拯救对象。finalize()能做的所有工作,使用try-finally或者其他方式都可以做得更好。

1.5 方法区回收

在方法区中进行垃圾收集的性价比一般比较低:在堆中,尤其是在新生代中,常规应用进行一次垃圾收集一般可以回收70%-95%的空间,而永久代的垃圾收集效率远低于此。

永久代的垃圾收集主要回收两部分内容:废弃常量和无用的类

回收废弃常量与回收堆中的对象非常类似,基于引用的方法可以实现。但是,判定一个类是否是“无用的类”的条件相对苛刻许多。

一个类需要同时满足下面三个条件才能算是“无用的类”:

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

在大量使用反射、动态代理、CGLib等ByteCode框架、动态生成JSP以及OSGi这类频繁自定义ClassLoader的场景都需要虚拟机具备类卸载的功能,以保证永久代不会溢出。

2. 垃圾回收算法

2.1 标记-清除算法(Mark-Sweep)

标记-清除算法分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象

不足之处:

  • 效率问题:标记和清除两个过程的效率都不高;
  • 空间问题:标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。

具体工作过程如下:

内存分布图:可见内存碎片化问题严重。

2.2 复制算法(Copying)

复制算法有效地解决了效率问题。

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

该算法过程实现简单,运行高效。但是,内存缩小一半,代价太大。

具体工作过程如下:

内存分布图:可见很好地解决了内存碎片化问题。

现在的商用虚拟机都采用这种收集算法来回收新生代,由于新生代中的对象98%都是朝生夕死,所以并不需要按照1:1的比例来划分内存空间,而是将内存分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden和其中一块Survivor(两块Survivor轮流使用)。当回收时,将Eden和Survivor中还存活的对象一次性地复制到另外一个Survivor空间上,最后清理掉Eden和刚才用过的Survivor空间。

HotSpot虚拟机默认Eden和Survivor的大小比例是8:1。

当Survivor空间不够用时,需要依赖其他内存(老年代)进行分配担保(Handle Promotion)。对于分配担保,如果另外一块Survivor空间没有足够空间存放上一次新生代收集下来的存活对象时,这些对象将直接通过分配担保机制进入老年代

2.3 标记-整理算法(Mark-Compact)

复制收集算法在对象存活率较高时就要进行较多的赋值操作,效率将会变低。

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

具体工作过程如下:

内存分布图为:

2.4 分代收集算法

当前商业虚拟机的垃圾收集都采用“分代收集(Generational Collection)”算法,其根据对象存活周期的不同将内存划分为几块。一般是把Java堆分为新生代老年代

在新生代中,大多采用复制收集算法。在老年代中,由于对象存活率较高并没有额外空间进行分配担保,多是使用“标记-清除”和“标记-整理”算法来进行回收

3. 垃圾回收实现

3.1 枚举根节点

从可达性分析中从GC Roots节点找引用链这个操作为例,可作为GC Roots的节点主要在全局性的引用与执行上下文中,现在很多应用仅仅方法区就有数百兆,如果要逐个检查这里面的引用,那么必然会消耗很多时间。

在准确式GC中,当执行系统停顿下来后,并不需要一个不漏地检查完所有执行上下文和全局的引用位置,虚拟机应当有办法直接得知哪些地方存放着对象引用。

在HotSpot中,其使用一组称为OopMap的数据结构来达到这个目的,在类加载完成时,HotSpot就把对象内什么偏移量上是什么类型的数据计算出来。这样,GC在扫描时就可以得知这些信息了。

3.2 安全点(SafePoint)

程序执行时,并非在所有地方都能停顿下来开始GC,只有在到达安全点时才能暂停。

安全点的选定既不能太少以致于GC等待时间太长,也不能过于频繁以致于过分增大运行时的负荷。因此,安全点的选定基本上是以程序是否具有让程序长时间执行的特征为标准进行选定的。

对于安全点,另一个问题就是如何在GC发生时让所有线程都运行到最近的安全点上再停顿下来。两种方案:

  • 抢先式中断(Preemptive Suspension)
  • 主动式中断(Voluntary Suspension)

对于抢先式中断,其不需要线程的执行代码主动去配合,在GC发生时,首先把所有线程全部中断,如果发现线程中断的地方不在安全点上,就恢复线程,让它执行到安全点上。

对于主动式中断,其在当GC需要中断线程的时候,不直接对线程操作,仅仅简单地设置一个标志,各个线程执行时主动去轮询这个标志,发现中断标志为真时就自己中断挂起。

现在基本使用“安全点轮询和触发线程中断”的主动式中断机制。

4. 垃圾收集器

如果说收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。

下图是HotSpot虚拟机的垃圾收集器。如果两个收集器之间存在连线,就说明可以搭配使用。虚拟机所处的区域,则表示它是属于新生代收集器还是老年代收集器。

4.1 Serial收集器

它是单线程的收集器。

这不仅意味着只会使用一个线程进行垃圾收集工作,更重要的是它在进行垃圾收集时,必须暂停所有其他工作线程,往往造成过长的等待时间。

它的优点是简单高效,对于单个 CPU 环境来说,由于没有线程交互的开销,因此拥有最高的单线程收集效率。

在 Client 应用场景中,分配给虚拟机管理的内存一般来说不会很大,该收集器收集几十兆甚至一两百兆的新生代停顿时间可以控制在一百多毫秒以内,只要不是太频繁,这点停顿是可以接受的。

4.2 ParNew收集器

它是 Serial 收集器的多线程版本。

它是 Server 模式下的虚拟机首选新生代收集器,除了性能原因外,主要是因为除了 Serial 收集器,只有它能与 CMS 收集器配合工作。

默认开始的线程数量与 CPU 数量相同,可以使用 -XX:ParallelGCThreads 参数来设置线程数。

4.3 Paraller Scavenge收集器

它是并行的多线程收集器。

其它收集器关注点是尽可能缩短垃圾收集时用户线程的停顿时间,而它的目标是达到一个可控制的吞吐量,它被称为“吞吐量优先”收集器。这里的吞吐量指 CPU 用于运行用户代码的时间占总时间的比值。

停顿时间越短就越适合需要与用户交互的程序,良好的响应速度能提升用户体验。而高吞吐量则可以高效率地利用 CPU 时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。

提供了两个参数用于精确控制吞吐量,分别是控制最大垃圾收集停顿时间-XX:MaxGCPauseMillis参数以及直接设置吞吐量大小的-XX:GCTimeRatio参数(值为大于 0 且小于 100 的整数)。缩短停顿时间是以牺牲吞吐量和新生代空间来换取的:新生代空间变小,垃圾回收变得频繁,导致吞吐量下降。

还提供了一个参数 -XX:+UseAdaptiveSizePolicy,这是一个开关参数,打开参数后,就不需要手工指定新生代的大小(-Xmn)、Eden 和 Survivor 区的比例(-XX:SurvivorRatio)、晋升老年代对象年龄(-XX:PretenureSizeThreshold)等细节参数了,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量,这种方式称为 GC 自适应的调节策略(GC Ergonomics)。自适应调节策略也是它与 ParNew 收集器的一个重要区别。

4.4 Serial Old收集器

Serial Old 是 Serial 收集器的老年代版本,也是给 Client 模式下的虚拟机使用。如果用在 Server 模式下,它有两大用途:

  • 在 JDK 1.5 以及之前版本(Parallel Old 诞生以前)中与 Parallel Scavenge 收集器搭配使用。
  • 作为 CMS 收集器的后备预案,在并发收集发生 Concurrent Mode Failure 时使用。

4.5 Parallel Old收集器

它是 Parallel Scavenge 收集器的老年代版本。

在注重吞吐量以及 CPU 资源敏感的场合,都可以优先考虑 Parallel Scavenge 加 Parallel Old 收集器。

4.6 CMS收集器

CMS(Concurrent Mark Sweep),从 Mark Sweep 可以知道它是基于标记 - 清除算法实现的。

特点:并发收集、低停顿

分为以下四个流程:

  1. 初始标记:仅仅只是标记一下 GC Roots 能直接关联到的对象,速度很快,需要停顿。
  2. 并发标记:进行 GC Roots Tracing 的过程,它在整个回收过程中耗时最长,不需要停顿。
  3. 重新标记:为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,需要停顿。
  4. 并发清除:不需要停顿。

在整个过程中耗时最长的并发标记和并发清除过程中,收集器线程都可以与用户线程一起工作,不需要进行停顿。

具有以下缺点:

  • 对 CPU 资源敏感。CMS 默认启动的回收线程数是 (CPU 数量 + 3) / 4,当 CPU 不足 4 个时,CMS 对用户程序的影响就可能变得很大,如果本来 CPU 负载就比较大,还要分出一半的运算能力去执行收集器线程,就可能导致用户程序的执行速度忽然降低了 50%,其实也让人无法接受。并且低停顿时间是以牺牲吞吐量为代价的,导致 CPU 利用率变低。
  • 无法处理浮动垃圾。由于并发清理阶段用户线程还在运行着,伴随程序运行自然就还会有新的垃圾不断产生。这一部分垃圾出现在标记过程之后,CMS 无法在当次收集中处理掉它们,只好留到下一次 GC 时再清理掉,这一部分垃圾就被称为“浮动垃圾”。也是由于在垃圾收集阶段用户线程还需要运行,那也就还需要预留有足够的内存空间给用户线程使用,因此它不能像其他收集器那样等到老年代几乎完全被填满了再进行收集,需要预留一部分空间提供并发收集时的程序运作使用。可以使用 -XX:CMSInitiatingOccupancyFraction 的值来改变触发收集器工作的内存占用百分比,JDK 1.5 默认设置下该值为 68,也就是当老年代使用了 68% 的空间之后会触发收集器工作。如果该值设置的太高,导致浮动垃圾无法保存,那么就会出现 Concurrent Mode Failure,此时虚拟机将启动后备预案:临时启用 Serial Old 收集器来重新进行老年代的垃圾收集。
  • 标记 - 清除算法导致的空间碎片,给大对象分配带来很大麻烦,往往出现老年代空间剩余,但无法找到足够大连续空间来分配当前对象,不得不提前触发一次 Full GC。

4.7 G1收集器

G1(Garbage-First)收集器是当今收集器技术发展最前沿的成果之一,它是一款面向服务端应用的垃圾收集器,HotSpot 开发团队赋予它的使命是(在比较长期的)未来可以替换掉 JDK 1.5 中发布的 CMS 收集器。

具备如下特点:

  • 并行与并发:能充分利用多 CPU 环境下的硬件优势,使用多个 CPU 来缩短停顿时间。
  • 分代收集:分代概念依然得以保留,虽然它不需要其它收集器配合就能独立管理整个 GC 堆,但它能够采用不同方式去处理新创建的对象和已存活一段时间、熬过多次 GC 的旧对象来获取更好的收集效果。
  • 空间整合:整体来看是基于“标记 - 整理”算法实现的收集器,从局部(两个 Region 之间)上来看是基于“复制”算法实现的,这意味着运行期间不会产生内存空间碎片。
  • 可预测的停顿:这是它相对 CMS 的一大优势,降低停顿时间是 G1 和 CMS 共同的关注点,但 G1 除了降低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为 M 毫秒的时间片段内,消耗在 GC 上的时间不得超过 N 毫秒,这几乎已经是实时 Java(RTSJ)的垃圾收集器的特征了。

在 G1 之前的其他收集器进行收集的范围都是整个新生代或者老生代,而 G1 不再是这样,Java 堆的内存布局与其他收集器有很大区别,将整个 Java 堆划分为多个大小相等的独立区域(Region)。虽然还保留新生代和老年代的概念,但新生代和老年代不再是物理隔离的了,而都是一部分 Region(不需要连续)的集合。

之所以能建立可预测的停顿时间模型,是因为它可以有计划地避免在整个 Java 堆中进行全区域的垃圾收集。它跟踪各个 Region 里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的 Region(这也就是 Garbage-First 名称的来由)。这种使用 Region 划分内存空间以及有优先级的区域回收方式,保证了它在有限的时间内可以获取尽可能高的收集效率。

Region 不可能是孤立的,一个对象分配在某个 Region 中,可以与整个 Java 堆任意的对象发生引用关系。在做可达性分析确定对象是否存活的时候,需要扫描整个 Java 堆才能保证准确性,这显然是对 GC 效率的极大伤害。为了避免全堆扫描的发生,每个 Region 都维护了一个与之对应的 Remembered Set。虚拟机发现程序在对 Reference 类型的数据进行写操作时,会产生一个 Write Barrier 暂时中断写操作,检查 Reference 引用的对象是否处于不同的 Region 之中,如果是,便通过 CardTable 把相关引用信息记录到被引用对象所属的 Region 的 Remembered Set 之中。当进行内存回收时,在 GC 根节点的枚举范围中加入 Remembered Set 即可保证不对全堆扫描也不会有遗漏。

如果不计算维护 Remembered Set 的操作,G1 收集器的运作大致可划分为以下几个步骤:

  1. 初始标记
  2. 并发标记
  3. 最终标记:为了修正在并发标记期间因用户程序继续运作而导致标记产生变动的那一部分标记记录,虚拟机将这段时间对象变化记录在线程的 Remembered Set Logs 里面,最终标记阶段需要把 Remembered Set Logs 的数据合并到 Remembered Set 中。这阶段需要停顿线程,但是可并行执行。
  4. 筛选回收:首先对各个 Region 中的回收价值和成本进行排序,根据用户所期望的 GC 停顿是时间来制定回收计划。此阶段其实也可以做到与用户程序一起并发执行,但是因为只回收一部分 Region,时间是用户可控制的,而且停顿用户线程将大幅度提高收集效率。

4.8 七种垃圾收集器的对比

收集器 串行、并行 or 并发 新生代 / 老年代 算法 目标 适用场景
Serial 串行 新生代 复制算法 响应速度优先 单 CPU 环境下的 Client 模式
Serial Old 串行 老年代 标记-整理 响应速度优先 单 CPU 环境下的 Client 模式、CMS 的后备预案
ParNew 并行 新生代 复制算法 响应速度优先 多 CPU 环境时在 Server 模式下与 CMS 配合
Parallel Scavenge 并行 新生代 复制算法 吞吐量优先 在后台运算而不需要太多交互的任务
Parallel Old 并行 老年代 标记-整理 吞吐量优先 在后台运算而不需要太多交互的任务
CMS 并发 老年代 标记-清除 响应速度优先 集中在互联网站或 B/S 系统服务端上的 Java 应用
G1 并发 both 标记-整理 + 复制算法 响应速度优先 面向服务端应用,将来替换 CMS

四、内存分配与回收策略

对象的内存分配,也就是在堆上分配。主要分配在新生代的 Eden 区上,少数情况下也可能直接分配在老年代中。

1. 优先在 Eden 分配

大多数情况下,对象在新生代 Eden 区分配,当 Eden 区空间不够时,发起 Minor GC。

关于 Minor GC 和 Full GC:

  • Minor GC:发生在新生代上,因为新生代对象存活时间很短,因此 Minor GC 会频繁执行,执行的速度一般也会比较快。
  • Full GC:发生在老年代上,老年代对象和新生代的相反,其存活时间长,因此 Full GC 很少执行,而且执行速度会比 Minor GC 慢很多。

2. 大对象直接进入老年代

大对象是指需要连续内存空间的对象,最典型的大对象是那种很长的字符串以及数组。经常出现大对象会提前触发垃圾收集以获取足够的连续空间分配给大对象。

提供 -XX:PretenureSizeThreshold 参数,大于此值的对象直接在老年代分配,避免在 Eden 区和 Survivor 区之间的大量内存复制。

3. 长期存活的对象进入老年代

JVM 为对象定义年龄计数器,经过 Minor GC 依然存活,并且能被 Survivor 区容纳的,移被移到 Survivor 区,年龄就增加 1 岁,增加到一定年龄则移动到老年代中(默认 15 岁,通过 -XX:MaxTenuringThreshold 设置)。

4. 动态对象年龄判定

JVM 并不是永远地要求对象的年龄必须达到 MaxTenuringThreshold 才能晋升老年代,如果在 Survivor 区中相同年龄所有对象大小的总和大于 Survivor 空间的一半,则年龄大于或等于该年龄的对象可以直接进入老年代,无序等待 MaxTenuringThreshold 中要求的年龄。

5. 空间分配担保

在发生 Minor GC 之前,JVM 先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果条件成立的话,那么 Minor GC 可以确认是安全的;如果不成立的话 JVM 会查看 HandlePromotionFailure 设置值是否允许担保失败,如果允许那么就会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行一次 Minor GC,尽管这次 Minor GC 是有风险的;如果小于,或者 HandlePromotionFailure 设置不允许冒险,那这时也要改为进行一次 Full GC。

6. Full GC的触发条件

对于 Minor GC,其触发条件非常简单,当 Eden 区空间满时,就将触发一次 Minor GC。而 Full GC 则相对复杂,有以下条件:

6.1 调用 System.gc()

此方法的调用是建议 JVM 进行 Full GC,虽然只是建议而非一定,但很多情况下它会触发 Full GC,从而增加 Full GC 的频率,也即增加了间歇性停顿的次数。因此强烈建议能不使用此方法就不要使用,让虚拟机自己去管理它的内存。可通过 -XX:+ DisableExplicitGC 来禁止 RMI 调用 System.gc()。

6.2 老年代空间不足

老年代空间不足的常见场景为前文所讲的大对象直接进入老年代、长期存活的对象进入老年代等,当执行 Full GC 后空间仍然不足,则抛出 Java.lang.OutOfMemoryError。为避免以上原因引起的 Full GC,调优时应尽量做到让对象在 Minor GC 阶段被回收、让对象在新生代多存活一段时间以及不要创建过大的对象及数组。

6.3 空间分配担保失败

使用复制算法的 Minor GC 需要老年代的内存空间作担保,如果出现了 HandlePromotionFailure 担保失败,则会触发 Full GC。

6.4 JDK 1.7 及以前的永久代空间不足

在 JDK 1.7 及以前,HotSpot 虚拟机中的方法区是用永久代实现的,永久代中存放的为一些 class 的信息、常量、静态变量等数据,当系统中要加载的类、反射的类和调用的方法较多时,永久代可能会被占满,在未配置为采用 CMS GC 的情况下也会执行 Full GC。如果经过 Full GC 仍然回收不了,那么 JVM 会抛出 java.lang.OutOfMemoryError,为避免以上原因引起的 Full GC,可采用的方法为增大永久代空间或转为使用 CMS GC。

6.5 Concurrent Mode Failure

执行 CMS GC 的过程中同时有对象要放入老年代,而此时老年代空间不足(有时候“空间不足”是 CMS GC 时当前的浮动垃圾过多导致暂时性的空间不足触发 Full GC),便会报 Concurrent Mode Failure 错误,并触发 Full GC。

五、JVM监控工具

给一个系统定位问题的时候,知识、经验是关键基础,数据是依据,工具是运用知识处理数据的手段。

这些数据包括:

  • 运行日志
  • 异常堆栈
  • GC日志
  • 线程快照(threaddump/javacore)
  • 堆转储快照(heapdump/hprof)

1. jps:虚拟机进程状况工具

jps(JVM Process Status Tool)主要用于显示指定系统内所有的HotSpot虚拟机进程。

2. jstat:虚拟机统计信息监视工具

jstat(JVM Statistics Monitoring Tool)是用于监视虚拟机各种运行状态信息的命令行工具。它可以显示本地或者远程虚拟机进程中类装载、内存、垃圾收集等运行数据。

3. jinfo:Java配置信息工具

jinfo(Configuration Info for Java)的作用是实时地查看和调整虚拟机各项参数。

4. jmap:Java内存映像工具

jmap(Memory Map for Java)命令用于生成堆转储快照。jmap的作用不仅仅是为了获取dump文件,还可以查询finalize执行队列、Java堆和永久代的详细信息,如空间使用率、当前用的是哪种收集器等。

5. jhat:虚拟机堆转储快照分析工具

jhat(JVM Heap Analysis Tool)命令主要用于分析jmap生成的堆转储快照。

6. jstack:Java堆栈跟踪工具

jstack(Stack Trace for Java)命令用于生成虚拟机当前时刻的线程快照(一般称为threaddump或者javacore文件)。

7. JConsole:Java监视与管理控制台

JConsole(Java Monitoring and Management Console)是一种基于JMX的可视化监视管理工具。

8.VisualVM:多合一故障处理工具

VisualVM 是一款免费的,集成了多个 JDK 命令行工具的可视化工具,它能提供强大的分析能力,对 Java 应用程序做性能分析和调优。这些功能包括生成和分析海量数据、跟踪内存泄漏、监控垃圾回收器、执行内存和 CPU 分析,同时它还支持在 MBeans 上进行浏览和操作。

六、Class类文件解析

Java:一次编写,到处运行。Wirte Once, Run Anywhere。

各种不同平台的虚拟机与所有平台都统一使用的程序存储格式——字节码(ByteCode)是构成平台无关性的基石。

Java虚拟机不和包括Java在内的任何语言绑定,它只与“Class文件”这种特定的二进制文件格式所关联。

Class文件中包含了Java虚拟机指令集和符号表以及若干其他辅助信息。

1. Class类文件的结构

任何一个Class文件都对应着唯一一个类或接口的定义信息,但反过来说,类或接口并不一定都得定义在文件里(譬如类或接口也可以通过类加载器直接生成)。

Class文件是一组以8位字节为基础单元的二进制流,各个数据项目严格按照顺序紧凑地排列在Class文件之中,中间没有添加任何分隔符。

Class文件中只有两种数据类型:无符号数和表

无符号数属于基本的数据类型,可以用来描述数字、索引引用、数量值或者按照UTF-8编码构成字符串值。

表是有多个无符号数或者其他表作为数据项构成的复合数据类型,所有表都习惯性地以“_info”结尾。

Class文件中的数据项,无论是顺序还是数量,甚至于数据存储的字节序,都是被严格限定的,哪个字节代表什么含义,长度是多少,先后顺序如何,都不允许改变。

Class文件格式如下:

下面,我们就Class文件中各个数据项的具体含义进行分析。

  • 魔数(magic)

每个Class文件的头4个字节称为魔数(magic),它的唯一作用是判断该文件是否为一个能被虚拟机接受的Class文件。它的值固定为0xCAFEBABE。

  • Class文件版本(version)

紧接着magic的4个字节存储的是Class文件的次版本号和主版本号,高版本的JDK能向下兼容低版本的Class文件,但不能运行更高版本的Class文件。

  • 常量池(constant_pool)

紧接着主次版本号之后的是常量池入口,常量池可以理解为Class文件之中的资源仓库,它是Class文件结构中与其他项目关联最多的数据类型,也是占用Class文件空间最大的数据项目之一,同时它还是在Class文件中第一个出现的表类型数据项目。

常量池中主要存放两大类常量:字面量和符号引用

字面量比较接近于Java层面的常量概念,如文本字符串、被声明为final的常量值等。

符号引用总结起来则包括了下面三类常量:

  • 类和接口的全限定名(即带有包名的Class名,如:org.lxh.test.TestClass)
  • 字段的名称和描述符(private、static等描述符)
  • 方法的名称和描述符(private、static等描述符)

虚拟机在加载Class文件时才会进行动态连接,也就是说,Class文件中不会保存各个方法和字段的最终内存布局信息,因此,这些字段和方法的符号引用不经过转换是无法直接被虚拟机使用的。当虚拟机运行时,需要从常量池中获得对应的符号引用,再在类加载过程中的解析阶段将其替换为直接引用,并翻译到具体的内存地址中。

这里说明下符号引用和直接引用的区别与关联:

符号引用:符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标并不一定已经加载到了内存中。

直接引用:直接引用可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。直接引用是与虚拟机实现的内存布局相关的,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那说明引用的目标必定已经存在于内存之中了。

  • 访问标志(access_flags)

在常量池结束之后,紧接着的两个字节表示访问标志(access_flags),这个标志用于识别一些类或者接口层次的访问信息。包括:

  • 这个Class是类还是接口;
  • 是否定义为public类型;
  • 是否定义为abstract类型;
  • 如果是类的话,是否被声明为final等。

访问标志包括public/protected/private/abstract/final等等。

  • 类索引、父类索引与接口索引集合

类索引(this_class):用于确定这个类的全限定名(u2类型)。

父类索引(super_class):用于确定这个类的父类的全限定名(u2类型)(Java不允许多重继承!!!)。

接口索引集合(interfaces):用于描述这个类实现了哪些接口,这些被实现的接口将按implements语句后的接口顺序从左到右在接口索引集合中(u2类型数据集合)。

  • 字段表集合(field_info)

字段表(field_info)用于描述接口或者类中声明的变量。

字段(field)包括类级变量以及实例级变量,但不包括在方法内部声明的局部变量。

可以包括的信息包括:

  • 字段的作用域(public/private/protected修饰符)
  • 是实例变量还是类变量(static修饰符)
  • 可变性(final修饰符)
  • 并发可见性(volatile修饰符)
  • 可否被序列化(transient修饰符)
  • 字段数据类型(基本类型、对象、数组)
  • 字段名称等。

字段表格式如下:

其中的access_flags与类中的access_flags类似,是表示数据类型的修饰符,如public、static、volatile等。

后面的name_index和descriptor_index都是对常量池的引用,分别代表字段的简单名称及字段和方法的描述符。

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

  • 方法表集合(method_info)

方法表(method_info)的结构与字段表的结构相同,如下表所示。

方法里的Java代码,经过编译器编译成字节码指令后,存放在方法属性表集合中一个名为“Code”的属性里。

与字段表集合相对应,如果父类方法在子类中没有被覆写,方法表集合中就不会出现来自父类的方法信息。但同样,有可能会出现由编译器自动添加的方法,最典型的便是类构造器方法和实例构造器方法。

在Java语言中,要重载一个方法,除了要与原方法具有相同的简单名称外,还要求必须拥有一个与原方法不同的特征签名,特征签名就是一个方法中各个参数在常量池中的字段符号引用的集合,也就是因为返回值不会包含在特征签名之中,因此Java语言里无法仅仅依靠返回值的不同来对一个已有方法进行重载。

  • 属性表集合(attribute_info)

在前面的Class文件、字段表、方法表都可以携带自己的属性表集合,用于描述某些场景专有的信息。

Code属性:

Java程序方法体中的代码讲过Javac编译后,生成的字节码指令便会存储在Code属性中,但并非所有的方法表都必须存在这个属性,比如接口或抽象类中的方法就不存在Code属性。

Code属性是Class文件中最重要的一个属性,如果把一个Java程序中的信息分为代码和元数据两部分,那么在整个Class文件里,Code属性用于描述代码,所有的其他数据项目都用于描述元数据。

Exception属性:

这里的Exception属性的作用是列举出方法中可能抛出的受查异常,也就是方法描述时在throws关键字后面列举的异常。它的结构很简单,只有attribute_name_index、attribute_length、number_of_exceptions、exception_index_table四项,从字面上便很容易理解,这里不再详述。

LineNumberTable属性:

它用于描述Java源码行号与字节码行号之间的对应关系。

LocalVariableTable属性:

它用于描述栈帧中局部变量表中的变量与Java源码中定义的变量之间的对应关系。

SourceFile属性:

它用于记录生成这个Class文件的源码文件名称。

ConstantValue属性:

ConstantValue属性的作用是通知虚拟机自动为静态变量赋值,只有被static修饰的变量才可以使用这项属性。

在Java中,对非static类型的变量(也就是实例变量)的赋值是在实例构造器方法中进行的;而对于类变量(static变量),则有两种方式可以选择:

  • 在类构造其中赋值
  • 使用ConstantValue属性赋值

下面简要说明下final、static、static final修饰的字段赋值的区别:

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

InnerClasses属性:

该属性用于记录内部类与宿主类之间的关联。如果一个类中定义了内部类,那么编译器将会为它及它所包含的内部类生成InnerClasses属性。

Deprecated属性和Synthetic属性:

该属性用于表示某个类、字段和方法,已经被程序作者定为不再推荐使用,它可以通过在代码中使用@Deprecated注释进行设置。

Synthetic属性:

该属性代表此字段或方法并不是Java源代码直接生成的,而是由编译器自行添加的,如this字段和实例构造器、类构造器等。

2. 字节码指令简介

Java虚拟机的指令由一个字节长度的、代表某种特定操作含义的数字(称为操作码,opcode)以及跟随其后的零至多个代表此操作所需参数(称为操作数,oprands)而构成。

2.1 加载和存储指令

加载和存储指令用于将数据在栈帧中的局部变量表和操作数栈之间来回传输。

包括:*load/*store/*push/wide/......

2.2 运算指令

运算或算术指令用于对两个操作数栈上的值进行某种特定运算,并把结果重新存入到操作数栈顶。

包括:*add/*sub/*mul/*div/*rem/*neg/*sh*/*or/*and/*xor/*inc/*cmp*/...

2.3 类型转换指令

类型转换指令可以将两种不同的数值类型进行相互转换,这些转换操作一般用于实现用户代码中的显式类型转换操作。

Java虚拟机直接支持以下数值类型的宽化类型转换(Widening Numeric Conversions,即小范围类型向大范围类型的安全转换):

  • int类型到long/float/double类型;
  • long类型到float/double类型;
  • float类型到double类型。

相对的,处理窄化类型转换(Narrowing Numeric Conversions)时,必须显式地使用转换指令来完成。

包括:i2b/i2c/i2s/l2i/f2i/f2l/d2i/d2l/d2f/...

2.4 对象创建与访问指令

对象创建后,就可以通过对象访问指令获取对象实例或者数组实例中的字段或者数组元素。

包括:

  • 创建类实例的指令:new
  • 创建数组的指令:newarray/anewarray/multianewarray
  • 访问类字段(static字段)和实例字段(非static字段):getfield/putfield/getstatic/putstatic
  • 把一个数组元素加载到操作数栈的指令:baload/caload/saload/...
  • 将一个操作数栈的值存储到数组元素中的指令:bastore/castore/sastore/...
  • 取数组长度的指令:arraylength
  • 检查类实例类型的指令:instanceof/checkcast

2.5 操作数栈管理指令

Java虚拟机提供了一些用于直接操作操作数栈的指令。

包括:pop/pop2/dup*/swap/...

2.6 控制转移指令

控制转移指令可以让Java虚拟机有条件或无条件地从指定的位置指令而不是控制转移指令的下一条指令继续执行程序。

包括:

  • 条件分支:ifeq/iflt/...
  • 复合条件分支:tableswitch/lookupswitch
  • 无条件分支:goto/goto_w/jsr/jsr_w/ret

2.7 方法调用及返回指令

包括:

  • invokevirtual指令用于调用对象的实例方法,根据对象的实际类型进行分派
  • invokeinterface指令用于调用接口方法,它会在运行时搜索一个实现了这个接口方法的对象,找出适合的方法进行调用
  • invokespecial指令用于调用一些需要特殊处理的实例方法,包括实例初始化方法、私有方法和父类方法
  • invokestatic指令用于调用类方法(static方法)
  • invokedynamic指令用于在运行时动态解析出调用点限定符所引用的方法,并执行该方法

前面四条调用指令的分派逻辑都固化在Java虚拟机内部,而invokedynamic指令的分派逻辑是由用户所设定的引导方法决定的。

2.8 异常处理指令

在Java程序中显式抛出异常的操作(throw语句)都由athrow指令来实现。

2.9 同步指令

同步一段指令集序列通常是由Java语言中的synchronized语句块来表示的,Java虚拟机的指令集中有monitorentermonitorexit两条指令来支持synchronized关键字的语义。

四、虚拟机类加载机制

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

1. 类加载过程

在Java语言里面,类型的加载、连接、初始化过程都是在程序运行期间完成的。

特点:灵活性、动态扩展(运行期动态加载和动态连接)

类从被加载到虚拟机内存中开始,到卸载出内存为止,整个生命周期包括:

  • 加载(Loading)
  • 验证(Verification)
  • 解析(Resolution)
  • 初始化(Initialization)
  • 使用(Using)
  • 卸载(Unloading)

那么,什么情况下需要开始类加载过程的第一个阶段加载呢?!!有且只有五种情况!!

  • 遇到new/getstatic/putstatic/invokestatic这4条字节码指令时,如果类没有进行过初始化,则需要先触发其初始化(分别对应于:使用new实例化对象、读取或设置类的静态字段、调用一个类的静态方法)。
  • 使用java.lang.reflect包的方法对类进行反射调用的时候,如果类没有进行过初始化,则需要先触发其初始化。
  • 当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
  • 当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类。
  • 当使用动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果的方法句柄,并且这个方法句柄所对应的类没有进行过初始化,则需要先触发其初始化。

被动引用:

  • 通过子类引用父类的静态字段,不会导致子类初始化;
  • 通过数组定义来引用类,不会触发此类的初始化;
  • 常量在编译阶段会调入类的常量池中,本质上并没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化。(常量传播优化)

对于接口的加载过程,我们需要注意的是:当一个类在初始化时,要求其父类全部都已经初始化过了,但是一个接口在初始化时,并不要求其父接口全部都完成了初始化,只有在真正使用到父接口时才会初始化。

类加载过程主要包括加载、验证、准备、解析和初始化5个阶段。

1.1 加载

在加载阶段,虚拟机需要完成以下三件事情:

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

值得注意的是,虚拟机设计团队在加载阶段搭建了一个相当开放的、广阔的“舞台”。Java发展历程中,开发人员在这个舞台上玩出了各种花样,例如:

  • 从zip包中读取,最终成为jar/war格式的基础。
  • 从网络中获取,最典型应用就是applet。
  • 运行时计算生成,这种场景使用得最多得就是动态代理技术,在 java.lang.reflect.Proxy 中,就是用了 ProxyGenerator.generateProxyClass的代理类的二进制字节流。
  • 由其他文件生成,典型场景是 JSP 应用,即由 JSP 文件生成对应的 Class 类。
  • 从数据库读取,这种场景相对少见,例如有些中间件服务器(如 SAP Netweaver)可以选择把程序安装到数据库中来完成程序代码在集群间的分发。

非数组类的加载

对于非数组类的加载,既可以使用系统提供的引导类加载器来完成,也可以由用户自定义的类加载器去完成。开发人员可以通过定义自己的类加载器去控制字节流的获取方式。

数组类的加载

数组类本身不通过类加载器去创建,而是由Java虚拟机直接创建。一个数组类创建过程遵循以下规则:

  • 如果数组的组件类型是引用类型,采用加载过程去加载这个组件类型。

  • 如果数组的组件类型不是引用类型,Java虚拟机将会把数组类标记为与引导类加载器关联。

  • 数组类的可见性与它的组件类型的可见性一致。

1.2 验证

验证阶段的目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害到虚拟机自身的安全。验证是虚拟机对自身保护的一项重要工作。

从整体上看,验证阶段大致上会完成下面4个阶段的检验动作:

  • 文件格式验证

第一阶段要验证字节流是否符合Class文件格式的规范,并且能被当前版本的虚拟机处理。该验证阶段的主要目的是保证输入的字节流能正确地解析并存储于方法区之内,格式上符合一个Java类型信息的要求

  • 元数据验证

第二阶段是对字节码描述的信息进行语义分析,以保证其描述的信息符合Java语言规范的要求。该验证阶段的主要目的是对类的元数据信息进行语义校验,保证不存在不符合Java语言规范的元数据信息

  • 字节码验证

第三阶段是对类的方法体进行校验分析,保证被校验类的方法在运行时不会做出危害虚拟机安全的事件。该验证阶段的主要目的是通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的

  • 符号引用验证

第四阶段是对类自身以外的信息进行匹配性校验。该验证阶段的主要目的是确保解析动作能正常执行,如果无法通过符号引用验证,那么将会抛出一个java.lang.IncompatibleClassChangeError异常的子类

对于虚拟机的类加载机制来说,验证阶段是一个非常重要的、但不是一定必要的阶段。

1.3 准备

准备阶段是为类变量分配内存并设置类变量初始值的阶段。

注意:此时进行内存分配的仅包括类变量(static修饰的变量),而不包括实例变量。

考虑下面一个问题:

试比较下面两种情况下在准备阶段后value对应的值是多少。

// 情形一
public static int value = 123;

// 情形二
public static final int value = 123;

答案是:对于情形一,准备阶段后value的值为0;对于情形二,准备阶段后value的值为123。

原因在于,情形一下value的赋值操作是在部分完成的,而在情形二下,value对应为ConstantValue属性。

1.4 解析

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

符号引用(Symbolic References):符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。
直接引用(Direct References):直接引用可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。

虚拟机规范中并未规定解析阶段发生的具体时间。

解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符7类符号引用进行。

类或接口的解析

假设当前代码所处的类为D,如果要把一个从未解析过的符号引用N解析为一个类或接口C的直接引用,虚拟机完成整个解析的过程需要以下三个步骤:

  • 如果C不是一个数组类型,那虚拟机会把代表N的全限定名传递给D的类加载器去加载这个类C。
  • 如果C是一个数组类型,并且数组的元素类型为对象,也就是N的描述符会是类似“[Ljava/lang/Integer”的形式,那将会按照第一点的规则加载数组元素类型。
  • 如果上面的步骤没有出现任何异常,那么C在虚拟机中实际上已经成为一个有效的类或接口了,但在解析完成之前还要进行符号引用验证,确认D是否有访问C的权限。

字段解析

要解析一个未被解析过的字段符号引用,首先会对字段表内class_index项中索引的CONSTANT_Class_info符号引用进行解析。虚拟机规范要求按照以下步骤进行搜索:

  • 如果C本身就包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束;
  • 否则,如果在C中实现了接口,将会按照继承关系从下往上递归搜索各个接口和它的父接口,如果接口中包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束;
  • 否则,如果C不是java.lang.Object的话,将会按照继承关系从下往上递归搜索其父类,如果在父类中包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束;
  • 否则,查找失败,抛出java.lang.NoSuchFieldError异常。

类方法解析

对于类方法解析,其首先需要先解析出类方法表class_index项中索引的方法所属的类或接口的符号引用,如果解析成功,接下来虚拟机将会按照如下步骤进行后续的类方法搜索:

  • 类方法和接口方法符号引用的常量类型定义是分开的,如果在类方法表中发现class_index中索引的C是个接口,那就直接抛出java.lang.IncompatibleClassChangeError异常;
  • 如果通过了第一步,在类C中查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束;
  • 否则,在类C的父类中递归查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接饮用,查找结束;
  • 否则,在类C实现的接口列表以及它们的父接口之中递归查找是否有简单名称和描述符都与目标相匹配的方法,如果存在匹配的方法,则说明类C是一个抽象类,这是查找结束,抛出java.lang.AbstractMethodError异常;
  • 否则,宣告方法查找失败,抛出java.lang.NoSuchMethodError。

接口方法解析

对于接口方法解析,也需要先解析出接口方法表的class_index项中索引的方法所属的类或接口的符号引用,解析成功后,接下来虚拟机将会按照如下步骤进行后续的接口方法搜索:

  • 与类方法不同,如果在接口方法表中发现class_index中的索引C是个类而不是接口,则直接抛出java.lang.IncompatibleClassChangeError异常;
  • 否则,在接口C中查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束;
  • 否则,在接口C的父接口中递归查找,直到java.lang.Object类为止,看是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束;
  • 否则,宣告方法查找失败,抛出java.lang.NoSuchMethodError异常。

1.5 初始化

类初始化阶段是类加载的最后一步。在准备阶段,变量已经赋过一次系统要求的初始值,而在初始化阶段,则根据程序员通过程序制定的主观计划去初始化类变量和其他资源。也就是说,初始化阶段是执行类构造器方法的过程。

  • 方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序所决定的,静态语句块只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值,但是不能访问。例如:
public class Test {
    static {
        i = 0;    // 给变量赋值可以正常编译通过
        System.out.println(i); // 非法前向引用!!!
    }

    static int i = 1;
}
  • 方法与类的构造函数(实例构造器)不同,它不需要显式地调用父类构造器,虚拟机会保证在子类的方法执行之前,父类的方法已经执行完毕
  • 由于父类的方法先执行,也就有,父类中定义的静态语句块要优先于子类的变量赋值操作
  • 方法对于类或接口来说并不是必需的,如果一个类中没有静态语句块,也没有对变量的赋值操作,那么编译器可以不为这个类生成方法。
  • 接口中不能使用静态语句块,但仍然有变量初始化的赋值操作。但是接口与类不同的是,执行接口的方法不需要先执行父接口的方法,只有当父接口中定义的变量使用时,父接口才会初始化
  • 虚拟机会保证一个类的方法在多线程环境中被正确地加锁、同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的方法,其他线程都需要阻塞等待,直到活动线程执行方法完毕。同时,需要注意的是,其他线程虽然会被阻塞,但如果执行方法的那条线程退出方法后,其他线程唤醒之后不会再次进入方法。同一个类加载器下,一个类型只会初始化一次。

2. 类加载器

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

2.1 类与类加载器

对于任意一个类,都需要由加载它的类加载器和这个类本身一同确立其在 Java 虚拟机中的唯一性,每一个类加载器,都拥有一个独立的类名称空间。通俗而言:比较两个类是否“相等”(这里所指的“相等”,包括类的 Class 对象的 equals() 方法、isAssignableFrom() 方法、isInstance() 方法的返回结果,也包括使用 instanceof() 关键字做对象所属关系判定等情况),只有在这两个类是由同一个类加载器加载的前提下才有意义,否则,即使这两个类来源于同一个 Class 文件,被同一个虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相等。

2.2 类加载器分类

从 Java 虚拟机的角度来讲,只存在以下两种不同的类加载器:

  • 启动类加载器(Bootstrap ClassLoader),这个类加载器用 C++ 实现,是虚拟机自身的一部分;
  • 所有其他类的加载器,这些类由 Java 实现,独立于虚拟机外部,并且全都继承自抽象类 java.lang.ClassLoader。

从 Java 开发人员的角度看,类加载器可以划分得更细致一些:

  • 启动类加载器(Bootstrap ClassLoader) 此类加载器负责将存放在 \lib目录中的,或者被-Xbootclasspath参数所指定的路径中的,并且是虚拟机识别的(仅按照文件名识别,如 rt.jar,名字不符合的类库即使放在 lib 目录中也不会被加载)类库加载到虚拟机内存中。 启动类加载器无法被 Java 程序直接引用,用户在编写自定义类加载器时,如果需要把加载请求委派给启动类加载器,直接使用 null 代替即可。
  • 扩展类加载器(Extension ClassLoader) 这个类加载器是由 ExtClassLoader实现的。它负责将/lib/ext或者被 java.ext.dir系统变量所指定路径中的所有类库加载到内存中,开发者可以直接使用扩展类加载器。
  • 应用程序类加载器(Application ClassLoader) 这个类加载器是由AppClassLoader实现的。由于这个类加载器是 ClassLoader 中的 getSystemClassLoader() 方法的返回值,因此一般称为系统类加载器。它负责加载用户类路径(ClassPath)上所指定的类库,开发者可以直接使用这个类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。

2.3 双亲委派模型

应用程序都是由三种类加载器相互配合进行加载的,如果有必要,还可以加入自己定义的类加载器。下图展示的类加载器之间的层次关系,称为类加载器的双亲委派模型(Parents Delegation Model)。该模型要求除了顶层的启动类加载器外,其余的类加载器都应有自己的父类加载器,这里类加载器之间的父子关系一般通过组合(Composition)关系来实现,而不是通过继承(Inheritance)的关系实现。

工作过程

如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载,而是把这个请求委派给父类加载器,每一个层次的加载器都是如此,依次递归。因此所有的加载请求最终都应该传送到顶层的启动类加载器中,只有当父加载器反馈自己无法完成此加载请求(它搜索范围中没有找到所需类)时,子加载器才会尝试自己加载。

好处

使用双亲委派模型来组织类加载器之间的关系,使得 Java 类随着它的类加载器一起具备了一种带有优先级的层次关系。例如类 java.lang.Object,它存放在 rt.jar 中,无论哪个类加载器要加载这个类,最终都是委派给处于模型最顶端的启动类加载器进行加载,因此 Object 类在程序的各种类加载器环境中都是同一个类。相反,如果没有双亲委派模型,由各个类加载器自行加载的话,如果用户编写了一个称为java.lang.Object 的类,并放在程序的 ClassPath 中,那系统中将会出现多个不同的 Object 类,程序将变得一片混乱。如果开发者尝试编写一个与 rt.jar 类库中已有类重名的 Java 类,将会发现可以正常编译,但是永远无法被加载运行。

实现

protected synchronized Class loadClass(String name, boolean resolve) throws ClassNotFoundException{
    //check the class has been loaded or not
    Class c = findLoadedClass(name);
    if(c == null) {
        try{
            if(parent != null) {
                c = parent.loadClass(name, false);
            } else{
                c = findBootstrapClassOrNull(name);
            }
        } catch(ClassNotFoundException e) {
            //if throws the exception , the father can not complete the load
        }
        if(c == null) {
            c = findClass(name);
        }
    }
    if(resolve) {
        resolveClass(c);
    }
    return c;
}

五、参考资料

  • 深入理解 Java 虚拟机
  • Jvm memory
  • Memory Architecture Of JVM(Runtime Data Areas)

你可能感兴趣的:(技术原理,编程语言)