JAVA虚拟机

本博客内容为《深入理解Java虚拟机:JVM高级特性与最佳实践》的阅读笔记。

1 Java技术体系

仅从传统意义上来看,Sun官方所定义的Java技术体系包括以下几个组成部分:

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

其中Java程序设计语言、Java虚拟机和Java API类库这三部分统称为JDK(Java Development Kit)。JDK是用于支持Java程序开发的最小环境,即如果你要进行Java开发,你至少要在你的开发机器上安装JDK。

Java SE API子集和Java虚拟机这两部分统称为JRE,JRE是支持Java程序运行的标准环境,如果你不需要进行Java程序开发,只是要运行Java程序,例如运行Jar文件,那么你可以在你的运行机器上只安装JRE。

如下图所示,展现了Java技术体系所包含的内容,以及JDK和JRE涵盖的范围:
JAVA虚拟机_第1张图片
Java技术体系按照关注的重点业务领域来划分,可以分为四个平台:

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

2 Java 发展史

2.1Java版本发展史

下图为Java各版本的发展发布时间表(截图来自维基百科):
JAVA虚拟机_第2张图片

2.2Java各类虚拟机
  1. Sun Classic VM:1996年1月23日,Sun公司发布JDK1.0,Java语言首次拥有了商用的正式运行环境,这个JDK中所带的虚拟机就是Classic VM。这款虚拟机只能用纯解释器方式来执行Java代码。
  2. Exact VM:JDK1.2时曾在Solaris平台上发布了Exact VM虚拟机,它的执行效率已经具备高性能虚拟机的雏形,如两级即时编译器、编译器和解释器混合工作模式等。
  3. Sun HotSpot VM:由一家名为“Longview Technologies”的小公司设计,1997被Sun公司收购。HotSpot VM是当前Sun JDK和OpenJDK中所自带的虚拟机,也是目前使用范围最广的虚拟机。
  4. Sun Mobile-Embedded VM/Meta-Circular VM:Sun公司还发布过其他的一些虚拟机,例如KVM、CDC/CLDC HotSpot Implementation、Squawk VM、JavaInJava、Maxine VM等。
  5. BEA JRockit/IBM J9 VM:除了Sun公司发布的虚拟机意外,其他组织和公司也曾发布过一些虚拟机,其中规模最大、最著名的就是BEA发布的BEA JRokkit VM和IBM的IBM J9 VM虚拟机了。
  6. Azul VM/BEA Liquid VM:除了我们平时所提及到的一些高性能Java虚拟机以外,还有Azul VM和BEA Liquid VM这类特定硬件平台专有的虚拟机,这类虚拟机才是真正的高性能虚拟机。
  7. Apache Harmony/Google Android Dalvik VM:Apache Harmony是一个Apache软件基金会旗下以Apache License协议开源的实际兼容于JDK1.5和JDK1.6的Java程序运行平台,它包含子集的虚拟机和Java库,用户可以在上面运行Eclipse、Tomcat、Maven等常见的Java程序,但是该虚拟机一直没有通过TCCK认证。Dalvik VM是Android平台的核心组成部分之一。

3 Java运行时数据区域(JVM Runtime Data Areas)

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

通常情况下Java虚拟机运行时数据区域分为如图所示的几个区域:
JAVA虚拟机_第3张图片
程序计数器

程序计数器(Program Counter Register)即图中的PC Register,它是一块较小的内存空间,可以看作是当前线程所执行的字节码的行号指示器。在虚拟机的模型概念中,字节码解释器的工作就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。

由于Java虚拟机的多线程是通过线程轮流切换处理器执行时间的方式实现的,在任何一个确定的时刻,一个处理器的一个核只会执行一条线程中的指令,因此,为了线程切换后能够恢复到正确的执行位置,每一条线程都需要拥有一个独立的程序计数器,各条线程之间的计数器互不影响,独立存储,这类内存区域称为“线程私有”的内存,即如上图所示,每一个线程都会拥有自己的一块内存区域。

程序计数器在执行本地方法时(例如调用C语言代码)计数器值为空,其他时候则是指向正在执行的虚拟机字节码指令的地址。

程序计数器是在Java虚拟机规范中唯一一个没有规定任何OutOfMemoryError情况的区域,因为Java程序计数器它所需要存储的内容仅仅就是下一个需要待执行的命令的地址,其所需内存是创建时即可只晓的,不需要后期进行扩容等其他的操作。

