重新认识 java(一) ---- 万物皆对象

883 阅读17分钟
原文链接: www.jianshu.com

如果你现实中没有对象,至少你在java世界里会有茫茫多的对象,听起来是不是很激动呢?

对象,引用,类与现实世界

现实世界里有许许多多的生物,非生物,跑的跳的飞的,过去的现在的未来的,令人眼花缭乱。我们编程的目的,就是解决现实生活中的问题。所以不可避免的我们要和现实世界中各种奇怪的东西打交道。

在现实世界里,你新认识了一个朋友,你知道他长什么样,知道了他的名字年龄,地址。知道他喜欢干什么有什么特长。你想用java语言描述一下这个人,你应该怎么做呢?

这个时候,就有了类的概念。每一个类对应现实世界中的某一事物。比如现实世界中有人。那么我们就创建一个关于“人”的类。

每一个人都有名字,都有地址等等个人信息。那么我们就在“人”的类里面添加这些属性。

每一个人都会吃,会走路,那么我们就在“人”的类里面添加吃和走的方法。

当这个世界又迎来了一个新生命,我们就可以“new”一个“人”,“new”出来的就叫”对象“。

每一个人一出生,父母就会给他取个名字。在程序里,我们需要用一种方式来操作这个“对象”,于是,就出现了引用。我们通过引用来操作对象,设置对象的属性,操作对象的方法。

这就是最基本的面向对象。

现实世界的事物】 ---抽象---> 【 】---new--->【对象 】<---控制--- 【引用

从创建一个对象开始

创建对象的前提是先得有一个类。我们先自己创建一个person类。

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

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

创建一个person对象。

    Person p = new Person();

怎么理解这句简单的代码呢?

  • new Person :一个Person类型的对象
  • () : 这个括号相当于调用了person的无参构造方法
  • p : Person对象的引用

有的人会认为p就是new出来的Person对象。这是错误的理解,p只是一个Person对象的引用而已。那么问题来了,什么是引用?什么又是对象呢?这个要从内存说起。

创建对象的过程

java大体上会把内存分为四块区域:堆,栈,静态区,常量区。

  • 堆 : 位于RAM中,用于存放所有的java对象。
  • 栈 : 位于RAM中,引用就存在于栈中。
  • 静态区 : 位于RAM中,被static修饰符修饰的变量会被放在这里
  • 常量区 :位于ROM中, 很明显,放常量的。

事实上,我们不需要关心java的对象,变量到底存在了哪里,因为jvm会帮我们处理好这些。但是理解了这些,有助于提高我们的水平。

当执行这句代码的时候。

Person p = new Person();

首先,会在堆中开辟一块空间存放这个新来的Person对象。然后,会创建一个引用p,存放在栈中,这个引用p指向Person对象(事实上是,p的值就是Person对象的内存地址)。

这样,我们通过访问p,然后得到了Person的内存地址,进而找到了Person对象。

然后又有了这样一句代码:

Person p2 = p;

这句代码的含义是:
创建了一个新的引用,保存在栈中,引用的地址也指向Person的地址。这个时候,你通过p2来改变Person对象的状态,也会改变p的结果。因为它们指向同一个对象。(String除外,之后会专门讲String)

此时,内存中是这样的:


用一种很通俗的方式来讲解一下引用和对象。

大家都应该用过windows吧。win有一个神奇的东西叫做快捷方式。我们桌面的图标大部分都是快捷方式。它并不是我们安装在电脑上的应用的可执行文件(不是.exe文件),那么为什么点击它可以打开应用程序呢?这个我不用讲了把。

我们的对象和引用就和快捷方式和它连接的文件一样。

我们不直接对文件进行操作,而是通过快捷方式来进行操作。快捷方式不能独立存在,同样,引用也不能独立存在(你可以只创建一个引用,但是当你要使用它的时候必须得给它赋值,否则它将毫无用处)。

一个文件可以有多个快捷方式,同样一个对象也可以有多个引用。而一个引用只能同时对应一个对象。

