java一个对象占用多少字节?

最近在读《深入理解Java虚拟机》,对Java对象的内存布局有了进一步的认识,于是脑子里自然而然就有一个很普通的问题,就是一个Java对象到底占用多大内存?

想弄清楚上面的问题,先补充一下基础知识。

1、JAVA 对象布局

在 HotSpot虚拟机中,对象在内存中的存储的布局可以分为三块区域:对象头(Header),实例数据(Instance Data)和对齐填充(Padding)

1.1对象头(Header)
Java中对象头由 Markword + 类指针kclass(该指针指向该类型在方法区的元类型) 组成。
普通对象头在32位系统上占用8bytes,64位系统上占用16bytes。64位机器上,数组对象的对象头占用24个字节,启用压缩之后占用16个字节。

用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等。在64位的虚拟机(未开启压缩指针)为64bit。
Markword:
在这里插入图片描述
类指针kclass:
kclass存储的是该对象所属的类在方法区的地址,所以是一个指针,默认Jvm对指针进行了压缩,用4个字节存储,如果不压缩就是8个字节。 关于Compressed Oops的知识,大家可以自行查阅相关资料来加深理解。

如果对象是一个Java数组,那在对象头中还必须有一块用于记录数组长度的数据,这块占用4个字节。因为虚拟机可以通过普通Java对象的元数据信息确定Java对象的大小,但是从数组的元数据中无法确定数组的大小。
(并不是所有的虚拟机实现都必须在对象数据上保留类型指针,换句话说,查找对象的元数据并不一定要经过对象本身,可参考对象的访问定位)

1.2实例数据(Instance Data)
实例数据部分就是成员变量的值,其中包含父类的成员变量和本类的成员变量。也就是说,除去静态变量和常量值放在方法区,非静态变量的值是随着对象存储在堆中的。
因为修改静态变量会反映到方法区中class的数据结构中,故而推测对象保存的是静态变量和常量的引用。

1.3对齐填充(Padding)
用于确保对象的总长度为8字节的整数倍。
HotSpot要求对象的总长度必须是8字节的整数倍。由于对象头一定是8字节的整数倍,但实例数据部分的长度是任意的。因此需要对齐补充字段确保整个对象的总长度为8的整数倍。

2、Java数据类型有哪些

  • 基础数据类型(primitive type)
  • 引用类型 (reference type)

2.1基础数据类型内存占用如下
java一个对象占用多少字节?_第1张图片2.2引用类型 内存占用如下:
引用类型跟基础数据类型不一样,除了对象本身之外,还存在一个指向它的引用(指针),指针占用的内存在64位虚拟机上8个字节,如果开启指针压缩是4个字节,默认是开启了的。

2.3字段重排序
为了更高效的使用内存,实例数据字段将会重排序。排序的优先级为: long = double > int = float > char = short > byte > boolean > object reference
如下所示的类

class FieldTest{
        byte a;
        int c;
        boolean d;
        long e;
        Object f;
    }

将会重排序为(开启CompressedOops选项):

   OFFSET  SIZE               TYPE DESCRIPTION            
         16     8               long FieldTest.e            
         24     4                int FieldTest.c            
         28     1               byte FieldTest.a            
         29     1            boolean FieldTest.d            
         30     2              (alignment/padding gap)
         32     8   java.lang.Object FieldTest.f

3、验证

讲完了上面的概念,我们可以去验证一下。
3.1有一个Fruit类继承了Object类,我们分别新建一个object和fruit,那他们分别占用多大的内存呢?

class Fruit extends Object {
     private int size;
}

Object object = new Object();
Fruit f = new Fruit();

先来看object对象,通过上面的知识,它的Markword是8个字节,kclass是4个字节, 加起来是12个字节,加上4个字节的对齐填充,所以它占用的空间是16个字节。
再来看fruit对象,同样的,它的Markword是8个字节,kclass是4个字节,但是它还有个size成员变量,int类型占4个字节,加起来刚好是16个字节,所以不需要对齐填充。

那该如何验证我们的结论呢?毕竟我们还是相信眼见为实!很幸运Jdk提供了一个工具jol-core可以让我们来分析对象头占用内存信息。具体使用参考
jol的使用也很简单:
打印头信息

	public static void main(String[] args) {
		System.out.print(ClassLayout.parseClass(Fruit.class).toPrintable());
		System.out.print(ClassLayout.parseClass(Object.class).toPrintable());
	}

输出结果

com.zzx.algorithm.tst.Fruit object internals:
 OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
      0    12        (object header)                           N/A
     12     4    int Fruit.size                                N/A
