数据结构、堆、栈
Java虚拟机中,数据类型可以分为两类:基本类型和引用类型。基本类型的变量保存原始值,即:他代表的值就是数值本身;而引用类型的变量保存引用值。“引用值”代表了某个对象的引用,而不是对象本身,对象本身存放在这个引用值所表示的地址的位置。
基本类型包括:byte, short, int, long, char, float, double, Boolean
引用类型包括:类类型,接口类型和数组。
堆与栈
堆和栈是程序运行的关键,很有必要把他们的关系说清楚。
栈是运行时的单位,而堆是存储的单位。
栈解决程序的运行问题,即程序如何执行,或者说如何处理数据;堆解决的是数据存储的问题,即数据怎么放、放在哪儿。
在Java中一个线程就会相应有一个线程栈与之对应,这点很容易理解,因为不同的线程执行逻辑有所不同,因此需要一个独立的线程栈。而堆则是所有线程共享的。栈因为是运行单位,因此里面存储的信息都是跟当前线程(或程序)相关信息的。包括局部变量、程序运行状态、方法返回值等等;而堆只负责存储对象信息。
为什么要把堆和栈区分出来呢?栈中不是也可以存储数据吗?
第一,从软件设计的角度看,栈代表了处理逻辑,而堆代表了数据。这样分开,使得处理逻辑更为清晰。分而治之的思想。这种隔离、模块化的思想在软件设计的方方面面都有体现。
第二,堆与栈的分离,使得堆中的内容可以被多个栈共享(也可以理解为多个线程访问同一个对象)。这种共享的收益是很多的。一方面这种共享提供了一种有效的数据交互方式(如:共享内存),另一方面,堆中的共享常量和缓存可以被所有栈访问,节省了空间。
第三,栈因为运行时的需要,比如保存系统运行的上下文,需要进行地址段的划分。由于栈只能向上增长,因此就会限制住栈存储内容的能力。而堆不同,堆中的对象是可以根据需要动态增长的,因此栈和堆的拆分,使得动态增长成为可能,相应栈中只需记录堆中的一个地址即可。
第四,面向对象就是堆和栈的完美结合。其实,面向对象方式的程序与以前结构化的程序在执行上没有任何区别。但是,面向对象的引入,使得对待问题的思考方式发生了改变,而更接近于自然方式的思考。当我们把对象拆开,你会发现,对象的属性其实就是数据,存放在堆中;而对象的行为(方法),就是运行逻辑,放在栈中。我们在编写对象的时候,其实即编写了数据结构,也编写的处理数据的逻辑。不得不承认,面向对象的设计,确实很美。
在Java中,Main函数就是栈的起始点,也是程序的起始点。
程序要运行总是有一个起点的。同C语言一样,java中的Main就是那个起点。无论什么java程序,找到main就找到了程序执行的入口:)
堆中存什么?栈中存什么?
堆中存的是对象。栈中存的是基本数据类型和堆中对象的引用。一个对象的大小是不可估计的,或者说是可以动态变化的,但是在栈中,一个对象只对应了一个4btye的引用(堆栈分离的好处:))。
为什么不把基本类型放堆中呢?因为其占用的空间一般是1~8个字节——需要空间比较少,而且因为是基本类型,所以不会出现动态增长的情况——长度固定,因此栈中存储就够了,如果把他存在堆中是没有什么意义的(还会浪费空间,后面说明)。可以这么说,基本类型和对象的引用都是存放在栈中,而且都是几个字节的一个数,因此在程序运行时,他们的处理方式是统一的。但是基本类型、对象引用和对象本身就有所区别了,因为一个是栈中的数据一个是堆中的数据。最常见的一个问题就是,Java中参数传递时的问题。
Java中的参数传递时传值呢?还是传引用?
要说明这个问题,先要明确两点:
1. 不要试图与C进行类比,Java中没有指针的概念
2. 程序运行永远都是在栈中进行的,因而参数传递时,只存在传递基本类型和对象引用的问题。不会直接传对象本身。
明确以上两点后。Java在方法调用传递参数时,因为没有指针,所以它都是进行传值调用(这点可以参考C的传值调用)。因此,很多书里面都说Java是进行传值调用,这点没有问题,而且也简化的C中复杂性。
但是传引用的错觉是如何造成的呢?在运行栈中,基本类型和引用的处理是一样的,都是传值,所以,如果是传引用的方法调用,也同时可以理解为“传引用值”的传值调用,即引用的处理跟基本类型是完全一样的。但是当进入被调用方法时,被传递的这个引用的值,被程序解释(或者查找)到堆中的对象,这个时候才对应到真正的对象。如果此时进行修改,修改的是引用对应的对象,而不是引用本身,即:修改的是堆中的数据。所以这个修改是可以保持的了。
对象,从某种意义上说,是由基本类型组成的。可以把一个对象看作为一棵树,对象的属性如果还是对象,则还是一颗树(即非叶子节点),基本类型则为树的叶子节点。程序参数传递时,被传递的值本身都是不能进行修改的,但是,如果这个值是一个非叶子节点(即一个对象引用),则可以修改这个节点下面的所有内容。
堆和栈中,栈是程序运行最根本的东西。程序运行可以没有堆,但是不能没有栈。而堆是为栈进行数据存储服务,说白了堆就是一块共享的内存。不过,正是因为堆和栈的分离的思想,才使得Java的垃圾回收成为可能。
Java中,栈的大小通过-Xss来设置,当栈中存储数据比较多时,需要适当调大这个值,否则会出现java.lang.StackOverflowError异常。常见的出现这个异常的是无法返回的递归,因为此时栈中保存的信息都是方法返回的记录点。
栈的优势是,存取速度比堆要快,仅次于寄存器,栈数据可以共享。但缺点是,存在栈中的数据大小与生存期必须是确定的,缺乏灵活性。栈中主要存放一些基本类 型的变量(,int, short, long, byte, float, double, boolean, char)和对象句柄。
栈有一个很重要的特殊性,就是存在栈中的数据可以共享。假设我们同时定义:
int a = 3;
int b = 3;
编译器先处理int a = 3;首先它会在栈中创建一个变量为a的引用,然后查找栈中是否有3这个值,如果没找到,就将3存放进来,然后将a指向3。接着处理int b = 3;在创建完b的引用变量后,因为在栈中已经有3这个值,便将b直接指向3。这样,就出现了a与b同时均指向3的情况。
这时,如果再令a=4;那么编译器会重新搜索栈中是否有4值,如果没有,则将4存放进来,并令a指向4;如果已经有了,则直接将a指向这个地址。因此a值的改变不会影响到b的值。
要注意这种数据的共享与两个对象的引用同时指向一个对象的这种共享是不同的,因为这种情况a的修改并不会影响到b, 它是由编译器完成的,它有利于节省空间。而一个对象引用变量修改了这个对象的内部状态,会影响到另一个对象引用变量。
String是一个特殊的包装类数据。可以用:
String str = new String("abc");
String str = "abc";
两种的形式来创建,第一种是用new()来新建对象的,它会在存放于堆中。每调用一次就会创建一个新的对象。
而第二种是先在栈中创建一个对String类的对象引用变量str,然后查找栈中有没有存放"abc",如果没有,则将"abc"存放进栈,并令str指向”abc”,如果已经有”abc” 则直接令str指向“abc”。
比较类里面的数值是否相等时,用equals()方法;当测试两个包装类的引用是否指向同一个对象时,用==,下面用例子说明上面的理论。
String str1 = "abc";
String str2 = "abc";
System.out.println(str1==str2); //true
可以看出str1和str2是指向同一个对象的。
String str1 =new String ("abc");
String str2 =new String ("abc");
System.out.println(str1==str2); // false
用new的方式是生成不同的对象。每一次生成一个。
因此用第一种方式创建多个”abc”字符串,在内存中其实只存在一个对象而已. 这种写法有利与节省内存空间. 同时它可以在一定程度上提高程序的运行速度,因为JVM会自动根据栈中数据的实际情况来决定是否有必要创建新对象。而对于String str = new String("abc");的代码,则一概在堆中创建新对象,而不管其字符串值是否相等,是否有必要创建新对象,从而加重了程序的负担。
另一方面, 要注意: 我们在使用诸如String str = "abc";的格式定义类时,总是想当然地认为,创建了String类的对象str。担心陷阱!对象可能并没有被创建!而可能只是指向一个先前已经创建的 对象。只有通过new()方法才能保证每次都创建一个新的对象。
由于String类的immutable性质,当String变量需要经常变换其值时,应该考虑使用StringBuffer类,以提高程序效率。
---------------------
Java 类加载
概述
定义:虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的java类型。类加载和连接的过程都是在运行期间完成的。
二. 类的加载方式
1):本地编译好的class中直接加载
2):网络加载:java.net.URLClassLoader可以加载url指定的类
3):从jar、zip等等压缩文件加载类,自动解析jar文件找到class文件去加载util类
4):从java源代码文件动态编译成为class文件
三.类加载的时机
1. 类加载的生命周期:加载(Loading)-->验证(Verification)-->准备(Preparation)-->解析(Resolution)-->初始化(Initialization)-->使用(Using)-->卸载(Unloading)
2. 加载:这有虚拟机自行决定。
3. 初始化阶段:
a) 遇到new、getstatic、putstatic、invokestatic这4个字节码指令时,如果类没有进行过初始化,出发初始化操作。
b) 使用java.lang.reflect包的方法对类进行反射调用时。
c) 当初始化一个类的时候,如果发现其父类还没有执行初始化则进行初始化。
d) 虚拟机启动时用户需要指定一个需要执行的主类,虚拟机首先初始化这个主类。
注意:接口与类的初始化规则在第三点不同,接口不要气所有的父接口都进行初始化。
四.类加载的过程
4.1. 加载
a) 加载阶段的工作
i. 通过一个类的全限定名来获取定义此类的二进制字节流。
ii. 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
iii. 在java堆中生成一个代表这个类的java.lang.Class对象,做为方法区这些数据的访问入口。
b) 加载阶段完成之后二进制字节流就按照虚拟机所需的格式存储在方区去中。
4.2. 验证
这一阶段的目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求。
a) 文件格式验证:验证字节流是否符合Class文件格式的规范,并且能被当前版本的虚拟机处理。
b) 元数据验证:对字节码描述的信息进行语义分析,以确保其描述的信息符合java语言规范的要求。
c) 字节码验证:这个阶段的主要工作是进行数据流和控制流的分析。任务是确保被验证类的方法在运行时不会做出危害虚拟机安全的行为。
d) 符号引用验证:这一阶段发生在虚拟机将符号引用转换为直接引用的时候(解析阶段),主要是对类自身以外的信息进行匹配性的校验。目的是确保解析动作能够正常执行。
4.3. 准备
准备阶段是正式为变量分配内存并设置初始值,这些内存都将在方法区中进行分配,这里的变量仅包括类标量不包括实例变量。
4.4. 解析
解析是虚拟机将常量池的符号引用替换为直接引用的过程。
a) 符号引用:符号引用以一组符号来描述所引用的目标,符号可以是任意形式的字面量,只要使用时能无歧义地定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标并不一定已经加载到内存中。
b) 直接引用:直接引用可以是直接指向目标的指针,相对偏移量或是一个能间接定位到目标的句柄。直接饮用是与内存布局相关的。
c) 类或接口的解析
d) 字段的解析
e) 类方法解析
f) 接口方法解析
4.5. 初始化
是根据程序员制定的主观计划区初始化变量和其他资源,或者可以从另外一个角度来表达:初始化阶段是执行类构造器<clinit>()方法的过程。
五. JVM三种预定义类型类加载器
当一个 JVM 启动的时候,Java 缺省开始使用如下三种类型类装入器:
启动(Bootstrap)类加载器:引导类装入器是用本地代码实现的类装入器,它负责将 <Java_Runtime_Home>/lib 下面的类库加载到内存中。由于引导类加载器涉及到虚拟机本地实现细节,开发者无法直接获取到启动类加载器的引用,所以不允许直接通过引用进行操作。
标准扩展(Extension)类加载器:扩展类加载器是由 Sun 的 ExtClassLoader(sun.misc.Launcher$ExtClassLoader) 实现的。它负责将 < Java_Runtime_Home >/lib/ext 或者由系统变量 java.ext.dir 指定位置中的类库加载到内存中。开发者可以直接使用标准扩展类加载器。
系统(System)类加载器:系统类加载器是由 Sun 的 AppClassLoader(sun.misc.Launcher$AppClassLoader)实现的。它负责将系统类路径(CLASSPATH)中指定的类库加载到内存中。开发者可以直接使用系统类加载器。
除了以上列举的三种类加载器,还有一种比较特殊的类型就是线程上下文类加载器,这个将在后面单独介绍。
a. Bootstrap ClassLoader/启动类加载器
主要负责jdk_home/lib目录下的核心 api 或 -Xbootclasspath 选项指定的jar包装入工作
b. Extension ClassLoader/扩展类加载器
主要负责jdk_home/lib/ext目录下的jar包或 -Djava.ext.dirs 指定目录下的jar包装入工作
c. System ClassLoader/系统类加载器
主要负责java -classpath/-Djava.class.path所指的目录下的类与jar包装入工作
d. User Custom ClassLoader/用户自定义类加载器(java.lang.ClassLoader的子类)
在程序运行期间, 通过java.lang.ClassLoader的子类动态加载class文件, 体现java动态实时类装入特性
六. 类加载双亲委派机制介绍和分析
在这里,需要着重说明的是,JVM在加载类时默认采用的是双亲委派机制。通俗的讲,就是某个特定的类加载器在接到加载类的请求时,首先将加载任务委托给父类加载器,依次递归,如果父类加载器可以完成类加载任务,就成功返回;只有父类加载器无法完成此加载任务时,才自己去加载。关于虚拟机默认的双亲委派机制,我们可以从系统类加载器和标准扩展类加载器为例作简单分析。
图一 标准扩展类加载器继承层次图
图二 系统类加载器继承层次图
通过图一和图二我们可以看出,类加载器均是继承自java.lang.ClassLoader抽象类。我们下面我们就看简要介绍一下java.lang.ClassLoader中几个最重要的方法:
//加载指定名称(包括包名)的二进制类型,供用户调用的接口
public Class<?> loadClass(String name) throws ClassNotFoundException{//…}
//加载指定名称(包括包名)的二进制类型,同时指定是否解析(但是,这里的resolve参数不一定真正能达到解析的效果~_~),供继承用
protectedsynchronized Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException{//…}
//findClass方法一般被loadClass方法调用去加载指定名称类,供继承用
protected Class<?> findClass(String name) throws ClassNotFoundException {//…}
//定义类型,一般在findClass方法中读取到对应字节码后调用,可以看出不可继承(说明:JVM已经实现了对应的具体功能,解析对应的字节码,产生对应的内部数据结构放置到方法区,所以无需覆写,直接调用就可以了)
protected final Class<?> defineClass(String name, byte[] b, int off, int len)
throws ClassFormatError{//…}
通过进一步分析标准扩展类加载器(sun.misc.Launcher$ExtClassLoader)和系统类加载器(sun.misc.Launcher$AppClassLoader)的代码以及其公共父类(java.net.URLClassLoader和java.security.SecureClassLoader)的代码可以看出,都没有覆写java.lang.ClassLoader中默认的加载委派规则---loadClass(…)方法。既然这样,我们就可以通过分析java.lang.ClassLoader中的loadClass(String name)方法的代码就可以分析出虚拟机默认采用的双亲委派机制到底是什么模样:
public Class<?> loadClass(String name)throws ClassNotFoundException {
return loadClass(name,false);
}
protectedsynchronized Class<?> loadClass(String name,boolean resolve)
throws ClassNotFoundException {
//首先判断该类型是否已经被加载
Class c = findLoadedClass(name);
if (c ==null) {
//如果没有被加载,就委托给父类加载或者委派给启动类加载器加载
try {
if (parent !=null) {
//如果存在父类加载器,就委派给父类加载器加载
c = parent.loadClass(name,false);
}else {
//如果不存在父类加载器,就检查是否是由启动类加载器加载的类,通过调用本地方法native Class findBootstrapClass(String name)
c = findBootstrapClass0(name);
}
}catch (ClassNotFoundException e) {
//如果父类加载器和启动类加载器都不能完成加载任务,才调用自身的加载功能
c = findClass(name);
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
通过上面的代码分析,我们可以对JVM采用的双亲委派类加载机制有了更感性的认识,下面我们就接着分析一下启动类加载器、标准扩展类加载器和系统类加载器三者之间的关系。可能大家已经从各种资料上面看到了如下类似的一幅图片:
图三 类加载器默认委派关系图
上面图片给人的直观印象是系统类加载器的父类加载器是标准扩展类加载器,标准扩展类加载器的父类加载器是启动类加载器,下面我们就用代码具体测试一下:
示例代码:
public static void main(String[] args) {
try {
System.out.println(ClassLoader.getSystemClassLoader());
System.out.println(ClassLoader.getSystemClassLoader().getParent();
System.out.println(ClassLoader.getSystemClassLoader().getParent().getParent());
} catch (Exception e) {
e.printStackTrace();
}
}
说明:通过java.lang.ClassLoader.getSystemClassLoader()可以直接获取到系统类加载器。
代码输出如下:
sun.misc.Launcher$AppClassLoader@197d257
sun.misc.Launcher$ExtClassLoader@7259da
null
通过以上的代码输出,我们可以判定系统类加载器的父加载器是标准扩展类加载器,但是我们试图获取标准扩展类加载器的父类加载器时确得到了null,就是说标准扩展类加载器本身强制设定父类加载器为null。我们还是借助于代码分析一下:
我们首先看一下java.lang.ClassLoader抽象类中默认实现的两个构造函数:
protected ClassLoader() {
SecurityManager security = System.getSecurityManager();
if (security !=null) {
security.checkCreateClassLoader();
}
//默认将父类加载器设置为系统类加载器,getSystemClassLoader()获取系统类加载器
this.parent = getSystemClassLoader();
initialized =true;
}
protected ClassLoader(ClassLoader parent) {
SecurityManager security = System.getSecurityManager();
if (security !=null) {
security.checkCreateClassLoader();
}
//强制设置父类加载器
this.parent = parent;
initialized =true;
}
我们再看一下ClassLoader抽象类中parent成员的声明:
// The parent class loader for delegation
e ClassLoaderparent;
声明为私有变量的同时并没有对外提供可供派生类访问的public或者protected设置器接口(对应的setter方法),结合前面的测试代码的输出,我们可以推断出:
1.系统类加载器(AppClassLoader)调用ClassLoader(ClassLoader parent)构造函数将父类加载器设置为标准扩展类加载器(ExtClassLoader)。(因为如果不强制设置,默认会通过调用getSystemClassLoader()方法获取并设置成系统类加载器,这显然和测试输出结果不符。)
2.扩展类加载器(ExtClassLoader)调用ClassLoader(ClassLoader parent)构造函数将父类加载器设置为null。(因为如果不强制设置,默认会通过调用getSystemClassLoader()方法获取并设置成系统类加载器,这显然和测试输出结果不符。)
现在我们可能会有这样的疑问:扩展类加载器(ExtClassLoader)的父类加载器被强制设置为null了,那么扩展类加载器为什么还能将加载任务委派给启动类加载器呢?
图四 标准扩展类加载器和系统类加载器成员大纲视图
图五扩展类加载器和系统类加载器公共父类成员大纲视图
通过图四和图五可以看出,标准扩展类加载器和系统类加载器及其父类(java.net.URLClassLoader和java.security.SecureClassLoader)都没有覆写java.lang.ClassLoader中默认的加载委派规则---loadClass(…)方法。有关java.lang.ClassLoader中默认的加载委派规则前面已经分析过,如果父加载器为null,则会调用本地方法进行启动类加载尝试。所以,图三中,启动类加载器、标准扩展类加载器和系统类加载器之间的委派关系事实上是仍就成立的。(在后面的用户自定义类加载器部分,还会做更深入的分析)。
七. 类加载双亲委派示例
以上已经简要介绍了虚拟机默认使用的启动类加载器、标准扩展类加载器和系统类加载器,并以三者为例结合JDK代码对JVM默认使用的双亲委派类加载机制做了分析。下面我们就来看一个综合的例子。首先在eclipse中建立一个简单的java应用工程,然后写一个简单的JavaBean如下:
package classloader.test.bean;
publicclass TestBean {
public TestBean() {}
}
在现有当前工程中另外建立一测试类(ClassLoaderTest.java)内容如下:
测试一:
public class ClassLoaderTest {
public staticvoid main(String[] args) {
try {
//查看当前系统类路径中包含的路径条目
System.out.println(System.getProperty("java.class.path"));
//调用加载当前类的类加载器(这里即为系统类加载器)加载TestBean
Class typeLoaded = Class.forName("classloader.test.bean.TestBean");
//查看被加载的TestBean类型是被那个类加载器加载的
System.out.println(typeLoaded.getClassLoader());
}catch (Exception e) {
e.printStackTrace();
}
}
}
对应的输出如下:
D:"DEMO"dev"Study"ClassLoaderTest"bin
sun.misc.Launcher$AppClassLoader@197d257
(说明:当前类路径默认的含有的一个条目就是工程的输出目录)
测试二:
将当前工程输出目录下的…/classloader/test/bean/TestBean.class打包进test.jar剪贴到< Java_Runtime_Home >/lib/ext目录下(现在工程输出目录下和JRE扩展目录下都有待加载类型的class文件)。再运行测试一测试代码,结果如下:
D:"DEMO"dev"Study"ClassLoaderTest"bin
sun.misc.Launcher$ExtClassLoader@7259da
对比测试一和测试二,我们明显可以验证前面说的双亲委派机制,系统类加载器在接到加载classloader.test.bean.TestBean类型的请求时,首先将请求委派给父类加载器(标准扩展类加载器),标准扩展类加载器抢先完成了加载请求。
测试三:
将test.jar拷贝一份到< Java_Runtime_Home >/lib下,运行测试代码,输出如下:
D:"DEMO"dev"Study"ClassLoaderTest"bin
sun.misc.Launcher$ExtClassLoader@7259da
测试三和测试二输出结果一致。那就是说,放置到< Java_Runtime_Home >/lib目录下的TestBean对应的class字节码并没有被加载,这其实和前面讲的双亲委派机制并不矛盾。虚拟机出于安全等因素考虑,不会加载< Java_Runtime_Home >/lib存在的陌生类,开发者通过将要加载的非JDK自身的类放置到此目录下期待启动类加载器加载是不可能的。做个进一步验证,删除< Java_Runtime_Home >/lib/ext目录下和工程输出目录下的TestBean对应的class文件,然后再运行测试代码,则将会有ClassNotFoundException异常抛出。有关这个问题,大家可以在java.lang.ClassLoader中的loadClass(String name, boolean resolve)方法中设置相应断点运行测试三进行调试,会发现findBootstrapClass0()会抛出异常,然后在下面的findClass方法中被加载,当前运行的类加载器正是扩展类加载器(sun.misc.Launcher$ExtClassLoader),这一点可以通过JDT中变量视图查看验证。
八. 程序动态扩展方式
Java的连接模型允许用户运行时扩展引用程序,既可以通过当前虚拟机中预定义的加载器加载编译时已知的类或者接口,又允许用户自行定义类装载器,在运行时动态扩展用户的程序。通过用户自定义的类装载器,你的程序可以装载在编译时并不知道或者尚未存在的类或者接口,并动态连接它们并进行有选择的解析。
运行时动态扩展java应用程序有如下两个途径:
8.1.调用java.lang.Class.forName(…)
这个方法其实在前面已经讨论过,在后面的问题2解答中说明了该方法调用会触发那个类加载器开始加载任务。这里需要说明的是多参数版本的forName(…)方法:
public static Class<?> forName(String name, boolean initialize, ClassLoader loader)
throws ClassNotFoundException;
这里的initialize参数是很重要的,可以觉得被加载同时是否完成初始化的工作(说明: 单参数版本的forName方法默认是不完成初始化的).有些场景下,需要将initialize设置为true来强制加载同时完成初始化,例如典型的就是利用DriverManager进行JDBC驱动程序类注册的问题,因为每一个JDBC驱动程序类的静态初始化方法都用DriverManager注册驱动程序,这样才能被应用程序使用,这就要求驱动程序类必须被初始化,而不单单被加载.
8.2.用户自定义类加载器
通过前面的分析,我们可以看出,除了和本地实现密切相关的启动类加载器之外,包括标准扩展类加载器和系统类加载器在内的所有其他类加载器我们都可以当做自定义类加载器来对待,唯一区别是是否被虚拟机默认使用。前面的内容中已经对java.lang.ClassLoader抽象类中的几个重要的方法做了介绍,这里就简要叙述一下一般用户自定义类加载器的工作流程吧(可以结合后面问题解答一起看):
1、首先检查请求的类型是否已经被这个类装载器装载到命名空间中了,如果已经装载,直接返回;否则转入步骤2
2、委派类加载请求给父类加载器(更准确的说应该是双亲类加载器,真个虚拟机中各种类加载器最终会呈现树状结构),如果父类加载器能够完成,则返回父类加载器加载的Class实例;否则转入步骤3
3、调用本类加载器的findClass(…)方法,试图获取对应的字节码,如果获取的到,则调用defineClass(…)导入类型到方法区;如果获取不到对应的字节码或者其他原因失败,返回异常给loadClass(…), loadClass(…)转抛异常,终止加载过程(注意:这里的异常种类不止一种)。
(说明:这里说的自定义类加载器是指JDK 1.2以后版本的写法,即不覆写改变java.lang.loadClass(…)已有委派逻辑情况下)
九. 常见问题分析
9.1.由不同的类加载器加载的指定类型还是相同的类型吗?
在Java中,一个类用其完全匹配类名(fully qualified class name)作为标识,这里指的完全匹配类名包括包名和类名。但在JVM中一个类用其全名和一个加载类ClassLoader的实例作为唯一标识,不同类加载器加载的类将被置于不同的命名空间.我们可以用两个自定义类加载器去加载某自定义类型(注意,不要将自定义类型的字节码放置到系统路径或者扩展路径中,否则会被系统类加载器或扩展类加载器抢先加载),然后用获取到的两个Class实例进行java.lang.Object.equals(…)判断,将会得到不相等的结果。这个大家可以写两个自定义的类加载器去加载相同的自定义类型,然后做个判断;同时,可以测试加载java.*类型,然后再对比测试一下测试结果。
9.2.在代码中直接调用Class.forName(String name)方法,到底会触发那个类加载器进行类加载行为?
Class.forName(String name)默认会使用调用类的类加载器来进行类加载。我们直接来分析一下对应的jdk的代码:
//java.lang.Class.java
public static Class<?>forName(String className)throws ClassNotFoundException {
return forName0(className,true, ClassLoader.getCallerClassLoader());
}
//java.lang.ClassLoader.java
// Returns the invoker's class loader, or null if none.
static ClassLoader getCallerClassLoader() {
// 获取调用类(caller)的类型
Class caller = Reflection.getCallerClass(3);
// This can be null if the VM is requesting it
if (caller ==null) {
returnnull;
}
//调用java.lang.Class中本地方法获取加载该调用类(caller)的ClassLoader
return caller.getClassLoader0();
}
//java.lang.Class.java
//虚拟机本地实现,获取当前类的类加载器,前面介绍的Class的getClassLoader()也使用此方法
native ClassLoader getClassLoader0();
9.3.在编写自定义类加载器时,如果没有设定父加载器,那么父加载器是?
前面讲过,在不指定父类加载器的情况下,默认采用系统类加载器。可能有人觉得不明白,现在我们来看一下JDK对应的代码实现。众所周知,我们编写自定义的类加载器直接或者间接继承自java.lang.ClassLoader抽象类,对应的无参默认构造函数实现如下:
//摘自java.lang.ClassLoader.java
protected ClassLoader() {
SecurityManager security = System.getSecurityManager();
if (security !=null) {
security.checkCreateClassLoader();
}
this.parent = getSystemClassLoader();
initialized =true;
}
我们再来看一下对应的getSystemClassLoader()方法的实现:
privatestaticsynchronizedvoid initSystemClassLoader() {
//...
sun.misc.Launcher l = sun.misc.Launcher.getLauncher();
scl = l.getClassLoader();
//...
}
我们可以写简单的测试代码来测试一下:
System.out.println(sun.misc.Launcher.getLauncher().getClassLoader());
本机对应输出如下:
sun.misc.Launcher$AppClassLoader@197d257
所以,我们现在可以相信当自定义类加载器没有指定父类加载器的情况下,默认的父类加载器即为系统类加载器。同时,我们可以得出如下结论:
即时用户自定义类加载器不指定父类加载器,那么,同样可以加载如下三个地方的类:
1. <Java_Runtime_Home>/lib下的类
2. < Java_Runtime_Home >/lib/ext下或者由系统变量java.ext.dir指定位置中的类
3. 当前工程类路径下或者由系统变量java.class.path指定位置中的类
9.4.在编写自定义类加载器时,如果将父类加载器强制设置为null,那么会有什么影响?如果自定义的类加载器不能加载指定类,就肯定会加载失败吗?
JVM规范中规定如果用户自定义的类加载器将父类加载器强制设置为null,那么会自动将启动类加载器设置为当前用户自定义类加载器的父类加载器(这个问题前面已经分析过了)。同时,我们可以得出如下结论:
即时用户自定义类加载器不指定父类加载器,那么,同样可以加载到<Java_Runtime_Home>/lib下的类,但此时就不能够加载<Java_Runtime_Home>/lib/ext目录下的类了。
说明:问题3和问题4的推断结论是基于用户自定义的类加载器本身延续了java.lang.ClassLoader.loadClass(…)默认委派逻辑,如果用户对这一默认委派逻辑进行了改变,以上推断结论就不一定成立了,详见问题5。
9.5.编写自定义类加载器时,一般有哪些注意点?
9.5.1.一般尽量不要覆写已有的loadClass(…)方法中的委派逻辑
一般在JDK 1.2之前的版本才这样做,而且事实证明,这样做极有可能引起系统默认的类加载器不能正常工作。在JVM规范和JDK文档中(1.2或者以后版本中),都没有建议用户覆写loadClass(…)方法,相比而言,明确提示开发者在开发自定义的类加载器时覆写findClass(…)逻辑。举一个例子来验证该问题:
//用户自定义类加载器WrongClassLoader.Java(覆写loadClass逻辑)
publicclassWrongClassLoaderextends ClassLoader {
public Class<?> loadClass(String name)throws ClassNotFoundException {
returnthis.findClass(name);
}
protected Class<?> findClass(String name)throws ClassNotFoundException {
//假设此处只是到工程以外的特定目录D:/library下去加载类
具体实现代码省略
}
}
通过前面的分析我们已经知道,用户自定义类加载器(WrongClassLoader)的默
认的类加载器是系统类加载器,但是现在问题4种的结论就不成立了。大家可以简
单测试一下,现在<Java_Runtime_Home>/lib、< Java_Runtime_Home >/lib/ext和工
程类路径上的类都加载不上了。
//问题5测试代码一
publicclass WrongClassLoaderTest {
publicstaticvoid main(String[] args) {
try {
WrongClassLoader loader =new WrongClassLoader();
Class classLoaded = loader.loadClass("beans.Account");
System.out.println(classLoaded.getName());
System.out.println(classLoaded.getClassLoader());
}catch (Exception e) {
e.printStackTrace();
}
}
}
(说明:D:"classes"beans"Account.class物理存在的)
输出结果:
java.io.FileNotFoundException: D:"classes"java"lang"Object.class (系统找不到指定的路径。)
at java.io.FileInputStream.open(Native Method)
at java.io.FileInputStream.<init>(FileInputStream.java:106)
at WrongClassLoader.findClass(WrongClassLoader.java:40)
at WrongClassLoader.loadClass(WrongClassLoader.java:29)
at java.lang.ClassLoader.loadClassInternal(ClassLoader.java:319)
at java.lang.ClassLoader.defineClass1(Native Method)
at java.lang.ClassLoader.defineClass(ClassLoader.java:620)
at java.lang.ClassLoader.defineClass(ClassLoader.java:400)
at WrongClassLoader.findClass(WrongClassLoader.java:43)
at WrongClassLoader.loadClass(WrongClassLoader.java:29)
at WrongClassLoaderTest.main(WrongClassLoaderTest.java:27)
Exception in thread "main" java.lang.NoClassDefFoundError: java/lang/Object
at java.lang.ClassLoader.defineClass1(Native Method)
at java.lang.ClassLoader.defineClass(ClassLoader.java:620)
at java.lang.ClassLoader.defineClass(ClassLoader.java:400)
at WrongClassLoader.findClass(WrongClassLoader.java:43)
at WrongClassLoader.loadClass(WrongClassLoader.java:29)
at WrongClassLoaderTest.main(WrongClassLoaderTest.java:27)
这说明,连要加载的类型的超类型java.lang.Object都加载不到了。这里列举的由于覆写loadClass(…)引起的逻辑错误明显是比较简单的,实际引起的逻辑错误可能复杂的多。
//问题5测试二
//用户自定义类加载器WrongClassLoader.Java(不覆写loadClass逻辑)
publicclassWrongClassLoaderextends ClassLoader {
protected Class<?> findClass(String name)throws ClassNotFoundException {
//假设此处只是到工程以外的特定目录D:/library下去加载类
具体实现代码省略
}
}
将自定义类加载器代码WrongClassLoader.Java做以上修改后,再运行测试代码,输出结果如下:
beans.Account
WrongClassLoader@1c78e57
这说明,beans.Account加载成功,且是由自定义类加载器WrongClassLoader加载。
这其中的原因分析,我想这里就不必解释了,大家应该可以分析的出来了。
9.5.2.正确设置父类加载器
通过上面问题4和问题5的分析我们应该已经理解,个人觉得这是自定义用户类加载器时最重要的一点,但常常被忽略或者轻易带过。有了前面JDK代码的分析作为基础,我想现在大家都可以随便举出例子了。
9.5.3.保证findClass(String)方法的逻辑正确性
事先尽量准确理解待定义的类加载器要完成的加载任务,确保最大程度上能够获取到对应的字节码内容。
9.6.如何在运行时判断系统类加载器能加载哪些路径下的类?
一是可以直接调用ClassLoader.getSystemClassLoader()或者其他方式获取到系统类加载器(系统类加载器和扩展类加载器本身都派生自URLClassLoader),调用URLClassLoader中的getURLs()方法可以获取到;
二是可以直接通过获取系统属性java.class.path 来查看当前类路径上的条目信息 , System.getProperty("java.class.path")
9.7.如何在运行时判断标准扩展类加载器能加载哪些路径下的类?
方法之一:
try {
URL[] extURLs = ((URLClassLoader)ClassLoader.getSystemClassLoader().getParent()).getURLs();
for (int i = 0; i < extURLs.length; i++) {
System.out.println(extURLs[i]);
}
} catch (Exception e) {//…}
本机对应输出如下:
file:/D:/DEMO/jdk1.5.0_09/jre/lib/ext/dnsns.jar
file:/D:/DEMO/jdk1.5.0_09/jre/lib/ext/localedata.jar
file:/D:/DEMO/jdk1.5.0_09/jre/lib/ext/sunjce_provider.jar
file:/D:/DEMO/jdk1.5.0_09/jre/lib/ext/sunpkcs11.jar
十、再分析类加载
10.1.类加载器的特性
1, 每个ClassLoader都维护了一份自己的名称空间, 同一个名称空间里不能出现两个同名的类。
2, 为了实现java安全沙箱模型顶层的类加载器安全机制, java默认采用了 ” 双亲委派的加载链 ” 结构.
如下图:
Class Diagram:
类图中, BootstrapClassLoader是一个单独的java类, 其实在这里, 不应该叫他是一个java类。
因为, 它已经完全不用java实现了。
它是在jvm启动时, 就被构造起来的, 负责java平台核心库。(具体上面已经有介绍)
启动类加载实现 (其实我们不用关心这块, 但是有兴趣的, 可以研究一下 ):
bootstrap classLoader 类加载原理探索
10.2.自定义类加载器加载一个类的步骤
ClassLoader 类加载逻辑分析, 以下逻辑是除 BootstrapClassLoader 外的类加载器加载流程:
// 检查类是否已被装载过
Class c = findLoadedClass(name);
if (c == null ) {
// 指定类未被装载过
try {
if (parent != null ) {
// 如果父类加载器不为空, 则委派给父类加载
c = parent.loadClass(name, false );
} else {
// 如果父类加载器为空, 则委派给启动类加载加载
c = findBootstrapClass0(name);
}
} catch (ClassNotFoundException e) {
// 启动类加载器或父类加载器抛出异常后, 当前类加载器将其
// 捕获, 并通过findClass方法, 由自身加载
c = findClass(name);
}
}
10.3.用Class.forName加载类
Class.forName使用的是被调用者的类加载器来加载类的.
这种特性, 证明了java类加载器中的名称空间是唯一的, 不会相互干扰.
即在一般情况下, 保证同一个类中所关联的其他类都是由当前类的类加载器所加载的.
public static Class forName(String className)
throws ClassNotFoundException {
return forName0(className, true , ClassLoader.getCallerClassLoader());
}
/** Called after security checks have been made. */
private static native Class forName0(String name, boolean initialize, ClassLoader loader)
throws ClassNotFoundException;
上图中 ClassLoader.getCallerClassLoader 就是得到调用当前forName方法的类的类加载器
10.4.线程上下文类加载器
java默认的线程上下文类加载器是 系统类加载器(AppClassLoader).
// Now create the class loader to use to launch the application
try {
loader = AppClassLoader.getAppClassLoader(extcl);
} catch (IOException e) {
throw new InternalError(
"Could not create application class loader" );
}
// Also set the context class loader for the primordial thread.
Thread.currentThread().setContextClassLoader(loader);
以上代码摘自sun.misc.Launch的无参构造函数Launch()。
使用线程上下文类加载器, 可以在执行线程中, 抛弃双亲委派加载链模式, 使用线程上下文里的类加载器加载类.
典型的例子有, 通过线程上下文来加载第三方库jndi实现, 而不依赖于双亲委派.
大部分java app服务器(jboss, tomcat..)也是采用contextClassLoader来处理web服务。
还有一些采用 hotswap 特性的框架, 也使用了线程上下文类加载器, 比如 seasar (full stack framework in japenese).
线程上下文从根本解决了一般应用不能违背双亲委派模式的问题.
使java类加载体系显得更灵活.
随着多核时代的来临, 相信多线程开发将会越来越多地进入程序员的实际编码过程中. 因此,
在编写基础设施时, 通过使用线程上下文来加载类, 应该是一个很好的选择.
当然, 好东西都有利弊. 使用线程上下文加载类, 也要注意, 保证多根需要通信的线程间的类加载器应该是同一个,
防止因为不同的类加载器, 导致类型转换异常(ClassCastException).
10.5.自定义的类加载器实现
defineClass(String name, byte[] b, int off, int len,ProtectionDomain protectionDomain)
是java.lang.Classloader提供给开发人员, 用来自定义加载class的接口.
使用该接口, 可以动态的加载class文件.
例如,在jdk中, URLClassLoader是配合findClass方法来使用defineClass, 可以从网络或硬盘上加载class.
而使用类加载接口, 并加上自己的实现逻辑, 还可以定制出更多的高级特性.
比如,一个简单的hot swap 类加载器实现:
import java.io.File;
import java.io.FileInputStream;
import java.lang.reflect.Method;
import java.net.URL;
import java.net.URLClassLoader;
/**
* 可以重新载入同名类的类加载器实现
* 放弃了双亲委派的加载链模式.
* 需要外部维护重载后的类的成员变量状态.
*
* @author ken.wu
* @mail ken.wug@gmail.com
* 2007-9-28 下午01:37:43
*/
public class HotSwapClassLoader extends URLClassLoader {
public HotSwapClassLoader(URL[] urls) {
super (urls);
}
public HotSwapClassLoader(URL[] urls, ClassLoader parent) {
super (urls, parent);
}
public Class load(String name)
throws ClassNotFoundException {
return load(name, false );
}
public Class load(String name, boolean resolve)
throws ClassNotFoundException {
if ( null != super .findLoadedClass(name))
return reload(name, resolve);
Class clazz = super .findClass(name);
if (resolve)
super .resolveClass(clazz);
return clazz;
}
public Class reload(String name, boolean resolve)
throws ClassNotFoundException {
return new HotSwapClassLoader( super .getURLs(), super .getParent()).load(
name, resolve);
}
}
public class A {
private B b;
public void setB(B b) {
this .b = b;
}
public B getB() {
return b;
}
}
public class B {}
这个类的作用是可以重新载入同名的类, 但是, 为了实现hotswap, 老的对象状态
需要通过其他方式拷贝到重载过的类生成的全新实例中来。(A类中的b实例)
而新实例所依赖的B类如果与老对象不是同一个类加载器加载的, 将会抛出类型转换异常(ClassCastException).
为了解决这种问题, HotSwapClassLoader自定义了load方法. 即当前类是由自身classLoader加载的, 而内部依赖的类
还是老对象的classLoader加载的.
public class TestHotSwap {
public static void main(String args[]) {
A a = new A();
B b = new B();
a.setB(b);
System.out.printf("A classLoader is %s n" , a.getClass().getClassLoader());
System.out.printf("B classLoader is %s n" , b.getClass().getClassLoader());
System.out.printf("A.b classLoader is %s n" , a.getB().getClass().getClassLoader());
HotSwapClassLoader c1 = new HotSwapClassLoader( new URL[]{ new URL( "file:\e:\test\")} , a.getClass().getClassLoader());
Class clazz = c1.load(" test.hotswap.A ");
Object aInstance = clazz.newInstance();
Method method1 = clazz.getMethod(" setB ", B.class);
method1.invoke(aInstance, b);
Method method2 = clazz.getMethod(" getB ", null);
Object bInstance = method2.invoke(aInstance, null);
System.out.printf(" reloaded A.b classLoader is %s n", bInstance.getClass().getClassLoader());
}
}
输出
A classLoader is sun.misc.Launcher$AppClassLoader@19821f
B classLoader is sun.misc.Launcher$AppClassLoader@19821f
A.b classLoader is sun.misc.Launcher$AppClassLoader@19821f
reloaded A.b classLoader is sun.misc.Launcher$AppClassLoader@19821f
---------------------
JVM详解
JVM是虚拟机,也是一种规范,他遵循着冯·诺依曼体系结构的设计原理。冯·诺依曼体系结构中,指出计算机处理的数据和指令都是二进制数,采用存储程序方式不加区分的存储在同一个存储器里,并且顺序执行,指令由操作码和地址码组成,操作码决定了操作类型和所操作的数的数字类型,地址码则指出地址码和操作数。从dos到window8,从unix到ubuntu和CentOS,还有MAC OS等等,不同的操作系统指令集以及数据结构都有着差异,而JVM通过在操作系统上建立虚拟机,自己定义出来的一套统一的数据结构和操作指令,把同一套语言翻译给各大主流的操作系统,实现了跨平台运行,可以说JVM是java的核心,是java可以一次编译到处运行的本质所在。
我研究学习了JVM的组成和运行原理,JVM的统一数据格式规范、字节码文件结构,JVM关于内存的管理。
一、JVM的组成和运行原理
JVM的毕竟是个虚拟机,是一种规范,虽说符合冯诺依曼的计算机设计理念,但是他并不是实体计算机,所以他的组成也不是什么存储器,控制器,运算器,输入输出设备。在我看来,JVM放在运行在真实的操作系统中表现的更像应用或者说是进程,他的组成可以理解为JVM这个进程有哪些功能模块,而这些功能模块的运作可以看做是JVM的运行原理。JVM有多种实现,例如Oracle的JVM,HP的JVM和IBM的JVM等,而在本文中研究学习的则是使用最广泛的Oracle的HotSpot JVM。
1.JVM在JDK中的位置。
JDK是java开发的必备工具箱,JDK其中有一部分是JRE,JRE是JAVA运行环境,JVM则是JRE最核心的部分。我从oracle.com截取了一张关于JDK Standard Edtion的组成图,
从最底层的位置可以看出来JVM有多重要,而实际项目中JAVA应用的性能优化,OOM等异常的处理最终都得从JVM这儿来解决。HotSpot是Oracle关于JVM的商标,区别于IBM,HP等厂商开发的JVM。Java HotSpot Client VM和Java HotSpot Server VM是JDK关于JVM的两种不同的实现,前者可以减少启动时间和内存占用,而后者则提供更加优秀的程序运行速度(参考自:http://docs.oracle.com/javase/8/docs/technotes/guides/vm/index.html,该文档有关于各个版本的JVM的介绍)。在命令行,通过java -version可以查看关于当前机器JVM的信息,下面是我在Win8系统上执行命令的截图,
可以看出我装的是build 20.13-b02版本,HotSpot 类型Server模式的JVM。
2.JVM的组成
JVM由4大部分组成:ClassLoader,Runtime Data Area,Execution Engine,Native Interface。
我从CSDN找了一张描述JVM大致结构的图:
2.1.ClassLoader是负责加载class文件,class文件在文件开头有特定的文件标示,并且ClassLoader只负责class文件的加载,至于它是否可以运行,则由Execution Engine决定。
2.2.Native Interface是负责调用本地接口的。他的作用是调用不同语言的接口给JAVA用,他会在Native Method Stack中记录对应的本地方法,然后调用该方法时就通过Execution Engine加载对应的本地lib。原本多于用一些专业领域,如JAVA驱动,地图制作引擎等,现在关于这种本地方法接口的调用已经被类似于Socket通信,WebService等方式取代。
2.3.Execution Engine是执行引擎,也叫Interpreter。Class文件被加载后,会把指令和数据信息放入内存中,Execution Engine则负责把这些命令解释给操作系统。
2.4.Runtime Data Area则是存放数据的,分为五部分:Stack,Heap,Method Area,PC Register,Native Method Stack。几乎所有的关于java内存方面的问题,都是集中在这块。下图是javapapers.com上关于Run-time Data Areas的描述:
可以看出它把Method Area化为了Heap的一部分,javapapers.com中认为Method Area是Heap的逻辑区域,但这取决于JVM的实现者,而HotSpot JVM中把Method Area划分为非堆内存,显然是不包含在Heap中的。下图是javacodegeeks.com中,2014年9月刊出的一片博文中关于Runtime Data Area的划分,其中指出,NonHeap包含PermGen和Code Cache,PermGen包含Method Area,而且PermGen在JAVA SE 8中已经不再用了。查阅资料(https://abhirockzz.wordpress.com/2014/03/18/java-se-8-is-knocking-are-you-there/)得知,java8中PermGen已经从JVM中移除并被MetaSpace取代,java8中也不会见到OOM:PermGen Space的异常。目前Runtime Data Area可以用下图描述它的组成:
2.4.1.Stack是java栈内存,它等价于C语言中的栈,栈的内存地址是不连续的,每个线程都拥有自己的栈。栈里面存储着的是StackFrame,在《JVM Specification》中文版中被译作java虚拟机框架,也叫做栈帧。StackFrame包含三类信息:局部变量,执行环境,操作数栈。局部变量用来存储一个类的方法中所用到的局部变量。执行环境用于保存解析器对于java字节码进行解释过程中需要的信息,包括:上次调用的方法、局部变量指针和操作数栈的栈顶和栈底指针。操作数栈用于存储运算所需要的操作数和结果。StackFrame在方法被调用时创建,在某个线程中,某个时间点上,只有一个框架是活跃的,该框架被称为Current Frame,而框架中的方法被称为Current Method,其中定义的类为Current Class。局部变量和操作数栈上的操作总是引用当前框架。当Stack Frame中方法被执行完之后,或者调用别的StackFrame中的方法时,则当前栈变为另外一个StackFrame。Stack的大小是由两种类型,固定和动态的,动态类型的栈可以按照线程的需要分配。 下面两张图是关于栈之间关系以及栈和非堆内存的关系基本描述(来自http://www.programering.com/a/MzM3QzNwATA.html):
2.4.2.Heap是用来存放对象信息的,和Stack不同,Stack代表着一种运行时的状态。换句话说,栈是运行时单位,解决程序该如何执行的问题,而堆是存储的单位,解决数据存储的问题。Heap是伴随着JVM的启动而创建,负责存储所有对象实例和数组的。堆的存储空间和栈一样是不需要连续的,它分为Young Generation和Old Generation(也叫Tenured Generation)两大部分。Young Generation分为Eden和Survivor,Survivor又分为From Space和 ToSpace。
和Heap经常一起提及的概念是PermanentSpace,它是用来加载类对象的专门的内存区,是非堆内存,和Heap一起组成JAVA内存,它包含MethodArea区(在没有CodeCache的HotSpotJVM实现里,则MethodArea就相当于GenerationSpace)。在JVM初始化的时候,我们可以通过参数来分别指定,PermanentSpace的大小、堆的大小、以及Young Generation和Old Generation的比值、Eden区和From Space的比值,从而来细粒度的适应不同JAVA应用的内存需求。
2.4.3.PC Register是程序计数寄存器,每个JAVA线程都有一个单独的PC Register,他是一个指针,由Execution Engine读取下一条指令。如果该线程正在执行java方法,则PC Register存储的是 正在被执行的指令的地址,如果是本地方法,PC Register的值没有定义。PC寄存器非常小,只占用一个字宽,可以持有一个returnAdress或者特定平台的一个指针。
2.4.4.Method Area在HotSpot JVM的实现中属于非堆区,非堆区包括两部分:Permanet Generation和Code Cache,而Method Area属于Permanert Generation的一部分。Permanent Generation用来存储类信息,比如说:class definitions,structures,methods, field, method (data and code) 和 constants。Code Cache用来存储Compiled Code,即编译好的本地代码,在HotSpot JVM中通过JIT(Just In Time) Compiler生成,JIT是即时编译器,他是为了提高指令的执行效率,把字节码文件编译成本地机器代码,如下图:
引用一个经典的案例来理解Stack,Heap和Method Area的划分,就是Sring a=”xx”;Stirng b=”xx”,问是否a==b? 首先==符号是用来判断两个对象的引用地址是否相同,而在上面的题目中,a和b按理来说申请的是Stack中不同的地址,但是他们指向Method Area中Runtime Constant Pool的同一个地址,按照网上的解释,在a赋值为“xx”时,会在Runtime Contant Pool中生成一个String Constant,当b也赋值为“xx”时,那么会在常量池中查看是否存在值为“xx”的常量,存在的话,则把b的指针也指向“xx”的地址,而不是新生成一个String Constant。我查阅了网络上大家关于String Constant的存储的说说法,存在略微差别的是,它存储在哪里,有人说Heap中会分配出一个常量池,用来存储常量,所有线程共享它。而有人说常量池是Method Area的一部分,而Method Area属于非堆内存,那怎么能说常量池存在于堆中?
我认为,其实两种理解都没错。Method Area的确从逻辑上讲可以是Heap的一部分,在某些JVM实现里从堆上开辟一块存储空间来记录常量是符合JVM常量池设计目的的,所以前一种说法没问题。对于后一种说法,HotSpot JVM的实现中的确是把方法区划分为了非堆内存,意思就是它不在堆上。我在HotSpot JVM做了个简单的实验,定义多个常量之后,程序抛出OOM:PermGen Space异常,印证了JVM实现中常量池是在Permanent Space中的说法。但是,我的JDK版本是1.6的。查阅资料,JDK1.7中InternedStrings已经不再存储在PermanentSpace中,而是放到了Heap中;JDK8中PermanentSpace已经被完全移除,InternedStrings也被放到了MetaSpace中(如果出现内存溢出,会报OOM:MetaSpace,这里有个关于两者性能对比的文章:http://blog.csdn.net/zhyhang/article/details/17246223)。 所以,仁者见仁,智者见智,一个馒头足以引发血案,就算是同一个商家的JVM,毕竟JDK版本在更新,或许正如StackOverFlow上大神们所说,对于理解JVM Runtime Data Area这一部分的划分逻辑,还是去看对应版本的JDK源码比较靠谱,或者是参考不同的版本JVM Specification(http://docs.oracle.com/javase/specs/)。
2.4.5.Native Method Stack是供本地方法(非java)使用的栈。每个线程持有一个Native Method Stack。
3.JVM的运行原理简介
Java 程序被javac工具编译为.class字节码文件之后,我们执行java命令,该class文件便被JVM的Class Loader加载,可以看出JVM的启动是通过JAVA Path下的java.exe或者java进行的。JVM的初始化、运行到结束大概包括这么几步:
调用操作系统API判断系统的CPU架构,根据对应CPU类型寻找位于JRE目录下的/lib/jvm.cfg文件,然后通过该配置文件找到对应的jvm.dll文件(如果我们参数中有-server或者-client, 则加载对应参数所指定的jvm.dll,启动指定类型的JVM),初始化jvm.dll并且挂接到JNIENV结构的实例上,之后就可以通过JNIENV实例装载并且处理class文件了。class文件是字节码文件,它按照JVM的规范,定义了变量,方法等的详细信息,JVM管理并且分配对应的内存来执行程序,同时管理垃圾回收。直到程序结束,一种情况是JVM的所有非守护线程停止,一种情况是程序调用System.exit(),JVM的生命周期也结束。
关于JVM如何管理分配内存,我通过class文件和垃圾回收两部分进行了学习。
二、JVM的内存管理和垃圾回收
JVM中的内存管理主要是指JVM对于Heap的管理,这是因为Stack,PC Register和Native Method Stack都是和线程一样的生命周期,在线程结束时自然可以被再次使用。虽然说,Stack的管理不是重点,但是也不是完全不讲究的。
1.栈的管理
JVM允许栈的大小是固定的或者是动态变化的。在Oracle的关于参数设置的官方文档中有关于Stack的设置(http://docs.oracle.com/cd/E13150_01/jrockit_jvm/jrockit/jrdocs/refman/optionX.html#wp1024112),是通过-Xss来设置其大小。关于Stack的默认大小对于不同机器有不同的大小,并且不同厂商或者版本号的jvm的实现其大小也不同,如下表是HotSpot的默认大小:
Platform
Default
Windows IA32
64 KB
Linux IA32
128 KB
Windows x86_64
128 KB
Linux x86_64
256 KB
Windows IA64
320 KB
Linux IA64
1024 KB (1 MB)
Solaris Sparc
512 KB
我们一般通过减少常量,参数的个数来减少栈的增长,在程序设计时,我们把一些常量定义到一个对象中,然后来引用他们可以体现这一点。另外,少用递归调用也可以减少栈的占用。 栈是不需要垃圾回收的,尽管说垃圾回收是java内存管理的一个很热的话题,栈中的对象如果用垃圾回收的观点来看,他永远是live状态,是可以reachable的,所以也不需要回收,他占有的空间随着Thread的结束而释放。(参考自:http://stackoverflow.com/questions/20030120/java-default-stack-size)
关于栈一般会发生以下两种异常:
1.当线程中的计算所需要的栈超过所允许大小时,会抛出StackOverflowError。
2.当Java栈试图扩展时,没有足够的存储器来实现扩展,JVM会报OutOfMemoryError。 我针对栈进行了实验,由于递归的调用可以致使栈的引用增加,导致溢出,所以设计代码如下:
我的机器是x86_64系统,所以Stack的默认大小是128KB,上述程序在运行时会报错:
而当我在Eclipse中调整了-Xss参数到3M之后,该异常消失。
另外栈上有一点得注意的是,对于本地代码调用,可能会在栈中申请内存,比如C调用malloc(),而这种情况下,GC是管不着的,需要我们在程序中,手动管理栈内存,使用free()方法释放内存。
2.堆的管理
堆的管理要比栈管理复杂的多,我通过堆的各部分的作用、设置,以及各部分可能发生的异常,以及如何避免各部分异常进行了学习。
上图是 Heap和PermanentSapce的组合图,其中 Eden区里面存着是新生的对象,From Space和To Space中存放着是每次垃圾回收后存活下来的对象 ,所以每次垃圾回收后,Eden区会被清空。 存活下来的对象先是放到From Space,当From Space满了之后移动到To Space。当To Space满了之后移动到Old Space。Survivor的两个区是对称的,没先后关系,所以同一个区中可能同时存在从Eden复制过来 对象,和从前一个Survivor复制过来的对象,而复制到年老区的只有从第一个Survivor复制过来的对象。而且,Survivor区总有一个是空的。同时,根据程序需要,Survivor区是可以配置为多个的(多于两个),这样可以增加对象在年轻代中的存在时间,减少被放到年老代的可能。
Old Space中则存放生命周期比较长的对象,而且有些比较大的新生对象也放在Old Space中。
堆的大小通过-Xms和-Xmx来指定最小值和最大值,通过-Xmn来指定Young Generation的大小(一些老版本也用-XX:NewSize指定), 即上图中的Eden加FromSpace和ToSpace的总大小。然后通过-XX:NewRatio来指定Eden区的大小,在Xms和Xmx相等的情况下,该参数不需要设置。通过-XX:SurvivorRatio来设置Eden和一个Survivor区的比值。(参考自博文:http://www.cnblogs.com/redcreen/archive/2011/05/04/2037057.html)
堆异常分为两种,一种是Out of Memory(OOM),一种是Memory Leak(ML)。Memory Leak最终将导致OOM。实际应用中表现为:从Console看,内存监控曲线一直在顶部,程序响应慢,从线程看,大部分的线程在进行GC,占用比较多的CPU,最终程序异常终止,报OOM。OOM发生的时间不定,有短的一个小时,有长的10天一个月的。关于异常的处理,确定OOM/ML异常后,一定要注意保护现场,可以dump heap,如果没有现场则开启GCFlag收集垃圾回收日志,然后进行分析,确定问题所在。如果问题不是ML的话,一般通过增加Heap,增加物理内存来解决问题,是的话,就修改程序逻辑。
3.垃圾回收
JVM中会在以下情况触发回收:对象没有被引用,作用域发生未捕捉异常,程序正常执行完毕,程序执行了System.exit(),程序发生意外终止。
JVM中标记垃圾使用的算法是一种根搜索算法。简单的说,就是从一个叫GC Roots的对象开始,向下搜索,如果一个对象不能达到GC Roots对象的时候,说明它可以被回收了。这种算法比一种叫做引用计数法的垃圾标记算法要好,因为它避免了当两个对象啊互相引用时无法被回收的现象。
JVM中对于被标记为垃圾的对象进行回收时又分为了一下3种算法:
1.标记清除算法,该算法是从根集合扫描整个空间,标记存活的对象,然后在扫描整个空间对没有被标记的对象进行回收,这种算法在存活对象较多时比较高效,但会产生内存碎片。
2.复制算法,该算法是从根集合扫描,并将存活的对象复制到新的空间,这种算法在存活对象少时比较高效。
3.标记整理算法,标记整理算法和标记清除算法一样都会扫描并标记存活对象,在回收未标记对象的同时会整理被标记的对象,解决了内存碎片的问题。
JVM中,不同的 内存区域作用和性质不一样,使用的垃圾回收算法也不一样,所以JVM中又定义了几种不同的垃圾回收器(图中连线代表两个回收器可以同时使用):
1.Serial GC。从名字上看,串行GC意味着是一种单线程的,所以它要求收集的时候所有的线程暂停。这对于高性能的应用是不合理的,所以串行GC一般用于Client模式的JVM中。
2.ParNew GC。是在SerialGC的基础上,增加了多线程机制。但是如果机器是单CPU的,这种收集器是比SerialGC效率低的。
3.Parrallel Scavenge GC。这种收集器又叫吞吐量优先收集器,而吞吐量=程序运行时间/(JVM执行回收的时间+程序运行时间),假设程序运行了100分钟,JVM的垃圾回收占用1分钟,那么吞吐量就是99%。Parallel Scavenge GC由于可以提供比较不错的吞吐量,所以被作为了server模式JVM的默认配置。
4.ParallelOld是老生代并行收集器的一种,使用了标记整理算法,是JDK1.6中引进的,在之前老生代只能使用串行回收收集器。
5.Serial Old是老生代client模式下的默认收集器,单线程执行,同时也作为CMS收集器失败后的备用收集器。
6.CMS又称响应时间优先回收器,使用标记清除算法。他的回收线程数为(CPU核心数+3)/4,所以当CPU核心数为2时比较高效些。CMS分为4个过程:初始标记、并发标记、重新标记、并发清除。
7.GarbageFirst(G1)。比较特殊的是G1回收器既可以回收Young Generation,也可以回收Tenured Generation。它是在JDK6的某个版本中才引入的,性能比较高,同时注意了吞吐量和响应时间。
对于垃圾收集器的组合使用可以通过下表中的参数指定:
默认的GC种类可以通过jvm.cfg或者通过jmap dump出heap来查看,一般我们通过jstat -gcutil [pid] 1000可以查看每秒gc的大体情况,或者可以在启动参数中加入:-verbose:gc -XX:+PrintGCTimeStamps -XX:+PrintGCDetails -Xloggc:./gc.log来记录GC日志。
GC中有一种情况叫做Full GC,以下几种情况会触发Full GC:
1.Tenured Space空间不足以创建打的对象或者数组,会执行FullGC,并且当FullGC之后空间如果还不够,那么会OOM:java heap space。
2.Permanet Generation的大小不足,存放了太多的类信息,在非CMS情况下回触发FullGC。如果之后空间还不够,会OOM:PermGen space。
3.CMS GC时出现promotion failed和concurrent mode failure时,也会触发FullGC。promotion failed是在进行Minor GC时,survivor space放不下、对象只能放入旧生代,而此时旧生代也放不下造成的;concurrent mode failure是在执行CMS GC的过程中同时有对象要放入旧生代,而此时旧生代空间不足造成的。
4.判断MinorGC后,要晋升到TenuredSpace的对象大小大于TenuredSpace的大小,也会触发FullGC。
可以看出,当FullGC频繁发生时,一定是内存出问题了。
三、JVM的数据格式规范和Class文件
1.数据类型规范
依据冯诺依曼的计算机理论,计算机最后处理的都是二进制的数,而JVM是怎么把java文件最后转化成了各个平台都可以识别的二进制呢?JVM自己定义了一个抽象的存储数据单位,叫做Word。一个字足够大以持有byte、char、short、int、float、reference或者returnAdress的一个值,两个字则足够持有更大的类型long、double。它通常是主机平台一个指针的大小,如32位的平台上,字是32位。
同时JVM中定义了它所支持的基本数据类型,包括两部分:数值类型和returnAddress类型。数值类型分为整形和浮点型。
整形:
byte
值是8位的有符号二进制补码整数
short
值是16位的有符号二进制补码整数
int
值是32位的有符号二进制补码整数
long
值是64位的有符号二进制补码整数
char
值是表示Unicode字符的16位无符号整数
浮点:
float
值是32位IEEE754浮点数
double
值是64位IEEE754浮点数
returnAddress类型的值是Java虚拟机指令的操作码的指针。
对比java的基本数据类型,jvm的规范中没有boolean类型。这是因为jvm中堆boolean的操作是通过int类型来进行处理的,而boolean数组则是通过byte数组来进行处理。
至于String,我们知道它存储在常量池中,但他不是基本数据类型,之所以可以存在常量池中,是因为这是JVM的一种规定。如果查看String源码,我们就会发现,String其实就是一个基于基本数据类型char的数组。如图:
2.字节码文件
通过字节码文件的格式我们可以看出jvm是如何规范数据类型的。下面是ClassFile的结构:
关于各个字段的定义(参考自JVM Specification 和 博文:http://www.cnblogs.com/zhuYears/archive/2012/02/07/2340347.html),
魔数,魔数的唯一作用是确定这个文件是否为一个能被虚拟机所接受的Class文件。魔数值固定为0xCAFEBABE,不会改变。
minor_version、major_version:
分别为Class文件的副版本和主版本。它们共同构成了Class文件的格式版本号。不同版本的虚拟机实现支持的Class文件版本号也相应不同,高版本号的虚拟机可以支持低版本的Class文件,反之则不成立。
constant_pool_count:
常量池计数器,constant_pool_count的值等于constant_pool表中的成员数加1。
constant_pool[]:
常量池,constant_pool是一种表结构,它包含Class文件结构及其子结构中引用的所有字符串常量、类或接口名、字段名和其它常量。常量池不同于其他,索引从1开始到constant_pool_count -1。
access_flags:
访问标志,access_flags是一种掩码标志,用于表示某个类或者接口的访问权限及基础属性。access_flags的取值范围和相应含义见下表:
this_class:
类索引,this_class的值必须是对constant_pool表中项目的一个有效索引值。constant_pool表在这个索引处的项必须为CONSTANT_Class_info类型常量,表示这个Class文件所定义的类或接口。
super_class:
父类索引,对于类来说,super_class的值必须为0或者是对constant_pool表中项目的一个有效索引值。如果它的值不为0,那constant_pool表在这个索引处的项必须为CONSTANT_Class_info类型常量,表示这个Class文件所定义的类的直接父类。当然,如果某个类super_class的值是0,那么它必定是java.lang.Object类,因为只有它是没有父类的。
interfaces_count:
接口计数器,interfaces_count的值表示当前类或接口的直接父接口数量。
interfaces[]:
接口表,interfaces[]数组中的每个成员的值必须是一个对constant_pool表中项目的一个有效索引值,它的长度为interfaces_count。每个成员interfaces[i] 必须为CONSTANT_Class_info类型常量。
fields_count:
字段计数器,fields_count的值表示当前Class文件fields[]数组的成员个数。
fields[]:
字段表,fields[]数组中的每个成员都必须是一个fields_info结构的数据项,用于表示当前类或接口中某个字段的完整描述。
methods_count:
方法计数器,methods_count的值表示当前Class文件methods[]数组的成员个数。
methods[]:
方法表,methods[]数组中的每个成员都必须是一个method_info结构的数据项,用于表示当前类或接口中某个方法的完整描述。
attributes_count:
属性计数器,attributes_count的值表示当前Class文件attributes表的成员个数。
attributes[]:
属性表,attributes表的每个项的值必须是attribute_info结构。
四、一个java类的实例分析
为了了解JVM的数据类型规范和内存分配的大体情况,我新建了MemeryTest.java:
编译为MemeryTest.class后,通过WinHex查看该文件,对应字节码文件各个部分不同的定义,我了解了下面16进制数值的具体含义,尽管不清楚ClassLoader的具体实现逻辑,但是可以想象这样一个严谨格式的文件给JVM对于内存管理和执行程序提供了多大的帮助。
运行程序后,我在windows资源管理器中找到对应的进程ID.
并且在控制台通过jmap -heap 10016查看堆内存的使用情况:
输出结果中表示当前java进程启动的JVM是通过4个线程进行Parallel GC,堆的最小FreeRatio是40%,堆的最大FreeRatio是70%,堆的大小是4090M,新对象占用1.5M,Young Generation可以扩展到最大是1363M, Tenured Generation的大小是254.5M,以及NewRadio和SurvivorRadio中,下面更是具体给出了目前Young Generation中1.5M的划分情况,Eden占用1.0M,使用了5.4%,Space占了0.5M,使用了93%,To Space占了0.5M,使用了0%。
下面我们通过jmap dump把heap的内容打印打文件中:
使用Eclipse的MAT插件打开对应的文件:
选择第一项内存泄露分析报告打开test.bin文件,展示出来的是MAT关于内存可能泄露的分析。
从结果来看,有3个地方可能存在内存泄露,他们占据了Heap的22.10%,13.78%,14.69%,如果内存泄露,这里一般会有一个比值非常高的对象。打开第一个Probem Suspect,结果如下:
ShallowHeap是对象本身占用的堆大小,不包含引用,RetainedHeap是对象所持有的Shallowheap的大小,包括自己ShallowHeap和可以引用的对象的ShallowHeap。垃圾回收的时候,如果一个对象不再引用后被回收,那么他的RetainedHeap是能回收的内存总和。通过上图可以看出程序中并没有什么内存泄露,可以放心了。如果还有什么不太确定的对象,则可以通过多个时间点的HeapDumpFile来研究某个对象的变化情况。
作者:大齐老师
链接:https://www.jianshu.com/p/753efd60f329
來源:简书
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
---------------------
Java应用的GC优化
当Java程序性能达不到既定目标,且其他优化手段都已经穷尽时,通常需要调整垃圾回收器来进一步提高性能,称为GC优化。但GC算法复杂,影响GC性能的参数众多,且参数调整又依赖于应用各自的特点,这些因素很大程度上增加了GC优化的难度。即便如此,GC调优也不是无章可循,仍然有一些通用的思考方法。本篇会介绍这些通用的GC优化策略和相关实践案例,主要包括如下内容:
优化前准备: 简单回顾JVM相关知识、介绍GC优化的一些通用策略。
优化方法: 介绍调优的一般流程:明确优化目标→优化→跟踪优化结果。
优化案例: 简述笔者所在团队遇到的GC问题以及优化方案。
一、优化前的准备
GC优化需知
为了更好地理解本篇所介绍的内容,你需要了解如下内容。
-
GC相关基础知识,包括但不限于:
a) GC工作原理。
b) 理解新生代、老年代、晋升等术语含义。
c) 可以看懂GC日志。 -
GC优化不能解决一切性能问题,它是最后的调优手段。
如果对第一点中提及的知识点不是很熟悉,可以先阅读小结-JVM基础回顾;如果已经很熟悉,可以跳过该节直接往下阅读。
JVM基础回顾
JVM内存结构
简单介绍一下JVM内存结构和常见的垃圾回收器。
当代主流虚拟机(Hotspot VM)的垃圾回收都采用“分代回收”的算法。“分代回收”是基于这样一个事实:对象的生命周期不同,所以针对不同生命周期的对象可以采取不同的回收方式,以便提高回收效率。
Hotspot VM将内存划分为不同的物理区,就是“分代”思想的体现。如图所示,JVM内存主要由新生代、老年代、永久代构成。
① 新生代(Young Generation):大多数对象在新生代中被创建,其中很多对象的生命周期很短。每次新生代的垃圾回收(又称Minor GC)后只有少量对象存活,所以选用复制算法,只需要少量的复制成本就可以完成回收。
新生代内又分三个区:一个Eden区,两个Survivor区(一般而言),大部分对象在Eden区中生成。当Eden区满时,还存活的对象将被复制到两个Survivor区(中的一个)。当这个Survivor区满时,此区的存活且不满足“晋升”条件的对象将被复制到另外一个Survivor区。对象每经历一次Minor GC,年龄加1,达到“晋升年龄阈值”后,被放到老年代,这个过程也称为“晋升”。显然,“晋升年龄阈值”的大小直接影响着对象在新生代中的停留时间,在Serial和ParNew GC两种回收器中,“晋升年龄阈值”通过参数MaxTenuringThreshold设定,默认值为15。
② 老年代(Old Generation):在新生代中经历了N次垃圾回收后仍然存活的对象,就会被放到年老代,该区域中对象存活率高。老年代的垃圾回收(又称Major GC)通常使用“标记-清理”或“标记-整理”算法。整堆包括新生代和老年代的垃圾回收称为Full GC(HotSpot VM里,除了CMS之外,其它能收集老年代的GC都会同时收集整个GC堆,包括新生代)。
③ 永久代(Perm Generation):主要存放元数据,例如Class、Method的元信息,与垃圾回收要回收的Java对象关系不大。相对于新生代和年老代来说,该区域的划分对垃圾回收影响比较小。
常见垃圾回收器
不同的垃圾回收器,适用于不同的场景。常用的垃圾回收器:
- 串行(Serial)回收器是单线程的一个回收器,简单、易实现、效率高。
- 并行(ParNew)回收器是Serial的多线程版,可以充分的利用CPU资源,减少回收的时间。
- 吞吐量优先(Parallel Scavenge)回收器,侧重于吞吐量的控制。
- 并发标记清除(CMS,Concurrent Mark Sweep)回收器是一种以获取最短回收停顿时间为目标的回收器,该回收器是基于“标记-清除”算法实现的。
GC日志
每一种回收器的日志格式都是由其自身的实现决定的,换而言之,每种回收器的日志格式都可以不一样。但虚拟机设计者为了方便用户阅读,将各个回收器的日志都维持一定的共性。JavaGC日志 中简单介绍了这些共性。
参数基本策略
各分区的大小对GC的性能影响很大。如何将各分区调整到合适的大小,分析活跃数据的大小是很好的切入点。
活跃数据的大小是指,应用程序稳定运行时长期存活对象在堆中占用的空间大小,也就是Full GC后堆中老年代占用空间的大小。可以通过GC日志中Full GC之后老年代数据大小得出,比较准确的方法是在程序稳定后,多次获取GC数据,通过取平均值的方式计算活跃数据的大小。活跃数据和各分区之间的比例关系如下(见参考文献1):
空间 | 倍数 |
---|---|
总大小 | 3-4 倍活跃数据的大小 |
新生代 | 1-1.5 活跃数据的大小 |
老年代 | 2-3 倍活跃数据的大小 |
永久代 | 1.2-1.5 倍Full GC后的永久代空间占用 |
例如,根据GC日志获得老年代的活跃数据大小为300M,那么各分区大小可以设为:
总堆:1200MB = 300MB × 4
新生代:450MB = 300MB × 1.5
老年代: 750MB = 1200MB - 450MB*
这部分设置仅仅是堆大小的初始值,后面的优化中,可能会调整这些值,具体情况取决于应用程序的特性和需求。
二、优化步骤
GC优化一般步骤可以概括为:确定目标、优化参数、验收结果。
确定目标
明确应用程序的系统需求是性能优化的基础,系统的需求是指应用程序运行时某方面的要求,譬如:
- 高可用,可用性达到几个9。
- 低延迟,请求必须多少毫秒内完成响应。
- 高吞吐,每秒完成多少次事务。
明确系统需求之所以重要,是因为上述性能指标间可能冲突。比如通常情况下,缩小延迟的代价是降低吞吐量或者消耗更多的内存或者两者同时发生。
由于笔者所在团队主要关注高可用和低延迟两项指标,所以接下来分析,如何量化GC时间和频率对于响应时间和可用性的影响。通过这个量化指标,可以计算出当前GC情况对服务的影响,也能评估出GC优化后对响应时间的收益,这两点对于低延迟服务很重要。
举例:假设单位时间T内发生一次持续25ms的GC,接口平均响应时间为50ms,且请求均匀到达,根据下图所示:
那么有(50ms+25ms)/T比例的请求会受GC影响,其中GC前的50ms内到达的请求都会增加25ms,GC期间的25ms内到达的请求,会增加0-25ms不等,如果时间T内发生N次GC,受GC影响请求占比=(接口响应时间+GC时间)×N/T 。可见无论降低单次GC时间还是降低GC次数N都可以有效减少GC对响应时间的影响。
优化
通过收集GC信息,结合系统需求,确定优化方案,例如选用合适的GC回收器、重新设置内存比例、调整JVM参数等。
进行调整后,将不同的优化方案分别应用到多台机器上,然后比较这些机器上GC的性能差异,有针对性的做出选择,再通过不断的试验和观察,找到最合适的参数。
验收优化结果
将修改应用到所有服务器,判断优化结果是否符合预期,总结相关经验。
接下来,我们通过三个案例来实践以上的优化流程和基本原则(本文中三个案例使用的垃圾回收器均为ParNew+CMS,CMS失败时Serial Old替补)。
三、GC优化案例
案例一 Major GC和Minor GC频繁
确定目标
服务情况:Minor GC每分钟100次 ,Major GC每4分钟一次,单次Minor GC耗时25ms,单次Major GC耗时200ms,接口响应时间50ms。
由于这个服务要求低延时高可用,结合上文中提到的GC对服务响应时间的影响,计算可知由于Minor GC的发生,12.5%的请求响应时间会增加,其中8.3%的请求响应时间会增加25ms,可见当前GC情况对响应时间影响较大。
(50ms+25ms)× 100次/60000ms = 12.5%,50ms × 100次/60000ms = 8.3% 。
优化目标:降低TP99、TP90时间。
优化
首先优化Minor GC频繁问题。通常情况下,由于新生代空间较小,Eden区很快被填满,就会导致频繁Minor GC,因此可以通过增大新生代空间来降低Minor GC的频率。例如在相同的内存分配率的前提下,新生代中的Eden区增加一倍,Minor GC的次数就会减少一半。
这时很多人有这样的疑问,扩容Eden区虽然可以减少Minor GC的次数,但会增加单次Minor GC时间么?根据上面公式,如果单次Minor GC时间也增加,很难保证最后的优化效果。我们结合下面情况来分析,单次Minor GC时间主要受哪些因素影响?是否和新生代大小存在线性关系?
首先,单次Minor GC时间由以下两部分组成:T1(扫描新生代)和 T2(复制存活对象到Survivor区)如下图。(注:这里为了简化问题,我们认为T1只扫描新生代判断对象是否存活的时间,其实该阶段还需要扫描部分老年代,后面案例中有详细描述。)
-
扩容前:新生代容量为R ,假设对象A的存活时间为750ms,Minor GC间隔500ms,那么本次Minor GC时间= T1(扫描新生代R)+T2(复制对象A到S)。
-
扩容后:新生代容量为2R ,对象A的生命周期为750ms,那么Minor GC间隔增加为1000ms,此时Minor GC对象A已不再存活,不需要把它复制到Survivor区,那么本次GC时间 = 2 × T1(扫描新生代R),没有T2复制时间。
可见,扩容后,Minor GC时增加了T1(扫描时间),但省去T2(复制对象)的时间,更重要的是对于虚拟机来说,复制对象的成本要远高于扫描成本,所以,单次Minor GC时间更多取决于GC后存活对象的数量,而非Eden区的大小。因此如果堆中短期对象很多,那么扩容新生代,单次Minor GC时间不会显著增加。下面需要确认下服务中对象的生命周期分布情况:
通过上图GC日志中两处红色框标记内容可知:
- new threshold = 2(动态年龄判断,对象的晋升年龄阈值为2),对象仅经历2次Minor GC后就晋升到老年代,这样老年代会迅速被填满,直接导致了频繁的Major GC。
- Major GC后老年代使用空间为300M+,意味着此时绝大多数(86% = 2G/2.3G)的对象已经不再存活,也就是说生命周期长的对象占比很小。
由此可见,服务中存在大量短期临时对象,扩容新生代空间后,Minor GC频率降低,对象在新生代得到充分回收,只有生命周期长的对象才进入老年代。这样老年代增速变慢,Major GC频率自然也会降低。
优化结果
通过扩容新生代为为原来的三倍,单次Minor GC时间增加小于5ms,频率下降了60%,服务响应时间TP90,TP99都下降了10ms+,服务可用性得到提升。
调整前:
调整后:
小结
如何选择各分区大小应该依赖应用程序中对象生命周期的分布情况:如果应用存在大量的短期对象,应该选择较大的年轻代;如果存在相对较多的持久对象,老年代应该适当增大。
更多思考
关于上文中提到晋升年龄阈值为2,很多同学有疑问,为什么设置了MaxTenuringThreshold=15,对象仍然仅经历2次Minor GC,就晋升到老年代?这里涉及到“动态年龄计算”的概念。
动态年龄计算:Hotspot遍历所有对象时,按照年龄从小到大对其所占用的大小进行累积,当累积的某个年龄大小超过了survivor区的一半时,取这个年龄和MaxTenuringThreshold中更小的一个值,作为新的晋升年龄阈值。在本案例中,调优前:Survivor区 = 64M,desired survivor = 32M,此时Survivor区中age<=2的对象累计大小为41M,41M大于32M,所以晋升年龄阈值被设置为2,下次Minor GC时将年龄超过2的对象被晋升到老年代。
JVM引入动态年龄计算,主要基于如下两点考虑:
-
如果固定按照MaxTenuringThreshold设定的阈值作为晋升条件:
a)MaxTenuringThreshold设置的过大,原本应该晋升的对象一直停留在Survivor区,直到Survivor区溢出,一旦溢出发生,Eden+Svuvivor中对象将不再依据年龄全部提升到老年代,这样对象老化的机制就失效了。
b)MaxTenuringThreshold设置的过小,“过早晋升”即对象不能在新生代充分被回收,大量短期对象被晋升到老年代,老年代空间迅速增长,引起频繁的Major GC。分代回收失去了意义,严重影响GC性能。 -
相同应用在不同时间的表现不同:特殊任务的执行或者流量成分的变化,都会导致对象的生命周期分布发生波动,那么固定的阈值设定,因为无法动态适应变化,会造成和上面相同的问题。
总结来说,为了更好的适应不同程序的内存情况,虚拟机并不总是要求对象年龄必须达到Maxtenuringthreshhold再晋级老年代。
案例二 请求高峰期发生GC,导致服务可用性下降
确定目标
GC日志显示,高峰期CMS在重标记(Remark)阶段耗时1.39s。Remark阶段是Stop-The-World(以下简称为STW)的,即在执行垃圾回收时,Java应用程序中除了垃圾回收器线程之外其他所有线程都被挂起,意味着在此期间,用户正常工作的线程全部被暂停下来,这是低延时服务不能接受的。本次优化目标是降低Remark时间。
优化
解决问题前,先回顾一下CMS的四个主要阶段,以及各个阶段的工作内容。下图展示了CMS各个阶段可以标记的对象,用不同颜色区分。
- Init-mark初始标记(STW) ,该阶段进行可达性分析,标记GC ROOT能直接关联到的对象,所以很快。
- Concurrent-mark并发标记,由前阶段标记过的绿色对象出发,所有可到达的对象都在本阶段中标记。
- Remark重标记(STW) ,暂停所有用户线程,重新扫描堆中的对象,进行可达性分析,标记活着的对象。因为并发标记阶段是和用户线程并发执行的过程,所以该过程中可能有用户线程修改某些活跃对象的字段,指向了一个未标记过的对象,如下图中红色对象在并发标记开始时不可达,但是并行期间引用发生变化,变为对象可达,这个阶段需要重新标记出此类对象,防止在下一阶段被清理掉,这个过程也是需要STW的。特别需要注意一点,这个阶段是以新生代中对象为根来判断对象是否存活的。
- 并发清理,进行并发的垃圾清理。
可见,Remark阶段主要是通过扫描堆来判断对象是否存活。那么准确判断对象是否存活,需要扫描哪些对象?CMS对老年代做回收,Remark阶段仅扫描老年代是否可行?结论是不可行,原因如下:
如果仅扫描老年代中对象,即以老年代中对象为根,判断对象是否存在引用,上图中,对象A因为引用存在新生代中,它在Remark阶段就不会被修正标记为可达,GC时会被错误回收。
新生代对象持有老年代中对象的引用,这种情况称为“跨代引用”。因它的存在,Remark阶段必须扫描整个堆来判断对象是否存活,包括图中灰色的不可达对象。
灰色对象已经不可达,但仍然需要扫描的原因:新生代GC和老年代的GC是各自分开独立进行的,只有Minor GC时才会使用根搜索算法,标记新生代对象是否可达,也就是说虽然一些对象已经不可达,但在Minor GC发生前不会被标记为不可达,CMS也无法辨认哪些对象存活,只能全堆扫描(新生代+老年代)。由此可见堆中对象的数目影响了Remark阶段耗时。
分析GC日志可以得出同样的规律,Remark耗时>500ms时,新生代使用率都在75%以上。这样降低Remark阶段耗时问题转换成如何减少新生代对象数量。
新生代中对象的特点是“朝生夕灭”,这样如果Remark前执行一次Minor GC,大部分对象就会被回收。CMS就采用了这样的方式,在Remark前增加了一个可中断的并发预清理(CMS-concurrent-abortable-preclean),该阶段主要工作仍然是并发标记对象是否存活,只是这个过程可被中断。此阶段在Eden区使用超过2M时启动,当然2M是默认的阈值,可以通过参数修改。如果此阶段执行时等到了Minor GC,那么上述灰色对象将被回收,Reamark阶段需要扫描的对象就少了。
除此之外CMS为了避免这个阶段没有等到Minor GC而陷入无限等待,提供了参数CMSMaxAbortablePrecleanTime ,默认为5s,含义是如果可中断的预清理执行超过5s,不管发没发生Minor GC,都会中止此阶段,进入Remark。
根据GC日志红色标记2处显示,可中断的并发预清理执行了5.35s,超过了设置的5s被中断,期间没有等到Minor GC ,所以Remark时新生代中仍然有很多对象。
对于这种情况,CMS提供CMSScavengeBeforeRemark参数,用来保证Remark前强制进行一次Minor GC。
优化结果
经过增加CMSScavengeBeforeRemark参数,单次执行时间>200ms的GC停顿消失,从监控上观察,GCtime和业务波动保持一致,不再有明显的毛刺。
小结
通过案例分析了解到,由于跨代引用的存在,CMS在Remark阶段必须扫描整个堆,同时为了避免扫描时新生代有很多对象,增加了可中断的预清理阶段用来等待Minor GC的发生。只是该阶段有时间限制,如果超时等不到Minor GC,Remark时新生代仍然有很多对象,我们的调优策略是,通过参数强制Remark前进行一次Minor GC,从而降低Remark阶段的时间。
更多思考
案例中只涉及老年代GC,其实新生代GC存在同样的问题,即老年代可能持有新生代对象引用,所以Minor GC时也必须扫描老年代。
JVM是如何避免Minor GC时扫描全堆的?
经过统计信息显示,老年代持有新生代对象引用的情况不足1%,根据这一特性JVM引入了卡表(card table)来实现这一目的。如下图所示:
卡表的具体策略是将老年代的空间分成大小为512B的若干张卡(card)。卡表本身是单字节数组,数组中的每个元素对应着一张卡,当发生老年代引用新生代时,虚拟机将该卡对应的卡表元素设置为适当的值。如上图所示,卡表3被标记为脏(卡表还有另外的作用,标识并发标记阶段哪些块被修改过),之后Minor GC时通过扫描卡表就可以很快的识别哪些卡中存在老年代指向新生代的引用。这样虚拟机通过空间换时间的方式,避免了全堆扫描。
总结来说,CMS的设计聚焦在获取最短的时延,为此它“不遗余力”地做了很多工作,包括尽量让应用程序和GC线程并发、增加可中断的并发预清理阶段、引入卡表等,虽然这些操作牺牲了一定吞吐量但获得了更短的回收停顿时间。
案例三 发生Stop-The-World的GC
确定目标
GC日志如下图(在GC日志中,Full GC是用来说明这次垃圾回收的停顿类型,代表STW类型的GC,并不特指老年代GC),根据GC日志可知本次Full GC耗时1.23s。这个在线服务同样要求低时延高可用。本次优化目标是降低单次STW回收停顿时间,提高可用性。
优化
首先,什么时候可能会触发STW的Full GC呢?
- Perm空间不足;
- CMS GC时出现promotion failed和concurrent mode failure(concurrent mode failure发生的原因一般是CMS正在进行,但是由于老年代空间不足,需要尽快回收老年代里面的不再被使用的对象,这时停止所有的线程,同时终止CMS,直接进行Serial Old GC);
- 统计得到的Young GC晋升到老年代的平均大小大于老年代的剩余空间;
- 主动触发Full GC(执行jmap -histo:live [pid])来避免碎片问题。
然后,我们来逐一分析一下:
- 排除原因2:如果是原因2中两种情况,日志中会有特殊标识,目前没有。
- 排除原因3:根据GC日志,当时老年代使用量仅为20%,也不存在大于2G的大对象产生。
- 排除原因4:因为当时没有相关命令执行。
- 锁定原因1:根据日志发现Full GC后,Perm区变大了,推断是由于永久代空间不足容量扩展导致的。
找到原因后解决方法有两种:
- 通过把-XX:PermSize参数和-XX:MaxPermSize设置成一样,强制虚拟机在启动的时候就把永久代的容量固定下来,避免运行时自动扩容。
- CMS默认情况下不会回收Perm区,通过参数CMSPermGenSweepingEnabled、CMSClassUnloadingEnabled ,可以让CMS在Perm区容量不足时对其回收。
由于该服务没有生成大量动态类,回收Perm区收益不大,所以我们采用方案1,启动时将Perm区大小固定,避免进行动态扩容。
优化结果
调整参数后,服务不再有Perm区扩容导致的STW GC发生。
小结
对于性能要求很高的服务,建议将MaxPermSize和MinPermSize设置成一致(JDK8开始,Perm区完全消失,转而使用元空间。而元空间是直接存在内存中,不在JVM中),Xms和Xmx也设置为相同,这样可以减少内存自动扩容和收缩带来的性能损失。虚拟机启动的时候就会把参数中所设定的内存全部化为私有,即使扩容前有一部分内存不会被用户代码用到,这部分内存在虚拟机中被标识为虚拟内存,也不会交给其他进程使用。
四、总结
结合上述GC优化案例做个总结:
- 首先再次声明,在进行GC优化之前,需要确认项目的架构和代码等已经没有优化空间。我们不能指望一个系统架构有缺陷或者代码层次优化没有穷尽的应用,通过GC优化令其性能达到一个质的飞跃。
- 其次,通过上述分析,可以看出虚拟机内部已有很多优化来保证应用的稳定运行,所以不要为了调优而调优,不当的调优可能适得其反。
- 最后,GC优化是一个系统而复杂的工作,没有万能的调优策略可以满足所有的性能指标。GC优化必须建立在我们深入理解各种垃圾回收器的基础上,才能有事半功倍的效果。
本文中案例均来北京业务安全中心(也称风控)对接服务的实践经验。同时感谢风控的小伙伴们,是他们专业负责的审阅,才让这篇文章更加完善。对于本文中涉及到的内容,欢迎大家指正和补充。