0x00 前言

本文直接参考或引自网上文章。

0x01 JVM简介

JVM是Java Virtual Machine(Java虚拟机)的缩写,JVM是一种用于计算设备的规范,它是一个虚构出来的计算机,是通过在实际的计算机上仿真模拟各种计算机功能来实现的。

引入Java语言虚拟机后,Java语言在不同平台上运行时不需要重新编译。Java语言使用Java虚拟机屏蔽了与具体平台相关的信息,使得Java语言编译程序只需生成在Java虚拟机上运行的目标代码(字节码),就可以在多种平台上不加修改地运行。

Java虚拟机有自己完善的硬件架构,如处理器、堆栈等,还具有相应的指令系统。

Java虚拟机本质上就是一个程序,当它在命令行上启动的时候,就开始执行保存在某字节码文件中的指令。Java语言的可移植性正是建立在Java虚拟机的基础上。任何平台只要装有针对于该平台的Java虚拟机,字节码文件(.class)就可以在该平台上运行。这就是“一次编译,多次运行”。

Java虚拟机不仅是一种跨平台的软件,而且是一种新的网络计算平台。该平台包括许多相关的技术,如符合开放接口标准的各种API、优化技术等。Java技术使同一种应用可以运行在不同的平台上。Java平台可分为两部分,即Java虚拟机(Java virtual machine,JVM)和Java API类库。

JVM特性:

  • 移植性:无论是GC还是Hotspot都可以用在任何Java可用的地方。比方说,JRuby可以运行在其他平台上,Rails应用就可以运行在IBM主机上的JRuby上,而且这台IBM主机运行的是CP/CMS.实际上,由于Java和OpenJDK项目的开源,我们正在看到越来越多的平台的衍生,因此JVM的移植性也将越来越棒。
  • 成熟:JVM已有多年的历史,在过去的这些年里,许多开发者为它做出了许多贡献,使得它的性能一次又一次地提升,让JVM变得更加稳定、快速和广泛。
  • 覆盖面:JRuby和JVM上的其他语言项目已经被承认,一个例子是invokedynamic specification(akaJSR292)。JSR越来越配合新的语言,JVM已不再是Java一个人定制规则。JVM正在构建成为类如JRuby等项目的优良平台。还有一个MLVM(multiple languageVM)项目,好比是新特性的清算机构,是一个许多企业应用的开发者试图添加应用的地方,而这些应用正是他们想在JVM中看到的。而且JVM开发者互相协作、彼此影响,无疑这有利于JVM新特性的诞生。这些细节都可以看到JVM正在关注开发者的需求,扩大他的覆盖面。

0x02 体系结构

Java虚拟机主要分为五大模块:类装载器子系统、运行时数据区、执行引擎、本地方法接口和垃圾收集模块。其中垃圾收集模块在Java虚拟机规范中并没有要求Java虚拟机垃圾收集,但是在没有发明无限的内存之前,大多数JVM实现都是有垃圾收集的。而运行时数据区都会以某种形式存在于每一个JAVA虚拟机实例中,但是Java虚拟机规范对它的描述却是相当抽象。这些运行时数据结构上的细节,大多数都由具体实现的设计者决定。

Java虚拟机不是真实的物理机,它没有寄存器,所以指令集是使用Java栈来存储中间数据,这样做的目的就是为了保持Java虚拟机的指令集尽量的紧凑,同时也便于JAVA虚拟机在那些只有很少通用寄存器的平台上实现。另外,JAVA虚拟机的这种基于栈的体系结构,有助于运行时某些虚拟机实现的动态编译器和即时编译器的代码优化。

下面对JVM体系结构中的五大模块分别进行说明。

0x03 运行时数据区

JVM体系结构如图:

几个主要区域的特点归纳如下:

各个空间的内存分配如下:

程序计数器

线程私有。

作用

字节码解释器工作通过改变程序计数器的值来选取下一条需要执行的字节码指令。分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖程序计数器来完成。

介绍

JVM的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,为了各条线程之间的切换后计数器能恢复到正确的执行位置,所以每条线程都会有一个独立的程序计数器。

当线程正在执行一个Java方法,程序计数器记录的是正在执行的JVM字节码指令的地址;如果正在执行的是一个Natvie(本地方法),那么这个计数器的值则为空(Underfined)。

程序计数器占用的内存空间很少,也是唯一一个在JVM规范中没有规定任何OutOfMemoryError(内存不足错误)的区域。

Java虚拟机栈

线程私有。

作用

Java虚拟机栈保存内容:存储局部变量,操作数栈,动态链接,方法出口。

介绍

与程序计数器一样,Java虚拟机栈也是线程私有的,用通俗的话将它就是我们常常听说到堆栈中的那个“栈内存”。

虚拟机栈描述的是Java方法执行的内存模型:每个方法在执行的同时都会创建一个栈帧(Stack Frame)用于存储局部变量表(局部变量表需要的内存在编译期间就确定了所以在方法运行期间不会改变大小),操作数栈,动态链接,方法出口等信息。

栈是Java方法执行的内存模型:每个方法被执行的时候都会创建一个“栈帧”用于存储局部变量表(包括参数)、操作栈、方法出口等信息。每一个方法从调用至出栈的过程,就对应着栈帧在虚拟机中从入栈到出栈的过程。

栈的生命周期是跟随线程的生命周期,线程创建时创建,线程结束栈内存也就释放,是线程私有的。

本地方法栈和Java虚拟机栈的区别:Java虚拟机栈为虚拟机执行的Java方法服务,而本地方法栈则是为Native方法服务。

保存具体内容

如图:

局部变量表

局部变量表是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量。在Java程序被编译成Class文件时,就在方法的Code属性的max_locals数据项中确定了该方法所需要分配的最大局部变量表的容量。

局部变量表的容量以变量槽(Slot)为最小单位,32位虚拟机中一个Slot可以存放一个32位以内的数据类型(boolean、byte、char、short、int、float、reference和returnAddress八种)。

reference类型虚拟机规范没有明确说明它的长度,但一般来说,虚拟机实现至少都应当能从此引用中直接或者间接地查找到对象在Java堆中的起始地址索引和方法区中的对象类型数据。

returnAddress类型是为字节码指令jsr、jsr_w和ret服务的,它指向了一条字节码指令的地址。

虚拟机是使用局部变量表完成参数值到参数变量列表的传递过程的,如果是实例方法(非static),那么局部变量表的第0位索引的Slot默认是用于传递方法所属对象实例的引用,在方法中通过this访问。

Slot是可以重用的,当Slot中的变量超出了作用域,那么下一次分配Slot的时候,将会覆盖原来的数据。Slot对对象的引用会影响GC(要是被引用,将不会被回收)。

系统不会为局部变量赋予初始值(实例变量和类变量都会被赋予初始值)。也就是说不存在类变量那样的准备阶段。

操作数栈

