精进Java——面向对象

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/tryandfight/article/details/82820734

这篇文章码了好几天,也是我准备开的一个新系列,是十月的目标,既然名字是《精进Java》,那么我也会也对各个点都进行深入了解, 当然有些比较基础的我就直接跳过了。到时我会搭配自己总结的面试题一起阅读(毕竟一篇文章不能太长了哈哈,面试题是否开放到时再看吧)。如果这里有没有总结到的知识点,希望各位读者能给我提提意见,我会加上我自己的理解和总结,后期还会根据变化不断补充内容。码字不易,都是一个字一个字敲出来的,希望各位大佬们觉得不错的话可以多关注我的文章,你们的每一个评论和收藏都是我的动力!!


面向对象和面向过程的区别

面向过程适合简单、不需协作的事务,按步骤实现,比如如何开车?

面向对象更趋向于的是如何设计,而不是按步骤进行,比如如何造车?

这两种都是解决问题的思维方式,都是代码组织的方式。

解决复杂的问题,宏观上使用面向对象把握,微观处理上仍然是面向过程。

面向对象三大特性

封装、继承、多态。后面会拆开讲解。

内存分析

Java虚拟机的内存可以分为三个区域:栈stack、堆heap、方法区method area

栈的特点

  1. 栈描述的是方法执行的内存模型,每个方法被调用都会创建一个栈帧(存储局部变量、操作数、方法入口),每个方法执行完就干掉这个栈帧
  2. JVM为每个线程创建一个栈,用于存放该线程执行方法的信息(实际参数、局部变量等),当这个线程的方法执行完,那么就把这整个栈干掉
  3. 栈属于线程私有,不能实现线程间的共享
  4. 栈的存储特点是“先进后出,后进先出”
  5. 栈是由系统自动分配的,速度快,栈是一个连续的内存空间

堆的特点

  1. 堆用于存储创建好的对象和数组(数组也是对象)
  2. JVM只有一个堆,被所有线程共享
  3. 堆是一个不连续的内存空间,分配灵活,速度慢

方法区(静态区)特点

  1. JVM只有一个方法区,被所有线程共享
  2. 方法区实际也是堆,只是用于存储类、常量相关的信息
  3. 用来存储程序中永远不变或唯一的内容(类信息、class对象、静态变量、字符串常量等等)
public class Student{
    int id;         //学号
    String name;    //姓名
    int age;        //年龄
    School school;  //所在学校
    
    void study(){
        System.out.println("好好学习天天向上");
    }
    
    void sleep(){
        System.out.println("好好休息");
    }
}

public static void main(String[] args){
    Student student = new Student();
    student.id = 1111;
    student.name = "tihom";
    student.age = 18;
    School school = new School;
    school.name = "GDUT";
    student.school = school;
}

class School{
    String name;
}

这里反映了内存间的关系

垃圾回收机制

拿C++与Java对比,C++好比没有服务员的饭店,每桌吃完之后没人收拾垃圾,那么随着店内干净的桌子不断减少,最后没有干净的桌子可以使用了;而Java则自带一个GC服务员,会在每桌吃完之后主动收拾垃圾。

垃圾回收算法一般要做的两件事

  • 发现无用的对象
  • 回收无用对象占用的内存空间

所使用的算法

  • 引用计数算法

    堆中每个对象都有一个引用计数。被引用一次计数+1,被引用变量变为null,则计数-1,直到计数为0,则表示变成无用对象,算法简单,但是”循环引用的无用对象“无法被识别

  • 引用可达算法(根搜索算法)

    程序把所有的引用关系看作一张图,从一个节点GC ROOT开始,寻找对应的引用节点,找到这个节点之后,继续寻找这个节点的引用节点,当所有的引用节点寻找完毕之后,剩余的节点则认为是没有被引用到的节点,即无用的节点

分代垃圾回收机制

不同对象有不同的生命周期。因此,不同生命周期的对象可以采取不同的回收算法,以便提高回收效率。我们将对象分为三种状态:年轻代、年老代、持久代。JVM将堆内存划分为 Eden、Survivor 和 Tenured/Old 空间。

  1. 年轻代

所有新生成的对象首先都是放在Eden区。 年轻代的目标就是尽可能快速的收集掉那些生命周期短的对象,对应的是Minor GC,每次 Minor GC 会清理年轻代的内存,算法采用效率较高的复制算法,频繁的操作,但是会浪费内存空间。当“年轻代”区域存放满对象后,就将对象存放到年老代区域。

  1. 年老代

在年轻代中经历了N(默认15)次垃圾回收后仍然存活的对象,就会被放到年老代中。因此,可以认为年老代中存放的都是一些生命周期较长的对象。年老代对象越来越多,我们就需要启动Major GC和Full GC(全量回收),来一次大扫除,全面清理年轻代区域和年老代区域。

  1. 持久代

