final
、this
、super
final
关键字
- 修饰类:
当一个类被final
修饰时,意味着这个类不能被继承,即它没有子类。例如,Java中的String
类就是被final
修饰的,public final class String {... }
,这样就保证了String
类的不可扩展性,其内部的实现逻辑、行为等不会被其他类通过继承的方式改变,确保了它在整个Java体系中的稳定性和一致性。
- 修饰方法:
被final
修饰的方法不能在子类中被重写(覆盖)。比如在一个自定义的图形类层次结构中,有一个Shape
基类,其中定义了一个计算面积的方法public final double getArea() {... }
,如果将其声明为final
,那么后续继承Shape
类的子类,像Circle
类、Rectangle
类等,就无法重写这个getArea
方法来改变其计算逻辑了,这有助于保证方法行为的一致性,防止子类对关键方法进行不恰当的修改。
- 修饰变量(常量):
- 如果是基本数据类型的变量被
final
修饰,那么它的值在初始化后就不能再改变,相当于一个常量。例如,final int MAX_COUNT = 100;
,后续在代码中如果试图对MAX_COUNT
重新赋值,编译器就会报错。 - 对于引用类型的变量,虽然变量本身不能再重新指向其他对象,但是对象内部的属性值如果是可变的,依然可以改变。例如,
final ArrayList<String> list = new ArrayList<>();
,不能再让list
指向其他ArrayList
对象了,但可以对list
调用add
、remove
等方法来改变其内部元素。
- 如果是基本数据类型的变量被
this
关键字
- 引用本类成员变量:
当类中的成员变量和方法的形参或者局部变量同名时,使用this
关键字来明确表示引用的是类的成员变量。例如:
public class Person {
private String name;
public Person(String name) {
this.name = name; // 这里的this.name表示成员变量name,等号右边的name是构造方法的形参
}
}
在上述代码中,通过this.name
就区分开了成员变量和构造方法传入的同名形参,确保将传入的值正确赋给类的成员变量。
- 调用本类成员方法:
在一个类中,如果需要在某个方法内部调用本类的其他成员方法,也可以使用this
关键字。例如:
public class Calculator {
public int add(int num1, int num2) {
return num1 + num2;
}
public int calculate(int num1, int num2) {
int sum = this.add(num1, num2); // 使用this调用本类的add方法
return sum;
}
}
这里在calculate
方法中,通过this.add
来调用了同一个类中的add
方法,增强了代码的可读性和结构的清晰性,尤其在类中有多个重载方法等复杂情况下,可以明确指定调用的是本类的哪个具体方法。
- 在构造方法中调用其他构造方法(通过
this()
形式):
一个类中可以有多个构造方法,并且可以利用this()
在一个构造方法中调用本类的其他构造方法,以避免重复代码。例如:
public class Student {
private String name;
private int age;
public Student() {
this("未知", 0); // 调用另一个有参数的构造方法,初始化默认值
}
public Student(String name, int age) {
this.name = name;
this.age = age;
}
}
不过要注意,this()
调用构造方法时必须放在构造方法体的首行,并且不能同时出现this()
和super()
调用构造方法的语句(因为它们都要占据首行的位置)。
super
关键字
- 引用父类成员变量:
当子类中定义了和父类同名的成员变量时,在子类中如果要访问父类的那个同名成员变量,就需要使用super
关键字。例如:
class Animal {
protected String name = "动物";
}
class Dog extends Animal {
private String name = "小狗";
public void showNames() {
System.out.println("子类的name:" + this.name);
System.out.println("父类的name:" + super.name);
}
}
在Dog
类的showNames
方法中,通过this.name
访问的是子类自己定义的name
变量,而通过super.name
访问的就是从父类Animal
继承来的name
变量,这样可以清晰地区分和使用不同作用域下的同名变量。
- 调用父类成员方法:
同理,当子类重写了父类的方法后,如果在子类中还想调用父类被重写的那个方法,就要使用super
关键字。比如:
class Parent {
public void sayHello() {
System.out.println("父类的问候");
}
}
class Child extends Parent {
@Override
public void sayHello() {
System.out.println("子类的问候");
super.sayHello(); // 调用父类的sayHello方法
}
}
在Child
类重写sayHello
方法后,通过super.sayHello()
就可以继续执行父类原本的sayHello
方法逻辑。
- 调用父类构造方法(通过
super()
形式):
当子类的构造方法被调用时,默认会先调用父类的无参构造方法(如果父类没有无参构造方法,子类必须在构造方法中通过super()
显式指定调用父类的某个有参构造方法)。例如:
class Base {
public Base(int num) {
System.out.println("父类有参构造方法,参数:" + num);
}
}
class Derived extends Base {
public Derived(int num) {
super(num); // 显式调用父类的有参构造方法
System.out.println("子类构造方法");
}
}
同样,super()
调用父类构造方法也需要放在子类构造方法体的首行,并且不能和this()
同时出现用于调用构造方法,因为它们的调用规则都要求占据构造方法首行的位置。
ArrayList
类和LinkedList
类
ArrayList
类和LinkedList
类是实现List接口的两个具体类。
ArrayList
类
底层数据结构:
ArrayList
的底层是基于数组实现的,它在内存中占用一段连续的存储空间。当创建一个ArrayList
对象时,会默认初始化一个容量大小的数组(例如,在Java 8中,初始容量为10)。随着元素的不断添加,如果元素个数超过了当前数组的容量,就会触发扩容机制。扩容时,会创建一个更大的新数组(通常是原数组容量的1.5倍左右,不同JDK版本可能略有差异),然后将原数组中的所有元素通过数组复制的方式逐个拷贝到新数组中,这个过程相对来说是比较耗时的,但不会频繁发生,除非持续大量地添加元素。
性能特点总结:
查询和遍历效率高,但在频繁进行元素的插入和删除操作(尤其是在列表中间位置进行操作)时,由于涉及到数组元素的大量移动(插入或删除元素后,后面的元素都要依次向前或向后移动位置),效率相对较低,时间复杂度可能达到 O(n)
(n
为元素个数)。
LinkedList
类
底层数据结构:
LinkedList
的底层是基于双向链表实现的,每个节点(Node)包含了数据元素本身、指向前一个节点的指针(prev)以及指向后一个节点的指针(next)。链表中的元素在内存中并不是连续存储的,而是通过指针相互关联起来形成一个链式结构。例如,简单示意其节点结构如下:
class Node {
Object data;
Node prev;
Node next;
}
当进行元素添加或删除操作时,只需要修改相应节点之间的指针指向关系即可,不需要像ArrayList
那样移动大量元素。
- 性能特点总结:
在进行元素的插入和删除操作时,尤其是在链表的头部或者尾部进行操作时,效率很高,时间复杂度可以达到 O(1)
,因为只需要改变少量的指针指向。但对于查询操作,比如要获取链表中间位置的某个元素,需要从表头(或者表尾)开始逐个遍历节点,时间复杂度为 O(n)
,相比ArrayList
的随机访问效率要低很多。
RandomAccessFile
RandomAccessFile
概述
RandomAccessFile
是Java中一个用于对文件进行随机访问操作的类,它既可以读取文件内容,也可以向文件写入内容,与普通的文件输入输出流(如FileInputStream
、FileOutputStream
等)相比,它的独特之处在于能够灵活地定位文件指针,实现在文件的任意位置进行读写操作,这也是“随机访问”含义的体现。
关于文件打开模式(mode
)
只读模式(r
)
当使用mode
为"r"
打开文件时,意味着只能从该文件中读取数据,不能进行写入操作。如果试图向以只读模式打开的文件中写入内容,将会抛出IOException
异常。例如,以下代码尝试以只读模式打开一个文件并读取其内容:
import java.io.RandomAccessFile;
import java.io.IOException;
public class RandomAccessFileExample {
public static void main(String[] args) {
try {
RandomAccessFile raf = new RandomAccessFile("example.txt", "r");
// 后续可以进行读取操作,比如读取字节、字符等
raf.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
这种模式适用于只需要查看文件已有内容的场景,比如读取配置文件、查看文本文件中的文本信息等情况。
读/写模式(rw
):
mode
为"rw"
时,表示可以对文件同时进行读取和写入操作。它会尝试打开文件,如果文件不存在,则会创建一个新的空文件用于读写。例如:
try {
RandomAccessFile raf = new RandomAccessFile("newFile.txt", "rw");
raf.write("Hello".getBytes()); // 向文件写入内容
raf.seek(0); // 将文件指针移到开头,准备读取
byte[] buffer = new byte[5];
raf.read(buffer); // 读取刚才写入的内容
System.out.println(new String(buffer));
raf.close();
} catch (IOException e) {
e.printStackTrace();
}
在上述代码中,先向文件写入了字符串"Hello"
,然后通过移动文件指针到开头,再进行读取操作,展示了在"rw"
模式下读写的基本用法,常用于需要动态更新文件内容的场景,像日志文件的追加写入同时又可能偶尔读取查看历史记录等情况。
同步的读/写模式(rws
、rwd
):
rws
模式:当mode
为"rws"
时,每次对文件进行更新(写入操作)时,不仅会将数据写入磁盘,还会同步更新文件的 元数据(比如文件的修改时间、访问权限等相关信息) 到磁盘。这种模式保证了数据和元数据的一致性,但相对来说性能开销会大一些,因为涉及到更多的磁盘写入操作。例如,在一些对数据完整性和文件系统元数据准确性要求极高的数据库文件操作场景中可能会使用到这种模式。rwd
模式:而mode
为"rwd"
时,每次更新文件时,只确保数据本身被写入磁盘,对于文件的元数据更新不一定立即同步到磁盘。相比于"rws"
模式,它的磁盘操作相对少一点,性能上会稍好一些,但在某些极端情况下,如果系统突然崩溃等意外发生,可能会出现数据和元数据不一致的小风险。常用于对性能有一定要求同时也希望尽量保证数据写入磁盘的关键业务文件操作场景。
相关方法介绍
length()
方法:
length()
方法用于返回文件按照字节来度量的长度。例如,有一个存储用户信息的文本文件,想要知道它里面一共有多少字节的数据(包含所有的换行符等),可以使用以下代码:
try {
RandomAccessFile raf = new RandomAccessFile("userInfo.txt", "r");
long fileLength = raf.length();
System.out.println("文件长度为:" + fileLength + "字节");
raf.close();
} catch (IOException e) {
e.printStackTrace();
}
通过获取文件长度,可以方便后续进行一些按字节范围读取或者判断文件是否为空等操作,比如可以结合文件指针定位来实现分段读取文件内容等功能。
seek(long pos)
方法:
seek(long pos)
方法的作用是将文件指针设置到距文件开头pos
个字节处。这是实现随机访问的关键方法,通过它可以灵活地在文件的不同位置之间跳转,进行读写操作。例如,假设一个文件中存储了多条记录,每条记录长度固定为100字节,想要读取第3条记录,可以这样操作:
try {
RandomAccessFile raf = new RandomAccessFile("records.txt", "r");
raf.seek(2 * 100); // 将文件指针移到第3条记录开头(索引从0开始,所以乘以100)
byte[] buffer = new byte[100];
raf.read(buffer); // 读取第3条记录内容到buffer数组
raf.close();
} catch (IOException e) {
e.printStackTrace();
}
利用seek
方法能够有针对性地访问文件中特定位置的数据,极大地提高了文件操作的灵活性。
将原文件中的内容逐步复制到临时文件中
while ((hasRead = raf.read(buff))!= -1) {
tmpOut.write(buff, 0, hasRead);
}
这段代码实现了一个循环读取文件内容并写入到临时文件(假设tmpOut
是一个合适的输出流对象,比如FileOutputStream
等)的功能。具体来说:
- 首先,
raf.read(buff)
会尝试从RandomAccessFile
对象raf
所关联的文件中读取数据到字节数组buff
中,它返回实际读取到的字节数。当读到文件末尾时,返回值为-1
,所以通过while
循环条件(hasRead = raf.read(buff))!= -1
来判断是否还有数据可读。 - 每次读取到一部分数据(字节数为
hasRead
)后,就通过tmpOut.write(buff, 0, hasRead)
将这部分刚读取的数据写入到临时文件中,这里的write
方法的参数表示从字节数组buff
的0
索引位置开始,写入hasRead
个字节的数据,这样就实现了将原文件中的内容逐步复制到临时文件中的操作,常用于文件备份、文件内容转换等场景,比如把一个大文件按照一定规则边读取边处理后写入到另一个文件中。
GC
在Java中,垃圾收集器负责自动管理内存的回收工作,这是Java语言的一大特性,与一些像C、C++等需要程序员手动管理内存(通过malloc
、free
等函数来分配和释放内存)的语言形成鲜明对比。
这种自动机制极大地减轻了程序员的负担,减少了因手动内存管理不当而引发的内存泄漏(Memory Leak,即一些不再使用的内存空间没有被释放,导致内存占用不断增大)、悬空指针(Dangling Pointer,指向已经被释放内存区域的指针,可能导致程序崩溃或错误行为)等问题的发生概率,使得程序员可以更专注于业务逻辑的实现。
不可强制执行:
垃圾收集器的执行时机是由Java虚拟机(JVM)内部的算法和策略来决定的。JVM会综合考虑多种因素,比如当前堆内存的使用情况、系统的空闲资源、对象的存活周期等。例如,当堆内存中的空闲空间逐渐减少,快要达到某个阈值时,垃圾收集器可能就会启动来回收那些不再被引用的对象所占用的内存,以腾出空间供新的对象创建使用。但这个过程是自动根据这些复杂的内部机制触发的,程序员无法直接干预并强制它立即去回收某块特定的内存,这是为了保证整个内存回收过程在系统层面的高效性、稳定性以及合理性。如果允许程序员随意强制执行,可能会打乱JVM的内存管理节奏,导致性能下降或者出现不可预期的错误。
“建议”功能:
虽然程序员不能强制垃圾收集器工作,但可以通过调用System.gc()
方法来向JVM“建议”启动垃圾收集器进行内存回收工作。不过要明确的是,这仅仅是一个建议,JVM并不一定会按照这个建议立即执行垃圾收集操作。
String
类和StringBuffer
类
String
类
声明方式多样性:
-
String
类确实既可以采用普通变量的声明方法,像直接用字符串字面常量赋值,例如String s1 = "hello";
,这是一种简洁常见的方式,编译器在处理时会将这样的字符串字面常量放入到一个字符串常量池中。 -
同时,也可以采用对象变量的声明方法,也就是使用
new
关键字来创建字符串对象,如String s2 = new String("Hello");
。这种通过new
创建的方式,每次都会在堆内存中创建一个新的字符串对象实例,无论字符串常量池中是否已经存在相同内容的字符串。
不可变特性及原因:
String
类的对象一经创建则不能通过字符串方法更改内容,这是因为String
类在设计上是不可变的(Immutable)。
在Java中,String
类内部实际是用一个char
数组来存储字符串内容,并且这个char
数组被声明为private final
,意味着一旦初始化赋值后就不能再指向其他数组,同时也没有提供对外的方法来修改这个数组中的元素。例如,以下代码尝试修改字符串内容是不可行的:
String str = "world";
str.charAt(0) = 'W'; // 编译报错,无法对String中的字符进行修改操作
这样设计的好处有很多,比如字符串作为一种常用的数据类型,在多线程环境下使用时,不可变的特性保证了其数据的一致性和安全性,不需要额外的同步措施;同时在作为哈希表(如HashMap
等)的键时,因为其不可变,所以哈希值始终保持不变,能保证哈希表操作的正确性和高效性。
字符串常量池机制及示例解析:
直接用字符串字面常量赋值时,Java会先到它的字符串缓冲区(也就是字符串常量池)里查找有没有这个字符串,如果有则直接返回引用,没有就在里面新建这个字符串。例如前面提到的:
String s1 = "hello";
String s3 = "hello";
-
在执行这两条语句时,当执行第一句
String s1 = "hello";
,Java会先在字符串常量池中查找是否存在"hello"
这个字符串,若不存在就创建并将其放入常量池,然后s1
指向这个字符串在常量池中的引用; -
当执行第二句
String s3 = "hello";
时,因为常量池中已经有了"hello"
,所以直接将s3
也指向这个已存在的字符串引用,这就导致s1
和s3
指向同一个字符串对象。
而对于使用new
关键字创建字符串对象的情况,如:
String s2 = new String("Hello");
String s4 = new String("Hello");
每次调用new
都会在堆内存中创建一个独立的字符串对象实例,即便字符串常量池中可能已经存在相同内容的字符串,所以s2
和s4
分别创建了不同的字符串对象,各自在堆内存中有独立的存储空间,它们的引用也是不同的。
以下图解,方便理解:
StringBuffer
类
可变特性及优势:
与描述字符串常量的String
类不同,StringBuffer
类创建的字符串对象可以修改。它内部同样是通过一个字符数组来存储字符串内容,但这个数组的长度是可以动态变化的,并且提供了诸如append
(用于追加字符串内容)、insert
(用于在指定位置插入字符串等内容)、replace
(用于替换指定区间的字符内容)等方法来对字符串进行修改操作。例如:
StringBuffer buffer = new StringBuffer("start");
buffer.append(" and end");
System.out.println(buffer.toString()); // 输出 "start and end"
所有的修改都直接发生在包含该字符串的缓冲区上,这样在需要频繁对字符串进行拼接、修改等操作的场景中,使用StringBuffer
就比String
更高效,因为String
每次修改(比如拼接字符串)实际上是创建了一个新的字符串对象,而StringBuffer
可以在原有的缓冲区上直接操作,避免了大量不必要的对象创建和内存开销。
包(Package)
概括:
包的优点:
- 通过类似目录树的形式组成)va程序,管理和查找类比较方便、有序。
- 包可以减少类重命名带来的问题。
- 包可以保护包中的类、方法等成员。
- 包可以标识类和接口的功能。
包定义语句:
- package语句必须是程序中可执行的第一行代码,即backage语句必须放在有效代码序的第一行。
- packagei语句在一个文件中只有有一句。
- 包名可以嵌套,在前面的包名为后面包名的父目录。
- 没有packagei语句,则为默认“缺省包”。
以下是详细介绍~
包的优点
方便管理和查找类(类似目录树形式)
在Java大型项目中,往往会涉及到大量不同功能、不同用途的类。通过使用包,就如同将这些类按照目录树的结构进行分类整理。
例如,一个电商项目可能会有专门用于处理用户相关操作的类放在com.example.ecommerce.user
包下,商品相关操作的类放在com.example.ecommerce.product
包下等等。当需要查找某个特定功能的类时,开发人员可以根据包名所代表的逻辑层次快速定位到相应的类,就像在文件系统中通过目录路径查找文件一样方便。而且这种组织方式使得整个项目结构更加清晰、有条理,易于维护和扩展。比如后续要新增一个订单模块,就可以创建com.example.ecommerce.order
包来存放相关的类。
减少类重命名带来的问题
在不同的开发人员或者不同的模块开发过程中,很可能会出现命名相同但功能不同的类。而包的存在可以有效地解决这个问题。
例如,两个不同的团队分别开发了名为Calculator
的类,一个是用于简单数学运算的,另一个是用于金融计算的。如果将它们分别放在不同的包中,比如com.team1.utils.Calculator
和com.team2.finance.Calculator
,那么在整个项目中就可以通过包名来区分这两个类,避免了类名冲突,即使类名相同,只要所在包不同,它们在项目中就是完全独立、可区分的,从而减少了因类重命名而带来的繁琐工作以及潜在的错误。
保护包中的类、方法等成员
包可以通过访问控制修饰符(如public
、protected
、private
)与包的层次结构相结合来对类及其成员进行保护。
例如,一个包内的类如果其成员(方法、变量等)被声明为非public
(比如private
或protected
),那么在其他包中的类通常是无法直接访问这些成员的,除非满足一定的继承或同包访问条件。这就相当于给包内的类及其成员设置了一层保护屏障,使得代码的封装性更好,只有被允许的其他类才能与之交互,有助于保证代码的安全性和稳定性,防止外部类随意调用或修改内部的关键逻辑和数据。
标识类和接口的功能
从包名本身就能大致了解其中类和接口的功能范围。
比如,看到java.util
包,就知道里面存放的大多是一些实用工具类,像ArrayList
、HashMap
等,用于帮助开发者更方便地处理数据集合、日期时间等常见的操作;再如java.net
包,显然是和网络相关的类所在的地方,包含了用于创建网络连接、发送接收网络数据等功能的类和接口。这种通过包名对功能的标识作用,使得代码的可读性大大提高,新加入项目的开发人员或者其他阅读代码的人可以快速根据包名判断相关类的大致用途,方便理解代码的整体架构和逻辑。
包定义语句的特点
必须位于程序中可执行的第一行代码(package
语句的位置要求)
在Java源文件中,package
语句必须放在有效代码序列的第一行,这是Java语法的强制规定。所谓有效代码,就是除去注释、空白行等非执行性的内容之外的实际代码部分。例如:
// 下面这种写法是错误的,因为有其他代码在package语句之前
import java.util.ArrayList;
package com.example.demo;
public class TestClass {
//...
}
正确的写法应该是:
package com.example.demo;
import java.util.ArrayList;
public class TestClass {
//...
}
这样规定的目的是为了让Java编译器在解析源文件时,首先能明确该文件所属的包,以便后续正确地处理类的命名空间、查找和组织等相关事宜。
一个文件中只能有一句(唯一性要求)
每个Java源文件中最多只能有一个package
语句,因为一个源文件中的所有类都属于同一个包。如果出现多个package
语句,编译器会无法确定该文件到底要归属于哪个包,从而导致编译错误。例如:
package com.example.demo1;
package com.example.demo2; // 这种写法会报错,不允许有两个package语句
public class TestClass {
//...
}
包名可以嵌套(包的层次结构体现)
包名可以按照层次结构进行嵌套,前面的包名为后面包名的父目录,这就形成了类似文件系统中目录嵌套的结构。例如,com.example.project.module
这样的包名,com
是最顶层的,example
是com
下的子目录,project
又是example
下的子目录,module
则是project
下的子目录。在实际的磁盘存储上(Java编译器会按照一定规则将包对应的类文件存储在相应的目录结构下),会体现为相应的文件夹嵌套关系,有助于更精细地对类进行分类管理,同时也能更好地体现类之间的逻辑关联和功能分组。
默认“缺省包”情况(无package
语句时)
如果一个Java源文件中没有package
语句,那么该文件中的类就属于默认的“缺省包”。不过在实际的项目开发中,尤其是稍具规模的项目,不建议大量使用缺省包,因为这样会使类的组织缺乏清晰的结构,容易出现命名冲突等问题,不利于代码的管理和维护。通常都应该按照功能、模块等合理地为类划分包,让整个项目的代码结构更加规范有序。
Java一些核心概念
类中各组成部分的区别
- 常量:使用
final
关键字修饰,一旦被初始化赋值后,其值就不能再改变。如果是基本数据类型的常量,那它的值固定;若是引用类型的常量,虽然不能再让其指向别的对象,但对象内部的可变成员仍可改变。例如:final int MAX_VALUE = 100;
,在后续代码中不能对MAX_VALUE
重新赋值。常量通常用于定义那些在程序运行过程中固定不变的重要数值、配置参数等,使代码更具可读性和可维护性。 - 变量:分为成员变量(类中定义,但不在方法内的变量)和局部变量(方法内定义的变量等)。成员变量有默认初始值(如
int
类型默认是0,boolean
类型默认是false
等),其作用域是整个类;局部变量没有默认初始值,必须先赋值再使用,且其作用域限定在定义它的代码块(如方法体、循环体等)内。例如:
public class MyClass {
private int memberVar; // 成员变量,有默认初始值0
public void myMethod() {
int localVar;
// localVar = localVar + 1; // 编译报错,局部变量未初始化不能使用
localVar = 5; // 先赋值后可使用
}
}
- 构造方法:是一种特殊的方法,用于创建类的对象时初始化对象的成员变量等。其方法名与类名相同,且没有返回值类型(连
void
都不能写)。可以有参数,用于接收创建对象时传入的初始值,一个类可以有多个构造方法(重载形式),方便以不同方式初始化对象。例如:
public class Person {
private String name;
private int age;
public Person() { // 无参构造方法
this.name = "未知";
this.age = 0;
}
public Person(String name, int age) { // 有参构造方法
this.name = name;
this.age = age;
}
}
- 静态方法:使用
static
关键字修饰,属于类本身,而不属于类的某个具体对象。可以通过类名直接调用(当然也能通过对象调用,但不推荐,会影响代码可读性和对静态方法的理解),常用于实现一些与类整体相关、不依赖于具体对象状态的功能,比如工具类中的方法。例如,Math
类中的sqrt
方法(Math.sqrt(9)
用于求9的平方根)就是静态方法,不需要创建Math
对象就能调用。 - 非静态方法:也叫实例方法,与对象实例相关联,必须通过类的具体对象来调用,其内部可以访问对象的成员变量以及调用其他非静态方法等,用于实现和对象具体状态相关的功能。例如:
public class Dog {
private String name;
public void bark() { // 非静态方法
System.out.println(name + " 汪汪叫");
}
}
Java面向对象编程的特点
- 抽象:
抽象是将现实世界中复杂的事物简化,提取出关键的、与程序设计相关的特征,忽略那些不重要的细节。在Java中,通过抽象类和接口来体现抽象的概念。
例如,定义一个抽象的“图形”概念,用抽象类Shape
表示,它可能包含计算面积的抽象方法abstract double getArea();
,但并不关心具体图形(如圆形、矩形等)如何去计算面积的细节,具体的实现留给继承这个抽象类的子类(像Circle
、Rectangle
等子类)去完成,这样就从复杂的各种图形具体实现中抽象出了通用的、核心的面积计算这个特征,便于程序的分层设计和代码的复用。
- 封装:
封装就是将类的内部实现细节隐藏起来,只对外暴露必要的接口,让其他类只能通过这些接口来与该类进行交互,而无法直接访问类内部的成员变量和一些内部方法等。通过访问控制符(private
、protected
、public
以及默认权限)来实现封装。
例如,一个BankAccount
类,将账户余额这个成员变量设为private
,其他类不能直接修改它,只能通过BankAccount
类提供的deposit
(存款)、withdraw
(取款)等公共方法来间接操作余额,这样保证了数据的安全性和类的独立性,防止外部的非法访问和错误操作。
- 继承:
继承允许创建的子类继承父类的属性(成员变量)和行为(方法),子类可以复用父类的代码,并且可以在父类的基础上进行扩展和修改。在Java中通过extends
关键字实现继承关系,例如:
class Animal {
protected String name;
public void eat() {
System.out.println(name + " 在吃东西");
}
}
class Dog extends Animal {
public void bark() {
System.out.println(name + " 汪汪叫");
}
}
Dog
类继承了Animal
类,就拥有了Animal
类的name
成员变量和eat
方法,同时又新增了自己特有的bark
方法,继承提高了代码的复用性和可扩展性,让类之间形成了层次关系。
- 多态:
多态意味着同一个行为(方法)在不同的对象上可能有不同的表现形式。主要有两种实现方式:方法重载和方法重写。
访问控制符及权限区别
访问控制符 | 权限宽窄 | 作用域范围 | 特点 |
---|---|---|---|
private | 最窄 | 只能在本类内部访问 | 用于将类的成员(变量、方法等)严格限制在类自身范围内,对外完全隐藏,保证数据和方法的私有性,防止外部类的非法访问,常用于类内部的一些辅助性、不希望被外部知晓的实现细节相关成员。 |
默认(无修饰符) | 较窄 | 在同一个包内的类可以访问 | 没有显式使用访问控制符修饰的成员(变量、方法等)具有默认权限,适用于在包内的类之间进行一定的共享和协作,但不想被其他包的类访问的情况,有助于在包这个层次上进行一定程度的代码封装和协作。 |
protected | 适中 | 本类、同包内的类以及子类(无论子类是否在同包内)都可以访问 | 主要用于在继承关系中,既允许子类访问父类中受保护的成员,又能在一定程度上限制外部无关类的访问,平衡了继承和封装的需求,使得子类可以在合理范围内复用和扩展父类的功能。 |
public | 最宽 | 所有类都可以访问 | 用于将类的成员完全公开,任何其他类,无论在哪个包中,只要能获取到类的声明,都可以访问这些公共成员,通常用于对外提供的接口、重要的公共方法和常量等,方便其他类与之交互和使用。 |
父类与子类之间方法重载和重写的条件
方法重载条件:
- 在同一个类中:重载的多个方法必须定义在同一个类里面,不能跨类进行重载。
- 方法名相同:这些方法的名称必须是一模一样的。
- 参数列表不同:参数列表的不同可以体现在参数个数不一样(如一个方法有两个参数,另一个方法有三个参数)、参数类型不同(如一个是
int
类型参数,另一个是double
类型参数)或者参数顺序不同(如一个方法参数顺序是int
、double
,另一个是double
、int
),但仅仅是返回值类型不同不能构成方法重载。例如:
public class OverloadExample {
public int add(int num1, int num2) {
return num1 + num2;
}
public double add(double num1, double num2) { // 参数类型不同,构成重载
return num1 + num2;
}
public int add(int num1, int num2, int num3) { // 参数个数不同,构成重载
return num1 + num2 + num3;
}
}
方法重写条件:
- 存在于父类与子类之间:重写是子类对父类中某个方法的重新定义,所以必须涉及到父类和子类这两个不同的类层次关系。
- 方法名、参数列表、返回值类型需符合相应规则:方法名必须和父类被重写的方法名完全相同,参数列表(参数个数、类型、顺序)也要完全一致,对于返回值类型,如果是基本数据类型,必须和父类的返回值类型相同;如果是引用类型,子类重写方法的返回值类型可以是父类返回值类型的子类。例如,父类有方法
public Animal getAnimal()
,子类可以重写为public Dog getAnimal()
(假设Dog
是Animal
的子类)。 - 访问权限限制:子类重写后的方法访问权限不能比父类被重写的方法访问权限更严格,也就是说,父类中如果是
protected
访问权限的方法,子类重写后可以是protected
或者public
,但不能是private
。 - 非静态方法(实例方法)重写:重写一般针对的是实例方法,静态方法不存在重写概念(虽然语法上子类可以定义和父类同名同参数的静态方法,但这属于隐藏,和重写有本质区别,调用时按类名调用各自的静态方法)。例如:
class Parent {
protected void sayHello() {
System.out.println("父类的问候");
}
}
class Child extends Parent {
@Override
protected void sayHello() { // 满足重写条件,重写父类的sayHello方法
System.out.println("子类的问候");
}
}
抽象类与接口的定义、包含及区别与联系
抽象类
定义:使用abstract
关键字修饰的类就是抽象类,它不能被实例化,也就是不能直接创建抽象类的对象,其目的是为了被其他类继承,作为一种抽象的模板来定义一些通用的属性和方法(包括抽象方法和非抽象方法)。例如:
abstract class Shape {
protected String color;
public Shape(String color) {
this.color = color;
}
abstract double getArea(); // 抽象方法,没有方法体,留给子类去实现
public void display() { // 非抽象方法,可以有方法体
System.out.println("这是一个 " + color + " 的图形");
}
}
包含:抽象类中可以包含抽象方法(没有具体的方法体,用abstract
关键字声明,要求子类必须实现这些抽象方法),也可以包含非抽象方法(有具体的方法体,子类可以继承使用或者重写),还可以有成员变量等,通过这些元素来构建一个抽象的概念或者行为框架,供子类去完善和扩展。
接口
定义:接口是一种更加纯粹的抽象类型,使用interface
关键字定义,接口中的所有方法默认都是抽象方法(在Java 8之前,接口中只能有抽象方法,Java 8及以后可以有默认方法和静态方法等新特性),接口同样不能被实例化,是用来定义一组相关的抽象行为规范,让实现该接口的类去遵循这些规范。例如:
interface Drawable {
void draw(); // 抽象方法,默认是抽象的,没有方法体
}
包含:接口中主要包含抽象方法(Java 8及以后增加了默认方法和静态方法,默认方法使用default
关键字修饰,有方法体,用于给实现接口的类提供默认的实现逻辑,方便接口的扩展和演化;静态方法和类中的静态方法类似,属于接口本身,可以通过接口名调用),另外还可以包含常量(接口中的变量默认都是public static final
的,也就是常量,需要初始化赋值)。
区别与联系:
- 区别:
- 实现方式不同:类通过
extends
关键字继承抽象类,而且在Java中一个类只能继承一个抽象类;而类通过implements
关键字实现接口,一个类可以实现多个接口,这使得接口在定义多组不同行为规范并让类去综合遵循方面更具灵活性。 - 方法特性不同:抽象类中既可以有抽象方法也可以有非抽象方法;而接口中在早期版本基本都是抽象方法(Java 8及以后虽有变化但抽象方法仍是主体),接口中的方法默认都是
public
访问权限(抽象类中的方法可以有多种访问权限)。 - 成员变量不同:抽象类中的成员变量可以是各种访问权限,也可以是普通变量;而接口中的成员变量默认都是
public static final
的常量,一旦定义赋值后不能改变。
- 实现方式不同:类通过
- 联系:
- 抽象类和接口都不能直接实例化,都用于在面向对象设计中进行抽象化处理,帮助构建类的层次结构和行为规范,引导子类或者实现类去完善具体的功能实现。
- 有时候在设计中可以结合使用抽象类和接口,例如先定义一个抽象类作为基础框架,在抽象类中实现一些通用的、非抽象的基础功能,然后定义接口来规范一些特定的行为,让继承抽象类的子类再去实现接口,这样可以充分发挥两者的优势,使代码结构更加合理、功能更加完善。
线程
线程状态转换图:
线程的状态
-
新建(New):
当通过new
关键字创建一个线程对象时,线程就处于新建状态,例如:Thread thread = new Thread(() -> System.out.println("线程执行逻辑"));
,此时线程对象已经在内存中实例化,但还没有开始执行。 -
就绪(Runnable):
调用线程对象的start()
方法后,线程进入就绪状态。处于该状态的线程已经获取了除CPU资源之外执行所需的其他资源,正在等待被分配CPU时间片,以便开始执行其run()
方法中的代码逻辑。例如:thread.start();
执行这句后线程就变为就绪状态,多个处于就绪状态的线程会由操作系统的线程调度器依据一定算法(如时间片轮转等)来决定何时获得CPU开始运行。 -
运行(Running):
当线程获得了CPU时间片,开始执行run()
方法中的代码时,线程就处于运行状态。在运行状态下,线程会执行其具体的业务逻辑代码,不过这个状态是短暂的,因为CPU时间片是有限的,时间片用完后,线程可能又会回到就绪状态等待下一次分配时间片继续执行,除非线程执行完了run()
方法或者因为某些原因被阻塞、终止等情况发生。 -
阻塞(Blocked):
线程在某些情况下会进入阻塞状态,暂时停止执行,等待某个条件满足后再继续执行。常见导致阻塞的情况有以下几种:- 等待获取锁(
synchronized
关键字相关):当多个线程竞争同一个对象的同步锁时,如果一个线程没有获取到锁,就会进入阻塞状态,等待锁被释放后才有机会获取锁并继续执行。例如,在一个有synchronized
块的代码中,多个线程同时访问该代码块对应的对象时会出现这种情况。 - 调用
Object
类的wait()
方法:线程在获取了某个对象的锁后,如果调用了该对象的wait()
方法,会释放锁并进入阻塞状态,直到其他线程调用同一个对象的notify()
或notifyAll()
方法来唤醒它,被唤醒后线程会重新去竞争锁,获取到锁后才能继续执行。 - 调用
Thread
类的sleep()
方法:线程执行到sleep()
方法时,会暂停执行指定的时间(以毫秒为单位),这段时间内线程处于阻塞状态,时间到了后会自动回到就绪状态等待CPU时间片继续执行。例如:Thread.sleep(1000);
会让线程阻塞1秒钟。 - 等待I/O操作完成:当线程发起输入输出操作(如读取文件、网络通信等),在I/O操作未完成时,线程会进入阻塞状态,等待I/O操作结束,结束后会回到就绪状态。
- 等待获取锁(
-
等待(Waiting):
这是一种特殊的阻塞状态,线程在等待某个特定的条件满足,并且不会占用CPU资源。和阻塞状态不同的是,等待状态通常是通过Object
的wait()
、Thread
的join()
方法等方式进入的,需要其他线程进行相应的唤醒操作才能继续执行。例如,线程A调用了另一个线程B的join()
方法,线程A就会进入等待状态,直到线程B执行完毕,线程A才会被唤醒继续执行。 -
超时等待(Timed Waiting):
和等待状态类似,但它是有时间限制的等待。比如通过Thread.sleep()
方法、Object
类的wait(long timeout)
方法(设置了超时时间参数的等待)等进入该状态,当等待的时间达到设定的超时时间后,线程会自动唤醒,回到就绪状态等待CPU资源继续执行。 -
终止(Terminated):
线程执行完run()
方法中的所有逻辑后,或者因为出现了未捕获的异常导致线程异常终止,线程就进入终止状态,此时线程的生命周期结束,不会再被调度执行。
线程相关的常用方法及对状态的影响
start()
方法:
属于Thread
类的方法,用于启动一个新线程,使线程从新建状态转变为就绪状态,等待被分配CPU时间片后进入运行状态开始执行run()
方法中的代码。例如:
Thread thread = new Thread(() -> {
System.out.println("线程开始执行");
});
thread.start();
调用 start()
方法后,线程就会按照线程调度机制准备开始运行。
run()
方法:
Thread
类实现了Runnable
接口,并重写了run()
方法,这个方法包含了线程要执行的具体逻辑代码。当线程处于运行状态时,就是在执行run()
方法中的内容。我们通常通过实现Runnable
接口或者继承Thread
类的方式来重写run()
方法,定义线程的具体业务逻辑,例如:
class MyThread implements Runnable {
@Override
public void run() {
System.out.println("自定义线程逻辑执行");
}
}
Thread thread = new Thread(new MyThread());
thread.start();
这里自定义的 MyThread
类实现了 Runnable
接口并重写了 run()
方法,然后创建 Thread
类对象并传入 MyThread
实例启动线程,线程获得CPU时间片后就会执行这个 run()
方法中的逻辑。
sleep(long millis)
方法:
属于Thread
类的方法,用于让当前线程暂停执行一段时间(参数millis
指定暂停的毫秒数)。当线程调用sleep()
方法时,线程从运行状态进入阻塞(具体是超时等待状态),在指定的时间过后,线程自动回到就绪状态,等待再次被分配CPU时间片继续执行,例如:
Thread thread = new Thread(() -> {
try {
System.out.println("线程开始,准备睡眠");
Thread.sleep(2000);
System.out.println("睡眠结束,继续执行");
} catch (InterruptedException e) {
e.printStackTrace();
}
});
thread.start();
在这个示例中,线程启动后执行到 sleep(2000)
处就会阻塞2秒钟,然后再继续执行后面的代码。
join()
方法:
Thread
类的方法有两种常用的重载形式:join()
和join(long millis)
。- 当一个线程(比如线程A)调用另一个线程(比如线程B)的
join()
方法时,线程A会进入等待状态,等待线程B执行完毕后,线程A才会被唤醒继续执行。例如:
- 当一个线程(比如线程A)调用另一个线程(比如线程B)的
Thread thread1 = new Thread(() -> {
System.out.println("线程1开始执行");
try {
Thread.sleep(3000);
System.out.println("线程1执行完毕");
} catch (InterruptedException e) {
e.printStackTrace();
}
});
Thread thread2 = new Thread(() -> {
System.out.println("线程2开始执行,等待线程1执行完毕");
try {
thread1.join();
System.out.println("线程1执行完了,线程2继续执行");
} catch (InterruptedException e) {
e.printStackTrace();
}
});
thread1.start();
thread2.start();
在这个例子中,线程2启动后调用了线程1的 join()
方法,所以线程2会等待线程1执行完3秒钟的睡眠并执行完所有逻辑后,才会继续执行自己后续的内容。
- 如果使用 join(long millis)
方法,线程会进入超时等待状态,等待指定的毫秒数,如果在这个时间内被调用 join()
方法的线程还没有执行完毕,调用 join
方法的线程也会自动被唤醒,继续执行,例如:thread1.join(2000);
表示线程会最多等待2秒钟,如果2秒钟内线程1没执行完,线程也会继续执行。
yield()
方法:
Thread
类的方法,它是一种提示性的方法,用于提示线程调度器当前线程愿意让出自己当前正在使用的CPU时间片,使当前线程从运行状态回到就绪状态,让其他线程有机会获得CPU资源开始执行,但线程调度器不一定会采纳这个提示,有可能当前线程在调用yield()
方法后马上又获得了CPU时间片继续执行,例如:
Thread thread = new Thread(() -> {
for (int i = 0; i < 10; i++) {
System.out.println("线程执行中: " + i);
if (i == 5) {
Thread.yield();
}
}
});
thread.start();
在这个示例中,线程在执行到 i == 5
时调用 yield()
方法,尝试让出CPU时间片,但实际是否让出以及后续执行情况取决于线程调度器的调度策略。
interrupt()
方法:
Thread
类的方法,用于向一个线程发送中断信号。它并不会直接终止线程的执行,而是设置线程的中断标志位为true
。线程可以通过检查自身的中断标志位(使用Thread.currentThread().isInterrupted()
方法来检查)或者捕获InterruptedException
异常(在一些可中断的阻塞方法中,如sleep()
、wait()
等,当收到中断信号时会抛出这个异常)来响应中断,然后根据具体情况决定如何处理,比如结束线程的执行或者进行一些清理工作后再结束等。例如:
Thread thread = new Thread(() -> {
try {
while (!Thread.currentThread().isInterrupted()) {
System.out.println("线程正常执行");
Thread.sleep(1000);
}
System.out.println("线程收到中断信号,准备结束");
} catch (InterruptedException e) {
System.out.println("线程在阻塞状态收到中断,进行相应处理");
}
});
thread.start();
try {
Thread.sleep(3000);
thread.interrupt();
} catch (InterruptedException e) {
e.printStackTrace();
}
在这个例子中,线程在循环执行过程中会每隔1秒钟睡眠一次,主线程在3秒钟后调用线程的 interrupt()
方法向其发送中断信号,线程在睡眠状态收到中断信号时会抛出 InterruptedException
异常,然后可以在 catch
块中进行相应的处理,比如这里只是输出提示信息。
wait()
、notify()
和notifyAll()
方法(在Object
类中):wait()
方法:当线程获取了某个对象的锁后,如果调用该对象的wait()
方法,线程会释放锁并进入等待状态,等待其他线程调用同一个对象的notify()
或notifyAll()
方法来唤醒它。例如:
class SharedObject {
synchronized void doWait() {
try {
this.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
synchronized void doNotify() {
this.notify();
}
}
Thread thread1 = new Thread(() -> {
SharedObject shared = new SharedObject();
shared.doWait();
System.out.println("线程1被唤醒,继续执行");
});
Thread thread2 = new Thread(() -> {
SharedObject shared = new SharedObject();
shared.doNotify();
System.out.println("线程2执行了唤醒操作");
});
thread1.start();
thread2.start();
在这个示例中,线程1获取了 SharedObject
对象的锁后调用 wait()
方法进入等待状态并释放锁,线程2获取同一个 SharedObject
对象的锁后调用 notify()
方法来唤醒等待在该对象上的线程(这里就是线程1),线程1被唤醒后需要重新竞争锁,获取到锁后才能继续执行。
- notify()
方法:用于唤醒在同一个对象上等待的单个线程,如果有多个线程等待,唤醒哪一个线程由线程调度器决定。
- notifyAll()
方法:用于唤醒在同一个对象上等待的所有线程,被唤醒的线程都需要重新竞争锁后才能继续执行。