Instance size: 16 bytes
Space losses: 0 bytes internal + 0 bytes external = 0 bytes total
java.lang.Object object internals:
 OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
      0    12        (object header)                           N/A
     12     4        (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

可以看到输出结果都是16 bytes,跟我们前面的分析结果一致。

3.2 除了类类型和接口类型的对象,Java中还有数组类型的对象,数组类型的对象除了上面表述的字段外,还有4个字节存储数组的长度(所以数组的最大长度是Integer.MAX)。所以一个数组对象占用的内存是 8 + 4 + 4 = 16个字节,当然这里不包括数组内成员的内存。
我们也运行验证一下。

	public static void main(String[] args) {
		String[] strArray = new String[0];
		System.out.println(ClassLayout.parseClass(strArray.getClass()).toPrintable());
	}

输出结果:

[Ljava.lang.String; object internals:
 OFFSET  SIZE               TYPE DESCRIPTION                               VALUE
      0    16                    (object header)                           N/A
     16     0   java.lang.String String;.                        N/A
Instance size: 16 bytes
Space losses: 0 bytes internal + 0 bytes external = 0 bytes total

输出结果object header的长度也是16,跟我们分析的一致。
3.3 接下来看对象的实例数据部分:
为了方便说明,我们新建一个Apple类继承上面的Fruit类

public class Apple extends Fruit {
	private int size;
	private String name;
	private Apple brother;
	private long create_time;
	
}

// 打印Apple的对象分布信息

System.out.println(ClassLayout.parseClass(Apple.class).toPrintable());

// 输出结果

com.zzx.algorithm.tst.Apple object internals:
 OFFSET  SIZE                          TYPE DESCRIPTION                               VALUE
      0    12                               (object header)                           N/A
     12     4                           int Fruit.size                                N/A
     16     8                          long Apple.create_time                         N/A
     24     4                           int Apple.size                                N/A
     28     4              java.lang.String Apple.name                                N/A
     32     4   com.zzx.algorithm.tst.Apple Apple.brother                             N/A
     36     4                               (loss due to the next object alignment)
Instance size: 40 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

可以看到Apple的对象头12个字节,然后分别是从Fruit类继承来的size属性(虽然Fruit的size是private的,还是会被继承,与Apple自身的size共存),还有自己定义的4个属性,基础数据类型直接分配,对象类型都是存的指针占4个字节(默认都是开启了指针压缩),最终是40个字节,所以我们new一个Apple对象,直接就会占用堆栈中40个字节的内存,清楚对象的内存分配,让我们在写代码时心中有数,应当时刻有内存优化的意识!
这里又引出了一个小知识点,上面其实已经标注出来了。

父类的私有成员变量是否会被子类继承?
答案当然是肯定的,我们上面分析的Apple类,父类Fruit有一个private类型的size成员变量,Apple自身也有一个size成员变量,它们能够共存。注意划重点了,类的成员变量的私有访问控制符private,只是编译器层面的限制,在实际内存中不论是私有的,还是公开的,都按规则存放在一起,对虚拟机来说并没有什么分别!

4、方法内部new的对象是在堆上还是栈上?

我们常规的认识是对象的分配是在堆上,栈上会有个引用指向该对象(即存储它的地址),到底是不是呢,我们来做个试验!
我们在循环内创建一亿个Apple对象,并记录循环的执行时间,前面已经算过1个Apple对象占用40个字节,总共需要4GB的空间。

public static void main(String[] args) {
     long startTime = System.currentTimeMillis();
     for (int i = 0; i < 100000000; i++) {
         newApple();
     }
     System.out.println("take time:" + (System.currentTimeMillis() - startTime) + "ms");
}

public static void newApple() {
     new Apple();
}

我们给JVM添加上-XX:+PrintGC运行配置,让编译器执行过程中输出GC的log日志
// 运行结果,没有输出任何gc的日志

take time:6ms

1亿个对象,6ms就分配完成,而且没有任何GC,显然如果对象在堆上分配的话是不可能的,其实上面的实例代码,Apple对象全部都是在栈上分配的,这里要提出一个概念指针逃逸,newApple方法中新建的对象Apple并没有在外部被使用,所以它被优化为在栈上分配,我们知道方法执行完成后该栈帧就会被清空,所以也就不会有GC。
我们可以设置虚拟机的运行参数来测试一下。
// 虚拟机关闭指针逃逸分析

-XX:-DoEscapeAnalysis

// 虚拟机关闭标量替换

-XX:-EliminateAllocations

在VM options里面添加上面二个参数,再运行一次

[GC (Allocation Failure)  236984K->440K(459776K), 0.0003751 secs]
[GC (Allocation Failure)  284600K->440K(516608K), 0.0004272 secs]
[GC (Allocation Failure)  341432K->440K(585216K), 0.0004835 secs]
[GC (Allocation Failure)  410040K->440K(667136K), 0.0004655 secs]
[GC (Allocation Failure)  491960K->440K(645632K), 0.0003837 secs]
[GC (Allocation Failure)  470456K->440K(625152K), 0.0003598 secs]

take time:5347ms

可以看到有很多GC的日志,而且运行的时间也比之前长了很多,因为这时候Apple对象的分配在堆上,而堆是所有线程共享的,所以分配的时候肯定有同步机制,而且触发了大量的gc,所以效率低很多。
总结一下: 虚拟机指针逃逸分析是默认开启的,对象不会逃逸的时候优先在栈上分配,否则在堆上分配。
到这里,关于“一个对象占多少内存?”这个问题,已经能回答的相当全面了。

5.看在Android ART虚拟机上面的分配情况

我们前面使用了jol工具来输出对象头的信息,但是这个jol工具只能用在hotspot虚拟机上,那我们如何在Android上面获取对象头大小呢?
可以使用sun.misc.Unsafe的objectFieldOffset方法,返回成员属性在内存中的地址相对于对象内存地址的偏移量
根据前面的知识,普通对象的结构 就是 对象头+实例数据+对齐字节,那如果我们能获取到第一个实例数据的偏移地址,其实就是获得了对象头的字节大小
5.1 如何拿到并使用Unsafe
因为Unsafe是不可见的类,而且它在初始化的时候有检查当前类的加载器,如果不是系统加载器会报错。但是好消息是,AtomicInteger中定义了一个Unsafe对象,而且是静态的,我们可以直接通过反射来得到。

  public static Object getUnsafeObject() {
        Class clazz = AtomicInteger.class;
        try {
            Field uFiled = clazz.getDeclaredField("U");
            uFiled.setAccessible(true);
            return uFiled.get(null);
        } catch (NoSuchFieldException e) {
            e.printStackTrace();
        } catch (IllegalAccessException e) {
            e.printStackTrace();
        }
        return null;
    }

拿到了Unsafe,我们就可以通过调用它的objectFieldOffset静态方法来获取成员变量的内存偏移地址。

  public static long getVariableOffset(Object target, String variableName) {
        Object unsafeObject = getUnsafeObject();
        if (unsafeObject != null) {
            try {
                Method method = unsafeObject.getClass().getDeclaredMethod("objectFieldOffset", Field.class);
                method.setAccessible(true);
                Field targetFiled = target.getClass().getDeclaredField(variableName);
                return (long) method.invoke(unsafeObject, targetFiled);
            } catch (NoSuchMethodException e) {
                e.printStackTrace();
            } catch (IllegalAccessException e) {
                e.printStackTrace();
            } catch (InvocationTargetException e) {
                e.printStackTrace();
            } catch (NoSuchFieldException e) {
                e.printStackTrace();
            }
        }
        return -1;
    }
     public static void printObjectOffsets(Object target) {
        Class targetClass = target.getClass();
        Field[] fields = targetClass.getDeclaredFields();
        for (Field field : fields) {
            String name = field.getName();
            Log.e(">>>>>", name + " offset: " + getVariableOffset(target, name));
        }
    }

输出结果:

2019-06-25 15:57:59.176 6056-6056/com.zzx.readhub E/>>>>>: size offset: 8
2019-06-25 15:57:59.176 6056-6056/com.zzx.readhub E/>>>>>: brother offset: 12
2019-06-25 15:57:59.176 6056-6056/com.zzx.readhub E/>>>>>: create_time offset: 24
2019-06-25 15:57:59.177 6056-6056/com.zzx.readhub E/>>>>>: name offset: 16
2019-06-25 15:57:59.177 6056-6056/com.zzx.readhub E/>>>>>: size offset: 20

通过输出结果,看出在 Android7.1 ART 虚拟机上,对象头的大小是8个字节,这跟hotspot虚拟机不同(hotspot是12个字节默认开启指针压缩),根据输出的结果目前只发现这一点差别,各种数据类型占用的字节数都是一样的,比如int占4个字节,指针4个字节,long8个字节等,都一样。

总结

全文我们总结了以下几个知识点

Java虚拟机通过字节码指令来操作内存,所以可以说它并不关心数据类型,它只是按指令行事,不同类型的数据有不同的字节码指令。
Java中基本数据类型和引用类型的内存分配知识,重点分析了引用类型的对象头,并介绍了JOL工具的使用
延伸到Android平台,介绍了一种获取Android中对象的对象头信息的方法,并对比了ART和Hotspot虚拟机对象头长度的差别。

你可能感兴趣的:(java)