爱收集资源网

Java堆内存布局及访问优化

网络 2023-06-28 07:02

类加载检测

当Java虚拟机遇见一条new指令的时侯,它会先去运行经常量池中找寻new的类的符号引用,但是检测这个符号引用所代表的类是否早已被加载、解析、初始化过。若果没有即须要进行相应的类加载过程。

为新生对象分配Java堆显存

对象所须要的显存大小在Java类加载的时侯早已确定出来了。为对象分配堆显存相当于把一块显存分下来放置对象。

主要分配显存的方法有两种:表针碰撞和空闲列表。

注意到对象创建在虚拟机执行的过程中是十分频繁的行为,仅仅更改一个表针所指向的位置,在并发情况下不是线程安全的。因而也有两种解决方案:

使用CAS并配上失败重试的方法保证更新操作的原子性。给每一个线程在Java堆中预先分配线程私有分配缓冲区,那个线程须要分配显存,只要在线程私有分配缓冲区中分配即可以。

将分配到的显存空间初始化零位

将分配到的显存空间初始化零位,这保证了实例数组不形参可以直接使用。假如使用了TLAB,这一步可以提早到TLAB分配的时侯进行。

对对象进行必要的设置

对象是那个类的实例;

怎样找到类的元数据信息;

对象的哈希码;

对象的GC分代年纪信息;

这种信息存在对象的对象头信息之中

构造函数

执行完以上四步,从虚拟机角度,一个对象早已形成了,而且对于java程序而言,构造函数还没有开始执行。接出来根据构造函数的要求,对对象进行初始化即可。

另:Java堆中对象的显存布局和访问定位

移动闲时流量包代码_移动闲时流量包代码_移动流量包开通代码

对象头主要包含两类信息。第一类是用于储存对象自身的运行时数据,如哈希码、GC分代年纪、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等。对象头的另外一部份是类型表针,即对象指向它的类型元数据的表针。类型数据部份是对象真正储存的有效信息,即程序代码中定义的各种类型的数组内容。对齐填充:任何对象的大小都必须是8字节的整数倍。

对象的访问定位:

对象的显存布局

问:在Java对象创建后,究竟是怎样被储存在Java显存里的呢?

答:在Java虚拟机(HotSpot)中,对象在Java显存中的储存布局可分为三块:

①对象头区域

此处储存的信息包括两部份:

如哈希码(HashCode)、GC分代年纪、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等

该部份数据被设计成1个非固定的数据结构便于在极小的空间储存尽量多的信息(会按照对象状态复用储存空间)

即对象指向它的类元数据的表针

虚拟机通过这个表针来确定这个对象是那个类的实例

非常注意

假如对象是链表,这么在对象头中还必须有一块用于记录字段宽度的数据!

由于虚拟机可以通过普通Java对象的元数据信息确定对象的大小,并且从链表的元数据中却未能确定字段的大小。

②实例数据区域

储存的信息:对象真正有效的信息

即代码中定义的数组内容

注:这部份数据的储存次序会遭到虚拟机分配参数(FieldAllocationStyle)和数组在Java源码中定义次序的影响。

// HotSpot虚拟机默认的分配策略如下:
longs/doubles、ints、shorts/chars、bytes/booleans、oop(Ordinary Object Pointers)
// 从分配策略中可以看出,相同宽度的字段总是被分配到一起
// 在满足这个前提的条件下,父类中定义的变量会出现在子类之前
CompactFields = true;
// 如果 CompactFields 参数值为true,那么子类之中较窄的变量也可能会插入到父类变量的空隙之中。

③对齐填充区域

储存的信息:占位符

占位作用

由于对象的大小必须是8字节的整数倍,而因HotSpotVM的要求对象起始地址必须是8字节的整数倍,且对象脚部分恰好是8字节的倍数。

因而,当对象实例数据部份没有对齐时(即对象的大小不是8字节的整数倍),就须要通过对齐填充来补全。

总结

对象的访问定位

问:构建对象后,该怎么访问对象呢?

实际上需访问的是对象类型数据&对象实例数据

答:Java程序通过栈上的引用类型数据(reference)来访问Java堆上的对象

因为引用类型数据(reference)在Java虚拟机中只规定了一个指向对象的引用,但没定义该引用应当通过何种方法去定位、访问堆中的对象的具体位置

