Java进阶之深入理解对象与垃圾回收机制

虚拟机中的对象

对象的分配

在这里插入图片描述

虚拟机遇到一条new指令时,首先检查是否被类加载器加载,如果没有,那必须先执行相应的类加载过程。
类加载就是把class加载到JVM的运行时数据区的过程(类加载后面有专门的专题讲)。

1)检查加载

首先检查这个指令的参数是否能在常量池中定位到一个类的符号引用(符号引用 :符号引用以一组符号来描述所引用的目标),并且检查类是否已经被加载、解析和初始化过。

2)分配内存

接下来虚拟机将为新生对象分配内存。为对象分配空间的任务等同于把一块确定大小的内存从Java堆中划分出来。

划分内存的两种方式

根据Java堆中的内存是否规整,有指针碰撞和空闲列表两种划分内存的方式。

指针碰撞

在这里插入图片描述

如果Java堆中的内存是绝对规整的,所有用过的内存都放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空间那边挪动一段与对象大小相等的距离,这种分配方式称为“指针碰撞”。

空闲列表

在这里插入图片描述

如果Java堆中的内存并不是规整的,已使用的内存和空闲的内存相互交错,那就没有办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式称为“空闲列表”。

选择哪种分配方式由Java堆是否规整决定,而Java堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。

如果是Serial、ParNew等带有压缩的整理的垃圾回收器的话,系统采用的是指针碰撞,既简单又高效。
如果是使用CMS这种不带压缩(整理)的垃圾回收器的话,理论上只能采用较复杂的空闲列表。

并发安全问题

除如何划分可用空间之外,还有另外一个需要考虑的问题是对象创建在虚拟机中是非常频繁的行为,即使是仅仅修改一个指针所指向的位置,在并发情况下也并不是线程安全的,可能出现正在给对象A分配内存,指针还没来得及修改,对象B又同时使用了原来的指针来分配内存的情况。解决这个问题有两种方案,CAS和TLAB:

CAS+失败重试

CAS这种方案是对分配内存空间的动作进行同步处理——实际上虚拟机采用CAS配上失败重试的方式保证更新操作的原子性;
在这里插入图片描述

本地线程分配缓冲(TLAB)

另一种是把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在Java堆中预先分配一小块私有内存,也就是本地线程分配缓冲(Thread Local Allocation Buffer,TLAB),JVM在线程初始化时,同时也会申请一块指定大小的内存,只给当前线程使用,这样每个线程都单独拥有一个Buffer,如果需要分配内存,就在自己的Buffer上分配,这样就不存在竞争的情况,可以大大提升分配效率,当Buffer容量不够的时候,再重新从Eden区域申请一块继续使用。
TLAB的目的是在为新对象分配内存空间时,让每个Java应用线程能在使用自己专属的分配指针来分配空间,减少同步开销。
TLAB只是让每个线程有私有的分配指针,但底下存对象的内存空间还是给所有线程访问的,只是其它线程无法在这个区域分配而已。当一个TLAB用满(分配指针top撞上分配极限end了),就新申请一个TLAB。

TLAB总结:
需要TLAB的原因就是提高对象在堆上的分配效率而采用的一种手段,就是给每个线程分配一小块私有的堆空间(从Eden区中划出的),这样每个线程在堆中分配对象时,就可以先分配到自己所属的那一块堆空间中,避免多线程同步带来的效率问题。

TLAB的思想:与ThreadLocal的思想一样,以空间换取时间,提高效率。

参数:
-XX:+UseTLAB
允许在年轻代空间中使用线程本地分配块(TLAB)。默认情况下启用此选项。要禁用TLAB,请指定-XX:-UseTLAB。

https://docs.oracle.com/javase/8/docs/technotes/tools/unix/java.html

在这里插入图片描述

3)内存空间初始化(设置对象的字段的零值)

(注意不是构造方法)内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(如int值为0,boolean值为false等等)。这一步操作保证了对象的实例字段在Java代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。

4)设置(设置对象头数据)

接下来,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息(Java classes在Java hotspot VM内部表示为类元数据)、对象的哈希码、对象的GC分代年龄等信息。这些信息存放在对象的对象头之中。

5)对象初始化

在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了,但从Java程序的视角来看,对象创建才刚刚开始,所有的字段都还为零值。所以,一般来说,执行new指令之后会接着把对象按照程序员的意愿进行初始化(构造方法),这样一个真正可用的对象才算完全产生出来。

对象的内存布局

在这里插入图片描述
在HotSpot虚拟机中,对象在内存中存储的布局可以分为3块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。

对象头
对象头包括两部分信息,第一部分用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等。

对象头的另外一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。

