注释
单行注释
1 | // 这是一个单行注释 |
多行注释
1 | /* |
文档注释
1 | /** |
注意: 文档注释一般是写在方法和类的上面.
基本数据类型
| 数据类型 (Data Type) | 字节 (Bytes) | 位数 (Bits) | 默认值 | 取值范围 / 说明 |
|---|---|---|---|---|
| byte | 1 | 8 | 0 | -128 到 127 |
| short | 2 | 16 | 0 | -32768 到 32767 |
| int | 4 | 32 | 0 | -2^31 到 2^31 - 1 |
| long | 8 | 64 | 0L | -2^63 到 2^63 - 1 |
| float | 4 | 32 | 0.0f | 单精度浮点数 |
| double | 8 | 64 | 0.0d | 双精度浮点数 |
| char | 2 | 16 | '\u0000' |
存储单个 Unicode 字符 |
| boolean | 不确定 | 逻辑上 1 | false |
只有 true 和 false 两个值 |
关键字与标识符
关键字
关键字是预先定义好的赋予了特殊含义的单词.
主要特点:
- 全部小写.
- 被 Java 语言保留,不能用作标识符(即不能用它们来给变量、方法、类等命名).
- 在常见的代码编辑器中,关键字通常会以高亮颜色显示.
标识符
标识符就是程序员在编程时为变量、方法、类、接口、包等程序元素自定义的名称。
主要特点:
- 可以由字母(A-Z, a-z)、美元符号(
$)、下划线(_)和数字(0-9)组成. - 不能以数字开头.
- 不能是 Java 的关键字或保留字(如
int,class,goto等). - 严格区分大小写.
命名规范
这是业界的最佳实践,虽然不遵守也能编译通过,但强烈建议遵循.
- 类名和接口名:
- 采用大驼峰命名法 (Upper Camel Case).
- 每个单词的首字母都大写.
- 示例:
public class HelloWorld,interface Runnable
- 变量名和方法名:
- 采用小驼峰命名法 (lower Camel Case).
- 第一个单词首字母小写,从第二个单词开始,每个单词的首字母大写.
- 示例:
int studentAge;,String userName;,void calculateSum()
- 常量名:
- 所有字母都大写,单词之间用**下划线(
_)**连接. - 通常由
public static final修饰. - 示例:
public static final int MAX_USERS = 100;,final double PI = 3.14;
- 所有字母都大写,单词之间用**下划线(
- 包名:
- 所有字母都小写,通常是公司或组织的域名的反向形式.
- 示例:
package com.google.common;,package java.util;
方法重载
方法重载是面向对象的一种概念,方法重载就是指在同一个类里,允许多个方法拥有相同的名字,但它们的参数列表必须不同(类型不同,个数不同,顺序不同).
1 | class Printer { |
逻辑运算符
| 运算符 | 名称 | 运算类型 | 功能说明 | 特点 |
|---|---|---|---|---|
& |
按位与 / 逻辑与(非短路) | 位运算 / 布尔运算 | 位运算:两个二进制位都为 1 才为 1;布尔运算:两个条件都为 true 才为 true |
不会短路,两个表达式都会执行 |
| ` | ` | 按位或 / 逻辑或(非短路) | 位运算 / 布尔运算 | 位运算:只要有一个二进制位为 1 就为 1;布尔运算:只要有一个条件为 true 就为 true |
! |
逻辑非 | 布尔运算 | 取反运算,true → false,false → true |
只能用于布尔类型 |
^ |
按位异或 / 逻辑异或 | 位运算 / 布尔运算 | 位运算:两个二进制位不同为 1;布尔运算:两个条件不同为 true |
常用于判断差异或变量交换 |
&& |
逻辑与(短路) | 布尔运算 | 两个条件都为 true,结果才为 true |
短路:左边为 false 时,右边不执行 |
| ` | ` | 逻辑或(短路) | 布尔运算 |
后缀补全
Java 后缀补全用法大全
这份表格适用于在 .java 文件中进行 Android 或其他 Java 项目开发.
| 分类 | 模板 | 说明 | 输入示例 | 生成代码 |
|---|---|---|---|---|
| 变量声明 | .var |
为表达式的结果创建一个局部变量 | new User("admin").var |
User user = new User("admin"); |
.field |
为表达式的结果创建一个类的成员变量(Field) | new Button(this).field |
private final Button button; (在类的顶部声明) |
|
| 循环 | .for |
遍历一个 Iterable 对象(增强 for 循环) |
getUsers().for |
for (User user : getUsers()) { ... } |
.fori |
使用索引遍历数组或 List(传统 for 循环) |
userList.fori |
for (int i = 0; i < userList.size(); i++) { ... } |
|
.forr |
使用索引反向遍历数组或 List |
userList.forr |
for (int i = userList.size() - 1; i >= 0; i--) { ... } |
|
| 条件与分支 | .if |
基于布尔表达式生成 if 语句 |
user.isActive().if |
if (user.isActive()) { ... } |
.else |
基于布尔表达式生成 if (!expr) 语句 |
list.isEmpty().else |
if (!list.isEmpty()) { ... } |
|
.switch |
为表达式生成 switch 语句 |
user.getRole().switch |
switch (user.getRole()) { ... } |
|
| 空值检查 | .null |
生成 if (expr == null) 检查 |
currentUser.null |
if (currentUser == null) { ... } |
.notnull / .nn |
生成 if (expr != null) 检查 |
currentUser.notnull |
if (currentUser != null) { ... } |
|
| 类型操作 | .cast |
将对象强制类型转换 | view.cast |
((Type) view) (光标在 Type 位置) |
.instanceof |
生成 instanceof 类型检查 |
obj.instanceof |
if (obj instanceof Type) { ... } (光标在 Type 位置) |
|
| 代码包裹 | .try |
将语句包裹在 try-catch 块中 |
someRiskyOperation().try |
try { someRiskyOperation(); } catch (Exception e) { e.printStackTrace(); } |
.synchronized |
将语句包裹在 synchronized 块中 |
this.synchronized |
synchronized (this) { ... } |
|
.sout |
用 System.out.println() 打印表达式 |
"Hello".sout |
System.out.println("Hello"); |
|
| 返回与抛出 | .return |
从方法返回表达式的值 | buildUser().return |
return buildUser(); |
.throw |
抛出一个 Throwable 异常 |
new Exception().throw |
throw new Exception(); |
|
| Android 专用 | .toast |
显示一个 Toast 提示 | "Login failed".toast |
Toast.makeText(context, "Login failed", Toast.LENGTH_SHORT).show(); |
.logd |
用 Log.d 打印 Debug 级别日志 |
message.logd |
Log.d(TAG, message); |
|
.loge |
用 Log.e 打印 Error 级别日志 |
errorMessage.loge |
Log.e(TAG, "errorMessage: ", errorMessage); |
|
.logi / .logw |
用 Log.i / Log.w 打印日志 |
info.logi |
Log.i(TAG, info); |
Kotlin 后缀补全用法大全
这份表格适用于在 .kt 文件中进行现代 Android 开发,充分利用了 Kotlin 的语言特性.
| 分类 | 模板 | 说明 | 输入示例 | 生成代码 |
|---|---|---|---|---|
| 变量声明 | .val |
创建一个不可变引用 (val) |
User("guest").val |
val user = User("guest") |
.var |
创建一个可变引用 (var) |
0.var |
var i = 0 |
|
| 空安全与 作用域函数 |
.null |
检查表达式是否为 null |
user.null |
if (user == null) { ... } |
.notnull |
检查表达式是否不为 null |
user.notnull |
if (user != null) { ... } |
|
.let |
安全调用,将非空对象作为 it 传入 lambda |
user?.name.let |
user?.name?.let { ... } |
|
.apply |
安全调用,将非空对象作为 this 配置.返回对象本身 |
TextView(this).apply |
TextView(this).apply { ... } |
|
.also |
安全调用,将非空对象作为 it 执行附加操作.返回对象本身 |
user.also |
user.also { ... } |
|
.run |
安全调用,将非空对象作为 this 执行 lambda。返回 lambda 结果 |
user?.address.run |
user?.address?.run { ... } |
|
.with |
使用 with(expr) { ... } 包裹代码块 |
user.with |
with(user) { ... } |
|
| 循环 | .for / .iter |
遍历一个 Iterable 对象 |
userList.for |
for (user in userList) { ... } |
.fori |
带索引遍历 | list.fori |
for (i in list.indices) { ... } |
|
.forr |
带索引反向遍历 | list.forr |
for (i in list.indices.reversed()) { ... } |
|
| 条件与分支 | .if |
基于布尔表达式生成 if 语句 |
user.age > 18.if |
if (user.age > 18) { ... } |
.else |
基于布尔表达式生成 if (!expr) 语句 |
list.isEmpty().else |
if (!list.isEmpty()) { ... } |
|
.when |
为表达式生成 when 分支语句 |
response.code.when |
when (response.code) { ... } |
|
| 类型操作 | .cast |
智能类型转换并赋值给新变量 | view.cast |
val type = view as Type (光标在 Type 位置) |
.is |
生成 if (expr is Type) 类型检查 |
animal.is |
if (animal is Dog) { ... } |
|
.notis |
生成 if (expr !is Type) 类型检查 |
animal.notis |
if (animal !is Cat) { ... } |
|
| 代码包裹 | .try |
将语句包裹在 try-catch 块中 |
readFile().try |
try { readFile() } catch (e: Exception) { ... } |
.sout |
用 println() 打印表达式 |
"Hello Kotlin".sout |
println("Hello Kotlin") |
|
| 返回与抛出 | .return |
从函数返回表达式的值 | buildResult().return |
return buildResult() |
数组
一维数组
1 | // 快捷输入 |
二维数组
1 | // 快捷输入 |
面向对象
对象
对象是一种存储数据的特殊的数据结构,里面也可以包含对应的数据代码,new对象前你需要创建一个class类,它作为模版来创建具体的对象.
1.创建类
1 | // 这是一个“猫”的蓝图 (Class) |
- new对象
1 | public class Main { |
构造器
构造器(也常被称为构造方法、构造函数)是类中的一个特殊方法.它的唯一目的就是在创建一个类的对象时,对这个新创建的对象进行初始化.
我们可以把它想象成一个“生产车间”里的“初始化工序”.当使用 new 关键字生产一个新对象时,这个初始化工具立刻被调用,为对象的属性(成员变量)赋予初始值.
特点:
- 名称必须与类名完全相同:这是识别构造器的硬性标准.
- 没有返回类型:它连
void都没有,因为它“返回”的其实是那个被初始化好的对象实例的引用,这个过程是隐式的. - 不能被直接调用:你不能像调用普通方法那样(例如
myCat.constructor()) 来调用构造器.它只能在创建对象时,由new关键字自动调用.
应用场景:
完成对象的成员变量的初始化赋值.
无参构造器
这是最简单的构造器,它不接受任何参数.
1 | class Dog { |
有参构造器
这种构造器接受一个或多个参数,使得我们可以在创建对象时就传递初始数据,实现更灵活的初始化。
1 | class Cat { |
注意:
- 类默认带了一个无参构造器.
- 如果定义了个有参构造器,默认的无参构造器会消失,需要手动定义一个.
通过构造器传递对象
将通过构造函数参数传递进来的 Movie 对象,赋值给当前 MovieOpearator 实例的成员变量 movie,再调用构造方法输出数据.
1 | /** |
this关键字
this 是一个关键字,也是一个引用,在任何方法或构造器内部,它都指向调用该方法或构造器的当前对象.
特点:
哪个对象调用带有this的方法,this就拿到哪个对象.
应用场景:
1. 区分成员变量和局部变量(最常用的功能)
当方法的参数名或局部变量名与类的成员变量名相同时,this 可以用来明确地指代成员变量.
场景示例: 在一个构造器或 Setter 方法中,我们通常希望参数名能清晰地描述其含义,所以会把它起得和成员变量名一样.
1 | class Student { |
小结:this.成员变量 = 参数; 是 this 最核心、最频繁的用法.
2.在构造器中调用本类的其他构造器
为了避免在重载的构造器中编写重复的初始化代码,可以使用 this(...) 来调用本类中的另一个构造器.
规则:
this(...)必须是构造器中的第一条可执行语句.- 只能在构造器中使用.
场景示例: 假设我们有一个 Rectangle 类,希望提供多种创建方式.
1 | class Rectangle { |
小结:通过 this(...) 实现构造器链,可以有效减少代码冗余。
3.返回当前对象的引用
在方法中可以使用 return this; 来返回调用该方法的对象本身,这种用法常用于实现链式调用(Method Chaining),让代码写起来更流畅,常见于构建器模式.
设计一个简单的计算器,可以连续进行操作.
1 | class Calculator { |
小结:return this; 是实现链式编程的关键.
4. 将当前对象作为参数传递
如果一个方法需要接收一个当前类的对象作为参数,可以在调用时直接传递 this.
场景示例: 一个事件监听器需要把自己注册到事件源中.
1 | // 事件源 |
| 用法 | 语法 | 目的 |
|---|---|---|
| 区分变量 | this.variable |
明确指出要访问的是类的成员变量,而非同名局部变量. |
| 调用构造器 | this(...) |
在一个构造器中调用本类的另一个构造器,减少代码重复. |
| 返回当前对象 | return this; |
从方法中返回当前对象实例,以支持链式调用. |
| 传递当前对象 | someMethod(this) |
在方法调用中,将当前对象作为参数传递给另一个方法. |
封装
封装是指将对象的数据(属性)和操作数据的代码(方法)捆绑成一个独立的单元(即类),并对对象的内部细节进行信息隐藏,外部世界只能通过该对象允许的公开接口来访问它,而不能直接访问其内部的私有数据.
设计要求:合理隐藏,合理暴露
在 Java 中,实现封装通常遵循以下三个步骤:
- 将属性设为私有(private):使用
private关键字修饰类的成员变量,这样它们就不能在类的外部被直接访问. - 提供公有的 Getter 方法:创建一个
public的方法,用于读取某个私有属性的值(例如getName()). - 提供公有的 Setter 方法:创建一个
public的方法,用于设置某个私有属性的值(例如setName(String newName)),最关键的是,我们可以在这个方法中加入控制和验证逻辑.
1 | class Person { |
注意: get和set方法可以通过右键里面的generate生成.
实体类/Javabean
它是一种特殊的技术规范或设计模式,可以把它想象成一个标准化的,自包含的数据封装容器,它的严格规范使得各种工具和框架都能自动地发现,使用和管理它的属性.
特点:
- 公有的无参构造器:必须提供一个
public的、不带任何参数的构造函数.这是为了让很多框架(如 Spring, JSP)能够轻松地通过反射来创建它的实例. - 私有的成员变量:所有属性(fields)都应该是
private的,这体现了封装的原则. - 公有的 Getter 和 Setter 方法:对每一个私有属性,都提供
public的getXxx()和setXxx()方法用于读取和写入.对于布尔类型的属性,getter 方法可以是isXxx(). - 可序列化(可选但推荐):实现
java.io.Serializable接口,这使得 JavaBean 对象可以在网络上传输或持久化到文件中.
1 | // 这是一个标准的 JavaBean |
static-静态变量/方法
static 关键字的核心思想是:被 static 修饰的成员(变量或方法)属于整个类,而不是属于某个具体的对象实例.
static修饰成员变量
被static修饰的成员变量我们称为静态变量或者类变量,在计算机中只有一份,会被类的全部对象共享.
特点:
当一个变量被 static 修饰后,它就不再是对象的属性,而是类的属性.
- 内存分配:
static变量在类被加载到内存时就分配了空间,并且只分配一次,无论创建了多少个对象,静态变量在内存中都只有一个副本. - 调用方式:推荐直接使用
类名.静态变量的方式调用,虽然也可以用对象名.静态变量调用,但这会引起混淆,不推荐.
应用场景
在开发中,如果某个数据只需要一份,且希望能够被共享(访问,修改),则该数据可以定义成类变量来记住.
1 | class Student { |
static修饰成员方法
与静态变量类似,静态方法是属于整个类的方法,而不是属于某个具体对象实例的方法.可以提高代码复用性与运行效率,不用创建对象,调用方便.
规范:
如果这个方法只是为了做一个功能且不需要直接访问,这个方法直接定义成静态方法.
特点:
- 静态方法内部不能直接调用非静态方法或访问非静态变量.
- 不能使用
this或super关键字.
应用场景
- 自定义工具类**(记得私有化构造器)**
- 工厂方法
1 | // 字符串处理工具类 |
1 | // 1. 产品接口 |
注意:
- 静态方法中可以直接访问静态成员,不可以直接访问实例成员(但可以在里面创建对象实现间接访问).
- 实例方法中既可以直接访问静态成员,也可以直接访问实例成员.
- 实例方法中可以出现
this和super关键字,静态方法中不能出现(this代表的是对象,super代表的是父类).
权限修饰符
这张表格可以清晰地展示四个修饰符的访问权限范围:
| 修饰符 | 同一个类 | 同一个包 | 不同包的子类 | 任何地方 |
|---|---|---|---|---|
private |
✅ | ❌ | ❌ | ❌ |
default |
✅ | ✅ | ❌ | ❌ |
protected |
✅ | ✅ | ✅ | ❌ |
public |
✅ | ✅ | ✅ | ✅ |
注意:
一般成员变量使用private,方法和函数使用public就可以了.
继承
继承的概念与现实世界中的生物遗传非常相似.
子女会从父母那里继承一些特征,比如眼睛的颜色、身高.同时,子女也会有自己独特的特征,比如一项新的爱好或技能.
在编程中:
- 子类可以从父类(基类)那里“继承”来属性(成员变量)和行为(方法).
- 子类不仅拥有父类的所有非私有成员,还可以添加自己独有的新属性和新方法,或者**重写(Override)**从父类继承来的方法,使其表现出不同的行为.
特点
Java是单继承模式,不支持多继承.Java中所有的类都是Object的子类.- 就近原则:会优先访问自己类中的局部变量,其次用
this访问成员变量,访问父类的变量要用super.
1 | // 父类 Animal |
访问成员-就进原则
“就近原则”指的是,当子类的方法中需要访问一个变量或调用一个方法时,程序会按照一个由近及远的顺序来查找这个成员,一旦找到了就会立刻停止查找并使用它.
这个查找顺序可以概括为:
- 先在局部范围找:首先在当前方法的局部变量中查找.
- 再到当前对象找:如果在方法内没找到,就在当前子类对象的成员变量中查找.
- 最后去父类对象找:如果在子类中也没找到,就沿着继承关系向上,到直接父类的成员变量中查找.如果父类还有父类,会继续向上,直到
Object类为止.
1 | class Father { |
从上面的例子可以看出,this 和 super 是打破默认“就近”查找顺序、进行精确访问的关键工具.
this关键字- 含义:代表当前对象的引用.
- 作用:用来明确访问当前类的成员变量或成员方法,当成员变量和局部变量同名时,必须使用
this来区分.
super关键字- 含义:代表对父类对象的引用.
- 作用:用来明确访问父类的成员变量或成员方法,当子类隐藏了父类的变量或重写了父类的方法后,可以用
super来访问父类中的版本.
方法重写
也称为方法覆盖,指的是在子类中创建一个与父类中某个方法具有相同方法签名(即方法名和参数列表完全相同)的新方法,从而覆盖掉父类的版本.
简单来说,就是父类有一个通用的功能,但子类觉得这个功能的实现方式不适合自己,需要一个“定制版”或“特殊版”,于是子类就重新写了一遍这个功能.
一个形象的比喻:
- 父类
Animal有一个move()方法,它的实现可能是“移动身体”. - 子类
Fish继承了Animal,但鱼的移动方式很特殊,是“摇动尾巴游泳”. - 子类
Bird也继承了Animal,但鸟的移动方式是“扇动翅膀飞翔”.
在这里,Fish 和 Bird 都重写了父类 Animal 的 move() 方法,为其提供了更具体的实现.
作用和好处
方法重写是实现多态性的基,这是面向对象编程的三大特性(封装、继承、多态)之一.
其主要好处是:
- 功能扩展和定制化:子类可以在不修改父类代码的前提下,改变或扩展从父类继承来的方法的行为,使其更符合子类的特性.
- 实现多态:允许我们使用父类型的引用来指向子类型的对象,并在调用方法时,程序能够自动选择并执行子类重写后的方法.这使得代码更加灵活、可扩展和易于维护.
规则
要想正确地重写一个方法,必须遵循以下严格的规则.一个流行的记忆法是**“三同一小一大”**:
-
三同- 三个地方必须相同- 方法名必须相同
- 参数列表必须相同(参数的类型、数量、顺序都必须一致)
- (方法名 + 参数列表 = 方法签名,所以核心是方法签名必须相同)
-
一小- 返回值类型要更小或相等- 返回值类型必须与父类中被重写方法的返回值类型相同,或者是其子类型(父类方法返回
Animal,子类重写后可以返回Animal或者Dog(假设Dog是Animal的子类)).
- 返回值类型必须与父类中被重写方法的返回值类型相同,或者是其子类型(父类方法返回
-
一大- 访问权限要更大或相等-
子类重写方法的访问修饰符权限不能低于父类被重写方法的权限.
-
权限从大到小排序为:
public>protected>default(包访问权限) >private(如果父类方法是protected,子类重写时可以是protected或public,但不能是default或private).
-
注意:
private方法不能被重写:因为private方法对子类不可见.final方法不能被重写:final关键字修饰的方法表示这是最终版本,不希望被任何子类修改.static方法不能被重写:静态方法属于类而不是对象.类可以定义同名的静态方法,但这被称为隐藏(Hiding),而不是重写.
构造器的调用
this():调用同一个类中的其他构造器
this() 用于在一个构造器中调用同一个类中的另一个重载的构造器.这在当你希望重用构造逻辑,避免代码重复时非常有用,通过调用兄弟构造器,来对其初始化,类似于继承.
核心规则:
- 必须是构造器的第一行语句:
this()调用必须是构造器中的第一个可执行语句. - 只能在构造器中使用:你不能在普通方法中使用
this()来调用构造器. - 不能形成递归调用:两个或多个构造器之间不能通过
this()相互调用,否则会造成无限循环,导致编译错误.
1 | public class Person { |
- 创建
p1时,new Person()调用了无参构造器。该构造器第一行this("Unknown", 0)将调用权转交给了Person(String, int)构造器。 Person(String, int)执行完核心的赋值操作后,控制权返回到无参构造器,继续执行后面的打印语句。- 这种模式被称为构造器链 (Constructor Chaining),它将初始化逻辑集中在一个构造器中,使得代码更易于维护。
- 相当于构造器不用重写
1 | // this.name = name; |
super():调用父类的构造器
super() 用于在子类的构造器中显式地调用其直接父类的构造器,子类的所有构造器都会先调用父类的构造器再执行自己,确保父对象的部分得到正确的初始化.
核心规则
- 隐式调用:如果你在子类的构造器中没有显式地使用
super()或this(),那么Java编译器会自动在构造器的第一行插入一个无参的super()调用,即super();。 - 父类必须有可访问的无参构造器:如果父类没有提供无参构造器(例如,父类只定义了有参构造器),那么在子类的构造器中必须显式地使用
super()来调用父类某个存在的有参构造器,否则会产生编译错误。 - 必须是构造器的第一行语句:和
this()一样,super()调用也必须是子类构造器中的第一个可执行语句。
1 | // 父类 |
- 当我们创建
Dog对象时new Dog(...),Dog的构造器被调用. Dog构造器的第一行是super(name)/这会立即调用父类Animal中匹配的构造器(即Animal(String name)),并把 “Buddy” 传递过去.Animal的构造器执行,初始化name属性,并打印消息.- 父类构造器执行完毕后,控制权返回到
Dog的构造器,继续执行this.breed = breed;和后面的打印语句.
这个过程确保了在初始化子类特有属性(如 breed)之前,从父类继承来的属性(如 name)已经被正确地初始化了.
| 特性 | this() |
super() |
|---|---|---|
| 目的 | 调用同一个类中的另一个重载构造器 | 调用直接父类的构造器. |
| 位置 | 必须是构造器的第一行语句 | 必须是构造器的第一行语句. |
| 共存 | this() 和 super() 不能在同一个构造器中同时出现,因为它们都要求自己是第一行语句. |
|
| 隐式调用 | 不会自动调用. | 如果没有显式调用 this() 或 super(),编译器会自动在第一行插入一个无参的 super(). |
| 使用场景 | 代码复用,将多个构造器的初始化逻辑引导到一个主构造器中. | 初始化子对象时,确保父对象的部分得到正确的初始化,这是继承中构造过程的必要环节. |
多态
多态是继承/实现情况下的一种现象,表现为: 对象多态和行为多态.它的核心思想是:同一操作作用于不同的对象,可以有不同的解释,产生不同的执行结果.
多态的基本介绍
实现多态的三个必要条件
在大多数主流编程语言中,要实现多态,通常需要满足以下三个条件:
- 要有继承.: 必须存在父类和子类的关系.
- 要有方法重写: 子类必须重写父类的某个方法.
- 要有父类引用指向子类对象.: 在使用时,通过父类的引用变量来引用子类的实例.
例如,在Java中:Animal animal = new Dog();
Animal是父类(引用变量的声明类型)Dog是子类(对象的实际类型)animal是一个父类引用,但它指向了一个Dog对象
多态主要分为两种:
- 编译时多态/ 静态多态/ 对象多态(只接受父类方法,编译看左边):
- 通过方法重载实现。
- 方法重载指的是在同一个类中,可以有多个同名的方法,但它们的参数列表(参数的个数、类型或顺序)不同。
- 编译器在编译阶段,就会根据你传入的参数来决定具体调用哪个方法。
- 运行时多态/ 动态多态/ 行为多态(接受不同子类的方法,运行看右边):
- 这是我们通常所说的多态,通过方法重写实现。
- 在程序运行时,系统根据对象的实际类型来决定调用哪个方法。
- 上面的代码示例就是典型的运行时多态。
1 | // 父类:形状 |
对象多态
1 | public class ObjectPolymorphismDemo { |
行为多态
1 | public class BehaviorPolymorphismDemo { |
我们可以用一个简单的因果关系来理解它们:
因为 有了 对象多态(一个 Dog 对象可以被看作是 Animal), 所以 才能实现 行为多态(当这个被看作 Animal 的 Dog 对象执行 makeSound 动作时,它表现出 Dog 的行为)。
| 特性 | 对象多态 | 行为多态 |
|---|---|---|
| 核心概念 | 对象身份的多样性 (一个对象, 多种类型) | 方法行为的多样性 (一个接口, 多种实现) |
| 关键机制 | 向上转型 | 方法重写 和 动态绑定 |
| 作用阶段 | 编译期进行类型检查,运行期进行对象实例化 | 运行期决定调用哪个方法 |
| 关注点 | 对象是什么 (Is-A relationship) | 对象做什么 (How it behaves) |
| 关系 | 前提 / 基础 | 结果 / 体现 |
注意
多态是对象的知识,和成员变量无关.
多态的好处及其存在的问题
好处
- 在多态形式下,右边对象是解耦合的(可以拆分与组装),更便于扩展和维护.
1 | People p1 = new Student(); |
- 定义方法时,使用父类类型的形参,可以接受一切子类对象,扩展性更强,更便利.
1 | Wolf w = new Wolf(); |
问题
- 多态下不能使用子类的独有功能(父类没有重写的).
1 | class Animal { |
编译器在检查 pet.bark() 时,它只知道 pet 是一个 Animal 类型的引用,它无法在编译阶段预知 getPet() 方法在运行时到底会返回一个 Dog 对象还是一个 Cat 对象.
- 如果返回的是
Dog,调用bark()没问题. - 但如果返回的是
Cat,Cat对象根本没有bark()方法,运行时就会出错(MethodNotFound之类的错误).
解决方法
既然编译器是因为不确定引用的真实类型才阻止我们,那么我们只需要向编译器“证明”这个引用的真实类型是安全的,就可以调用了,这个“证明”的过程就是 强制类型转换(向下转型).
但是,在转换之前,做一个负责任的程序员,我们应该先用 instanceof 来检查一下,确保转换是安全的,避免 ClassCastException.
1 | public static void main(String[] args) { |
通过 if (pet instanceof Dog),我们向编译器和JVM都明确了一件事:在这个代码块内部,pet 确实指向一个 Dog 对象,因此可以安全地将其转换为 Dog 类型,并调用其独有的 bark() 方法.
final关键字
final关键字是最终的意思,可以修饰:类,方法,变量.
- 修饰类: 该类被称为最终类,特点是不能被继承.(工具类)
- 修饰方法: 该方法被称为最终方法,特点是不能被重写了.
- 修饰变量: 该变量有且仅能被赋值一次.(定义常量)
1 | /** |
| 修饰目标 | 效果 | 主要目的 |
|---|---|---|
| 变量 | 一次赋值,终身不变。 - 基本类型:值不变. - 引用类型:指向的地址不变,但对象内容可变. |
创建常量,保证线程安全,实现不可变对象. |
| 方法 | 不能被子类重写 (Override). | 锁定核心逻辑,防止子类破坏原有设计,保证程序稳定性. |
| 类 | 不能被继承 (Inherit). | 保证类的实现不会被修改,安全性,设计完整性. |
单例类
单例模式是一种创建型设计模式,它确保一个类只有一个实例,并提供一个全局访问点来获取这个唯一的实例.
这种模式在很多场景下都非常有用,比如:
- 应用上下文管理:提供一个全局、安全的 Context,避免内存泄漏.
- 数据库/Repository:保证全局使用唯一的数据库连接,避免资源浪费和性能开销.
- 网络请求客户端:复用底层的线程池、连接池和缓存,提高网络请求效率.
SharedPreferences工具类:统一管理数据读写,方便在应用各处调用.
特点:
- **私有的构造函数 **:为了防止外部通过
new关键字直接创建类的实例,需要将构造函数声明为私有的. - **私有的静态实例变量 **:在类的内部创建一个该类自身的静态实例.
- **公有的静态工厂方法 **:提供一个全局的、静态的方法,用于返回这个唯一的实例,这个方法通常被命名为
getInstance().
饿汉式
饿汉式在类加载的时候就直接创建实例,不管你是否需要它.
1 | public class EagerSingleton { |
- 优点:
- 实现简单.
- 线程安全.因为 JVM 在加载类时,静态变量的初始化过程是线程安全的.
- 缺点:
- 资源浪费.如果这个实例从始至终都没有被使用,那么它所占用的内存就浪费了.
懒汉式
懒汉式在第一次调用 getInstance() 方法时才创建实例.
基础懒汉式(线程不安全)
1 | public class LazySingleton { |
- 优点:
- 实现了懒加载,避免了资源浪费.
- 缺点:
- 线程不安全.在多线程环境下,可能会有多个线程同时进入
if (instance == null)判断,从而创建出多个实例,违背了单例的初衷.因此,这种方式不推荐在多线程环境中使用.
- 线程不安全.在多线程环境下,可能会有多个线程同时进入
同步方法懒汉式(线程安全)
为了解决线程不安全问题,最直接的方法就是给 getInstance() 方法加上 synchronized 关键字.
1 | public class SynchronizedLazySingleton { |
- 优点:
- 线程安全.
- 实现了懒加载.
- 缺点:
- 性能低下.
synchronized会给整个方法加锁,每次调用getInstance()都会进行同步,但实际上只有在第一次创建实例时才需要同步.一旦实例创建完毕,后续的同步就成了不必要的开销,会严重影响性能.
- 性能低下.
双重检查锁定
这是对同步方法懒汉式的一种优化,在保证线程安全的同时,也兼顾了性能.
1 | public class DoubleCheckedLockingSingleton { |
- 注意:
instance变量必须用volatile关键字修饰.这是因为instance = new DoubleCheckedLockingSingleton();这行代码在 JVM 中并非原子操作,它大致可以分为三步:- 为
instance分配内存空间. - 初始化
instance对象. - 将
instance变量指向分配的内存地址. JVM 可能会进行指令重排序,导致步骤 3 在步骤 2 之前执行.如果一个线程执行了 1 和 3,但还没执行 2,另一个线程在第一次检查if (instance == null)时会发现instance不为null,然后直接返回一个未完全初始化的对象,从而导致问题.volatile可以禁止这种指令重排.
- 为
- 优点:
- 线程安全.
- 性能较高,只有在第一次创建时才会同步.
- 实现了懒加载.
- 缺点:
- 实现相对复杂.
静态内部类
这是一种被广泛推荐的实现方式,它结合了懒汉式和饿汉式的优点.
1 | public class StaticInnerClassSingleton { |
- 原理:
- 当
StaticInnerClassSingleton类被加载时,它的静态内部类SingletonHolder并不会被加载. - 只有当第一次调用
getInstance()方法时,JVM 才会加载SingletonHolder类,并初始化INSTANCE静态变量. - JVM 在类加载时保证了初始化的线程安全性.
- 当
- 优点:
- 线程安全.
- 实现了懒加载.
- 实现简单,代码清晰.
枚举
这是《Effective Java》作者 Joshua Bloch 极力推荐的方式,也是最简单、最安全的实现方式.
1 | public enum EnumSingleton { |
使用时,直接通过 EnumSingleton.INSTANCE 来访问.
1 | public enum TrafficLight { |
做信息分类和标志.
- 优点:
- 实现极其简单.
- 线程安全.由 JVM 从根本上保证了线程安全.
- 能防止反序列化重新创建新的对象.Java 的序列化机制允许通过反序列化创建一个新的对象,但对于枚举类,JVM 会保证即使反序列化也不会创建新的实例.而其他方式实现的单例类需要特殊处理才能防止反序列化破坏单例.
- 能防止反射攻击。其他方式可以通过反射调用私有构造函数来创建新实例,而枚举类则可以防止这种情况.
- 缺点:
- 非懒加载.
- 可读性可能稍差,对于不熟悉枚举单例的开发者可能需要时间理解.
| 实现方式 | 线程安全 | 懒加载 | 推荐程度 | 备注 |
|---|---|---|---|---|
| 饿汉式 | 是 | 否 | ⭐⭐⭐ | 简单,但可能浪费资源 |
| 懒汉式(基础) | 否 | 是 | ⭐ | 不推荐使用 |
| 懒汉式(同步方法) | 是 | 是 | ⭐⭐ | 性能差,不推荐使用 |
| 双重检查锁定 | 是 | 是 | ⭐⭐⭐⭐ | 推荐,但实现略复杂,需注意 volatile |
| 静态内部类 | 是 | 是 | ⭐⭐⭐⭐⭐ | 强烈推荐,兼顾性能和简洁 |
| 枚举 | 是 | 否 | ⭐⭐⭐⭐⭐ | 强烈推荐,最简单、最安全的方式 |
抽象类/方法
抽象方法
抽象方法是指一个只有方法声明,没有具体方法体(即没有 {} 代码块)的方法.它存在的意义是定义一个规范或契约,告诉子类“你必须实现这个功能”,但具体怎么实现,由子类自己决定.
语法特征:
- 使用
abstract关键字进行修饰. - 没有方法体,直接以分号
;结束.
1 | public abstract class Vehicle { |
在这个例子中,startEngine() 就是一个抽象方法.它规定了所有 Vehicle(交通工具)都必须有“启动引擎”这个功能,但汽车(Car)和摩托车(Motorcycle)启动引擎的方式可能不同,所以具体实现留给它们各自的子类.
抽象类
抽象类是指一个包含抽象方法的类.只要一个类里哪怕只有一个抽象方法,这个类就必须被声明为抽象类.抽象类就像一个“半成品”或“模板”,它不能被直接实例化(即不能用 new 关键字创建对象),因为它包含未实现的功能.它的唯一用途就是被其他类继承.
核心特点:
- 不能被实例化:你不能写
new Vehicle()这样的代码,因为Vehicle是一个抽象类,它的startEngine()方法还没有实现,直接创建对象没有意义. - 必须被继承:抽象类存在的目的就是为了让子类来继承它,并实现它所有的抽象方法.
- 可以包含非抽象方法:抽象类不仅可以有抽象方法,也可以有已经实现了的具体方法(如上面的
turnOff()方法).这使得子类可以复用这些通用功能**(用final关键字修饰)**. - 可以包含成员变量、构造函数等:抽象类和普通类一样,可以拥有成员变量和构造函数.其构造函数主要是为了方便子类在构造时调用
super()来初始化父类的成员. - 子类的责任:
- 一个类如果继承了抽象类,那么它必须实现父类中所有(未被实现的)抽象方法.
- 如果子类不想实现父类的所有抽象方法,那么这个子类必须被声明为抽象类.
1 | // Car 是一个具体的类,它继承了抽象类 Vehicle |
抽象类/方法的作用
抽象类的主要目的是为了代码复用和强制规范.
- 代码复用 (Code Reusability): 当多个子类有一些共同的功能时,可以将这些功能实现在抽象父类中(作为具体方法),子类只需要继承就可以直接使用,避免了在每个子类中重复编写相同的代码.例如上面例子中的
turnOff()方法. - 强制规范 (Enforce a Contract): 通过抽象方法,父类可以为所有子类定义一个统一的接口或模板.它强制要求所有子类都必须提供某个特定功能的实现,从而保证了体系内所有对象都具有某些基本行为,这使得多态(Polymorphism)的应用更加安全和方便.例如,我们能确保任何
Vehicle类型的对象,都可以调用startEngine()方法,而不用担心这个方法不存在.
接口
接口(Interface)是一种行为规范或契约,它一般只定义一种方法签名(方法名,参数,返回值类型),但不提供任何具体的实现.如果任何类实现了这个接口,那么它必须要为方法提供具体的实现代码.
你可以把接口想象成一份合同或一个设备的插座标准:
- 合同:合同规定了甲方和乙方需要履行的义务(必须做什么),但没有规定具体怎么做。任何签署合同的人都必须履行这些义务。
- USB 插座:USB 接口标准规定了插座的形状、引脚定义和功能。任何设备(U盘、鼠标、键盘)只要遵循这个标准,就能插入USB端口并正常工作。电脑不关心你插的是什么设备,只关心它是否符合USB规范。
特点与优势
- 纯粹的抽象:接口是完全抽象的,它只包含方法的声明,不包含方法的实现.注:现代编程语言如 Java 8+ 允许接口包含
private,default和static方法,但这属于特例,其核心思想仍是定义规范. - 不能被实例化:你不能直接用
new关键字创建一个接口的对象,因为它没有具体的功能实现. - 必须被类实现:接口的价值在于被类(Class)来实现,一个类可以实现一个或多个接口.
- 强制实现:实现接口的类必须为接口中的所有方法提供具体的实现,否则,这个类必须被声明为抽象类.
- 多实现:一个类只能继承一个父类(单继承),但可以实现多个接口.这解决了单继承的局限性,使得一个类可以同时拥有多种“能力”.
- 多继承: 一个接口可以继承多个接口.
- 解耦合: 让程序可以面向接口编程,程序员可以切换各种任务实现.
1 | // 定义一个“可飞行的”接口 |
接口的主要用途
-
实现多态接口使得我们可以编写更通用、更灵活的代码,我们可以面向接口编程,而不是面向具体的实现类.
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
26public class Airport {
// 这个方法可以接受任何实现了 Flyable 接口的对象
public void performFlight(Flyable flyingObject) {
System.out.println("--- 飞行表演开始 ---");
flyingObject.takeOff();
flyingObject.fly();
flyingObject.land();
System.out.println("--- 飞行表演结束 ---\n");
}
}
public class Main {
public static void main(String[] args) {
Airport airport = new Airport();
Flyable bird = new Bird();
Flyable airplane = new Airplane();
Flyable kite = new Kite();
airport.performFlight(bird);
airport.performFlight(airplane);
airport.performFlight(kite);
}
}performFlight方法不关心传来的是鸟、飞机还是风筝,它只认Flyable这个接口.这大大提高了代码的扩展性.如果未来我们新增一个Drone(无人机)类,只要它也实现Flyable接口,就可以无缝地被performFlight方法调用,无需修改任何现有代码. -
定义程序的契约和规范 (API Contract) 在大型项目中,不同模块或团队之间可以通过接口来约定功能.例如,A 团队负责定义接口(需要什么功能),B 团队负责实现这些接口(如何实现这些功能),这实现了开发的解耦,双方可以并行工作.
-
降低耦合度 (Decoupling) 通过面向接口编程,模块之间的依赖关系从“依赖具体的类”转变为“依赖抽象的接口”.这样,只要接口不变,具体的实现类可以随意更换,而不会影响到调用方.这在依赖注入 (Dependency Injection) 和各种设计模式中是核心思想.
| 应用场景 | 核心目的 | 典型例子 |
|---|---|---|
| 事件监听/回调 | 解耦 和 异步通知。让一个对象能在特定事件发生时,通知另一个关心该事件的对象 | View.OnClickListener, TextWatcher |
| 网络/数据库API定义 | 定义契约。只声明需要做什么(如获取数据),而不关心具体怎么做(由库生成实现) | Retrofit 的Service接口, Room 的 DAO 接口 |
| Fragment-Activity通信 | 解耦 和 标准化交互。让 Fragment 可复用,不依赖于任何特定的 Activity | 自定义的回调接口 |
| 依赖注入(DI) | 面向接口编程。降低模块间依赖,方便替换实现和进行单元测试 | 在 Hilt/Dagger 中为Repository等定义接口 |
接口与抽象类的区别
| 特性 | 接口 (Interface) | 抽象类 (Abstract Class) |
|---|---|---|
| 设计意图 | 定义一种能力或行为规范 (can-do) | 定义一个族系或模板 (is-a) |
| 关系 | 类与接口是实现关系 (implements。 |
类与抽象类是继承关系 (extends) |
| 继承限制 | 一个类可以实现多个接口 | 一个类只能继承一个父类(包括抽象类) |
| 主要用途 | 做功能的解耦合 | 实现代码复用性 |
总结一下:
- 当你想要定义一组不相关的类应该共同遵守的行为规范时,使用接口.例如,
Flyable、Runnable(可运行的)、Serializable(可序列化的). - 当你想要为一组高度相关的类创建一个共享代码和通用特性的基类时,使用抽象类.例如,
Shape(图形)是Circle(圆形)和Square(方形)的抽象父类.
拓展(通过接口声明变量来创建对象)
CopyOnWriteArrayList<>()已经实现了List接口并重写了List里面的方法,那List<String> myList = new CopyOnWriteArrayList<>();还有什么意义
这是一个非常非常好的问题,它直接触及了面向对象设计中一个极其重要的核心原则,也是区分初级和中高级程序员的一个关键点.
这个原则就是:面向接口编程
List<String> myList = new ArrayList<>(); 这行代码的两个部分各司其职,缺一不可:
List<String> myList(左边部分 - 变量类型)- 作用:定义了**“契约”和“规范”**.它向你的程序承诺,“
myList这个变量只能执行List里面含有的方法”. - 目的:为了代码的灵活性和可维护性(面向接口编程).
- 作用:定义了**“契约”和“规范”**.它向你的程序承诺,“
new ArrayList<>()(右边部分 - 对象实例化)- 作用:提供了**“具体的实现”和“性能的保障”**.它创建了一个真实存在的、按照
ArrayList方式工作的对象,对List里面含有的方法进行重写. - 目的:为了让程序能够实际运行,并选择符合你需求的性能特征(比如快速随机访问).
- 作用:提供了**“具体的实现”和“性能的保障”**.它创建了一个真实存在的、按照
简单来说,ArrayList 的用处就是提供一个具体的、可运行的、具有特定性能优势的工人,来完成 List 这份合同里规定的所有任务.而你的 myList 变量,就是那个只看合同不关心工人是谁的“项目经理”.
为了未来的灵活性和可维护性
通过使用接口类型 List 来声明变量,你的代码就不再依赖于某个具体的实现(如 CopyOnWriteArrayList),而仅仅依赖于抽象的规范(List 接口).
我们来看一个场景:
假设你正在开发一个用户列表展示功能.一开始,你预估这个列表的读操作会非常频繁,而写操作很少,并且可能存在多线程读取的情况.CopyOnWriteArrayList 是一个绝佳的选择。
你的代码是这样写的:
1 | // V1 版本:使用 CopyOnWriteArrayList |
这段代码运行得很好.但是,几个月后,产品需求变更了,现在用户可以频繁地添加和删除好友,写操作变得非常多.CopyOnWriteArrayList 每次写入都复制整个数组,性能急剧下降.此时,一个普通的 ArrayList(在单线程环境下)或 Collections.synchronizedList(new ArrayList<>()) 会是更好的选择.
现在,神奇的事情发生了:
因为你的变量 userList 和方法 displayUsers 的参数类型都是 List,所以要更换实现,你只需要修改一行代码!
1 | // V2 版本:只需要修改这一行! |
反过来想,如果你当初写的是:
1 | // 耦合于具体实现的“坏”代码 |
那么当你要更换成 ArrayList 时,你需要修改变量声明、方法参数、以及所有其他使用了 CopyOnWriteArrayList 类型的地方.在一个大型项目中,这会是一场灾难.
解耦合
使用 List<String> list = ... 的写法,你的代码被分成了两部分:
- 使用者 (Client):
displayUsers方法就是使用者.它不关心列表具体是哪种实现,它只关心“我拿到的是一个List,它有.get()、.size()等方法就够了”. - 提供者 (Provider):
new CopyOnWriteArrayList<>()是提供者.它提供了List功能的一种具体实现.
List 接口就像它们之间的一份合同.只要提供者遵守这份合同,使用者就可以放心地工作,完全不用在乎提供者是谁,或者将来会换成谁.
清晰地表达意图
当你把变量或参数声明为 List 时,你其实在告诉其他阅读你代码的程序员:“我在这里只需要这个对象基本的 List 功能,我不在乎它的底层是数组还是链表,也不在乎它是否线程安全.只要它是个列表就行.” 这使得代码的意图更加清晰.
什么时候应该使用具体类来声明?
当然,也有例外.当你需要使用那个具体实现类特有的、而接口中没有定义的方法时,你就必须使用具体类来声明.
例如,LinkedList 类有 addFirst() 和 getLast() 方法,但 List 接口没有.
1 | // 错误的代码,因为 List 接口没有 addFirst 方法 |
在这种情况下,你就做出了一个权衡:为了使用特殊功能,你牺牲了未来更换实现的灵活性.
List<String> myList = new CopyOnWriteArrayList<>(); 的意义在于:
它遵循了“面向接口编程”的黄金法则,为你带来了巨大的灵活性、可维护性和代码解耦.这使得你的代码像是由标准的乐高积木搭建而成,可以轻松替换零件;而不是用胶水粘死的、牵一发而动全身的脆弱结构.
代码块
实例代码块
实例代码块,或称构造代码块,是直接定义在类中的、没有 static 关键字修饰的代码块.
1 | public class MyClass { |
核心特性
- 执行时机: 每次创建类的实例(对象)时执行.它的执行顺序在父类构造方法之后,当前类构造方法之前.
- 主要用途: 用于初始化实例变量(非静态成员变量),或者执行所有对象在创建时都需要运行的通用逻辑.它可以被看作是所有构造方法的“公共前缀”,有助于减少构造方法之间的代码重复.
- 访问权限: 可以访问类的任何成员,包括实例成员(变量和方法)和静态成员(变量和方法).因为它在对象创建时运行,所以此时实例成员已经分配了内存.
静态代码块
静态代码块是使用 static 关键字修饰的、直接定义在类中的代码块.
1 | public class MyClass { |
核心特性
- 执行时机: 在 类第一次被加载到 JVM(Java 虚拟机)时执行.这通常发生在第一次创建该类的对象、第一次访问该类的静态成员、或通过反射加载该类时.
- 执行频率: 整个程序的生命周期中只执行一次.
- 主要用途: 用于对类级别的静态成员(
static变量)进行复杂的初始化,或者执行一些类级别的、只需要进行一次的设置操作(如加载数据库驱动、初始化配置文件等). - 访问权限: 只能访问类的静态成员(静态变量和静态方法).它不能直接访问实例成员(非静态变量和方法),因为在静态代码块执行时,类的任何实例(对象)都还没有被创建,实例成员尚未分配内存.
| 特性 | 静态代码块 (Static Block) | 实例代码块 (Instance Block) |
|---|---|---|
| 关键字 | 使用 static {} 定义 |
直接使用 {} 定义 |
| 执行时机 | 类第一次加载到 JVM 时执行 | 每次创建类的实例(new一个对象)时执行 |
| 执行频率 | 仅执行一次 | 每创建一个对象就执行一次 |
| 执行顺序 | 最先执行,在任何对象创建之前 | 在构造方法之前执行 |
| 访问能力 | 只能访问静态成员(变量和方法) | 可以访问静态成员和实例成员 |
| 主要用途 | 对静态变量进行初始化,或执行类级别的、一次性的准备工作(如加载驱动) | 对实例变量进行初始化,提取多个构造方法中的公共代码,执行对象级别的通用逻辑 |
1 | public class CodeBlockExample { |
1 | 1. 静态代码块执行 |
- 静态代码块只执行了一次:在
main方法开始执行,CodeBlockExample类被加载时,静态代码块就立刻执行了,并且之后再也没有执行过. - 实例代码块和构造方法执行了两次:每次调用
new CodeBlockExample()创建新对象时,实例代码块和构造方法都会按顺序执行一次. - 执行顺序:对于单个对象的创建过程,顺序是 实例代码块 -> 构造方法.
- 访问权限:静态代码块中无法访问
instanceField,而实例代码块中可以自由访问staticField和instanceField.
内部类
优点:
- 逻辑分组与封装:如果一个类只被某一个其他类使用,那么将它定义为内部类可以从逻辑上将它们组织在一起。这隐藏了实现细节,使得代码结构更清晰。
- 增强封装性:内部类可以访问其外部类的所有成员,包括私有成员(private fields and methods)。这提供了一种特殊的访问权限,可以实现更紧密的协作,而无需将外部类成员声明为
public或protected。 - 代码更简洁可读:尤其是在事件处理(如 GUI 编程)或实现某些设计模式(如迭代器模式)时,使用内部类(特别是匿名内部类)可以让代码更紧凑、更贴近使用场景。
成员内部类
这是最常见的内部类形式,它像外部类的成员变量一样,定义在类的内部、方法的外部。
特点:
- 依赖外部类实例:成员内部类的实例必须依附于一个外部类的实例,你不能在没有外部类对象的情况下创建成员内部类对象.
- 访问权限:它可以无条件地访问外部类的所有成员(包括静态和非静态,私有和公有).
- 不能有静态成员:成员内部类中不能定义任何静态(
static)方法或变量,除非是static final的常量. this关键字:在内部类中,this指向内部类自身的实例,要引用外部类的实例需要使用OuterClassName.this.
1 | public class Outer { |
如何实例化: OuterClass.InnerClass innerObject = outerObject.new InnerClass();
静态内部类
静态内部类使用 static 关键字修饰.它与外部类的关系更像是“命名空间”的归属关系,而不是成员关系.
特点:
- 不依赖外部类实例:创建静态内部类的实例不需要外部类的实例.
- 访问权限:它只能访问外部类的静态(
static)成员,不能直接访问外部类的非静态(实例)成员. - 可以有静态成员:静态内部类可以拥有自己的静态和非静态成员.
- 行为类似普通类:除了定义的位置和对外部类静态成员的访问权限外,它几乎和一个普通的顶级类一样.
1 | public class Outer { |
如何实例化: OuterClass.StaticInnerClass nestedObject = new OuterClass.StaticInnerClass();
局部内部类
局部内部类是定义在一个方法或者一个作用域(如 if 语句块)内部的类.
特点:
- 作用域极小:它的生命周期和可见性仅限于定义它的方法或块中.
- 不能有访问修饰符:不能使用
public,private等修饰符,也不能使用static修饰. - 访问外部成员:和成员内部类一样,它可以访问外部类的所有成员.
- 访问局部变量:它可以访问所在方法中的局部变量,但这些变量必须是
final或“事实上的 final”(effectively final,即变量在初始化后没有被再次赋值).
1 | public class Outer { |
匿名内部类
匿名内部类是没有名字的局部内部类.它通常用于快速创建某个类或接口的子类实例,非常适合“一次性使用”的场景.
特点:
- 没有名字:它在定义的同时就创建了实例.
- 语法:通常是
new SuperType() { ... }或new Interface() { ... }的形式. - 没有构造函数:因为它没有名字,所以不能定义构造函数,如果需要初始化,可以使用实例初始化块.
- 适用场景:最常用于实现接口(如
Runnable,Comparator)或作为事件监听器. - 访问规则:和局部内部类一样,可以访问外部类的所有成员,以及所在方法中
final或“事实上的 final”的局部变量.
1 | // 使用接口 |
| 特性 | 成员内部类 | 静态内部类 | 局部内部类 | 匿名内部类 |
|---|---|---|---|---|
| 定义位置 | 类的成员位置 | 类的成员位置 | 方法或代码块内 | 表达式中(new之后) |
static 关键字 |
不能使用 | 必须使用 | 不能使用 | 不能使用 |
| 访问外部类成员 | 所有成员 | 仅静态成员 | 所有成员 | 所有成员 |
| 访问局部变量 | N/A | N/A | 必须是final或effectively final |
必须是final或effectively final |
| 创建实例方式 | outer.new Inner() |
new Outer.Inner() |
在方法内 new Inner() |
new Interface(){...} |
| 依赖外部实例 | 是 | 否 | 是 | 是 |
| 拥有静态成员 | 否 (除了static final) |
是 | 否 | 否 |
| 应用场景 | 创建与外部类实例状态紧密相关的辅助类,如实现迭代器(Iterator). | 仅为逻辑上组织,与外部类实例无关,如 Map.Entry 或建造者模式(Builder Pattern). |
在方法内需要一个临时的、具名的类,且实现比匿名类复杂时使用(较少用). | 用于实现事件监听器、Runnable、Comparator 等只需一次性使用的简单接口或类。 |
函数式编程
函数式编程是一种编程范式,就像面向对象编程一样,它将计算机运算视为数学函数的求值,并避免使用可变状态和副作用.
简单来说,它的核心思想是:像对待数学函数一样对待程序中的函数.
我们来分解一下这个概念的关键特征:
a. 函数是“一等公民”(First-Class Citizens)
这是函数式编程最核心的特点。它意味着函数和其他数据类型(如 int、String、对象)处于同等地位.具体表现为:
- 可以作为变量赋值:
Function<Integer, Integer> square = x -> x * x; - 可以作为参数传递:将一个函数传递给另一个方法.
- 可以作为返回值返回:一个方法可以返回一个函数.
Java 通过函数式接口和 Lambda 表达式实现了这一点.
b. 纯函数(Pure Functions)
纯函数是指满足以下两个条件的函数:
- 无副作用:函数不会修改其作用域之外的任何状态.例如,它不会修改全局变量、类的成员变量,也不会进行 I/O 操作(如打印到控制台、读写文件).
- **引用透明性:**对于相同的输入,永远返回相同的输出.函数的结果只依赖于其输入参数,而不依赖于任何外部状态.
1 | // 纯函数:结果只依赖于输入 a 和 b |
纯函数的好处是巨大的:
- 可预测性:给定输入,输出总是确定的,非常容易理解和推理.
- 易于测试:不需要复杂的 Mock 或环境设置,只需提供输入并验证输出即可.
- 并行/并发安全:因为纯函数不共享和修改状态,所以它们天然是线程安全的,可以安全地并行执行.
c. 不可变性(Immutability)
函数式编程推崇创建不可变的数据结构.一旦创建了数据,就不再修改它,如果需要修改,应该是创建一个包含新值的新对象,而不是在原地修改旧对象.
这避免了因状态变化而引起的复杂性和错误,尤其是在多线程环境中.Java 中的 String 类就是一个典型的不可变对象.
d. 声明式编程(Declarative) vs. 命令式编程(Imperative)
- 命令式编程:关注**“如何做”(How)**.你需要一步步地告诉计算机执行的具体指令,传统的
for循环就是典型的命令式编程. - 声明式编程:关注**“做什么”(What)**.你只描述你想要的结果,而不关心具体的实现步骤,函数式编程就是一种声明式编程.
1 | List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6); |
Lambda 表达式
Lambda 表达式(闭包)是java8的新特性,Lambda运行将函数作为一个方法的参数,也就是函数作为参数传递到方法中,使用lambda表达式可以让代码更加简洁.
Lambda表达式的使用场景: 用以简化匿名内部类和接口实现.
关于接口实现,可以有很多种方式来实现.例如: 设计接口的实现类,使用匿名内部类.但是lambda表达式,比这两种方式都简单.
函数式接口
虽然说,lambda表达式可以在⼀定程度上简化接口的实现.但是,并不是所有的接口都可以使用lambda表达式来简洁实现的.
lambda表达式毕竟只是⼀个匿名方法,当实现的接口中的方法过多或者多少的时候,lambda表达式都是不适用的.
lambda表达式只能实现函数式接口。
如果说,⼀个接口中,要求实现类必须实现的抽象方法,有且只有⼀个!这样的接口,就是函数式接口.
1 | //有且只有一个实现类必须要实现的抽象方法,所以是函数式接口 |
@FunctionalInterface
是⼀个注解,用在接口之前,判断这个接口是否是⼀个函数式接口.如果是函数式接口,没有任何问题.如果不是函数式接口,则会报错.功能类似于 @Override.
1 |
|
Lambda 表达式的核心语法
Lambda 表达式的语法非常紧凑,其基本结构如下:
1 | (parameters) -> expression |
或者
1 | (parameters) -> { statements; } |
它由三个部分组成:
- 参数列表 (parameters):方法的参数.
- 如果没有参数,必须使用一对空括号
(). - 如果只有一个参数,可以省略括号.
- 参数的类型通常可以由编译器根据上下文推断出来,所以大部分情况下可以省略.
- 如果没有参数,必须使用一对空括号
- 箭头操作符 (->):也称为 Lambda 操作符,它将参数列表和 Lambda 主体分开.
- Lambda 主体 (body):
- 如果主体只有一条语句,可以省略大括号
{}和return.表达式的结果会自动作为返回值. - 如果主体包含多条语句,则必须使用大括号
{}将它们包裹起来,并且如果方法需要返回值,必须显式使用return语句.
- 如果主体只有一条语句,可以省略大括号
1 | // 未使用 Lambda (通过匿名内部类) |
方法引用
方法引用是 Java 8 中与 Lambda 表达式一同引入的一项重要特性,你可以把它看作是 Lambda 表达式的一种语法糖.当你想要使用的 Lambda 表达式的实现恰好是调用一个已经存在的方法时,可以用方法引用来进一步简化代码,使其更加简洁、清晰和可读.
方法引用使用 ::(双冒号)操作符.:: 的左边是类名或对象名,右边是方法名或 new 关键字.
使用 Lambda 表达式:
1 | List<String> names = Arrays.asList("Alice", "Bob", "Charlie"); |
观察这个 Lambda 表达式 s -> System.out.println(s).它所做的唯一一件事就是:接收一个参数 s,然后立刻将这个 s 作为参数传递给 System.out.println() 方法.
在这种“参数只是简单地传递给另一个方法”的情况下,我们就可以使用方法引用来让代码变得更干净.
使用方法引用:
1 | List<String> names = Arrays.asList("Alice", "Bob", "Charlie"); |
System.out::println 就是一个方法引用,它清晰地表达了“对列表中的每个元素,都调用 System.out 对象的 println 方法”这个意图.
静态方法引用
- 语法:
ClassName::staticMethodName - 说明:如果 Lambda 表达式的主体只是调用一个静态方法,并且 Lambda 的参数列表与该静态方法的参数列表相匹配.
示例: 将一个字符串列表转换为整数列表,
1 | List<String> numbersAsStrings = Arrays.asList("1", "2", "3"); |
这里的 Integer::parseInt 就等同于 s -> Integer.parseInt(s)。
特定对象的实例方法引用
- 语法:
instance::instanceMethodName - 说明:如果 Lambda 表达式的主体只是调用一个已经存在的对象的实例方法,并且 Lambda 的参数与该实例方法的参数相匹配.
示例: 我们回到开头的打印例子.
1 | List<String> names = Arrays.asList("Alice", "Bob", "Charlie"); |
这里的 System.out::println 就是 out::println,因为 System.out 是一个已经存在的对象。
特定类型的任意对象的实例方法引用
- 语法:
ClassName::instanceMethodName - 说明:这是最特殊的一种.当 Lambda 表达式的第一个参数是实例方法的调用者,并且后续参数(如果有的话)被传递给该实例方法时,可以使用这种引用.
示例: 将一个字符串列表全部转为大写.
1 | List<String> names = Arrays.asList("alice", "bob", "charlie"); |
这里,map 方法接收的 Lambda s -> s.toUpperCase() 中,第一个参数 s 正好是 toUpperCase() 方法的调用者.因此,它可以被简化为 String::toUpperCase.
另一个例子: 排序
1 | String[] names = {"Charlie", "Alice", "Bob"}; |
这里,第一个参数 s1 是方法的调用者,第二个参数 s2 是方法的参数,完美匹配.
构造方法引用
- 语法:
ClassName::new - 说明:如果 Lambda 表达式的主体只是创建一个新对象,那么可以使用构造方法引用.
示例: 将一个字符串列表的内容复制到一个新的 ArrayList 中.
1 | List<String> names = Arrays.asList("Alice", "Bob", "Charlie"); |
这里的 ArrayList::new 就等同于 () -> new ArrayList<>(),它提供了一个创建 ArrayList 实例的功能。
- 方法引用是 Lambda 表达式的简化写法,让代码更具可读性。
- 当你发现你的 Lambda 表达式
(...) -> ClassName.someMethod(...)或(...) -> object.someMethod(...)只是在简单地调用一个已存在的方法时,就应该考虑使用方法引用。 - 它和 Lambda 表达式一样,都必须在需要函数式接口的上下文中使用。编译器会根据函数式接口的抽象方法签名来推断并匹配对应的方法引用。
String类
String代表字符串,它的对象可以封装字符串数据,并提供了很多方法对字符串进行处理.
String创建字符串的方式
- 方式一: Java程序中的所有字符串文字(例如
"abc")都为此类的对象.
1 | String name = "小何"; |
只要是以"…"方式写出的字符串对象,字符串会存储到字符串常量池,且相同内容的字符串只存储一份.
- 方式二: 调用
String类的构造器初始化字符串对象.
1 | char[] chars = {'h','e','b'}; |
通过new方式创建字符串对象,每new一次都会产生一个新的对象放在堆内存中,他们在栈内存的地址不相同.
字符串对象的内容比较
千万不要用==,==默认比较地址,字符串对象内容一样时地址不一定一样(方式二).判断字符串内容,建议大家用String提供的equals方法,只关心内容一样,就返回true,不关心地址.
1 | if(okLoginName.equals(loginName)){ |
异常
在 Java 中,异常是在程序执行期间发生的、中断了程序正常指令流的事件.当一个方法中发生错误时,该方法会创建一个异常对象并将其抛出 (throw).这个异常对象包含了关于错误的详细信息,例如错误的类型和程序当时的状态.然后,Java 运行时系统会负责寻找能够处理这个异常的代码块,这个过程被称为捕获异常 (catch an exception)…
Java 异常的层次结构
要理解 Java 的异常,首先需要了解其层次结构.所有异常类型都是从 java.lang.Throwable 类派生出来的.Throwable 类有两个主要的子类:Error 和 Exception.
1 | java.lang.Throwable |
Error(错误):表示 Java 运行时系统内部的错误和资源耗尽等严重问题.这些问题通常是应用程序无法处理和恢复的,例如OutOfMemoryError(内存溢出)、StackOverflowError(栈溢出).程序通常对此无能为力,我们一般不编写代码来捕获Error.Exception(异常):是应用程序本身可以处理的异常情况.这是我们编程时主要关注和处理的部分,它分为两大类:编译时异常 和 运行时异常.
编译时异常和运行时异常
编译时异常
编译时异常,也称为受检异常,是那些 Java 编译器 强制 要求程序员必须处理的异常.如果一个方法可能会抛出编译时异常,那么该方法必须在方法签名中使用 throws 关键字声明它可能抛出的异常类型,或者在方法内部使用 try-catch 块来捕获并处理这个异常.否则,程序将无法通过编译.
这种机制的设计目的是提醒开发者,调用这个方法时可能会出现一些可预见的、需要处理的问题,从而强制编写更健壮的代码.
- 特点:
- 强制处理:编译器会检查代码是否对这类异常进行了处理.
- 可预见性:通常是程序外部的、可预见的问题,例如 I/O 操作失败、数据库连接中断等.
- 继承关系:继承自
java.lang.Exception类,但不继承自java.lang.RuntimeException.
- 常见例子:
IOException: 处理文件、网络流等 I/O 操作时最常见的异常.SQLException: 与数据库交互时可能发生的异常.ClassNotFoundException: 试图加载不存在的类时抛出.
注意
外层当你写了return异常(Exception)时,他会强制你使用try-catch或throw给方法(使用当前方法的时候用try-catch).
快捷键: ctrl+alt+t.
运行时异常
运行时异常,也称为非受检异常 (Unchecked Exception),是那些 Java 编译器 不强制 要求程序员必须处理的异常.这类异常通常是由程序自身的逻辑错误(Bugs)引起的,是可以在编码阶段避免的.
Java 的设计者认为,如果强制要求处理所有运行时异常,代码会变得非常冗长.因此,选择不强制检查它们,而是留给程序员自己决定是否需要处理.
- 特点:
- 非强制处理:编译器不会检查代码是否处理了这类异常.
- 逻辑错误:通常是由程序逻辑缺陷导致的,例如访问了空对象、数组越界等.
- 可避免性:在大多数情况下,可以通过编写更严谨的代码(如添加
if判断)来避免. - 继承关系:继承自
java.lang.RuntimeException类.
- 常见例子:
NullPointerException: 空指针异常,最常见的运行时异常.ArrayIndexOutOfBoundsException: 数组索引越界异常.IllegalArgumentException: 非法参数异常.ClassCastException: 类型转换异常.
推荐使用运行时异常
尽管编译时异常的初衷是好的,但在长期的软件工程实践中,开发者们发现它往往会带来一些弊端,而运行时异常则能提供更大的灵活性.因此,现代 Java 开发(尤其是大型后端应用和框架设计)的趋势是优先和广泛地使用运行时异常.
推荐运行时异常的原因
-
避免代码污染和降低耦合度编译时异常具有“传染性”.如果一个底层方法声明
throws SomeCheckedException,那么所有直接或间接调用它的方法都必须处理这个异常(要么catch,要么继续throws).这会导致:throws子句冗长:方法签名上会挂上一长串的throws声明,非常混乱.- 破坏封装:高层业务逻辑被迫关心底层的实现细节.例如,一个
UserService的register()方法,本应只关心业务逻辑,但如果底层使用了文件操作,它可能就不得不throws IOException.如果未来实现从文件改成数据库,throws声明又要改成SQLException,所有调用链上的代码都要修改.这严重违反了高内聚、低耦合的设计原则. - 运行时异常解决了这个问题:它不需要在方法签名上声明,从而将异常处理的责任从“强制的层层传递”变成了“在合适的地方统一处理”.
-
**避免无意义的
catch块 **为了通过编译,开发者常常被迫编写毫无意义的catch块,这被称为“异常吞噬”,是一种非常危险的反模式.1
2
3
4
5
6
7
8// 糟糕的实践:为了通过编译而吞噬异常
try {
someMethodThatThrowsCheckedException();
} catch (SomeCheckedException e) {
// 只是打印一下,然后假装无事发生
e.printStackTrace();
// 更糟糕的是,catch块为空,完全忽略错误!
}这样做会隐藏真正的问题,让程序在一个错误的状态下继续运行,最终可能在别处引发一个更难追踪的错误.而运行时异常不会强制你
try-catch,从而鼓励你只在真正有能力处理异常的地方才去捕获它. -
更符合现代应用的处理逻辑 在大多数服务器端应用中,绝大多数异常对于当前执行的方法来说是不可恢复的.例如:
- 数据库连接池耗尽.
- 配置文件加载失败.
- 必要的远程服务不可用.
在这些情况下,当前方法几乎不可能自己修复问题.最好的策略是快速失败 (Fail-Fast):中断当前操作,让异常自然地冒泡到上层的全局异常处理器。全局处理器可以统一记录日志、向监控系统报警,并向客户端返回一个标准化的错误响应(例如 HTTP 500).运行时异常完美契合这种模型.
使用运行时异常
-
使用标准运行时异常进行断言和前置条件检查 在你的方法入口处,检查传入的参数是否合法,如果不合法,立即抛出运行时异常来中断执行.
1
2
3
4
5
6
7
8
9
10
11
12
13
14public void setAge(int age) {
if (age < 0 || age > 150) {
// 参数不合法,这是一个编程错误,应使用运行时异常
throw new IllegalArgumentException("Age must be between 0 and 150.");
}
this.age = age;
}
public void processOrder(OrderData order) {
if (order == null) {
throw new NullPointerException("OrderData cannot be null.");
}
// ...
} -
异常包装 (Exception Wrapping) 这是现代框架(如 Spring)中广泛使用的一种强大技术.当底层的代码(如 JDBC、JPA)抛出编译时异常时,在你的数据访问层捕获它,然后将其包装成一个自定义的、更能体现业务含义的运行时异常再抛出.
- 优点:
- 隐藏实现细节:上层调用者无需关心底层用的是 JDBC 还是 Hibernate,它只需要处理
DataAccessException. - 提供更多上下文:在包装时,可以将原始异常作为 cause 传入,并添加更多业务相关的描述信息.
- 保持代码干净:避免了编译时异常的“污染”.
- 隐藏实现细节:上层调用者无需关心底层用的是 JDBC 还是 Hibernate,它只需要处理
示例代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19// 自定义一个运行时异常
public class DataAccessException extends RuntimeException {
public DataAccessException(String message, Throwable cause) {
super(message, cause);
}
}
// 在数据访问层进行异常包装
public class UserRepository {
public User findById(String id) {
try {
// ... 执行JDBC查询,可能会抛出 SQLException (编译时异常)
return someJdbcOperation(id);
} catch (SQLException e) {
// 捕获编译时异常,包装成自定义的运行时异常再抛出
throw new DataAccessException("Failed to find user with id: " + id, e);
}
}
}分析这段代码的行为:
-
异常传播链没有中断:
catch块的最后一行是throw.这意味着findById方法的执行会立即终止,并将一个新的DataAccessException异常抛给上层调用者.错误信息并没有被隐藏,而是以另一种形式继续向上传播. -
原始异常信息被保留:通过
new DataAccessException(..., e),原始的SQLException对象e被设置为了新异常的cause.当这个新异常被打印堆栈时,你会清晰地看到 “Caused by: java.sql.SQLException: …” 的信息.所有细节都没有丢失. -
提升了代码的抽象层次:上层的服务层(Service Layer)调用
UserRepository时,它不需要关心底层用的是什么数据库技术,因此它不应该处理SQLException.通过包装,UserRepository将底层的技术细节(SQLException)转换成了更高层次的、与业务更相关的抽象(DataAccessException),这是一种非常重要的解耦方式.
- 优点:
-
创建自定义的运行时异常来表达业务问题 对于特定的业务失败场景,可以创建继承自
RuntimeException的自定义异常类,使其名称更具可读性和业务含义.1
2
3
4
5
6
7
8
9
10
11
12
13
14// 用户未找到异常
public class UserNotFoundException extends RuntimeException {
public UserNotFoundException(String userId) {
super("User with ID '" + userId + "' was not found.");
}
}
// 库存不足异常
public class InsufficientStockException extends RuntimeException {
public InsufficientStockException(String productId) {
super("Insufficient stock for product: " + productId);
}
}
| 场景 | 推荐做法 |
|---|---|
| 编程错误 (如非法参数, null 值) | 必须 使用标准的运行时异常 (IllegalArgumentException, NullPointerException). |
| 不可恢复的系统/环境问题 (如数据库访问失败) | 强烈推荐 使用“异常包装”技术,将底层的编译时异常转换为自定义的运行时异常. |
| 可预测且调用方应处理的 API 问题 | 可以考虑 使用编译时异常.这是它唯一推荐的用武之地.例如,一个库的 login(user, pass) 方法可能会 throws InvalidCredentialsException,强制调用者处理登录失败的逻辑,但即便如此,很多人也倾向于用运行时异常来设计. |
| 业务逻辑失败 (如用户不存在, 库存不足) | 推荐 使用自定义的运行时异常.这样可以在全局异常处理器中根据异常类型返回不同的错误码和信息给前端. |
泛型
Java 泛型是 JDK 5 中引入的一个重要新特性,它允许在定义类、接口和方法时使用“类型参数”(type parameter).通过这种方式,可以编写出更通用、更安全、更清晰的代码.
想象一下,在没有泛型之前,如果你想创建一个可以持有任何类型对象的列表,你可能会使用 ArrayList,它内部存储的是 Object 类型的对象.
1 | // 没有泛型的代码 |
这里有两个主要问题:
- 类型不安全:你可以往列表里添加任何类型的对象,但在取出时,你必须“记住”你当初存的是什么类型,并进行强制类型转换.如果记错了,就会在运行时抛出
ClassCastException异常. - 代码可读性差:任何人看到
List list都无法直接知道这个列表到底打算存储什么类型的元素.
泛型就是为了解决这些问题而生的。
通过使用泛型,你可以在编译时就指定容器中能够存放的对象类型.
1 | // 使用泛型的代码 |
使用泛型后:
- 类型安全:编译器会检查你向集合中添加的元素类型是否正确.如果类型不匹配,代码将无法通过编译.这等于将运行时错误提前到了编译时,大大提高了程序的健壮性.
- 代码更清晰:
List<String>清晰地表明了这个列表只能包含String类型的元素,提高了代码的可读性和可维护性. - 消除强制类型转换:从泛型集合中获取元素时,不再需要手动进行强制类型转换,代码更简洁.
泛型类
通过在类名后面加上 <T> 来定义一个泛型类,其中 T 是一个类型参数的占位符,可以换成任何合法的标识符,通常使用单个大写字母(如 T for Type, E for Element, K for Key, V for Value).
示例: 创建一个通用的容器类 Box.
1 | public class Box<T> { |
泛型接口
泛型接口的定义与泛型类类似.
1 | public interface Pair<K, V> { |
泛型方法
泛型方法允许方法的类型参数独立于类的类型参数.你可以在任何方法(静态或非静态)中定义类型参数.类型参数声明位于修饰符和返回类型之间.
1 | public class Util { |
有界类型参数
有时候,你可能希望限制泛型参数的类型范围.例如,一个方法可能只接受 Number 类或其子类(如 Integer, Double).这时就可以使用 extends 关键字来设置上界.
1 | public class Stats<T extends Number> { |
通配符
通配符 ? 表示未知的类型,它主要用于提高代码的灵活性,尤其是在处理泛型方法的参数时.
上界通配符: <? extends Type>
? extends Type 表示这个未知的类型是 Type 本身或者其某个子类。这种集合只能从中读取数据 (get),不能写入数据 (add)(除了null),因为编译器无法确定 ? 到底代表哪个具体的子类型。
示例: 一个可以计算任何 Number 列表总和的方法。
1 | public static double sumOfList(List<? extends Number> list) { |
这个方法可以接受 List<Integer>, List<Double> 等。
下界通配符: <? super Type>
? super Type 表示这个未知的类型是 Type 本身或者其某个父类.这种集合只能向其中写入 Type 或其子类型的数据 (add),但读取出来的对象只能被当作 Object 对待.
示例: 一个可以将整数添加到列表的方法。
1 | public static void addIntegers(List<? super Integer> list) { |
这个方法可以接受 List<Integer>, List<Number>, List<Object>。
**
PECS法则 **
- 如果你需要从一个数据结构中读取数据(生产者),使用
? extends T。- 如果你需要向一个数据结构中写入数据(消费者),使用
? super T。
包装类
将基本数据类型定义为对象,避免强转,多用于泛型中(泛型不支持基本数据类型,只支持引用数据类型).它会帮助你自动装箱和自动拆箱.
| 基本类型 | 包装类 | 主要用途和特点 | 常用方法/常量示例 |
|---|---|---|---|
byte |
Byte |
8位有符号整数的对象表示。 | Byte.parseByte(String s), byteValue() |
short |
Short |
16位有符号整数的对象表示。 | Short.parseShort(String s), shortValue() |
int |
Integer |
32位有符号整数的对象表示。最常用。 | Integer.parseInt(String s), Integer.valueOf(int i), intValue(), toString() |
long |
Long |
64位有符号整数的对象表示。 | Long.parseLong(String s), longValue() |
float |
Float |
32位单精度浮点数的对象表示。 | Float.parseFloat(String s), floatValue(), isNaN() |
double |
Double |
64位双精度浮点数的对象表示。 | Double.parseDouble(String s), doubleValue() |
char |
Character |
16位 Unicode 字符的对象表示。 | Character.isDigit(char ch), Character.isLetter(char ch), toLowerCase(char ch) |
boolean |
Boolean |
布尔值的对象表示 (true/false)。 |
Boolean.parseBoolean(String s), booleanValue(), Boolean.TRUE, Boolean.FALSE |
类型擦除
Java 泛型的一个核心概念是类型擦除,这是为了兼容 JDK 5 之前的代码而做出的设计.
简单来说,泛型信息只存在于代码的编译阶段,在编译后的字节码 (.class 文件) 中,所有的泛型类型参数都会被替换为它们的上界(如果没有指定上界,则替换为 Object).
例如,List<String> 在编译后会变成 List,Box<T extends Number> 会变成 Box<Number>.
这意味着:
- 在运行时,
ArrayList<String>和ArrayList<Integer>的类对象是同一个,即ArrayList.class. - 你不能创建泛型数组,如
new T[],因为在运行时T的类型信息已经被擦除了.
编译器会在必要的地方自动插入强制类型转换代码,来保证我们前面提到的类型安全.
| 特性 | 描述 | 优点 | 应用场景 (Android 开发中) |
|---|---|---|---|
| 核心思想 | 在编译时进行类型检查, 而不是在运行时。 | 提高程序的类型安全性和健壮性。 | 定义 List<User> 来接收用户数据列表,而不是使用原始的 List,可以防止在运行时因添加了错误类型的数据(如 Product 对象)而导致 ClassCastException 崩溃. |
| 泛型类/接口 | class MyClass<T> {}, interface MyInterface<T> {} |
定义通用的数据结构,实现代码复用。 | 网络请求封装: 创建一个通用的 ApiResponse<T> 类来处理所有网络请求的返回数据.T 可以是 User、Order 或任何其他数据模型,避免为每种数据类型都写一个重复的包装类. |
| 泛型方法 | <T> T myMethod(T arg) |
允许方法处理多种类型的数据,独立于类的泛型。 | JSON 解析工具: 编写一个通用的 JsonUtils.fromJson(String json, Class<T> clazz) 方法.调用时可以传入不同的 Class 对象(如 User.class),方法会返回相应类型的实例,非常灵活. |
| 有界类型 | <T extends Number> |
限制类型参数的范围,可以使用上界类型的方法。 | RecyclerView Adapter: 定义 Adapter 时,其 ViewHolder 必须继承自 RecyclerView.ViewHolder.public class MyAdapter extends RecyclerView.Adapter<MyAdapter.MyViewHolder> { ... } 就是一个典型的有界类型应用. |
| 通配符 | ? extends T, ? super T |
增加 API 的灵活性,特别是在处理集合参数时。 | 数据库操作: 在 Room 或其他 ORM 框架中,一个批量保存方法可以定义为 void saveItems(List<? extends BaseEntity> items).这样它就能接受任何继承自 BaseEntity 的实体列表(如 List<User>, List<Product>),增强了方法的通用性. |
| 类型擦除 | 泛型信息在编译后被移除。 | 保证了对旧版本 Java 的向后兼容性。 | 理解其限制以避免错误: 运行时无法直接判断 list instanceof List<String>.因此,在需要运行时类型信息的场景(如反序列化),必须显式传递 Class<T> 对象或使用 TypeToken (如 Gson 库中) 来捕获和传递完整的泛型类型信息. |
Collection集合
在 Java 中,集合框架 (Collection Framework) 是一个用来存储和操作一组对象的统一架构,它包含了一系列的接口和类,位于 java.util 包中.这个框架的核心是 Collection 接口和 Map 接口.
这里我们主要关注 Collection 接口,它是所有单列集合的“根”接口.也就是说,它处理的是一组独立的对象元素,而不是键值对.
Collection 接口下面主要派生出三个核心子接口:List、Set 和 Queue,它们各自代表了一种不同的集合类型.
List 接口 (列表)
List 是一个有序的集合,允许存储重复的元素.因为它有序,所以可以通过索引(整数位置)来访问、添加或删除元素.
- 主要实现类:
ArrayList: 基于动态数组实现.查询和随机访问速度快(通过索引get(i)),但在中间插入或删除元素较慢.LinkedList: 基于双向链表实现.插入和删除速度快,但随机访问速度慢.它也实现了Queue接口,可以作为队列或栈使用.Vector: 古老的实现,与ArrayList类似但线程安全,性能较差,现在已不推荐使用.
Set 接口 (集)
Set 是一个无序(通常情况下)的集合,不允许存储重复的元素.它主要用来确保集合中元素的唯一性.
- 主要实现类:
HashSet: 基于哈希表 (HashMap) 实现.提供最快的性能(添加、删除、查找),但不保证元素的任何顺序.LinkedHashSet:HashSet的子类,使用链表维护了元素插入的顺序.在需要保持插入顺序且保证元素唯一的场景下使用.TreeSet: 基于红黑树实现.元素会自动进行自然排序(或者根据构造时提供的Comparator排序),性能略低于HashSet.
Queue 接口 (队列)
Queue 是一种遵循 先进先出 (FIFO - First-In, First-Out) 原则的集合.元素在队尾添加,在队头被移除.
- 主要实现类:
LinkedList: 如前所述,它也实现了Queue接口,可以作为标准的 FIFO 队列使.PriorityQueue: 优先队列.元素并非按照插入顺序排序,而是根据其自然顺序或者指定的比较器进行排序,每次取出的都是队列中“最小”的元素.ArrayDeque: 基于数组实现的双端队列,可以高效地在队列的头部和尾部进行添加和删除操作.
注意:
Map接口(如HashMap,TreeMap)是集合框架的另一大分支,它存储的是键值对 (Key-Value),不继承自Collection接口,因此这里不详细展开,但它的遍历方式与Set类似.
集合的遍历方法
遍历是操作集合最常见的需求.Java 提供了多种遍历 Collection 的方法,主要有以下三种:
迭代器 (Iterator)
这是最通用、最标准的遍历方式,适用于所有 Collection 类型.Iterator 是一个接口,它提供了一种安全地访问集合元素并按需移除元素的方式.
核心方法:
hasNext(): 检查序列中是否还有下一个元素.next(): 返回序列中的下一个元素.remove(): 从集合中移除next()方法最后返回的那个元素.
1 | List<String> list = new ArrayList<>(); |
优点:
- 通用性: 所有
Collection都支持. - 安全删除: 它是唯一一种可以在遍历过程中安全地修改集合(删除元素)的方式.
For-Each 循环 (增强型 For 循环)
这是从 JDK 5 开始引入的语法糖,其底层实现原理仍然是 Iterator,它使得遍历代码更加简洁易读.
1 | Set<String> set = new HashSet<>(); |
优点:
- 代码简洁: 写法非常简单,可读性强.
- 不易出错: 无需手动管理迭代器,避免了
hasNext()/next()的调用错误.
缺点:
- 无法在遍历时修改集合: 如果在 for-each 循环中直接调用集合的
remove()或add()方法,会抛出ConcurrentModificationException异常. - 无法访问索引: 对于
List,无法在循环中直接获取当前元素的索引.
forEach()方法 (Stream API 或 Lambda)
从 Java 8 开始,Collection 接口(继承自 Iterable)有了 forEach() 方法,它结合 Lambda 表达式,可以写出非常紧凑的函数式风格的代码.
1 | Queue<Integer> queue = new LinkedList<>(); |
优点:
- 代码最简洁: 特别适合简单的遍历打印或对每个元素执行单一操作.
- 函数式编程: 可以与强大的 Stream API 无缝集成,进行
filter,map,reduce等链式操作.
缺点:
- 和 for-each 循环一样,不能在其中安全地修改集合.
- 无法使用
break或return来提前终止循环(但可以通过一些变通方法实现).
| 遍历方式 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| Iterator | 通用,可在遍历时安全删除元素 | 写法相对繁琐 | 需要在遍历过程中修改集合(仅限删除)的场景. |
| For-Each 循环 | 代码简洁,可读性高,不易出错 | 无法在遍历时修改集合,无法访问索引 | 最常用、最推荐的通用遍历场景. |
forEach() + Lambda |
代码最简洁,可与 Stream API 结合 | 无法在遍历时修改集合,无法 break |
简单的元素处理,或进行复杂的函数式编程(过滤、转换等). |
Map集合
与 Collection 存储单个独立的元素不同,Map 接口存储的是键值对 (Key-Value Pair).它是一个将唯一的“键 (Key)”映射到“值 (Value)”的对象.你可以把它想象成一本现实生活中的字典,通过单词(Key)可以查到它的释义(Value).
Map 接口本身不继承自 Collection 接口,是 Java 集合框架中独立的一大分支.
核心特点
- 键值对存储:每个元素都由一个键和一个值组成,即一个
Entry. - 键的唯一性:在一个
Map中,键 (Key) 必须是唯一的,不允许重复.如果用一个已存在的键去添加新的值,那么旧的值将会被覆盖. - 值的可重复性:值 (Value) 可以重复,多个不同的键可以映射到同一个值.
- 无序性(通常):大多数
Map的实现类不保证元素的存入顺序,例如HashMap.
Map的实现类
Map 接口有多个实现类,各自具有不同的特性,适用于不同的场景.
HashMap:- 内部结构:基于哈希表(数组 + 链表/红黑树)实现.
- 特点:性能最高(增删改查的平均时间复杂度为 O(1)),不保证元素的任何顺序(既不是插入顺序也不是排序顺序),允许一个
null键和多个null值,是Map中最常用的实现类. - 适用场景:绝大多数需要键值对存储且不关心顺序的场景**(设置缓存)**.
LinkedHashMap:- 内部结构:继承自
HashMap,额外使用一个双向链表来维护元素的顺序. - 特点:保留了元素的插入顺序,性能略低于
HashMap,因为需要维护链表. - 适用场景:需要保持元素插入顺序的场景,例如实现 LRU 缓存.
- 内部结构:继承自
TreeMap:- 内部结构:基于红黑树实现.
- 特点:元素会根据键 (Key) 进行自然排序(要求 Key 实现
Comparable接口)或根据构造时传入的Comparator进行定制排序.性能通常低于HashMap. - 适用场景:需要对键进行排序的场景.
Hashtable:- 内部结构:与
HashMap类似,也是基于哈希表. - 特点:是古老的线程安全版本,方法都是同步的 (
synchronized),因此性能很差,不允许null键和null值.现在已不推荐使用,线程安全的场景应使用ConcurrentHashMap.
- 内部结构:与
集合的遍历方法
由于 Map 本身不是 Collection,它没有迭代器 iterator()。因此,不能直接用 for-each 循环来遍历 Map.但我们可以通过 Map 提供的三个“视图”方法,将其转换为 Collection 类型,然后再进行遍历.这三种视图分别是:
keySet(): 获取Map中所有键 (Key) 的Set集合.values(): 获取Map中所有值 (Value) 的Collection集合.entrySet(): 获取Map中所有键值对 (Map.Entry) 的Set集合.
下面是基于这三种视图的四种核心遍历方法.
遍历 entrySet() (键值对集合) - 最推荐
这是最高效、最常用的遍历方式,因为它一次性就可以获取到键和值.无需二次查询.
1 | Map<String, Integer> map = new HashMap<>(); |
特点:
- 效率高: 同时获取 Key 和 Value,无需像遍历
keySet那样再去map.get(key). - 功能全: 可以同时操作键和值.
遍历 keySet() (键集合)
先获取所有键的集合,然后根据每个键去 Map 中获取对应的值.
1 | // 使用 for-each 循环遍历 keySet |
特点:
- 可以直接操作所有的键.
- 效率略低于遍历
entrySet,因为每次循环都需要通过map.get(key)进行一次额外的查找操作.
遍历 values() (值集合)
如果你只关心 Map 中的值,不关心键,可以使用这种方式。
1 | System.out.println("\n--- 遍历 values ---"); |
特点:
- 写法简单,适用于只关心值的场景.
- 无法在循环中获取到与值对应的键.
使用 forEach() 方法和 Lambda
从 Java 8 开始,Map 接口也提供了 forEach 方法,它接受一个 BiConsumer(同时消费 Key 和 Value),使得遍历代码更加简洁.
1 | System.out.println("\n--- 使用 forEach 和 Lambda ---"); |
优点:
- 代码最简洁,可读性强.
- 函数式编程风格,方便进行链式操作.
| 遍历方式 | 优点 | 缺点/注意事项 | 适用场景 |
|---|---|---|---|
entrySet() |
效率最高,一次性获取键和值 | 无明显缺点 | 强烈推荐的通用遍历方式. |
keySet() |
直观,可以直接操作键 | 效率稍低(需要额外 get() 操作) |
当你主要想对键进行操作,偶尔需要值时. |
values() |
简单直接 | 无法获取键 | 只关心值,完全不需要键的场景. |
forEach() + Lambda |
代码最简洁,语法优雅 | 属于内部迭代,无法 break 终止循环 |
适用于简单的遍历操作,代码风格现代化. |
Stream流
你可以把 Stream 想象成一个来自数据源(如集合、数组)的元素队列,并支持聚合操作.它不是一个数据结构,本身也不存储数据.它更像是一个高级的迭代器(Iterator),可以让你对数据进行一系列的流水线操作.
核心理念:
- 声明式处理: 你只需要描述“做什么”,而不需要关心“怎么做”.例如,你想“从一个整数列表中筛选出所有偶数,然后将它们乘以2,最后求和”,使用 Stream 可以非常直观地表达这个过程.
- 流水线操作: 多个操作可以链接在一起,形成一个流水线.数据在流水线中流动,并被逐个操作处理.
- 内部迭代: 与传统的
for循环(外部迭代)不同,Stream 使用内部迭代,它会自动处理元素的遍历过程,你只需要关注每个元素需要执行的操作.
关键特性:
- 非存储性 : Stream 本身不存储任何元素,它只是在源数据上进行计算的视图.
- 函数式接口 : Stream 的操作(如
filter,map,reduce)通常接受 Lambda 表达式或方法引用作为参数,这使得代码非常简洁. - 懒加载/延迟执行 : 很多 Stream 操作(中间操作)是延迟执行的,这意味着它们不会立即执行,只有在遇到一个触发计算的“终端操作”时,整个流水线才会开始工作,这使得 Stream 可以进行很多优化.
- 一次性消费 : 一个 Stream 只能被消费(即执行终端操作)一次,一旦消费完毕,就不能再被使用.如果需要再次遍历,你需要从数据源重新创建一个新的 Stream.
- 可以并行处理 : Stream API 天然支持并行处理,你可以通过调用
.parallel()方法轻松地将一个顺序流转换为并行流,从而在多核处理器上利用并行计算来提高性能.
生命周期:
一个典型的 Stream 操作包含三个阶段:
- 创建: 从一个数据源(如
Collection,Array, I/O channel)创建一个 Stream. - 中间操作 : 对 Stream 进行一系列的转换操作,每个中间操作都会返回一个新的 Stream,这样就可以形成一个链式调用,这些操作是懒执行的.
- 终端操作 : 触发整个 Stream 流水线的执行,并产生最终结果或副作用,终端操作执行后,该 Stream 就被消费掉了.
创建 Stream
你可以从多种数据源创建 Stream:
-
从集合创建:
1
2
3List<String> list = Arrays.asList("a", "b", "c");
Stream<String> stream = list.stream(); // 创建顺序流
Stream<String> parallelStream = list.parallelStream(); // 创建并行流 -
从数组创建:
1
2String[] array = new String[]{"a", "b", "c"};
Stream<String> stream = Arrays.stream(array); -
使用
Stream.of():1
Stream<String> stream = Stream.of("a", "b", "c");
-
使用
Stream.generate()或Stream.iterate()创建无限流:
// 创建一个无限流,每个元素都是 “hello” Stream
1 | // 创建一个从0开始,每次加2的无限流 (0, 2, 4, 6, ...) |
常用的中间操作
这些操作会返回一个新的 Stream,并且是懒加载的.
-
filter(Predicate<T> predicate): 过滤元素,只保留满足条件的元素.1
2
3
4List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
numbers.stream()
.filter(n -> n % 2 == 0) // 只保留偶数
.forEach(System.out::println); // 输出 2, 4 -
map(Function<T, R> mapper): 对每个元素进行转换,映射成一个新的元素.1
2
3
4List<String> words = Arrays.asList("hello", "world");
words.stream()
.map(String::toUpperCase) // 将每个单词转为大写
.forEach(System.out::println); // 输出 HELLO, WORLD -
flatMap(Function<T, Stream<R>> mapper): 将每个元素映射成一个 Stream,然后将所有生成的 Stream 连接成一个单一的 Stream(扁平化处理).1
2
3
4List<String> lines = Arrays.asList("Hello World", "Java Stream");
lines.stream()
.flatMap(line -> Arrays.stream(line.split(" "))) // 将每行拆分成单词流,然后合并
.forEach(System.out::println); // 输出 Hello, World, Java, Stream -
distinct(): 去除重复的元素. -
sorted(): 对元素进行自然排序. -
sorted(Comparator<T> comparator): 使用自定义比较器进行排序. -
peek(Consumer<T> action): 对每个元素执行一个操作,主要用于调试. -
limit(long maxSize): 截断流,使其元素不超过给定数量. -
skip(long n): 跳过前 n 个元素.
常用的终端操作
这些操作会触发 Stream 的计算并产生一个最终结果.
-
遍历:
forEach(Consumer<T> action): 对每个元素执行一个操作.
-
收集:
-
collect(Collector<T, A, R> collector): 将 Stream 中的元素收集到一个容器中,如List,Set,Map.这是最常用的终端操作之一.1
2
3
4List<String> words = Arrays.asList("a", "b", "c");
List<String> upperCaseWords = words.stream()
.map(String::toUpperCase)
.collect(Collectors.toList()); // 收集到 List -
toArray(): 将 Stream 转换为数组.
-
-
归约 (Reduce):
-
reduce(T identity, BinaryOperator<T> accumulator): 将所有元素聚合成一个结果.1
2
3List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
int sum = numbers.stream()
.reduce(0, (a, b) -> a + b); // 计算总和,结果为 15 -
count(): 返回流中元素的总数. -
min(Comparator<T> comparator): 返回流中的最小元素. -
max(Comparator<T> comparator): 返回流中的最大元素.
-
-
匹配:
anyMatch(Predicate<T> predicate): 是否有任何一个元素匹配给定条件.allMatch(Predicate<T> predicate): 是否所有元素都匹配给定条件.noneMatch(Predicate<T> predicate): 是否没有元素匹配给定条件.
-
查找:
findFirst(): 返回第一个元素(通常用于串行流).findAny(): 返回任意一个元素(通常用于并行流,性能更好).
使用 Stream 的好处
- 代码简洁优雅: 链式调用和 Lambda 表达式使得代码更短、更易读,能够清晰地表达业务逻辑.
- 高效: 通过懒加载和内部优化(如短路操作),Stream 在某些情况下比传统循环更高效.
- 易于并行化: 无需编写复杂的并发代码,只需调用
.parallelStream()或.parallel()即可利用多核CPU的优势,处理大规模数据时效果显著.
注意事项
- 无状态的 Lambda: 传递给 Stream 操作的 Lambda 表达式最好是无状态的(即不依赖于任何外部可变状态),这样才能保证在并行计算时结果的正确性.
- 性能考量: 并非所有场景下 Stream 都比传统循环快.对于简单、数据量小的操作,传统
for循环的开销可能更小.并行流在数据量大、单个元素处理耗时较长的情况下才能发挥优势,否则线程切换的开销可能得不偿失. - 调试困难: 由于懒加载和内部迭代的特性,调试 Stream 流水线比调试传统循环要困难一些,可以使用
peek操作来帮助观察流中元素的状态.
Collections工具类
java.util.Collections 是一个针对集合(Collection)操作的工具类,提供了大量静态方法,用于操作或返回集合.你可以把它看作是处理 List、Set、Map 等集合类型的“瑞士军刀”.它的所有方法都是静态的,因此你无需创建 Collections 类的实例,直接通过类名调用即可.
Collections 工具类的核心功能可以分为以下几类:
排序
这是最常用的功能之一,用于对 List 集合进行排序.
sort(List<T> list): 根据元素的自然顺序对列表进行升序排序,要求列表中的元素必须实现Comparable接口.sort(List<T> list, Comparator<? super T> c): 根据指定的Comparator对列表进行排序.
1 | List<Integer> numbers = new ArrayList<>(Arrays.asList(5, 2, 8, 1, 9)); |
查找与替换
binarySearch(List<? extends Comparable<? super T>> list, T key): 在一个已排序的列表中使用二分查找法查找指定元素.如果列表未排序,结果是未定义的.replaceAll(List<T> list, T oldVal, T newVal): 替换列表中所有指定的旧值为新值.max(Collection<? extends T> coll)/min(Collection<? extends T> coll): 查找集合中的最大/最小元素(根据自然顺序).frequency(Collection<?> c, Object o): 统计指定元素在集合中出现的次数.
1 | List<Integer> sortedNumbers = Arrays.asList(1, 2, 5, 8, 9); |
同步控制
在 Java 早期,Vector 和 Hashtable 是线程安全的集合,但性能较差.ArrayList 和 HashMap 等是线程不安全的,Collections 工具类提供了一系列包装方法,可以将线程不安全的集合包装成线程安全的版本.
synchronizedList(List<T> list)synchronizedSet(Set<T> s)synchronizedMap(Map<K,V> m)
1 | List<String> unsafeList = new ArrayList<>(); |
注意: 这种方式是通过在每个方法上加锁(synchronized)来实现的,性能开销较大.在现代 Java 并发编程中,更推荐使用 java.util.concurrent 包下的并发集合,如 ConcurrentHashMap、CopyOnWriteArrayList 等.
创建不可变集合
用于创建一个只读的集合视图.任何尝试修改这个视图的操作(如 add, remove, set)都会抛出 UnsupportedOperationException.这对于保护数据不被意外修改非常有用.
unmodifiableList(List<? extends T> list)unmodifiableSet(Set<? extends T> s)unmodifiableMap(Map<? extends K,? extends V> m)
1 | List<String> mutableList = new ArrayList<>(); |
其他实用方法
shuffle(List<?> list): 随机打乱列表中的元素顺序.reverse(List<?> list): 反转列表中的元素顺序.fill(List<? super T> list, T obj): 用指定对象填充整个列表.emptyList(),emptySet(),emptyMap(): 返回一个空的、不可变的集合.
Collections 工具类 vs. Stream API 对比
现在,我们将 Collections 和 Stream 进行一个详细的对比.它们是解决不同问题的工具,但有时在功能上会有重叠(例如排序).
| 特性 | java.util.Collections | java.util.Stream |
|---|---|---|
| 核心目的 | 提供一系列静态方法来直接操作或包装现有的集合. | 提供一种声明式的数据处理流水线,用于对数据源进行复杂的查询和转换. |
| 操作范式 | 命令式 (Imperative):直接告诉程序“如何做”,比如 Collections.sort(list). |
声明式 (Declarative):描述“做什么”,而不是“如何做”,比如 list.stream().sorted(). |
| 数据源 | 只能是 Collection 框架下的具体实现,如 List,Set. |
可以是 Collection、数组、I/O Channel、生成函数等任何可以转换为流的数据源. |
| 修改原始数据 | 会修改原始集合.例如 sort, shuffle, reverse 都会直接改变传入的 List. |
不会修改原始数据源,它通过流水线操作生成一个新的结果或集合视图. |
| 执行模型 | 及早求值 (Eager Evaluation):调用方法后立即执行,并完成操作. | 延迟执行 (Lazy Evaluation):中间操作(如 filter, map)不立即执行,只有当终端操作被调用时才开始整个处理过程. |
| 链式调用 | 不支持.每个操作都是独立的静态方法调用. | 核心特性.多个中间操作可以链接在一起,形成一个优雅的处理流水线. |
| 并行处理 | 不直接支持.需要手动管理多线程. | 内建支持.通过 .parallel() 或 .parallelStream() 可以轻松实现并行处理. |
| 典型用例 | - 对列表进行原地排序、反转、打乱. - 将集合包装为线程安全或不可修改的版本. - 在集合中查找最大/最小值. |
- 复杂的数据处理和转换,如筛选、映射、分组、归约. - 对大数据集合进行并行计算. - 需要组合多个操作形成流水线的场景. |
核心差异详解
- 修改性: 这是最根本的区别.
Collections.sort(myList);会直接改变myList内部元素的顺序.这是一种破坏性操作.myList.stream().sorted().collect(Collectors.toList());不会改变myList.它会创建一个新的、排好序的List作为结果返回。这是一种非破坏性操作,更符合函数式编程的理念.
- 迭代方式 :
Collections的操作通常是外部迭代.即使你看不到循环,它的实现内部也是通过循环来遍历和修改集合的.Stream使用的是内部迭代.你将行为(Lambda 表达式)传递给 Stream,由 Stream API 自身来控制迭代过程,这也为并行化和性能优化提供了可能.
- 表达力:
Collections的方法是单一、离散的.如果你想先过滤再排序,需要分多步操作,可能会创建中间集合.Stream通过链式调用,可以将多个操作(filter,map,sorted等)组合成一个连贯、易读的语句,无需创建中间变量或集合.
假设有一个 Person 对象的 List,我们想找出所有成年人,按年龄排序,并获取他们的名字列表.
使用传统 Collections 的方式(命令式):
1 | // 1. 筛选出成年人,存入新列表 |
这个过程繁琐,需要创建中间集合 adults,并且代码逻辑分散.
使用 Stream API 的方式(声明式):
1 | List<String> adultNames = people.stream() // 1. 创建流 |
这个过程一气呵成,代码简洁、易读,并且逻辑非常清晰.所有操作在一个流水线中完成,没有显式的中间集合.
- 当你需要对一个已有的
List进行原地修改(如排序、打乱),或者需要一个快速的方法来获取集合的同步/不可变包装时,Collections工具类是你的首选.它的方法简单直接,易于理解. - 当你需要处理复杂的数据查询和转换逻辑,特别是当这些逻辑涉及多个步骤(过滤、映射、排序、分组等)时,
StreamAPI 是更强大、更优雅的选择。它通过非破坏性的链式操作和内部迭代,让代码更具可读性和可维护性,并且能轻松地利用多核处理器进行并行计算.
在现代 Java 开发中,**Stream API 已经成为处理集合数据的首选方式,**而 Collections 工具类则继续在它擅长的特定领域(如原地排序、集合包装)发挥作用.两者相辅相成,共同构成了 Java 强大的集合处理工具集.
字符集
- 标准
ASCLL字符集( 美国信息交换标准代码)- 使用一个字节存储一个字符
GBK(汉字内码扩展规范,国标)- 一个中文编码成两个字节的形式存储
GBK兼容了ASCLL字符集- 第一个字节的第一位必须是1
UTF-8(Unicode字符集的一种编码方案)- 采取可变长编码方案
- 英文字符和数字只占一个字节,汉字字符占用三个字节
注意:
字符编码时使用的字符集和解码时使用的字符集必须一致,否则会乱码.
IO 流
你可以把 IO 流想象成一个连接数据源和程序的“管道”.
- **输入流 **:从数据源(如文件、网络连接、内存数组)读取数据到程序中,就像从水库抽水到你家.
- 输出流:将程序中的数据写入到目标位置(如文件、网络连接、内存数组),就像把你家的水排到下水道.
这个“管道”是单向的,要么是输入,要么是输出,数据在管道中像水流一样,按照顺序一个接一个地流动,因此被称为“流”.
按数据流向划分
- **输入流 **:用于读取数据,所有输入流的基类是
java.io.InputStream(字节输入流) 和java.io.Reader(字符输入流). - **输出流 **:用于写入数据,所有输出流的基类是
java.io.OutputStream(字节输出流) 和java.io.Writer(字符输出流).
按处理的数据单元划分
这是最重要的分类方式,直接决定了你如何处理数据.
- **字节流 **:
- 处理最基本的数据单位:字节 (byte),即 8 位二进制.
- 可以处理任何类型的文件,包括文本、图片、音频、视频等二进制文件.
- 基类是
InputStream和OutputStream. - 常见的子类有
FileInputStream,FileOutputStream,BufferedInputStream,BufferedOutputStream.
- **字符流 **:
- 处理的数据单位是字符 (char),Java 内部使用 Unicode 编码(一个字符占 2 个字节).
- 专门用于处理纯文本数据(
.txt,.java,.xml等),它能自动处理字符编码的转换问题. - 基类是
Reader和Writer. - 常见的子类有
FileReader,FileWriter,BufferedReader,BufferedWriter.
关键区别:字节流是万能的,但处理文本时需要自己处理编码问题,字符流专门为文本而生,更方便、高效,且能避免乱码问题.
按功能角色划分
- **节点流 **:
- 直接与数据源或目标设备相连接的“管道”,负责实际的读写操作.
- 例如:
FileInputStream直接连接到文件,ByteArrayInputStream直接连接到内存中的字节数组. - 你可以把它看作是“基础管道”.
- **处理流 / 包装流 **:
- 不直接连接数据源,而是“包装”在已有的节点流之上,为其增加额外的功能.
- 例如:
BufferedInputStream包装在FileInputStream之上,为其增加缓冲功能以提高读写效率,ObjectInputStream包装在其他输入流之上,使其能够直接读取 Java 对象(反序列化). - 你可以把它看作是“给基础管道加装的各种功能阀门或过滤器”,比如加速器、转换器等.
文件流 - 字节流
这是最基础的读写文件的方式.
场景:读写图片、音频或任何二进制文件.
示例:将一张图片从 res/raw 复制到应用的内部存储
1 | // 从 raw 文件夹获取输入流 |
文件字符流 - 字符流
场景:读写纯文本文件,如 JSON 字符串、日志文件.
示例:向内部存储写入和读取一个文本文件
1 | // --- 写入文件 --- |
缓冲流 - 处理流
直接对文件进行频繁的读写操作,性能会比较低.缓冲流内部维护一个缓冲区(一个内存数组),它会一次性从文件中读取大量数据到缓冲区,或将程序中的大量数据先写入缓冲区,然后一次性写入文件.这大大减少了与物理磁盘的交互次数,从而显著提高性能.
强烈建议在所有文件 IO 操作中都使用缓冲流进行包装!
示例:使用缓冲流高效地读写文本文件
1 | // --- 使用 BufferedWriter 高效写入 --- |
对象流 - 处理流
场景:当需要直接将 Java 对象完整地保存到文件或通过网络传输时,可以使用对象流.这个过程称为序列化(写入对象)和反序列化(读取对象).
注意:要被序列化的对象所属的类必须实现 java.io.Serializable 接口.
示例:保存和读取一个 User 对象
1 | // 1. 定义一个可序列化的 User 类 |
最佳实践
- 明确数据类型:首先确定你要处理的是文本数据还是二进制数据.
- 文本 -> 优先使用 字符流 (
Reader/Writer). - 非文本 (图片、音频等) -> 必须使用 字节流 (
InputStream/OutputStream).
- 文本 -> 优先使用 字符流 (
- 性能优先:无论使用哪种流,都强烈建议使用缓冲流 (
Buffered...) 进行包装,以提高 IO 性能. - 代码简洁与安全:尽量使用 try-with-resources 语句来管理流,它能确保在代码块执行完毕后,无论是否发生异常,流都会被自动关闭,避免了资源泄漏.
- 关闭流:如果未使用
try-with-resources,必须在finally块中手动调用close()方法关闭流,并处理可能抛出的IOException. - 字符编码:在使用字符流时,特别是
InputStreamReader和OutputStreamWriter(它们是字节流和字符流之间的桥梁),可以指定字符编码(如 “UTF-8”),这在处理网络数据或特定编码的文本文件时非常重要,可以有效防止乱码.
多线程
继承 Thread 类
这是最直观的一种创建线程的方式,你需要创建一个类,让它直接继承 java.lang.Thread 类,然后重写 run() 方法,run() 方法中包含了该线程需要执行的任务代码.
实现步骤:
- 定义一个类继承
Thread. - 重写父类的
run()方法,将线程要执行的逻辑写在其中. - 创建该子类的实例.
- 调用实例的
start()方法来启动线程,JVM 会在新的线程中调用run()方法.
1 | class MyThread extends Thread { |
优点:
- 实现简单,代码直观,易于理解.
- 可以直接在类内部通过
this关键字获取当前线程的引用,方便进行操作.
缺点:
- Java 是单继承的.如果你的类已经继承了另一个类,就无法再继承
Thread类了,这大大限制了其使用场景. - 任务与线程耦合度高.线程的创建(
Thread)和线程要执行的任务(run方法的逻辑)绑定在同一个类中,不符合面向对象的“高内聚,低耦合”原则.
实现 Runnable 接口
这是更常用、更推荐的一种方式.你需要创建一个类实现 java.lang.Runnable 接口,并实现其唯一的 run() 方法,然后将这个 Runnable 的实例作为参数传递给一个 Thread 类的构造函数来创建线程.
实现步骤:
- 定义一个类实现
Runnable接口. - 实现接口中的
run()方法. - 创建该实现类的实例.
- 创建一个
Thread对象,并将Runnable实例作为构造函数的参数传入. - 调用
Thread对象的start()方法.
1 | class MyRunnable implements Runnable { |
优点:
- 解耦合.将线程的创建和要执行的任务分离开来,
Runnable对象只关心任务本身,而Thread对象只负责线程的创建和管理. - 避免单继承的局限性.你的任务类可以继承其他任何类,因为它只是实现了一个接口.
- 资源共享.多个线程可以共享同一个
Runnable实例,这使得在处理共享资源时非常方便.
缺点:
run()方法没有返回值.run()方法不能抛出受检异常,只能在内部try-catch.
实现 Callable 接口
Callable 接口是在 Java 1.5 中引入的,是对 Runnable 的一个重要补充,它解决了 Runnable 不能有返回值和不能抛出异常的痛点.
Callable 通常与 FutureTask 或线程池(ExecutorService)结合使用.
实现步骤:
- 定义一个类实现
Callable<V>接口,其中V是你希望返回值的类型. - 实现接口中的
call()方法,该方法可以有返回值,也可以抛出异常. - 创建一个
FutureTask<V>对象,用Callable实例作为其构造函数的参数,FutureTask既是一个Runnable,又可以持有Callable的返回值. - 将
FutureTask对象作为参数传递给Thread构造函数并启动线程. - 通过
FutureTask对象的get()方法获取线程执行完毕后的返回值.
1 | class MyCallable implements Callable<String> { |
优点:
- 可以有返回值.
call()方法可以返回一个结果,这在需要获取异步任务执行结果的场景中非常有用. - 可以抛出异常.
call()方法的签名允许向外抛出受检异常,使得异常处理更加灵活. - 通常与功能更强大的
ExecutorService(线程池)结合使用,能更好地管理线程.
缺点:
- 实现比
Runnable稍复杂,需要借助FutureTask等辅助类.
三种方式的最终比较
为了让你更清晰地理解它们的区别和适用场景,这里提供一个总结表格:
| 特性 | 继承 Thread 类 |
实现 Runnable 接口 |
实现 Callable 接口 |
|---|---|---|---|
| 本质 | 是一个线程 (is-a 关系) |
是一个任务 (has-a 关系) |
是一个可以返回结果的任务 (has-a 关系) |
| 继承限制 | 有 (Java 单继承) | 无 (可以实现多个接口) | 无 (可以实现多个接口) |
| 耦合性 | 高 (任务和线程绑定) | 低 (任务与线程分离) | 低 (任务与线程分离) |
| 返回值 | 无 (run() 方法是 void) |
无 (run() 方法是 void) |
有 (call() 方法可以返回泛型结果) |
| 抛出异常 | 不能 (只能内部 try-catch) |
不能 (只能内部 try-catch) |
可以 (call() 方法签名允许 throws Exception) |
| 启动方式 | new MyThread().start() |
new Thread(new MyRunnable()).start() |
FutureTask + Thread 或通过线程池的 submit 方法 |
| 获取结果 | 无法直接获取 | 无法直接获取 | 通过 FutureTask.get() 或 Future.get() 获取 (会阻塞) |
| 推荐度 | 不推荐 | 推荐 | 推荐 (尤其在需要返回值或处理复杂异步任务时) |
- 首选
Runnable和Callable:在绝大多数情况下,你应该优先选择实现接口的方式 (Runnable或Callable).这使得你的代码更加灵活,耦合度更低. - 何时选择
Runnable:当你只需要执行一个异步任务,不关心其执行结果时,Runnable是最简单、最直接的选择. - 何时选择
Callable:当你需要一个任务在执行完毕后返回一个结果,或者任务在执行过程中可能会抛出需要上层调用者处理的异常时,Callable是不二之选.它与ExecutorService线程池的结合使用是现代 Java 并发编程的基石. - 何时选择继承
Thread:只有在一个非常简单的场景,或者你需要重写Thread类的其他方法(而不仅仅是run())时,才考虑继承Thread.在实际项目开发中,这种情况非常罕见.
线程同步(锁)
线程同步是一种机制,用于控制多个线程对共享资源的访问.当多个线程需要同时读取或修改同一个数据(例如一个变量、一个文件或一个数据库记录)时,线程同步可以确保在任何时刻,只有一个线程能够访问该资源,从而避免数据混乱和程序错误.
synchronized
synchronized 是 Java 中最基本、最常用的同步机制.它是一种“隐式锁”或“内置锁”(也称为监视器锁),使用起来非常直观.当一个线程进入 synchronized 代码块或方法时,它会自动获取锁;当它退出时,会自动释放锁.
synchronized 可以用在两个地方:
a. 同步方法 (Synchronized Methods)
直接在方法的声明中使用 synchronized 关键字.
- 对于普通实例方法:锁是当前类的实例对象(
this). - 对于静态方法:锁是当前类的 Class 对象.
1 | class Counter { |
在上面的例子中,任何时候只有一个线程可以调用 increment() 方法,因为它们需要获取同一个 Counter 实例的锁.
b. 同步代码块 (Synchronized Blocks)
使用 synchronized(lockObject) 来包裹需要同步的代码片段.这种方式更加灵活,因为你可以明确指定用作锁的对象,并且可以减小锁的粒度,只对必要的代码进行同步,从而提高性能.
lockObject可以是任何对象,但通常推荐使用一个专门为此目的创建的private final Object.
1 | class Counter { |
特点总结:
- 优点:使用简单,是 JVM 内置的特性,不易出错.
- 缺点:不够灵活.如果一个线程获得了锁,其他尝试获取该锁的线程会无限期地阻塞,无法中断,也无法设置超时.在竞争激烈的情况下,性能可能不如
Lock.
Lock
java.util.concurrent.locks 包提供了更强大和灵活的锁机制.Lock 是一个接口,其最常见的实现是 ReentrantLock(可重入锁).
与 synchronized 自动获取和释放锁不同,Lock 需要手动获取锁 (lock()) 和释放锁 (unlock()).为了确保锁一定会被释放(即使在代码块中发生异常),unlock() 操作通常放在 finally 块中.
主要方法:
lock(): 获取锁,如果锁不可用,则当前线程将被阻塞,直到获取锁.unlock(): 释放锁.tryLock(): 尝试非阻塞地获取锁,调用后立即返回true(获取成功)或false(获取失败).tryLock(long time, TimeUnit unit): 在指定时间内尝试获取锁,超时则返回false.
1 | class Counter { |
与 synchronized 的比较和优势:
- 灵活性:可以尝试获取锁(
tryLock),可以设置超时,也可以被中断. - 公平性:
ReentrantLock构造函数可以接受一个布尔值,用于创建公平锁.公平锁会按照线程请求的顺序来分配锁,但通常会带来性能开销.synchronized是非公平的. - 高级功能:可以实现更复杂的锁定逻辑,例如一个
Lock对象可以关联多个Condition对象,实现分组唤醒(signal()vsnotify(),signalAll()vsnotifyAll()).
volatile(变量线程间统一)
volatile 是一种轻量级的同步机制,但它与 synchronized 和 Lock 有本质区别.它不保证原子性,但它保证了两个关键特性:
a. 可见性
当一个线程修改了被 volatile 修饰的变量的值,这个新值对其他线程是立即可见的.JVM 会确保每次读取 volatile 变量时,都直接从主内存中读取,而不是使用线程本地缓存.
**b. 禁止指令重排序 **
编译器和处理器为了优化性能,可能会对指令进行重排序.volatile 关键字可以防止对其修饰的变量的读写操作进行重排序,从而避免在某些并发场景下出现问题(例如双重检查锁定模式中的懒汉式单例).
适用场景: volatile 通常用于一个线程写,多个线程读的场景,或者当变量的更新不依赖于其当前值时.例如,一个布尔标志位,用于控制线程的终止.
1 | class TaskRunner implements Runnable { |
当其他线程调用 stop() 方法将 running 设置为 false 时,volatile 保证了正在执行 run() 方法的线程能够立即看到这个变化,从而跳出循环.
重要提醒: volatile 不能替代 synchronized 来保证复合操作的原子性.例如,count++ 这个操作实际上包含了三个步骤:读取 count 值、将值加一、写回新值.volatile 只能保证每次读取的 count 是最新的,但不能保证这三个步骤作为一个原子操作执行.因此,对于 count++ 这样的操作,还是需要使用 synchronized 或 Lock.
| 特性 | synchronized 关键字 |
java.util.concurrent.locks.Lock |
volatile 关键字 |
|---|---|---|---|
| 锁机制 | 悲观锁,隐式锁 | 悲观锁,显式锁 | 无锁 |
| 原子性 | 保证 | 保证 | 不保证(仅保证单次读/写) |
| 可见性 | 保证 | 保证 | 保证 |
| 使用方式 | 自动加锁和解锁 | 手动加锁和解锁 | 修饰变量 |
| 灵活性 | 较低,阻塞不可中断 | 高,可中断、可超时、可尝试 | 不适用(不是锁) |
| 性能 | 简单场景下性能不错,JVM 持续优化 | 竞争激烈时通常性能更好 | 轻量级,性能开销最小 |
| 适用场景 | 大多数需要同步的场景 | 需要高级功能或高竞争的场景 | 保证变量的可见性,一写多读 |
线程池
线程池是Java并发编程中不可或缺的核心工具.通过ThreadPoolExecutor,我们可以精细地控制线程的创建、销毁和任务的调度,从而在提高系统性能和响应速度的同时,保证系统的稳定性.在实际开发中,强烈建议使用ThreadPoolExecutor的构造函数来创建线程池,并根据任务类型和系统资源进行合理的参数配置.
想象一下你开了一家餐厅,如果没有线程池,就相当于每来一位顾客(一个任务),你就雇佣一位新的服务员(一个线程)去服务他.服务结束后,这位服务员就直接解雇了.如果同时来很多顾客,你就需要瞬间雇佣大量的服务员,这会导致招聘和解雇(创建和销毁线程)的成本非常高,而且服务员数量(线程数)过多,他们之间可能会互相干扰,导致餐厅(系统)效率低下,甚至混乱崩溃.
线程池(Thread Pool) 就是为了解决这个问题而生的.它就像是你提前雇佣了一批固定数量的核心服务员,并让他们随时待命.
- 当有顾客(任务)来时,你从这群服务员中找一个空闲的去服务.
- 如果所有服务员都在忙,新来的顾客就在等候区(任务队列)排队等候.
- 当一位服务员服务完一个顾客后,他不会被解雇,而是回到待命状态,准备服务下一位等候的顾客.
通过这种方式,线程池在程序启动时就创建了一定数量的线程,并将它们放入一个池中进行统一管理,当有任务需要执行时,直接从池中获取一个空闲线程来执行,任务执行完毕后,线程并不会立即销毁,而是返回池中等待下一个任务.
优点:
- 降低资源消耗:通过重复利用已创建的线程,减少了线程创建和销毁带来的开销.
- 提高响应速度:当任务到达时,无需等待新线程的创建,可以直接从池中获取线程立即执行,从而缩短了响应时间.
- 提高线程的可管理性:线程是稀缺资源,如果无限制地创建,不仅会消耗系统资源,还会降低系统的稳定性.线程池可以对线程进行统一分配、调优和监控,避免了野线程(unmanaged threads)的出现.
核心参数
当你创建一个 ThreadPoolExecutor 实例时,通常需要指定以下几个核心参数:
corePoolSize(核心线程数):- 线程池中保持存活的核心线程数量.
- 即使这些线程处于空闲状态,它们也不会被回收(除非设置了
allowCoreThreadTimeOut).
maximumPoolSize(最大线程数):- 线程池能够容纳同时执行的最大线程数.
- 这个值必须大于或等于
corePoolSize.
keepAliveTime(线程空闲存活时间):- 当线程池中的线程数量超过
corePoolSize时,如果一个非核心线程空闲的时间超过keepAliveTime,它就会被销毁. - 这样可以释放掉多余的资源.
- 当线程池中的线程数量超过
unit(时间单位):keepAliveTime的时间单位,例如TimeUnit.SECONDS(秒)、TimeUnit.MILLISECONDS(毫秒)等.
workQueue(任务队列):- 一个阻塞队列(
BlockingQueue),用于存放等待执行的任务. - 当核心线程都在忙时,新提交的任务会进入这个队列排队.
- 常用的队列类型有:
ArrayBlockingQueue(有界队列)、LinkedBlockingQueue(无界或有界队列)、SynchronousQueue(不存储元素的队列)等.
- 一个阻塞队列(
threadFactory(线程工厂):- 用于创建新线程的工厂.
- 可以通过它来自定义线程的名称、是否为守护线程、优先级等.
rejectedExecutionHandler(拒绝策略):- 当任务队列已满,并且线程池中的线程数也达到了
maximumPoolSize时,新提交的任务会根据这个策略来处理. - Java内置了四种拒绝策略:
AbortPolicy(默认): 直接抛出RejectedExecutionException异常.CallerRunsPolicy: 由提交任务的线程自己来执行这个任务.DiscardPolicy: 直接丢弃这个任务,什么也不做.DiscardOldestPolicy: 丢弃任务队列中最旧的一个任务,然后尝试重新提交当前任务.
- 当任务队列已满,并且线程池中的线程数也达到了
工作流程
当一个新任务通过 execute() 方法提交给线程池时,处理流程如下:
- 判断核心线程数:判断当前运行的线程数是否小于
corePoolSize.如果是,则立即创建一个新的核心线程来执行该任务,即使其他核心线程是空闲的. - 尝试加入任务队列:如果当前运行的线程数已经等于或大于
corePoolSize,则尝试将任务放入workQueue任务队列中. - 尝试创建非核心线程:如果任务队列已满,无法再放入新任务,则判断当前运行的线程数是否小于
maximumPoolSize,如果是,则创建一个新的非核心线程来执行该任务. - 执行拒绝策略:如果当前运行的线程数已经达到
maximumPoolSize,并且任务队列也满了,那么就无法再处理这个任务了.此时会启动指定的rejectedExecutionHandler拒绝策略来处理该任务.
创建线程池的方法
使用 Executors工厂类 (不推荐)
Executors 提供了一些静态工厂方法,可以方便地创建几种预设配置的线程池.
-
newFixedThreadPool(int nThreads)- 特点: 创建一个固定大小的线程池.
corePoolSize和maximumPoolSize相等,keepAliveTime为0. - 工作队列:
LinkedBlockingQueue(理论上是无界的). - 适用场景: 适用于负载比较重的服务器,需要执行长期任务,可以稳定地控制并发线程数.
- 风险: 任务队列是无界的,如果任务提交速度远大于处理速度,可能导致大量任务堆积,引发内存溢出.
1
2
3
4ExecutorService fixedThreadPool = Executors.newFixedThreadPool(5);
fixedThreadPool.execute(() -> {
System.out.println(Thread.currentThread().getName() + " is running...");
}); - 特点: 创建一个固定大小的线程池.
-
newSingleThreadExecutor()- 特点: 创建一个只有一个线程的线程池.
- 工作队列:
LinkedBlockingQueue(理论上是无界的). - 适用场景: 适用于需要保证所有任务按照指定顺序(FIFO)执行的场景.
- 风险: 与
newFixedThreadPool类似,也存在任务堆积导致内存溢出的风险.
1
ExecutorService singleThreadExecutor = Executors.newSingleThreadExecutor();
-
newCachedThreadPool()- 特点: 创建一个可缓存的线程池.
corePoolSize为0,maximumPoolSize为Integer.MAX_VALUE,keepAliveTime为60秒. - 工作队列:
SynchronousQueue(不存储元素的队列). - 工作机制: 来一个任务,如果没有空闲线程,就创建一个新线程;如果一个线程空闲60秒,就会被回收.
- 适用场景: 适用于执行大量、耗时较短的异步任务.
- 风险:
maximumPoolSize是无限大的,如果并发任务量巨大,可能会瞬间创建大量线程,导致系统资源耗尽,引发内存溢出.
1
ExecutorService cachedThreadPool = Executors.newCachedThreadPool();
- 特点: 创建一个可缓存的线程池.
注意: 阿里巴巴的《Java开发手册》中强制规定:不允许使用
Executors去创建线程池,而是通过ThreadPoolExecutor的方式,这样的处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽的风险.
使用 ThreadPoolExecutor 构造函数 (推荐)
这是最灵活、最推荐的创建方式,因为它让你对线程池的每一个细节都有完全的控制.
1 | public class ThreadPoolDemo { |
合理配置线程池参数
合理配置线程池的大小非常关键,通常需要根据任务的类型来决定.
- CPU密集型任务 (CPU-bound):
- 这种任务需要大量的CPU计算,例如加密、计算哈希值等.
- 理论上,线程数不应超过CPU核心数,因为过多的线程会导致频繁的上下文切换,反而降低性能.
- 配置建议:
corePoolSize= CPU核心数 + 1 (这个“+1”是为了防止某个线程因意外原因,如缺页中断,而暂停时,CPU能有另一个线程可以调度).
- I/O密集型任务 (I/O-bound):
- 这种任务大部分时间都在等待I/O操作(如读写文件、网络请求等),CPU空闲时间较多.
- 可以配置更多的线程,以便在一个线程等待I/O时,其他线程可以继续使用CPU.
- 配置建议:
- 一个常见的经验公式是:
corePoolSize= CPU核心数 * (1 + 平均等待时间 / 平均CPU计算时间). - 在实践中,一个更简单的估算方法是
corePoolSize= CPU核心数 * 2. - 当然,最佳的线程数需要通过性能压测来确定.
- 一个常见的经验公式是:
网络编程三要素
- IP地址 (IP Address):相当于收件人的家庭住址.它唯一地标识了网络上的一台设备(比如电脑、服务器或手机).
- 端口 (Port):相当于你家里的某个具体的房间或者人.数据到达了这栋房子(IP地址),还需要知道具体要交给谁(哪个应用程序).
- 协议 (Protocol):相当于快递公司使用的打包、运输和签收规则,它规定了数据应该如何被格式化、传输和解析.
IP 地址 (IP Address)
IP地址是“Internet Protocol Address”的缩写,即互联网协议地址.它是在网络中分配给每台设备的一个唯一的数字标识.
- 作用:用来在复杂的网络环境中定位和识别一台特定的计算机或网络设备.就像邮递员需要通过地址找到你的家一样,网络数据包也需要通过IP地址找到目标主机.
- 主要版本:
- IPv4 (Internet Protocol version 4): 这是目前最广泛使用的版本.它由4个字节(32位)组成,通常用“点分十进制”表示,例如
192.168.1.1.IPv4地址资源已接近枯竭. - IPv6 (Internet Protocol version 6): 为了解决IPv4地址耗尽的问题而推出,它由16个字节(128位)组成,地址空间极大,表示形式更复杂,例如
2001:0db8:85a3:0000:0000:8a2e:0370:7334.
- IPv4 (Internet Protocol version 4): 这是目前最广泛使用的版本.它由4个字节(32位)组成,通常用“点分十进制”表示,例如
- 特殊IP地址:
127.0.0.1:这是一个特殊的回环地址 (Loopback Address),代表本机.当你访问这个地址时,数据包不会发送到网络上,而是直接在本机内部循环.它通常与域名localhost绑定.
端口 (Port)
如果说IP地址帮助我们将数据送到了正确的计算机,那么端口就是用来告诉这台计算机,数据应该交给哪一个应用程序来处理.
- 作用:区分和定位一台主机上正在运行的不同网络应用程序.例如,你的电脑可能同时开着浏览器(通常使用80端口)、邮件客户端(如SMTP用25端口)和FTP客户端(21端口).当数据包到达时,操作系统通过端口号就能知道这个数据是给浏览器的,还是给邮件客户端的.
- 本质:端口是一个逻辑概念,而不是物理设备.它是一个16位的整数,范围从
0到65535. - 端口分类:
- 公认端口 (Well-Known Ports):
0~1023.这些端口被预留给一些众所周知的服务,例如:80: HTTP (Web服务)443: HTTPS (加密的Web服务)21: FTP (文件传输服务)22: SSH (安全远程登录)
- 注册端口 (Registered Ports):
1024~49151.分配给用户进程或应用程序. - 动态/私有端口 (Dynamic/Private Ports):
49152~65535.客户端程序通常会使用这个范围内的端口与服务器进行通信.
- 公认端口 (Well-Known Ports):
协议 (Protocol)
协议是“Protocol”的音译,指的是网络通信的规则和约定.通信的双方(例如你的浏览器和Web服务器)必须遵守相同的协议,才能正确地解释对方发送的数据.
- 作用:规范数据的格式、传输时序、错误处理等.没有协议,数据交换就会变得混乱无序,就像两个说着不同语言且不懂对方规则的人无法交流一样.
- 分层模型:网络协议通常被组织成一个分层结构,最著名的是 TCP/IP协议簇 (TCP/IP Protocol Suite).
- 常见的关键协议:
- TCP (Transmission Control Protocol, 传输控制协议):
- 特点:面向连接、可靠、基于字节流的传输.在通信前需要先建立连接(“三次握手”),通信结束后断开连接(“四次挥手”).它能保证数据完整、有序地到达目的地.
- 场景:适用于对数据可靠性要求高的场景,如文件传输、发送邮件、浏览网页.
- UDP (User Datagram Protocol, 用户数据报协议):
- 特点:无连接、不可靠、面向报文的传输.它只是尽最大努力交付数据,不保证数据是否到达、是否按顺序到达.但它的优点是开销小、传输速度快.
- 场景:适用于对实时性要求高、能容忍少量数据丢失的场景,如在线视频、网络直播、语音通话、DNS查询.
- HTTP (HyperText Transfer Protocol, 超文本传输协议): 建立在TCP协议之上的应用层协议,是万维网数据通信的基础.
- IP (Internet Protocol, 网际协议): 负责IP地址的分配和数据包的路由.
- TCP (Transmission Control Protocol, 传输控制协议):
完整过程
当你在浏览器中输入一个网址(例如
www.google.com)并按下回车时,你的电脑(客户端)需要向谷歌的服务器发送一个请求,这个过程就完美地体现了三要素:
- 协议:浏览器会使用 HTTP/HTTPS 协议来构建请求数据.
- IP地址:通过DNS服务,你的电脑将域名
www.google.com解析成一个具体的 IP地址(比如172.217.164.100),从而找到了网络中谷歌服务器这台“房子”.- 端口:因为是Web请求,所以数据会发往服务器的 80端口(HTTP)或443端口(HTTPS),告诉服务器这个数据请交给你的Web服务程序来处理.
最终,通过 协议 规定好格式,通过 IP地址 找到服务器,再通过 端口 找到对应的服务程序,一次完整的网络通信就得以实现.
Socket
Socket(套接字) 可以被理解为网络上两个程序之间进行双向通信的端点.它封装了底层的网络细节(如IP地址和端口号),让我们能够像读写本地文件一样方便地进行网络数据交换.
一个很经典的类比是电话:
- IP地址 就像是城市的区号,标识了一台独一无二的计算机.
- 端口号 (Port) 就像是电话的分机号,标识了计算机上的一个特定应用程序(比如80端口通常是Web服务器,3306是MySQL数据库).
- Socket 就是你手里的那部电话机,你需要知道对方的区号(IP)和分机号(Port)才能拨号建立连接.一旦连接建立,你们就可以通过各自的电话机(Socket)进行通话(数据交换).
在Java中,Socket编程主要指的是基于TCP协议的编程,因为UDP是无连接的,它使用不同的类(DatagramSocket 和 DatagramPacket).因此,当我们通常谈论java.net.Socket时,我们指的就是TCP Socket.
Java Socket编程模型:客户端/服务器 (C/S)
Java的Socket编程通常遵循客户端/服务器模型.
- 服务器 (Server):
- 创建一个
ServerSocket对象,并将其“绑定”到一个特定的端口上. ServerSocket就像是一个总机,它负责在一个端口上持续监听,等待客户端的连接请求.- 当一个客户端请求连接时,
ServerSocket会调用accept()方法,这个方法会阻塞(暂停等待),直到一个连接建立. - 一旦连接成功,
accept()方法会返回一个全新的Socket对象,这个新的Socket对象才是真正用于与这个特定客户端进行通信的通道.服务器可以继续监听,以接受更多客户端的连接.
- 创建一个
- 客户端 (Client):
- 创建一个
Socket对象. - 在创建时,需要指定服务器的IP地址和端口号,以表明“我想连接哪台机器上的哪个程序”.
- 一旦
Socket对象成功创建,就意味着客户端和服务器之间的连接已经建立成功. - 之后,客户端就可以使用这个
Socket对象与服务器进行通信了.
- 创建一个
核心类与工作流程
java.net.ServerSocket(服务器端)
这是服务器的入口点.
ServerSocket(int port): 构造函数,创建一个监听指定端口的服务器Socket.Socket accept(): 监听并接受客户端的连接请求,这是一个阻塞方法,程序会在这里等待,直到有客户端连接进来,它返回一个用于与该客户端通信的Socket实例.void close(): 关闭服务器Socket,停止监听.
java.net.Socket (客户端和服务器与客户端的连接)
这是通信的端点.
Socket(String host, int port): 客户端使用的构造函数,用于连接到指定主机和端口的服务器.InputStream getInputStream(): 获取输入流,用于从对方读取数据.OutputStream getOutputStream(): 获取输出流,用于向对方发送数据.void close(): 关闭这个Socket连接,这会释放所有资源,并断开与对方的连接.
数据交换流程
一旦连接建立(服务器的accept()返回Socket,客户端的new Socket(...)执行完毕),双方就可以通过输入/输出流(InputStream/OutputStream)来读写数据,就像操作文件一样.
- A想给B发消息:A从自己的
Socket获取OutputStream,然后写入数据. - B想接收A的消息:B从自己的
Socket获取InputStream,然后读取数据.
这个过程是全双工的,意味着双方可以同时进行读写.
Java Socket代码示例
下面是一个非常基础的例子,客户端发送一条消息,服务器接收后回复一条消息。
服务器端代码 (Server.java)
1 | public class Server { |
客户端代码 (Client.java)
1 | public class Client { |
单元测试
在 Android 中,测试主要分为两类,了解它们的区别非常重要:
- 本地单元测试
- 位置:
app/src/test/java/目录下. - 运行环境: 在你电脑的 Java 虚拟机 (JVM) 上直接运行,不依赖 Android 设备或模拟器.
- 优点: 速度极快,几秒钟就能跑完成百上千个测试.
- 用途: 主要用于测试应用的业务逻辑,例如 ViewModel、Repository、Presenter、工具类等不直接依赖 Android 框架(如
Context、Activity)的代码. - 常用框架: JUnit、Mockito、Robolectric.
- 位置:
- 仪器化测试
- 位置:
app/src/androidTest/java/目录下. - 运行环境: 必须在真实的 Android 设备或模拟器上运行.
- 优点: 可以测试与 Android 框架紧密耦合的代码,例如 UI 交互、数据库、Service 等.
- 缺点: 速度慢,启动模拟器和安装 APK 都需要时间.
- 常用框架: Espresso (UI 测试), UI Automator, AndroidX Test.
- 位置:
重点讲解第一种:本地单元测试 (Local Unit Tests)。
配置 build.gradle
通常,当你创建一个新的 Android Studio 项目时,相关的测试依赖已经自动添加好了.
1 | dependencies { |
编写你单元测试
我们从一个不依赖任何 Android 框架的简单计算器类开始.
创建被测试的类
在 app/src/main/java/com/yourpackage/ 目录下创建一个简单的类,例如 Calculator.java.
1 | public class Calculator { |
创建测试类
- 在
Calculator.java类名上右键,选择 Go To > Test. - 在弹出的窗口中,点击 Create New Test….
- Android Studio 会自动为你配置好测试类的信息.
- Testing library: 选择 JUnit4.
- Class name: 默认是
CalculatorTest(这是标准的命名规范). - Destination package: 确保是
com.yourpackage(在 test 目录下).
- 点击 OK,Android Studio 会在
app/src/test/java/com/yourpackage/目录下生成一个CalculatorTest.java文件.
生成的代码框架如下:
1 | public class CalculatorTest { |
编写测试方法
为 add 方法编写一个测试,一个标准的测试方法遵循 Arrange-Act-Assert (准备-执行-断言) 模式.
- @Test 注解:告诉 JUnit 这是一个测试方法.
- 方法命名: 最好能清晰地描述测试的内容,例如
subject_action_expectedResult(被测试对象行为期望结果).
1 | public class CalculatorTest { |
assertEquals(expected, actual) 是 JUnit 提供的一个断言方法,用于判断期望值和实际值是否相等,如果不想等,测试就会失败.
运行测试
你有多种方式可以运行测试:
- 运行单个测试方法: 点击方法名左侧的绿色三角形图标,选择 “Run ‘add_twoNumbers_returnsCorrectSum()’”.
- 运行整个测试类: 点击类名左侧的绿色三角形图标,选择 “Run ‘CalculatorTest’”.
- 运行所有测试: 在项目视图中,右键点击
test目录,选择 “Run ‘Tests in com.yourpackage’”.
查看结果
运行后,Android Studio 底部会弹出测试结果窗口.
- 绿色 对勾表示测试通过.
- 红色 感叹号表示测试失败,失败时会明确指出哪一行断言失败了,以及期望值和实际值的差异.
测试依赖 Android 框架的代码
麻烦的地方来了:如果你的代码依赖了 Android 的 API (比如 Context, SharedPreferences, Log 等),直接在 JVM 上运行测试会抛出 RuntimeException: Method ... not mocked 异常.
这时我们有两种主流的解决方案:
Mockito (模拟)
当你的类依赖于其他对象时 (例如,一个 ViewModel 依赖一个 Repository),你可以使用 Mockito 来创建一个 “假” 的依赖对象,从而隔离被测试的类.
示例:假设有一个类,它需要从 Context 中获取一个字符串资源.
1 | public class ResourceManager { |
测试类 ResourceManagerTest.java
1 | // 告诉 JUnit 使用 Mockito 的运行器 |
关键点:
@RunWith(MockitoJUnitRunner.class): 启用 Mockito 注解.@Mock: 创建一个模拟对象.when(...).thenReturn(...): 定义当模拟对象的某个方法被调用时,应该返回什么值,这样就避免了真实调用 Android 框架的代码.
Robolectric
Robolectric 是一个更强大的框架,它在 JVM 内部创建了一个模拟的 Android 环境.使用它,你可以像在真实设备上一样调用 Android API,而无需 mock 它们.
示例:我们测试一个使用了 Android 工具类 Patterns 的邮箱验证器.
被测试的类 EmailValidator.java
1 | public class EmailValidator { |
Patterns.EMAIL_ADDRESS 是一个 Android 框架内的静态变量,直接测试会失败.
测试类 EmailValidatorTest.java (使用 Robolectric)
1 | // 使用 Robolectric 运行器 |
关键点:
@RunWith(RobolectricTestRunner.class): 只要加上这个注解,Robolectric 就会在后台施展魔法,让Patterns.EMAIL_ADDRESS等 Android API 能够正常工作.- Robolectric 比较重,会拖慢测试速度,所以只在必要时使用.
最佳实践
- 优先选择本地单元测试: 因为它速度快,能提供快速反馈.
- 让代码易于测试: 遵循单一职责原则,使用依赖注入(例如通过构造函数传入依赖项),这会让 mock 变得非常容易,像 MVVM 这样的架构模式本身就是为了让业务逻辑(ViewModel)和 UI(View)分离,从而方便对 ViewModel 进行单元测试.
- 测试边界情况: 不仅要测试正常情况(“happy path”),还要测试各种边界条件,比如
null输入、空字符串、无效值等. - 保持测试独立: 每个测试方法都应该是独立的,不应该依赖于其他测试方法的执行顺序或结果,使用
@Before注解来设置每个测试运行前的通用环境,使用@After来清理.
反射
Java 反射机制是在程序运行时,对于任意一个类,都能够知道这个类的所有属性和方法;对于任意一个对象,都能够调用它的任意一个方法和属性.这种动态获取信息以及动态调用对象方法的功能被称为 Java 语言的反射机制.
简单来说,正常情况下,我们要在编译时就确定要使用的类和方法.而反射允许我们在运行时获取和使用类的所有数据,这让代码变得更加灵活和强大,Java 反射的核心是 java.lang.Class 类和 java.lang.reflect 包中的一系列类.
反射的核心组成
java.lang.reflect 包提供了实现反射功能的主要工具类,其中最重要的几个是:
Class类: 这是反射的入口.每个类在 JVM 中都有一个对应的Class对象,这个对象包含了类的完整信息(如类名、父类、接口、字段、方法、构造函数等),获取Class对象是使用反射的第一步.Constructor类: 代表类的构造函数.通过它可以动态地创建类的实例.Method类: 代表类的方法.通过它可以动态地调用对象的方法.Field类: 代表类的成员变量(属性).通过它可以动态地读取或设置对象的属性值,即使是私有(private)属性.
优点
- 灵活性和动态性: 反射是 Java 动态性的核心,它允许程序在运行时创建和控制任何类的对象,而无需在编译时进行硬编码,这对于编写通用性强的框架和库至关重要.
- 扩展性: 程序可以动态加载和使用在编译时完全未知的外部类.
缺点
- 性能开销大: 反射调用涉及到一系列的动态解析和类型检查,其性能远不及直接代码调用.因此,不应在性能敏感的热点代码中滥用.
- 破坏封装性: 反射可以访问和修改类的私有成员,这违背了面向对象的封装原则,可能导致代码混乱和安全问题.
- 代码可读性差: 反射相关的代码通常比直接调用更复杂、更冗长,降低了代码的可读性和可维护性.
- 编译期类型检查失效: 反射操作的参数和返回值都是
Object类型,编译器无法进行类型检查,容易在运行时抛出ClassCastException等异常.
应用场景
尽管有缺点,但在许多场景下,反射是不可或缺的:
- 依赖注入框架 (DI): 像 Dagger、Hilt、Koin(部分功能)等框架,会通过反射扫描带有
@Inject、@Provides等注解的类、字段和方法,然后在运行时动态地创建实例并注入到需要它们的地方,这免去了大量手动创建和传递对象的样板代码. - 网络请求库 (Networking): 以 Retrofit 为例,它通过反射解析你在接口中定义的注解(如
@GET、@POST、@Path)和方法签名,然后在运行时动态地创建一个实现了该接口的代理对象,这个代理对象会负责构建和发起实际的 HTTP 请求. - JSON 解析库 (Serialization/Deserialization): GSON 和 Jackson 这类库,通过反射读取一个类的字段信息(字段名、类型),然后将 JSON 字符串中的数据自动填充到这个类的实例中,或者反过来将对象序列化为 JSON,这样就不用手动一个个解析字段了.
- 视图绑定库 (View Binding - 早期): 在官方推出 ViewBinding 之前,像 ButterKnife 这样的库非常流行.它通过反射查找带有
@BindView等注解的字段,并将它们与 XML 布局文件中的视图进行关联,从而省去了大量的findViewById()调用. (注:现代的 ViewBinding 和 DataBinding 采用编译时代码生成技术,避免了反射的性能损耗)
获取 Class 对象
主要有三种方式:
-
通过对象实例获取:
person.getClass()1
2String str = "Hello";
Class<?> clazz = str.getClass(); -
通过类名获取:
类名.class(最安全、性能最好)1
Class<?> clazz = String.class;
-
通过类的全限定名获取:
Class.forName("类的全限定名")(常用于配置文件加载驱动等场景)1
2
3
4
5try {
Class<?> clazz = Class.forName("java.lang.String");
} catch (ClassNotFoundException e) {
e.printStackTrace();
}
核心功能与示例
我们以一个简单的 Person 类为例,来演示反射的强大功能.
1 | public class Person { |
创建对象实例
反射可以调用任意构造函数(包括私有的)来创建对象.
1 | Class<?> personClass = Person.class; |
获取和操作成员变量 (Field)
反射可以读取和修改对象的任意字段(包括私有的).
1 | Class<?> personClass = Person.class; |
获取和调用方法 (Method)
反射可以调用对象的任意方法(包括私有的).
1 | Class<?> personClass = Person.class; |
get...()和getDeclared...()的区别:
get...()系列方法(如getField,getMethod)只能获取public的成员.getDeclared...()系列方法(如getDeclaredField,getDeclaredMethod)可以获取该类自己声明的所有成员(public, protected, private, default),但不能获取父类的成员.
注解
注解(Annotation) 本质上是一种元数据(Metadata),可以把它理解成一个“标签”.
它本身不会直接改变代码的执行逻辑,而是为代码提供额外的信息.这些信息可以被编译器、注解处理器或者在运行时通过反射来读取和使用,从而实现特定的功能.
Android SDK (通过 androidx.annotation 包) 提供了很多非常有用的内置注解,它们主要用于静态代码分析,帮助你在编译阶段就发现潜在的错误.
- 注解是代码的“标签”,提供元数据.
- Android 内置注解主要用于编译期静态检查,提升代码健壮性.
- 自定义注解通过
@interface定义,并通过元注解 (@Retention,@Target) 控制其行为. - 注解的处理方式主要有运行时反射(灵活但性能稍差,如 Retrofit)和编译时代码生成(高效但复杂,如 Room、Hilt).
- 它是现代 Android 开发中解耦、减少模板代码、提升开发效率的核心技术之一.
常见的 Android 内置注解
-
Nullness 注解 (
@Nullable,@NonNull)-
作用:标记一个变量、参数或返回值是否可以为 null,这是最有用的注解之一.
-
好处:Android Studio 会根据这个注解进行检查,如果你试图对一个标记为
@Nullable的对象直接调用方法,或者给一个标记为@NonNull的参数传递 null,IDE 会立刻给出警告,帮你提前发现潜在的NullPointerException.1
2
3
4Javapublic void setUserName( String name) { // ... }
public String getUserName() { // ... }
// 调用时
setUserName(null); // IDE 会立即警告!
-
-
资源类型注解 (
@StringRes,@DrawableRes,@ColorRes,@LayoutRes等)-
作用:标记一个整型参数或变量应该是一个特定类型的资源 ID (例如
R.string.app_name),而不是普通的整数. -
好处:如果你传递了一个错误的类型(比如把一个
R.drawable.icon传给了要求@StringRes的方法),编译器会报错.1
2
3
4
5
6public void setTitle( int titleId) {
textView.setText(titleId);
}
// 调用时
setTitle(R.string.welcome_message); // 正确
setTitle(12345); // 错误,IDE 会警告 `
-
-
线程注解 (
@UiThread,@WorkerThread,@MainThread)-
作用:标记一个方法应该在哪种类型的线程上被调用。
-
好处:如果你在一个工作线程(WorkerThread)中调用了标记为
@UiThread的方法(比如更新 UI),IDE 会给出警告,帮你避免主线程阻塞或子线程UI操作的错误.1
2
3
4
public void updateUI() { // ... }
public void doHeavyTask() { // ... }
-
-
值约束注解 (
@IntRange,@FloatRange,@Size)-
作用:限制数字的范围或集合/数组的大小.
-
好处:静态检查,确保传入的值在有效范围内.
1
public void setAlpha( int alpha) { // ... }
-
-
类型定义注解 (
@IntDef,@StringDef)-
作用:替代 Java 的枚举,创建一组类型安全的常量.
-
好处:枚举的性能开销(内存占用)比常量要大,使用
@IntDef可以在保证类型安全的同时,获得和使用普通常量一样的性能.1
2
3
4
5
6
7
8
9
10
11
12// 1. 定义常量
public static final int STYLE_NORMAL = 0;
public static final int STYLE_BOLD = 1;
// 2. 使用 @IntDef 将它们“捆绑”成一个新的注解类型
// 保留策略,后面会讲
public TextStyle {}
// 3. 在方法参数中使用这个新注解
public void setTextStyle( int style) { // ... }
// 调用时
setTextStyle(STYLE_NORMAL); // 正确
setTextStyle(2); // 错误,IDE 会警告
-
-
@Keep- 作用:告诉 Proguard/R8,在代码混淆和优化时,不要移除或重命名被这个注解标记的类、方法或字段.
- 好处:当你需要通过反射访问某些代码时(比如 JNI 调用或者某些序列化库),这个注解非常有用,可以防止它们在打包时被优化掉.
创建自定义注解
创建自定义注解主要涉及两个方面:定义注解和处理注解.
定义注解
使用 @interface 关键字来定义一个注解,在定义时,需要使用**元注解(Meta-Annotation)**来修饰你的注解,告诉系统它应该如何工作.
@Retention: 定义注解的保留策略,即注解的生命周期.RetentionPolicy.SOURCE: 注解只保留在源代码中,编译后会被丢弃.主要用于静态代码检查,比如@Override和@IntDef.RetentionPolicy.CLASS: 注解会保留在编译后的.class文件中,但在运行时(JVM)不可见,这是默认策略.RetentionPolicy.RUNTIME: 注解会保留到运行时,可以通过**反射(Reflection)**来获取和使用,大多数自定义注解(尤其是框架类)都使用这个策略.
@Target: 定义注解可以被应用在哪些代码元素上.ElementType.TYPE: 类、接口、枚举.ElementType.FIELD: 字段(成员变量).ElementType.METHOD: 方法.ElementType.PARAMETER: 方法参数.ElementType.CONSTRUCTOR: 构造函数.- 等等…
@Documented: 表示这个注解应该被包含在 Javadoc 中.@Inherited: 表示子类可以继承父类中的这个注解.
示例:创建一个用于事件追踪的注解
假设我们想标记某些方法,当它们被调用时,自动上报一个追踪事件。
1 | // 1. 使用元注解定义注解的行为 |
如何使用我们定义的注解:
1 | public class MyActivity extends AppCompatActivity { |
处理注解
定义好注解后,它自己不会做任何事,你需要编写代码来处理它,处理方式主要有两种:
-
运行时处理(通过反射) 这是最直接的方式.代码在运行时,通过 Java 的反射 API 来检查某个类、方法或字段上是否有特定的注解,并获取注解的参数值,然后执行相应逻辑.
示例:处理上面定义的
@EventTracking注解(通常用 AOP 思想实现,这里用一个简单的代理作为演示)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 EventTracker {
public static void track(Object target, String methodName, Class<?>... parameterTypes) {
try {
// 1. 获取方法对象
Method method = target.getClass().getMethod(methodName, parameterTypes);
// 2. 检查方法上是否存在 @EventTracking 注解
if (method.isAnnotationPresent(EventTracking.class)) {
// 3. 获取注解实例
EventTracking annotation = method.getAnnotation(EventTracking.class);
// 4. 获取注解的参数值并上报
String eventName = annotation.eventName();
int eventId = annotation.eventId();
System.out.println(String.format("上报事件:name=%s, id=%d", eventName, eventId));
}
// 实际场景中,这里会调用方法的原始逻辑
// method.invoke(target, args);
} catch (NoSuchMethodException e) {
e.printStackTrace();
}
}
}
// 模拟调用
MyActivity activity = new MyActivity();
// 模拟登录按钮点击
EventTracker.track(activity, "onLoginButtonClick", View.class); // 输出:上报事件 name=user_click_login_button,id=0
// 模拟分享
EventTracker.track(activity, "onShare"); // 输出:上报事件 name=share_content, id=1001- 优点:实现简单直观.
- 缺点:性能开销大,反射操作在运行时比较耗时,不适合用在频繁调用的代码中.
-
编译时处理(通过注解处理器 APT) 这是一种更高效、更现代的方式,你需要创建一个注解处理器(Annotation Processor Tool,它会在编译代码期间运行,处理器会扫描源码,找到你定义的注解,然后自动生成 Java 代码.
- 工作流程:
- 你写了带注解的 Java 代码.
javac编译器开始编译.- 编译器发现有注解处理器注册了,就调用它.
- 你的注解处理器开始工作,扫描源码,找到
@EventTracking注解. - 处理器根据注解信息,自动生成一些新的
.java文件(比如一个EventTrackerHelper.java). - 编译器继续编译,把这些新生成的代码也一起编译成
.class文件.
- 优点:没有运行时的性能损耗,所有工作都在编译期完成,代码是静态生成的,类型安全.
- 缺点:实现起来比反射复杂,需要单独创建一个 Java 模块来编写处理器.
- 工作流程:
使用场景
-
依赖注入
-
框架:Dagger, Hilt, ButterKnife (已废弃但原理相同).
-
原理:通过
@Inject,@Component,@Module等注解标记依赖关系.注解处理器在编译时生成所有对象的创建和注入逻辑,避免了手动new对象和管理依赖的麻烦.
-
-
**网络请求库 **
-
框架:Retrofit.
-
原理:使用
@GET,@POST,@Path,@Query等注解来描述一个 HTTP 请求,Retrofit 在运行时通过反射解析这些注解,然后动态地创建一个实现了 API 接口的代理对象,这个对象会负责发起真实的 HTTP 请求.
-
-
数据库
-
框架:Room, GreenDAO.
-
原理:使用
@Entity,@PrimaryKey,@ColumnInfo,@Dao等注解来定义数据表结构和数据库操作,注解处理器在编译时解析这些注解,生成所有用于操作 SQLite 的模板代码,避免了手写复杂的SQL语句.
-
-
**序列化/反序列化 **
- 框架:Gson, Moshi, Jackson.
- 原理:使用
@SerializedName,@Json等注解来指定一个 Java 字段和 JSON key 之间的映射关系,库在运行时通过反射读取这些注解,实现对象和 JSON 字符串之间的自动转换.
-
路由
- 框架:ARouter.
- 原理:使用
@Route注解标记一个 Activity 或 Fragment,并给它一个路径.注解处理器会收集所有路径信息并生成一个映射表.在需要跳转时,你只需要提供路径,路由框架就能根据映射表找到对应的组件并启动它,实现模块间的解耦.
动态代理
动态代理是 Java 反射机制的一个强大应用,它允许我们在运行时创建“替身”对象,从而在不侵入原有代码的基础上,为程序增加统一的、额外的功能.理解了它的原理,你就能更深刻地理解许多优秀开源框架的设计思想.
优点
- 代码简洁:避免了静态代理的大量重复代码和类.
- 高扩展性:可以为任意接口的实现类提供代理,易于维护和扩展.
- 业务解耦:实现了非业务逻辑(如日志、监控)与核心业务逻辑的完全分离.
缺点
- 只能代理接口:JDK 的动态代理是基于接口的,如果一个类没有实现任何接口,那么它就不能被这种方式代理. (可以通过 CGLIB 等第三方库实现对类的代理,但原理更复杂).
- 性能开销:由于使用了反射机制,调用速度会比直接调用慢一些.但在绝大多数场景下(尤其是涉及 IO 操作如网络、数据库),这点性能差异可以忽略不计.
代理模式
在聊动态代理之前,我们先要理解代理模式 (Proxy Pattern).
代理模式的核心思想是:为一个对象提供一个代理(替身),以来控制对这个对象的访问. 代理对象和真实对象通常会实现同一个接口,这样调用者就可以像使用真实对象一样使用代理对象,而察觉不到差异.
生活中的例子: 你想租房子(访问“房源”这个真实对象),但你不直接找房东,而是通过房产中介(代理对象).中介除了帮你完成租房的核心业务外,还可能做一些额外的事情,比如:
- 看房前:帮你筛选房源、核实信息.
- 租房时:带你看房、协商价格.
- 签约后:帮你办理合同、收取中介费.
在这里,中介(代理)控制了你对房东(真实对象)的访问,并在此过程中加入了自己的逻辑.
动态代理
代理模式可以分为静态代理和动态代理.
静态代理
静态代理是指在编译期间就已经创建好了代理类(.java 文件存在).你需要手动为每一个需要被代理的接口或类创建一个代理类.
- 缺点:
- 类爆炸:如果需要代理的接口很多,你就得为每个接口都写一个代理类,导致项目中类的数量剧增.
- 代码重复:所有代理类中的附加逻辑(比如打印日志)可能是相似的,这会导致大量重复代码.
- 不易维护:如果接口发生变化(比如增加一个方法),真实对象和代理对象都需要同步修改.
动态代理
动态代理则是在程序运行时,通过反射机制动态地创建一个代理类及其对象,你不需要手动编写任何代理类的 .java 文件.
- 优点:
- 通用性强:只需要一个“处理器”就可以代理实现了任意接口的任意对象,解决了静态代理的类爆炸和代码重复问题.
- 灵活性高:可以在运行时决定代理的逻辑,非常灵活.
- 解耦:将代理的附加逻辑(如日志、事务、权限检查)与业务逻辑完全分离.
实现动态代理
在 Java/Android 中实现动态代理,主要依赖两个核心的 API:
java.lang.reflect.Proxy: 这是创建动态代理对象的主类,它最重要的方法是newProxyInstance().java.lang.reflect.InvocationHandler: 这是一个接口,你需要创建一个类来实现它,所有对代理对象的任何方法调用,最终都会被转发到这个接口的invoke()方法中.
实现步骤
第一步:定义一个接口和它的实现类(真实对象)
1 | // 1. 定义一个售卖电脑的接口 |
第二步:创建一个 InvocationHandler 的实现类
这是动态代理的核心,所有代理逻辑都在这里编写.
1 | // 代理处理器,可以把它想象成“房产中介”或“电脑销售代理商” |
第三步:使用 Proxy.newProxyInstance() 创建代理对象并调用
1 | public class Main { |
运行结果:
1 | ------- 场景1: 出价 5000 元 ------- |
应用场景
动态代理的思想——面向切面编程 (AOP),在不修改原有业务代码的情况下,增加通用功能.
-
Retrofit 网络请求框架
这是最经典的应用! 你只需要定义一个 Java 接口,并用注解 (
@GET,@POST等) 描述网络请求.- 当你调用
retrofit.create(ApiService.class)时,Retrofit 内部就使用了动态代理.它动态地创建了一个ApiService接口的实现类. - 当你调用
getUser("123")方法时,InvocationHandler的invoke方法被触发.在这个方法里,Retrofit 解析了方法上的注解 (@GET) 和参数 (@Path),然后利用 OkHttp 组装并发出一个真实的 HTTP 请求.
- 当你调用
-
通用日志和性能监控
- 你可以创建一个动态代理,在
invoke方法的开头和结尾记录方法名、参数、执行耗时等信息,而无需在每个业务方法里手动添加日志代码.
- 你可以创建一个动态代理,在
-
权限检查和身份验证
- 在调用需要特定权限的业务方法前,可以在
invoke方法里统一检查用户是否登录、是否有操作权限.如果没有,就直接拦截,不再执行真实方法.
- 在调用需要特定权限的业务方法前,可以在
-
RPC 框架 (远程过程调用)
- 像 Retrofit 一样,RPC 框架允许你像调用本地方法一样调用远程服务器上的方法.其底层也是通过动态代理,将本地方法调用转换为网络请求发送到远端.
说些什么吧!