Java虚拟机栈

Java虚拟机栈(Java Virtual Machine Stacks)即图中的Stack,Java虚拟机栈也是线程私有的,它的生命周期与线程相同。Java每个方法在执行的同时都会创建一个栈帧用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法从调用直至方法执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。

虚拟机栈中局部变量表部分与Java对象内存分配关系密切,局部变量表存放了编译器可知的各种基本数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference类型,该类型可能是一个指向对象起始地址的引用指针,也可能是一个代表对象的句柄或其他于此对象相关的位置)和returnAddress类型(指向了一条字节码指令的地址)。

局部变量表中,64位长度的long和double类型的数据会占用2个局部变量空间,其余的数据类型只占用一个。局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。

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

本地方法栈

本地方法栈(Native Method Stack)与虚拟机栈作用类似,它们之间的区别是虚拟机栈为虚拟机执行Java方法,而本地方法栈则为虚拟机执行Native方法服务。有些虚拟机会将本地方法栈和虚拟机栈合二为一。与虚拟机栈一样,本地方法栈也会抛出StackOverflowErro和OutOfMemoryError异常。

Java堆

Java堆(Java Heap)即图中的Heap区域,对于大多数的应用来说,Java堆是虚拟机所管理的最大的一块内存。Java堆是被所有的线程所共享的,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都是在这里分配内存的(Java虚拟机规范中描述为所有的对象实例和数组都要在堆上分配内存)。

Java堆是垃圾收集器管理的主要区域,因此很多时候也被称为GC。从内存回收的角度来看,由于现在收集器基本都是采用分代算法收集器,所以Java堆中还可以细分为:新生代和老年代;再细致一点可以分为Eden空间、From Survivor空间、To Survivor空间等。从内存分配的角度来看,线程共享的Java堆中可能划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer,TLAB)。

根据Java虚拟机规范,Java堆可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可,在实现时既可以是固定大小的,也可以是可拓展的,当前主流的虚拟机都是按照可拓展来实现的。如果在堆中没有内存完成实例分配,并且堆也无法再拓展时,将会抛出OutOfMemoryError异常。

方法区

方法区(Method Area)与Java堆一样,是线程共享的,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。

Java虚拟机规范堆方法区的限制非常宽松,除了和Java堆一样不需要连续的内存和可以选择固定大小或者可拓展外,还可以选择不实现垃圾收集。相对而言,垃圾收集行为在这个区域是比较少出现的,这个区域的内存回收目标主要是针对常量池的回收和对类型的卸载,一般来说,这个区域的内存回收成绩比较令人难以满意,尤其时类型卸载,条件相当苛刻,但是这个区域的内存回收也是必要的。

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

运行时常量池

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

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

当常量池无法再申请到内存时会抛出OutOfMemoryError异常。

直接内存

直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是Java虚拟机规范所定义的内存区域,但是这部分内存也被频繁的使用,而且也可能导致OutOfMemoryError异常出现。

在JDK1.4中新加入的NIO(New Input/Output)类,引入了一种基于通道与缓冲区的I/O方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆中的DirectByteBuffer对象作为这块内存的引用进行操作,这样能在一些场景中显著提高性能,因为避免了在Java堆中和Native堆中来回复制数据。

直接内存虽然不会收到Java堆大小的限制,但是受到本机总内存大小以及处理器寻址空间的限制,如果忽略了直接内存,当各个区域内存总和大于服务器内存时,将会导致动态拓展时出现OutOfMemoryError异常。

各数据区域异常发生举例

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

虚拟机栈和本地方法栈溢出:如果线程请求的栈深度大于虚拟机所允许的最大深度,将抛出StackOverFlowError异常;如果虚拟机在拓展时无法申请到足够的内存空间,则抛出OutOfMemoryError异常。

方法区和运行时常量池溢出:通过不断的创建字符串常量,同时还要保证这些字符串常量不被垃圾回收机制回收。

4 Java对象

4.1对象的创建

Java程序在运行的过程中无时无刻都有对象被创建出来。在语言层面上,创建对象通常仅仅是一个new关键字,但是在虚拟机中,则是一个复杂的创建过程。

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

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

