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

1. 对象的创建

1.1 类加载检查

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

1.2 内存分配

对象所需要的内存大小在类加载完成之后就可以确认。在已知要分配内存大小的情况下,去堆中划分区域出来。

1.2.1 划分内存的方案

  • 指针碰撞(默认方案)

如果堆内存中的区域都是规整的,已用内存和空闲内存分别在一边,中间放着一个分界线的指示器指针,需要把指针向空闲区域移动所需大小的的距离。

  • 空闲列表

如果经历过gc等步骤,内存不规整,需要维护一个空闲内存表,从表中找出足够大的空间用于创建对象

1.2.2 解决内存分配中的并发问题

  • CAS
  • 本地线程分配缓冲(Thread Local Allocation Buffer,TLAB) 把内存分配放在不同的线程内存空间执行,即每个线程预先在java堆中分配一小块内存,通过-XX:UseTLAB参数来设置jvm是否使用TLAB(默认开启),-XX:TLABSize指定TLAB大小。

1.3 初始化零值

内存分配完成后,JVM将初始化后的内存空间初始化为零值(不包括对象头),如果使用了TLAB,这个过程可以提前到TLAB中执行。这一操作保证了对象的实例字段在JAVA代码中可以不赋予初始值就可以使用,拿到的是字段类型所对应的零值。

1.4 设置对象头

  • 对象在内存中可分为三个区域:对象头,实例数据,对齐填充(保证大小是8的倍数)
  • 对象头包含两部分信息:
    *
    1. 存储对象自身的运行时数据:如HashCode、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等。
      1. 类型指针:指向对象所属类的元数据,JVM通过类型指针判断对象的类型。
  • 32位对象头
  • 64位对象头

1.5 执行init方法

即按照编写的JAVA代码的意愿初始化对象,执行构造方法,赋予代码中给予的属性值。

2. 对象大小与指针压缩

对象大小可以使用jol-core包查看

xml 复制代码
<dependency>
    <groupId>org.openjdk.jol</groupId>
    <artifactId>jol-core</artifactId>
    <version>0.9</version>
</dependency>
java 复制代码
import org.openjdk.jol.info.ClassLayout;

/**
 * 计算对象大小
 */
public class JOLSample {

    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.Object object internals:
 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)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total


[I 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)                           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
Instance size: 16 bytes
Space losses: 0 bytes internal + 0 bytes external = 0 bytes total


com.tuling.jvm.JOLSample$A 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)                           61 cc 00 f8 (01100001 11001100 00000000 11111000) (-134165407)
     12     4                int A.id                                      0
     16     1               byte A.b                                       0
     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)
Instance size: 32 bytes
Space losses: 3 bytes internal + 4 bytes external = 7 bytes total

2.1 指针压缩

  • JDK1.6 Updated 14 开始支持指针压缩。
  • -XX:+UseCompressedOops(默认开启),禁止指针压缩:-XX:-UseCompressedOops

2.1.1 为什么要进行指针压缩?

  • 1.在64位平台HotSpot中使用32位指针,但实际存储会占用64位,内存会增大很多,增加GC的压力。
  • 2.为了缓解gc压力和内存消耗,需要引入指针压缩。
  • 3.32位地址最大支持内存2^32,4G内存。可以通过对象指针存入时进行压缩编码,在取出到CPU寄存器的后再解码真正内存地址。(对象指针在堆中是32位,在寄存器中是35位,2^35为32G)使得在开启内存压缩的情况下最大支持内存到了32G
  • 4.堆内存<=4G时,不需要开启指针压缩,JVM会自动取出大于32位的地址,使用低虚拟地址空间
  • 5.内存大于32G时,指针压缩会失效,会强制使用64位8字节来寻址。堆内存最好不要大于32G

