# 一.面向对象

# 1.什么是面向对象

面向过程——步骤化

  • 面向过程就是分析出实现需求所需要的步骤,通过函数(方法)一步一步实现这些步骤,接着依次调用即可

面向对象——行为化(概念相对抽象,可结合下面的例子理解)

  • 面向对象是把整个需求按照特点、功能划分,将这些存在共性的部分封装成类(类实例化后才是对象),创建了对象不是为了完成某一个步骤,而是描述某个事物在解决问题的步骤中的行为

# 2.举例说明

例如我们设计一个桌球游戏(略过开球,只考虑中间过程)

A:面向过程方式思考:

把下述的步骤通过函数一步一步实现,这个需求就完成了。(只为演示概念,不细究逻辑问题)。

① palyer1 击球 —— ② 实现画面击球效果 —— ③ 判断是否进球及有效 —— ④ palyer2 击球

⑤ 实现画面击球效果 —— ⑥ 判断是否进球及有效 —— ⑦ 返回步骤 1—— ⑧ 输出游戏结果

B:面向对象方式思考:

经过观察我们可以看到,其实在上面的流程中存在很多共性的地方,所以我们将这些共性部分全集中起来,做成一个通用的结构

  1. 玩家系统:包括 palyer1 和 palyer2

  2. 击球效果系统:负责展示给用户游戏时的画面

  3. 规则系统:判断是否犯规,输赢等

image-20240117174042298

我们将繁琐的步骤,通过行为、功能,模块化,这就是面向对象,我们甚至可以利用该程序,分别快速实现 8 球和斯诺克的不同游戏(只需要修改规则、地图和球色即可,玩家系统,击球效果系统都是一致的)

# 3.面向对象的优缺点

A:面向过程

优点:性能上它是优于面向对象的,因为类在调用的时候需要实例化,开销过大。

缺点:不易维护、复用、扩展

用途:单片机、嵌入式开发、Linux/Unix 等对性能要求较高的地方

B:面向对象

优点:易维护、易复用、易扩展,由于面向对象有封装继承多态性的特性,可以设计出低耦合的系统,使系统更加灵活、更加易于维护

缺点:一般来说性能比面向过程低

低耦合:简单的理解就是说,模块与模块之间尽可能的独立,两者之间的关系尽可能简单,尽量使其独立的完成成一些子功能,这避免了牵一发而动全身的问题。这一部分我们会在面向对象学习结束后进行系统的整理和总结。

总结:只通过教科书后的例题是无法体会到面向过程所存在的问题的,在一些小例程中,面向过程感觉反而会更加的简单,但是一旦面临较大的项目,我们需要编写 N 个功能相似的函数,函数越来越多,代码量越来越多,你就知道这是一场噩梦了。

说明:关于性能的问题,这里只是在笼统意义上来说,具体性能优劣,需要结合具体程序,环境等进行比对

# 4.成员变量和成员方法

:一组相关的属性和行为的集合,是一个抽象的概念。

对象:该类事物的具体表现形式,具体存在的个体。

成员变量:事物的属性

成员方法:事物的行为

上面我们说了这几个概念,那么到底应该怎么理解呢?

类就是对一些具有共性特征,并且行为相似的个体的描述。

比如小李和老张都有姓名、年龄、身高、体重等一些属性,并且两人都能够进行聊天、运动等相似的行为

由于这两个人具有这些共性的地方,所以我们把它抽象出来,定义为一个——人类,而小李、老王正是这个类中的个体(对象),而每一个个体才是真正具体的存在,光提到人类,你只知道应该有哪些属性行为,但你不知道他具体的一些属性值,比如你知道他属于 “人类” 所以他应该拥有姓名,年龄等属性,但你并不知道他具体叫什么,年龄多大了。而小李和老王这两个具体的对象,却能够实实在在的知道老王今年 30 岁了、身高 175 等值。

所以可以得出结果:类是对象的抽象,而对象是类的具体实例。类是抽象的,不占用内存,而真正根据类实例化出具体的对象,就需要占用内存空间了。

# 5.成员变量和局部变量?

A:在类中的位置不同

  • 成员变量:类中方法外

  • 局部变量:代码块,方法定义中或者方法声明上(方法参数)

B:在内存中的位置不同

  • 成员变量:在堆中

  • 局部变量:在栈中

C:生命周期不同

  • 成员变量:随着对象的创建而存在,随着对象的消失而消失

  • 局部变量:随着方法的调用而存在,随着方法的调用完毕而消失

D:初始化值不同

  • 成员变量:有默认值(构造方法对它的值进行初始化)

  • 局部变量:没有默认值,必须定义,赋值,然后才能使用

# 6.为什么局部变量存在于栈中而不是堆中

有一个问题,在我们学习 Java 中内存分配的时候,有这样一句话,“堆内存用来存放 new 创建的对象和数组”。 换句话说对象存在于堆中,而成员变量又存在于类中,而且对象是类具体的个体,所以成员变量也存在于堆中,那么问题就来了,同理,是不是方法也和成员变量一样存在于对象中,而局部变量又定义在方法中,岂不就是说,局部变量也存在于堆中呢?这明显与我们上面的定义有区别

解释:一个类可以创建 n 个不同的对象,当我们 new 一个对象后,这个对象实体,已经在堆上分配了内存空间,由于类的成员变量在不同的对象中各不相同(例如,小李和老王的姓名不同),都需要自己各自的存储空间,所以类的成员变量会随着对象存储在堆中,而由于类的方法是所有对象通用的,所以创建对象时,方法还未出现,只有声明,方法里面的局部变量也并没有被创建,只有等到对象使用方法的时候才会被压入栈。

补充:类变量(静态变量)存在于方法区,引用类型的局部变量声明在栈,存储在堆

# 7.访问权限修饰符

public、private、protected, 以及不写(默认)时的区别

访问权限 子类 其他包
public
protect
default
private
  • public:公共的,可以被项目中所有的类访问。
  • protected:受保护的,可以被这个类本身访问;被同一个包中的类访问;被它的子类(同一个包以及不同包中的子类)访问。
  • default:默认的,可以被这个类本身访问;被同一个包中的类访问。
  • private:私有的,只能被这个类本身访问。

# 8.类在初始化过程

public class Student {
    private String name = "BWH_Steven";
    private Integer age = 22;

    // 是个无参构造,为了演示初始化顺序,特意加了两个赋值语句
    public Student (){
        name = "阿文";
        age = 30;
    }
}