Java对象内存分配有两种方式,第一种是“指针碰撞”,即假设Java堆中的内存是绝对规整的,所有用过的内存都放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点指示器,那所分配内存就仅仅是把那个指针向空闲空间那边挪动一段与对象所需内存大小相等的距离。第二种分配方式为“空闲列表”,当Java堆中的内存并不是规整的,已使用的内存和空闲的内存相互交错,那就没有办法简单的使用指针碰撞这种方式来进行内存分配了,虚拟机这时候必须要维护一个列表,记录内存中哪些是空闲的,在分配内存的时候从列表中找到一块足够大的内存空间划分给对象,并更新列表。因此使用哪一种分配方式是由Java堆是否规整来决定的,而Java堆是否规整则是由所采用的垃圾收集器是否带有压缩整理功能来决定的。

除了如何划分可用空间外,还有一个需要考虑的问题是对象创建是一个非常频繁的过程,在并发情况下修改指针所指向的位置是不安全的,可能对象A和对象B被分配在了同一个内存块中。解决这个问题的方案有两种,一种是分配内存空间的动作进行同步处理,另一种是把内存分配动作按照线程划分在不同的空间之中进行,即每一个线程在Java堆中预先分配一小块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer,TLAB)。

内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),如果使用TLAB,这一工作过程也可以提前至TLAB分配时进行。这一步操作保证了对象的实例字段在Java代码中可以不赋值就直接使用。

接下来虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息。这些信息都存放在对象头中。根据虚拟机当前的运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。

当上述的过程都执行完成以后,从虚拟机的角度,一个新的Java对象已经创建完成了,但是对于Java程序而言,对象创建才刚刚开始,执行完new指令后会接着按照程序员的意愿对对象进行各种初始化的操作,至此一个对象就算真正的创建完成了。

4.2对象的内存布局

在HotSpot虚拟机中,对象在内存中存储的的布局可以分为3块区域,对象头(Header)、实例数据(Instance Data)和对其填充(Padding)。

对象头

对象头包括两部分的信息,第一部分用于存储对象自身的运行时数据,如哈希码、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,这部分数据的长度在32位和64位虚拟机中分别为32bit和64bit,官方称它为“Mark Word”。对象头的另一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。如果对象是一个Java数组,对象头中还必须有一块用于记录数组长度的数据,虚拟机可以以此来确定数组的大小。

实例数据

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

对齐填充

对齐填充并不是必然存在的,也没有特别的含义,它仅仅起着占位符的作用(因为HotSpot虚拟机的自动内存管理要求对象起始地址必须是8的整数倍)。

4.3对象的访问定位

建立对象是为了使用对象,Java程序需要通过栈上的reference数据来操作堆上的具体对象。由于reference类型在Java虚拟机中只规定了一个指向对象的引用,并没有定义这个引用以何种方式去定位、访问堆中对象的具体位置,所以对象访问方式也是取决于虚拟机实现而定的,目前主流的访问方式有使用句柄和直接指针两种方式。

使用句柄

如果使用句柄访问的话,那么Java堆中将会划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息,如下图所示(图片来自《深入理解Java虚拟机:JVM高级特性与最佳实践》):

JAVA虚拟机_第4张图片

使用句柄相当于在reference和对象中间增加了一层句柄层,对象的reference中只需要记录一个句柄的地址,而句柄中记录了对象中各个对象实例数据、对象类型数据的地址,访问时一共需要进行两次的指针定位,第一次先找到句柄,第二次通过句柄找到数据。

句柄访问的好处是当对象被移动后,只需要改变句柄中的实例数据指针即可,不需要修改reference。

直接指针

如果使用直接指针访问,那么Java堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,而reference中存储的直接就是对象地址,如下图所示(图片来自《深入理解Java虚拟机:JVM高级特性与最佳实践》):

JAVA虚拟机_第5张图片

使用指针进行访问只需要进行一次指针定位就可以找到对象数据,在Java中对象的访问是非常频繁的,因此一次指定定位能够节省非常大的一部分开销。就HotSpot而言,它使用的就是指针访问的方式。

5 垃圾回收机制

5.1对象是否存活

对象存活表示的是当前对象是否还在被使用,没有被使用的对象我们可以称其为已经“死亡”,如果对象依然在被使用,我们称其为“存活”状态,对象是否被使用则是通过对象的引用进行判断的。而垃圾回收机制就是负责将已经死亡的对象进行清理。