3. 对象内存分配

  • 对象一般都会分配在堆上,堆上的对象在没有被引用的时候都需要通过GC回收。当堆上对象过多的时候会给GC带来很大压力。
  • 为了缓解GC压力,就引入了逃逸分析机制。
  • 逃逸分析通过判断对象会不会在方法外被引用,如果逃逸不出方法区域,就可以将该对象分配在栈帧的存储空间中,这样对象会随着方法执行完毕出栈而一同被销毁,减轻了GC的压力。

3.1 对象在栈上分配

3.1.1 对象逃逸分析

就是动态分析对象的作用域,当对象在方法中创建之后,可能被外部方法使用,例如随着方法返回而被传递到其他方法中。;例如:

public 复制代码
   User user = new User();
   user.setId(1);
   user.setName("zhuge");
   //TODO 保存到数据库
   return user;
}

public void test2() {
   User user = new User();
   user.setId(1);
   user.setName("zhuge");
   //TODO 保存到数据库
}java

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

3.1.2 标量替换

通过逃逸分析得出该对象作用域不会超出当前方法,不会被外部访问,并且对象可以被进一步分解时(被多个成员变量所替换),JVM就不会创建当前对象,而是分解当前对象为多个成员变量。这个可以解决没有连续的内存空间导致大对象无法分配空间的问题。JDK7后默认开启标量替换。(参数:-XX:+EliminateAllocations)

3.1.3 标量与聚合量

标量就是不可分割的变量,如JAVA的基本类型。与之对应的就是聚合量可以被分解为标量。

3.1.4 栈上分配示例

java 复制代码
/**
 * 栈上分配,标量替换
 * 代码调用了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
 */
public class AllotOnStack {

    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("zhuge");
    }
}

3.2 对象在Eden区分配

  • 大多数情况下对象在新生代Eden区分配。
  • 当Eden区没有足够的空间分配对象时,JVM会发起一次Minor GC。

3.2.1 Minor GC 和 Full GC 的区别

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

Eden区与Survivor区比例默认8:1:1

  • 1.大量对象被分配在eden区域,eden区放满后会执行Minor GC。
  • 2.Minor GC清除掉99%的垃圾对象,剩余的会被移动到为空的那块Survivor区
  • 3.下一次Minor GC会清理eden区和survivor区,将存活对象移动到另一块空白survivor区域。
  • 4.新生代都是朝生夕死,JVM默认的比例是最优的,eden区足够大,survivor够用即可。

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

3.2.2 示例

创建60M的allocation1

java 复制代码
//添加运行JVM参数: -XX:+PrintGCDetails
public class GCTest {
   public static void main(String[] args) throws InterruptedException {
      byte[] allocation1, allocation2/*, allocation3, allocation4, allocation5, allocation6*/;
      allocation1 = new byte[60000*1024];

      //allocation2 = new byte[8000*1024];

      /*allocation3 = new byte[1000*1024];
     allocation4 = new byte[1000*1024];
     allocation5 = new byte[1000*1024];
     allocation6 = new byte[1000*1024];*/
   }
}

运行结果:
Heap
 PSYoungGen      total 76288K, used 65536K [0x000000076b400000, 0x0000000770900000, 0x00000007c0000000)
  eden space 65536K, 100% used [0x000000076b400000,0x000000076f400000,0x000000076f400000)
  from space 10752K, 0% used [0x000000076fe80000,0x000000076fe80000,0x0000000770900000)
  to   space 10752K, 0% used [0x000000076f400000,0x000000076f400000,0x000000076fe80000)
 ParOldGen       total 175104K, used 0K [0x00000006c1c00000, 0x00000006cc700000, 0x000000076b400000)
  object space 175104K, 0% used [0x00000006c1c00000,0x00000006c1c00000,0x00000006cc700000)
 Metaspace       used 3342K, capacity 4496K, committed 4864K, reserved 1056768K
  class space    used 361K, capacity 388K, committed 512K, reserved 1048576K

