如何精确地测量java对象的大小

关于java对象的大小测量,网上有很多例子,大多数是申请一个对象后开始做GC,后对比前后的大小,不过这样,虽然说这样测量对象的大小是可行的,不过未必是完全准确的,因为过程中包含对象本身的开销,也许你运气好,正好能碰上,差不多,不过这种测试往往显得十分的笨重,因为要写一堆代码才能测试一点点东西,而且只能在本地测试玩玩,要真正测试实际的系统的对象大小这样可就不行了,本文说说java一些比较偏底层的知识,如何测量对象大小,java其实也是有提供方法的。注意:本文的内容仅仅针对于Hotspot VM,如果你以前不知道jvm的对象大小怎么测量,而又很想知道,跟我一步一步做一遍你就明白了。

首先,我们先写一段大家可能不怎么写或者认为不可能的代码:一个类中,几个类型都是private类型,没有public方法,如何对这些属性进行读写操作,看似不可能哦,为什么,这违背了面向对象的封装,其实在必要的时候,留一道后门可以使得语言的生产力更加强大,对象的序列化不会因为没有public方法就无法保存成功吧,OK,我们简单写段代码开个头,逐步引入到怎么样去测试对象的大小,一下代码非常简单,相信不用我解释什么:

import java.lang.reflect.Field;  
class NodeTest1 {    
    private int a = 13;  
    private int b = 21;  
} 

public class Test001 {    
    public static void main(String []args) {  
        NodeTest1 node = new NodeTest1();  
        Field []fields = NodeTest1.class.getDeclaredFields();  
        for(Field field : fields) {  
            field.setAccessible(true);  
            try {  
                int i = field.getInt(node);  
                field.setInt(node, i * 2);  
                System.out.println(field.getInt(node));  
            } catch (IllegalArgumentException e) {  
                e.printStackTrace();  
            } catch (IllegalAccessException e) {  
                e.printStackTrace();  
            }  
        }  
    }  
}

代码最基本的意思就是:实例化一个NodeTest1这个类的实例,然后取出两个属性,分别乘以2,然后再输出,相信大家会认为这怎么可能,NodeTest1根本没有public方法,代码就在这里,将代码拷贝回去运行下就OK了,OK,现在不说这些了,运行结果为:

26
42

为什么可以取到,是每个属性都留了一道门,主要是为了自己或者外部接入的方便,相信看代码自己仔细的朋友,应该知道门就在:field.setAccessible(true);代表这个域的访问被打开,好比是一道后门打开了,呵呵,上面的方法如果不设置这个,就直接报错。

看似和对象大小没啥关系,不过这只是抛砖引玉,因为我们首先要拿到对象的属性,才能知道对象的大小,对象如果没有提供public方法我们也要知道它有哪些属性,所以我们后面多半会用到这段类似的代码哦!

对象测量大小的方法关键为java提供的(1.5过后才有):java.lang.instrument.Instrumentation,它提供了丰富的对结构的等各方面的跟踪和对象大小的测量的API(本文只阐述对象大小的测量方法),于是乎我心喜了,不过比较恶心的是它是实例化类:sun.instrument.IntrumentationImpl是sun开头的,这个鬼东西有点不好搞,翻开源码构造方法是private类型,没有任何getInstance的方法,写这个类干嘛?看来这个只能被JVM自己给初始化了,那么怎么将它自己初始化的东西取出来用呢,唯一能想到的就是agent代理,那么我们先抛开代理,首先来写一个简单的对象测量方法:

步骤1:(先创建一个用于测试对象大小的处理类)

import java.lang.instrument.Instrumentation;  
public class MySizeOf {  
        private static Instrumentation inst;  
        /** 
         *这个方法必须写,在agent调用时会被启用 
         */
        public static void premain(String agentArgs, Instrumentation instP) {  
            inst = instP;  
        }

        //用来测量java对象的大小(这里先理解这个大小是正确的,后面再深化)  
        public static long sizeOf(Object o) {  
            if(inst == null) {  
                throw new IllegalStateException("Can not access instrumentation environment.\n" +  
                    "Please check if jar file containing SizeOfAgent class is \n" +  
                    "specified in the java‘s \"-javaagent\" command line argument.");  
            }
            return inst.getObjectSize(o);  
        }
}

步骤2:上面我们写好了agent的代码,此时我们要将上面这个类编译后打包为一个jar文件,并且在其包内部的META-INF/MANIFEST.MF文件中增加一行:Premain-Class: MySizeOf代表执行代理的全名,这里的类名称是没有package的,如果你有package,那么就写全名,我们这里假设打包完的jar包名称为agent.jar(打包过程这里简单阐述,就不细说了),OK,继续向下走:

步骤3:编写测试类,测试类中写:

public class TestSize {  
        public static void main(String []args) {  
            System.out.println(MySizeOf.sizeOf(new Integer(1)));  
            System.out.println(MySizeOf.sizeOf(new String("a")));  
            System.out.println(MySizeOf.sizeOf(new char[1]));  
        }
}

下一步准备运行,运行前我们准备初步估算下结果是什么,目前我是在32bit模式下运行jvm(注意,不同位数的JVM参数设置不一样,对象大小也不一样大)。

(1) 首先看Integer对象,在32bit模式下,class区域占用4byte,mark区域占用最少4byte,所以最少8byte头部,Integer内部有一个int类型的数据,占4个byte,所以此时为8+4=12,java默认要求按照8byte对象对其,所以对其到16byte,所以我们理论结果第一个应该是16;
(2) 再看String,长度为1,String对象内部本身有4个非静态属性(静态属性我们不计算空间,因为所有对象都是共享一块空间的),4个非静态属性中,有offset、count、hash为int类型,分别占用4个byte,char value[]为一个指针,指针的大小在bit模式下或64bit开启指针压缩下默认为4byte,所以属性占用了16byte,String本身有8byte头部,所以占用了24byte;其次,一个String包含了子对象char数组,数组对象和普通对象的区别是需要用一个字段来保存数组的长度,所以头部变成12byte,java中一个char采用UTF-16编码,占用2个byte,所以是14byte,对其到16byte,24+16=40byte;
(3) 第三个在第二个基础上已经分析,就是16byte大小;

也就是理论结果是:16、40、16;

步骤4:现在开始运行代码:运行代码前需要保证classpath把刚才的agent.jar包含进去:

D:>javac TestSize.java
D:>java -javaagent:agent.jar TestSize
16
24
16

第一个和第三个结果一致了,不过奇怪了,第二个怎么是24,不是40,怎么和理论结果偏差这么大,再回到理论结果中,有一个24曾经出现过,24是指String而不包含char数组的空间大小,那么这么算还真是对的,可见,java默认提供的方法只能测量对象当前的大小,如果要测量这个对象实际的大小(也就是包含了子对象,那么就需要自己写算法来计算了,最简单的方法就是递归,不过递归一项是我不喜欢用的,无意中在一个地方看到有人用栈写了一个代码写得还不错,自己稍微改了下,就是下面这种了)。

import java.lang.instrument.Instrumentation;  
import java.lang.reflect.Array;  
import java.lang.reflect.Field;  
import java.lang.reflect.Modifier;  
import java.util.IdentityHashMap;  
import java.util.Map;  
import java.util.Stack;  

public class MySizeOf {  

    static Instrumentation inst;  

    public static void premain(String agentArgs, Instrumentation instP) {  
       inst = instP;  
    }  

    public static long sizeOf(Object o) {  
       if(inst == null) {  
          throw new IllegalStateException("Can not access instrumentation environment.\n" +  
             "Please check if jar file containing SizeOfAgent class is \n" +  
             "specified in the java‘s \"-javaagent\" command line argument.");  
       }  
       return inst.getObjectSize(o);  
    }  

    public static long fullSizeOf(Object obj) {//深入检索对象,并计算大小  
       Map<Object, Object> visited = new IdentityHashMap<Object, Object>();  
       Stack<Object> stack = new Stack<Object>();  
       long result = internalSizeOf(obj, stack, visited);  
       while (!stack.isEmpty()) {//通过栈进行遍历  
          result += internalSizeOf(stack.pop(), stack, visited);  
       }  
       visited.clear();  
       return result;  
    }  
    //判定哪些是需要跳过的  
    private static boolean skipObject(Object obj, Map<Object, Object> visited) {  
       if (obj instanceof String) {  
          if (obj == ((String) obj).intern()) {  
             return true;  
          }  
       }  
       return (obj == null) || visited.containsKey(obj);  
    }  

    private static long internalSizeOf(Object obj, Stack<Object> stack, Map<Object, Object> visited) {  
       if (skipObject(obj, visited)) {//跳过常量池对象、跳过已经访问过的对象  
           return 0;  
       }  
       visited.put(obj, null);//将当前对象放入栈中  
       long result = 0;  
       result += sizeOf(obj);  
       Class <?>clazz = obj.getClass();  
       if (clazz.isArray()) {//如果数组  
           if(clazz.getName().length() != 2) {// skip primitive type array  
              int length =  Array.getLength(obj);  
              for (int i = 0; i < length; i++) {  
                 stack.add(Array.get(obj, i));  
              }  
           }  
           return result;  
       }  
       return getNodeSize(clazz , result , obj , stack);  
   }  

