JAVA 七月 09, 2021

6.Java 对象基础

文章字数 24k 阅读约需 22 mins. 阅读次数 1000000

1:对象基本概念:成员与初始化

Java 是一种面向对象的语言程序设计语言 (Object Oriented Programming : OOP)。

类与对象

类是具备某些共同特征的实体的集合,它是一种抽象的数据类型,也是对所具有相同特征实体的抽象。

在面向对象的程序设计语言中,类是对一类 “事物” 的属性与行为的抽象。

对象则是类的实例,创建对象最简单的方式就是使用 new 关键字。

示例:

比如,Person 是一个类,那么具体的某个人 “张三” 就是 Person 这个类的对象。而 “姓名,身高,体重” 这些信息就是对象的属性,人的行为比如 “吃饭,睡觉” 等就是对象的方法。

Person

public class Person {
  private String name;
  private int height;
  private int weight;

  public Person() {

  }

  public Person(String name,int height,int weight) {
    this.name = name;
    this.height = height;
    this.weight = weight
  }

  public void eat(){
    System.out.println("eating...");
  }

  public void sleep(){
    System.out.println("sleeping...");
  }
}

创建 Person 类的对象 “zhangsan

// 调用了 Person 的全参构造器
Person zhangsan = new Person("张三",175,130);
// 调用方法
zhangsan.eat();
zhangsan.sleep();

Java 中 new 一个对象发生了什么

Javanew 一个类的对象时,会先查看对象所属的类有没有被加载到内存,如果没有,就会先通过类的权限定类名来加载类。加载并初始化类后,再进行对象的创建工作。

我们假设是第一次使用一个类,这样的话 new 一个对象就可以分为两个过程:

  1. 加载并初始化类

  2. 创建对象

类加载过程

类从被加载到 JVM 到卸载出内存,整个生命周期如图所示:

加载 -> 连接(验证 -> 准备 -> 解析) -> 初始化 ->使用 -> 卸载

各个阶段的主要功能为:

  • 加载:查找并加载类文件的二进制数据

  • 连接:将已经读入内存的类的二进制数据合并到 JVM 运行时环境中去,包含如下几个步骤:

  • 验证:确保被加载类的正确性

  • 准备:为类的静态变量分配内存,赋默认值;例如:public static int a = 1; 在准备阶段对静态变量 a 赋默认值 0

  • 解析:把常量池中的符号引用转换成直接引用

  • 初始化:为类的静态变量赋初始值,这个时候才对静态变量 a 赋初始值 1

我们可以看到,Java 中,类的静态成员在类加载过程中就已经被加载到内存中了!

那么类是如何被加载的呢?答案是:类加载器

类加载器

Java 虚拟机自带的加载器包括如下几种:( JDK9 开始)

  • 启动类加载器(BootstrapClassLoader)

  • 平台类加载器(PlatformClassLoader)

  • 应用程序类加载器(AppClassLoader)

JDK8 虚拟机自带的加载器:

  • BootstrapClassLoader

  • ExtensionClassLoader

  • AppClassLoader

除了虚拟机自带的类加载器外,用户也可以自定义类加载器,关于用户如何自定义类加载器这部分内容并不是本章节的重点,所以就不再赘述了。

类加载器之间的关系
  • UserClassLoader (用户自定义类加载器)的父级为 AppClassLoader

  • AppClassLoader 的父级为 PlatformClassLoader

  • PlatformClassLoader 的父级为 BootstrapClassLoader

其关系如图所示:

双亲委派模型

JVM 中的 ClassLoader 采用双亲委派模型的方式加载类:

那么什么是双亲委派模型呢?

双亲委托模型就是:如果一个类加载器(ClassLoader)收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委托给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中,只有当父类加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需要加载的类)时,子加载器才会尝试自己去加载。

使用双亲委托机制的好处是:能够有效确保一个类的全局唯一性,当程序中出现多个限定名相同的类时,类加载器在执行加载时,始终只会加载其中的某一个类。

创建对象

介绍了类加载的过程后,我们就可以真正地创建这个类的对象了。

创建对象包括几个过程;

  1. 在堆区中分配对象所需的内存
    分配的内存包括本类和父类的所有实例变量,但不包括任何静态变量

  2. 对所有实例变量赋默认值
    将方法区内对实例变量的定义拷贝一份到堆区,然后赋默认值,譬如整型的默认值为 0,引用类型的默认值为 null

  3. 执行实例初始化代码
    初始化顺序是先初始化父类再初始化子类,初始化时先执行实例代码块然后是构造方法

