Java学习笔记(三):面向对象编程
声明:本篇笔记部分摘自《Java核心技术(卷Ⅰ) - 机械工业出版社》及Java教程-廖雪峰-2025-06-16,遵循CC BY 4.0协议。
存在由AI生成的小部分内容,仅供参考,请仔细甄别可能存在的错误。从本节开始,所有的代码片段都尽量保证完整性,可以直接复制粘贴到IDEA中运行(文件名需为Demo.java以便编译器能找到主类)。代码片段均经过实机运行检测,若运行结果与示例不同,欢迎在文末的评论区指出错误。
第一节 面向对象基础
一、引入
1.面向对象概述
引言:面向对象编程,是一种通过对象的方式,把现实世界映射到计算机模型的一种编程方法。
拿洗衣服为例,涉及到以下流程:
1 | |
经典的面向过程编程,会实现这几个函数然后依次调用:
1 | |
面向对象编程则会将这一系列流程分为对象、动作(方法) 和 字段(属性)。如上例中的人、洗衣机和衣服可以看做是三个独立的对象;人具有放衣服、拿衣服两个动作;洗衣机具有洗衣服、烘干两个动作;而衣服具有是否干净以及是否在洗衣机里的属性。
这样一来,我们就给这些对象定义一系列的行为(又称作方法
、函数)以及属性;程序运行的逻辑就从面向过程的自上而下变成了对象之间的交互;这样的程序设计思想抽象程度更高,也很好地降低了代码之间的耦合度,是一种更加接近现实的程序设计思路。
关于上文中提到的“降低了代码之间的耦合度”,这里给出我的理解,或许不一定正确:
对于一个“人吃饭”的这么一个事儿,面向过程编程定义的函数一般是 吃(人,饭),即参数中包含了人和饭两个对象;而面向对象编程中,一般将吃饭看做是人的行为,将饭作为人这个吃的行为涉及到的另一个对象,于是写成 人.吃(饭), 这样一来,无需像 吃(人,饭) 一样,既要考虑饭,还要考虑人,把人和饭绑定在一起。面向对象的写法中,"吃"是人的普遍行为,只要考虑是吃什么饭,而不用想是什么人来吃,这样就解除了人和饭的绑定,于是就让不同模块的代码之间的耦合度降低了。
需要注意以下两点:
- 面向对象中的“对象”不一定是具象化的物体,比如说小狗、桌子等,也可以是一个抽象的概念,如成绩(具有分数、绩点等属性;修改分数、计算绩点等方法)、字符串(具有长度、内容等属性;计算长度、替换内容等方法)。
- 虽然面向对象看起来比面向过程看起来更加高深莫测,在实际应用时也各有各的优势,不能认为学习了面向对象就看不上/用不着面向过程了。
- 类与对象的关系:类是概念,对象是概念衍生出的实例,即
类 --实例化--> 对象。如同所有的人统称为“人类”,每一个人都是“人类”这一概念下的实体对象。
2.重点学习方向
本篇笔记重点学习Java中面向对象的以下内容:
- 基本概念
- 类
- 实例
- 方法
- 面向对象特性
- 继承
- 多态
- Java提供的一些机制
- package
- classpath
- jar
- Java核心类
- 字符串
- 包装类型
- JavaBean
- 枚举
- 常用工具类
最后同样需要提醒的是,即使学习了面向对象的程序设计思想,也不能保证能找到对象🤣。

