Java创建对象

1. new一个对象

用关键字new进行对象的创建,几乎是写代码时最常用的操作之一了,比如:

Sheep sheep1 = new Sheep();
Sheep sheep2 = new Sheep( "codesheep", 18, 65.0f );

通过new的方式,我们可以调用类的无参或者有参构造方法来实例化出一个对象。

表面上看,简简单单new一下对象就有了,但面试时如果仅仅答到这一层,大概率会扑街,因为比这个更重要的是new对象时的原理和流程,因为JVM这个牵线红娘在背后默默地帮我们做了很多工作。

说到new一个对象的具体流程,用一张图可大致描述成如下所示:

  1. 首先,当我们new一个对象时,比如Sheep sheep = new Sheep(),JVM首先就回去检查Sheep这个符号引用所代表的类是否已经被加载过,如果没有就要执行对应类的加载过程;
  2. 声明类型引用很简单,比如Sheep sheep = new Sheep()就会声明一个Sheep类型的引用sheep;
  3. 第一步类加载完成以后,对象所需的内存大小其实就已经确定下来了,接下来JVM就会在堆上为对象分配内存;
  4. 所谓的属性“0”值初始化非常好理解,即为实例化对象的各个属性赋上默认初始化“0”值,比如int的初始化0值就是0,而一个对象的初始化0值就是null;
  5. 接下来JVM会进行对象头的设置,这里面就主要包括对象的运行时数据(比如Hash码、分代年龄、锁状态标志、锁指针、偏向线程ID、偏向时间戳等)以及类型指针(JVM通过该类型指针来确定该对象是哪个类的实例);
  6. 属性的显示初始化也好理解,比如定义一个类的时候,针对某个属性字段手动的赋值,如:private String name = “codesheep”; 就在这时候给初始化上;
  7. 最后是调用类的构造方法来进行进行构造方法内描述的初始化动作。

应该说,经过了这一系列步骤,一个新的可用对象方才得以诞生。

2. 反射出一个对象

学过Java反射机制的都知道,只要能拿到类的Class对象,就可以通过强大的反射机制来创造出实例对象了。

一般来说,拿到Class对象有三种方式:

  • 类名.class
  • 对象名.getClass()
  • Class.forName(全限定类名)

有了Class对象之后,接下来就可以调用其newInstance()方法来创建一个对象,就像这样:

Sheep sheep3 = (Sheep) Class.forName( "cn.codesheep.article.obj.Sheep" ).newInstance();
Sheep sheep4 = Sheep.class.newInstance();

当然,这种方式的局限性也有目共睹,因为使用的是类的无参构造方法来创建的对象。

所以比这个更进一步的方式是通过java.lang.relect.Constructor这个类的newInstance()方法来创建对象,因为它可以明确指定某个构造器来创建对象。

比如,在我们拿到了类的Class对象后,就可以通过getDeclaredConstructors()函数来获取到类的所有构造函数列表,这样我们就可以调用对应的构造函数来创建对象了,就像这样:

Constructor<?>[] constructors = Sheep.class.getDeclaredConstructors();
Sheep sheep5 = (Sheep) constructors[0].newInstance(); 
Sheep sheep6 = (Sheep) constructors[1].newInstance( "codesheep", 18, 65.1f );

而且,如果我们想明确获取类的某个构造函数,也可以在getDeclaredConstructors()函数里直接指定构造函数传参类型来精确控制,就像这样:

Constructor constructor = Sheep.class.getDeclaredConstructor( String.class, Integer.class, Float.class );
Sheep sheep7 = (Sheep) constructor.newInstance( "codesheep", 18, 65.2f );

3. 克隆出一个对象(及拷贝知识补充)

对象克隆在我们日常写代码的时候基本上是刚性需求,基于一个对象克隆出另一个对象,这也是写Java代码时十分常见的操作。

3.1 值类型和引用类型

这两个概念的准确区分,对于深、浅拷贝问题的理解非常重要。

所以来Java的世界,我们要习惯用引用去操作对象。在Java中,像数组、类Class、枚举EnumInteger包装类等等,就是典型的引用类型,所以操作时一般来说采用的也是引用传递的方式;

