目录
1. 面向对象(封装,继承,多态)详解
1.1 面向过程和面向对象的区别
1.2面向对象的三大特性
1.2.1 封装
1.2.2 继承
1.2.3 多态
1.2.4 方法重写和方法重载的区别(面试题)
1.2.5 访问权限修饰符分类和限制的范围
1.2.6 静态(static)
1.2.6.1静态方法为什么不能调用非静态成员?(面试题)
1.2.7 final修饰符
1.2.8 abstract修饰符
1.3 抽象类和接口的区别(面试题)
1.4 数据类型和大小,自动类型转换
1.5 Java的六个包装类(Byte、Short、Integer、Long、Character、Boolean)
1.5.1 Java为什么要有包装类?(面试题来了)
1.6 字符串常量池
1.6.1 请问这行代码创建了几个对象?(面试题)
1.6.2 为什么要先在字符串常量池中创建对象,然后再在堆上创建呢?(面试题)
1.6.3 字符串常量池在内存中的什么位置呢?(面试题)
1.6.4 String.intern()方法的作用
1.6.5 StringBuilder和StringBuffer和String的区别(面试题)
1.6.6 equals()和 == 的区别(面试题)
1.7 Java hashCode方法解析
1.7.1 为什么重写 equals 时必须重写 hashCode ⽅法?(高频面试题)
1.8 深入理解Java浅拷贝与深拷贝
1.8.1 请说一下Java浅拷贝和深拷贝的区别(重点面试题)
1.9 Java是值传递还是引用传递(面试题)
2. 集合框架详解
2.1 整体架构
2.2 List,Set,Queue,Map四者的区别?
2.3 右移操作符以及ArrayList的数组扩容
2.4 讲一下HashMap(重点面试题)
2.4.1 为什么HashMap的初始容量是2的次幂
2.4.2 为什么取模之前要调用hash()方法呢?
2.4.3 HashMap的扩容机制
2.4.4 加载因子为什么是 0.75
2.4.5 JDK1.7中的 HashMap 使⽤头插法插⼊元素为什么会出现环形链表?(面试题)
2.4.6 HashMap 和 TreeMap 的区别?(面试题)
2.4.7 HashMap为什么线程不安全?(面试题)
2.4.8 ConcurrentHashMap(面试题)
3. 异常处理
3.1 java中的异常
3.2 try、catch、finally
4. 泛型(Java语法糖的其中之一)
4.1 类型擦除(泛型擦除)(面试题)
4.2 为什么要类型擦除(面试题)
5. 多线程基础(多线程后面的知识会再额外开辟一篇文章)
5.1 进程和线程的区别(面试题)
5.2 创建线程的四种方法(面试题)
5.3为什么要重写run()方法?(面试题)
5.4 run方法和start方法有什么区别?(面试题)
5.5 通过继承Thread的方法和实现Runnable接口的方式创建多线程,哪个好?(面试题)
5.6 线程生命周期
6. 反射详解
6.1反射如何使用
6.2 反射的应用场景?
6.3 Java代理模式
7. IO(重点是序列化和反序列化)
7.1序列化和反序列化(面试题)
1. 面向对象(封装,继承,多态)详解
1.1 面向过程和面向对象的区别
面向过程是一步一步执行的,是流程化的,而面向对象是模块化的,其实面向对象的底层也是面向过程,面向对象是面向过程进行了进一步的封装,成为了类,方便以后调用。
其中面向对象引入了新的概念:对象,类
对象:就是一个类的实例,赋有状态和行为,例如:一条狗(Bean的实例化)就是一个对象,它的状态(属性)有:颜色,名字,品种;行为(DAO的实例化):摇尾巴,叫,吃等。
类(Bean/实体/Controller/Service/ServiceImpl/DAO):类是一个模板,它描述一类对象的行为和状态
1.2面向对象的三大特性
1.2.1 封装
也就是包装的意思,好处就是隐藏了信息,利用抽象将数据和基于数据的操作封装在一个类里面,使其构成了一个独立实体且不可分割。使用封装的好处有4点:
- 减少代码的耦合(解耦)
- 类内部结构可以自由修改。
- 可以对成员进行精确控制。
- 隐藏信息。
对比一下有封装和无封装的两行代码
public class Husband {
public String name;
public String sex;
public int age;
public Wife wife;
}//无封装
我们每次进行赋值都要写这以下四条语句,想一想如果以后有成千个类需要这个实体类(Entry),那每一个类都要这么去使用它,代码的长度瞬间增大四倍,而且当这里面的成员变量的类型变化之后,每一个使用这个实体类的类都要去一个一个修改这个实体类的成员变量的类型。
Husband husband = new Husband();
husband.age = 30;
husband.name = "张三";
husband.sex = "男";
那如果有封装的情况下就使得修改一处即可和代码更加简洁。
public class Husband {
/*
* 对属性的封装
* 一个人的姓名、性别、年龄、妻子都是这个人的私有属性
*/
private String name ;
private String sex ;
private String age ; /* 改成 String类型的*/
private Wife wife;
public String getAge() {
return age;
}
public void setAge(int age) {
//转换即可
this.age = String.valueOf(age);
}
/** 省略其他属性的setter、getter **/
}//有封装
1.2.2 继承
他使复用以前的代码更加方便的同时也可以支持扩展以前的代码。
面试的过程中经常会问:为什么需要继承?(这一点就是让你回答继承的好处)
继承有以下好处:
- 使得代码结构更加清晰的同时也减少了代码量。
- 使得业务功能拓展更加方便,不用重复新建类。
- 解耦。
在Java语言中继承就是子类继承父类的属性和方法,使得子类对象具有父类的属性和方法,或子类从父类继承方法,使得子类具有父类相同的方法,子类对象也可以继续拓展父类没有的方法。
在 Java 语言中实现 Cat 和 Dog 等类的时候,就需要继承 Animal 这个类。继承之后 Cat、Dog 等具体动物类就是子类,Animal 类就是父类。
Java采用的是单继承。(一个子类只拥有一个父类)
优点是:在类层次结构式比较清晰
缺点:结构的丰富度有时不能满足使用需求
继承有以下要求:
子类继承父类后,就拥有父类的非私有的属性和方法。
父类的构造方法不能被继承,因为构造方法语法是与类同名,而继承则不更改方法名,如果子类继承父类的构造方法,那明显与构造方法的语法冲突。但是子类可以通过super()来调用父类的构造方法。Java虚拟机构造子类对象前会先构造父类的对象,父类对象构造完成之后再来构造子类特有的属性,这被称为内存叠加。如果子类的构造方法中没有显示地调用父类构造方法,则系统默认调用父类无参数的构造方法。
1.2.3 多态
Java的多态指在面向对象编程中,同一个类的对象在不同情况下表现出不同的行为和状态,多态的前提条件有三个:
- 子类继承父类/实现(接口)
- 子类覆盖父类的方法
- 父类引用指向子类的对象
多态的特点:
- 对象类型和引⽤类型之间具有继承(类)/实现(接⼝)的关系;
- 引⽤类型变量发出的⽅法调⽤的到底是哪个类中的⽅法,必须在程序运⾏期间才能确定;
- 多态不能调⽤“只在⼦类存在但在⽗类不存在”的⽅法;
- 如果⼦类重写了⽗类的⽅法,真正执⾏的是⼦类覆盖的⽅法,如果⼦类没有覆盖⽗类的⽅ 法,执⾏的是⽗类的⽅法
看这段代码,说明为什么多态在调用子类构造方法的时候会先去执行父类的构造方法,但是却又执行了子类的成员方法。
public class Wangxiaosan extends Wangsan {
private int age = 3;
public Wangxiaosan(int age) {
this.age = age;
System.out.println("王小三的年龄:" + this.age);
}
public void write() { // 子类覆盖父类方法
System.out.println("我小三上幼儿园的年龄是:" + this.age);
}
public static void main(String[] args) {
new Wangxiaosan(4);
// 执行结果:上幼儿园之前
// 执行结果:我小三上幼儿园的年龄是:0
// 执行结果:上幼儿园之后
// 执行结果:王小三的年龄:4
}
}
class Wangsan {
Wangsan () {
System.out.println("上幼儿园之前");
write();
System.out.println("上幼儿园之后");
}
public void write() {
System.out.println("老子上幼儿园的年龄是3岁半");
}
}
是因为在创建子类对象时,会先去调用父类的构造方法,而父类构造方法中又调用了被子类覆盖的多态方法,由于父类并不清楚子类对象中的属性值是什么,于是把int类型的属性暂时初始化为 0,然后再调用子类的构造方法(子类构造方法知道王小二的年龄是 4)。
1.2.4 方法重写和方法重载的区别(面试题)
方法重写:子类中出现和父类中一模一样的方法(包括返回值类型,方法名,参数列表),它建立在继承的基础上。
class E1{
public void doA(int a){
System.out.println("这是父类的方法");
}
}
class E2 extends E1{
@Override
public void doA(int a) {
System.out.println("我重写父类方法,这是子类的方法");
}
}
其中@Override注解显示声明该方法为注解方法,可以帮你检查重写方法的语法正确性。(如果不加也可以,默认加上)
方法重载:如果有两个方法的方法名相同,但参数不一致,那么可以说一个方法是另一个方法的重载。
重载可以通常理解为方法名相同但参数列表不同,其他条件(异常处理,访问修饰符)等。
区别点 | 重载方法 | 重写方法 |
发生范围 | 同一个类 | 子类 |
参数列表 | 必须不一致 | 必须保持一致 |
返回类型 | 可以不一致 | 子类返回值类型应该比父类方法返回值范围更小或相等(引用类型) |
异常 | 范围可以变 | 子类的异常要比父类的异常更小或相等 |
访问修饰符 | 可以不一致 | 子类可以降低限制程度,但是一定不能做更严格的限制 |
发生阶段 | 编译期 | 运行期 |
注:重写方法的返回值类型如果是引用类型的话重写是子类可以返回该引用类型的子类,但是如果是基本数据类型或者void的话,返回值重写是一定不能修改。
1.2.5 访问权限修饰符分类和限制的范围
可见性 | private | default | protected | public |
同一个类 | √ | √ | √ | √ |
同一个包 | × | √ | √ | √ |
子类中 | × | × | √ | √ |
全局范围 | × | × | × | √ |
1.2.6 静态(static)
static修饰符,能够与变量,方法和类一起使用,称为静态变量,静态方法。如果在一个类中使用static修饰变量或者方法的话,它们可以直接通过类访问,不需要创建一个类的对象来访问成员变量和成员方法。
关于静态的底层实现:被静态变量只想的对象存储在Java的堆内存中。静态变量存储在方法区(Method Area)中。
1.2.6.1静态方法为什么不能调用非静态成员?(面试题)
是因为静态方法是属于类的,在类加载的时候就会分配内存,可以通过类名直接访问。而非静态成员属于实例对象,只有在对象实例化之后才存在,需要通过类的实例对象去访问。
在类的非静态成员不存在的时候静态方法就已经存在了,此时调用在内存中还不存在的非静态成员,属于非法操作。
加载顺序:父类静态变量->静态代码块->静态方法->子类的静态变量->静态代码块->静态方法->父类的构造代码块->父类的构造方法->子类的构造代码块->子类的构造方法。
1.2.7 final修饰符
final变量:变量一旦被赋值后,不能被重新赋值,被final修饰的实例变量必须显示指定初始值。final修饰符通常和static修饰符一起使用来创建类常量。
final方法:父类中的final可以被子类继承,但是不能被子类重写。
final类:final类不能被继承,没有类能够继承final类的任何特性。
1.2.8 abstract修饰符
abstract修饰类和方法,成为抽象类和抽象方法。
抽象方法:有很多不同类的方法是相似的,但是具体内容又不太一样,所以只能抽取他的声明(和接口的方法声明类似),但是没有具体的方法实现。即抽象方法可以表达概念,但是无法具体实现(和接口有些许类似)。
抽象类:有抽象方法的类必须是抽象类,抽象类可以表达概念但是无法构造实体的类。
比如声明一个People抽象类以及声明一个抽象方法:
abstract class People{
public abstract void sayHello();//抽象方法
}
class Chinese extends People{
@Override
public void sayHello() {//实现抽象方法
System.out.println("你好");
}
}
class Japanese extends People{
@Override
public void sayHello() {//实现抽象方法
System.out.println("口你七哇");
}
}
class American extends People{
@Override
public void sayHello() {//实现抽象方法
System.out.println("hello");
}
}
1.3 抽象类和接口的区别(面试题)
上面说到了抽象类的方法声明和接口的方法声明比较类似,那么面试的朋友们很有可能会被问到抽象类和接口的区别。
先说一下抽象类的特征,抽象类是不能被实例化的,如果使用new来实例化抽象类,编译器会错。
虽然抽象类不能实例化,但可以有子类。子类通过 extends
关键字来继承抽象类。
如果一个类定义了一个或多个抽象方法,那么这个类必须是抽象类。
抽象类中既可以定义抽象方法,也可以定义普通方法。
public abstract class AbstractPlayer {
abstract void play();
public void sleep() {
System.out.println("运动员也要休息而不是挑战极限");
}
}
但是子类继承了该抽象类之后,就必须重写父类抽象类的抽象方法。如果没有实现的话,编译器会提示“子类必须实现抽象方法”
抽象类的运用场景我觉得就是多个子类继承了该抽象类之后,在实现它的抽象方法的时候会显得代码更加灵活和定制化,也就是说虽然不同子类都拥有同一个抽象方法,但是每个子类实现的抽象方法又不同,体现面向对象语言的封装性的同时,也解耦了代码。
1.4 数据类型和大小,自动类型转换
>>>:无符号右移,忽略符号位,空位都以0补齐。
一字节 8位,boolean是1位 byte是 1字节,char和short是2字节,int和float是4字节,long和double是8字节。
boolean | byte | char | short | int | float | long | double |
1位 | 1字节 | 2字节 | 2字节 | 4字节 | 4字节 | 8字节 | 8字节 |
注意自动类型转换:大转小不行(double->int 达咩!),小转大可以(int->long 呦西!)
自动类型转换图
1.5 Java的六个包装类(Byte、Short、Integer、Long、Character、Boolean)
先看下面的代码判断是否正确
Integer x = new Integer(18);
Integer y = new Integer(18);
System.out.println(x == y); //false
Integer z = Integer.valueOf(18);
Integer k = Integer.valueOf(18);
System.out.println(z == k); //true
Integer m = Integer.valueOf(300);
Integer p = Integer.valueOf(300);
System.out.println(m == p); //false
其中第一个是false是因为new出来了不同的对象,地址值不相同;第二个true是因为Integer有自动拆箱会转化为int型,并返回常量池中的数据的引用,而没有创建对象;第三个false是因为Integer自动拆箱是有范围的(-128~127),如果 大于这个范围就无法自动拆箱,就会new出来新的对象。
再来三道题大家可以自行判断一下:
Integer i1 = 33;
Integer i2 = 33;
System.out.println(i1 == i2);// 输出 true
Float i11 = 333f;
Float i22 = 333f;
System.out.println(i11 == i22);// 输出 false
Double i3 = 1.2;
Double i4 = 1.2;
System.out.println(i3 == i4);// 输出 false
Integer a=128;
Integer b=128;
System.out.println("a==b : " + (a == b)); //false,不会自动拆箱,比较
的是引用,不是同一个地址引用
也就是说六大包装类的范围如下表所示
Byte | -128~127 |
Short | -128~127 |
Integer | -128~127 |
Long | -128~127 |
Character | \u0000 - \u007F |
Boolean | true 和 false |
下面来看一下valueOf()的源代码可见底层引用了IntegerCache缓存池的一个静态类
public static Integer valueOf(int i) {
if (i >=IntegerCache.low && i <=IntegerCache.high)
return IntegerCache.cache[i + (-IntegerCache.low)];
return new Integer(i);
}
那让我们在看一下IntegerCache底层是如何实现的:
private static class IntegerCache {
static final int low = -128;
static final int high;
static final Integer cache[];
static {
// high value may be configured by property
int h = 127;
String integerCacheHighPropValue =
sun.misc.VM.getSavedProperty("java.lang.Integer.IntegerCache.high");
if (integerCacheHighPropValue != null) {
try {
int i = parseInt(integerCacheHighPropValue);
i = Math.max(i, 127);
// Maximum array size is Integer.MAX_VALUE
h = Math.min(i, Integer.MAX_VALUE - (-low) -1);
} catch( NumberFormatException nfe) {
// If the property cannot be parsed into an int, ignore it.
}
}
high = h;
cache = new Integer[(high - low) + 1];
int j = low;
for(int k = 0; k < cache.length; k++)
cache[k] = new Integer(j++);
// range [-128, 127] must be interned (JLS7 5.1.7)
assert Integer.IntegerCache.high >= 127;
}
private IntegerCache() {}
}
详细解释下:当我们通过 Integer.valueOf()
方法获取整数对象时,会先检查该整数是否在 IntegerCache 中,如果在,则返回缓存中的对象,否则创建一个新的对象并缓存起来。
需要注意的是,如果使用 new Integer()
创建对象,即使值在 -128 到 127 范围内,也不会被缓存,每次都会创建新的对象。因此,推荐使用 Integer.valueOf()
方法获取整数对象。
在静态代码块中,low 为 -128,也就是缓存池的最小值;high 默认为 127,也就是缓存池的最大值,共计 256 个。
可以在 JVM 启动的时候,通过 -XX:AutoBoxCacheMax=NNN
来设置缓存池的大小,当然了,不能无限大,最大到 Integer.MAX_VALUE -129
之后,初始化 cache 数组的大小,然后遍历填充,下标从 0 开始。
另提一嘴 assert 是 Java 中的一个关键字,寓意是断言,为了方便调试程序,并不是发布程序的组成部分。默认情况下,断言是关闭的,可以在命令行运行 Java 程序的时候加上 -ea
参数打开断言。
包装类和基本类型可以互相转换,转换的过程称之为装箱拆箱,可以⼿动转换,也可⾃动转换。 包装类⽐较⼤⼩的时候有很多坑,⽐如: ==⽐较引⽤,Integer 类型只有在-128 到 127 的范 围内,才会持有同⼀个引⽤。 equals ⽅法会先⽐较类型是否⼀致,不⼀致直接 false。 最佳的操作实践是,⽐较⼤⼩的时候,统⼀先⼿动拆箱,然后再⽐较值。
记住: 所有整型包装类对象之间值的⽐较,全部使⽤ equals ⽅法⽐较。
1.5.1 Java为什么要有包装类?(面试题来了)
提供对象化:基本数据类型是⾮对象类型,⽆法直接参与⾯向对象的操作。包装类通过将基本 数据类型封装成对象,使得可以将其作为对象使⽤,并调⽤对象的⽅法。
提供集合的⽀持:Java的集合框架(如List、Set、Map等)只能存储对象,⽆法直接存储基本 数据类型。包装类提供了与基本数据类型对应的对象形式,使得可以将基本数据类型作为对象 存储在集合中。Java 是号称⾯向对象的语⾔,所有的类型都是引⽤类型。 Object 类是所有类 的⽗类,⽽且是唯⼀不⽤指定明确继承的类。但是基本类型如 int 不是引⽤类型,也不是继承 ⾃ Object,所以 Java 需要⼀个这样的包装类来使其⾯向对象的完整性。 包装类同时也可以实 现可空类型,即⼀个数值是空的。Java 集合中也只能放⼊包装类型,⽽不⽀持基本类型。
提供与基本数据类型相关的⼯具⽅法:包装类提供了⼀些与基本数据类型相关的⽅法,如数值 ⽐较、数值转换、数值运算等,这些⽅法在处理基本数据类型时⾮常有⽤。
⽀持泛型:泛型是Java中的⼀种强类型机制,要求集合中的元素必须是对象类型。在需要使⽤ 基本数据类型的泛型集合时,可以通过包装类来实现。
1.6 字符串常量池
面试题来了
String s = new String("你好");
1.6.1 请问这行代码创建了几个对象?(面试题)
答:根据场景不同会创建一个或着两个,使用new关键词创建字符串对象是,Java虚拟机会先在字符串常量池中查找有没有“你好”这个字符串对象,如果有,就不会在字符串常量池中创建“你好”这个对象,直接在堆中new一个字符串对象,然后将堆中的对象地址返回赋值给变量s。(只创建了一个)
如果没有,那就现在字符串常量池中创建一个“你好”字符串对象(创建一个对象了),然后再在堆中创建一个“你好”的字符串对象,然后将堆中的“你好”字符串对象地址返回赋值给s。(创建了两个对象)。
这里拷贝了其他文章的图片来说明创建了多少个对象:
1.6.2 为什么要先在字符串常量池中创建对象,然后再在堆上创建呢?(面试题)
由于字符串的使用频率太高,所以 Java 虚拟机为了提高性能和减少内存开销,在创建字符串对象的时候进行了一些优化,特意为字符串开辟了一块空间——也就是字符串常量池。
字符串常量池的作用or好处?
节省堆空间内存提升引用效率。
1.6.3 字符串常量池在内存中的什么位置呢?(面试题)
在JDK7之前,字符串常量位于永久代(Permanent Generation)的内存区域,主要用于存储一些字符串常量。永久代是Java堆的一部分,用于存储类信息,方法信息,常量池信息等静态数据。
永久代是Java堆的子区域,永久代中存储的静态数据与堆中存储的对象实例和数组是分开的,它们有不同的生命周期和分配方式。
而且永久代合堆的大小是相互影响的,因为他们都使用了JVM的堆内存,因此它们的大小都受JVM堆大小的限制。
当我们创建一个字符串常量时,它会被存储在永久代的字符串常量池中。如果我们创建一个普通的字符串对象,则它将被储存在堆中。如果字符串对象的内容已经在字符串常量池中,那么这个对象会指向已经存在的字符串常量,而不是重新创建一个新的字符串对象。
在JDK7时
永久代的大小是有限的,并且很难准确地确定一个应用程序需要多少永久代空间。如果我们大量使用类,方法,常量等静态数据,就可能导致永久代空间不足。这种情况下,JVM就会抛出OutOfMemoryError错误。
因此在JDK7开始,为了解决永久代空间不足的问题,将字符串常量池从永久代中移动到堆中。这个改变也是为了更好地支持动态语言的运行时特性。
在JDK8时,永久代被取消,并由元空间(Metaspace)取代。元空间是一块本机内存区域,和JVM内存区域是分开的。不过,元空间的作用依然和之前的永久代一样,用于存储类信息,方法信息,常量池信息等静态数据。
与永久代不同的是,元空间具有以下优点:
- 它不会导致 OutOfMemoryError 错误,因为元空间的大小可以动态调整。
- 元空间使用本机内存,而不是 JVM 堆内存,这可以避免堆内存的碎片化问题。
- 元空间中的垃圾收集与堆中的垃圾收集是分离的,这可以避免应用程序在运行过程中因为进行类加载和卸载而频繁地触发 Full GC。
再看一下三个版本的对比图
1.6.4 String.intern()方法的作用
首先回顾前面的字符串创建对象时JVM的内存发生的变化:
第一,使用双引号声明的字符串对象会保存在字符串常量池中。
第二,使用 new 关键字创建的字符串对象会先从字符串常量池中找,如果没找到就创建一个,然后再在堆中创建字符串对象;如果找到了,就直接在堆中创建字符串对象。
第三,针对没有使用双引号生命的字符串对象来说,就像下面代码的s1一样:
String s1 = new String("Hello") + new String("World");
如果也想把s1放入字符串常量池中,就可以调用intern()方法来实现。
不过,需要注意的是,Java 7 的时候,字符串常量池从永久代中移动到了堆中,虽然此时永久代还没有完全被移除。Java 8 的时候,永久代被彻底移除。
这个变化也直接影响了 String.intern()
方法在执行时的策略,Java 7 之前,执行 String.intern()
方法的时候,不管对象在堆中是否已经创建,字符串常量池中仍然会创建一个内容完全相同的新对象; Java 7 之后呢,由于字符串常量池放在了堆中,执行 String.intern()
方法的时候,如果对象在堆中已经创建了,字符串常量池中就不需要再创建新的对象了,而是直接保存堆中对象的引用,也就节省了一部分的内存空间。
举个例子:
String s1 = new String("HelloWorld");
String s2 = s1.intern();
System.out.println(s1 == s2);
//false
输出是false,因为第一个s1是new到了堆内存中,并且将“HelloWorld”顺便放入了常量池中,此时s2是由s1.intern()得来的,也就是说s2得来的字符是从常量池引用的对象,和s1的地址不一样,因此输出是false。
再来看一个例子:
String s1 = new String("Hello") + new String("World");
String s2 = s1.intern();
System.out.println(s1 == s2);
//true
输出是true,因为第一行代码会在堆中创建两个对象,一个是“Hello”,一个是“world”,然后堆中还会创建一个“HelloWorld”对象,此时s1引用的是堆中的“HelloWorld”对象。
第二行代码对s1进行了intern(),然后就开始从字符串常量池中查找“HelloWorld”对象,由于常量池中没有“HelloWorld”对象,但是堆中却有这个对象,所以字符串常量池保存的是堆中“HelloWorld”对象的地址,所以不难看出,s1和s2的引用地址相同,因此输出true。
那为什么会创建三个对象(“Hello”对象,“World”对象,“HelloWorld”对象)呢 ,是因为
- 创建 "Hello" 字符串对象,存储在字符串常量池中。
- 创建 "World" 字符串对象,存储在字符串常量池中。
- 执行
new String("Hello")
,在堆上创建一个字符串对象,内容为 "Hello
"。 - 执行
new String("World")
,在堆上创建一个字符串对象,内容为 "World
"。 - 执行
new String("Hello") + new String("World")
,会创建一个 StringBuilder 对象,并将 "Hello
" 和 "World
" 追加到其中,然后调用 StringBuilder 对象的 toString() 方法,将其转换为一个新的字符串对象,内容为 "HelloWorld"。这个新的字符串对象存储在堆上。
也就是说,当编译器遇到 +
号这个操作符的时候,会将 new String("Hello") + new String("World")
这行代码编译为以下代码:
new StringBuilder().append("Hello").append("World").toString();
实际执行过程如下:
- 创建一个 StringBuilder 对象。
- 在 StringBuilder 对象上调用 append("Hello"),将 "Hello" 追加到 StringBuilder 中。
- 在 StringBuilder 对象上调用 append("World"),将 "World" 追加到 StringBuilder 中。
- 在 StringBuilder 对象上调用 toString() 方法,将 StringBuilder 转换为一个新的字符串对象,内容为 "HelloWorld"。
1.6.5 StringBuilder和StringBuffer和String的区别(面试题)
String:String 的值被创建后不能修改,任何对 String 的修改都会引发新的 String 对象 的⽣成。存在于字符串常量池,这玩意是在java的⽅法区中的
StringBuffer:跟 String 类似,但是值可以被修改,使⽤ synchronized 来保证线程安全。
StringBuilder:StringBuffer 的⾮线程安全版本,性能上更⾼⼀些。 在Java8 时JDK 对“+”号拼接进⾏了优化,上⾯所写的拼接⽅式会被优化为基于 StringBuilder 的 append ⽅法进⾏处理。Java 会在编译期对“+”号进⾏处理。
在循环使用"+"拼接的情况下,编译器不会只创建一个StringBuilder对象,而会创建多个StringBuilder对象,这样会造成一个内存占用的缺陷,因此对于String str3=“Hello”+“World”,编译器会自动优化为String str3="HelloWorld";
1.6.6 equals()和 == 的区别(面试题)
- “==”操作符用于比较两个对象的地址是否相等。
.equals()
方法用于比较两个对象的内容是否相等。
注意,equals这个方法前提是比较的是两个对象,而不是基本数据类型,比如String的字符串的比较,和Integer等一些包装类的比较如果比较两者的值是否相等可以使用equals(),==如果是比较基本数据类型如int,char型可以直接使用,比较的是两者的值是否相等。
我们来看一下JDK8环境下的equals()底层源码
public boolean equals(Object anObject) {
// 判断是否为同一对象
if (this == anObject) {
return true;
}
// 判断对象是否为 String 类型
if (anObject instanceof String) {
String anotherString = (String)anObject;
int n = value.length;
// 判断字符串长度是否相等
if (n == anotherString.value.length) {
char v1[] = value;
char v2[] = anotherString.value;
int i = 0;
// 判断每个字符是否相等
while (n-- != 0) {
if (v1[i] != v2[i])
return false;
i++;
}
return true;
}
}
return false;
}
JDK 8 比 JDK 17 更容易懂一些:首先判断两个对象是否为同一个对象,如果是,则返回 true。接着,判断对象是否为 String 类型,如果不是,则返回 false。如果对象为 String 类型,则比较两个字符串的长度是否相等,如果长度不相等,则返回 false。如果长度相等,则逐个比较每个字符是否相等,如果都相等,则返回 true,否则返回 false。
那请看下面的几道题:
new String("你好").equals("你好") //true,因为比较的值相等
new String("你好") == "你好" //false,因为前者的地址在堆里,后者的地址在常量池里,地址不相同
new String("你好") == new String("你好") //false,因为new了两个不同的对象在堆里,地址值不同
"你好" == "你好" //true,两个字符串都在常量池里,地址相同
"你好" == "你" + "好" //true,两个字符串都在常量池了,而且跟去上面说的编译器会自动优化后面的代码为“你好”
new String("你好").intern() == "你好" //true,前者在堆中创建对象后通过intern()方法将该对象新建了一份在常量池中和后者的字符串对象相比都在常量池中,因此地址相同
1.7 Java hashCode方法解析
首先每一个类都会有一个hashCode()方法,这是因为每一个类都继承Object类,而Object类中包含了hashCode()方法:
public native int hashCode(); //native修饰符代表该方法是本地方法
顺带一提,JDK9之后,hashCode方法被 @HotSpotIntrinsicCandidate 注解修饰,基于CPU指令。
在Java中,hashCode() 方法主要作用就是为了配合哈希表使用的。
哈希表也叫散列表,是一种数据结构,通过键值对(key-value)来直接访问,它最大的好处就是可以快速实现查找,插入和删除。其中用到的算法叫哈希,就是把任意长度的输入,变成固定长度的输出,该输出就是哈希值。
像 Java 中的 HashSet、Hashtable、HashMap 都是基于哈希表的具体实现。其中的 HashMap 就是最典型的代表。
对HashMap来说,当我们要在它里面添加对象时,先调用了这个对象的hashCode()方法,得到对应的哈希值,然后将哈希值和对象一起放到HashMap中。当我们要再添加一个新的对象是:
- 获取对象的哈希值;
- 和之前已经存在的哈希值进行比较,如果不相等,直接存进去;
- 如果有相等的,再调用
equals()
方法进行对象之间的比较,如果相等,不存了; - 如果不等,说明哈希冲突了,增加一个链表,存放新的对象;
- 如果链表的长度大于 8,转为红黑树来处理。
这一套新的添加思想要比调用equals()方法去判断是否不相同的效率大大提升,也就是说只要哈希算法足够高效,把哈希冲突的频率降到最低,哈希表的效率就会更高。
来看一下HashMap的哈希算法:
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
先调用对象的 hashCode() 方法,然后对该值进行右移运算,然后再进行异或运算。
通常来说,String 会用来作为 HashMap 的键进行哈希运算,因此我们再来看一下 String 的 hashCode()
方法:
public int hashCode() {
int h = hash;
if (h == 0 && value.length > 0) {
char val[] = value;
for (int i = 0; i < value.length; i++) {
h = 31 * h + val[i];
}
hash = h;
}
return h;
}
对于两个不同对象,它们通过 hashCode()
方法计算后的值可能相同。因此,不能使用 hashCode()
方法来判断两个对象是否相等,必须得通过 equals()
方法。
也就是说:
- 如果两个对象调用
equals()
方法得到的结果为 true,调用hashCode()
方法得到的结果必定相等; - 如果两个对象调用
hashCode()
方法得到的结果不相等,调用equals()
方法得到的结果必定为 false;
反之:
- 如果两个对象调用
equals()
方法得到的结果为 false,调用hashCode()
方法得到的结果不一定不相等;
如果两个对象调用 hashCode()
方法得到的结果相等,调用 equals()
方法得到的结果不一定为 true;
public class Test {
public static void main(String[] args) {
Student s1 = new Student(18, "豆粉");
Map<Student, Integer> scores = new HashMap<>();
scores.put(s1, 98);
System.out.println(scores.get(new Student(18, "豆粉"))); //null
}
}
class Student {
private int age;
private String name;
public Student(int age, String name) {
this.age = age;
this.name = name;
}
@Override
public boolean equals(Object o) {
Student student = (Student) o;
return age == student.age &&
Objects.equals(name, student.name);
}
}
这段代码的输出结果是null,究其原因就是在于没有重写hashCode方法。默认情况下,hashCode方法是一个本地方法,会返回对象的存储地址,显然put()中的s1和get()中的new Student()不是一个对象,它们存储的地址是不同的。
HashMap 的 get()
方法会调用 hash(key.hashCode())
计算对象的哈希值,虽然两个不同的 hashCode()
结果经过 hash()
方法计算后有可能得到相同的结果,但这种概率微乎其微,所以就导致 scores.get(new Student(18, "张三"))
无法得到预期的值 18,因此就打印不出来该学生的信息。
如果想要解决这个问题就要重写hashCode()方法
@Override
public int hashCode() {
return Objects.hash(age, name);
}
Objects类的hash()方法可以针对不同数量的参数生成新的hashCode()的值。
public static int hashCode(Object a[]) {
if (a == null)
return 0;
int result = 1;
for (Object element : a)
result = 31 * result + (element == null ? 0 : element.hashCode());
return result;
}
注意:31 是个奇质数,不大不小,一般质数都非常适合哈希计算,偶数相当于移位运算,容易溢出,造成数据信息丢失。
这就意味着年纪和姓名相同的情况下,会得到相同的哈希值。scores.get(new Student(18, "豆粉"))
就会返回 98 的预期值了。
设计 hashCode()
时最重要的因素就是:无论何时,对同一个对象调用 hashCode()
都应该生成同样的值。如果在将一个对象用 put()
方法添加进 HashMap 时产生一个 hashCode()
值,而用 get()
方法取出时却产生了另外一个 hashCode()
值,那么就无法重新取得该对象了。所以,如果你的 hashCode()
方法依赖于对象中易变的数据,用户就要当心了,因为此数据发生变化时,hashCode()
就会生成一个不同的哈希值,相当于产生了一个不同的键。
也就是说,如果在重写 hashCode()
和 equals()
方法时,对象中某个字段容易发生改变,那么最好舍弃这些字段,以免产生不可预期的结果。
这就是引入了下面的一道面试题:
1.7.1 为什么重写 equals 时必须重写 hashCode ⽅法?(高频面试题)
因为Hash⽐equals⽅法的开销要⼩,速度更快,所以在涉及到hashcode的容器中(⽐如 HashSet),判断⾃⼰是否持有该对象时,会先检查hashCode是否相等,如果hashCode不相 等,就会直接认为不相等,并存⼊容器中,不会再调⽤equals进⾏⽐较。
这样就会导致,即使该对象已经存在HashSet中,但是因为hashCode不同,还会再次被存⼊。
因此要重写hashCode保证:如果equals判断是相等的,那hashCode值也要相等。
总结⼀下就是有些⽐如hashmap hashset这种, 他们内部判断元素是否相等并不仅仅是依赖于 equals⽅法, 即使重写了equals, 内容相同的对象也有可能被判断为是不⼀样的对象, 因此还需 要重写hashCode
1.8 深入理解Java浅拷贝与深拷贝
不管是浅拷贝还是深拷贝,都可以通过调用 Object 类的 clone()
方法来完成
protected native Object clone() throws CloneNotSupportedException;
可以看到,clone()是一个本地方法,它的具体实现会交给HotSpot(Java的虚拟机),那就意味着虚拟机在调用该方法时,会将其替换成效率更高的C/C++代码,进而调用操作系统去完成对象的克隆。
Java 9 后,该方法会被标注
@HotSpotIntrinsicCandidate
注解,被该注解标注的方法,在 HotSpot 虚拟机中会有一套高效的实现。
先说一下浅拷贝是如何实现的
class Writer implements Cloneable{
private int age;
private String name;
// getter/setter 和构造方法都已省略
@Override
public String toString() {
return super.toString().substring(26) + "{" +
"age=" + age +
", name='" + name + '\'' +
'}';
}
}
Writer类实现了Cloneable接口,这个接口是一个标记接口,里面没有声明任何方法。
public interface Cloneable {
}
但是如果一个类没有实现Cloneable接口,是无法使用clone()进行对象克隆的,程序会在执行clone()方法的时候抛出 CloneNotSupportedException 异常。
标记接口的作用其实很简单,用来表示某个功能在执行的时候是合法的。
下面来看一下如何实现浅拷贝:
class TestClone {
public static void main(String[] args) throws CloneNotSupportedException {
Writer writer1 = new Writer(18,"豆粉");
Writer writer2 = (Writer) writer1.clone();
System.out.println("浅拷贝后:");
System.out.println("writer1:" + writer1);
System.out.println("writer2:" + writer2);
writer2.setName("蚊朔");
System.out.println("调整了 writer2 的 name 后:");
System.out.println("writer1:" + writer1);
System.out.println("writer2:" + writer2);
}
}
/*浅拷贝后:
writer1:Writer@68837a77{age=18, name='豆粉'}
writer2:Writer@b97c004{age=18, name='豆粉'}
调整了 writer2 的 name 后:
writer1:Writer@68837a77{age=18, name='豆粉'}
writer2:Writer@b97c004{age=18, name='蚊朔'}
*/
可以看得出,浅拷贝后,writer1 和 writer2 引用了不同的对象,但值是相同的,说明拷贝成功。之后,修改了 writer2 的 name 字段。
再看一个有引用类型的例子(上一个类全是基本类型)
class Book {
private String bookName;
private int price;
// getter/setter 和构造方法都已省略
@Override
public String toString() {
return super.toString().substring(26) +
" bookName='" + bookName + '\'' +
", price=" + price +
'}';
}
}
然后我们进行Write类的定义:
class Writer implements Cloneable{
private int age;
private String name;
private Book book;
// getter/setter 和构造方法都已省略
@Override
public String toString() {
return super.toString().substring(26) +
" age=" + age +
", name='" + name + '\'' +
", book=" + book +
'}';
}
@Override
protected Object clone() throws CloneNotSupportedException {
return super.clone();
}
}
比之前的例子多了一个自定义类型的字段 book,clone()
方法并没有任何改变。
那直接进行浅拷贝:
class TestClone {
public static void main(String[] args) throws CloneNotSupportedException {
Writer writer1 = new Writer(18,"豆粉");
Book book1 = new Book("大话数据结构",100);
writer1.setBook(book1);
Writer writer2 = (Writer) writer1.clone();
System.out.println("浅拷贝后:");
System.out.println("writer1:" + writer1);
System.out.println("writer2:" + writer2);
Book book2 = writer2.getBook();
book2.setBookName("王阳明心理学");
book2.setPrice(70);
System.out.println("writer2.book 变更后:");
System.out.println("writer1:" + writer1);
System.out.println("writer2:" + writer2);
}
}
/*
浅拷贝后:
writer1:Writer@68837a77 age=18, name='豆粉', book=Book@32e6e9c3 bookName='大话数据结构', price=100}}
writer2:Writer@6d00a15d age=18, name='豆粉', book=Book@32e6e9c3 bookName='大话数据结构', price=100}}
writer2.book 变更后:
writer1:Writer@68837a77 age=18, name='豆粉', book=Book@32e6e9c3 bookName='王阳明心理学', price=70}}
writer2:Writer@36d4b5c age=18, name='豆粉', book=Book@32e6e9c3 bookName='王阳明心理学', price=70}}
*/
与之前例子不同的是,writer2.book 变更后,writer1.book 也发生了改变。这是因为字符串 String 是不可变对象,一个新的值必须在字符串常量池中开辟一段新的内存空间,而自定义对象的内存地址并没有发生改变,只是对应的字段值发生了改变,堆中的Book1和Book2的地址是相同的。
浅拷贝克隆的对象中,引用类型的字段指向的是同一个,当改变任何一个对象,另外一个对象也会随之改变,除去字符串的特殊性外
再来看一下深拷贝是如何实现的:
class Book implements Cloneable{
private String bookName;
private int price;
// getter/setter 和构造方法都已省略
@Override
public String toString() {
return super.toString().substring(26) +
" bookName='" + bookName + '\'' +
", price=" + price +
'}';
}
@Override
protected Object clone() throws CloneNotSupportedException {
return super.clone();
}
}
与浅拷贝不同的是,此时的Book类重写了clone()方法,并实现了Cloneable接口。为的就是深拷贝的时候也能克隆该字段。
class Writer implements Cloneable{
private int age;
private String name;
private Book book;
// getter/setter 和构造方法都已省略
@Override
public String toString() {
return super.toString().substring(26) +
" age=" + age +
", name='" + name + '\'' +
", book=" + book +
'}';
}
@Override
protected Object clone() throws CloneNotSupportedException {
Writer writer = (Writer) super.clone();
writer.setBook((Book) writer.getBook().clone());
return writer;
}
}
注意,此时 Writer 类也与之前的不同,clone()方法当中,不再只调用 Object 的clone() 方法对 Writer 进行克隆了,还对 Book 也进行了克隆。
来看深拷贝如何实现:
class TestClone {
public static void main(String[] args) throws CloneNotSupportedException {
Writer writer1 = new Writer(18,"豆粉");
Book book1 = new Book("编译原理",100);
writer1.setBook(book1);
Writer writer2 = (Writer) writer1.clone();
System.out.println("深拷贝后:");
System.out.println("writer1:" + writer1);
System.out.println("writer2:" + writer2);
Book book2 = writer2.getBook();
book2.setBookName("永恒的图灵");
book2.setPrice(70);
System.out.println("writer2.book 变更后:");
System.out.println("writer1:" + writer1);
System.out.println("writer2:" + writer2);
}
}
/*
深拷贝后:
writer1:Writer@6be46e8f age=18, name='豆粉', book=Book@5056dfcb bookName='编译原理', price=100}}
writer2:Writer@6d00a15d age=18, name='豆粉', book=Book@51efea79 bookName='编译原理', price=100}}
writer2.book 变更后:
writer1:Writer@6be46e8f age=18, name='豆粉', book=Book@5056dfcb bookName='编译原理', price=100}}
writer2:Writer@6d00a15d age=18, name='豆粉', book=Book@51efea79 bookName='永恒的图灵', price=70}}
*/
可以看到,Book2的字段修改之后,并不会影响Book1的字段,也就是说,Book1和Book2完全不是同一个对象,地址值也不相同,因此深拷贝验证成功。
不过,通过 clone()
方法实现的深拷贝比较笨重,因为要将所有的引用类型都重写 clone()
方法,当嵌套的对象比较多的时候就会开销很大很大。
但是可以利用序列化,将对象写到流中,写入流中的对象就是对原始对象的拷贝,需要注意的是,每个要序列化的类都要实现Serializable接口,该接口和Cloneable接口类似,都是标记型接口。
来看如何进一步优化:
class Book implements Serializable {
private String bookName;
private int price;
// getter/setter 和构造方法都已省略
@Override
public String toString() {
return super.toString().substring(26) +
" bookName='" + bookName + '\'' +
", price=" + price +
'}';
}
}
和前面两个Book类不同的是,此类实现了Serializable接口。
class Writer implements Serializable {
private int age;
private String name;
private Book book;
// getter/setter 和构造方法都已省略
@Override
public String toString() {
return super.toString().substring(26) +
" age=" + age +
", name='" + name + '\'' +
", book=" + book +
'}';
}
//深度拷贝
public Object deepClone() throws IOException, ClassNotFoundException {
// 序列化
ByteArrayOutputStream bos = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(bos);
oos.writeObject(this);
// 反序列化
ByteArrayInputStream bis = new ByteArrayInputStream(bos.toByteArray());
ObjectInputStream ois = new ObjectInputStream(bis);
return ois.readObject();
}
}
Writer 类也需要实现 Serializable 接口,并且在该类中,增加了一个 deepClone()
的方法,利用 OutputStream 进行序列化,InputStream 进行反序列化,这样就实现了深拷贝。
来看如何使用序列化实现深拷贝:
class TestClone {
public static void main(String[] args) throws IOException, ClassNotFoundException {
Writer writer1 = new Writer(18,"豆粉");
Book book1 = new Book("编译原理",100);
writer1.setBook(book1);
Writer writer2 = (Writer) writer1.deepClone();
System.out.println("深拷贝后:");
System.out.println("writer1:" + writer1);
System.out.println("writer2:" + writer2);
Book book2 = writer2.getBook();
book2.setBookName("永恒的图灵");
book2.setPrice(70);
System.out.println("writer2.book 变更后:");
System.out.println("writer1:" + writer1);
System.out.println("writer2:" + writer2);
}
}
/*
深拷贝后:
writer1:Writer@9629756 age=18, name='豆粉', book=Book@735b5592 bookName='编译原理', price=100}}
writer2:Writer@544fe44c age=18, name='豆粉', book=Book@31610302 bookName='编译原理', price=100}}
writer2.book 变更后:
writer1:Writer@9629756 age=18, name='豆粉', book=Book@735b5592 bookName='编译原理', price=100}}
writer2:Writer@544fe44c age=18, name='豆粉', book=Book@31610302 bookName='永恒的图灵', price=70}}
*/
不过,由于是序列化涉及到输入流和输出流的读写,在性能上要比 HotSpot 虚拟机实现的 clone()
方法差很多。
1.8.1 请说一下Java浅拷贝和深拷贝的区别(重点面试题)
浅拷⻉:仅拷⻉被拷⻉对象的成员变量的值,也就是基本数据类型变量的值,和引⽤数据类型变量的地址值,⽽对于引⽤类型变量指向的堆中的对象不会拷⻉。
深拷⻉:完全拷⻉⼀个对象,拷⻉被拷⻉对象的成员变量的值,堆中的对象也会拷⻉⼀份。
因此深拷⻉是安全的,浅拷⻉的话如果有引⽤类型,那么拷⻉后对象,引⽤类型变量修改,会影响原对象。
浅拷贝的实现是通过Object 类提供的 clone()⽅法可以⾮常简单地实现对象的浅拷⻉。
深拷贝的实现有两种方式:
- 重写克隆⽅法:重写克隆⽅法,引⽤类型变量单独克隆,这⾥可能会涉及多层递归。
- 序列化:可以先将原对象序列化,再反序列化成拷⻉对象,但是由于涉及到输入流和输出流,性能效率上并没有HotSpot虚拟机的clone()方法高。
1.9 Java是值传递还是引用传递(面试题)
先说一下什么是值传递和引用传递以及什么是形参和实参
方法的定义可能会用到 参数(有参的方法),参数在程序语言中分为:
- 实参(实际参数,Arguments):用于传递给函数/方法的参数,必须有确定的值。
- 形参(形式参数,Parameters):用于定义函数/方法,接收实参,不需要有确定的值。
String hello = "Hello!";
// hello 为实参
sayHello(hello);
// str 为形参
void sayHello(String str) {
System.out.println(str);
}
- 值传递:方法接收的是实参值的拷贝,会创建副本。
- 引用传递:方法接收的直接是实参所引用的对象在堆中的地址,不会创建副本,对形参的修改将影响到实参。
答: Java语言采用的是值传递,Java 语⾔的⽅法调⽤只⽀持参数的值传递。当⼀个对象实例作为⼀个参 数被传递到⽅法中时,参数的值就是对该对象的引⽤。对象的属性可以在被调⽤过程中被改 变,但对对象引⽤的改变是不会影响到调⽤者的。
JVM 的内存分为堆和栈,其中栈中存储了基本数据类型和引⽤数据类型实例的地址,也就是对象地址。 ⽽对象所占的空间是在堆中开辟的,所以传递的时候可以理解为把变量存储的对象地址给传递过去,因此引⽤类型也是值传递。
2. 集合框架详解
面向对象基础部分终于结束了,不知不觉已经过去4天了。
2.1 整体架构
2.2 List,Set,Queue,Map四者的区别?
- List: 存储元素是有序的,可重复的。
- Set:存储的元素不可重复,HashSet和其子类LinkedHashSet是无序的,TreeSet是有序的。
- Queue:按特定的排队规则来确定先后顺序,存储的元素是有序的,可重复的。
- Map: 使用键值对(Key-Value)存储,key是无序的,不可重复的,value是无序的,可重复的,每个键最多映射到一个值。
List
- ArrayList: Object[]数组。
- Vector:Object[]数组。
- LinkedList:双向链表。
Set
- HashSet(无序,唯一):基于HashMap实现的,底层采用HashMap来保存元素。
- LinkedHashSet:LinkedHashSet是HashSet的子类,并且内部通过LinkedHashMap实现。
- TreeSet(有序,唯一):红黑树(自平衡的排序二叉树)。
Queue
- PriorityQueue:Object[]数组来实现小顶堆。
- DelayQueue: PriorityQueue。
- ArrayDeque:可扩容动态双向数组
Map
- HashMap:JDK8之前HashMap由数组+链表组成的,数组是HashMap的主体,链表则是为了解决哈希冲突而存在的。JDK8之后在解决哈希冲突时有了较大的变化,当链表长度大于8,数组长度大于64的话,会将链表转换成红黑树。
- LinkedHashMap:LinkedHashMap继承自HashMap,所以它的底层仍然是基于拉链式哈希结构,即由数组和链表和红黑树组成。LinkedHashMap在上面结构的基础上,增加了一条双向链表,使得上面的结构可以保持键值对的插入顺序。
- Hashtable:数组+链表组成的,数组是Hashtable的主体,链表则是为了解决哈希冲突采用拉链法而存在的。
- TreeMap:红黑树(自平衡的排序二叉树)。
2.3 右移操作符以及ArrayList的数组扩容
>>的意思是相当于十进制除以2的几次方,例如 “10>>1=5”就是10转换成二进制之后右边的位向右移移位,前面的空位用0来补(如果是负数前面的空位用1补),也就是1010->0101,转换成十进制就是5。
那么这跟数组扩容有什么关系呢,ArrayList底层有一个扩容机制就是通过这个右移操作符来计算并扩容的,通常是ArrayList 的大小会扩容为原来的大小+原来大小/2,也就是 1.5 倍。源码是:
/**
* ArrayList扩容的核心方法。
*/
private void grow(int minCapacity) {
// oldCapacity为旧容量,newCapacity为新容量
int oldCapacity = elementData.length;
//将oldCapacity 右移一位,其效果相当于oldCapacity /2,
//我们知道位运算的速度远远快于整除运算,整句运算式的结果就是将新容量更新为旧容量的1.5倍,
int newCapacity = oldCapacity + (oldCapacity >> 1);
//然后检查新容量是否大于最小需要容量,若还是小于最小需要容量,那么就把最小需要容量当作数组的新容量,
if (newCapacity - minCapacity < 0)
newCapacity = minCapacity;
//再检查新容量是否超出了ArrayList所定义的最大容量,
//若超出了,则调用hugeCapacity()来比较minCapacity和 MAX_ARRAY_SIZE,
//如果minCapacity大于MAX_ARRAY_SIZE,则新容量则为Integer.MAX_VALUE,否则,新容量大小则为 MAX_ARRAY_SIZE。
if (newCapacity - MAX_ARRAY_SIZE > 0)
newCapacity = hugeCapacity(minCapacity);
// minCapacity is usually close to size, so this is a win:
elementData = Arrays.copyOf(elementData, newCapacity);
}
//比较minCapacity和 MAX_ARRAY_SIZE
private static int hugeCapacity(int minCapacity) {
if (minCapacity < 0) // overflow
throw new OutOfMemoryError();
return (minCapacity > MAX_ARRAY_SIZE) ?
Integer.MAX_VALUE :
MAX_ARRAY_SIZE;
}
顺提一嘴:ArrayList的序列化不太⼀样,它使⽤ transient 修饰存储元素的 elementData 的数 组, transient 关键字的作⽤是让被修饰的成员属性不被序列化。
/**
* 保存ArrayList数据的数组
*/
transient Object[] elementData;
2.4 讲一下HashMap(重点面试题)
HashMap是线程不安全的, JDK8之前主要是Entry数组+链表, jdk8之后主要是数组+链表+红⿊树组成的.
如果发⽣哈希碰撞, 当链表⻓度⼤于8切数组⻓度⼤于64的时候, 会将链表转化成红⿊树的结构, 当数组⻓度⼩于64的时候,会先选择扩容数组扩容操作为当前⼤⼩的⼆倍
计算下标位置的⽅法主要是先计算hash, 将hashCode右移16位, 然后⾼16位和低16位进⾏异或运算,扰动运算, 这样做是为了把⾼低位都利⽤起来不容易造成哈希冲突.
然后将hash和HashMap⼤⼩的次幂减⼀进⾏&运算.这⼀点其实就是取余操作
因为HashMap的⼤⼩总是2的n次幂, 因为2的n次⽅减⼀是n个1,这样在计算下标位置的时候可以保证散列的均匀性, 减少碰撞⽅便取余
HashMap默认⼤⼩是16, 那么如果构造函数中如果传⼊了⼤⼩, HashMap也会调⽤ tableSizeFor将⼤⼩变成你传⼊的数的最进2的倍数.
扩容因⼦是0.75
JDK1.8中的优化操作,在扩容的时候,可以不需要再重新计算每⼀个元素的哈希值。
因为HashMap的初始容量是2的次幂,扩容之后的⻓度是原来的⼆倍,新的容量也是2的次幂,所以,元素,要么在原位置,要么在原位置再移动2的次幂。
在Java中的HashMap实现中,当某个桶(bucket)中的链表⻓度超过阈值(默认为8时,会将该链表转化为红⿊树,以提⾼查找效率。但是,当红⿊树中的节点数量减少到⼀定程度时,会将红⿊树重新转换为链表。
具体来说,以下情况下红⿊树会转化为单链表:
- 删除节点操作:当从红⿊树中删除节点后,如果红⿊树的节点数量减少到⼀定程度(默认为6),会将红⿊树重新转换为链表结构。这是为了避免频繁进⾏红⿊树的平衡操作,以节省空间和维护成本。
- 扩容操作:当HashMap进⾏扩容时,会重新计算每个节点在新的桶数组中的位置,并重新构建哈希表。在这个过程中,原先的红⿊树会被拆解为单链表,然后根据新的哈希值重 新进⾏节点的插⼊操作。
2.4.1 为什么HashMap的初始容量是2的次幂
因为在做hash运算的时候会涉及到&运算符,也就是会两个十进制的数转换成二进制的数进行与运算,如果hashMap的容量不是2的次幂而是奇数,当这个数减去1得到的偶数的二进制形式的低位碰巧是0的话,&计算的结果有可能(4&2=0)就全是0,就会导致全部数据都会存在哈希表的第一个索引位。
2 的整次幂刚好是偶数,偶数-1 是奇数,奇数的二进制最后一位是 1,保证了 hash &(length-1)
的最后一位可能为 0,也可能为 1(取决于 hash 的值),即 & 运算后的结果可能为偶数,也可能为奇数,这样便可以保证哈希值的均匀分布。
2.4.2 为什么取模之前要调用hash()方法呢?
hash()方法是用来做哈希值优化的,把哈希值右移16位,也就是正好是自己长度的一半,之后与原哈希值做异或运算,这样就混合了原哈希值中的高位和低位,增大了随机性,让数据元素更加均匀分布,减少哈希碰撞。
2.4.3 HashMap的扩容机制
HashMap 的扩容是通过 resize 方法来实现的,JDK 8 中融入了红黑树(链表长度超过 8 的时候,会将链表转化为红黑树来提高查询效率)
JDK 8 的扩容源代码:
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table; // 获取原来的数组 table
int oldCap = (oldTab == null) ? 0 : oldTab.length; // 获取数组长度 oldCap
int oldThr = threshold; // 获取阈值 oldThr
int newCap, newThr = 0;
if (oldCap > 0) { // 如果原来的数组 table 不为空
if (oldCap >= MAXIMUM_CAPACITY) { // 超过最大值就不再扩充了,就只好随你碰撞去吧
threshold = Integer.MAX_VALUE;
return oldTab;
}
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && // 没超过最大值,就扩充为原来的2倍
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
else { // zero initial threshold signifies using defaults
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
// 计算新的 resize 上限
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr; // 将新阈值赋值给成员变量 threshold
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap]; // 创建新数组 newTab
table = newTab; // 将新数组 newTab 赋值给成员变量 table
if (oldTab != null) { // 如果旧数组 oldTab 不为空
for (int j = 0; j < oldCap; ++j) { // 遍历旧数组的每个元素
Node<K,V> e;
if ((e = oldTab[j]) != null) { // 如果该元素不为空
oldTab[j] = null; // 将旧数组中该位置的元素置为 null,以便垃圾回收
if (e.next == null) // 如果该元素没有冲突
newTab[e.hash & (newCap - 1)] = e; // 直接将该元素放入新数组
else if (e instanceof TreeNode) // 如果该元素是树节点
((TreeNode<K,V>)e).split(this, newTab, j, oldCap); // 将该树节点分裂成两个链表
else { // 如果该元素是链表
Node<K,V> loHead = null, loTail = null; // 低位链表的头结点和尾结点
Node<K,V> hiHead = null, hiTail = null; // 高位链表的头结点和尾结点
Node<K,V> next;
do { // 遍历该链表
next = e.next;
if ((e.hash & oldCap) == 0) { // 如果该元素在低位链表中
if (loTail == null) // 如果低位链表还没有结点
loHead = e; // 将该元素作为低位链表的头结点
else
loTail.next = e; // 如果低位链表已经有结点,将该元素加入低位链表的尾部
loTail = e; // 更新低位链表的尾结点
}
else { // 如果该元素在高位链表中
if (hiTail == null) // 如果高位链表还没有结点
hiHead = e; // 将该元素作为高位链表的头结点
else
hiTail.next = e; // 如果高位链表已经有结点,将该元素加入高位链表的尾部
hiTail = e; // 更新高位链表的尾结点
}
} while ((e = next) != null); //
if (loTail != null) { // 如果低位链表不为空
loTail.next = null; // 将低位链表的尾结点指向 null,以便垃圾回收
newTab[j] = loHead; // 将低位链表作为新数组对应位置的元素
}
if (hiTail != null) { // 如果高位链表不为空
hiTail.next = null; // 将高位链表的尾结点指向 null,以便垃圾回收
newTab[j + oldCap] = hiHead; // 将高位链表作为新数组对应位置的元素
}
}
}
}
}
return newTab; // 返回新数组
}
当我们往 HashMap 中不断添加元素时,HashMap 会自动进行扩容操作(条件是元素数量达到负载因子(load factor)乘以数组长度时),以保证其存储的元素数量不会超出其容量限制。
在进行扩容操作时,HashMap 会先将数组的长度扩大一倍,然后将原来的元素重新散列到新的数组中。
由于元素的位置是通过 key 的 hash 和数组长度进行与运算得到的,因此在数组长度扩大后,元素的位置也会发生一些改变。一部分索引不变,另一部分索引为“原索引+旧容量”。
2.4.4 加载因子为什么是 0.75
HashMap 的加载因子(load factor,直译为加载因子,意译为负载因子)是指哈希表中填充元素的个数与桶的数量的比值,当元素个数达到负载因子与桶的数量的乘积时,就需要进行扩容。这个值一般选择 0.75,是因为这个值可以在时间和空间成本之间做到一个折中,使得哈希表的性能达到较好的表现。
如果负载因子过大,填充因子较多,那么哈希表中的元素就会越来越多地聚集在少数的桶中,这就导致了冲突的增加,这些冲突会导致查找、插入和删除操作的效率下降。同时,这也会导致需要更频繁地进行扩容,进一步降低了性能。
如果负载因子过小,那么桶的数量会很多,虽然可以减少冲突,但是在空间利用上面也会有浪费,因此选择 0.75 是为了取得一个平衡点,即在时间和空间成本之间取得一个比较好的平衡点。
总之,选择 0.75 这个值是为了在时间和空间成本之间达到一个较好的平衡点,既可以保证哈希表的性能表现,又能够充分利用空间。
2.4.5 JDK1.7中的 HashMap 使⽤头插法插⼊元素为什么会出现环形链表?(面试题)
主要原因是当链表扩容之后, 因为是头插法的原因,数组所在的链表会发⽣翻转,假设某个hash数 组上的链表内容为ABC,那么扩容之后,该数组上的链表为CBA。
但当多线程场景下,两个线程恰好同时到来并需要扩容,每个线程会创建⾃⼰的数组, ⽽共享同⼀个扩容前的旧HashMap。⽽在扩容初始,线程2由于某个原因卡住,但已经给指针赋值,其与线程1的指针指向同⼀位置,且线程2的指针随线程1的元素转移⽽转移⾄array1,此 时线程2恢复继续操作转移元素,但由于转移元素的逆序特性,导致线程2的当前元素指针 (e2)与记录下⼀元素指针(next2)位置互换,next2⽆法起到记录下⼀元素的作⽤,导致转 移元素丢失或滞留,⽽新转移过去的唯⼆元素形成环永远next下去。
扩容前的样子假如是下面这样子。
正常扩容后就是下面这样子。
假设现在有两个线程同时进行扩容,线程 A 在执行到 newTable[i] = e;
被挂起,此时线程 A 中:e=3、next=7、e.next=null
线程 B 开始执行,并且完成了数据转移。
此时,7 的 next 为 3,3 的 next 为 null。
随后线程 A 获得 CPU 时间片继续执行 newTable[i] = e
,将 3 放入新数组对应的位置,执行完此轮循环后线程 A 的情况如下:
执行下一轮循环,此时 e=7,原本线程 A 中 7 的 next 为 5,但由于 table 是线程 A 和线程 B 共享的,而线程 B 顺利执行完后,7 的 next 变成了 3,那么此时线程 A 中,7 的 next 也为 3 了。
采用头部插入的方式,变成了下面这样子:
此时 next = 3,e = 3。
进行下一轮循环,但此时,由于线程 B 将 3 的 next 变为了 null,所以此轮循环应该是最后一轮了。
接下来当执行完 e.next=newTable[i]
即 3.next=7 后,3 和 7 之间就相互链接了,执行完 newTable[i]=e
后,3 被头插法重新插入到链表中,执行结果如下图所示:
2.4.6 HashMap 和 TreeMap 的区别?(面试题)
对于内部的实现, HashMap 主要是基于哈希表的, 通过hashCode和equals⽅法来确定存储位置, 内部数据是没有顺序的.
⽽TreeMap主要是基于红⿊树, 可以根据Comparator来对元素排序.
同时他们两个都是线程不安全的, HashMap 的插⼊,查找,删除平均时间复杂度为O(1)
TreeMap的这些操作平均时间复杂度为O(logn)
HashMap允许将null当做key, ⽽TreeMap不允许。
2.4.7 HashMap为什么线程不安全?(面试题)
HashMap不是线程安全的,可能会发⽣这些问题:
多线程下扩容死循环。JDK1.7 中的 HashMap 使⽤头插法插⼊元素,在多线程的环境 下,扩容的时候有可能导致环形链表的出现,形成死循环。因此,JDK1.8 使⽤尾插法插 ⼊元素,在扩容时会保持链表元素原本的顺序,不会出现环形链表的问题。 多线程的 put 可能导致元素的丢失。
多线程同时执⾏ put 操作,如果计算出来的索引位 置是相同的,那会造成前⼀个 key 被后⼀个 key 覆盖,从⽽导致元素的丢失。此问题在 JDK 1.7 和 JDK 1.8 中都存在。
put 和 get 并发时,可能导致 get 为 null。线程 1 执⾏ put 时,因为元素个数超出 threshold ⽽导致 rehash,线程 2 此时执⾏ get,有可能导致这个问题。这个问题在 JDK 1.7 和 JDK 1.8 中都存在。
2.4.8 ConcurrentHashMap(面试题)
ConcurrentHashMap线程安全在jdk1.7版本是基于分段锁实现,在jdk1.8是基于CAS+synchronized实现。
1.7版本的ConcurentHashMap采用分段锁机制,里面包含一个Segment数组,Segment继承于ReentrantLock,Segment则包含HashEntry的数组,HashEntry本身就是一个链表的结构,具有保存key,value的能力指向下一个节点的指针。
实际上就是相当于每个Segment都是一个HashMap,默认的Segment长度是16,也就是支持16个线程的并发写,Segment之间相互不会受到影响。
put流程
整个流程和HashMap非常相似,只不过是先定位到具体的Segment,然后通过ReentrantLock去操作,后面的流程就和HashMap基本上一致。
- 计算hash,定位segment,segment如果为空就先初始化
- 使用ReentrantLock加锁,如果获取锁失败则尝试自旋,自旋超过次数就阻塞获取,保证一定获取锁成功。
- 遍历HashEntry,就是和HashMap一样,数组中的key和hash一样就直接替换,不存在就再插入链表,链表同样操作。
get流程
key通过hash定位到segment,再遍历链表定位到具体的元素上,需要注意的是value是volatile的,所以get是不需要加锁的
JDK1.8的实现已经摒弃了Segment的概念,而是直接用Node数组+链表+红黑树的数据结构来实现,并发控制使用Synchronized和CAS来操作,每次锁的话只用锁住每个数组的头结点,而不是锁住整个段。
在初始化的时候,java会先通过CAS初始化容量,让一个线程来进行初始化,其他线程直接调用yield,扩容因子为四分之三。
在插入的时候如果当前位置为空,也会通过CAS操作直接放到该位置。
如果不为空,则会在头结点加锁,加完锁这时候还会再检查一下改元素的位置
随后方法会检查当前容量是否需要进行扩容。
如果CAS失败,说明有其他线程提前插入了节点,自旋重新尝试在这个位置插入节点。
如果f的hash值为-1,说明当前节点,意味着有其它线程正在扩容,则一起进行扩容操作。
其余情况把新的Node节点按链表或红黑树的方式插入到合适的位置,这个过程采用synchronized锁实现并发。
get查询
get很简单,和HashMap基本相同,通过key计算位置,table该位置key相同就返回,如果是红黑树就按照红黑树获取,否则就遍历链表获取。
3. 异常处理
3.1 java中的异常
Throwable是Java语言中所有错误或异常的基类。Throwable又分为Error和Exception。
其中Error是系统内部错误,比如虚拟机或堆内存不足,是程序无法处理的。Exception是程序问题导致的异常,分为两种:
- CheckedException受检异常:编译器会强制检查并要求处理的异常。比如FileNotFound这些
- RuntimeException运行时异常:程序运行中出现异常,比如我们熟悉的空指针、数组下标越界等等。
3.2 try、catch、finally
在return前会先执行finally语法块
当try语句和finally语句中都有return语句时,try语句块中的return语句会被忽略。
这是因为try语句中的return返回值会先被暂时存在一个本地变量中,当执行到finally语句中的return之后,这个本地变量的值就变为了finally语句中的return返回值。
public class TryDemo {
public static void main(String[] args) {
System.out.println(test1());
}
public static int test1() {
int i = 0;
try {
i = 2;
return i;
} finally {
i = 3;
}
}
}
执行结果:2。
4. 泛型(Java语法糖的其中之一)
泛型一般有三种使用方式:泛型类,泛型接口,泛型方法。常见的如T、E、K、V等形式的参数,常用于表示泛型,在实例化泛型类时,必须指定T的具体类型。
public class Generic<T>
Generic<Integer> genericInteger = new Generic<Integer>(123456);
public interface Generator<T>
class GeneratorImpl<T> implements Generator<String>
public static < E > void printArray( E[] inputArray )
Integer[] intArray = { 1, 2, 3 };
String[] stringArray = { "Hello", "World" };
printArray( intArray );
printArray( stringArray );
4.1 类型擦除(泛型擦除)(面试题)
Java的泛型是伪泛型,这是因为Java在编译期间,所有的类型信息都会被擦除。
也就是说在运行的时候是没有泛型的。例如以下代码:
Map<String, String> map = new HashMap<String, String>();
map.put("name", "hollis");
map.put("wechat", "Hollis");
map.put("blog", "www.hollischuang.com");
在运行时会变成:
Map map = new HashMap();
map.put("name", "hollis");
map.put("wechat", "Hollis");
map.put("blog", "www.hollischuang.com");
因为Java的泛型只存在于源码里,编译的时候给你静态地检查一下泛型类型是否正确,而到运行时就不检查了。
4.2 为什么要类型擦除(面试题)
主要是为了向下兼容,因为 JDK5 之前是没有泛型的,为了让 JVM 保持向下兼容,就出了类型擦除这个策略。
上面的代码注意:如果传入的类型不是<String,String>类型的话也是会报错的!
- 向后兼容性:Java是一门面向对象语言,泛型是在Java5中引入的新特性。为了确保现有的Java代码能够在引入泛型之后继续正常工作,Java使用泛型擦除机制。在编译时,泛型类型信息会被擦除,编译器将泛型类型转换为原始类型,这样编译后的字节码与旧版本的Java代码兼容。
- 减少冗余代码:泛型擦除可以避免生成大量重复的字节码。在泛型代码中,不同类型的泛型参数在运行时都会被擦除为相同的原始类型,这样可以减少生成的字节码数量,节省存储空间和加载时间。
- 类型安全:尽管泛型类型在运行时被擦除,但在编译时会进行类型检查,以确保类型的一致性和安全性。通过编译器的类型检查,可以在编译阶段捕获一些类型错误,避免在运行时出现类型不匹配的问题。
5. 多线程基础(多线程后面的知识会再额外开辟一篇文章)
5.1 进程和线程的区别(面试题)
进程,是对运行时程序的封装,是系统进行资源调度和分配的基本单位,实现了操作系统的并发。
线程,是进程的子任务,是 CPU 调度和分派的基本单位,实现了进程内部的并发。
线程在进程下进行
进程直接不会相互影响,但是主线程结束将会导致整进程结束
不同的进程数据很难共享
同进程下的不同线程之间数据容易共享
进程使用内存地址可以限定使用量
5.2 创建线程的四种方法(面试题)
1.继承Thread类,缺点是不能继承扩展
2.通过实现Runnable接口来创建,Thread类本身也是实现了Runnbale接口,执行线程的run方法还是调用的是Runnable,这种方法可以避免单继承的局限性,可以将一个线程任务对象包装成多个线程对象,代码比较解耦,还可以放入Callable里面
3.实现一个Callable接口重新call()方法,再配合FutureTask,可以得到线程执行完的结果,调用get的线程会阻塞。
4.可以使用线程池来创建。
线程启动必须调用start方法,若直接调用run方法,那开始相当于单线程。
5.3为什么要重写run()方法?(面试题)
这是因为默认的run()
方法不会做任何事情。
为了让线程执行一些实际的任务,我们需要提供自己的run()
方法实现,这就需要重写run()
方法。
5.4 run方法和start方法有什么区别?(面试题)
run()
:封装线程执行的代码,直接调用相当于调用普通方法。start()
:启动线程,然后由 JVM 调用此线程的run()
方法。
5.5 通过继承Thread的方法和实现Runnable接口的方式创建多线程,哪个好?(面试题)
实现 Runable 接口好,原因有两个:
- 避免了 Java 单继承的局限性,Java 不支持多重继承,因此如果我们的类已经继承了另一个类,就不能再继承 Thread 类了。
- 适合多个相同的程序代码去处理同一资源的情况,把线程、代码和数据有效的分离,更符合面向对象的设计思想。Callable 接口与 Runnable 非常相似,但可以返回一个结果。
5.6 线程生命周期
6. 反射详解
反射允许在运行时动态地获取和操作类的信息,包括类的字段、方法、构造函数等,而不需要再编译时确定。可以在运行时根据需要加载类、创建对象、调用方法等,使得代码更加灵活和高扩展。
反射可以突破访问权限限制,即使是私有成员也可以通过反射进行访问和操作。
反射可以用于创建动态代理对象,通过代理对象可以在运行时,拦截和处理方法调用。这在实现AOP(面向切面编程)等方面非常有用。
6.1反射如何使用
1.知道具体类的情况下可以使用:
Class alunbarClass = TargetObject.class;
但是我们一般是不知道具体类的,基本都是通过遍历包下面的类来获取Class对象,通过此方式获取Class对象不会进行初始化。
2.通过Class.forName()传入类的全路径获取:
Class alunbarClass1 = Class.forName("cn.javaguide.TargetObject");
3.通过对象实例instance.getClass()获取:
TargetObject o = new TargetObject();
Class alunbarClass2 = o.getClass();
4.通过类加载器xxxClassLoader.loadClass()传入类路径。
ClassLoader.getSystemClassLoader().loadClass("cn.javaguide.TargetO
bject");
package cn.javaguide;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
public class Main {
public static void main(String[] args) throws
ClassNotFoundException, NoSuchMethodException,
IllegalAccessException, InstantiationException,
InvocationTargetException, NoSuchFieldException {
/**
* 获取 TargetObject 类的 Class 对象并且创建 TargetObject 类实
例
*/
Class<?> targetClass =
Class.forName("cn.javaguide.TargetObject");
TargetObject targetObject = (TargetObject)
targetClass.newInstance();
/**
* 获取 TargetObject 类中定义的所有方法
*/
Method[] methods = targetClass.getDeclaredMethods();
for (Method method : methods) {
System.out.println(method.getName());
}
/**
* 获取指定方法并调用
*/
Method publicMethod =
targetClass.getDeclaredMethod("publicMethod",
String.class);
publicMethod.invoke(targetObject, "JavaGuide");
/**
* 获取指定参数并对参数进行修改
*/
Field field = targetClass.getDeclaredField("value");
//为了对类中的参数进行修改我们取消安全检查
field.setAccessible(true);
field.set(targetObject, "JavaGuide");
/**
* 调用 private 方法
*/
Method privateMethod =
targetClass.getDeclaredMethod("privateMethod");
//为了调用private方法我们取消安全检查
privateMethod.setAccessible(true);
privateMethod.invoke(targetObject);
}
}
6.2 反射的应用场景?
像咱们平时大部分时候都是在写业务代码,很少会接触到直接使用反射机制的场景。但是!这并不代表反射没有用。相反,正是因为反射,你才能这么轻松地使用各种框架。像 Spring/Spring Boot、MyBatis 等等框架中都大量使用了反射机制。
这些框架中也大量使用了动态代理,而动态代理的实现也依赖反射。
比如下面是通过 JDK 实现动态代理的示例代码,其中就使用了反射类 Method
来调用指定的方法。
public class DebugInvocationHandler implements InvocationHandler {
/**
* 代理类中的真实对象
*/
private final Object target;
public DebugInvocationHandler(Object target) {
this.target = target;
}
public Object invoke(Object proxy, Method method, Object[] args) throws InvocationTargetException, IllegalAccessException {
System.out.println("before method " + method.getName());
Object result = method.invoke(target, args);
System.out.println("after method " + method.getName());
return result;
}
}
另外,像 Java 中的一大利器 注解 的实现也用到了反射。
6.3 Java代理模式
简单来说就是使用代理对象来代替真正操作的对象,在不修改原目标对象的前提下,提供额外的功能
代理模式有静态代理和动态代理,对于静态代理,每个方法的增强都是手动完成的,比较不灵活,很少用,在编译时就把接口,实现类,代理类变成一个class文件。
对于动态代理,动态代理比较灵活一点,不需要针对每个目标类都创建代理,SpringAOP,RPC框架都是依赖于动态代理
在JDK动态代理中,InvocationHandler接口和Proxy类是核心,主要是通过Proxy的newProxyInstance()方法来生成一个代理对象。
我们需要指定类的加载器,被代理类实现的接口和实现了InvocationHandler接口的对象。
当我们通过Proxy类创建的代理对象在调用方法的时候,实际会调用到现在InvocationHandler接口的invoke方法中自定义处理逻辑
invoke()方法有下面三个参数:
- proxy:动态生成的代理类
- method:与代理类对象调用的方法相对应
- args:当前method方法的参数
jdk的动态代理是jdk原生的,不需要任何的依赖就可以使用,同时生成代理类的速度更快一些
但是被代理的类必须实现了接口,否则就无法代理。
而对于CGLIB动态代理方法,可以代理未实现任何接口的类。CGLIB动态代理是通过生成一个被代理类的子类来拦截被代理类的方法调用,因此不能代理声明为final类型的类和方法。
使用CGLIB动态代理方法,CGLIB动态代理是使用字节码处理框架ASM,其原理是通过字节码技术为一个类创建子类,并在子类中采用方法拦截的技术拦截所有父类方法的调用。
7. IO(重点是序列化和反序列化)
7.1序列化和反序列化(面试题)
如果我们需要持久化Java对象,比如将Java对象保存在文件中,或者在网络传输Java对象,这些场景都需要用到序列化。
简单来说:
- 序列化:将数据结构或对象转换成二进制字节流的过程
- 反序列化:将在序列化过程中所生成的二进制字节流转换成数据结构或者对象的过程
序列化协议属于TCP/IP协议应用层的一部分。
对于不想进行序列化的变量,使用transient关键字修饰。
transient修饰的变量,在反序列化后变量值将被置成类型的默认值。例如,如果是修饰int类型,那么反序列结果就是0。
static变量因为不属于任何对象(Object),所以无论有没有transient关键字修饰,均不会被序列化。
下一篇文章:JVM主要知识详解、Java多线程主要知识详解