牛批!终于有人把JVM内存分配机制讲明白了!超详细解析!

本文深入剖析了Java对象的加载过程,从类加载检查到内存分配,包括指针碰撞、空闲列表、并发安全、TLAB(Thread Local Allocation Buffer)等策略。同时,讨论了对象的内存分配,如栈上分配、逃逸分析、标量替换,以及大对象直接进入老年代的策略。通过对内存分配细节的解析,帮助读者理解JVM如何高效管理内存。
摘要由CSDN通过智能技术生成

一、对象的加载过程

那么,当一个象被new的时候,是如何加载的呢?有哪些步骤,如何分配内存空间的呢?

1.1 对象创建的主要流程

还是这段代码为例说明:

public static void main(String[] args) {
   
    Math math = new Math();
    math.compute();

    new Thread().start();
}

当我们new一个Math对象的时候,其实是执行了一个new指令创建对象。我们之前研究过类加载的流程,那么创建一个对象的流程是怎样的呢?如下图所示。下面我们一个环节一个环节的分析。

fa

1.1.1类加载检查

当虚拟机执行到一条new指令时,首先去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个

符号引用代表的类是否已经被加载、解析和初始化过(也就是检查类是否已经被加载过)。如果没有,那必须先执行相应的类加载流程。

1.1.2分配内存空间

类加载检查通过以后,接下来就是给new的这个对象分配内存空间。对象需要多大内存是在类加载的时候就已经确定了的。为对象分配空间的过程就是从java堆中划分出一块确定大小的内存给到这个对象。那么到底如何划分内存呢?如果存在并发,多个对象同时都想占用同一块内存该如何处理呢?

1)如何给对象划分内存空间?

通常,给对象分配内存有两种方式:一种是指针碰撞,另一种是空闲列表。

  • 指针碰撞

指针碰撞(Bump the Pointer),默认采用的是指针碰撞的方式。如果Java堆中内存是绝对规整的,所有用过的内存都放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空间那边挪动一段与对象大小相等的距离。

image

  • 空闲列表

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

image

不同的内存分配方式,在垃圾回收的时候采用不同的方法。

2)如何解决多个对象并发占用空间的问题?

当有多个线程同时启动的时候,多个线程new的对象都要分配内存,不管内存分配使用的是哪种方式,指针碰撞也好,空闲列表也好,这些对象都要去争抢这块内存。当多个线程都想争抢某一块内存的时候,这时该如何处理呢?通常有两种方式:CAS和本地线程分配缓冲。

  • CAS(compare and swap)

CAS可以理解为多个线程同时去争抢一个快内存,抢到了的就使用,没抢到的就重试去抢下一块内存。

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

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

什么是TLAB呢?简单说,TLAB是为了避免多线程争抢内存,在每个线程初始化的时候,就在堆空间中为线程分配一块专属的内存。自己线程的对象就往自己专属的那块内存存放就可以了。这样多个线程之间就不会去哄抢同一块内存了。jdk8默认使用的就是TLAB的方式分配内存。

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

1.1.3 初始化

内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头), 如果使用TLAB,这一工作过程也

可以提前至TLAB分配时进行。这一步操作保证了对象的实例字段在Java代码中可以不赋初始值就直接使用,程序能访问

到这些字段的数据类型所对应的零值。

1.1.4 设置对象头

我们来看看这个类:

public class Math {
   
    public static int initData = 666;
    public static User user = new User();

    public int compute() {
   
        int a = 1;
        int b = 2;
        int c = (a + b) * 10;
        return c;
    }

    public static void main(String[] args) {
   
        Math math = new Math();
        math.compute();

        new Thread().start();
    }
}

对于一个类,通常我们看到的是成员变量和方法,但并不是说一个类的信息只有我们目光所及的这些内容。在对象初始化零值之后,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息。这些信息存放在对象的对象头Object Header中。 在HotSpot虚拟机中,对象在内存中包含3个部分:

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

实例数据就不多说了,就是我们经常看到的并使用的数据。对象头和填充数据下面我们重点研究。先来说对象头。

1. 对象头的组成部分

HotSpot虚拟机的对象头包括以下几部分信息:

第一部分:Mark Word标记字段,32位占4个字节,64位占8个字节。用于存储对象自身的运行时数据, 如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等。