但是Java的语言级基础数据类型,诸如int这些基本类型,操作时一般采取的则是值传递的方式,所以有时候也称它为值类型。

为了便于下文的讲述和举例,我们这里先定义两个类:StudentMajor,分别表示「学生」以及「所学的专业」,二者是包含关系:

// 学生的所学专业
public class Major {
    private String majorName; // 专业名称
    private long majorId;     // 专业代号
    
    // ... 其他省略 ...
}
// 学生
public class Student {
    private String name;  // 姓名
    private int age;      // 年龄
    private Major major;  // 所学专业
    
    // ... 其他省略 ...
}

3.2. 赋值 vs 浅拷贝 vs 深拷贝

对象赋值

赋值是日常编程过程中最常见的操作,最简单的比如:

Student codeSheep = new Student();
Student codePig = codeSheep;

严格来说,这种不能算是对象拷贝,因为拷贝的仅仅只是引用关系,并没有生成新的实际对象:

浅拷贝

浅拷贝属于对象克隆方式的一种,重要的特性体现在这个 「浅」 字上。

比如我们试图通过studen1实例,拷贝得到student2,如果是浅拷贝这种方式,大致模型可以示意成如下所示的样子:

很明显,值类型的字段会复制一份,而引用类型的字段拷贝的仅仅是引用地址,而该引用地址指向的实际对象空间其实只有一份。

一图胜前言,我想上面这个图已经表现得很清楚了。

浅拷贝代码实现

还以上文的例子来讲,我想通过student1拷贝得到student2,浅拷贝的典型实现方式是:让被复制对象的类实现Cloneable接口,并重写clone()方法即可。

以上面的Student类拷贝为例:

public class Student implements Cloneable {
    private String name;  // 姓名
    private int age;      // 年龄
    private Major major;  // 所学专业
    @Override
    public Object clone() throws CloneNotSupportedException {
        return super.clone();
    }
    // ... 其他省略 ...
}

然后我们写个测试代码,一试便知:

public class Test {
    public static void main(String[] args) throws CloneNotSupportedException {
        Major m = new Major("计算机科学与技术",666666);
        Student student1 = new Student( "CodeSheep", 18, m );
        // 由 student1 拷贝得到 student2
        Student student2 = (Student) student1.clone();
        System.out.println( student1 == student2 );
        System.out.println( student1 );
        System.out.println( student2 );
        System.out.println( "\n" );
        // 修改student1的值类型字段
        student1.setAge( 35 );
        // 修改student1的引用类型字段
        m.setMajorName( "电子信息工程" );
        m.setMajorId( 888888 );
        System.out.println( student1 );
        System.out.println( student2 );
    }
}

运行得到如下结果:

从结果可以看出:

  • student1==student2打印false,说明clone()方法的确克隆出了一个新对象;
  • 修改值类型字段并不影响克隆出来的新对象,符合预期;
  • 而修改了student1内部的引用对象,克隆对象student2也受到了波及,说明内部还是关联在一起的

深拷贝

深拷贝相较于上面所示的浅拷贝,除了值类型字段会复制一份,引用类型字段所指向的对象,会在内存中也创建一个副本,就像这个样子:

原理很清楚明了,下面来看看具体的代码实现吧。

深拷贝代码实现

深度遍历式拷贝

虽然clone()方法可以完成对象的拷贝工作,但是注意:clone()方法默认是浅拷贝行为,就像上面的例子一样。若想实现深拷贝需覆写 clone()方法实现引用对象的深度遍历式拷贝,进行地毯式搜索。

所以相对于浅拷贝代码实现的例子,如果想实现深拷贝,首先需要对更深一层次的引用类Major做改造,让其也实现Cloneable接口并重写clone()方法:

public class Major implements Cloneable {
    @Override
    protected Object clone() throws CloneNotSupportedException {
        return super.clone();
    }
    // ... 其他省略 ...
}

其次我们还需要在顶层的调用类中重写clone方法,来调用引用类型字段的clone()方法实现深度拷贝,对应到本文那就是Student类:

