1基本知识:
java基础部分,包括语法基础,泛型,注解,异常,反射和其它(如SPI机制等)
1.1语法基础
封装
利用抽象数据类型将数据和基于数据的操作封装在一起,使其构成一个不可分割的独立实体。数据被保护在抽象数据类型的内部,尽可能地隐藏内部的细节,只保留一些对外接口使之与外部都发生联系。用户无需知道对象内部的细节,但可以通过对象对提供的接口来访问该对象。
优点:
- 减少耦合:可以独立地开发、测试、优化、使用、理解和修改
- 减轻维护的负担:可以更容易被程序员理解,并且在调试的时候可以不影响其他模块
- 有效地调节性能:可以通过剖析确定那些模块影响了系统的性能
- 提高软件的可重用性
- 降低了构建大型系统的风险:即使整个系统不可用,但是这些独立的模块却有可能是可用的
以下Person类封装name、gender、age等属性,外界只能通过get()方法获取一个Person对象的name属性和gender属性,而无法获取age属性,但是age属性可以提供work()方法使用。
注意到gender属性使用int数据类型进行存储,封装使得用户注意不到这种实现细节。并且在需要修改gender属性使用的数据类型时,也可以在不影响客户端代码的情况下进行。
public class Person {
private String name;
private int gender;
private int age;
public String getName() {
return name;
}
public String getGender() {
return gender == 0 ? "man" : "woman";
}
public void work() {
if (18 <= age && age <= 50) {
System.out.println(name + " is working very hard!");
} else {
System.out.println(name + " can't work any more!");
}
}
}
继承
继承实现了IS-A关系,例如Cat和Animal就是一直IS-A的关系,因此Cat可以继承自Animal,从而获得Animal非private的属性和方法。
继承应该遵循里氏替换原则,子类对象必须能够替换掉所有父类对象。
里氏替换原则的表述大致可以概括为:
- 子类替换原则:
如果一个类 B 继承自类 A,那么在所有需要使用 A 类对象的地方,都可以用 B 类的对象替换,而且程序的行为保持不变,不会引发任何错误或者异常。- 行为兼容性:
子类不仅需要保持与父类相同的接口,还必须保证在相同条件下表现出与父类一致的行为,即子类对父类的行为不得进行缩减或者改变原有约定的行为逻辑。- 继承的约束:
子类可以增加新的行为,但不应该通过重写父类的方法来破坏父类原有的不变式条件或后置条件。
子类不应降低父类公开方法的可见性(如将public方法改为protected或private)。
子类不应抛出比父类更宽泛的异常。
遵循里氏替换原则有助于提升代码的可读性、可维护性和可扩展性,支持在不影响现有代码的情况下,通过继承和多态机制安全地扩展功能。同时,它也是实现开闭原则(Open/Closed Principle)的一种有效手段,允许系统对扩展开放,对修改关闭。
Cat可以当做Animal来使用,也就是说可以使用Animal引用Cat对象。父类引用指向子类对象称为向上转型。
Animal animal = new Cat();
多态
多态分为编译时多态和运行时多态:
- 编译时多态主要指方法的重载
- 运行时多态指程序中定义的对象引用所指向的具体类型在运行期间才确定
运行时多态有三个条件:
- 继承
- 覆盖(重写)
- 向上转型
下面的代码中,乐器类(Instrument)有俩个子类:Wind和Percussion,它们都覆盖(重写)了父类的play()方法,并且在main()方法中使用父类Instrument来引用Wind和Percussion对象。在Instrument引用调用play()方法时,会执行实际引用对象所在类的play()方法,而不是Instrument类的方法。
public class Instrument {
public void play() {
System.out.println("Instrument is playing...");
}
}
public class Wind extends Instrument {
public void play() {
System.out.println("Wind is playing...");
}
}
public class Percussion extends Instrument {
public void play() {
System.out.println("Percussion is playing...");
}
}
public class Music {
public static void main(String[] args) {
List<Instrument> instruments = new ArrayList<>();
instruments.add(new Wind());
instruments.add(new Percussion());
for(Instrument instrument : instruments) {
instrument.play();
}
}
}
a = a + b 与 a += b 的区别
+=隐式的将加操作的结果类型强制转换为持有结果的类型。如果俩个整型相加,如byte、short或者int,首先会将它们提升到int类型,然后在执行加法操作。
byte a = 127;
byte b = 127;
b = a + b; // error : cannot convert from int to byte
b += a; // ok
b = a + b 会报错不能从整型转换为byte(因为a + b操作会将a、b提升为int类型,所以将int类型赋值给byte就会编译出错)
3 * 0.1 == 0.3 将会返回什么?true还是false?
false, 因为有些浮点数不能完全精确的表示出来。
计算机内部是以二进制存储数值,而十进制的小数如0.1和0.3在二进制下并不是有限长度的,它们会被近似表示。具体来说,十进制的0.1在二进制下是一个无限不循环小数,转换成二进制时会有一定的舍入误差。
当Java执行 3 * 0.1 这样的计算时,虽然结果显示为 0.30000000000000004(或其他非常接近但不等于0.3的浮点数),而不是精确的 0.3,因此在进行相等性判断时,尽管人眼看两者相等,但由于浮点数精度的问题,实际比较的结果是不相等,即 false。
能在Switch中使用String吗?
从java7开始,我们可以在switch case 中使用字符串,但这仅仅只是一个语法糖。内部实现在switch中使用字符串的hash code。
在此之前,switch语句仅支持以下类型:byte
short
char
int
其各自对应的包装类:Byte, Short, Character, Integer
enum类型
对equals()和hashCode()的理解?
- 为什么在重写equals方法的时候需要重写hashCode方法?
因为有强制的规范指定需要同时重写hashCode与equals方法,许多容器类,如HashMap、HashSet都依赖于hashCode与equals的规定。
- 一致性要求: 根据Java API文档中Object.hashCode()方法的规定,如果两个对象通过equals()方法比较是相等的(即a.equals(b)返回true),那么这两个对象的hashCode()方法必须返回相同的整数值。反之,如果两个对象不相等,则它们的哈希码不必一定不同,但最好有所不同以优化散列表的表现。
- 集合类的行为: 集合类如HashSet或HashMap依赖于hashCode()方法快速定位对象。它们首先计算对象的哈希码并使用该哈希码找到可能包含相等对象的桶(bucket)。一旦找到了匹配哈希码的桶,就会调用equals()方法逐一比较桶内的对象,确认是否存在真正的相等对象。若只重写了equals()而不重写hashCode(),可能导致逻辑上相等的对象因哈希码不同而被错误地分散到集合的不同部分,进而影响集合的正常行为,如插入、查找和删除操作。
- 性能考虑: 哈希码用于索引数据结构,通过哈希码可以直接定位到潜在的相等对象,显著提高了查找速度。如果没有适当地重写hashCode()方法,即使两个对象在逻辑上是相等的,也可能因为哈希码不同而被分别存放在集合的不同位置,这将浪费存储空间并且增加不必要的equals()方法调用,降低了整体性能。
- 有没有可能俩个不相等的对象有相同的hashcode?
有可能,俩个不相等的对象可能会有相同的hashcode值,这就是为什么在hashmap中会有冲突。hashcode值的规定只是说如果俩个对象相等,必须有相同的hashcode值,但是没有关于不相等对象的任何规定。
- 俩个相同的对象会有不同的hashcode吗?
不能,根据hashcode的规定,这是不可能的。
-
equals() 和 hashCode() 是Java中每个对象都拥有的两个基本方法,它们都定义在 java.lang.Object 类中,是所有Java类的祖先类。以下是它们各自的含义和使用规则:
-
equals() 方法
目的:equals() 方法用于判断两个对象是否相等,即在逻辑意义上是否代表同一个实体。在默认情况下,Object 类的 equals() 方法检查的是两个对象引用是否指向内存中的同一个对象实例,即是否是同一个对象的引用(通过 “==” 运算符也能达到相同的效果)。
重写:在实际应用中,通常需要根据类的具体业务逻辑重写 equals() 方法,以便基于对象的字段值而不是对象引用来判断两个对象是否相等。例如,对于一个Person类,如果认为具有相同姓名和年龄的人视为相等,那么就需要重写 equals() 方法来比较这些字段。
约定:
自反性:对于任何非null的对象x,x.equals(x) 应该返回true。
对称性:对于任何非null的对象x和y,如果 x.equals(y) 返回true,那么 y.equals(x) 也应该返回true。
传递性:对于任何非null的对象x、y和z,如果 x.equals(y) 返回true,y.equals(z) 返回true,那么 x.equals(z) 也应该返回true。
一致性:对于任何非null的对象x和y,如果用于equals()比较的信息没有被修改,多次调用 x.equals(y) 应该始终返回相同的结果。
非空性:对于任何非null的对象x,x.equals(null) 应该返回false。
- hashCode() 方法
目的:hashCode() 方法返回对象的一个整数表示,这个整数称为对象的哈希码。哈希码主要用于基于哈希表的数据结构,如 HashMap、HashSet 等,用于快速查找对象的位置或标识对象。
重写:当重写了 equals() 方法时,应同时重写 hashCode() 方法,以确保相等的对象拥有相同的哈希码,不相等的对象尽量拥有不同的哈希码。这是因为如果两个对象通过 equals() 判断为相等,它们的 hashCode() 返回值必须一致;反之,如果两个对象的 hashCode() 相同,它们并不一定相等,但这种情况应当尽量避免以减少哈希冲突。
一致性:
同一对象在它的生命周期内,只要它的equals()方法的比较逻辑没有变化,那么每次调用 hashCode() 方法得到的哈希码应该保持不变。
如果两个对象通过 equals() 方法判断为相等,那么它们的 hashCode() 方法必须返回相同的哈希码。
- 总结来说,equals() 和 hashCode() 方法一起确保了对象在逻辑相等性上的正确处理,特别是当对象被用作散列容器的键时。良好的 hashCode() 实现能够帮助系统更高效地处理大量数据,减少不必要的计算和资源消耗。
final、finalize和finally的不同之处?
- final
final 关键字 主要用于声明类、方法和变量,赋予它们不可更改的特性。
- final 类:声明一个类为final意味着它不能被其他类继承。
- final 方法:在一个类中声明一个方法为final,表明这个方法不能被子类重写(override)。
- final 变量(成员变量或局部变量):
- 成员变量(字段):声明为final的成员变量必须在声明时初始化或者在构造函数中赋值,一旦赋值后就不能再改变其值,即它是不可变的。
- 局部变量:声明为final的局部变量一旦被赋值后,在该方法或代码块的作用域内不能再被重新赋值,但如果是引用类型的变量,其引用的对象的内容是可以改变的。
- finalize
finalize() 方法 是Java中 java.lang.Object 类提供的一个方法,它在对象即将被垃圾回收器回收前自动调用一次。这是一个特殊的方法,允许对象在被销毁之前执行一些清理操作,比如关闭文件流、网络连接等资源释放。然而,由于finalize()方法的执行时机不确定且次数有限,现代Java编程实践中强烈不推荐依赖finalize()方法来释放关键资源,而是推荐使用 try-with-resources 语句或手动管理资源的释放。
- finally
finally 关键字 用于Java异常处理机制中的异常捕获语句(try-catch-finally)结构中,无论try块里面的代码是否抛出异常,finally块中的代码总会被执行。finally块通常用于放置那些无论是否出现异常都需要执行的清理代码,比如关闭数据库连接、关闭IO流等,确保在控制流离开try-catch结构时资源能够得到正确的释放。即使在try或catch块中有return语句,finally块也会在return语句执行前被执行。
String、StringBuffer与StringBuilder的区别?
- String
- 不可变性:String对象一旦创建之后,其内容是不可改变的。每次对String对象进行拼接、替换等操作实际上都会创建一个新的String对象。
- 内存效率:由于不可变性,频繁进行字符串操作时会产生大量的临时字符串对象,占用较多的内存空间。
- 线程安全性:String是线程安全的,因为它不可变,所以在多线程环境下不需要额外同步。
- StringBuffer
- 可变性:StringBuffer对象是可变的,它提供append、insert、delete、replace等方法可以在原对象基础上修改内容,而不会生成新的对象。
- 内存效率:相较于String,StringBuffer在修改字符串时不会每次都创建新的对象,因此在处理大量字符串拼接操作时更为高效。
- 线程安全性:StringBuffer是线程安全的,其内部的所有方法都经过同步处理,适用于多线程环境下的字符串操作。
- StringBuilder
- 可变性:StringBuilder与StringBuffer相似,也是可变的字符序列,同样提供了append、insert、delete、replace等方法。
- 内存效率:StringBuilder与StringBuffer一样,在修改字符串时也避免了重复创建对象,因此在处理大量字符串拼接时更加高效。
- 线程安全性:StringBuilder是非线程安全的,它的方法没有进行同步处理,因此在单线程环境下执行字符串操作时性能优于StringBuffer,因为不需要同步所带来的开销。
- 总结起来,如果你在多线程环境下需要频繁修改字符串内容,应选用StringBuffer;而在单线程环境下或者对性能要求较高时,优先选用StringBuilder。如果字符串内容不会发生改变,或者改变不频繁且不需要考虑线程安全,那么直接使用String即可。
接口与抽象类的区别?
- 抽象程度不同:
接口:接口完全抽象,它只包含抽象方法(在Java 8及以后版本还可以包含默认方法和静态方法),不能有任何实例字段(除了static final常量)。
抽象类:抽象类既可以包含抽象方法,也可以包含非抽象(已实现)的方法,并且可以拥有实例字段(包括final和非final)以及初始化块。
- 继承与实现:
接口:类通过implements关键字来实现接口,一个类可以实现多个接口,从而实现多重继承的效果。
抽象类:类通过extends关键字来继承抽象类,但一个类只能继承一个抽象类(尽管它可以同时实现多个接口)。
- 构造器:
接口:接口不能有构造器。
抽象类:抽象类可以有构造器,用于构造其非抽象子类的对象。
- 访问修饰符:
接口:接口中的所有方法默认是public的,变量默认是public static final的。
抽象类:方法和变量可以有不同的访问修饰符,如public、protected或private。
- 设计意图与用途:
接口:主要用于定义一种规范或契约,强调的是能够做什么的能力,适合于实现多重继承以及分离接口和实现,体现了设计模式中的“依赖倒置原则”和“里氏替换原则”。
抽象类:更倾向于定义一个类族的共性,不仅包含共同的方法签名,还能提供部分实现,允许子类通过继承共享代码,同时也是一种强制子类必须实现某些方法的方式。
依赖倒置原则的主要内容包括:
高层模块不应该依赖低层模块,二者都应该依赖于抽象。
在软件设计中,高层次的模块(如业务逻辑层)不应该直接依赖于低层次的模块(如数据访问层),而应该依赖于抽象接口或者抽象类。这样做的好处是解耦了高层模块与低层模块的实现细节,使得代码更易于维护和扩展。
抽象不应该依赖于细节,细节应该依赖于抽象。
抽象是指接口或抽象类,它们定义了通用的操作规范。具体的实现细节(也就是所谓的“细节”)应该依赖于这些抽象,也就是说,具体类应当实现抽象定义的方法,而不是让抽象去依赖具体实现。
依赖倒置原则的应用实践包括:
使用接口或抽象类来定义组件间的交互方式。
避免在模块间传递具体类的引用,转而传递抽象的引用。
将依赖项通过构造函数注入、setter方法注入等方式注入到依赖对象中,而非在内部创建依赖对象(这也符合控制反转/依赖注入的设计思想)。
- 方法实现:
接口:在Java 8以前,接口中的方法全都是抽象的,不能有方法体;但从Java 8开始,接口可以有静态方法和默认方法(带有方法体)。
抽象类:抽象类中的方法可以有具体实现,不是所有的方法都需要是抽象的。
- 总的来说,选择接口还是抽象类取决于设计上的需求。接口更偏向于设计独立的行为规范,而抽象类则更适合于定义具有部分共同实现的类结构。
this()&super()在构造方法中的区别?
- 调用super()必须写在子类构造方法的第一行,负责编译不通过
- super从子类调用父类构造器,this在同一类中调用其他构造器均需要放在第一行
- this和super不能出现在同一个构造器中
- 如果子类构造方法没有显式调用 super() 或 this(),编译器会在构造方法的隐式第一行自动添加一个无参数的 super() 调用(除非父类有一个无参数构造器,否则需要明确指定调用哪个带参数的父类构造方法)。
- 在子类构造过程中,通过 super() 初始化父类部分的状态是非常重要的,因为父类的成员变量和初始化逻辑可能对子类来说是必需的。
java位运算符?
java中有三种位运算符
- <<:左移运算符,x << 1,相当于x乘以2(不溢出的情况下),如果是二进制则就是往左移一位,地位补0
-
:带符号右移,x >> 1,相当于x除以2,如果是二进制则是往右移一位,正数的话高位补0,负数的话高位补1
-
:无符号右移,和带符号右移同理,只不过不管是正数还是负数高位都补0
1.2泛型
为什么需要泛型?
1.使用于多种数据类型执行相同的代码
private static int add(int a,int b){
System.out.println(a + "+" + b + "=" + (a + b));
return a + b;
}
private static float add(float a,float b){
System.out.println(a + "+" + b + "=" + (a + b));
return a + b;
}
private static double add(double a,double b){
System.out.println(a + "+" + b + "=" + (a + b));
return a + b;
}
如果没有泛型,要实现不同类型的加法,每种类型都需要重载一个add方法;通过泛型,我们可以复用为一个方法:
private static <T extends Number> double add(int a,int b){
System.out.println(a + "+" + b + "=" + (a.doubleValue() + b.doubleValue()));
return a.doubleValue() + b.doubleValue();
}
泛型中的类型在使用时指定,不需要强制类型转换(类型安全,编译器会检查类型)
看一下这个例子:
List list = new ArrayList();
list.add("xxString");
list.add(100d);
list.add(new Person());
我们在使用上述list中,list中的元素都是Object类型(无法约束其中的类型),所以在取出集合元素时需要人为的强制类型转化具体的目标类型,且很容易出现java.lang.ClassCastException异常。
引入泛型,它将提供类型的约束,提供编译前的检查;
List<String> list = new ArrayList<String>();
// list中只能放String, 不能放其它类型的元素
- 类型安全:
泛型允许在编译阶段就进行类型检查,这意味着编译器能够在程序编译时确保类型的一致性。如果不使用泛型,程序员可能会忘记或错误地执行类型转换,导致运行时出现 ClassCastException。通过泛型,可以在编译时预防这类错误,提高了代码的健壮性与安全性。
- 代码重用与简化:
泛型允许创建适用于多种类型的通用算法和数据结构,无需针对每种数据类型编写单独的代码。例如,在Java中,List 泛型类可以在编译时接受任何类型参数,这样可以创建 List, List 等不同类型的列表,而不需要为每种类型创建独立的类。
- 消除强制类型转换:
在泛型代码中,由于编译器知道容器元素的具体类型,取出元素时无需手动进行类型转换。这不仅减少了代码量,也消除了因类型转换错误导致的问题。
- 增强可读性与可维护性:
泛型使代码意图更加清晰,通过参数化类型表达式可以直接看出容器或其他组件期望存储或处理的数据类型,便于其他开发人员理解和维护代码。
- 性能优化:
尽管Java泛型在运行时会擦除为原始类型(类型擦除),但在编译阶段仍有助于生成更高效的代码。通过编译时的类型检查和转换优化,泛型可以减少不必要的运行时类型判断和转换,从而间接提升性能。
泛型类如何定义使用?
- 从一个简单的泛型类看起:
class Point<T>{ // 此处可以随便写标识符号,T是type的简称
private T var ; // var的类型由T指定,即:由外部指定
public T getVar(){ // 返回值的类型由外部决定
return var ;
}
public void setVar(T var){ // 设置的类型也由外部决定
this.var = var ;
}
}
public class GenericsDemo06{
public static void main(String args[]){
Point<String> p = new Point<String>() ; // 里面的var类型为String类型
p.setVar("it") ; // 设置字符串
System.out.println(p.getVar().length()) ; // 取得字符串的长度
}
}
- 多元泛型
class Notepad<K,V>{ // 此处指定了两个泛型类型
private K key ; // 此变量的类型由外部决定
private V value ; // 此变量的类型由外部决定
public K getKey(){
return this.key ;
}
public V getValue(){
return this.value ;
}
public void setKey(K key){
this.key = key ;
}
public void setValue(V value){
this.value = value ;
}
}
public class GenericsDemo09{
public static void main(String args[]){
Notepad<String,Integer> t = null ; // 定义两个泛型类型的对象
t = new Notepad<String,Integer>() ; // 里面的key为String,value为Integer
t.setKey("汤姆") ; // 设置第一个内容
t.setValue(20) ; // 设置第二个内容
System.out.print("姓名;" + t.getKey()) ; // 取得信息
System.out.print(",年龄;" + t.getValue()) ; // 取得信息
}
}
泛型接口如何定义使用?
- 简单的泛型接口
interface Info<T>{ // 在接口上定义泛型
public T getVar() ; // 定义抽象方法,抽象方法的返回值就是泛型类型
}
class InfoImpl<T> implements Info<T>{ // 定义泛型接口的子类
private T var ; // 定义属性
public InfoImpl(T var){ // 通过构造方法设置属性内容
this.setVar(var) ;
}
public void setVar(T var){
this.var = var ;
}
public T getVar(){
return this.var ;
}
}
public class GenericsDemo24{
public static void main(String arsg[]){
Info<String> i = null; // 声明接口对象
i = new InfoImpl<String>("汤姆") ; // 通过子类实例化对象
System.out.println("内容:" + i.getVar()) ;
}
}
泛型方法如何定义使用?
泛型方法,是在调用方法的时候指明泛型的具体类型。
- 定义泛型方法基本格式:
修饰符 <类型参数> 返回类型 方法名(参数列表) {
// 方法体
}
// 定义一个泛型方法,返回类型为泛型T
public <T> T getSomeValue(T defaultValue) {
// 这里可以根据条件返回任意类型T的对象,这里仅作演示,简单返回传入的defaultValue
return defaultValue;
}
- 调用泛型方法的基本格式:
// 调用方法,返回值类型根据传入的参数类型确定
String stringResult = getSomeValue("defaultString");
Integer integerResult = getSomeValue(0);
System.out.println(stringResult); // 输出: defaultString
System.out.println(integerResult); // 输出: 0
// 或者在Java 7及以上版本中使用钻石操作符<>简化类型推断
String anotherResult = <String>getSomeValue("anotherInput"); // 这里的String可以被自动推断,所以也可以省略
- 为什么要使用泛型方法呢?
因为泛型类要在实例化的时候就指明类型,如果想换一种类型,不得不重新new一次,可能不够灵活;而泛型方法可以在调用的时候指明类型,更加灵活。
泛型的上限和下限?
在使用泛型的时候,我们可以为传入的泛型类型实参进行上下边界的限制,如:类型实参只准传入某种类型的父类或某种类型的子类。
- 上限:
class Info<T extends Number>{ // 此处泛型只能是数字类型
private T var ; // 定义泛型变量
public void setVar(T var){
this.var = var ;
}
public T getVar(){
return this.var ;
}
public String toString(){ // 直接打印
return this.var.toString() ;
}
}
public class demo1{
public static void main(String args[]){
Info<Integer> i1 = new Info<Integer>() ; // 声明Integer的泛型对象
}
}
- 下限
class Info<T>{
private T var ; // 定义泛型变量
public void setVar(T var){
this.var = var ;
}
public T getVar(){
return this.var ;
}
public String toString(){ // 直接打印
return this.var.toString() ;
}
}
public class GenericsDemo21{
public static void main(String args[]){
Info<String> i1 = new Info<String>() ; // 声明String的泛型对象
Info<Object> i2 = new Info<Object>() ; // 声明Object的泛型对象
i1.setVar("hello") ;
i2.setVar(new Object()) ;
fun(i1) ;
fun(i2) ;
}
public static void fun(Info<? super String> temp){ // 只能接收String或Object类型的泛型,String类的父类只有Object类
System.out.print(temp + ", ") ;
}
}
如何理解Java中的泛型是伪泛型?
Java中的泛型之所以被称为“伪泛型”,主要是因为其在编译时期与运行时期的差异性处理方式。在Java中,泛型提供了类型参数化的能力,允许程序员在定义类、接口或方法时指定一个或多个未知的类型参数,并在使用这些类、接口或方法时指定具体的类型。这样做可以增强代码的类型安全性和重用性,避免强制类型转换。
然而,Java语言设计上的一个显著特点是其类型擦除(Type Erasure)。在编译过程中,尽管编译器会根据提供的类型参数执行类型检查并阻止非法操作,但编译后的字节码中并不会保留具体的类型参数信息。这意味着在运行时,Java虚拟机(JVM)实际上并不知道关于泛型的具体类型,所有类型的泛型引用都被替换为其限定的原始类型或者Object(如果没有显式限定的话)。
具体来说,以下几个方面体现了Java泛型的“伪”性质:
-
类型擦除:编译器会在编译时擦除类型参数,并在必要时插入类型转换代码来保持类型安全。例如,List在运行时实际上是List,其中的所有元素都被视为Object。
-
运行时类型不可知:运行时无法获取到声明时的泛型类型信息,比如new ArrayList().getClass()得到的类类型与new ArrayList().getClass()相同,均为ArrayList.class,而不是分别代表String和Integer类型的ArrayList。
-
不能创建泛型类的对象实例:例如,不能直接创建new T()这样的泛型类型实例,因为在运行时不存在T的实际类型。
-
反射限制:虽然可以通过反射访问一些泛型类型信息,但这需要额外的努力,并且仍然受限于类型擦除的实际情况。
综上所述,Java泛型并不是像C++模板那样的真正意义上的编译时多态,而是在编译时提供了静态类型检查的优势,而在运行时却缺乏了对泛型类型的具体认知。这一特点使得Java的泛型被称为“伪泛型”。尽管如此,Java的泛型设计仍然是一个强大的工具,能够在很大程度上提高代码质量和减少运行时错误。
1.3注解
注解的作用?
注解是JDK1.5版本开始引入的一个特性,用于对代码进行说明,可以对包、类、接口、字段、方法参数、局部变量等进行注解。它主要的作用有以下几个方面:
- 生成文档:
Java中的一些标准注解,如@param、@return、@throws等,用于在源码中添加描述信息,配合Javadoc工具生成文档,提高代码的可读性和维护性。
- 编译时和运行时信息处理:
编译器可以通过注解来执行特定的编译检查,例如@Override确保一个方法确实重写了超类中的方法,@Deprecated提示开发者不应再使用某个过时的元素。
@SuppressWarnings用来抑制特定的编译器警告。
@SafeVarargs则用于消除不安全的varargs方法调用可能引发的警告。
- 代码分析和处理:
注解处理器(如APT)能够读取并处理注解信息,在编译阶段生成额外的代码或执行验证逻辑。
运行时通过反射API,应用程序可以根据注解信息动态改变程序行为,例如Spring框架利用注解实现依赖注入、Hibernate使用注解映射数据库实体等。
- 框架集成与配置:
许多现代框架如Dagger 2、Spring MVC、JSR 330等广泛使用注解作为配置手段,替代或简化XML或其他配置文件的使用,使得代码更加简洁且易于管理。
- 元数据存储:
注解可以作为一种元数据存储方式,为第三方工具、测试框架或者性能分析工具提供有用的信息。
- 类型安全与设计约束:
如@FunctionalInterface用来确保一个接口符合函数式接口的要求,即只有一个抽象方法。
注解的常见分类?
- 预定义注解(内置注解或标准注解):
@Override: 用于标记方法,表明此方法覆盖了父类的同名方法,编译器会在此注解下进行相应的检查以保证正确覆盖。
@Deprecated: 标记已过时的元素(类、方法、字段等),编译器会发出警告,提示开发者不应该再使用这个被标记的元素。
@SuppressWarnings: 用于抑制编译器产生的特定警告信息。
@SafeVarargs: 用于非泛型静态方法或构造函数,指示没有因为 varargs 参数而导致的潜在类型安全问题。
@FunctionalInterface: 自Java 8起,用于标记一个接口声明为函数式接口,即接口中最多只能有一个抽象方法(除了默认方法和静态方法)。
- 元注解:
元注解是用来注解其他注解的注解,它们控制着注解的行为,例如:
@Retention: 指定注解的保留策略(SOURCE、CLASS 或 RUNTIME)。
@Target: 指定注解可以应用于哪些类型的Java元素(如类、方法、变量等)。
@Documented: 指明该注解应该被包含在用户生成的API文档中。
@Inherited: 允许子类继承父类上的注解。
- 自定义注解:
开发者可以根据需要创建自己的注解,通常用于实现框架集成、配置、代码分析、日志记录、性能追踪等多种用途。自定义注解的声明使用@interface关键字,并且可以带有属性(成员变量)。
1.4异常
Java异常类层次结构?
- Java的异常类层次结构基于java.lang.Throwable类构建,它是最顶层的基类,所有异常和错误都直接或间接地继承自Throwable。Throwable类下有两个主要的子类:
- Error:
表示系统级的错误或者严重的资源故障,这些错误发生时,通常意味着 JVM 已经遇到了不可恢复的问题。比如:
- java.lang.VirtualMachineError:当 JVM 运行时环境崩溃或资源耗尽时抛出,如内存溢出(OutOfMemoryError)或栈空间不足(StackOverflowError)。
- java.lang.LinkageError:链接时错误,涉及类加载器问题。
- java.lang.ThreadDeath:线程终止异常。
- Exception:
表示程序运行时可能出现的常规条件异常,进一步分为两大类:
- Checked Exceptions(受检异常): 必须在程序中显式地捕获处理或在方法签名中通过throws关键字声明抛出。例如:
- java.io.IOException:在输入/输出操作中遇到的异常。
- java.sql.SQLException:在数据库操作中遇到的异常。
- Unchecked Exceptions(运行时异常): 不强制要求在方法中捕获或声明抛出,但如果不处理可能在运行时导致程序异常终止。例如:
- java.lang.RuntimeException:这是所有未检查异常的父类,包括NullPointerException、IllegalArgumentException、ArrayIndexOutOfBoundsException等。
- java.lang.IllegalStateException:当对象状态不合法时抛出。
- java.util.ConcurrentModificationException:并发修改集合时抛出。
可查的异常(checked exceptions)和不可查的异常(unchecked excepitons)区别?
- 可查异常(Checked Exceptions):
- 可查异常是指那些在编译阶段就被编译器(前端编译器)检查的异常,如果方法可能会抛出这样的异常,那么必须在方法内部处理它(使用try-catch块),或者在方法签名中使用throws关键字声明该方法会抛出这个异常。
- 这些异常通常是外部因素引起的,比如I/O操作失败(如FileNotFoundException,IOException)、网络连接失败、数据库访问异常(如SQLException)等,它们都是Exception类的直接或间接子类,但不是RuntimeException或其子类。
- 编译器(前端编译器)强制在编码阶段就必须考虑如何处理这类异常,从而提高了程序的健壮性。
- 不可查异常(Unchecked Exceptions):
- 不可查异常也被称为运行时异常(Runtime Exceptions),它们在编译时不强制处理。这意味着即使不捕获这些异常,代码也能通过编译,但在运行时如果出现此类异常,则会导致程序立即停止运行(除非有全局的异常处理器)。
- 这类异常通常由编程错误或逻辑错误引起的,如空指针异常(NullPointerException)、数组越界异常(ArrayIndexOutOfBoundsException)、非法参数异常(IllegalArgumentException)等,它们或者时RuntimeException的直接或间接子类,或者时Error的子类。
- 因为不可察异常通常代表着程序设计或逻辑上的错误,所以良好的编程实践鼓励在代码编写阶段尽量避免这些异常的发生,而不是仅仅依赖于异常处理机制。
throw 和 throws的区别?
throw和throws时Java中用于异常处理的关键字,它们在异常处理机制中有不同的用途和用法:
- throw:
- throw是一个关键字,用于在程序中抛出一个具体的异常对象。
- 当程序在运行过程中遇到错误条件或不符合预期的行为时,可以使用throw来主动抛出一个异常实例。
- 一旦执行了throw语句,程序将立即停止当前方法的正常流程执行,跳转到与之匹配的catch块(如果有)进行异常处理,或者沿着方法调用链向上查找合适的异常处理器。
- 语法格式实在方法内部使用,后跟一个已经创建好的异常对象实例,例如:
if(someCondition){
throw new IllegalArgumentException("Invalid argument");
}
- throws:
- throws是一个关键字,用于在方法签名中声明该方法可能抛出的异常列表。
- 当一个方法内部可能产生某些异常,但并不打算在该方法内部处理这些异常时,可以使用throws关键字把这些异常声明出来,这样调用者必须处理这些声明的异常,否则调用者自己也需要在其方法签名中继续声明这些异常,或者在调用该方法的地方捕获异常。
- 语法格式时在方法头部声明,后跟一个或多个异常类名,用逗号分隔,例如:
public void someMethod() throws IOException,SQLException{ // 方法体 }
- 使用throws关键字仅表示方法可能抛出异常,但并不实际抛出异常,实际的异常抛出仍需使用throw关键字完成。
java7的try-with-resource?
java7引入了一种新的异常处理结构——try-with-resources,也称为ARM(Automatic Resource Management,自动资源管理)。这种结构简化了对实现了AutoCloseabe接口(或其祖先接口Closeable)资源的管理,确保在使用完毕后能够及时、正确地关闭资源,从而避免资源泄露的问题。
在传统的Java编程中,我们需要在使用如InputStream、Outputstream、Connection(如数据库连接)等资源之后,手工调用close()方法来关闭它们。但如果忘记关闭或者在关闭过程中发生异常,可能会导致资源无法释放。
try-with-resource的基本语法如下:
try (Resource r = new Resource()) {
// 在这里使用资源r
} catch (SomeException e) {
// 处理在使用资源过程中抛出的异常
} finally {
// (这里的finally块是隐含的,不需要显式编写)
// 在此处资源r会被自动关闭,无论是否抛出异常
}
在这个结构中,初始化资源的语句放在 try 后面的小括号内,当 try 块执行完毕(无论是正常结束还是抛出了异常),Java虚拟机都会确保资源被关闭。如果资源类的 close() 方法抛出了异常,那么这个异常会在正常的 try 块中捕获的任何异常之前或者之后(取决于实现)被处理,这可以通过多重 catch 子句来实现。如果多个资源需要同时关闭,它们可以一起列在 try 语句的括号内,用分号分隔,系统会按逆序自动关闭它们。
1.5反射
什么是反射?
在Java编程语言中,反射(Reflection)是一种强大的运行时元编程机制,它允许程序在运行时分析类和对象的信息,并且能够在运行时操作类或对象的内部属性和方法,即使这些信息在编译时是未知的。
具体来说,主要包括以下能力:
- 运行时类信息获取:可以在运行时加载类,并获取类的完整构造,包括类名、包名、父类、接口、注解、字段(域、成员变量)、方法等。
- 动态实例化对象:可以根据类的Class对象动态创建一个新的实例,即使在编译时并未明确指定类的类型。
- 动态方法调用:可以找出类中的方法并在运行时调用它们,包括私有方法(通常不受外部访问的限制也可以通过反射调用)。
- 动态设置和获取字段值:可以直接读取或修改对象的私有或受保护的属性值。
- 创建新的代理对象:结合Java动态代理API,可以利用反射创建代理对象,实现代理模式。
反射机制极大地增强了Java程序的灵活性和动态性,但需要注意的是,过度或不当使用反射可能会影响性能,破坏封装性,并可能导致安全问题。因此,在实际开发中,应谨慎权衡师傅需要使用反射功能。
反射的使用?
在Java中,反射主要通过java.lang.Class、java.lang.reflect.Method、java.lang.reflect.Field和java.lang.reflect.Constructor来实现对类的运行时操作。
以下是基本使用场景和示例:
- 获取Class对象:
- 通过类名获取
// 使用全限定类名 Class<?> clazz = Class.forName("oom.example.MyClass");
- 通过对象实例获取:
MyClass obj = new MyClass(); Class<?> clazz = obj.getClass();
- 创建对象实例
- 通过无参构造函数创建:
//通过上面获取的Class对象 Constructor<MyClass> ctor = clazz.getDeclaredConstructor(); MyClass instance = ctor.newInstance();
- 通过带参数的构造函数创建:
Constructor<MyClass> ctor = clazz.getConstructor(String.class, int.class); MyClass instance = ctor.newInstance("param1", 123);
- 访问字段:
- 获取字段:
Field field = clazz.getDeclaredField("myField"); field.setAccessible(true); // 如果是私有字段,需设为可访问 Object fieldValue = field.get(instance);
- 设置字段值:
field.set(instance,newValue);
- 调用方法:
- 获取方法:
Method method = clazz.getMethod("myMethod", String.class); Object result = method.invoke(instance, "methodArgument");
- 调用私有方法:
Method privateMethod = clazz.getDeclaredMethod("privateMethod"); privateMethod.setAccessible(true); Object privateResult = privateMethod.invoke(instance);
- 获取类信息
- 获取类的所有方法、字段或构造函数:
Method[] methods = clazz.getDeclaredMethods(); Field[] fields = clazz.getDeclaredFields(); Constructor<?>[] constructors = clazz.getDeclaredConstructors();
通过反射,开发者可以在运行时决定执行的操作,这对于动态加载类、实现插件框架、序列化/反序列化工具、单元测试、AOP编程等方面非常有用。然而,由于反射绕过了编译器的静态类型检查,可能导致错误更难察觉,同时反射操作比正常的方法调用慢,因此在性能敏感的应用中应当谨慎使用。
getName、getCanonicalName与getSimpleName的区别?
在Java中,getName()、getCanonicalName() 和 getSimpleName() 都是 java.lang.Class 类提供的方法,用于获取类或接口等相关类型的名称,但它们之间存在细微差别:
- getName()
返回的是完全限定名,即类的完整名称,包括包名以及内部类的嵌套结构,如果是数组,则采用特殊的JVM规范格式表示。
对于非数组类型,返回的结果类似于 package.name.OuterClass$InnerClass(如果是一个内部类的话)。
对于数组类型,如整型数组,返回结果形如 int[] 或者多维数组 [I、[[I 等。
- getCanonicalName()
返回的是规范化的全名,对于常规类同样包含包名,但对于顶级类(非内部类),它和 getName() 返回的结果一致。
对于非匿名内部类,它将提供内部类的全路径名,如 package.name.OuterClass.InnerClass。
如果类没有包名或者是一个匿名内部类,那么 getCanonicalName() 可能返回 null。
- getSimpleName()
返回的是类名本身的简单形式,不包含任何包名或者父类信息,也不包含任何数组维度信息。
对于普通的顶级类,返回的就是类名本身,如 MyClass。
对于内部类,返回的是内部类的名字,如 OuterClass.InnerClass。
对于数组类型,它返回的是元素类型的简单名称加上适当的方括号,如 String[]。
- 总结一下:
- 当你需要获取类的完整、绝对标识符时,无论是否是内部类或是数组类型,getName() 是最通用的选择。
- 如果你关心的是类名及其所在的包,但不关心内部类的具体嵌套结构,对于非匿名内部类和顶级类,getCanonicalName() 提供了较为友好的字符串形式。
- 当你只需要类名本身,不包括包名和其他修饰信息时,getSimpleName() 方法最为合适。
1.6 SPI机制?
SPI(Service Provider Interface),是JDK内置的一种 服务提供发现机制,可以用来启用框架扩展和替换组件,主要是被框架的开发人员使用,比如java.sql.Driver接口,其他不同厂商可以针对同一接口做出不同的实现,MySQL和PostgreSQL都有不同的实现提供给用户,而Java的SPI机制可以为某个接口寻找服务实现。Java中SPI机制主要思想是将装配的控制权移到程序之外,在模块化设计中这个机制尤其重要,其核心思想就是 解耦。
当服务的提供者提供了一种接口的实现之后,需要在classpath下的META-INF/services/目录里创建一个以服务接口命名的文件,这个文件里的内容就是这个接口的具体的实现类。当其他的程序需要这个服务的时候,就可以通过查找这个jar包(一般都是以jar包做依赖)的META-INF/services/中的配置文件,配置文件中有接口的具体实现类名,可以根据这个类名进行加载实例化,就可以使用该服务了。JDK中查找服务的实现的工具类是:java.util.ServiceLoader。