在java里,“=”不能被看成是一个赋值语句,它不是在把一个对象赋给另外一个对象,它的执行过程实质上是将右边对象的地址传给了左边的引用,使得左边的引用指向了右边的对象。java表面上看起来没有指针,但它的引用其实质就是一个指针。在java里,“=”语句不应该被翻译成赋值语句,因为它所执行的确实不是一个简单的赋值过程,而是一个传地址的过程,被译成赋值语句会造成很多误解,译得不准确。

特例:基本数据类型

为什么会有特例呢?因为用new操作符创建的对象会存在堆里,二在堆里开辟空间等行为效率较操作栈要低。而我们平时写代码的时候会经常创建一些“小变量”,比如int i = 1;如果每次都用Interger来new一个,效率不是很高而且浪费内存。

所以针对这些情况,java提供了“基本数据类型”,基本数据类型一共有八种,每一个基本数据类型存放在栈中,而他们的值存放在常量区中。

举个例子:

int i = 2;
int j = 2;

我们需要知道的是,在常量区中,相同的常量只会存在一个。当执行第一句代码时。先查找常量区中有没有2,没有,则开辟一个空间存放2,然后在栈中存入一个变量i,让i指向2;

执行第二句的时候,查找发现2已经存在了,所以就不开辟新空间了。直接在栈中保存一个新变量j,让j指向2;

当然,java堆每一个基本数据类型都提供了对应的包装类。我们依旧可以用new操作符来创建我们想要的变量。

Integer i = new Integer(1);
Integer j = new Integer(1);

但是,用new操作符创建的对象是不同的,也就是说,此时,i和j指向不同的内存地址。因为每次调用new操作符,都会在堆开辟新的空间。

当然,说到基本数据类型,不得不提一下java的经典设计。

先看一段代码:


为什么一个是true一个是false呢?

我就不讲了,应该都知道吧。我就贴一个Integer的源码(jdk1.8)吧。


Integer 类的内部定义了一个内部类,缓存了从-128到127的所有数字,所以,你懂得。

又一个特例 :String

String是一个特殊的类,因为它被final修饰符所修饰,是一个不可改变的类。当然,看过java源码后你会发现,基本类型的各个包装类也被final所修饰。这里以String为例。

我们来看这样一个例子


执行第一句 : 常量区开辟空间存放“abc”,s1存放在栈中指向“abc”

执行第二句,s2 也指向 “abc”,

执行第三句,因为“abc”已经存在,所以直接指向它。

所以三个变量指向同一块内存地址,结果都为true。

当s1内容改变的时候。这个时候,常量区开辟新的空间存放“bcd”,s1指向“bcd”,而s2和s3指向“abc”所以只有s2和s3相等。

这种情况下,s1,s2,s3都是字符串常量,类似于基本数据类型。(如果执行的是s1 = "abc",那么结果会都是true)

我们再看一个例子:


执行第一行代码: 在堆里分配空间存放String对象,在常量区开辟空间存放常量“abc”,String对象指向常量,s1指向该对象。

执行第二行代码:s2指向上一步new出来的string对象。

执行第三行代码: 在堆里分配新的空间存放String对象,新对象指向常量“abc”,s3指向该对象。

到这里,很明显,s1和s2指向的是同一个对象

接着就很诡异了,我们让s1 依旧= “abc",但是结果s1和s2指向的地址不同了。

怎么回事呢?这就是String类的特殊之处了,new出来的String不再是上面的字符串常量,而是字符串对象。

由于String类是不可改变的,所以String对象也是不可改变的,我们每次给String赋值都相当于执行了一次new String(),然后让变量指向这个新对象,而不是在原来的对象上修改。

当然,java还提供了StringBuffer类,这个是可以在原对象上做修改的。如果你需要修改原对象,那么请使用StringBuffer类。

值传递和引用传递的战争

java是值传递还是引用传递的呢?毫无疑问,java是值传递的。那么什么又叫值传递和引用传递呢?

我们先来看一个例子:


这是一个很经典的例子,我们希望调用了swap函数以后,a和b的值可以互换,但是事实上并没有。为什么会这样呢?

