深入学习Java虚拟机——垃圾收集器与内存分配策略

  1. 云栖社区>
  2. 博客>
  3. 正文

深入学习Java虚拟机——垃圾收集器与内存分配策略

江左煤郎 2018-08-27 15:44:19 浏览4594
展开阅读全文

垃圾回收操作的步骤:首先确定对象是否死亡,然后进行回收

1. 如何判断对象是否死亡

1.1 引用计数法

    1.引用计数法:给对象添加一个引用计数器,每当有一个地方引用它时,计数器值就加1,当引用失效时就减1,任何时刻,计数器为0的对象是不可能在被使用的。

    2.优缺点:优点是实现简单,判定效率高;缺点是很难解决对象间相互循环引用的问题,所以如今的主流Java虚拟机都没使用该方法进行管理内存。比如以下代码

/**
 * 
 * @ClassName:ReferenceCountGC
 * @Description:引用计数法无法解决的对象间互相循环引用的问题
 * @author: 
 * @date:2018年7月29日
 */
public class ReferenceCountGC {
	public Object obj;
	public static void main(String[] args) {
		ReferenceCountGC a=new ReferenceCountGC();
		ReferenceCountGC b=new ReferenceCountGC();
		a.obj=b;
		b.obj=a;
		a=null;
		b=null;
		
		//假设此处进行GC,若虚拟机采用引用计数法,则无法回收a,b两个对象
		System.gc();
	}
}

1.2 可达性分析算法(根追踪算法)

    1. 可达性分析算法:通过一系列的“GC Roots”的对象为起点,从这些节点开始往下搜索,搜索所走过的路径称为引用链,当一个对象到GC Roots没有任何引用链相连,则证明该对象时不可用的。以下图为例,

对象obj1,2,3,4到GC Roots都是可达的,所以这四个对象都是不可回收的,而obj5,6,7虽然有引用关系,但无法到达GC Roots,所以他们将会被判定为可回收对象。

    2. 可作为GC Roots的对象包括以下几种:

(1)虚拟机栈中的引用的对象

(2)方法区中静态属性引用的对象

(3)方法区中常量引用的对象

(4)本地方法栈引用的对象

1.3 再谈引用

    1. 引用分类:

(1)强引用:类似于 Object obj=new Object() 这类的引用,只要强引用还存在,垃圾收集器永远不会回收该类引用的对象。

(2)软引用:用来描述一些好有用但并非必需的对象,在系统将要发生内存溢出异常之前,会把这些对象进行回收,如果这次回收之后还没有足够的内存就会发生内存溢出异常。JDK提供了SoftReference类来实现软引用。

(3)弱引用:用来描述非必需对象,但它的强度比弱引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生之前,无论内存是否足够,都会回收掉只被弱引用关联的对象。JDK使用WeakReference类来实现弱引用。

(4)虚引用:他是最弱的一种引用关系,一个对象是否有虚引用的存在,完全不会对其生存时间产生影响,也无法通过一个虚引用来取得一个对象实例,一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。Jdk中使用PhantomReference类来实现虚引用。

1.4 对象是否死亡

    1. 即使在可达性算法分析中不可达的对象,也并不是直接被判定为死亡,而是进行一次标记,要真正确定一个对象是否死亡,至少需要两次标记过程:如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链那么他将会被第一次标记并进行第一次筛选,筛选的条件是该对象是否有必要执行finalize()方法,对象没有覆盖finalize()方法或finalize()方法已经被调用过,则都没有必要执行;如果被判定为有必要执行,则该对象会被放置在一个队列中,并在稍后由一个由虚拟机自动建立的、低优先级的finalizer线程去执行该队列中所有对象的finalize()方法,虚拟机会执行该对象的finalize()方法,但不会保证等待它执行结束,因为如果执行对象的finalize()方法时非常缓慢或发生死循环就有可能导致该队列中的其他对象处于等待中,甚至导致虚拟机崩溃。finalize()方法的执行是对象逃脱死亡的最后一次机会,在执行finalize()方法后,GC将对队列中的对象进行第二次小规模标记。如果在finalize()方法执行时重新建立与引用链上的任意一个对象建立关联即可避免回收,否则如果在此次finalize()执行后仍没有逃脱标记队列,那么基本就会被回收。对象自我拯救实例代码如下

