【JVM和性能优化】2.垃圾回收器和内存分配策略

本文详细解析了Java虚拟机的内存回收机制,包括GC的原因、策略及算法,重点介绍了可达性分析、引用计数法、标记-清除、复制算法等核心概念。探讨了年轻代与老年代的差异,以及各种垃圾回收器如Serial、ParNew、CMS、G1、ZGC的特点和应用场景。
摘要由CSDN通过智能技术生成

在这里插入图片描述

内存回收

为什么要了解GC(Garbage Collection)和内存分配策略

1、面试需要
2、GC对应用的性能是有影响的
3、写代码有好处

那些需要GC:

共享区的都要被回收比如堆区以及方法区。

在进行内存回收之前要做的事情就是判断那些对象是‘死’的,哪些是‘活’的。常用方法有两种 引用计数法可达性分析

引用计数法

给 Java 对象添加一个引用计数器,每当有一个地方引用它时,计数器 +1;引用失效则 -1,当计数器不为 0 时,判断该对象存活;否则判断为死亡(计数器 = 0)。
该方法的优点是实现简单,判断高效。缺点是无法解决 对象间相互循环引用 的问题,比如demo如下:


public class GcDemo {
    public static void main(String[] args) {
        GcObject object1 = new GcObject(); // step 1 
        GcObject object2 = new GcObject(); // step 2
        
        object1.instance = object2 ;//step 3
        object2.instance = object1; //step 4
        
        object1 = null; //step 5
        object2 = null; // step 6
        
    }
}
class GcObject {
    public Object instance = null;
}

step1: GcObject实例1的引用计数+1,实例1引用数 = 1
step2: GcObject实例2的引用计数+1,实例2引用数 = 1
step3: GcObject实例2的引用计数+1,实例2引用数 = 2
step4: GcObject实例1的引用计数+1,实例1引用数 = 2
step5: GcObject实例1的引用计数-1,结果为 1
step6: GcObject实例2的引用计数-1,结果为 1

至此发现实例1跟实例2的引用数都不为0而又相互引用。这两个实例所占有的内存则无法释放

可达性分析

