• Java - 对象内存计算以及String类型的相关注意事项


    前言

    本篇文章介绍的方式:jol-core这种方法,只能计算当前Object对象所占的内存大小,Object里面如果有内嵌的对象,是不计入其中的,主要看的的是对象的一个内存布局。而更详细的内存占用则需要采取另外的方式计算,后续会讲到。

    一. 基础知识复习

    我们知道,Java中,对象在内存中的内存布局分为三个部分:

    • 对象头。
    • 实例数据。
    • 对齐填充。

    可以复习下相关知识深入理解Java虚拟机系列(一)–Java内存区域和内存溢出异常


    而对象头又可以分为三个部分:

    1. Mark Word,64位操作系统下占8字节,32位系统下占用4字节。
    2. 类型指针:在开启指针压缩的状况下占 4 字节,未开启状况下占 8 字节,默认开启指针压缩,因此占用4字节。
    3. 数组长度(只有数组对象才会有,本篇文章都是普通对象为例)

    1.1 对齐填充占用内存

    然后说下对齐填充。需要注意的是:Java对象的大小默认按照8字节来对齐。即为8字节的整数倍大小。倘若对象大小不足,则由对齐填充部分来补充。

    提问:为什么要进行8字节的对齐?

    回答:

    1. CPU 进行内存访问时,一次寻址的指针大小是 8 字节,正好也是 L1 缓存行的大小。
    2. 如果不进行内存对齐,则可能出现跨缓存行的情况,即缓存行污染。 如图:
      在这里插入图片描述

    缓存污染是指操作系统将不常用的数据从内存移到缓存,降低了缓存效率的现象

    解释:

    1. 我们主要访问obj1这个对象,CPU就将对应的L1缓存读取过来。里面包含了obj1obj2两个对象。
    2. 在后续对obj1进行修改的时候。倘若CPU在访问obj2对象时。由于obj2所在的缓存行中数据被修改了。因此此时CPU必须将其重新加载到缓存行中。影响程序的执行效率。
    3. 倘若obj2有自己的L1 Cache空间。那么在修改obj1对象的时候,就不会对obj2产生影响。

    因此,采用8字节的对齐填充,是一种用空间换时间的一种方案。

    那么总的来说,一个对象所占的内存,记住2点即可:

    1. 对象内存 = 对象头 + 实例数据 + padding 填充。
    2. 对象内存为8字节的整数倍。

    1.2 基础数据类型占用内存表

    类型占用空间(B)
    boolean1
    byte1
    short2
    char2
    int4
    float4
    long8
    double8

    1.3 指针压缩

    上文中提到了类型指针的内存占用情况,在开启指针压缩功能的情况下,占用4个字节,否则是8个字节。这个功能在JDK1.6版本之后就开始支持了。JDK1.8里面则是默认开启状态的。

    启用 CompressOops 后,会压缩的对象包括:

    • 对象的全局静态变量(即类属性)。
    • 对象头信息。
    • 对象的引用类型:64 位系统下,引用类型本身大小为 8 字节,压缩后为 4 字节。

    二. String类型所占内存

    我们以String为例,做一个小测试。首先我们引入一个pom依赖:

    <dependency>
        <groupId>org.openjdk.jol</groupId>
        <artifactId>jol-core</artifactId>
        <version>0.16</version>
    </dependency>
    
    • 1
    • 2
    • 3
    • 4
    • 5

    Java代码:

    System.out.println(ClassLayout.parseInstance("a").toPrintable())
    
    • 1

    结果如下:
    在这里插入图片描述


    分析:

    首先我们来看下object header,上图一共有2个,即对象头部分总共加起来消耗了12kb

    1. 首先我的机器是64位的。使用java -version命令即可查看:
      在这里插入图片描述

    2. 其次,由于是64位的机器,因此对象头中的Mark Word部分占用的8个字节大小。对应的是图中的object header: mark部分。而object header: class则指的是类型指针,占用4个字节大小。因此这里一共是12字节。

    其次我们来看下这两个部分:
    在这里插入图片描述
    这里我们看下String类中包含了哪些成员变量:

    public final class String implements java.io.Serializable, Comparable<String>, CharSequence {
        /** The value is used for character storage. */
        private final char value[];
    
        /** Cache the hash code for the string */
        private int hash; // Default to 0
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    分别对应了:

    1. char[] 数组。用来存储字符串的一个引用。64 位系统下,引用类型本身大小为 8 字节,压缩后为 4 字节。
    2. int类型的hash变量。根据1.2中基础数据类的内存占用表得知,int类型占用了4个字节。对得上。

    到这里为止,总共的大小为12 + 4 + 4 = 20字节。但是其并不是8的整数倍。因此对齐填充会额外占用4个字节的大小,因此一个String类型的字符串占用了24个字节。


    以防万一,我在拿一个自定义类当例子:

    public class SizeObject {
        public int size;
        public double money;
        public byte[] bytes;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5

    计算其所占内存:

    System.out.println(ClassLayout.parseInstance(new SizeObject()).toPrintable());
    
    • 1

    结果如下:
    在这里插入图片描述

    1. 对象头+类型指针,依旧是固定的8+4=12个字节。
    2. int类型占用4个字节,double占用8个字节,byte[]数组属于引用类型,4个字节。
    3. 到这里一共12 + 4 + 8 + 4 = 28个字节。然后并不是8的整数倍,通过对齐填充,再补4个字节。最终得到32字节。

    2.1 String类型的易错点

    首先,我们依旧用上述的例子:我们增长了这个字符串对象,我们看看它占用了多少的内存。

    System.out.println(ClassLayout.parseInstance("adfadsfdsfdsafas").toPrintable());
    
    • 1

    结果如下:
    在这里插入图片描述

    可见,它还是24个字节大小。为什么我字符内容变长了,这个对象的占用内存还是24B呢?这要说到Java的内存数据结构了,这是我在深入理解Java虚拟机系列(一)–Java内存区域和内存溢出异常中贴出的图:
    在这里插入图片描述
    我们看到,有一个运行时常量池和字符串常量池。Java中对于String类型,在实例化字符串的时候做了对应的优化操作:

    1. 每当创建字符串常量时,JVM会首先检查字符串常量池,如果该字符串已经存在常量池中,那么就直接返回常量池中的实例引用。
    2. 如果字符串不在常量池中,就会实例化该字符串,并将其放在常量池中。

    文章提到过,String类型中的char[]数组保存的是这个字符串的引用地址真正的实例对象则是在堆中另外开辟一块空间来存储的。 因此,无论我这个字符串的内容有多少,并不会改变char[]这个引用数组所占的内存。因此在计算String这个实例所占内存的时候,char[]占用的字节数永远是4个字节。

    测试:

    String a = "hello";
    String b = "world";
    String c= "helloworld";
    String res = a + b;
    String res2 = "hello" + "world";
    System.out.println(c == res);
    System.out.println(c == res2);
    System.out.println(res == res2);
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    结果如下:
    在这里插入图片描述
    分析:

    1. 栈中开辟了一块空间引用(String类中的char[]数组),“hello”放入到常量池中,a指向它。
    2. 栈中又开辟了一块空间引用(String类中的char[]数组),“world”放入到常量池中,b指向它。
    3. 栈中又又开辟了一块空间引用(String类中的char[]数组),“helloworld”放入到常量池中,c指向它。
    4. String res = a + b;这段代码本质上调用的是StringBuilder().toString()方法,会返回一个新的String实例,因此此时会在堆中生成一个对象来保存。运行时执行。
    5. String res2 = "hello" + "world";由于“hello”“world”都是常量池中的常量,当字符串由多个字符串常量拼接而成的时候,其本身也是字符串常量。
    6. c==res -->falseres为堆中的一个对象,和常量相比,必定为false
    7. c==res2 -->true:因此res2在创建的时候,发现常量池中已经存在同样的字符串helloworld。返回对应的实例。因此两者本质是一个东西。
    8. res2==res -->falseres为堆中的一个对象,和常量相比,必定为false。同理第六点。

    2.2 真实内存占用计算

    我这里给出两种方式,两种都是仅供参考。第一种比较简单,也不打算详细说:

    System.setProperty("java.vm.name", "Java HotSpot(TM) ");
    String str = "a33adsasdasdas";
    System.out.println(ObjectSizeCalculator.getObjectSize(str));
    
    • 1
    • 2
    • 3

    结果如下:
    在这里插入图片描述


    这里主要讲下第二种方式,首先添加个pom依赖:

    <dependency>
        <groupId>com.carrotsearch</groupId>
        <artifactId>java-sizeof</artifactId>
        <version>0.0.3</version>
    </dependency>
    
    • 1
    • 2
    • 3
    • 4
    • 5

    代码如下:

    // 长度14
    String str = "a33adsasdasdas";
    // 计算这个对象本身在堆中的占用内存大小,单位字节。这里计算出的值应该和上述一致,也是24B
    System.out.println(RamUsageEstimator.shallowSizeOf(str));
    // 计算指定对象及其引用树上的所有对象的综合大小
    System.out.println(RamUsageEstimator.sizeOf(str));
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    结果如图:
    在这里插入图片描述

    可以发现,24B就是当前这个字符串对象本身所占用的内存,而里面真实数据所占用的内存确实不算入其中。至于后者的RamUsageEstimator.sizeOf(str),计算的是指定对象包括其引用树上所有的对象占用的内存总和,计算出72B。我们看下代码做了什么操作,大致分析一下:

    RamUsageEstimator.sizeOf(str);
    ↓↓↓↓↓↓↓↓
    public static long sizeOf(Object obj) {
    	// 相当于引用链,从自身开始,自己当做root去往下延伸。
        ArrayList<Object> stack = new ArrayList();
        stack.add(obj);
        return measureSizeOf(stack);
    }
    ↓↓↓↓↓↓↓↓
    private static long measureSizeOf(ArrayList<Object> stack) {
        IdentityHashSet<Object> seen = new IdentityHashSet();
        IdentityHashMap<Class<?>, RamUsageEstimator.ClassCache> classCache = new IdentityHashMap();
        long totalSize = 0L;
    
        while(true) {
            while(true) {
                Object ob;
                do {
                    do {
                    	// 如果引用链中没有对象了,说明遍历完毕了,可以返回计算的总内存大小了
                        if (stack.isEmpty()) {
                            seen.clear();
                            stack.clear();
                            classCache.clear();
                            return totalSize;
                        }
    					// 每从引用链中遍历一个,就剔除一个对象。
                        ob = stack.remove(stack.size() - 1);
                    } while(ob == null);
                } while(seen.contains(ob));
    			// 表面这个对象已经遍历过
                seen.add(ob);
                Class<?> obClazz = ob.getClass();
                int len$;
                Object o;
                // 对于数组的内存计算
                if (obClazz.isArray()) {
                	// 数组的对象头,占用16个字节,Mark Word:8B,压缩指针4B,数组长度4B(int类型)。8 + 4 + 4 = 16
                    long size = (long)NUM_BYTES_ARRAY_HEADER;
                    len$ = Array.getLength(ob);
                    if (len$ > 0) {
                        Class<?> componentClazz = obClazz.getComponentType();
                        // 确定这个类的类型是否是基本数据类型
                        if (componentClazz.isPrimitive()) {
                        	// 如果是的话,内存占用大小 = 数组长度 * 单个基本数据类型对于的占用大小
                            size += (long)len$ * (long)(Integer)primitiveSizes.get(componentClazz);
                        } else {
                        	// 否则,这个数组的每个对象,先计算它的引用大小。固定是4B。这里的类型就是Object[]
                            size += (long)NUM_BYTES_OBJECT_REF * (long)len$;
                            int i = len$;
    
                            while(true) {
                                --i;
                                if (i < 0) {
                                    break;
                                }
    
                                o = Array.get(ob, i);
                                if (o != null && !seen.contains(o)) {
                                    stack.add(o);
                                }
                            }
                        }
                    }
    
                    totalSize += alignObjectSize(size);
                } 
                // 普通对象的计算
                else {
                    try {
                    	// 一个缓存,如果存在两个实例,但是他们本质是同一个对象,那么内存占用肯定也就只有一块了。
                        RamUsageEstimator.ClassCache cachedInfo = (RamUsageEstimator.ClassCache)classCache.get(obClazz);
                        // 缓存不存在,就放进去。
                        if (cachedInfo == null) {
                            classCache.put(obClazz, cachedInfo = createCacheEntry(obClazz));
                        }
    
                        Field[] arr$ = cachedInfo.referenceFields;
                        len$ = arr$.length;
    
                        for(int i$ = 0; i$ < len$; ++i$) {
                            Field f = arr$[i$];
                            o = f.get(ob);
                            if (o != null && !seen.contains(o)) {
                                stack.add(o);
                            }
                        }
    					// 这里都是计算每个对象的本身占用内存。
                        totalSize += cachedInfo.alignedShallowInstanceSize;
                    } catch (IllegalAccessException var13) {
                        throw new RuntimeException("Reflective field access failed?", var13);
                    }
                }
            }
        }
    }
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68
    • 69
    • 70
    • 71
    • 72
    • 73
    • 74
    • 75
    • 76
    • 77
    • 78
    • 79
    • 80
    • 81
    • 82
    • 83
    • 84
    • 85
    • 86
    • 87
    • 88
    • 89
    • 90
    • 91
    • 92
    • 93
    • 94
    • 95
    • 96
    • 97

    String中用一个char[]数组保存了相关的数据,这部分的内存我们重点关注这段代码:

    long size = (long)NUM_BYTES_ARRAY_HEADER;
    // ...
    size += (long)len$ * (long)(Integer)primitiveSizes.get(componentClazz);
    
    • 1
    • 2
    • 3

    我们来看下primitiveSizes是什么,它相当于一个Map映射。保存了每个基本数据类型对应的内存占用大小,可以看到char类型对应的是2个字节。即16位。由于本次的案例字符串,长度为14,因此这里计算出的结果是 14 * 2 = 28B。但是我们知道,Java对象内存占用大小为8的整数倍。因此由对其填充的存在,此时最终的内存占用为32B。
    在这里插入图片描述
    那么String类型中,char[]数组占用的总内存就是:数组对象头 + 实例数据占用的内存。即16B + 32B = 48B

    你可以看做每个对象的真实内存占用大小分为两个部分:

    1. 第一部分:对象本身占用。这一部分中,对象中的属性,如果是引用类型,例如Object[],或者Object类型的成员,其占用内存统一为4B。如果是基础数据类型,就按照1.2节中的表格来计算。
    2. 和该对象有关的引用链上所有对象的内存总和。计算Object类型占用的内存。也可能递归整个操作。因为无论什么类型的对象,其最底层最底层肯定是由基础数据类型的成员构建而成的,而最终要计算的内存占用大小就是针对这块来进行的。

    那么这个str对象,占用的总内存计算为:

    1. String本身的占用内存为24B,这部分可以复习2.1节,主要是看他的内存布局。本身对象头12B,char[]数组引用(注意这里相对于String类型本身而言,只是一个引用对象 )占用4B,int类型的hash属性占用4B。对其填充占用4B,共24B。
    2. char[]数组占用48B,因此总共24B + 48B = 72B
  • 相关阅读:
    【Rust日报】2023-10-23 让 Rust 编译器快 5% 的奇怪窍门
    C++ 学习之旅(2.5)——变量与函数
    【SemiDrive源码分析】系列文章链接汇总(全)
    MES如何提升企业数字化能力?
    SAP ABAP Function Module 的动态调用方式使用方式介绍试读版
    唐老师讲电赛
    运行npm install时报错“npm ERR! code 1”
    HarmonyOS 习题(二)
    Ansible的lookup,query,with_xxx
    一些工具/网站自用总结
  • 原文地址:https://blog.csdn.net/Zong_0915/article/details/126177253