JVM复习(一):JVM内存模型、HotSpot虚拟机对象、垃圾回收算法、垃圾收集器、内存分配与回收策略

一、JVM内存模型

JVM运行时数据区域分为线程独占区线程共享区,线程独占区包括虚拟机栈、本地方法栈、程序计数器,线程共享区包括堆和方法区

在这里插入图片描述

1、堆

堆是JVM内存中最大的一块内存空间,该内存被所有线程共享,几乎所有对象和数组都被分配到了堆内存中。堆被划分为新生代和老年代,新生代又被进一步划分为Eden和Survivor区,最后Survivor由From Survivor和To Survivor组成

在Java6版本中,永久代在非堆内存区;到了Java7版本,永久代的静态变量和运行时常量池被合并到了堆中;而到了Java8,永久代被元空间取代了

在这里插入图片描述

2、程序计数器

程序计数器是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器

如果线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是native方法,这个计数器值则为空

3、方法区

方法区主要用来存放已被虚拟机加载的类相关信息,包括类信息、运行时常量池、字符串常量池。类信息又包括了类的版本、字段、方法、接口和父类等信息

JVM在执行某个类的时候,必须经过加载、连接、初始化,而连接又包括验证、准备、解析三个阶段。在加载类的时候,JVM会先加载class文件,而在class文件中除了有类的版本、字段、方法和接口等描述信息外,还有一项信息是常量池,用于存放编译期间生成的各种字面量和符号引用

字面量包括字符串(String a = "b";)、基本类型的常量(final修饰的变量),符号引用则包括类和方法的全限定名(例如String这个类,它的全限定名就是Java/lang/String)、字段的名称和描述符以及方法的名称和描述符

而当类加载到内存中后,JVM就会将class文件常量池中的内容存放到运行时的常量池中;在解析阶段,JVM会把符号引用替换为直接引用(对象的索引值)

例如,类中的一个字符串常量在class文件中时,存放在class文件常量池中的;在JVM加载完类之后,JVM会将这个字符串常量放到运行时常量池中,并在解析阶段,指定该字符串对象的索引值。运行时常量池是全局共享的,多个类共用一个运行时常量池,class文件中常量池多个相同的字符串在运行时常量池只会存在一份

方法区也是线程共享的。假如两个线程都试图访问方法区中的同一个类信息,而这个类还没有装入JVM,那么此时就只允许一个线程去加载它,另一个线程必须等待

在HotSpot虚拟机的JDK1.7版本中,已经将永久代的静态变量和运行时常量池转移到了堆中,其余部分则存储在JVM的非堆内存中,而JDK1.8版本已经将方法区中实现的永久代去掉了,并用元空间代替了之前的永久代,并且元空间的存储位置是本地内存。之前永久代的类的元数据存储在了元空间,永久代的静态变量以及运行时常量池则跟JDK1.7一样,转移到了堆中

4、虚拟机栈

Java虚拟机栈是线程私有的内存空间,它和Java线程一起创建。当创建一个线程时,会在虚拟机栈中申请一个线程栈,用来保存方法的局部变量、操作数栈、动态链接方法和返回地址等信息,并参与方法的调用和返回。每一个方法的调用都伴随着栈帧的入栈操作,方法的返回则是栈帧的出栈操作

5、本地方法栈

虚拟机栈为虚拟机执行Java方法服务,而本地方法栈则为虚拟机使用到的native方法服务

二、HotSpot虚拟机对象

1、对象的创建

虚拟机遇到一条new指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,那必须先执行相应的类加载过程

在类加载检查通过后,虚拟机将为新生对象分配内存。对象所需内存的大小在类加载完成后便可完全确定,为对象分配空间的任务等同于把一块确定大小的内存从Java堆中划分出来

分配内存有两种方式:

  • 指针碰撞:假设Java堆中内存是绝对规整的,所有用过的内存都放在一边,空间的内存放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空间那边挪动一段与对象大小相等的距离
  • 空闲列表:如果Java堆中的内存并不是规整的,已使用的内存和空闲的内存相互交错,虚拟机维护一个列表,记录哪些内存是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录

