Kevin's blog Kevin's blog
首页
  • AI基础
  • RAG技术
  • 提示词工程
  • Wireshark抓包
  • 常见问题
  • 数据库
  • 代码技巧
  • 浏览器
  • 手册教程
  • 技术应用
  • 流程规范
  • github技巧
  • git笔记
  • vpn笔记
  • 知识概念
  • 学习笔记
  • 环境搭建
  • linux&运维
  • 微服务
  • 经验技巧
  • 实用手册
  • arthas常用
  • spring应用
  • javaAgent技术
  • 网站
友情链接
  • 分类
  • 标签
  • 归档

Kevin

你可以迷茫,但不可以虚度
首页
  • AI基础
  • RAG技术
  • 提示词工程
  • Wireshark抓包
  • 常见问题
  • 数据库
  • 代码技巧
  • 浏览器
  • 手册教程
  • 技术应用
  • 流程规范
  • github技巧
  • git笔记
  • vpn笔记
  • 知识概念
  • 学习笔记
  • 环境搭建
  • linux&运维
  • 微服务
  • 经验技巧
  • 实用手册
  • arthas常用
  • spring应用
  • javaAgent技术
  • 网站
友情链接
  • 分类
  • 标签
  • 归档
  • JVM性能调优

    • JVM类的加载机制
    • JVM内存模型
    • JVM对象创建与内存分配机制
      • 一 、对象的创建
        • 1、加载检查
        • 2、分配内存
        • 3、初始化
        • 4、设置对象头
        • 5、执行<init>方法
      • 二、对象大小与指针压缩
        • 什么是java对象的指针压缩?
        • 为什么要进行指针压缩?(节约内存空间)
        • 这两者有什么作用呢?
      • 三、对象内存分配
        • 对象栈上分配
        • 对象栈上分配
        • 对象在Eden区(年轻代)分配
        • 大对象直接进入老年代
        • 长期存活的对象将进入老年代
        • 对象动态年龄判断
        • 老年代空间分配担保机制
      • 四、对象内存回收
        • 引用计数器法
        • 可达性分析算法
        • GC Root 根节点
        • 常见引用类型
        • finalize()方法最终判定对象是否存活
        • 如何判断一个类是无用的类?
    • JVM垃圾收集算法
    • JVM垃圾收集器
    • JVM调优工具以及调优实战
    • 字节码与操作数栈
    • GCLog分析
    • JVM 内存分析工具 MAT及实践
  • 并发编程

  • MySql

  • spring

  • redis

  • zookeeper

  • rabbitMQ

  • 架构

  • 锁

  • 分库分表

  • 学习笔记
  • JVM性能调优
kevin
2022-05-09
目录

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

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

# 一 、对象的创建

image-20230527131718984

# 1、加载检查

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

# 2、分配内存

  • 在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需内存大小在类加载完成后便可完全确定,为对象分配空间的任务等同于把一块确定大小的内存从Java堆中划分出来。

    1. 如何划分内存

      • 指针碰撞(Bump the Pointer)默认

        • 如果Java堆中内存是绝对规整的,所有用过的内存都放在一边,空闲的内存存放在另一边(按内存分配顺序规整分配),中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空间那边挪动一段与对象大小相等的距离
      • 空闲列表(Free List)

        • 如果Java堆中的内存并不是规整的,已使用的内存和空闲的内存相互交错,那就没有办法简单地进行 ’指针碰撞‘ 了,虚拟机就必须维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录
    2. 并发情况

      [可能出现正在给对象A分配内存,指针还没来得及修改,对象B又同时使用了原来的指针来分配内存的情况]

    1. CAS失败重试(compare and swap)

    2. 虚拟机采用CAS配上失败重试的方式保证更新操作的原子性来对分配内存空间动作进行同步处理

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

      把内存分配的动作按照线程划分不同的空间之中进行,即每个线程在Java堆中预先分配一小块内存。通过-XX:+/-UseTLAB参数来设定虚拟机是否使用TLAB(JVM会默认开启-XX:+UseTLAB),-XX:TLABSize指定TLAB大小。

# 3、初始化

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

# 4、设置对象头

  • 初始化零值之后,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息。这些信息存放在对象的对象头Object Header之中。
  • 在hotSpot虚拟机中,对象在内存中存储的布局可以分为3块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)(不足8个字节填充到8个字节或者8字节的倍数,存储效率最高,计算机寻址最优大小)。hotSpot虚拟机的对象头包括两部分信息,第一部分用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、偏向线程ID、偏向时间戳。对象头的另外一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
  • 6273815c07912918e24f4102