这就是因为java是值传递的。也就是说,我们在调用一个需要传递参数的函数时,传递给函数的参数并不是我们传进去的参数本身,而是它的副本。说起来比较拗口,但是其实原理很简单。我们可以这样理解:

一个有形参的函数,当别的函数调用它的时候,必须要传递数据。
比如swap函数,别的函数要调用swap就必须传两个整数过来。

这个时候,有一个函数按耐不住寂寞,扔了两个整数过来,但是,swap函数有洁癖,它不喜欢用别人的东西,于是它把传过来的参数复制了一份,然后对复制的数据修修改改,而别人传过来的参数动根本没动。

所以,当swap函数执行完毕之后,交换了的数据只是swap自己复制的那一份,而原来的数据没变。

也可以理解为别的函数把数据传递给了swap函数的形参,最后改变的只是形参而实参没变,所以不会起到任何效果。

我们再来看一个复杂一点的例子(Person类添加了get,set方法):


可以看到,我们把p1传进去,它并没有被替换成新的对象。因为change函数操作的不是p1这个引用本身,而是这个引用的一个副本。

你依然可以理解为,主函数将p1复制了一份然后变成了chagne函数的形参,最终指向新Person对象的是那个副本引用,而实参p1并没有改变。

再来看一个例子:


这次为什么就改变了呢?分析一下。

首先,new了一个Person对象,暂且叫他小明吧。然后p1指向小明。

小明10岁了,随着时间的推移,小明的年龄要变了,调用了一下changgeAge方法,把小明的引用传了进去。

传递的过程中,changgeAge也有洁癖,于是复制了一份小明的引用,这个副本也指向小明。

然后changgeAge通过自己的副本引用,改变了小明的年龄。

由于是小明这个对象被改变了,所以所有小明的引用调用方法得到的年龄都会改变

所以就变了。

最后简单的总结一下。

java的传值过程,其实传的是副本,不管是变量还是引用。所以,不要期待把变量传递给一个函数来改变变量本身。

对象的强引用,软引用,弱引用和虚引用

Java中是JVM负责内存的分配和回收,这样虽然使用方便,程序不用再像使用c那样操心内存,但同时也是它的缺点(不够灵活)。为了解决内存操作不灵活这个问题,可以采用软引用等方法。

先介绍一下这四种引用:

  • 强引用

    以前我们使用的大部分引用实际上都是强引用,这是使用最普遍的引用。如果一个对象具有强引用,那就类似于必不可少的生活用品,垃圾回收器绝不会回收它。当内存空 间不足,Java虚拟机宁愿抛出OutOfMemoryError错误,使程序异常终止,也不会靠随意回收具有强引用的对象来解决内存不足问题。

  • 软引用(SoftReference)

    如果一个对象只具有软引用,那就类似于可有可物的生活用品。如果内存空间足够,垃圾回收器就不会回收它,如果内存空间不足了,就会回收这些对象的内存。只要垃圾回收器没有回收它,该对象就可以被程序使用。软引用可用来实现内存敏感的高速缓存。

    软引用可以和一个引用队列(ReferenceQueue)联合使用,如果软引用所引用的对象被垃圾回收,JAVA虚拟机就会把这个软引用加入到与之关联的引用队列中。

  • 弱引用(WeakReference)

    如果一个对象只具有弱引用,那就类似于可有可物的生活用品。弱引用与软引用的区别在于:只具有弱引用的对象拥有更短暂的生命周期。在垃圾回收器线程扫描它 所管辖的内存区域的过程中,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。不过,由于垃圾回收器是一个优先级很低的线程, 因此不一定会很快发现那些只具有弱引用的对象。

    弱引用可以和一个引用队列(ReferenceQueue)联合使用,如果弱引用所引用的对象被垃圾回收,Java虚拟机就会把这个弱引用加入到与之关联的引用队列中。

  • 虚引用(PhantomReference)

    "虚引用"顾名思义,就是形同虚设,与其他几种引用都不同,虚引用并不会决定对象的生命周期。如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收。

    虚引用主要用来跟踪对象被垃圾回收的活动。虚引用与软引用和弱引用的一个区别在于:虚引用必须和引用队列(ReferenceQueue)联合使用。当垃 圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用加入到与之关联的引用队列中。程序可以通过判断引用队列中是 否已经加入了虚引用,来了解被引用的对象是否将要被垃圾回收。程序如果发现某个虚引用已经被加入到引用队列,那么就可以在所引用的对象的内存被回收之前采取必要的行动。

