JVM垃圾收集器与内存分配策略

小龙 782 2020-04-20

JVM程序编译模式

JIT即时编译

JIT(Just In Time、即时编译器),在运行期间可以对热点代码进行二次编译。热点代码就是多次被调用的代码。像for循环、while循环中的代码,每一循环执行的内容都不同,无法通过正常的class编码实现

正常编译器

HotSpot虚拟机中有两个即时编译器,分别为Client Compiler简称(“C1”)和Server Compiler(“简称C2”)

  • C1 “-client”:启动速度块、占用内存小、执行效率慢于“-server”模式,默认状态下不进行动态编译,适合单机版桌面程序运行。
  • C2 “-server”:启动速度慢,占用内存多,执行效率高,适用于服务器 端使用,也是默认的模式
    Java 7 开始引入了分层编译(对应参数 -XX:TieredCompilation)的概念,综合了C1的启动性能优势和C2的峰值性能优势,分层编译将Java虚拟机的执行状态分为了个层次

GC10.png

混合模式

JVM在默认在运行时采用混合模式,正常的解释器和JIT(即时编译器)两种。可以最大提升程序的处理性能

运行时数据区

在JVM内存模型之中,运行时数据去是最为重要的部分;而在运行时数据区中我们唯一可以操作的部分就是堆内存,所有的优化都是围绕着堆内存展开的

堆内存划分

堆内存在JDK 1.8以前和JDK 1.8及以后发生了较大的变化
观察JDK 1.8以前的内存划分

GC1.png

从JDK 1.8开始永久代被元空间所取代

GC2.png

在JVm之中所有程序的数据都在运行时数据区之中进行存储的,而在运行时数据区之中需要开发者进行关注的空间就是堆内存,所以首先就必须搞清楚堆内存的基础划分,对堆内存空间有如下的几个组成部分:

  • 年轻代(Young):所有新创建的对象都保存在年轻代里面,年轻代分为三个部分
    • 伊甸园:新生对象
    • 存活区:新对象的存活
    • 伸缩区:动态开辟区域
  • 老年代(Old、Tenured):所有常用的对象、超大对象或者在多次清理之后还被保留的对象
  • 元空间(Mate):JDK 1.8之后,新增了元空间的概念,元空间的本质描述的就是直接内存;
    • 在JDK 1.8之前,是没有元空间的的,对应的内存称为“永久代”(Permanent Space,方法区的内存无法被回收)
  • 伸缩区:当内存不足的时候,会释放伸缩区的内存空间,进行更多对象的存储,所以伸缩区是一块能够被不断修改大小的内存区域,所有的内存代都有此区域

对象的创建与垃圾收集

在Java之中没有任何指向的堆内存区域都将其称为垃圾,而且在Java之中为了方便的进行垃圾空间的回收与再利用,提供有Java垃圾收集器(Garbage Collector),但是垃圾收集器的调用实际上分为两种情况完成

  • 第一种:通过代码明确调用gc()处理方法手工进行垃圾回收;
  • 第二种:JVM自动进行GC的处理,自动进行垃圾回收。

对于手工进行垃圾回收,使用的就是Runtime类中提供的gc()处理方法;
System.gc()Runtome.getRuntime().gc()

System.gc()里面调用得也是Runtime.getRuntime().gc()

JVM通过可达分析算法来判断对象是否存活,通过GC ROOTS Set集合的对象作为起点,从这些起点开始往下搜索,搜索所走过的线路被称为引用链,当一个对象到GC ROOTS Set集合没有 任何引用链相连时,则该对象为不可达对象,就会判定该对象是可回收对象。

GC ROOTS Set:在Java虚拟机里面有几种可以作为GC RootS对象的(虚拟机栈中栈帧的本地变量表中的引用对象;比如说各个线程被调用的方法堆栈中使用的参数、局部变量、临时变量;在方法区中的类静态属性引用对象比如java类里面的引用类型的静态变量;在方法区中常量引用的对象比如字符串、常量池的引用;还有就是在本地方法栈中的GNI引用的对象;Java内部的引用比如基本数据类型对应的class对象;异常对象;系统的类加载器;被同步锁持有的对象,反映Java虚拟机内部情况的这种Bean,注册回调,本地代码缓存等)都可以作为GC ROOTS Set,还会根据用户选择的垃圾回收器和回收的区域不同还可有其他对象零时性的加入,构成一个完整的GC Roots集合