一个关于类初始化机制和顺序的面试题

MyTestClass

public class MyTestClass {
    private static MyTestClass myTestClass = new MyTestClass();

    private static int a = 0;
    private static int b;

    private MyTestClass() {
        a++;
        b++;
    }

    public static MyTestClass getInstance() {
        return myTestClass;
    }

    public int getA() {
        return a;
    }

    public int getB() {
        return b;
    }
}

Test

public class Test {

    public static void main(String[] args) {
        MyTestClass myTestClass = MyTestClass.getInstance();
        System.out.println("myTestClass.a : " + myTestClass.getA());
        System.out.println("myTestClass.b : " + myTestClass.getB());

    }
}

请问程序输出的结果?

这个问题涉及到了类的初始化顺序

先来看看答案:

myTestClass.a : 0
myTestClass.b : 1

为什么出现这样的结果呢?

我们再次回顾下类的加载过程:

加载 -> 连接(验证 -> 准备 -> 解析) -> 初始化 -> 使用 -> 卸载

首先在连接的准备阶段JVM 会为类的静态变量分配内存,并赋缺省值,即:

myTestClass = null;
a = 0;
b = 0;

接着,在类的初始化阶段,会为这些静态变量赋初始值

myTestClass = new MyTestClass();

这句话会回调构造器

private MyTestClass() {
    a++;
    b++;
}

a++,b++;导致 ab 的结果均为 1

然后代码执行到:

a = 0;
b;

这个时候,执行对 ab 真正的初始化赋值

又将 a 变为了 0 ;而 b 则没有赋值结果仍然是 1;所以输出结果为

myTestClass.a : 0
myTestClass.b : 1

我们再来看一个变形题目:

MyTestClass2

public class MyTestClass2 {

    private static int a = 0;
    private static int b;

    private MyTestClass2(){
        a++;
        b++;
    }

    private static final MyTestClass2 myTestClass2 = new MyTestClass2();

    public static MyTestClass2 getInstance(){
        return myTestClass2;
    }
}

Test

public class Test {
    public static void main(String[] args) {
        MyTestClass2 myTestClass2 = MyTestClass2.getInstance();
        System.out.println("myTestClass2.a : " + myTestClass2.getA());
        System.out.println("myTestClass2.b : " + myTestClass2.getB());
    }
}

那么这个程序运行的结果为多少呢?

结果为:

myTestClass2.a : 1
myTestClass2.b : 1

我们再次按照类的初始化顺序进行分析:

首先在连接的准备阶段JVM 会为类的静态变量分配内存,并赋缺省值,即:

a = 0;
b = 0;
myTestClass2 = null;

然后,在类的初始化阶段,会为这些静态变量赋初始值

首先,代码执行到:

a = 0;
b;

a 赋初始值为 0,然后 b 没有赋值,其结果还是 0

接着,执行到语句:

myTestClass = new MyTestClass();

这句话会回调构造器

private MyTestClass() {
    a++;
    b++;
}

执行:a++,b++,导致 ab 的结果均为 1,所以最终程序输出的结果为:

myTestClass2.a : 1
myTestClass2.b : 1

Java 创建对象的几种方式

本部分内容可以等到学习过 Java IO 流和反射之后再进行深入阅读!

Java 中创建对象有四种方式:

  1. 使用new关键字

  2. 反射

  3. 使用clone()方法

  4. 序列化与反序列化

1. 使用new关键字创建对象
public class Person {

    private String name;
    private int age;

    public Person() {
        this.name = "default";
        this.age = 0;
    }

    public Person(String name, int age) {
        this.name = name;
        this.age = age;
    }

    public void info() {
        System.out.println("My name is " + name + ", I'm " + age + "years old.");
    }

    public static void main(String[] args) {
        Person p1 = new Person("Jack",30);
        p1.info();
    }
}

程序输出结果:

My name is Jack, I'm 30 years old.
2. 使用反射创建对象

反射创建对象的方式还可以具体划分为

  1. 使用Class类的newInstance方法

  2. 使用Constructor类的newInstance方法

使用Class类的newInstance方法创建对象

这个方法会调用无参构造器来创建对象

public class Person {
    private String name;
    private int age;

    public Person() {
        this.name = "default";
        this.age = 0;
    }

    public Person(String name, int age) {
        this.name = name;
        this.age = age;
    }

    public void info() {
        System.out.println("My name is " + name + ", I'm " + age + " years old.");
    }

