Java
Java基础
Java语言
Java的特点
首先,平台无关性强。Java 程序编译后生成字节码,可以在任何安装了 Java 虚拟机(JVM)的平台上运行,实现了“一次编写,处处运行”的跨平台能力。
其次,Java 是面向对象的语言,支持封装、继承、多态,有利于代码复用和模块化管理,提升系统的可维护性。
第三,Java 拥有丰富的类库和强大的生态系统,涵盖网络编程、多线程、数据库访问、分布式开发等各个领域,适合构建各类企业级应用。
此外,Java 具备自动内存管理和垃圾回收机制,降低了内存泄漏和指针错误的风险,提升了程序的稳定性和安全性。
最后,Java 支持多线程编程,并发能力强,特别适用于高并发、高可靠性的服务端开发。
Java的劣势
首先,Java的内存占用较高,JVM启动时间较长。由于JVM的运行时开销和对象模型设计,Java程序通常消耗更多内存;并且由于类加载和JIT预热过程,JVM的启动时间较长,这使得Java不太适合短生命周期的任务。(因资源限制,Java不适合嵌入式系统)
其次,Java的语法设计存在一定的局限性。例如需要手动编写Getter/Setter、匿名内部类等样板代码。同时,Java对函数式编程的支持较弱,模式匹配、值类型等特性直到较新版本才逐步引入,导致在某些场景下开发效率不如其他语言。
第三,Java在实时性和底层控制方面存在不足。 Java 的 GC(垃圾回收)会在系统运行时自动触发,会暂停所有线程,这就可能导致关键任务延迟执行。另外Java无法直接操作内存,能直接操作硬件寄存器、中断等底层资源,这使得它不适合开发操作系统、驱动程序等需要精细内存管理的底层软件。
当然,这些劣势需要结合具体场景评估。例如,对于高吞吐分布式系统,Java的GC劣势可能被其成熟的并发库和监控工具所抵消。
解释与编译并存
Java 程序先编译成字节码(编译),运行时再由 JVM 解释执行或 JIT 编译执行(解释+编译),同时具备解释型语言和编译型语言的特性。
从设计上讲:
1,字节码使得 Java 程序能跨平台,不同平台只需要实现自己的 JVM,就可以运行相同的字节码。
2,解释执行让程序可以快速启动,不需要等待全部编译完。
3,JIT 编译让热点代码运行时能更高效,避免纯解释执行的性能瓶颈。
编译型语言和解释型语言的区别
编译型语言:在程序执行之前,整个源代码会被编译成机器码或者字节码,生成可执行文件。执行时直接运行编译后的代码,速度快,但跨平台性较差。
解释型语言:在程序执行时,逐行解释执行源代码,不生成独立的可执行文件。通常由解释器动态解释并执行代码,跨平台性好,但执行速度相对较慢。
典型的编译型语言如C、C++,典型的解释型语言如Python、JavaScript。
为什么跨平台
Java的跨平台能力主要基于JVM(Java虚拟机)和"一次编写,到处运行"的设计理念
1,Java编译器把程序编译成.class字节码文件,这种字节码是平台无关的中间表示,相当于在源代码和机器指令之间增加了一个抽象层。
2,每个操作系统平台都有对应的JVM实现。JVM负责将统一的字节码JIT编译或解释执行为当前系统的本地机器指令。
3,Java通过严格规范确保所有JVM实现遵循相同的行为标准,包括统一的内存模型、一致的类加载机制、标准的核心类库。
JVM、JDK、JRE
1、JVM是Java虚拟机,是Java程序运行的环境。它负责将Java字节码(由Java编译器生成)解释或编译成机器码,并执行程序。JVM提供了内存管理、垃圾回收、安全性等功能,使得Java程序具备跨平台性。
2、JDK是Java开发工具包,是开发Java程序所需的工具集合。它包含了JVM、编译器(javac)、调试器(jdb)等开发工具,以及一系列的类库(如Java标准库和开发工具库)。JDK提供了开发、编译、调试和运行Java程序所需的全部工具和环境。
3、JRE是Java运行时环境,是Java程序运行所需的最小环境。它包含了JVM和一组Java类库,用于支持Java程序的执行。JRE不包含开发工具,只提供Java程序运行所需的运行环境。
数据类型
基本数据类型
类型 | 占用内存 | 默认值 | 范围/说明 | 使用场景 |
---|---|---|---|---|
byte | 1 字节 | 0 | -128 ~ 127 | 适合用于节省内存,例如在处理文件或网络流时存储小范围整数数据。 |
short | 2 字节 | 0 | -2¹⁵ ~ 2¹⁵ - 1 | 通常用于在需要节省内存且数值范围在该区间的场景。 |
int | 4 字节 | 0 | -2³¹ ~ 2³¹-1 | 最常用,可满足大多数日常编程中整数计算的需求。 |
long | 8 字节 | 0L | -2⁶³ ~ 2⁶³-1 | 用于表示非常大的整数,定义时数值后需加 L 或 l 。 |
float | 4 字节 | 0.0f | 约 ±3.4e38,7 位精度 | 单精度浮点数,定义时数值后需加 F 或 f 。 |
double | 8 字节 | 0.0d | 约 ±1.8e308,15 位精度 | 双精度浮点数,是 Java 中表示小数的默认类型。 |
char | 2 字节 | '\u0000' | 单个 Unicode 字符(0 ~ 65535) | 用于表示单个字符,采用 Unicode 编码。 |
boolean | 1 位* | false | true / false(JVM 中通常按 1 字节处理) | 用于逻辑判断 |
数据类型转换方式
1,自动类型转换:例如,将int
转换为long
、将float
转换为double
等。
2,强制类型转换:例如,将long
转换为int
、将double
转换为int
等,可能导致数据丢失或溢出。
3,字符串转换:例如,将字符串转换为整型int
,可以使用Integer.parseInt()
方法。
4,数值之间转换:例如,将整型转换为字符型、将字符型转换为整型等。通过类型的包装类来实现提供的相应的转换方法。
String
String、StringBuilder、StringBuffer
String
是不可变的。
StringBuilder
与 StringBuffer
都继承自 AbstractStringBuilder
类,在 AbstractStringBuilder
中也是使用字符数组保存字符串,不过没有使用 final
和 private
关键字修饰,最关键的是这个 AbstractStringBuilder
类还提供了很多修改字符串的方法比如 append
方法。
String
中的对象是不可变的,也就可以理解为常量,线程安全。StringBuffer
对方法加了同步锁或者对调用的方法加了同步锁,所以是线程安全的。StringBuilder
并没有对方法进行加同步锁,所以是非线程安全的。
每次对 String
类型进行改变的时候,都会生成一个新的 String
对象,然后将指针指向新的 String
对象。StringBuilder
和StringBuffer
每次都会对对象本身进行操作,但是StringBuffer
有线程安全的开销。
String为什么不可变?
首先从存储结构来看,String内部使用final修饰的字符数组(JDK9后改为byte[])存储数据,这个数组不仅被final限定不可重新赋值,还被private修饰彻底封装,外部无法直接操作底层数据。
其次从类设计层面,String类本身被final修饰形成终极防线,任何继承行为都被禁止,从根本上杜绝了通过子类覆写方法破坏不可变性的可能。
最后从方法设计角度,String没有提供任何会修改内部状态的方法,而StringBuilder和StringBuffer通过继承AbstractStringBuilder获得了修改能力,这种鲜明的对比恰恰凸显了String的不可变特性。
⬆实现上解释不可变,⬇设计上解释不可变
为什么设计成不可变的?
首先从安全性上讲,当 String 对象作为参数传递或在多线程间共享时,由于其内容不可更改,完全避免了并发修改导致的数据竞争问题。这种特性在网络通信、安全认证等关键场景中尤为重要。
其次在性能优化方面,JVM 可以基于此特性实现字符串常量池。同时 String 的 hashCode 可以被缓存,因为内容不变意味着哈希值永远有效,这极大提升了 HashMap 等集合类的操作效率。
从设计哲学层面,不可变 String 建立了明确的行为契约:任何字符串操作都保证返回新对象而非修改原对象。这种设计使 String 作为方法参数时,调用方无需担心内容被意外修改,大大提高了代码的可预测性。
**字符串常量池 : ** JVM 为了提升性能和减少内存消耗针对字符串(String 类)专门开辟的一块区域,主要目的是为了避免字符串的重复创建。
当你使用 new String("abc")
时,实际上会发生两个步骤:
- 首先,字符串字面量
"abc"
会被检查是否在字符串常量池中。如果"abc"
不在常量池中,它会被添加进去;如果已经在常量池中,直接复用常量池中的"abc"
。 - 然后,
new String("abc")
会创建一个新的String
对象,这个新的对象会指向常量池中的"abc"
字符串。虽然它和常量池中的"abc"
内容一样,但它是 一个新的对象(存储在堆内存中)。
所以,new String("abc")
实际上会创建两个 "abc"
:一个是存储在字符串常量池中的 "abc"
(如果之前没有的话)。另一个是存储在堆内存中的新 String
对象。
//字符串常量池 String s1 = "hello"; // "hello" 被加入常量池 String s2 = "hello"; // s1 和 s2 引用的是同一个对象 String s3 = new String("hello"); // 通过 new 创建,指向堆内存,不在常量池中 String s4 = s1.intern(); // s4 仍然指向常量池中的 "hello" String s5 = s1;// s5 仍然指向常量池中的 "hello" //intern() 方法的作用是让你获得常量池中的字符串,如果该字符串已经存在于常量池中,直接返回该对象;如果不存在,则将它添加到常量池。
//包装类的缓存机制 Integer i1 = 100; // 会从缓存池获取 Integer i2 = 100; // 同样会从缓存池获取,i1 和 i2 指向同一个对象 Integer i3 = 200; // 不会从缓存池获取,而是新建对象 Integer i4 = 200; // i3 和 i4 不是同一个对象
**String的 “ + ” **:字符串对象通过“+”的字符串拼接方式,实际上是通过 StringBuilder
调用 append()
方法实现的,拼接完成之后调用 toString()
得到一个 String
对象 。
s = new StringBuilder(s).append("b").toString();
String str3 = new StringBuilder().append(str1).append(str2).toString();
**String的 “ equals ” **:检查是否是同一个对象; 检查传入的对象是否是 String 类型; 检查长度是否相等; for循环逐个字符比较。
BigDecimal
浮点数精度丢失
浮点数运算进度丢失是因为计算机二进制存储数字,但是有些小数不能在有限位数里转化成二进制。所以就需要截断,导致精度丢失。
二进制表示小数的时候只能够表示能够用1/(2^n)的和的任意组合
使用BigDecimal
: 用字符串的形式存储小数位,以十进制方式精确表示每一位,适合做高精度运算,比如金额计算。
在创建BigDecimal
对象时,应该使用字符串作为参数。
常见方法:
- 创建:推荐使用它的
BigDecimal(String val)
构造方法或者BigDecimal.valueOf(double val)
静态方法来创建对象。不能直接把double转化为BigDecimal对象,因为 double 是不精确的二进制浮点数,直接用它构造BigDecimal
会把那些 误差也带进去,导致结果不准确。- 加减乘除:
add
方法用于将两个BigDecimal
对象相加,subtract
方法用于将两个BigDecimal
对象相减。multiply
方法用于将两个BigDecimal
对象相乘,divide
方法用于将两个BigDecimal
对象相除使用
divide
方法的时候尽量使用 3 个参数版本,并且RoundingMode
不要选择UNNECESSARY
,否则很可能会遇到ArithmeticException
(无法除尽出现无限循环小数的时候),其中scale
表示要保留几位小数,roundingMode
代表保留规则。System.out.println(a.divide(b));// 无法除尽,抛出 ArithmeticException 异常 System.out.println(a.divide(b, 2, RoundingMode.HALF_UP));// 1.11
- 大小比较:
a.compareTo(b)
: 返回 -1 表示a
小于b
,0 表示a
等于b
, 1 表示a
大于b
。equals()
方法不仅仅会比较值的大小(value)还会比较精度(scale),而compareTo()
方法比较的时候会忽略精度。- 保留几位小数:通过
setScale
方法设置保留几位小数以及保留规则。
BigInteger
BigInteger
内部使用 int[]
数组来存储任意大小的整形数据。可以用来存储超过long整型的数据。
相对于常规整数类型的运算来说,BigInteger
运算的效率会相对较低。
包装类型
包装类型 vs 基本数据类型
1,本质上讲,包装类是对象而基本类型是原始数据。包装类继承自Object,可以调用方法,而int等基本类型直接存储数值,没有面向对象特性。
2,存储位置上,基本数据类型作为方法局部变量时直接存储在虚拟机栈的栈帧中,访问速度最快;作为对象成员变量时则随对象实例存储在堆内存中;而静态的基本类型变量会被分配到方法区(元空间);包装类对象则始终存储在堆内存中,Java对部分包装类实现了缓存优化,缓存在堆内存的特殊区域。
3,默认值,基本类型有明确默认值(int为0),包装类默认值为null。
4,应用场景上,高频计算场景推荐使用基本类型(性能更好),而需要对象特性的场景(如集合存储、泛型使用)必须使用包装类。
包装类型的缓存机制
JVM
会将常见的重复使用的对象进行复用,避免每次都创建新的对象,提高性能,减少内存使用。
比如Integer,JVM
会缓存-128到127
的整数。调用Integer.valueOf(int)
的时候,如果传入的整数在这个范围,JVM
就返回一个已经创建好的共享对象,而不是在创建一个新的Integer对象。Boolean类型JVM
只会缓存true和false两个值。
包装类的作用
包装类的设计是为了解决基本数据类型无法满足对象化操作需求的问题
1,包装类使基本数据类型具备面向对象特性,使其可以调用方法、实现接口、参与多态等。
2,包装类使得基本数据类型能够以对象形式存储在集合中,例如List<Integer>
。
3,提供null值支持:基本数据类型不能表示"无值"状态(如数据库中的NULL),而包装类可以通过null
来表示这种状态,
4,包装类提供了许多静态方法,如Integer.parseInt()
、Double.toString()
等,方便进行数据类型转换和数值处理。
5,包装类是对象,可以参与反射操作和序列化/反序列化过程,而基本数据类型无法直接支持这些特性。
6,部分包装类(如Integer、Long等)对特定范围内的值进行了缓存优化(如-128到127),提高了性能和内存使用效率。
自动装箱与拆箱
自动装箱是将基本数据类型转换为其对应的包装类的过程,拆箱则是将包装类转换为基本数据类型的过程。这两个过程是由编译器自动完成的。装箱的时候JVM
调用包装类的构造方法或 valueOf()
方法,拆箱时会通过 xxxValue()
方法(如 Integer.intValue()
)获取基本数据类型的值。另外包装类的缓存机制会避免重复创建对象,提升性能。
但是频繁的装箱可能会导致性能问题,因为如果你的基本数据类型的大小超过了缓存机制的界限,那么就会不断产生新的对象,增加垃圾回收的压力。
关键字
static
在 Java 中,static
是一个非常基础的关键字,表示“静态的”,意思就是这个东西不依赖于对象,而是属于类本身。可以修饰静态变量,静态方法,静态代码块,静态内部类,接口的static方法(8+),接口中的变量默认都是static final的。
在类中,static
方法是属于类的,可以通过类名直接调用,不需要实例化对象,也不能被重写,因为它不属于实例,不参与多态。
在接口中(Java 8+),允许定义 static
方法,只能通过接口名调用,不能被实现类继承或重写,这主要是为了给接口提供一些工具方法。
**静态变量有什么作用?**静态变量也就是被 static
关键字修饰的变量。它可以被类的所有实例共享,无论一个类创建了多少个对象,它们都共享同一份静态变量。好处就是:节省内存、共享数据、方便统一管理。
静态方法为什么不能调用非静态成员?
静态方法属于类而不是实例,而非静态成员属于对象(实例)。
静态方法在类加载时就可以访问,不依赖于类的具体实例。非静态成员只有在类的实例化对象存在时,才能访问。
静态方法没有this
引用,不能访问与实例关联的成员。
final
final
这个关键字在 Java 里主要是用来限制“修改”的,可以修饰变量、方法和类
如果修饰变量,基本数据类型就表示值不能再变了,引用数据类型表示这个引用不能再指向别的对象,但对象本身的内容是可以改变的。
如果修饰方法,表示它不能被子类重写。比如,lang.Object
类的getClass
方法。因为这个方法的行为是由 Java 虚拟机底层实现来保证的,不应该被子类修改。
如果修饰类,表示不能被继承。比如,String
类是final
修饰的。
static final
组合在一起使用时表示这个变量是全局常量,在整个程序中只存在一份,值不能修改,是一种线程安全、全局唯一的只读变量。
transient
transient
关键字的作用是:阻止实例中那些用此关键字修饰的的变量序列化;当对象被反序列化时,被 transient
修饰的变量值不会被持久化和恢复。
关于 transient
还有几点注意:
transient
只能修饰变量,不能修饰类和方法。transient
修饰的变量,在反序列化后变量值将会被置成类型的默认值。例如,如果是修饰int
类型,那么反序列后结果就是0
。static
变量因为不属于任何对象(Object),所以无论有没有transient
关键字修饰,均不会被序列化。
Java 对象
Java创建对象的方式
1,使用new
关键字:最直接的创建方式,通过调用类构造方法完成内存分配和初始化。
2,反射机制:分为两种实现路径:
Class.newInstance()
:调用无参构造(JDK9已废弃,推荐Constructor方式)Constructor.newInstance()
:支持带参构造,更灵活的反射实例化方案
4,使用clone()方法:如果类实现了Cloneable接口,可以使用clone()方法复制对象。
5,使用反序列化:通过ObjectInputStream
读取序列化字节流,绕过构造方法直接重建对象。要求类实现Serializable
接口,常用于网络传输和持久化场景。
对象创建的过程
当new一个对象的时候,JVM首先判断类是否已经加载,如果没有就加载,如果已经加载了,那么就会在 堆内存 中为这个对象分配内存,分配好内存后,JVM 会先对这块内存做默认初始化,比如 int 是 0,引用是 null 等,接下来会准备好 this 指针,把它绑定到刚才分配的那块内存上,进程初始化,执行父类的非静态变量和代码块、父类构造器、子类的非静态变量和代码块、子类构造器。最后返回这个对象在堆的引用。
如何获取私有对象
私有对象通常指的是类中被声明为private
的成员变量或方法,这些成员只能在其所在的类内部被访问。
但也可以通过下面两种方式来间接获取私有对象:
1,使用公共访问器方法(getter 方法)。
2,反射机制:反射机制允许在运行时检查和修改类、方法、字段等信息。
Java 类
类加载过程
类加载是指 Java 程序在运行过程中,把 .class 文件加载到 JVM 中,并转化为 Class 对象的过程。
加载:JVM 通过类的全限定名查找 .class
文件,把它的字节码读取进内存,并创建一个 Class
对象。
验证:这一步是为了安全,检查这个字节码文件是否合法,比如语法结构、常量池是否合法、防止字节码被篡改等。
准备:这个阶段会为类的静态变量分配内存,并设置默认值,比如 int
是 0,boolean
是 false。
解析:把类中的符号引用(就是类似类名、字段名这种字符串)替换为真正的地址引用(也就是指向内存的引用)。
初始化:这一步才是真正执行代码,比如执行 static
代码块,或者静态变量的赋值操作,这些代码只会执行一次。
Java 类加载器采用的是双亲委派模型,意思是:一个类加载器在加载某个类时,先把请求交给它的父加载器,父加载器找不到,才由自己来加载。有两个好处,应该是避免重复加载,另一个是保证核心类的优先级。
Unsafe类
Unsafe
是 Java 提供的一个底层工具类,放在 sun.misc
包里,平时不能直接访问。它提供了很多绕过 JVM 安全机制的操作,比如直接操作内存、对象实例化、CAS 原子操作等。
它的主要作用就是提升性能、实现并发底层原语。不过它比较危险,因为可以绕开 Java 的内存模型、类型安全等限制,一般只在底层框架或者 JDK 内部使用。
从 Java 9 开始,官方推荐使用 VarHandle
来替代 Unsafe
,更加安全规范。
新特性
Java8新特性
首先最重要的是Lambda表达式和函数式编程的支持。Lambda表达式提供了一种简洁的语法来表示匿名函数,使得我们可以用更少的代码实现同样的功能。配合函数式接口(@FunctionalInterface),我们可以写出更优雅的代码。比如用()->{}替代匿名内部类,大大简化了代码。
其次是Stream API的引入。Stream提供了一种高效且易于并行化的数据处理方式。通过filter、map、reduce等操作,我们可以用声明式的方式处理集合数据。比如处理一个订单列表时,可以轻松实现过滤、排序、分组等操作,而且还能自动利用多核处理器进行并行计算。
第三是新的日期时间API(java.time包)。这个全新的API解决了旧Date/Calendar类的各种问题,提供了线程安全、不可变且更直观的日期时间操作类,如LocalDate、LocalDateTime等。
另外还有几个重要特性:
- 默认方法(default method),允许接口包含方法实现
- 方法引用(Method Reference),进一步简化Lambda表达式
- Optional类,提供了更好的null值处理方式
Java 21新特性
1. 虚拟线程(Virtual Threads) 这是 Java 21 最核心的特性,通过 java.lang.VirtualThread
实现了轻量级线程。虚拟线程由 JVM 管理,显著提升了高并发场景下的性能,可以支持数百万级别的线程创建,而传统线程受限于操作系统,通常只能创建数千个。使用方式也很简单,通过 Thread.startVirtualThread()
或 ExecutorService.newVirtualThreadPerTaskExecutor()
即可创建。
2. 序列集合(Sequenced Collections) 新增了 SequencedCollection
、SequencedSet
和 SequencedMap
接口,为集合提供了统一的获取首尾元素的方法,如 getFirst()
、getLast()
、addFirst()
等,解决了之前不同集合类型操作方式不一致的问题。
3. 字符串模板(String Templates) 预览特性,通过 STR
模板处理器实现更安全的字符串拼接,避免 SQL 注入等安全问题。
4. 分代 ZGC(Generational ZGC) ZGC 垃圾回收器现在支持分代收集,可以更高效地管理内存,降低停顿时间,尤其适合大内存应用。
5. switch 模式匹配增强 支持更简洁的模式匹配语法,可以直接在 switch 中匹配类型。
Lambda表达式
本质上是一个匿名函数,允许我们将函数作为方法参数传递,或者将代码作为数据处理。它的核心思想来自于函数式编程。
从语法上看,Lambda表达式由三部分组成:
- 参数列表:可以省略参数类型(类型推断)
- 箭头符号:->
- 方法体:可以是表达式或代码块
优点:
1,简洁性:Lambda表达式大幅减少了样板代码。相比传统的匿名内部类,它消除了冗余的类定义和方法声明,使代码更加紧凑。
2,可读性:通过将关注点集中在业务逻辑本身,过滤掉了非核心的语法结构。链式调用配合方法引用,使数据处理流程可以像自然语言一样线性表达。
3,允许将函数作为方法参数传递,实现了策略模式的轻量化应用。比如集合的sort方法,现在可以直接传入比较逻辑,而不需要创建完整的Comparator实现类。
虽然 Lambda 表达式优点蛮多的,不过也有一些缺点,比如会增加调试困难,因为 Lambda 表达式是匿名的,在调试时很难定位具体是哪个 Lambda 表达式出现了问题。尤其是当 Lambda 表达式嵌套使用或者比较复杂时,调试难度会进一步增加。
Stream API
Java Stream API是Java 8引入的声明式数据处理框架,它通过函数式编程范式重新定义了集合操作方式。Stream的本质是对数据源的元素序列进行函数式流水线操作。
这个API最大的优势是让数据处理代码更加简洁明了,特别是在需要对集合进行复杂操作时,可以避免编写多层嵌套的循环语句。同时,它的函数式风格也使得代码更容易理解和维护。
Stream API还支持并行处理,通过parallelStream()方法可以自动将任务分配到多个线程执行。在使用时需要注意,Stream是单向的,一旦被消费就不能重复使用。
Stream的操作分为中间操作和终止操作两种类型。中间操作会返回一个新的Stream,可以进行链式调用,主要包括:
- filter:数据过滤
- map:数据转换
- sorted:数据排序
- distinct:去重操作
终止操作会触发实际计算,产生一个具体结果或副作用,常见的有:
- forEach:遍历元素
- collect:将流转换为集合
- reduce:聚合计算
- count:统计元素数量
CompletableFuture
CompletableFuture是Java 8引入的异步编程工具,它扩展了Future接口,提供了更强大的异步任务编排能力。这个类主要解决了传统Future的几个痛点:无法手动完成任务、缺乏任务编排能力、异常处理不够灵活。
CompletableFuture的核心功能可以分为几个方面:首先是任务链式编排,通过thenApply、thenAccept等方法实现异步任务的流水线处理。其次是组合操作,支持thenCompose、thenCombine等方法组合多个异步任务的结果。然后是异常处理,提供了exceptionally、handle等方法来优雅处理异步任务中的异常。
面向对象编程
面向对象编程是一种以对象为基本单元的编程范式,其核心是将现实世界的事物抽象为程序中的独立实体。每个对象包含数据(属性)和操作数据的方法,通过对象之间的交互来完成复杂功能。
OOP的三大特性
首先OOP的三大特性分别是封装,继承,多态。
封装就是把数据和方法封装起来,隐藏内部的实现细节,外部只能通过提供的接口访问和修改数据,并且封装还提供了访问修饰符来控制权限,进一步增强了安全性和灵活性。它强调的是安全性和可维护性。
继承允许我们在已有类的基础上创建类,也就是子类继承父类的属性和方法,避免了代码的重复,提高了代码的复用性,它体现的是一个(is-a)的关系,存在耦合的问题,在实践中我们有”组合(has-a)优于继承“的设计思想。强调的是复用。
多态是指”一个接口,多种实现“,分为运行时多态和编译时多态,多态让我们可以用统一的方式处理不同的对象,强调的是扩展性和灵活性。
面向对象的设计原则
面向对象设计主要遵循SOLID五大核心原则,这些原则共同构成了健壮可维护系统的设计基础:
单一职责原则(SRP) 强调每个类应该只有一个引起变化的原因。例如用户管理系统中,将用户信息存储和用户权限校验分离到不同类中,避免修改用户属性时影响权限逻辑。
开闭原则(OCP) 指出软件实体应对扩展开放,对修改关闭。通过抽象化和多态机制实现,比如定义Shape抽象类后,新增圆形、三角形等子类时无需修改原有图形处理代码。
里氏替换原则(LSP) 规定子类必须能够替换父类而不影响程序正确性。这就要求子类不重写父类已实现的方法,而是通过抽象方法或接口扩展。例如鸟类有fly()方法,企鹅作为子类不应重写为空方法,而应重新设计继承体系。
接口隔离原则(ISP) 主张客户端不应依赖它不需要的接口。将臃肿接口拆分为多个专用接口,就像打印机功能应该将打印、扫描、传真分为不同接口,避免多功能设备强制实现所有方法。
依赖倒置原则(DIP) 要求高层模块不依赖低层模块,二者都应依赖抽象。通过依赖注入实现,如订单服务依赖Payment接口而非具体的Alipay实现,支持灵活切换支付方式。
此外,还有两个重要的辅助原则:
迪米特法则(LoD) 限制对象间的交互范围,强调最少知识原则。例如顾客购车时,销售员不应直接操作发动机的零部件,而应该通过整车接口交互。
组合复用原则(CRP) 提倡优先使用组合而非继承来扩展功能。就像给汽车增加导航系统,应该通过组合GPS设备实现,而不是继承某个导航基类。
静态变量与静态方法
静态变量(也称为类变量)是在类中使用static
关键字声明的变量。常用于需要在所有对象间共享的数据,如计数器、常量等。它们属于类而不是任何具体的对象。主要的特点:
1,共享性:所有该类的实例共享同一个静态变量。如果一个实例修改了静态变量的值,其他实例也会看到这个更改。
2,初始化:静态变量在类被加载时初始化,只会对其进行一次分配内存。
3,访问方式:静态变量可以直接通过类名访问,也可以通过实例访问,但推荐使用类名。
静态方法是在类中使用static
关键字声明的方法。类似于静态变量,静态方法也属于类,而不是任何具体的对象。常用于助手方法、获取类级别的信息或者是没有依赖于实例的数据处理。主要的特点:
1,无实例依赖:静态方法可以在没有创建类实例的情况下调用。对于静态方法来说,不能直接访问非静态的成员变量或方法,因为静态方法没有上下文的实例。
2,访问静态成员:静态方法可以直接调用其他静态变量和静态方法,但不能直接访问非静态成员。
3,多态性::静态方法不支持重写(Override),但可以被隐藏(Hide)。
非静态内部类与静态内部类
内部类是为了更好地组织代码结构,让逻辑上紧密关联的类写在一起,提高封装性和可读性。
非静态内部类和静态内部类的区别
本质区别:非静态内部类持有外部类的隐式引用,可以直接访问外部类的所有成员;静态内部类不持有外部类引用,只能访问外部类的静态成员。
实例化方式上,非静态内部类必须通过外部类实例来创建;静态内部类可以直接实例化。
内存管理方面,非静态内部类会导致外部类实例无法被GC回收,这在Android开发中容易引发Activity泄漏;静态内部类则不存在此问题。
应用场景上,非静态内部类适合紧密耦合的场景,如事件处理器需要访问UI组件状态。静态内部类更适用于工具类、Builder模式等独立功能实现。
非静态内部类可以直接访问外部方法是因为编译器在生成字节码时会为非静态内部类维护一个指向外部类实例的引用。
这个引用使得非静态内部类能够访问外部类的实例变量和方法。编译器会在生成非静态内部类的构造方法时,将外部类实例作为参数传入,并在内部类的实例化过程中建立外部类实例与内部类实例之间的联系,从而实现直接访问外部方法的功能。
重写 vs 重载
首先在定义上,重载是指同一个类中存在多个同名方法,通过不同的参数列表进行区分;而重写是子类对父类方法进行重新定义,保持方法签名完全一致。
其次在作用范围上,重载发生在同一个类内部,重写则是在父子类之间进行。重载关注的是方法调用的多样性,重写关注的是方法实现的差异性。
第三在绑定时机上,重载属于编译时多态,编译器在编译阶段就能确定调用哪个方法;重写属于运行时多态,需要等到程序运行时才能确定具体调用的方法实现。
第四在方法签名要求上,重载要求方法名称相同但参数列表必须不同;重写则要求方法名称、参数列表和返回类型都必须与父类方法保持一致。
最后在访问权限上,重载对方法的访问修饰符没有特殊要求;重写的方法访问权限不能比父类方法更严格,比如父类方法是protected,子类重写时就不能用private。
抽象类 vs 普通类
抽象类用于定义具有部分实现的结构,强调抽象概念,而普通类用于描述具体实体,强调具体实现。
抽象类不能被直接实例化,必须通过子类继承后实例化子类对象。普通类可以直接使用new关键字创建对象实例。
抽象类可以包含抽象方法和具体方法。普通类中的所有方法都必须有完整实现,不能存在抽象方法。
继承抽象类的子类必须实现所有抽象方法(除非子类也是抽象类),而继承普通类时子类可以选择性地重写父类方法。
接口 vs 抽象类
抽象类是对一类事物的本质抽象,强调"是什么"的层次关系,可以有成员变量、构造方法和具体方法。接口是对行为的抽象,强调"能做什么"的能力约定,只能有常量和抽象方法(Java 8 以后可以有默认方法和静态方法)。
一个类只能继承一个抽象类,但可以实现多个接口。
接口更强调行为标准的统一,而抽象类提供部分实现,强调代码的复用。
接口里面可以定义哪些方法
抽象方法:抽象方法是接口的核心部分,所有实现接口的类都必须实现这些方法。
默认方法:默认方法是在 Java 8 中引入的,允许接口提供具体实现。实现类可以选择重写默认方法。
静态方法:默认方法是在 Java 8 中引入的,允许接口提供具体实现。实现类可以选择重写默认方法。
私有方法:默认方法是在 Java 8 中引入的,允许接口提供具体实现。实现类可以选择重写默认方法。
成员变量 vs 局部变量
成员变量就是你在类里面定义的、但不写在方法里的变量,它是属于对象或者类本身的。只要对象在,它就一直在;对象没了,它才跟着消失。比如你定义了一个 int age
放在类里面,这就是成员变量。它有默认值,就算你不赋值,它也不是空的。
而局部变量呢,是写在方法、代码块或者参数里的,比如你在一个方法里面写了 int count = 0;
,这个就是局部变量。它只在方法执行时存在,用完方法就被销毁了。而且它必须赋值后才能用,不然编译都不会通过。
还有一个区别是:成员变量可以加 public
、private
、static
这些修饰符,但局部变量不行,它不能有访问控制符,也不能是 static。
**为什么成员变量有默认值,局部变量没有?**成员变量是跟着对象走的,存在于堆内存里,一旦你创建了对象,JVM 就会自动帮你把这些变量都初始化成默认值,比如 int
是 0,boolean
是 false,引用类型是 null。这样做的好处是让程序更安全——你用成员变量的时候,不会因为“没初始化”导致程序崩掉或行为异常。局部变量没有,它是跟着方法走的,存在于栈内存里。Java 不给它默认值,目的就是强制你自己先赋值,不然编译器直接报错。
静态方法 vs 实例方法
调用静态方法可以无需创建对象:前者可以使用 类名.方法名
的方式,也可以使用 对象.方法名
的方式,后者只能用第二种
静态方法只允许访问静态成员,不允许访问实例成员,而实例方法没有这种限制。
构造代码块 vs 构造方法
构造代码块是写在类中、没有方法名、没有修饰符的代码块。它在每次创建对象时都会执行,并且优先于构造方法执行。
构造方法用于创建对象时初始化,名字与类名相同,可以有多个重载版本。
构造代码块 vs 静态代码块
构造代码块在每次创建对象前都会执行,提取多个构造方法的共同行为,减少代码重复
静态代码块是在 Java 类中用 static {}
包裹的一段代码。它在类加载时执行,并且只执行一次,不管你创建了多少个对象。
多态体现在哪里
首先是编译时多态,主要通过方法重载机制实现。当类中存在多个同名方法但参数列表不同时,编译器在编译阶段就能确定具体调用的方法版本。
其次是运行时多态,这是面向对象最核心的多态形式。通过方法重写机制,子类可以重新定义父类方法的实现。当父类引用指向子类对象时,JVM会根据对象实际类型动态绑定方法调用。这个特性依赖于虚方法表机制,每个类都会维护一个包含可调用方法地址的vtable。
第三是接口多态,通过接口与实现类的关系实现。不同于类继承的单继承限制,一个类可以实现多个接口,每个接口方法在不同的实现类中可以有不同的行为表现。例如集合框架中的List接口与ArrayList、LinkedList等实现类的关系。
面向对象 vs 面向过程
POP把解决问题的过程拆成一个个方法,通过一个个方法的执行解决问题。
OOP关注的是对象和它们之间的互动,每个对象负责自己的行为,程序通过“对象”来组织和管理。
相比较于 POP,OOP 开发的程序一般具有下面这些优点:
易维护:由于良好的结构和封装性,OOP 程序通常更容易维护。
易复用:通过继承和多态,OOP 设计使得代码更具复用性,方便扩展功能。
易扩展:模块化设计使得系统扩展变得更加容易和灵活。
Java 机制
异常
Java异常处理机制为我们提供了一套完善的错误处理和恢复体系。Java中的异常都是Throwable类的子类,主要分为两大类:Error和Exception。
Error表示严重的系统错误,通常与程序逻辑无关,而是JVM运行时出现的问题,比如OutOfMemoryError或StackOverflowError。这类错误一般无法通过程序处理,只能尽量避免。
Exception则是程序可以处理的异常,它又分为受检异常和非受检异常。受检异常如IOException、SQLException等,编译器会强制要求我们处理这些异常,要么用try-catch捕获,要么在方法签名中用throws声明。这种设计确保了程序对可能发生的问题有明确的处理逻辑。非受检异常即RuntimeException及其子类,如NullPointerException、ArrayIndexOutOfBoundsException等,这类异常通常由程序逻辑错误引起,编译器不强制处理。
异常处理机制
处理异常有三种机制:
try-catch-finally
块:try
块用于捕获并处理异常,其后可接零个或多个catch
块,如果没有catch
块,则必须跟一个finally
块。catch
块用于处理 try 捕获到的异常。finally
块无论是否发生异常都会执行,常用于释放资源;不要在 finally 语句块中使用 return! 当 try 语句和 finally 语句中都有 return 语句时,try 语句块中的 return 语句会被忽略。这是因为 try 语句中的 return 返回值会先被暂存在一个本地变量中,当执行到 finally 语句中的 return 之后,这个本地变量的值就变为了 finally 语句中的 return 返回值。
**什么情况下finally 中的代码不会被执行?**1,在 finally 之前虚拟机被终止运行;2,程序所在的线程死亡;3,关闭 CPU。
throw
:用于手动抛出一个异常实例;throws
:用于在方法签名中声明该方法可能抛出哪些异常,方便调用者进行处理。
异常的传播机制:如果一个方法抛出了异常而没有处理,就会沿着调用栈一直向上传递,直到被某一层捕获为止,如果始终没有被捕获,最终 JVM 会终止程序并打印异常堆栈。
最佳实践
首先,应该捕获具体的异常类型,而不是简单地捕获Exception。其次,不要吞没异常,至少要记录异常信息。第三,合理使用自定义异常来区分业务异常和系统异常。最后,要注意异常处理的性能开销,特别是在高频执行的代码路径中。
补充:异常不能被定义为静态变量,否则会导致异常栈信息错乱
举个例子,在电商系统开发中,我们可能会定义BusinessException来表示库存不足等业务异常,同时使用@ExceptionHandler将其转换为友好的错误信息返回给前端。
Throwable
常见方法有哪些:
String getMessage()
: 返回异常发生时的详细信息
String toString()
: 返回异常发生时的简要描述
String getLocalizedMessage()
: 返回异常对象的本地化信息。使用 Throwable
的子类覆盖这个方法,可以生成本地化信息。如果子类没有覆盖该方法,则该方法返回的信息与 getMessage()
返回的结果相同
void printStackTrace()
: 在控制台上打印 Throwable
对象封装的异常信息
try-with-resources
try-with-resources
是 Java 7 引入的一种语法,用来简化资源(比如文件、数据库连接、输入输出流等)的关闭操作。
就是在 try()
里声明一个可自动关闭的资源,try
代码块结束后**,**JVM 会自动帮你调用 close()
方法,不需要再写 finally
手动关闭。
使用条件:资源类必须实现 AutoCloseable
接口(Closeable
也是它的子接口,比如 FileInputStream
、BufferedReader
等)。
优点:语法简洁;自动关闭资源,避免资源泄露;多个资源可以一起声明。
try (Scanner scanner = new Scanner(new File("test.txt"))) {
while (scanner.hasNext()) {
System.out.println(scanner.nextLine());
}
} catch (FileNotFoundException fnfe) {
fnfe.printStackTrace();
}
泛型
泛型就是在定义类、接口或方法时,不指定具体的数据类型,而是用一个“占位符”(比如 <T>
)来代替,等真正使用的时候再传入具体的类型。
泛型本质上是编译期的检查机制,运行时其实是擦除了类型信息,所以它是典型的语法糖。
好处呢一个是提高代码的通用性:可以写一次代码,适用于多种类型;增强类型安全:编译阶段就能检查类型,避免运行时出错;减少强制类型转换:不需要手动强转,代码更简洁安全。
使用方式
1,泛型类,比如集合框架、自定义容器、工具类;2,泛型接口;3,泛型方法
//泛型接口
public interface Generator<T> {
public T method();
}
//泛型方法
public static < E > void printArray( E[] inputArray )
{
for ( E element : inputArray ){
System.out.printf( "%s ", element );
}
System.out.println();
}
静态方法不能用类外面那个 T(因为那时候 T 还没值), 但可以自己在方法前写
<E>
,来定义一个属于自己的泛型。public class Box<T> { public void normalMethod(T value) { // 可以用T System.out.println(value); } public static void staticMethod(T value) { // ❌ 报错!不能用T // T 是类上的泛型,但静态方法用不了 } public static <E> void printArray(E[] inputArray) { // ✅ 正确 for (E element : inputArray) { System.out.println(element); } } }
项目哪里用到了泛型?:
自定义接口通用返回结果
CommonResult<T>
通过参数T
可根据具体的返回类型动态指定结果的数据类型定义
Excel
处理类ExcelUtil<T>
用于动态指定Excel
导出的数据类型构建集合工具类(参考
Collections
中的sort
,binarySearch
方法)
泛型的坑
//泛型遇到重载-----报错,因为泛型擦除后一样
public class GenericTypes {
public static void method(List<String> list) {
System.out.println("invoke method(List<String> list)");
}
public static void method(List<Integer> list) {
System.out.println("invoke method(List<Integer> list)");
}
}
//泛型遇到 catch-----JVM 是无法区分两个异常类型MyException<String>和MyException<Integer>
//泛型内包含静态变量-----GT<Integer>.var和GT<String>.var其实是一个变量
public class StaticTest{
public static void main(String[] args){
GT<Integer> gti = new GT<Integer>();
gti.var=1;
GT<String> gts = new GT<String>();
gts.var=2;
System.out.println(gti.var);//输出结果为:2
}
}
class GT<T>{
public static int var=0;
public void nothing(T x){}
}
反射
Java 反射 (Reflection) 是一种在程序运行时,动态地获取类的信息并操作类或对象的能力。
特性实现
动态对象创建:即使在编译时不知道具体类名也能使用反射API动态创建对象实例。这是通过Class类的newInstance()方法或Constructor对象的newInstance()方法实现的。
动态方法调用:可以在运行时动态地调用对象的方法,包括私有方法。这通过Method类的invoke()方法实现,允许你传入对象实例和参数值来执行方法。
访问和修改字段值:反射还允许程序在运行时访问和修改对象的字段值,即使是私有的。这是通过Field类的get()和set()方法完成的。
优点
灵活性和动态性:反射允许程序在运行时动态地加载类、创建对象、调用方法和访问字段。这样可以根据实际需求(如配置文件、用户输入、注解等)动态地适应和扩展程序的行为,显著提高了系统的灵活性和适应性。
框架开发的基础:许多现代 Java 框架(如 Spring、Hibernate、MyBatis)都大量使用反射来实现依赖注入(DI)、面向切面编程(AOP)、对象关系映射(ORM)、注解处理等核心功能。
解耦合和通用性:通过反射,可以编写更通用、可重用和高度解耦的代码,降低模块之间的依赖。例如,可以通过反射实现通用的对象拷贝、序列化、Bean 工具等。
缺点
性能开销:反射操作通常比直接代码调用要慢。因为涉及到动态类型解析、方法查找以及 JIT 编译器的优化受限等因素。不过,对于大多数框架场景,这种性能损耗通常是可以接受的,或者框架本身会做一些缓存优化。
安全性问题:反射可以绕过 Java 语言的访问控制机制(如访问 private
字段和方法),破坏了封装性,可能导致数据泄露或程序被恶意篡改。此外,还可以绕过泛型检查,带来类型安全隐患。
代码可读性和维护性:过度使用反射会使代码变得复杂、难以理解和调试。错误通常在运行时才会暴露,不像编译期错误那样容易发现。
应用场景
依赖注入与控制反转:以 Spring/Spring Boot 为代表的 IoC 框架,会在启动时扫描带有特定注解(如 @Component
, @Service
, @Repository
, @Controller
)的类,利用反射实例化对象(Bean),并通过反射注入依赖(如 @Autowired
、构造器注入等)
注解处理:框架通过反射检查类、方法、字段上有没有特定的注解,然后根据注解信息执行相应的逻辑。比如,看到 @Value
,就用反射读取注解内容,去配置文件找对应的值,再用反射把值设置给字段。
动态代理与 AOP:动态代理是实现 AOP 的常用手段。在运行时创建一个代理对象,这个对象对外看起来跟原对象一样,但它在方法调用的过程中,会先通过反射拿到你要调用的方法,然后可以在调用前后插入一些逻辑,比如打印日志,最后再通过反射去真正调用目标方法。
对象关系映射(ORM):像 MyBatis这种框架通过反射获取 Java 类的属性列表,然后把查询结果按名字或配置对应起来,再用反射调用 setter 或直接修改字段值。反过来,保存对象到数据库时,也是用反射读取属性值来拼 SQL。
注解
Annotation
(注解) 是 Java5 开始引入的新特性,主要用于修饰类、方法或者变量,提供某些信息供程序在编译或者运行时使用。
原理
注解本质是一个继承了Annotation的特殊接口,其具体实现类是Java运行时生成的动态代理类。
我们通过反射获取注解时,返回的是Java运行时生成的动态代理对象。通过代理对象调用自定义注解的方法,会最终调用AnnotationInvocationHandler的invoke方法。该方法会从memberValues这个Map中索引出对应的值。而memberValues的来源是Java常量池。
注解解析
注解解析的两种核心机制
1,运行时解析依赖Java反射API,JVM在类加载阶段会将注解信息存储在Class对象的AnnotationData结构中,后续通过getAnnotation()等反射方法读取。Spring框架对此进行了深度优化,比如使用ASM字节码技术绕过类加载直接读取注解,并通过缓存机制避免重复解析带来的性能损耗。
2.编译时解析则遵循JSR 269规范,通过注解处理器在javac编译阶段介入。注解处理器继承AbstractProcessor基类,可以扫描分析源代码中的注解信息并生成新代码或编译错误。Lombok就是典型代表,它通过直接修改AST语法树实现在编译期间自动生成getter/setter等方法。
作用域
注解的作用域(Scope)指的是注解可以应用在哪些程序元素上,例如类、方法、字段等。
1,类级别作用域:用于描述类的注解,通常放置在类定义的上面,可以用来指定类的一些属性,如类的访问级别、继承关系、注释等。
2,方法级别作用域:用于描述方法的注解,通常放置在方法定义的上面,可以用来指定方法的一些属性,如方法的访问级别、返回值类型、异常类型、注释等。
3,字段级别作用域:用于描述字段的注解,通常放置在字段定义的上面,可以用来指定字段的一些属性,如字段的访问级别、默认值、注释等。
4,除了这三种作用域,Java还提供了其他一些注解作用域,例如构造函数作用域和局部变量作用域。这些注解作用域可以用来对构造函数和局部变量进行描述和注释。
SPI*
是 Java 提供的一种服务发现机制。
它的作用是让我们可以只依赖接口,具体的实现类由第三方提供,并且在运行时动态加载,不需要写死。比如 JDBC 加载数据库驱动,底层就是用 SPI 机制实现的,Java 会自动去找配置好的驱动类并加载它们。
通俗讲,SPI 就像是插件机制,我只负责定义接口,谁来实现、怎么实现,运行时系统自己去找,这样就实现了解耦和扩展。
优缺点:提高接口设计的灵活性;
效率低:需要遍历加载所有的实现类,不能做到按需加载
并发问题:当多个 ServiceLoader
同时 load
时,会有并发问题。
和API的区别
API 是“我去用别人”,它是别人定义好一套功能接口,我作为调用者去使用,比如我们常用的 List、HttpClient 这些都是 API。 而 SPI 是“别人来接我”,我定义一套接口,让第三方去实现,然后在运行时由系统动态加载这些实现类,比如 JDBC 加载数据库驱动,就是通过 SPI 实现的。
API是“用户”使用外卖平台给你提供的“点餐功能”、“下单功能”、“付款功能”
SPI是平台方想让商家来入驻,就告诉商家:“只要你能送外卖,就来报名,遵守我的规则(接口)。” 商家来实现你的送外卖接口。平台在运行时会去找这些商家来派单。
ServiceLoader
ServiceLoader
是 Java 提供的一个工具类,用来实现 SPI 机制的服务发现。 它的作用就是在运行时根据接口,自动加载并实例化配置好的实现类,也就是说我只依赖接口,不需要手动 new 实现类,系统会自动去找“谁实现了这个接口”。
常见的场景比如 JDBC 驱动加载,Java 就是用 ServiceLoader
去找实现了 java.sql.Driver
的类,从而实现驱动的自动注册。
对比
== vs equals
==是一个运算符,对于基本数据类型,表示对比值是否相等,对于引用数据类型,表示对比是否指向同一个对象,也就是在堆内存的内存地址是否相同
而equals是Obejct
类的方法,默认实现就是return this == obj
,也就是比较引用的地址,但是Object
作为所有类的直接或间接的父类,有些标准类都是重写了equals
方法的,比如String
,Integer
,用来比较对象的内容是否相等。
但是需要注意的一点是Integer a = 127; Integer b = 127;
比较时 a == b
为 true
,因为 Java 会缓存 -128
到 127
之间的整数对象,也就是包装类的缓存机制。
JMM vs JVM
JVM 是一个具体的虚拟机,它负责在特定的硬件和操作系统上运行 Java 程序。它包括内存管理、垃圾回收、执行字节码等多个方面。
JMM 是 Java 语言的内存模型规范,它并不具体描述实现,而是提供了多线程编程中如何访问共享变量的规范,主要目的是确保并发编程中的线程安全。MM 主要定义了 内存可见性、原子性 和 有序性 三个方面的规则。
值传递vs引用传递
Java 是值传递
方法调用时,传递的是变量中存储的值的副本,而不是变量本身。
基本类型:传的是值的拷贝,方法里怎么改都不会影响原来的。
引用类型:传的是引用地址的拷贝,能通过这个地址修改对象的内容,但不能改原来的引用指向。
深拷贝,浅拷贝,引用拷贝
浅拷贝仅复制对象本身及其中基本类型字段,对于引用类型字段则复制引用地址,新旧对象共享同一子对象。
深拷贝会递归复制对象及其所有引用字段指向的整个对象图,生成完全独立的副本。
引用拷贝是最基础的对象地址复制,仅产生指向原对象的新引用变量
实现深拷贝的三种方式
在 Java 中,实现对象深拷贝的方法有以下几种主要方式:
1,实现 Cloneable 接口并重写 clone() 方法:在 clone() 方法中,通过递归克隆引用类型字段来实现深拷贝。(默认的clone()
是浅拷贝,深拷贝需要手动实现)
2,使用序列化和反序列化:通过将对象序列化为字节流,再从字节流反序列化为对象来实现深拷贝。
3,手动递归复制:手动递归复制对象及其引用类型字段。适用于对象结构复杂度不高的情况。
深拷贝 vs 浅拷贝
浅拷贝仅复制对象本身及其中基本类型字段,对于引用类型字段则复制引用地址,新旧对象共享同一子对象。深拷贝会递归复制对象及其所有引用字段指向的整个对象图,生成完全独立的副本。
浅拷贝后新旧对象的引用类型字段指向同一个实例,修改任一对象的List都会影响另一个。深拷贝会创建全新的List实例及其所有元素副本,新旧对象完全隔离。
浅拷贝 vs 引用拷贝
引用拷贝是最基础的对象地址复制,仅产生指向原对象的新引用变量。浅拷贝是对象层级的复制,创建新对象实例但对引用类型字段仅复制指针。
执行引用拷贝后,两个变量完全共享同一对象实体,任何修改都是对原对象的直接操作。而浅拷贝会创建新对象容器,虽然其引用字段与原对象共享子对象,但对象本身是独立的,修改新对象的非引用字段不会影响原对象。
序列化,反序列化
序列化就是把 Java 对象转换成字节流的过程,方便把对象保存到磁盘、或者通过网络传输。反序列化就是把字节流恢复成原来的 Java 对象。
常见应用场景:网络传输;存储到文件;存储到数据库;存储到内存;
OSI 七层协议模型中表示层做的事情主要就是对应用层的用户数据进行处理转换为二进制流,所以序列化协议对应于 TCP/IP 4 层模型的TCP/IP 协议应用层。
对于不想进行序列化的变量,使用 transient
关键字修饰
常见的序列化协议:比较常用的序列化协议有 Hessian、Kryo、Protobuf、ProtoStuff,这些都是基于二进制的序列化协议。
像 JSON 和 XML 这种属于文本类序列化方式。虽然可读性比较好,但是性能较差,一般不会选择。
JDK 自带的序列化方式
JDK 自带的序列化方式一般不会用 ,因为序列化效率低并且存在安全问题。
为什么不推荐使用 JDK 自带的序列化?
- 无法跨语言: Java 序列化目前只适用基于 Java 语言实现的框架,其它语言大部分都没有使用 Java 的序列化框架,也没有实现 Java 序列化这套协议。因此,如果是两个基于不同语言编写的应用程序相互通信,则无法实现两个应用服务之间传输对象的序列化与反序列化。
- 容易被攻击:Java 序列化是不安全的,我们知道对象是通过在 ObjectInputStream 上调用 readObject() 方法进行反序列化的,这个方法其实是一个神奇的构造器,它可以将类路径上几乎所有实现了 Serializable 接口的对象都实例化。这也就意味着,在反序列化字节流的过程中,该方法可以执行任意类型的代码,这是非常危险的。
- 序列化后的流太大:序列化后的二进制流大小能体现序列化的性能。序列化后的二进制数组越大,占用的存储空间就越多,存储硬件的成本就越高。如果我们是进行网络传输,则占用的带宽就更多,这时就会影响到系统的吞吐量。
我会考虑用主流序列化框架,比如FastJson、Protobuf来替代Java 序列化。
序列化如何实现
在 Java 中,将对象转换为二进制字节流的过程叫做序列化,主要是为了实现对象的持久化存储或跨网络传输。
具体实现方式是:对象必须实现 java.io.Serializable
接口,这只是一个标记接口,用来告诉 JVM 这个类的对象是可以被序列化的。然后,使用 Java 提供的 ObjectOutputStream
将对象写出为字节流。
序列化的本质,是将对象的状态(即成员变量的值)转换为一组可以保存或传输的字节信息,写入文件或通过网络发送。接收端则通过反序列化将字节流还原为对象。
需要注意的是,序列化不会保存 static
和 transient
修饰的字段,它们在反序列化时会被忽略。
场景
如何将对象从一个JVM转移到另一个JVM时
对象跨JVM转移的核心方法
- 对象序列化与反序列化 这是最常用的跨JVM对象传输方案。通过实现Serializable接口,可以将对象转换为字节序列,然后通过任意传输机制(如网络、文件)发送到另一个JVM后重建。Java原生序列化虽然简单,但存在性能较低、安全性等问题。实际开发中更推荐使用JSON、Protocol Buffers等跨语言序列化方案。
- RPC框架调用 在分布式系统中,通过Dubbo、gRPC等RPC框架可以实现透明的对象传输。框架内部会处理序列化和网络通信细节,开发者只需像调用本地方法一样使用远程对象。这种方式适合生产环境,但需要引入额外依赖。
- 分布式缓存/消息队列 使用Redis、Memcached等分布式缓存作为中转,或通过Kafka、RabbitMQ等消息队列传递序列化后的对象。这种方案具有松耦合特性,适合异步场景。
- Java远程方法调用(RMI) Java原生提供的RMI技术允许直接跨JVM调用方法,底层自动处理对象序列化和网络传输。但由于其强耦合性和防火墙穿透问题,现代项目中已较少使用。
学生类按照分数排序再按学号排序
可以使用Comparable接口来实现按照分数排序,再按照学号排序。首先在学生类中实现Comparable接口,并重写compareTo方法,然后在compareTo方法中实现按照分数排序和按照学号排序的逻辑。
// 实现 Comparable 接口,自定义排序逻辑
@Override
public int compareTo(Student other) {
if (this.score != other.score) {
return Integer.compare(other.score, this.score); // 分数降序
}
return Integer.compare(this.id, other.id); // 学号升序
}
//也可以使用 Comparator 的方式排序
list.sort(Comparator
.comparingInt(Student::getScore).reversed()
.thenComparingInt(Student::getId));
其他
hashCode()
hashCode()
的作用是获取哈希码(int
整数),也称为散列码。这个哈希码的作用是确定该对象在哈希表中的索引位置。
hashCode()
定义在 JDK 的 Object
类中,这就意味着 Java 中的任何类都包含有 hashCode()
函数。另外需要注意的是:Object
的 hashCode()
方法是本地方法,也就是用 C 语言或 C++ 实现的。
**为什么重写 equals() 时必须重写 hashCode() 方法?**Java 为了保证集合类在处理对象时能够正常工作,规定的一对规则:equals()
判断对象相等时,两个相等的对象必须返回相同的 hashCode()
值。
重写 equals() 时没有重写 hashCode() 方法的话,使用 HashMap 可能会出现什么问题?
1, 相同的键无法正确查找:get失败,查不到值。你往 HashMap
里放了一个对象当 key,然后你用另一个“内容一样”的对象去查,发现返回了 null
,查不到。
2,无法正确覆盖旧的键值对:put 不会覆盖,存重复 key。你往 HashMap
里放了一个 key,然后又放了一个“内容一模一样”的 key,理论上应该覆盖旧值,结果没覆盖,变成了两个 key 各自存在。
可变长参数
从 Java5
开始,Java 支持定义可变长参数,它允许在方法中传递可变数量的参数来简化方法参数。
遇到方法重载的情况会优先匹配固定参数,因为固定参数的方法匹配度更高。
Java 的可变参数编译后实际会被转换成一个数组,从编译后生成的 class
文件就可以看出。
语法糖
语法糖就是 Java 提供的一些语法层面的简化写法,让代码更简洁、更好读,但最终编译出来还是普通的底层代码。语法糖的存在主要是方便开发人员使用。但其实, Java 虚拟机并不支持这些语法糖,在编译阶段就会被还原成简单的基础语法结构,这个过程就是解语法糖。
Java 中最常用的语法糖主要有泛型、自动拆装箱、变长参数、枚举、内部类、增强 for 循环、try-with-resources 语法、lambda 表达式等。
- switch 支持 String 与枚举:本质上 String 会被转成
hashCode + equals
,枚举会被转成ordinal()
索引,编译器自动完成了这些逻辑。 - 泛型:编译器在编译期做类型检查、自动补上强转,运行期泛型信息被擦除,底层是原始类型操作(如 Object)。
- 自动装拆箱:编译器在编译时会自动把基本类型和包装类互相转换,底层调用的是如
Integer.valueOf()
和intValue()
这些方法。 - 可变长参数:方法传任意多个参数,编译器会把它转换成一个数组参数来处理。
- 枚举:编译器会将
enum
类转化为一个普通的类,并生成所有的常量、构造方法以及辅助方法(values()
、valueOf()
等),这让我们可以方便地使用枚举而不需要手动编写大量的常量和方法。 - 内部类:内部类、局部内部类、匿名内部类 都是语法糖。编译器会把它们拆成独立的类文件,并加上必要的外部类引用或构造函数参数。比如成员内部类会持有外部类的引用,匿名类会生成自动命名的类,局部变量也会被处理为 final 拷贝传入。
- 增强 for 循环:编译器会把它转成普通的
Iterator
遍历(如果是集合)或下标访问(如果是数组)。 - try-with-resources:用来自动关闭资源,编译器会自动帮你加上 finally 和 close() 调用。
- lambda 表达式:用来简化函数式接口的写法。底层要么编译成匿名内部类,要么使用
invokedynamic
和LambdaMetafactory
在运行时动态生成函数对象,本质仍然是函数式接口的实现类实例。
值传递
Java 只有值传递,没有引用传递。
不管是基本类型还是引用类型,方法调用时传进去的都是“值”的拷贝。区别在于:
- 传基本类型时,传的是变量的值本身;
- 传引用类型时,传的是“对象引用”的拷贝,也就是指向对象的地址。
所以你可以通过这个引用修改对象的内容,但不能让它指向另一个新对象,因为这个引用本身也是拷贝,改不了外面的引用地址。
移位运算符
优点
1.高效:移位运算符直接对应于处理器的移位指令。现代处理器具有专门的硬件指令来执行这些移位操作,这些指令通常在一个时钟周期内完成。相比之下,乘法和除法等算术运算在硬件层面上需要更多的时钟周期来完成。
2.节省内存:通过移位操作,可以使用一个整数(如 int
或 long
)来存储多个布尔值或标志位,从而节省内存。
常用场景
1.快速乘以或除以 2 的幂次方:a << n 即 a * 2ⁿ
;a >> n 即 a / 2ⁿ
(向下取整);>>> 即 无符号右移,忽略符号位,空位都以 0 补齐。
2.位字段管理:例如存储和操作多个布尔值。
3.哈希算法和加密解密:通过移位和与、或等操作来混淆数据
4.数据压缩:例如霍夫曼编码通过移位运算符可以快速处理和操作二进制数据,以生成紧凑的压缩格式。
5.数据校验:例如 CRC(循环冗余校验)通过移位和多项式除法生成和校验数据完整性。
6.内存对齐:通过移位操作,可以轻松计算和调整数据的对齐地址。
由于 double
,float
在二进制中的表现比较特殊,因此不能来进行移位操作。移位操作符实际上支持的类型只有int
和long
,编译器在对short
、byte
、char
类型进行移位前,都会将其转换为int
类型再操作。
**如果移位的位数超过数值所占有的位数会怎样?**当 int 类型左移/右移位数大于等于 32 位操作时,会先求余(%)后再进行左移/右移操作。实际执行的是 位数 % 数据类型位宽
。
构造方法
构造方法是一种特殊的方法,主要作用是完成对象的初始化工作。如果一个类没有声明构造方法,也会有默认的不带参数的构造方法。如果我们自己添加了类的构造方法(无论是否有参),Java 就不会添加默认的无参数的构造方法了。
构造方法具有以下特点:名称与类名相同,没有返回值,自动执行。不能被重写,可以被重载。
Native方法
在 Java 中,native
方法是用非 Java 语言实现的方法,通常是用 C 或 C++ 编写的。它通过关键字 native
声明,表示该方法在 Java 中没有具体实现,而是由本地代码在操作系统层面实现的。
Java 通过 JNI(Java Native Interface)机制 调用这些本地方法。主要用于两个场景:一是访问操作系统底层功能,比如硬件、系统调用等;二是与已有的本地库(如 C/C++ 库)集成,提高性能或复用现有代码