操作数栈和局部变量区一样,操作数栈也是被组织成一个以字长为单位的数组。但是和前者不同的是,它不是通过索引来访问,而是通过标准的栈操作——压栈和出栈来访问的。比如,如果某个指令把一个值压入到操作数栈中,稍后另一个指令就可以弹出这个值来使用。

虚拟机在操作数栈中存储数据的方式和在局部变量区中是一样的:如int、long、float、double、reference和returnType的存储。对于byte、short以及char类型的值在压入到操作数栈之前,也会被转换为int。

虚拟机把操作数栈作为它的工作区——大多数指令都要从这里弹出数据,执行运算,然后把结果压回操作数栈。比如,iadd指令就要从操作数栈中弹出两个整数,执行加法运算,其结果又压回到操作数栈中,看看下面的示例,它演示了虚拟机是如何把两个int类型的局部变量相加,再把结果保存到第三个局部变量的:

1
2
3
4
5
6
begin
iload_0 // push the int in local variable 0 ontothe stack
iload_1 //push the int in local variable 1 onto the stack
iadd // pop two ints, add them, push result
istore_2 // pop int, store into local variable 2
end

在这个字节码序列里,前两个指令iload_0和iload_1将存储在局部变量中索引为0和1的整数压入操作数栈中,其后iadd指令从操作数栈中弹出那两个整数相加,再将结果压入操作数栈。第四条指令istore_2则从操作数栈中弹出结果,并把它存储到局部变量区索引为2的位置。

下图详细表述了这个过程中局部变量和操作数栈的状态变化,图中没有使用的局部变量区和操作数栈区域以空白表示:

动态链接

动态链接 : 虚拟机运行的时候,运行时常量池会保存大量的符号引用,这些符号引用可以看成是每个方法的间接引用。如果代表栈帧A的方法想调用代表栈帧B的方法,那么这个虚拟机的方法调用指令就会以B方法的符号引用作为参数,但是因为符号引用并不是直接指向代表B方法的内存位置,所以在调用之前还必须要将符号引用转换为直接引用,然后通过直接引用才可以访问到真正的方法。

如果符号引用是在类加载阶段或者第一次使用的时候转化为直接应用,那么这种转换成为静态解析。

如果是在运行期间转换为直接引用,那么这种转换就成为动态链接。

返回地址

方法的返回分为两种情况:

  • 一种是正常退出,退出后会根据方法的定义来决定是否要传返回值给上层的调用者。
  • 一种是异常导致的方法结束,这种情况是不会传返回值给上层的调用方法。

不过无论是那种方式的方法结束,在退出当前方法时都会跳转到当前方法被调用的位置。

  • 如果方法是正常退出的,则调用者的PC计数器的值就可以作为返回地址;
  • 如果是因为异常退出的,则是需要通过异常处理表来确定。

方法的一次调用就对应着栈帧在虚拟机栈中的一次入栈出栈操作,因此方法退出时可能做的事情包括:恢复上层方法的局部变量表以及操作数栈,如果有返回值的话,就把返回值压入到调用者栈帧的操作数栈中,还会把PC计数器的值调整为方法调用入口的下一条指令。

关于Java栈的更多解析可以查阅下文:https://www.jianshu.com/p/15932712fcb4

本地方法栈

线程私有。

作用

主要用于存储本地方法的局部变量表,本地方法的操作数栈等信息。

介绍

栈作为一种线性的管道结构,遵循先进后出的原则。当栈内的数据在超出其作用域后,会被自动释放掉。

本地方法栈是在程序调用或JVM调用本地方法接口(Native)时候启用。

本地方法栈和Java虚拟机栈的区别:Java虚拟机栈为虚拟机执行的Java方法服务,而本地方法栈则是为Native方法服务。

Java堆(Heap)

各线程共享区域。

作用

Java堆是一个运行时的数据区,用来存储数据的单元,存放通过new关键字新建的对象和数组,对象从中分配内存。

介绍

在堆中声明的对象,是不能直接访问的,必须通过在栈中声明的指向该引用的变量来调用。引用变量就相当于是为数组或对象起的一个名称,以后就可以在程序中使用栈中的引用变量来访问堆中的数组或对象。

特点

  • Java虚拟机所管理的内存中最大的一块。Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。
  • 此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存。几乎所有的对象实例以及数组都要在堆上分配。
  • Java堆是GC管理的区域,也称为GC堆。
  • Java堆中还细分为:新生代,老年代;再细分一点有Eden空间,From Survivor(sərˈvaɪvə(r),幸存者)空间,To Survivor空间。

堆的结构

堆内存是所有线程共有的,可以分为两个部分:新生代和老年代、永久代(HotSpot有)。下图中的Perm代表的是永久代,但是注意永久代并不属于堆内存中的一部分,同时jdk1.8之后永久代也将被移除。

新生代

程序新创建的对象都是从新生代分配内存,新生代由Eden Space和两块相同大小的Survivor Space(通常又称S0和S1或From和To)构成。

可通过-Xmn参数来指定新生代的大小;也可以通过-XX:SurvivorRation来调整Eden Space及SurvivorSpace的大小。

  • 新生代的初始值NewSize默认为1M,最大值需要设置,可以通过参数-XX:NewSize和-XX:MaxNewSize或-Xmn进行设置;

  • 为老年代与新生代的大小比值,默认为2:1;

  • SurvivorRatio为新生代中Eden和Survivor的大小比值,默认为8:1

    Edem : from : to = 8 :1 : 1

    可以通过参数-XX:SurvivorRatio来设定,即: Eden = 8/10 的新生代空间大小,from = to = 1/10 的新生代空间大小。

JVM每次只会使用Eden和其中的一块Survivor区域来为对象服务,所以无论什么时候,总是有一块Survivor区域是空闲着的。

新生代实际可用的内存空间为 9/10 ( 即90%)的新生代空间。

Eden区 、From区 - Surivivor 0 、To 区 - Surivivor 1:

  • 在未开始GC的时候,对象只会存在于Eden区和名为“From”的Survivor区,Survivor区“To”是空的。
  • 紧接着进行GC,Eden区中所有存活的对象都会被复制到“To”,而在“From”区中,仍存活的对象会根据他们的年龄值来决定去向。
  • 年龄达到一定值(年龄阈值,可以通过-XX:MaxTenuringThreshold来设置)的对象会被移动到年老代中,没有达到阈值的对象会被复制到“To”区域。经过这次GC后,Eden区和From区已经被清空。
  • 这个时候,“From”和“To”会交换他们的角色,也就是新的“To”就是上次GC前的“From”,新的“From”就是上次GC前的“To”。不管怎样,都会保证名为To的Survivor区域是空的。
  • Minor GC会一直重复这样的过程,直到“To”区被填满,“To”区被填满之后,会将所有对象移动到年老代中。
老生代

用于存放经过多次新生代GC仍然存活的对象,例如缓存对象,新建的对象也有可能直接进入老年代,主要有两种情况:

  • 大对象,可通过启动参数设置-XX:PretenureSizeThreshold=1024(单位为字节,默认为0)来代表超过多大时就不在新生代分配,而是直接在老年代分配。
  • 大的数组对象,且数组中无引用外部对象。