程序计数器、虚拟机栈、本地方法栈3个区域随线程而生,随线程而灭,栈中的栈帧随着方法的进入和退出而有条不紊地执行着出栈和入栈的操作。每一个栈帧中分配多少内存基本上是在类结构确定下来时就已知的,因此这几个区域的内存分配和回收都具备确定性,在这几个区域不需要过多的考虑回收的问题,当方法结束或者线程结束的时候,内存自然就跟着回收了。Java堆则和上述三种区域不同,Java中一个接口的多个实现类需要的内存可能不一样,一个方法中的多个分支需要的内存也不一样,而只有当Java程序运行时我们才能知道哪些对象会被创建,所以堆中的内存分配和回收都是动态进行的,因此垃圾收集器所关注的也是这部分的内存。

垃圾回收器在对堆进行回收前,第一件事情就是要判断堆中的对象哪些是依旧在使用的,哪些已经不可能再被使用了。这里的判断主要有两种方式,第一种是引用计数算法,第二种是可达性分析算法。

引用计数算法

引用计数算法给对象添加一个引用计数器,每当有一个地方引用它时,计数器就加1,当引用失效时,计数器就减1,任何时刻计数器为0的对象就是不可能再被使用的。这种算法实现简单,判定效率也很高,但是它难以解决对象之间循环引用的问题,例如对象A和对象B相互引用了对方,而A和B都没有在被使用了,但这两个对象却也不会被垃圾回收器回收。

可达性分析算法

主流的判断方法则是使用可达性分析算法来判断对象是否存活。这个算法需要选择一些对象作为“GC Roots”,每次都通过这些roots节点向下搜索,搜索所走过的路径称为引用链,当一个对象到GC Roots不存在引用链的时候,则证明这个对象是不可用的。

如下图所示,对象4和对象5都没有与GC Roots连接,因此对象4和对象5将可以被垃圾回收器进行回收。
JAVA虚拟机_第6张图片

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

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

可达性分析算法中根据GC Roots找引用链,存在两个主要的问题,一个是可作为GC Roots的节点主要在全局性的引用(例如常量或者类静态属性)于上下文(例如栈帧中的本地变量表)中,现在很多应用仅仅方法区就有数百兆,如果要逐个检查这里面的引用,将会消耗很多的时间。还有一个问题是GC停顿,可达性分析必须确保在整个的分析过程中,执行系统就像被冻结在某个时间节点,整个分析过程中对象的引用关系不能发生变化,这样才能保证分析结果的准确性,因此在进行GC时,需要停顿所有的Java线程。

5.2对象引用分类

JDK1.2以前,Java中引用的定义很传统,如果reference类型的数据中存储的数值代表的是另一块内存的起始地址,就称这块内存代表着一个引用。这种定义下的对象只存在两种状态,被引用和未被引用状态。但有些对象我们希望当内存存够的时候能够保留这些对象,当内存不足的时候则能够对这些对象进行清理,这一类对象则无法使用这种传统的定义来表示。

JDK1.2之后,Java对引用进行了扩充,将引用分为强引用、软引用、弱引用和虚引用四种,这四种引用的强度依次逐渐减弱。

  • 强引用就是指在程序代码中普遍存在的,类似”Object obj = new Object()“这类的引用,只要强引用还存在,垃圾收集器永远不会回收被引用的对象。即使内存不足时,垃圾回收器也不会回收强引用的对象,而是会直接抛出OutOfMemoryError异常。如果想让强引用对象被回收,可以手动设置obj = null;来实现。
  • 软引用是用来描述一些还有用但并非必需的对象。对于软引用关联着的对象,在内存足够的时候,是不会回收软引用的对象的,而在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围之中进行第二次回收,如果软引用回收后依然内存不足,则会抛出OutOfMemoryError异常。在JDK1.2之后,提供了SoftReference类来实现软引用。软引用可以用来实现缓存技术。
  • 弱引用和软引用一样用来描述非必须的对象,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生之前。当垃圾收集器工作时,无论当前内存是否足够,都会回收掉被弱引用关联的对象。在JDK1.2之后,提供了WeakReference类来实现弱引用。
  • 虚引用也被称为幽灵引用或者幻影引用,它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。在JDK1.2之后,提供了PhantomReference类来实现虚引用。