直接创建了60M左右的byte数组,可以看到eden区完全被占满,即使什么都不做,jvm本身也会占用一些(几M)eden区的空间。

下面创建8M的allocation2

java 复制代码
//添加运行JVM参数: -XX:+PrintGCDetails
public class GCTest {
   public static void main(String[] args) throws InterruptedException {
      byte[] allocation1, allocation2/*, allocation3, allocation4, allocation5, allocation6*/;
      allocation1 = new byte[60000*1024];

      allocation2 = new byte[8000*1024];

      /*allocation3 = new byte[1000*1024];
      allocation4 = new byte[1000*1024];
      allocation5 = new byte[1000*1024];
      allocation6 = new byte[1000*1024];*/
   }
}

运行结果:
[GC (Allocation Failure) [PSYoungGen: 65253K->936K(76288K)] 65253K->60944K(251392K), 0.0279083 secs] [Times: user=0.13 sys=0.02, real=0.03 secs] 
Heap
 PSYoungGen      total 76288K, used 9591K [0x000000076b400000, 0x0000000774900000, 0x00000007c0000000)
  eden space 65536K, 13% used [0x000000076b400000,0x000000076bc73ef8,0x000000076f400000)
  from space 10752K, 8% used [0x000000076f400000,0x000000076f4ea020,0x000000076fe80000)
  to   space 10752K, 0% used [0x0000000773e80000,0x0000000773e80000,0x0000000774900000)
 ParOldGen       total 175104K, used 60008K [0x00000006c1c00000, 0x00000006cc700000, 0x000000076b400000)
  object space 175104K, 34% used [0x00000006c1c00000,0x00000006c569a010,0x00000006cc700000)
 Metaspace       used 3342K, capacity 4496K, committed 4864K, reserved 1056768K
  class space    used 361K, capacity 388K, committed 512K, reserved 1048576K
  • 当给allocation2分配8M空间时,eden区已经没有足够的空间,于是开始minor gc。
  • allocation1有60M,survivor放不下这么大的内容。
  • 只好将新生代提前转移到老年代,老年代有足够的空间容纳allocation1。不会出现full gc。 > - allocation2如果能在eden区容纳,还会放在eden区。

3.3 大对象直接进入老年代

  • 大对象就是需要连续的存储空间的(数组,字符串等)
  • JVM参数 -XX:PretenureSizeThreshold 可以设置大对象的大小,如果对象超过设置大小会直接进入老年代,不会进入年轻代,这个参数只在 Serial 和ParNew两个收集器下有效。
  • 比如设置JVM参数:-XX:PretenureSizeThreshold=1000000 (单位是字节) -XX:+UseSerialGC ,再执行下上面的第一个程序会发现大对象直接进了老年代
  • 这样可以避免大对象在gc时发生区域复制转移造成的效率低下。

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

  • JVM通对给对象设置年龄计数器的方式,判断对象是否应该进入老年代。
  • 如果对象在eden出生,经过第一minor gc后存活,并且能在survivor中保存的,会给age设置为1.
  • 此后每经历一次minor gc,age会+1。
  • 年龄增加到默认15,就会被转移到老年代。
  • 年龄阈值可以通过 -XX:MaxTenuringThreshold来设置。

3.5 对象动态年龄判断

  • 目的:希望有可能是长期存活的对象尽早进入老年代。
  • 规则:-XX:TargetSurvivorRatio默认50%,如果要向survivor区存放一批对象最大年龄为n,超过了单块survivor区的50%,那么把年龄大于等于n的对象都进入老年代。

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

-XX:-HandlePromotionFailure jdk1.8默认已经设置

4. 对象内存回收