用于存放静态文件,如Java类、方法等。持久代对垃圾回收没有显著影响。

1.png

  • Minor GC

    用于清理年轻代区域。Eden区满了就会触发一次Minor GC。清理无用对象,将有用对象复制 到“Survivor1”、“Survivor2”区中(这两个区,大小空间也相同,同一时刻Survivor1和Survivor2只有一个在用,一个为空)

  • Major GC

用于清理年老代区域。

  • Full GC

用于清理年轻代、年老代区域。 成本较高,会对系统性能产生影响。

垃圾回收过程

​ 1、新创建的对象,绝大多数都会存储在Eden中,

​ 2、当Eden满了(达到一定比例)不能创建新对象,则触发垃圾回收(GC),将无用对象清理掉,

​ 然后剩余对象复制到某个Survivor中,如S1,同时清空Eden区

​ 3、当Eden区再次满了,会将S1中的不能清空的对象存到另外一个Survivor中,如S2,

​ 同时将Eden区中的不能清空的对象,也复制到S1中,保证Eden和S1,均被清空。

​ 4、重复多次(默认15次)Survivor中没有被清理的对象,则会复制到老年代Old(Tenured)区中,

​ 5、当Old区满了,则会触发一个一次完整地垃圾回收(FullGC),之前新生代的垃圾回收称为(minorGC)

JVM调优和Full GC

在对JVM调优的过程中,很大一部分都是对Full GC的调优

有如下原因可能导致Full GC

  1. 年老代(Tenured)被写满

  2. 持久代(Perm)被写满

  3. System.gc()被显式调用(程序建议GC启动,不是调用GC)

  4. 上一次GC之后Heap的各域分配策略动态变化

内存泄漏

为什么会发生内存泄漏?

A引用了B,A的生命周期为t1-t4,B的生命周期为t2-t3,当B不使用时,A仍然保持着对B的引用,垃圾回收机制无法对B进行清理,导致B一直在内存中存在,如果很多个这种存在的话,那么内存会消耗很大的空间。

也有情况是B引用着很多个其他的对象,那些对象无法被销毁,导致A引用B,B引用了其他对象都无法被回收。

  • 创建大量的无用对象

    比如,使用字符串拼接时,使用了String而不是StringBuilder

  • 静态集合类的使用

    像HashMap、Vector、List等的使用最容易出现内存泄露,这些静态变量的生命周期和应用程序一致,所有的对象Object也不能被释放

  • 各种连接对象未关闭(IO流对象、数据库连接对象、网络连接对象)

    这些连接对象都是物理对象,和硬盘或者网络连接,不使用时一定要关闭

  • 监听器的使用

    释放对象时,未删除对应的监听器

注意

  • 可以调用System.gc(),但是该方法只是通知JVM,并不是运行垃圾回收器。尽量少用,会申请启动Full GC,成本高,影响系统性能
  • finalize方法,是Java提供给程序员用来释放对象或资源的方法,但是尽量少用

this和this()

this指的就是当前对象,this()指的就是当前对象的构造方法

public class Main{
	int a,b;
    Main(int a,int b){
        this.a = a;
        this.b = b;
    }
    
    Main(int a,int b,int c){
        this(a,b);
        c = a+b;
    }
}

static

static修饰的方法是属于类的,而普通的方法是属于实例(对象)的,所以static修饰的方法不能直接调用普通的方法,因为普通的方法需要对象去调用

核心就是

static修饰的成员变量和方法,从属于类。

普通变量和方法从属于对象。

普通的方法可以调用static修饰的方法和变量,但是static修饰的并不能直接调用普通方法和变量。

静态初始化块

构造方法用于对象的初始化;静态初始化块,用于类的初始化操作;所以先执行的是初始化块,没有类也就没有了对象。在静态初始化块中不能直接访问非static成员。

注意

静态初始化块执行顺序

  1. 上溯到Object类,先执行Object的静态初始化块,再向下执行子类的静态初始化块,直到我们的类的静态初始化块为止。

  2. 构造方法执行顺序和上面顺序一样

参数传值机制

Java内方法的参数都是使用值传递,而值传递本身又传递的是值的副本,所以我们得到的都是复印件而非原件,所以复印件的改变并不影响原件。

基本数据类型的传值

传递的是值的副本,副本改变不会影响原件。

引用类型参数的传值

传递的是值的副本,但是引用类型指的是“对象的地址”。因此,副本和原参数都指向了同一个“地址”,改变“副本指向地址对象的值,也意味着原参数指向对象的值也发生了改变”。