在实际开发中,弱引用和虚引用不常用,用得比较多的是软引用,因为它可以加速jvm的回收。

软引用的使用方式:


关于软引用,我之后会单独写一篇文章,所以这里先一笔带过。

对象的复制

java除了用new来创建对象,还可以通过clone来复制对象。

那么这两种方式有什么相同和不同呢?

  • new

new操作符的本意是分配内存。程序执行到new操作符时,首先去看new操作符后面的类型,因为知道了类型,才能知道要分配多大的内存空间。分配完内存之后,再调用构造函数,填充对象的各个域,这一步叫做对象的初始化,构造方法返回后,一个对象创建完毕,可以把他的引用(地址)发布到外部,在外部就可以使用这个引用操纵这个对象。

  • clone

    clone在第一步是和new相似的, 都是分配内存,调用clone方法时,分配的内存和源对象(即调用clone方法的对象)相同,然后再使用原对象中对应的各个域,填充新对象的域, 填充完成之后,clone方法返回,一个新的相同的对象被创建,同样可以把这个新对象的引用发布到外部。

如何利用clone的方式来得到一个对象呢?

看代码:


对Person类做了一些修改

看实现代码:


这样就得到了一个和原来一样的新对象。

深复制和浅复制

但是,细心并且善于思考的人可能一经发现了一个问题。

age是一个基本数据类型,支架clone没什么问题,但是name可是一个String类型的啊。我们clone后的对象里的name和原来对象的name是不是指向同一个字符串常量呢?

做个试验:


果然,是同一个对象。如果你不能理解,那么看这个图。


其实如果只是String还好,因为String的不可变性,当你随便修改一个值的时候,他们就会指向不同的地址了,但是除了String,其他都是可变的。这就危险了。

上面的这种情况,就是浅克隆。这种方式在你的属性列表中有其他对象的引用的时候其实是很危险的。所以,我们需要深克隆。也就是说我们需要将这个对象里的对象也clone一份。怎么做呢?

在内存中通过字节流的拷贝是比较容易实现的。把母对象写入到一个字节流中,再从字节流中将其读出来,这样就可以创建一个新的对象了,并且该新对象与母对象之间并不存在引用共享的问题,真正实现对象的深拷贝。

//使用该工具类的对象必须要实现 Serializable 接口,否则是没有办法实现克隆的。
public class CloneUtils {

    public static <T extends Serializable> T clone(T   obj){
        T cloneObj = null;
        try {
            //写入字节流
            ByteArrayOutputStream out = new ByteArrayOutputStream();
            ObjectOutputStream obs = new   ObjectOutputStream(out);
            obs.writeObject(obj);
            obs.close();

            //分配内存,写入原始对象,生成新对象
            ByteArrayInputStream ios = new  ByteArrayInputStream(out.toByteArray());
            ObjectInputStream ois = new ObjectInputStream(ios);
            //返回生成的新对象
            cloneObj = (T) ois.readObject();
            ois.close();
        } catch (Exception e) {
            e.printStackTrace();
        }
        return cloneObj;
    }
}

使用该工具类的对象只要实现 Serializable 接口就可实现对象的克隆,无须继承 Cloneable 接口实现 clone() 方法。

测试一下:


很完美

这个时候,Person类实现了Serializable接口

是否使用复制,深复制还是浅复制看情况来使用。

关于序列化与反序列化以后会讲。


这篇文章到这里就暂时告一段落了,后续有补充的话我会继续补充,有错误的话,我也会及时改正。欢迎大家提出问题。

示例代码放在github:github.com/CleverFan/J…