对象创建在虚拟机中是非常频繁的行为,即使是仅仅修改一个指针所指向的位置,在并发情况下也并不是线程安全的,可能出现正在给对象A分配内存,指针还没来得及修改,对象B又同时使用了原来的指针来分配内存的情况。解决方案有两种:

  • 对分配内存空间的动作进行同步处理——实际上虚拟机采用CAS配上失败重试的方式保证更新操作的原子性
  • 把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在Java堆中预先分配一块内存,称为本地线程分配缓冲(TLAB)。哪个线程要分配内存,就在哪个线程的TLAB上分配,只有TLAB用完并分配新的TLAB时,才需要同步锁定。虚拟机是否使用TLAB,可以通过-XX:+/-UseTLAB参数来设定

内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),如果使用TLAB,这一工作过程也可以提前至TLAB分配时进行。这一步保证了对象的实例字段在Java代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值

2、对象的内存布局

在HotSpot虚拟机中,对象在内存中存储的布局可以分为3块区域:对象头、实例数据和对齐填充

对象头包括两部分信息,第一部分用于存储对象自身的运行时数据,如哈希码、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,这部分数据的长度在32位和64位的虚拟机中分别为32bit和63bit,官方称它为Mark Word

Mark Word被设计成一个非固定的数据结构以便在极小的空间内存储尽量多的信息,它会根据对象的状态复用自己的存储空间

对象头的另一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。并不是所有的虚拟机实现都必须在对象数据上保留类型指针,换句话说,查找对象的元数据信息并不一定要经过对象本身,如果对象是一个Java数组,那在对象头中还必须有一块用于记录数组长度的数据

实例数据部分是对象真正存储的有效信息,也是在程序代码中所定义的各种类型的字段内容。无论是从父类继承下来的,还是在子类中定义的,都需要记录起来。这部分的存储顺序会受到虚拟机分配策略参数和字段在Java源码中定义顺序的影响。HotSpot虚拟机默认的分配策略为longs/doubles、ints、shorts/chars、bytes/booleans、oops,相同宽度的字段总是被分配到一起。在满足这个前提条件的情况下,在父类中定义的变量会出现在子类之前。如果CompactFields参数值为true(默认为true),那么子类之中较窄的变量也可能会插入到父类变量的空隙之中

对齐填充并不是必然存在的,它仅仅起着占位符的作用,由于HotSpot VM的自动内存管理系统要求对象起始地址必须是8字节的整数倍,而对象头部分正好是8字节的倍数,因此,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全

3、对象的访问定位

Java程序需要通过栈上的reference数据来操作堆上的具体对象,目前主流的访问方式有使用句柄直接指针两种

如果使用句柄访问的话,那么Java堆中将会划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息

在这里插入图片描述

如果使用直接指针访问,那么Java堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,而reference中存储的直接就是对象地址

在这里插入图片描述

使用句柄来访问的最大好处就是reference中存储的稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针,而reference本身不需要修改

使用直接指针访问方式的最大好处就是速度更快,它节省了一次指针定位的时间开销

虚拟机Sun HotSpot是使用直接指针就行对象访问的

三、String.intern()方法

String.intern()是一个native方法,它的作用是:如果字符串常量池中已经包含一个等于此String对象的字符串,则返回常量池中这个字符串的String对象;否则,将此String对象包含的字符串添加到常量池中,并且返回此String对象的引用

在JDK1.6及之前的版本中,由于常量池分配在永久代内,可以通过-XX:PermSize和-XX:MaxPermSize限制方法区大小,从而间接限制其中常量池的容量

        String str1 = new StringBuilder("计算机").append("软件").toString();
        System.out.println(str1.intern() == str1);// true(JDK1.8执行)
        String str2 = new StringBuilder("ja").append("va").toString();
        System.out.println(str2.intern() == str2);// false
        String str3 = new StringBuilder("zhangsan").toString();
        System.out.println(str3 == str3.intern());// false