5.2对象存活判定

即使在可达性分析算法中不可达的对象,也并非是”非死不可“的,这时候它们暂时处于”缓刑“阶段,要宣告一个对象死亡,至少要经历两次标记过程:如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,那它将会被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法(当对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,虚拟机将这两种情况都视为”没有必要执行“)。如果一个对象被判定为有必要执行finalize()方法,那么这个对象将会放置在一个叫做F-Queue的队列之中,并在稍后由一个由虚拟机自动建立的、低优先级的Finalizer线程去执行它。这里的执行是指由虚拟机去触发这个方法,但并不一定会等待该方法执行完毕(为了避免finalize方法中出现类似死循环都操作,导致内存无法被回收,同时导致F-Queue队列中的其他对象一直处于等待状态)。当执行完finalze()方法后,GC将会对F-Queue中的对象进行第二次小规模的标记,如果对象在finalize()方法中又重新获得了引用,对象将会被移出对列并且继续存活,如果对象依旧存在于队列中并且被进行第二次标记,对象将被GC回收。

需要注意的是任何一个对象的finalize()方法只会执行一次,如果第一次通过finalize()方法救活了对象,那么第二次相同的方法就会失效。同时由于finalize()方法的运行代价高昂,不确定性大,无法保证各个对象的调用顺序,因此应当尽量避免使用finalize()方法。

5.3 方法区的垃圾回收

常规应用中进行一次垃圾回收,能够回收70%~95%的空间,而方法区中回收效率远低于此。方法区中回收的主要内容是:废弃常量和无用的类。

废弃常量回收与Java堆中的内存回收非常的类似,例如一个字符串,当没有任何String对象引用常量池中该字符串,也没有任何其他地方引用这个字面量时,该字符串常量将可以在下一次垃圾回收时被回收。

无用的类判定就会比较麻烦。类需要同时满足下面三个条件才算是”无用的类“:

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

当一个类满足上述条件时,该类将可以被回收,但不是说该类不使用了就必然会被回收,类的回收可以通过-Xnoclassgc参数进行配置。在大量使用反射、动态代理、CGLib等ByteCode框架、动态生成JSP已经OSGi这类频繁自定义ClassLoader的场景中都需要虚拟机具备类卸载的功能,以保证永久代不会溢出。

5.4垃圾收集算法

通过之前的方法,已经能确定哪些内存空间要被回收,而垃圾收集算法的目的是在已经明确了哪些内存块需要回收以后,如何高效的回收这些内存空间。

标记清除算法
标记清除算法是最基础的收集算法,后续很多算法都是基于它的思路进行改造而得出的,如同它的名字一样,算法分为两个阶段:首先标记出所有需要回收的对象(这里的标记就是使用之前提到的引用计数或者可达性分析算法),然后对所有被标记的对象进行统一回收。
JAVA虚拟机_第7张图片
标记清除算法主要有两个不足之处:一个是效率问题,标记和清除两个过程的效率都不高;另一个问题是空间问题,标记清除之后会造成内存空间中存在大量的内存碎片,空间碎片太多时,当要分配一片大内存空间时可能会找不到合适的连续内存空间进行分配,从而触发另一次垃圾收集动作。

复制算法

复制算法将内存按照容量划分为大小相等的两块,每次只使用其中的一块。当其中一块的内存用完了,就把这块内存中已存活的对象全部移动到另一块,再把已使用的这块内存全部清理掉。这样使得每次都是对整个半区的内存进行回收,避免了碎片空间的产生。这种算法实现简单,运行高效,但是要付出一般的内存空间作为代价来实现。
JAVA虚拟机_第8张图片
现在的商业虚拟机都会采用这种算法来回收新生代,根据统计新生代中98%的对象都是“朝夕生死”的,因此对于新生代的回收不用按照1:1的比例来进行内存划分,可以将内存划分为一块Eden区域和两块Survivor空间,每次使用时都选择Eden区域和一块Survivor区域进行内存分配。当回收时,将Eden区域和Survivor区域中还存活的对象全部移动到另一块Survivor区域,然后清理掉Eden区域和刚刚使用的Survivor区域。HotSpot虚拟机中Eden和Survivor的比例是1:8,即每次都有90%的内存空间在进行使用,只有10%的内存空间被浪费了。当然,如果每次内存都有98%被回收,那么每次被移动到另一块Survivor区域的内存只有2%,这样是没有任何问题的,但是如果移动到另一块Survivor区域的内存超过了10%,就需要依赖其他的内存(这里指老年代)进行分配担保了(将多出的对象分配到老年代)。

