你连你的对象都还不了解,还天天喊着面向对象编程?

推荐阅读:

Java是一种面向对象的编程语言,详细自己对对象的理解是否只有一句话来描述:一切皆对象,new出来的对象都在堆上!等等,这不是2句话?不,后面这句只是我写这篇文章的原由。初学Java大家都说new出来的对象都在堆上,对此深信不疑!但是后续越发对这句话产生怀疑,想想每个类的toString方法都会new一个StringBuffer,这样做堆内存岂不是增大一倍?For循环中创建对象为什么没有堆溢出?创建的对象到底在堆中占用多少内存?怀着以上疑问往下看,本篇文章作为Java对象的综合整理来描述何谓对象。

Java中一切皆对象,对象的创建主要如下:

People people = new People();

现在面试都是各种文字坑,例如:问这个对象是否在堆上分配内存?怎么回答,是?不是?

这个问题,要根据上下文来回答,就是要根据这行代码所处的环境来回答,何谓环境:运行环境JRE、书写位置,不同环境结果不一样。想知道结果,先Get到以下知识点:

逃逸分析是JDK6+版本后默认开启的技术(现在都JDK15了,都是旧技术了==!),主要分析方法内部的局部变量的引用作用域,用于做后续优化。逃逸分析之后一个方法内的局部变量被分为3类逃逸对象

  • 全局逃逸对象: 对外部而言,该对象可以在类级别上直接访问到(调用类获取对象实例)
  • 参数逃逸对象:对外部而言,该对象可以在方法级别上直接访问到(调用方法获取对象实例)
  • 未逃逸对象:对外部而言,该对象仿佛不存在一样,不可嗅探

后续优化指的是对未逃逸的优化,主要分为标量替换和锁消除

标量替换:在Java中8种基本数据类型已经是可以直接分配空间的,不可再被细化,称为标准变量,简称标量。对象的引用是内存地址也不可再被细化,也可以称为标量。而Java对象则是由多个标量聚合而来,称为聚合量。按照这种标准将Java对象的成员变量拆分替换为标量的过程,称为标量替换。这个过程会导致对象的分配不一定在堆中,而是在栈上或者寄存器中。

锁消除:Java锁是针对多线程而使用的,当在单线程环境下使用锁后被JIT编译器优化后就会移除掉锁相关代码,这个过程就是锁消除(属于优化,不影响对象)。

指针压缩:32位机器对象的引用指针使用32位表示,在64位使用64位表示,同样的配置而内存占用增多,这样真的好吗?JDK给出指针优化技术,将64位(8字节)指针引用(Refrence类型)压缩为32位(4字节)来节省内存空间。

对象的逃逸

一个标准大小=32byte的Java对象(后面会写如何计算)

class People {
    int i1;
    int i2;
    int i3;
    byte b1;
    byte b2;
    String str;
}

未逃逸对象

public class EscapeAnalysis {

    public static void main(String[] args) throws IOException {
        // 预估:在不发生GC情况下32M内存
        for (int j = 0; j < 1024 * 1024; j++) {
            unMethodEscapeAnalysis();
        }
        // 阻塞线程,便于内存分析
        System.in.read();
    }

    /**
     * people对象引用作用域未超出方法作用域范围
     */
    private static void unMethodEscapeAnalysis() {
        People people = new People();
        // do  something
    }
}

未开启逃逸分析

启动JVM参数

-server -Xss108k -Xmx1G -Xms1G -XX:+PrintGC -XX:-UseTLAB -XX:-DoEscapeAnalysis -XX:-EliminateAllocations

启动控制台:无输出:未发生GC

堆内存查看

$ jps
3024 Jps
16436 EscapeAnalysis
24072 KotlinCompileDaemon
$ jmap -histo 16436

 num     #instances         #bytes  class name