如果对象是一个java数组,那么在对象头中还有一块用于记录数组长度的数据。

实例数据
实例数据部分是对象真正存储的有效信息,也是在程序代码中所定义的各种类型的字段内容。无论是从父类继承下来的,还是在子类中定义的,都需要记录起来。

对齐填充
第三部分对齐填充并不是必然存在的,也没有特别的含义,它仅仅起着占位符的作用。由于HotSpot VM的自动内存管理系统要求对对象的大小必须是8字节的整数倍。当对象其他数据部分没有对齐时,就需要通过对齐填充来补全。

在这里插入图片描述
也可参见:https://github.com/openjdk/jdk/blob/master/src/hotspot/share/oops/markWord.hpp

对象的访问定位

在这里插入图片描述

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

句柄(指针的指针)

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

直接指针

如果使用直接指针访问, reference中存储的直接就是对象地址。
这两种对象访问方式各有优势,使用句柄来访问的最大好处就是reference中存储的是稳定的句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而reference本身不需要修改。
使用直接指针访问方式的最大好处就是速度更快,它节省了一次指针定位的时间开销,由于对象的访问在Java中非常频繁,因此这类开销积少成多后也是一项非常可观的执行成本。
对Sun HotSpot而言,它是使用直接指针访问方式进行对象访问的。

判断对象的存活

在堆里面存放着几乎所有的对象实例,垃圾回收器在对对进行回收前,要做的事情就是确定这些对象中哪些还是“存活”着,哪些已经“死去”(即不可能再被任何途径使用的对象)

引用计数法

在对象中添加一个引用计数器,每当有一个地方引用它,计数器就加1,当引用失效时,计数器减1.
Python语言用的是引用计数法,但主流虚拟机没有使用,因为存在对象相互引用的情况,这个时候需要引入额外的机制来处理,这样做影响效率,
在这里插入图片描述
在这里插入图片描述
在代码中看到,只保留相互引用的对象还是被回收掉了,说明JVM中采用的不是引用计数法。

可达性分析

在这里插入图片描述

