JVM虚拟机详解
JVM虚拟机详解
本文内容可能过多, 可下载pdf进行查看 {点击下载}
jvm是java程序运行环境的虚拟机, 编写的java程序在不同操作系统安装了对应的虚拟机之后都可以运行
JVM原理
从最初的我们编写的Java源文件(.java文件)是如何一步步执行的,如下图所示,首先Java源文件经过前端编译器(javac或ECJ)将.java文件编译为Java字节码文件,然后JRE加载Java字节码文件,载入系统分配给JVM的内存区,然后执行引擎解释或编译类文件,再由即时编译器将字节码转化为机器码。主要介绍下图中的类加载器和运行时数据区两个部分。
JVM整体架构
从Java平台的逻辑结构上来看,我们可以从下图来了解JVM:
从上图能清晰看到Java平台包含的各个逻辑模块,也能了解到JDK与JRE的区别,对于JVM自身的物理结构,我们可以从下图鸟瞰一下:
JVM的运行如下图:
从图中,可知内存分为线程私有和共享两大类:
-
线程私有区,包含以下3类:
-
程序计数器,记录正在执行的虚拟机字节码的地址;
-
虚拟机栈:方法执行的内存区,每个方法执行时会在虚拟机栈中创建栈帧;
-
本地方法栈:虚拟机的Native方法执行的内存区;
-
-
线程共享区,包含以下2类
-
Java堆:对象分配内存的区域;
-
方法区:存放类信息、常量、静态变量、编译器编译后的代码等数据;
- 常量池:存放编译器生成的各种字面量和符号引用,是方法区的一部分。
-
1. JVM-类加载子系统
类加载子系统是JVM里面的一个重要的环节。与C/C++那些需要在编译器期进行连接工作的语言不同,Java类的加载、连接和初始化都是在程序运行时完成的,只有在类被需要的时候才进行动态加载。
1.1 JVM何时加载类?
有且只有以下5种情况:
- 创建新对象(new)、设置/读取static字段(putstatic/getstatic)或调用静态方法(invokestatic)这四条指令时,如果该类没有初始化,则初始化。
- 使用java.lang.reflect包得方法进行反射调用的时候,如果该类没有初始化,则初始化。
- 当初始化一个类时,父类没有初始化,则先初始化父类。
- 当虚拟机启动,需要执行main()的主类,JVM首先初始化该类。
- JDK 1.7的动态语言支持时,如果java.lang.invoke.MethodHandle实例最后的解析结果REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,并且这个方法句柄所对应的类没有进行过初始化,则初始化。
1.2 如何加载类?
类加载过程包括加载(Loading)、链接(Linking)和初始化(Initialization)三个过程。
加载(Loading)
- 其中“根据全限定名获取字节流”的过程,可以有系统提供的类加载器完成,也可以由用户自定义类加载器。
- 对于HotSpot,Class对象虽然是对象,但仍然存放在方法区中。
链接-准备
- 该阶段值正式在方法区为类变量分配内存初始化类变量,
public static int value = 123
在准备阶段初始化零值,而非123。初始化123的过程在初始化阶段完成。
链接-解析
- 将常量池中的符号引用替换成直接引用。其中对于非虚方法,在类加载阶段就可以确定调用的版本,因此可以在此阶段直接解析为直接引用;为对于虚方法(即支持多态),无法在此阶段确定调用版本,虚方法的符号引用需等到程序执行到该符号引用的字节码时才能解析为直接引用。
- 解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法类型等符号引用。
- 执行读写字段(getstatic、putstatic、getfield、putfield)、instanceof、方法调用(iinvokestatic、invokespecial、invokevirtual、invokedynamic)、new等操作符号引用的字节码之前,需要先进行符号引用解析。
初始化(Initialization)
- 执行类构造器
<clinit>
:自动收集static变量和static{}块,按原文件出席西安的顺序执行初始化。 <clinit>
由编译器自动生成,如果没有static变量和static{}块,就不会生成。
2. JVM-方法区
2.1 JVM内存模型
大多数JVM将内存分配为Method Area(方法区)、Heap(堆)、Program Counter Register(程序计数器)、JAVA Method Stack(JAVA方法栈)、Native Method Stack(本地方法栈)。
2.2 方法区(Method Area)
线程共享,存储已经被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等等。(HotSpot虚拟机上开发部署人员更愿意成为“永久代”,Permanent Generation)。示意图如下,下面的图片显示的是JVM加载类的时候,方法区存储的信息:
类型信息
- 类型的全限定名
- 超类的全限定名
- 直接超接口的全限定名
- 类型标志(该类是类类型还是接口类型)
- 类的访问描述符(public、private、default、abstract、final、static)
类型的常量池
存放该类型所用到的常量的有序集合,包括直接常量(如字符串、整数、浮点数的常量)和对其他类型、字段、方法的符号引用。常量池中每一个保存的常量都有一个索引,就像数组中的字段一样。因为常量池中保存中所有类型使用到的类型、字段、方法的字符引用,所以它也是动态连接的主要对象(在动态链接中起到核心作用)。
字段信息
(该类声明的所有字段)
- 字段修饰符(public、protect、private、default)
- 字段的类型
- 字段名称
方法信息
方法信息中包含类的所有方法,每个方法包含以下信息:
- 方法修饰符
- 方法返回类型
- 方法名
- 方法参数个数、类型、顺序等
- 方法字节码
- 操作数栈和该方法在栈帧中的局部变量区大小
- 异常表
类变量(静态变量)
指该类所有对象共享的变量,即使没有任何实例对象时,也可以访问的类变量。它们与类进行绑定。
指向类加载器的引用
每一个被JVM加载的类型,都保存这个类加载器的引用,类加载器动态链接时会用到。
指向Class实例的引用
类加载的过程中,虚拟机会创建该类型的Class实例,方法区中必须保存对该对象的引用。通过Class.forName(String className)来查找获得该实例的引用,然后创建该类的对象。
方法表
为了提高访问效率,JVM可能会对每个装载的非抽象类,都创建一个数组,数组的每个元素是实例可能调用的方法的直接引用,包括父类中继承过来的方法。这个表在抽象类或者接口中是没有的,类似C++虚函数表vtbl。
运行时常量池
英文名: (Runtime Constant Pool)
Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池,用于存放编译器生成的各种字面常量和符号引用,这部分内容被类加载后进入方法区的运行时常量池中存放。
运行时常量池相对于Class文件常量池的另外一个特征具有动态性,可以在运行期间将新的常量放入池中(典型的如String类的intern()方法)。
(这个地方不太理解,网上找来的解释不知道对否:运行时常量池是把Class文件常量池加载进来,每个类有一个独立的。刚开始时运行的时候常量池里的链接都是符号链接(只用名字没有实体),跟在Class文件里的一样;边运行边把常量转换成直接链接。例如说要Class A调用Foo.bar()方法,A.class文件里就会有对该方法的Method ref常量,是个符号链接(只有名字没有实体),加载到运行时常量池也还是一样是符号链接,等真的要调用该方法的时候该常量就会被resolve为一个直接链接(直接指向要调用的方法的实体))。
3. JVM-堆
Java 中的堆是 JVM 所管理的最大的一块内存空间,主要用于存放各种类的实例对象。
在 Java 中,堆被划分成两个不同的区域:新生代 ( Young )、老年代 ( Old )。新生代 ( Young ) 又被划分为三个区域:Eden、From Survivor、To Survivor。
这样划分的目的是为了使 JVM 能够更好的管理堆内存中的对象,包括内存的分配以及回收。
堆的内存模型大致为:
从图中可以看出: 堆大小 = 新生代 + 老年代。其中,堆的大小可以通过参数 –Xms、-Xmx 来指定。
本人使用的是 JDK1.6,以下涉及的 JVM 默认值均以该版本为准。
默认的,新生代 ( Young ) 与老年代 ( Old ) 的比例的值为 1:2 ( 该值可以通过参数 –XX:NewRatio 来指定 ),即:新生代 ( Young ) = 1/3 的堆空间大小。
老年代 ( Old ) = 2/3 的堆空间大小。其中,新生代 ( Young ) 被细分为 Eden 和 两个 Survivor 区域,这两个 Survivor 区域分别被命名为 from 和 to,以示区分。
默认的,Edem : from : to = 8 : 1 : 1 ( 可以通过参数 –XX:SurvivorRatio 来设定 ),即: Eden = 8/10 的新生代空间大小,from = to = 1/10 的新生代空间大小。
JVM 每次只会使用 Eden 和其中的一块 Survivor 区域来为对象服务,所以无论什么时候,总是有一块 Survivor 区域是空闲着的。
因此,新生代实际可用的内存空间为 9/10 ( 即90% )的新生代空间。
3.1 java的堆和栈的结构:
java堆
根据垃圾回收机制的不同,java堆有可能拥有不同的结构,常见的java堆分为新生代和老年代。其中新生代存放刚创建的对象及年龄不大的对象,老年带存放着在新生代中经历过多次回收后还存在的对象。
对象晋升过程:
新生代分为eden区s0,s1区(from,to)。多数情况下对象首先分配在eden区,在一次新生代回收后,存活下来的对象存入s0或s1区。每经过一次新生代的回收,对象的年龄加1。默认情况下年龄达到15的对象将晋升至老年代。如果在第一次回收的时候,存活的对象大于s0(s1)空间,将直接晋升至老年代,如果在为对象第一次分配空间时,对象空间大于eden空间的话,对象也直接分配到老年代。
java栈
Java栈和数据结构中的栈有着类似的含义,先进后出,只支持入栈和出栈操作。Java栈中保存的只要内容是栈帧,每一次进行函数调用,都会有一个对应的栈帧被压入栈中,函数调用结束,都会有一个栈帧被弹出栈。
栈帧:
每一个栈帧中包含局部变量表,操作数栈和帧数据区。
栈上分配:
栈上分配的基本思想,是将线程私有的对象,打散分配到栈上,分配在栈上的函数调用结束后对象会自行销毁,不需要垃圾回收接入,从而提升性能。对于大量的零散小对象,栈上分配提供了一种很好的对象分配优化策略,但由于和堆空间相比,栈空间较小,因此大对象无法也不适合在栈上分配
栈上分配依赖逃逸分析和标量替换的实现,同时必须在server模式下才能启用。参数-XX:+DoEscapeAnalysis启用逃逸分析 -XX:+EliminateAllocations开启标量替换(默认打开).
例:-server -Xms 100m -Xmx 100m -XX:+DoEscapeAnalysis -XX:+EliminateAllocations
3.2 堆栈案例
案例1
第1步——执行int[] x=new int[3];
隐藏以下几分支
JVM执行main()
函数,在栈内存中开辟一个空间,存放x变量(x变量是局部变量)。
同时,在堆内存中也开辟一个空间,存放new int[3]
数组,堆内存会自动内存首地址值,如0x0045。
数组在栈内存中的地址值,会附给x,这样x也有地址值。所以,x就指向(引用)了这个数组。此时,所有元素均未附值,但都有默认初始化值0。
第2步——执行x[0]=20
即在堆内存中将20附给[0]这个数组元素。这样,数组的三个元素值分别为20,0,0
示图如下:
案例2
main()
int[] x=new int[3];
x[0]=20
x=null;
以上步骤执行步骤
第1、2步——与示例2完全一样,略。
第3步——执行x=null;
null表示空值,即x的引用数组内存地址0x0045被删除了,则不再指向栈内存中的数组。此时,堆中的数组不再被x使用了,即被视为垃圾,JVM会启动垃圾回收机制,不定时自动删除。
示图如下
案例3
main()
int[] x=new int[3];
int[] y=x;
y[1]=100
x=null;
以上步骤执行步骤
第1步——与示例2第1步一致,略。
第2步——执行int[] y=x,
在栈内存定义了新的数组变量内存y,同时将x的值0x0045附给了y。所以,y也指向了堆内存中的同一个数组。
第3步——执行y[1]=100
即在堆内存中将20附给[0]这个数组元素。这样,数组的三个元素值分别为0,100,0
第4步——执行x=null
则变量x不再指向栈内存中的数组了。但是,变量y仍然指向,所以数组不消失。
示图如下
案例4
Car c=new Car;
c.color="blue";
Car c1=new Car;
c1.num=5;
虽然是个对象都引用new Car
,但是是两个不同的对象。每一次new,都产生不同的实体
示例5
Car c=new Car;
c.num=5;
Car c1=c;
c1.color="green";
c.run();
Car c1=c
,这句话相当于将对象复制一份出来,两个对象的内存地址值一样。所以指向同一个实体,对c1的属性修改,相当于c的属性也改了。
3.3 JVM 参数选项
jvm 可配置的参数选项可以参考 Oracle 官方网站给出的相关信息:http://www.oracle.com/technetwork/java/javase/tech/vmoptions-jsp-140102.html
下面只列举其中的几个常用和容易掌握的配置选项:
参数 | 描述 |
---|---|
-Xms | 初始堆大小。如:-Xms256m |
-Xmx | 最大堆大小。如:-Xmx512m |
-Xmn | 新生代大小。通常为 Xmx 的 1/3 或 1/4。新生代 = Eden + 2 个 Survivor 空间。实际可用空间为 = Eden + 1 个 Survivor,即 90% |
-Xss | JDK1.5+ 每个线程堆栈大小为 1M,一般来说如果栈不是很深的话, 1M 是绝对够用了的。 |
-XX:NewRatio | 新生代与老年代的比例,如 –XX:NewRatio=2,则新生代占整个堆空间的1/3,老年代占2/3 |
-XX:SurvivorRatio | 新生代中 Eden 与 Survivor 的比值。默认值为 8。即 Eden 占新生代空间的 8/10,另外两个 Survivor 各占 1/10 |
-XX:PermSize | 永久代(方法区)的初始大小 |
-XX:MaxPermSize | 永久代(方法区)的最大值 |
-XX:+PrintGCDetails | 打印 GC 信息 |
-XX:+HeapDumpOnOutOfMemoryError | 让虚拟机在发生内存溢出时 Dump 出当前的内存堆转储快照,以便分析用 |
可以参照一下下面代码:
/**
-Xms60m
-Xmx60m
-Xmn20m
-XX:NewRatio=2 ( 若 Xms = Xmx, 并且设定了 Xmn, 那么该项配置就不需要配置了 )
-XX:SurvivorRatio=8
-XX:PermSize=30m
-XX:MaxPermSize=30m
-XX:+PrintGCDetails
*/
public static void main(String[] args) {
new Test().doTest();
}
public void doTest(){
Integer M = new Integer(1024 * 1024 * 1); //单位, 兆(M)
byte[] bytes = new byte[1 * M]; //申请 1M 大小的内存空间
bytes = null; //断开引用链
System.gc(); //通知 GC 收集垃圾
System.out.println();
bytes = new byte[1 * M]; //重新申请 1M 大小的内存空间
bytes = new byte[1 * M]; //再次申请 1M 大小的内存空间
System.gc();
System.out.println();
}-
执行后的信息如下:
[ GC [ PSYoungGen: 1351K -> 288K (18432K) ] 1351K -> 288K (59392K), 0.0012389 secs ] [ Times: user=0.00 sys=0.00, real=0.00 secs ]
[ Full GC (System) [ PSYoungGen: 288K -> 0K (18432K) ] [ PSOldGen: 0K -> 160K (40960K) ] 288K -> 160K (59392K) [ PSPermGen: 2942K -> 2942K (30720K) ], 0.0057649 secs ] [ Times: user=0.00 sys=0.00, real=0.01 secs ]
[ GC [ PSYoungGen: 2703K -> 1056K (18432K) ] 2863K -> 1216K(59392K), 0.0008206 secs ] [ Times: user=0.00 sys=0.00, real=0.00 secs ]
[ Full GC (System) [ PSYoungGen: 1056K -> 0K (18432K) ] [ PSOldGen: 160K -> 1184K (40960K) ] 1216K -> 1184K (59392K) [ PSPermGen: 2951K -> 2951K (30720K) ], 0.0052445 secs ] [ Times: user=0.02 sys=0.00, real=0.01 secs ]
Heap
PSYoungGen total 18432K, used 327K [0x00000000fec00000, 0x0000000100000000, 0x0000000100000000)
eden space 16384K, 2% used [0x00000000fec00000,0x00000000fec51f58,0x00000000ffc00000)
from space 2048K, 0% used [0x00000000ffe00000,0x00000000ffe00000,0x0000000100000000)
to space 2048K, 0% used [0x00000000ffc00000,0x00000000ffc00000,0x00000000ffe00000)
PSOldGen total 40960K, used 1184K [0x00000000fc400000, 0x00000000fec00000, 0x00000000fec00000)
object space 40960K, 2% used [0x00000000fc400000,0x00000000fc5281f8,0x00000000fec00000)
PSPermGen total 30720K, used 2959K [0x00000000fa600000, 0x00000000fc400000, 0x00000000fc400000)
object space 30720K, 9% used [0x00000000fa600000,0x00000000fa8e3ce0,0x00000000fc400000)
从打印结果可以看出,堆中新生代的内存空间为 18432K ( 约 18M ),eden 的内存空间为 16384K ( 约 16M),from / to survivor 的内存空间为 2048K ( 约 2M)。
这里所配置的 Xmn 为 20M,也就是指定了新生代的内存空间为 20M,可是从打印的堆信息来看,新生代怎么就只有 18M 呢? 另外的 2M 哪里去了?
别急,是这样的。新生代 = eden + from + to = 16 + 2 + 2 = 20M,可见新生代的内存空间确实是按 Xmn 参数分配得到的。
而且这里指定了 SurvivorRatio = 8,因此,eden = 8/10 的新生代空间 = 8/10 * 20 = 16M。from = to = 1/10 的新生代空间 = 1/10 * 20 = 2M。
堆信息中新生代的 total 18432K 是这样来的: eden + 1 个 survivor = 16384K + 2048K = 18432K,即约为 18M。
因为 jvm 每次只是用新生代中的 eden 和 一个 survivor,因此新生代实际的可用内存空间大小为所指定的 90%。
因此可以知道,这里新生代的内存空间指的是新生代可用的总的内存空间,而不是指整个新生代的空间大小。
另外,可以看出老年代的内存空间为 40960K ( 约 40M ),堆大小 = 新生代 + 老年代。因此在这里,老年代 = 堆大小 – 新生代 = 60 – 20 = 40M。
最后,这里还指定了 PermSize = 30m,PermGen 即永久代 ( 方法区 ),它还有一个名字,叫非堆,主要用来存储由 jvm 加载的类文件信息、常量、静态变量等。
4. 垃圾回收器
HotSpot虚拟机中的7种垃圾收集器:Serial、ParNew、Parallel Scavenge、Serial Old、Parallel Old、CMS、G1,先介绍一些垃圾收集的相关概念,再介绍它们的主要特点、应用场景、以及一些设置参数和基本运行原理。
4.1 概述
垃圾收集器是垃圾回收算法(标记-清除算法、复制算法、标记-整理算法、火车算法)的具体实现,不同商家、不同版本的JVM所提供的垃圾收集器可能会有很在差别,本文主要介绍HotSpot虚拟机中的垃圾收集器。
4.2 垃圾收集器组合
JDK7/8后,HotSpot虚拟机所有收集器及组合(连线),如下图:
-
图中展示了7种不同分代的收集器
Serial、ParNew、Parallel Scavenge、Serial Old、Parallel Old、CMS、G1;
-
而它们所处区域,则表明其是属于新生代收集器还是老年代收集器:
新生代收集器:Serial、ParNew、Parallel Scavenge;
老年代收集器:Serial Old、Parallel Old、CMS;
整堆收集器:G1;
-
两个收集器间有连线,表明它们可以搭配使用:
Serial/Serial Old、Serial/CMS、ParNew/Serial Old、ParNew/CMS、Parallel Scavenge/Serial Old、Parallel Scavenge/Parallel Old、G1;
-
其中Serial Old作为CMS出现"Concurrent Mode Failure"失败的后备预案(后面介绍);
4.2.1 Serial收集器
Serial(串行)垃圾收集器是最基本、发展历史最悠久的收集器;
JDK1.3.1前是HotSpot新生代收集的唯一选择;
特点
针对新生代;
采用复制算法;
单线程收集;
进行垃圾收集时,必须暂停所有工作线程,直到完成;
即会"Stop The World";
Serial/Serial Old组合收集器运行示意图如下:
应用场景
依然是HotSpot在Client模式下默认的新生代收集器;
也有优于其他收集器的地方:
简单高效(与其他收集器的单线程相比);
对于限定单个CPU的环境来说,Serial收集器没有线程交互(切换)开销,可以获得最高的单线程收集效率;
在用户的桌面应用场景中,可用内存一般不大(几十M至一两百M),可以在较短时间内完成垃圾收集(几十MS至一百多MS),只要不频繁发生,这是可以接受的
设置参数
"-XX:+UseSerialGC":添加该参数来显式的使用串行垃圾收集器;
Stop TheWorld说明
JVM在后台自动发起和自动完成的,在用户不可见的情况下,把用户正常的工作线程全部停掉,即GC停顿;
会带给用户不良的体验;
从JDK1.3到现在,从Serial收集器-》Parallel收集器-》CMS-》G1,用户线程停顿时间不断缩短,但仍然无法完全消除;
更多Serial收集器请参考:
《Memory Management in the Java HotSpot™ Virtual Machine》 4.3节 Serial Collector(内存管理白皮书):http://www.oracle.com/technetwork/java/javase/tech/memorymanagement-whitepaper-1-150020.pdf
《Java Platform, Standard Edition HotSpot Virtual Machine Garbage Collection Tuning Guide》 第5节 Available Collectors(官方的垃圾收集调优指南):http://docs.oracle.com/javase/8/docs/technotes/guides/vm/gctuning/collectors.html#sthref27
4.2.2 ParNew收集器
ParNew垃圾收集器是Serial收集器的多线程版本
特点
除了多线程外,其余的行为、特点和Serial收集器一样;
如Serial收集器可用控制参数、收集算法、Stop The World、内存分配规则、回收策略等;
两个收集器共用了不少代码;
ParNew/Serial Old组合收集器运行示意图如下:
应用场景
在Server模式下,ParNew收集器是一个非常重要的收集器,因为除Serial外,目前只有它能与CMS收集器配合工作
但在单个CPU环境中,不会比Serail收集器有更好的效果,因为存在线程交互开销。
设置参数
"-XX:+UseConcMarkSweepGC"
:指定使用CMS后,会默认使用ParNew作为新生代收集器;
"-XX:+UseParNewGC"
:强制指定使用ParNew;
"-XX:ParallelGCThreads"
:指定垃圾收集的线程数量,ParNew默认开启的收集线程与CPU的数量相同;
为什么只有ParNew能与CMS收集器配合
CMS是HotSpot在JDK1.5推出的第一款真正意义上的并发(Concurrent)收集器,第一次实现了让垃圾收集线程与用户线程(基本上)同时工作;
CMS作为老年代收集器,但却无法与JDK1.4已经存在的新生代收集器Parallel Scavenge配合工作;
因为Parallel Scavenge(以及G1)都没有使用传统的GC收集器代码框架,而另外独立实现;而其余几种收集器则共用了部分的框架代码;
4.2.3 Parallel Scavenge收集器
Parallel Scavenge垃圾收集器因为与吞吐量关系密切,也称为吞吐量收集器(Throughput Collector)。
特点
-
有一些特点与ParNew收集器相似
新生代收集器;
采用复制算法;
多线程收集;
-
主要特点是:它的关注点与其他收集器不同
CMS等收集器的关注点是尽可能地缩短垃圾收集时用户线程的停顿时间;
而Parallel Scavenge收集器的目标则是达一个可控制的吞吐量(Throughput);
关于吞吐量与收集器关注点说明详见本节后面;
应用场景
高吞吐量为目标,即减少垃圾收集时间,让用户代码获得更长的运行时间;
当应用程序运行在具有多个CPU上,对暂停时间没有特别高的要求时,即程序主要在后台进行计算,而不需要与用户进行太多交互;
例如,那些执行批量处理、订单处理、工资支付、科学计算的应用程序;
设置参数
Parallel Scavenge收集器提供两个参数用于精确控制吞吐量:
(A)、"-XX:MaxGCPauseMillis"
控制最大垃圾收集停顿时间,大于0的毫秒数;
MaxGCPauseMillis设置得稍小,停顿时间可能会缩短,但也可能会使得吞吐量下降;
因为可能导致垃圾收集发生得更频繁;
(B)、"-XX:GCTimeRatio"
设置垃圾收集时间占总时间的比率,0<n<100的整数;
GCTimeRatio相当于设置吞吐量大小;
垃圾收集执行时间占应用程序执行时间的比例的计算方法是:
1 / (1 + n)
例如,选项-XX:GCTimeRatio=19,设置了垃圾收集时间占总时间的5%--1/(1+19);
默认值是1%--1/(1+99),即n=99;
垃圾收集所花费的时间是年轻一代和老年代收集的总时间;
如果没有满足吞吐量目标,则增加代的内存大小以尽量增加用户程序运行的时间;
此外,还有一个值得关注的参数:
(C)、"-XX:+UseAdptiveSizePolicy"
开启这个参数后,就不用手工指定一些细节参数,如:
新生代的大小(-Xmn)、Eden与Survivor区的比例(-XX:SurvivorRation)、晋升老年代的对象年龄(-XX:PretenureSizeThreshold)等;
JVM会根据当前系统运行情况收集性能监控信息,动态调整这些参数,以提供最合适的停顿时间或最大的吞吐量,这种调节方式称为GC自适应的调节策略(GC Ergonomiscs);
这是一种值得推荐的方式:
(1)、只需设置好内存数据大小(如"-Xmx"设置最大堆);
(2)、然后使用"-XX:MaxGCPauseMillis"或"-XX:GCTimeRatio"给JVM设置一个优化目标;
(3)、那些具体细节参数的调节就由JVM自适应完成;
这也是Parallel Scavenge收集器与ParNew收集器一个重要区别;
更多目标调优和GC自适应的调节策略说明请参考:
《Memory Management in the Java HotSpot™ Virtual Machine》 5节 Ergonomics -- Automatic Selections and Behavior Tuning:http://www.oracle.com/technetwork/java/javase/tech/memorymanagement-whitepaper-1-150020.pdf
《Java Platform, Standard Edition HotSpot Virtual Machine Garbage Collection Tuning Guide》 第2节 Ergonomics:http://docs.oracle.com/javase/8/docs/technotes/guides/vm/gctuning/ergonomics.html#ergonomics
吞吐量与收集器关注点说明
-
吞吐量(Throughput)
CPU用于运行用户代码的时间与CPU总消耗时间的比值;
即吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间);
高吞吐量即减少垃圾收集时间,让用户代码获得更长的运行时间;
-
垃圾收集器期望的目标(关注点)
(1)、停顿时间
停顿时间越短就适合需要与用户交互的程序;
良好的响应速度能提升用户体验;
(2)、吞吐量
高吞吐量则可以高效率地利用CPU时间,尽快完成运算的任务;
主要适合在后台计算而不需要太多交互的任务;
(3)、覆盖区(Footprint)
在达到前面两个目标的情况下,尽量减少堆的内存空间;
可以获得更好的空间局部性;
更多Parallel Scavenge收集器的信息请参考:
官方的垃圾收集调优指南 第6节:http://docs.oracle.com/javase/8/docs/technotes/guides/vm/gctuning/parallel.html#parallel_collector
4.2.4 Serial Old收集器
Serial Old是 Serial收集器的老年代版本;
特点
针对老年代;
采用"标记-整理"算法(还有压缩,Mark-Sweep-Compact);
单线程收集;
Serial/Serial Old收集器运行示意图如下:
应用场景
主要用于Client模式;
而在Server模式有两大用途:
(A)、在JDK1.5及之前,与Parallel Scavenge收集器搭配使用(JDK1.6有Parallel Old收集器可搭配);
(B)、作为CMS收集器的后备预案,在并发收集发生Concurrent Mode Failure时使用(后面详解);
更多Serial Old收集器信息请参考:
内存管理白皮书 4.3.2节:http://www.oracle.com/technetwork/java/javase/tech/memorymanagement-whitepaper-1-150020.pdf
4.2.5 CMS收集器
并发标记清理(Concurrent Mark Sweep,CMS)收集器也称为并发低停顿收集器(Concurrent Low Pause Collector)或低延迟(low-latency)垃圾收集器;
在前面ParNew收集器曾简单介绍过其特点;
特点
针对老年代;
基于"标记-清除"算法(不进行压缩操作,产生内存碎片);
以获取最短回收停顿时间为目标;
并发收集、低停顿;
需要更多的内存(看后面的缺点);
是HotSpot在JDK1.5推出的第一款真正意义上的并发(Concurrent)收集器;
第一次实现了让垃圾收集线程与用户线程(基本上)同时工作;
应用场景
与用户交互较多的场景;
希望系统停顿时间最短,注重服务的响应速度;
以给用户带来较好的体验;
如常见WEB、B/S系统的服务器上的应用;
设置参数
"-XX:+UseConcMarkSweepGC":指定使用CMS收集器;
CMS收集器运作过程
比前面几种收集器更复杂,可以分为4个步骤:
(A)、初始标记(CMS initial mark)
仅标记一下GC Roots能直接关联到的对象;
速度很快;
但需要"Stop The World";
(B)、并发标记(CMS concurrent mark)
进行GC Roots Tracing的过程;
刚才产生的集合中标记出存活对象;
应用程序也在运行;
并不能保证可以标记出所有的存活对象;
(C)、重新标记(CMS remark)
为了修正并发标记期间因用户程序继续运作而导致标记变动的那一部分对象的标记记录;
需要"Stop The World",且停顿时间比初始标记稍长,但远比并发标记短;
采用多线程并行执行来提升效率;
(D)、并发清除(CMS concurrent sweep)
回收所有的垃圾对象;
整个过程中耗时最长的并发标记和并发清除都可以与用户线程一起工作;
所以总体上说,CMS收集器的内存回收过程与用户线程一起并发执行;
CMS收集器运行示意图如下:
CMS收集器3个明显的缺点
(A)、对CPU资源非常敏感
并发收集虽然不会暂停用户线程,但因为占用一部分CPU资源,还是会导致应用程序变慢,总吞吐量降低。
CMS的默认收集线程数量是=(CPU数量+3)/4;
当CPU数量多于4个,收集线程占用的CPU资源多于25%,对用户程序影响可能较大;不足4个时,影响更大,可能无法接受。
增量式并发收集器:
针对这种情况,曾出现了"增量式并发收集器"(Incremental Concurrent Mark Sweep/i-CMS);
类似使用抢占式来模拟多任务机制的思想,让收集线程和用户线程交替运行,减少收集线程运行时间;
但效果并不理想,JDK1.6后就官方不再提倡用户使用。
更多请参考:
官方的《垃圾收集调优指南》8.8节 Incremental Mode:http://docs.oracle.com/javase/8/docs/technotes/guides/vm/gctuning/cms.html#CJAGIIEJ
《内存管理白皮书》 4.6.3节可以看到一些描述;
(B)、无法处理浮动垃圾,可能出现"Concurrent Mode Failure"失败
(1)、浮动垃圾(Floating Garbage)
在并发清除时,用户线程新产生的垃圾,称为浮动垃圾;
这使得并发清除时需要预留一定的内存空间,不能像其他收集器在老年代几乎填满再进行收集;
也要可以认为CMS所需要的空间比其他垃圾收集器大;
"-XX:CMSInitiatingOccupancyFraction":设置CMS预留内存空间;
JDK1.5默认值为68%;
JDK1.6变为大约92%;
(2)、"Concurrent Mode Failure"失败
如果CMS预留内存空间无法满足程序需要,就会出现一次"Concurrent Mode Failure"失败;
这时JVM启用后备预案:临时启用Serail Old收集器,而导致另一次Full GC的产生;
这样的代价是很大的,所以CMSInitiatingOccupancyFraction不能设置得太大。
(C)、产生大量内存碎片
由于CMS基于"标记-清除"算法,清除后不进行压缩操作;
前面《Java虚拟机垃圾回收(二) 垃圾回收算法》"标记-清除"算法介绍时曾说过:
产生大量不连续的内存碎片会导致分配大内存对象时,无法找到足够的连续内存,从而需要提前触发另一次Full GC动作。
解决方法:
(1)、"-XX:+UseCMSCompactAtFullCollection"
使得CMS出现上面这种情况时不进行Full GC,而开启内存碎片的合并整理过程;
但合并整理过程无法并发,停顿时间会变长;
默认开启(但不会进行,结合下面的CMSFullGCsBeforeCompaction);
(2)、"-XX:+CMSFullGCsBeforeCompaction"
设置执行多少次不压缩的Full GC后,来一次压缩整理;
为减少合并整理过程的停顿时间;
默认为0,也就是说每次都执行Full GC,不会进行压缩整理;
由于空间不再连续,CMS需要使用可用"空闲列表"内存分配方式,这比简单实用"碰撞指针"分配内存消耗大;
更多关于内存分配方式请参考:《Java对象在Java虚拟机中的创建过程》
总体来看,与Parallel Old垃圾收集器相比,CMS减少了执行老年代垃圾收集时应用暂停的时间;
但却增加了新生代垃圾收集时应用暂停的时间、降低了吞吐量而且需要占用更大的堆空间;
更多CMS收集器信息请参考:
《垃圾收集调优指南》 8节 Concurrent Mark Sweep (CMS) Collector:http://docs.oracle.com/javase/8/docs/technotes/guides/vm/gctuning/cms.html#concurrent_mark_sweep_cms_collector
《内存管理白皮书》 4.6节 Concurrent Mark-Sweep (CMS) Collector:http://www.oracle.com/technetwork/java/javase/tech/memorymanagement-whitepaper-1-150020.pdf
4.2.6 Parallel Old收集器
Parallel Old垃圾收集器是Parallel Scavenge收集器的老年代版本;
JDK1.6中才开始提供;
特点
针对老年代;
采用"标记-整理"算法;
多线程收集;
Parallel Scavenge/Parallel Old收集器运行示意图如下:
应用场景
JDK1.6及之后用来代替老年代的Serial Old收集器;
特别是在Server模式,多CPU的情况下;
这样在注重吞吐量以及CPU资源敏感的场景,就有了Parallel Scavenge加Parallel Old收集器的"给力"应用组合;
设置参数
"-XX:+UseParallelOldGC":指定使用Parallel Old收集器;
更多Parallel Old收集器收集过程介绍请参考:
《内存管理白皮书》 4.5.2节: http://www.oracle.com/technetwork/java/javase/tech/memorymanagement-whitepaper-1-150020.pdf
4.2.7 G1收集器
G1(Garbage-First)是JDK7-u4才推出商用的收集器;
特点
(A)、并行与并发
能充分利用多CPU、多核环境下的硬件优势;
可以并行来缩短"Stop The World"停顿时间;
也可以并发让垃圾收集与用户程序同时进行;
(B)、分代收集,收集范围包括新生代和老年代
能独立管理整个GC堆(新生代和老年代),而不需要与其他收集器搭配;
能够采用不同方式处理不同时期的对象;
虽然保留分代概念,但Java堆的内存布局有很大差别;
将整个堆划分为多个大小相等的独立区域(Region);
新生代和老年代不再是物理隔离,它们都是一部分Region(不需要连续)的集合;
更多G1内存布局信息请参考:
《垃圾收集调优指南》 9节:http://docs.oracle.com/javase/8/docs/technotes/guides/vm/gctuning/g1_gc.html#garbage_first_garbage_collection
(C)、结合多种垃圾收集算法,空间整合,不产生碎片
从整体看,是基于标记-整理算法;
从局部(两个Region间)看,是基于复制算法;
这是一种类似火车算法的实现;
都不会产生内存碎片,有利于长时间运行;
(D)、可预测的停顿:低停顿的同时实现高吞吐量
G1除了追求低停顿处,还能建立可预测的停顿时间模型;
可以明确指定M毫秒时间片内,垃圾收集消耗的时间不超过N毫秒;
应用场景
面向服务端应用,针对具有大内存、多处理器的机器;
最主要的应用是为需要低GC延迟,并具有大堆的应用程序提供解决方案;
如:在堆大小约6GB或更大时,可预测的暂停时间可以低于0.5秒;
用来替换掉JDK1.5中的CMS收集器;
在下面的情况时,使用G1可能比CMS好:
(1)、超过50%的Java堆被活动数据占用;
(2)、对象分配频率或年代提升频率变化很大;
(3)、GC停顿时间过长(长于0.5至1秒)。
是否一定采用G1呢?也未必:
如果现在采用的收集器没有出现问题,不用急着去选择G1;
如果应用程序追求低停顿,可以尝试选择G1;
是否代替CMS需要实际场景测试才知道。
设置参数
"-XX:+UseG1GC":指定使用G1收集器;
"-XX:InitiatingHeapOccupancyPercent":当整个Java堆的占用率达到参数值时,开始并发标记阶段;默认为45;
"-XX:MaxGCPauseMillis":为G1设置暂停时间目标,默认值为200毫秒;
"-XX:G1HeapRegionSize":设置每个Region大小,范围1MB到32MB;目标是在最小Java堆时可以拥有约2048个Region;
更多关于G1参数设置请参考:
《垃圾收集调优指南》 10.5节:http://docs.oracle.com/javase/8/docs/technotes/guides/vm/gctuning/g1_gc_tuning.html#important_defaults
为什么G1收集器可以实现可预测的停顿
G1可以建立可预测的停顿时间模型,是因为:
可以有计划地避免在Java堆的进行全区域的垃圾收集;
G1跟踪各个Region获得其收集价值大小,在后台维护一个优先列表;
每次根据允许的收集时间,优先回收价值最大的Region(名称Garbage-First的由来);
这就保证了在有限的时间内可以获取尽可能高的收集效率;
一个对象被不同区域引用的问题
一个Region不可能是孤立的,一个Region中的对象可能被其他任意Region中对象引用,判断对象存活时,是否需要扫描整个Java堆才能保证准确?
在其他的分代收集器,也存在这样的问题(而G1更突出):
回收新生代也不得不同时扫描老年代?
这样的话会降低Minor GC的效率;
解决方法:
无论G1还是其他分代收集器,JVM都是使用Remembered Set来避免全局扫描:
每个Region都有一个对应的Remembered Set;
每次Reference类型数据写操作时,都会产生一个Write Barrier暂时中断操作;
然后检查将要写入的引用指向的对象是否和该Reference类型数据在不同的Region(其他收集器:检查老年代对象是否引用了新生代对象);
如果不同,通过CardTable把相关引用信息记录到引用指向对象的所在Region对应的Remembered Set中;
当进行垃圾收集时,在GC根节点的枚举范围加入Remembered Set;
就可以保证不进行全局扫描,也不会有遗漏。
G1收集器运作过程
不计算维护Remembered Set的操作,可以分为4个步骤(与CMS较为相似)。
(A)、初始标记(Initial Marking)
仅标记一下GC Roots能直接关联到的对象;
且修改TAMS(Next Top at Mark Start),让下一阶段并发运行时,用户程序能在正确可用的Region中创建新对象;
需要"Stop The World",但速度很快;
(B)、并发标记(Concurrent Marking)
进行GC Roots Tracing的过程;
刚才产生的集合中标记出存活对象;
耗时较长,但应用程序也在运行;
并不能保证可以标记出所有的存活对象;
(C)、最终标记(Final Marking)
为了修正并发标记期间因用户程序继续运作而导致标记变动的那一部分对象的标记记录;
上一阶段对象的变化记录在线程的Remembered Set Log;
这里把Remembered Set Log合并到Remembered Set中;
需要"Stop The World",且停顿时间比初始标记稍长,但远比并发标记短;
采用多线程并行执行来提升效率;
(D)、筛选回收(Live Data Counting and Evacuation)
首先排序各个Region的回收价值和成本;
然后根据用户期望的GC停顿时间来制定回收计划;
最后按计划回收一些价值高的Region中垃圾对象;
回收时采用"复制"算法,从一个或多个Region复制存活对象到堆上的另一个空的Region,并且在此过程中压缩和释放内存;
可以并发进行,降低停顿时间,并增加吞吐量;
G1收集器运行示意图如下:
更多G1收集器信息请参考:
《垃圾收集调优指南》 9节 Garbage-First Garbage Collector:http://docs.oracle.com/javase/8/docs/technotes/guides/vm/gctuning/g1_gc.html#garbage_first_garbage_collection
《垃圾收集调优指南》 10节 Garbage-First Garbage Collector Tuning:http://docs.oracle.com/javase/8/docs/technotes/guides/vm/gctuning/g1_gc_tuning.html#g1_gc_tuning
4.3 新生代、老年代和永生代
4.3.1 为什么JVM会有年轻代
我们先来屡屡,为什么需要把堆分代?不分代不能完成他所做的事情么?其实不分代完全可以,分代的唯一理由就是优化GC性能。你先想想,如果没有分代,那我们所有的对象都在一块,GC的时候我们要找到哪些对象没用,这样就会对堆的所有区域进行扫描。而我们的很多对象都是朝生夕死的,如果分代的话,我们把新创建的对象放到某一地方,当GC的时候先把这块存“朝生夕死”对象的区域进行回收,这样就会腾出很大的空间出来。
4.3.2 JVM年轻代中的GC
HotSpot JVM把年轻代分为了三部分:1个Eden区和2个Survivor区(分别叫from和to)。默认比例为8:1,为啥默认会是这个比例,接下来我们会聊到。一般情况下,新创建的对象都会被分配到Eden区(一些大对象特殊处理),这些对象经过第一次Minor GC后,如果仍然存活,将会被移到Survivor区。对象在Survivor区中每熬过一次Minor GC,年龄就会增加1岁,当它的年龄增加到一定程度时,就会被移动到年老代中。
因为年轻代中的对象基本都是朝生夕死的(80%以上),所以在年轻代的垃圾回收算法使用的是复制算法,复制算法的基本思想就是将内存分为两块,每次只用其中一块,当这一块内存用完,就将还活着的对象复制到另外一块上面。复制算法不会产生内存碎片。
在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”区被填满之后,会将所有对象移动到年老代中。
4.3.3 JVM一个对象的这一辈子
我是一个普通的Java对象,我出生在Eden区,在Eden区我还看到和我长的很像的小兄弟,我们在Eden区中玩了挺长时间。有一天Eden区中的人实在是太多了,我就被迫去了Survivor区的“From”区,自从去了Survivor区,我就开始漂了,有时候在Survivor的“From”区,有时候在Survivor的“To”区,居无定所。直到我18岁的时候,爸爸说我成人了,该去社会上闯闯了。于是我就去了老年代那边,老年代里,人很多,并且年龄都挺大的,我在这里也认识了很多人。在老年代里,我生活了20年(每次GC加一岁),然后被回收。
4.3.4 有关年轻代的JVM参数
1)-XX:NewSize和-XX:MaxNewSize
用于设置年轻代的大小,建议设为整个堆大小的1/3或者1/4,两个值设为一样大。
2)-XX:SurvivorRatio
用于设置Eden和其中一个Survivor的比值,这个值也比较重要。
3)-XX:+PrintTenuringDistribution
这个参数用于显示每次Minor GC时Survivor区中各个年龄段的对象的大小。
4).-XX:InitialTenuringThreshol和-XX:MaxTenuringThreshold
用于设置晋升到老年代的对象年龄的最小值和最大值,每个对象在坚持过一次Minor GC之后,年龄就加1。
4.3.5 jvm 中的 “永生代”
“方法区” 主要存储的信息包括:常量信息,类信息,方法信息,而且是全局共享的(多线程共享);
jvm 有多种实现方式(不同的厂商); 并不是所有的jvm 都有永生代的概念;
通常情况下, 很多人把 “方法区” 和“永生代” 对等; 换句话说,是利用“永生代”
去实现“方法区”, 这样可能导致OOM (因为“永生代”的大小是可以通过-XX:PermSize -XX:MaxPermSize)
设置的; 但是在J9 和JRockit jvm中,方法区使用的内存上限是4G(32位系统可表示的最大范围),不
会存在该问题。
如果利用“永生代”实现“方法区”的垃圾收集, 主要是收集常量池和对类型进行卸载; 通常情况下,这个区域的
收集效率比较低。
一: 常量的回收比较容易
二: 类信息的回收需要满足的条件:
1 . 该类所有的实例已经被回收,JVM中没有任何类的实例;
\2. 加载该类的ClassLoader被回收;
\3. 该类对应的java.lang.class没有地方引用
Note: 可以使用-verbose:class以及-XX:TraceClassLoading和-XX:TraceClassUnLoading来查看类的加载和卸载情况。
4.4 并发垃圾收集和并行垃圾收集的区别
-
并行(Parallel)
指多条垃圾收集线程并行工作,但此时用户线程仍然处于等待状态;
如ParNew、Parallel Scavenge、Parallel Old;
-
并发(Concurrent)
指用户线程与垃圾收集线程同时执行(但不一定是并行的,可能会交替执行);
用户程序在继续运行,而垃圾收集程序线程运行于另一个CPU上;
如CMS、G1(也有并行);
4.5 Minor GC和Full GC的区别
-
Minor GC
又称新生代GC,指发生在新生代的垃圾收集动作;
因为Java对象大多是朝生夕灭,所以Minor GC非常频繁,一般回收速度也比较快;
-
Full GC
又称Major GC或老年代GC,指发生在老年代的GC;
出现Full GC经常会伴随至少一次的Minor GC(不是绝对,Parallel Sacvenge收集器就可以选择设置Major GC策略);
Major GC速度一般比Minor GC慢10倍以上;
下面将介绍这些收集器的特性、基本原理和使用场景,并重点分析CMS和G1这两款相对复杂的收集器;但需要明确一个观点:
没有最好的收集器,更没有万能的收集;
选择的只能是适合具体应用场景的收集器。
5 什么是内存泄漏?
内存泄漏的定义:对象已经没有被应用程序使用,但是垃圾回收器没办法移除它们,因为还在被引用着。
要想理解这个定义,我们需要先了解一下对象在内存中的状态。下面的这张图就解释了什么是无用对象以及什么是未被引用对象。
上面图中可以看出,里面有被引用对象和未被引用对象。未被引用对象会被垃圾回收器回收,而被引用的对象却不会。未被引用的对象当然是不再被使用的对象,因为没有对象再引用它。然而无用对象却不全是未被引用对象。其中还有被引用的。就是这种情况导致了内存泄漏。
5.1 为什么会发生内存泄漏?
来先看看下面的例子,为什么会发生内存泄漏。下面这个例子中,A对象引用B对象,A对象的生命周期(t1-t4)比B对象的生命周期(t2-t3)长的多。当B对象没有被应用程序使用之后,A对象仍然在引用着B对象。这样,垃圾回收器就没办法将B对象从内存中移除,从而导致内存问题,因为如果A引用更多这样的对象,那将有更多的未被引用对象存在,并消耗内存空间。
B对象也可能会持有许多其他的对象,那这些对象同样也不会被垃圾回收器回收。所有这些没在使用的对象将持续的消耗之前分配的内存空间。
5.2 如何防止内存泄漏的发生?
下面是几条容易上手的建议,来帮助你防止内存泄漏的发生。
- 特别注意一些像HashMap、ArrayList的集合对象,它们经常会引发内存泄漏。当它们被声明为static时,它们的生命周期就会和应用程序一样长。
- 特别注意事件监听和回调函数。当一个监听器在使用的时候被注册,但不再使用之后却未被反注册。
- “如果一个类自己管理内存,那开发人员就得小心内存泄漏问题了。” 通常一些成员变量引用其他对象,初始化的时候需要置空。
5.3 如何用Java创建内存泄漏
为了更好地理解Java的内存泄漏,我们可以用Java创建内存泄漏。
我们知道Java有Java垃圾收集机制GC但是有些对象垃圾回收器没办法移除它们,因为他们还在被引用着,这就造成了Java的内存泄漏。为了更好地理解内存泄漏,我们来用Java创建内存泄漏.
以下是在Java中创建真正的内存泄漏(通过运行代码无法访问但仍保存在内存中的对象)的方法:
- 应用程序创建一个长时间运行的线程(或使用线程池来更快地泄漏)。
- 线程通过一个(可选的自定义)ClassLoader加载一个类。
- 该类分配一大块内存(例如new byte[1000000]),在静态字段中存储对它的强引用,然后将引用存储在ThreadLocal中。分配额外的内存是可选的(泄露Class实例就足够了),但它会使泄漏工作更快。
- 线程清除对自定义类或从其加载的ClassLoader的所有引用。
- 重复。
这是可行的,因为ThreadLocal保留对该对象的引用,该引用保持对其Class的引用,该引用继而保持对其ClassLoader的引用。ClassLoader反过来保持对它所加载的所有类的引用。
(在许多JVM实现中,特别是在Java 7之前,这种情况更糟糕,因为Classes和ClassLoader被直接分配到了permgen中,并且根本没有GC'd。但是,无论JVM如何处理类卸载,ThreadLocal仍然会阻止类对象被回收。)
这种模式的一个变种就是为什么应用程序容器(如Tomcat)会像筛子一样泄漏内存,如果您经常以任何方式重新部署恰巧使用ThreadLocals的应用程序。(由于应用程序容器使用了所描述的线程,并且每次重新部署应用程序时都会使用新的ClassLoader。)
import java.io.IOException;
import java.net.URLClassLoader;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.nio.file.Path;
/**
* Example demonstrating a ClassLoader leak.
*
* To see it in action, copy this file to a temp directory somewhere,
* and then run:
* {@code
* javac ClassLoaderLeakExample.java
* java -cp . ClassLoaderLeakExample
* }
*
* And watch the memory grow! On my system, using JDK 1.8.0_25, I start
* getting OutofMemoryErrors within just a few seconds.
*
* This class is implemented using some Java 8 features, mainly for
* convenience in doing I/O. The same basic mechanism works in any version
* of Java since 1.2.
*/
public final class ClassLoaderLeakExample {
static volatile boolean running = true;
public static void main(String[] args) throws Exception {
Thread thread = new LongRunningThread();
try {
thread.start();
System.out.println("Running, press any key to stop.");
System.in.read();
} finally {
running = false;
thread.join();
}
}
/**
* Implementation of the thread. It just calls {@link #loadAndDiscard()}
* in a loop.
*/
static final class LongRunningThread extends Thread {
@Override
public void run() {
while(running) {
try {
loadAndDiscard();
} catch (Throwable ex) {
ex.printStackTrace();
}
try {
Thread.sleep(100);
} catch (InterruptedException ex) {
System.out.println("Caught InterruptedException, shutting down.");
running = false;
}
}
}
}
/**
* A simple ClassLoader implementation that is only able to load one
* class, the LoadedInChildClassLoader class. We have to jump through
* some hoops here because we explicitly want to ensure we get a new
* class each time (instead of reusing the class loaded by the system
* class loader). If this child class were in a JAR file that wasn't
* part of the system classpath, we wouldn't need this mechanism.
*/
static final class ChildOnlyClassLoader extends ClassLoader {
ChildOnlyClassLoader() {
super(ClassLoaderLeakExample.class.getClassLoader());
}
@Override
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException {
if (!LoadedInChildClassLoader.class.getName().equals(name)) {
return super.loadClass(name, resolve);
}
try {
Path path = Paths.get(LoadedInChildClassLoader.class.getName()
+ ".class");
byte[] classBytes = Files.readAllBytes(path);
Class<?> c = defineClass(name, classBytes, 0, classBytes.length);
if (resolve) {
resolveClass(c);
}
return c;
} catch (IOException ex) {
throw new ClassNotFoundException("Could not load " + name, ex);
}
}
}
/**
* Helper method that constructs a new ClassLoader, loads a single class,
* and then discards any reference to them. Theoretically, there should
* be no GC impact, since no references can escape this method! But in
* practice this will leak memory like a sieve.
*/
static void loadAndDiscard() throws Exception {
ClassLoader childClassLoader = new ChildOnlyClassLoader();
Class<?> childClass = Class.forName(
LoadedInChildClassLoader.class.getName(), true, childClassLoader);
childClass.newInstance();
// When this method returns, there will be no way to reference
// childClassLoader or childClass at all, but they will still be
// rooted for GC purposes!
}
/**
* An innocuous-looking class. Doesn't do anything interesting.
*/
public static final class LoadedInChildClassLoader {
// Grab a bunch of bytes. This isn't necessary for the leak, it just
// makes the effect visible more quickly.
// Note that we're really leaking these bytes, since we're effectively
// creating a new instance of this static final field on each iteration!
static final byte[] moreBytesToLeak = new byte[1024 * 1024 * 10];
private static final ThreadLocal<LoadedInChildClassLoader> threadLocal
= new ThreadLocal<>();
public LoadedInChildClassLoader() {
// Stash a reference to this class in the ThreadLocal
threadLocal.set(this);
}
}
}