   //这个方法获取非数组对象自身的大小,并且可以向父类进行向上搜索  
   private static long getNodeSize(Class <?>clazz , long result , Object obj , Stack<Object> stack) {  
      while (clazz != null) {  
          Field[] fields = clazz.getDeclaredFields();  
          for (Field field : fields) {  
              if (!Modifier.isStatic(field.getModifiers())) {//这里抛开静态属性  
                   if (field.getType().isPrimitive()) {//这里抛开基本关键字(因为基本关键字在调用java默认提供的方法就已经计算过了)  
                       continue;  
                   }else {  
                       field.setAccessible(true);  
                      try {  
                           Object objectToAdd = field.get(obj);  
                           if (objectToAdd != null) {  
                                  stack.add(objectToAdd);//将对象放入栈中,一遍弹出后继续检索  
                           }  
                       } catch (IllegalAccessException ex) {   
                           assert false;  
                  }  
              }  
          }  
      }  
      clazz = clazz.getSuperclass();//找父类class,直到没有父类  
   }  
   return result;  
  }  
}

修改测试类:

public class TestSize {
   public static void main(String []args) {
     System.out.println(MySizeOf.sizeOf(new Integer(1)));
     System.out.println(MySizeOf.sizeOf(new String("a")));
     System.out.println(MySizeOf.fullSizeOf(new String("a")));
     System.out.println(MySizeOf.sizeOf(new char[1]));
   } 
}

D:>javac TestSize.java
D:>java -javaagent:agent.jar TestSize
16
24
40
16

这个结果是我们想要的了,看来这个测试是靠谱的,面对理论和测试结果,以及上面所谓的对齐方法,大家可以自己编写一些类的对象来测试大小看时候和实际的保持一致;

最后,文章补充一些:

  1. 对象采用8字节对齐的方式是不论32bit还是64bit都是一样的;
  2. Java在64bit模式下开启指针压缩,比32bit模式下,头部会大4byte(mark区域变成8byte,class区域被压缩),如果没有开启指针压缩,头部会大8byte(_mark和_class都会变成8byte),jdk1.6推出参数-XX:+UseCompressedOops,在32G内存一下默认会自动打开这个参数,如下:

    [xieyu@oracle001 ~]$ java -Xmx31g -XX:+PrintFlagsFinal |grep Compress
    bool SpecialStringCompress = true {product}
    bool UseCompressedOops := true {lp64_product}
    bool UseCompressedStrings = false {product}
    [xieyu@oracle001 ~]$ java -Xmx32g -XX:+PrintFlagsFinal |grep Compress
    bool SpecialStringCompress = true {product}
    bool UseCompressedOops = false {lp64_product}
    bool UseCompressedStrings = false {product}

简单计算一个,在指针压缩的情况下,一个new String(“a”);这个对象的空间大小为:12字节头部+4*4 = 28字节对齐到32字节,然后c所指向的char数组头部比普通对象多4个byte来存放长度,12+4+2byte的字符=16,也就是48个byte,其实即使你new String()也会占这么大的空间,因为有对齐,如果字符的长度是8个,那么就是12+4+16=32,也就是有64byte;

如果不开启指针压缩再算算:头部变成16byte + 4*3个int数据 + 8(1个指针) = 36对齐到40byte,对应的char数组的头部变成16+4 + 2 = 22对齐到24byte,40+24=64,也就是只有一个字符或者0个字符都会对齐到64byte,所以,你懂的,参数该怎么调,代码该怎么写,如果长度为8个字符的那么后面部分就会变成16+4+16=36对齐到40byte,40+40=80byte,也就是说,抛开其他的引用空间(比如通过数组或集合类引用),如果你有10来个String,每个大小就装8个字符,就会有1K的大小,你的代码里头有多少?呵呵!

这些不是我说的,这些是一种计算方法,而且这个计算结果只会少不会多,因为代码运行过程中,一些对象的头部会伸展,_mark区域装不下会用外部的空间来存放,所以官方给出的说明也是,最少会占用多少字节,绝对不会说只占用多少字节。

OK,说得挺吓人的,不过写代码还是不要怕,不过就这些而言,只是说明java是如何浪费空间的,不要一味使用一些高级的东西,在必要的时候,考虑性能还是有很大的空间,类似集合类以及多维数组,前面的引用其实和数据一点关系都没有,但是占用的空间比数据本身都要大很多。

郑重声明:本站内容如果来自互联网及其他传播媒体,其版权均属原媒体及文章作者所有。转载目的在于传递更多信息及用于网络分享,并不代表本站赞同其观点和对其真实性负责,也不构成任何其他建议。