(面试时重要的知识点,牢记)
在主流的商用程序语言中(Java和C#),都是使用可达性分析算法来判定对象是否存活的。这个算法的基本思路就是通过一系列的称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连时,则证明此对象是不可引用的,所以它们将会被判定为可回收对象。

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

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象。
  • 方法区中类静态属性引用的对象。
  • 方法区中常量引用的对象。
  • 本地方法栈中JNI(即一般说的Native方法)引用的对象。
  • JVM的内部引用(class对象、异常对象NullPointException、OutofMemoryError,系统类加载器)。
  • 所有被同步锁(synchronized关键)持有的对象。
  • JVM内部的JMXBean、JVMTI中注册的回调、本地代码缓存等
  • JVM实现中的“临时性”对象,跨代引用的对象(在使用分代模型回收只回收部分代时)

以上的回收的都是堆中的对象。

虚拟机栈中的引用所指向的对象必然是GC Roots,不然方法还在执行时虚拟机栈中的引用所指向的对象就被GC回收了。

finalize()方法

即使通过可达性分析判断不可达的对象,也不是“非死不可”,它还会处于“缓刑”阶段,真正要宣告一个对象死亡,需要经过两次标记过程:

  1. 可达性分析后没有找到与GCRoots的引用链,它将被第一次标记。
  2. 查看对象是否有finalize方法,如果有重写且在方法内完成自救(比如再建立引用),还是可以抢救一下,注意这边一个类的finalize只执行一次,这就会出现一样的代码第一次自救成功第二次失败的情况。如果类重写finalize且还没调用过,会将这个对象放到一个叫做F-Queue的序列里,这边finalize不承诺一定会执行,这么做是因为如果里面死循环的话可能会使F-Queue队列处于等待,严重会导致内存崩溃,这是我们不希望看到的。

代码演示:
在这里插入图片描述
运行结果:
在这里插入图片描述
可以看到,对象可以被拯救一次(finalize执行第一次,但是不会执行第二次)
代码改一下,再来一次。

在这里插入图片描述
运行结果:
在这里插入图片描述
对象没有被拯救,这个就是因为finalize方法执行缓慢,还没有完成拯救,垃圾回收器就已经回收掉了。
所以建议大家尽量不要使用finalize,因为这个方法太不可靠。在生产中你很难控制方法的执行或者对象的调用顺序,建议大家忘了finalize方法!因为在finalize方法能做的工作,java中有更好的其他方式,比如try-finally或者其他方式可以做得更好。

Java中的四种引用类型

强引用 StrongReference

一般的Object obj = new Object() ,就属于强引用。在任何情况下,只有有强引用关联(与根可达)还在,垃圾回收器就永远不会回收掉被引用的对象(即便抛出OOM异常也不会回收被引用的对象)。

软引用 SoftReference

一些有用但是并非必需,用软引用关联的对象,系统将要发生内存溢出(OutOfMemory)之前,这些对象就会被回收(如果这次回收后还是没有足够的空间,才会抛出内存溢出异常)。

SoftReference的主要特点就是:当内存不够的时候,GC会回收SoftReference所引用的对象。

参见代码:
VM参数 -Xms10m -Xmx10m -XX:+PrintGC
在这里插入图片描述
运行结果
在这里插入图片描述
例如,一个程序用来处理用户提供的图片。如果将所有图片读入内存,这样虽然可以很快的打开图片,但内存空间使用巨大,一些使用较少的图片浪费内存空间,需要手动从内存中移除。如果每次打开图片都从磁盘文件中读取到内存再显示出来,虽然内存占用较少,但一些经常使用的图片每次打开都要访问磁盘,代价巨大。这个时候就可以用软引用构建缓存。

软引用可以和一个引用队列(ReferenceQueue)联合使用,如果软引用所引用的对象被垃圾回收器回收,Java虚拟机就会把这个软引用加入到与之关联的引用队列中。

弱引用 WeakReference

一些有用(程度比软引用更低)但是并非必需,用弱引用关联的对象,只能生存到下一次垃圾回收之前,GC发生时,不管内存够不够,都会被回收。
参看代码:
在这里插入图片描述
在这里插入图片描述
注意:软引用 SoftReference和弱引用 WeakReference,可以用在内存资源紧张的情况下以及创建不是很重要的数据缓存。当系统内存不足的时候,缓存中的内容是可以被释放的。

实际运用(WeakHashMap、ThreadLocal)

虚引用 PhantomReference

即幽灵引用。
最弱的引用关系(随时会被回收掉),不能通过虚引用取得所引用的对象,为对象设置虚引用的唯一目的就是在这个对象被垃圾收集器回收时收到一个系统通知。

如果一个对象仅持有虚引用,那么它就和没有任何引用一样,它随时可能会被回收。

虚引用用 PhantomReference 类来表示,它只有一个构造函数和一个 get() 方法,而且它的 get() 方法仅仅是返回一个null,也就是说将无法通过虚引用来获取对象,虚引用必须要和 ReferenceQueue 引用队列一起使用。

引用队列 ReferenceQueue

我们希望当一个对象被gc掉的时候通知用户线程,进行额外的处理时,就需要使用引用队列了。当一个对象被gc掉之后,该对象关联的引用对象(SoftReference,WeakReference,PhantomReference)会被放入ReferenceQueue中。我们可以从ReferenceQueue中获取到相应的对象信息,同时进行额外的处理。比如反向操作,数据清理等。

/**
 * PhantomReference及ReferenceQueue
 */
public class ReferenceQueueTest {
    
    
    public static boolean isRun = true;

    public static void main(String[] args) throws Exception {
    
    
        String abcString = new String("abc");
        System.out.println("新建字符串=" + abcString.getClass() + "@" + abcString.hashCode());
        final ReferenceQueue<String> referenceQueue = new ReferenceQueue<String>();
        new Thread() {
    
    
            @Override
            public void run() {
    
    
                while (isRun) {
    
    
                    PhantomReference phantomReference = (PhantomReference) referenceQueue.poll();
                    if (phantomReference != null) {
    
    
                        try {
    
    
                            //PhantomReference的referent是不可访问的,所以不能通过get()方法获取referent,只能通过反射获取所引用的对象referent
                            Field referentField = Reference.class.getDeclaredField("referent");
                            referentField.setAccessible(true);
                            Object result = referentField.get(phantomReference);
                            System.out.println("gc collected:"
                                    + result.getClass() + "@"
                                    + result.hashCode() + "\t"
                                    + (String) result);
                        } catch (Exception e) {
    
    
                            e.printStackTrace();
                        }
                    }
                }
            }
        }.start();
        PhantomReference<String> stringPhantomReference = new PhantomReference<String>(abcString, referenceQueue);
        abcString = null;//强引用置为null
        System.gc();
        Thread.currentThread().sleep(3000);//gc线程是优先级比较低的线程,所以main线程等待一会儿
        isRun = false;
    }

}

JVM垃圾回收区域

程序计数器、虚拟机栈、本地方法栈三个区域随线程而生,随线程而灭;栈中的栈帧随着方法的进入和退出而有条不紊的执行着出栈和入栈操作。每一个栈帧中分配多少内存基本是在类结构确定下来时就已知的,因此这三个区域的内存分配和回收都具有确定性,在这三个区域内不需要过多考虑回收的问题,因为方法结束或者线程结束时,内存自然就跟着回收了。

而java堆和方法区不一样,一个接口中的多个实现类需要的内存可能不一样,一个方法中的多个分支需要的内存也不一样,我们只有在程序运行时才能知道会创建哪些对象,这部分内存的分配和回收是动态的,垃圾收集器所关注的是这部分内存。

方法区的回收

方法区(永久代)的垃圾收集主要回收两部分内容:废弃常量无用的类

回收无用的类

下面是类的回收条件(即卸载方法区中的类信息的条件):
注意Class要被回收,条件比较苛刻,必须同时满足以下的条件(仅仅是可以,不代表必然,因为还有一些参数可以进行控制):
1、该类所有的实例都已经被回收,也就是堆中不存在该类的任何实例。
2、加载该类的ClassLoader已经被回收。
3、该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
4、参数控制:
在这里插入图片描述

回收废弃常量

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

学习垃圾回收的意义

GC:
Garbage Collection

Java与C++等语言最大的技术区别
自动化的垃圾回收机制(GC)

谁需要GC?
栈:栈中的生命周期是跟随线程,所以GC一般不需要关注这块区域
堆:堆中的对象是垃圾回收的重点
方法区(永久代/元空间):这一块也会发生垃圾回收,不过这块的效率比较低,一般不是我们关注的重点

即GC关注的内存区域是堆和方法区,重点是堆。

GC要做的事
1、Where/Which?
2、When?
3、How?

为什么要了解GC和内存分配策略:
1、面试需要
2、GC对应用的性能是有影响的;
3、写代码有好处

对象的分配策略

对象分配流程图:
在这里插入图片描述

下图是摘自 实战Java虚拟机 一书
在这里插入图片描述

栈上分配

我们常说“几乎所有的对象都在堆中分配”,注意是“几乎”,不是所有对象,因为JVM中对象也可以在栈上进行分配,但是前提是需要进行逃逸分析。

什么是栈上分配

在我们的应用程序中,其实有很多的对象的作用域都不会逃逸出方法外,也就是说该对象的生命周期会随着方法的调用开始而开始,方法的调用结束而结束,对于这种对象,是不是该考虑不将对象分配在堆空间中呢?因为一旦分配在堆空间中,当方法调用结束,就没有了引用指向该对象,该对象就需要被gc回收,而如果存在大量的这种情况,对gc来说无疑是一种负担。

因此,JVM提供了一种叫做栈上分配的概念,针对那些作用域不会逃逸出方法的对象,在分配内存时不在将对象分配在堆内存中,而是将对象分配在栈(线程私有的,属于栈内存)上,这样,随着方法的调用结束,栈空间的回收就会将栈上分配的的对象回收掉,不再给gc增加额外的无用负担,从而提升应用程序整体的性能。

什么是逃逸

逃逸是指在某个方法之内创建的对象,除了在该方法体之内被引用之外,还在该方法体之外被其它变量引用到;这样带来的后果是在该方法执行完毕之后,该方法中创建的对象将无法被GC回收(由于其被该方法体之外的其它变量引用)。

通俗一点讲,当一个对象的指针被多个方法或线程引用时,我们称这个指针发生了逃逸。

没有逃逸即方法中的对象没有发生逃逸。

逃逸分析的原理:主要就是分析对象的动态作用域。

逃逸有两种:方法逃逸和线程逃逸。
方法逃逸 (对象逃出当前方法):
当一个对象在方法里面被定义后,它可能被外部方法所引用,例如:作为调用参数传递到其它方法中。
线程逃逸 (对象逃出当前线程):
这个对象甚至可能被其它线程访问到,例如:赋值给类变量或赋值给可以在其它线程中访问到的变量。

从不逃逸到方法逃逸到线程逃逸,称之为对象由低到高的不同逃逸程度。

如果确定一个对象不会逃逸出线程之外,那么让对象在栈上分配内存可以提高JVM的效率。

逃逸分析代码验证

public class EscapeAnalysisTest {
    
    
   public static void main(String[] args) throws Exception {
    
    
       long start = System.currentTimeMillis();
       for (int i = 0; i < 50000000; i++) {
    
    
           allocate();
      }
       System.out.println((System.currentTimeMillis() - start) + " ms");
       Thread.sleep(600000);
  }static void allocate() {
    
    
       MyObject myObject = new MyObject(2020, 2020.6);
  }static class MyObject {
    
    
       int a;
       double b;MyObject(int a, double b) {
    
    
           this.a = a;
           this.b = b;
      }
  }
}

这段代码在调用的过程中 myObject 指向的对象没有发生逃逸,JVM可以做栈上分配。

然后通过开启和关闭DoEscapeAnalysis开关观察不同。
开启逃逸分析(JVM默认开启)
在这里插入图片描述
查看执行速度
在这里插入图片描述
关闭逃逸分析
在这里插入图片描述
查看执行速度
在这里插入图片描述
测试结果可见,开启逃逸分析对代码的执行性能有很大的影响!那为什么有这个影响?

逃逸分析(Escape Analysis)

关于 Java 逃逸分析的定义:
逃逸分析(Escape Analysis)简单来讲就是,Java Hotspot 虚拟机可以分析新创建对象的使用范围,并决定是否在 Java 栈上分配内存的一项技术。

如果逃逸分析后确定对象可以在栈上分配的话,那么该对象的生命周期就跟随线程了,就不需要垃圾回收,如果是频繁的调用此方法则可以得到很大的性能提高。
采用了逃逸分析后,满足逃逸分析(即没有发生逃逸)的对象在栈上分配
在这里插入图片描述
没有开启逃逸分析,则对象都在堆上分配,会频繁触发垃圾回收(垃圾回收会影响系统性能),导致代码运行慢
在这里插入图片描述
代码验证:
开启逃逸分析
-XX:+PrintGC //开启GC打印日志
-XX:+DoEscapeAnalysis //开启逃逸分析
在这里插入图片描述
可以看到没有GC日志,因为JVM没有发生GC。

关闭逃逸分析
-XX:+PrintGC //开启GC打印日志
-XX:-DoEscapeAnalysis //关闭逃逸分析
在这里插入图片描述
可以看到关闭了逃逸分析后,JVM在频繁的进行垃圾回收(GC),正是这一块的操作导致性能有较大的差别。

开启逃逸分析的优劣分析

进行逃逸分析后,如果不存在逃逸,则可以对这个变量进行优化:
1,栈上分配。
在一般应用中,不会逃逸的局部对象占比很大,如果使用栈上分配,那大量对象会随着方法结束而自动销毁,垃圾回收系统压力就小很多。
2,同步消除
线程同步本身比较耗时,如果确定一个变量不会逃逸出线程,无法被其它线程访问到,那这个变量的读写就不会存在竞争,对这个变量的同步措施
可以清除。
3,标量替换。
1),标量就是不可分割的量,java中基本数据类型,reference类型都是标量。相对的一个数据可以继续分解,它就是聚合量(aggregate)。
2),如果把一个对象拆散,将其成员变量恢复到基本类型来访问就叫做标量替换。
3),如果逃逸分析证明一个对象不会被外部访问,并且这个对象可以被拆散的话,那么程序真正执行的时候将可能不直接创建这个对象,而改为直接在栈上创建这个对象的若干个成员变量。