很多主流商用语言(如Java、C#)都采用 引用链法 判断 Java对象是否存活,

将一系列的 GC Roots 对象作为起点,从这些起点开始向下搜索。
在Java语言中,可作为GC Roots的对象包含以下几种:

  1. 虚拟机栈(栈帧中的本地变量表)中引用的对象。(可以理解为:引用栈帧中的本地变量表的所有对象)
  2. 方法区中静态属性引用的对象(可以理解为:引用方法区该静态属性的所有对象)
  3. 方法区中常量引用的对象(可以理解为:)引用方法区中常量的所有对象
  4. 本地方法栈中(Native方法)引用的对象(可以理解为:)
    引用Native方法的所有对象

可以理解为:

  1. 首先第一种是虚拟机栈中的引用的对象,我们在程序中正常创建一个对象,对象会在堆上开辟一块空间,同时会将这块空间的地址作为引用保存到虚拟机栈中,如果对象生命周期结束了,那么引用就会从虚拟机栈中出栈,因此如果在虚拟机栈中有引用,就说明这个对象还是有用的,这种情况是最常见的

  2. 第二种是我们在类中定义了全局的静态的对象,也就是使用了static关键字,由于虚拟机栈是线程私有的,所以这种对象的引用会保存在共有的方法区中,显然将方法区中的静态引用作为GC Roots是必须的。

  3. 第三种便是常量引用,就是使用了static final关键字,由于这种引用初始化之后不会修改,所以方法区常量池里的引用的对象也应该作为GC Roots。最后一种是在使用JNI技术时,有时候单纯的Java代码并不能满足我们的需求,我们可能需要在Java中调用C或C++的代码,因此会使用native方法,JVM内存中专门有一块本地方法栈,用来保存这些对象的引用,所以本地方法栈中引用的对象也会被作为GC Roots。

含3个步骤:

  1. 可达性分析
  2. 第一次标记 & 筛选
  3. 第二次标记 & 筛选

可达性分析
在这里插入图片描述
当一个对象到 GC Roots 没有任何引用链相连时,则判断该对象不可达。
注意: 可达性分析 仅仅只是判断对象是否可达,但还不足以判断对象是否存活 / 死亡
当在 可达性分析 中判断不可达的对象,只是“被判刑” = 还没真正死亡。

第一次标记和筛选
对象 在 可达性分析中 被判断为不可达后,会被第一次标记 & 准备被筛选

不筛选:继续留在 ”即将回收“的集合里,等待回收;
筛选:从 ”即将回收“的集合取出

筛选的标准:该对象是否有必要执行 finalize()方法
若有必要执行(人为设置),则筛选出来,进入下一阶段(第二次标记 & 筛选);
没必要执行
,判断该对象死亡,不筛选 并等待回收.
当对象无finalize()方法 或finalize()已被虚拟机调用过,则视为“没必要执行”
第二次标记和筛选
当对象经过了第一次的标记 & 筛选,会被进行第二次标记 & 准备被进行 筛选

a. 方式描述

该对象会被放到一个 F-Queue 队列中,并由 虚拟机自动建立、优先级低的Finalizer 线程去执行 队列中该对象的finalize()
finalize()只会被执行一次
但并不承诺等待finalize()运行结束。这是为了防止 finalize()执行缓慢 / 停止 使得 F-Queue队列其他对象永久等待。

b. 筛选标准
在执行finalize()过程中,若对象依然没与引用链上的GC Roots 直接关联 或 间接关联(即关联上与GC Roots 关联的对象),那么该对象将被判断死亡,不筛选(留在”即将回收“集合里) 并 等待回收.
整体流程如下:
在这里插入图片描述

浅谈引用

通过GC Root分析到到各种引用对象也有不同到引用级别。

强引用

一般的Object obj = new Object() ,就属于强引用。

软引用

SoftReference:一些有用但是并非必需,用软引用关联的对象,系统将要发生OOM之前,这些对象就会被回收,参见代码:

public class TestSoftRef {

    public static class User {
        public int id = 0;
        public String name = "";

        public User(int id, String name) {
            super();
            this.id = id;
            this.name = name;
        }

        @Override
        public String toString() {
            return "User [id=" + id + ", name=" + name + "]";
        }

    }

    public static void main(String[] args) {

        User u = new User(1, "sowhat");
        SoftReference<User> userSoft = new SoftReference<>(u);
        u = null; // 保证new User(1,"sowhat") 这个实例只有userSoft在软引用

        System.out.println(userSoft.get());
        System.gc();//展示gc的时候,SoftReference不一定会被回收,只是建议回收
        System.out.println("AfterGc");
        System.out.println(userSoft.get()); // new User(1,"sowhat")没有被回收
        List<byte[]> list = new LinkedList<>();

        try {
            for (int i = 0; i < 100; i++) {
                //User(1,"sowhat")实例一直存在
                System.out.println("********************" + userSoft.get());
                list.add(new byte[1024 * 1024 * 1]);
            }
        } catch (Throwable e) {
            //抛出了OOM异常后打印的,User(1,"sowhat")这个实例被回收了
            System.out.println("Throwable********************" + userSoft.get());
        }

    }
}
弱引用

WeakReference :一些有用(程度比软引用更低)但是并非必需,用弱引用关联的对象,只能生存到下一次垃圾回收之前,GC发生时,不管内存够不够,都会被回收。

public class TestWeakRef {
	public static class User{
		public int id = 0;
		public String name = "";
		public User(int id, String name) {
			super();
			this.id = id;
			this.name = name;
		}
		@Override
		public String toString() {
			return "User [id=" + id + ", name=" + name + "]";
		}
		
	}
	
	public static void main(String[] args) {
		User u = new User(1,"sowhat");
		WeakReference<User> userWeak = new WeakReference<>(u);
		u = null;
		System.out.println(userWeak.get());
		System.gc();
		System.out.println("AfterGc");
		System.out.println(userWeak.get());
		
	}
}
虚引用

PhantomReference:幽灵引用,最弱,object.fun() 都无法执行 。 被垃圾回收的时候收到一个通知

方法区

方法区一般存放类加载信息,字符串常量,静态变量或者静态不可变量等数据。回收静态常量跟无用类,静态常量一般就用引用可达法即可,但判断无用的类:要以下三个条件都满足

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

GC 算法

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

算法分为标记清除两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。
它的主要不足空间问题,标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。
在这里插入图片描述

复制算法(Copying)

将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。这样使得每次都是对整个半区进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要按顺序分配内存即可,实现简单,运行高效。只是这种算法的代价是将内存缩小为了原
来的一半,还要来回移动数据

在这里插入图片描述

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

首先标记出所有需要回收的对象,在标记完成后,后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。
在这里插入图片描述

GC算法综合用

JVM区域总体分两类,heap区和非heap区。
heap区又分为:

  1. Eden Space(伊甸园)、字面意思是伊甸园,对象被创建的时候首先放到这个区域,进行垃圾回收后,不能被回收的对象被放入到空的survivor区域。
  2. Survivor Space(幸存者区)、
  3. Old Gen(老年代)。
年轻代

年轻代发生的GC是Minor GC

  1. 为什么会有年轻代

我们先来屡屡,为什么需要把堆分代?不分代不能完成他所做的事情么?其实不分代完全可以,分代的唯一理由就是优化GC性能。你先想想,如果没有分代,那我们所有的对象都在一块,GC的时候我们要找到哪些对象没用,这样就会对堆的所有区域进行扫描。而我们的很多对象都是朝生夕死的,如果分代的话,我们把新创建的对象放到某一地方,当GC的时候先把这块存“朝生夕死”对象的区域进行回收,这样就会腾出很大的空间出来。

  1. 年轻代中的GC

HotSpot JVM把年轻代分为了三部分:1个Eden区和2个Survivor区(分别叫from和to)。默认比例为8:1: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区已经被清空。这个时候,FromTo会交换他们的角色,也就是新的To就是上次GC前的From,新的From就是上次GC前的To。不管怎样,都会保证名为To的Survivor区域是空的。Minor GC会一直重复这样的过程,直到“To”区被填满,“To”区被填满之后,会将所有对象移动到年老代中。

也就是每次新生代中可用内存空间为整个新生代容量的90%(8+1),只有10%的内存会被“浪费”。当然,98%的对象可回收只是一般场景下的数据,我们没有办法保证每次回收都只有不多于10%的对象存活,当Survivor空间不够用时,需要依赖其他内存(这里指老年代)进行分配担保(Handle Promotion)。

在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。而老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用标记—清理或者标记—整理算法来进行回收。
在这里插入图片描述
年轻代为什么要有survivor?

Survivor的存在意义,就是减少被送到老年代的对象,进而减少Full GC的发生,Survivor的预筛选保证,只有经历16次Minor GC还能在新生代中存活的对象,才会被送到老年代

为什么survivor有两个?

只有一个的话在GC的过程中是会产生内存碎片的。至于为什么是两个,因为两个就可以满足要求了,大于2个每一个survivor空间都太小了。

老年代

在老年代发生的GC称:Major GC

MajorGC采用标记—清除算法(或者标记—整理算法)
MajorGC的耗时比较长,因为要先整体扫描再回收,MajorGC会产生内存碎片。为了减少内存损耗,一般需要合并或者标记出来方便下次直接分配。

Old Gen老年代,用于存放新生代中经过多次垃圾回收仍然存活的对象,也有可能是新生代分配不了内存的大对象会直接进入老年代。经过多次垃圾回收都没有被回收的对象,这些对象的年代已经足够old了,就会放入到老年代。

当老年代被放满的之后,虚拟机会进行垃圾回收,称之为Major GC。由于Major GC除并发GC外均需对整个堆进行扫描和回收,因此又称为Full GC。

heap区即堆内存,整个堆大小=年轻代大小 + 老年代大小。堆内存默认为物理内存的1/64(<1GB);默认空余堆内存小于40%时,JVM就会增大堆直到-Xmx的最大限制物理内存的1/4,可以通过MinHeapFreeRatio参数进行调整;默认空余堆内存大于70%时,JVM会减少堆直到-Xms的最小限制,可以通过MaxHeapFreeRatio参数进行调整。

堆模型分配比例:
在这里插入图片描述

永久代

指内存的永久保存区域,主要存放Class和Meta(元数据)的信息。

Class在被加载的时候被放入永久区域。它和和存放实例的区域不同,GC不会在主程序运行期对永久区域进行清理。所以这也导致了永久代的区域会随着加载的Class的增多而胀满,最终抛出OOM异常。

Java8中永久代已经被移除,被一个称为“元数据区”(元空间)的区域所取代。

元空间的本质和永久代类似,都是对JVM规范中方法区的实现。不过元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。因此,默认情况下,元空间的大小仅受本地内存限制。类的元数据放入 native memory, 字符串池和类的静态变量放入java堆中. 这样可以加载多少类的元数据就不再由MaxPermSize控制, 而由系统的实际可用空间来控制。

枚举根节点

Java代码GC的时候 会STW(Stop the World)。也就是停止所有的工作线程,“你们先别干活,我先来清理清理垃圾!”。目前用的就是可达性分析来操作。即使在号称(几乎)不会发生停顿的CMS收集器中,枚举根节点时也是必须要停顿的。

根节点主要在全局性的引用(常量、类静态属性)和执行上下文(栈帧中的本地变量表)中。那我们如果要一个一个的找过去就很慢。并且我们的HotSpot又是准确性GC,也就是它需要知道某个位置上的某个数据的类型,类型是准确的。这样它就能准确的知道这块数据类型是不是它关心的指针也就是引用啦!

在HotSpot中是用了一种叫OopMap的结构来存放一个对象内什么偏移量上是什么类型的数据。在类加载过程中就会进行记录。可以把OopMap理解为一个附加信息,或者说一件衣服的吊牌,咱们看吊牌就知道这衣服啥做的。所以GC在扫描的时候就可以直接看这些“吊牌”来知道信息了。

安全点

在OopMap的协助下,HotSpot可以快速且准确地完成GC Roots枚举,但一个很现实的问题随之而来:可能导致引用关系变化,或者说OopMap内容变化的指令非常多,提高了GC的空间成本。所以要在特定的位置SafePoint才记录OopMap,所以用一些比较关键的点来记录就能有效的缩小记录所需的空间。因此GC不是随时随地来的,得到达安全点时才可以开始GC。
对于Safepoint,另一个需要考虑的问题是如何在GC发生时让所以线程(这里不包括执行JNI调用的线程)都“跑”到最近的安全点上再停顿下来。这里有两种方案可供选择:

  1. 抢先式中断(Preemptive Suspension)
    其中抢先式中断不需要线程的执行代码主动去配合,在GC发生时,首先把所有线程全部中断,如果发现有线程中断的地方不在安全点上,就恢复线程,让它“跑”到安全点上。现在几乎没有虚拟机实现采用抢先式中断来暂停线程从而响应GC事件。

  2. 主动式中断(Voluntary Suspension)
    而主动式中断的思想是当GC需要中断线程的时候,不直接对线程操作,仅仅简单地设置一个标志,各个线程执行时主动去轮询这个标志,发现中断标志为真时就自己中断挂起。轮询标志的地方和安全点是重合的,另外再加上创建对象需要分配内存的地方。

安全区域

使用Safepoint似乎已经完美地解决了如何进入GC的问题,但实际情况却并不一定。Safepoint机制保证了程序执行时,在不太长的时间内就会遇到可进入GC的Safepoint。但是,程序就不执行的时候呢?所谓的程序不执行就是没有分配CPU时间,典型的例子就是线程处于Sleep状态或者Blocked状态,这时候线程无法响应JVM的中断请求,”走“到安全的地方去中断挂起,JVM也显然不太可能等待线程重新被分配CPU时间。对于这种情况,就需要安全区域(Safe Region)来解决。

安全区域是指在一段代码片段之中,引用关系不会发生变化。在这个区域中的任意地方开始GC都是安全的。我们也可以把Safe Region看做是被扩展了的Safepoint。

在线程执行到Safe Region中的代码时,首先标识自己已经进入了Safe Region,那样,当在这段时间里JVM要发起GC时,就不用管标识自己为Safe Region状态的线程了。在线程要离开Safe Region时,它要检查系统是否已经完成了根节点枚举(或者是整个GC过程),如果完成了,那线程就继续执行,否则它就必须继续等待直到收到可以安全离开Safe Region的信号为止。

GC回收器

前面说到的GC算法跟GC年代都是方法论,那么垃圾回收器的具体实现由于不同厂商跟不同版本还是略有不同的。
主要的回收器如下:
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
吞吐量:运行用户代码时间/(运行用户代码时间+ 垃圾收集时间)
垃圾收集时间:垃圾回收频率 * 单次垃圾回收时间
不同垃圾回收器相互协作关系如下:
在这里插入图片描述

Serial 收集器

这是一个单线程收集器,最古老版本。意味着它只会使用一个 CPU 或一条收集线程去完成收集工作,并且在进行垃圾回收时必须暂停其它所有的工作线程直到收集结束。
在这里插入图片描述

ParNew 收集器

简单认为是 Serial 收集器的并行多线程版本。

在这里插入图片描述

Parallel Scavenge 收集器

这是一个新生代收集器,也是使用复制算法实现,同时也是并行的多线程收集器。

CMS 等收集器的关注点是尽可能地缩短垃圾收集时用户线程所停顿的时间,而 Parallel Scavenge 收集器的目的是达到一个可控制的吞吐量(Throughput = 运行用户代码时间 / (运行用户代码时间 + 垃圾收集时间))。
虚拟机总共运行了100分钟,其中垃圾收集花掉1分钟,那吞吐量就是99%。

-XX:MaxGCPauseMills 用来设置垃圾回收时间,但是需要注意单位回收时间不是越低越好,时间越快意味着垃圾回收越频繁,
-XX:+UseAdaptiveSizePolicy 当这个参数打开之后,就不需要手工指定新生代的大小(-Xmn)、Eden与Survivor区的比例(-XX:SurvivorRatio)、晋升老年代对象年龄(-XX:PretenureSizeThreshold)等细节参数了,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量,这种调节方式称为GC自适应的调节策略
如果对于收集器运作原来不太了解,手工优化存在困难的时候,使用Parallel Scavenge收集器配合自适应调节策略,把内存管理的调优任务交给虚拟机去完成将是一个不错的选择。只需要把基本的内存数据设置好(如-Xmx设置最大堆),然后使用MaxGCPauseMillis参数(更关注最大停顿时间)或GCTimeRatio(更关注吞吐量)参数给虚拟机设立一个优化目标,那具体细节参数的调节工作就由虚拟机完成了。自适应调节策略也是Parallel Scavenge收集器与ParNew收集器的一个重要区别。

作为一个吞吐量优先的收集器,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整停顿时间。这就是 GC 的自适应调整策略(GC Ergonomics)。

Serial Old 收集器

收集器的老年代版本,单线程
在这里插入图片描述

ParallelNew Old 收集器

Parallel Old 是 Parallel Scavenge 收集器的老年代版本。多线程,

在这里插入图片描述

CMS 收集器

Concurrent Mark Sweep:划时代的GC器,让垃圾回收线程跟用户线程可以同时工作,收集器是一种以获取最短回收停顿时间为目标的收集器。目前很大一部分的Java应用集中在互联网站或者B/S系统的服务端上,这类应用尤其重视服务的响应速度,希望系统停顿时间最短,以给用户带来较好的体验。CMS收集器就非常符合这类应用的需求。
CMS收集器是基于标记—清除算法实现的,它的运作过程相对于前面几种收集器来说更复杂一些,整个过程分为4个步骤,包括:
CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器,适用于集中在互联网站或者B/S系统的服务端的Java应用。CMS收集器是基于标记-清除算法实现的,整个收集过程大致分为4个步骤:

①.初始标记(CMS initial mark)
②.并发标记(CMS concurrenr mark)
③.重新标记(CMS remark)
④.并发清除(CMS concurrent sweep)

其中初始标记、重新标记这两个步骤任然需要停顿其他用户线程。初始标记仅仅只是标记出GC ROOTS能直接关联到的对象,速度很快,并发标记阶段是进行GC ROOTS 根搜索算法阶段,会判定对象是否存活。而重新标记阶段则是为了修正并发标记期间,因用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间会被初始标记阶段稍长,但比并发标记阶段要短。
由于整个过程中耗时最长的并发标记和并发清除过程中,收集器线程都可以与用户线程一起工作,所以整体来说,CMS收集器的内存回收过程是与用户线程一起并发执行的。
CMS收集器的优点:并发收集、低停顿,但是CMS还远远达不到完美,主要有三个显著缺点:cpu敏感,浮动垃圾,空间碎片
  CMS收集器对CPU资源非常敏感。在并发阶段,虽然不会导致用户线程停顿,但是会占用CPU资源而导致引用程序变慢,总吞吐量下降。CMS默认启动的回收线程数是:(CPU数量+3) / 4。
  CMS收集器无法处理浮动垃圾,可能出现Concurrent Mode Failure,失败后而导致另一次Full GC的产生。由于CMS并发清理阶段用户线程还在运行,伴随程序的运行自热会有新的垃圾不断产生,这一部分垃圾出现在标记过程之后,CMS无法在本次收集中处理它们,只好留待下一次GC时将其清理掉。这一部分垃圾称为浮动垃圾。也是由于在垃圾收集阶段用户线程还需要运行,
即需要预留足够的内存空间给用户线程使用,因此CMS收集器不能像其他收集器那样等到老年代几乎完全被填满了再进行收集,需要预留一部分内存空间提供并发收集时的程序运作使用。在默认设置下,CMS收集器在老年代使用了68%的空间时就会被激活,也可以通过参数-XX:CMSInitiatingOccupancyFraction的值来提供触发百分比,以降低内存回收次数提高性能。要是CMS运行期间预留的内存无法满足程序其他线程需要,就会出现“Concurrent Mode Failure”失败,这时候虚拟机将启动后备预案:临时启用Serial Old收集器来重新进行老年代的垃圾收集,这样停顿时间就很长了。所以说参数-XX:CMSInitiatingOccupancyFraction设置的过高将会很容易导致“Concurrent Mode Failure”失败,性能反而降低。
  最后一个缺点,CMS是基于“标记-清除”算法实现的收集器,使用“标记-清除”算法收集后,会产生大量碎片。空间碎片太多时,将会给对象分配带来很多麻烦,比如说大对象,内存空间找不到连续的空间来分配不得不提前触发一次Full GC。为了解决这个问题,CMS收集器提供了一个-XX:UseCMSCompactAtFullCollection开关参数,用于在Full GC之后增加一个碎片整理过程,还可通过-XX:CMSFullGCBeforeCompaction参数设置执行多少次不压缩的Full GC之后,跟着来一次碎片整理过程。

在这里插入图片描述

G1 收集器

G1的第一篇paper(附录1)发表于2004年,在2012年才在jdk1.7u4中可用。oracle官方计划在jdk9中将G1变成默认的垃圾收集器,以替代CMS。为何oracle要极力推荐G1呢,G1有哪些优点?

首先,G1的设计原则就是简单可行的性能调优,开发人员仅仅需要声明以下参数即可:

-XX:+UseG1GC -Xmx32g -XX:MaxGCPauseMillis=200

其中-XX:+UseG1GC为开启G1垃圾收集器,-Xmx32g 设计堆内存的最大内存为32G,-XX:MaxGCPauseMillis=200设置GC的最大暂停时间为200ms。如果我们需要调优,在内存大小一定的情况下,我们只需要修改最大暂停时间即可。

其次,G1将新生代,老年代的物理空间划分取消了。这样我们再也不用单独的空间对每个代进行设置了,不用担心每个代内存是否足够。

1 内存结构

G1首先在内存结构上采用了region化的方法,将堆内存划分成2000块左右的小块,每块大小1-32M(2的幂次),每块region都可以作为E、S、O任意一种,分配灵活,但是存在大对象问题。解决方法是:

  • 小于一半region size的可以正常存入E区
  • 一半到一个region size的直接存入O区一个region中,这个region又叫Humongous region,我们也可以把它叫做H区(他本质还是O区的)
  • 比一个region size还要大的对象,需要存入连续的多个region中,这多个region都是H区。

在这里插入图片描述

2 两个概念
  • RememberSets:又叫Rsets是每个region中都有的一份存储空间,用于存储本region的对象被其他region对象的引用记录。
  • CollectionSets:又叫Csets是一次GC中需要被清理的regions集合,注意G1每次GC不是全部region都参与的,可能只清理少数几个,这几个就被叫做Csets。
3 YGC

年轻代的GC,StopTheWorld,复制算法。将E和S(from)区复制到S(to),注意S(to)一开始是没有标识的,就是个free region。下图中没有标出YGC进入老年代的对象,有可能有一部分会进入O区!!
在这里插入图片描述
在这里插入图片描述

4 MixGC

G1对于老年代的GC比较特殊,本质上不是只针对老年代,也有部分年轻代,所以又叫MixGC

  • 初始标记(Initial Marking):也是标记GCroot直接引的对象和所在Region,但是与CMS不同的是,这里不止标记O区。注意初次标记一般和YGC同时发生,利用YGC的STW时间,顺带把这事给干了。标记对象所在Region(RootRegion),这阶段需要停顿线程,但耗时很短。日志格式如下

[GC pause (G1 Evacuation Pause) (young) (initial-mark), 0.0062656 secs]

  • RootRegion扫描:扫描GCroot所在的region到Old区的引用。日志格式

1.362: [GC concurrent-root-region-scan-start]
1.364: [GC concurrent-root-region-scan-end, 0.0028513 secs]

  • 并发标记:类似CMS,但是标记的是整个堆,而不是只有O区。并发标记阶段(Concurrent Marking Phase),如果发现有空的块(这里用红叉X标示的区域), 则会在 Remark 阶段立即移除。当然,清单(accounting)信息决定了活跃度的计算。
    在这里插入图片描述
  • 重新标记阶段(Remark Phase)空的区域块被移除并回收。现在计算所有区域块的活跃度(Region liveness)。
    在这里插入图片描述
  • 复制清除阶段(Copying/Cleanup Phase)
    G1选择“活跃度(liveness)”最低的区域块,这些区域可以最快的完成回收。然后这些区域和年轻代GC在同时被垃圾收集 。 日志上是被标识为 [GC pause (mixed)]。所以年轻代和老年代都在同一时间被垃圾收集。
    在这里插入图片描述
  • 复制/清除之后阶段(After Copying/Cleanup Phase)
    所选择的区域被收集和压缩到下图所示的深蓝色区域和深绿色区域
    在这里插入图片描述

老生代GC总结,老生代的G1垃圾回收有以下几个关键点

1、并发标记阶段(Concurrent Marking Phase)

活跃度信息在程序运行的时候就被并行的计算了出来。活跃度(liveness)信息标记出哪些区域块最适合回收,在转移暂停期间最适合回收掉。没有sweep阶段。但CMS是有这个阶段的。

2、重新标记阶段(Remark Phase)

使用了Snapshot-at-the-Beginning (SATB)算法,这个要比CMS的算法快很多。完全空的区域块会被直接回收掉。

3、复制/清除阶段(Copying/Cleanup Phase)

年轻代和老年代会被同时回收。老年代的区域块会不会被选择,取决于它的活跃度。

ZGC

JDK11中的ZGC一种可扩展的低延迟垃圾收集器

● 处理TB量级的堆
● GC时间不超过10ms
● 与使用G1相比,应用吞吐量的降低不超过15%

JDK11 通过技术手段把stw的情况控制在仅有一次,就是第一次的初始标记才会发生,这样也就不难理解为什么GC停顿时间不随着堆增大而上升了,再大我也是通过并发的时间去回收了
关键技术

1.有色指针(Colored Pointers)
2.加载屏障(Load Barrier)

zgc是为大内存、多cpu而生,它通过分区的思路来降低STW,比如原来我有一个巨大的房间需要打扫卫生,每打扫一次就需要很长时间的STW,因为房间太大了,后来我改变了思路,把这个巨大的房间打隔断,最终出来100个小房间,这些小房间可以是eden区、可以是s区、也可以是old区,没有固定,每次只选择最需要打扫的房间(垃圾最多的)进行打扫,最终把这个房间的存活对象转移到其它房间,同时其它房间还可以继续供客户使用,也就是并行、并发的打扫,只在某几个必须的阶段进行很短暂的STW,其它时间都是和用户线程并行工作,这样可以很好的控制STW时间,同时也因为占用了很多cpu时间并发干活导致吞吐量降低,所以如果硬件充足的情况下 可以考虑 ZGC。

常用垃圾回收器参数总结:
在这里插入图片描述
在这里插入图片描述

STW实现

/**
 *  VM参数:-Xmx300M -Xms300m -XX:+UseSerialGC -XX:+PrintGCDetails
 *  模拟实现 STW现象的产生
 */
public class StopWorld {

    /*不停往list中填充数据*/
    public static class FillListThread extends Thread {
        List<byte[]> list = new LinkedList<>();

        @Override
        public void run() {
            try {
                while (true) {
                    if (list.size() * 512 / 1024 / 1024 >= 990) {
                        list.clear();
                        System.out.println("list is clear");
                    }
                    byte[] bl;
                    for (int i = 0; i < 100; i++) {
                        bl = new byte[512];
                        list.add(bl);
                    }
                    Thread.sleep(1);
                }

            } catch (Exception e) {
            }
        }
    }

    /*每100ms定时打印*/
    public static class TimerThread extends Thread {
        public final static long startTime = System.currentTimeMillis();

        @Override
        public void run() {
            try {
                while (true) {
                    long t = System.currentTimeMillis() - startTime;
                    System.out.println(t / 1000 + "." + t % 1000);
                    Thread.sleep(100);
                }

            } catch (Exception e) {
            }
        }
    }

    public static void main(String[] args) {
        FillListThread myThread = new FillListThread();
        TimerThread timerThread = new TimerThread();
        myThread.start();
        timerThread.start();
    }
}

有年轻代回收跟全局回收

5.523
[5.709s][info   ][gc,start     ] GC(2) Pause Young (Allocation Failure)
...
5.870s][info   ][gc,cpu         ] GC(2) User=0.14s Sys=0.01s Real=0.17s
5.687
---------------
7.381
[7.573s][info   ][gc,start       ] GC(5) Pause Full (Allocation Failure)
...
7.954s][info   ][gc,cpu         ] GC(6) User=0.21s Sys=0.01s Real=0.21s
7.771

