GC算法与内存分配策略
一、运行时数据区域
二、可达性分析算法
基本思路:通过一系列GC Roots(通常是一组特别管理的指向引用类型对象的指针)对象作为七点,从这些节点往下搜索,搜索经过的路径成为引用链。当一个对象到GC Roots不可达时,则证明此对象不可用,即可回收。
在Java中,可作为GC Roots的对象包括以下几种:(GC管理的的区域是Java堆,虚拟机栈、方法区和本地方法栈不被GC管理,因此选用这些区域内引用的对象作为GC Roots是不会被GC回收的)
- 虚拟机栈(栈帧中的局部变量表)中引用的对象
- 方法去中类静态属性引用的对象
- 方法区中常量引用的对象
- 本地方法栈中JNI(Native方法)引用的对象
在JDK 1.8中,JVM摒弃了永久代,用元空间来作为方法区的实现。
元空间的内存管理由元空间虚拟机完成。在元空间中,类和其元数据的生命周期和对应的类加载器相同。只要类加载器存活,加载的类的元数据也是存活的,因而不会被回收掉。 准确的来说,每一个类加载器的存储区域都称作一个元空间,所有的元空间合在一起就是前面说的元空间。当一个类加载器被垃圾回收器标记为不再存活,其对应的元空间会被回收。
三、垃圾收集算法
1.标记-清除算法
算法思想:首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。
不足:
- 空间问题:标记清除后会产生大量不连续的内存碎片,碎片太多可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不出发另一次垃圾收集操作
- 效率问题:因为内存碎片的存在,操作会变得更加费时,因为查找下一个可用空闲块已经不再是一个简单操作
2.复制算法
算法思想:将可用内存按容量分成大小相等的两块,每次只使用其中一块。当这一块内存用完,就将还存活着的对象复制到另一块上面,然后再把已使用过的内存空间一次清理掉。
这样做使得每次都是对整个半区进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。只是这种算法的代价是将内存缩小为原来的一半,代价可能过高了。
Minor GC
现在的商业虚拟机都使用复制算法来回收新生代。新生代的GC又叫“Minor GC”。
事实上,新生代将内存分为一块较大的Eden空间和两块较小的Survivor空间(From Survivor和To Survivor),每次Minor GC都使用Eden和From Survivor,当回收时,将Eden和From Survivor中还存活着的对象都一次性地复制到另外一块To Survivor空间上,最后清理掉Eden和刚使用的Survivor空间。一次Minor GC结束的时候,Eden空间和From Survivor空间都是空的,而To Survivor空间里面存储着存活的对象。在下次MinorGC的时候,两个Survivor空间交换他们的标签。因此,在MinorGC结束的时候,Eden空间是空的,两个Survivor空间中的一个是空的,而另一个存储着存活的对象。
HotSpot虚拟机默认的Eden : Survivor的比例是8 : 1,由于一共有两块Survivor,所以每次新生代中可用内存空间为整个新生代容量的90%(80%+10%),只有10%的容量会被“浪费”。
当Survivor空间不够用时,需要依赖老年代内存进行分配担保(Handle Promotion)。如果另外一块Survivor上没有足够空间存放上一次新生代收集下来的存活对象,这些对象将直接通过分配担保机制进入老年代。
3.标记-整理算法
算法思想: 此算法的标记过程与标记-清除算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉边界以外的内存。
4.分代收集算法
算法思想:根据对象存活周期的不同将内存划分为几块,一般是把Java堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适合的收集算法。对于新生代,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集;对于老年代,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。
四、内存分配策略
1.对象优先在Eden分配
大多数情况下,对象在新生代Eden区分配。当Eden区没有足够空间进行分配时,虚拟机将发起一次Minor GC。
2.大对象直接进入老年代
大对象是需要大量连续内存空间的Java对象,比如很长的字符串和数组。大对象容易导致内存还有不少空间时就提前出发GC以获取足够的连续空间安置它们。虚拟机提供了一个-XX:PretenureSizeThreshold参数,令大于该设置值的对象直接在老年代分配。目的是避免在Eden区及两个Survivor区之间发生大量的内存复制(新生代采用复制算法回收内存)。
3.长期存活的对象将进入老年代
虚拟机采用了分代收集的思想来管理内存,那么内存回收时就必须能识别哪些对象放在新生代,哪些对象放在老年代中。
为了做到这点,虚拟机给每个对象定义了一个对象年龄(Age)计数器。如果对象在Eden出生并经过第一次Minor GC后仍然存活,并且能被Survivor容纳的话,将被移动到Survivor空间中,并且对象年龄设为1。对象在Survivor区中每经过一次Minor GC,年龄就增加1岁,当它的年龄增加到一定程度(默认为15岁),将会被晋升到老年代中。对象晋升老年代的年龄阈值,可以通过参数-XX:MaxTenuringThreshold设置。
4.动态对象年龄判定
如果在Survivor空间中所有相同年龄的对象的大小总和大于Survivor空间的一半,则年龄大于等于该年龄的对象就可以直接进入老年代,不必等到MaxTenuringThreshold中要求的年龄。
5.空间分配担保
在发生Minor GC前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果条件成立,那么Minor GC可以确保是安全的;如果不成立,则虚拟机会查看HandlePromotionFailure设置值是否允许担保失败。
如果允许担保失败,那么会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行一次Minor GC,尽管是有风险的;如果小于,或者HandlePromotionFailure设置不允许冒险,那这时也要改为进行一次Full GC。
五、Full GC的触发条件
1.调用System.gc()
此方法的调用是建议JVM进行Full GC,虽然只是建议而非一定,但很多情况下它会触发 Full GC,从而增加Full GC的频率,即增加了间歇性停顿的次数。因此强烈建议能不使用此方法就不要使用,让虚拟机自己去管理它的内存,可通过-XX:+ DisableExplicitGC来禁止RMI调用该方法。
2.老年代空间不足
老年代空间不足的常见场景为前面所讲的大对象直接进入老年代、长期存活的对象进入老年代等,当执行Full GC后空间仍然不足,则抛出如下错误:Java.lang.OutOfMemoryError:Java heap space,为避免以上两种状况引起的Full GC,调优时应尽量做到让对象在Minor GC阶段被回收、让对象在新生代多存活一段时间及不要创建过大的对象及数组。
3.空间分配担保失败
使用复制算法的Minor GC需要老年代的内存空间做担保,如果出现了HandlePromotionFailure担保失败,则会出发Full GC。
4.Concurrent Mode Failure
执行CMS GC的过程中同时有对象要放入老年代,而此时老年代空间不足(有时候“空间不足”是CMS GC时当前的浮动垃圾过多导致暂时性的空间不足触发Full GC),便会报Concurrent Mode Failure错误,并触发Full GC。
7种垃圾收集器
一、新生代收集器
- Serial收集器
- ParNew收集器
- Parallel Scavenge收集器
二、老年代收集器
- Serial Old收集器
- Parallel Old收集器
- CMS收集器
三、G1收集器
特点:
- 并行与并发
- 分代收集
- 空间整合
- 可预测的停顿
四、总结
虚拟机类加载机制
一、类加载过程概览
类从加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期包括以下7个阶段:
- 加载
- 验证
- 准备
- 解析
- 初始化
- 使用
- 卸载
1-5过程为类加载的全过程。2-4过程统称为连接。
二、类初始化的时机
JVM规范严格地规定了有且只有下列几种情况必须对类进行初始化(1-3过程需要在此之前开始):
- 遇到new、getstatic、putstatic、invokestatic这4条字节码指令时,如果类没有进行过初始化,则必须先触发其初始化。比如使用new关键字实例化对象的时候;读取或设置一个类的静态字段(被final修饰、已在编译期把结果放入常量池的静态字段除外)的时候;调用一个类的静态方法的时候
- 使用java.lang.reflect包的方法对类进行反射调用的时候,如果类没有进行初始化,则需要先触发其初始化
- 当初始化一个类的时候,如果其父类没有进行过初始化,则需要先触发其父类的初始化
- 当虚拟机启动时,用户需要制定一个要执行的主类(包含main方法的那个类),虚拟机会先初始化这个主类
以上场景中的行为称为对一个类进行主动引用。除此之外,所有引用类的方式都不会被触发初始化,称为被动引用。
三、类加载过程详解
1.加载
加载是类加载过程的一个阶段。虚拟机规范规定了在加载阶段,JVM需要完成以下3件事:
- 通过一个类的全限定名来获取定义此类的二进制字节流
- 将这个字节流所代表的的静态存储结构转化为方法区的运行时存储结构
- 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口
2.验证
验证是连接阶段的第一步,这一阶段的目的是确保输入的Class文件的字节流能正确地解析并存储于方法区内,格式上符合描述一个Java类型信息的要求,且不会危害虚拟机自身的安全。
从整体上看,验证阶段大致上会完成下面四个阶段的验证动作:文件格式验证、元数据验证、字节码验证、符号引用验证。
3.准备
准备阶段的主要任务是以下2点:
- 为类变量分配内存
- 设置类变量初始值
这些变量所使用的内存都将在方法区中分配。
首先,在准备阶段进行内存分配的仅包括类变量(被static修饰的变量),而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在Java堆中。
其次,这里所说的初始值“通常情况”下是数据类型的零值,假设一个类变量的定义为:
public static int value = 123;
则变量value在准备阶段过后的初始值为0而不是123,因为这时候尚未开始执行任何Java方法,而把value赋值为123的putstatic指令是程序被编译后,存放于类构造器<clinit>()方法之中,所以把value赋值为123的动作在初始化阶段才会执行。 值得注意的是,如果类字段的字段属性中存在ConstantValue属性,那在准备阶段变量value就会被初始化为ConstantValue属性所指定的值,假设上面类变量value的定义变为:
public static final int value = 123;
编译时Javac将会为value生成ConstantValue属性,在准备阶段虚拟机就会根据ConstantValue的设置将value赋值为123。
4.解析
解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。符号引用和直接引用的关联如下:
- 符号引用: 符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。
- 直接引用: 直接引用可以是直接目标的指针、相对偏移量或是一个能间接定位到目标的句柄。
5.初始化
在之前的阶段,除了在加载阶段用户应用程序可以通过自定义类加载器参与外,其它动作完全由虚拟机主导和控制。直到初始化阶段,才真正开始执行类中定义的Java程序代码(或者说是字节码)。
<clinit>()方法:
- 该方法是由编译器自动收集类中所有类变量的赋值动作和静态语句块(static{} 块)中的语句合并产生的,编译器收集的顺序由语句在源文件中出现的顺序决定。特别注意的是,静态语句块只能访问到定义在它之前的类变量,定义在它之后的类变量只能赋值,不能访问。
- 该方法与类的构造函数不同,不需要显式地调用父类方法。虚拟机会自动保证在子类的<clinit>()方法运行之前,父类的<clinit>()方法已经执行结束。因此虚拟机中第一个执行<clinit>()方法的类肯定是java.lang.Object
- 由于父类的<clinit>()方法先执行,也就意味着父类中定义的静态语句块要优于子类的变量赋值操作。
- 该方法对于类或接口不是必须的,如果一个类中不包含静态语句块,也没有对类变量的赋值从操作,编译器可以不为该类生成<clinit>()方法。
- 接口中不可以使用静态语句块,但仍然有类变量初始化的赋值操作,因此接口与类都会生成<clinit>()方法。但接口与类不同的是,执行接口的<clinit>()方法不需要先执行父接口的<clinit>()方法。只有当父接口中定义的变量使用时,父接口才会初始化。
- 虚拟机会保证一个类的<clinit>()方法在多线程环境下被正确地加锁和同步,如果多个线程同时初始化一个类,只会有一个线程执行这个类的<clinit>()方法中有耗时的操作,就可能造成进程阻塞
类加载器
虚拟机设计团队把类加载阶段中的“通过一个类的全限定名来获取描述此类的二进制字节流”这个动作放到Java虚拟机外部去实现,以便让应用程序去决定如何获取所需要的类。实现这个动作的代码模块成为“类加载器”。
Java虚拟机使用Java类的方式如下:
- Java源程序(.java文件)在经过Java编译器编译后就被转换成字节码(.class文件)
- 类加载器负责读取Java字节代码,并转换成java.lang.Class类的一个实例。 每个这样的实例用来表示一个 Java 类。通过此实例的newInstance()方法就可以创建出该类的一个对象。
一、类与类加载器
类加载器虽然只用于实现类的加载动作,但它在Java程序起到的作用却远大于类加载阶段。对于任意一个类,都需要由加载它的类加载器和这个类本身一同确立其在Java虚拟机中的唯一性,每一个类加载器,都拥有一个独立的类名称空间。通俗而言:比较两个类是否“相等”(这里所指的“相等”,包括类的Class对象的equals()方法、isAssignableFrom()方法、isInstance()方法的返回结果,也包括使用instanceof()关键字对做对象所属关系判定等情况),只有在这两个类时由同一个类加载器加载的前提下才有意义,否则,即使这两个类来源于同一个Class文件,被同一个虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相等。
JVM 运行并不是一次性加载所需要的全部类的,它是按需加载,也就是延迟加载。程序在运行的过程中会逐渐遇到很多不认识的新类,这时候就会调用 ClassLoader 来加载这些类。加载完成后就会将 Class 对象存在 ClassLoader 里面,下次就不需要重新加载了。
比如你在调用某个类的静态方法时,首先这个类肯定是需要被加载的,但是并不会触及这个类的实例字段,那么实例字段的类别 Class 就可以暂时不必去加载,但是它可能会加载静态字段相关的类别,因为静态方法会访问静态字段。而实例字段的类别需要等到你实例化对象的时候才可能会加载。
二、双亲委派模型
从Java开发人员的角度看,类加载器可以划分为以下几类:
- 启动类加载器(BootstrapClassLoader):负责加载JVM运行时核心类。这些类位于JAVA_HOME/lib/rt.jar文件中,我们常用内置库 java.xxx.* 都在里面,比如 java.util.*、java.io.*、java.nio.*、java.lang.* 等等。这个 ClassLoader 比较特殊,它是由C代码实现的,我们将它称之为根加载器。
- 扩展类加载器(ExtensionClassLoader):负责加载JVM扩展类。比如swing系列、内置的js引擎、xml解析器等等,这些库名通常以javax开头,它们的jar包位于JAVA_HOME/lib/ext/*.jar中,有很多jar包。
- 应用程序加载器(AppClassLoader):直接面向用户的加载器。它会加载Classpath环境变量里定义的路径中的jar包和目录。我们自己编写的代码以及使用的第三方jar包通常都是由它来加载的。
那些位于网络上静态文件服务器提供的 jar 包和 class文件,jdk 内置了一个 URLClassLoader,用户只需要传递规范的网络路径给构造器,就可以使用 URLClassLoader 来加载远程类库了。URLClassLoader 不但可以加载远程类库,还可以加载本地路径的类库,取决于构造器中不同的地址形式。ExtensionClassLoader 和 AppClassLoader 都是 URLClassLoader 的子类,它们都是从本地文件系统里加载类库。
AppClassLoader 可以由 ClassLoader 类提供的静态方法 getSystemClassLoader() 得到,它就是我们所说的「系统类加载器」,我们用户平时编写的类代码通常都是由它加载的。当我们的 main 方法执行的时候,这第一个用户类的加载器就是 AppClassLoader。
下图显示的类加载器之间的层次关系,称为类加载器的双亲委派模型。 该模型要求除了顶层的启动类加载器外,其余的类加载器都应有自己的父
类加载器,这里类加载器之间的父子关系一般通过组合关系来实现,而不是通过继承的关系实现。
工作过程:
如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载,而是把这个请求委派给父类加载器,每一个层次的加载器都是如此,依次递归,因此所有的加载请求最终都应该传送到顶层的启动类加载器中,只有当父加载器反馈自己无法完成此加载请求(它搜索范围中没有找到所需类)时,子加载器才会尝试自己加载。
优点:
使用双亲委派模型来组织类加载器之间的关系,使得Java类随着它的类加载器一起具备了一种带有优先级的层次关系。例如类java.lang.Object,它存放再rt.jar中,无论哪个类加载器要加载这个类,最终都是委派给处于模型最顶端的启动类加载器进行加载,因此Object类在程序的各种类加载器环境中都是同一个类。
相反,如果没有双亲委派模型,由各个类加载器自行加载的话,如果用户编写了一个称为java.lang.Object的类,并放在程序的ClassPath中,那系统中将会出现多个不同的Object类,程序将变得一片混乱。如果开发者尝试编写一个与rt.jar类库中已有类重名的Java类,将会发现可以正常编译,但是永远无法被加载运行。
三、自定义加载器
ClassLoader 里面有三个重要的方法 loadClass()、findClass() 和 defineClass()。
loadClass() 方法是加载目标类的入口,它首先会查找当前 ClassLoader 以及它的双亲里面是否已经加载了目标类,如果没有找到就会让双亲尝试加载,如果双亲都加载不了,就会调用 findClass() 让自定义加载器自己来加载目标类。ClassLoader 的 findClass() 方法是需要子类来覆盖的,不同的加载器将使用不同的逻辑来获取目标类的字节码。拿到这个字节码之后再调用 defineClass() 方法将字节码转换成 Class 对象。
自定义类加载器不易破坏双亲委派规则,不要轻易覆盖 loadClass 方法。否则可能会导致自定义加载器无法加载内置的核心类库。在使用自定义加载器时,要明确好它的父加载器是谁,将父加载器通过子类的构造器传入。如果父类加载器是 null,那就表示父加载器是「根加载器」。