    public static void main(String[] args) throws ClassNotFoundException, IllegalAccessException, InstantiationException {

        Person p2 = (Person)Class.forName("test.Person").newInstance();
        Person p3 = Person.class.newInstance();

        p2.info();
        p3.info();
    }
}

程序输出结果:

My name is default, I'm 0 years old.
My name is default, I'm 0 years old.

使用Constructor类的newInstance方法创建对象

Class类的newInstance方法很像,java.lang.reflect.Constructor类里也有一个newInstance方法可以创建对象。我们可以通过这个newInstance方法调用有参数的私有的构造函数。

import java.lang.reflect.InvocationTargetException;

public class Person {
    private String name;
    private Integer age;

    public Person() {
        this.name = "default";
        this.age = 18;
    }

    public Person(String name){
        this(name,18);
    }
    // 私有构造器
    private Person(String name, Integer age) {
        this.name = name;
        this.age = age;
    }

    public void info() {
        System.out.println("My name is " + name + ", I'm " + age + " years old.");
    }

    public static void main(String[] args) throws ClassNotFoundException, NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException {

        Person p4 = (Person) Class.forName("test.Person").getConstructor(String.class).newInstance("Tracy");
        p4.info();

        Person p5 = (Person) Class.forName("test.Person").getDeclaredConstructor(String.class,Integer.class).newInstance("James",25);
        p5.info();
    }
}

程序运行结果:

My name is Tracy, I'm 18 years old.
My name is James, I'm 25 years old.
3. 使用clone()方法创建对象

关于clone()方法,有几个需要特别注意的点:

  1. 一个类,需要实现Cloneable接口,才能调用Objectclone()方法,否则会报CloneNotSupportedException

  2. clone()并不会调用被复制实例的构造函数

  3. clone()方法实际上为浅拷贝,在《Core Java》中提到了这一点:应该完全避免使用clone()方法,而使用其他方法达到拷贝的目的,例如工厂模式,或者序列化等等。

我们来看如下程序:

public class Person implements Cloneable {
    private int age;
    private String name;

    public Person(int age,String name){
        this.age = age;
        this.name = name;
    }

    public int getAge(){
        return this.age;
    }
    public String getName(){
        return this.name;
    }

    @Override
    public Object clone(){
        Person p = null;
        try {
            p = (Person) super.clone();
        } catch (CloneNotSupportedException e) {
            e.printStackTrace();
        }
        return p;
    }

    public static void main(String[] args) {
        Person p1 = new Person(26,"kim");
        Person p2 = (Person)p1.clone();
        System.out.println(p1.getName() == p2.getName() ? "shallow copy" : "deep copy");
    }
}

该程序运行的结果为:

shallow copy

我们来看下本程序的内存分配图:

从本图中就可以看出,clone()方法只是field-to-field-copy,也就是它的本质是浅拷贝

而真正的深拷贝则应该是这样的:

4. 使用序列化与反序列化创建对象

所谓的序列化是指:将对象通过流的方式存储到磁盘中;

反序列化则是将磁盘上的对象信息转换到内存中

使用序列化,首先要实现Serializable接口,如果我们想要序列化的对象的类没有实现Serializable接口,那么就会抛出NotSerializableException异常;其次要求:

  1. 对象中的所有属性也都是可以序列化才能被序列化,static变量无法序列化

  2. 如果某个属性不想序列化,可以在属性上加transient关键字

程序示例如下:

序列化

import java.io.*;

public class Person implements Serializable {
    private int age;
    private String name;

    public Person(int age, String name) {
        this.age = age;
        this.name = name;
    }

    public int getAge() {
        return age;
    }

    public void setAge(int age) {
        this.age = age;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public void info() {
        System.out.println("My name is " + name + ", I'm " + age + " years old.");
    }

    public static void main(String[] args) throws IOException {
        File file = new File("src\\test\\file.txt");
        ObjectOutputStream o = new ObjectOutputStream(new FileOutputStream(file));
        Person p = new Person(30,"Jack");
        o.writeObject(p);
    }
}

反序列化

import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.ObjectInputStream;

public class Test {
    public static void main(String[] args) throws IOException, ClassNotFoundException {
        File file = new File("src\\test\\file.txt");
        ObjectInputStream o = new ObjectInputStream(new FileInputStream(file));
        Person p = (Person) o.readObject();
        p.info();
    }
}

序列化与反序列化的方式创建对象不会调用类的构造器,并且序列化反序列化方式创建对象的本质是深拷贝

2:方法的重载

什么是方法的重载?

重载(overload)是指我们可以定义一些名称相同的方法,通过定义不同的输入参数来区分这些方法,然后在调用方法的时候,JVM会根据不同的参数样式来选择合适的方法执行。

示例程序:

public class Overload {
    public void info(String name, int age) {
        System.out.println("my name is "
                + name
                + ",I'm " + age + " years old");
    }