比如

 public class User {
    int id;        //id
    String name;   //账户名
    String pwd;   //密码
       
    public User(int id, String name) {
        this.id = id;
        this.name = name;
    }
      
    public void testParameterTransfer1(User u){
        u.name="tihom1";
    }
     
    public void testParameterTransfer2(User u){
        u = new User(200,"tihom2");
    }
      
    public static void main(String[] args) {
    	//创建一个User对象,在堆内存中产生这个对象的内存空间,里面存的值是100、tihom3,然后u1在栈中引用这个对象,指向的地址假设是123
        User u1 = new User(100, "tihom3");
        //这里的参数u1是副本,进入方法中,u指向的地址也是123,所以将name的值更改了 
        u1.testParameterTransfer1(u1);
        System.out.println(u1.name); //tihom1
 
 		//这里传入的u1是副本,但是在方法内重新创建了新的内存空间(地址为124),所以u指向的地址为124并非123
        u1.testParameterTransfer2(u1);
        //但是这里仍然用的是u1的引用,而u1的值在testParameterTransfer2方法中并未被改变,所以结果依然是tihom1
        System.out.println(u1.name);
    }
}

import

静态导入:import static是用来直接引入类中的静态属性的

继承

继承的概念应该都很了解了,注意的就是Java中类只有单继承,接口有多继承,且类如果没extends如何类的话,那么默认继承的是Object类,Object类是所有类的根基类

重写

需要注意的几个点

  • 方法名、形参列表相同

  • 返回值类型和声明异常类型,子类小于等于父类

  • 访问权限,子类大于等于父类

instanceof

instanceof是二元运算符,左边是对象,右边是类,当对象是右边类或子类所创建的对象时,返回true,反之,false

使用a instanceof b

toString方法

Object类中的toString方法源码如下

public String toString() {
    return getClass().getName() + "@" + Integer.toHexString(hashCode());
}

表示会输出类名+@+16进制的hashCode,在print或者字符串连接时会调用该方法。

所以,一般在构造对象时,我们会重写toString方法,在打印时能更清晰的显示对象信息。

”==“、equals方法、hashCode()

对于字符串常量来说,使用"=="和equals方法比较字符串时

  • “==”比较两个变量本身的值,即两个对象在内存中的地址

  • equals方法比较的是字符串中所包含的内容是否相同

对于非字符串变量来说,使用“==”和equals方法的作用是相同的

  • 都是用来比较其对象在堆内存的首地址,即用来比较两个引用变量是否指向同一个对象

Object中有equals方法,比较的是对象内容是否相等,比如通过身份证号码、学号等等确定是否是同一个人。

Object中的equals方法默认比较的是两个对象的hashCode,是同一个对象的引用时返回true,否则返回false,同时,我们可以根据自己的不同需求重写equals方法

复写equals时需要注意的准则

  • 自反性(reflexive)。对于任意不为null的引用值x,x.equals(x)一定是true
  • 对称性(symmetric)。对于任意不为null的引用值xy,当且仅当x.equals(y)true时,y.equals(x)也是true
  • 传递性(transitive)。对于任意不为null的引用值xyz,如果x.equals(y)true,同时y.equals(z)true,那么x.equals(z)一定是true
  • 一致性(consistent)。对于任意不为null的引用值xy,如果用于equals比较的对象信息没有被修改的话,多次调用时x.equals(y)要么一致地返回true要么一致地返回false
  • 对于任意不为null的引用值xx.equals(null)返回false

hashCode()方法

在Object类中,hashCode方法的源码如下

public native int hashCode()

说明这是一个本地方法,它的实现是根据本地机器相关的。当然我们可以在自己的类中复写hashCode方法,比如String、Integer、Double等这些类都是复写了hashCode方法。下面是String中的实现

public int hashCode() {  
    int h = hash;  
    if (h == 0) {  
        int off = offset;  
        char val[] = value;  
        int len = count;  
  
        for (int i = 0; i < len; i++) {  
            h = 31 * h + val[off++];  
        }  
         hash = h;  
    }  
    return h;  
}

hashCode的作用

hashCode与Java集合的联系比较明显,Java集合有两类,List和Set,List集合中的元素是有序的,而Set中的元素是无序的,那么是怎么做到元素不重复的呢,判断的方法是什么?

使用的是Object中的equals方法,每增加一个元素就进行一次比较,但是如果数据量大起来之后,没增加一个都进行比较的话,效率会很慢,比如现在有2000个元素,那么增加一个元素就要equals比较2000次,任何人都不会设计出这种不科学的方法,所以Java采用了哈希表的原理。

使用哈希算法(散列算法),主要通过求模、异或、移位来实现,有兴趣的可以去更深入的了解一下。根据算法找到特定的地址,如果地址位置没有元素,那么直接存储在这个地址上,如果这个地址位置已经有元素,那么就需要调用equals方法,若相同就不存了(因为这里做到的是不重复),不相同的话就散列到别的地址上。所以这里可能会出现哈希冲突问题

哈希冲突