public class Student implements Cloneable {
    @Override
    public Object clone() throws CloneNotSupportedException {
        Student student = (Student) super.clone();
        student.major = (Major) major.clone(); // 重要!!!
        return student;
    }
    // ... 其他省略 ...
}

这时候上面的测试用例不变,运行可得结果:

很明显,这时候student1student2两个对象就完全独立了,不受互相的干扰。

利用反序列化实现深拷贝

利用反序列化技术,我们也可以从一个对象深拷贝出另一个复制对象,而且这货在解决多层套娃式的深拷贝问题时效果出奇的好

所以我们这里改造一下Student类,让其clone()方法通过序列化和反序列化的方式来生成一个原对象的深拷贝副本:

public class Student implements Serializable {
    private String name;  // 姓名
    private int age;      // 年龄
    private Major major;  // 所学专业
    public Student clone() {
        try {
            // 将对象本身序列化到字节流
            ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
            ObjectOutputStream objectOutputStream =
                    new ObjectOutputStream( byteArrayOutputStream );
            objectOutputStream.writeObject( this );
            // 再将字节流通过反序列化方式得到对象副本
            ObjectInputStream objectInputStream =
                    new ObjectInputStream( new ByteArrayInputStream( byteArrayOutputStream.toByteArray() ) );
            return (Student) objectInputStream.readObject();
        } catch (IOException e) {
            e.printStackTrace();
        } catch (ClassNotFoundException e) {
            e.printStackTrace();
        }
        return null;
    }
    // ... 其他省略 ...
}

当然这种情况下要求被引用的子类(比如这里的Major类)也必须是可以序列化的,即实现了Serializable接口:

public class Major implements Serializable {
  // ... 其他省略 ...
}

这时候测试用例完全不变,直接运行,也可以得到如下结果:

很明显,这时候student1student2两个对象也是完全独立的,不受互相的干扰,深拷贝完成。

4. 反序列化出一个对象(及序列化知识补充)

4.1 序列化是干啥用的?

序列化的原本意图是希望对一个Java对象作一下“变换”,变成字节序列,这样一来方便持久化存储到磁盘,避免程序运行结束后对象就从内存里消失,另外变换成字节序列也更便于网络运输和传播,所以概念上很好理解:

  • 序列化:把Java对象转换为字节序列。
  • 反序列化:把字节序列恢复为原先的Java对象。


而且序列化机制从某种意义上来说也弥补了平台化的一些差异,毕竟转换后的字节流可以在其他平台上进行反序列化来恢复对象。

事情就是那么个事情,看起来很简单,不过后面的东西还不少,请往下看。

4.2对象如何序列化?

然而Java目前并没有一个关键字可以直接去定义一个所谓的“可持久化”对象。

对象的持久化和反持久化需要靠程序员在代码里手动显式地进行序列化和反序列化还原的动作。

举个例子,假如我们要对Student类对象序列化到一个名为student.txt的文本文件中,然后再通过文本文件反序列化成Student类对象:

1、Student类定义

public class Student implements Serializable {
    private String name;
    private Integer age;
    private Integer score;
    @Override
    public String toString() {
        return "Student:" + '\n' +
        "name = " + this.name + '\n' +
        "age = " + this.age + '\n' +
        "score = " + this.score + '\n'
        ;
    }
    // ... 其他省略 ...
}

2、序列化

public static void serialize(  ) throws IOException {
    Student student = new Student();
    student.setName("CodeSheep");
    student.setAge( 18 );
    student.setScore( 1000 );
    ObjectOutputStream objectOutputStream = 
        new ObjectOutputStream( new FileOutputStream( new File("student.txt") ) );
    objectOutputStream.writeObject( student );
    objectOutputStream.close();
    System.out.println("序列化成功!已经生成student.txt文件");
    System.out.println("==============================================");
}

3、反序列化

public static void deserialize(  ) throws IOException, ClassNotFoundException {
    ObjectInputStream objectInputStream = 
        new ObjectInputStream( new FileInputStream( new File("student.txt") ) );
    Student student = (Student) objectInputStream.readObject();
    objectInputStream.close();
    System.out.println("反序列化结果为:");
    System.out.println( student );
}