1、str1比较结果

这段代码在JDK1.6中运行,第一个结果为false,internet()方法会把首次遇到的字符串实例复制到永久代中,返回的也是永久代中这个字符串实例的引用,而由StringBuilder创建的字符串实例在Java堆上,所以必然不是同一个引用,将返回false

JDK1.7开始逐步去永久代,internet()实现不会再复制实例,只是在常量池中记录首次出现的实例引用,因此internet()返回的引用和由StringBuilder创建的那个字符串实例是同一个

2、str2比较结果

对str2比较返回false是因为“java”这个字符串在执行StringBuilder.toString()之前已经出现过,字符串常量池中已经有它的引用了

查看System类的源码,根据注释可以看出,System是由虚拟机自动调用的

    public final class System {
    
        /* register the natives via the static initializer.
         *
         * VM will invoke the initializeSystemClass method to complete
         * the initialization for this class separated from clinit.
         * Note that to use properties set by the VM, see the constraints
         * described in the initializeSystemClass method.
         */
        private static native void registerNatives();

initializeSystemClass()方法中调用了Version对象的init()静态方法

    public final class System {
        private static void initializeSystemClass() {
            props = new Properties();
            initProperties(props);  // initialized by the VM
    
            sun.misc.VM.saveAndRemoveProperties(props);
    
            lineSeparator = props.getProperty("line.separator");
            // 调用了Version对象的init静态方法
            sun.misc.Version.init();
    
            FileInputStream fdIn = new FileInputStream(FileDescriptor.in);
            FileOutputStream fdOut = new FileOutputStream(FileDescriptor.out);
            FileOutputStream fdErr = new FileOutputStream(FileDescriptor.err);
            setIn0(new BufferedInputStream(fdIn));
            setOut0(newPrintStream(fdOut, props.getProperty("sun.stdout.encoding")));
            setErr0(newPrintStream(fdErr, props.getProperty("sun.stderr.encoding")));
    
            loadLibrary("zip");
    
            Terminator.setup();
    
            sun.misc.VM.initializeOSEnvironment();
            Thread current = Thread.currentThread();
            current.getThreadGroup().add(current);
            setJavaLangAccess();
            sun.misc.VM.booted();
        }

而Version类里laucher_name是私有静态字符串常量

    public class Version {
        private static final String launcher_name = "java";
        private static final String java_version = "1.8.0_162";
        private static final String java_runtime_name = "Java(TM) SE Runtime Environment";
        private static final String java_profile_name = "";
        private static final String java_runtime_version = "1.8.0_162-b12";

因此sun.misc.Version类会在JDK类库的初始化过程中被加载并初始化,而在初始化时它需要对静态常量字段根据指定的常量值(ConstantValue)做默认初始化,此时被sun.misc.Version.launcher静态常量字段所引用的"java"字符串字面量就被intern到HotSpotVM的字符串常量池

3、str3比较结果

new StringBuilder("zhangsan")这条代码时,"zhangsan"就已经创建了一个引用,而str3.intern()指向的是"zhangsan"的引用而不是StringBuilder.toString()的引用

四、垃圾收集

1、回收发生在哪里

JVM的内存区域中,程序计数器、虚拟机栈和本地方法栈这3个区域是线程私有的,随着线程的创建而创建,销毁而销毁;栈中的栈帧随着方法的进入和退出进行入栈和出栈操作,每个栈帧中分配多少内存基本是在类结构确定下来的时候就已知的,因此这三个区域的内存分配和回收都具有确定性

那么垃圾回收的重点就是关注堆和方法区中的内存了,堆中的回收主要是对象的回收,方法区的回收主要是废弃常量和无用的类的回收

2、对象在什么时候可以被回收

一般一个对象不再被引用,就代表该对象可以被回收。目前有以下两种算法可以判断该对象是否可以被回收:

1)、引用计数法

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

主流的Java虚拟机里面没有选用引用计数算法来管理内存,其中最主要的原因是它很难解决对象之间相互循环引用的问题。两个对象相互引用着对方,导致它们的引用计数都不为0,于是引用计数算法无法通知GC收集器回收它们

2)、可达性分析法

可达性分析算法通过一系列的称为GC Roots的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链,当一个对象到GC Roots没有任何引用链相连时,则证明此对象是不可用的

在Java语言中,可作为GC Roots的对象包括下面几种:

  • Java 方法栈桢中的局部变量
  • 已加载类的静态变量
  • JNI handles
  • 已启动且未停止的 Java 线程

3、再谈引用

在JDK1.2之后,Java将引用分为强引用软引用弱引用虚引用

  • 强引用是指类似Object obj=new Object()这类的引用,只要强引用还存在,垃圾收集器永远不会回收掉被引用的对象
  • 软引用是用来描述一些还有用但并非必需的对象。对于软引用关联着的对象,在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围之中进行第二次回收。如果这次回收还没有足够的内存,才会抛出内存溢出异常。在JDK1.2之后,提供了SoftReference类来实现软引用
  • 弱引用也是用来描述非必需对象的,但是它的强度要比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生之前。当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。在JDK1.2之后,提供了WeakReference类来实现弱引用
  • 虚引用是最弱的一种引用关系,一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。在JDK1.2之后,提供了PhantomReference类来实现虚引用

4、finalize()方法

如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,那它将会被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法。当对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,虚拟机将这两种情况都是为没有必要执行

如果这个对象被判定为有必要执行finalize()方法,那么这个对象将会放置在一个叫做F-Queue的队列之中,并在稍后由一个由虚拟机自动建立的、低优先级的Finalizer线程去执行它。这里所谓的执行是指虚拟机会触发这个方法,但并不承诺会等待它运行结束,这样做的原因是,如果一个对象在finalize()方法中执行缓慢,或者发生了死循环将很可能会导致F-Queue队列中其他对象永久处于等待,甚至导致整个内存回收系统崩溃。finalize()方法是对象逃脱死亡命运的最后一次机会,稍后GC将对F-Queue中的对象进行第二次小规模的标记,如果对象在finalize()中成功拯救自己——只要重新与引用链上的任何一个对象关联即可,那在第二次标记时他将被移除出“即将回收”的集合;如果对象这时候还没有逃脱,那基本上它就真的被回收了

/*
 * 代码演示了两点:
 * 1.对象可以在被GC时自我拯救
 * 2.这种自救的机会只有一次,因为一个对象的finalize()方法最多只会被系统自动调用一次
 */
public class FinalizeEscapeGC {
    public static FinalizeEscapeGC SAVE_HOOK = null;

    public void isAlive() {
        System.out.println("yes,i am still alive");
    }

    @Override
    protected void finalize() throws Throwable {
        super.finalize();
        System.out.println("finalize method executed!");
        FinalizeEscapeGC.SAVE_HOOK = this;
    }

    public static void main(String[] args) throws Throwable {
        SAVE_HOOK = new FinalizeEscapeGC();
        //对象第一次成功拯救自己
        SAVE_HOOK = null;
        System.gc();
        //因为finalize方法优先级很低,所以暂停1秒等待它
        TimeUnit.SECONDS.sleep(1);
        if (SAVE_HOOK != null) {
            SAVE_HOOK.isAlive();
        } else {
            System.out.println("no,i am dead");
        }
        //下面这段代码与上面的完全相同,但是这次自救却失败了
        SAVE_HOOK = null;
        System.gc();
        TimeUnit.SECONDS.sleep(1);
        if (SAVE_HOOK != null) {
            SAVE_HOOK.isAlive();
        } else {
            System.out.println("no,i am dead");
        }
    }
}

运行结果:

finalize method executed!
yes,i am still alive
no,i am dead

代码中有两段完全一样的代码片段,执行结果却是一次逃脱成功,一次失败,这是因为任何一个对象的finalize()方法都只会被系统自动调用一次,如果对象面临下一次回收,它的finalize()方法不会被再次执行,因此第二段代码的自动行动失败了

finalize()方法的运行代价高昂,不确定性大,无法保证各个对象的调用顺序,因此不建议使用

5、垃圾回收算法

1)、标记-清除算法