由于哈希算法被计算的数据是无限的,而计算后的结果范围有限,因此总会存在不同的数据经过计算后得到的值相同,这就是哈希冲突

解决哈希方法的方法

  • 开放定址法(线性探测再散列、平方探测再散列)

    线性探测再散列很好理解,就是算出来的地址上如果已经存在元素了,那么就在表格上往后走,假设0位置上冲突了,并且1、2有元素而3没有,那么就将0处冲突的元素存在3处,以此类推,后面有冲突的也是这样解决。

    平方探测再散列也不难,假设在1的位置上冲突了,那么就计算 1 + 1 2 = 2 1+1^2=2 ,如果2的位置上是空的,那么就存在2上。如果在2上冲突了,那么就计算 2 + 1 2 = 3 2+1^2=3 ,3位置有元素,那么计算 2 1 2 = 1 2-1^2=1 ,如果还是有元素,那么计算 2 + 2 2 = 6 2+2^2=6 ,有人就继续计算 2 2 2 = 2 2-2^2=-2 ,-2就相当于这个有限表的倒数第二个位置,以此类推下去…

  • 链地址法

    将所有哈希地址相同的记录都链接在同一链表中,具体可以看hashMap的源码

  • 再哈希法

    算出来重复了,那么就用另外一个算法去算,直到不重复为止。(猜测)

  • 建立公共溢出区法

    就是不将数据存在那个表中了,放在另外的地方。(猜测)

hashCode()和equals()的联系

根据资料查询和上面讲的这么多东西可以得出来的归纳

  1. 若重写了equals(Object obj)方法,则有必要重写hashCode()方法。

  2. 若两个对象equals(Object obj)返回true,则hashCode()有必要也返回相同的int数。

  3. 若两个对象equals(Object obj)返回false,则hashCode()不一定返回不同的int数,但为不相等的对象生成不同hashCode值可以提高 哈希表的性能

  4. 若两个对象hashCode()返回相同int数,则equals(Object obj)不一定返回true。

  5. 若两个对象hashCode()返回不同int数,则equals(Object obj)一定返回false。

  6. 同一对象在执行期间若已经存储在集合中,则不能修改影响hashCode值的相关信息,否则会导致内存泄露问题。

  7. hashCode是为了提高在散列结构存储中查找的效率,在线性表中没有作用。

这里与Java集合的密切关系我准备到时在精进Java集合的时候进行讲解,敬请关注嘿嘿嘿

继承树的追溯

构造方法第一句总是:super(…)来调用父类对应的构造方法,先追溯到Object类,依次向下执行类的初始化和构造方法,直到当前子类为止。

静态初始化块的调用与构造方法的调用流程一致。

封装

访问控制符

对象属性一般使用的是private访问权限,一些只在本类使用的辅助方法也使用private,set/get类的需要外面调用来赋值与读取操作的还是使用public

多态

多态指的是一个方法的调用,不同对象有不同的处理方案。比如我同样调用“吃饭”这个方法,中国人会用筷子吃饭,美国人会用叉子吃饭,摩洛哥人会用手抓饭。

多态的要点

  1. 多态是方法的多态,不是属性的多态

  2. 多态的存在有三个必要的条件

    • 继承
    • 方法重写
    • 父类引用指向子类对象
  3. 父类引用指向子类对象后,用该父类引用调用子类重写的方法

多态提高了代码的可扩展性,符合开闭原则。但是多态也有弊端,父类无法调用子类特有的方法。不过,如果要使用子类的特有方法,可以使用对象的转型

对象的转型

理解何为向上转型和向下转型只需要通过代码即可清晰了解

public class TestCasting {
    public static void main(String[] args) {
        Object obj = new String("tihom"); // 向上可以自动转型
        // obj.charAt(0) 无法调用。编译器认为obj是Object类型而不是String类型
        // 编写程序时,如果想调用运行时类型的方法,只能进行强制类型转换,不然通不过编译器的检查
        String str = (String) obj; // 向下转型
        System.out.println(str.charAt(0)); // 位于0索引位置的字符
        System.out.println(obj == str); // true.他们俩运行时是同一个对象
    }
}

在进行强制类型转换之前,先用instanceof运算符判断是否可以成功转换,从而避免出现ClassCastException异常

抽象方法和抽象类

何为抽象类?

就是你无法具体的描述出这个类代表的是什么,你需要其他类来说明,比如我们定义一个Animal动物类,我们只知道这是动物,但是具体是什么动物呢,我们需要其他类来说明,假如我在Animal类中定义了talk方法

public abstract class Animal{
    public abstract void talk();
}

public class Dog extends Animal{
	@Override
	public void talk(){
        System.out.println("汪汪汪~");
	}
}

public class Cat extends Animal{
    @Override
    public void talk(){
        System.out.println("喵喵喵~");
    }
}