4、运行结果

控制台打印:

序列化成功!已经生成student.txt文件
==============================================
反序列化结果为:
Student:
name = CodeSheep
age = 18
score = 1000

4.3 Serializable接口有何用?

上面在定义Student类时,实现了一个Serializable接口,然而当我们点进Serializable接口内部查看,发现它竟然是一个空接口,并没有包含任何方法!

试想,如果上面在定义Student类时忘了加implements Serializable时会发生什么呢?

实验结果是:此时的程序运行会报错,并抛出NotSerializableException异常:

我们按照错误提示,由源码一直跟到ObjectOutputStreamwriteObject0()方法底层一看,才恍然大悟:

如果一个对象既不是字符串、数组、枚举,而且也没有实现Serializable接口的话,在序列化时就会抛出NotSerializableException异常!

哦,我明白了!

原来Serializable接口也仅仅只是做一个标记用!!!

它告诉代码只要是实现了Serializable接口的类都是可以被序列化的!然而真正的序列化动作不需要靠它完成。

4.4 serialVersionUID号有何用?

相信你一定经常看到有些类中定义了如下代码行,即定义了一个名为serialVersionUID的字段:

private static final long serialVersionUID = -4392658638228508589L;

你知道这句声明的含义吗?为什么要搞一个名为serialVersionUID的序列号?

继续来做一个简单实验,还拿上面的Student类为例,我们并没有人为在里面显式地声明一个serialVersionUID字段。

我们首先还是调用上面的serialize()方法,将一个Student对象序列化到本地磁盘上的student.txt文件:

public static void serialize() throws IOException {
    Student student = new Student();
    student.setName("CodeSheep");
    student.setAge( 18 );
    student.setScore( 100 );
    ObjectOutputStream objectOutputStream = 
        new ObjectOutputStream( new FileOutputStream( new File("student.txt") ) );
    objectOutputStream.writeObject( student );
    objectOutputStream.close();
}

接下来我们在Student类里面动点手脚,比如在里面再增加一个名为studentID的字段,表示学生学号:

这时候,我们拿刚才已经序列化到本地的student.txt文件,还用如下代码进行反序列化,试图还原出刚才那个Student对象:

public static void deserialize(  ) throws IOException, ClassNotFoundException {
    ObjectInputStream objectInputStream = 
        new ObjectInputStream( new FileInputStream( new File("student.txt") ) );
    Student student = (Student) objectInputStream.readObject();
    objectInputStream.close();
    System.out.println("反序列化结果为:");
    System.out.println( student );
}

运行发现报错了,并且抛出了InvalidClassException异常:

这地方提示的信息非常明确了:序列化前后的serialVersionUID号码不兼容!

从这地方最起码可以得出两个重要信息:

  • 1、 serialVersionUID是序列化前后的唯一标识符
  • 2、 默认如果没有人为显式定义过serialVersionUID,那编译器会为它自动声明一个!

第1个问题: serialVersionUID序列化ID,可以看成是序列化和反序列化过程中的“暗号”,在反序列化时,JVM会把字节流中的序列号ID和被序列化类中的序列号ID做比对,只有两者一致,才能重新反序列化,否则就会报异常来终止反序列化的过程。

第2个问题: 如果在定义一个可序列化的类时,没有人为显式地给它定义一个serialVersionUID的话,则Java运行时环境会根据该类的各方面信息自动地为它生成一个默认的serialVersionUID,一旦像上面一样更改了类的结构或者信息,则类的serialVersionUID也会跟着变化!

所以,为了serialVersionUID的确定性,写代码时还是建议,凡是implements Serializable的类,都最好人为显式地为它声明一个serialVersionUID明确值!

当然,如果不想手动赋值,你也可以借助IDE的自动添加功能,比如我使用的IntelliJ IDEA,按alt + enter就可以为类自动生成和添加serialVersionUID字段,十分方便:

4.5 两种特殊情况

  • 1、凡是被static修饰的字段是不会被序列化的
  • 2、凡是被transient修饰符修饰的字段也是不会被序列化的

