三对象创建与回收

创建对象流程

  • 一个对象的创建过程一般如下:
  1. 首先是代码执行到new关键词,于是根据new后面的参数到常量池总定位该类的符号引用。
  2. 如果没有找到这个符号引用,说明类还没被加载,那么进行类的加载、连接和初始化。
  3. 然后jvm为实例在堆中分配内存,并把该内存空间都初始化为0值。
  4. 初始化后,jvm会进行一些必要的设置,如,把这个对象是哪个类的实例、在GC中的分代年龄信息放到对象头中。
  5. 通过构造函数对该对象进行初始化。

为实例分配内存

  • 在堆中给实例分配内存的方式有两种:指针碰撞和空闲列表。具体使用哪一种,就要看jvm中使用的是什么垃圾回收机制了。
  • 指针碰撞:如果回收空间后能做到压缩整理,使得Java堆中的内存是绝对规整的,即所有已分配内存都凑在一块连续空间,而空闲的凑在另一块,那么只要在中间放着一个指针作为分界点的指示器,在分配空间时把指针向空闲那边移动(即把部分“空闲空间”划到“已分配空间”那边)即可。

三对象创建与回收_第1张图片

  • 空闲列表:有一个列表,其中记录了哪些内存块空闲,在分配的时候从列表中找到一块足够大的划分给实例,然后更新列表。

对象具体内容

  • 对象在内存中包括三个部分:对象头、实例数据和对其填充。
  • 对象头包括对象本身运行时的数据MarkWord(哈希码、GC分代年龄、线程持有的锁、锁状态、偏向时间戳、偏向线程id等)和类型指针(通过该指针可确定是哪个类的实例,如果是数组,还会存储数组的长度)。
  • 实例数据是对象真正存储的有效信息,即我们定义的各种字段的内容。
  • 对其填充是把实例数据填充补全到8的整数倍,因为HotSpot的内存管理要求对任何对象的大小必须是8的整数倍,而对象头已经被设计为8的整数倍了,但是实例数据就不一定了,所以需要补全。

垃圾回收

判断对象是否已死

  • 垃圾收集器在进行垃圾回收之前,要先判断对象是否已死,已死才能被回收。下面介绍两种主流的判断对象已死的算法。
  1. 引用计数算法:在对象中添加一个引用计数器,每当一个对象,计数器就加一,每当引用失效则减一。计数器为0时,说明对象不被任何人引用,那么可以回收。

    public static void method() {
    A a = new A();
    }

    public static void main(String[] args) {
    method();
    }

  • 当调用method()时,执行到new,把A的实例赋值给局部变量a时,堆中A的实例的计数器就会加一;当方法结束时,局部变量a随之销毁,那么A的实例的计数器就会减一。

  • python就是使用这种算法,但在Java中,该算法不能解决循环引用问题,即当对象A,B互相引用时,即A引用了B,B也包含了A的引用,那么当它们都为null时,它们的计数器也并不为0,如果采用引用计数算法,那么此时Java就不能回收它们了,显然是错误的。

    class A {
    private B b;
    public void setB(B b) {
    this.b = b;
    }
    }

    class B {
    private A a = new A();
    public void setA(A a) {
    this.a = a;
    }
    }

    public void method() {
    A a = new A();
    B b = new B();
    a.setB(b);
    b.setA(a);
    }

  • 堆中的实例A同时被栈内的局部变量a和堆内的实例B的成员变量a引用,实例B同时被栈内的局部变量b和堆内的实例A的成员变量b引用。

三对象创建与回收_第2张图片

  • 当method方法执行完毕后,栈被销毁,局部变量a,b自然也被销毁,于是它们对实例A,B的引用都失效,即图中两条红线消失,实例A,B的计数器各自减一;但问题是,堆中的实例不会随着方法完毕而被销毁,于是实例A中的成员变量a仍保持着对实例B的引用,所以实例B的计数器至少还是1,实例A的计数器同理也是1,但此时已经没有地方在用它们了,它们却还是不能被回收。
  1. 可达性分析算法:判断当前引用的对象是否处于某一GCRoot的引用链上。一类可以作为GCRoot的对象是栈中的对象,因为栈帧随着方法的执行自动进栈和出栈,所以在栈中的对象肯定是有用的,那么其引用的对象也必然是有用的,所以只要在这条引用链上的对象,就肯定得判定为存活。PS:可作为GCRoot的对象:虚拟机栈和本地方法栈中的对象、方法区中的静态属性、方法区中的常量。
  • JVM实际也会回收方法区内的常量和不再使用的类的信息。不过并没有强制要求回收方法区,主要原因是性价比太低,即判断条件高,回收空间少。对于常量,如果任何对象都没有引用它,那么就可以回收了,对于类,判断其是否可回收比较复杂:该类的所有实例被回收;加载该类的类加载器被回收;没有通过反射使用该类。