在这里插入图片描述

标记-清除算法分为标记清除两个阶段:首先标记处所有需要回收的对象,在标记完成后统一回收所有被标记的对象

不足:

  • 会造成内存碎片,标记清除之后会产生大量不连续的空间碎片,空间碎片太多可能导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作
  • 分配效率较低,如果是一块连续的内存空间,那么可以通过指针碰撞来做分配。而对于空闲列表,JVM则需要逐个访问列表中的项,来查找能够放入新建对象的空闲内存

2)、标记-整理算法

在这里插入图片描述

标记-整理算法标记过程仍然与标记-清除算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。这种做法能够解决内存碎片化的问题,但代价是整理算法的性能开销

3)、复制算法

在这里插入图片描述

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

复制算法的代价是将内存缩小为了原来的一半,未免太高了一点

现在的商业虚拟机都采用复制算法来回收新生代,将内存分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden和其中一块Survivor空间。当回收时,将Eden和Survivor中还存活着的对象一次性地复制到另外一块Survivor空间上,最后清理掉Eden和刚才用过的Survivor空间。HotSpot虚拟机默认Eden和Survivor的大小比例是8:1,当Survivor空间不够用时,需要依赖其他内存(老年代)进行分配担保

4)、分代收集算法

在这里插入图片描述