对于第一点,因为序列化保存的是对象的状态而非类的状态,所以会忽略static静态域也是理所应当的。

对于第二点,就需要了解一下transient修饰符的作用了。

如果在序列化某个类的对象时,就是不希望某个字段被序列化(比如这个字段存放的是隐私值,如:密码等),那这时就可以用transient修饰符来修饰该字段。

比如在之前定义的Student类中,加入一个密码字段,但是不希望序列化到txt文本,则可以:

这样在序列化Student类对象时,password字段会设置为默认值null,这一点可以从反序列化所得到的结果来看出:

4.6 序列化的受控和加强

约束性加持

从上面的过程可以看出,序列化和反序列化的过程其实是有漏洞的,因为从序列化到反序列化是有中间过程的,如果被别人拿到了中间字节流,然后加以伪造或者篡改,那反序列化出来的对象就会有一定风险了。

毕竟反序列化也相当于一种 “隐式的”对象构造 ,因此我们希望在反序列化时,进行受控的对象反序列化动作。

那怎么个受控法呢?

答案就是: 自行编写readObject()函数,用于对象的反序列化构造,从而提供约束性。

既然自行编写readObject()函数,那就可以做很多可控的事情:比如各种判断工作。

还以上面的Student类为例,一般来说学生的成绩应该在0 ~ 100之间,我们为了防止学生的考试成绩在反序列化时被别人篡改成一个奇葩值,我们可以自行编写readObject()函数用于反序列化的控制:

private void readObject( ObjectInputStream objectInputStream ) throws IOException, ClassNotFoundException {
    // 调用默认的反序列化函数
    objectInputStream.defaultReadObject();
    // 手工检查反序列化后学生成绩的有效性,若发现有问题,即终止操作!
    if( 0 > score || 100 < score ) {
        throw new IllegalArgumentException("学生分数只能在0到100之间!");
    }
}

比如我故意将学生的分数改为101,此时反序列化立马终止并且报错:

对于上面的代码,有些小伙伴可能会好奇,为什么自定义的privatereadObject()方法可以被自动调用,这就需要你跟一下底层源码来一探究竟了,我帮你跟到了ObjectStreamClass类的最底层,看到这里我相信你一定恍然大悟:

又是反射机制在起作用!是的,在Java里,果然万物皆可“反射”(滑稽),即使是类中定义的private私有方法,也能被抠出来执行了,简直引起舒适了。

单例模式增强

一个容易被忽略的问题是:可序列化的单例类有可能并不单例!

举个代码小例子就清楚了。

比如这里我们先用java写一个常见的「静态内部类」方式的单例模式实现:

public class Singleton implements Serializable {
    private static final long serialVersionUID = -1576643344804979563L;
    private Singleton() {
    }
    private static class SingletonHolder {
        private static final Singleton singleton = new Singleton();
    }
    public static synchronized Singleton getSingleton() {
        return SingletonHolder.singleton;
    }
}

然后写一个验证主函数:

public class Test2 {
    public static void main(String[] args) throws IOException, ClassNotFoundException {
        ObjectOutputStream objectOutputStream =
                new ObjectOutputStream(
                    new FileOutputStream( new File("singleton.txt") )
                );
        // 将单例对象先序列化到文本文件singleton.txt中
        objectOutputStream.writeObject( Singleton.getSingleton() );
        objectOutputStream.close();
        ObjectInputStream objectInputStream =
                new ObjectInputStream(
                    new FileInputStream( new File("singleton.txt") )
                );
        // 将文本文件singleton.txt中的对象反序列化为singleton1
        Singleton singleton1 = (Singleton) objectInputStream.readObject();
        objectInputStream.close();
        Singleton singleton2 = Singleton.getSingleton();
        // 运行结果竟打印 false !
        System.out.println( singleton1 == singleton2 );
    }
}

运行后我们发现:反序列化后的单例对象和原单例对象并不相等了,这无疑没有达到我们的目标。

解决办法是:在单例类中手写readResolve()函数,直接返回单例对象,来规避之:

private Object readResolve() {
    return SingletonHolder.singleton;
}


这样一来,当反序列化从流中读取对象时,readResolve()会被调用,用其中返回的对象替代反序列化新建的对象。

5. Unsafe黑魔法

Unsafe类这个名字一听就有点悬了,的确,我们平时的业务代码里接触得好像并不多。

我们都知道写Java代码,很少会去操作位于底层的一些资源,比如内存等这些。而位于sun.misc.Unsafe包路径下的Unsafe类提供了一种直接访问系统资源的途径和方法,可以进行一些底层的操作。比如借助Unsafe我们就可以分配内存、创建对象、释放内存、定位对象某个字段的内存位置甚至并修改它等等。

可见这玩意误用时的破坏力是很大的,所以一般也都是受控使用的。业务代码里很少能看到它的身影,但是JDK内部的一些诸如io、nio、juc等包中的代码里还是有不少关于它的身影存在的。

Unsafe类中有一个allocateInstance()方法,通过其就可以创建一个对象。为此我们只需要获取到一个Unsafe类的实例对象,我们自然就可以调用allocateInstance()来创建对象了。

那如何才能获取到一个Unsafe类的实例对象呢?

大致瞅一眼Unsafe类的源码我们就会发现,它是一个单例类,其构造方法是私有的,所以直接构造是不太现实了:

public final class Unsafe {
    private static final Unsafe theUnsafe;
    // ... 省略 ...
    private static native void registerNatives();
    private Unsafe() {
    }
    @CallerSensitive
    public static Unsafe getUnsafe() {
        Class var0 = Reflection.getCallerClass();
        if (!VM.isSystemDomainLoader(var0.getClassLoader())) {
            throw new SecurityException("Unsafe");
        } else {
            return theUnsafe;
        }
    }
    // ... 省略 ...
}

而且获取单例对象的入口函数getUnsafe()上也做了特殊标记,意思是只能从引导加载的类才可以调用该方法,这意味着该方法也是供JVM内部使用的,外部代码直接使用会报类似这样的异常:

Exception in thread "main" java.lang.SecurityException: Unsafe

走投无路,我们只能再次重拾强大的反射机制来创建Unsafe类的实例了:

Field f = Unsafe.class.getDeclaredField("theUnsafe");
f.setAccessible(true);
Unsafe unsafe = (Unsafe) f.get(null);

然后接下来我们就可以愉快地利用它来创建对象了:

Sheep sheep8 = (Sheep) unsafe.allocateInstance( Sheep.class );

6. 对象的隐式创建场景

当然除了上述这几种显式地对象创建场景之外,还有一些我们并没有进行手动对象创建的隐式场景,举几个常见例子。

6.1 Class类实例隐式创建

我们都知道JVM虚拟机在加载一个类的时候,也都会创建一个类对应的Class实例对象,很明显这一过程是JVM偷偷地背着我们干的。

6.2字符串隐式对象创建

典型的,比如定义一个String类型的字面变量时,就可能会引起一个新的String对象的创建,就像这样:

String name = "codesheep";

还常见的比如String的+号连接符也会隐式地导致新String对象的创建等:

String str = str1 + str2;

6.3自动装箱机制

这种例子也有很多,比如在执行类似如下代码时:

Integer codeSheepAge = 18;

其触发的自动装箱机制就会导致一个新的包装类型的对象在后台被隐式地创建出来。

6.4函数可变参数

比如像下面这样,当我们使用可变参数语法`int… nums来描述一个函数的入参时:

public double avg( int... nums ) {
    double sum = 0;
    int length = nums.length;
    for (int i = 0; i<length; ++i) {
        sum += nums[i];
    }
    return sum/length;
}

从表面上看,函数的调用处可以传入各种离散参数参与计算:

avg( 2, 2, 4 );
avg( 2, 2, 4, 4 );
avg( 2, 2, 4, 4, 5, 6 );

而背地里可能会隐式地产生一个对应的数组对象进行计算。

总而总之,很多场景下对象的隐式创建也是数见不鲜,我们最起码要做到心中大致有数。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值