Java教程

JVM对象创建与内存分配机制

本文主要是介绍JVM对象创建与内存分配机制,对大家解决编程问题具有一定的参考价值,需要的程序猿们随着小编来一起学习吧!

仅以此记录学习笔记等!

对象创建

大概流程:

img

1类加载检查

虚拟机遇到一个new命令时(new指令在语言层次上,代表new关键字,对象的克隆或者序列化等),

首先将要检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已经被加载、解析和初始化过(即类加载那五个步骤)。如果没有就必须先执行类加载过程。

 

那什么情况下需要去加载类呢?

1、当创建对象和使用到静态变量的时候,即遇到new、getstatic、putstatic或者invokestatic指令的时候(读取或者设置一个静态变量,finnal修饰和已加载的静态字段除外)。

下图是上面字节码解析中涉及到的一个static变量:

img

2、当一个类被初始化时,如果其有父类,且没被初始化过,那会先触发父类的初始化。

3、当使用java.lang.reflect(即反射)时,如果对象没被初始化,则会先进行初始化。

4、当JVM启动时,会先执行一个指定的方法(public main()),这个类会被先初始化。

 

2分配内存

在类加载成功后,虚拟机就需要为新生对象分配内存了。

Java的堆内存是被所有线程所共享的一块内存区域,其主要用于存放对象的实例(也包括数组实例),为实例对象分配内存就是指在堆内存中分配一块大小确定的内存划分出来,并给到对象。

其实现主要通过两个方法:

1.指针碰撞法

假设JAVA堆中的内存是绝对规整的,所有用过的内存都放在一侧,空闲的内存放在另一侧,中间通过一个指针作为分界点,当需要分配内存时,仅仅需要将那个指针往空闲侧移动与对象大小相等的距离即可

使用的GC收集器:Serial、ParNew,适用堆内存规整(即没有内存碎片)的情况下。

img

(蓝色代表已分配内存空间,白色代表没分配的内存空间)

在最后一块已分配内存后有一个指针连接空闲内存区域。

 

2.空闲列表法

假设JAVA堆中的内存不是规整的,已经使用过的内存空间是相互交错的,那此时就不能使用指针碰撞来进行内存分配了。JVM通过维护一个列表,来记录可用的内存信息,在分配的时候从列表中找到一块足够大的空间来划分给对象实例,并更新列表上的记录

使用的GC收集器:CMS,适用堆内存不规整的情况下。

img

(蓝色代表已分配内存空间,白色代表没分配的内存空间)

 

问题:划分内存不管是指针碰撞法,抑或是空闲列表法,当一个线程开始准备占用内存时,另一个线程也可能在抢占内存,这时候即会引起一个熟知的问题-->并发?

解决:如何解决并发来保证线程安全,一般是两种方法

1.CAS(compare and swap)比较替换

CAS是乐观锁的一种实现方式。即每次不去加锁,而是假设不会发生冲突的来完成某个操作,如果发生冲突失败就重试,直到操作成功为止。JVM采用CAS配上失败重试的方式保证更新操作的原子性,来对分配内存的动作进行同步处理。

2.TLAB(Thread Local Allocation Buffer) 本地线程分配缓冲

按照线程**在不同的空间之中进行内存的划分操作。即给每一个线程在Java堆内存中预先划分、分配一小块内存。也就是说首先在TLAB中配内存,当对象实例需要的内存大于TLAB的剩余内存,或者TLAB中的内存消耗殆尽**的时候,使用CAS机制进行内存的分配。

参数设置:-XX:+UseTLAB(JVM默认开启),关闭-XX:-UseTLAB

 

3初始化零值

在分配内存后, JVM需要将分配到的内存空间都初始化为零值(不包括对象头),如果使用TLAB,这一过程也可以提前至TLAB分配内存时安进行。这一步操作保证了对象的实例字段在Java代码中可以步赋初始值就能直接使用,程序能够访问到这些字段的数据类型所对应的零值(如int的0,boolean的false)。

 

4设置对象头

在初始化零值后,JVM需要对对象进行必要的设置,比如:某一个对象属于哪个类的实例,如何才能够找到该类的元数据信息,该对象的hash码、GC分代年龄等信息。以上这些信息基本存在与对象头(Object Header)之中。