第二部分:Klass Pointer类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。 开启压缩占4个字节,关闭压缩占8个字节。

第三部分:数组长度,通常是4字节,只有对象数组才有。

2.Mark Word标记字段

如下图所示是一个32位机器的对象头的mark word标记字段。对象不同的状态对应的对象头的结构也是不一样的。根据锁状态划分对象有5种状态,分别是:无状态、轻量级锁、重量级锁、GC标记、偏向锁。

image

无锁状态,就是普通对象的状态。一个对象被new出来以后,没有任何的加锁标记,这时候他的对象头分配是

  • 25位:用来存储对象的hashcode
  • 4位:用来存储分代年龄。之前说过一个新生对象的年龄超过15还没有被回收就会被放入到老年代。为什么年龄设置为15呢?因为分代年龄用4个字节存储,最大就是15了。
  • 1位:存储是否是偏向锁
  • 2位:存储锁标志位

最后这两个就和并发编程有关系了,后面我们会重点研究并发编程的时候研究这一块。

3.Klass Pointer类型指针

在64位机器下,类型指针占8个字节,但是当开启压缩以后,占4个字节

一个对象new出来以后是被放在堆里的,类的元数据信息是放在方法区里的,在new对象的头部有一个指针指向方法区中该类的元数据信息。这个头部的指针就是Klass Pointer。而当代码执行到math.compute()方法调用的时候,是怎么找到compute()方法的呢?实际上就是通过类型指针去找到的。(知道了math指向的对象的地址,再根据对象的类型指针找到方法区中的源代码数据,再从源代码数据中找到compute()方法)。

public static void main(String[] args) {
   
  	Math math = new Math();
  	math.compute();
}

image

对于Math类来说,他还有一个类对象, 如下代码所示:

Class<? extends Math> mathClass = math.getClass();

这个类对象是存储在哪里的呢?这个类对象是方法区中的元数据对象么?不是的。这个类对象实际上是jvm虚拟机在堆中创建的一块和方法区中源代码相似的信息。如下图堆空间右上角。

image

那么在堆中的类对象和在方法区中的类元对象有什么区别呢?

类的元数据信息是放在方法区的。堆中的类信息,可以理解为是类装载后jvm给java开发人员提供的方便的访问类的信息。通过类的反射我们知道,我们可以通过Math的class拿到这个类的名称,方法,属性,继承关系,接口等等。我们知道jvm的大部分实现是通过c++实现的,jvm在拿到Math类的时候,他不会通过堆中的类信息(上图堆右上角math类信息)拿到,而是直接通过类型指针找到方法区中元数据实现的,这块类型指针也是c++实现的。在方法区中的类元数据信息都是c++获取实现的。而我们java开发人员要想获得类元数据信息是通过堆中的类信息获得的。堆中的class类是不会存储元数据信息的。我们可以吧堆中的类信息理解为是方法区中类元数据信息的一个镜像。

Klass Pointer类型指针的含义:Klass不是class,class pointer是类的指针;而Klass Pointer指的是底层c++对应的类的指针

4.数组长度

如果一个对象是数组的话,除了Mark Word标记字段和Klass Pointer类型指针意外,还会有一个数组长度。用来记录数组的长度,通常占4个字节。

对象头在hotspot的C++源码里的注释如下:

5.对象对齐(Object alignment)

我们上面说了对象有三块:对象头,实体,对象对齐。那么什么是对象对齐呢?

对于一个对象来说,有的时候有对象对齐,有的时候没有。JVM内部会将对象的读取信息按照8个字节对齐。至于为什么要按8个字节对齐呢?这是计算机底层原理了,经过大量的实践证明,对象按照8个字节读取效率会非常高。也就是说,最后要求字节数是8的整数倍。可以是8,16,24,32.

6.代码查看对象结构

如何查看对象的内部结构和大小呢?我们可以通过引用jol-core包,然后调用里面的几个方法即可查看

引入jar包

引入jar包:

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

测试代码

import org.openjdk.jol.info.ClassLayout;

/**
 * 查询类的内部结构和大小
 */
public class JOLTest {
   
    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 Object());
        System.out.println(layout2.toPrintable());

    }

    class A {
   
        int id;
        String name;
        byte b;
        Object o;
    }
}

执行代码运行结果:

java.lang.Object object internals:
 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)                           e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值