结构良好的Java程序中数据结构比同样结构良好的C程序的数据结构会耗用更多内存是不争的事实。跟Go相比的话看情况。
最主要的问题是当前版本的Java对数据的布局的控制,在控制精度和粒度上都比较受限。
以C或者C++为例,对数据的操作可以有若干自由度:(下面提到“对象”不只指class或者struct,而是也包括像int这样的原始类型。为了方便叙述而统称为对象)直接访问对象的实体(值)
通过指针间接访问对象
可以在聚合类型(数组或struct / class / union)中直接嵌入别的对象的实体(值)
可以在聚合类型中存指针,间接指向别的对象
甚至可以在定长的聚合类型的末尾嵌入不定长的数据
在C或C++里,class或者struct自身其实并没有限制该以值类型还是引用类型的方式来使用之,纯粹取决于某次使用具体是怎么用的。当然C++里可以通过一些声明方式来引导使用者只以某些特定的方式来用某些自定义class,例如说只允许作为局部变量使用(StackObject),只允许作为值来使用(ValueObject),或者只能够通过某种分配器来分配,或者只允许在堆上独立分配(HeapObject)——换言之只能应该指针来访问;但这些都并不是class或者struct内在的特性,而是需要额外通过技巧来实现的。
例如说,C里的:
struct Point {
int x;
int y;
};
作为一个局部变量来声明的时候,我们就可以直接访问这个Point对象的实体(值):
int foo() {
struct Point p = { 2, 3 };
return p.x;
}
通过malloc()在堆上分配一个Point对象,我们就会通过指针去间接访问其实体,同时也可以通过解引用去直接访问其实体:
int foo() {
struct Point *p = malloc(sizeof(struct Point));
p->x = 2;
p->y = 3;
int result = p->x;
free(p);
return result;
}
我们可以在别的struct里嵌入Point的实体:
struct Line {
struct Point begin;
struct Point end;
};
也可以在嵌入Point的指针:
struct LinkedListOfPoint {
struct Point *data;
struct LinkedListOfPoint *next;
};
还可以在别的struct的末尾嵌入可变个数的Point:
struct EmbeddedListOfPoint {
int count;
struct Point points[0];
};
struct EmbeddedListOfPoint* makeList(int count) {
size_t sizeof_header = sizeof(struct EmbeddedListOfPoint);
size_t sizeof_trailing_array = count * sizeof(struct Point);
struct EmbeddedListOfPoint *list = malloc(sizeof_header + sizeof_trailing_array);
list->count = count;
memset(&list->points, 0, sizeof_trailing_array);
return list;
}
int main() {
struct EmbeddedListOfPoint *list = makeList(2);
puts("Initial points list:");
for (int i = 0; i < list->count; i++) {
printf("points[%d] = Point { x: %d, y: %d }\n", i, list->points[i].x, list->points[i].y);
}
return 0;
}
===================================
Java的情况
相比之下,Java的自由度有哪些呢?类型有分值类型和引用类型,其中到Java 9为止值类型只有Java语义预定义的几种整型和浮点型原始类型;引用类型可以分为类、接口和数组三种,引用类型的实例可以是类的实例或者数组的实例。由于值类型不支持自定义,所有聚合类型都无可避免的是引用类型。
对于值类型,只能直接访问其实体(值);对于引用类型,只能通过引用去间接访问其实体,用户写的代码只能持有指向引用类型的实例的引用,而无法持有其实体。
引申出来,值类型的实体可以直接嵌入在聚合类型中,而引用类型则只能让引用嵌入在聚合类型中。
如果我们在Java里也写个Point类:
public class Point {
public int x;
public int y;
}
那么这个Point类就只能是一个引用类型了。如果声明一个Point类型的局部变量:
void foo() {
Point p;
}
则这个p只是一个引用,而不是Point对象的实体。
如果把Point嵌在其它类里:
public class Line {
public Point begin;
public Point end;
}
则begin和end两个字段也只是引用,而不是Point类的实体。
这就使得Java非常便于声明带有很多指针的数据结构,例如链表、树、图之类,但要想精确控制把什么嵌入在什么别的东西里就很困难。
在Java里要想精确地让一个对象直接内嵌另一个对象的内容,目前只有一种办法(歪招),那就是继承。一个典型(不好的)例子就是Point2d vs Point3d:
public class Point2d {
public int x;
public int y;
}
public class Point3d extends Point2d {
public int z;
}
这样声明之后,Point3d的实例里就会有继承自Point2d声明而来的x和y字段,外加自己声明的z字段。这就像是让Point3d内嵌了一个Point2d实例一样,就像这样的C的声明一样:
struct Point2d {
int x;
int y;
};
struct Point3d {
Point2d base;
int z;
};
然而如果纯粹是为了嵌入对象而在Java中使用继承,这是属于anti-pattern——这常常会违反面向对象设计原则中的 Liskov可置换原则(Liskov substitution principle)。以上面的例子看,Point3d是一个Point2d么?并不是。Point3d不是Point2d,正好相反,Point3d比Point2d更宽泛,Point2d才可以看作是Point3d的特例(z值永远相同表明在同一平面上)。
Anyway,就算是anti-pattern,至少Java里也有个歪招能在绝对绝对必要的时候让程序精确控制在一个对象里嵌入一个对象。也就一个,再多了也还是不行(Java不支持类的多继承,而接口无法声明字段)。
那么Java能在对象末尾嵌入可变长的东西么?目前也不行。
例如说最经典的例子,字符串类型的实现,典型的Java标准库会使用两个对象来实现一个字符串:一个固定长度的String对象作为皮,其中有一个引用字段去引用着一个可变长度的 char[] 数组:
public class String {
private char[] value;
int hash;
}
如果用C11(为了用char16_t来跟Java的char等价)来写的话,一种紧凑的做法是直接在对象末尾嵌入字符串内容:
struct string {
int count;
char16_t value[0];
};
这样的string内就没有任何额外的指针,数据全部是紧凑排布的。
Java的数据密度低,除了数据结构里常常充满指针(引用)之外,还有就是Java的引用类型的实例的对象头(object header)有不可控的额外开销。对象头里的信息对JVM来说是必要的,例如说记录对象的类型、对象的GC、identity hash code、锁状态等许多信息,但对写Java程序的人来说这就无可避免使得数据比想像的要更耗内存。
在64位HotSpot VM上,开压缩指针的话类实例的对象头会默认占12字节,不要压缩指针的话占16字节;数组实例则是开压缩指针的话占16字节,不开的话要占20字节;这些数据还得额外考虑某些必要的padding还要额外吃空间。HotSpot VM是用2-word header的,而较早期的IBM J9 VM则有很长一段时间都是用3-word header,对象头吃的空间更多。
为了让Java对数据布局有更高度的控制,Java社区有几种不同的方案:IBM提出的 PackedObject 实验性功能。随手放个传送门:IBM Knowledge Center
Azul Systems提出的 ObjectLayout 项目,可以在对其有优化的JVM上给Java提供三种额外的自由度array-of-struct:例如说StructuredArray就会直接在数组内部嵌入Point的实体,而不像普通Java数组Point[]那样只能持有Point的引用(指针)
struct-with-struct:例如说使用ObjectLayout方式声明Line的话就可以直接嵌入两个Point的实体
struct-with-array-at-the-end:经典例子就是像String那样的场景
Oracle提出的Value Objects,本质上是用户自定义值类型,将在Java 10或之后的未来版本Java中出现。放个传送门:JEP 169: Value Objects
其中Azul的ObjectLayout是试图兼容Java当前语义的前提下提供更高的Java堆内数据布局的控制度,Oracle的Value Object是直接给新加值类型,而IBM的PackedObject其实最主要的场景是让Java能更好地跟Java堆外的数据互操作。PackedObject的未来发展方向被并入了OpenJDK: Panama 。
Java的泛型采用擦除法来实现,常常会导致不必要的对象包装,也会增加内存的使用量。放个传送门吧:Java不能实现真正泛型的原因? - RednaxelaFX的回答 - 知乎
另外,Java程序通常要跑在JVM上,而JVM的常见实现都是通过tracing GC来实现Java堆的自动内存管理的。Tracing GC的一个常见结果是在回收内存的时效性上偏弱——要过一会儿再一口气回收一大堆已经无用的内存,而不会总是在对象刚无用的时候就立即回收其空间。而且tracing GC通常都需要更多额外空间(head room)才会比较高效;如果给tracing GC预留的空间只是刚好比程序某一时刻动态所需要的有用对象的总大小大一点点(意味着head room几乎为0)的话,那么tracing GC就会工作得特别辛苦,需要频繁触发GC,带来极大的额外开销。通常tracing GC就会建议用户配置较大的堆来保证其不需要频繁收集,从而提高收集效率。这也会使得一个常见的健康运行的Java系统吃内存显得比较多。
另外就是,虽然先进的JVM实现可能会通过逃逸分析+标量替换的优化方式来消除局部使用对象时的对象开销,但它对堆中需要长时间存活的数据结构来说是没有任何帮助的。所以这个回答里就不特别讨论开启逃逸分析的情况了。
主要是里面有提到Java的java.lang.String的若干种实现方式,以及用C/C++实现的JavaScript引擎里String的实现是如何有弹性的,而这些弹性正好体现了对数据布局的精确控制。
对Java对象如何能更省内存的研究一直都有在进行,也有不少有趣的成果。再多放几个传送门吧:
===================================
Go的情况
写到这里Chrome又开始给我频繁crash了…大概是在告诉我要洗澡睡觉了吧。拼着crash了很多次终于写完了这句,诶先发出来再说了。