二、面向对象基础
1.一个简单的demo
1 | |
Java中类的声明、属性和方法的定义、实例化以及方法的调用都和C几乎相同,这里不再赘述;有C编程经验的同学应该都能看懂上面这个简单的例子。运行程序,输出了小明吃蛋糕的过程:
1 | |
2.数据保护
在面向对象程序设计时,为了防止外部的程序读写对象的属性,从而引起意料之外的错误,通常把属性设置为 private 或 protected(需要继承时),然后通过定义读写的方法 getXXX() 或 setXXX() 来向外暴露接口,其中包含数据验证和读写等逻辑,通过调用这些方法来读取和修改对象中的属性。如下例:
1 | |
运行程序,先输出了对象 s1 的 name 初始值“张三”,然后输出了修改后的值“李四”。
3.this变量
在方法内部,可以使用一个隐含的变量 this ,它始终指向当前实例。因此,通过 this 就可以访问当前实例的字段。
1 | |
小技巧:两个同类的对象进行操作时(如累加、比较),可以在对应的方法形参中将另一个对象命名为
other,这样使用this和other就可以很清晰地弄清楚是在对哪个对象进行操作。
4.可变参数
1 | |
"可变参数"主要"变"的是参数的个数,实现类似于数组的效果。通过指定形参为 数据类型... 形参名 的格式,可以指定这个参数是可变的。可变参数须位于参数列表的末尾,以免混淆前面参数的一一对应关系。
(这里的三个点是英文输入法下的句号 ... 哈,不要打成中文省略号 … 【Shift + 6】了。编程语言中字符串之外的符号应该都是英文符号。)
三、构造方法
与C++类似,Java的构造方法须与类同名,没有返回值,且被声明为 public(不然外部都无法调用构造函数来进行实例化)。一个类可以有多个参数不同的构造方法(即方法重载,其实一般的方法也支持重载),编译器会根据参数自动选择执行相应的构造方法。
一个构造函数还可以调用其他的构造函数以提高代码复用率:
1 | |
需要注意的是,Java不支持C++中通过成员初始化列表定义构造函数,成员变量的赋值必须在函数体中进行:
1 | |
四、继承
1.概述
使用关键字 extend 继承一个现有的类,会自动拥有它的属性和方法。
1 | |
被继承的类被称作父类、基类;相对而言继承父类的类被称作子类、派生类。注意到这里的 Person 类没有 extend ,这种情况下编译器自动添加 extend Object,即上例的继承树是这样的:
1 | |
Java只允许一个class继承自一个类,因此一个类有且仅有一个父类。Object 比较特殊,它没有父类。
与C++相同,继承类也无法访问父类的 private 属性或方法,只能访问 public 和 protected。想要在子类中访问父类的属性,需要像 this 一样使用 super 关键字。
子类应该是以下三种之一:
final(不允许继续继承)sealed(进一步限制继承)non-sealed(开放继承)
与C++不同的是,Java中的继承只有 public 一种,没有 protected 继承和 private 继承。
2.继承的构造过程
1 | |
上面这个程序看起来没啥问题,实际上将它粘贴到编辑器中,就会发现子类的构造函数报错了。提示 'Person'中没有可用的无形参构造函数。这是因为Java在构造子类的对象之前,必须先调用父类的构造方法,如果没有明确调用父类的构造方法,就会默认在子类的构造方法前面加上super()(很理所当然,先有父再有子么),然后一看发现父类没有这么一个无参数的构造函数,于是就无了…
想要解决这个问题也很简单,直接调用 Person 类存在的某个构造方法:
1 | |
如果父类没有默认的构造方法,子类就必须显式调用 super() 并给出参数,以便让编译器定位到父类的一个合适的构造方法。同时也要注意,子类不会继承任何父类的构造方法。子类默认的构造方法是编译器自动生成的,不是继承自父类的。
3.继承的限制
不允许某个类被其他的类所继承,可以使用 final 修饰符打断继承。从Java 15开始,允许使用 sealed 修饰class,并通过 permits 明确写出能够从该class继承的子类名称(子类白名单)。
以下是一个演示Shape父类允许继承三种类型,以及子类的继承方式的写法:
1 | |
运行该程序,由于 Shape 类中没有允许 Line 类继承,该程序会报告如下的错误:
1 | |
为了获得较好的编码体验,建议使用 Intellij IDEA 来进行Java的代码编写工作。IDEA有较好的错误反馈、完整的工具链支持(Git、数据库等),甚至能进行一些简单的代码补全。
4.向上转型
想想一个情景:我们定义了父类 Person 及子类 Student,当然可以使用这两句来分别将其实例化:
1 | |
诶,有的同学就要问了,能不能写成这样咧?

