JVM(三)JVM对象创建与内存分配机制学习

一、对象创建的过程

我们先画一个流程图来看一下对象在创建的过程中,经历了哪些步骤:

类加载检查

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

分配内存

  在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需内存的大小在类加载完成后便可完全确定,为对象分配空间就是把一块确定大小的内存从Java堆中划分出来。 这个步骤存在两个问题需要思考:

  • 1.如何划分内存。
  • 2.在并发情况下, 可能出现正在给对象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大小

初始化

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

设置对象头

  初始化零值之后,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息。

  这些信息存放在对象的对象头Object Header之中。 在HotSpot虚拟机中,对象在内存中存储的布局可以分为3块区域:

  • 对象头(Header)
  • 实例数据(Instance Data)
  • 对齐填充(Padding)

  HotSpot虚拟机的对象头包括两部分信息:

  • 用于存储对象自身的运行时数据 :如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等。
  • 类型指针 :即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。:

  这里我们以32位系统为例来分析一下对象头包含了什么内容:

Mark Word

  Mark Word记录了对象和锁有关的信息,当这个对象被synchronized关键字当成同步锁时,围绕这个锁的一系列操作都和Mark Word有关。

  Mark Word在32位JVM中的长度是32bit,在64位JVM中长度是64bit。

  Mark Word在不同的锁状态下存储的内容不同,在32位JVM中是这么存的:

  PS:其中无锁和偏向锁的锁标志位都是01,只是在前面的1bit区分了这是无锁状态还是偏向锁状态。

指向类的指针

  该指针在32位JVM中的长度是32bit,在64位JVM中长度是64bit。

  PS:Java对象的类数据保存在方法区

数组长度

  PS:只有数组对象保存了这部分数据。

  该数据在32位和64位JVM中长度都是32bit

PS:关于对象头的解析,这篇文章写得不错~

执行<init>方法

  执行<init>方法,即对象按照程序员的意愿进行初始化

  对应到语言层面上讲,就是为属性赋值(PS:这与上面的赋零值不同,这是由程序员赋的值),执行构造方法。

二、对象大小

  我们要分析一个对象结构的话,可以借助一个JOL的工具来帮助我们查看,首先先引入maven依赖:

  然后编写一个测试类:

 1 package com.happyfat.day3.test;
 2 
 3 import org.openjdk.jol.info.ClassLayout;
 4 
 5 /**
 6  * JOL工具计算对象的大小
 7  * @author 有梦想的肥宅
 8  */
 9 public class JOLTest {
10     public static void main(String[] args) {
11         //打印一个Object对象的大小
12         ClassLayout layout = ClassLayout.parseInstance(new Object());
13         System.out.println(layout.toPrintable());
14         System.out.println();
15 
16         //打印一个int数组对象的大小
17         ClassLayout layout1 = ClassLayout.parseInstance(new int[]{});
18         System.out.println(layout1.toPrintable());
19         System.out.println();
20 
21         //打印一个类A的对象的大小
22         ClassLayout layout2 = ClassLayout.parseInstance(new A());
23         System.out.println(layout2.toPrintable());
24     }
25 
26     // ‐XX:+UseCompressedOops 默认开启的压缩所有指针
27     // ‐XX:+UseCompressedClassPointers 默认开启的压缩对象头里的类型指针Klass Pointer
28     // Oops : Ordinary Object Pointers
29     public static class A {
30         /* 8B mark word */
31         /* 4B Klass Pointer */
32         //PS:如果关闭压缩‐XX:‐UseCompressedClassPointers或‐XX:‐UseCompressedOops,则占用8B
33         int id; //4B
34         String name; //4B 【如果关闭压缩‐XX:‐UseCompressedOops,则占用8B】
35         byte b; //1B
36         Object o; //4B 【如果关闭压缩‐XX:‐UseCompressedOops,则占用8B】
37     }
38 }
JOL工具计算对象的大小

  我们对代码进行分析:

  PS:8个字节对齐是对象寻址最优效率最高的一种方式(对齐后对象大小会是8的整数倍,这样的对象存储效率是最高的)

  指针压缩的作用:缩小对象的大小,减少占用堆内存的空间,也减少了GC的次数(idk1.6以后默认是开启指针压缩的)

我们继续对代码进行分析:

三、指针压缩

什么是java对象的指针压缩?

  • 1.jdk1.6开始,在64bit操作系统中,JVM支持指针压缩
  • 2.jvm配置参数-:­XX:+UseCompressedOops。compressed【压缩】、oop(ordinary object pointer)­­【对象指针】
  • 3.启用指针压缩-:­XX:+UseCompressedOops(默认开启),禁止指针压缩-:­XX:-­UseCompressedOops

