类与对象
面向对象是整个 Java 的核心,而类与对象又是支撑起整个 Java 面向对象开发的基本概念单元。下面将通过具体的描述来为读者阐述类与对象的定义及使用。
类与对象的基本概念
在面向对象中类和对象是最基本、最重要的组成单元,那么什么叫类呢?类实际上是表示一个客观世界中某类群体的一些基本特征抽象,属于抽象的概念集合,如汽车、轮船、书描述的都是某一类事物的公共特征。而对象呢?就是表示一个个具体的事物,例如:张三同学、李四账户、王五的汽车,这些都是可以使用的事物,就可以理解为对象,所以对象表示的是一个个独立的个体。
例如,在现实生活中,人就可以表示为一个类,因为人本身属于一种广义的概念,并不是一个具体个体描述。而某一个具体的人,如张三同学,就可以称为对象,可以通过各种信息完整地描述这个具体的人,如这个人的姓名、年龄、性别等信息,这些信息在面向对象的概念中就称为属性,当然人是可以吃饭、睡觉的,那么这些人的行为在类中就称为方法。也就是说如果要使用一个类,就一定会产生对象,每个对象之间是靠各个属性的不同来进行区分的,而每个对象所具备的操作就是类中规定好的方法,类与对象的关系如图所示。
类与对象的另一种解释
关于类与对象,初学者在理解上存在一定难度,在此处笔者再为各位读者做一个简单的比喻。读者应该都很清楚,如果要想生产出汽车,则首先一定要设计出一个汽车的设计图纸,然后按照此图纸规定的结构生产汽车。这样生产出的汽车结构和功能都是一样的,但是每辆车的具体内容,如各个汽车的颜色、是否有天窗等都会存在一些差异。
在这个实例中,汽车设计图纸实际上就规定了汽车应该有的基本组成:外型、内部结构、发动机等信息的定义,这个图纸就可以称为一个类。显然只有图纸是无法使用的,而通过这个模型产生出的一辆辆具体的汽车是可以被用户使用的,所以就可以称其为对象。
通过这个举例相信读者也已经能够总结出二者的区别:类实际上是对象操作的模板但是类不能够直接使用,必须通过实例对象来使用。
类与对象的基本定义
从之前的概念中可以了解到,类是由属性和方法组成的。属性中定义类一个个具体信息,实际上一个属性就是一个变量,而方法是一些操作的行为,但是在程序设计中,定义类也要按照具体的语法要求完成,如果要定义类则需要使用 class
关键字定义,类的定义语法如下。
一些名词的使用
类中的属性在开发中不一定只是变量,也有可能是其他内容,所以一般也会有人将其称为 成员
(Field),在 Java 中使用的就是 Field
”单词来描述的。
类中的方法在 Java 中使用 Method
单词来描述,但是有一些书上也会将其称为 行为
。
class Book { // 定义一个新的类
String title; // 书的名字
double price; // 书的价格
/**
* 输出对象完整信息
*/
public void getInfo() { // 此方法将由对象调用
System.out.println("图书名称:" + title + ",价格:" + price);
}
}
class Book { // 定义一个新的类
String title; // 书的名字
double price; // 书的价格
/**
* 输出对象完整信息
*/
public void getInfo() { // 此方法将由对象调用
System.out.println("图书名称:" + title + ",价格:" + price);
}
}
此时根据给定的语法已经定义了一个 Book
类,在这个类中定义了两个属性:图书名称(title、 String类型)、价格 (price、double类型),以及一个取得图书完整信息的 getInfo()
方法.
类定义完成后,肯定无法直接使用,如果要使用,必须依靠对象,由于类属于 引用数据类型 ,所以对象的产生格式如下。
格式:声明并实例化对象。
类名称 对象名称 = new 类名称();
格式:分布完成
声明对象: 类名称 对象名称 = null;
实例化对象: 对象名称 = new 类名称();
因为类属于 引用数据类型 ,而引用数据类型与基本数据类型最大的不同在于需要内存的开辟及使用,所以关键字 new
的主要功能就是开辟内存空间 ,即只要是引用数据类型想使用,就必须使用关键字 new 来开辟空间。
当一个对象实例化后就可以按照如下方式利用对象来操作类的结构。
- 对象.属性:表示要操作类中的属性内容;
- 对象.方法():表示要调用类中的方法。
class Book { // 定义一个新的类
String title; // 书的名字
double price; // 书的价格
public void getInfo() { // 此方法将由对象调用
System.out.println("图书名称:" + title + ",价格:" + price);
}
}
public class TestDemo {
public static void main(String args[]) {
Book bk = new Book() ; // 声明并实例化对象
bk.title = "Java开发" ; // 操作属性内容
bk.price = 89.9 ; // 操作属性内容
bk.getInfo() ; // 调用类中的getInfo()方法
}
}
class Book { // 定义一个新的类
String title; // 书的名字
double price; // 书的价格
public void getInfo() { // 此方法将由对象调用
System.out.println("图书名称:" + title + ",价格:" + price);
}
}
public class TestDemo {
public static void main(String args[]) {
Book bk = new Book() ; // 声明并实例化对象
bk.title = "Java开发" ; // 操作属性内容
bk.price = 89.9 ; // 操作属性内容
bk.getInfo() ; // 调用类中的getInfo()方法
}
}
本程序在主方法中使用关键字 new
实例化 Book
类的对象 bk
。当类产生实例化对象后就可以利用 对象.属性
如 bk.title="Java开发"”“bk.price=89.9
与 对象.方法()
如 bk.getInfo()
进行类结构的调用。
上述代码实现了一个最基础的类使用操作,但是类本身属于 引用数据类型 ,而对于引用数据类型的执行分析就必须结合内存操作来看。下面给出读者两块内存空间的概念。
- 堆内存(heap):保存每一个对象的属性内容,堆内存需要用关键字
new
才可以开辟,如果一个对象没有对应的堆内存指向,将无法使用; - 栈内存(stack):保存的是一块堆内存的地址数值,可以把它想象成一个
int
型变量(每一个int
型变量只能存放一个数值) ,所以每一块栈内存只能够保留一块堆内存地址。
关于堆内存与栈内存的补充说明
对于以上给出的堆一栈内存关系,可能有许多读者不理解,下面换个角度来说明这两块内存空间的作用。。
- 堆内存:保存对象的真正数据,都是每一个对象的属性内容;
- 栈内存:保存的是一块堆内存的空间地址,但是为了方便理解,可以简单地将栈内存中保存的数据理解为对象的名称(Book bk) ,就假设保存的是
bk
对象名称。
按照这种方式理解,可以得出图所示的内存关系。
如果要想开辟堆内存空间,只能依靠关键字 new
来进行开辟。即:**只要看见了关键字new不管何种情况下,都表示要开辟新的堆内存空间 **。
- 根据以上的概念就可以利用图所示的内存关系图来分析上述的程序。
- 声明并实例化对象:
Book bk=new Book0;
,如图 a 所示,每一个堆内存都会存在一个地址数值,本次假设其地址为OX0001
; - 设置title属性内容:
bk.title="Java开发";
,如图 b 所示; - 设置price属性内容:
bk.price=89.9;
,如图 c 所示。
必须掌握上图所示的分析方法
在 Java 中引用数据类型是与开发联系最为紧密的知识点,包括后续讲解的内容都离不开引用类型。对于许多初学者而言,掌握以上内存分析方法对于程序理解与概念应用是非常重要的。
上图很好地解释了之前范例每一步代码的内存操作,可以发现,使用对象时 一定需要一块对应的堆内存空间 ,而堆内存空间的开辟需要通过关键字 new
来完成。每一个对象在刚刚实例化后,里面所有属性的内容都是其对应数据类型的 **默认值 ** ,只有设置了属性内容之后,属性才可以保存内容。
上述程序使用的是一行语句实现了对象的声明与实例化操作,而这一操作也可以分为两步完成。
public class TestDemo {
public static void main(String args[]) {
Book bk = null; // 声明对象
bk = new Book(); // 实例化对象(开辟了堆内存)
bk.title = "Java开发"; // 操作属性内容
bk.price = 89.9; // 操作属性内容
bk.getInfo(); // 调用类中的getInfo()方法
}
}
public class TestDemo {
public static void main(String args[]) {
Book bk = null; // 声明对象
bk = new Book(); // 实例化对象(开辟了堆内存)
bk.title = "Java开发"; // 操作属性内容
bk.price = 89.9; // 操作属性内容
bk.getInfo(); // 调用类中的getInfo()方法
}
}
本程序首先声明了一个 Book
类的对象 bk
,但是这个时候由于没有为其开辟堆内存空间,所以 bk
对象还无法使用,然后使用关键字new
实例化 bk
对象,最后利用对象为属性赋值已经调用相应的 getInfo()
方法。本程序的内存关系如图所示。
如果使用了没有实例化的对象会如何?
对象使用前都需要进行实例化操作,如果只是声明了对象,但是并没有为其实例化那么会如何呢?
如果现在只是声明对象,却没有使用关键字 new
实例化对象,则代码如下所示。
public class TestDemo {
public static void main(String args[]) {
Book bk = null; // 声明对象
bk.title = "Java开发"; // 操作属性内容
bk.price = 89.9; // 操作属性内容
bk.getInfo(); // 调用类中的getInfo()方法
}
}
public class TestDemo {
public static void main(String args[]) {
Book bk = null; // 声明对象
bk.title = "Java开发"; // 操作属性内容
bk.price = 89.9; // 操作属性内容
bk.getInfo(); // 调用类中的getInfo()方法
}
}
程序执行完毕会出现 NullPointerException
的信息,这属于 Java 的异常信息,而且这种异常造成的原因只有一个,即在使用引用数据类型时没有为其开辟堆内存空间。
引用数据的初步分析
引用传递
是整个 Java 中的精髓所在,而引用传递的核心概念也只有一点:一块堆内存空间(保存对象的属性信息) 可以同时被多个栈内存共同指向,则每一个栈内存都可以修改同一块堆内存空间的属性值。
对于引用传递的另外一种方式的理解
实际上引用传递就好比一个人有多个名字那样,例如:现在有一个人,他的真实姓名叫 “张三” ,而这个人小时候有个乳名叫 “狗剩” ,而他在工作中同事又开玩笑的给他起名叫 “小三” ,虽然名字各不相同,但是却表示同一个人。有一天, “张三” 走路时不小心掉进了下水沟里,结果把腿摔断了,那么 “狗剩” 和 “小三” 的腿也一定摔断了,这是因为不同的名字(栈内存)指向了同一个实体(堆内存)。所以读者只需要按照这一思路就定可以理解下面的程序。
在所有的引用分析里面,最关键的还是关键字 new
。一定要注意的是,每一次使用关键字 new
都一定会开辟新的堆内存空间,所以如果在代码里面声明两个对象,并且使用了关键字 new
为两个对象分别进行对象的 实例化操作 ,那么一定是各自占有各自的堆内存空间,并且不会互相影响。
public class TestDemo {
public static void main(String args[]) {
Book bookA = new Book() ; // 声明并实例化第一个对象
Book bookB = new Book() ; // 声明并实例化第二个对象
bookA.title = "Java开发" ; // 设置第一个对象的属性内容
bookA.price = 89.8 ; // 设置第一个对象的属性内容
bookB.title = "JSP开发" ; // 设置第二个对象的属性内容
bookB.price = 69.8 ; // 设置第二个对象的属性内容
bookA.getInfo() ; // 调用类中的方法输出信息
bookB.getInfo() ; // 调用类中的方法输出信息
}
}
public class TestDemo {
public static void main(String args[]) {
Book bookA = new Book() ; // 声明并实例化第一个对象
Book bookB = new Book() ; // 声明并实例化第二个对象
bookA.title = "Java开发" ; // 设置第一个对象的属性内容
bookA.price = 89.8 ; // 设置第一个对象的属性内容
bookB.title = "JSP开发" ; // 设置第二个对象的属性内容
bookB.price = 69.8 ; // 设置第二个对象的属性内容
bookA.getInfo() ; // 调用类中的方法输出信息
bookB.getInfo() ; // 调用类中的方法输出信息
}
}
本程序首先实例化了两个对象:bookA
、bookB
,然后分别为各自的对象设置属性的内容,由于这两个对象分别使用关键字 new
开辟新的内存空间,所以各自对象操作属性时互不影响。本程序的执行内存关系如图所示。
以上代码声明并实例化了两个对象,由于其各自占着各自的内存空间,所以 不会互相影响 。下面的代码将进行简单的修改,以实现对象引用的关系配置。
public class TestDemo {
public static void main(String args[]) {
Book bookA = new Book() ; // 声明并实例化第一个对象
Book bookB = null ; // 声明第二个对象
bookA.title = "Java开发" ; // 设置第一个对象的属性内容
bookA.price = 89.8 ; // 设置第一个对象的属性内容
bookB = bookA ; // 引用传递
bookB.price = 69.8 ; // 利用第二个对象设置属性内容
bookA.getInfo() ; // 调用类中的方法输出信息
}
}
public class TestDemo {
public static void main(String args[]) {
Book bookA = new Book() ; // 声明并实例化第一个对象
Book bookB = null ; // 声明第二个对象
bookA.title = "Java开发" ; // 设置第一个对象的属性内容
bookA.price = 89.8 ; // 设置第一个对象的属性内容
bookB = bookA ; // 引用传递
bookB.price = 69.8 ; // 利用第二个对象设置属性内容
bookA.getInfo() ; // 调用类中的方法输出信息
}
}
本程序首先实例化了一个 bookA
对象,接着又声明了一个 bookB
对象(此时并没有使用关键字 new 实例化),然后使用 bookA
对象分别设置了 title
与 price
两个属性的内容,最后执行了本程序之中最为关键的一行语句 bookB=bookA
,此时就表示 bookB
的内存将指向 bookA
的内存空间,也表示 bookA
对应的堆内存空间同时被 bookB
所指向,即: 两个不同的栈内存指向了同一块堆内存空间(引用传递),所以当 bookB
修改属性内容时 book B.price=69.8
,会直接影响 bookA
对象的内容。本程序的内存关系如图所示。
严格来讲,bookA
和 bookB
里面保存的是对象的地址信息,所以上述代码的引用过程就属于将 bookA
的地址给 bookB
。在引用的操作过程中,一块堆内存可以同时被多个栈内存所指向,但是反过来,一块栈内存只能够保存一块堆内存空间的地址。
public class TestDemo {
public static void main(String args[]) {
Book bookA = new Book() ; // 声明并实例化第一个对象
Book bookB = new Book() ; // 声明并实例化第二个对象
bookA.title = "Java开发" ; // 设置第一个对象的属性内容
bookA.price = 89.8 ; // 设置第一个对象的属性内容
bookB.title = "JSP开发" ; // 设置第二个对象的属性内容
bookB.price = 69.8 ; // 设置第二个对象的属性内容
bookB = bookA ; // 引用传递
bookB.price = 100.1 ; // 利用第二个对象设置属性内容
bookA.getInfo() ; // 调用类中的方法输出信息
}
}
public class TestDemo {
public static void main(String args[]) {
Book bookA = new Book() ; // 声明并实例化第一个对象
Book bookB = new Book() ; // 声明并实例化第二个对象
bookA.title = "Java开发" ; // 设置第一个对象的属性内容
bookA.price = 89.8 ; // 设置第一个对象的属性内容
bookB.title = "JSP开发" ; // 设置第二个对象的属性内容
bookB.price = 69.8 ; // 设置第二个对象的属性内容
bookB = bookA ; // 引用传递
bookB.price = 100.1 ; // 利用第二个对象设置属性内容
bookA.getInfo() ; // 调用类中的方法输出信息
}
}
本程序首先分别实例化了 bookA
与 bookB
两个不同的对象,由于其保存在不同的内存空间,所以设置属性时不会互相影响。然后发生了引用传递 bookB=bookA
,由于 bookB
对象原本存在有指向的堆内存空间,并且一块栈内存只能够保存一块堆内存空间的地址,所以bookB
要先断开已有的堆内存空间,再去指向 bookA
对应的堆内存空间,这个时候由于原本的 bookB
内存没有任何指向,bookB
将成为垃圾空间。最后由于 bookB
对象修改了price属性的内容。程序的内存关系如图所示。
通过内存分析可以发现,在引用数据类型关系时,一块没有任何栈内存指向的堆内存空间将成为垃圾,所有的垃圾会不定期地被 **垃圾收集器 ** (Garbage Collector) 回收,回收后会被释放掉其所占用的空间。虽然 Java 支持自动的垃圾收集处理,但是在代码的开发过程中应该尽量减少垃圾空间的产生。
关于 GC 处理的深入分析
GC 在 Java 中的核心功能就是对内存中的对象进行内存的分配与回收,所以对于 GC 的理解不要局限于只是进行垃圾收集,还应该知道GC 决定了内存的分配。最常见的情况就是当开发者创建一个对象后,GC 就会监视这个对象的地址、大小和状态。对象的引用会保存在 栈内存
(Stack)中,而对象的具体内容会保存在 堆内存
(Heap)中。当 GC 检测到一个堆中的某个对象不再被栈所引用时,就会不定期的对这个堆内存中保存的对象进行回收。有了GC的帮助,开发者不用再考虑内存回收的事情,GC 也可以最大限度地帮助开发者防止内存泄露。
在 Java 中针对垃圾收集也提供了多种不同的处理分类。
(1)引用计数: 一个实例化对象,如果有程序使用了这个引用对象,引用计数加 1 ,当一个对象使用完毕,引用计数减 1,当引用计数为 0 时,则可以回收。
(2)跟踪收集: 从 root set (包括当前正在执行的线程、全局或者静态变量、 JVM Handles、JNDI Handles) 开始扫描有引用的对象,如果某个对象不可到达,则说明这个对象已经 死亡
(dead),则 GC 可以对其进行回收。也就是说:如果 A 对象引用了 B 对象的内存,那么虚拟机会记住这个引用路径,而如果一个对象没有在路径图中,则就会被回收。
(3)基于对象跟踪的分代增量收集: 所有的对象回收要根据堆内存的结构划分来进行收集,具体如下。
①基于对象跟踪:是由跟踪收集发展而来的,分代是指对堆进行了合理的划分, JVM将整个堆分为以下三代。
A.YoungGen (新生代,使用Minor GC回收):YoungGen
区里面的对象的生命周期比较短,GC 对这些对象进行回收的时候采用复制拷贝算法。
|-young:又分为 eden
、survivor1
(from space)、survivor2
(to sapce)。 eden
是在每个对象创建的时候才会分配的空间,当eden
无法分配时,则会自动触发一次 Minor GC
。当 GC 每次执行时都会将 eden
空间中存活的对象和 survivor1
中的对象拷贝到 survivor2
中,此时 eden
和 survivor1
的空间内容将被清空。当 GC 执行下次回收时将 eden
和 survivor2
中的对象拷贝到 surivor1
中,同时会清空 eden
和 survivor2
空间。按照此类的顺序依次执行,经过数次回收将依然存活的对象复制到 OldGen (年老代)区。
B.OldGen (年老代,使用Major GC回收):当对象从 YoungGen
保存到 OldGen
后,会检测 OldGen
的剩余空间是否大于要晋升对象的大小,此时会有以下两种处理形式。
|-如果小于要保存的对象,则直接进行一次 Full GC
(对整个堆进行扫描和回收,但是Major GC除外),这样就可以让 OldGen
腾出更多的空间。然后执行 Minor GC
,把 YoungGen
空间的对象复制到 OldGen
空间。
|-如果大于要保存的对象,则会根据条件(HandlePromotionFailure 配置:是否允许担保分配内存失败,即整个 OldGen
空间不足,而YoungGen
空间中 Eden
和 Survivor
对象都存活的极端情况。) 进行 Minor GC
和 Full GC
回收。
C.PermGen (持久区):要存放加载进来的类信息,包括方法、属性、对象池等,满了之后可能会引起 Out Of Memory
错误。
MetaSpace
(元空间):持久化的替换者,直接使用主机内存进行存储。
②增量收集:不是每一次都全部收集,而是累积的增量收集。