当前商业虚拟机的垃圾收集都采用分代收集算法,根据对象存活周期的不同将内存划分为几块。一般是把Java堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法

在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法;而老年代中因为对象存活率高、没有额外空间对它进行分配担保,那就必须使用标记-清理或者标记-整理算法来进行回收

4、Stop The World以及安全点

可达性分析过程中需要保证一致性,这里一致性是指在整个分析期间整个执行系统看起来就像被冻结在某个时间点上,不可以出现分析过程中对象引用关系还在不断变化的情况,该点不满足的话分析结果准确性就无法得到保证

为了解决这个问题,JVM会在GC的时候停止所有Java执行线程(Sun将这件事情称为Stop The World),直到完成垃圾回收

JVM中的Stop The World是通过安全点机制来实现的。当JVM收到Stop The World请求,它便会等待所有的线程都到达安全点或者进入安全区域,才允许请求Stop The World的线程进行独占的工作

1)、安全点

在安全点,虚拟机会生成OopMap用来记录引用关系,协助HotSpot快速且准确地完成GC Roots枚举(这也是不能在任何地方停下的原因,如果每一条指令都生成OopMap那么效率会非常低,也会占用大量的空间)

一般安全点设置在以下位置:

  • 方法调用
  • 循环跳转
  • 异常跳转

那么JVM是如何让线程停下的呢?事先会约定一个标志,当需要进行GC的时候,JVM会更改这个标志的值,线程在运行的时候会轮询这个标志,当收到要发生GC信号,它会运行到下一个安全点停下来,等待GC的进行

当然,仅仅用安全点是不够的,有下面一种情况,就是当线程sleep或者阻塞的时候,它根本就不会运行,更谈不上进入安全点了,更不可能让所有的线程去等它,于是引入了安全区域这个概念

2)、安全区域

安全区域是指在一段代码片段之中,引用关系不会发生变化。在这个区域中的任意地方开始GC都是安全的

当线程进入安全区域,如sleep或者阻塞时,会标志自己已经进入了安全区域,当进行GC的时候,就不用去管它了,当它要离开安全区域是,会先看看JVM已经完成了GC没有,如果没有就等到GC完成之后再离开安全区域

7、垃圾收集器

在这里插入图片描述

如果两个收集器之间存在连线,就说明它们可以搭配使用

并行与并发:

  • 并行(Parallel):指多条垃圾收集线程并行工作,但此时用户线程仍然处于等待状态

  • 并发(Concurrent):指用户线程与垃圾收集线程同时执行(但不一定是并行的,可能会交替执行),用户程序在继续运行,而垃圾收集程序运行在另一个CPU上