(锁相关:Java对象头和monitor是实现synchronized的基础

在HotSpot,对象在内存中存储的布局可以分为3个区域:

1对象头(Header)

2实例数据(Instance Date)

3对齐填充(Padding)

对象头由Mark Word(标记字段)和Kclass pointer(类型指针)组成。

Mark Word:(32位占4字节,64位占8字节)用于存储对象自身的运行时数据,如哈希码(Hash Code)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等。(对象和数组稍有差别,数组长度占4字节)。

Kclass pointer:(开启指针压缩占4字节,不开启占8字节)指对象指向它的类元数据的指针,JVM通过这个指针来确定这个对象是哪个类的实例。

下图是内存布局大概分布:

img

下图是32位虚拟机对象头各锁对应的信息;

对象头一般占有两个机器码(在32位虚拟机中,1个机器码等于4字节,也就是32bit)),它是实现轻量级锁和

偏向锁的关键。

img

下图是结合栈以及方法区画了一个个人理解和参考的图

img

 

5执行init方法

执行<init>方法,即对象按照程序员的意愿进行初始化。对应到语言层面上讲,就是为属性赋值(注意,这与上面的赋零值不同,这是由程序员赋的值),和执行构造方法。

 

指针压缩和对象大小

启用指针压缩:­XX:+UseCompressedOops(默认开启),禁止指针压缩:­XX:­-UseCompressedOops

大概理解:

在64位平台的HotSpot中使用32位指针,内存使用会多出1.5倍左右,使用较大指针在主内存和缓存之间移动数据,占用较大宽带,同时GC也会承受较大压力。

为了减少64位平台下内存的消耗,启用指针压缩功能。

 

对象内存分配

img

1在栈上面为对象分配内存

在Java中,对象的实例几乎都在堆上分配内存,当对象没有被引用的时候,需要依赖GC进行垃圾回收,释放内存。如果对象实例过多,会给GC带来巨大的压力,也会影响应用程序的性能。

为了减少临时对象在堆内分配的数量,JVM通过逃逸分析确定该对象不会被外部访问。如果不会逃逸可以将该对象在栈上分配内存,这样该对象所占用的内存空间就可以随栈帧出栈而销毁,就减轻了垃圾回收的压力。

对象逃逸分析:就是分析对象动态作用域,当一个对象在方法中被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他地方中(此时则需要分配堆内存)。

 

举例:

    public MyMath getMath(){
        MyMath res = new MyMath();
        //xxx
        return res;
    }
​
    public void getMath2(){
        MyMath res = new MyMath();
        //xxx
    }

 

  • 该类所有的实例都已经被回收,也就是 Java 堆中不存在该类的任何实例。

  • 加载该类的 ClassLoader 已经被回收。

  • 该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

方法区主要回收的是无用的类,那么如何判断一个类是无用的类的呢? 类需要同时满足下面3个条件才能算是 “无用的类” :

如何判断一个类是无用的类

 

如果这个对象被判定为有必要执行finalize()方法,那么这个对象将会放置在一个叫做F-Queue的队列之中,并在稍后由一个由虚拟机自动建立的、低优先级的Finalizer线程去执行它。这里所谓的"执行"是指虚拟机会触发这个方法,但并不承诺会等待它运行结束,这样做的原因是,如果一个对象在finalize()方法中执行缓慢,或者发生了死循环,将很可能会导致F-Queue队列中其他对象永久处于等待,甚至导致整个内存回收系统崩溃。finalize()方法是对象逃脱死亡命运的最后一次机会,稍后GC将对F-Queue中的对象进行第二次小规模标记,如果对象要在finalize()中成功拯救自己——只要重新与引用链上的任何一个对象建立关联即可,譬如把自己(this关键字)赋值给某个类变量或者对象的成员变量,那在第二次标记时它将被移除出"即将回收"的集合;如果对象这时候还没有逃脱,那基本上它就真的被回收了。

 

在可达性分析算法中不可达的对象,也并非是“非死不可”的,这时候它们暂时处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历两次标记过程——如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,那它将会被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法。当对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,虚拟机将这两种情况都视为“没有必要执行”。(那么问题来了,这种情况下的二次标记在哪里?)

