Java作为OOP语言,抽象性不言而喻。如果需要深入了解Java语言的实现机制,则不得不对Java语言中基础的概念有清晰的了解。今天是我在cnblog上写博客的第一天,希望今天的博客可以是我成为未来"大牛"跨出的第一步。
面向对象语言中,对象概念其实挺抽象的,对于初学者甚至有开发经验的同志来说都不太容易弄明白。最近看到这篇牛人写的文章,觉得蛮受益的,和大家共同分享吧。翻译有些拙劣,"大牛"请忽略我直接看原文,嘻嘻~。
原文出处链接:http://www.programcreek.com/2011/11/what-do-java-objects-look-like-in-memory/
We know functions are implemented in memory as a stack of activation records. And we know Java methods are implemented as a stack of frames in JVM Stack and Java objects are allocated in Heap.
函数在内存中通过一堆"活动记录"(activation record,活动记录也叫栈帧)实现。我们也知道Java方法在虚拟机栈中通过一堆"栈帧"(stack frame)实现,Java对象在"堆"(Heap)中分配内存空间。
COMMENT1:
1)Java中funtion和procedure统称为method。一般来说function和procedure是有区别的(当然很多编程书上是混用的,我也是无语==!)
funtion -- 无返回值(void或构造函数那样返回值类型都没写的)的method; procedure -- 有返回值的method
2)栈帧(stack frame)也称活动记录(activation record)。method在被调用(called)时会将方法区的方法压入栈帧,栈帧中保存有局部变量,返回值类型等信息。具体内容本文不深究。
How do Java objects look like in heap? Once an object is laid out in memory, it's just a series of bytes.
Java对象在"堆"中到底长得啥样呢?一旦对象被放到内存中,此时它仅仅就是一系列的字节。
Then how do we know where to look to find a particular field? Keep an internal table inside the compiler containing the offsets of each field.
那么我们是如何知道到哪里去查看、寻找这样一个特别的字段(field)的呢?在编译器内部保存有一张包含每一个字段偏移量的表。
COMMENT2:
1)field有的译作"域"、"字段",其实都是一个意思。都是指类的成员变量(包括static成员变量,只不过static存放在方法区,属于类,所有类对象共享)
2)对象成员变量自动寻址:编译器内部会保存一张虚拟表(Virtual Table or called Vtable),包括每个对象成员变量相对于对象空间首地址(第一个成员变量)的地址偏移量,这
样就可以轻松地访问对象的成员变量了。
Here is an example of an object layout for class "Base"(B). This class does not have any method, how methods are laid out in memory is in the nextsection.
举个Base类实例对象的例子。这个类没有任何的方法,它的方法如何在内存中被安排放置将在下一部分中解释。
If we have another class "Derived"(D) extending this "Base" class. The memory layout would look like the following:
如果我们有另一个Derived类继承了Base类。内存布局情况如下:
Child object has the same memory layout as parent objects, except that it needs more space to place the newly added fields. The benefit of this layout is that a pointer of type B pointing at a D object still sees the B object at the beginning. Therefore, operations done on a D object through the B reference guaranteed to be safe, and there is no need to check what B points at dynamically.
子类对象除了需要更多空间去放置新增的字段外,其余的和父类对象有相同类型的内存布局。这样布局的好处就是:当有一个Base类引用指向Derived类对象时,引用仍然能访问内存区域开始的Base类对象。因此,所有通过Base类引用对Derived类对象上的操作是确保安全的,也就没有必要动态地去检查Base类所指向的内容。
COMMENT3:
1) pointer和reference
其实有时我在看Java英文文献的时候也是傻傻分不清到底在说那个。如果是C++那pointer和reference(引用和指针表面上是两码事,引用在底层也是通过指针实现的)明显就是两个概念。而在Java引用和C++引用表面上根本就是不同的概念,Java中的引用更像C++的指针变量,只不过是阉割版的C++指针变量(Java引用和C++的type * const ptr等价,即ptr的值不能改变,但是ptr指向的内容可以改变)。
2) 向上造型(也称"上溯造型")语法现象
The benefit of this layout is that a pointer of type B pointing at a D object still sees the B object at the beginning.
B类(父类)的引用在指向D类对象后,仍然能够访问B对象。still sees the B object at the beginning 就说明了Object B 和Object D 开头部分内存是相同的,Object D开头的内存可以认为就是Object B。
通过上述的分析,我们看下面一道笔试题:
1 import static java.lang.System.out; 2 3 class SupClass { 4 int x = 10; 5 void show() { 6 System.out.println("SupClass"); 7 } 8 } 9 10 class SubClass extends SupClass{ 11 int x = 20; 12 void show() { 13 System.out.println("SubClass"); 14 } 15 } 16 17 public class Test { 18 public static void main(String[] args) { 19 SupClass sup = new SubClass(); 20 out.println(sup.x); 21 } 22 }
SupClass sup = new SubClass(); 父类(SupClass)引用指向子类(SubClass)对象,由于父类引用只能访问子类中从父类继承的成分,因此x = 20是不能够被sup访问的。
答案就是10,而不是20。
3)为什么父类引用可以指向子类对象而编译器不报错?
Therefore, operations done on a D object through the B reference guaranteed to be safe, and there is no need to check what B points at dynamically.
因此,所有通过Base类引用堆D对象的操作确保是安全的,不需要动态地检查Base类到底引用了啥。
这句话其实可以很好地解释为什么这种操作编译器不报错。其实仔细理解起来是这么回事,由于子类从父类继承的那部分结构与父类相同(通过super关键字,此时子类对象中多了父类的那部分内容)。父类引用可以通过子类的对象来访问子类从父类继承来的那部分,因而编译器不会报错。而且这种访问形式是安全的,既不能改变父类的内容,也不会去访问子类多出来的那部分。
4)深入堆内存
堆内存是什么?堆内存就是物理机上的一段虚拟内存(Virtual Memory)。哪啥叫虚拟内存?虚拟内存实际是硬盘的东西,只是用部分硬盘的储存容量来作为JVM的内存,显然这个内存是假的,和物理机的内存是两码事。当然由于JVM(Java Virtual Machine)就是一个虚拟计算机,因此不光是堆,其实JVM的所有内存(包括Java栈等)都是物理机中的虚拟内存,即实际硬盘中的储存空间。
JVM堆内存实际上是链表(Linked List)这种数据结构维护的,因此堆内存中的连续内存往往是指逻辑连续,物理堆内存上是不连续。
Following the same logic, the method can be put at the beginning of the objects.
根据相同的逻辑,方法可以被放在对象的开头。
However, this approach is not efficient. If a class has many methods(e.g. M), then each object must have O(M) pointers set. In addition, each object needs to have space for O(M) pointers. Those make creating objects slower and objects bigger.
然而,此方法效率并不高。如果一个类有很多的方法,那么每个对象必须有一个指针集合(就是指很多指针变量而已)。除此之外,每个对象需要有空间去储存这些指针变量。这些会导致创建对象变得更慢,同时对象空间变得更大。
COMMENT4:
1)理论上我们在创建类对象时将方法写在对象内存的开头,但是相比有限的对象成员变量来说,对象的方法可能有很多,如果每次创建一个对象就需要许多指向方法的指针,那么无疑堆内存将会变得很大,同时在创建对象时会变得很慢(对象创建是从首地址开始一个一个创建的)。
2)对象不包括方法,而是包括了指向方法的指针变量。方法储存在方法区中。在这种模型(JVM不使用这种模型)中,对象在调用非静态方法时,堆中的指针变量在需要的时候通过堆中的指针变量访问方法区中相应的方法,并将其放入Java栈中,对应一个栈帧。
The optimization approach is to create a virtual function table (or vtable) which is an array of pointers to the member function implementations for a particular class. Create a single instance of the vtable for each class and make each object store a pointer to the vtable.
优化方法是创建一个特定类虚函数表,这个虚函数表是指向成员函数实现的指针数组。为每个类创建虚表的单一实例,同时让每个对象储存一个指向虚函数表的指针。
COMMENT5:
1)JVM实际使用就是上面第二种模型(方法区在存放方法的同时时,也会存放一份类虚函数表,虚函数表放方法代码)。
Code for Base.sayHi指的是方法区中的方法代码信息;sayHi指的是堆中创建的指向方法区方法的方法指针;Vtable*指的是方法指针数组集合,用于创建动态指针数组(这些动态指针指向栈中的具体栈帧),被所有类对象共享(储存在方法区)。
2)对象可以访问堆中常量池内容,说明啥?是不是说明堆中还存在一个指针指向常量池。而且访问常量池的内容仅限某个类的内容。因此可以看出,实际上堆中对象还存有.class句柄指针。当然这个是我猜的,没有啥依据。
3)我想,看到这你我都明白啥是真正意义上的对象了。对象=.class信息句柄指针(能指向方法区常量池)+虚函数表指针变量(指向方法区的方法)+对象成员变量(数据)
4)类的方法储存在方法区,被所有类的对象所共享。具体对象的虚函数表内的方法指针被创建时,会将方法区中的方法放入Java栈,这个具体的指针和栈帧绑定。这样每个类对象通过虚函数表解决了必须调用众多方法的问题,创建对象速度比第一种模型快,对象所需的堆内存空间也比第一种要小。
通过上面部分讨论,可以解决以下这几道面试题:
1 public class TestClass { 2 public static void main(String[] args) { 3 SupClass sup = new SubClass(); 4 sup.show1(); 5 sup.show2(); 6 } 7 } 8 9 class SupClass { 10 int x = 10; 11 public void show1() { 12 System.out.println("SupClass"); 13 } 14 public void show2() { 15 System.out.println(x); 16 } 17 } 18 19 class SubClass extends SupClass{ 20 int x = 20; 21 public void show1() { 22 System.out.println("SubClass"); 23 } 24 public void show2() { 25 System.out.println(x); 26 } 27 }
试题的答案是:
SubClass
20
分析:本题考查的是完全是方法的覆盖(重写override)知识点。
1)在同一个类中,如果有两个方法的方法签名相同,编译器会报错。从计算机思维来说,机器处理的必须是明确的东西,不能是不确定量,如果出现不确定量,机器会报错,因为你把它搞晕了,它不知道该选择那个。
2)在父子类关系这两个类中,子类方法签名和父类方法签名相同,属于方法覆盖。那么为什么父类引用访问子类覆盖父类的方法时只能访问子类重写版本而不能是父类版本,而且也不会出现问题?其实,在方法区中子类并没有包含父类的方法(包括构造方法),而是拥有各自代码中所写的方法。使用父类引用指向子类对象时,如果子类覆盖父类的方法,那么就会在子类方法区寻找这个方法,找到就将其放入栈中;如果找不到,由于有extends关键字,此时就会通过super指针指向父类方法区,在父类方法区寻找相应的方法。
3)父类哪些东西不能被子类继承?
Java继承机制中,超类的方法(包括构造方法)不能被派生类继承,而是通过调用机制实现。具体的内容会在后续的文章中讨论。
1 public class TestClass { 2 public static void main(String[] args) { 3 SupClass sup = new SubClass(); 4 sup.show1(); 5 sup.show2(); 6 } 7 } 8 9 class SupClass { 10 int x = 10; 11 public void show1() { 12 System.out.println("SupClass"); 13 } 14 public void show2() { 15 System.out.println(x); 16 } 17 } 18 19 class SubClass extends SupClass{ 20 int x = 20; 21 public void show1() { 22 System.out.println("SubClass"); 23 } 24 public void show2(int a) { 25 System.out.println(x); 26 } 27 }
试题的答案是:
SubClass
10
分析:本题是上题的变式,SupClass和SubClass都有2个方法,但是在子类中父类show1()被覆盖,而父类的show2()方法未被覆盖。在方法区中父类方法是父类方法,子类方法是子类方法,都是实际写的方法中哪些代码,子类方法区空间中并没有父类的方法。但在具体对象创建时,子类的虚函数表却拥有指向父类方法区方法的指针。当子类重写父类方法后,这张表其实是更新的,也就是说表中父类的方法指针被子类方法指针覆盖了。
This is the optimized approach.
上面的就是优化后的方法。
References:
1. Stanford Compilers Lectures
2. JVM
学习感悟:
1)如果国内的博客没有你需要的答案,或者不满意,最好能够去国外的社区走走看看,当然Java官方文档是一切的基础吧。
2)一些专业名词确实需要积累,比如activation record我一开始没有翻译准确,后来是通过浏览器查询才翻译准确的。
3)不要怕说错,就像可能我这篇文章有许多错的。重在和别人交流,在交流中提升自己吧。
4)有些概念可能没有表述清晰,需要进一步加强自己的语言表述能力。毕竟任何东西,自己懂和让别人懂事两码事,两种能力。