老年代所占的内存大小为-Xmx对应的值减去-Xmn对应的值。

如果在堆中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出OutOfMemoryError异常。

永生代

永久代可以简单理解为方法区(本质上两者并不等价)

如上文所说:对于习惯在HotSpot虚拟机上开发和部署程序的开发者来说,很多人愿意把方法区称为“永久代”,本质上两者并不等价。仅仅是因为HotSpot虚拟机的设计团队选择把GC分代收集扩展至方法区,或者说使用永久代来实现方法区而已。

对于其他虚拟机(如BEA JRockit、IBM J9等)来说是不存在永久代的概念的。

即使是HotSpot虚拟机本身,根据官方发布的路线图信息,现在也有放弃永久代并“搬家”至Native Memory来实现方法区的规划了:

  • Jdk1.6及之前:常量池分配在永久代;
  • Jdk1.7:有,但已经逐步“去永久代”;
  • Jdk1.8及之后:没有永久代(java.lang.OutOfMemoryError: PermGen space,这种错误将不会出现在JDK1.8中);

方法区(Method Area)

各线程共享区域。

作用

方法区保存内容:类信息、常量、静态变量、即时编译器编译后的代码等数据。

类信息具体内容:

  • 类型信息:全限定名、直接超类的全限定名、类的类型还是接口类型、访问修饰符、直接超接口的全限定名的有序列表
  • 字段信息:字段名、字段类型、字段的修饰符
  • 方法信息:方法名、方法返回类型、方法参数的数量和类型(按照顺序)、方法的修饰符
  • 其他信息:除了常量以外的所有类(静态)变量、一个指向ClassLoader的指针、一个指向Class对象的指针、常量池(常量数据以及对其他类型的符号引用)

介绍

类型信息是由类加载器在类加载的过程中从类文件中提取出来的信息。

需要注意的一点是,常量池也存放于方法区中。

程序中所有的线程共享一个方法区,所以访问方法区的信息必须确保线程是安全的。如果有两个线程同时去加载一个类,那么只能有一个线程被允许去加载这个类,另一个必须等待。

在程序运行时,方法区的大小是可以改变的,程序在运行时可以扩展。

方法区也可以被垃圾回收,但条件非常严苛,必须在该类没有任何引用的情况下。

已装载类详细信息

  • 运行时常量池:在方法区中,每个类型都对应一个常量池,存放该类型所用到的所有常量,常量池中存储了诸如文字字符串、final变量值、类名和方法名常量。

  • 字段信息:字段信息存放类中声明的每一个字段的信息,包括字段的名、类型、修饰符。

  • 字段名称:指的是类或接口的实例变量或类变量,字段的描述符是一个指示字段的类型的字符串,如private A a=null;则a为字段名,A为描述符,private为修饰符

  • 方法信息:类中声明的每一个方法的信息,包括方法名、返回值类型、参数类型、修饰符、异常、方法的字节码。(在编译的时候,就已经将方法的局部变量、操作数栈大小等确定并存放在字节码中,在装载的时候,随着类一起装入方法区。)

  • 静态变量:就是类变量,类的所有实例都共享,在方法区有个静态区,静态区专门存放静态变量和静态块。

  • 到类classloader的引用:到该类的类装载器的引用。

  • 到类class的引用:虚拟机为每一个被装载的类型创建一个class实例,用来代表这个被装载的类。

运行时常量池

Java常量池实际上分为两种形态:静态常量池和运行时常量池。方法区Method Area包含运行时常量池。

  • 静态常量池 ,即*.class文件中的常量池。class文件中的常量池不仅仅包含字符串(数字)字面量,还包含类、方法的信息,它们占用class文件绝大部分空间。
  • 运行时常量池 ,则是jvm虚拟机在完成类装载操作后,将class文件中的常量池载入到内存中,并保存在方法区中,我们常说的常量池,就是指方法区中的运行时常量池。

运行时常量池(Constant Pool Table),用于存放编译期生成的各种字面量、符号引用,String字符串、final变量值、类和结构的完全限定名,方法的名称和描述符,字段的名称和描述符,这部分内容将在类加载后存放到方法区的运行时常量池中。它们以数组形式通过索引被访问,是外部调用与类联系及类型对象化的桥梁。

在运行时,JVM从常量池中获得符号引用,然后在运行时解析成引用项的实际地址,最后通过常量池中的全限定名、方法和字段描述符,把当前类或接口中的代码与其它类或接口中的代码联系起来。

运行时常量池中的常量,基本来源于各个class文件中的常量池。

程序运行时,除非手动向常量池中添加常量(比如调用intern方法),否则jvm不会自动添加常量到常量池。

运行时常量池除了存放编译期产生的Class文件的常量外,还可存放在程序运行期间生成的新常量,比较常见增加新常量方法有String类的intern()方法。

String.intern()是一个Native方法,它的作用是:如果运行时常量池中已经包含一个等于此String对象内容的字符串,则返回常量池中该字符串的引用;如果没有,则在常量池中创建与此String内容相同的字符串,并返回常量池中创建的字符串的引用。

不过JDK7的intern()方法的实现有所不同,当常量池中没有该字符串时,不再是在常量池中创建与此String内容相同的字符串,而改为在常量池中记录堆中首次出现的该字符串的引用,并返回该引用。

由于运行时常量池在方法区中,我们可以通过JVM参数:-XX:PermSize-XX:MaxPermSize来设置方法区大小,从而间接限制常量池大小。

在JDK8中,移除了方法区,转而用Metaspace区域替代,所以我们需要使用新的JVM参数:-XX:MaxMetaspaceSize

但是,JDK1.7之前运行时常量池是方法区的一部分,JDK1.7及之后版本已经将运行时常量池从方法区中移了出来,在堆(Heap)中开辟了一块区域存放运行时常量池。

0x04 类加载机制

类加载简介

基本概念

类加载指的是将类的.class文件中的二进制数据读入到内存中,将其放在运行时数据区的方法区内,然后在堆区创建一个java.lang.Class对象,用来封装类在方法区内的数据结构。

所处环节

在介绍类加载机制之前,先来看看,类的加载机制在整个Java程序运行期间处于一个什么环节,下面使用一张图来表示:

从上图可以看,Java文件通过编译器变成了.class文件,接下来类加载器又将这些.class文件加载到JVM中。其中类装载器的作用其实就是类的加载。

何时才会启动类加载器

其实,类加载器并不需要等到某个类被“首次主动使用”时再加载它,JVM规范允许类加载器在预料某个类将要被使用时就预先加载它,如果在预先加载的过程中遇到了.class文件缺失或存在错误,类加载器必须在程序首次主动使用该类时才报告错误(LinkageError错误)。如果这个类一直没有被程序主动使用,那么类加载器就不会报告错误。

何处去加载.class文件