但是在对象被标记为不可达对象之后,并不会立马进行回收,要想真正的回收还需要经历两次标记过程。当对象被标记为不可达之后,会进行一次筛选,筛选的条件是判断该对象是否有必要进行终结(执行finalize()方法,如果没有覆写finalize()或者finalize()已经被调用过了,都将认为没必要)。

通过下面程序观察finalize()方法

public class demo {
	public static demo SAVE_HOOK = null;
	public void isAlive(){
		System.out.println("是的,我还活着");
	}
	// 覆写finalize()方法
	@Override
	protected void finalize() throws Throwable{
		super.finalize();
		System.out.println("finalize()方法执行");
		demo.SAVE_HOOK = this;
	}
	public static void main(String[] args) throws InterruptedException {
		SAVE_HOOK = new demo();
		// 对象第一次成功拯救自己
		SAVE_HOOK = null;
		System.gc();
		// 因为finalize()的优先级很低,可先线程休眠等待
		Thread.sleep(500);
		if (SAVE_HOOK != null){
		SAVE_HOOK.isAlive();
		}else{
			System.out.println("不,我已经挂掉了");
		}
		// 第二次执行
		SAVE_HOOK = null;
		System.gc();
		// 因为finalize()的优先级很低,可先线程休眠等待
		Thread.sleep(500);
		if (SAVE_HOOK != null){
		SAVE_HOOK.isAlive();
		}else{
			System.out.println("不,我已经挂掉了");
		}
	}
}

执行结果
GC3.png

每个对象都只会执行一次finalize()方法。

注意:开发中不建议使用finalize()方法;

对象创建与GC流程
GC4.png

所有自动执行的CG操作都只会在对象创建的情况下进行调用,而调用的GC分为两种GC

  • 年轻代GC:MinorGC
  • 老年代GC:MajorGC(Full GC)

1、所有新创建的对象都优先保存在年轻代的伊甸园区(Eden、此类对象可能只是一个小范围的临时使用的对象,由于这种对象较多,所以都将其放在伊甸园区),伊甸园区虽然属于年轻代,但是其也有自己所属的内存空间分配。
2、如果此时伊甸园区的内存充足,那么就可以直接在伊甸园区为新创建的对象进行堆内存空间的开辟
3、如果此时伊甸园区的内存不足,那么就意味着当前的内存区域已经满了,当内存空间满了之后就会引起第一次的GC处理操作,而这种GC称为MinorGC(清理普通的无用对象),如果在进行了MinorGc操作之后,内存空间充足了,就会在伊甸园区开辟新的内存空间保存新的对象数据
4、经过伊甸园区持续的MinorGC还被保存下来的对象(如果伊甸园区空间不足才会执行MinorGc,而伊甸园区调用MinorGC之后发现空间依然不足,则才会进行晋升处理),就会认为该对象可能是一个有用对象,对于这些有用对象将会进行晋升处理,将其保存在存活区中(存活区依然属于年轻代),此时由于存在有对象的内存区域的改变,那么必然将释放伊甸园区的部分内存空间,这样就可以进行新对象的创建了。
5、如果发现伊甸园区和存活区的内存空间全都满了,俺么这个时候就需要考虑老年代的内存空间了,如果此时老年代内存空间充足,那么会将存活区的数据晋升到老年代之中进行存储,这样年轻代就会有空闲的内存空间,继续进行新对象的创建。
6、如果发现老年代的内存空间也不足了(整个的内存区域全都都被占满了),那么这个时候将会引发MajorGC(Full GC)进行老年代的内存空间释放,如果出现Full GC失败,就表示整个的内存依据彻底被占满了,这个时候将不会再执行GC处理,而是直接抛出OutOfMemoryError
7、如果创建的对象太过庞大,那么将不会在年轻代中存储,而是直接在老年代中进行对象存储,有可能创建很大的对象之后出现OOM异常;