public static void main(String[] args){
    Animal a1 = new Cat();
    Animal a2 = new Dog();
    //Animal a3 = new Animal();   这样是错误的,无法实例化,只能交给子类
    a1.talk();
    a2.talk();
}

这就是很经典的抽象方法和抽象类,抽象类中定义的抽象方法必须要子类去重写

注意的点

  1. 抽象类不能被实例化,实例化的工作交给子类去完成,抽象类只需要有一个引用即可。

  2. 抽象方法必须由子类来进行重写。

  3. 只要包含一个抽象方法的抽象类,该方法必须要定义成抽象类,不管是否还包含有其他方法。

  4. 抽象类中可以包含具体的方法,也可以不包含抽象方法。

  5. 子类中的抽象方法不能与父类的抽象方法同名。

  6. abstract不能与final并列修饰同一个类和方法。

  7. abstract 不能与private、static、final或native并列修饰同一个方法。

接口

何为接口?

可以说是比抽象类还抽象的抽象类,哈哈哈,是不是突然觉得很绕,这里只是做个描述而已。

准确来说,接口有了更多的约束,JDK7以前接口中的方法必须都是抽象的,并且没有没有具体的实现,而JDK8后接口可以使用default、static方法

default方法

简单的说,就是可以在接口中定义一个已经实现的方法,并且该接口的实现类不需要实现该方法

为什么要有默认方法?

因为在之前的开发中,我们接口一旦添加或删除了一个方法,那么所有实现该接口的类都需要进行修改,如果这个接口被很多类实现了,那么修改起来还是很麻烦的。所以Java8为了更好的扩展性,假如我们要在接口中添加一个方法,那么只需要使用default实现一个默认方法,不用对实现类进行修改,并且实现类都会继承这个default方法。

参考了网上的资料,在Java8的Iterable接口中,我们新增了一个默认方法forEach,因为这是default修饰的默认方法,所以不用修改所有实现了Iterable接口的类

default void forEach(Consumer<? super T> action) {   //入参是函数式接口,支持Lambda表达式
        Objects.requireNonNull(action);
        for (T t : this) {
            action.accept(t);
        }
}

因为Collection接口继承了Iterable接口,所以Collection具有了forEach方法

List<String> list = new ArrayList<String>();
list.add("001");
list.add("002");
list.forEach(System.out::println);

可见,我们在未破坏Iterable接口实现类的前提下,给Iterable接口的所有实现类添加了一个新方法forEach,这在Java 8之前是不可能的。

重写Override方法

如果接口实现的类没有重写接口的默认方法,那么默认继承了接口中的默认实现。

如果接口实现类重写了接口中的默认方法,那么与普通的重写没有区别。

如果子类(接口或抽象类)重写父接口的默认方法是抽象方法,那么子类的子类都需要实现这个方法。

默认方法调用冲突问题

因为一个类是可以实现多个接口的,那么如果多个接口都定义了一样的默认方法,我们实现的时候该如何调用父类的默认方法呢?

  1. 首先,如果子类覆盖了父类的默认方法,那么直接使用子类覆盖后的方法e
  2. 其次,优先选择调用更加具体的默认方法,就是说比如接口1继承了接口2,那么接口实现类调用默认方法时优先调用的是接口2的方法,因为接口2的比接口1的更具体
  3. 如果实现类同时实现了接口1和接口2,且接口1和接口2有同名的默认方法,那么实现类调用时会编译报错,提示定义了重名的接口,快速修复的方法是覆盖其中的一个即可
interface InterfaceA{
    default void test(){
        System.out.println("testA~~");
    }
}

interface InterfaceB extends InterfaceA{
    @Override
    default void test(){
        System.out.println("testB~~");
    }
}

interface InterfaceC extends InterfaceA{
    
}

class TestClass implements InterfaceB,InterfaceC{
    @Override
    public void test(){
        InterfaceB.super.test();
        //InterfaceC.super.test();  这句会报错,报错说明中的意思是接口B中有比C更具体的实现,所以不使用接口C,默认使用接口B
    }
}

还要注意一个问题,如果该类实现接口时,还继承了某个抽象类,该抽象类拥有一个和default签名一样的抽象方法,则在该类中必须重写抽象方法(也是接口中的该default方法)

抽象类、接口存在同样的签名方法,抽象类有实现体但是不是public修饰的

—-> 如果子类没有去实现,那么编译错误:抽象接口中的实现不能隐藏接口中的方法;如果子类实现了方法,编译通过

—->解决办法:将抽象类中的方法访问控制符使用public修饰

没想到一个小小的接口可以引申出这么多问题,Java还真是深似海啊。。。

static静态方法

static修饰的方法,只能使用接口名调用,接口.xxx来调用,所以它不存在调用冲突问题,因为编译器可以区分不同接口的调用

