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

JVM对象创建过程

类加载检查

  • 虚拟机遇到一条new指令时,首先会去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过,如果没有,就必须先执行对应的类加载过程;
  • new指令对应到语言层面就是new关键词、对象克隆、对象序列化等。

分配内存

  • 在类加载检查通过后,虚拟机会给新的对象分配内存。对象所需内存的大小在类加载完成后就可以完全确定,为对象分配空间的任务等同于把一块确定大小的内存从Java堆中划分出来;
  • 这里有两个问题
    • 如何划分内存?
    • 在并发的情况下,可能出现正在给对象A分配内存,指针还没来得及修改,对象B又同时使用了原来的指针来分配内存的情况。
  • 划分内存的方法
    • “指针碰撞”(Bump the Pointer)【默认使用指针碰撞】  
      • 如果Java堆中内存是绝对规整的,所有用过的内存都放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针指向空闲空间那边挪动一段与对象大小相等的距离。
    • “空闲列表”(Free List)
      • 如果Java堆中的内存并不是规整的,已使用的内存和空闲的内存相互交错,那就没有办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记录哪些内存块是可用的。在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录。
  • 解决并发问题的方法
    • CAS(compare and swap)
      • 虚拟机采用CAS+失败重试的方式保证更新操作的原子性来对分配内存空间的动作进行同步处理。
    • 本地线程分配缓冲(Thread Local Allocation Buffer,TLAB)
      • 把内存分配的动作按照线程划分在不同的空间中进行,即每个线程在Java堆中预先分配一小块内存,通过-XX:+/-UseTLAB参数来设定虚拟机是否使用TLAB【JVM会默认开启-XX:+UseTLAB】,-XX:TLABSize指定TLAB大小。

 

 

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

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

标量与聚合量:标量即不可被进一步分解的量,而Java的基本数据类型就是标量【如:int、long等基本数据类型以及引用类型等】,标量的对立就是可以被进一步分解的量,称为聚合量,而在Java中对象就是可以被进一步分解的聚合量。

栈上分配依赖于逃逸分析和标量替换,如果关闭逃逸分析或标量替换,那么JVM就不会在栈上分配内存了,而是直接在堆中分配内存,从而导致大量的GC影响性能。

对象在Eden区分配

一般情况下,对象在新生代中Eden区分配。当Eden区没有足够空间进行分配时,虚拟机将触发一次Minor GC。

Minor GC和Full GC的异同点

  1. Minor GC/Young GC:指发生在新生代的垃圾收集动作,Minor GC非常频繁,回收速度一般也比较快;
  2. Major GC/Full GC:一般会回收老年代、年轻代、方法区的垃圾,Major GC的速度一般比Minor GC的速度慢10倍以上。

 

Eden与Survivor区默认8:1:1

大量的对象被分配在Eden区,Eden区满了后会触发Minor GC,可能有99%以上的对象成为垃圾被回收掉,剩余存活的对象会被挪到空的那块Survivor区,下一次Eden区满了后又会触发Minor GC,把Eden区和Survivor区垃圾对象回收,把剩余存活的对象一次性挪到另外一块空的Survivor区。因为新生代的对象都是朝生夕死的,存活时间很短,所以JVM默认的8:1:1比例很合适,让Eden区尽量大,Survivor区够用就行。

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

大对象直接进入老年代

大对象就是需要大量连续内存空间的对象【比如:字符串、数组】。

JVM参数-XX:PretenureSizeThreshold可以设置大对象的大小,如果对象超过设置的大小就会直接进入老年代,不会进入年轻代,这个参数只在SerialParNew两个收集器下有效。

比如设置JVM参数【-XX:PretenureSizeThreshold=1000000(单位是字节) -XX:+UseSerialGC】,对象超过这个大小就会直接进入老年代了。这么做的目的是为了避免为大对象分配内存时的复制操作而降低效率。

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

既然虚拟机采用了分代收集的思想来管理内存,那么内存回收时就必须能识别哪些对象应该放在新生代,哪些对象应该放在老年代中。因此,虚拟机给每个对象设置了一个Age计数器。

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

对象动态Age判断

当前放对象的Survivor区域【其中一块区域,放对象的那块S区】,一批对象的总大小大于这块Survivor区域内存大小的50%【-XX:TargetSurvivorRatio可以指定数值】,那么此时大于等于这批对象Age值最大值的对象,就可以直接进入老年代了,例如Survivor区域里现在有一批对象,age1+age2+ageN的多个age对象总和超过了Survivor区域的50%,此时就会把ageN(含)以上的对象都放入老年代。这个规则其实是希望那些可能长期存活的对象尽早进入老年代。

对象动态age判断机制一般是在Minor GC之后触发。

老年代空间分配担保机制

年轻代每次Minor GC之前JVM都会计算下老年代剩余可用空间

如果这个可用空间小于年轻代里现有的所有对象大小之和(包括垃圾对象),就会看一个参数【-XX:-HandlePromotionFailure】是否设置了,JDK8默认就设置了。

如果有这个参数,就会看老年代的可用内存大小,是否大于之前每一次Minor GC后进入老年代的对象的平均大小。如果不是或者没有设置这个参数,那么就会触发一次Full GC,对老年代和年轻代一起回收一次垃圾,如果回收完还是没有足够空间存放新的对象就会OOM。

如果Minor GC之后剩余存活的需要挪动到老年代的对象大小还是大于老年代可用空间,也会触发Full GC,Full GC之后还没有空间放Minor GC之后的存活对象,也会OOM。

 

 

 

打印GC日志

如果需要打印GC日志,可以设置JVM参数【-XX:+PrintGCDetails】