标记-整理算法

标记-整理算法的标记过程和标记-清除算法的标记过程一致,但是在标记完以后,标记-整理算法会将所有存活的对象都移动到一端,然后再进行清除。这种算法适用于老年代,因为老年代的对象存活率都会比较高,如果像之前一样进行复制移动,将会产生大量的复制操作导致效率变低,同时每次都会存活下大量对象导致需要很多的内存空间来进行分配担保。
JAVA虚拟机_第9张图片
分代收集算法

当前商业虚拟机的垃圾收集都采用“分代收集”算法,这种算法根据对象存活周期的不同将内存划分为几块,一般是把Java堆划分位新生代和老年代。新生代中每次都会有大批对象死去,只有少量对象存活,因此可以选用复制算法。老年代每次都会有大量对象存活,因此选择标记-清理或者标记-整理算法来进行。

6 再谈对象内存分配

Java内存体系中所提倡的自动内存管理最终可以归结为自动化地解决两个问题:给对象分配内存和回收分配给对象的内存。

对象的内存分配,往大方向讲,就是在堆上分配,对象主要分配在新生代的Eden区域,如果启动了本地线程分配缓冲,将按线程优先分配在TLAB上。少数情况下也可能直接分配在老年代中。具体的分配规则取决于垃圾收集器的类型以及虚拟机中参数的配置。但是有几条最普遍的内存分配规则如下:

  • 对象优先在Eden分配:大多数情况下,对象在新生代Eden区进行分配。当Eden区没有足够内存进行分配时,虚拟机将会发起一次Minor GC。
  • 大对象直接进入老年代:所谓大对象,是指需要大量连续存储空间的Java对象,最典型的大对象就是那种很长的字符串或者数组。大对象对虚拟机分配来说是一个坏消息,经常出现大对象会导致虚拟机需要经常调用GC来为这些大对象整理出足够的连续空间。
  • 长期存活的对象将进入老年代:既然虚拟机采用了分代收集的思想来管理内存,那么内存回收时就必须能识别哪些对象应该放在新生代,哪些对象应该放在老年代。为了做到这一点,虚拟机给每一个对象定义了一个对象年龄计数器。如果对象在Eden出生并且经过了第一次Minor GC后仍然存活,并且能够被Survivor容纳的话,将被移动到Survivor空间中,并且对象年龄设置为1。对象在Survivor区域中每熬过一次Minor GC,年龄就增加1岁,当它的年龄增加到一定程度(默认为15岁)对象将会被晋身到老年代中。
  • 动态对象年龄判定:为了能够更好的适应不同程序的内存状况,虚拟机并不是每次都要等到对象的年龄到达阈值才将对象移动到老年代。如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一般,年龄大于或等于该年龄的对象就可以直接进入老年代,无需等待年龄增长。
  • 空间分配担保:在发生Minor GC之前,虚拟机会先检查老年代中最大可用的连续空间是否大于新生代所有对象空间综合,如果这个条件成立,那么Minor GC可以确保是安全的。如果不成立,则虚拟机会查看HandlePromotionFailure设置值是否允许担保失败。如果允许,那么会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试进行一次Minor GC,尽管这次GC是有风险的;如果小于,或者设置不允许,那这时将改为进行一次Full GC。

7 虚拟机性能监控与故障处理工具

JDK bin目录下除了“java.exe"、”javac.exe"这两个命令以外,还有一些其他的工具可以用于监控虚拟机和故障处理。

  • jps:虚拟机进程状况工具。可以列出正在运行的虚拟机进程,并显示虚拟机执行主类(Main Class,main()方法所在的类)的名称以及这些进程的本地虚拟机唯一ID。
  • jstat:虚拟机统计信息监视工具。用于监视虚拟机各种运行状态信息的命令工具。可以显示本地或者远程虚拟机进程中的类装载、内存、垃圾收集、JIT编译等数据。
  • jinfo:Java配置信息工具。可以实时地查看和调整虚拟机的各项参数。
  • jmap:Java内存映像工具。用于生成堆转储快照。
  • jhat:虚拟机堆转储快照分析工具。可与jmap搭配使用,来分析jmap生成的快照。
  • jstack:Java堆栈跟踪工具。用于生成虚拟机当前时刻的线程快照。

