您的位置:首页 > 编程语言 > Java开发

Java对象创建与垃圾收集器实现

2017-10-06 08:08 357 查看
  Java是一门面向对象的编程语言,在Java程序运行过程中,无时无刻都有对象被创建出来;当对象没被引用时,Java垃圾收集器此时发挥作用,回收没有用的对象,释放对应所占的内存。

1.Java对象创建

        在语言层面上,java创建对象通常仅仅是一个new关键字而已,而在虚拟机中,对象是如何创建的呢?

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

        在类加载检查通过后,接下来虚拟机将为新生对象分配内存对象所需内存的大小在类加载完成后便可完全确定,为对象分配空间的任务等同于把一块确定大小的内存从Java堆中划分出来。如果Java堆中内存是绝对规整的,用过的内存放在一边,空闲的内存放在另一边,中间放一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空间移动一段与对象大小相等的距离,这种方式称为“指针碰撞”。如果Java堆中内存不是规整的,虚拟机必须维护一个列表,记录哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式称为“空闲列表”。选择何种分配方式由Java堆是否规整决定,而Java堆是否规整则由垃圾收集器是否带有压缩整理功能决定

        除如何划分可用空间之外,还有另一个需要考虑的问题是对象创建在虚拟机中时非常频繁的行为,即使是仅仅修改一个指针所指向的位置,在并发情况下也并不是线程安全的。解决这个问题有两个方案:一种是对分配内存空间的动作进行同步处理--实际上虚拟机采用CAS配上失败重试的方式保证更新操作的原子性;另一种是把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在Java堆预先分配一个小块内存,称为本地线程分配缓冲(Thread
Local Allocation Buffer,TLAB)。哪个线程要分配内存,就在哪个线程的TLAB上分配,只有TLAB用完并分配新的TLAB时,才需要同步锁定

        内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值。如果使用TLAB,这一工作过程也可提前至TLAB分配时进行。这一步操作[b]保证了对象的实例字段在Java代码中可以不赋初始值就直接使用程序能访问到这些字段的数据类型所对应的零值。接下来,虚拟机要对对象进行必要的设置,如这个对象时哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息。

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

1.1对象的内存布局

        在HotSpot虚拟机中,对象在内存中存储的布局可以分为3个区域:对象头(Header),实例数据(Instance Data)和对齐填充(Padding)。
        HotSpot虚拟机的对象头包括两部分信息,第一部分是存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄。锁状态等,这部分数据的的长度在32位和64位的虚拟机中分别为32bit和64bit,官方称为"Mark Word"。它被设计成一个非固定的数据结构以便在极小的空间内存储尽量多的信息,它会根据对象的状态复用自己的存储空间。

存储内容标志位状态
对象哈希码,对象分代年龄01未锁定
指向锁记录的指针00轻量级锁定
指向重量级所的指针10膨胀(重量级锁定)
空,不要需要记录信息11GC标志
偏向线程ID,偏向时间戳,对象分代年龄等01可偏向
        对象头的另外一部分是类型指针,即对象指向它的类元数据的指针虚拟机通过这个指针来确定这个对象时哪个类的实例。并不是所有的虚拟机实现都必须在对象数据上保留类型指针。另外,如果对象是一个Java数组,那在对象头中还必须有一块用于记录数组长度的数据,因为虚拟机可以通过普通Java对象的元数据信息确定Java对象的大小,但从数组的元数据中却无法确定数组的大小。

1.2对象的访问定位

        建立对象是为了使用对象,Java程序需要通过栈上的reference数据来操作堆上的具体对象。由于reference类型在Java虚拟机规范中只规定了一个指向对象的引用,并没有定义这个引用应该通过何种上是去定位、访问堆中的对象的具体位置,所以对象访问方式是取决于虚拟机实现。目前主流的访问方式是使用句柄直接指针两种。
        如果使用句柄访问的话,那么Java堆中将会划分出一块内存来作为句柄池reference中存储的就是对象的句柄地址,而句柄中包含了对象的实例数据和类型数据各自的具体地址信息,如下图所示:
               


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


        这两种对象访问方式各有优势,使用句柄来访问对象,最大的好处就是reference中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据,而reference本身不需要修改。
        使用直接指针访问方式最大的好处就是速度更快,它节省了一次指针定位的时间开销,由于对象的访问在Java中非常频繁,因此这类开销积少成多后也是一项非常可观的执行成本。对于HotSpot虚拟机而言,是使用第二种方式进行对象访问的,但从整个软件开发的范围来看,使用句柄来访问的情况也是十分常见的。