初始化

  • 内存分配完成之后,虚拟机需要把分配到的内存空间都初始化为零值【不包括对象头】,如果使用TLAB,这个工作过程也可以提前到TLAB分配时进行。这一步操作保证了对象的实例字段在Java代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。

设置对象头

  • 初始化零值之后,虚拟机要对对象进行必要的设置,比如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息,这些信息都存放在对象的对象头Object Header中。
  • 在HotSpot虚拟机中,对象在内存中存储的布局可以分为3块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。
  • HotSpot虚拟机的对象头包括两部分信息,第一部分用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等;另一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。

  执行<init>方法

  • 就是把初始化的默认值赋值为程序员给定的值以及执行构造方法。

对象大小与指针压缩

对象大小可以用jol-core包查看,需要引入依赖:

<dependency>
   <groupId>org.openjdk.jol</groupId>
   <artifactId>jol-core</artifactId>
   <version>0.9</version>
</dependency>

导入类:

import org.openjdk.jol.info.ClassLayout;

使用: 

ClaccLayout layout = ClassLayout.parseInstance(new Xxx());
System.out.println(layout.toPrintable());

什么是Java对象的指针压缩

  • jdk1.6 update14开始,在64bit操作系统中,JVM支持指针压缩;
  • jvm配置参数:UseCompressedOops,compressed表示压缩,oop(ordinary object pointer)表示对象指针;
  • 启用指针压缩:-XX:+UseCompressedOops(默认开启),禁止指针压缩:-XX:-UseCompressedOops,默认开启的只压缩对象头里的类型指针Klass Pointer:-XX:+UseCompressedClassPointers

 为什么要进行指针压缩?

  1. 在64位平台的HotSpot中使用32位指针(实际存储用64位),内存使用会多出1.5倍左右,使用较大指针在主内存和缓存之间移动数据,占用宽带较大,同时GC也会承受较大压力;
  2. 为了减少64位平台下内存的消耗,启用指针压缩功能;
  3. 在JVM中,32位地址最大支持4G内存(2的32次方),可以通过对对象指针的存入堆内存时压缩编码,取出到CPU寄存器后解码的方式进行优化(对象指针在堆中是32位,在寄存器中是35位,2的35次方=32G),使得JVM只用32位地址就可以支持更大的内存配置(小于等于32G);
  4. 堆内存小于4G时,不需要启用指针压缩,JVM会直接去除高32位地址,即使用低虚拟地址空间;
  5. 堆内存地址大于32G时,压缩指针会失效,会强制使用64位(即8字节)来对Java对象寻址,这就会出现1的问题,所以堆内存最好不要大于32G。

关于对齐填充

  • 对于大部分处理器,对象以8字节整数倍来对齐填充都是最高效的存取方式。

对象内存回收

堆中几乎放着所有的对象实例,对堆垃圾回收前的第一步就是要判断哪些对象已经死亡【即不能再被任何途经使用的对象】。

  • 引用计数法
    • 给对象中添加一个引用计数器,每当有一个地方引用它,计数器就+1;当引用失效,计数器就-1;任何时候计数器为0的对象就是不可能再被使用的对象;
    • 这个方法简单高效,但是目前主流的虚拟机中并没有有选择这个算法来管理内存,因为它很难解决对象间相互循环引用的问题。
  • 可达性分析算法
    • 把“GC Roots”对象作为起点,从这些节点开始向下搜索引用的对象,找到的对象都标记为非垃圾对象,其余未标记的对象都是垃圾对象;
    • GC Roots根节点:线程栈的本地变量、静态变量、本地方法栈的变量等。

常见的引用类型

Java的引用类型一般分为:强引用、软引用、弱引用、虚引用。

强引用:普通的变量引用

Order order = new Order();

软引用:将对象用SoftReference软引用类型的对象包裹,正常情况不会被回收,但是GC结束后发现释放不出空间存放新的对象,就会把这些软引用的对象回收掉,软引用可以用来实现内存敏感的高速缓存。

SoftReference<Order> order = new SoftReference<Order>(new Order());

弱引用:把对象用WeakReference软引用类型的对象包裹,弱引用跟没引用差不多,GC会直接回收掉,很少使用。

WeakReference<Order> order = new WeakReference<Order>(new Order());

虚引用:虚引用也称为幽灵引用或者幻影引用,它是最弱的一种引用关系,基本不用。

finalize()方法最终判定对象是否存活

即使在可达性分析算法中不可达的对象,也不是“非死不可”的,这时它们暂时处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历再次标记过程。

标记的前提是对象在进行可达性分析后发现没有与GC Roots相连接的引用链。

  1. 第一次标记并进行一次筛选:筛选的条件是此对象是否有必要执行finalize()方法,当对象没有覆盖finalize()方法,对象会直接被回收。
  2. 第二次标记:如果这个对象覆盖了finali()方法,finalize()方法就是这个对象逃脱死亡命运的最后一次机会,如果对象要在finalize()中成功拯救自己,只要重新与引用链上的任何一个对象建立关联即可,譬如把自己复制给某个类变量或对象的成员变量,那在第二次标记时这个对象就会被移出“即将回收”的集合。如果对象这时候还没逃脱,那基本上就真的被回收了。注意:一个对象的finalize()只会被执行一次,也就是说对象通过调用finalize()自我拯救的机会只有一次。

一般不会用finalize()自救。

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

方法区主要回收的是无用的类,那么如何判断一个类是无用的类呢?

无用的类需要满足这3个条件:

  1. 该类所有的对象实例都已经被回收,也就是Java堆中不存在该类的任何实例;
  2. 加载该类的ClassLoader已经被回收;
  3. 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

 

 

 感谢图灵学院的诸葛老师!!

 

posted @ 2022-09-01 12:02  敲代码的小浪漫  阅读(194)  评论(0编辑  收藏  举报