public interface JDK8Interface {
 
    // static修饰符定义静态方法
    static void staticMethod() {
        System.out.println("接口中的静态方法");
    }
 
    // default修饰符定义默认方法
    default void defaultMethod() {
        System.out.println("接口中的默认方法");
    }
}

public class JDK8InterfaceImpl implements JDK8Interface {
    //实现接口后,因为默认方法不是抽象方法,所以可以不重写,但是如果开发需要,也可以重写
}

public class Main {
    public static void main(String[] args) {
        // static方法必须通过接口类调用
        JDK8Interface.staticMethod();
 
        //default方法必须通过实现类的对象调用
        new JDK8InterfaceImpl().defaultMethod();
    }
}

上面代码已经直观的表示了

抽象类和接口的区别

尽管抽象类和接口之间存在较大的相同点,甚至有时候还可以互换,但这样并不能弥补他们之间的差异之处。下面将从语法层次和设计层次两个方面对抽象类和接口进行阐述。

语法层次

在语法层次,java语言对于抽象类和接口分别给出了不同的定义。下面以Demo类来说明他们之间的不同之处。

使用抽象类来实现:

public abstract class Demo {
    abstract void method1();
    void method2(){
        //实现
    }
}

使用接口来实现

interface Demo {
    void method1();
    void method2();
}

抽象类方式中,抽象类可以拥有任意范围的成员数据,同时也可以拥有自己的非抽象方法,但是接口方式中,它仅能够有静态、不能修改的成员数据(但是我们一般是不会在接口中使用成员数据),同时它所有的方法都必须是抽象的。在某种程度上来说,接口是抽象类的特殊化。(java8之后接口中可以有默认实现的方法)

对子类而言,它只能继承一个抽象类(这是java为了数据安全而考虑的),但是却可以实现多个接口。

设计层次

上面只是从语法层次和编程角度来区分它们之间的关系,这些都是低层次的,要真正使用好抽象类和接口,我们就必须要从较高层次来区分了。只有从设计理念的角度才能看出它们的本质所在。一般来说他们存在如下三个不同点:

  1. 抽象层次不同。抽象类是对抽象,而接口是对行为的抽象。抽象类是对整个类整体进行抽象,包括属性、行为,但是接口却是对类局部(行为)进行抽象。

  2. 跨域不同。抽象类所跨域的是具有相似特点的类,而接口却可以跨域不同的类。我们知道抽象类是从子类中发现公共部分,然后泛化成抽象类,子类继承该父类即可,但是接口不同。实现它的子类可以不存在任何关系,共同之处。例如猫、狗可以抽象成一个动物类抽象类,具备叫的方法。鸟、飞机可以实现飞Fly接口,具备飞的行为,这里我们总不能将鸟、飞机共用一个父类吧!所以说抽象类所体现的是一种继承关系,要想使得继承关系合理,父类和派生类之间必须存在**“is-a”**关系,即父类和派生类在概念本质上应该是相同的。对于接口则不然,并不要求接口的实现者和接口定义在概念本质上是一致的, 仅仅是实现了接口定义的契约而已。

  3. 设计层次不同。对于抽象类而言,它是自下而上来设计的,我们要先知道子类才能抽象出父类,而接口则不同,它根本就不需要知道子类的存在,只需要定义一个规则即可,至于什么子类、什么时候怎么实现它一概不知。比如我们只有一个猫类在这里,如果你这是就抽象成一个动物类,是不是设计有点儿过度?我们起码要有两个动物类,猫、狗在这里,我们在抽象他们的共同点形成动物抽象类吧!所以说抽象类往往都是通过重构而来的!但是接口就不同,比如说飞,我们根本就不知道会有什么东西来实现这个飞接口,怎么实现也不得而知,我们要做的就是事前定义好飞的行为接口。所以说抽象类是自底向上抽象而来的,接口是自顶向下设计出来的。

​ (上面纯属个人见解,如有出入、错误之处,望各位指点!!!!)

为了更好的阐述他们之间的区别,下面将使用一个例子来说明。该例子引自:http://blog.csdn.net/ttgjz/article/details/2960451

我们有一个Door的抽象概念,它具备两个行为open()和close(),此时我们可以定义通过抽象类和接口来定义这个抽象概念:

抽象类

abstract class Door{
    abstract void open();
    abstract void close();
}

接口

interface Door{
    void open();
    void close();
}       

至于其他的具体类可以通过使用extends使用抽象类方式定义Door或者Implements使用接口方式定义Door,这里发现两者并没有什么很大的差异。

但是现在如果我们需要门具有报警的功能,那么该如何实现呢?

解决方案一:给Door增加一个报警方法:clarm();

abstract class Door{
    abstract void open();
    abstract void close();
    abstract void alarm();
}

或者