1)、新生代收集器

1)Serial收集器——复制算法

Serial收集器是一个单线程的收集器,它只会使用一个CPU或者一条收集线程去完成垃圾收集工作,而且在进行垃圾收集时,必须暂停其他所有的工作线程,直到收集结束

2)ParNew收集器——复制算法

ParNew收集器是一个并行收集器,其实就是Serial收集器的多线程版本

3)Parallel Scavenge收集器——复制算法

Parallel Scavenge收集器是一个新生代收集器,它也是使用复制算法的收集器,又是并行的多线程收集器,也被称为吞吐量优先收集器

Parallel Scavenge收集器的目标是达到一个可控制的吞吐量,所谓吞吐量就是CPU用于运行用户代码的时间与CPU总消耗时间的比值,即吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间)

停顿时间越短越适合需要与用户交互的程序,良好的响应速度能提升用户体验,而高吞吐量则可以高效地利用CPU时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务

2)、老年代收集器

1)Serial Old收集器——标记整理算法

Serial Old是Serial收集器的老年代版本,它同样是一个单线程收集器,使用标记-整理算法,主要意义是在于给Client模式下的虚拟机使用

如果在Server模式下,那么它主要还有两大用途:

  • 在JDK1.5以及以前的版本中与Parallel Scavenge收集器搭配使用
  • 作为CMS收集器的后备预案,在并发收集发生Concurrent Mode Failure时使用

2)Parallel Old收集器——标记整理算法

Parallel Old是Parallel Scavenge收集器的老年代版本,使用多线程和标记整理算法

在注重吞吐量以及CPU资源敏感的场合,都可以优先考虑Parallel Scavenge加Parallel Old收集器

3)CMS收集器——标记-清除算法

CMS收集器是一种以获取最短回收停顿时间为目标的收集器,基于标记-清除算法

整个过程分为4个步骤:

  • 初始标记
  • 并发标记
  • 重新标记
  • 并发清除

初始标记、重新标记这两个步骤仍然需要Stop The World

初始标记仅仅只是标记一下GC Roots能直接关联到的对象,速度很快

并发标记阶段就是进行GC Roots Tracing的过程

而重新标记阶段则是为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段稍长一些,但远比并发标记的时间短

由于整个过程中耗时最长的并发标记和并发清除过程收集器线程都可以与用户线程一起工作,所以,从总体上来说,CMS收集器的内存回收过程是与用户线程一起工作

在这里插入图片描述

CMS收集器有3个明显的缺点:

1)CMS收集器对CPU资源非常敏感,在并发阶段,它虽然不会导致用户线程停顿,但是会因为占用了一部分线程(或者说CPU资源)而导致应用程序变慢,总吞吐量会降低。CMS默认启动的回收线程数是(CPU数量+3)/4,也就是当CPU在4个以上时,并发回收时垃圾收集线程不少于25%的CPU资源,并且随着CPU数量的增加而下降。但是当CPU不足4个时,CMS对用户程序的影响就可能变得很大

2)CMS收集器无法处理浮动垃圾,可能出现Concurrent Mode Failure失败而导致另一次Full GC的产生。由于CMS并发清理阶段用户线程还在运行着,伴随程序运行自然就还会有新的垃圾不断产生,这一部分垃圾出现在标记过程之后,CMS无法在当次收集中处理掉它们,只好留到下一次GC时再清理掉。这一部分垃圾就称为浮动垃圾。由于在垃圾收集阶段用户线程还需要运行,那也就还需要预留有足够的内存空间给用户线程使用,因此CMS收集器不能像其他收集器那样等到老年代几乎完全被填满了再进行收集,需要预留一部分空间提供并发收集时的程序运作使用