多看几个会发现年轻代回收跟全局回收耗时性不一样的,尽量让回收发生在年轻代。

内存分配与回收策略

  1. 对象优先在Eden分配

对象主要分配在新生代的 Eden 区上,如果启动了本地线程分配缓冲区,将线程优先在 (TLAB) 上分配。少数情况会直接分配在老年代中。

新生代

发生在新生代的垃圾回收动作,频繁,速度快。

老年代

出现了 Major GC 经常会伴随至少一次 Minor GC(非绝对)。Major GC 的速度一般会比 Minor GC 慢很多,要尽量发生MinorGC 而非Major GC。

  1. 大对象直接在老年代产生

大对象一般指超过1M的需要大量联系内存空间的Java对象,比如大字符串或者数组。

  1. 长期存活的对象将进入老年代

默认如果一个新生代对象经过15次GC还存在则进如老年代,可通过
-XX:MaxTenuringThreshold调整

  1. 动态对象年龄判定

为了能更好地适应不同程序的内存状况,虚拟机并不是永远地要求对象的年龄必须达到了MaxTenuringThreshold才能晋升老年代,如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到MaxTenuringThreshold中要求的年龄

  1. 空间分配担保

新生代中有大量的对象存活,survivor空间不够,当出现大量对象在MinorGC后仍然存活的情况(最极端的情况就是内存回收后新生代中所有对象都存活),就需要老年代进行分配担保,把Survivor无法容纳的对象直接进入老年代,只要老年代的连续空间大于新生代对象的总大小或者历次晋升的平均大小,就进行Minor GC,否则FullGC。