interface Door{
    void open();
    void close();
    void alarm();
}

这种方法违反了面向对象设计中的一个核心原则ISP (Interface Segregation Principle - 接口隔离原理)—见批注,在Door的定义中把Door概念本身固有的行为方法和另外一个概念"报警器"的行为方法混在了一起。这样引起的一个问题是那些仅仅依赖于Door这个概念的模块会因为"报警器"这个概念的改变而改变,反之依然。

解决方案二

既然open()、close()和alarm()属于两个不同的概念,那么我们依据ISP原则将它们分开定义在两个代表两个不同概念的抽象类里面,定义的方式有三种:

  1. 两个都使用抽象类来定义。

  2. 两个都使用接口来定义。

  3. 一个使用抽象类定义,一个是用接口定义。

由于java不支持多继承所以第一种是不可行的。后面两种都是可行的,但是选择何种就反映了你对问题域本质的理解。

如果选择第二种都是接口来定义,那么就反映了两个问题:1、我们可能没有理解清楚问题域,AlarmDoor在概念本质上到底是门还报警器。2、如果我们对问题域的理解没有问题,比如我们在分析时确定了AlarmDoor在本质上概念是一致的,那么我们在设计时就没有正确的反映出我们的设计意图。因为你使用了两个接口来进行定义,他们概念的定义并不能够反映上述含义。

第三种,如果我们对问题域的理解是这样的:AlarmDoor本质上Door,但同时它也拥有报警的行为功能,这个时候我们使用第三种方案恰好可以阐述我们的设计意图。AlarmDoor本质是门,所以对于这个概念我们使用抽象类来定义,同时AlarmDoor具备报警功能,说明它能够完成报警概念中定义的行为功能,所以alarm可以使用接口来进行定义。如下:

abstract class Door{
    abstract void open();
    abstract void close();
}
 
interface Alarm{
    void alarm();
}
 
class AlarmDoor extends Door implements Alarm{
    void open(){}
    void close(){}
    void alarm(){}
}

这种实现方式基本上能够明确的反映出我们对于问题领域的理解,正确的揭示我们的设计意图。其实抽象类表示的是"is-a"关系,接口表示的是"like-a"关系,大家在选择时可以作为一个依据,当然这是建立在对问题领域的理解上的,比如:如果我们认为AlarmDoor在概念本质上是报警器,同时又具有Door的功能,那么上述的定义方式就要反过来了。

批注: ISP(Interface Segregation Principle):面向对象的一个核心原则。它表明使用多个专门的接口比使用单一的总接口要好。 一个类对另外一个类的依赖性应当是建立在最小的接口上的。 一个接口代表一个角色,不应当将不同的角色都交给一个接口。没有关系的接口合并在一起,形成一个臃肿的大接口,这是对角色和接口的污染。

总结

  1. 抽象类在java语言中所表示的是一种继承关系,一个子类只能存在一个父类,但是可以存在多个接口。

  2. 在抽象类中可以拥有自己的成员变量和非抽象类方法,但是接口中只能存在静态的不可变的成员数据(不过一般都不在接口中定义成员数据),而且它的所有方法都是抽象的。

  3. 抽象类和接口所反映的设计理念是不同的,抽象类所代表的是“is-a”的关系,而接口所代表的是“like-a”的关系。

抽象类和接口是Java语言中两种不同的抽象概念,他们的存在对多态提供了非常好的支持,虽然他们之间存在很大的相似性。但是对于他们的选择往往反应了您对问题域的理解。只有对问题域的本质有良好的理解,才能做出正确、合理的设计。

摘自 https://blog.csdn.net/chenssy/article/details/12858267

面向接口编程

面向接口编程实现了**“高内聚、低耦合”**

内聚性又称块内联系。指单个模块的功能强度的度量,即一个模块内部各个元素彼此结合的紧密程度的度量。若一个模块内各元素(语名之间、程序段之间)联系的越紧密,则它的内聚性就越高。
高内聚就是在一个模块内,让每个元素之间都尽可能的紧密相连。也就是充分利用每一个元素的功能,各施所能,以最终实现某个功能。如果某个元素与该模块的关系比较疏松的话,可能该模块的结构还不够完善,或者是该元素是多余的。

最充分的利用模块中每一个元素的功能,达到功能实现最大化,内聚性越强越好,用最小的资源干最大的事情

耦合性也称块间联系。指软件系统结构中各模块间相互联系紧密程度的一种度量。模块之间联系越紧密,其耦合性就越强,模块的独立性则越差。模块间耦合高低取决于模块间接口的复杂性、调用的方式及传递的信息。
项目中的各个模块之间的关联要尽可能的小,耦合性(相互间的联系)越低越好,减小“牵一发而动全身”的可能性