public class FinalizeEscapeGC {
	public static FinalizeEscapeGC SAVE_HOOK=null;
	public void isAlive(){
		System.out.println("Object is alive");
	}
	//重写finalize()方法,第一次标记时使虚拟机判定该对象需要执行finalize()方法
	@Override
	protected void finalize() throws Throwable {
		// TODO Auto-generated method stub
		super.finalize();
		System.out.println("finalize() excute");
		FinalizeEscapeGC.SAVE_HOOK=this;//此行代码对该对象进行的拯救
	}
	public static void main(String[] args) throws InterruptedException {
		SAVE_HOOK=new FinalizeEscapeGC();
		
		//初次拯救:成功
		SAVE_HOOK=null;
		System.gc();
		Thread.sleep(1000);
		if(SAVE_HOOK!=null){
			SAVE_HOOK.isAlive();
		}else{
			System.out.println("Object is dead");
		}
		
		//第二次拯救:失败
		SAVE_HOOK=null;
		System.gc();
		Thread.sleep(1000);
		if(SAVE_HOOK!=null){
			SAVE_HOOK.isAlive();
		}else{
			System.out.println("Object is dead");
		}
	}
}

运行结果:
finalize() excute
Object is alive
Object is dead

注意:每个对象的finalize() 方法只会被自动调用一次,在下一次回收时不会被执行,所以在上述代码中第一次拯救成功,第二次拯救失败。对于finalize()这个方法不推荐使用,或者说禁止使用,更推荐使用try-finally或者其他方式。

1.5 回收方法区

    1. 方法区(在某些虚拟机中被称之为永久代)的垃圾收集主要回收两部分内容:废弃常量与无用的类。

(1)回收废弃常量与回收堆中的普通对象类似,以常量池中字面量的回收为例,假如一个字符串“abc”进入了常量池,但当前系统中没有任何一个String对象是叫做“abc”的,换句话说,也就是没有任何String对象引用常量池中的“abc”常量,也没有其他地方引用这个字面量,如果此时发生内存回收,而且有必要的话,这个“abc”就会被清理出常量池。常量池中其他的类(接口),方法,字段的符合引用也与此类似。

扩展:关于String的创建

(1)String str = "abc"创建对象的过程

1 首先在常量池中查找是否存在内容为"abc"字符串对象

2 如果不存在则在常量池中创建"abc",并让str引用该对象

3 如果存在则直接让str引用该对象

至 于"abc"是怎么保存,保存在哪?常量池属于类信息的一部分,而类信息反映到JVM内存模型中是对应存在于JVM内存模型的方法区,也就是说这个类信息 中的常量池概念是存在于在方法区中,而方法区是在JVM内存模型中的堆中由JVM来分配的,所以"abc"可以说存在于堆中。一般这种情况下,"abc"在编译时就被写入字节码中,所以class被加载时,JVM就为"abc"在常量池中 分配内存,所以和静态区差不多。

(2)String str = new String("abc")创建实例的过程

1 首先在堆中(不是常量池)创建一个指定的对象"abc",并让str引用指向该对象

2 在字符串常量池中查看,是否存在内容为"abc"字符串对象

3 若存在,则将new出来的字符串对象与字符串常量池中的对象联系起来

4 若不存在,则在字符串常量池中创建一个内容为"abc"的字符串对象,并将堆中的对象与之联系起来

(3)String str1 = "abc"; String str2 = "ab" + "c"; str1==str2是ture

是因为String str2 = "ab" + "c"会查找常量池中时候存在内容为"abc"字符串对象,如存在则直接让str2引用该对象,显然String str1 = "abc"的时候,上面说了,会在常量池中创建"abc"对象,所以str1引用该对象,str2也引用该对象,所以str1==str2