1.3对象引用

        在JDK1.2以前,Java中的引用的定义很传统:如果reference类型的数据中存储的熟知代表的是另外一个内存的起始地址,就称这块内存代表着一个引用。而在JDK1.2之后,Java对引用的概念进行了扩充,将引用分为强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)、虚引用(Phantom Reference),这4种引用强度一次逐渐减弱。
强引用:是指在程序代码之中普遍存在的,一般用new创建出来的对象。只要强引用存在,垃圾收集器永远不会回收掉被引用的对象。
软引用:是用来描述一些还有用但并非必须得对象。对于软引用关联着的对象,在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围之中进行第二次回收。如果这次回收没有足够的内存,才会抛出内存溢出异常。
弱引用:是用来描述非必需的对象,但它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾回收之前当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。
虚引用:也称为幽灵引用或幻影引用,它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时受到一个系统通知。

1.4判断对象存活算法

        在堆里面存放这个Java世界中几乎所有的对象实例,垃圾收集器在对堆进行回收前,第一件事就是要确定这些对象之中哪些还“存活”着。哪些已经“死去”,而判断对象的“存活”有两种算法:引用计数算法和可达性分析算法

1.4.1引用计数算法

        引用计数算法判断对象是否存活的过程是这样的:给对象添加一个引用计数器,每当有一个地方引用它时,计数器值加1;当引用失效时,计数器值减1;任何时刻,计数器为0的对象就是不可能再被使用的
        客观地说,引用计数算法(Reference Counting)的实现简单,判断效率也很高,在大部分情况下它都是一个不错的算法,但它有一个很大的缺点:它很难解决对象之间相互循环引用的问题

1.4.2可达性分析算法

        在主流商用程序语言的实现中,都是通过可达性分析(Reachability Analysis)来判断对象是否存活。这个算法的基本思想是通过一系列称为"GC Roots"的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连时,则证明此对象是不可用的。如下图所示,对象object5,object6,object7虽然相互有关联,但他们到GC Roots是不可达的,所以它们将会被判定为是可回收的对象。
                


       在Java语言中,可作为GC Roots的对象包括下面几种:①虚拟机栈(栈帧中的本地变量表)中引用的对象②方法区中类静态属性引用的对象③方法区中常量引用的对象④本地方法栈中JNI(Native方法)引用的对象。

2.垃圾收集器实现

        说起垃圾收集(Garbage Collection,GC),大部分人都把这项技术当做Java语言的半生产物。事实上,GC的历史比Java久远。

2.1垃圾收集算法

        由于垃圾收集算法的实现涉及大量的程序细节,而且每个平台的虚拟机操作内存的方法又各有不同,因此这里只介绍几种算法的思想。

2.1.1标记-清除算法

        最基础的收集算法是“标记-清除(Mark-Sweep)”算法,该算法分为“标记”和“清除”两阶段:首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。它主要有两个不足:一个是效率问题,标记和清除两个过程的效率不高;灵一个是空间问题,标记清除后悔产生大量不连续的内存碎片,空间碎片太多可能会导致以后程序运行中分配较大对象时,无法找到足够的连续内存而提前触发另一次垃圾收集动作。示意图如下所示:
                


2.1.2复制算法

        为了解决效率问题,复制收集算法就出现了。它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块内存用完后,将还存活的对象复制到另外一块内存上,然后再把已使用过的内存空间一次清理掉。示意图如下所示:
              


        研究表明,新生代中的对象98%都是“朝生夕死”的,所以没有必要按照1:1的比例来划分内存空间,而将内存划分为一块较大的Eden空间和两块较小的Survivor空间每次使用Eden和其中一块Survivor。当回收时,将Eden和Survivor中还存活着对象一次性复制到另外一块Survivor空间上,最后清理掉Eden和刚才使用的Survivor空间。HotSpot虚拟机默认Eden和Survivor的大小比例是8:1。
        当Survivor空间不够用时,需要依赖其他内存(老年代)进行分配担保。如果另外一块Survivor空间没有足够空间存放上一次新生代收集下来的存活对象时,这些对象将直接通过分配担保机制进入老年代。