内聚和耦合,需要尽量实现功能的内聚和数据的耦合,在纵向和横向都要实现优化,纵向主要是对各个层的内聚和耦合,横向主要是对一个层上的各个模块和类之间的耦合。

并且面向接口编程也符合多项面向对象的设计原则,单一职责原则告诉我们实现类要职责单一;里氏替换原则告诉我们不要破坏继承体系;依赖倒置原则告诉我们要面向接口编程;接口隔离原则告诉我们在设计接口的时候要精简单一;迪米特法则告诉我们要降低耦合。而开闭原则是总纲,他告诉我们要对扩展开放,对修改关闭。

内部类

内部类我自身使用的也不是很多,很难有自己的看法,所以我还是贴上别人总结的比较好的文章吧

https://blog.csdn.net/chenssy/article/details/13024951

匿名内部类的详解

https://blog.csdn.net/chenssy/article/details/13170015

常量池

全局字符串常量池(String Pool)

存放的内容是在类加载完成后存到String Pool中的,在每个VM中只有一份,存放的是字符串常量的引用值(在堆中生成字符串对象实例)。

class文件常量池(Class Constant Pool)

class常量池是在编译时每个程序都有的,在编译阶段,存放的是常量(文本字符串、final常量等)和符号引用。

运行时常量池(Runtime Constant Pool)

在类加载完之后,将每个class常量池中的符号引用转存到运行时常量池中,也就是说,每个class都有一个运行时常量池,类在解析之后,将符号引用替换成直接引用,与全局常量池的引用池保持一致。

举个例子来反映String使用时的内存分析

String str1 = "tihom";
String str2 = new String("tihomcode");
String str3 = "tihom";
String str4 = str2.intern();  //intern方法是返回这个字符串在常量池中的引用
String str5 = "tihomcode";
System.out.println(str1==str3); //true
System.out.println(str2==str4); //false
System.out.println(str4==str5); //true

首先,堆内存中会创建一个"tihom"的实例,String Pool中存着"tihom"的引用值(也就是tihom),然后str2的时候先在堆内存中创建一个"tihomcode"的实例,而且String Pool中存着"tihomcode"的引用值,同时还有一个实例对象是new出来的"tihomcode"。接着,在str3的时候,会在String Pool中查找是否存在"tihom",发现在String Pool中已经有了"tihom"的引用值,所以str3直接引用的"tihom"的地址,str1和str3都引用的同一个堆内存地址。str4这里调用了intern方法,返回的是str2的引用值,如果String Pool中没有那么就直接加进去,不过这里String Pool中已经存在了,所以返回的是第一个"tihomcode",而不是new的那个实例对象,所以这里str2==str4为false,str4引用的是常量池中"tihomcode",str5引用的也是"tihomcode",所以true。这里讲的还是有点绕,我自己还是能理解的,读者如果不明白的我到时想一个更好的表达方式。

String、StringBuilder、StringBuffer

String StringBuffer StringBuilder
不可变对象,只能赋值一次,不能再更改(源码中final) 线程安全,可变字符序列,默认长度为16的数组 线程不安全,包含字符序列的变长数组,默认长度为16
每次修改String都要new对象,内存消耗大 修改时对自身做操作,适合多变的字符串使用 与StringBuffer
操作少量数据使用 多线程操作大量数据 单线程操作大量数据
  1. 在使用StringBuffer和StringBuilder时,最好指定他们的容量大小。
  2. 避免使用String类的"+"来拼接,性能奇差。
  3. 相同情况下使用 StringBuilder 相比使用 StringBuffer 仅能获得 10%~15% 左右的性能提升,但却要冒多线程不安全的风险。而在现实的模块化编程中,负责某一模块的程序员不一定能清晰地判断该模块是否会放入多线程的环境中运行,因此:除非确定系统的瓶颈是在 StringBuffer 上,并且确定你的模块不会运行在多线程模式下,才可以采用StringBuilder;否则还是用StringBuffer。

通过源码分析

StringBuffer和StringBuilder都继承于AbstractStringBuilder,所以两者的方法基本一样。

public final class StringBuilder extends AbstractStringBuilder implements java.io.Serializable, CharSequence

public final class StringBuffer extends AbstractStringBuilder implements java.io.Serializable, CharSequence

AbstractStringBuilder类

char[] value;

String类

private final char value[];

扩展容量

private int newCapacity(int minCapacity) {
        // overflow-conscious code
        int newCapacity = (value.length << 1) + 2;
        if (newCapacity - minCapacity < 0) {
            newCapacity = minCapacity;
        }
        return (newCapacity <= 0 || MAX_ARRAY_SIZE - newCapacity < 0)
            ? hugeCapacity(minCapacity)
            : newCapacity;
    }

具体方法使用和源码解析现在不进行深入

猜你喜欢

转载自blog.csdn.net/tryandfight/article/details/82820734