垃圾收集算法

  1. 标记-清除算法:分为标记阶段和清除阶段,前者将通过可达性分析将不可达的对象标记出来;后者会把被标记的垃圾对象清除。
  • 如图,堆中的黄色对象为不可达对象,在标记阶段被标记。

三对象创建与回收_第3张图片

  • 经过清除阶段后:

三对象创建与回收_第4张图片

  • 缺点:从上图显见,回收后会产生大量不连续的内存空间,即内存碎片。由于Java在分配内存时通常是按连续内存分配的,所以当碎片空间不足时就会被浪费掉。因此,目前基本没有GC还在使用该算法。
  1. 标记-复制算法:将内存空间分为两块,每次只分配其中一块内存给对象,当GC执行时,会将存活对象(在标记阶段中通过可达性分析后没被标记的对象)连续复制到另一空白空间中,然后清空之前使用的内存空间。
  • 优点:在存活对象少、垃圾对象多的情况下,只需要复制少数对象,十分高效,并且不会产生内存碎片。
  • 缺点:有一半的内存空间被浪费掉,只能空着用来复制存活对象,而不能被分配给新对象。
  1. 标记-压缩算法:分为三步——标记垃圾对象、清除垃圾对象和内存碎片整理。其实就是在标记-清除算法的基础上多了内存碎片整理这一步。

三对象创建与回收_第5张图片

三对象创建与回收_第6张图片

三对象创建与回收_第7张图片

  • 优点:当存活对象多时,可减少内存碎片的产生,碎片整理的代价会小很多。
  • 缺点:整理碎片会造成较大消耗。
  1. 分代算法:基于标记-复制算法和标记-压缩算法。
  • 将堆区分为年轻代(YoungGen)、老年代(OldGen)和持久代(PermGen)。分代即分年龄,年龄指的是对象熬过垃圾收集的次数。不同代中存放不同特点的对象,使用不同的垃圾收集算法。

  • (1)年轻代包括1个EdenSpace(伊甸园区)和2个SurvivorSpace(幸存者区)A,B。由于新生代中对象的特点是创建出来没多久就可以被回收(如,虚拟机栈中创建的对象,随着方法执行完毕就会被销毁),所以每次回收时大部分是垃圾,要复制的存活对象较少,故使用标记-复制算法。

  • 新创建的实例总是进入年轻代的Eden,当定期(或Eden满导致AllocationFailure分配失败)会触发垃圾回收。那么开始扫描Eden和SurvivorA,仍然存活的对象则被复制到SurvivorB中(被复制后对象年龄+1)。如果此次扫描发现有满15岁的对象(年龄保存在对象头中,用4bit表示,最大取到15),则直接复制到老年代,或者如果B满,则把要复制不进B区的存活对象直接复制到老年代。

  • 扫描、复制完毕后,正式开始清理已死对象,即把Eden和SurvivorA清空,然后把空了的SurvivorA和B交换,下次扫描的就是B了,而存放存活对象的区域则变成了空的A。

  • 自定义对象最大年龄,超过则直接放入老年代:

    -XX:MaxTenuringThreshold=15

  • (2)老年代主要存放JVM认为生命周期较长的对象(即经过几次垃圾回收后仍然存在的对象)和大对象(默认超过3M,因为大对象放在新生代的话则需要不断移动,性能较差),内存空间较大,垃圾回收也没那么频繁,基本都是在空间已满时才会发生。由于每次GC时大部分是存活对象,所以使用标记-压缩算法。