逃逸分析还不成熟。
1,不能保证逃逸分析的性能收益必定高于它的消耗。
判断一个对象是否逃逸耗时长,如果分析完发现没有几个不逃逸的对象,那时间就白白浪费了。
2,基于逃逸分析的优化手段不成熟,如上面提到的栈上分配,由于hotspot目前的实现方式导致栈上分配实现起来复杂。

逃逸分析的原理很简单,但JVM在应用过程中,还是有诸多考虑。
比如,逃逸分析不能在静态编译时进行,必须在JIT里完成。原因是,与java的动态性有冲突。因为你可以在运行时,通过动态代理改变一个类的行为,此时,逃逸分析是无法得知类已经变化了。

相关JVM参数:
-XX:+DoEscapeAnalysis 开启逃逸分析
-XX:+PrintEscapeAnalysis 开启逃逸分析后,可通过此参数查看分析结果。
-XX:+EliminateAllocations 开启标量替换
-XX:+EliminateLocks 开启同步消除
-XX:+PrintEliminateAllocations 开启标量替换后,查看标量替换情况。

对象优先在Eden区分配

虚拟机参数:
-Xms20m
-Xmx20m
-Xmn10m
-XX:+PrintGCDetails

-XX:+PrintGCDetails 打印垃圾回收日志,程序退出时输出当前内存的分配情况
注意:新生代初始时就有大小
大多数情况下,对象在新生代Eden区中分配。当Eden区没有足够空间分配时,虚拟机将发起一次Minor GC。