(4)String str1 = "abc"; String str2 = "ab"; String str3 = str2 + "c"; str1==str3是false

是因为String str3 = str2 + "c"涉及到变量(不全是常量)的相加,所以会生成新的对象,其内部实现是先new一个StringBuilder,然后 append(str2),append("c");然后让str3引用toString()返回的对象

(2)回收无用类:类需要满足3个条件才能算是无用类,

  1. 该类的所有的实例都已经被回收,也就是说堆中不存在该类以及其子类的任何对象
  2. 加载该类的ClassLoader已经被回收
  3. 该类对应的java.lang.Class对象没有在任何地方被引用,没有在任何地方通过反射来访问该类

满足以上三个条件就是无用类,此时虚拟机可以对其进行回收,但不是必须回收。在大量使用反射、动态代理、CGLib等频繁定义ClassLoader的场景都需要虚拟机具备类卸载功能,保证永久代不会溢出。

2. 对象的回收——垃圾收集算法

2.1 标记-清除算法(Mark-Sweep)

    1. 算法思想:分为两个阶段,标记和清除;首先对要进行回收的对象进行标记,然后清除。

    2. 缺点:

(1)效率低,无论是标记过程还是清除过程效率都很低。

(2)浪费内存空间,标记清除后会造成大量的不连续的内存碎片,导致无法为后续分配较大内存的对象时无法分配,从而引起又一次的垃圾清理动作。

2.2 复制算法(Copying)

    1. 算法思想:将内存分为大小相等的两块,每次只使用其中一块。当正在使用的这块内存即将用完时,就将所有存货的对象复制到另一块内存中,然后将使用过的上一块内存全部清空。

    2. 优缺点:优点是效率相较于标记-清除算法较高,也不会存在大量内存碎片的情况,只需移动堆顶指针,顺序分配内存即可,实现简单。

缺点是对内存空间消耗大,可使用内存仅为原来的一半,内存代价高。

    3. 应用:商业虚拟机大多选用该算法对堆中的新生代中的对象进行回收。对于新生代区域中的对象,几乎98%都是“朝生夕死”的,所以不需要按照1比1划分内存空间,而是将新生代的内存空间划分为较大的Eden空间和两块较小的Survivor空间,每次使用Eden和其中一块Survivor。当回收时,将Eden区和Survivor区存活的对象一次性复制到另一块Survivor空间上,最后清理掉Eden和刚才用过的Survivor空间。HotSpot虚拟机默认的Eden和Survivor区的大小比例时8:1,也就是说新生代中可用内存空间为整个新生代的90%。如果另一块Survivor空间没有足够的内存空间存放上一次垃圾回收从新生代中存活的对象时,这些对象将直接通过分配担保机制进入老年代。

2.3 标记-整理算法(Mark-Compact)

对于新生代所采用的的复制算法,在对象存活率较高时因为需要大量的复制操作而导致效率变低,并且还需要额外的空间进行分配担保避免浪费50%的新生代内存空间。而为了应对老年代区域存活率较高的特点,甚至是被使用内存中所有对象都全部存活的极端情况,所以不采用此算法。

    1. 算法思想:首先是标记所有的可回收对象,然后将所有的存活的对象向同一端移动,保证所有的存活对象所占内存空间都是连续的时候,直接清理边界以外的内存。

2.4 分代收集算法

    1. 算法思想:也就是依据对象的存活周期将内存分为几块。一般是吧Java堆分为新生代、老年代和永久代,永久代不做讨论。对于新生代和老年代分别采用不同的收集算法,以此保证垃圾收集的高效性。比如新生代中每次垃圾收集时都会有大量的对象死去,只有少量对象存活,那么就用复制算法。而老年代中对象存活率高而且没有额外空间对它进行分配担保,所以就必须使用标记-整理或标记-清除算法。

3.  垃圾收集器——对GC相关算法的实现