public class Test {
    public static void main(String[] args) {
        Student stu = new Student();
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

例如: Student stu = new Student(); 其在内存中做了如下的事情:

首先加载 Student.class (编译成字节码文件)文件进内存,在栈内存为 stu 变量开辟一块空间,在堆内存为 Student 类实例化出的学生对象开辟空间,对学生对象的成员变量进行默认初始化(例如 name = null,age = 0 ),对学生对象的成员变量进行显示初始化( 例如 name = "BWH_Steven",age = 22),接着就会通过构造方法对学生对象的成员变量赋值(执行构造函数内,我们特意加的赋值语句 name = "阿文",age = 30)学生对象初始化完毕,把对象地址赋值给 stu 变量

# 9.static 关键字修饰的作用?

static 方法就是没有 this 的方法。在 static 方法内部不能调用非静态方法,反过来是可以的。而且可以在没有创建任何对象的前提下,仅仅通过类本身来调用 static 方法。这实际上正是 static 方法的主要用途。 —— 《Java 编程思想》P86

可以知道,被 static 关键字修饰的方法或者变量不需要依赖于对象来进行访问,只要类被加载了,就可以通过类名去进行访问。也就是说,即使没有创建对象也可以进行调用(方法/变量)

static 可以用来修饰类的成员方法、类的成员变量,另外可以编写 static 代码块来优化程序性能。

# 10.什么是静态方法

static 修饰的方法一般叫做静态方法,静态方法不依赖于对象访问,因此没有 this 的概念(this 代表所在类的对象引用),正因如此静态方法能够访问的成员变量和成员方法也都必须是静态的

  • 例如在静态方法 A 中 调用了非静态成员 B,如果通过 类名.A 访问静态方法 A,此时对象还不存在,非静态成员 B 自然也根本不存在,所以就会有问题。调用非静态方法 C 也是如此,你不清楚这个方法 C 中是否调用了费静态变量

# 11.什么是静态变量

static 修饰的变量也称作静态变量,静态变量属于类,所以也称为类变量,存储于方法区中的静态区,随着类的加载而加载,消失而消失,可以通过类名调用,也可以通过对象调用。

# 12.什么是 静态代码块

静态代码块是在类中(方法中不行)使用 static 关键字和{} 声明的代码块

static {
	... 内容
}
1
2
3

执行: 静态代码块在类被加载的时候就运行了,而且只运行一次,并且优先于各种代码块以及构造函数。

作用: 一般情况下,如果有些代码需要在项目启动的时候就执行,这时候 就需要静态代码块。比如一个项目启动需要加载的 很多配置文件等资源,我们就可以都放入静态代码块中。

# 13.构造代码块

**概念:**在 java 类中使用{}声明的代码块(和静态代码块的区别是少了 static 关键字)

执行: 构造代码块在创建对象时被调用,每次创建对象都会调用一次,但是优先于构造函数执行。

作用: 和构造函数的作用类似,都能对对象进行初始化,并且只创建一个对象,构造代码块都会执行一次。但是反过来,构造函数则不一定每个对象建立时都执行(多个构造函数情况下,建立对象时传入的参数不同则初始化使用对应的构造函数)。

因为每个构造方法执行前, 首先执行构造代码块,所以可以把多个构造方法中相同的代码可以放到这里,

# 二.面向对象三大特征

# 1.封装

封装的概念

封装是指隐藏对象的属性和实现细节,仅对外提供公共访问方式

  • 简单的来说就是我将不想给别人看的数据,以及别人无需知道的内部细节, “锁起来” ,我们只留下一些入口,使其与外部发生联系。

我们如何给我们的数据 “上锁” 呢?

  • 我们使用,public、private、protected 等权限修饰符 在类的内部设定了边界,这些不同程度的 ”锁“ 决定了紧跟其后被定义的东西能够被谁使用。

封装的好处

隐藏实现细节,提供公共的访问方式,提高了代码的复用性,提高安全性

好处 1:隐藏实现细节,提供公共的访问方式

隐藏实现细节怎么理解呢?

  • 我们将一些功能封装到类中,而客户端的程序员,不需要知道类中的这个方法的逻辑原理,类程序员只需要给他一个对外的接口,客户端程序员只需要能够调用这个方法即可,

  • 例如:夏天宿舍很热,我们(用户)只需要操作遥控器即可使用空调,并不需要了解空调内部是如何运行的

提供公共的访问方式又怎么理解呢?

我们先来看一段标准案例

public class Student {
	//定义成私有成员变量(private)
    private String name;
    private int age;

    //无参构造
    public Student() {
        super();
    }

    //带参构造
    public Student(String name, int age) {
        super();
        this.name = name;
        this.age = age;
    }

    //成员变量的set和get方法(与外界联系的桥梁)
    public void setName(String name) {
        this.name = name;
    }

    public String getName() {
        return name;
    }

    public void setAge(int age) {
        this.age = age;
    }

    public int getAge() {
        return age;
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
public class StudentTest {
    public static void main(String[] args) {
        //创建学生类对象 s
        Student s = new Student()//对象s调用类中的公共方法setName()和setAge()
        //set方法给成员变量赋值
        s.setName("BWH_Steven");
        s.setAge(20);

        //get方法获取成员变量
        System.out.println(s.getName() + s.getAge());
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

我们可以看到在上面的案例中,成员变量都使用 private 修饰,而下面的 get 和 set 方法均使用了 public 修饰,其实被 private 修饰的属性就是我们想要锁起来的数据,而 set、get 方法就是我们打开这把锁的钥匙

被 private 所修饰的内容是,除类型创建者和类型的内部方法之外的任何人都不能访问的元素,所以我们这些数据就被我们通过 private “锁” 了起来,而我们外界是可以通过创建对象来调用一个类中的公共方法的,所以被 public 修饰的 set 和 get 方法外界所能访问的,而这两个方法又可以直接访问我们的私有成员变量,所以 set 和 get 方法就成为了私有成员与外界沟通的钥匙。

好处 2:提高了代码的复用性

功能被封装成了类,通过基类与派生类之间的一些机制(组合和继承),来提高代码的复用性

好处 3:提高安全性(此处待修改)

关于安全性的问题,实际上还是存在争议的,我们先看一种说法:

public class Student {

    private String name;
    private int age;

    public Student() {
        super();
    }

    public Student(String name, int age) {
        super();
        this.name = name;
        this.age = age;
    }

    public void setName(String name) {
        this.name = name;
    }

    public String getName() {
        return name;
    }

    //在setAge()方法中加入了校验的内容
    //不合法数据是不允许传递给成员变量的
    public void setAge(int age) {
        if (age < 0 || age > 120) {
            System.out.println("Error");
        }else {
            this.age = age;
        }
    }

    public int getAge() {
        return age;
    }
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
public class StudentTest {
    public static void main(String[] args) {
        Student s = new Student();
        System.out.println(s.getName() + s.getAge());

        //错误的方式!!!
        s.name = "BWH_Steven";
        s.age = 20;
        System.out.println(s.getName() + s.getAge());

       	//正确的方式!!!
        s.setName("BWH_Steven");
        s.setAge(20);
    }
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

我们用 private 来修饰我们的成员变量不是没有任何依据的,如果我们的成员变量修饰符改为 public,这句意味着外界可以使用对象直接访问,修改这个成员变量,这可能会造成一些重大的问题

例如:外界通过对象去给成员变量赋值,可以赋值一些非法的数据,这明显是不合理的。所以在赋值之前应该先对数据进行判断。StudenTest 是一个测试类,测试类一般只创建对象,调用方法,所以这个判断应该定义在 Student 类中。需要使用逻辑语句,而逻辑语句应该定义在方法中。所以在 Student 类中提供一个方法来对数据进行校验但是如果偏偏不调用方法来赋值,还是通过 对象名.变量 直接赋值,这样我们的方法内的逻辑就没有起作用所以我们必须强制要求使用我的方法,而不能直接调用成员变量这也正是我们使用 private 修饰成员变量的原因!

注:此处举例为 JavaBean 类,一般很少在 set get 中去添加一些逻辑,一般都是一种简单的赋值,而且诸多框架和不错的项目均使用了这种规范方法。

# 2.继承

继承就是在一个已有类的基础上派生出新类(例如动物类可以派生出狗类和猫类),子类继承父类的特征和行为,使得子类对象(实例)具有父类的实例域和方法,或子类从父类继承方法,使得子类具有父类相同的行为

提高了代码的复用性,提高了代码的维护性(通过少量的修改,满足不断变化的具体要求),让类与类产生了一个关系,是多态的前提。但是缺点也很显著:让类的耦合性增强,这样某个类的改变就会影响其他和该类相关的类。

特点:Java 只支持单继承,不支持多继承(C++支持),但是 Java 支持多层继承(继承体系)形象的说就是:儿子继承爸爸,爸爸继承爷爷,儿子可以通过爸爸继承爷爷。

注意:

A: 子类只能继承父类所有非私有成员(成员方法和成员变量)

B:子类不能继承父类的构造方法,但是可以通过 super 关键字去访问方法

C: 不要为了部分功能而继承(多层继承会使得子类继承多余的方法)

# 2.多态

多态是同一个行为具有多个不同表现形式或形态的能力,例如:黑白打印机和彩色打印机相同的打印行为却有着不同的打印效果,

  • 对象类型和引用类型之间存在着继承(类)/ 实现(接口)的关系;

  • 当使用多态方式调用方法时,首先检查父类中是否有该方法,如果没有,则编译错误;如果有,再去调用子类的同名方法。

  • 如果子类重写了父类的方法,最终执行的是子类覆盖的方法,如果没有则执行的是父类的方法。

# 三.接口与抽象类

# 1.抽象类和接口

抽象类:我们创建一个动物类,并且在这个类中创建动物对象,但是当你提到动物类,你并不知道我说的是什么动物,只有看到了具体的动物,你才知道这是什么动物,所以说动物本身并不是一个具体的事物,而是一个抽象的事物。只有真正的猫,狗才是具体的动物,同理我们也可以推理不同的动物,他们的行为习惯应该是不一样的,所以我们不应该在动物类中给出具体体现,而是给出一个声明即可。

接口:常见的猫狗案例,猫和狗它们仅仅提供一些基本功能。但有一些不是动物本身就具备的,比如:猫钻火圈,狗跳高等功能是在后面的培养中训练出来的,这种额外的功能,java 提供了接口表示。

接口和抽象类的使用条件

  • 抽象类是一个具体的概念,存在实际实例的时候使用抽象类。
  • 当描述特性时,使用接口,比如飞的动作等。

# 2.为什么抽象类必须重写所有抽象方法

“猫”和“狗”都是“动物”这个类的实体,比如动物都有 eat() 这个方法,但是狗是吃肉的,猫是吃鱼的。所以每个动物关于具体吃的方式是需要在子类中重写的,不然的话,狗和猫不就一样了吗?

// Animal类

public abstract class Animal {

    public void sleep() {
        System.out.println("我趴着睡");
    }
    public abstract void eat();
}
1
2
3
4
5
6
7
8
9
// Dog类
public class Dog extends Animal {

    public Dog() {
        super();
    }

    @Override
    public void eat() {
        System.out.println("我实现了父类方法,狗吃肉");
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
// Cat类
public class Cat extends Animal{
    public Cat() {
        super();
    }

    @Override
    public void eat() {
        System.out.println("我实现了父类方法,猫吃鱼");
    }
}
1
2
3
4
5
6
7
8
9
10
11
// 测试类
public class AnimalTest {
    public static void main(String[] args) {
        Animal a1 = new Dog();
        a1.sleep();
        a1.eat();
        System.out.println("-------------------------");
        Animal a2 = new Cat();
        a2.sleep();
        a2.eat();
    }
}
1
2
3
4
5
6
7
8
9
10
11
12

运行结果:

我趴着睡
我实现了父类方法,狗吃肉
-------------------------
我趴着睡
我实现了父类方法,猫吃鱼
1
2
3
4
5

通过上面的例子我们可以看到,Dog 和 Cat 两个子类继承 Animal,两者 sleep() 方法是一样的均继承于 Animal 类,而 eat() 方法由于特性不同则在 Animal 类中定义为抽象方法,分别在子类中实现。

# 3.抽象类和接口的区别

我们从我们实际设计场景中来切入这个话题

先来举一个简单的例子:

狗都具有 eat() 、sleep() 方法,我们分别通过抽象类和接口定义这个抽象概念

// 通过抽象类定义
public abstract class Dog {
	public abstract void eat();
	public abstract void sleep();
}
1
2
3
4
5
// 通过接口定义
public interface Dog {
    public abstract void eat();
    public abstract void sleep();
}
1
2
3
4
5

但是我们现在如果需要让狗拥有一项特殊的技能——钻火圈 DrillFireCircle(),如何增加这个行为呢?

思考:

  1. 将钻火圈方法与前面两个方法一同写入抽象类中,但是这样的话,但凡继承这个抽象类狗都具有了钻火圈技能,明显不合适

  2. 将钻火圈方法与前面两个方法一同写入接口中,当需要使用钻火圈功能的时候,就必须实现 接口中的 eat() 、sleep() 方法(重写该接口中所有的方法)显然也不合适

那么该如何解决呢 ? 我们可以仔细想一想,eat 和 sleep 都是狗本身所应该具有的一种行为,而钻火圈这种行为则是后天训练出来的,只能算是对狗类的一种附加或者延伸, 两者不应该在同一个范畴内,所以我们考虑将这个单独的行为,独立的设计一个接口,其中包含 DrillFireCircle()方法, Dog 设计为一个抽象类, 其中又包括 eat() 、sleep() 方法。

一个 SpecialDog 即可继承 Dog 类并且实现 DrillFireCircle()接口

下面给出代码:

// 定义接口,含有钻火圈方法
public interface DrillFireCircle() {
    public abstract void drillFireCircle();
}

// 定义抽象类狗类
public abstract class Dog {
    public abstract void eat();
    public abstract void sleep();
}

// 继承抽象类且实现接口
class SpecialDog extends Dog implements drillFireCircle {
    public void eat() {
      // ....
    }
    public void sleep() {
      // ....
    }
    public void drillFireCircle() () {
      // ....
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

总结:继承是一个 "是不是"的关系,而 接口 实现则是 "有没有"的关系。如果一个类继承了某个抽象类,则子类必定是抽象类的种类,而接口实现则是有没有、具备不具备的关系,比如狗是否能钻火圈,能则可以实现这个接口,不能就不实现这个接口。

# 4.几种内部类

概述:把类定义在另一个类的内部,该类就被称为内部类。

举例:把类 Inner 定义在类 Outer 中,类 Inner 就被称为内部类。

class Outer {
    class Inner {
    }
}
1
2
3
4

访问规则:内部类可以直接访问外部类的成员,包括私有。外部类要想访问内部类成员,必须创建对象

内部类的分类:A:成员内部类、B:局部内部类、C:静态内部类、D:匿名内部类

# 5.成员内部类

成员内部类——就是位于外部类成员位置的类

特点:可以使用外部类中所有的成员变量和成员方法(包括 private 的)

A:格式

class Outer {
    private int age = 20;
    // 成员位置
    class Inner {
        public void show() {
            System.out.println(age);
        }
    }
}

class Test {
    public static void main(String[] ages) {
        // 成员内部类是非静态的演示
        Outer.Inner oi = new Outer().new Inner();
        oi.show();
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

B:创建对象时:

// 成员内部类不是静态的:
外部类名.内部类名 对象名 = new 外部类名.new 内部类名();

// 成员内部类是静态的:
外部类名.内部类名 对象名 = new 外部类名.内部类名();
1
2
3
4
5

C:成员内部类常见修饰符:

a:private

如果我们的内部类不想轻易被任何人访问,可以选择使用 private 修饰内部类,这样我们就无法通过创建对象的方法来访问,想要访问只需要在外部类中定义一个 public 修饰的方法,间接调用。这样做的好处就是,我们可以在这个 public 方法中增加一些判断语句,起到数据安全的作用。

class Outer {
    private class Inner {
        public void show() {
            System.out.println(“密码备份文件”);
        }
    }

    public void method() {
    	if(你是管理员){
    		Inner i = new Inner();
    		i.show();
    	}else {
    		System.out.println(“你没有权限访问”);
    	}
   	}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

下面我们给出一个更加规范的写法

class Outer {
    private class Inner {
        public void show() {
            System.out.println(“密码备份文件”);
        }
    }
    // 使用getXxx()获取成员内部类,可以增加校验语句(文中省略)
    public Inner getInner() {
		return new Inner();
   	}

    public static void main(String[] args) {
    	Outer outer = new Outer();
        Outer.Inner inner = outer.getInner();
        inner.show();
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

b:static

这种被 static 所修饰的内部类,按位置分,属于成员内部类,但也可以称作静态内部类,也常叫做嵌套内部类。具体内容我们在下面详细讲解。

D:成员内部类经典题(填空)

请在三个 println 后括号中填空使得输出 25,20,18

class Outer {
	public int age = 18;
	class Inner {
		public int age = 20;
		public viod showAge() {
			int age  = 25;
			System.out.println(age);//空1
			System.out.println(this.age);//空2
			System.out.println(Outer.this.age);//空3
		}
	}
}
1
2
3
4
5
6
7
8
9
10
11
12

# 6.局部内部类

局部内部类 —— 就是定义在一个方法或者一个作用域里面的类

特点:主要是作用域发生了变化,只能在自身所在方法和属性中被使用

A 格式:

class Outer {
    public void method(){
        class Inner {
        }
    }
}
1
2
3
4
5
6

B:访问时:

// 在局部位置,可以创建内部类对象,通过对象调用和内部类方法
class Outer {
    private int age = 20;
    public void method() {
        final int age2 = 30;
        class Inner {
            public void show() {
           	    System.out.println(age);
                // 从内部类中访问方法内变量age2,需要将变量声明为最终类型。
                System.out.println(age2);
            }
        }

        Inner i = new Inner();
        i.show();
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

C: 为什么局部内部类访问局部变量必须加 final 修饰呢?

因为局部变量是随着方法的调用而调用,使用完毕就消失,而堆内存的数据并不会立即消失。

所以,堆内存还是用该变量,而该变量已经没有了。为了让该值还存在,就加 final 修饰。

原因是,当我们使用 final 修饰变量后,堆内存直接存储的是值,而不是变量名。

(即上例 age2 的位置存储着常量 30 而不是 age2 这个变量名)

# 7.静态内部类

我们所知道 static 是不能用来修饰类的,但是成员内部类可以看做外部类中的一个成员,所以可以用 static 修饰,这种用 static 修饰的内部类我们称作静态内部类,也称作嵌套内部类。

特点:不能使用外部类的非 static 成员变量和成员方法

解释:非静态内部类编译后会默认的保存一个指向外部类的引用,而静态类却没有。

简单理解:即使没有外部类对象,也可以创建静态内部类对象,而外部类的非 static 成员必须依赖于对象的调用,静态成员则可以直接使用类调用,不必依赖于外部类的对象,所以静态内部类只能访问静态的外部属性和方法。

class Outter {
    int age = 10;
    static age2 = 20;
    public Outter() {
    }

    static class Inner {
        public method() {
            System.out.println(age);//错误
            System.out.println(age2);//正确
        }
    }
}

public class Test {
    public static void main(String[] args)  {
        Outter.Inner inner = new Outter.Inner();
        inner.method();
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

# 8.匿名内部类

一个没有名字的类,是内部类的简化写法

A 格式:

new 类名或者接口名() {
    重写方法();
}
1
2
3

本质:其实是继承该类或者实现接口的子类匿名对象

这也就是下例中,可以直接使用 new Inner() {}.show(); 的原因等于 子类对象.show();

interface Inter {
	public abstract void show();
}

class Outer {
    public void method(){
        new Inner() {
            public void show() {
                System.out.println("HelloWorld");
            }
        }.show();
    }
}

class Test {
	public static void main(String[] args)  {
    	Outer o = new Outer();
        o.method();
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

如果匿名内部类中有多个方法又该如何调用呢?

Inter i = new Inner() {  //多态,因为new Inner(){}代表的是接口的子类对象
	public void show() {
		System.out.println("HelloWorld");
	}
};
1
2
3
4
5

B:匿名内部类在开发中的使用

我们在开发的时候,会看到抽象类,或者接口作为参数。

而这个时候,实际需要的是一个子类对象。

如果该方法仅仅调用一次,我们就可以使用匿名内部类的格式简化。

# 9.为什么使用内部类

封装性:

作为一个类的编写者,我们很显然需要对这个类的使用访问者的访问权限做出一定的限制,我们需要将一些我们不愿意让别人看到的操作隐藏起来,

如果我们的内部类不想轻易被任何人访问,可以选择使用 private 修饰内部类,这样我们就无法通过创建对象的方法来访问,想要访问只需要在外部类中定义一个 public 修饰的方法,间接调用。

public interface Demo {
    void show();
}
1
2
3
class Outer {
    private class test implements Demo {
        public void show() {
            System.out.println("密码备份文件");
        }
    }

    public Demo getInner() {
        return new test();
    }

}
1
2
3
4
5
6
7
8
9
10
11
12

我们来看其测试

    public static void main(String[] args) {
    	Outer outer = new Outer();
        Demo d = outer.getInner();
        i.show();
    }

//运行结果
密码备份文件
1
2
3
4
5
6
7
8

这样做的好处之一就是,我们可以在这个 public 方法中增加一些判断语句,起到数据安全的作用。

其次呢,我们的对外可见的只是 getInner() 这个方法,它返回了一个 Demo 接口的一个实例,而我们真正的内部类的名称就被隐藏起来了

实现多继承:

我们之前的学习知道,java 是不可以实现多继承的,一次只能继承一个类,我们学习接口的时候,有提到可以用接口来实现多继承的效果,即一个接口有多个实现,但是这里也是有一点弊端的,那就是,一旦实现一个接口就必须实现里面的所有方法,有时候就会出现一些累赘,但是使用内部类可以很好的解决这些问题

public class Demo1 {
    public String name() {
        return "BWH_Steven";
    }
}
1
2
3
4
5
public class Demo2 {
    public String email() {
        return "xxx.@163.com";
    }
}
1
2
3
4
5
public class MyDemo {

    private class test1 extends Demo1 {
        public String name() {
            return super.name();
        }
    }

    private class test2 extends Demo2  {
        public String email() {
            return super.email();
        }
    }

    public String name() {
        return new test1().name();
    }

    public String email() {
        return new test2().email();
    }

    public static void main(String args[]) {
        MyDemo md = new MyDemo();
        System.out.println("我的姓名:" + md.name());
        System.out.println("我的邮箱:" + md.email());
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28

我们编写了两个待继承的类 Demo1 和 Demo2,在 MyDemo 类中书写了两个内部类,test1 和 test2 两者分别继承了 Demo1 和 Demo2 类,这样 MyDemo 中就间接的实现了多继承

用匿名内部类实现回调功能:

我们用通俗讲解就是说在 Java 中,通常就是编写一个接口,然后你来实现这个接口,然后把这个接口的一个对象作以参数的形式传到另一个程序方法中, 然后通过接口调用你的方法,匿名内部类就可以很好的展现了这一种回调功能

public interface Demo {
    void demoMethod();
}
1
2
3
public class MyDemo{
    public test(Demo demo){
    	System.out.println("test method");
    }

    public static void main(String[] args) {
        MyDemo md = new MyDemo();
        //这里我们使用匿名内部类的方式将接口对象作为参数传递到test方法中去了
        md.test(new Demo){
            public void demoMethod(){
                System.out.println("具体实现接口")
            }
        }
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

解决继承及实现接口出现同名方法的问题:

public interface Demo {
    void test();
}
1
2
3
public class MyDemo {

    public void test() {
        System.out.println("父类的test方法");
    }

}
1
2
3
4
5
6
7
public class DemoTest extends MyDemo implements Demo {
    public void test() {
    }
}
1
2
3
4

这样的话我就有点懵了,这样如何区分这个方法是接口的还是继承的,所以我们使用内部类解决这个问题

public class DemoTest extends MyDemo {

    private class inner implements Demo {
        public void test() {
            System.out.println("接口的test方法");
        }
    }

    public Demo getIn() {
        return new inner();
    }


    public static void main(String[] args) {
        //调用接口而来的test()方法
        DemoTest dt = new DemoTest();
        Demo d = dt.getIn();
        d.test();

        //调用继承而来的test()方法
        dt.test();
    }
}

//运行结果
接口的test方法
父类的test方法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27

# 五.概念常识

# 1.JVM(Java Virtual Machine)

JVM 又被称作 Java 虚拟机,用来运行 Java 字节码文件(.class),因为 JVM 对于特定系统(Windows,Linux,macOS)有不同的具体实现,即它屏蔽了具体的操作系统和平台等信息,因此同一字节码文件可以在各种平台中任意运行,且得到同样的结果。

# 2.什么是字节码?

扩展名为 .class 的文件叫做字节码,是程序的一种低级表示,它不面向任何特定的处理器,只面向虚拟机(JVM),在经过虚拟机的处理后,可以使得程序能在多个平台上运行。

# 3.采用字节码的好处是什么?

Java 语言通过字节码的方式,在一定程度上解决了传统解释型语言执行效率低的问题,同时又保留了解释型语言可移植的特点。所以 Java 程序运行时比较高效,而且,由于字节码并不专对一种特定的机器,因此,Java 程序无须重新编译便可在多种不同的计算机上运行。

为什么一定程度上解决了传统解释型语言执行效率低的问题(参考自思否-scherman (opens new window) ,仅供参考)

首先知道两点,① 因为 Java 字节码是伪机器码,所以会比解析型语言效率高 ②JVM 不是解析型语言,是半编译半解析型语言

解析型语言没有编译过程,是直接解析源代码文本的,相当于在执行时进行了一次编译,而 Java 的字节码虽然无法和本地机器码完全一一对应,但可以简单映射到本地机器码,不需要做复杂的语法分析之类的编译处理,当然比纯解析语言快。

# 4.JRE(Java Runtime Environment)

JRE 是 Java 运行时环境,它包含了 JVM 和 Java 的一些基础类库,它用来运行已经编译好的 Java 程序(它就是用来运行的,不能创建新程序)

# 5.JDK(Java Development Kit)

JDK 是 Java 开发工具包,是程序员使用 Java 语言开发 Java 程序必备的工具包,它不仅包含 JRE ,同时还包含了编译器(javac)还包含了很多 java 调试,分析,文档相关的工具。使用 JDK 可以创建以及编译程序。

只为 Java 运行环境 JRE,有必要安装 JDK 吗

单纯为了运行一个 Java 程序,JRE 是完全够用的,但话不能这么绝对

有时,即使您不打算在计算机上进行任何 Java 开发,仍然需要安装 JDK。例如,如果要使用 JSP 部署 Web 应用程序,那么从技术上讲,您只是在应用程序服务器中运行 Java 程序。那你为什么需要 JDK 呢?因为应用程序服务器会将 JSP 转换为 Java servlet,并且需要使用 JDK 来编译 servlet。(参考自 Github-JavaGuide (opens new window) ,仅供参考)

所以,大部分情况安装 JDK 还是有必要的,需要根据具体情况来决定。

你能谈一谈 Java 程序从代码到运行的一个过程吗?

java-javase-basis-001

过程:编写 -> 编译 -> 解释(这也是 Java 编译与解释共存的原因)

首先通过 IDE/编辑器编写源代码然后经过 JDK 中的编译器(javac)编译成 Java 字节码文件(.class 文件),字节码通过虚拟机执行,虚拟机将每一条要执行的字节码送给解释器,解释器会将其翻译成特定机器上的机器码(及其可执行的二进制机器码)。

# 6.Java 与 JavaScript

  • 用处不同:Java 被广泛用于互联网应用程序的开发。而 JavaScript 主要内嵌于 Web 页面中运行,用来读写 HTML,控制 cookies 等。

  • 定位不同:Java 是基于对象的,简单开发也需要设计并创建类。而 JavaScript 这种脚本语言是基于对象的和事件驱动的,它可以通过大量提供好的内部对象实现各种功能。

  • 代码执行过程不同:Java 源代码会经过编译编程字节码文件,然后 JVM 将字节码文件分发给解释器进行解释处理,因此属于编译与解释共存。而 JavaScript 是一种解释性语言,源代码不需要经过编译,浏览器即可解释执行( JIT (即时编译)可以提高 JavaScript 的运行效率)。

  • 数据类型不同:Java 采用强类型检查,编译前必须声明,而 JavaScript 是弱类型,甚至变量使用前可以不声明,JavaScript 在运行时检查推断其数据类型。

# 7.Java 与 C++

  • 指针:Java 语言不显式地向用户提供指针来访问内存,添加了自动内存管理功能,可以避免在 C/C++ 中因操作失误而导致的野指针的问题,使程序更安全(只是不提供,并不是没有指针,虚拟机中内部还是使用了指针只是不向外提供而已)。

  • 继承:Java 的类是单继承的,而 C++ 却可以多重继承,但 Java 可以通过继承/实现多个接口。

  • 内存管理: Java 的 JVM 中有自己的 GC 机制(垃圾回收机制),不需要程序员手动释放没用的内存

  • 运算符重载:Java 不可以运算符重载,而 C++ 则可以。

# 六.基础语法

# 1.标识符和关键字的区别

标识符和关键字的本质都是一个名字,例如类名,方法名,变量名... ,而关键字,是一种特殊的标识符,它被冠以一种特殊的含义,例如 public return try catch ... ,因为关键字有其特定的位置和用途,所以普通标识符不允许与关键字重名。

# 2.Java 常见的命名规则

注:下面所述,均为命名的硬性规则,而非推荐的规范,具体可参考 《阿里巴巴 Java 开发手册》

基本:

  • 包名:全部小写用 . 隔开 eg: com.baidu.www (域名反写)
  • 类名/接口:首字母大写,多个单词组成则使用驼峰命名
  • 方法或变量名:首字母小写,多个单词组成则使用驼峰命名
  • 常量名:全部大写,用 _ 隔开

标识符:

  • 首字符:字母(A-Z、a-z)、美元符($)、下划线(_

  • 首字符之后:字母(A-Z、a-z)、美元符($)、下划线(_)或者数字的任何字符组合

# 3.注释的种类

注释有三种:① 单行注释(//注释内容)、② 多行注释(/*注释内容*/)、③ 文档注释(/**注释内容*/

谈谈你对注释规范的看法:

首先要说注释的作用:① 能准确的反映设计思想和代码逻辑 ② 能够描述业务含义

一份好的注释,可以在较长一段时间后,帮助你快速回忆当时的思路,也可以帮助接收这份代码的新人,快速了解你的想法。

代码的注释不是越详细越好。实际上好的代码本身就是注释,我们要尽量规范和美化自己的代码来减少不必要的注释。若编程语言足够有表达力,就不需要注释,尽量通过代码来阐述。—— 《Clean Code》

# 4.字符常量和字符串常量的区别

  • 形式不同:字符常量是单引号引起的一个字符,而字符串常量是双引号引起的 0 个或若干个字符
  • 含义不同:字符常量相当于一个整型值( ASCII 值),可以参加表达式运算,而字符串常量代表一个地址值(该字符串在内存中存放位置)
  • 占内存大小不同: 字符常量只占 2 个字节( char 在 Java 中占两个字节),字符串常量占若干个字节

# 5.char 型变量可以存储一个中文汉字?

char 型变量是用来存储 Unicode 编码的字符的,而 Unicode 编码字符集中包含了汉字,所以,char 型变量中当然可以存储汉字啦。如果某个特殊的汉字没有被包含在 unicode 编码字符集中,那么,这个 char 型变量中就不能存储这个特殊汉字。

补充说明:unicode 编码占用两个字节,所以,char 类型的变量也是占用两个字节

# 6.final 关键字有什么作用

  • final 修饰的类不能被继承,final 类中的成员变量可以根据需要设为 final,但要注意 final 类中的所有成员方法都会被隐式地指定为 final 方法。
  • final 修饰的方法不能被重写
  • final 修饰的变量叫做常量,如果是基本类型,则数值初始化后就不能改变了,如果是引用类型,则对其初始化后则不能再让其指向到另一个对象了。

# 7.前置或后置自增/自减运算符的区别

++-- 就是对变量进行自增 1 或者自减 1 的运算符,前置后置是有区别的:

规则:运算符前置则先加/减,运算符后置则后加/减

int x = 4;
int y = (x++) + (++x) + (x * 10);
System.out.println(y);
1
2
3

首先 (x++) 中 x 后置 ++ 所以后加减,即 x 运算时取 4 然后自增为 5

其次 (++x) 中 x 前置 ++ 所以先加减, x = 6

接着 x _ 10 = 6 _ 10 = 60

最后执行赋值语句,即:y = 4 + 6 + 60 = 70

# 8.& 和 && 的区别

& 运算符有两种用法:① 按位与 ② 逻辑与(这里只讨论)

&& 运算符是短路与运算

逻辑与跟短路与都要求运算符左右两端的布尔值都是 true 整个表达式的值才是 true

&& 具有短路作用,如果&&左边的表达式的值是 false,右边的表达式会被直接短路掉,不会进行运算,因此效率更高

一般更推荐使用 &&,例如在验证用户登录时判定用户名不是 null 而且不是空字符串,应当写为:username != null &&!username.equals(""),二者的顺序不能交换,更不能用&运算符,因为第一个条件如果不成立,根本不能进行字符串的 equals 比较,否则会产生 NullPointerException 异常。

注意:逻辑或运算符(|)和短路或运算符(||)的差别也是如此。

补充:& 还可以当做位运算符,当 &两边操作数或表达式的结果不是布尔类型的时候,& 即按照位于运算符操作

# 9.交换两个整型数的值你有几种方法?

方式 1:使用一个中间值传递(因其可读性高,所以开发中也常用这种方式)

方式 2:用位异或实现

  • ^ 位异或运算符的特点:一个数据对另一个数据位异或两次,该数本身不变
a = a ^ b;
b = a ^ b; // 将 a 带入,即: b = a ^ b ^ b
a = a ^ b; // a 还是 a ^ b , b 变成了 a 即: a = a ^ b ^ a = b
1
2
3

方式 3:用变量相加的方法

a = a + b;
b = a - b;
a = a - b;
1
2
3

方式 4:一句话的事

b = (a + b) - (a = b);
1

此处方式 1 2 4 都好理解,顺便回顾一下原反补码,以及各种位运算

# 10.简单讲一下原码,补码,反码

在计算机内,有符号数有三种表示方法,源码、反码、和补码。而所有的数据运算都是采用补码进行的

  • 原码:二进制点表示法,最高位为符号位,“0”表示正,“1”表示负,其余位置表示数值大小,可直观反映出数据的大小。
    • 正数的原码最高位是 0 ,负数的原码最高位是 1 ,其他的是数值位
  • 反码:解决负数加法运算问题,将减法运算转换为加法运算,从而简化运算规则。
    • 正数的反码与原码相同,负数的反码与源码符号位相同,数值位取反 1 -> 0 、 0 -> 1
  • 补码:解决负数加法运算正负零问题,弥补了反码的不足。
    • 正数的补码与原码相同,负数的补码是在反码的基础上+1

# 11.介绍一下几种位运算

位运算需要将数据转换成二进制,用 0 补齐位数

& 位与运算符:有 0 则 0

| 位或运算符:有 1 则 1

^ 位异或运算符:相同则 0,不同则 1

~ 按位取反运算符:0 变 1,1 变 0(拿到的是补码,要转换为原码)

<< 按位左移运算符:左边最高位丢弃,右边补齐

  • 快速计算:把 << 左边的数据 乘以 2 的移动次幂:例如 3 << 2 即:3 * 2 ^ 2 = 12

>> 按位右移运算符:最高位为 0,左边补齐 0,最高位是 1,左边补齐 1

  • 快速计算:把 >> 左边的数据 除以 2 的移动次幂:例如 -24 >> 2 即:-24 / 2 ^ 2 = -6

>>> 按位右移补零操作符:无论最高位是 0 还是 1 ,左边补齐 0

分别演示 ^>> 两个典型运算符

  • 3 ^ 4
// 3 的二进制: 11 补齐位数
00000000 00000000 00000000 00000011
// 4 的二进制: 100 补齐位数
00000000 00000000 00000000 00000100
// 位异或运算符: 相同则 0, 不同则1
00000000 00000000 00000000 00000011
00000000 00000000 00000000 00000100
-----------------------------------
00000000 00000000 00000000 00000111
// 得到的为补码,因为符号位为 0 即为正数,所以原反补码一致,所以结果(原码)就是二进制的111,即十进制的 7
1
2
3
4
5
6
7
8
9
10
  • -24 >> 2
// -24 的二进制: 11000 负数符号位为 1, 补齐位数
原码: 10000000 00000000 00000000 00011000
反码: 11111111 11111111 11111111 11100111
补码: 11111111 11111111 11111111 11101000
// 右移 2 位 最高位是 1,左边补齐 1
11111111 11111111 11111111 11101000
1111111111 11111111 11111111 111010(00)
// 拿到的结果为补码,按8位划分开
补码: 11111111 11111111 11111111 11111010
反码: 11111111 11111111 11111111 11111001
原码: 10000000 00000000 00000000 00000110
// 结果是二进制的 110, 即十进制的 -6
1
2
3
4
5
6
7
8
9
10
11
12

# 12.快速 2 乘以 8 等于多少

用最有效率的方法算出 2 乘以 8 等于多少:

使用位运算来实现效率最高。位运算符是对操作数以二进制比特位为单位进行操作和运算,操作数和结果都是整型数。

对于位运算符 << , 是将一个数左移 n 位,就相当于乘以了 2 的 n 次方,那么,一个数乘以 8 只要将其左移 3 位即可,位运算是 cpu 直接支持的,效率最高。

所以,2 乘以 8 等于几的最效率的方法是 2 << 3

# 13.break、continue、return 的区别?

break、continue 均属于跳出循环的关键字,return 属于跳出方法的关键字

  • break:完全跳出一个循环体,执行该循环体下接着的语句
  • continue:跳过本次循环,执行下一次循环
  • return:结束方法的运行,有两种用法
    • return;:用于没有返回值的方法(可不写)
    • retu
    • rn value:用于返回一个特定的值

# 七.数据类型与方法

# 1.讲一讲 Java 中的几种基本数据类型

首先 Java 是一种强类型的语言,针对每一种数据都定义了明确的数据类型(就是将一些值的范围做了约束,从而为不同类型的值在内存中分配不同的内存空间)

Name Size(字节|位数) Range
byte 1byte | 8bit -128~127 之间
short 2bytes | 16bit -32768~32767 之间,最大数据存储量是 65536
int 4bytes | 32bit -2^31 ~ 2^31-1 之间
long 8bytes | 64bit -2^63 ~ 2^63-1
float 4bytes | 32bit 3.4e-45~1.4e38,直接赋值时必须在数字后加上 f 或 F
double 8bytes | 64bit 4.9e-324~1.8e308,赋值时可以加 d 或 D 也可以不加
boolean 只有 true 和 false 两个取值
char 2bytes 存储 Unicode 码,用单引号赋值

注意:对于 boolean,官方文档未明确定义,它依赖于 JVM 厂商的具体实现。逻辑上理解是占用 1 位,但是实际中会考虑计算机高效存储因素。

# 2.谈谈数据类型转换时的精度处理问题

一般来说,我们在运算的时候,要求参与运算的数值类型必须一致,针对类型不一致的时候,有两种将不同数据类型统一的方式,即:默认自动转换(从小到大的转换)和 强制转换。

  • 默认自动转换:即从 byte,short, char 三者都会被默认的转到更高的精度类型,精度等级顺序如下 ( ==> int ==> long ==> float ==> double )

疑惑:为什么 float(4 个字节)在 long(8 个字节)后面

A: 它们底层的存储结构不同

B: float 表示的数据范围比 long 范围要大

long:2^63-1

float:3.410^38 > 210^38 > 28^38 > 22^3^38

= 2*2^144 > 2^63 -1

默认类型转换示例:

// 例如低精度byte到高精度int 会根据默认转换,自动转换类型
// 可以正常执行
public static void main(String[] args) {
    byte a = 2int b = 3;
    int c = a + b
    System.out.println(c);
}

// 高精度int到低精度byte 可能会损失精度
// 直接报错,不兼容的类型:从int转换到byte可能会有损失
public static void main(String[] args) {
    byte a = 3int b = 4;
    byte c = a + b
    System.out.println(c);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
  • 强制类型转换

    • 格式:目标数据类型 变量 = (目标数据类型)(被转换的数据)

注意:不要随便的去用强制转化,因为它隐含了精度损失的问题,把容量大的类型转换为容量小的类型时必须使用强制类型转换。

int i = 128;
// 因为byte类型是8位,最大值为127,所以当int强制转换为byte类型的时候,值128就会导致溢出
byte b = (byte)i;
1
2
3

# 3.变量相加和常量相加类型转换

变量相加,会首先看类型问题,最终把结果赋值也会考虑类型问题

常量相加,首先做加法,然后看结果是否在赋值的数据类型范围内,如果不是,才报错

# 4.byte 类型溢出错误问题

Java 背后是如何强制转换 byte 类型溢出错误问题的

public static void main(String[] args) {
    // byte 的范围是: -128到127,所以报错
    byte a = 130;
    // 使用强制类型转换
    byte b = (byte)130;
    System.out.println(b);
}
1
2
3
4
5
6
7

我们想要知道结果是什么,就应该知道是如何计算的,而我们又知道计算机中数据的运算都是补码进行的,得到补码,首先要计算出数据的二进制

// 求出130的二进制 10000010
// 130 是一个整数 所以补齐4个字节 (一个字节8位)
0000000  00000000  00000000  10000010

// 做截取操作,截成byte类型(1个字节,8位)
10000010

// 上述结果是补码,求其原码
补码: 10000010
反码: 10000001
原码: 11111110
// 11111110 转换为十进制为 -126
1
2
3
4
5
6
7
8
9
10
11
12

# 5.包装类型

Java 中基础类型对应的包装类型是什么,自动装箱与拆箱又是什么?

Java 中有 8 种基本数据类型,分别为:byte、short、int、long、float、double、char、boolean。

对应的包装类分别为:Byte、Short、Integer、Long、Float、Double、Character、Boolean

将基本数据类型封装成对象的的好处在于可以在对象中定义更多的功能方法操作该数据,比如 String 和 int 类型的相互转换。同时简化了基本数据类型和相对应对象的转化步骤。

  • 自动装箱:将基本类型用它们对应的引用类型包装起来
  • 自动拆箱:将包装类型转换为基本数据类型

而在我们想要使用包装类的一些方法的时候,可以通过基本类型包装类的构造方法将值传入,但是 JDK5 后的新特性就为我们大大的简化了一些麻烦的步骤。

// 定义一个 包装类型 Integer 接收一个基本类型 int 整数 1, 这就是一个自动装箱。
Integer a = 1;
// 如果没有自动装箱的话,需要使用构造函数
Integer a = new Integer(1)
// 继续用 int 类型 b 接收一个 上面的包装类型 Integer a, 这就是一个自动拆箱
int b = a;
// 如果没有自动拆箱的话,需要使用方法
int b = a.intValue()
1
2
3
4
5
6
7
8

# 6.常量池(缓冲区)问题

几种包装类类型的常量池(缓冲区)问题

在 JDK 5 以后,几种包装类对象在内部实现中通过使用相同的对象引用实 现了缓存和重用。例如:Integer 类型对于-128-127 之间的数字是在缓冲区取的,所以对于在这个范围内的数值用双等号(==)比较是一致的,因为对应的内存地址是相同的。但对于不在这区间的数字是在堆中 new 出来的,所以地址空间不一样,也就不相等。

  • Byte、Short、Integer、Long 缓存范围:[-128,127]

  • Character 缓存范围:[0,127]

  • Boolean 直接返回 True Or False

注:浮点数类型的包装类 Float 和 Double 并没有实现常量池技术

Boolean 源码节选:

// 一开始就定义 TRUE FALSE 两个常量
public static final Boolean TRUE = new Boolean(true);
public static final Boolean FALSE = new Boolean(false);

// 很少使用此构造函数, 非必须时推荐使用静态工厂
public Boolean(boolean value) {
    this.value = value;
}

// valueOf 是一个更好的选择,它能产生更好的时间和空间性能
public static Boolean valueOf(boolean b) {
    return (b ? TRUE : FALSE);
}
1
2
3
4
5
6
7
8
9
10
11
12
13

Character 源码节选:

// 此方法通常优先于构造函数, 原因也是产生更好的时间和空间性能
public static Character valueOf(char c) {
    if (c <= 127) { // must cache
        return CharacterCache.cache[(int)c];
    }
    return new Character(c);
}

// 具体的逻辑在此
private static class CharacterCache {
    private CharacterCache(){}

    static final Character cache[] = new Character[127 + 1];

    static {
        for (int i = 0; i < cache.length; i++)
            cache[i] = new Character((char)i);
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

Integer 源码节选:

// 该方法缓存的值总是在-128到127之间,并可能缓存该范围之外的其他值
public static Integer valueOf(int i) {
    if (i >= IntegerCache.low && i <= IntegerCache.high)
        return IntegerCache.cache[i + (-IntegerCache.low)];
    return new Integer(i);
}

// IntegerCache 具体逻辑可自行研究
1
2
3
4
5
6
7
8

# 7.方法参数传递为值传递还是引用传递

Java 中方法参数传递方式是按值传递

  • 如果参数是基本类型,传递的是基本类型的字面量值的拷贝。形式参数的改变对实际参数没有影响
  • 如果参数是引用类型,传递的是该参量所引用的对象在堆中地址值的拷贝。形式参数的改变直接影响实际参数

下面我们对以上结论进行简单分析:

示例 1:

public static void main(String[] args) {
    // 基本类型
    int a = 100;
    int b = 200;
    System.out.println("main 调用 modify 前: " + "a: " + a + ", b: " + b);
    modify(a, b);
    System.out.println("main 调用 modify 后: " + "a: " + a + ", b: " + b);
}

/**
* 参数为基本类型
* @param a
* @param b
*/
public static void modify(int a, int b) {
    System.out.println("modify 接收到参数: " + "a: " + a + ", b: " + b);
    a = 300;
    b = 400;
    System.out.println("modify 修改参数后: " + "a: " + a + ", b: " + b);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

运行结果:

main 调用 modify 前: a: 100, b: 200
modify 接收到参数: a: 100, b: 200
modify 修改参数后: a: 300, b: 400
main 调用 modify 后: a: 100, b: 200
1
2
3
4

示例 2:

public static void main(String[] args) {
    // 引用类型
    int[] arr = {1, 2, 3, 4, 5};
    System.out.println("main 调用 modify 前: " + "arr[0]: " + arr[0]);
    modify(arr);
    System.out.println("main 调用 modify 后: " + "arr[0]: " + arr[0]);
}

/**
* 参数为引用类型
* @param arr
*/
public static void modify(int[] arr) {
    System.out.println("modify 接收到参数(以arr[0]举例): " + "arr[0]: " + arr[0]);
    arr[0] = 100;
    System.out.println("modify 修改参数后(以arr[0]举例): " + "arr[0]: " + arr[0]);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

运行结果:

main 调用 modify 前: arr[0]: 1
modify 接收到参数(以arr[0]举例): arr[0]: 1
modify 修改参数后(以arr[0]举例): arr[0]: 100
main 调用 modify 后: arr[0]: 100
1
2
3
4

上述代码的结果,即:以基本类型作为方法参数,方法内对形参的修改,不会影响到实际参数。以引用类型作为方法参数,方法内对形参的修改,会直接影响到实际参数。画一张图简单分析一下:

java-javase-basis-002

对于基本类型,a 和 b ,在 modify(int, int) 方法中进行修改不会影响原先的值,这是因为 modify 方法中的参数 a 和 b 是从原先的 a 和 b 复制过来的一个副本。无论如何修改 a 和 b 的值,都不会影响到原先的值。

对于引用类型,arr 数组初始化后,指向到了一个具体的地址中,而将其作为方法参数传递,modify 方法中的 arr 也就指向到了同一个地址去,所以方法内的修改,会直接反映在所对应的对象上。

# 8.说一说方法重载和重写的区别

方法重载:在一个类中,同名的方法如果有不同的参数列表(参数类型、个数甚至顺序不同)则叫做重载

  • 规则:在同一个类中,方法名必须相同,参数类型不同、个数不同、顺序不同,方法返回值和访问修饰符可以不同。
  • 表现形式:方法名,返回值,访问修饰符,相同的方法,根据不同的数据列表,做出不同的逻辑处理。

方法重写:是子类对父类的允许访问的方法的实现过程进行重新编写

  • 规则

    • 方法名、参数列表、返回类型都相同的情况,对方法体进行修改或者重写。

    • 访问修饰符的限制一定要大于被重写方法的访问修饰符(public > protected > default > private)。

    • 重写方法一定不能抛出新的检查异常或者比被重写方法申明更加宽泛的检查型异常

  • 表现形式:重写就是当子类继承自父类的相同方法,输入一样的数据,你就要覆盖父类方法,使得方法能做出不同的响应

# 9.重载和重写都是实现多态的方式

方法的重载和重写都是实现多态的方式,区别在于重载实现的是编译时的多态性,而重写实现的是运行时的多态

性。这里的多态可以理解为一个方法的调用,或者函数入口参数的不同,而造成的方法行为不同。

两种不同时期的多态:

  • 编译时期多态:其又被称为静态多态,编译时期的多态是靠重载实现的,根据参数个数,类型和顺序决定的(必须在同一个类中)
    • 在方法调用之前,编译器就已经确定了所要调用的方法,这称为“早绑定”或“静态绑定” ;
  • 运行时的多态:运行时期的多态是靠方法的重写实现的,在编译期间被视作相同方法,但是运行期间根据对象的不同调用不同的方法
    • 只有等到方法调用的那一刻, 解释运行器才会确定所要调用的具体方法,这称为“晚绑定”或“动态绑定” 。
    • 这也就是我们说的,编译看左边,运行看右边(会在面向对象篇设涉及)

# 10.函数不能根据返回类型来区分重载?

Java 允许重载任何方法,而不只是构造器方法。因此要完整的指出方法名以及参数类型。这叫做方法的签名(signature)。例如 String 类有 4 个称为 indexOf 的公有方法。它们的签名是:

indexOf(int)
indexOf(int, int)
indexOf(String)
indexOf(String, int)
1
2
3
4

返回值类型不是方法签名的一部分。也就是说,不能有两个名字相同、参数类型也相同却返回不同类型值的方法。

同时函数的返回值只是作为函数运行之后的一个“状态”,他是保持方法的调用者与被调用者进行通信的关键。并不能作为某个方法的“标识”。

说明:本章主要涉及到了:Object 类、Scanner 类、String 类、StringBuffer 和 StringBuilder、Arrays 工具类、基本类型包装类、正则表达式、System 类、Math、Random 类、BigInteger 和 BigDecimal 类、Date、DateFormat 和 Calendar 类

补充:由于 Object 以及 String 类属于高频内容,所以总结题目以及小点知识之前,会对其做一个基本的归纳复习。

# 八.常见问题

# 1. Object 类

  • Object 是类层次结构的根类,所有的类都隐式的(不用写 extends)继承自 Object 类。
  • Java 所有的对象都拥有 Object 默认方法
  • Object 类的构造方法有一个,并且是无参构造

这就对应了前面学习中的一句话,子类构造方法默认访问父类的构造是无参构造

我们需要了解的方法又有哪些呢?

A:hashCode() B:getClass() C: finalize() D:clone() E:wait() F:notify() G:notifyAll()

我们需要掌握的方法又有哪些呢?

A:toString() B:equals()

方法总结:

// 1. 返回此Object的运行时类,是一个 native方法,同时因为使用了final关键字修饰,故不允许子类重写。
public final native Class<?> getClass()

// 2. 用于返回对象的哈希码,是一个native方法,例如主要涉及在 HashMap 中。
public native int hashCode()

// 3. 比较两个对象是否相同,默认比较的是地址值是否相同。而比较地址值是没有意义的,所以,一般子类也会重写该方法。
public boolean equals(Object obj)

// 4. 实现对象的克隆,包括成员变量的数据复制,分为深浅克隆两种。是一个native方法。
protected native Object clone() throws CloneNotSupportedException

// 5. 返回类的名字@该实例16进制的哈希码字符串。因此建议Object 所有的子类都重写此方法。
public String toString()

// 6. 唤醒一个在此对象监视器上等待的线程(监视器理解为锁)。若有多个线程在等待只会任意唤醒一个。是一个 native方法,且不能重写。
public final native void notify()

// 7. 同 notify(),区别是会唤醒在此对象监视器上等待的所有线程。
public final native void notifyAll()

// 8. 意为暂停线程的执行.是一个native方法。注意:释放了锁,而sleep方法不释放锁。timeout是等待时间。
public final native void wait(long timeout) throws InterruptedException

// 9. 多了一个nanos参数,代表额外时间(以毫微秒为单位,范围是 0-999999)。 所以时间最后要计算总和。
public final void wait(long timeout, int nanos) throws InterruptedException

// 10同前两个 wait() 只不过该方法一直等待
public final void wait() throws InterruptedException

// 11. 在对象将被垃圾回收器清除前调用,但不确定时间
protected void finalize() throws Throwable { }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32

# 2.String 类

String 是一个很常用的类,简单归纳一下常见的方法

构造方法

// 1. 空构造
public String()

// 2. 把字节数组转换成字符串
public String(byte[] bytes)

// 3. 把字节数组的一部分转换成字符串
public String(byte[] bytes,int offset,int length)

// 4. 把字符数组转换成字符串
public String(char[] value)

// 5. 把字符数组的一部分转换成字符串
public String(char[] value,int offset,int count)

// 6. 把字符串常量值转换成字符串
public String(String original)

// 7. 下面的这一个虽然不是构造方法,但是结果也是一个字符串对象
String s = "hello";
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

简单总结:String 类的构造方法可以将 字节、字符数组、字符串常量(全部或者部分)转换为字符串类型

判断方法

// 1. 比较字符串的内容是否相同,区分大小写
boolean equals(Object obj)

// 2. 比较字符串的内容是否相同,不区分大小写
boolean equalsIgnoreCase(String str)

// 3. 判断大字符串中是否包含小字符串
boolean contains(String str)

// 4. 判断某个字符串是否以某个指定的字符串开头
boolean startsWith(String str)

// 5. 判断某个字符串是否以某个指定的字符串结尾
boolean endsWith(String str)

// 6. 判断字符串是否为空
boolean isEmpty()

注意:
String s = “”;    // 字符串内容为空
String s = null;  // 字符串对象为空
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

获取方法

// 1. 获取字符串的长度
int length()

// 2. 获取指定索引的字符
char charAt(int index)

// 3. 返回指定字符在此字符串中第一次出现的索引
int indexOf(int ch)
// 为什么这里是int而不是char?
// 原因是:‘a’和‘97’其实都能代表‘a’ int方便

// 4. 返回指定字符串在此字符串中第一次出现的索引
int indexOf(String str)

// 5. 返回指定字符在此字符串中从指定位置后第一次出现的索引
int indexOf(int ch,int fromIndex)

// 6. 返回指定字符串在此字符串中从指定位置后第一次出现的索引
int indexOf(String str, int fromIndex)

// 7. 从指定位置开始截取字符串,默认到末尾
String substring(int start)

// 8. 从指定位置开始指定位置结束截取字符串
String substring(int start, int end)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25

转换方法

// 1. 把字符串转换为字节数组
byte[] getBytes()

// 2. 把字符串转换成字符数组
char[] toCharArray()

// 3. 把字符数组转换成字符串
static String valueOf(char[] chs)

// 3. 把int类型的数据转换成字符串
static String valueOf(int i)
// 注意:String类的valueOf方法可以把任何类型的数据转换成字符串!

// 4. 把字符串转换成小写
String toLowerCase()

// 5. 把字符串转换成大写
String toUpperCase()

// 7. 把字符串拼接
String concat(String str)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

其他方法

// 1. 替换功能
String replace(char old,char new)
String replace(String old,String new)

// 2. 去除字符串两端空格
String trim()

// 3. 按字典比较功能
int compareTo(String str)
int compareToIgnoreCase(String str)
1
2
3
4
5
6
7
8
9
10

# 6.深拷贝和浅拷贝的区别?

浅拷贝(浅克隆):基本数据类型为值传递,对象类型为引用传递(两者同生共死)

深拷贝(深克隆):对于对象或者数值,所有元素或者属性均完全复制,与原对象脱离(真正意义上的复制, 两者独立无关)

举例:

public class Book {
    private String name; // 姓名
    private int price; // 价格
    private Partner partner; // 合作伙伴
    // 省略构造函数、get set、toString 等
}
1
2
3
4
5
6
public class Partner{
    private String name;

// 省略构造函数、get set、toString 等
}
1
2
3
4
5

浅拷贝用到拷贝,首先就对 Book 类进行处理

  • 首先实现 Cloneable 接口
  • 接着重写 clone 方法
public class Book implements Cloneable{
    private String name; // 姓名
    private int price; // 价格
    private Partner partner; // 合作伙伴

    @Override
    protected Object clone() throws CloneNotSupportedException {
        return super.clone();
    }

    // 省略构造函数、get set、toString 等
}

1
2
3
4
5
6
7
8
9
10
11
12
13

再来测试一下

public class Test {
    public static void main(String[] args) throws CloneNotSupportedException {
        // 初始化一个合作伙伴类型
        Partner partner = new Partner("张三");
        // 带参赋值
        Book bookA = new Book("理想二旬不止", 66, partner);
        // B 克隆 A
        Book bookB = (Book) bookA.clone();

        System.out.println("A: " + bookA.toString());
        System.out.println("A: " + bookA.hashCode());
        System.out.println("B: " + bookB.toString());
        System.out.println("B: " + bookB.hashCode());
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

执行结果

A: Book{name='理想二旬不止', price=66, partner=Partner{name=张三}} A: 460141958 B: Book{name='理想二旬不止', price=66, partner=Partner{name=张三}} B: 1163157884

结果非常明显,书籍信息是一致的,但是内存地址是不一样的,也就是说确实克隆成功了,打印其 hashCode 发现两者并不相同,说明不止指向同一个,也是满足我们要求的

到这里并没有结束,你会发现还是有问题,当你刊印的过程中修改一些值的内容的时候,你看看效果

public class Test {
    public static void main(String[] args) throws CloneNotSupportedException {
        // 初始化一个合作伙伴类型
        Partner partner = new Partner("张三");
        // 带参赋值
        Book bookA = new Book("理想二旬不止", 66, partner);
        // B 克隆 A
        Book bookB = (Book) bookA.clone();
        // 修改数据
        bookB.getPartner().setName("李四");
        bookB.setPrice(44);

        System.out.println("A: " + bookA.toString());
        System.out.println("A: " + bookA.hashCode());
        System.out.println("B: " + bookB.toString());
        System.out.println("B: " + bookB.hashCode());
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

执行结果

A: Book{name='理想二旬不止', price=66, partner=Partner{name=李四}} A: 460141958 B: Book{name='理想二旬不止', price=66, partner=Partner{name=李四}} B: 1163157884

???这不对啊,B 明明是克隆 A 的,为什么我在克隆后,修改了 B 中两个值,但是 A 也变化了啊

这就是典型的浅克隆,在 Book 类,当字段是引用类型,例如 Partner 这个合作伙伴类,就是我们自定义的类,这种情况不复制引用的对象,因此,原始对象和复制后的这个 Partner 对象是引用同一个对象的。而作为基本类型的的值就没事。

如何解决上面的问题呢,我们需要重写主类的 clone 的内容(改为深拷贝),同时在引用类型中也实现浅拷贝

A:被引用类型实现浅克隆

public class Partner implements Cloneable {
    private String name;

    @Override
    protected Object clone() throws CloneNotSupportedException {
        return super.clone();
    }
    // 省略构造函数、get set、toString 等
}
1
2
3
4
5
6
7
8
9

B:修改引用类 cloen 方法

public class Book implements Cloneable{
    private String name; // 姓名
    private int price; // 价格
    private Partner partner; // 合作伙伴

    @Override
    protected Object clone() throws CloneNotSupportedException {
		Object clone = super.clone();
        Book book = (Book) clone;
        book.partner =(Partner) this.partner.clone();
        return clone;
    }
	// 省略构造函数、get set、toString 等
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

C:测试一下

public class Test {
    public static void main(String[] args) throws CloneNotSupportedException {
        // 初始化一个合作伙伴类型
        Partner partner = new Partner("张三");
        // 带参赋值
        Book bookA = new Book("理想二旬不止", 66, partner);
        // B 克隆 A
        Book bookB = (Book) bookA.clone();
        // 修改数据
        partner.setName("李四");

        System.out.println("A: " + bookA.toString());
        System.out.println("A: " + bookA.hashCode());
        System.out.println("B: " + bookB.toString());
        System.out.println("B: " + bookB.hashCode());
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

执行效果

A: Book{name='理想二旬不止', price=66, partner=Partner{name=李四}} A: 460141958 B: Book{name='理想二旬不止', price=66, partner=Partner{name=张三}} B: 1163157884

可以看到,B 克隆 A 后,修改 A 中 合作伙伴 的值,没有受到影响,这也就是我们通常意义上想要实现的效果了。

# 7.为什么重写 toString() 方法?

主要目的还是为了简化输出

  1. 在类中重写 toString()后,输出类对象就变得有了意义(输出 s 和 s.toString()是一样的 ,不写也会默认调用),变成了我们实实在在的信息 ,例如 Student{name='admin', age=20},而不是上面的 cn.ideal.pojo.Student@1b6d3586

  2. 如果我们想要多次输出 类中的成员信息,就需要多次书写 ge t 方法(每用一次就得写)

toString() 方法,返回该对象的字符串表示。

Object 类的 toString 方法返回一个字符串,该字符串由类名(对象是该类的一个实例)at 标记符 @ 和此对象哈希码的无符号十六进制表示组成。换句话说,该方法返回一个字符串,它的值等于:

代码:getClass().getName()+ '@' + Integer.toHexString(hashCode())

通常我们希望, toString 方法会返回一个“以文本方式表示” 此对象的字符串。结果应是一个简明但易于读懂的信息表达式。因此建议所有子类都重写此方法。

# 8.字符串使用 += 赋值

字符串使用 += 赋值后,原始的 String 对象中的内容会改变吗?

答案:不会

/*
 *  字符串特点:一旦被赋值,就不能改变
 */
public class StringDemo {
    public static void main(String[] args) {
        String s = "Hello";
        s += "World";
        System.out.println("s:" + s);
    }
}

//运行结果:
s:HelloWorld
1
2
3
4
5
6
7
8
9
10
11
12
13

**解释:**不能改变是指字符串对象本身不能改变,而不是指对象的引用不能改变,上述过程中,字符串本身的内容是没有任何变化的,而是分别创建了三块内存空间,(Hello) (World) (HelloWorld) Hello + World 拼接成 HelloWorld 这时,s 不指向原来那个 “Hello” 对象了,而指向了另一个 String 对象,内容为 “HelloWorld ” ,原来那个对象还存在内存中,只是 s 这个引用变量不再指向它了。

总结:开发中,尽量少使用 + 进行字符串的拼接,尤其是循环内,我们更加推荐使用 StringBuild、StringBuffer。

# 9.字符串构造函数赋值

字符串构造函数赋值和直接赋值的区别?

通过 new 构造函数创建字符串对象。String s = new String("Hello"); 系统会先创建一个匿名对象 "Hello" 存入堆内存,而后 new 关键字会在堆内存中又开辟一块新的空间,然后把"Hello"存进去,并且把地址返回给栈内存中的 s, 刚才的匿名对象 "Hello" 就变成了一个垃圾对象,因为它没有被任何栈中的变量指向,会被 GC 自动回收。

直接赋值。如 String str = "Hello"; 首先会去字符串常量池中找有没有一个"Hello"对象,如果没有,则新建一个,并且入池,所以此种赋值有一个好处,下次如果还有 String 对象也用直接赋值方式定义为“Hello”, 则不需要开辟新的堆空间,而仍然指向这个池中的"Hello"。

//两者的区别
String s = new String("Hello");
String s = "Hello";
1
2
3

总结:前者 new 一个对象,“hello”隐式创建一个对象,后者只有“Hello”创建一个对象,在开发中,尽量使用 String s = "Hello" 的方式,效率比另一种高。

# 10.String、StringBuffer、StringBuilder

前面我们用字符串做拼接,比较耗时并且也耗内存(每次都会构造一个新的 string 对象),而这种拼接操作又是比较常见的,为了解决这个问题,Java 就提供了两个字符串缓冲区类。StringBuffer 和 StringBuilder 供我们使用。

简单比较

String:长度大小不可变

StringBuffer:长度可变、线程安全、速度较慢

StringBuilder:长度可变、线程不安全、速度最快

解释:

  1. 在执行速度方面的比较:StringBuilder > StringBuffer

  2. StringBuffer 与 StringBuilder,他们是字符串变量,是可改变的对象,每当我们用它们对字符串做操作时,实际上是在一个对象上操作的,不像 String 一样创建一些对象进行操作,所以速度就快了。

  3. StringBuilder:线程非安全的

    StringBuffer:线程是安全的(synchronized 关键字进行修饰)

当我们在字符串缓冲区被多个线程使用时,JVM 不能保证 StringBuilder 的操作是安全的,虽然他的速度最快,但是可以保证 StringBuffer 是可以正确操作的。当然大多数情况下就是我们是在单线程下进行的操作,所以大多数情况下是建议用 StringBuilder 而不用 StringBuffer 的,就是速度的原因。

对于三者使用的总结:

  1. 如果要操作少量的数据用 String
  2. 单线程操作字符串缓冲区 下操作大量数据 StringBuilder
  3. 多线程操作字符串缓冲区 下操作大量数据 StringBuffer

# 11.字符串 “+” 和 StringBuilder

首先 java 并不支持运算符重载(String 类中的 “+” 和 “+=” 是 Java 中仅有的两个重载过的运算符),所以我们可以通过 “+” 符号 将多个字符串进行拼接

将图中代码(使用了 “+” 符号)利用 javap -c filename 反编译

我们可以看到代码被编译器自动优化成使用 StringBuilder 方式拼接,运行效率得到了保证

image-20240118163759847

下面一个案例 数组拼接成指定格式的字符串 代码中使用了循环语句

// 在循环中通过String拼接字符串
public class StringBuilderDemo {
   public static void main(String[] args) {
       String[] arr = {"Hello", "World", "!!!"};
       String s1 = arrayToString(arr);
       System.out.println(s1);
   }

   public static String arrayToString(String[] arr) {
       String s = "";

       s += "[";
       for (int x = 0; x < arr.length; x++) {
           if (x == arr.length - 1) {
               s += arr[x];
           } else {
               s += arr[x];
               s += ", ";
           }
       }
       s += "]";
       return s;
   }
}
//运行结果
[Hello, World, !!!]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26

使用 String 方式进行拼接,我们反编译可以看到,StringBuilder 被创建在循环的内部,这意味着每循环一次就会创建一次 StringBuilder 对象,这可是一个糟糕的事情。

// 在循环中使用StringBuilder拼接字符串
public class StringBuilderDemo2 {
    public static void main(String[] args) {
        String[] arr = {"Hello", "World", "!!!"};
        String s1 = arrayToString(arr);
        System.out.println(s1);
    }

    public static String arrayToString(String[] arr) {
        StringBuilder s = new StringBuilder();
        s.append("[");
        for (int x = 0; x < arr.length; x++) {
            if (x == arr.length - 1) {
                s.append(arr[x]);
            } else {
                s.append(arr[x]);
                s.append(", ");
            }
        }
        s.append("]");
        return s.toString();
    }
}
//运行结果
[Hello, World, !!!]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25

使用 StringBuilder 方式进行拼接,自行去看一下汇编代码中,不仅循环部分的代码更为简洁,而且它只生成了一个 StringBuilder 对象。显式的创建 StringBuilder 对象还允许你预先为其指定大小。可以避免多次重新分配缓冲。

总结:

如果字符串操作比较简单,就可以使用 “+” 运算符操作,编译器会为你合理的构造出最终的字符串结果

如果使用循环语句 最好自己手动创建一个 StringBuilder 对象,用它来构最终结果

上次更新: 10/29/2024, 10:27:50 AM