大对象直接进入老年代

-Xms20m
-Xmx20m
-Xmn10m
-XX:+PrintGCDetails
-XX:PretenureSizeThreshold=4m
-XX:+UseSerialGC

PretenureSizeThreshold参数只对Serial和ParNew两款收集器有效。
最典型的大对象是那种很长的字符串以及数组。这样做的目的:1.避免大量内存复制,2.避免提前进行垃圾回收,明明内存有空间进行分配。

长期存活对象进入老年区

如果对象在Eden出生并经过第一次Minor GC后仍然存活,并且能被Survivor容纳的话,将被移动到Survivor空间中,并将对象年龄设为1,对象在Survivor区中每熬过一次 Minor GC,年龄就增加1,当它的年龄增加到一定程度(并发的垃圾回收器默认为15),CMS是6时,就会被晋升到老年代中。
-XX:MaxTenuringThreshold调整

对象年龄动态判定

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

空间分配担保

在发生Minor GC之前,虚拟机会检查老年代最大可用的连续空间是否大于新生代所有对象的总空间,

  • 如果大于,则此次Minor GC是安全的。
  • 如果小于,则虚拟机会查看HandlePromotionFailure设置值是否允许担保失败。
    如果HandlePromotionFailure=true(即允许担保失败),那么会继续检查老年代最大可用连续空间是否大于历次晋升到老年代的对象的平均大小,如果大于,则尝试进行一次Minor GC,但这次Minor GC依然是有风险的,失败后会重新发起一次Full GC;如果小于或者HandlePromotionFailure=false,则改为进行一次Full GC。