三对象创建与回收_第8张图片

  • 采用复制而不是保留原对象的策略,是为了减少内存碎片。因为一开始分配内存的时候就是顺序分配,在垃圾回收之前,已分配内存一直都是紧挨着的,直到开始垃圾回收,如果此时清掉中间某个已死对象,保留存活对象,那么已分配内存中间必然出现空缺,于是内存碎片产生了,如果采用的是清理掉已死对象,而把存活对象依次拷贝到SurvivorB的连续空间上,再清理掉旧对象的话,那么就不会产生内存碎片。

  • 自定义大对象大小:

    -XX:PretenureSizeThreShold=6M

  • (3)持久代主要存放类定义、字节码和常量等很少会变更的信息。注意:好像说就是方法区?

  • MinorGC、MajorGC和FullGC:

  • MinorGC:Minor,次要的;是清理、整合年轻代的过程,Eden和A、B区的清理都属于MinorGC。当AllocationFailure即年轻代空间不足时会触发MinorGC。

  • MajorGC:是清理、整合老年代的过程。大多数时候MajorGC是MinorGC触发的,所以很多情况下将这两种GC分离都是不太可能的。

  • FullGC:是清理、整合整个堆空间的过程,包括年轻代、老年代和持久代。当用户调用System.gc时,系统建议执行的是FullGC(但最终不一定执行的是FullGC),或年代晋升失败(如,Eden的存货对象晋升到B区放不下,尝试晋升到老年代还是放不下,也即是老年代空间不足时),或持久代空间不足,或检测到执行MinorGC时进入老年代的平均大小超过老年代当前可用空间,都会触发FullGC。

  • 以上分代收集理论存在一个问题,跨代引用,即年轻代被老年代引用,那么可达性分析是找不到这个位于老年代的GCRoot的。前面讲可达性分析时,提到GCRoot可以是在虚拟机栈中的对象,但老年代中的某些对象其实也可以作为GCRoot对象,这样一来,GC在进行扫描时,只扫描新生代,可达性分析也只针对新生代,不能追溯到位于老生代的对象,所以,我们可能会错误地回收了依然被老生代引用的新生代对象。不过这个问题存在的可能性不大,因为有一个假说认为,如果两个对象之间存在引用,那么它们应该是很难被分到不同代的,因此基本能做到共存亡。

  • 尽管如此,HotSpot虚拟机也为这个问题提供了解决方案:卡表。即将老年代内存均分为许多卡片,每张卡片包含一部分对象,这些卡片组成一张卡表数组,其中每个元素都指向一个卡片,并标识出该卡片中是否有指向新生代的对象,若某张卡片的标识=1,那么GC就需要扫描该卡片中哪些数据指向了哪些新生代。

三对象创建与回收_第9张图片

  • 优点:GC不需要扫描庞大的老年代,只需要扫描卡表和包含在其中某张卡片的对象即可。