所以对象访问方法取决于虚拟机实现。目前主流的对象访问方法有两种:

具体请看如下介绍:

对象生死判定算法

垃圾回收的第一步就是判定对象是否存活,只有“死去”的对象,就会被垃圾回收器所收回。

①引用计数器算法

引用估算器判定对象是否存活的算法是这样的:给每一个对象设置一个引用计数器,每每有一个地方引用这个对象的时侯,计数器就加1,与之相反,每每引用失效的时侯就减1。

优点:实现简单、性能高。

缺点:增减处理频繁消耗cpu估算、计数器占用好多位浪费空间、最重要的缺点是难以解决循环引用的问题。

由于引用计数器算法很难解决循环引用的问题,所以主流的Java虚拟机都没有使用引用计数器算法来管理显存。

移动闲时流量包代码_移动闲时流量包代码_移动流量包开通代码

来看一段循环引用的代码:

public class ReferenceDemo {
    public Object instance = null;
    private static final int _1Mb = 1024 * 1024;
    private byte[] bigSize = new byte[10 * _1Mb]; // 申请内存
    public static void main(String[] args) {
        System.out.println(String.format(
                "开始:%d M",Runtime.getRuntime().freeMemory() / (1024 * 1024)));
        ReferenceDemo referenceDemo = new ReferenceDemo();
        ReferenceDemo referenceDemo2 = new ReferenceDemo();
        referenceDemo.instance = referenceDemo2;
        referenceDemo2.instance = referenceDemo;
        System.out.println(String.format(
                "运行:%d M",Runtime.getRuntime().freeMemory() / (1024 * 1024)));
        referenceDemo = null;
        referenceDemo2 = null;
        System.gc(); // 手动触发垃圾回收
        System.out.println(String.format(
                "结束:%d M",Runtime.getRuntime().freeMemory() / (1024 * 1024)));
    }
}

运行的结果:

开始:117 M
运行中:96 M
结束:119 M

从结果可以看出,虚拟机并没有由于互相引用就不回收它们,也侧面说明了虚拟机并不是使用引用计数器实现的。

②可达性剖析算法

在主流的语言的主流实现中,例如Java、C#、甚至是古老的Lisp都是使用的可达性剖析算法来判定对象是否存活的。

这个算法的核心思路就是通过一些列的“GCRoots”对象作为起始点,从那些对象开始往下搜索,搜索所经过的路径称之为“引用链”。

当一个对象到GCRoots没有任何引用链相连的时侯,证明此对象是可以被回收的。如右图所示:

在Java中,可作为GCRoots对象的列表:

对象生死与引用的关系

从前面的两种算法来看,不管是引用计数法还是可达性剖析算法都与对象的“引用”有关,这说明:对象的引用决定了对象的生死。那对象的引用都有这些呢?

在JDK1.2之前,引用的定义很传统:假如reference类型的数据中储存的数值代表的是另一块显存的起始地址,就称这块显存代表着一块引用。

这样的定义很纯粹,而且也很自私,这些情况下一个对象要么被引用,要么没引用,对于介于二者之间的对象变得无能为力。

JDK1.2以后对引用进行了扩展,将引用分为:

对象不是非生即死的,当空间还足够时,还可以保留那些对象,假如空间不足时,再抛弃那些对象。好多缓存功能的实现也符合这样的场景。

强引用、软引用、弱引用、虚引用,这4种引用的硬度是依次递减的。

强引用:在代码中普遍存在的,类似“Objectobj=newObject()”这类引用,只要强引用还在,垃圾搜集器永远不会回收掉被引用的对象。

软引用:是一种相对强引用弱化一些的引用,可以让对象豁免一些垃圾搜集,只有当jvm觉得显存不足时,就会去企图回收软引用指向的对象。jvm会确保在抛出OutOfMemoryError之前,清除软引用指向的对象。

弱引用:非必需对象,但它的硬度比软引用更弱,被弱引用关联的对象只能生存到下一次垃圾搜集发生之前。

虚引用:俗称为幽灵引用或幻影引用,是最弱的一种引用关系,未能通过虚引用来获取一个对象实例,为对象设置虚引用的目的只有一个,就是当着个对象被搜集器回收时收到一条系统通知。

死亡标记与挽救

在可达性算法中不可达的对象,并不是“非死不可”的,要真正宣告一个对象死亡,起码要经历两次标记的过程。

假如对象在进行可达性剖析以后,没有与GCRoots相联接的引用链,它会被第一次标记,并进行筛选,筛选的条件是此对象是否有必要执行finalize()技巧。

执行finalize()方式的两个条件:

重画了finalize()方式。finalize()方式之前没被调用过,由于对象的finalize()方式只能被执行一次。

假如满足以上两个条件,这个对象将会放置在F-Queue的队列之中,并在稍后由一个虚拟机自建的、低优先级Finalizer线程来执行它。

①对象的“自我挽救”

finalize()方式是对象脱离死亡命运最后的机会,假如对象在finalize()方式中重新与引用链上的任何一个对象构建关联即可,例如把自己(this关键字)形参给某个类变量或对象的成员变量。

来看具体的实现代码:

public class FinalizeDemo {
    public static FinalizeDemo Hook = null;
    @Override
    protected void finalize() throws Throwable {
        super.finalize();
        System.out.println("执行finalize方法");
        FinalizeDemo.Hook = this;
    }
    public static void main(String[] args) throws InterruptedException {
        Hook = new FinalizeDemo();
        // 第一次拯救
        Hook = null;
        System.gc();
        Thread.sleep(500); // 等待finalize执行
        if (Hook != null) {
            System.out.println("我还活着");
        } else {
            System.out.println("我已经死了");
        }
        // 第二次,代码完全一样
        Hook = null;
        System.gc();
        Thread.sleep(500); // 等待finalize执行
        if (Hook != null) {
            System.out.println("我还活着");
        } else {
            System.out.println("我已经死了");
        }
    }
}

执行的结果:

执行finalize方法
我还活着
我已经死了

从结果可以看出,任何对象的finalize()方式都只会被系统调用一次。

不建议使用finalize()方式来挽救对象,诱因如下:

对象的finalize()只能执行一次。它的运行代价昂贵。不确定性大。难以保证各个对象的调用次序。基本垃圾回收算法①按照基本回收策略分

(1)引用计数(ReferenceCounting)

比较古老的回收算法。原理是此对象有一个引用,即降低一个计数,删掉一个引用则降低一个计数。垃圾回收时,只用搜集计数为0的对象。此算法最致命的是难以处理循环引用的问题。

(2)可达性剖析清除

标记-消除(Mark-Sweep):此算法执行分两阶段。第一阶段从引用根节点开始标记所有被引用的对象,第二阶段遍历整个堆,把未标记的对象清理。此算法须要暂停整个应用,同时,会形成显存碎片

移动流量包开通代码_移动闲时流量包代码_移动闲时流量包代码

复制(Copying):此算法把显存空间划为两个相等的区域,每次只使用其中一个区域。垃圾回收时,遍历当前使用区域,把正在使用中的对象复制到另外一个区域中。次算法每次只处理正在使用中的对象,因而复制成本比较小,同时复制过去之后能够进行相应的显存整理,不会出现“碎片”问题。其实,此算法的缺点也是很显著的,就是须要两倍显存空间。

标记-整理(Mark-Compact):此算法结合了“标记-消除”和“复制”两个算法的优点。也是分两阶段,第一阶段从根节点开始标记所有被引用对象,第二阶段遍历整个堆,消除标记对象,并未标记对象而且把存活对象“压缩”到堆的其中一块,按次序排放。此算法避开了“标记-消除”的碎片问题,同时也防止了“复制”算法的空间问题。

②按分区对待的形式分

(1)增量搜集(IncrementalCollecting):实时垃圾回收算法,即:在应用进行的同时进行垃圾回收。不晓得哪些缘由JDK5.0中的搜集器没有使用这些算法的。

(2)分代搜集(GenerationalCollecting):基于对对象生命周期剖析后得出的垃圾回收算法。把对象分为年轻代、年老代、持久代,对不同生命周期的对象使用不同的算法(上述方式中的一个)进行回收。现今的垃圾回收器(从J2SE1.2开始)都是使用此算法的。

③按系统线程分

(1)串行搜集:串行搜集使用单线程处理所有垃圾回收工作,由于无需多线程交互,实现容易,但是效率比较高。并且,其局限性也比较显著,即难以使用多处理器的优势,所以此收集适宜单处理器机器。其实,此搜集器也可以用在小数据量(100M左右)情况下的多处理器机器上。

(2)并行搜集:并行搜集使用多线程处理垃圾回收工作,因此速率快,效率高。并且理论上CPU数量越多,越能彰显出并行搜集器的优势。

(3)并发搜集:相对于串行搜集和并行搜集而言,上面两个在进行垃圾回收工作时,须要暂停整个运行环境,而只有垃圾回收程序在运行,因而,系统在垃圾回收时会有显著的暂停,并且暂停时间会由于堆越大而越长。

优化技术:分代处理垃圾

试想,在不进行对象存活时间分辨的情况下,每次垃圾回收都是对整个堆空间进行回收,耗费时间相对会长,同时,由于每次回收都须要遍历所有存活对象,但实际上,对于生命周期长的对象而言,这些遍历是没有疗效的,由于可能进行了好多次遍历,而且她们仍然存在。为此,分代垃圾回收采用分治的思想,进行代的界定,把不同生命周期的对象置于不同代上,不同代上采用最适宜它的垃圾回收方法进行回收。

虚拟机中的共界定为三个代:年青代(YoungGeneration)、年老点(OldGeneration)和持久代(PermanentGeneration)。其中持久代主要储存的是Java类的类信息,与垃圾搜集要搜集的Java对象关系不大。年青代和年老代的界定是对垃圾搜集影响比较大的。

年青代:所有新生成的对象首先都是置于年青代的。年青代的目标就是尽可能快速的搜集掉这些生命周期短的对象。年青代分三个区。一个Eden区,两个Survivor区(通常而言)。大部份对象在Eden区中生成。当Eden区满时,还存活的对象将被复制到Survivor区(两个中的一个),当这个Survivor区满时,此区的存活对象将被复制到另外一个Survivor区,当这个Survivor去也满了的时侯,从第一个Survivor区复制过来的而且此时还存活的对象,将被复制“年老区(Tenured)”。须要注意,Survivor的两个区是对称的,没先后关系,所以同一个区中可能同时存在从Eden复制过来对象,和从前一个Survivor复制过来的对象,而复制到年老区的只有从第一个Survivor去过来的对象。并且,Survivor区总有一个是空的。同时,按照程序须要,Survivor区是可以配置为多个的(少于两个),这样可以降低对象在年青代中的存在时间,降低被放在年老代的可能。

年老代:在年青代中经历了N次垃圾回收后依然存活的对象,都会被放在年老代中。为此,可以觉得年老代中储存的都是一些生命周期较长的对象。

持久代:用于储存静态文件,现在Java类、方法等。持久代对垃圾回收没有明显影响,然而有些应用可能动态生成或则调用一些class,比如Hibernate等,在这些时侯须要设置一个比较大的持久代空间来储存那些运行过程中新增的类。持久代大小通过-XX:MaxPermSize=进行设置。

JAVA中垃圾回收GC的类型

因为对象进行了分代处理,因而垃圾回收区域、时间也不一样。GC有两种类型:ScavengeGC和FullGC。

ScavengeGC:通常情况下,当新对象生成,但是在Eden申请空间失败时,才会触发ScavengeGC,对Eden区域进行GC,消除非存活对象,而且把仍旧存活的对象联通到Survivor区。之后整理Survivor的两个区。这些方法的GC是对年青代的Eden区进行,不会影响到年老代。由于大部份对象都是从Eden区开始的,同时Eden区不会分配的很大,所以Eden区的GC会频繁进行。因此,通常在这儿须要使用速率快、效率高的算法,使Eden去能早日空闲下来。

FullGC:对整个堆进行整理,包括Young、Tenured和Perm。FullGC由于须要对整个对进行回收,所以比ScavengeGC要慢,因而应当尽可能降低FullGC的次数。在对JVM调优的过程中,很大一部份工作就是对于FullGC的调节。

有如下诱因可能造成FullGC:·年老代(Tenured)被写满、持久代(Perm)被写满、System.gc()被显示调用、上一次GC以后Heap的各域分配策略动态变化。

移动闲时流量包代码
上一篇:联想电脑进bios设置键 下一篇:没有了
相关文章