    public void info(String name, int age, String hobby) {
        System.out.println("my name is "
                + name
                + ",I'm " + age + " years old,"
                + "and My hobby is " + hobby);

    }

    public static void main(String[] args) {
        Overload overload = new Overload();
        overload.info("Kim",27);
        overload.info("Kim",27,"football");
    }
}

该程序输出结果:

my name is Kim,I'm 27 years old.
my name is Kim,I'm 27 years old,and My hobby is football

方法重载和重写的区别

首先,方法重载和重写没有任何关系

重载我们已经介绍过了,什么是重写呢?重写(override)是应用在 Java 多态中,让子类表现出和父类不同特点的一种能力;子类重写的新方法将覆盖父类原有的方法。这一部分我们会在 Java 继承部分着重讲解。

重写的示例程序:

public class Polymorphic {
    public static void main(String[] args) {
        // 父类的引用指向子类的对象
        Animal cat = new Cat();
        cat.speak(); // 重写父类的speak方法 
    }
}
class Animal {
    public void speak(){
        System.out.println("我是一个动物");
    }
}
class Cat extends Animal{
    @Override
    public void speak() {
        System.out.println("我是一只猫");
    }
}

为什么不能根据返回类型区分重载

我们能否根据返回类型区分重载呢?答案是否定的。

因为在调用时,如果不指定返回类型信息,编译器不知道你要调用哪一个函数。

假设,可以根据返回类型区分方法重载

示例程序:

float max(int a,int b){...};
int max(int a,int b){...};

当我们没有用变量去接收函数返回值,仅仅调用max(1,2)时,编译器是无法确定调用的具体是哪一个方法,单从这一点上来说,仅返回值类型不同的重载是不应该允许的。

所以,重载设计成方法名相同,标签不同(参数的个数或类型不同)才是合理的。

重载的优先级匹配

重载方法参数匹配优先级顺序为:

  1. 匹配直接能够匹配到的类型

  2. 匹配“最近的”自动转换类型

  3. 匹配装箱类型

  4. 匹配接口实现

  5. 匹配父类

  6. 匹配变长参数

我们来看一个示例程序:

import java.io.Serializable;

public class Reload {
    // 直接匹配到的类型
    public static void test(char arg) {
        System.out.println("char");
    }

    // 自动转换类型 char -> int
    public static void test(int arg) {
        System.out.println("int");
    }

    // 自动转换类型 char -> long
    public static void test(long arg) {
        System.out.println("long");
    }

    // 装箱类型
    public static void test(Character arg) {
        System.out.println("Character");
    }

    // 匹配接口
    public static void test(Serializable arg) {
        System.out.println("Serializable");
    }

    // 匹配父类
    public static void test(Object obj) {
        System.out.println("Object");
    }

    // 匹配变长参数
    public static void test(char... arg) {
        System.out.println("char...");
    }

    public static void main(String[] args) {
        test('a');
    }

}

将重载方法从上至下依次注释掉,我们获得的结果依次为:

char
int
long
Character
Serializable
Object
char...

3:对象的初始化顺序

在没有继承的条件下,实例化一个对象初始化的顺序为:

  1. 静态成员的初始化

  2. 静态初始化块

  3. 成员的初始化

  4. 初始化块

  5. 构造器

这里面需要注意的是【静态部分只在类加载时初始化一次

如果有继承关系,那么实例化子类对象的初始化顺序为:

  1. 父类静态成员的初始化

  2. 父类静态代码块初始化

  3. 子类静态成员的初始化

  4. 子类静态代码块初始化

  5. 父类成员的初始化

  6. 父类初始化块

  7. 父类构造器

  8. 子类成员的初始化

  9. 子类初始化块