8 虚拟机类加载机制

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

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

类从被加载到虚拟机内存中开始,到卸载出内存为止,一共经历了:加载、验证、准备、解析、初始化、使用和卸载7个阶段。其中验证、准备、解析3个部分统称为连接。
JAVA虚拟机_第10张图片
其中加载、验证、准备、初始化和卸载这5个阶段的顺序是一定的,类的加载过程必须按照这种顺序按部就班的开始,而解析则不一定:它在某些情况下可以在初始化阶段之后再开始,这是为了支持Java语言的运行时绑定。

加载

“加载"是”类加载“的一个阶段,在加载过程中,虚拟机需要完成三件事:

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

对于一个数组类而言,其创建过程遵循以下规则:

  1. 如果数组的组件类型是引用类型,那就递归采用上述的类加载过程去加载这个组件的类型,数组C将在类加载该组件类型的类加载器的类名称空间上被标识。
  2. 如果数组的组件类型不是引用雷西,Java虚拟机将会把数组C标记为与引导类加载器关联。
  3. 数组类的可见性与它的组件类型的可见性一致,如果组件类型不是引用类型,那数组类的可见性将默认为public。

当加载阶段完成后,虚拟机外部的二进制字节流就按照虚拟机所需要的格式存储在方法区之中,方法区中的数据存储格式由虚拟机实现自行定义。然后再内存中实例化一个java.lang.Class类的对象,这个对象将作为程序访问方法区中的这些类型数据的外部接口。

加载阶段与连接阶段的部分内容是交叉进行的,加载阶段尚未完成,连接阶段可能已经开始,但这些夹在加载阶段之中进行的动作,仍然属于连接阶段的内容,这两个阶段的开始时间仍然保持着固定的先后顺序。

验证

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

验证阶段是非常重要的,这个阶段是否严谨,直接决定了Java虚拟机是否能承受恶意代码的攻击,从执行性能的角度上讲,验证阶段的工作量在虚拟机的类加载子系统中又占了相当大的一部分。

验证阶段大致上会完成下面4个阶段的检验动作:文件格式验证、元数据验证、字节码验证和符号引用验证。

  • 文件格式验证:第一阶段验证字节流是否符合Class文件格式的规范,并且能够被当前版本的虚拟机处理。
  • 元数据验证:第二阶段是对字节码描述的信息进行语义分析,以保证其描述的信息符合Java语言规范的要求。
  • 字节码验证:第三阶段是整个验证过程中最复杂的一个阶段,主要目的是通过数据流和控制流分析,确定程序语义是否合法的、符合逻辑的。
  • 符号引用验证:最后一个阶段的校验发生在虚拟机将符号引用转化为直接引用的时候,这个转化动作将在连接的第三阶段-解析阶段中发生。

准备

准备阶段是正式为类变量分配内存并设置变量初始值的阶段,这些变量所使用的内存都将在方法区中进行分配。内存分配仅包括类变量、而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在Java堆中。

解析

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

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

初始化

Java虚拟机规范并没有强制约束什么时候要开始类的加载过程,但是对于初始化阶段,则是严格规定了有且只有5种情况必须立即对类进行初始化:

  1. 遇到new、getstatic、putstatic或invokestatic这四条字节码指令时,如果类没有进行过初始化,则需要触发其初始化。
  2. 使用java.lang.reflect包的方法对类进行反射调用的时候,如果类没有初始化,则要触发初始化。
  3. 当初始化一个类的时候,如果发现其父类还没有进行初始化,则要触发其父类的初始化。
  4. 当虚拟机启动时,用户需要指定一个要执行的主类(main()方法所在的类),虚拟机会先初始化这个类。
  5. 当使用JDK1.7的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果REF_getStatic、REF_pubStatic、REF_invokeStatic的方法句柄,并且这个方法句柄对应的类没有进行过初始化,则要触发对其的初始化。

接口与类初始化过程中大致相同,但是在规则3中,接口并不一定要求先初始化其父类,只有当有用到时才会进行初始化。