1 | |
蛙趣,竟然没报错!这是是因为 Student 继承自 Person ,因此,它拥有 Person 的全部功能,具备父类的所有属性,也能够执行父类所有的方法。这种把一个子类类型安全地变为父类类型的赋值,被称为向上转型(upcasting),相当于使用子类的构造方法来构造一个父类的对象 怎么感觉有点倒反天罡…
很容易就能猜到,向上转型是可以跨越多层的,比如说继承关系是 a -> b -> c -> d,d可以直接向上转型成a、b以及c这三类。
5.向下转型
和向上转型相反,如果把一个父类类型强制转型为子类类型,就是向下转型(downcasting)。向下转型通常使用强制类型转换实现:
1 | |
由于子类通常比父类有更多的属性或方法,这样向下转型大多数情况下是不允许的,因为父类无法实现一些子类特有的方法。向下转型失败时,编译器会报告 ClassCastException。
为了避免向下转型出错,Java提供了 instanceof 操作符,可以先判断一个实例究竟是不是某种类型,写法为 (a instanceof b),返回一个布尔值表示a是否与b类型相同,或者是否为b的子类。
1 | |
需要向下转型时,可以先判断是否可以转型:
1 | |
从Java 14开始,判断 instanceof 后,可以直接转型为指定变量,避免再次强制转型:
1 | |
6.组合
1 | |
对于这一个 Book 类,也具有 name 字段。但是如果想表示一个 Student 拥有一本书,无论是让 Student 继承 Book 还是让 Book 继承 Student 都显得不太对劲。这里将 Book 作为 Student 的属性就合理了:
1 | |
Student 作为 Person 的子类,是归属(is)的关系,即 Student is Person,这种关系适合使用继承。而对于 Book,应该是 Student has Book 的持有(has)关系,这种关系适合用组合。
五、多态
1.方法覆写
在继承关系中,子类如果定义了一个与父类方法签名(方法名称、参数类型、顺序及数量)完全相同的方法,被称为覆写(Override)。例如:
1 | |
Override(方法覆写)和 Overload(方法重载)不同之处在于方法签名相同。如果不同就是Overload了,Overload定义的是一个新方法;如果方法签名相同,并且返回值也相同,就是 Override。如果方法签名相同,返回值不同,Java编译器会报告错误。
加上 @Override 可以明确告诉编译器这里是覆写方法,让它帮助我们检查是否进行了正确的覆写。这个符号不是必须添加的。即使对子类进行了父类方法的覆写,我们仍然可以通过 super 关键字调用父类中被覆盖掉的方法。
2.实现多态
现在考虑这样一个情况,子类 Student 继承了父类 Person ,并且覆写了 run 方法,那当我们通过 Person p = new Student() 创建一个实际类型为 Person,引用类型为 Student的变量,再调用run方法时,实际调用的是哪一个方法呢?
1 | |
大家可以自己粘贴到IDEA运行一下,这个例子很好地展现了多态这一面向对象的重要特性。结果会输出 "学生跑步",即调用的是子类的覆写方法。
从而我们得出了重要结论:Java的实例方法调用是基于运行时的实际类型的动态调用,而非变量的声明类型。
这样做是有很大好处而且很方便的。例如在税务计算程序中,通过定义一个统一的 Income 父类和 getTax() 方法,然后为不同类型的收入(普通收入、工资收入、国务院津贴)创建子类并覆写各自的税率计算方法。在计算总税费时,只需处理 Income 父类类型,程序会根据实际对象类型自动调用相应的税率计算方法。
这样设计使得系统具有良好的扩展性 —— 新增收入类型时只需添加新的子类,无需修改现有的税务计算逻辑,实现了"对扩展开放,对修改关闭"的设计原则。
1 | |
3.覆写Object方法
Java中所有的类都继承自 Object,这个类定义了一些通用方法:
toString():转换为字符串格式equals():判断两个对象是否相同hashCode():计算对象的哈希值
我们也可以在自己的类中覆写这些方法(是不是有点像运算符重载)。
1 | |
(这段代码有多处错误,你发现了吗?)
前面提到过,方法覆写需要定义完全相同的方法签名,其中包括了参数类型必须相同。所以这里需要改成 Object 类型。还要注意Object 类型不一定有 name 这么一个属性,需要向下转型成 Person 类才能确保可以比较。最后一个问题是 String 作为引用类型,需要调用 equals 方法来比较,不能使用 ==。
最终修改好之后的程序是这样的:
1 | |
六、抽象类
既然我们实现了多态,有的情况下子类各有各的实现方式,父类或许难以实现具体的方法来实现大一统,例如说描述各种动物的叫声。这个时候我们去掉父类方法的方法体肯定不行,去掉整个方法也不行,那咋办咧?
办不成也得办,跟编译器玩抽象↓
1. 抽象方法
如果父类的方法本身不需要实现任何功能,仅仅是为了定义统一的方法签名,目的是让子类去覆写它,那么可以使用关键字 abstract 把父类的方法声明为抽象方法(类似于C++中的纯虚函数):
1 | |
把一个方法声明为 abstract ,表示它是一个抽象方法,本身没有实现任何方法语句。
2.抽象类
实际上就算定义了抽象方法,还是有没解决的问题:抽象方法本身是无法执行的,所以 Person 类也无法被实例化。编译器会告诉我们,无法编译 Person 类,因为它包含抽象方法。
那就干脆把 Person 类定义成抽象的,就引出了抽象类(类似于C++中的纯虚基类):
1 | |
抽象类可以强迫子类实现其定义的抽象方法,否则编译会报错。因此,抽象方法实际上相当于定义了“规范”。
七、接口
1.概述
这里的“接口”并不是指用于接收网络请求的接口,而是在Java中特指定义类的一种范式。在抽象类中,抽象方法本质上是定义子类的规范,自身没有具体的方法和含义。这样的情况下,我们可以使用 interface 将其改写为接口:
1 | |
然后我们使用 implements 关键字,通过 Person 接口实现一个 Student 类:
1 | |
需要注意的是,接口中不需要定义属性和构造函数,这些须在实现类中定义。一个类无法继承多个父类,但是可以实现多个接口。(接口是一种比抽象类更加抽象的存在。)
2. 接口继承
一个接口也可以继承自另一个接口。接口的继承同样使用 extends 关键字。若A继承B,A自动拥有B中定义的抽象方法。
1 | |
3. default方法
在接口中,可以定义 default 方法。例如,把 Person 接口的 run() 方法改为 default 方法:
1 | |
实现类可以不必覆写 default 方法。 default 方法的目的是,当我们需要给接口新增一个方法时,需要给所有的子类也分别添加这个方法的具体实现。但如果新增的是 default 方法,相当是给全部子类都添加了这个方法;那么子类就不必全部修改,只需要在需要覆写的地方去覆写新增方法。
default 方法和抽象类的普通方法是有所不同的。因为接口中没有类的属性, default方法无法访问属性,而抽象类的普通方法可以访问实例的属性。
八、静态字段和静态方法
(Java中习惯将类的“属性”和“函数”分别称为“字段”和“方法”,考虑到专业术语这里沿用这种称呼,大家看多了也会习惯的。实际上这里)
1.静态字段
来考虑这样一个情景:
一个班级中有很多位同学,都有自己的姓名等信息~~(这不废话么)~~,此时需要统计全班同学的人数。常见的思路是定义一个班级类 Class,将全班人数作为该类的一个属性进行处理。但是这样一来就会多定义一个不必要的类,同时还要在学生增减时关联 Class 类中属性的变化,既让代码变长了,还让不同类之间的耦合度变高了,不是一个很优雅的做法。
当然会有同学想到使用全局的公共变量来存储这个字段,但是在实际项目中,非必要情况下不建议使用全局变量。全局变量暴露在整个项目或文件中可以访问,无法确保不会被其他的逻辑意外修改,不能实现数据保护。
来看这样的写法:
1 | |
通过 static 关键字声明人数为静态字段,实现了 Student 对象对于人数这一属性的共享,通过在类被构造和回收(Java中没有析构方法,而且不建议在回收时进行处理,因为这样做不稳定,于是在这个例子中应用了毕业的情景来实现人数的减少)时对静态字段进行处理,这样无论访问的是哪一个对象的静态字段,得到的都是它们共享的值。
说句题外话,这个例子让我印象很深刻,因为它不仅展示了静态字段的特性和写法,还很好地展示了静态字段的使用场景。我们学习编程语言的一些特性时,不仅要明白这些技术是什么,怎么写;更应该思考为什么这样做,什么情况下需要这样做。 后者带来的是程序设计思想的提升和进阶,能够让我们在面对一些复杂的情境下仍然能写出优雅精简的程序,快准狠地解决实际问题。
2.静态方法
有静态字段,就有静态方法。用 static 修饰的方法称为静态方法。调用实例方法必须通过一个实例变量,而调用静态方法则不需要实例变量,通过类名就可以调用。
因为静态方法属于 class 而不属于实例,因此,静态方法内部,无法访问 this 变量,也无法访 问实例字段,它只能访问静态字段。 当然,通过实例变量也可以调用静态方法。
静态方法经常用于工具类。例如:
- Arrays.sort()
- Math.random()
静态方法也经常用于辅助方法。注意到Java程序的入口 main() 也是静态方法。
3.接口的静态字段
接口作为纯抽象类,无法定义普通的属性,但是可以定义静态字段。静态字段在接口中必须声明为 final 类型:
1 | |
九、包
1.概述
有时两个开发者定义了相同的类名,就会产生冲突。为了解决这种冲突,可以考虑使用一种方法来限定类的作用范围,在C++中称之为命名空间,Java中则称之为包。
Java推荐使用包将不同模块的类放到不同的文件中,根据文件夹的组织形式定义包的名称,在文件的第一行使用 package 关键字定义包名,然后在包中将类定义为 public。
例如,小明和小军一起进行开发,他们都定义了Person这个类,文件夹的组织形式如下:
1 | |
那么,小军的Person.Java应该这样写:
1 | |
1 | |
从这个例子中可以看出包可以是多层结构,用 . 隔开。例如: java.util。但是需要注意包没有父子的关系,如 java.util 和 java.util.zip 是不同的包,两者之间没有继承关系。
一个类总是属于某个包的。如果没有声明,这个类就属于默认包。类的完整名称是 包.类 ,JVM根据这个完整的名称来辨识不同包中的类。即只要包不同,即使类名相同也,不是同一个类。
2.包作用域
位于同一个包的类,可以访问包作用域的字段和方法。不用 public 、 protected 、 private 修饰的字段和方法就是包作用域。
3.包的导入
有时我们需要在一个包中使用另一个包中的类,这时就需要使用 import 导入另外的包了。有三种导入方法:
① 直接写完整类名
1 | |
② 使用 import 语句
1 | |
这里可以使用通配符 * 来导入包中的所有类,如 import conponents.ming.*;
③ 导入另一个包的静态方法和静态字段
1 | |
这种方式使用得较少。
4.避免包的重名
通常为了防止出现包的重名,需要确定包名唯一,一般使用域名反写:
1 | |
也要注意不要与Java中已有的类和重名:
- String
- System
- Runtime
- java.util.List
- java.text.Format
- java.math.BigInteger
- …
十、作用域
与C++类似,类中的字段和方法有 public 、 private 以及 protected 三种,public 可以在类外访问,private 只能在类内访问,而 protected 多用于在继承中将方法或字段暴露给子类。
另外,包作用域是指一个类允许访问同一个包的没有 public、private 修饰的 class ,以及没有 public、protected、private 修饰的字段和方法。
在方法内部定义的变量称为局部变量,局部变量作用域从变量声明处开始到对应的块结束。方法参数也是局部变量。Java中不建议使用全局变量,这样做不利于模块化以及数据保护。
我们来回顾一下 final 这个修饰符,它在数据保护中有“终止”的意味,包括终止类的继承、禁止子类复写、禁止重新赋值(定义常量)等。
十一、内部类
在Java程序中,通常情况下,我们把不同的类组织在不同的包下面,对于一个包下面的类来说,它们是在同一层次,没有父子关系。有些情况下,我们也会将一个类放在另一个类的内部进行定义,这个类就称为内部类(Inner Class)。
1.内部类的声明与实例化
内部类无法单独存在,必须依附于一个Outer Class(外部类),类似于这个外部类的一个属性。也就是说,实例化内部类之前必须先实例化内部类:
1 | |
注意内部类的实例化写法是:外部类.内部类 内部对象名 = 外部对象.new 内部类构造方法();。内部类作为外部类的一个字段,可以访问外部类的 private 属性和方法。
2.匿名类
在类的方法内部,定义一个匿名类(Anonymous Class),也会定义一个内部类。
1 | |
map1 是一个普通的 HashMap 实例,但 map2 是一个匿名类实例,只是该匿名类继承自 HashMap。map3 也是一个继承自 HashMap 的匿名类实例,并且添加了 static 代码块来初始化数据。观察编译输出可发现 Main$1.class 和 Main$2.class 两个匿名类文件。
3.静态内部类
使用 static 定义的内部类称为静态内部类(Static Nested Class)。用 static 修饰的内部类和普通内部类有很大的不同,它不再依附于外部类的实例对象,而是一个完全独立的类,因此无法引用 Outer.this ,但它可以访问外部类的 private 静态字段和静态方法。如果把静态内部类移到外部类之外,就失去了访问 private 的权限。
十二、classpath和jar
1.Classpath
classpath 是JVM用到的一个环境变量,它用来指示JVM如何搜索定义的类。现代使用的IDE如Eclipse、Intellij IDEA会自动配置这个变量,这里就不深入学习了,简单概括一下廖老师的文章;大家感兴趣的话可以研究一下,点击访问原文地址:
① classpath的作用
- classpath是JVM用于搜索编译后的.class文件的环境变量(一组目录集合)。
- JVM根据
classpath中的路径来查找需要加载的类(例如abc.xyz.Hello对应abc/xyz/Hello.class)。 - 搜索顺序是从左到右,一旦找到就停止搜索;如果所有路径都未找到,则报错。
② classpath的格式
- Windows:用分号
;分隔,含空格的路径需用双引号括起(示例:.;C:\work\bin;"D:\My Documents\bin")。 - Linux/Mac:用冒号
:分隔(示例:.:/usr/shared:/home/user/bin)。
③ 设置classpath的两种方式
- 不推荐:在系统环境变量中设置classpath(会污染系统环境)。
- 推荐:启动JVM时通过
-classpath(或-cp)参数指定(仅对当前进程有效)。
1 | |
- 默认行为:如果不设置任何classpath,JVM默认使用当前目录(
.)作为classpath。
④ 重要注意事项
- 无需添加Java核心库(如
rt.jar):JVM会自动加载核心库(例如String、ArrayList等),手动添加反而可能导致问题。 - IDE的处理:IDE(如Eclipse、IntelliJ IDEA)会自动设置classpath(通常包括项目的
bin目录和依赖的jar包)。 - 目录结构必须匹配包名:
例如,类com.example.Hello必须位于com/example/Hello.class。
如果当前目录是C:\work,则完整路径应为C:\work\com\example\Hello.class,并使用命令:
1 | |
⑤ 实操建议
- 避免设置系统级classpath,始终通过
-cp参数传递。 - 默认使用当前目录(
.) 通常足够满足大部分场景。 - 确保目录结构与包名一致,否则JVM无法找到类。
2.jar包
此部分忽略,实际项目中通常使用比较成熟的构建工具(如maven)来打包。
十三、class版本
通常我们提到的Java 8,Java11,Java 21;指的是Java的JDK版本
在cmd中执行命令 java -version,返回的是JDK的版本,也是JVM的版本,即 Java.exe 的版本。