经典垃圾收集器

  1. Serial(SerialOld)收集器:serial,连续的,顺序排列的;是个单线程收集器,只开一个线程来收集垃圾;在工作时用户线程必须停止,也就是常说的STW(StopTheWorld机制,是在执行垃圾收集算法时,Java应用程序的其他线程都被挂起),因为在年轻代中采用的是标记-复制算法(标记信息存储在对象头里),所以必须停止用户线程,防止错误地把用户线程新建的对象复制到老年代,导致该对象迟迟不能被回收。在老年代中采用标记-压缩算法。虽然这是最基础、历史最悠久的收集器,但相比其他单线程收集器依然是非常优秀的。在HotSpot虚拟机的客户端模式下,新生代的默认收集器就是它。

  2. ParNew收集器:是Serial的多线程版本,能同时开多个线程进行垃圾回收其他的与Serial没多大区别,仍然需要STW。是许多运行在server模式下的虚拟机新生代的首选收集器。

  3. ParallelScavenge(ParallelOld)收集器:scavenge,觅食,捡破烂;是一款新生代收集器,采用标记-复制算法。它也是多线程的,但与其他收集器不同的是,它更关注吞吐量(用户代码运行时间/(用户代码运行时间+垃圾收集时间)。适合经常在后台运算而不需要太多交互的任务。ParallelOld是其老年代版本,采用标记-整理算法。

  4. CMS收集器:ConcurrentMarkSweep,并发标记与扫除,老年代的收集器,使用标记-清除算法。注重减少STW的时间。其流程更加复杂:

  • 初始标记:根据GCRoots找到直接关联的对象。
  • 并发标记:根据初始标记阶段的对象找到更完整的关联对象(如果该阶段不能并发的话,将非常耗时)。
  • 重新标记:由于并发标记是和用户线程并发的,所以过程中难免会发生引用关系改变从而出现一些新的可回收对象的情况,需要重新标记。主要是利用原始快照的方式标记出上个阶段中变动的对象。
  • 并发清理:由于采用的是标记-清除算法,不需移动对象,所以可以和用户线程并发。
  • 优点:停顿时间少。
  • 缺点:在并发清理阶段用户线程会产生新的垃圾(浮动垃圾),无法在当次收集中处理掉它们;标记-清除算法会产生内存碎片;默认的回收线程数=(处理器核数+3)/4,对处理器敏感。
  1. G1收集器:G1,GarbageFirst,第一次把region的概念引入垃圾收集,是HotSpot力推的收集器;主要面向服务端应用;作用于整个Java堆(不局限于某个代);具有里程碑意义,因为把设计思想从原来的一次性把垃圾收集干净,到只是保证回收速度比分配速度快就行了,这样在满足需求的情况下,性能也得到了很大的提升。
  • 不再维护固定大小的新生代和老年代,而是把堆划分为多个等大的region(区域),每个region随时扮演新生代和老生代中的角色,由于新生代的空间不再是固定的,所以节省了扫描空闲新生代空间的时间,也就压缩了STW的时间。region间采用标记-复制算法,整体采用标记-整理算法。

  • 工作流程分为初始标记(STW)、并发标记、最终标记(STW)和筛选回收(STW)。前三个阶段与CMS类似,最后筛选回收时会对各个region的回收价值和成本排序,优先回收价值收益最大的regions。

  • 总结:多线程收集器适用于服务器,因为服务器的CPU一般是多核的。

低延迟垃圾收集器

  • 下面介绍几个低延迟的垃圾收集器。低延迟指的是STW的时间短,这是垃圾收集器的重要指标。随着内存条的容量越来越大,Java堆可用的内存也越来越大,这意味着需要回收的空间也越来越大,于是STW也就越久。
  1. Shenandoah收集器:一款非官方的收集器,由RedHat公司开发,但受到sun公司的排斥,所以在正式商用版的JDK中不支持该收集器,只在OpenJDK支持。能把STW的时间限制在10ms内。
  • 在代码上比后面的ZGC更像G1的继承者,很多阶段都与G1高度一致,甚至共用了一部分源码,并有如下改进:

  • 支持并发标记-压缩算法。

  • 默认不使用分代管理,虽然和G1一样使用region分区,但这些region不会扮演新生代或老年代。

  • 使用连接矩阵存储引用关系,比G1使用的记忆集节约了很多空间。

  • 工作流程:

  • 初始标记、并发标记、最终标记:和G1一致;用可达性分析算法找出已死对象,统计回收价值最高的region,组成一个回收集。

  • 并发清理:清理掉完全没有存活对象的region。

  • 并发回收:将回收集中存活的对象复制到空白的region中。

  • 初始引用更新:把存活对象的转发指针指向新的地址,并确保上一阶段的所有线程都完成了复制工作。

  • 并发引用更新:修改堆中引用了存活对象的引用地址(注意:只修改了堆中的未修改栈中的,所以还存在旧引用)。

  • 最终引用更新:修改存活对象在GCRoots中的引用地址。

  • 并发清理:清空回收集。

  • 并发时,如果用户线程是要来读对象的话,那么存在一个问题。如果在“并发回收”->“最终引用更新”时,即在移动存活对象地址后,所有指向该对象的引用都还是旧地址,此时用户线程就可能访问到旧地址。解决该问题的旧方案是保护陷阱,即当用户线程访问到对象的旧地址时,就会进入一个异常处理器(即读屏障),由该处理器转发到新的地址。而Shenandoah给出一种更好的方案:转发指针,即在原有对象布局前面加个新的、独立的引用字段,当不处于并发移动的情况时,该引用指向自己,如下图:

三对象创建与回收_第10张图片

  • 当并发移动时就指向新地址,如下图:

三对象创建与回收_第11张图片

  • 如果用户线程是来写对象的话,shenandoah会通过CAS操作,来保证收集器线程和用户线程只有一个可以修改同一对象,以此来保证并发时对象访问的正确性。

  • 优点:低延迟。

  • 缺点:使用大量的读写屏障,尤其是读屏障,增大了系统的性能开销。

  1. ZGC收集器:ZGarbageCollector,和Shenandoah相似,能将STW限制在10ms内。基于region布局,不支持分代管理,region分三种容量,小型region容量为2MB,只存放小于256KB的小对象;中型region32MB,存放大于等于256KB、小于4MB的对象;大型region容量不固定,但必为2的整数倍,存放大于等于4MB的对象,一个大region值存放一个大对象,并且不会被标记-复制,因为复制一个大对象的代价非常高昂。
  • 染色指针:引用类型实际就是指针,这个指针的值是某个对象的地址。目前在Linux64位操作系统中,指针的长度为64位,其中高18位不能用来寻址,是弃置不用的,而剩余的46位全部用来存储地址号的话,可以支持64T的空间,但实际我们根本用不了这么多的。于是我们拿出这46位中的高4位来作为标志位,用于存储可达性分析阶段的标记。

三对象创建与回收_第12张图片

  • finalizable:是否只能通过finalize()方法才能访问该引用指向的对象。
  • remapped:该引用指向的对象是否被移动过(即被复制到了空闲region中)。
  • marked1,marked0:该引用指向的对象的三色标记状态。在可达性分析中,使用三色标记来表示对象是否被收集器放过。白色表示对象未被访问过,在可达性分析刚开始时,所有对象都是白色的,若在分析结束时仍然保持白色,则说明该对象不可达;灰色表示对象已被访问过,但还没把所有指向该对象的引用都扫描完;黑色表示已经把所有指向该对象的引用都访问过。所有对象在扫描过程中都是由白变灰再变黑的,或者始终都是白色不变的。
  • 以前的三色标记都是隐藏在对象身上的,要么是隐藏在对象头中(如Serial收集器),要么是隐藏在类似对象头那样绑定在对象身上的字段(如Shenandoah收集器),而ZGC却是把它隐藏在引用身上,也就是在进行可达性分析时,既要扫描到对象也要扫描到指向对象的引用,当扫描到指向某个对象的引用时,顺便改变这个引用的标记位为灰色或黑色,这个改变操作就称为染色。以前操作的是对象实例表,而现在ZGC操作的是引用表。这个机制类似Shenandoah的转发指针,但转发指针只能实现转发,并不能同时修正引用的地址值,所以,只要引用还未修正过来,每次访问存活对象时就都会陷入转发,导致访问速度慢一点,而ZGC只陷入一次。

三对象创建与回收_第13张图片

  • 上图中,引用有几个,ZGC就要标记几次。

  • 但此时只是标记了而已,如果该引用指向的对象真的被移动了的话,那么此时还要修正引用的地址才对。ZGC采用的方法是在有存活对象的region中维护一张转发表,记录存活从旧地址到新地址的转向关系,当然,这张表在清空region时不能一起被清理,必须留下来。于是,如果用户线程通过未修正的引用访问到了旧地址也没关系,这次访问会被预置的内存屏障截获,然后根据转发表将访问转发到新地址,并修正该引用的地址值。该过程叫做指针的自愈。

  • 染色指针三大优势:

  • 一旦某个region的存活对象被复制好后,这个region就能够立即清除和重用,而不必等待整个堆中所有指向存活对象的引用都被修正后才能清理(Shenandoah就是这样)。这使得理论上只要还有一个空闲的region,ZGC就能顺利完成收集,因为它是复制完一个region后就能清理一个region,而Shenandoah则是复制完所有的region后再一次性清理所有的region,所以在复制阶段,有几个需要复制的region,就需要几个空白的region来承接。

  • 染色指针大幅减少了垃圾收集过程中的内存屏障,ZGC只使用了内存屏障。

  • 染色指针具有强大的扩展性,可以用来记录更多与对象标记、重定位过程相关的数据,以便日后进一步提高性能。

  • 内存多重映射:JVM作为一个普通的进程,这样随意重新定义指针的其中几位,处理器真的能支持吗?要知道,处理器可不会管指针中哪部分存的是标志位,哪部分是真正的地址值,只会把整个指针都视为一个内存地址。于是,ZGC提供了多重映射,将多个只有前置位不同的虚拟内存地址映射到同一个物理内存地址上,这使得无论染色指针前面4位怎么改变,只要后面不变,就能定位到同一个内存地址上。

  • 工作流程:

  • 初始标记、并发标记和最终标记:和Shenandoah一样,不过标记是在引用上而不是对象上。

  • 并发预备重分配:根据特定的查询条件统计出要清理哪些region,组成分配集。

  • 并发重分配:把分配集中的存活对象复制到新的region中,并为分配集中的每个region维护一个转发表。得益于染色指针的帮助,用户线程可以仅从引用上就知道对象是否在分配集中,是的话则会被内存屏障截获,并修正引用。

  • 并发重映射:修正堆中指向存活对象的所有旧引用。ZGC实际上把这一步巧妙地合并到了并发标记过程中,这一步并不是迫切需要完成的,上面的自愈过程完全可以避免旧引用问题,只不过是在第一步访问旧引用时触发了转发会稍慢一点;而合并到并发标记这一步上,是因为并发标记时也会遍历所有引用。

  • 优点:回收TB级内存(最大4TB);STW限制在10ms内。

  • 缺点:没有引入分代管理,导致它能承受的对象分配速率不高。如果长时间的回收速率比不上分配速率,那么产生的浮动垃圾将越来越多,可分配的空间也越来越小。因此,引入分代管理还是必须的,让新生代去专门存储需要频繁回收、创建的对象。

  • 新生代与老年代

  • 循环引用问题

  • 可达性分析算法

  • 垃圾收集算法

  • Shenandoah收集器

  • ZGC收集器的染色指针

  • ZGC的内存多重映射

  • MinorGC、MajorGC和FullGC

你可能感兴趣的:(三对象创建与回收)