public class demo {
	private static final int _1MB = 1024 * 1024;
	public static void testAllocation(){
		byte[] allocation1,allocation2,allocation3,allocation4,allocation5;
		allocation1 = new byte[2 * _1MB];
		allocation2 = new byte[2 * _1MB];
		allocation3 = new byte[2 * _1MB];
		allocation5 = new byte[_1MB];
		// 触发一次Minor GC
		allocation4 = new byte[4 * _1MB];
	}
	public static void main(String[] args) throws InterruptedException {
		testAllocation();
	}
}

执行命令

java -verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8 demo
-verbose:gc  ——  使用 GC
-Xms20M      ——  初始化内存
-Xmx20M	     ——  最大内存
-Xmn10M      年轻代,老年代各10M
-XX:+PrintGCDetails —— 打印GC日志:
-XX:SurvivorRatio=8 —— 年轻代伊甸园个存活区内存为 8:1:1

执行结果

[GC (Allocation Failure) [PSYoungGen: 7292K->800K(9216K)] 7292K->6952K(19456K), 0.0304731 secs] [Times: user=0.00 sys=0.
00, real=0.03 secs]
[Full GC (Ergonomics) [PSYoungGen: 800K->0K(9216K)] [ParOldGen: 6152K->6770K(10240K)] 6952K->6770K(19456K), [Metaspace:
2621K->2621K(1056768K)], 0.0096456 secs] [Times: user=0.00 sys=0.00, real=0.01 secs]
Heap
 PSYoungGen      total 9216K, used 5362K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
  eden space 8192K, 65% used [0x00000000ff600000,0x00000000ffb3c9d8,0x00000000ffe00000)
  from space 1024K, 0% used [0x00000000ffe00000,0x00000000ffe00000,0x00000000fff00000)
  to   space 1024K, 0% used [0x00000000fff00000,0x00000000fff00000,0x0000000100000000)
 ParOldGen       total 10240K, used 6770K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
  object space 10240K, 66% used [0x00000000fec00000,0x00000000ff29cae0,0x00000000ff600000)
 Metaspace       used 2627K, capacity 4486K, committed 4864K, reserved 1056768K
  class space    used 281K, capacity 386K, committed 512K, reserved 1048576K

年轻代内存管理

年轻代分为两个部分:伊甸园区、存活区(存存活区都会为其设置两块),年轻代的内存结构如下图:
GC5.png

伊甸园区在年轻代中的比重是最大的,伊甸园区和两块存活区的比例是:(8:1:1),因为从整体的设计结构来讲,所有新产生的 小对象可能会非常的多,而且也有可能会非常快的消亡掉。例如执行了如下的操作

String str = new String("Hello World");

此时的字符串并不会自动的入池保存,并且随着创建线程执行完毕之后就自动消失了,但是其所产生的对象肯定是直接保存在伊甸园区中,如果有N个线程执行都执行了类似的操作,那么会在伊甸园区产生N个与之匹配的 堆内存空间进行数据的存储,并且这些都属于小对象,这些对象可能使用一次之后就不再使用了。所以MinorGC是非常频繁的操作。
之所以在整个年轻代准备存活区是为了保证即便有对象会被重复保存下来并使用的时候,也可以进行该对象的晋升处理,由于该对象长期处于可以状态,并且多次的MinorGC都无法将其清除,就认为该对象会被始终使用,既然会被始终使用,就没必要每一次都对其进行GC处理,而存活区就是负责伊甸园区向老年代晋升的处理机制,但是在整个存活区里面,永远有一块存活区是空的。

现假设存活区有两个:S0、S1,在多次的MinorGC之后,会将某一些对象保存在S0里面(但是此时的S1是空的),并且当年轻代的空间不足时会向老年代晋升处理,于是此时会将S0存活区的内容晋升到老年代,在S0晋级的时候此时的S1将代替S1的工作,接收所有年轻代的存活对象的存储,如果再次向老年代晋升,则所有被晋升的对象从S1中提取(此时的S0一致处于空的状态)

年轻代本身是拥有GC的处理机制的,年轻代里面针对于GC的处理可以采用复制算法的形式完成。

年轻代GC实现算法 —— 复制算法(Copying)

  • 算法:复制采用的方式为通过可达分析算法扫描出存活对象,并将找到的存活对象复制到一块新的完全未使用的空间中。