所有才会说一次Full GC很有可能是由一次Minor GC触发的。

垃圾回收算法

垃圾回收算法的实现设计到大量的程序细节,并且每一个平台的虚拟机操作内存的方式都有不同,所以不需要去了解算法的实现,我们重点讲解分代收集理论和3种回收算法的思想。

分代收集理论

当前商业虚拟机的垃圾收集器,大多遵循“分代收集”的理论来进行设计,这个理论大体上是这么描述的:
1、绝大部分的对象都是朝生夕死。(那么这类对象就放在新生代这块区域)
2、熬过多次垃圾回收的对象就越难回收。(那么这类对象就放在老年代这块区域)

根据以上两个理论,朝生夕死的对象放一个区域,难回收的对象放另外一个区域,这个就构成了新生代和老年代。

GC种类

在这里插入图片描述

市面上发生垃圾回收的叫法很多,我大体整理了一下:
1、新生代回收(Minor GC/Young GC):指只是进行新生代的回收。
2、老年代回收(Major GC/Old GC):指只是进行老年代的回收。目前只有CMS垃圾回收器会有这个单独的收集老年代的行为。(Major GC定义是比较混乱,有说指是老年代,有的说是做整个堆的收集,这个需要你根据别人的场景来定,没有固定的说法)
3、整堆收集(Full GC):收集整个Java堆和方法区(注意:包含方法区)
在这里插入图片描述
上图中GC日志里面的GC就是指Minor GC/Young GC,Full GC就是整堆收集(Full GC)。
在这里插入图片描述

复制算法(Copying)

gc前,右边那一半内存空间存在对象:
在这里插入图片描述
进行gc,只需将存活的对象复制到另一半内存区域,原先的右边那一半内存空间一次性清除干净:
在这里插入图片描述

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

注意:内存移动是必须实打实的移动(复制),不能使用指针。

复制回收算法适合于新生代,因为大部分对象朝生夕死,那么复制过去到另一半内存的对象比较少,效率自然就高,原先那一半内存的一次性清理是很快的。

优点:
1.实现简单,运行高效
2.没有内存碎片产生
缺点:
1.利用率只有一半

IBM公司曾有一项专门研究对新生代“朝生夕灭”的特点做了更量化的诠释——新生代中的对象有98%熬不过第一轮收集。因此并不需要按照1∶1的比例来划分新生代的内存空间。
在1989年,Andrew Appel针对具备“朝生夕灭”特点的对象,提出了一种更优化的半区复制分代策略,现在称为“Appel式回收”。

Appel式回收 (改进的复制回收算法)

一种更加优化的复制回收分代策略:具体做法是把新生代分为一块较大的Eden空间和两块较小的Survivor空间(你可以叫做From或者To,也可以叫做Survivor1和Survivor2)
在这里插入图片描述

专门研究表明,新生代中的对象98%是“朝生夕死”的,所以并不需要按照1:1的比例来划分内存空间,而是将内存分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden和其中一块Survivor。当回收时,将Eden和Survivor中还存活着的对象一次性地复制到另外一块Survivor空间上,最后清理掉Eden和刚才用过的Survivor空间。

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

优点:
1.将复制算法的内存利用率由50%提高到90%,只有10%的内存空间浪费。

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

在这里插入图片描述

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

它的不足之处主要有两个:
效率问题:标记和清除两个过程的效率都不高
空间问题:标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。