在这里进行一个简单的分类。例举了5个来源:

  1. 本地磁盘
  2. 网上加载.class文件(Applet)
  3. 从数据库中
  4. 压缩文件中(ZAR,jar等)
  5. 从其他文件生成的(JSP应用)

类加载过程

总过程

类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期包括:加载、验证、准备、解析、初始化、使用和卸载等七个阶段。它们的顺序如下图所示:

其中类加载的过程包括了加载、验证、准备、解析、初始化五个阶段。

在这五个阶段中,加载、验证、准备和初始化这四个阶段发生的顺序是确定的,而解析阶段则不一定,它在某些情况下可以在初始化阶段之后开始。

另外注意这里的几个阶段是按顺序开始,而不是按顺序进行或完成,因为这些阶段通常都是互相交叉地混合进行的,通常在一个阶段执行的过程中调用或激活另一个阶段。

启动时如果加上如下系统参数,即可跟踪JVM类的加载:

1
-XX:+TraceClassLoading

加载

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

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

相对于类加载的其他阶段而言,加载阶段是可控性最强的阶段,因为程序员可以使用系统的类加载器加载,还可以使用自己的类加载器加载。我们在最后一部分会详细介绍这个类加载器。在这里我们只需要知道类加载器的作用就是上面虚拟机需要完成的三件事,仅此而已就好了。

验证

验证的主要作用就是确保被加载的类的正确性。也是连接阶段的第一步。说白了也就是我们加载好的.class文件不能对我们的虚拟机有危害,所以先检测验证一下。它主要是完成四个阶段的验证:

  1. 文件格式的验证:验证.class文件字节流是否符合class文件的格式的规范,并且能够被当前版本的虚拟机处理。这里面主要对魔数、主版本号、常量池等等的校验(魔数、主版本号都是.class文件里面包含的数据信息、在这里可以不用理解)。
  2. 元数据验证:主要是对字节码描述的信息进行语义分析,以保证其描述的信息符合Java语言规范的要求,比如说验证这个类是不是有父类,类中的字段方法是不是和父类冲突等等。
  3. 字节码验证:这是整个验证过程最复杂的阶段,主要是通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。在元数据验证阶段对数据类型做出验证后,这个阶段主要对类的方法做出分析,保证类的方法在运行时不会做出危害虚拟机安全的事。
  4. 符号引用验证:它是验证的最后一个阶段,发生在虚拟机将符号引用转化为直接引用的时候。主要是对类自身以外的信息进行校验。目的是确保解析动作能够完成。

对整个类加载机制而言,验证阶段是一个很重要但是非必需的阶段,如果我们的代码能够确保没有问题,那么我们就没有必要去验证,毕竟验证需要花费一定的的时间。当然我们可以使用-Xverfity:none来关闭大部分的验证。

准备

准备阶段主要为类变量分配内存并设置初始值。这些内存都在方法区分配。在这个阶段我们只需要注意两点就好了,也就是类变量和初始值两个关键词:

  1. 类变量(static)会分配内存,但是实例变量不会,实例变量主要随着对象的实例化一块分配到Java堆中,
  2. 这里的初始值指的是数据类型默认值,而不是代码中被显示赋予的值。比如public static int value = 1; 在这里准备阶段过后的value值为0,而不是1。赋值为1的动作在初始化阶段。当然还有其他的默认值。

注意,在上面value是被static所修饰的准备阶段之后是0,但是如果同时被final和static修饰准备阶段之后就是1了。我们可以理解为static final在编译器就将结果放入调用它的类的常量池中了。

解析

解析阶段主要是虚拟机将常量池中的符号引用转化为直接引用的过程。什么是符号应用和直接引用呢?

  • 符号引用:以一组符号来描述所引用的目标,可以是任何形式的字面量,只要是能无歧义的定位到目标就好,就好比在班级中,老师可以用张三来代表你,也可以用你的学号来代表你,但无论任何方式这些都只是一个代号(符号),这个代号指向你(符号引用)
  • 直接引用:直接引用是可以指向目标的指针、相对偏移量或者是一个能直接或间接定位到目标的句柄。和虚拟机实现的内存有关,不同的虚拟机直接引用一般不同。

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

初始化

这是类加载机制的最后一步,在这个阶段,Java程序代码才开始真正执行。我们知道,在准备阶段已经为类变量赋过一次值。在初始化阶段,程序员可以根据自己的需求来赋值了。一句话描述这个阶段就是执行类构造器< clinit >()方法的过程。

在初始化阶段,主要为类的静态变量赋予正确的初始值,JVM负责对类进行初始化,主要对类变量进行初始化。在Java中对类变量进行初始值设定有两种方式:

  1. 声明类变量是指定初始值
  2. 使用静态代码块为类变量指定初始值

JVM初始化步骤

总的来说,初始化顺序依次是:(静态变量、静态初始化块)–>(变量、初始化块)–> 构造器;如果有父类,则顺序是:父类static方法 –> 子类static方法 –> 父类构造方法- -> 子类构造方法

  1. 假如这个类还没有被加载和连接,则程序先加载并连接该类
  2. 假如该类的直接父类还没有被初始化,则先初始化其直接父类
  3. 假如类中有初始化语句,则系统依次执行这些初始化语句

类初始化时机