GC6.png

所有的垃圾如果想要被回收,那么一定要首先进行垃圾的标记(区分出那些是垃圾对象,那些事非垃圾对象),而且所有的标准都是从**根对象(GC ROOT)**开始(从整个变量表的头部开始进行依次的扫描,那么那么如果此时产生的对象特别多,所带来的问题就是一定会进行操作的延迟),随后将年轻代中存活的对象(按照一定的 回收次数来判断是否有效)保存到存活区中,而存活区也有可能将自己一块的存活区空间保存到老年代之中,于是所有的数据复制到一个新的空间里面,所复制到的空间内存一定是连续的,而原始的内存空间由于进行回收,也变成了一块连续的空间

在实际运行中,由于Eden区总是会保存大量的新生对象,所有HotSpot虚拟机为了可以加快内存空间的内配,而使用了Bump-The-PointerTLAB、Thread-Lical Allocation Buffers两种技术。

Bump-The-Pointer

该技术的主要特点是跟踪在Eden区保存的最后一个对象,这个最后保存的对象一般会保存在Eden区的顶部,这样每次创建新对象的时候只需要检查最后保存的对象后面是否还有足够的空间就可以很快的判断出Eden区中是否还有剩余空间,这种做法可以极大提高内存分配速度

GC7.png

但是这样的处理流程本身会存在一个特别严重的性能问题,在多线程并发访问处理的形式之中,那么一定会造成线程操作的堵塞问题,导致整体的伊甸园区内存分配的性能低下

GC8.png

这种操作最大的问题在于只有一个指针,该指针必须精确的指向最后一个保存位置,但是在多线程环境下,线程的并发创建,会使该指针指向出现错误,那么对剩余空间的判断就会出现偏差。为了避免这种问题出现,在多线程情况下肯定会进行线程同步,但是一旦进行线程同步,会造成伊甸园区内存分配的性能低下。
为了改进BTP(Bump-The-Pointer)的分配设计问题,在JVM中就考虑将整个伊甸园区拆分为若干个不同的子区域,而后每一个子区域分别进行一个BTP的处理形式进行新对象的存储,这样的设计思路就是TLAB(Thread-Local Allocation Buffers)技术。

TLAB(Thread-local Allocation Buffers)

  • 虽然“Bump-The-Pointer”算法可以提高内存的分配速度,但是这种做法并不适合于多线程的操作情况。所以又采用了“TLAB”算法将Eden区分为多个数据块。每个数据块分别使用“Bump-The-Pointer”技术进行对象保存与内存分配,但是该操作会产生内存碎片。

GC9.png

虽然TLAB存在有内存碎片的问题,但是在每一次进行MinorGC处理的时候都有可能通过复制算法,来实现对象的移动以及内存空间的释放,这样看来似乎整体结构上不存在有太多的逻辑问题

老年代内存管理

老年代所保存的对象实际上都是被长期使用的对象,在老年代中的对象很对都是经历过了很多次的MinorGC,通过存活区晋升上来的,一般来讲老年代的对象不容易释放,除非系统运行了很长的时间,在某一段时间内发现有些常用的对象暂时没人使用了,而此时年轻代的内存空间又被占满了,这时才有可能会进行老年代的GC处理(Full GC),对老年代进行释放

在堆内存里面存在有老年代的设计,主要的原因在于,为了放在多次MinorGc对象重复使用的对象进行重复的标记,在年轻代进行空间回收之间一定要进行垃圾空间的标记,如果重复的进行标记会造成时间复杂度的升高,造成性能浪费,所以才存在有老年代;

老年代空间的主要目的适用于存储由Eden(伊甸园区)、Survivor(存活区)晋升的对象,默认是经历过 15次MinorGC还没被回收的对象,才会被复制到老年代中,一般老年代的内存空间大小会设置的比年轻代要大,这样就可以存放更多的对象,在老年代中执行GC的次数相对比较少。当老年代内存不足时会自动执行Full GC(也被称为Major GC);