demo演示

新生代大小配置参数的优先级:

高:-XX:NewSize/MaxNewSize
中间 -Xmn (NewSize= MaxNewSize) 年轻代大小
低:-XX:NewRatio 表示比例,例如=2,表示 新生代:老年代 = 1:2

-XX:SurvivorRatio 表示Eden和Survivor的比值,
缺省值=8 表示 Eden:FromSurvivor:ToSurvivor= 8:1:1

-Xms20M -Xmx20M 堆空间最大值跟最小值

相同的代码当我们设定VM参数不同时,print的GC也是不同的。

public class NewSize {

	public static void main(String[] args) {
		int cap = 1*1024*1024;//1M
		byte[] b1 = new byte[cap];
		byte[] b2 = new byte[cap];
		byte[] b3 = new byte[cap];
		byte[] b4 = new byte[cap];
		byte[] b5 = new byte[cap];
		byte[] b6 = new byte[cap];
		byte[] b7 = new byte[cap];
		byte[] b8 = new byte[cap];
		byte[] b9 = new byte[cap];
		byte[] b0 = new byte[cap];
    }
}
  1. -Xms20M -Xmx20M -XX:+PrintGCDetails –Xmn2m -XX:SurvivorRatio=2

没有垃圾回收,数组都在老年代。

  1. -Xms20M -Xmx20M -XX:+PrintGCDetails -Xmn7m -XX:SurvivorRatio=2