# 5、执行<init>方法

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

# 二、对象大小与指针压缩

<dependency>
	<groupId>org.openjdk.jol</groupId> 
	<artifactId>jol‐core</artifactId>  
	<version>0.9</version> 
</dependency>
1
2
3
4
5
 /**

 * 计算对象大小
   */
publicclassJOLSample{

   public static void main(String[] args) {
     ClassLayout layout = ClassLayout.parseInstance(new Object());
     System.out.println(layout.toPrintable());

     System.out.println();
     ClassLayout layout1 = ClassLayout.parseInstance(new int[]{});
     System.out.println(layout1.toPrintable());

     System.out.println();
     ClassLayout layout2 = ClassLayout.parseInstance(new A());
     System.out.println(layout2.toPrintable());
   }

   // ‐XX:+UseCompressedOops 默认开启的压缩所有指针
   // ‐XX:+UseCompressedClassPointers 默认开启的压缩对象头里的类型指针Klass Pointer
   // Oops : Ordinary Object Pointers
   public static class A {
    //8B mark word
    //4B Klass Pointer 如果关闭压缩‐XX:‐UseCompressedClassPointers或‐XX:‐UseCompressedOops,则占用8B
     int id; //4B
     String name; //4B 如果关闭压缩‐XX:‐UseCompressedOops,则占用8B
     byte b; //1B
     Object o; //4B 如果关闭压缩‐XX:‐UseCompressedOops,则占用8B
   }
 }

 运行结果:
 java.lang.Objectobjectinternals:
 OFFSET SIZE TYPE DESCRIPTION VALUE
 0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1) //mark word
 4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0) //mark word
 8 4 (object header) e5 01 00 f8 (11100101 00000001 00000000 11111000) (‐134217243) //Klass Pointer
 12 4 (loss due to the next object alignment)
 Instancesize:16bytes
 Spacelosses:0bytesinternal+4bytesexternal=4bytestotal

 [Iobjectinternals:
 OFFSET SIZE TYPE DESCRIPTION VALUE
 0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1)
 4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
 8 4 (object header) 6d 01 00 f8 (01101101 00000001 00000000 11111000) (‐134217363)
 12 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
 16 0 int [I.<elements> N/A
 Instancesize:16bytes
 Spacelosses:0bytesinternal+0bytesexternal=0bytestotal
 56
 com.tuling.jvm.JOLSample$Aobjectinternals:
 OFFSET SIZE TYPE DESCRIPTION VALUE
 0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1)
 4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
 8 4 (object header) 61 cc 00 f8 (01100001 11001100 00000000 11111000) (‐134165407)
 124intA.id0
 161byteA.b0
 17 3 (alignment/padding gap)
 20 4 java.lang.String A.name null
 24 4 java.lang.Object A.o null
 28 4 (loss due to the next object alignment)
 Instancesize:32bytes
 Spacelosses:3bytesinternal+4bytesexternal=7bytestotal
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65

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

  1. jdk1.6 update14开始,在64bit操作系统中,JVM支持指针压缩

  2. jvm配置参数: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次方),可以通过对对象指针的压缩编码、解码方式进行优化,使得jvm只用32位地址就可以支持更大的内存配置(小于等于32G)
  4. 堆内存小于4G时,不需要启用指针压缩,jvm会直接去除高32位地址,即使用低虚拟地址空间
  5. 堆内存大于32G时,压缩指针会失效,会强制使用64位(即8字节)来对java对象寻址,这就会出现1的问题,所以堆内存不要大于32G为好
  • UseCompressedOops:普通对象指针压缩
  • UseCompressedClassPointers:类指针压缩

# 这两者有什么作用呢?

拿新建一个对象来说:

Object o = new Object() 如果不开启普通对象指针压缩,-UseCompressedOops,会在内存中消耗24个字节,o 指针占8个字节,Object对象占16个字节。

如果开启普通对象指针压缩,+UseCompressedOops,会在内存中消耗20个字节,o指针占4个字节,Object对象占16个字节。

这样一看,好像UseCompressedOops 对Object的内存并没有影响,其实不然,Object对象在内存中的布局,包括markword 、 klass pointer、实例数据和填充对其,开启UseCompressedOops,默认会开启UseCompressedClassPointers,会压缩klass pointer 这部分的大小,由8字节压缩至4字节,间接的提高内存的利用率。