对象中有一个对象的年龄计数器,保存在对象头中,当这个对象每经历一次MinorGC之后没有被回收,那么就会将对象的年龄增加1,在JVM中每个对象的最大年龄默认是15,也就是当一个对象熬过15次MinorGC还没被回收,那么这个对象就将晋升到老年代中保存。可以使用“-XX:MaxTenuringThreshold”参数调整对象的最大年龄

老年代垃圾回收算法:标记-清除、标记压缩

标记-清除(Mark-Sweep)

  • 标记-清除算法采用的方式为从根集合(GC ROOT)开始扫描,对存活的对象进行标记,标记完毕之后,在扫描整个空间中未标记的对象,并进行回收

GC11.png

标记-删除在空间中存活对象较多的情况下较为高效,但是由于该算法为直接回收不存活对象所占用的内存,因此该算法会造成内存碎片,一旦内存碎片过多,有可能在进行一些较大对象创建的时候,没有足够的连续空间进行分配,从而频繁触发GC操作。

标记-压缩(Mark-Compact)

  • 标记-压缩算法在标记阶段与“标记-删除”算法类似,但是在删除阶段有所不同。在回收不存活对象所占用的内存空间之后,会将所有存活对象都往左端空闲的空间进行移动,并更新引用其对象的指针

GC12.png

标记-压缩算法在“标记-清除”的基础上还需要进行对象的移动,虽然时间复杂度会提高,但是这种算法不会产生碎片空间。

永久代(JDK 1.8废除)

从JDK 1.8开始,JVM中已经将永久代彻底非常了,所谓的永久代实际上最初的设计是希望他可以和方法区结合在一起,而方法区本身是没有GC回收处理的,也就是说保存在永久代中的对象永远不会被回收,例如:在String类中出现的intern()方法,实际上就会将对象保存在永久代里面,所以如果控制不当,那么永久区也会法术内存溢出问题。

元空间(JDK 1.8提供)

元空间是在JDK 1.8开始被加入到JVM体系中的,其就是为了顶替掉永久代直接进行内存的数据处理,元空间代表了本机系统上所有未被JVM所占用的内存空间的总和(也就是直接内存)

  • 元空间与永久代最大的区别在于:永久代保存使用的是JVM的堆内存,而元空间使用的是本机物理内存,所以元空间的大小受到本机物理内存的限制。

在大部分情况下开发者是不需要对元空间进行调整处理的,所有的调整都只是针对于JVM的堆内存空间大小进行配置,利用元空间可以避免JVM内存提醒的限制,相当于可以将一部分重要的数据保存在元空间之中,这样即使JVM的操作崩溃了,那么也可以直接访问元空间之中重要的数据。在JVM启动的时候会将一些重要的信息保存在元空间里面,所以我们在进行内存分配的时候就需要注意,如果元空间的内存太小了,那么将无法实现正常的数据保存,自然会抛出OOM错误,在实际的项目中对于元空间的配置基本上不需要开发者进行额外的处理,使用自动配置即可

JVM启动参数

No.参数名称描述
01-Xmn设置年轻代堆内存大小,默认为物理内存的1/64
02-Xss设置每个线程栈的大小,JDK 1.5之后默认每个线程分配1MB的栈大小,减少此数值可以产生更多的线程对象,但是不能无限生成
03-XX:SurvivorRatio设置Eden与Survivor空间大小比例,默认为“8:1:1”,不建议修改
04-XX:NewSize设置新生代内存区大小
05-XX:NewRatio设置年轻代与老年代的比率
07-XX:+UseAdaptiveSizePolicy控制是否采用动态控制策略,如果动态控制,则动态调整Java堆中各个区域的大小以及进入老年代的年龄
08-XX:PretenureSizeThreshold控制直接在老年代保存的对象大小,大于这个值对象将直接分配在老年代中
09-XX:MetaspaceSize设置元空间的初始化大小
10-XX:MaxMetaspaceSize设置元空间的最大容量,默认是没有限制的(受本机物理内存限制)
11-XX:MinMetaspaceFreeRatio执行GC之后,最小的剩余元空间百分比,减少为分配空间所导致的垃圾收集
12-XX:MaxMetaspaceFreeRatio执行GC之后,最大的Metaspace剩余空间容量的百分比,减少释放空间所导致的垃圾收集