回收的时候如果需要回收的对象越多,需要做的标记和清除的工作越多,所以标记清除算法适用于老年代。复制回收算法适用于新生代。

优点:
1.实现较容易,不需要移动对象

缺点:
1.执行效率不稳定,回收的对象越多,效率越低,回收的对象越少,效率越高。
2.会产生内存碎片,可能会提前出发另一次GC

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

在这里插入图片描述

首先标记出所有需要回收的对象,在标记完成后,后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。标记整理算法虽然没有内存碎片,但是效率偏低。

我们看到标记整理与标记清除算法的区别主要在于对象的移动。对象移动不单单会加重系统负担,同时需要全程暂停用户线程才能进行,同时所有指向这些对象的引用都需要更新(引用存储着对象的地址,即直接指针需要更新值)。

优点:
1.没有内存碎片产生
缺点:
1.需要移动对象
2.需要更新引用
3.用户线程暂停

所以看到,老年代采用的标记整理算法与标记清除算法,各有优点,各有缺点。

JVM中常见的垃圾收集器

分代收集的思想

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

请记住下图的垃圾收集器和它们之间的连线关系。
在这里插入图片描述

在这里插入图片描述
在这里插入图片描述
并行:垃圾收集器的多线程的同时进行。
并发:垃圾收集器的多线程和应用的多线程同时进行。(即垃圾收集线程和用户线程可以同时进行)

注:
吞吐量=运行用户代码时间/(运行用户代码时间+ 垃圾收集时间)
垃圾收集时间= 垃圾回收频率 * 单次垃圾回收时间

JVM具体使用哪种垃圾回收器可通过jvm参数配置,具体看官网JVM参数:https://docs.oracle.com/javase/8/docs/technotes/tools/unix/java.html

垃圾回收器工作示意图

Serial/Serial Old

最古老的,单线程,独占式,成熟,适合单CPU 服务器
-XX:+UseSerialGC 新生代和老年代都用串行收集器
-XX:+UseParNewGC 新生代使用ParNew,老年代使用Serial Old
-XX:+UseParallelGC 新生代使用ParallerGC,老年代使用Serial Old

ParNew

和Serial基本没区别,唯一的区别:多线程,多CPU的,停顿时间比Serial少
-XX:+UseParNewGC 新生代使用ParNew,老年代使用Serial Old

Parallel Scavenge(ParallelGC)/Parallel Old

关注吞吐量的垃圾收集器,高吞吐量则可以高效率地利用CPU时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。
所谓吞吐量就是CPU用于运行用户代码的时间与CPU总消耗时间的比值,即吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间),虚拟机总共运行了100分钟,其中垃圾收集花掉1分钟,那吞吐量就是99%。

单线程垃圾回收器和多线程并行的垃圾回收器工作示意图:
在这里插入图片描述

Concurrent Mark Sweep (CMS)

在这里插入图片描述
CMS收集器是一种以获取最短回收停顿时间为目标的收集器。目前很大一部分的Java应用集中在互联网站或者B/S系统的服务端上,这类应用尤其重视服务的响应速度,希望系统停顿时间最短,以给用户带来较好的体验。CMS收集器就非常符合这类应用的需求。

从名字(包含“Mark Sweep”)上就可以看出,CMS收集器是基于“标记—清除”算法实现的,它的运作过程相对于前面几种收集器来说更复杂一些,整个过程分为4个步骤,包括:

  • 初始标记-短暂,仅仅只是标记一下GC Roots能直接关联到的对象,速度很快。
  • 并发标记-和用户的应用程序同时进行,进行GC Roots追踪的过程
  • 重新标记-短暂,为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段稍长一些,但远比并发标记的时间短。
  • 并发清除 -和用户的应用程序同时进行

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

CMS的优点:
采用的是标记-清除算法,大幅减少STW时间。

CMS的缺点:
CPU敏感:CMS对处理器资源敏感,毕竟采用了并发的收集、当处理核心数不足4个时,CMS对用户的影响较大。
浮动垃圾:由于CMS并发清理阶段用户线程还在运行着,伴随程序运行自然就还会有新的垃圾不断产生,这一部分垃圾出现在标记过程之后,CMS无法在当次收集中处理掉它们,只好留待下一次GC时再清理掉。这一部分垃圾就称为“浮动垃圾”。
由于浮动垃圾的存在,因此需要预留出一部分内存,意味着 CMS 收集器不能像其它收集器那样等待老年代快满的时候再回收。( 在1.6的版本中,老年代空间使用率阈值是92% )