在准备阶段,变量已经赋过一次系统要求的初始值,而在初始化阶段,则根据程序员通过程序制定的主观计划去初始化类变量和其他资源,或者可以从另外一个角度来表达:初始化阶段是执行类构造器< clinit >()方法的过程。

  • < clinit >()方法是由编译器自动收集类中的所有类变量(static)的赋值动作和静态语句块(static{})中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序所决定的,静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值,但是不能访问。
public class Test{
	static {
		i = 0;	//编译通过
		System.out.print(i); 	//报错  Error:(20, 28) java: 非法前向引用
	}
	static int i = 1;
}
  • < clinit >()方法与类的构造函数不同,它不需要显示地调用父类构造器,虚拟机会保证在子类地< clinit >()方法执行之前,父类地< clinit >()方法已经执行完毕。因此在虚拟机中第一个被执行地< clinit >()方法地类肯定是java.lang.Object。
  • 由于父类的< clinit >()方法先执行,也就意味着父类中定义的静态语句块要优先于子类的变量赋值操作。
  • < clinit >()方法对于类或者接口不是必须的,如果一个类没有静态语句和对变量的赋值操作,那么编译器可以不为这个类生成< clinit >()方法。
  • 接口中不能使用静态语句块,但仍然有变量初始化的赋值操作,因此接口与类一样都会生成< clinit >()方法。
  • 虚拟机会保证一个类的< clinit >()方法在多线程环境中被正确地加锁、同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的< clinit >()方法,其他线程都要阻塞等待。

9 类加载器

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

类加载器虽然只用于实现类的加载动作,但它在Java程序中起到的作用却远远不限于类加载阶段。对于任意一个类,都需要由加载它的类加载器和这个类本身一同确立其在Java虚拟机中的唯一性,每一个类加载器都拥有一个独立的类名称空间(即比较两个类是否相等的前提是这两个类是由同一个类加载器产生的)。

从Java虚拟机的角度来讲,只存在两种不同的类加载器:一种是启动类加载器,这个类加载器使用C++语言实现,是虚拟机自身的一部分;另一种就是所有其他的类加载器,这些类加载器都由Java语言实现,独立于虚拟机外部,并且全都继承自抽象类java.class.ClassLoader。

从Java开发人员的角度来看,类加载器还可以划分得更细致一些,绝大部分Java程序都会使用到以下三种系统提供的类加载器。

  1. 启动类加载器:这个类加载器负责将存放在\lib目录中,或者被-Xbootclasspath参数所指定的路径中的,并且是虚拟机识别的类库加载到虚拟机内存中。启动类加载器无法被Java程序直接引用,用户在编写自定义类加载器时,如果需要把加载请求委派给引导类加载器,那直接使用null代替即可。
  2. 拓展类加载器:这个加载器由sun.misc.Launcher$ExtClassLoader实现,它负责加载\lib\ext目录中的,或者被java.ext.dirs系统变量所指定的路径中的所有类库,开发者可以直接使用拓展类加载器。
  3. 应用程序类加载器:这个类加载器由sun.misc.Launcher$App-ClassLoader实现。由于这个类加载器时ClassLoader中的getSystemClassLoader()方法的返回值,所以一般也称它为系统类加载器。它负责加载用户类路径上所指定的类库,开发者可以直接使用这个类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。

类加载器之前的这种层次关系,称为类加载器的双亲委派模型。双亲委派模型要求除了自己顶层的启动类加载器外,其余的类加载器都应当有自己的父类加载器。这里类加载器之间的父子关系一般不会以继承的关系来实现,而是都使用组合关系来复用父加载器的代码。

双亲委派模型的工作过程时:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求时,子加载器才会尝试自己去加载。

使用双亲委派模型来组织类加载器之间的关系,有一个显而易见的好处就是Java类随着它的类加载器一起具备了一种带有优先级的层次关系。例如类java.lang.Object,它存放在rt.jar之中,无论哪一个类加载器都要加载这个类,最终都是委派给处于模型最顶端的启动类加载器进行加载,因此Object类在程序的各种类加载器环境中都是同一个类。

双亲委派模型对于保证Java程序的稳定运作很重要,但它的实现却非常简单,实现双亲委派的代码都集中在java.lang.ClassLoader的loadClass()方法之中。

你可能感兴趣的:(java)