4.1 引用计数法

  • 给对象中添加一个引用计数器,每当有一个地方引用它,计数器就加1;当引用失效,计数器就减1;任何时候计数器为0的对象就是不可能再被使用的。 这个方法实现简单,效率高,但是目前主流的虚拟机中并没有选择这个算法来管理内存,其最主要的原因是它很难解决对象之间相互循环引用的问题。 所谓对象之间的相互引用问题,如下面代码所示:除了对象objA 和 objB 相互引用着对方之外,这两个对象之间再无任何引用。但是他们因为互相引用对方,导致它们的引用计数器都不为0,于是引用计数算法无法通知 GC 回收器回收他们。
java 复制代码
public class ReferenceCountingGc {
   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;
   }
}

4.2 可达性分析算法

  • 将"GC Roots" 对象作为起点,从这些节点开始向下搜索引用的对象,找到的对象都标记为非垃圾对象,其余未标记的对象都是垃圾对象 GC Roots根节点:线程栈的本地变量、静态变量、本地方法栈的变量等等

5.常见引用类型

  • 强引用:普通的变量引用
java 复制代码
public static User user = new User();
  • 软引用 :将对象用SoftReference软引用类型的对象包裹,正常情况不会被回收,但是GC做完后发现释放不出空间存放新的对象,则会把这些软引用的对象回收掉。软引用可用来实现内存敏感的高速缓存
java 复制代码
public static SoftReference<User> user = new SoftReference<User>(new User());

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

  • (1)如果一个网页在浏览结束时就进行内容的回收,则按后退查看前面浏览过的页面时,需要重新构建

  • (2)如果将浏览过的网页存储到内存中会造成内存的大量浪费,甚至会造成内存溢出

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

java 复制代码
public static WeakReference<User> user = new WeakReference<User>(new User());
  • 虚引用:虚引用也称为幽灵引用或者幻影引用,它是最弱的一种引用关系,几乎不用

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

即使在可达性分析算法中不可达的对象,也并非是"非死不可"的,这时候它们暂时处于"缓刑"阶段,要真正宣告一个对象死亡,至少要经历再次标记过程。 标记的前提是对象在进行可达性分析后发现没有与GC Roots相连接的引用链。

    1. 第一次标记并进行一次筛选。

筛选的条件是此对象是否有必要执行finalize()方法。 当对象没有覆盖finalize方法,对象将直接被回收。

    1. 第二次标记

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

  • finalize()方法的运行代价高昂,不确定性大,无法保证各个对象的调用顺序, 如今已被官方明确声明为不推荐使用的语法。 有些资料描述它适合做"关闭外部资源"之类的清理性工作, 这完全是对finalize()方法用途的一种自我安慰。finalize()能做的所有工作, 使用try-finally或者其他方式都可以做得更好、更及时,所以建议大家完全可以忘掉Java语言里面的这个方法。

7. 如何判断JVM方法区中的类是一个无用的类?

需要满足三个条件:

  • 该类所有的对象实例都已经被回收,也就是 Java 堆中不存在该类的任何实例。
  • 加载该类的 ClassLoader 已经被回收。
  • 该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
相关推荐
失散133 小时前
并发编程——11 并发容器(Map、List、Set)实战及其原理分析
java·架构·并发编程
秋难降3 小时前
结构型模式 “全家桶”:适配、装饰、代理…7 种模式让你的代码更 “有章法”
java·设计模式·程序员
yinke小琪3 小时前
Spring生态全家桶:从基础到微服务的演进与关联是什么?
java·后端·spring
AAA修煤气灶刘哥4 小时前
微服务又崩了?5 招 + Sentinel 救场,后端小白也能学会
java·后端·spring cloud
渣哥4 小时前
90% 的 Java 初学者都搞不懂的 List、Set、Map 区别
java
何中应5 小时前
Spring Boot单体项目整合Nacos
java·spring boot·后端
dylan_QAQ5 小时前
Java转Go全过程01-基础语法部分
java·后端·go
Ka1Yan5 小时前
[算法] 双指针:本质是“分治思维“——从基础原理到实战的深度解析
java·开发语言·数据结构·算法·面试
轮子大叔6 小时前
Spark学习记录
java·spark