发生了垃圾回收,新生代存了部分数组,老年代也保存了部分数组,发生了晋升现象

  1. -Xms20M -Xmx20M -XX:+PrintGCDetails -Xmn15m -XX:SurvivorRatio=8

新生代可以放下所有的数组 ,老年代没放

  1. -Xms20M -Xmx20M -XX:+PrintGCDetails -XX:NewRatio=2

发生了垃圾回收出现了空间分配担保,而且发生了FullGC。

内存泄露跟内存溢出

内存溢出:比如你有一个5L的容器,你要往里面放6L的水,这个时候就会发生内存溢出。
内存泄露:你有一个5L的容器,你先放了1L沙跟4L水,沙凝固后只可以将水倒出来,沙子还在容器中占据内存,这个时后就1L沙的容器就是内存泄露。

内存泄露多发生在我们不用一个空间对象后而没有将其置NULL,比如我自己实现了一个栈

public class Stack {
	
	public  Object[] elements;
	private int size = 0; //指示器,用来指示当前栈顶的位置

    private static final int Cap = 16;

    public Stack() {
    	elements = new Object[Cap];
    }
    //入栈
    public void push(Object e){
    	elements[size] = e;
    	size++;
    }
    //出栈
    public Object pop(){
    	size = size-1;
    	Object object = elements[size];
    	elements[size] = null;//  这里很重要
        return object;
    }
}

