概述
每个Java开发人员都知道字节码经由JRE(Java Runtime Environment,Java运行时环境)执行。但他们或许不知道JRE其实是由JVM(Java Virtual Machine,Java虚拟机)实现,JVM分析字节码,解释并执行它。作为开发人员,了解JVM的架构是非常重要的,因为它使我们能够编写出更高效的代码。本文中,我们将深入了解Java中的JVM架构和JVM的各个组件。
虚拟机是物理机的软件实现。Java的设计理念是WORA(Write Once Run Anywhere,一次编写随处运行)。编译器将Java文件编译为.class文件,然后将.class文件输入到JVM中,JVM执行类文件的加载和执行的操作。
JVM主要分为5个部分:类加载子系统、运行时数据区、执行引擎、本地方法接口、本地方法库。JVM架构图如下:
- 类加载子系统
熟悉类加载子系统,有助于了解JVM运行过程,能更深入了解Java动态性(热部署、动态加载等),提高程序的灵活性。
1.1.类加载机制
虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这就是虚拟机的类加载机制。
与那些在编译时需要进行连接工作的语言不同,在Java语言里面,类型的加载、连接和初始化过程都是在程序运行期间完成的,这种策略虽然会令类加载时稍微增加一些性能开销,但是会为Java应用程序提供高度的灵活性,Java里天生可以动态扩展的语言特性就是依赖运行期动态加载和动态连接这个特点实现的。例如,如果编写一个面向接口的应用程序,可以等到运行时再指定其实际的实现类;用户可以通过Java预定义的和自定义类加载器,让一个本地的应用程序可以在运行时从网络或其他地方加载一个二进制流作为程序代码的一部分,这种组装应用程序的方式目前已广泛应用于Java程序之中。从最基础的Applet、JSP到相对复杂的OSGi技术,都使用了Java语言运行期类加载的特性。
Java的动态类加载功能由类加载器子系统处理。
1.2.类加载过程中类的生命周期
类从被加载到虚拟机内存中开始,到卸载出内存为止, 它的整个生命周期包括:加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载(Unloading)7个阶段。
1.3.类加载过程顺序
加载、验证、准备、初始化和卸载这5个阶段的顺序是确定的,类的加载过程必须按照这种顺序按部就班地开始,而解析阶段则不一定:它在某些情况下可以在初始化阶段之后再开始,这是为了支持Java语言的运行时绑定(也称也称为动态绑定或晚期绑定)。注意,这里笔者写的是按部就班地“开始”,而不是按部就班地“进行”或“完成”,强调这点是因为这些阶段通常都是互相交叉地混合式进行的,通常会在一个阶段执行的过程中调用、激活另外一个阶段。
1.4.类加载时机
什么情况下需要开始类加载过程的第一个阶段:加载?
Java虚拟机规范中并没有进行强制约束,这点可以交给虚拟机的具体实现来自由把握。但是对于初始化阶段,虚拟机规范则是严格规定了有且只有5种情况必须立即对类进行“初始化”(而加载、验证、准备自然需要在此之前开始)。
1.5.加载(Loading)
“加载”是“类加载”(ClassLoading)过程的一个阶段,希望读者没有混淆这两个看起来很相似的名词。在加载阶段,虚拟机需要完成以下3件事情:
1)通过一个类的全限定名来获取定义此类的二进制字节流。
2)将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
3)在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。
虚拟机规范的这3点要求其实并不算具体,因此虚拟机实现与具体应用的灵活度都是相当大的。例如:“通过一个类的全限定名来获取定义此类的二进制字节流”这条,它没有指明二进制字节流要从一个Class文件中获取,准确地说是根本没有指明要从哪里获取、怎样获取。虚拟机设计团队在加载阶段搭建了一个相当相当开放的、广阔的“舞台”,Java发展历程中,充满创造力的开发人员则在这个“舞台”上玩出了各种花样,许多举足轻重的Java技术都建立在这一基础之上,例如:
从ZIP包中读取,这很常见,最终成为日后JAR、EAR、WAR格式的基础。
从网络中获取,这种场景最典型的应用就是Applet。
运行时计算生成,这种场景使用得最多的就是动态代理技术,在java.lang.reflect.Proxy中,就是用了ProxyGenerator.generateProxyClass来为特定接口生成形式为"*$Proxy"的代理类的二进制字节流。
由其他文件生成,典型场景是JSP应用,即由JSP文件生成对应的Class类。
从数据库中读取,这种场景相对少见些,例如有些中间件服务器(如SAPNetweaver)可以选择把程序安装到数据库中来完成程序代码在集群间的分发。
……
相对于类加载过程的其他阶段,一个非数组类的加载阶段(准确地说,是加载阶段中获取类的二进制字节流的动作)是开发人员可控性最强的,因为加载阶段既可以使用系统提供的引导类加载器来完成,也可以由用户自定义的类加载器去完成,开发人员可以通过定义自己的类加载器去控制字节流的获取方式(即重写一个类加载器的loadClass()方法)。
对于数组类而言,情况就有所不同,数组类本身不通过类加载器创建,它是由Java虚拟机直接创建的。但数组类与类加载器仍然有很密切的关系,因为数组类的元素类型(ElementType,指的是数组去掉所有维度的类型)最终是要靠类加载器去创建,一个数组类(下面简称为C)创建过程就遵循以下规则:
如果数组的组件类型(ComponentType,指的是数组去掉一个维度的类型)是引用类型,那就递归采用本节中定义的加载过程去加载这个组件类型,数组C将在加载该组件类型的类加载器的类名称空间上被标识(这点很重要,在7.4节会介绍到,一个类必须与类加载器一起确定唯一性)。
如果数组的组件类型不是引用类型(例如int[]数组),Java虚拟机将会把数组C标记为与引导类加载器关联。
数组类的可见性与它的组件类型的可见性一致,如果组件类型不是引用类型,那数组类的可见性将默认为public。
加载阶段完成后,虚拟机外部的二进制字节流就按照虚拟机所需的格式存储在方法区之中,方法区中的数据存储格式由虚拟机实现自行定义,虚拟机规范未规定未规定此区域的具体数据结构。然后在内存中实例化一个java.lang.Class类的对象(并没有明确规定是在Java堆中,对于HotSpot虚拟机而言,Class对象比较特殊,它虽然是对象,但是存放在方法区里面),这个对象将作为程序访问方法区中的这些类型数据的外部接口。
加载阶段与连接阶段的部分内容(如一部分字节码文件格式验证动作)是交叉进行的,加载阶段尚未完成,连接阶段可能已经开始,但这些夹在加载阶段之中进行的动作,仍然属于连接阶段的内容,这两个阶段的开始时间仍然保持着固定的先后顺序。
1.6.验证(Verification)
确保加载的类信息符合JVM规范,没有安全方面的问题。验证是连接阶段的第一步,这一阶段的目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。
从整体上看,验证阶段大致上会完成下面4个阶段的检验动作:
- 文件格式验证
- 元数据验证
- 字节码验证
- 符号引用验证
1.7.准备(Prepare)
准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些变量所使用的内存都将在方法区中进行分配。这个阶段中有两个容易产生混淆的概念需要强调一下,首先,这时候进行内存分配的仅包括类变量(被static修饰的变量),而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在Java堆中。其次,这里所说的初始值“通常情况”下是数据类型的零值,假设一个类变量的定义为:
public static int value= 123;
那变量value在准备阶段过后的初始值为0而不是123,因为这时候尚未开始执行任何Java方法,而把value赋值为123的putstatic指令是程序被编译后,存放于类构造器<clinit>()方法之中,所以把value赋值为123的动作将在初始化阶段才会执行。表7-1列出了Java中所有基本数据类型的零值。
上面提到,在“通常情况”下初始值是零值,那相对的会有一些“特殊情况”:如果类字段的字段属性表中存在ConstantValue属性,那在准备阶段变量value就会被初始化为ConstantValue属性所指定的值,假设上面类变量value的定义变为:
public static final int value= 123;
编译时javac将会为value生成ConstantValue属性,在准备阶段虚拟机就会根据ConstantValue的设置将value赋值为123。
1.8.解析(Resolution)
解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程,符号引用在Class文件中它以CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info等类型的常量出现。
符号引用(SymbolicReferences):符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标并不一定已经加载到内存中。各种虚拟机实现的内存布局可以各不相同,但是它们能接受的符号引用必须都是一致的,因为符号引用的字面量形式明确定义在Java虚拟机规范的Class文件格式中。
直接引用(DirectReferences):直接引用可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。直接引用是和虚拟机实现的内存布局相关的,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标必定已经在内存中存在。
解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符7类符号引用进行,分别对应于常量池的CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info、CONSTANT_InterfaceMethodref_info、CONSTANT_MethodType_info、CONSTANT_MethodHandle_info和CONSTANT_InvokeDynamic_info7种常量类型
1.9.初始化(Initialization)
类初始化阶段是类加载过程的最后一步,前面的类加载过程中,除了在加载阶段用户应用程序可以通过自定义类加载器参与之外,其余动作完全由虚拟机主导和控制。到了初始化阶段,才真正开始执行类中定义的Java程序代码(或者说是字节码)。
在准备阶段,变量已经赋过一次系统要求的初始值,而在初始化阶段,则根据程序员通过程序制定的主观计划去初始化类变量和其他资源,或者可以从另外一个角度来表达:初始化阶段是执行类构造器<clinit>()方法的过程。我们在下文会讲解<clinit>()方法是怎么生成的,在这里,我们先看一下<clinit>()方法执行过程中一些可能会影响程序运行行为的特点和细节,这部分相对更贴近于普通的程序开发人员。
<clinit>()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{}块)中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序所决定的,静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值,但是不能访问,如下:
public class Test{
static{
i= 0; // 给变量赋值可以正常编译通过
System. out. Print(i); // 这句编译器会提示“非法向前引用”
}
static int i= 1;
}
<clinit>()方法与类的构造函数(或者说实例构造器<init>()方法)不同,它不需要显式地调用父类构造器,虚拟机会保证在子类的<clinit>()方法执行之前,父类的<clinit>()方法已经执行完毕。因此在虚拟机中第一个被执行的<clinit>()方法的类肯定是java.lang.Object。
由于父类的<clinit>()方法先执行,也就意味着父类中定义的静态语句块要优先于子类的变量赋值操作
虚拟机会保证一个类的<clinit>()方法在多线程环境中被正确地加锁、同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的<clinit>()方法,其他线程都需要阻塞等待,直到活动线程执行<clinit>()方法完毕。如果在一个类的<clinit>()方法中有耗时很长的操作,就可能造成多个进程阻塞[2],在实际应用中这种阻塞往往是很隐蔽的。同一个类加载器下,一个类型只会初始化一次。
虚拟机规范严格规定了有且只有5种情况必须立即对类进行“初始化”:
1)遇到new、getstatic、putstatic或invokestatic这4条字节码指令时,如果类没有进行过初始化,则需要先触发其初始化。生成这4条指令的最常见的Java代码场景是:使用new关键字实例化对象的时候、读取或设置一个类的静态字段(被final修饰、已在编译期把结果放入常量池的静态字段除外)的时候,以及调用一个类的静态方法的时候。
2)使用java.lang.reflect包的方法对类进行反射调用的时候,如果类没有进行过初始化,则需要先触发其初始化。
3)当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
4)当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类。
5)当使用JDK1.7的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,并且这个方法句柄所对应的类没有进行过初始化,则需要先触发其初始化。
对于这5种会触发类进行初始化的场景,虚拟机规范中使用了一个很强烈的限定语:“有且只有”,这5种场景中的行为称为对一个类进行主动引用。除此之外,所有引用类的方式都不会触发初始化,称为被动引用。
主动引用:类的主动引用(一定会发生类的初始化)
- new一个类的对象
- 调用类的静态成员(除了final常量)和静态方法
- 使用java.lang.reflect包的方法对类进行反射调用
- 当虚拟机启动,java Hello,则一定会初始化Hello类。说白了就是先启动main方法所在的类
- 当初始化一个类,如果其父类没有被初始化,则先会初始化他的父类
被动引用:类的被动引用(不会发生类的初始化)
- 当访问一个静态域时,只有真正声明这个域的类才会被初始化
- 通过子类引用父类的静态变量,不会导致子类初始化
- 通过数组定义类引用,不会触发此类的初始化
- 引用常量不会触发此类的初始化(常量在编译阶段就存入调用类的常量池中了)
1.10.类加载器
类加载器:是一个代码模块,这个模块通过一个类的全限定名来获取描述此类的二进制字节流。最初是为了满足JavaApplet的需求而开发出来的,后来在类层次划分、OSGi、热部署、代码加密等领域大放异彩。
1.10.1.类缓存
标准的Java SE类加载器可以按要求查找类,但一旦某个类被加载到类加载器中,它将维持加载(缓存)一段时间。不过,JVM垃圾收集器可以回收这些Class对象。
1.10.2.java.lang.ClassLoader
作用:
- java.lang.ClassLoader类的基本职责就是根据一个指定的类的名称,找到或者生成其对应的字节代码,然后从这些字节代码中定义出一个Java 类,即 java.lang.Class类的一个实例。
- 除此之外,ClassLoader还负责加载 Java 应用所需的资源,如图像文件和配置文件等。
相关方法:
- getParent() 返回该类加载器的父类加载器。
- loadClass(String name) 加载名称为 name的类,返回的结果是 java.lang.Class类的实例。
- findClass(String name) 查找名称为 name的类,返回的结果是 java.lang.Class类的实例。
- findLoadedClass(String name) 查找名称为 name的已经被加载过的类,返回的结果是 java.lang.Class类的实例。
- defineClass(String name, byte[] b, int off, int len) 把字节数组 b中的内容转换成 Java 类,返回的结果是
- java.lang.Class类的实例。这个方法被声明为 final的。
- resolveClass(Class<?> c) 链接指定的 Java 类。
对于以上给出的方法,表示类名称的name参数,它的值是类的二进制名称。需要注意内部类的表示,如com.example.Sample$1和com.example.Sample$Inner等表示方式。
1.10.3.类加载器的层次结构(树状结构)
各个加载器存在父子关系,不是靠继承,是靠组合实现父子关系的。
1.10.3.1.引导类加载器(bootstrap class loader)
- 它用来加载 Java 的核心库(JAVA_HOME/jre/lib/rt.jar,或sun.boot.class.path路径下的内容),是用原生代码来实现的,并不继承自 java.lang.ClassLoader。
- 加载扩展类和应用程序类加载器。并指定他们的父类加载器。
1.10.3.2.扩展类加载器(extensions class loader)
- 用来加载 Java 的扩展库(JAVA_HOME/jre/ext/*.jar,或java.ext.dirs路径下的内容) 。Java 虚拟机的实现会提供一个扩展库目录。该类加载器在此目录里面查找并加载 Java类。
- 由sun.misc.Launcher$ExtClassLoader实现
- 继承自 java.lang.ClassLoader。
1.10.3.3.应用程序类加载器(application class loader)
- 它根据 Java 应用的类路径(classpath,java.class.path 路径下的内容)来加载 Java 类。一般来说,Java 应用的类都是由它来完成加载的。
- 由sun.misc.Launcher$AppClassLoader实现
- 继承自 java.lang.ClassLoader。
1.10.3.4.自定义类加载器
开发人员可以通过继承 java.lang.ClassLoader类的方式实现自己的类加载器,以满足一些特殊的需求。
1.10.4.双亲委派模型
1.10.4.1.代理模式:
当被请求加载一个类时,把请求交给其他加载器来加载指定的类。
1.10.4.2.双亲委派模型:
双亲委派模型:就是某个特定的类加载器在接到加载类的请求时,首先将加载任务委托给父类加载器,依次追溯,直到最高的爷爷辈的,如果父类加载器可以完成类加载任务,就成功返回,只有父类加载器无法完成此加载任务时,才自己去加载。
- 双亲委派机制是为了保证 Java 核心库的类型安全。这种机制就保证不会出现用户自己定义java.lang.Object类的情况。【说明:在顶级的类加载器发现自己的类库中有了自定义的类,就不会加载这个自定义类,而是加载它自己类库中的类。假设我们定义了java.lang.Object类,类加载器会发现自己的类库中已经有这个类,就不会加载我们自定义的java.lang.Object类,此时我们自定义java.lang.Object类没有意义了,所以就不会出现用户自定义java.lang.Object类的情况。】
- 类加载器除了用于加载类,也是安全的最基本的屏障。
- 双亲委托机制是代理模式的一种
并不是所有的类加载器都采用双亲委托机制。Tomcat服务器类加载器也使用代理模式,所不同的是它是首先尝试去加载某个类,如果找不到再代理给父类加载器。这与一般类加载器的顺序是相反的。
1.10.5.自定义类加载器
1.10.5.1.流程
- 首先检查请求的类型是否已经被这个类装载器装载到命名空间中了,如果已经装载,直接返回;否则转入步骤2
- 委派类加载请求给父类加载器(更准确的说应该是双亲类加载器,真个虚拟机中各种类加载器最终会呈现树状结构),如果父类加载器能够完成,则返回父类加载器加载的Class实例;否则转入步骤3
- 调用本类加载器的findClass(…)方法,试图获取对应的字节码,如果获取的到,则调用defineClass(…)导入类型到方法区;如果获取不到对应的字节码或者其他原因失败,返回异常给loadClass(…), loadClass(…)转抛异常,终止加载过程(注意:这里的异常种类不止一种)。
注意:被两个类加载器加载的同一个类,JVM不认为是相同的类。
1.10.6.服务器类加载原理和OSGI介绍
1.10.6.2.Tomcat服务器的类加载机制
- 一切都是为了安全!Tomcat不能使用系统默认的类加载器。
• 如果TOMCAT跑你的WEB项目使用系统的类加载器那是相当危险的,你可以直接肆无忌惮使用操作系统的各个目录了。
• 对于运行在 Java EE™容器中的 Web 应用来说,类加载器的实现方式与一般的 Java 应用有所不同。
• 每个 Web 应用都有一个对应的类加载器实例。该类加载器也使用代理模式(不同于前面说的双亲委托机制),所不同的是它是首先尝试去加载某个类,如果找不到再代理给父类加载器。这与一般类加载器的顺序是相反的。但也是为了保证安全,这样核心库就不在查询范围之内。
- 为了安全,Tomcat需要实现自己的类加载器。
我可以限制你只能把类写在指定的地方,否则我不给你加载!
1.10.6.3.OSGI原理介绍
- OSGi™(Open Service Gateway Initative)是 面向Java的动态模块系统。它为开发人员提供了面向服务和基于组件的运行环境,并提供标准的方式用来管理软件的生命周期。
- OSGi 已经被实现和部署在很多产品上,在开源社区也得到了广泛的支持。Eclipse就是基于 OSGi 技术来构建的。
- 原理:
OSGi 中的每个模块(bundle)都包含 Java 包和类。模块可以声明它所依赖的需要导入(import)的其它模块的 Java 包和类(通过 Import-Package),也可以声明导出(export)自己的包和类,供其它模块使用(通过 Export-Package)。也就是说需要能够隐藏和共享一个模块中的某些 Java 包和类。这是通过 OSGi 特有的类加载器机制来实现的。OSGi 中的每个模块都有对应的一个类加载器。它负责加载模块自己包含的Java 包和类。当它需要加载 Java 核心库的类时(以 java开头的包和类),它会代理给父类加载器(通常是启动类加载器)来完成。当它需要加载所导入的 Java 类时,它会代理给导出此 Java 类的模块来完成加载。模块也可以显式的声明某些 Java 包和类,必须由父类加载器来加载。只需要设置系统属性 org.osgi.framework.bootdelegation的值即可。
- Equinox: OSGI的一个框架实现
1.11.接口加载与初始化
接口的加载过程与类加载过程稍有一些不同,针对接口需要做一些特殊说明:接口也有初始化过程,这点与类是一致的,类都是用静态语句块“static{}”来输出初始化信息的,而接口中不能使用“static{}”语句块,但编译器仍然会为接口生成“<clinit>()”类构造器,用于初始化接口中所定义的成员变量。接口与类真正有所区别的是前面讲述的5种“有且仅有”需要开始初始化初始化场景中的第3种:当一个类在初始化时,要求其父类全部都已经初始化过了,但是一个接口在初始化时,并不要求其父接口全部都完成了初始化,只有在真正使用到父接口的时候(如引用接口中定义的常量)才会初始化。
<clinit>()方法对于类或接口来说并不是必需的,如果一个类中没有静态语句块,也没有对变量的赋值操作,那么编译器可以不为这个类生成<clinit>()方法。接口中不能使用静态语句块,但仍然有变量初始化的赋值操作,因此接口与类一样都会生成<clinit>()方法。但接口与类不同的是,执行接口的<clinit>()方法不需要先执行父接口的<clinit>()方法。只有当父接口中定义的变量使用时,父接口才会初始化。另外,接口的实现类在初始化时也一样不会执行接口的<clinit>()方法。
- 运行时数据区
2.1.栈(Stack Area)
2.2.堆(Heap Area)
2.3.方法区(Method Area)
2.4.程序计数器(Program Counter Registers)
2.5.本地方法栈(Native Method Stack)
2.6.直接内存(NIO)
- 执行引擎
3.1.解释器
解释器更快地解释字节码,但执行缓慢。解释器的缺点是当一个方法被调用多次时,每次都需要一个新的解释。
3.2.JIT编译器
JIT编译器消除了解释器的缺点。执行引擎将在转换字节码时使用解释器的帮助,但是当它发现重复的代码时,将使用JIT编译器,它编译整个字节码并将其更改为本地代码。这个本地代码将直接用于重复的方法调用,这提高了系统的性能。JIT的构成组件为:
- 中间代码生成器(Intermediate Code Generator):生成中间代码
- 代码优化器(Code Optimizer):负责优化上面生成的中间代码
- 目标代码生成器(Target Code Generator):负责生成机器代码或本地代码
- 分析器(Profiler):一个特殊组件,负责查找热点,即该方法是否被多次调用;
3.3.垃圾回收(Garbage Collection)
收集和删除未引用的对象。可以通过调用“System.gc()”触发垃圾收集,但不能保证执行。JVM的垃圾回收对象是已创建的对象。
3.3.1.JVM堆内存分代
- JVM的内存分代策略
Java虚拟机根据对象存活的周期不同,把堆内存划分为几块,一般分为新生代、老年代和永久代(对HotSpot虚拟机而言),这就是JVM的内存分代策略。
- 为什么要分代?
堆内存是虚拟机管理的内存中最大的一块,也是垃圾回收最频繁的一块区域,我们程序所有的对象实例都存放在堆内存中。给堆内存分代是为了提高对象内存分配和垃圾回收的效率。试想一下,如果堆内存没有区域划分,所有的新创建的对象和生命周期很长的对象放在一起,随着程序的执行,堆内存需要频繁进行垃圾收集,而每次回收都要遍历所有的对象,遍历这些对象所花费的时间代价是巨大的,会严重影响我们的GC效率,这简直太可怕了。
有了内存分代,情况就不同了,新创建的对象会在新生代中分配内存,经过多次回收仍然存活下来的对象存放在老年代中,静态属性、类信息等存放在永久代中,新生代中的对象存活时间短,只需要在新生代区域中频繁进行GC,老年代中对象生命周期长,内存回收的频率相对较低,不需要频繁回收,永久代中回收效果太差,一般不进行垃圾回收,还可以根据不同年代的特点采用合适的垃圾收集算法。分代收集大大提升了收集效率,这些都是内存分代带来的好处。
- 内存分代划分
Java虚拟机将堆内存划分为新生代、老年代和永久代,永久代是HotSpot虚拟机特有的概念,它采用永久代的方式来实现方法区,其他的虚拟机实现没有这一概念,而且HotSpot也有取消永久代的趋势,在JDK1.7中HotSpot已经开始了“去永久化”,把原本放在永久代的字符串常量池移出。永久代主要存放常量、类信息、静态变量等数据,与垃圾回收关系不大,新生代和老年代是垃圾回收的主要区域。内存分代示意图如下:
3.3.1.1.新生代(Young Generation)
新生成的对象优先存放在新生代中,新生代对象朝生夕死,存活率很低,在新生代中,常规应用进行一次垃圾收集一般可以回收70%~95%的空间,回收效率很高。
HotSpot将新生代划分为三块,一般较大的Eden Space(伊甸园空间)和两块较小的Survivor Space(幸存空间),默认比例为8:1:1。划分的目的是因为HotSpot采用复制算法来回收新生代,设置这个比例是为了充分利用内存空间,减少浪费。新生成的对象在Eden去分配(大对象除外,大对象直接进入老年代),当Eden区没有足够的空间进行分配时,虚拟机将发起一次Minor GC。GC开始时,对象只会存在于Eden区和Survivor From区,Survivor To区是空的(作为保留区域)。GC进行时,Eden区中所有存活的对象都会被复制到Survivor To区,而在From区中,仍存活的对象会根据他们的年龄值决定去向,年龄值达到年龄阀值(默认为15,新生代中的对象每熬过一轮垃圾回收,年龄值就加1,GC分代年龄存储在对象的header中)的对象会被移到老年代中,没有达到阀值的对象会被复制到To区。接着清空Eden区和From区,新生代中存活的对象都在To区。接着From区和To区会交换他们的角色,也就是新的To区是上次GC清空的From区,新的From区就是上次GC的To区。
总之,不管怎样都会保证Survivor To Space在一轮GC后是空的。GC是当Survivor To Space没有足够的空间存放上一次新生代收集下来的存活对象时,需要依赖老年代进行分配担保,将这些对象存放在老年代中。
3.3.1.2.老年代(Old Generation)
老年代也可以称为Tenured Generation,在新生代中经历了多次(具体看虚拟机配置的阀值)GC后仍然存活下来的对象会进入老年代中。老年代中的对象生命周期较长,存活率较高,在老年代中进行GC的频率相对而言较低,而且回收的速度也比较慢。
3.3.1.3.永久代(Permanent Generation)
永久代存储类信息、常量、静态变量、即时编译器编译后的代码等数据,对这一区域而言,Java虚拟机规范支出可以不进行垃圾收集,一般而言不会进行垃圾回收。
在JDK1.7中HotSpot已经开始了“去永久化”,把原本放在永久代的字符串常量池移出。
3.3.2.垃圾收集算法
3.3.2.1.引用计数(Reference Counting)
比较古老的回收算法。原理是此对象有一个引用,即增加一个计数,删除一个引用则减少一个计数。垃圾回收时只用收集计数为0的对象。此算法最致命的是无法处理循环引用的问题。
3.3.2.2.复制(Copying)
此算法把内存空间划分为两个相等的区域,每次只使用其中一个区域。垃圾回收时,遍历当前使用区域,把正在使用中的对象复制到另外一个区域中。此算法每次只处理正在使用中国的对象,因此复制成本比较小,同时复制过去以后还能进行相应的内存整理,不会出现“碎片”问题。当然,此算法的缺点也是和明显的,就是需要两倍内存空间。
3.3.2.3.标记-清除(Mark-Sweep)
此算法执行分为两个阶段。第一阶段从引用根节点开始标记所有被引用的对象,第二阶段遍历整个堆,被未标记的对象清除。此算法需要暂停整个应用,同时会产生内存碎片。
3.3.2.4.标记-整理(Mark-Compact)
此算法结合了“标记-清除”和“复制”两个算法的优点,是对“标记-清除”算法的优化。也是分为两个阶段,第一阶段从根节点开始标记所有被引用的对象,第二阶段遍历整个堆,把清除未标记对象并且把存活对象“压缩”到堆的其中一块,按顺序排放。此算法避免了“标记-清除”的碎片问题,同时也避免了“复制”算法的空间问题。
3.3.3.垃圾收集器
- Scavenge GC(次收集,也称Minor GC)和Full GC(全收集)的区别
新生代GC(Scavenge GC/Minor GC):Scavenge GC指发生在新生代的GC,因为新生代的Java对象大多都是朝生夕死,所以Scavenge GC非常频繁,一般回收速度也比较快。当Eden空间不足以为对象分配内存是,会触发Scavenge GC。
一般情况下,当新对象生成,并且在Eden申请空间失败是,就会触发Scavenge GC,当Eden区域进行GC,清除非存活对象,并且把尚且存活的对象移动到Survivor区。然后整理Survivor的两个区。这种方式的GC是对年轻代的Eden区进行,不会影响到年老代。因为大部分对象都是从Eden区开始的,同事Eden区不会分配的很大,所以Eden区的GC会频繁进行。因而,一般在这里需要使用速度快、效率高的算法,是Eden区能尽快空闲出来。
老年代GC(Full GC/Major GC):Full GC指发生在老年代的GC,出现了Full GC一般会伴随着至少一次的Minor GC(老年代的对象大部分是Minor GC过程中从新生代进入老年代),比如:分配担保失败。Full GC的速度一般会比Minor GC慢10倍以上。当老年代内存不足或者显示调用System.gc()方法时,会触发Full GC。
- 次收集(Scavenge GC/Minor GC)
当年轻代堆空间紧张时会被触发。相对于全收集而言,收集间隔较短。
- 全收集(Full GC/Major GC)
当老年代或者持久代堆空间满了,会触发全收集操作。
可以使用System.gc()启动Full GC,但不是立即启动,只是让它优先启动。
全收集一般根据堆大小的不同,需要的时间不尽相同,但一般会比较长。不过,如果全收集时间超过3到5秒钟,那就太长了。
3.3.3.1.年轻代收集器
HotSpot提供了7个垃圾收集器,这7个垃圾收集器配合使用,有些是可以一起配合使用,有些则不能。下图中连线的表示可以配合使用,G1收集器既可以收集年轻代,也可以收集年老代。
3.3.3.1.1.串行收集器Serial
JDK1.3版本之前,HotSpot只有一个收集器,那就是串行收集器(Serial)。
Serial收集器是HotSpot运行在Client模式下的默认新生代收集器,它的特点是只用一个CPU/一条收集线程去完成GC工作,且在进行垃圾收集是必须暂停其他所有的工作线程(“Stop The World” - 后面简称STW)。可以使用-XX:+UseSerialGC打开。
Serial收集器虽然是单线程收集,但它却简单而高效,在VM管理内存不大的情况下(收集几十兆至一两百兆的新生代),停顿时间完全可以空载在几十毫秒到一百多毫秒之内。
3.3.3.1.2.并行收集器ParNew
在单核CPU上,ParNew不一定比Serial效率高,ParNew很多代码都是复用了Serial。
ParNew收集器其实是前面Serial的多线程版本,除了使用多线程进行GC外,包括Serial可用的所有控制参数、收集算法、STW、对象分配规则、回收策略等都与Serial完全一样(也是VM启用CMS收集器-XX:+UseConcMarkCompactGC的默认新生代收集器)。
由于存在线程切换的开销,ParNew在单CPU的环境中比不上Serial,且在通过超线程技术实现的两个CPU的环境中也不能100%保证能超越Serial。但随着可用的CPU数量的增加,收集效率肯定也会大大增加(ParNew收集线程数与CPU的数量相同,因此在CPU数量过大的环境中,可用-XX:ParallelGCTheads=<N>参数控制GC线程数)。
ParNew的优点在于缩短了安全点的时间。
CMS启动后,默认与ParNew搭配使用,CMS也可以与Serial搭配,但不可以与Parallel Scavenge搭配使用,会导致虚拟机不能启动。
-XX:ParallelGCThreads=<N>,参数中的N一般设置为物理机的内核数。
3.3.3.1.3.Parallel Scavenge
与ParNew类似,Parallel Scavenge也是使用复制算法,也是并行多线程收集器。但与其他收集器关注尽可能缩短垃圾收集时间不同,Parallel Scavenge更关注系统吞吐量(系统吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间) )
停顿时间越短就越使用与用户交互的程序,良好的响应速度能提升用户的体验;而高吞吐量则适用于后台运算而不需要太多交互的任务,可以最高效率地利用CPU时间,尽快地完成程序的运算任务。
Parallel Scavenge提供了以下参数设置系统吞吐量:
Parallel Scavenge参数 | 描述 |
-XX:MaxGCPauseMillis | (毫秒数)收集器将尽力保证内存回收花费的时间不超过设定值,但如果太小将会导致GC的频率增加 |
-XX:GCTimeRatio | (整数:0<GCTimeRatio<100)垃圾收集时间占比 |
3.3.3.2.年老代收集器
3.3.3.2.1.Serial Old
Serial Old是Serial收集器的老年代版本,同样是单线程收集器,使用“标记-整理”算法。
Serial Old兼容性特别好,可以与任何一个青年代收集器(Serial、ParNew、Parallel Scavenge)配合使用
3.3.3.2.2.Parallel Old
Parallel Old是Parallel Scavenge收集器的老年代版本,使用多线程和“标记-整理”算法,吞吐量优先,主要与Parallel Scavenge配合在注重吞吐量及CPU资源敏感系统内使用。
Parallel Old只能与Parallel Scavenge配合使用。
3.3.3.2.3.CMS
CMS(Concurrent Mark Sweep)收集器是一款具有划时代意义的收集器,一款真正意义上的并发收集器,虽然现在已经有了理论意义上表现更好的G1收集器,但现在主要互联网企业线上选用的仍然是CMS(如淘宝、微店)。
CMS是一种以获得最短回收挺对时间为目标的收集器(CMS又称多并发低短暂的收集器),基于“标记-清除”算法实现,整个GC过程分为以下4个步骤:
1、初始标记(CMS initial mark)
2、并发标记(CMS concurrent mark: GC Roots Tracing过程)
3、重新标记(CMS remark)
4、并发清除(CMS concurrent sweep: 已死对象将会就地释放,注意:此处没有压缩)
3.3.3.3.G1收集器(Garbage-First)
G1收集器是现在最新的收集器,G1(Garbage First)是一款面向服务端应用的收集器,主要目标用于配备多频CPU的服务器治理大内存。
-XX:+UserG1GC启用G1收集器。
与其他基于分代的收集器不同,G1将整个Java堆划分为多个大小相等的独立区域(Region),虽然还保留有新生代和老年代概念,但新生代和老年代不再是物理隔离的了,他们都是一部分Region(不需要连续)的集合。
每块区域既有可能属于O区,也有可能属于Y区,因此不需要一次就对整个老年代/新生代回收。而是当线程并发寻找可回收对象时,有些区块包含可回收的对象要比其他区块多得多。虽然在清理这些区块时,G1仍然需要暂停应用线程,但可以用相对较少的时间优先回收垃圾较多的Region(这也是G1命名的来源)。这种方式保证了G1可以在有限的时间内获取尽可能高的收集效率。
3.3.4.JVM优化
- JVM小工具
bin | 描述 | 功能 |
jps | 打印HotSpot VM进程 | VMID、JVM参数、main()函数参数、主类名、jar路径 |
jstat | 查看HotSpot VM运行时信息 | 类加载、内存、GC[可分代查看]、JIT编译 命令格式:jstat -gc 10340 250 20 |
jinfo | 查看和修改虚拟机各项配置 | -flag name-value |
jmap | heapdump:生成VM堆转储快照、查询finalize执行队列、Java堆和永久代详细 | jmap -dump:live,format=b,file=heap.bin[VMID] |
jconsole | 基于JMX的可视化监视、管理工具 | 可以查看内存、线程、类、CPU信息、以及对JMX MBean进行管理 |
jvisualvm | JDK中最强大运行监视和故障处理工具 | 可以监视内存泄漏、跟踪垃圾回收、执行时内存分析、CPU分析、线程分析…… |
jstat命令结果:
S0C表示Survivor Space的From区创建(Create)的大小,以字节为单位。
S1C表示Survivor Space的To区创建的大小
S0U表示S0 Use,即使用的大小
S1U表示S1 Use
EC表示Eden区创建的大小
EU表示Eden区使用的大小
OC表示Old Generation区创建大小
OU…
PC…
PU…
YGC表示Young GC进行垃圾收集的次数
YGCT表示YGC进行垃圾收集的时间,以秒为单位
FGC…
FGCT…
GCT…
- Java本地接口(Java Native Interface,JNI)
JNI将与本机方法库进行交互,并提供执行引擎所需的本机库。
- 本地方法库(Native Method Libaray)
它是执行引擎所需的本机库的集合。