Java有像C语言sizeof()的运算符吗?
一个肤浅的答案是Java不提供任何类似于C的sizeof()
的东西。但是,让我们考虑一下为什么Java程序员偶尔会想要它。
C程序员自己管理大多数数据结构内存分配,而sizeof()
对于知道要分配的内存块大小是必不可少的。此外,像malloc()
这样的C内存分配器在对象初始化方面几乎什么都不做:程序员必须设置所有指向其他对象的对象字段。但是,当一切都说出来并编码时,C/C++内存分配是非常有效的。
相比之下,Java对象的分配和构造是绑定在一起的(不可能使用已分配但未初始化的对象实例)。如果Java类定义的字段是对其他对象的引用,那么在构建时设置它们也是很常见的。因此,分配Java对象经常会分配许多互连的对象实例:对象图。再加上自动垃圾收集,这一切都太方便了,可以让你觉得你永远不必担心Java内存分配的细节。
当然,这只适用于简单的Java应用程序。与C/C++相比,等价的Java数据结构往往占用更多的物理内存。在企业软件开发中,在今天的32位JVM上接近最大可用虚拟内存是一个常见的可伸缩性限制。因此,Java程序员可以从sizeof()
或类似的东西中受益,以关注他的数据结构是否变得太大或包含内存瓶颈。幸运的是,Java反射允许您非常容易地编写这样一个工具。
在继续之前,我将省略对本文问题的一些常见但不正确的回答。
谬论:不需要Sizeof(),因为Java基本类型的大小是固定的
是的,Java int
在所有JVM和所有平台上都是32位的,但这只是程序员对该数据类型的可感知宽度的语言规范要求。这样的int
本质上是一种抽象数据类型,可以由64位机器上的64位物理内存字进行备份。非区分类型也是如此:Java语言规范没有说明类字段应该如何在物理内存中对齐,也没有说明布尔数组不能在JVM中实现为紧凑的位向量。
谬论:可以通过将对象序列化为字节流并查看结果流长度来测量对象的大小
这不起作用的原因是,序列化布局只是内存中true布局的远程反映。一个简单的方法是观察字符串是如何序列化的:在内存中,每个字符至少有2个字节,但在序列化形式中,字符串是UTF-8编码的,因此任何ASCII内容都占用一半的空间。
object的大小是多少?
上面的讨论强调了一个哲学观点:考虑到你通常处理对象图,对象大小的定义是什么?它只是您正在检查的对象实例的大小,还是植根于该对象实例的整个数据图的大小?后者在实践中通常更重要。正如你将看到的,事情并不总是那么明确,但对于初学者来说,你可以遵循这种方法:
- 对象实例的大小可以通过合计其所有非静态数据字段(包括在超类中定义的字段)来确定(近似)
- 与C++不同,类方法及其虚拟性对对象大小没有影响
- 类超接口对对象大小没有影响
- 完整的对象大小可以作为根在起始对象的整个对象图的闭包来获得
注意:实现任何Java接口都只是标记有问题的类,而不会向其定义中添加任何数据。事实上,JVM甚至没有验证接口实现是否提供了接口所需的所有方法:在当前规范中,这完全是编译器的责任。
事实证明,对于常见的32位JVM,一个普通的java.lang.Object
占用8个字节,并且基本数据类型通常是能够满足语言要求的最小物理大小(布尔值占用整个字节除外):
// java.lang.Object shell size in bytes:
public static final int OBJECT_SHELL_SIZE = 8;
public static final int OBJREF_SIZE = 4;
public static final int LONG_FIELD_SIZE = 8;
public static final int INT_FIELD_SIZE = 4;
public static final int SHORT_FIELD_SIZE = 2;
public static final int CHAR_FIELD_SIZE = 2;
public static final int BYTE_FIELD_SIZE = 1;
public static final int BOOLEAN_FIELD_SIZE = 1;
public static final int DOUBLE_FIELD_SIZE = 8;
public static final int FLOAT_FIELD_SIZE = 4;
(重要的是要认识到,这些常量并不是永远硬编码的,必须针对给定的JVM进行独立测量。)当然,对象字段大小的忽略了JVM中的内存对齐问题。内存对齐确实很重要,但我认为追逐这种低级细节是无利可图的。这些细节不仅依赖于JVM供应商,而且不在程序员的控制之下。我们的目标是很好地猜测对象的大小,并希望在类字段可能是多余的时候得到线索;或者当字段应该被惰性地填充时;或者当需要更紧凑的嵌套数据结构时,等等。
为了帮助分析对象实例的组成,我们的工具不仅会计算大小,还会构建一个有用的数据结构作为副产品:一个由IObjectProfileNode
组成的图:
interface IObjectProfileNode
{
Object object ();
String name ();
int size ();
int refcount ();
IObjectProfileNode parent ();
IObjectProfileNode [] children ();
IObjectProfileNode shell ();
IObjectProfileNode [] path ();
IObjectProfileNode root ();
int pathlength ();
boolean traverse (INodeFilter filter, INodeVisitor visitor);
String dump ();
} // End of interface
IObjectProfileNode
的互连方式与原始对象图几乎完全相同,IObjectProfileNode.object()
返回每个节点表示的真实对象。IObjectProfileNode.size()
返回以该节点的对象实例为根的对象子树的总大小(以字节为单位)。如果对象实例通过非null实例字段或数组字段中包含的引用链接到其他对象,则IObjectProfileNode.children()
将是相应的子图节点列表,按降序排序。相反,对于除起始节点之外的每个节点,IObjectProfileNode.Pparent()
都返回其父节点。因此,IObjectProfileNode
的整个集合对原始对象进行切片和切割,并显示数据存储在其中的分区方式。此外,图形节点名称是从类字段派生的,通过检查图形中节点的路径(IObjectProfileNode.path()
),可以跟踪从原始对象实例到任何内部数据的所有权链接。
你可能在阅读前一段时注意到,到目前为止,这个想法仍然有一些歧义。如果在遍历对象图时,您多次遇到同一对象实例(即图中某个位置有多个字段指向它),如何分配其所有权(父指针)?请考虑以下代码片段:
Object obj = new String [] {new String ("JavaWorld"),
new String ("JavaWorld")};
每个java.lang.String
实例都有一个类型为char[]的内部字段,该字段是实际的字符串内容。String复制构造函数在Java 2 Platform Standard Edition(J2SE)1.4中的工作方式是,上述数组中的两个String实例将共享同一个char[]
数组,该数组包含{'J'、'a'、'v'、'a'、'W'、'o'、'r'、'l'、'd'}
字符序列。两个字符串都平等地拥有这个数组,那么在这种情况下应该怎么做呢?
如果我总是想给一个图节点分配一个单亲,那么这个问题并没有普遍的完美答案。然而,在实践中,许多这样的对象实例可以追溯到单个“自然”父对象。这种自然的连接顺序通常比其他更迂回的路线更短。将实例字段指向的数据视为更多地属于该实例,而不是其他任何数据。将数组中的条目视为更多地属于该数组本身。因此,如果一个内部对象实例可以通过多条路径到达,我们选择最短路径。如果我们有几个相等长度的路径,那么,我们只选择第一个发现的路径。在最坏的情况下,这是一个很好的通用策略。
考虑图遍历和最短路径应该在这一点上敲响警钟:广度优先搜索是一种图遍历算法,它保证找到从起始节点到任何其他可到达图节点的最短路径。
在完成了所有这些准备工作之后,这里是这样一个图遍历的教科书实现:
public static IObjectProfileNode profile (Object obj)
{
final IdentityHashMap visited = new IdentityHashMap ();
final ObjectProfileNode root = createProfileTree (obj, visited,
CLASS_METADATA_CACHE);
finishProfileTree (root);
return root;
}
private static ObjectProfileNode createProfileTree (Object obj,
IdentityHashMap visited,
Map metadataMap)
{
final ObjectProfileNode root = new ObjectProfileNode (null, obj, null);
final LinkedList queue = new LinkedList ();
queue.addFirst (root);
visited.put (obj, root);
final ClassAccessPrivilegedAction caAction =
new ClassAccessPrivilegedAction ();
final FieldAccessPrivilegedAction faAction =
new FieldAccessPrivilegedAction ();
while (! queue.isEmpty ())
{
final ObjectProfileNode node = (ObjectProfileNode) queue.removeFirst ();
obj = node.m_obj;
final Class objClass = obj.getClass ();
if (objClass.isArray ())
{
final int arrayLength = Array.getLength (obj);
final Class componentType = objClass.getComponentType ();
// Add shell pseudo-node:
final AbstractShellProfileNode shell =
new ArrayShellProfileNode (node, objClass, arrayLength);
shell.m_size = sizeofArrayShell (arrayLength, componentType);
node.m_shell = shell;
node.addFieldRef (shell);
if (! componentType.isPrimitive ())
{
// Traverse each array slot:
for (int i = 0; i < arrayLength; ++ i)
{
final Object ref = Array.get (obj, i);
if (ref != null)
{
ObjectProfileNode child =
(ObjectProfileNode) visited.get (ref);
if (child != null)
++ child.m_refcount;
else
{
child = new ObjectProfileNode (node, ref,
new ArrayIndexLink (node.m_link, i));
node.addFieldRef (child);
queue.addLast (child);
visited.put (ref, child);
}
}
}
}
}
else // the object is of a non-array type
{
final ClassMetadata metadata =
getClassMetadata (objClass, metadataMap, caAction, faAction);
final Field [] fields = metadata.m_refFields;
// Add shell pseudo-node:
final AbstractShellProfileNode shell =
new ObjectShellProfileNode (node,
metadata.m_primitiveFieldCount,
metadata.m_refFields.length);
shell.m_size = metadata.m_shellSize;
node.m_shell = shell;
node.addFieldRef (shell);
// Traverse all non-null ref fields:
for (int f = 0, fLimit = fields.length; f < fLimit; ++ f)
{
final Field field = fields [f];
final Object ref;
try // to get the field value:
{
ref = field.get (obj);
}
catch (Exception e)
{
throw new RuntimeException ("cannot get field [" +
field.getName () + "] of class [" +
field.getDeclaringClass ().getName () +
"]: " + e.toString ());
}
if (ref != null)
{
ObjectProfileNode child =
(ObjectProfileNode) visited.get (ref);
if (child != null)
++ child.m_refcount;
else
{
child = new ObjectProfileNode (node, ref,
new ClassFieldLink (field));
node.addFieldRef (child);
queue.addLast (child);
visited.put (ref, child);
}
}
}
}
}
return root;
}
private static void finishProfileTree (ObjectProfileNode node)
{
final LinkedList queue = new LinkedList ();
IObjectProfileNode lastFinished = null;
while (node != null)
{
// Note that an unfinished nonshell node has its child count
// in m_size and m_children[0] is its shell node:
if ((node.m_size == 1) || (lastFinished == node.m_children [1]))
{
node.finish ();
lastFinished = node;
}
else
{
queue.addFirst (node);
for (int i = 1; i < node.m_size; ++ i)
{
final IObjectProfileNode child = node.m_children [i];
queue.addFirst (child);
}
}
if (queue.isEmpty ())
return;
else
node = (ObjectProfileNode) queue.removeFirst ();
}
}
这段代码缓存反射元数据以提高性能,并使用hashmap
来标记访问的对象。profile()
方法首先以广度优先遍历的方式使用IObjectProfileNode
树来跨越原始对象图,然后以快速的订单后遍历结束,该遍历汇总并分配所有节点大小。profile()
返回一个IObjectProfileNode
,它是生成的生成树的根,其size()
是整个图的大小。
当然,profile()
的输出只有在我有很好的方法来探索它的情况下才有用。为此,每个IObjectProfileNode
都支持由节点访问者和节点过滤器进行检查:
interface IObjectProfileNode
{
interface INodeFilter
{
boolean accept (IObjectProfileNode node);
} // End of nested interface
interface INodeVisitor
{
/**
* Pre-order visit.
*/
void previsit (IObjectProfileNode node);
/**
* Post-order visit.
*/
void postvisit (IObjectProfileNode node);
} // End of nested interface
boolean traverse (INodeFilter filter, INodeVisitor visitor);
...
} // End of interface
只有当附带的过滤器为null或过滤器接受节点时,节点访问者才有机会使用树节点执行某些操作。为了简单起见,只有在检查了节点本身的情况下,才会检查节点的子节点。支持订单前和订单后访问。java.lang.Object
shell的大小贡献加上所有原始数据字段被集中在一个伪节点中,该伪节点连接到表示对象实例的每个“真实”节点。这样的shell节点可以通过IObjectProfileNode.shell()
访问,也可以显示在IObjectProfileNode.children()
列表中:其思想是能够编写数据过滤器和访问者,将原始数据开销与可实例化数据类型同等考虑。
如何实现过滤器和访问者取决于您。作为起点,ObjectProfileFilters
类提供了几个有用的库存过滤器,这些过滤器有助于根据节点大小、节点相对于其父对象的大小、节点相对根对象的大小等修剪大型对象树。ObjectProfileVisitors
类包含IObjectProfileNode.dump()
使用的默认访问者,以及可以创建用于更复杂的对象浏览的XML转储的访问者。将概要文件转换为SwingTreeModel
也很容易。
作为示例,让我们对上面提到的两个字符串数组对象进行完整转储:
public class Main
{
public static void main (String [] args)
{
Object obj = new String [] {new String ("JavaWorld"),
new String ("JavaWorld")};
IObjectProfileNode profile = ObjectProfiler.profile (obj);
System.out.println ("obj size = " + profile.size () + " bytes");
System.out.println (profile.dump ());
}
} // End of class
事实上,正如前面所解释的,内部字符数组(由java.lang.String#value
引用)在两个字符串之间共享。即使ObjectProfiler.profile()
将此数组的所有权分配给第一个发现的字符串,它也会注意到该数组是共享的(旁边的refcount=2
显示)。
局限性
ObjectProfiler
的方法并不完美。除了已经解释过的对内存对齐的无知之外,它的另一个明显问题是Java对象实例可以共享非静态数据,例如当实例字段指向全局singleton
和其他共享内容时。
考虑DecimalFormat.getPercentInstance()
。即使它每次都返回一个新的NumberFormat
实例,但它们通常都共享Locale.getDefault()
单例。因此,即使sizeof(DecimalFormat.getPercentInstance())
每次报告1111
个字节,这也是一个高估值。这实际上只是定义Java对象的大小度量的概念性困难的另一个表现。在这种情况下,ObjectProfiler.sizedelta(Object base,Object obj)
可能很方便:此方法遍历以base为根的对象图,然后使用在第一次遍历期间预先填充的访问对象集来评测obj。结果被有效地计算为obj拥有的数据的总大小,而obj似乎不属于base。换句话说,它是在基础已经存在的情况下实例化obj所需的内存量(有效地减去了共享数据)。
ObjectProfiler
看不到的另一种数据类型是本机内存分配。java.nio.ByteBuffer.allocate(1000)
的结果是一个1059字节的JVM堆分配结构,但ByteBuffer.AllocateDirect1000
的结果似乎只有140字节;这是因为实际存储是在本机内存中分配的。这是当您放弃纯Java,转而使用基于JVM探查器接口(JVMPI)的探查器时。
同样问题的一个相当模糊的例子是试图调整Throwable的一个实例的大小。ObjectProfiler.sizeof(new Throwable())
报告了20个字节,原因是Throwable
中隐藏的字段:
private transient Object backtrace;
JVM以一种特殊的方式处理这个字段:即使在JDK源代码中可以看到它的定义,它也不会显示在反射调用中。显然,JVM使用这个对象属性来存储大约250字节的支持堆栈跟踪的本地数据。
最后,对使用java.lang.ref.*
引用的对象进行分析可能会导致混淆的结果(例如,在对同一对象的重复sizeof()
调用之间波动的结果)。之所以会发生这种情况,是因为弱引用在应用程序中创建了额外的并发性,而遍历这样一个对象图的纯粹事实可能会改变弱引用的可达性状态。此外,像ObjectProfiler
那样大胆地深入java.lang.ref.Reference
的内部可能不是纯java代码应该做的事情。也许最好增强遍历代码,以避开所有非强引用对象(也不清楚这些数据是否会导致根对象的大小)。
总结
本文试图构建一个纯Java对象探查器可能有些过头了。不过,我的经验是,通过ObjectProfiler.profile()
这样的简单方法快速查看大型数据结构,可以突出显示轻松节省的内存,节省的内存约为百分之几十到数百。这种方法可以补充商业评测器,后者倾向于呈现JVM堆内发生的事情的非常肤浅(而不是基于图的)的视图。如果没有别的,观察对象图内部可能会很有教育意义。
原文链接:https://www.infoworld.com/article/2077408/sizeof-for-java.html
除特别注明外,本站所有文章均为老K的Java博客原创,转载请注明出处来自https://javakk.com/2922.html
暂无评论