  10. 子类构造器

我们来看两道题目加深下理解。

题目一:

请说出该程序的输出结果?

class Test {
    {
        System.out.println("非静态代码块1");
    }
    {
        System.out.println("非静态代码块2");
    }
    static {
        System.out.println("静态代码块1");
    }
    static {
        System.out.println("静态代码块2");
    }
    public Solution(){
        System.out.println("构造器");
    }
    public static void main(String[] args) {
        new Test();
        new Test();
    }
}

答案:

程序的输出结果为:

静态代码块1
静态代码块2
非静态代码块1
非静态代码块2
构造器
非静态代码块1
非静态代码块2
构造器

解析:

静态代码块:在虚拟机加载类的时候就会加载执行,并且只执行一次;所有的静态代码块按照书写的顺序进行加载执行,所以静态代码块会最先按顺序输出,并且只输出了一次。

非静态代码块(或者叫做初始化块)与构造器在每次生成实例(对象)的时候都会执行一次;非静态代码块会在构造器前执行,且非静态代码块按照书写顺序依次执行。

题目二:

题目二涉及到了继承,学完继承之后,可以回过头再看本题加深理解。

请说出该程序的输出结果?

class A {
    public A() {
        System.out.println("class A constructor");
    }

    {
        System.out.println("class A block");
    }

    static {
        System.out.println("class A static block");
    }
}

public class B extends A {
    public B() {
        System.out.println("class B constructor");
    }

    {
        System.out.println("class B block");
    }

    static {
        System.out.println("class B static block");
    }

    public static void main(String[] args) {
        new B();
    }
}

答案:

class A static block
class B static block
class A block
class A constructor
class B block
class B constructor

我们通过有继承关系实例化子类对象的初始化顺序就可以分析出答案,就不再赘述整个过程了。

4:对象的生命周期

JVM 运行空间中,对象的整个生命周期大致可以分为七个阶段:

  1. 创建阶段(Creation

  2. 应用阶段(Using

  3. 不可视阶段(Invisible

  4. 不可达阶段(Unreachable

  5. 可收集阶段(Collected

  6. 终结阶段(Finalized

  7. 释放阶段(Free

创建阶段

一个对象想要进入创建阶段,前提是它的类文件必须已经加载到内存中,并且已经创建了 Class 对象,这样才能根据类信息进行创建

在对象的创建阶段,系统通过以下步骤完成对象的创建过程:

  1. 为对象在堆内存中分配空间

  2. 构造对象。从最顶层的父类开始对局部变量进行赋值

  3. 从最顶层的父类开始往下调用构造方法

应用阶段

当对象创建阶段结束之后,通常就会进入到对象的应用阶段。这个阶段是对象得以表现自身能力的阶段。也就是说对象的应用阶段是对象整个生命周期中证明自身“存在价值”的时期。在对象的应用阶段,对象具备下列特征:

  • 系统至少维护着对象的一个强引用(Strong Reference

  • 所有对该对象的引用全部是强引用(除非我们显式地使用了:软引用(Soft Reference)、弱引用(Weak Reference)或虚引用(Phantom Reference))

不可视阶段

不可视阶段中,对象存在且被引用,但是这个引用在接下来的代码中并没有被使用到,这就造成了内存的冗余。

例如代码:

public void process() {
    try {
        MyObject obj = new MyObject();
        obj.doSomething();
    }catch (Exception e) {
        e.printStackTrace();
    }

    while (true) {
        // 该代码块对 obj 对象来说已经是不可视的
        // 因此下面代码在编译时会引发错误
        obj.doSomething();
    }
}

如果一个对象已经使用完毕,并且在可视区域内不再使用,那么应该主动将其设置为 null*。这样做的意义是,可以帮助 *JVM 及时地发现这个垃圾对象,并且可以及时地回收该对象所占用的系统资源。

不可达阶段

当一个对象没有再被强引用时,就会进入不可达阶段,在这个阶段中,对象随时会被回收,这由 JVM 中的垃圾回收器(GC)来决定

可收集阶段、终结阶段与释放阶段

对象生命周期的最后一个阶段是可收集阶段、终结阶段与释放阶段。当对象处于这个阶段的时候,可能处于下面三种情况:

  1. 垃圾回收器发现该对象已经不可到达

  2. finalize 方法已经被执行

  3. 对象空间已被重用

当对象处于上面三种情况时,该对象就处于可收集阶段、终结阶段与释放阶段了。虚拟机就可以直接将该对象回收了。

JVM 如何判断哪个对象没有被用到

JVM 通过 GC Roots 来判断对象是否“存活”。

这是 GC 算法中的一种,叫做根搜索算法。

从根(GC Roots)节点向下搜索对象节点,搜索走过的路径称为引用链,当一个对象到根之间没有连通的话,则该对象不可用。

根搜索算法示意图:

可以作为 GC Roots 的对象包括:

  • 虚拟机栈(栈帧局部变量)中引用的对象

  • 方法区类静态属性引用的对象

  • 方法区中常量( final )引用的对象

  • 本地方法栈中 JNI 引用的对象


上一篇:
下一篇:
0%