为什么要进行指针压缩?

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

四、对象内存分配

  要理解对象内存分配,很多概念还是需要理解一波,先上个图来看看大致的流程是怎样的:

对象栈上分配

  我们通过JVM内存分配可以知道JAVA中的对象都是在堆上进行分配,当对象没有被引用的时候,需要依靠GC进行回收内存,如果对象数量较多的时候,会给GC带来较大压力,也间接影响了应用的性能。

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

  对象逃逸分析:就是分析对象动态作用域。当一个对象在方法中被定义后,分析它是否被外部方法所引用,例如作为调用参数传递到其他地方中。

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

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

栈上分配示例

  PS:栈上分配依赖于逃逸分析和标量替换。

对象在Eden区分配

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

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

  PS:Eden与Survivor区默认8:1:1【可设置-XX:+UseAdaptiveSizePolicy(默认开启)参数让比例自动变化】

大对象直接进入老年代

  大对象:需要大量连续内存空间的对象(比如:字符串、数组)。

  参数设置:-XX:PretenureSizeThreshold=字节数 可以设置多大的对象算是大对象,如果对象超过设置大小会直接进入老年代,不会进入年轻代,这个参数只在 Serial 和ParNew两个收集器下有效。

  设计目的:为了避免为大对象在幸存者区之间复制操作而降低效率

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

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

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

对象动态年龄判断

  算法:Survivor区域里现在有一批对象,年龄1+年龄2+年龄n的多个年龄对象总和超过了Survivor区域的50%(-XX:TargetSurvivorRatio可以指定),此时就会把年龄n(含)以上的对象都放入老年代。

  目的:这个规则其实是希望那些可能是长期存活的对象,尽早进入老年代,省去了在幸存者区来回复制的功夫,影响性能。

  PS:对象动态年龄判断机制一般是在minor gc之后触发的。

老年代空间分配担保机制

大致流程以下图为小结:

五、对象内存回收

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

引用计数法

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

  弊端:这个方法实现简单,效率高,但是目前主流的虚拟机中并没有选择这个算法来管理内存,其最主要的原因是它很难解决对象之间相互循环引用的问题

可达性分析算法【推荐】

  将“GC Roots” 对象作为起点,从这些节点开始向下搜索引用的对象,找到的对象都标记为非垃圾对象,其余未标记的对象都是垃圾对象。

  GC Roots根节点:线程栈的本地变量静态变量本地方法栈的变量等等。

常见引用类型

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

强引用

  普通的变量引用。

1 public static User user = new User();

软引用

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

1 public static SoftReference<User> user = new SoftReference<User>(new User());

  软引用在实际中有重要的应用,例如浏览器的后退按钮。按后退时,这个后退时显示的网页内容是重新进行请求还是从缓存中取出呢?这就要看具体的实现策略了。

  • (1)如果一个网页在浏览结束时就进行内容的回收,则按后退查看前面浏览过的页面时,需要重新构建
  • (2)如果将浏览过的网页存储到内存中会造成内存的大量浪费,甚至会造成内存溢出

  所以这里可以使用软引用来缓存前面浏览过的页面内容,如果内存够的时候,“后退”按钮可以直接从软引用对象中读取出前一个页面的内容,如果内存不够,也会直接把这些软引用给回收掉,也不影响正常的新对象创建。

弱引用

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

1 public static WeakReference<User> user = new WeakReference<User>(new User());

虚引用

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

六、小问答

Q:服务器的内存并不是越大越好?为啥?

  一般服务器内存大小不超过32G,因为超过32G的时候,内存地址寻址就要用到35位以上的地址,目前JVM只支持将35位以内的内存地址压缩成32位储存,寻址时再将其解压;若内存大于32G,此时就会直接用到35位以上的内存地址来存取我们的数据,效率上会慢一些。

  PS:详见本文第三点指针压缩相关的解析。

Q:什么时候会去设置分代年龄阈值【超过分代年龄进入老年代】?

  当我们可以推断大部分对象不会长期存活,即方法执行时间不会太长的时候,可以根据经验适当缩小分代年龄阈值,省出更多的年轻代空间【因为老年区的空间往往更大,而年轻代需要频繁的GC以及在幸存者区之间相互复制而影响性能】,提高效率。

posted @ 2021-04-06 21:14  有梦想的肥宅  阅读(555)  评论(0编辑  收藏  举报