由于UseCompressedClassPointers的开启是依赖于UseCompressedOops的开启,因此,要使UseCompressedClassPointers起作用,得先开启UseCompressedOops,并且开启UseCompressedOops 也默认强制开启UseCompressedClassPointers,关闭UseCompressedOops 默认关闭UseCompressedClassPointers。

如果开启类指针压缩,+UseCompressedClassPointers,根据上面的条件,结果跟只开启UseCompressedOops一样,会在内存中消耗20个字节,o指针占4个字节,Object对象占16个字节。

如果关闭类指针压缩,-UseCompressedClassPointers,根据上面的条件,UseCompressedOops还是会开启,会在内存中消耗20个字节,o指针占4个字节,Object对象占16个字节。

如果开启类指针压缩,+UseCompressedClassPointers,并关闭普通对象指针压缩,-UseCompressedOops,此时会报错, UseCompressedClassPointers requires UseCompressedOops

// UseCompressedOops must be on for UseCompressedClassPointers to be on.
  if (!UseCompressedOops) {
    if (UseCompressedClassPointers) {
      warning("UseCompressedClassPointers requires UseCompressedOops");
    }
    FLAG_SET_DEFAULT(UseCompressedClassPointers, false);
  }
1
2
3
4
5
6
7

# 三、对象内存分配

6278d500f346fb18e7c92f43

# 对象栈上分配

  /**
    * 栈上分配,标量替换 代码调用了1亿次alloc(),如果是分配到堆上,大概需要1GB以上堆空间,如果堆空间小于该  值,必然会触发GC。
    * 使用如下参数不会发生GC
    * ‐Xmx15m ‐Xms15m ‐XX:+DoEscapeAnalysis ‐XX:+PrintGC ‐XX:+EliminateAllocations
    * 使用如下参数都会发生大量GC
    * ‐Xmx15m ‐Xms15m ‐XX:‐DoEscapeAnalysis ‐XX:+PrintGC ‐XX:+EliminateAllocations
    * ‐Xmx15m ‐Xms15m ‐XX:+DoEscapeAnalysis ‐XX:+PrintGC ‐XX:‐EliminateAllocations
    */
    publicclassAllotOnStack{
      public static void main(String[] args) { 
        long start = System.currentTimeMillis(); 
        for (int i = 0; i < 100000000; i++) { 
          alloc();
        }
        long end = System.currentTimeMillis(); 
        System.out.println(end ‐ start);
      }
      private static void alloc() { 
        User user = new User(); 
        user.setId(1); 
        user.setName("bajie");
      }
    }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

提示

结论:栈上分配依赖于逃逸分析和标量替换 对象在Eden区分配

# 对象栈上分配

通过JVM内存分配可以知道JAVA中的对象都是在堆上进行分配,当对象没有被引用的时候,需要依靠GC进行回收内存,如果对象数量较多的时候,会给GC带来较大压力,也间接影响了应用的性能。为了减少临时对象在堆内分配的数量,JVM通过逃逸分析确定该对象不会被外部访问。如果不会逃逸可以将该对象在栈上分配内存,这样该对象所占用的内存空间就可以随栈帧出栈而销毁,就减轻了垃圾回收的压力。

  • 对象逃逸分析

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

    public User test1(){
      User user = new User();
      user.setId(1);
      user.setName("bajie");
      //TODO 保存到数据库
      return user;
    }
    
    public void test2(){
      User user = new User(); 
      user.setId(1); 
      user.setName("bajie"); 
      //TODO 保存到数据库
    }
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14

    很显然test1方法中的user对象被返回了,这个对象的作用域范围不确定,test2方法中的user对象我们可以确定当方法结 束这个对象就可以认为是无效对象了,对于这样的对象我们其实可以将其分配在栈内存里,让其在方法结束时跟随栈内 存一起被回收掉。 JVM对于这种情况可以通过开启逃逸分析参数(-XX:+DoEscapeAnalysis)来优化对象内存分配位置,使其通过标量替换优 先分配在栈上(栈上分配),JDK7之后默认开启逃逸分析,如果要关闭使用参数(-XX:-DoEscapeAnalysis)

  • 开启逃逸分析

    开启:-XX:+DoEscapeAnalysis;关闭:-XX:-DoEscapeAnalysis;开启后通过标量替换:-XX:+EliminateAllocations;优先分配在栈上(栈上分配);JDK7之后默认开启逃逸分析

  • 标量替换

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

  • 标量与聚合量

    标量即不可被进一步分解的量,而JAVA的基本数据类型就是标量(如:int,long等基本数据类型以及reference类型等),标量的对立就是可以被进一步分解的量,而这种量成为聚合量。而在 JAVA对象 就是可以被进一步分解的聚合量(通过多个标量集合在一起)