2.1.3标记-整理算法

        复制收集算法在对象存活率较高时就要进行较多的复制操作,效率会变低。所以老年代一般不选用这用算法,而使用“标记-整理(Mark-Compact)”算法,标记过程与“标记-清除”算法一样,但后续步骤是让所有存活对象都向一端移动,然后直接清理掉端边界意外的内存。示意图如下所示:
               


2.1.4分代收集算法

        当前商业虚拟机的垃圾收集都采用分代收集算法,它只是根据对象存活周期的不同将内存划分为几块。一般是把Java堆分为新生代和老年代,这样可以根据各个年代的特点采用最适当的收集算法。在新生代中,每次垃圾收集时都发现有大批对象死去,只有少数存活,那就选用复制算法,而老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用“标记-清理”或“标记-整理”算法来进行回收。

2.2垃圾收集器

        如果说垃圾收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。Java虚拟机规范中对垃圾收集器应该如何实现并没有任何规定,因此不同的厂商、不同版本的虚拟机所提供的垃圾收集器都可能会有很大差别,并且一般都会提供参数供用户根据自己的引用特点和要求组合出各个年代所使用的收集器。如下图所示,是HotSpot虚拟机包含的所有收集器。
                          


2.2.1Serial 收集器

        Serial收集器是最基础、发展历史最悠久的收集器,曾经是虚拟机新生代收集的唯一选择。这个收集器是一个单线程的收集器,“单线程”不仅仅说明它只会使用一个CPU或一条收集线程去完成垃圾收集工作,更重要的是在进行垃圾回收时,必须暂停其他所有工作线程,直到它收集结束,即就是“Stop The World”。如下图所示是Serial/Serial Old收集器的运行过程。
                    


        到目前为止,Serial收集器依然是虚拟机运行在Client模式下的默认新生代收集器,它简单高效。对于限定单个CPU的环境来说,它由于没有线程交互的开销,专心做垃圾收集就获最高的单线程收集效率。

2.2.2ParNew 收集器

        ParNew收集器其实是Serial收集器的多线程版本,除了使用多线程进行垃圾收集外,其余行为与Serial收集器一致。它的工作过程如下图所示:
                     


        ParNew收集器是Server模式下虚拟机首选的新生代收集器,其中有一个与性能无关的原因是它能与CMS收集器配合工作。它在单CPU环境下绝不会有比Serial收集器更好的效果。当然,随着使用的CPU数量增加,它对于GC时系统资源的有效利用是很有好处的。它默认开启的收集线程数与CPU的数量相同,也可以使用-XX:ParallelGCThreads参数来限制线程数。

2.2.3Parallel Scavenge 收集器

        Parallel Scavenge收集器是一个新生代收集器,它使用复制算法,又是并行的多线程收集器
aa84

        这个收集器的收集目标是达到一个可控制的吞吐量,它提供了两个参数用于精准控制吞吐量:①-XX:MaxGCPauseMillis:最大垃圾收集停顿时间 ②-XX:GCTimeRatio:设置吞吐量大小。除了这两个参数外,还有一个参数-XX:UseAdaptiveSizePolicy,这是一个开关参数。当这个参数打开后,不需要指定新生代的大小、Eden与Survivor的比例等参数,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量,这种调节方式称为GC自适应调节策略。自适应策略也是 Parallel
Scavenge收集器与ParNew收集器的一个重要区别。

2.2.4Serial Old 收集器

        Serial Old是Serial收集器的老年代版本,是一个单线程收集器,使用“标记-整理”算法。它主要也是给Client模式下的虚拟机使用,其工作过程如下图所示。
                   