你在pop的时候将对象从数组中拿出而没有将对应位置置空,标准的Java容器pop或者删除一个对象的时候是会置空的。

JDK提供自带工具

工欲善其事必先利其器,JDK为我们提供了很多自带的内存监控性能分析工具。
在这里插入图片描述
jps :列出当前机器上正在运行的虚拟机进程

-p :仅仅显示VM 标示,不显示jar,class, main参数等信息.
-m:输出主函数传入的参数. 下的hello 就是在执行程序时从命令行输入的参数
-l: 输出应用程序主类完整package名称或jar完整名称.
-v: 列出jvm参数, -Xms20m -Xmx50m是启动程序指定的jvm参数

jstat:用于监视虚拟机各种运行状态信息的命令行工具。

它可以显示本地或者远程虚拟机进程中的类装载、内存、垃圾收集、JIT编译等运行数据,在没有GUI图形界面,只提供了纯文本控制台环境的服务器上,它将是运行期定位虚拟机性能问题的首选工具。

假设需要每250毫秒查询一次进程2764垃圾收集状况,一共查询20次,那命令应当是:jstat -gc 2764 250 20
常用参数:

-class (类加载器)
-compiler (JIT)
-gc (GC堆状态)
-gccapacity (各区大小)
-gccause (最近一次GC统计和原因)
-gcnew (新区统计)
-gcnewcapacity (新区大小)
-gcold (老区统计)
-gcoldcapacity (老区大小)
-gcpermcapacity (永久区大小)
-gcutil (GC统计汇总)
-printcompilation (HotSpot编译统计)

其中JConsole跟VisualVM相当于前面的工具综合体,一般这两个可以更加直观查看内存信息,如下图的VisualVM:
在这里插入图片描述

参考

春风得意马蹄疾一文看尽(JVM)虚拟机
可达性分析
JVM GC 机制与性能优化
为什么只需要一个eden而需要两个survivor
JVM 为什么有年轻代
年轻代年老代大小比例
GC分类
Java新生代、老生代和永久代详解
深入理解Java虚拟机Book
内存回收笔记

评论 42
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

SoWhat1412

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值