对象是怎么被杀死的?

最后的救赎(判断是否存活)

 

虚引用也被称为幽灵引用或者幻影引用,它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。

 

弱引用也是用来描述非必需对象的,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生之前。当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。

 

软引用是用来描述一些还有用但并非必需的对象。对于软引用关联着的对象,在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围之中进行第二次回收。如果这次回收还没有足够的内存,才会抛出内存溢出异常。

 

强引用就是指在程序代码之中普遍存在的,类似"Object obj=new Object()"这类的引用,只要强引用还存在,垃圾收集器永远不会回收掉被引用的对象。

 

在JDK1.2之后,Java对引用的概念进行了扩充,将引用分为强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)、虚引用(Phantom Reference)4种,这4种引用强度依次逐渐减弱。

 

无论是通过引用计数算法判断对象的引用数量,还是通过可达性分析算法判断对象的引用链是否可达,判断对象是否存活都与"引用"有关。

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象

  • 方法区中类静态属性引用的对象

  • 方法区中常量引用的对象

  • 本地方法栈中JNI(即一般说的Native方法)引用的对象

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

常用引用类型

 

 

 

img

如图所示:

可达性分析算法:在主流的商用程序语言的主流实现中,都是称通过可达性分析来判断对象是否存活的。这个算法的基本思路就是通过一系列的称为"GC Roots"的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链,当一个对象到GC Roots没有任何引用链相连时,则证明此对象是不可用的。

 

既然我们学习的是Java虚拟机相关的内容,那我们就要了解Java虚拟机的实现中是怎么判断对象是否存活的?

 

虽然引用计数算法实现简单且判断效率高效,但是在主流的Java虚拟机里面没有选用引用计数算法来管理内存,其中最主要的原因是它很难解决对象间相互循环引用的问题。

引用计数法:给对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加1;当引用失效时,计数器值就减1;任何时刻计数器为0的对象就是不可能再使用的。

 

比较容易想到的是引用计数法。那么什么是引用计数法呢?

都知道内存回收的是无用对象,但是怎么判断对象是无用还是有用呢?

一般回收方法

 

对象内存回收

 

当然,如果**Minor** gc之后剩余存活的需要挪动到老年代的对象大小还是大于老年代可用空间,那么也会触发full gc,full gc完之后如果还是没有空间放minor gc之后的存活对象,则也会发生“OOM”

如果回收完还是没有足够空间存放新的对象就会发生"OOM"

,如果不小于则发生Minor GC,如果小于,则会发生Full GC(对老年代和年轻代一起发生一次垃圾回收)。

2.2如果有设置,则会判断老年代剩余空间大小是否 小于 历史每一次Minor GC后进入老年代的对象的平均大

2.1如果没有设置,则会进行Full GC(给老年代和年轻代一起清理一次)

2如果小于,则会检查-XX:-HandlePromotionFailure

1如果大于,则此次Minor GC是安全的,直接进行Minor GC即可。

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

2.5老年代空间分配担保机制

 

当前放对象的Survivor区域里(其中一块区域,放对象的那块s区),一批对象的总大小大于这块Survivor区域内存大小的 50%(-XX:TargetSurvivorRatio可以指定),那么此时大于等于这批对象年龄最大值的对象,就可以直接进入老年代了, 例如Survivor区域里现在有一批对象,年龄1+年龄2+年龄n的多个年龄对象总和超过了Survivor区域的50%,此时就会把年龄n(含)以上的对象都放入老年代。这个规则其实是希望那些可能是长期存活的对象,尽早进入老年代。对象动态年龄判断机制一般是在minor gc之后触发的。

2.4对象动态年龄判断

 

如果对象在 Eden 出生并经过第一次 Minor GC 后仍然能够存活,并且能被 Survivor 容纳的话,将被移动到 Survivor空间中,并将对象年龄设为1。对象在 Survivor 中每熬过一次 MinorGC,年龄就增加1岁,当它的年龄增加到一定程度(默认为15岁,CMS收集器默认6岁,不同的垃圾收集器会略微有点不同),就会被晋升到老年代中。对象晋升到老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 来设置。

虚拟机给每个对象一个对象年龄(Age)计数器。

2.3长期存活的对象将进入老年代

 

为了避免为大对象分配内存时的复制操作而降低效率。

为什么要这样呢?

-XX:+UseSerialGC 。

大对象就是需要大量连续内存空间的对象(比如:字符串、数组)。JVM参数 -XX:PretenureSizeThreshold 可以设置大对象的大小,如果对象超过设置大小会直接进入老年代,不会进入年轻代,这个参数只在 Serial 和ParNew两个收集器下有效。比如设置JVM参数:-XX:PretenureSizeThreshold=1000000 (单位是字节)

2.2大对象直接进入老年代

 

可通过-XX:+PrintGCDetails进行GC的查看

  • Minor GC/Young GC:指发生新生代的的垃圾收集动作,Minor GC非常频繁,回收速度一般也比较快。

  • Major GC/Full GC:一般会回收老年代 ,年轻代,方法区的垃圾,Major GC的速度一般会比Minor GC的慢10倍以上。

两种情况的GC:

 

JVM默认有这个参数-XX:+UseAdaptiveSizePolicy(默认开启),会导致这个8:1:1比例自动变化,如果不想这个比例有变化可以设置参数-XX:-UseAdaptiveSizePolicy

因为新生代的对象都是朝生夕死的,存活时间很短,所以JVM默认的8:1:1的比例是很合适的,让eden区尽量的大,survivor区够用即可,

 

即Eden=N(Xmn/(N+2)),survivor(from)=suvivor(to)=1(Xmn/(N+2))

Xmn/(N+1+1) 就是每一份的大小,对应乘就是Eden,s0,s1的大小

eden:s0:s1=N:1:1

参数假设配置N,配置N表示伊甸园区Eden大小即是幸存区from的N倍,也是幸存区to的N倍。

堆新生代:包括Eden区域和Survivor区域(默认是8)

-XX:+SurvivorRatio 设置新生代内存的占比

Eden与Survivor区默认8:1:1

2.1Eden区分配

 

所有的类都是在伊甸区被new出来的。幸存者区又分为 From 区 和 To 区。当Eden区的空间用完时,程序要继续new对象,JVM的垃圾回收器将对Eden区进行垃圾回收(Minor GC),将Eden区中的不再被其他对象应用的对象销毁,然后将剩余存活的对象移到From Survivor区。若From区也满了,再对该去、区进行垃圾回收,然后将剩余的移动到To Survivor区。最终再继续存活下来的将存放到老年代。

上面介绍堆时已提到过,堆上分配内存大概情况:

2在堆上面为对象分配内存

 

标量与聚合量:标量即不可被进一步分解的量,而JAVA的基本数据类型就是标量(如:int,long等基本数据类型以及 reference类型等),标量的对立就是可以被进一步分解的量,而这种量称之为聚合量。而在JAVA中对象就是可以被进一步分解的聚合量。

标量替换:通过逃逸分析确定该对象不会被外部访问,并且对象可以被进一步分解时,JVM不会创建该对象,而是将该 对象成员变量分解若干个被这个方法使用的成员变量所代替,这些代替的成员变量在栈帧或寄存器上分配空间,这样就 不会因为没有一大块连续空间导致对象内存不够分配。开启标量替换参数(-XX:+EliminateAllocations),JDK7之后默认开启。

(在**栈上面分配内存依赖于逃逸分析和标量替换,需要同时开启**)

开启逃逸分析参数(-XX:+DoEscapeAnalysis)来优化对象内存分配位置,使其通过标量替换优先分配在栈上(栈上分配),JDK7之后默认开启逃逸分析,如果要关闭使用参数(-XX:-DoEscapeAnalysis)

但getMath2方法,当方法一结束,res对象即无效了,因此对于这种对象,可以将其放在栈中,让它与方法一起结束,跟随栈内存一起被回收。

像getMath方法,很显然其需要返回一个对象,可能是被另一个调用方使用或者返回等,因此它的作用域不确定。

这篇关于JVM对象创建与内存分配机制的文章就介绍到这儿,希望我们推荐的文章对大家有所帮助,也希望大家多多支持为之网!