2.2.5Parallel Old 收集器

        Parallel Old是Parallel Scanenge收集器的老年代版本,使用多线程和“标记-整理”算法。在JDK1.6中开始提供。
        直到Parallel Old收集器出现后,“吞吐量优先”的收集器终于有了应用组合,在注重吞吐量及CPU资源敏感的场合,可以优先考虑Parallel Scavenge加Parallel Old收集器。Parallel Scavenge/Parallel Old的工作过程如下图所示。
                   


2.2.6CMS 收集器

        CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收时间为目标的收集器,目前很大一部分的Java应用集中在互联网站或B/S系统的服务器上。
        CMS收集器是基于“标记-清除”算法实现,它整个过程分为4个步骤:①初始标记(CMS initial mark)②并发标记(CMS concurrent mark)③重新标记(CMS remark)④并发清除(CMS concurrent sweep)。
        初始标记、重新标记着两个步骤仍需"Stop The World"初始标记只是标记下与GC Roots直接关联的对象,速度很快;并发标记阶段就是进行GC Roots Tracing的过程,而重新标记阶段则是为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录并发标记和并发清除是整个过程耗时最长的,可以与用户线程一起工作,所以总体上来说,CMS收集器的内存回收过程是与用户线程一起并发执行的,如下图所示是CMS收集器的运作步骤。
   


        CMS有3个明显的错误:1.CMS收集器对CPU资源非常敏感;2.CMS收集器无法处理浮动垃圾(Floating Garbage),可能出现"Concurrent Mode Faliure"失败而导致另一次Full G唱的产生;3.CMS是一款基于“标记-清除”算法实现的收集器,收集结束后会产生大量空间碎片。

2.2.7G1 收集器

        G1(Garbage First)收集器是当今收集器技术发展最前沿成果之一。G1是一款面向服务端引用的垃圾收集器,G1具有以下特点:①并行与并发②分代收集③空间整合④可预测的停顿。
        在G1之前的其他收集器进行收集的范围都是整个新生代或老年代,而G1不再是这样,它将整个Java堆划分为多个大小相等的独立区域(Region),可是新生代和老年代不再是物理隔离了,它们都是一部分Region的集合。G1收集器之所以能建立可预测的停顿时间模型,是因为它可以有计划地表面在整个Java堆中进行全区域的垃圾收集。G1跟踪各个Region里面的垃圾堆积的价值大小,在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region。
        在G1收集器中,Region之间的对象引用以及其他收集器中的新生代与老年代之间的对象引用,虚拟机都是使用Remembered Set来避免全堆扫描。G1中每个Region都有一个与之对应的Remembered Set,虚拟机发现程序在堆Reference类型的数据进行写操作时,会产生一个Write Barrier暂时中断写操作,检查Reference引用对象是否处于不同的Region之中,若是,便通过CardTable把相关引用信息记录到被引用对象所属的Region的Remembered
Set之中。当进行内存回收时,在GC根节点的枚举范围中加入Remember Set即可保证不对全堆也不会有遗漏。
        如果不计算维护Remember Set的操作,G1收集器的运作大致有如下如下几个步骤:①初始标记(Initial Marking)②并发标记(Concurrent Marking)③最终标记(Final Marking)④筛选回收(Live Data Counting and Evacuation)。
        初始标记阶段仅仅只是标记以下GC Roots能直接关联到的对象,并且修改TAMS(Next  Top at Start)的值,让下一阶段用户程序并发运行时,能在确定可用的Region中创建新对象,这阶段需要停顿所有线程。并发标记阶段是从GC Root开始对堆中对象进行可达性分析,找出存活的对象,这阶段耗时较长,但可与用户程序并发执行。最终标记阶段则是为了修正在并发标记期间因用户程序继续运作而导致标记产生变动的那一部分标记记录,虚拟机将这段时间对象变化记录到线程Remembered
Set Logs
最终标记阶段需要把Remembered Set Logs的数据合并到Remembered Set 中,这阶段需要停顿线程,但可并行执行。在筛选回收阶段,首先对各个Region的回收价值和成本进行排序,根据用户所期望的GC停顿时间来指定回收计划。如下图所示是G1收集器的运作步骤。
           


        由于目前G1成熟版本的发布时间还短,G1收集器几乎没有经过实际应用的考验。如果你采用的收集器没有出现问题,就没有任何理由现在去选择G1。
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: