类的拷贝和构造

C++是默认具有拷贝语义的,对于没有拷贝运算符和拷贝构造函数的类,可以直接进行二进制拷贝,但是Java并不天生支持深拷贝,它的拷贝只是拷贝在堆上的地址,不同的变量引用的是堆上的同一个对象,那最初的对象是怎么被构建出来的呢?

Java对象的创建过程

关于对象的创建过程一般是从new指令(我说的是JVM的层面)开始的(具体请看图1),JVM首先对符号引用进行解析,如果找不到对应的符号引用,那么这个类还没有被加载,因此JVM便会进行类加载过程(具体加载过程可参见我的另一篇博文)。符号引用解析完毕之后,JVM会为对象在堆中分配内存,HotSpot虚拟机实现的JAVA对象包括三个部分:对象头、实例字段和对齐填充字段(对齐不一定),其中要注意的是,实例字段包括自身定义的和从父类继承下来的(即使父类的实例字段被子类覆盖或者被private修饰,都照样为其分配内存)。相信很多人在刚接触面向对象语言时,总把继承看成简单的“复制”,这其实是完全错误的。JAVA中的继承仅仅是类之间的一种逻辑关系(具体如何保存记录这种逻辑关系,则设计到Class文件格式的知识),唯有创建对象时的实例字段,可以简单的看成“复制”。

为对象分配完堆内存之后,JVM会将该内存(除了对象头区域)进行零值初始化,这也就解释了为什么JAVA的属性字段无需显示初始化就可以被使用,而方法的局部变量却必须要显示初始化后才可以访问。最后,JVM会调用对象的构造函数,当然,调用顺序会一直上溯到Object类。

Java对象的初始化

初始化的顺序是父类的实例变量构造、初始化->父类构造函数->子类的实例变量构造、初始化->子类的构造函数。对于静态变量、静态初始化块、变量、初始化块、构造器,它们的初始化顺序依次是(静态变量、静态初始化块)>(变量、初始化块)>构造器。

JVM在为一个对象分配完内存之后,会给每一个实例变量赋予默认值,这个时候实例变量被第一次赋值,这个赋值过程是没有办法避免的。如果我们在实例变量初始化器中对某个实例x变量做了初始化操作,那么这个时候,这个实例变量就被第二次赋值了。 如果我们在实例初始化器中,又对变量x做了初始化操作,那么这个时候,这个实例变量就被第三次赋值了。如果我们在类的构造函数中,也对变量x做了初始化操作,那么这个时候,变量x就被第四次赋值。也就是说,一个实例变量,在Java的对象初始化过程中,最多可以被初始化4次。

下面还是举一个例子吧

class Parent {

/* 静态变量 */

public static String p_StaticField = "父类--静态变量";

/* 变量 */

public String p_Field = "父类--变量";

protected int i = 9;

protected int j = 0;

/* 静态初始化块 */

static {

System.out.println( p_StaticField );

System.out.println( "父类--静态初始化块" );

}

/* 初始化块 */

{

System.out.println( p_Field );

System.out.println( "父类--初始化块" );

}

/* 构造器 */

public Parent()

{

System.out.println( "父类--构造器" );

System.out.println( "i=" + i + ", j=" + j );

j = 20;

}

}

public class SubClass extends Parent {

/* 静态变量 */

public static String s_StaticField = "子类--静态变量";

/* 变量 */

public String s_Field = "子类--变量";

/* 静态初始化块 */

static {

System.out.println( s_StaticField );

System.out.println( "子类--静态初始化块" );

}

/* 初始化块 */

{

System.out.println( s_Field );

System.out.println( "子类--初始化块" );

}

/* 构造器 */

public SubClass()

{

System.out.println( "子类--构造器" );

System.out.println( "i=" + i + ",j=" + j );

}

/* 程序入口 */

public static void main( String[] args )

{

System.out.println( "子类main方法" );

new SubClass();

}

}

上面的初始化结果是:

父类--静态变量

父类--静态初始化块

子类--静态变量

子类--静态初始化块

子类main方法

父类--变量

父类--初始化块

父类--构造器

i=9, j=0

子类--变量

子类--初始化块

子类--构造器

i=9,j=20

子类的静态变量和静态初始化块的初始化是在父类的变量、初始化块和构造器初始化之前就完成了。静态变量、静态初始化块,变量、初始化块初始化了顺序取决于它们在类中出现的先后顺序。

分析:

访问SubClass.main(),(这是一个static方法),于是装载器就会为你寻找已经编译的SubClass类的代码(也就是SubClass.class文件)。在装载的过程中,装载器注意到它有一个基类(也就是extends所要表示的意思),于是它再装载基类。不管你创不创建基类对象,这个过程总会发生。如果基类还有基类,那么第二个基类也会被装载,依此类推。

执行根基类的static初始化,然后是下一个派生类的static初始化,依此类推。这个顺序非常重要,因为派生类的“static初始化”有可能要依赖基类成员的正确初始化。

当所有必要的类都已经装载结束,开始执行main()方法体,并用new SubClass()创建对象。

类SubClass存在父类,则调用父类的构造函数,你可以使用super来指定调用哪个构造函数。基类的构造过程以及构造顺序,同派生类的相同。首先基类中各个变量按照字面顺序进行初始化,然后执行基类的构造函数的其余部分。

对子类成员数据按照它们声明的顺序初始化,执行子类构造函数的其余部分。

静态变量初始化器和静态初始化器基本同实例变量初始化器和实例初始化器相同,也有相同的限制(按照编码顺序被执行,不能引用后定义和初始化的类变量)。静态变量初始化器和静态初始化器中的代码会被编译器放到一个名为static的方法中(static是Java语言的关键字,因此不能被用作方法名,但是JVM却没有这个限制),在类被第一次使用时,这个static方法就会被执行。

Java对象的引用方式

接下来我们再问一个问题,Java是怎么通过引用找到对象的呢?

至此,一个对象就被创建完毕,此时,一般会有一个引用指向这个对象。在JAVA中,存在两种数据类型,一种就是诸如int、double等基本类型,另一种就是引用类型,比如类、接口、内部类、枚举类、数组类型的引用等。引用的实现方式一般有两种,具体请看图3。此处说一句题外话,经常用人拿C++中的引用和JAVA的引用作对比,其实他们两个只是“名称”一样,本质并没什么关系,C++中的引用只是给现存变量起了一个别名(引用变量只是一个符号引用而已,编译器并不会给引用分配新的内存),而JAVA中的引用变量却是真真正正的变量,具有自己的内存空间,只是不同的引用变量可以“指向”同一个对象而已。因此,如果要拿C++和JAVA引用对象的方式相对比,C++中的指针倒和JAVA中的引用如出一辙,毕竟,JAVA中的引用其实就是对指针的封装。

关于对象引用更深层次的问题,我们将在JVM篇章中详细解释。

匿名类、内部类和静态类

这一部分的内容相当宽泛,详细的可以查阅下面的参考文章,我在这里主要强调几个问题:

内部类的访问权限(它对外部类的访问权限和外部对它的访问权限)

成员内部类为什么不能有静态变量和静态函数(final修饰的除外)

内部类和静态内部类(嵌套内部类)的区别

局部内部类使用的形参为什么必须是final的

匿名内部类无法具有构造函数,怎么做初始化操作

内部类的继承问题(由于它必须和外部类实例相关联)

在这里只回答一下最后一个问题,由于成员内部类的实现其实是其构造函数的参数添加了外部类实体,所以内部类的实例化必须有外部类,但就类定义来说,内部类的定义只和外部类定义有关,代码如下

public class Out {

private static int a;

private int b;

public class Inner {

public void print() {

System.out.println(a);

System.out.println(b);

}

}

}

// 内部类实例化

Out out = new Out();

Out.Inner inner = out.new Inner();

public class InheritInner extends Out.Inner {

InheritInner(Out out){

out.super();

}

}

最后关于内部类的实现原理,请阅读参考文章中的《内部类的简单实现原理》,这非常重要

Java多态的实现原理

Java的多态主要有以下几种形式:

继承

覆盖

接口

方法调用的原理

多态是面向对象编程语言的重要特性,它允许基类的指针或引用指向派生类的对象,而在具体访问时实现方法的动态绑定。Java 对于方法调用动态绑定的实现主要依赖于方法表,但通过类引用调用(invokevitual)和接口引用调用(invokeinterface)的实现则有所不同。

类引用调用的大致过程为:Java编译器将Java源代码编译成class文件,在编译过程中,会根据静态类型将调用的符号引用写到class文件中。在执行时,JVM根据class文件找到调用方法的符号引用,然后在静态类型的方法表中找到偏移量,然后根据this指针确定对象的实际类型,使用实际类型的方法表,偏移量跟静态类型中方法表的偏移量一样,如果在实际类型的方法表中找到该方法,则直接调用,否则,认为没有重写父类该方法。按照继承关系从下往上搜索。

方法表是实现动态调用的核心。方法表存放在方法区中的类型信息中。为了优化对象调用方法的速度,方法区的类型信息会增加一个指针,该指针指向一个记录该类方法的方法表,方法表中的每一个项都是对应方法的指针。这些方法中包括从父类继承的所有方法以及自身重写(override)的方法。

Java 的方法调用有两类:

动态方法调用:动态方法调用需要有方法调用所作用的对象,是动态绑定的。

静态方法调用:静态方法调用是指对于类的静态方法的调用方式,是静态绑定的;

类调用 (invokestatic) 是在编译时就已经确定好具体调用方法的情况。

实例调用 (invokevirtual)则是在调用的时候才确定具体的调用方法,这就是动态绑定,也是多态要解决的核心问题。

JVM 的方法调用指令有四个,分别是 invokestatic,invokespecial,invokesvirtual 和 invokeinterface。前两个是静态绑定,后两个是动态绑定的。

class Person {

public String toString(){

return "I'm a person.";

}

public void eat(){}

public void speak(){}

}

class Boy extends Person{

public String toString(){

return "I'm a boy";

}

public void speak(){}

public void fight(){}

}

class Girl extends Person{

public String toString(){

return "I'm a girl";

}

public void speak(){}

public void sing(){}

}

如果子类改写了父类的方法,那么子类和父类的那些同名的方法共享一个方法表项。因此,方法表的偏移量总是固定的。所有继承父类的子类的方法表中,其父类所定义的方法的偏移量也总是一个定值。Person 或 Object中的任意一个方法,在它们的方法表和其子类 Girl 和 Boy 的方法表中的位置 (index) 是一样的。这样 JVM 在调用实例方法其实只需要指定调用方法表中的第几个方法即可。

在常量池(这里有个错误,上图为ClassReference常量池而非Party的常量池)中找到方法调用的符号引用 。

查看Person的方法表,得到speak方法在该方法表的偏移量(假设为15),这样就得到该方法的直接引用。

根据this指针得到具体的对象(即 girl 所指向的位于堆中的对象)。

根据对象得到该对象对应的方法表,根据偏移量15查看有无重写(override)该方法,如果重写,则可以直接调用(Girl的方法表的speak项指向自身的方法而非父类);如果没有重写,则需要拿到按照继承关系从下往上的基类(这里是Person类)的方法表,同样按照这个偏移量15查看有无该方法。

接口方法调用的原理

因为 Java 类是可以同时实现多个接口的,而当用接口引用调用某个方法的时候,情况就有所不同了。

Java 允许一个类实现多个接口,从某种意义上来说相当于多继承,这样同样的方法在基类和派生类的方法表的位置就可能不一样了。

interface IDance{

void dance();

}

class Person {

public String toString(){

return "I'm a person.";

}

public void eat(){}

public void speak(){}

}

class Dancer extends Person

implements IDance {

public String toString(){

return "I'm a dancer.";

}

public void dance(){}

}

class Snake implements IDance{

public String toString(){

return "A snake.";

}

public void dance(){

//snake dance

}

}

方法调用的补充

我们先来看一个示例

public class Test {

public static class A {

public void print() {

System.out.println("A");

}

public void invoke() {

print();

sprint();

}

static void sprint() {

System.out.println("sA");

}

}

public static class B extends A {

@Override

public void print() {

System.out.println("B");

}

static void sprint() {

System.out.println("sB");

}

}

public static void main(String[] args){

A a = new B();

a.invoke(); // B SA

}

}

由于静态方法是静态调用的,在编译期就决定了跳转的符号,所以进入父类的invoke方法调用的sprint在编译期即是A的sprint,A的sprint符号和B的sprint在class中并不相同,这个符号在编译期已经确定了。

但是当在invoke中调用print,Java是通过传进来的this去找他的类型信息,再从类别信息里去找方法表,所以依然调用的是子类方法表中的print。

我们再看一个例子。

public class Test {

public static class A {

public int a = 3;

public void print() {

System.out.println(a);

}

}

public static class B extends A {

public int a = 4;

}

public static void main(String[] args){

B b = new B();

b.print(); // 3

}

}

多态只适用于父子类同样签名的方法,而属性是不参与多态的。在print里的符号a在编译期就确定是A的a了。同样的还有private的方法,私有方法不参与继承, 也不会出现在方法表中,因为私有方法是由invokespecial指令调用的。

成员变量的访问只根据静态类型进行选择,不参与多态

私有方法不会发生多态选择,只根据静态类型进选择。

继承的实现原理

上面已经说明了类方法调用的问题,子类继承父类在方法调用时依然是根据对象头找型别信息,然后去自己的类信息里找到方法区调用方法指针,和C++通过在对象中增加虚函数表指针不一样,Java需要通过自己的运行时型别信息找到自己的方法表,而且这张方法表不仅包含覆盖的方法也包含不覆盖的,不像C++,不同的虚函数表包含不同的方法。比如A->B->C,那么A对象部分包含的虚函数表只有A声明的虚方法,假设B新声明了虚方法X,在C类的B类部分的末尾的虚函数表指针指向的才包含X,但是A类部分的指向的虚函数表则不会包含X。Java实际上是先在编译时期就得知方法的偏移,在调用的时候直接找到真正型别的方法表对应偏移的方法,如果一个父类引用调用了一个父类没有的方法,在编译期就会报错。

和C++不同,C++的内存布局是非常紧凑的,这也是为了支持它天然的拷贝语义,c++父类对象的内存空间是直接被包含在子类对象的连续内存空间中的,其属性的偏移都取决于声明顺序和对齐。而Java虽然父类的实例变量依然是和子类的放在同一个连续的内存空间,但并非是通过简单的偏移来取成员的。不过在Java对象的内存布局中,依然是先安置父类的再安置子类的,所以讲sizeof(Parent)大小的内容转型成为父类指针,就可以实现super了。具体是在字节码中子类会有个u2类型的父类索引,属于CONSTANT_Class_info类型,通过CONSTANT_Class_info的描述可以找到CONSTANT_Utf8_info,然后可以找到指定的父类。

重载、覆盖和隐藏

重载:方法名相同,但参数不同的多个同名函数

参数不同的意思是参数类型、参数个数、参数顺序至少有一个不同

返回值和异常以及访问修饰符,不能作为重载的条件(因为对于匿名调用,会出现歧义,eg:void a ()和int a() ,如果调用a(),出现歧义)

main方法也是可以被重载的

覆盖:子类重写父类的方法,要求方法名和参数类型完全一样(参数不能是子类),返回值和异常比父类小或者相同(即为父类的子类),访问修饰符比父类大或者相同

子类实例方法不能覆盖父类的静态方法;子类的静态方法也不能覆盖父类的实例方法(编译时报错),总结为方法不能交叉覆盖

隐藏:父类和子类拥有相同名字的属性或者方法时,父类的同名的属性或者方法形式上不见了,实际是还是存在的。

当发生隐藏的时候,声明类型是什么类,就调用对应类的属性或者方法,而不会发生动态绑定

方法隐藏只有一种形式,就是父类和子类存在相同的静态方法

属性只能被隐藏,不能被覆盖

子类实例变量/静态变量可以隐藏父类的实例/静态变量,总结为变量可以交叉隐藏

隐藏和覆盖的区别:

被隐藏的属性,在子类被强制转换成父类后,访问的是父类中的属性

被覆盖的方法,在子类被强制转换成父类后,调用的还是子类自身的方法

因为覆盖是动态绑定,是受RTTI(run time type identification,运行时类型检查)约束的,隐藏不受RTTI约束,总结为RTTI只针对覆盖,不针对隐藏

java的对象模型

Java中存在两种类型,原始类型和对象(引用)类型。原始类型,即数据类型,内存布局符合其类型规范,并无其他负载。而对象类型,则由于自定义类型、垃圾回收,对象锁等各种语义与JVM性能原因,需要使用额外空间。

Java对象的内存布局:对象头(Header),实例数据(Instance Data),对齐填充(Padding)。

详细的内容可以查阅参考文章

这里我们主要讲讲在继承和组合两种情形下会对内存布局造成什么变化。

类属性按照如下优先级进行排列:长整型和双精度类型;整型和浮点型;字符和短整型;字节类型和布尔类型,最后是引用类型。这些属性都按照各自的单位对齐。

不同类继承关系中的成员不能混合排列。首先按照规则2处理父类中的成员,接着才是子类的成员

当父类中最后一个成员和子类第一个成员的间隔如果不够4个字节的话,就必须扩展到4个字节的基本单位。

如果子类第一个成员是一个双精度或者长整型,并且父类并没有用完8个字节,JVM会破坏规则1,按照整形(int),短整型(short),字节型(byte),引用类型(reference)的顺序,向未填满的空间填充。

数组有一个额外的头部成员,用来存放“长度”变量。数组元素以及数组本身,跟其他常规对象同样,都需要遵守8个字节的边界规则。

下面给一个例子

public class Test {

public static class A {

public A() {

System.out.println(this.hashCode());

}

}

public static class B extends A {

public B(){

System.out.println(this.hashCode());

System.out.println(super.equals(this));

}

}

public static void main(String[] args){

B b = new B();

}

}

/*

* 输出如下:

* 1627674070

* 1627674070

* true

*/

参考文章

java面向对象_谈谈Java的面向对象相关推荐

  1. java 节假日_谈谈JAVA实现节假日验证

    原标题:谈谈JAVA实现节假日验证 我们需要两个类,第一个类: 我们叫它验证类. 第二个类: 它是对法定节假日的抽象. 第一步开始: 当验证类被初始化的时候,会加载本年的所有法定节假日到一个list里 ...

  2. java装箱_谈谈Java的自动装箱和拆箱

    Java作为面向对象语言,有人认为所看到的都是对象,事实上,在Java SE 5之前,基本类型默认并不是采用对象存在的如果您想要把基本类型作为对象来处理,就必须自行转换,不过,在Java SE 5之后 ...

  3. Java面试一百道题目(第一题)-什么是面向对象,谈谈你对面向对象的理解

    Java面试一百道题目(第一题) 1,什么是面向对象,谈谈你对面向对象的理解. 思路:用面向过程和面向对象做对比来突出什么是面向对象. 答:高级语言分为,面向对象语言和面向过程语言,面向过程语言,距离 ...

  4. java多核并行计算_谈谈Java任务的并行处理

    前言 谈到并行,我们可能最先想到的是线程,多个线程一起运行,来提高我们系统的整体处理速度:为什么使用多个线程就能提高处理速度,因为现在计算机普遍都是多核处理器,我们需要充分利用cpu资源:如果站的更高 ...

  5. java stream 求和_谈谈Java任务的并行处理

    作者:ksfzhaohui 前言 谈到并行,我们可能最先想到的是线程,多个线程一起运行,来提高我们系统的整体处理速度:为什么使用多个线程就能提高处理速度,因为现在计算机普遍都是多核处理器,我们需要充分 ...

  6. check在java意思吗_谈谈Java:Checked Exception与 unCheckException Runtime Exception 的区别...

    Java里有个很重要的特色是Exception ,也就是说允许程序产生例外状况.而在学Java 的时候,我们也只知道Exception 的写法,却未必真能了解不同种类的Exception 的区别. 首 ...

  7. java书籍_学习Java最好的10本书,从入门到精通

    在当代,学习Java等编程课程的主要方式是视频资源,如果你想学,在网上五分钟之内就可以找到一堆学习视频,瞬间将你的硬盘填满.但是这些课程质量良莠不齐,对于小白来说很难辨别好坏. 但是书籍不同,书籍都是 ...

  8. 成都两年JAVA工程师_成都Java工程师学习路线

    成都Java工程师学习路线.java分成J2ME(移动应用开发),J2SE(桌面应用开发),J2EE(Web企业级应用),所以java并不是单机版的,只是面向对象语言.建议如果学习java体系的话可以 ...

  9. java 析构函数_《JAVA编程思想》5分钟速成:第5章(初始化和清理)

    第五章.初始化和清理 前言 1.初始化顺序(静态成员.非静态成员,构造器.父类构造器)的排序: 2.构造器(constructor)是否可被重写(override)? 3.final, finally ...

最新文章

  1. python 中的位置参数和默认参数
  2. mac mysql 报错_mac os mysql 配置?报错-问答-阿里云开发者社区-阿里云
  3. ndoe.js实战之开发微博第一讲之工具准备
  4. count(*),count(1),count(0)效率
  5. 【算法分析与设计】快速幂算法与快速幂取模算法
  6. Flutter ListView 下拉刷新与上拉加载更多
  7. Linux运维之道之ENGINEER1.3(配置SMB共享,配置NFS共享)
  8. PHPStorm 调式JS /同时调式PHP和jS
  9. 【Oracle】RAC中控制文件多路复用
  10. 网络营销数据解读(九)——客户族群细分(Segmentation)2-2
  11. 没有GPS模块无人机无法解锁解决方法测试及其他无法解锁APM疑难杂症
  12. vtuber面部捕捉工具_做一名VTuber 虚拟UP主需要准备哪些?
  13. C# Halcon 图像放大缩小代码
  14. 中关村标协智能物联分技术委员会成立,小米张明当选第一届主任委员
  15. JNDI全攻略(一)
  16. 华硕ASUS 笔记本 改WIN7 BIOS 设置详解
  17. 说说 HWND_TOP 和 HWND_TOPMOST 的区别
  18. 十大城市男人魅力新榜 [转帖]
  19. [深度学习]Part1 Python学习进阶Ch23爬虫Spider——【DeepBlue学习笔记】
  20. SQL编写:表A{id, name},表B{id, course, score}。求每名学生的 id 和 name 和平均成绩

热门文章

  1. python后台架构Django教程——视图views渲染
  2. 安卓网络连接全解:包括网络连接状态的监听、网络数据使用状态的监听、获取当前网络连接情况、启动wifi、获取当前连接wifi的网络情况、扫描wifi热点
  3. 银行招聘笔试中行测和综合知识复习心得
  4. [理解] Linux 作为一个服务器是怎样的存在 (一)
  5. 深入理解PHP之foreach
  6. thinkphp3.2.3 自动验证 正则验证
  7. 三种urllib实现网页下载,含cookie模拟登陆
  8. 获取Oracle隐含參数信息
  9. 冲刺One之站立会议6 /2015-5-19
  10. iOS常用第三方类库