在JDK1.5的默认设置下,CMS收集器当老年代使用了68%的空间后被会被激活,如果在应用中老年代增长不是太快,可以适当调高参数-XX:CMSInitiatingOccupancyFraction的值来提高触发百分比,以便降低内存回收次数从而获取更好的性能,在JDK1.6中,CMS收集器的启动阈值已经提升至92%。要是CMS运行期间预留的内存无法满足程序需要,就会出现一次Concurrent Mode Failure失败,这时虚拟机将启动后备预案:临时启动Serial Old收集器来重新进行老年代的垃圾收集,所以说参数-XX:CMSInitiatingOccupancyFraction设置得太高很容易导致大量Concurrent Mode Failure失败,性能反而降低

3)CMS是一款基于标记-清除算法实现的收集器,收集结束时会有大量空间碎片产生。空间碎片过多时,将会给大对象分配带来很大麻烦,往往会出现老年代还有很大空间剩余,但是无法找到足够大的连续空间来分配当前对象,不得不提前触发一次Full GC。为了解决这个问题,CMS收集器提供了一个-XX:+UseCMSCompactAtFullCollection开关参数(默认就是开启的),用于在CMS收集器顶不住要进行Full GC时开启内存碎片的合并整理过程,内存整理的过程是无法并发的,空间碎片问题没有了,但停顿时间不得不变长。还提供了另外一个参数-XX:+CMSFullGCBeforeCompaction,这个参数是用于设置执行多少次不压缩的Full GC后,跟着来一次带压缩的(默认值是0,表示每次进入Full GC时都进行碎片整理)

3)、G1收集器

G1垃圾收集器是基于标记整理算法实现的,是一个分代垃圾收集器,既负责年轻代,也负责老年代的垃圾回收

G1具备以下特点:

  • 并行与并发:G1能充分利用多CPU、多核环境下的硬件优势,使用多个CPU来缩短Stop The World停顿的时间,部分其他收集器原本需要停顿Java线程执行的GC动作,G1收集器仍然可以通过并发的方式让Java程序继续执行
  • 分代收集
  • 空间整合:G1从整体上看是基于标记-整理算法实现的收集器,从局部(两个Region之间)上看是基于复制算法实现的,这两种算法都意味着G1运作期间不会产生内存空间碎片,收集后能提供规整的可用内存
  • 可预测的停顿:G1能建立可预测的停顿时间模型,能让使用者明确指定一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒,这几乎已经是实时Java的垃圾收集器的特征了

G1将整个Java堆划分为多个大小相等的独立区域(Region),虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔离的了,它们都是一部分Region(不需要连续)的集合

G1收集器之所以能建立可预测的停顿时间模型,是因为它可以有计划地避免在整个Java堆中进行全区域的垃圾收集。G1跟踪各个Region里面的垃圾堆积的价值大小(回收所获取的空间大小以及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region。这种使用Region划分内存空间以及有优先级的区域回收方式,保证了G1收集器在有限的时间内可以获取尽可能高的收集效率

在G1收集器中,Region之间的对象引用以及其他收集器中的新生代与老年代之间的对象引用,虚拟机都是使用Remembered Set来避免全堆扫描的。G1中每个Region都有一个与之对应的Remembered Set,虚拟机发现程序在对Reference类型的数据进行写操作时,会产生一个Write Barrier暂时中断写操作,检查Reference引用的对象是否处于不同的Region之中,如果是,便通过CardTable把相关引用信息记录到被引用对象所属的Region的Remembered Set之中。当进行内存回收时,在GC根节点的枚举范围中加入Remembered Set即可保证不对全堆扫描也不会有遗漏

G1的运作大致划分为几个步骤:

  • 初始标记
  • 并发标记
  • 最终标记
  • 筛选回收

初始标记阶段仅仅只是标记一下GC Roots能直接关联到的对象,并且修改TAMS的值,让下一阶段用户程序并发运行时,能在正常可用的Region中创建新对象,这阶段需要停顿线程,但耗时很短

并发标记阶段是从GC Root开始对堆中对象进行可达性分析,找出存活的对象,这阶段耗时较长,但可与用户程序并发执行

而最终标记阶段则是为了修改正在并发标记期间因用户程序继续运作而导致标记产生变动的那一部分标记记录,虚拟机将这段时间对象记录在线程Remebered Set Logs里面,最终标记阶段需要把Remebered Set Logs的数据合并到Remebered Set中,这阶段需要停顿线程,但是可并行执行

最后在筛选回收阶段首先对各个Region的回收价值和成本进行排序,根据用户所期望的GC停顿时间来制定回收计划,这个阶段其实也可以做到与用户程序一起并发执行,但是因为只回收一部分Region,时间是用户可控制的,而且停顿用户线程将大幅提高收集效率

在这里插入图片描述

五、内存分配与回收策略

1、对象优先在Eden分配

大多数情况下,对象在新生代Eden区中分配。当Eden区没有足够空间进行分配时,虚拟机将发起一次Minor GC

虚拟机提供了-XX:+PrintGCDetails这个收集器日志参数,告诉虚拟机在发生垃圾收集行为时打印内存回收日志,并在进程退出的时候输出当前的内存各区域分配情况

补充:

1)、-Xms20M -Xmx20M -Xmn10M -XX:SurvivorRatio=8几个参数的含义