3.1 可达性分析算法中枚举根节点

    1. 在可达性分析算法中需要从GC Roots节点找引用链,而可以作为GC Roots的节点主要是在全局性的引用(常量,静态属性等)以及执行上下文(栈桢中的本地变量表)中。但如果有方法区达到几百兆内存,此时在逐个检查引用,那么将会消耗大量时间。

    另外,GC耗时的另一个体现为GC停顿,在GC工作正在进行时,Java虚拟机必须终止其他所有的Java执行线程,随着堆的扩大这个暂停时间也会越久,因为可达性分析工作必须在一个能确保一致性的快照中进行,“一致性”指的是在整个分析期间不可以出现对象引用关系处于不断变化的情况。所以对于 System.gc()方法时禁止在程序中使用的,因为显式声明是做堆内存全扫描,也就是 Full GC,是需要停止所有的活动的,也就是上面所说的终止其他所有线程,对于程序是无法接受的。

    2. HotSpot虚拟机使用一组称为OopMap的数据结构来直接得知哪些地方存放对象引用,在类加载完成的时候,HotSpot就把对象内什么偏移量上是什么类型的数据计算出来,在JIT编译过程中,也会在特定位置记录下栈和寄存器中哪些位置是引用。

3.2 垃圾收集器

    1. 收集算法是内存回收的方法论,而垃圾收集器就是内存回收的具体实现。对于不同的虚拟机可能会有不同的收集器实现,对于HotSpot虚拟机,其总共有6种垃圾收集器用于不同分代的垃圾收集。如下图所示,两个收集器之间的连线表示可以搭配使用,所处区域表示用于老年代或新生代。

    没有最好的收集器,也没有万能的收集器,只有最合适的收集器。

并行垃圾收集器:指多条垃圾收集线程并行工作,但用户线程处于等待状态

并发垃圾收集器:指用户线程与垃圾收集线程并发执行或并行执行,用户程序继续执行,而垃圾收集线程运行在另一个CPU上

    2. Serial收集器:单线程收集器,也就是说当它在进行垃圾收集时必须暂停其他所有线程,直到该收集器的线程执行结束,该动作由虚拟机后台自动执行,用户不可见。

(1)采用算法:新生代使用复制算法,老年代使用标记-整理算法。

(2)优缺点:在单线程情况下(也就是只有垃圾收集器线程执行),该收集器具有简单高效的特点,但问题就是GC时导致所有应用程序的暂停,所以在后来的收集器就出现了并发收集器,使暂停时间尽量缩短,但无法完全消除。

    3. ParNew收集器:Serial收集器的并行版,可以使用多条线程收集垃圾,其余行为与Serial收集器完全相同,比如GC暂停,控制参数设置,应用的收集算法,对象分配策略和回收策略等。只有此收集器可以与CMS收集器配合工作。

(1)优缺点:与Serial收集器相比,其最大的优点就是可以使用多条线程进行垃圾回收,在单线程中其效率不会比Serial收集器更好,由于线程交互的开销,在CPU较少的情况下,都无法保证可以超越erial收集器。但是随着CPU数量的增加,其效率肯定要更好。缺点与Serial收集器相同,会发生GC时暂停现象。

    4. Parallel Scavenge收集器:专用于新生代的收集器,使用复制算法,并且是并行的多线程收集器,用于达到一个可控制的  用户代码运行时间/(垃圾收集时间+用户程序运行时间),即吞吐量。虚拟机会依据当前系统运行状态自动调整Parallel Scavenge收集器的控制参数,比如停顿时间或最大的吞吐量,这种调节被称为GC自适应调节策略,而该策略也是Parallel Scavenge收集器与PreNew收集器的区别。

    5. Serial Old收集器:该收集器是Serial收集器的老年代版本,即针对老年代进行收集。同样为单线程收集器,使用标记-整理算法。可以与Parallel Scavenge收集器搭配使用或作为CMS的后备方案。

    6. Parallel Old收集器:Parallel Scavenge收集器的老年代版本,使用标记-整理算法,并行收集器。

    7. CMS收集器:该收集器以获取最短停顿时间为目标的收集器,是并发收集器,使用标记-清除算法,分为4个步骤,初始标记,并发标记,重新标记,并发清除,其中初始标记和重新标记仍会发生GC暂停。初始标记是进行标记GC Roots直接关联的对象,并发标记是进行GC Roots根追踪,重新标记是修正并发标记期间用户程序继续运行而导致的标记变动的对象的标记记录。