# 对象在Eden区(年轻代)分配

大多情况下,对象在新生代中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

  • Minor GC和Full GC有什么不同?

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

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

提示

当Eden区没有足够空间进行分配时,虚拟机将发起一次Minor GC,GC期间新产生的对象无法存入Survivor空间,那么新生代的对象会提前转移到老年代中

# 大对象直接进入老年代

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

  • 为什么要这样做?

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

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

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

# 对象动态年龄判断

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

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

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

  2. 如果这个可用空间小于年轻代里现有的所有对象大小之和(包括垃圾对象)

  3. 就会看一个 ”-XX:-HandlePromotionFailure“ (jdk1.8默认设置)参数是否设置

  4. 如果设置,就会判断老年代的可用内存大小是否大于之前每一次Minor GC后进入老年代的对象的平均大小

  5. 如果上一步结果是小于或者之前说的参数没有设置,那么就会触发一次Full GC,对老年代和年轻代一起回收一次垃圾

# 四、对象内存回收

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

# 引用计数器法

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

优点

实现简单、效率高

缺点

它很难解决对象之间相互循环引用的问题

所谓对象之间的相互引用问题,如下面代码所示:除了对象objA 和 objB 相互引用着对 方之外,这两个对象之间再无任何引用。但是他们因为互相引用对方,导致它们的引用计数器都不为0,于是引用计数算 法无法通知 GC 回收器回收他们。

publicclassReferenceCountingGc{
 Object instance = null;

 public static void main(String[] args) {
 ReferenceCountingGc objA = new ReferenceCountingGc();
 ReferenceCountingGc objB = new ReferenceCountingGc();
 objA.instance = objB;
 objB.instance = objA;
 objA = null;
 objB = null;
 }
}
1
2
3
4
5
6
7
8
9
10
11
12

# 可达性分析算法

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

# GC Root 根节点
  • 线程栈本地变量、静态变量、本地方法栈变量等等

# 常见引用类型

强引用:普通变量引用的

public static User user = new User();
1

软引用:将对象用SoftReference软引用类型的对象包裹,正常情况不会被回收,但是GC做完后发现释放不出空间存放 新的对象,则会把这些软引用的对象回收掉。

应用场景:软引用可用来实现内存敏感的高速缓存,例如浏览器的后退按钮。按后退时,这个后退时显示的网页内容是重新进行请求还是从 缓存中取出就要看具体的实现策略了

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

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

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

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

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

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

public class OOMTest{ 
 public static void main(String[] args) {
  List<Object> list = new ArrayList<>();
  inti=0;
  intj=0;
  while (true) {
  list.add(new User(i++, UUID.randomUUID().toString()));
  new User(j‐‐, UUID.randomUUID().toString());
  } 
 } 
}
1
2
3
4
5
6
7
8
9
10
11

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

  • 第一次标记

    • 筛选条件是此对象是否有必要执行finalize()方法,当对象么有覆盖finalize()方法,对象将被回收
  • 第二次标记

    • 如果这个对象覆盖了finalize方法,finalize方法是对象脱逃死亡命运的最后一次机会,如果对象要在finalize()中成功拯救 自己,只要重新与引用链上的任何的一个对象建立关联即可,譬如把自己赋值给某个类变量或对象的成员变量,那在第 二次标记时它将移除出“即将回收”的集合。如果对象这时候还没逃脱,那基本上它就真的被回收了。

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

  • 该类所有的实例都已经被回收,也就是java堆中不存在该类的任何实例
  • 加载该类的ClassLoader已经被回收
  • 该类对应的java.lang.class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法
上次更新: 2023/05/27, 13:27:05
JVM内存模型
JVM垃圾收集算法

← JVM内存模型 JVM垃圾收集算法→

最近更新
01
AI是如何学习的
06-03
02
提示词工程实践指南
06-03
03
chatGpt提示原则
06-03
更多文章>
| Copyright © 2022-2025 Kevin | MIT License
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式