每个版本的JVM执行的class文件(字节码文件)版本也不同。例如,Java 11对应的class文件版本是55,而Java 17对应的class文件版本是61。Java是向下兼容的,即使用旧版本JDK编写的程序和字节码能在高版本的JVM上执行,而高版本的Java通常定义了新的方法和语句,新版本JDK编写的程序和字节码可能难以在旧版本的JVM上运行。
十四、模块
为了实现Java的模块化,从Java 9开始,原有的Java标准库已经由一个单一巨大的 rt.jar 分拆成了几十个模块,这些模块以 .jmod 扩展名标识,可以在 $JAVA_HOME/jmods 目录下找到它们:
- java.base.jmod
- java.compiler.jmod
- java.datatransfer.jmod
- java.desktop.jmod
- …
把一堆class封装为jar仅仅是一个打包的过程,而把一堆class封装为模块则不但需要打包,还需要写 入依赖关系,并且还可以包含二进制代码(通常是JNI扩展)。此外,模块支持多版本,即在同一个 模块中可以为不同的JVM提供不同的版本。
这里模块的编写、运行以及JRE打包暂时忽略,以后需要使用时再来学习。
第二节 Java核心类
一、字符串和编码
1.String类
在Java中, String 是一个引用类型,它本身也是一个类,可以由构造方法定义一个字符串实例。但是Java编译器对 String 有特殊处理,可以直接用双引号 "" 来定义一个字符串。
1 | |
2.String类的常用方法
String类型已在前文探讨过,此处只补充一些实用的方法。
① trim():去除首尾的空白字符
1 | |
这里的空白字符不仅包含空格,还包含\t、\r以及\n等转义字符。
另外 strip() 方法也用于去除空白符,但也会移除中文空格"\u3000"等字符。
② 字符串与char[]的转换
1 | |
这里需要注意的是,使用 toCharArray() 将字符串转换成数组后对数组进行处理,原来的字符串不会变化。这样做其实也好理解,字符串作为不可变的类型,没有实现变化内容的方法。这里的转换更多是复制拷贝,而不是将两个变量关联起来。
StringBuilder、StringJoiner这两个对象已在前文提过,这里不再赘述。
四、包装类型
Java中的数据分为两种:
- 基本类型:
byte、byte,short,int,long,boolean,float,double,char; - 引用类型:基于类和接口的数据类型,如
String。
引用类型可以赋值为null,表示空,但基本类型不能赋值为null
想要实现以引用类型的方式来处理基本类型,我们可以将其包装成类。如定义 Int 类,让它只包含一个字段 private int value = 0; 。这样一来,Int 类就可以视为 int 的包装类。实际上无需我们来进行包装,Java的核心库已经定义好了这些基本类型对顶的包装类型:
| 基本类型 | 对应的引用类型 |
|---|---|
| boolean | java.lang.Boolean |
| byte | java.lang.Byte |
| short | java.lang.Short |
| int | java.lang.Integer |
| long | java.lang.Long |
| float | java.lang.Float |
| double | java.lang.Double |
| char | java.lang.Character |
这些包装对象都是不可变的,对他们进行比较也不能使用 a == b,应该使用 a.equals(b)。
静态工厂方法
Integer 类有一个方法 ValueOf(),会将输入转换成一个 Integer 对象。使用此方法也可以同来创建 Integer 类的对象:
1 | |
这两种写法几乎等效,但是下面的更好。当我们使用 Integer.valueOf(int i) 时,如果传入的 i 在 -128 到 127 之间,方法会直接从内部的缓存数组中返回一个已经创建好的、相同的 Integer 对象。如果传入的值超出了这个范围,则会 new 一个新的 Integer 对象。
使用这样的方法有利于提升性能和节省内存,是一种比 new 更好的处理方式。
五、JavaBean
很多情况下,我们将类的属性设置为private,并且使用 public 方法来暴露读取或修改属性的“接口”,如 getXxx() 和 setXxx()。
1.JavaBean概述
如果一个类满足这些特点,我们可以说它是一个 JavaBean(看到这个词我的第一印象是咖啡豆?):
- 拥有无参的公共构造函数
- 所有的属性均为
private - 提供了
public的属性读写方法,并且命名成getXxx()及SetXxx()的形式
对于 boolean 类型的字段,读写方法应该是这种格式:
1 | |
我们通常把一组对应的读方法(getter)和写方法(setter)称为属性(property)。只有getter的属性称为只读属性(read-only),只有setter的属性称为只写属性(write-only)。
只读属性比较常见,而只写属性就相对很少使用了。
这里之所以把这两个读写方法称之为属性而不是方法,是因为只需要定义 getter 和 setter,即可,不一定要有对应的字段,如:
1 | |
这里可以直接通过 isAdult(),根据类中的 age 字段判断是否为成年人,而无需再添加一个 boolean 类型的字段,这样看来 isAdult() 更像是标记了类的一种属性。正是由于对应的字段可能是虚拟的(或者说是间接的),这样的读写(主要是读取)更像是在操作类的一种属性,所以这里更倾向于认为它是一种属性而不是方法。
2.JavaBean的作用
JavaBean主要用来传递数据,即把一组数据组合成一个JavaBean便于传输。此外,JavaBean可以方便地被IDE工具分析,生成读写属性的代码,主要用在图形界面的可视化设计中。在IDE中也可以快速生成 getter 和 setter。
六、枚举类
枚举的基础知识已经在前文介绍过了,这里结合面向对象进行一些拓展:
1.枚举也是一种类型
通过 enum 定义的枚举也是一个 class,并且与其他的类没有很大的差异,主要具有以下几个特点:
- 定义的
enum类型总是继承自java.lang.Enum,且无法被继承; - 只能定义出
enum的实例,而无法通过new操作符创建enum的实例; - 定义的每个实例都是引用类型的唯一实例;
- 可以将
enum类型用于switch语句。
1 | |
我们可以使用这些方法对枚举进行操作,或者获取枚举的信息:
- name():返回枚举项的名称
- ordinal():返回枚举项的编号
- toString():返回枚举项的名称,但是可以进行覆写(不建议用于判断枚举项名称,可以覆写后用于优化输出格式)
1 | |
七、记录类
使用String、Integer等类型的时候,这些类型都是不变类,一个不变类具有以下特点:
- 定义class时使用
final,无法派生子类; - 每个字段使用
final,保证创建实例后无法修改任何字段。
1.record类
Java 14之后,我们可以使用 record 类定义一个记录类:
1 | |
可以看到,除了用 final 修饰class以及每个字段外,编译器还自动为我们创建了构造方法,和字段名同名的方法,以及覆写 toString()、equals() 和 hashCode() 方法(良心大大的好啊)。
使用record关键字,可以一行写出一个不变类。和enum类似,我们自己不能直接从Record派生,只能通过record关键字由编译器实现继承。
2.记录类的构造
我们也可以手动修改构造类,比如说加上发现参数非法就抛出异常的处理:
1 | |
3.添加静态方法
通常我们定义一个 of() 静态方法,用于创建类对应的实例:
1 | |
这样我们可以通过 var p = Point.of(1,2); 来实例化一个类的对象,大家看出来这属于前文提到的静态工厂方法了吗?这样做有利于节省内存和提升性能,是一种推荐的做法。
八、BigInteger
如果我们在开发数据量很大的项目(人口统计系统、银行储蓄管理系统等)时,或许会超过 long 类型的表示范围。
在Java中,可以通过 Java.math.BigInteger 表示任意大小的整数。其内部通过 int[] 数组来模拟一个很大很大的整数。
1 | |
1.BigInteger的计算
对BigInteger做运算的时候,只能使用实例方法。下表列出了 BigInteger 的常用运算方法;无需记忆,用时查表即可。
| 类别 | 方法签名 | 描述 |
|---|---|---|
| 算术运算 | BigInteger add(BigInteger val) |
返回 this + val 的和 |
BigInteger subtract(BigInteger val) |
返回 this - val 的差 |
|
BigInteger multiply(BigInteger val) |
返回 this * val 的积 |
|
BigInteger divide(BigInteger val) |
返回 this / val 的商(整数除法) |
|
BigInteger[] divideAndRemainder(BigInteger val) |
返回一个数组,包含 [商, 余数] |
|
BigInteger remainder(BigInteger val) |
返回 this % val 的余数 |
|
| 模运算 | BigInteger mod(BigInteger m) |
返回 this mod m(模数必须为正数) |
BigInteger modPow(BigInteger exponent, BigInteger m) |
返回 (this^exponent) mod m |
|
BigInteger modInverse(BigInteger m) |
返回 this^(-1) mod m(乘法逆元) |
|
| 位运算 | BigInteger and(BigInteger val) |
返回 this & val(按位与) |
BigInteger or(BigInteger val) |
返回 this | val(按位或) |
|
BigInteger xor(BigInteger val) |
返回 this ^ val(按位异或) |
|
BigInteger not() |
返回 ~this(按位取反) |
|
BigInteger shiftLeft(int n) |
返回 this << n(左移n位) |
|
BigInteger shiftRight(int n) |
返回 this >> n(算术右移n位) |
|
| 比较运算 | int compareTo(BigInteger val) |
比较大小。返回负数、零或正数,分别表示 this < val, this == val, this > val |
boolean equals(Object x) |
判断值是否相等(与compareTo一致,不同于==) |
|
| 其他运算 | BigInteger abs() |
返回绝对值 |
BigInteger negate() |
返回相反数 (-this) |
|
BigInteger pow(int exponent) |
返回 this^exponent(指数) |
|
BigInteger gcd(BigInteger val) |
返回 this 和 val 的最大公约数 (GCD) |
|
BigInteger sqrt() |
返回 this 的整数平方根 |
|
BigInteger nextProbablePrime() |
返回第一个大于 this 的素数(概率性) |
|
boolean isProbablePrime(int certainty) |
判断此 BigInteger 是否为素数(概率性测试) |
重要说明:
-
不可变性 (Immutability):
BigInteger和BigDecimal对象是不可变的。所有上述方法执行运算后都会返回一个全新的对象,原来的对象值不会被修改。 -
静态常量:
BigInteger类提供了常用的静态常量,方便使用:BigInteger.ZERO:表示数字0;BigInteger.ONE:表示数字1;BigInteger.TWO:表示数字2;BigInteger.TEN:表示数字10。
-
性能考量:由于不可变性和任意精度,
BigInteger的运算开销远大于基本数据类型(如int,long)。应在确实需要处理大整数时才使用它。 -
素数测试:
nextProbablePrime()和isProbablePrime()方法中使用的是概率性测试(米勒-拉宾算法)。参数certainty表示对确定度的衡量,值越大,结果是素数的概率越高,但计算时间也更长。
2.类型转换
1 | |
使用longValueExact()方法时,如果超出了long型的范围,会抛出ArithmeticException。
BigInteger和Integer、Long一样,也是不可变类,并且也继承自Number类。因为Number定义了转换为基本类型的几个方法:
- 转换为
byte:byteValue() - 转换为
short:shortValue() - 转换为
int:intValue() - 转换为
long:longValue() - 转换为
float:floatValue() - 转换为
double:doubleValue()
如果BigInteger表示的范围超过了基本类型的范围,转换时将丢失高位信息,即结果不一定是准确的。如果需要准确地转换成基本类型,可以使用intValueExact()、longValueExact()等方法,在转换时如果超出范围,将直接抛出ArithmeticException异常。
如果BigInteger的值甚至超过了float的最大范围(3.4x1038),会返回无限值 infinity
九、BigDecimal
和BigInteger类似,BigDecimal可以表示一个任意大小且精度完全准确的浮点数。
1 | |
可以使用 scale() 方法获取小数的位数。如果一个BigDecimal的scale()返回负数,例如,-2,表示这个数是个整数,并且末尾有2个0。
对BigDecimal做加、减、乘时,精度不会丢失,但是做除法时,存在无法除尽的情况,这时,就必须指定精度以及如何进行截断:
1 | |
还可以对BigDecimal做除法的同时求余数:
1 | |
调用divideAndRemainder()方法时,返回的数组包含两个BigDecimal,分别是商和余数,其中商总是整数,余数不会大于除数。
需要注意的是,在比较两个BigDecimal的值是否相等时,要特别注意,使用equals()方法不但要求两个BigDecimal的值相等,还要求它们的scale(),即小数点后的位数相等。
十、常用工具类
1.Math类
多用于数学计算,提供了很多静态方法:
求绝对值:
1 | |
取最大或最小值:
1 | |
计算 :
1 | |
计算 :
1 | |
计算 :
1 | |
计算 (底为e的对数):
1 | |
计算 (底为10的对数):
1 | |
三角函数:
1 | |
Math还提供了几个数学常量:
1 | |
生成一个随机数x,x的范围是0 <= x < 1:
1 | |
如果我们要生成一个区间在[MIN, MAX)的随机数,可以借助Math.random()实现,计算如下:
1 | |
2.Random类
Random用来创建伪随机数。所谓伪随机数,是指只要给定一个初始的种子,产生的随机数序列是完全一样的。
要生成一个随机数,可以使用nextInt()、nextLong()、nextFloat()、nextDouble():
1 | |
有同学问,每次运行程序,生成的随机数都是不同的,没看出伪随机数的特性来。
这是因为我们创建Random实例时,如果不给定种子,就使用系统当前时间戳作为种子,因此每次运行时,种子不同,得到的伪随机数序列就不同。
如果我们在创建Random实例时指定一个种子,就会得到完全确定的随机数序列:
1 | |
前面我们使用的Math.random()实际上内部调用了Random类,所以它也是伪随机数,只是我们无法指定种子。
3.SecureRandom
真正的真随机数只能通过量子力学原理来获取,我们想要获取一个不可预测的安全的随机数时,可以使用SecureRandom这个类。
1 | |
SecureRandom无法指定种子,它使用RNG(random number generator)算法。JDK的SecureRandom实际上有多种不同的底层实现,有的使用安全随机种子加上伪随机数算法来产生安全的随机数,有的使用真正的随机数生成器。实际使用的时候,可以优先获取高强度的安全随机数生成器,如果没有提供,再使用普通等级的安全随机数生成器:
1 | |
SecureRandom的安全性是通过操作系统提供的安全的随机种子来生成随机数。这个种子是通过CPU的热噪声、读写磁盘的字节、网络流量等各种随机事件产生的“熵”。
在密码学中,安全的随机数非常重要。如果使用不安全的伪随机数,所有加密体系都将被攻破。因此,时刻牢记必须使用SecureRandom来产生安全的随机数。
参考资料
- 廖雪峰的官方网站.Java教程[EB/OL].(2025-06-07)[2025-08-21]. https://liaoxuefeng.com/books/java/introduction/index.html ↩