只有当对类的主动使用的时候才会导致类的初始化,类的主动使用包括以下六种:

  • 创建类的实例,也就是new的方式
  • 访问某个类或接口的静态变量,或者对该静态变量赋值
  • 调用类的静态方法
  • 反射(如Class.forName("com.shengsiyuan.Test")
  • 初始化某个类的子类,则其父类也会被初始化
  • Java虚拟机启动时被标明为启动类的类( JavaTest),直接使用java.exe命令来运行某个主类

类加载器介绍

类加载器的调用顺序如下:

Java默认提供的三个ClassLoader:BootStrap ClassLoader、Extension ClassLoader、App ClassLoader

加载顺序:Bootstrap ClassLoader > Extention ClassLoader > App ClassLoader

BootStrap ClassLoader

BootStrap ClassLoader被称为启动类加载器,是Java类加载层次中最顶层的类加载器,负责加载JDK中的核心类库,如:rt.jar、resources.jar、charsets.jar等,可通过如下程序获得该类加载器从哪些地方加载了相关的jar或class文件:

1
2
3
4
5
6
7
8
9
10
public class BootStrapTest
{
public static void main(String[] args)
{
URL[] urls = sun.misc.Launcher.getBootstrapClassPath().getURLs();
for (int i = 0; i < urls.length; i++) {
System.out.println(urls[i].toExternalForm());
}
}
}

上述代码查询结果可以通过查找 sun.boot.class.path 这个系统属性所得知的:

1
System.out.println(System.getProperty("sun.boot.class.path"));

Extension ClassLoader

Extension ClassLoader,扩展类加载器,负责加载Java的扩展类库,Java虚拟机的实现会提供一个扩展库目录。该类加载器在此目录里面查找并加载 Java 类。默认加载JAVA_HOME/jre/lib/ext/目下的所有jar。

App ClassLoader

App ClassLoader,系统类加载器,负责加载应用程序classpath目录下的所有jar和class文件。一般来说,Java应用的类都是由它来完成加载的。可以通过ClassLoader.getSystemClassLoader()来获取它。

Tomcat的类加载顺序

如图:

双亲委托策略

双亲委托策略内容

ClassLoader使用的是双亲委托模型来搜索类的,每个ClassLoader实例都有一个父类加载器的引用(不是继承的关系,是一个包含的关系),虚拟机内置的类加载器(Bootstrap ClassLoader)本身没有父类加载器,但可以用作其它ClassLoader实例的的父类加载器。

当一个ClassLoader实例需要加载某个类时,它会试图亲自搜索某个类之前,先把这个任务委托给它的父类加载器,这个过程是由上至下依次检查的,首先由最顶层的类加载器Bootstrap ClassLoader试图加载,如果没加载到,则把任务转交给Extension ClassLoader试图加载,如果也没加载到,则转交给App ClassLoader 进行加载,如果它也没有加载得到的话,则返回给委托的发起者,由它到指定的文件系统或网络等URL中加载该类。

如果它们都没有加载到这个类时,则抛出ClassNotFoundException异常。否则将这个找到的类生成一个类的定义,并将它加载到内存当中,最后返回这个类在内存中的Class实例对象。

为什么使用双亲委托策略

因为这样可以避免重复加载,当父亲已经加载了该类的时候,就没有必要子ClassLoader再加载一次。

考虑到安全因素,我们试想一下,如果不使用这种委托模式,那我们就可以随时使用自定义的String来动态替代Java核心API中定义的类型,这样会存在非常大的安全隐患。

而双亲委托的方式,就可以避免这种情况,因为String已经在启动时就被引导类加载器(Bootstrcp ClassLoader)加载,所以用户自定义的ClassLoader永远也无法加载一个自己写的String,除非你改变JDK中ClassLoader搜索类的默认算法。

JVM在搜索类的时候,如何判断两个class相同呢?

JVM在判定两个class是否相同时,不仅要判断两个类名是否相同,而且要判断是否由同一个类加载器实例加载的。

只有两者同时满足的情况下,JVM才认为这两个class是相同的。就算两个class是同一份class字节码,如果被两个不同的ClassLoader实例所加载,JVM也会认为它们是两个不同class。

比如网络上的一个Java类org.classloader.simple.NetClassLoaderSimple,javac编译之后生成字节码文件NetClassLoaderSimple.class,ClassLoaderA和ClassLoaderB这两个类加载器并读取了NetClassLoaderSimple.class文件,并分别定义出了java.lang.Class实例来表示这个类。

对于JVM来说,它们是两个不同的实例对象,但它们确实是同一份字节码文件,如果试图将这个Class实例生成具体的对象进行转换时,就会抛运行时异常java.lang.ClassCaseException,提示这是两个不同的类型。

在一个单虚拟机环境下,标识一个类有两个因素:class的全路径名、该类的ClassLoader。

不遵循“双亲委托机制”的场景

上面说了双亲委托机制主要是为了实现不同的ClassLoader之间加载的类的交互问题,被大家公用的类就交由父加载器去加载,但是Java中确实也存在父类加载器加载的类需要用到子加载器加载的类的情况。

Java中有一个SPI(Service Provider Interface)标准。

使用了SPI的库,比如JDBC、JNDI等,我们都知道JDBC需要第三方提供的驱动才可以,而驱动的jar包是放在我们应用程序本身的classpath的,而JDBC本身的API是JDK提供的一部分,它已经被bootstrap加载了,那第三方厂商提供的实现类怎么加载呢?

这里面Java引入了线程上下文类加载的概念,线程类加载器默认会从父线程继承,如果没有指定的话,默认就是系统类加载器(App ClassLoader),这样的话当加载第三方驱动的时候,就可以通过线程的上下文类加载器来加载。

另外为了实现更灵活的类加载器OSGI以及一些Java app server也打破了双亲委托机制。

自定义ClassLoader

看到Java为我们提供了三个类加载器,应用程序都是由这三种类加载器互相配合进行加载的,如果有必要,我们还可以加入自定义的类加载器。

如何自定义类加载

以下两个步骤:

  • 继承java.lang.ClassLoader;
  • 覆写父类的findClass()方法;

几个关键的方法

汇总
  • defineClass(byte[], int, int):把字节数组b中的内容转换成 Java 类,返回的结果是java.lang.Class类的实例。这个方法被声明为final的;
  • findClass(String name):查找名称为name的类,返回的结果是java.lang.Class类的实例;
  • loadClass(String name):加载名称为name的类,返回的结果是java.lang.Class类的实例;
  • resolveClass(Class<?>):链接指定的 Java 类;
方法使用

(1) loadClass()方法

方法定义:

1
public Class<?> loadClass(String name)  throws ClassNotFoundException

方法的实现:

1
2
3
public Class<?> loadClass(String name) throws ClassNotFoundException{  
return loadClass(name, false);
}

从上面可以看出loadClass方法调用了loadClass(name, false)方法,那么接下来我们再来看看另外一个loadClass()方法的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
protected synchronized Class<?> loadClass(String name, boolean resolve)  throws ClassNotFoundException   
{ // First, check if the class has already been loaded Class c = findLoadedClass(name);
//检查class是否已经被加载过了
if (c == null){
try{
if (parent != null){
c = parent.loadClass(name, false); //如果没有被加载,且指定了父类加载器,则委托父加载器加载。
} else {
c = findBootstrapClass0(name);//如果没有父类加载器,则委托bootstrap加载器加载}
}catch (ClassNotFoundException e){
// If still not found, then invoke findClass in order
// to find the class.
c = findClass(name);//如果父类加载没有加载到,则通过自己的findClass来加载。
}
}

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

上面的代码,通过注释可以清晰看出loadClass()的双亲委托机制是如何工作的。 这里我们需要注意一点就是public Class<?> loadClass(String name) throws ClassNotFoundException没有被标记为final,也就意味着我们是可以override这个方法的,也就是说双亲委托机制是可以打破的。另外上面注意到有个findClass()方法,接下来我们就来说说这个方法到底是做什么的。

(2) findClass()方法

我们查看java.lang.ClassLoader的源代码,我们发现findClass的实现如下:

1
2
3
protected Class<?> findClass(String name) throws ClassNotFoundException{
throw new ClassNotFoundException(name);
}

我们可以看出此方法默认的实现是直接抛出异常,其实这个方法就是留给我们应用程序来override的。那么具体的实现就看你的实现逻辑了,你可以从磁盘读取,也可以从网络上获取class文件的字节流,获取class二进制了以后就可以交给defineClass来实现进一步的加载。defineClass我们在下面再来描述。

(3) defineClass方法

defineClass的代码如下:

1
2
3
protected final Class<?> defineClass(String name, byte[] b, int off, int len)  throws ClassFormatError{     
return defineClass(name, b, off, len, null);
}

从上面的代码我们看出此方法被定义为了final,这也就意味着此方法不能被Override,其实这也是JVM留给我们的唯一的入口,通过这个唯一的入口,JVM保证了类文件必须符合Java虚拟机规范规定的类的定义。此方法最后会调用native的方法来实现真正的类的加载工作。

0x05 JVM垃圾回收机制GC

JVM内存空间介绍

在前面已经具体介绍过。

JVM的内存空间,从大的层面上来分析包含:新生代空间(Young)和老年代空间(Old)。新生代空间(Young)又被分为2个部分(Eden区域、Survivous区域)和3个板块(1个Eden区域和2个Survivous区域)。

下面来看下具体每部分都是用来干什么的:

  • Eden(伊甸园)区域:用来存放使用new或者newInstance等方式创建的对象,默认这些对象都是存放在Eden区,除非这个对象太大,或者超出了设定的阈值-XX:PretenureSizeThresold,这样的对象会被直接分配到Old区域。

  • 2个Survivous(幸存)区域:一般称为S0、S1,理论上一样大。

针对不同代的垃圾回收机制

新生代(Young generation)

绝大多数最新被创建的对象都会被分配到这里,由于大部分在创建后很快变得不可达,很多对象被创建在新生代,然后“消失”。对象从这个区域“消失”的过程我们称之为:Minor GC 。

老年代(Old generation)

对象没有变得不可达,并且从新生代周期中存活了下来,会被拷贝到这里。其区域分配的空间要比新生代多。也正由于其相对大的空间,发生在老年代的GC次数要比新生代少得多。对象从老年代中消失的过程,称之为:Major GC或者Full GC。

持久代(Permanent generation)

这个区域不是用于存储那些从老年代存活下来的对象,这个区域也可能发生GC。发生在这个区域的GC事件也被算为Major GC。只不过在这个区域发生GC的条件非常严苛,必须符合以下三种条件才会被回收:

  • 所有实例被回收
  • 加载该类的ClassLoader被回收
  • Class对象无法通过任何途径访问(包括反射)

第一次GC

在不断创建对象的过程中,当Eden区域被占满,此时会开始做Young GC也叫Minor GC:

  1. 第一次GC时Survivous中S0区和S1区都为空,将其中一个作为To Survivor(用来存储Eden区域执行GC后不能被回收的对象)。比如:将S0作为To Survivor,则S1为From Survivor。
  2. 将Eden区域经过GC不能被回收的对象存储到To Survivor(S0)区域(此时Eden区域的内存会在垃圾回收的过程中全部释放),但如果To Survivor(S0)被占满了,Eden中剩下不能被回收对象只能存放到Old区域。
  3. 将Eden区域空间清空,此时From Survivous区域(S1)也是空的。
  4. S0与S1互相切换标签,S0为From Survivor,S1为To Survivor。

第二次及之后的GC

当第二次Eden区域被占满时,此时开始做GC:

  1. 将Eden和From Survivor(S0)中经过GC未被回收的对象迁移到To Survivor(S1),如果To Survious(S1)区放不下,将剩下的不能回收对象放入Old区域;
  2. 将Eden区域空间和From Survivor(S0)区域空间清空;
  3. S0与S1互相切换标签,S0为To Survivor,S1为From Survivor。

第三、第四次依次类推,始终保证S0和S1有一个空的,用来存储临时对象,用于交换空间的目的。反反复复多次没有被淘汰的对象,将会被放入Old区域中,默认15次(由参数--XX:MaxTenuringThreshold=15决定)。

垃圾回收算法

根搜索算法

根搜索算法是从离散数学中的图论引入的,程序把所有引用关系看作一张图,从一个节点GC ROOT开始,寻找对应的引用节点,找到这个节点后,继续寻找这个节点的引用节点。当所有的引用节点寻找完毕后,剩余的节点则被认为是没有被引用到的节点,即无用的节点。

上图红色为无用的节点,可以被回收。

目前Java中可以作为GC ROOT的对象有:

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

基本所有GC算法都引用根搜索算法这种概念。

标记-清除算法

标记-清除算法采用从根集合进行扫描,对存活的对象进行标记,标记完毕后,再扫描整个空间中未被标记的对象进行直接回收。

标记-清除算法不需要进行对象的移动,并且仅对不存活的对象进行处理,在存活的对象比较多的情况下极为高效,但由于标记-清除算法直接回收不存活的对象,并没有对还存活的对象进行整理,因此会导致内存碎片。

如图:

复制算法

复制算法将内存划分为两个区间,使用此算法时,所有动态分配的对象都只能分配在其中一个区间(活动区间),而另外一个区间(空闲区间)则是空闲的。

复制算法采用从根集合扫描,将存活的对象复制到空闲区间,当扫描完毕活动区间后,会的将活动区间一次性全部回收。此时原本的空闲区间变成了活动区间。下次GC时候又会重复刚才的操作,依次循环。

复制算法在存活对象比较少的时候,极为高效,但是带来的成本是牺牲一半的内存空间用于进行对象的移动。所以复制算法的使用场景,必须是对象的存活率非常低才行,而且最重要的是,我们需要克服50%内存的浪费。

如图:

标记-整理算法

标记-整理算法采用标记-清除算法一样的方式进行对象的标记、清除,但在回收不存活的对象占用的空间后,会将所有存活的对象往左端空闲空间移动,并更新对应的指针。

标记-整理算法是在标记-清除算法之上,又进行了对象的移动排序整理,因此成本更高,但却解决了内存碎片的问题。

JVM为了优化内存的回收,使用了分代回收的方式,对于新生代内存的回收(Minor GC)主要采用复制算法。而对于老年代的回收(Major GC),大多采用标记-整理算法。

如图:

垃圾回收器

新生代收集器:

  • Serial(-XX:+UseSerialGC)
  • ParNew(-XX:+UseParNewGC)
  • ParallelScavenge(-XX:+UseParallelGC)
  • G1收集器

老年代收集器:

  • SerialOld(-XX:+UseSerialOldGC)
  • ParallelOld(-XX:+UseParallelOldGC)
  • CMS(-XX:+UseConcMarkSweepGC)
  • G1收集器

Serial(-XX:+UseSerialGC)

从名字我们可以看出,这是一个串行收集器。

Serial收集器是Java虚拟机中最基本、历史最悠久的收集器。在JDK1.3之前是Java虚拟机新生代收集器的唯一选择。目前也是ClientVM下ServerVM 4核4GB以下机器默认垃圾回收器。Serial收集器并不是只能使用一个CPU进行收集,而是当JVM需要进行垃圾回收的时候,需暂停所有的用户线程,直到回收结束。

使用算法:复制算法

Serial收集器虽然是最老的,但是它对于限定单个CPU的环境来说,由于没有线程交互的开销,专心做垃圾收集,所以它在这种情况下是相对于其他收集器中最高效的。

JVM中文名称为Java虚拟机,因此它像一台虚拟的电脑在工作,而其中的每一个线程都被认为是JVM的一个处理器,因此图中的CPU0、CPU1实际上为用户的线程,而不是真正的机器CPU,不要误解哦。

SerialOld(-XX:+UseSerialGC)

SerialOld是Serial收集器的老年代收集器版本,它同样是一个单线程收集器,这个收集器目前主要用于Client模式下使用。如果在Server模式下,它主要还有两大用途:一个是在JDK1.5及之前的版本中与Parallel Scavenge收集器搭配使用,另外一个就是作为CMS收集器的后备预案,如果CMS出现Concurrent Mode Failure,则SerialOld将作为后备收集器。

使用算法:标记-整理算法

运行示意图与上图一致。

ParNew(-XX:+UseParNewGC)

ParNew收集器其实就是Serial收集器的多线程版本,除了使用多条线程进行垃圾收集之外,其余行为包括Serial收集器可用的所有控制参数、收集算法、Stop The World、对象分配规则、回收策略等都与Serial收集器完全一样,在实现上,这两种收集器也共用了相当多的代码。

应用场景:ParNew收集器是许多运行在Server模式下的虚拟机中首选的新生代收集器。有一个很重要的原因是除了Serial收集器外,目前只有它能与CMS收集器配合工作。

使用算法:复制算法

ParNew是许多运行在Server模式下的JVM首选的新生代收集器。但是在单CPU的情况下,它的效率远远低于Serial收集器,所以一定要注意使用场景。

ParallelScavenge(-XX:+UseParallelGC)

ParallelScavenge又被称为吞吐量优先收集器,和ParNew 收集器类似,是一个新生代收集器。

使用算法:复制算法

ParallelScavenge收集器的目标是达到一个可控件的吞吐量,所谓吞吐量就是CPU用于运行用户代码的时间与CPU总消耗时间的比值,即吞吐量 = 运行用户代码时间 / (运行用户代码时间 + 垃圾收集时间)。

如果虚拟机总共运行了100分钟,其中垃圾收集花了1分钟,那么吞吐量就是99% 。

ParallelOld(-XX:+UseParallelOldGC)

ParallelOld是并行收集器,和SerialOld一样,ParallelOld是一个老年代收集器,是老年代吞吐量优先的一个收集器。

这个收集器在JDK1.6之后才开始提供的,在此之前,ParallelScavenge只能选择SerialOld来作为其老年代的收集器,这严重拖累了ParallelScavenge整体的速度。而ParallelOld的出现后,“吞吐量优先”收集器才名副其实!

使用算法:标记-整理算法

在注重吞吐量与CPU数量大于1的情况下,都可以优先考虑ParallelScavenge + ParalleloOld收集器。

CMS (-XX:+UseConcMarkSweepGC)

CMS是一个老年代收集器,全称 Concurrent Low Pause Collector,是JDK1.4后期开始引用的新GC收集器,在JDK1.5、1.6中得到了进一步的改进。它是对于响应时间的重要性需求大于吞吐量要求的收集器。对于要求服务器响应速度高的情况下,使用CMS非常合适。

CMS的一大特点,就是用两次短暂的暂停来代替串行或并行标记整理算法时候的长暂停。

使用算法:标记-清理

CMS的执行过程

CMS的执行过程如下:

  • 初始标记(STW initial mark)

    在这个阶段,需要虚拟机停顿正在执行的应用线程,官方的叫法STW(Stop Tow World)。这个过程从根对象扫描直接关联的对象,并作标记。这个过程会很快的完成。

  • 并发标记(Concurrent marking)

    这个阶段紧随初始标记阶段,在“初始标记”的基础上继续向下追溯标记。注意这里是并发标记,表示用户线程可以和GC线程一起并发执行,这个阶段不会暂停用户的线程哦。

  • 并发预清理(Concurrent precleaning)

    这个阶段任然是并发的,JVM查找正在执行“并发标记”阶段时候进入老年代的对象(可能这时会有对象从新生代晋升到老年代,或被分配到老年代)。通过重新扫描,减少在一个阶段“重新标记”的工作,因为下一阶段会STW。

  • 重新标记(STW remark)

    这个阶段会再次暂停正在执行的应用线程,重新重根对象开始查找并标记并发阶段遗漏的对象(在并发标记阶段结束后对象状态的更新导致),并处理对象关联。这一次耗时会比“初始标记”更长,并且这个阶段可以并行标记。

  • 并发清理(Concurrent sweeping)

    这个阶段是并发的,应用线程和GC清除线程可以一起并发执行。

  • 并发重置(Concurrent reset)

    这个阶段任然是并发的,重置CMS收集器的数据结构,等待下一次垃圾回收。

CMS的缺点
  1. 内存碎片。由于使用了 标记-清理 算法,导致内存空间中会产生内存碎片。不过CMS收集器做了一些小的优化,就是把未分配的空间汇总成一个列表,当有JVM需要分配内存空间的时候,会搜索这个列表找到符合条件的空间来存储这个对象。但是内存碎片的问题依然存在,如果一个对象需要3块连续的空间来存储,因为内存碎片的原因,寻找不到这样的空间,就会导致Full GC。
  2. 需要更多的CPU资源。由于使用了并发处理,很多情况下都是GC线程和应用线程并发执行的,这样就需要占用更多的CPU资源,也是牺牲了一定吞吐量的原因。
  3. 需要更大的堆空间。因为CMS标记阶段应用程序的线程还是执行的,那么就会有堆空间继续分配的问题,为了保障CMS在回收堆空间之前还有空间分配给新加入的对象,必须预留一部分空间。CMS默认在老年代空间使用68%时候启动垃圾回收。可以通过-XX:CMSinitiatingOccupancyFraction=n来设置这个阀值。

GarbageFirst(G1)

这是一个新的垃圾回收器,既可以回收新生代也可以回收老年代。

SunHotSpot1.6u14以上EarlyAccess版本加入了这个回收器,Sun公司预期SunHotSpot1.7发布正式版本。通过重新划分内存区域,整合优化CMS,同时注重吞吐量和响应时间。杯具的是Oracle收购这个收集器之后将其用于商用收费版收集器。因此目前暂时没有发现哪个公司使用它。

G1收集器的运作大致可划分为以下几个步骤:

  1. 初始标记(Initial Marking):初始标记阶段仅仅只是标记一下GC Roots能直接关联到的对象,并且修改TAMS(Next Top at Mark Start)的值,让下一阶段用户程序并发运行时,能在正确可用的Region中创建新对象,这阶段需要停顿线程,但耗时很短。
  2. 并发标记(Concurrent Marking):并发标记阶段是从GC Root开始对堆中对象进行可达性分析,找出存活的对象,这阶段耗时较长,但可与用户程序并发执行。
  3. 最终标记(Final Marking):最终标记阶段是为了修正在并发标记期间因用户程序继续运作而导致标记产生变动的那一部分标记记录,虚拟机将这段时间对象变化记录在线程Remembered Set Logs里面,最终标记阶段需要把Remembered Set Logs的数据合并到Remembered Set中,这阶段需要停顿线程,但是可并行执行。
  4. 筛选回收(Live Data Counting and Evacuation):筛选回收阶段首先对各个Region的回收价值和成本进行排序,根据用户所期望的GC停顿时间来制定回收计划,这个阶段其实也可以做到与用户程序一起并发执行,但是因为只回收一部分Region,时间是用户可控制的,而且停顿用户线程将大幅提高收集效率。

GC中的相关问题

问题1:怎么定义活着的对象?

从根引用开始,对象的内部属性可能也是引用,只要能级联到的都被认为是活着的对象。

问题2:什么是根?

本地变量引用,操作数栈引用,PC寄存器,本地方法栈引用等这些都是根。

问题3:对象进入Old区域有什么坏处?

Old区域一般称为老年代,老年代与新生代不一样。新生代,我们可以认为存活下来的对象很少,而老年代则相反,存活下来的对象很多,所以JVM的堆内存,才是我们通常关注的主战场,因为这里面活着的对象非常多,所以发生一次FULL GC,来找出来所有存活的对象是非常耗时的,因此,我们应该避免FULL GC的发生。

问题4:S0和S1一般多大,靠什么参数来控制,有什么变化?

一般来说很小,我们大概知道它与Young差不多相差一倍的比例,设置的参数主要有两个:

1
2
-XX:SurvivorRatio=8
-XX:InitialSurvivorRatio=8

第一个参数(-XX:SurvivorRatio)是Eden和Survivous区域比重(注意Survivous一般包含两个区域S0和S1,这里是一个Survivous的大小)。如果将-XX:SurvivorRatio=8设置为8,则说明Eden区域是一个Survivous区的8倍,换句话说S0或S1空间是整个Young空间的1/10,剩余的8/10由Eden区域来使用。

第二个参数(-XX:InitialSurvivorRatio)是Young/S0的比值,当其设置为8时,表示S0或S1占整个Young空间的1/8(或12.5%)。

问题5:一个对象每次Minor GC时,活着的对象都会在S0和S1区域转移,讲过MInor GC多少次后,会进入Old区域呢?

默认是15次,参数设置

1
--XX:MaxTenuringThreshold=15

计数器会在对象的头部记录它的交换次数。

问题6:为什么发生FULL GC会带来很大的危害?

在发生FULL GC的时候,意味着JVM会安全的暂停所有正在执行的线程(Stop The World),来回收内存空间,在这个时间内,所有除了回收垃圾的线程外,其他有关JAVA的程序,代码都会静止,反映到系统上,就会出现系统响应大幅度变慢,卡机等状态。

问题7:JVM GC回收哪个区域内的垃圾?

需要注意的是,JVM GC只回收堆区和方法区内的对象。而栈区的数据,在超出作用域后会被JVM自动释放掉,所以其不在JVM GC的管理范围内。

问题8:JVM GC怎么判断对象可以被回收了?

  • 对象没有引用
  • 作用域发生未捕获异常
  • 程序在作用域正常执行完毕
  • 程序执行了System.exit()
  • 程序发生意外终止(被杀线程等)

在Java程序中不能显式的分配和注销缓存,因为这些事情JVM都帮我们做了,那就是GC。

有些时候我们可以将相关的对象设置成null 来试图显示的清除缓存,但是并不是设置为null 就会一定被标记为可回收,有可能会发生逃逸。

将对象设置成null 至少没有什么坏处,但是使用System.gc() 便不可取了,使用System.gc() 时候并不是马上执行GC操作,而是会等待一段时间,甚至不执行,而且System.gc() 如果被执行,会触发Full GC ,这非常影响性能。

问题9:JVM GC什么时候执行?

Eden区空间不够存放新对象的时候,执行Minro GC。升到老年代的对象大于老年代剩余空间的时候执行Full GC,或者小于的时候被HandlePromotionFailure 参数强制Full GC 。调优主要是减少 Full GC 的触发次数,可以通过 NewRatio 控制新生代转老年代的比例,通过MaxTenuringThreshold 设置对象进入老年代的年龄阀值(后面会介绍到)。

0x06 本地方法接口

简介

JNI(Java Native Interface),本地方法接口。

简单来讲,一个Native Method就是一个Java调用非Java代码的接口,一个Native Method是这样一个Java方法:该方法的底层实现由非Java语言实现,比如C。这个特征并非Java特有,很多其他的编程语言都有这一机制,比如在C++中,你可以用extern “C” 告知C++编译器去调用一个C的函数。

在定义一个native method时,并不提供实现体(有些像定义一个Java interface),因为其实现体是由非Java语言在外面实现的。

本地接口的作用是融合不同的编程语言为Java所用,它的初衷是融合C/C++程序。

标识符native可以与其他所有的Java标识符连用,但是abstract除外。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/**
* 本地方法
*/
public class IHaveNatives {

//abstract 没有方法体
public abstract void abstractMethod(int x);

//native 和 abstract不能共存,native是有方法体的,由C语言来实现
public native void Native1(int x);

native static public long Native2();

native synchronized private float Native3(Object o);

native void Native4(int[] array) throws Exception;

}

为何使用JNI

Java使用起来非常方便,然而有些层次的任务用Java实现起来不容易,或者我们对程序的效率很在意时,问题就来了。

  • 与Java环境外交互:有时Java应用需要与Java外面的环境交互,这是本地方法存在的主要原因。 你可以想想Java需要与一些底层系统,如擦偶偶系统或某些硬件交换信息时的情况。本地方法正式这样的一种交流机制:它为我们提供了一个非常简洁的接口,而且我们无需去了解Java应用之外的繁琐细节。
  • 与操作系统交互(比如线程最后要回归于操作系统线程):JVM支持着Java语言本身和运行库,它是Java程序赖以生存的平台,它由一个解释器(解释字节码)和一些连接到本地代码的库组成。然而不管怎样,它毕竟不是一个完整的系统,它经常依赖于一些底层系统的支持。这些底层系统常常是强大的操作系统。通过使用本地方法,我们得以用Java实现了jre的与底层系统的交互,甚至JVM的一些部分就是用C写的。还有,如果我们要使用一些Java语言本身没有提供封装的操作系统特性时,我们也需要使用本地方法。
  • Sun’s Java:Sun的解释器是用C实现的,这使得它能像一些普通的C一样与外部交互。jre大部分是用Java实现的,它也通过一些本地方法与外界交互。例如:类java.lang.Thread的setPriority()方法是用Java实现的,但是它实现调用的是该类里的本地方法setPriority0()。这个本地方法是用C实现的,并被植入JVM内部,在Windows 95的平台上,这个本地方法最终将调用Win32 setPriority() API。这是一个本地方法的具体实现由JVM直接提供,更多的情况是本地方法由外部的动态链接库(external dynamic link library)提供,然后被JVM调用。

0x07 执行引擎

参考:JVM字节码执行引擎