如果预留的内存不够存放浮动垃圾,就会出现 Concurrent Mode Failure,这时虚拟机将临时启用 Serial Old 来替代 CMS。(所以上面垃圾回收器关系图中可以看到CMS和Serial Old有一条连线)
会产生空间碎片:标记 - 清除算法会导致产生不连续的空间碎片

总体来说,CMS是JVM推出了第一款并发垃圾收集器,所以还是非常有代表性。
但是最大的问题是CMS采用了标记清除算法,所以会有内存碎片,当碎片较多时,给大对象的分配带来很大的麻烦,为了解决这个问题,CMS提供一个参数:-XX:+UseCMSCompactAtFullCollection,一般是开启的,即如果分配不了大对象,就进行内存碎片的整理过程,这时JVM一般会切换成Serial Old垃圾收集器来替代CMS,因为Serial Old是一个单线程,所以如果内存空间很大、且对象较多时,CMS发生切换成Serial Old这种情况时会很卡。

注意点:当JVM老年代使用的是CMS垃圾收集器时,如果产生了很多浮动垃圾或者很多内存碎片时,这时JVM一般会将CMS切换成Serial Old垃圾收集器。

-XX:+UseConcMarkSweepGC ,表示新生代使用ParNew,老年代的用CMS

G1

在这里插入图片描述

在这里插入图片描述
设置JVM使用G1收集器:-XX:+UseG1GC

并行与并发:G1能充分利用多CPU、多核环境下的硬件优势,使用多个CPU(CPU或者CPU核心)来缩短Stop-The-World停顿的时间,部分其他收集器原本需要停顿Java线程执行的GC动作,G1收集器仍然可以通过并发的方式让Java程序继续执行。
分代收集:与其他收集器一样,分代概念在G1中依然得以保留。虽然G1可以不需要其他收集器配合就能独立管理整个GC堆,但它能够采用不同的方式去处理新创建的对象和已经存活了一段时间、熬过多次GC的旧对象以获取更好的收集效果。
空间整合:与CMS的“标记—清理”算法不同,G1从整体来看是基于“标记—整理”算法实现的收集器,从局部(两个Region之间)上来看是基于“复制”算法实现的,但无论如何,这两种算法都意味着G1运作期间不会产生内存空间碎片,收集后能提供规整的可用内存。这种特性有利于程序长时间运行,分配大对象时不会因为无法找到连续内存空间而提前触发下一次GC。
内存布局:在G1之前的其他收集器进行收集的范围都是整个新生代或者老年代,而G1不再是这样。使用G1收集器时,Java堆的内存布局就与其他收集器有很大差别,它将整个Java堆划分为多个大小相等的独立区域(Region),虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔离的了,它们都是一部分Region(不需要连续)的集合。每一个区域的大小可以通过参数-XX:G1HeapRegionSize=size 来设置。
Region中还有一块特殊区域:Humongous区域,专门用于存储大对象,G1认为只要大小超过了一个Region容量一半的对象即可判定为大对象。如果对象超级大,那么使用连续的N个Humongous区域来存储。

一般在G1和CMS中间选择的话,看内存的大小,平衡点在6~8G,只有内存比较大G1才能发挥优势。

G1中重要的参数:
-XX:MaxGCPauseMillis 指定目标的最大停顿时间,G1尝试调整新生代和老年代的比例,堆大小,晋升年龄来达到这个目标时间。
-XX:ParallerGCThreads:设置GC的工作线程数量

未来的垃圾回收器

ZGC通过技术手段把stw的情况控制在仅有一次,就是第一次的初始标记才会发生,这样也就不难理解为什么GC停顿时间不随着堆增大而上升了,再大我也是通过并发的时间去回收了
关键技术
1.有色指针(Colored Pointers)
2.加载屏障(Load Barrier)

Stop The World现象

任何垃圾回收器都有Stop The World现象。

什么是STW?

在这里插入图片描述

垃圾回收器的垃圾回收线程在工作时需要暂停用户线程。

为什么要STW?

如果不暂停用户线程,垃圾回收不干净。

STW的危害在这里插入图片描述

STW时间太长会造成程序卡顿,对于用户来说用户体验不好。

GC收集器和我们GC调优的目标就是尽可能的减少STW的时间和次数。

jvm垃圾回收之引用计数算法和可达性分析算法
Java基础篇 - 强引用、弱引用、软引用和虚引用

面试官:你了解对象的分配吗?对象的分配策略,栈上分配与TLAB
用大白话来聊一聊Java对象的栈上分配和TLAB
JVM源码分析之线程局部缓存TLAB
Java堆内存是线程共享的!面试官:你确定吗?

猜你喜欢

转载自blog.csdn.net/yzpbright/article/details/108011503