----------------------------------------------
   1:       1048576       33554432  cn.tinyice.demo.object.People
   2:          1547        1074176  [B
   3:          6723        1009904  [C
   4:          4374          69984  java.lang.String

此时堆中共创建了1024*1024个实例,每个实例32byte,共32M内存

开启逃逸分析

启动JVM参数

-server -Xss108k -Xmx1G -Xms1G -XX:+PrintGC -XX:-UseTLAB -XX:-DoEscapeAnalysis -XX:-EliminateAllocations

启动控制台:无输出:未发生GC

堆内存查看

$ jps
3840 Jps
24072 KotlinCompileDaemon
25272 EscapeAnalysis
$ jmap -histo 25272

 num     #instances         #bytes  class name
----------------------------------------------
   1:       1048576       33554432  cn.tinyice.demo.object.People
   2:          1547        1074176  [B
   3:          6721        1009840  [C
   4:          4372          69952  java.lang.String

此时与未开启一致,仍然是在堆中创建了1024*1024个实例,每个实例32byte,共32M内存

开启逃逸分析和标量替换

启动JVM参数

-server -Xss108k -Xmx1G -Xms1G -XX:+PrintGC -XX:-UseTLAB -XX:+DoEscapeAnalysis -XX:+EliminateAllocations

堆内存查看

$ jps
7828 Jps
21816 EscapeAnalysis
24072 KotlinCompileDaemon
$ jmap -histo 21816

 num     #instances         #bytes  class name
----------------------------------------------
   1:         92027        2944864  cn.tinyice.demo.object.People
   2:          1547        1074176  [B
   3:          6721        1009840  [C
   4:          4372          69952  java.lang.String

此时堆中仅创建了92027个实例,内存占用少了11倍。

启动控制台:无输出:未发生GC,说明实例的确未分配到堆中

未分配到堆中,是因为一部分分配到了栈中,这种未逃逸对象如果分配到栈上,则其生命周期随栈一起,使用完毕自动销毁。下面为java对象分配的具体细节。

对象的内存分配

实例分配原则


  1. 尝试栈上分配

    • 基于逃逸分析和标量替换,将线程私有对象直接分配在栈上

    • 在函数调用完毕后自动销毁对象,不需要GC回收

    • 栈空间很小,默认108K,不能分配大对象

  2. 尝试TLAB

    • 判断是否使用TLAB(Thread Local Allocation Buffer)技术

      • 虚拟机参数 -XX:+UseTLAB,-XX:-UseTLAB,默认开启

      • 虚拟机参数-XX:TLABWasteTargetPercent 来设置TLAB占用eEden空间百分比,默认1%

      • 虚拟机参数-XX:+PrintTLAB 打印TLAB的使用情况

      • TLAB本身占用eEden区空间,空间很小不能存放大对象,

      • 每个线程在Java堆中预先分配了一小块内存,当有对象创建请求内存分配时,就会在该块内存上进行分配

      • 使用线程控制安全,不需要在Eden区通过同步控制进行内存分配

  3. 尝试老年代分配(堆分配原则)

    • 如果可以直接进入老年代,直接在老年代分配
  4. 以上都失败时(注意分配对象时很容易触发GC,堆分配原则)

    • 内存连续时:使用指针碰撞(Serial、ParNew等带Compact过程的收集器)

      • 分配在堆的Eden区,该区域内存连续

      • 指针始终指向空闲区的起始位置。

      • 在新对象分配空间后,指针向后移动了该对象所占空间的大小个单位,从而指向新的空闲区的起始位置

      • 对象分配过程中使用了CAS加失败重试的方式来保证线程安全(CAS即原子操作)

      • 如果成功:则进行对象头信息设置

    • 内存不连续时:使用空闲列表(CMS这种基于Mark-Sweep算法的收集器)

      • 如果堆空间不是连续的,则JVM维护一张关系表,来使内存逻辑上连续从而达到对象分配的目

堆分配原则:


  • 优先在Eden(伊甸园)区进行分配

    • 可通过-XX:SurvivorRation=8来确定Eden与Survivor比例为 8:1

    • 新生代存在2个Survivor区域(From和To),当新生代10份时,Survivor共占2份,Eden占8份

    • 新建对象会先在Eden中分配

      • 空间足够时直接分配

      • 当Eden空间不足时

        • 将Eden内的对象进行一次Minor Gc 回收准备放入进入From类型的Survivor区

          • From类型的Survivor区

            • 空间足够时,放置GC对象时将GC对象回收进来

            • 空间不足时,将GC对象直接放入老年代中

        • Minor GC后Eden空间仍然不足

          • 新建对象直接进入老年代
  • 长期存活的对象移交老年代(永久代)

    • 在Eden的对象经过一次Minor GC进入Survivo 区后,对象的对象头信息年龄字段Age+1

    • Survivor区对象每经过一次Minor GC对象头信息年龄字段Age+1

      • 会在From Survivor和ToSurvivor 区进行来回GC(复制算法)
    • 当对象的年龄达到一定值(默认15岁)时就会晋升到老年代

    • -XX:MaxTenuringThreshold=15设置分代年龄为15

  • 大对象直接进入老年代(永久代)

    • 大对象为占用堆内大量连续空间的对象(数组类、字符串)

    • -XX:MaxTenuringThreshold=4M 可以设置大于4M的对象直接进入老年代

  • 动态年龄判断

    • GC回收对象时并不一定必须严格要求分代年龄进行晋升老年代

    • 当Survivor区的同年龄对象的总和大于Survivor空间1/2时

      • 年龄大于等于该年龄(相同年龄)的对象都可以直接进入老年代
  • 老年代对象分配使用空间分配担保

    • 新生代所有对象大小小于老年代可用空间大小时,Minor GC是安全的

      • 相当于新生代所有对象都可以放到老年代里面,因而不会出现溢出等现象
    • 相反,Minor GC是不安全的

      • 相当于新生代对象只能有一部分可以放入老年代,另一部分会因为空间不足而放入失败

      • 安全措施-XX:HandlePromotionFailure=true,允许担保失败

      • 发生MinorGC之前,JVM会判断之前每次晋升到老年代的平均大小是否大于老年代剩余空间的大小

        • 若小于于并且允许担保失败则进行一次Minor GC

          • 对象GC预测平稳,不会发生大量对象突然进入老年代导致其空间不足而溢出
        • 若小于并且不允许担保失败则进行一次full GC

          • 即使对象GC预测平稳,但是不保证不会激增,所以安全点还是先去Full GC下

          • 回收所有区域,给老年代清理出更多空间

        • 若小于即使允许担保失败也进行一次full GC

          • 即Minor GC后的存活对象数量突然暴增,即使允许担保失败但是还是极大可能是不安全的

          • 回收所有区域,给老年代清理出更多空间

对象实例组成

  • 对象头

    • MarkWord(必须)

    • 类型指针:指向对象的类元数据(非必须)

    • 数组长度(数组类型对象才有)

  • 实例数据

    • 对象的字段属性,方法等,存储在堆中
  • 数据填充

    • JVM要求java的对象占的内存大小应该是8bit的倍数

    • 实例数据有可能不是8的倍数,需要使用0进行填充对齐

MarkWord结构

对象的初始化

由于对象初始化涉及到类加载,这里不多描述

  • 分配到的空间设置为0

  • 数据填充0,8字节对齐

  • 对象头信息设置

  • 调用进行初始化(类的实例化)

给个示例先体会下

public class ClinitObject {

    static ClinitObject clinitObject;

    static {
        b = 2;
        clinitObject = new ClinitObject();
        System.out.println(clinitObject.toString());
    }

    int a = 1;
    static int b;
    final static int c = b;
    final static String d = new String("d");
    String e = "e";
    String f = "f";

    public ClinitObject() {
        e = d;
        a = c;
    }

    @Override
    public String toString() {
        return "ClinitObject{" + "\n" +
                "\t" + "a=" + a + "\n" +
                "\t" + "b=" + b + "\n" +
                "\t" + "c=" + c + "\n" +
                "\t" + "d=" + d + "\n" +
                "\t" + "e=" + e + "\n" +
                "\t" + "f=" + f + "\n" +
                '}';
    }

    public static void main(String[] args) {
        System.out.println(clinitObject.toString());
    }
}

控制台

ClinitObject{
	a=0
	b=2
	c=0
	d=null
	e=null
	f=f
}
ClinitObject{
	a=0
	b=2
	c=2
	d=d
	e=null
	f=f
}

对象的大小计算

  • 普通对象

    • 4或8字节(MarkWord)+4或8字节(klass Reference)+实例数据长度+ 0填充(Padding)
  • 数组对象

    • 4或8字节(MarkWord)+4或8字节(klass Reference)+4字节(ArrayLength)+实例数据长度+0填充(Padding)
  • 其它说明:

    • 对象头(MarkWord)在32位JVM中为4字节,在64位JVM中为8字节

    • 为了节约空间,使用了指针压缩技术:

      • JDK6开始对类型指针(Reference)进行压缩,压缩前8字节,压缩后4字节

        • 参数 -XX:+UseCompressedOops
      • JDK8开始新增元数据空间metaSpace,于是新增参数来控制指针压缩:

        • -XX:+UseCompressedClassPointers(指针压缩开关,堆内存>=32G时,自动关闭)

        • -XX:CompressedClassSpaceSize (Reference指向的类元数据空间大小,默认1G,上限32G)

    • 数据填充(Padding)为保证对象大小为8的整数倍的数据填充,使数据对齐

  • 常用数据类型大小

对象的定位

java源码中调用对象在JVM中是通过虚拟机栈中本地变量标的reference来指向对象的引用来定位和访问堆中的对象的,访问方式存在主流的2种

  • 句柄访问

    • jvm堆中单独维护一张reference与对象实例数据(实例化数据)和对象类型数据(ClassFile数据)的关系表

    • 通过该关系表来查找到java实例对象

  • 直接访问(Sun HotSpot 采用该方式)

    • reference直接指向了java堆中对象的实例数据(实例化数据),该实例对象的类型指针(Reference)指向类型数据(ClassFile数据)

指针压缩示例

public class CompressedClassPointer {

    public static void main(String[] args) throws IOException {
        People people=new People();
        System.in.read();
    }
}

启用指针压缩(默认)

JVM参数

-server -XX:+UseCompressedOops -XX:+UseCompressedClassPointers -XX:CompressedClassSpaceSize=1G

堆内存查看

$ jps
11440
11744 RemoteMavenServer
14928 KotlinCompileDaemon
15540 Launcher
15908 Jps
9996 CompressedClassPointer
$ jmap.exe -histo 9996

 num     #instances         #bytes  class name
----------------------------------------------
... 
233:             1             32  cn.tinyice.demo.object.People

关闭指针压缩

JVM参数

-server -XX:-UseCompressedOops

堆内存查看

$ jps
11440
11744 RemoteMavenServer
14928 KotlinCompileDaemon
8448 CompressedClassPointer
$ jmap.exe -histo 8448

 num     #instances         #bytes  class name
----------------------------------------------
...
254:             1             40  cn.tinyice.demo.object.People

示例解析

示例中开启之后对象大小会减少8byte。而指针压缩是8字节变4字节,按理说应该少4字节即32位,为什么这个样子?

开启压缩指针时的对象大小计算

/**
 * Size(People) =
 * 8(mark word)+4(klass reference)+ 4(i1)+4(i2)+4(i2)+1(b1)+1(b2)+4(str reference) + 2(padding)
 * |----------------------------------- 30 byte ---------------------------------|----00-------/
 * |---------------------------------------- 32 byte ------------------------------------------/
 */

关闭压缩指针时的对象大小计算

/**
 * Size(People) =
 * 8(mark word)+8(klass reference)+ 4(i1)+4(i2)+4(i2)+1(b1)+1(b2)+8(str reference) + 2(padding)
 * |----------------------------------- 38 byte ---------------------------------|----00-------/
 * |---------------------------------------- 40 byte ------------------------------------------/
 */

这里就看到区别了,是数据填充造成的,java为了便于数据管理,于是对象都是8字节对齐的,不足的使用0进行填充(padding)。

至于对象的实例化,会在写类加载流程是再做描述。

猜你喜欢

转载自blog.csdn.net/weixin_45784983/article/details/108791174