美文网首页
深入理解对象和垃圾回收机制

深入理解对象和垃圾回收机制

作者: 仕明同学 | 来源:发表于2020-06-14 21:09 被阅读0次

    虚拟机创建对象的过程

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

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

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


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


      image.png

    使用Serial、ParNew(ParNew 收集器是年轻代常用的垃圾收集器,它采用的是复制算法)等带Compact过程的收集器时,系统采用的分配算法是指针碰撞,而使用CMS这种基于Mark-Sweep算法的收集器时,通常采用空闲列表

    MS,mark-sweep 算法的简写(先扫描整个 heap,标出可到达对象,然后执行 sweep 操作回收不可到达对象。这个算法本身比较简单)

    • MS 有以下问题
      heap 容易出现碎片
      破坏引用本地性(由于对象不会被移动,存活的对象与空闲空间交错在一起)
      GC 时间与 heap 空间大小成正比
      在进行 GC 期间,整个系统会被挂起,即stop-the-world
    指针碰撞 空闲列表

    并发安全的问题

    • 多线程下,CAS加上失败的重试,会导致性能的问题

    • 本地线程分配缓存TLAB
      TLAB的全称是Thread Local Allocation Buffer,即线程本地分配缓存区。这是一个线程专用的内存分配区域。
        由于对象一般会分配在堆上,而堆是全局共享的。因此在同一时间,可能会有多个线程在堆上申请空间。因此,每次对象分配都必须要进行同步,而在竞争激烈的场合分配的效率又会进一步下降。JVM使用TLAB来避免多线程冲突,每个线程使用自己的TLAB,这样就保证了不使用同步,提高了对象分配的效率

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

    内存分配完成后根据配置可能会需要将分配到的内存空间初始化为0值。

    • 如果使用TLAB,可以提前至在TLAB中执行这一操作。
    • 这一操作确保了对象的实例在java中可以不赋初始值而直接使用。因此程序可以访问到这些字段所对应的默认值

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


    image.png

    6、对象完成 初始化


    对象的内存的布局

    image.png image.png

    对象头

    Mark Word:包含一系列的标记位,比如轻量级锁的标记位,偏向锁标记位等等。在32位系统占4字节,在64位系统中占8字节;
    Class Pointer:用来指向对象对应的Class对象(其对应的元数据对象)的内存地址。在32位系统占4字节,在64位系统中占8字节;
    Length:如果是数组对象,还有一个保存数组长度的空间,占4个字节;

    对象实际数据

    对象实际数据包括了对象的所有成员变量,其大小由各个成员变量的大小决定,比如:byte和boolean是1个字节,short和char是2个字节,int和float是4个字节,long和double是8个字节,reference是4个字节(64位系统中是8个字节)

    对齐填充

    Java对象占用空间是8字节对齐的,即所有Java对象占用bytes数必须是8的倍数。例如,一个包含两个属性的对象:int和byte,这个对象需要占用8+4+1=13个字节,这时就需要加上大小为3字节的padding进行8字节对齐,最终占用大小为16个字节。

    对象头占用空间大小

    image.png
    • 32位系统和64位系统中对象所占用内存空间的大小:
    • 在32位系统下,存放Class Pointer的空间大小是4字节,MarkWord是4字节,对象头为8字节;
    • 在64位系统下,存放Class Pointer的空间大小是8字节,MarkWord是8字节,对象头为16字节;
    • 64位开启指针压缩的情况下,存放Class Pointer的空间大小是4字节,MarkWord是8字节,对象头为12字节;
    • 如果是数组对象,对象头的大小为:数组对象头8字节+数组长度4字节+对齐4字节=16字节。其中对象引用占4字节(未开启指针压缩的64位为8字节),数组MarkWord为4字节(64位未开启指针压缩的为8字节);
      静态属性不算在对象大小内。

    指针压缩

    从上文的分析中可以看到,64位JVM消耗的内存会比32位的要多大约1.5倍,这是因为对象指针在64位JVM下有更宽的寻址。对于那些将要从32位平台移植到64位的应用来说,平白无辜多了1/2的内存占用,这是开发者不愿意看到的。
    从JDK 1.6 update14开始,64位的JVM正式支持了 -XX:+UseCompressedOops 这个可以压缩指针,起到节约内存占用的新参数。

    对象的访问定位

    image.png

    1、句柄
    Java堆中将会划分出一块内存来作为句柄池,reference中 存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息
    2、直接指针
    使用直接指针访问,Java堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,而reference中存储的直接就是对象地址

    两者比较

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

    判断对象的存活

    引入计数方法

    引用计数是计算机编程语言中的一种内存管理技术,是指将资源(可以是对象内存磁盘空间等等)的被引用次数保存起来,当被引用次数变为零时就将其释放的过程。使用引用计数技术可以实现自动资源管理的目的。同时引用计数还可以指使用引用计数技术回收未使用资源的垃圾回收算法。

    当创建一个对象的实例并在堆上申请内存时,对象的引用计数就为1,在其他对象中需要持有这个对象时,就需要把该对象的引用计数加1,需要释放一个对象时,就将该对象的引用计数减1,直至对象的引用计数为0,对象的内存会被立刻释放。

    使用这种方式进行内存管理的语言:Objective-C
    Python在用,但主流虚拟机没有使用,因为存在对象相互引用的情况,这个时候需要引入额外的机制来处理,这样做影响效率,

    可达性分析(根可达)

    image.png

    在计算机编程中,跟踪垃圾收集(英语: Tracing garbage collection )是一种自动内存管理的算法,该算法通过分析某些“根”对象的引用关系,来确定需要保留的可访问对象,并释放其余的不可访问对象的内存空间。该算法在实际的软件工程中得到了广泛的应用

    • 来判定对象是否存活的。这个算法的基本思路就是通过一系列的称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连时,则证明此对象是不可用的。
      作为GC Roots的对象包括下面几种:
    • 虚拟机栈(栈帧中的本地变量表)中引用的对象。
    • 方法区中类静态属性引用的对象。
    • 方法区中常量引用的对象。
    • 本地方法栈中JNI(即一般说的Native方法)引用的对象。
    • JVM的内部引用(class对象、异常对象NullPointException、OutofMemoryError,系统类加载器)。
    • 所有被同步锁(synchronized关键)持有的对象。
    • JVM内部的JMXBean、JVMTI中注册的回调、本地代码缓存等
    • JVM实现中的“临时性”对象,跨代引用的对象(在使用分代模型回收只回收部分代时)

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

    finalize 方法强制对象不回收,但是这个线程执行比较低,需要延迟一下

    image.png

    finalize :当对象变成(GC Roots)不可达时,GC会判断该对象是否覆盖了finalize方法,若未覆盖,则直接将其回收。否则,若对象未执行过finalize方法,将其放入F-Queue队列,由一低优先级线程执行该队列中对象的finalize方法。执行finalize方法完毕后,GC会再次判断该对象是否可达,若不可达,则进行回收,否则,对象“复活”

    即使通过可达性分析判断不可达的对象,也不是“非死不可”,它还会处于“缓刑”阶段,真正要宣告一个对象死亡,需要经过两次标记过程,一次是没有找到与GCRoots的引用链,它将被第一次标记。随后进行一次筛选(如果对象覆盖了finalize),我们可以在finalize中去拯救。

    image.png image.png

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

    对象的分配策略

    image.png
    • 对象优先在Eden区中分配
      目前主流的垃圾收集器都会采用分代回收算法,因此需要将堆内存分为新生代和老年代。
      在新生代中为了防止内存碎片问题,因此垃圾收集器一般都选用“复制”算法。因此,堆内存的新生代被进一步分为:Eden区+Survior1区+Survior2区。
      每次创建对象时,首先会在Eden区中分配。
      若Eden区已满,则在Survior1区中分配。
      若Eden区+Survior1区剩余内存太少,导致对象无法放入该区域时,就会启用“分配担保”,将当前Eden区+Survior1区中的对象转移到老年代中,然后再将新对象存入Eden区。

    • 大对象直接进入老年代
      所谓“大对象”就是指一个占用大量连续存储空间的对象,如数组。
      当发现一个大对象在Eden区+Survior1区中存不下的时候就需要分配担保机制把当前Eden区+Survior1区的所有对象都复制到老年代中去。
      我们知道,一个大对象能够存入Eden区+Survior1区的概率比较小,发生分配担保的概率比较大,而分配担保需要涉及到大量的复制,就会造成效率低下。
      因此,对于大对象我们直接把他放到老年代中去,从而就能避免大量的复制操作。
      那么,什么样的对象才是“大对象”呢?
      通过-XX:PretrnureSizeThreshold参数设置大对象
      该参数用于设置大小超过该参数的对象被认为是“大对象”,直接进入老年代。

    • 生命周期较长的对象进入老年代
      老年代用于存储生命周期较长的对象,那么我们如何判断一个对象的年龄呢?
      新生代中的每个对象都有一个年龄计数器,当新生代发生一次MinorGC后,存活下来的对象的年龄就加一,当年龄超过一定值时,就将超过该值的所有对象转移到老年代中去。

    • 相同年龄的对象内存超过Survior内存一半的对象进入老年代
      如果当前新生代的Survior中,年龄相同的对象的内存空间总和超过了Survior内存空间的一半,那么所有年龄相同的对象和超过该年龄的对象都被转移到老年代中去。无需等到对象的年龄超过MaxTenuringThreshold才被转移到老年代中去。

    • 栈上分配之逃逸分析
        JVM允许将线程私有的对象打散分配在栈上,而不是分配在堆上。分配在栈上的好处是可以在函数调用结束后自行销毁,而不需要垃圾回收器的介入,从而提高系统性能。
        栈上分配的一个技术基础是进行逃逸分析,逃逸分析的目的是判断对象的作用域是否有可能逃逸出函数体。另一个是标量替换,允许将对象打散分配在栈上,比如若一个对象拥有两个字段,会将这两个字段视作局部变量进行分配。
    class Main {   
      public static void main(String[] args) {     
        example();   
      }   
      public static void example() {     
        Foo foo = new Foo(); //alloc     
        Bar bar = new Bar(); //alloc     
        bar.setFoo(foo);   
      }
    }  
    class Foo {}  
    class Bar {   
      private Foo foo;   
      public void setFoo(Foo foo) {     
        this.foo = foo;   
      }
    }
    

    在这个示例中,创建了两个对象(用alloc注释),其中一个作为方法的参数。方法setFoo()接收到foo参数后,保存Foo对象的引用。如果Bar对象保存在堆中,那么Foo的引用将逃逸。但在这种情况下,编译器可以使用逃逸分析确定Bar对象本身并没有逃逸example()的调用。这意味着Foo引用无法逃逸。因此,编译器可以安全地在栈上分配两个对象。

    各种引用

    • 强引用 = ,要回收的话,直接OOM

    • 软引用 SoftReference 将要发生OOM,就去回收,实际效果不大,开发中很少用,除非有种大的图片在内存中,然后去回收
      例如,一个程序用来处理用户提供的图片。如果将所有图片读入内存,这样虽然可以很快的打开图片,但内存空间使用巨大,一些使用较少的图片浪费内存空间,需要手动从内存中移除。如果每次打开图片都从磁盘文件中读取到内存再显示出来,虽然内存占用较少,但一些经常使用的图片每次打开都要访问磁盘,代价巨大。这个时候就可以用软引用构建缓存。

    • 弱引用 WeakReference 发生一次GC,就会回收,这个使用的多,在安卓中Handler就要这样包装使用,才是最正确的方法

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

    虚引用 PhantomReference 这个其实开发中也不会使用,因为这个出来就会被回收,看不到具体的值,这个主要是来监听GC是否在运用,我理解的就是这样
    最弱(随时会被回收掉)
    垃圾回收的时候收到一个通知,就是为了监控垃圾回收器是否正常工作

    • 千万不要再程序中使用 System.gc() 哈哈

    分代收集理论

    image.png

    个人理解:分新生代(Eden,翻译为伊甸园,就是新生代)和老年代( Tenured ,翻译为终生制,就是老年代)

    新生代
    占比和老年代为 1:3
    使用复制算法,因为新生代里面的对象都是朝夕生死,迭代的很快
    老年代

    每次GC的时候,如果是存活的对象,就直接放到from 区,同时格式化其他的区域

    垃圾回收算法

    复制算法(Copying)

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

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

    image.png
    专门研究表明,新生代中的对象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)

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

    image.png

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

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

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

    image.png

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

    相关文章

      网友评论

          本文标题:深入理解对象和垃圾回收机制

          本文链接:https://www.haomeiwen.com/subject/yccutktx.html