(1)优缺点:

缺点有

  • CMS对CPU资源非常敏感
  • 无法处理浮动垃圾导致可能出现“Concurrent Model Failure”失败而导致另一次Full GC的发生。由于用户程序的不断运行,那么就会有垃圾产生,但如果部分垃圾出现在标记之后就会导致CMS无法处理,只有在下一次GC时进行处理,这一部分垃圾就被称为浮动垃圾。
  • 标记-清除算法导致的内存空间碎片化。

优点是响应速度快,系统停顿时间短。

    8. 理解GC日志:

    最前方的数字(比如  “ 88.11:”)表示GC活动发生的时间,即从虚拟机启动以来经过得秒数;

    紧跟的“GC”或“Full GC”表示此处垃圾回收的停顿类型,“Full GC”表示会暂停其他所有用户程序的线程活动,而用户程序中显示调用System.gc()方法也会导致Full GC,所以不建议调用该方法;

    而接下来的 [DefNew,[Tenured,[Perm分别表示在新生代,老年代或持久代进行的垃圾回收,但对于不同的收集器,对于对象分带的名称也可能不同;对于具体年代方括号内的如“333k->3k”表示GC前该内存区域使用量—>GC后该内存区域使用量,后再跟在这个区域GC所用时间;    

    而在方括号之外的表示GC前堆已使用空间—>GC后堆已使用空间。

4 内存分配与垃圾回收策略

4.1 内存分配

    对象的内存分配,绝大部分在堆上分配,主要分配与新生代的Eden区,如果启动了T本地线程分配缓冲,按线程优先分配在TLAB上。少数情况下分配在老年代,没有绝对确定的分配规则。其细节取决于当前使用的是哪一种垃圾收集器组合。

        1. 以下有几种较为普遍的对象分配策略:

  • 绝大部分新对象优先在新生代中的Eden区分配,当Eden没有足够空间进行分配时,虚拟机则会进行一次新生代GC。
  • 大对象直接进入老年代,大对象指大量连续内存空间的Java对象,比如极长的字符串或数组,在程序中更应该避免大量的”朝生夕死”的大对象。
  • 虚拟机为每个对象定义了一个对象年龄计数器,如果对象在Eden区出生并经过第一次新生代GC后仍然存活,则对象年龄就会加1,并且该对象能被Survivor区容纳的话,就将还存活的对象将被复制到 Survivor 区(两个中的一个),当对象每熬过一次新生代GC后,年龄就会加1,当对象年龄超过15(默认,可以通过控制参数设置)时,将被复制“年老区(Tenured)”。
  • 动态对象年龄判断,即虚拟机并不一定要求对象年龄必须达到最大年龄才能晋升老年代,当新生代中的相同年龄的存活对象的大小总和大于Survivor区的空间的一半时(也就是说正在使用的Survivor1区或Survivor2区内存空间不足时),年龄大于或等于该年龄的可以直接进入老年代。
  • 空间分配担保,在进行新生代GC之前,虚拟机会检查老年代最大可用连续内存空间是否大于新生代所有对象总空间,如果成立那么新生代GC就是安全的。但是可能会出现新生代GC后新生代中有大量的对象存活,导致Survivor区无法容纳,此时就需要老年代分配担保,把Survivor区无法容纳的对象直接进入老年代,但前提是老年代本身具有足够的空间,而是否采用这种方式承担风险是可以通过控制参数设置的。

网友评论

登录后评论
0/500
评论
江左煤郎
+ 关注