用关键字new
进行对象的创建,几乎是写代码时最常用的操作之一了,比如:
Sheep sheep1 = new Sheep();
Sheep sheep2 = new Sheep( "codesheep", 18, 65.0f );
通过new
的方式,我们可以调用类的无参或者有参构造方法来实例化出一个对象。
表面上看,简简单单new
一下对象就有了,但面试时如果仅仅答到这一层,大概率会扑街,因为比这个更重要的是new
对象时的原理和流程,因为JVM这个牵线红娘在背后默默地帮我们做了很多工作。
说到new
一个对象的具体流程,用一张图可大致描述成如下所示:
应该说,经过了这一系列步骤,一个新的可用对象方才得以诞生。
学过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 );
对象克隆在我们日常写代码的时候基本上是刚性需求,基于一个对象克隆出另一个对象,这也是写Java代码时十分常见的操作。
这两个概念的准确区分,对于深、浅拷贝问题的理解非常重要。
所以来Java
的世界,我们要习惯用引用去操作对象。在Java
中,像数组、类Class
、枚举Enum
、Integer
包装类等等,就是典型的引用类型,所以操作时一般来说采用的也是引用传递
的方式;
但是Java
的语言级基础数据类型,诸如int
这些基本类型,操作时一般采取的则是值传递
的方式,所以有时候也称它为值类型。
为了便于下文的讲述和举例,我们这里先定义两个类:Student
和Major
,分别表示「学生」以及「所学的专业」,二者是包含关系:
// 学生的所学专业
public class Major {
private String majorName; // 专业名称
private long majorId; // 专业代号
// ... 其他省略 ...
}
// 学生
public class Student {
private String name; // 姓名
private int age; // 年龄
private Major major; // 所学专业
// ... 其他省略 ...
}
赋值是日常编程过程中最常见的操作,最简单的比如:
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;
}
// ... 其他省略 ...
}
这时候上面的测试用例不变,运行可得结果:
利用反序列化技术,我们也可以从一个对象深拷贝出另一个复制对象,而且这货在解决多层套娃式的深拷贝问题时效果出奇的好
。
所以我们这里改造一下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 {
// ... 其他省略 ...
}
这时候测试用例完全不变,直接运行,也可以得到如下结果:
序列化的原本意图是希望对一个Java对象作一下“变换”,变成字节序列,这样一来方便持久化存储到磁盘,避免程序运行结束后对象就从内存里消失,另外变换成字节序列也更便于网络运输和传播,所以概念上很好理解:
序列化
:把Java对象转换为字节序列。反序列化
:把字节序列恢复为原先的Java对象。
而且序列化机制从某种意义上来说也弥补了平台化的一些差异,毕竟转换后的字节流可以在其他平台上进行反序列化来恢复对象。
事情就是那么个事情,看起来很简单,不过后面的东西还不少,请往下看。
然而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
上面在定义Student
类时,实现了一个Serializable
接口,然而当我们点进Serializable
接口内部查看,发现它竟然是一个空接口,并没有包含任何方法!
试想,如果上面在定义Student
类时忘了加implements Serializable
时会发生什么呢?
实验结果是:此时的程序运行会报错
,并抛出NotSerializableException
异常:
我们按照错误提示,由源码一直跟到ObjectOutputStream
的writeObject0()
方法底层一看,才恍然大悟:
如果一个对象既不是字符串、数组、枚举
,而且也没有实现Serializable
接口的话,在序列化时就会抛出NotSerializableException
异常!
哦,我明白了!
原来Serializable
接口也仅仅只是做一个标记用!!!
它告诉代码只要是实现了Serializable
接口的类都是可以被序列化的!然而真正的序列化动作不需要靠它完成。
相信你一定经常看到有些类中定义了如下代码行,即定义了一个名为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
序列化ID,可以看成是序列化和反序列化过程中的“暗号”,在反序列化时,JVM会把字节流中的序列号ID和被序列化类中的序列号ID做比对,只有两者一致,才能重新反序列化,否则就会报异常来终止反序列化的过程。
第2个问题: 如果在定义一个可序列化的类时,没有人为显式地给它定义一个serialVersionUID
的话,则Java运行时环境会根据该类的各方面信息自动地为它生成一个默认的serialVersionUID
,一旦像上面一样更改了类的结构或者信息,则类的serialVersionUID
也会跟着变化!
所以,为了serialVersionUID
的确定性,写代码时还是建议,凡是implements Serializable
的类,都最好人为显式地为它声明一个serialVersionUID
明确值!
当然,如果不想手动赋值,你也可以借助IDE的自动添加功能,比如我使用的IntelliJ IDEA
,按alt + enter
就可以为类自动生成和添加serialVersionUID
字段,十分方便:
static
修饰的字段是不会被序列化的transient
修饰符修饰的字段也是不会被序列化的对于第一点,因为序列化保存的是对象的状态
而非类的状态,所以会忽略static
静态域也是理所应当的。
对于第二点,就需要了解一下transient
修饰符的作用了。
如果在序列化某个类的对象时,就是不希望某个字段被序列化(比如这个字段存放的是隐私值,如:密码
等),那这时就可以用transient
修饰符来修饰该字段。
比如在之前定义的Student
类中,加入一个密码字段
,但是不希望序列化到txt文本,则可以:
这样在序列化Student
类对象时,password
字段会设置为默认值null
,这一点可以从反序列化所得到的结果来看出:
从上面的过程可以看出,序列化和反序列化的过程其实是有漏洞的
,因为从序列化到反序列化是有中间过程的,如果被别人拿到了中间字节流,然后加以伪造或者篡改,那反序列化出来的对象就会有一定风险了。
毕竟反序列化也相当于一种 “隐式的”对象构造
,因此我们希望在反序列化时,进行受控的
对象反序列化动作。
那怎么个受控法呢?
答案就是: 自行编写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
,此时反序列化立马终止并且报错:
对于上面的代码,有些小伙伴可能会好奇,为什么自定义的private
的readObject()
方法可以被自动调用,这就需要你跟一下底层源码来一探究竟了,我帮你跟到了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()
会被调用,用其中返回的对象替代反序列化新建的对象。
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 );
当然除了上述这几种显式地对象创建场景之外,还有一些我们并没有进行手动对象创建的隐式场景,举几个常见例子。
我们都知道JVM
虚拟机在加载一个类的时候,也都会创建一个类对应的Class
实例对象,很明显这一过程是JVM
偷偷地背着我们干的。
典型的,比如定义一个String
类型的字面变量时,就可能会引起一个新的String
对象的创建,就像这样:
String name = "codesheep";
还常见的比如String
的+号连接符也会隐式地导致新String
对象的创建等:
String str = str1 + str2;
这种例子也有很多,比如在执行类似如下代码时:
Integer codeSheepAge = 18;
其触发的自动装箱机制就会导致一个新的包装类型的对象在后台被隐式地创建出来。
比如像下面这样,当我们使用可变参数语法`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 );
而背地里可能会隐式地产生一个对应的数组对象进行计算。
总而总之,很多场景下对象的隐式创建也是数见不鲜,我们最起码要做到心中大致有数。
因篇幅问题不能全部显示,请点此查看更多更全内容