在运行时通过-Xms20M-Xmx20M-Xmn10M这3个参数限制了Java堆大小为20MB,不可扩展,其中10MB分配给新生代,剩下的10MB分配给老年代。-XX:SurvivorRatio=8决定了新生代中Eden区与一个Survivor区的空间比例是8:1,“eden space 8192K、from space 1024K、to space 1024K”,新生代总可用空间为9216KB(Eden区+1个Survivor区的总容量)

2Minor GC和Full GC有什么不一样?

  • 新生代GC(Minor GC):指发生在新生代的垃圾收集动作,因为Java对象大多都具备朝生夕死的特性,所以Minor GC非常频繁,一般回收速度也比较快
  • 老年代GC(Full GC):指发生在老年代的GC,出现了Full GC经常会伴随至少一次的Minor GC。Full GC的速度一般会比Minor GC慢10倍以上

2、大对象直接进入老年代

大对象指需要大量连续内存空间的Java对象,最典型的大对象就是那种很长的字符串或数组

虚拟机提供了一个-XX:PretenureSizeThreshold参数,使得大于这个设置值的对象直接在老年代分配。这样做的目的是避免在Eden区及两个Survivor区之间发生大量的内存复制

PretenureSizeThreshold参数只对Serial和ParNew两款收集器有效,Parallel Scavenge收集器不认识这个参数,Parallel Scavenge收集器一般并不需要设置。如果遇到必须使用此参数的场合,可以考虑ParNew加CMS的收集器组合

3、长期存活的对象将进入老年代

虚拟机给每个对象定义了一个对象年龄计数器,如果对象在Eden出生并经过第一次Minor GC后仍然存活,并且能被Survivor容纳的话,将被移动到Survivor空间中,并且对象年龄设为1。对象在Survivor区中每熬过一次Minor GC,年龄就增加1岁,当它的年龄增加到一定程度(15岁),就将会被晋升到老年代中

补充:

1)、JVM的分代年龄为什么是15

MarkWord中对象的分代年龄占4位,最大值为1111也就是15

4、动态对象年龄判定

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

5、空间分配担保

在发生Minor GC之前,虚拟机会检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那么Minor GC可以确保是安全的。如果不成立,则虚拟机会检查HandlePromotionFailure设置值是否允许担保失败。如果允许,那么会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行一次Minor GC,尽管这次Minor GC是有风险的;如果小于或者HandlePromotionFailure设置不允许冒险,那这时也要改为进行一次Full GC。如果出现了担保失败,那就只好在失败后重新发起一次Full GC

取平均值进行比较其实仍然是一种动态概率的手段,也就是说,如果某次Minor GC存活后的对象突增,远远高于平均值的话,依然会导致。如果出现了担保失败,那就只好在失败后重新发起一次Full GC

发布了177 篇原创文章 · 获赞 407 · 访问量 8万+

猜你喜欢

转载自blog.csdn.net/qq_40378034/article/details/103830201