《Java 后端面试经》专栏文章索引:
《Java 后端面试经》Java 基础篇
《Java 后端面试经》Java EE 篇
《Java 后端面试经》数据库篇
《Java 后端面试经》多线程与并发编程篇
《Java 后端面试经》JVM 篇
《Java 后端面试经》操作系统篇
《Java 后端面试经》Linux 篇
《Java 后端面试经》设计模式篇
《Java 后端面试经》计算机网络篇
《Java 后端面试经》微服务篇
《Java 后端面试经》 Java 基础篇
- 🚀面向对象
- 🚀JDK、JRE 和 JVM 三者之间的区别
- 🚀Java 创建对象有哪几种方式?
- 🚀获取一个类对象的几种方式?
- 🚀Java 中的自动类型转换
- 🚀final
- 🚀String 相关
- 🚀重载和重写的区别
- 🚀单例与 static 的区别
- 🚀谈谈接口和抽象类
- 🚀hashCode() 与 equals()
- 🚀集合
- 🚀int 和 Integer 哪个会占用更多的内存?
- 🚀Java 中 ++ 操作符是线程安全的吗?
- 🚀什么是字节码?采用字节码的好处是什么?
- 🚀异常
- 🚀Java 中的深拷贝和浅拷贝说一下?
- 🚀Java 中的基本数据类型有哪些?
- 🚀谈谈全局变量和局部变量的区别?
- 🚀Java 中抽象类和接口中方法的默认访问权限?
- 🚀Object 类中的方法有哪些
- 🚀IO
- 🚀Java 的关键字、保留字
- 🚀阐述成员变量和局部变量的区别?
- 🚀Java 对象初始化顺序
- 🚀Java 内部类
- 🚀关于接口中的属性和方法
- 🚀Java 的体系结构
- 🚀Java 中的访问修饰符
- 🚀谈谈对泛型的理解?
- 🚀介绍一下泛型擦除?
- 🚀Java 中序列化和反序列化是什么?
🚀面向对象
面向对象编程与之相对应的是面向过程编程。
面向过程(Procedure Oriented 简称 PO):把事情拆分成一个个的方法和数据,然后按照一定的顺序,执行完这些方法,等方法执行完了,事情就搞定了。(因为每个方法都可以看作一个过程,所以叫面向过程)。
面向对象(Object Oriented 简称 OO):面向对象会把事物抽象成对象的概念,先抽象出对象,然后给对象赋一些属性和方法,然后让每个对象去执行自己的方法,问题得到解决。
举例:用洗衣机洗衣服,来看一下两者的差别。
面向过程:
放衣服(方法)-> 加洗衣粉(方法)-> 加水(方法)-> 漂洗(方法)-> 清洗(方法)-> 甩干(方法).
面向对象:
new 出两个对象 ”人“ 和 ”洗衣机“
”人“ 加入属性和方法:放衣服(方法)、加洗衣粉(方法)、加水(方法)
”洗衣机“ 加入属性和方法:漂洗(方法)、清洗(方法)、甩干(方法)
然后执行:
人.放衣服(方法)-> 人.加洗衣粉(方法)-> 人.加水(方法)-> 洗衣机.漂洗(方法)-> 洗衣机.清洗(方法)-> 洗衣机.甩干(方法)
优缺点对比
面向过程:
优点:性能比面向对象高,因为不需要实例化对象。
缺点:可维护性差(例如:洗衣服我不喜欢甩干、我洗衣服更喜欢用洗衣液)
面向对象:
优点:易维护、易复用、易扩展,由于面向对象有封装、继承、多态性的特点,可以设计出低耦合的系统,使系统更加灵活、更加易于维护。
缺点:性能比面向过程低。
面向对象编程的特性:封装、继承、多态。
封装: 把客观事物封装成抽象的类,并且类可以把自己的数据和方法只让可信的类或者对象操作,对不可信的信息隐藏。
继承: 继承可以使用现有类的所有功能,并在无需重新编写原来的类的情况下对这些功能进行扩展。
多态: 一个类实例的相同方法在不同情形有不同的表现形式。
🚀JDK、JRE 和 JVM 三者之间的区别
JDK: Java Development Kit,java 开发工具,提供给开发人员使用
JRE: Java Runtime Environment,java 运行时环境,提供给运行 java 程序的用户使用
JVM: Java Virtual Machine,java 虚拟机,解释 class 文件
🚀Java 创建对象有哪几种方式?
- 使用 new 关键字调用对象的构造器。
- 使用 Java 反射的 newInstance() 方法。
User user = Class.forName("com.hzz.User").newInstance();
- 使用 Object 类的 clone() 方法。
- 使用对象流 ObjectInputStream 的 readObject() 方法读取序列化对象。
🚀获取一个类对象的几种方式?
- 通过类对象的 getClass() 方法获取,即
A.getClass()
. - 通过类的静态成员表示,每个类都有隐含的静态成员 class,即
A.class
. - 通过 Class 类的静态方法 forName() 方法获取,即
Class.forName("com.hzz.A")
. - 通过类加载器
xxxClassLoader.loadClass()
传入类路径获取。
🚀Java 中的自动类型转换
自动类型转换遵循下面的规则:
- 若参与运算的数据类型不同,则先转换成同一类型,然后进行运算
- 转换按数据长度增加的方向进行,以保证精度不降低。例如 int 型和 long 型运算时,先把 int 型转成 long 型后再进行运算
- 所有的浮点运算都是以双精度进行的,即使仅含 float 单精度量运算的表达式,也要先转换成 double 型,再做运算
- char 型和 short 型参与运算时,必须先转换成 int 型
🚀final
- 修饰类:表示类不可被继承。final 类中的所有成员方法都会被隐式地指定为 final 方法。
- 修饰方法:表示方法不可被子类重写,但是可以重载。
- 修饰变量:表示变量一旦被赋值就不可以更改它的值。如果是基本数据类型的变量,则其数值一旦在初始化之后便不能更改;如果是引用类型的变量,则在对其初始化之后便不能再让其指向另一个对象。
(1)修饰成员变量
- 如果 final 修饰的是类变量,只能在静态初始化块中指定初始值或者声明该类变量时指定初始值。
- 如果 final 修饰的是成员变量,可以在非静态初始化块、声明该变量时或者构造器中指定初始值。
(2)修饰局部变量
系统不会为局部变量进行初始化,局部变量必须由程序员显式初始化。因此使用 final 修饰局部变量时,既可以在定义时指定默认值(后面的代码不能对变量再赋值),也可以不指定默认值,而在后面的代码中对 final 变量赋初始值(仅一次)。
public class FinalVar {
final static int a = 0; // 在声明的时候需要赋值或者静态代码块赋值
/**
static {
a = 0;
}
*/
final int b = 0; // 在声明的时候需要赋值或者代码块中赋值或者构造器赋值
/**
{
b = 0;
}
*/
public static void main(String[] args) {
final int localA; // 局部变量只声明没有初始化,不会报错,与 final 无关
localA = 0; // 在使用之前一定要赋值
// localA = 1; 但是不允许第二次赋值
}
}
(3)修饰基本类型数据和引用类型数据
- 如果是基本数据类型的变量,则其数值一旦在初始化之后便不能修改
- 如果是引用类型的变量,则在其初始化之后便不能再让其指向另一个对象。但是引用的值是可变的。
public class FinalReferenceTest {
public static void main() {
final int[] iArr = {1,2,3,4};
iArr[2] = -3; // 合法
iArr = null;
final Person p = new Person(25);
p.setAge(24); // 合法
p = null; // 非法
}
}
🚁为什么局部内部类和匿名内部类只能访问局部 final 变量?
编译之后会生成两个 class 文件,Test.class 和 Test1.class
public class Test {
public static void main(String[] args) {
}
// 局部 final 变量 a, b
public void test(final int b) {
final int a = 10;
// 匿名内部类
new Thread() {
public void run() {
System.out.println(a);
System.out.println(b);
}
}.start();
}
class OutClass {
private int age = 12;
public void outPrint(final int x) {
class InClass {
public void InPrint() {
System.out.println(x);
System.out.println(age);
}
}
new InClass().InPrint();
}
}
}
首先需要知道一点的是:内部类和外部类是处于同一个级别的,内部类不会因为定义在方法中就会随着方法的执行完毕就被销毁。
这里就会产生问题:当外部类的方法结束时,局部变量就会被销毁了,但是内部类对象可能还存在(只有没有人再引用它时,才会死亡)。这里就出现了一个矛盾:内部类对象访问了一个不存在的变量。为了解决这个问题,就将局部变量复制了一份作为内部类的成员变量,这样当局部变量死亡后,内部类仍然可以访问它,实际访问的是局部变量的 “copy”。这样就好像延长了局部变量的生命周期。
将局部变量复制为内部类的成员变量时,必须保证这两个变量是一样的,也就是如果我们在内部类中修改了成员变量,方法中的局部变量也得跟着改变,怎么解决问题?
就将局部变量设置为 final, 对它初始化后,我就不让你再去修改这个变量,就保证了内部类的成员变量和方法的局部变量的一致性。这实际上也是一种妥协,使得局部变量与内部类建立的拷贝保持一致。
🚀String 相关
🚁String、StringBuffer 和 StringBuilder 区别及使用场景
- String 类底层使用
final
关键字修饰的字符数组来保存字符串,private final char value[]
,所以 String 对象是不可变的。 - StringBuilder 与 StringBuffer 都继承自 AbstractStringBuilder 类,在 AbstractStringBuilder 中也是使用字符数组保存字符串
char[] value
但是没有用 final 关键字修饰,所以这两种对象都是可变的。 - StringBuffer 对方法加了同步锁(synchronized)或者对调用的方法加了同步锁,所以是线程安全。 StringBuilder 并没有对方法进行加同步锁,所以是非线程安全的。
性能:StringBuilder > StringBuffer > String.
场景:经常需要改变字符串内容时使用前面两个。
- 操作少量的数据: 适用 String.
- 单线程操作字符串缓冲区下操作大量数据: 适用 StringBuilder.
- 多线程操作字符串缓冲区下操作大量数据: 适用 StringBuffer.
想要效率就优先使用 StringBuilder,多线程使用共享变量时使用 StringBuffer.
🚁String str = new String(“abc”) 创建了几个对象?
一个或者两个对象。
如果常量池中没有 “abc”,则会在常量池中创建一个 String 对象 ”abc“,再在堆内存中创建一个对象。如果常量池中已经存在对象 “abc”,则不会重复创建,而是只在堆内存中创建一个对象。
🚁为什么 String 类不可变?
之所以要把 String 类设计为不可变类,主要是出于安全和性能的考虑,可归纳为如下三点:
- 字符串通常会用来存储敏感信息(如账号,密码等),保证字符串 String 类的安全性就尤为重要了,如果字符串是可变的,容易被篡改,那我们就无法保证使用字符串进行操作时,它是安全的,很有可能出现 SQL 注入,访问危险文件等操作。
- 在多线程中,只有不变的对象和值是线程安全的,可以在多个线程中共享数据。由于 String 天然的不可变,当一个线程”修改“了字符串的值,只会产生一个新的字符串对象,不会对其他线程的访问产生副作用,访问的都是同样的字符串数据,不需要任何同步操作。
- 当字符串不可变时,字符串常量池才有意义。字符串常量池的出现,可以减少创建相同字面量的字符串,让不同的引用指向池中同一个字符串,为运行时节约很多的堆内存。若字符串可变,字符串常量池失去意义,基于常量池的
String.intern()
方法也失效,每次创建新的字符串将在堆内开辟出新的空间,占据更多的内存。
🚁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;
}
其计算过程主要是对每一位进行遍历,用当前位数的 ascll 码值加上前面每位的累加和。
🪂为什么选择 31*h?
- 乘法运算可以被移位和减法运算取代,来获取更好的性能:
31 * i == (i << 5) - i
,现代的 Java 虚拟机可以自动的完成这个优化。 - 尽量减少 hash 冲突。
选择数字31是因为它是一个奇质数,如果选择一个偶数会在乘法运算中产生溢出,导致数值信息丢失,因为乘二相当于移位运算。选择质数的优势并不是特别的明显,但这是一个传统。同时,数字31有一个很好的特性,即
🚀重载和重写的区别
重载和重写都是多态的一种表现形式。
重载:
- 重载是在编译期通过方法中形参的静态类型确定调用方法版本的过程。
- 重载是多态在编译期的表现形式。
- 重载的判定只有两个条件(其他的条件都不能作为判定):1. 方法名相同。2. 形参列表不同。
重写:
- 重写在方法运行时,通过调用者的实际类型来确定调用的方法版本。(具体细说,就是子父类中的重写方法在对应的 class 文件常量池的位置相同,一旦子类没有重写,那么子类的实例就会沿着这个位置往上找,直到找到父类的同名方法)。
- 重写只发生在可见的实例方法中:
1. 静态方法不存在重写,形式上的重写只能说是隐藏。
2. 私有方法也不存在重写,父类中 private 的方法,子类中就算定义了,就是相当于一个新的方法。
3. 静态方法和实例方法不存在相互重写。 - 重写满足一个规则:两同两小一大
1. 两同:方法名和形参列表相同。
2. 两小:重写方法的返回值(引用类型)和抛出异常,要和被重写方法的返回值(引用类型)和抛出异常相同或者是其子类。注意,一旦返回值是基本数据类型,那么重写方法和被重写方法必须相同,且不存在自动拆装箱的问题。
3. 一大:重写方法的访问修饰符大于等于被重写方法的访问修饰符。
🚀单例与 static 的区别
-
static 在类加载时执行一次,其生命周期是随着类方法的执行完成而结束,其不需要再 new 对象,因为在类加载时就 new 对象了,所以 static 有更好的性能。
-
工具类适用于 static,因为有更好的访问效率(和状态有关的用单例模式,如游戏中全局的一些状态和变量)。
-
单例模式的灵活性更高,方法可以被重写,因为静态类都是静态方法,所以不能被重写。
-
如果是一个非常重的对象,单例模式可以懒加载,静态类就无法做到。
🚀谈谈接口和抽象类
接口和抽象类是支持抽象类定义的两种机制。
相同点:
- 都不能被实例化。
- 接口的实现类或抽象类的子类都只有实现了接口或抽象类中的方法后才能实例化。
不同点:
- 接口只有定义,不能有方法的实现,但 java 1.8 中可以定义 default 方法体,而抽象类可以有定义与实现,方法可在抽象类中实现。
- 接口强调特定功能的实现,而抽象类强调所属关系。
- 一个类可以实现多个接口,但一个类只能继承一个抽象类。所以,使用接口可以间接地实现多重继承。
- 接口方法默认修饰符是 public,抽象方法可以有 public、protected 和 default 这些修
饰符(抽象方法就是为了被重写所以不能使用 private`关键字修饰!)。 - 接口被用于常用的功能,便于日后维护和添加删除,而抽象类更倾向于充当公共类的角色,不适用于日后重新对立面的代码修改。从设计层面来说,抽象是对类的抽象,是一种模板设计,而接口是对行为的抽象,是一种行为的规范。
🚀hashCode() 与 equals()
🚁hashCode() 介绍
- hashCode() 的作用是获取哈希码,它实际上是返回一个
int
整数,这个哈希码的作用是确定该对象在哈希表中的索引位置。 - hashCode() 定义在 JDK 的
Object.java
中,Java 中的任何类都包含有hashCode()
函数。 - 散列表存储的是键值对(key-value),它的特点是:能根据 key 快速的检索出对应的 value.
🚁为什么要有 hashcode
以 ”HashSet“ 如何检查重复” 为例子来说明为什么要有 hashcode:
-
对象加入 HashSet 时,HashSet 会先计算对象的 hashcode 值来判断对象加入的位置,看该位置是否有值:
-
如果没有,HashSet 会假设对象没有重复出现,但是如果发现有值,这时就会调用 equals() 方法来检查两个对象是否真的相同:
-
如果两者相同,HashSet 就不会让其加入,操作失败。如果不同的话,就会重新散列到其他位置,这样就会大大减少了 equals 的次数,相应就大大提高了执行速度。
-
两个对象相等,则 hashcode 一定也是相同的。
-
两个对象相等,对两个对象分别调用 equals 方法都返回 true.
-
两个对象的 hashcode 值相同,它们不一定是相等的。
-
因此,如果 equals() 方法被覆盖过,则 hashCode() 方法也必须被覆盖。
-
hashCode() 的默认行为是对堆上的对象产生独特值,如果没有重写 hashCode(),则该类的两个对象无论如何都不会相等(即使这两个对象指向相同的数据)。
🚁hashCode() 和 equals() 的关系
hashCode() 用于获取哈希码,eauqls() 用于比较两个对象是否相等,它们应遵守如下规定:
- 如果两个对象相等,则它们必须有相同的哈希码
- 如果两个对象有相同的哈希码,则它们未必相等
🚁为什么要重写 hashCode() 和 equals()
- Object 类提供的 equals() 方法默认是用 == 来进行比较的,也就是说只有两个对象是同一个对象时,才能返回相等的结果。
- 而实际的业务中的需求通常是,若两个不同的对象它们的内容是相同的,就认为它们相等。鉴于这种情况,Object 类中 equals() 方法的默认实现是没有实用价值的,所以通常都要重写。
- 由于 hashCode() 与 equals() 具有联动关系,所以 equals() 方法重写时,通常也要将 hashCode() 进行重写,使得这两个方法始终满足相关的约定。
🚁== 和 equals 的区别
== 运算符:
- 作用于基本数据类型时,是比较两个数值是否相等
- 作用于引用数据类型时,是比较两个对象的内存地址是否相同,即判断它们是否为同一个对象
equals() 方法:
- equals() 方法不能作用于基本数据类型的变量
- 没有重写时,Object 默认以 == 来实现,即比较两个对象的内存地址是否相同
- 进行重写后,一般会按照对象的内容来进行比较,若两个对象内容相同则认为对象相等,否则认为对象不等
例如,String 类中对 equals() 方法进行了重写:
Object
public boolean equals(Object obj) {
return (this == obj);
}
String
public boolean equals(Object object) {
if (this == anObject) {
return true;
}
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;
}
上述代码可以看出,String 类中被复写的 equals() 方法其实是比较两个字符串的内容。
public class StringDemo {
public static void main(String args[]) {
String str1 = "Hello";
String str2 = new String("hello");
String str3 = str2;
System.out.println(str1==str2); // false
System.out.println(str1==str3); // faslse
System.out.println(str2==str3); // true
System.out.println(str1.equals(str2)); // true
System.out.println(str1.equals(str3)); // true
System.out.println(str2.equals(str3)); // true
}
}
🚁重写 equals() 方法的原则
- 自反性:对于任何非空参考值 x,x.equals(x) 应该返回 true
- 对称性:对于任何非空参考值 x 和 y,当且仅当 y.equals(x) 返回 true 时,x.equals(y) 才应返回 true
- 传递性:对于 x,y 和 z 的任何非空引用值,如果 x.equals(x) 返回 true,而 y.equals(z) 返回 true,则 x.equals(z) 应该返回 true.
- 一致性:对于任何非空引用值 x 和 y,只要未修改对象的 equals 比较中使用的信息,对x.equals(y) 的多次调用将始终返回 true 或始终返回 false.
- 对于任何非 null 参考值 x,x.equals(null) 应该返回 false
🚀集合
🚁Collection 和 Collections 区别?
Collection 是一个接口,它是 Set、List 等容器的父接口。
public interface Collection<E> extends Iterable<E> {
...
}
public interface Set<E> extends Collection<E> {
...
}
public interface List<E> extends Collection<E> {
....
}
Collections 是一个工具类,提供了一系列的静态方法来辅助容器操作,这些方法包括对容器的搜索、排序、线程安全化等等。
🚁Java 中的集合框架有哪些?
Java 集合框架主要包括两种类型的容器,一种是集合(Collection),存储一个元素集合,另一种是图(Map),存储键/值对映射。
🚁Map 家族继承实现关系
Map 家族的继承实现关系如下,注意一点就是顶层的 Map 接口与 Collection 接口是依赖关系:
关于 key 和 value 能否为 null 的问题:
Map 集合类 | Key | Value |
---|---|---|
HashMap | 允许为 null | 允许为 null |
TreeMap | 不允许为 null | 允许为 null |
ConcurrentHashMap | 不允许为 null | 不允许为 null |
🚁List 和 Set 的区别
- List: 有序,按对象进入的顺序保存对象,可重复,允许多个 null 元素对象,可以使用 iterator 取出所有元素,再逐一遍历,还可以使用 get(int index) 方法获取指定下标的元素。
- Set: 无序,不可重复,最多有一个 null 元素对象,取元素时只能用 iterator 接口取得所有元素,再逐一遍历各个元素,并没有提供下标访问的方法。
🚁ArrayList 和 LinkedList 区别
理解版:
- ArrayList: 基于
Object
数组实现的,连续内存存储,适合随机访问。扩容机制:因为数组长度固定,超过长度存数据需要新建数组,然后将旧数组的数据拷贝到新数组,如果不是尾部插入数据还会涉及到元素的移动(往后复制一份,插入新元素),使用尾插法并指定初始容量可以极大提高性能,甚至超过 LinkedList (需要创建大量的 node 对象)。 - LinkedList: 基于链表,可以存储在分散的内存中,适合做数据插入及删除操作,不适合查询,需要逐一遍历。值得注意的是,LinkedList 没有初始化大小,也没有扩容机制,就是直接在前面或者后面添加就可以了。
- 遍历 LinkedList 必须使用 迭代器(iterator) 不能使用 for 循环,因为每次 for 循环体内部通过 get(i) 取得某一元素时都需要对 list 重新进行遍历,性能消耗极大。
- 另外不要试图使用 indexOf 等返回元素索引,并利用其进行遍历,使用 indexOf 对 list 进行了遍历,当结果为空时会遍历整个列表。
速记版:
-
ArrayList 底层使用的是
Object[]
数组;LinkedList 底层使用的是双向链表数据结构。 -
ArrayList 增删慢、查询快,线程不安全,对元素必须连续存储。
-
LinkedList 增删快,查询慢,线程不安全,元素可以在内存中分散存储。
🪂说说 ArrayList 的扩容机制?
通过阅读 ArrayList 的源码我们可以发现当以无参数构造方法创建 ArrayList 时,实际上初始化赋值的是一个空数组。当真正对数组进行添加元素操作时,才真正分配容量。即向数组中添加第一个元素时,数组容量扩为 10. 当插入的元素个数大于当前容量时,就需要进行扩容了,**ArrayList 每次扩容之后容量都会变为原来的 1.5 倍。
🚁HashMap 的底层实现?扩容?是否线程安全?
(1)HashMap 的数据结构:
JDK 1.7 及之前的 HashMap 底层是数组和链表,采用头插法。这种实现方案有一个缺点就是,当 hash 冲突严重时,在桶上形成的链表会变得越来越长,这样在查询时的效率就会越来越低,其时间复杂度为O(N).
JDK 1.8 及以后 HashMap 底层是数组和链表(或红黑树),采用尾插法。当链表的存储的数据个数大于等于 8 的时候,不再采用链表存储,而采用了红黑树存储结构。这么做主要是在查询的时间复杂度上进行优化,链表的时间复杂度为 O(N),而红黑树一直是 O(logN),可以大大的提升查找性能。
🪂HashMap 的扩容机制是怎样的?
- 数组的初始容量为 16,而容量是以 2 的次方扩充的,一是为了提升性能使用足够大的数组,二是为了能使用位运算代替取模运算,提高了运算效率。
- 数组是否需要扩充是通过负载因子判断的,如果当前元素个数为数组容量的 0.75 时,就会扩充数组。这个 0.75 就是默认的负载因子,可由构造器传入。我们也可以设置大于 1 的负载因子,这样数组就不会扩充,牺牲性能,节省内存。
- 为了解决碰撞问题,数组中的元素是单向链表类型。当链表长度到达一个阈值(8),会将链表转换成红黑树提升性能。而当链表长度缩小到另一个阈值(7),又会将红黑树转换回单向链表提高性能。
- 检查链表长度转换成红黑树之前,还会先检测当前数组长度是否到达一个阈值(64),如果没有到达这个容量,会放弃转换,先去扩充数组。
🪂线程安全性:
HashMap 是线程不安全的,其主要体现:
- 在 jdk1.7 中,在多线程环境下,HashMap 执行
put
操作时会引起死循环,因为多线程会导致 HashMap 的 Entry 链表形成环形数据结构,一旦形成环形数据结构, Entry 的 next 节点永远不为空,就会产生死循环获取 Entry. - 在 jdk1.8 中,在多线程环境下,会发生数据覆盖的情况。
🪂HashMap 扩容的时候为什么是 2 的 n 次幂?
数组下标的计算方法是 (n - 1) & hash,取余(%)操作中如果除数是 2 的幂次,则等价于与其除数减一的与(&)操作(也就是说 hash%length==hash&(length-1) 的前提是 length 是 2 的 n 次方)。” 并且采用二进制位操作 &,相对于 % 能够提高运算效率,这就解释了 HashMap 的长度为什么是 2 的幂次方。
🪂HashMap 中的循环链表是如何产生的?
- 在多线程的情况下,当重新调整 HashMap 大小的时候,就会存在条件竞争,因为如果两个线程都发现 HashMap 需要重新调整大小了,它们会同时试着调整大小。
- 在调整大小的过程中,存储在链表中的元素的次序会反过来,因为移动到新的 bucket 位置的时候,HashMap 并不会将元素放在链表的尾部,而是放在头部,这是为了避免尾部遍历。如果条件竞争发生了,那么就会产生死循环了。
🪂HashMap 为什么用红黑树而不用 B 树?
- B/B+ 树多用于外存上时,因此 B/B+ 也被称为一个磁盘友好的数据结构。
- HashMap 本来是数组+链表的形式,链表由于其查找慢的特点,所以需要被查找效率更高的树结构来替换。如果用 B/B+ 树的话,在数据量不是很多的情况下,数据都会“挤在”一个结点里面,这个时候遍历效率就退化成了链表。
🪂HashMap 的 put 方法说一下
-
根据 key 计算出来 hash 值,然后
hash&length-1
运算得出数组下标。 -
如果数组下标元素为空,则将 key 和 value 封装为 Entry 对象(JDK1 .7 是 Entry 对象,JDK 1.8 是 Node 对象)并放入该位置。
-
如果数组下标位置元素不为空,则要分情况:
-
如果是在 JDK 1.7,则首先会判断是否需要扩容,如果要扩容就进行扩容,如果不需要扩容就生成 Entry 对象,并使用头插法添加到当前链表中。
-
如果是在 JDK 1.8 中,则会先判断当前位置上的节点的类型,看是红黑树还是链表 Node:
(a) 如果是红黑树 TreeNode,则将 key 和 value 封装为一个红黑树节点并添加到红黑树中去,在这个过程中会判断红黑树中是否存在当前 key,如果存在则更新 value.(b) 如果此位置上的 Node 对象是链表节点,则将 key 和 value 封装为一个 Node 并通过尾插法插入到链表的最后位置去,因为是尾插法,所以需要遍历链表,在遍历过程中会判断是否存在当前 key,如果存在则更新其 value,当遍历完链表后,将新的 Node 插入到链表中,插入到链表后,会看当前链表的节点个数,如果大于等于 8,则会将链表转为红黑树。
-
将 key 和 value 封装为 Node 插入到链表或红黑树后,再判断是否需要扩容,如果需要扩容,就结束 put 方法。
-
下图是 HashMap 的 put 和 get 方法流程:
源码(JDK 1.8 根据 key 值计算 hash 值的方法)
static final int hash(Object key) {
int h;
return (key == null) ? 0:(h = key.hashCode()) ^ (h >> 16);
}
源码(JDK 1.8)根据 hash 值计算出对应数组的下标的代码:
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,boolean evict) {
Node<K,V>[] tab;
Node<K,V> p;
int n, i;
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
if (e.hash == hash &&
((k = e.key) == key|| (key != null && key.equals(k))))
break;
p = e;
}
}
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
🪂追问3:HashMap源码中在计算 hash 值的时候为什么要右移 16 位?
让元素在 HashMap 中更加均匀的分布。
🚁Java 中线程安全的集合有哪些?
Vector:相比 ArrayList 多了个同步化机制(线程安全)。
Stack:栈,也是线程安全的,继承于 Vector.
HashTable:相比 HashMap 多了个线程安全。
Enumeration:枚举,相当于迭代器。
ConcurrentHashMap:是一种高效但是线程安全的集合。
🚁ConcurrentHashMap 的底层实现,它为什么是线程安全的?
- 首先将数据分为一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据时,其他段的数据也能被其他线程访问。
- ConcurrentHashMap 是由 Segment 数组结构和 HashEntry 数组结构组成。Segment 实现了 ReentrantLock,所以 Segment 是一种可重入锁,扮演锁的角色,HashEntry 用于存储键值对数据。
static class Segment<K,V> extends ReentrantLock implements Serializable {}
一个 ConcurrentHashMap 里包含一个 Segment 数组。Segment 的结构和 HashMap 类似,是一种数组和链表结构,一个 Segment 包含一个 HashEntry 数组,每个 HashEntry 是一个链表结构的元素,每个 Segment 守护着一个 HashEntry 数组里的元素,当对 HashEntry 数组的数据进行修改时,必须首先获得对应的 Segment 的锁。
在 JDK 1.8 中摒弃了 Segment 的概念,而是直接用 Node 数组+链表+红黑树的数据结构来实现,并发控制使用 synchronized 和 CAS 来操作,整个看起来就像是优化过且线程安全的 HashMap,虽然在 JDK1.8 中还能看到 Segment 的数据结构,但是已经简化了属性,只是为了兼容旧版本。
🚁ConcurrentHashMap 是如何分段分组的?
Segment 的 get 操作实现非常简单和高效,先经过一次散列,然后使用这个散列值通过散列运算定位到 Segment,再通过散列算法定位到元素。get 操作的高效之处在于整个 get 过程都不需要加锁,除非读到空的值才会加锁重读,原因就是将使用的共享变量定义成 volatile 类型。
当执行 put 操作时,会经历两个步骤:
- 判断是否需要扩容
- 定位到添加元素的位置,将其放入 HashEntry 数组中
插入过程会进行第一次 key 的 hash 来定位 Segment 的位置,如果该 Segment 还没有初始化,即通过 CAS 操作进行赋值,然后进行第二次 hash 操作,找到相应的 HashEntry 的位置,这里会利用继承过来的锁的特性,在将数据插入指定的 HashEntry 位置时(尾插法),会通过继承 ReentrantLock 的 tryLock() 方法尝试去获取锁,如果获取成功就直接插入相应的位置,如果已经有线程获取该 Segment 的锁,那当前线程会以自旋的方式去继续的调用 tryLock() 方法去获取锁,超过指定次数就挂起,等待唤醒。
🚁HashMap 和 HashTable 的区别?
1、 线程是否安全: HashMap 是非线程安全的,HashTable 是线程安全的,因为 HashTable 内部的方法基本都经过 synchronized 修饰。(如果你要保证线程安全的话就使用ConcurrentHashMap)。
2、 对 Null key 和 Null value 的支持: HashMap 可以存储 null 的 key 和 value,但 null 作为键只能有一个,null 作为值可以有多个。HashTable 不允许有 null 键和 null 值,否则会抛出 NullPointerException.
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
可以看到,当 key 为 null 时,它的 hashcode 返回值为 0,并不报错。
HashTable 源码:
public synchronized V put(K key, V value) {
// Make sure the value is not null
if (value == null) {
throw new NullPointerException();
}
// Makes sure the key is not already in the hashtable.
Entry<?,?> tab[] = table;
int hash = key.hashCode();
int index = (hash & 0x7FFFFFFF) % tab.length;
@SuppressWarnings("unchecked")
Entry<K,V> entry = (Entry<K,V>)tab[index];
for(; entry != null ; entry = entry.next) {
if ((entry.hash == hash) && entry.key.equals(key)) {
V old = entry.value;
entry.value = value;
return old;
}
}
addEntry(hash, key, value, index);
return null;
}
可以看到,当 value 为 null 时,抛出 NullPointerException;当 key 为 null 时,在调用 hashCode() 方法时,也会抛出 NullPointerException.
3、初始容量大小和每次扩充容量大小的不同 :
- 创建时如果不指定容量初始值,HashTable 默认的初始大小为 11,之后每次扩充,容量变为原来的 2n+1. HashMap 默认的初始化大小为 16,之后每次扩充,容量变为原来的 2 倍。
- 创建时如果给定了容量初始值,那么 HashTable 会直接使用你给定的大小,而 HashMap 会将其扩充为 2 的幂次方大小(HashMap 中的 tableSizeFor() 方法保证,下面给出了源代码)。例如,指定初始容量为20,实际容量会变成32
HashMap 带有初始容量的构造函数源码如下:
public HashMap(int initialCapacity, float loadFactor) {
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
this.loadFactor = loadFactor;
this.threshold = tableSizeFor(initialCapacity);
}
tableSizeFor 方法源码:
/**
* Returns a power of two size for the given target capacity.
*/
static final int tableSizeFor(int cap) {
int n = cap - 1;
n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
4、 底层数据结构: JDK 1.8 以后的 HashMap 在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树)时,将链表转化为红黑树,以减少搜索时间。HashTable 没有这样的机制。
5、效率: 因为线程安全的问题,HashMap 要比 HashTable 效率高一点。另外,HashTable基本被淘汰,不要在代码中使用它。
🚁HashMap 和 TreeMap 的区别?
- HashMap 是通过 hash 值进行快速查找的,HashMap 中的元素是没有顺序的。TreeMap 中所有的元素都是有某一固定顺序的,如果需要得到一个有序的结果,就应该使用 TreeMap.
- HashMap 和 TreeMap 都是线程不安全的。
- HashMap 继承 AbstractMap 类,覆盖了 hashcode() 和 equals() 方法,以确保两个相等的映射返回相同的哈希值。TreeMap 继承 SortedMap 类,它保持键的有序顺序。
- HashMap 基于 hash 表实现的,使用 HashMap 要求添加的键类明确定义了 hashcode() 和 equals() (可以重写该方法),为了优化 HashMap 的空间使用,可以调优初始容量和负载因子。TreeMap 基于红黑树实现的,TreeMap 就没有调优选项,因为红黑树总是处于平衡的状态。
- HashMap 适用于 Map 插入,删除,定位元素。TreeMap 适用于按自然顺序或自定义顺序遍历键(key).
🚁HashSet 的底层数据结构?
- HashSet 是基于 HashMap 实现的,默认构造函数是构建一个初始容量为 16,负载因子为 0.75 的 HashMap.
- 它封装了一个 HashMap 对象来存储所有的集合元素,所有放入 HashSet 中的集合元素实际上由 HashMap 的 key 来保存,而 HashMap 的 value 则存储了一个 PRESENT,它是一个静态的 Object 对象。
private transient HashMap<E,Object> map;
// Dummy value to associate with an Object in the backing Map
private static final Object PRESENT = new Object();
public boolean add(E e) {
return map.put(e, PRESENT)==null;
}
🚀int 和 Integer 哪个会占用更多的内存?
Integer 对象会占用更多的内存,Integer 是一个对象,需要存储对象的元数据,但是 int 是一个基本数据类型的数据,所以占用的空间更少。
int 本身没有空值,定义出来时候初始值为 0,但是在数据库操作的时候,有可能数据的值是空的,因此封装为 Integer,它允许有 null 值。
🚀Java 中 ++ 操作符是线程安全的吗?
不是线程安全的操作,它涉及多个指令,如读取变量值,增加,然后存储回内存,这个过程可能出现多线程交错从而导致值的不正确。
🚁追问:Serializable 接口为什么需要定义 serialVersionUID 常量?
serialVersionUID 代表序列化的版本,通过定义类的序列化版本,在反序列化时,只要对象中所存的版本和当前类的版本一致,就允许做恢复数据的操作,否则将会抛出序列化版本不一致的错误。
如果不定义序列化版本,在反序列化时可能出现冲突的情况,如:
- 创建该类的实例,并将这个实例序列化,保存在磁盘上。
- 升级这个类,例如增加、删除、修改这个类的成员变量;
- 反序列化该类的实例,即从磁盘上恢复修改之前保存的数据。
在第 3 步恢复数据的时候,当前的类已经和序列化的数据的格式产生了冲突,可能会发生各种意想不到的问题。增加了序列化版本之后,在这种情况下则可以抛出异常,以提示这种矛盾的存在,提高数据的安全性。
🚀什么是字节码?采用字节码的好处是什么?
Java 中的编译器和解释器:
- Java 中引入了虚拟机的概念,即在机器和编译程序之间加入了一层抽象的虚拟的机器。这台虚拟的机器在任何平台上都提供给编译程序一个共同的接口。
- 编译程序只需要面向虚拟机,生成虚拟机能够理解的代码,然后由解释器来将虚拟机代码转换为特定系统的机器码执行。在 Java 中,这种虚拟机理解的代码叫做字节码(即扩展名为 .class 的文件),它不面向任何特定的处理器,只面向虚拟机。
- 每一种平台的解释器是不同的,但是实现的虚拟机是相同的,Java 源程序经过编译器编译后变成字节码,字节码由虚拟机解释执行,虚拟机将每一条要执行的字节码送给解释器,解释器将其翻译成特定机器上的机器码,然后在特定的机器上运行。这也就解释了 Java 的编译与解释并存的特点。
Java 源代码 ----> 编译器 -----> jvm 可执行的 java 字节码(即虚拟指令)-----> jvm -----> jvm 中的解释器 -----> 机器可执行的二进制机器码 -----> 程序运行
采用字节码的好处:
- Java 语言通过字节码的方式,在一定程度上解决了传统解释型语言执行效率的问题,同时又保留了解释型语言可移植的特点。
- Java 程序运行时比较高效,由于字节码并不针对一种特定的机器,Java 程序无须重新编译便可在多种不同的计算机上运行。
🚀异常
🚁Java 中的异常体系
Java 中的所有异常都来自顶级父类 Throwable. Throwable 下有两个子类 Error 和 Exception:
- Error 是错误,一般是指与虚拟机相关的问题,如系统崩溃、虚拟机错误、动态链接失败(
OutOfMemoryError
、StackOverFlowError
、VirtualMachineError
、AssertionError
.)等,这种错误无法恢复或不可能捕获,将导致应用程序中断。 - Exception 不会导致程序停止,又分为两个部分 RunTimeException 运行时异常和 CheckedException 检查异常。
- RunTimeException 常常发生在程序运行过程中,会导致程序当前线程执行失败。CheckedException 常常发生在程序编译过程中,会导致程序编译不通过。
- 粉红色的是受检查的异常(checked exceptions),其必须被
try{} catch
语句块所捕获,或者在方法签名里通过throws
子句声明。受检查的异常必须在编译时被捕捉处理,命名为 CheckedException 是因为Java 编译器要进行检查,Java 虚拟机也要进行检查,以确保这个规则得到遵守。 - 绿色的异常是运行时异常 (Runtime Exceptions),需要程序员自己分析代码决定是否捕获和处理,比如空指针,被 0 除…
- 而声明为 Error 的,则属于严重错误,如系统崩溃、虚拟机错误、动态链接失败等,这些错误无法恢复或者不可能捕捉,将导致应用程序中断,Error 不需要捕捉。
🪂追问1:异常的处理方式?
异常处理方式有抛出异常和使用 try catch
语句块捕获异常两种方式。
- 抛出异常:遇到异常时不进行具体的处理,直接将异常抛给调用者,让调用者自己根据情况处理。抛出异常的三种形式:throws、throw 和系统自动抛出异常。其中 throws 作用在方法上,用于定义方法可能抛出的异常;throw 作用在方法内,表示明确抛出一个异常。
- 使用 try catch 捕获并处理异常:使用
try catch
捕获异常能够有针对性的处理每种可能出现的异常,并在捕获到异常后根据不同的情况做不同的处理。其使用过程比较简单:用try catch
语句块将可能出现异常的代码抱起来即可。
🚁throws 和 throw
- throws 出现在方法头,throw 出现在方法体。
- throws 表示出现异常的一种可能性,并不一定会发生异常;throw 则是抛出了异常,执行throw 则一定抛出了某种异常。
- 两者都是消极的异常处理方式,只是抛出或者可能抛出异常,是不会由函数处理,真正的处理异常由它的上层调用处理。
🚀Java 中的深拷贝和浅拷贝说一下?
深拷贝和浅拷贝都是对象拷贝。
浅拷贝:按位拷贝对象,它会创建一个新对象,这个对象有着原始对象属性值的一份精确拷贝。如果属性是基本类型,拷贝的就是基本类型的值。如果属性是内存地址(引用类型),拷贝的就是内存地址 ,因此如果其中一个对象改变了这个地址,就会影响到另一个对象。(浅拷贝仅仅复制所考虑的对象,而不复制它所引用的对象)。
上图: 两个引用 student1 和 student2 指向不同的两个对象,但是两个引用 student1 和student2 中的两个 teacher 引用指向的是同一个对象,所以说明是浅拷贝。
深拷贝:在拷贝引用类型成员变量时,为引用类型的数据成员另辟了一个独立的内存空间,实现真正内容上的拷贝。(深拷贝把要复制的对象所引用的对象都复制了一遍)
上图:两个引用 student1 和 student2 指向不同的两个对象,两个引用 student1 和 student2 中的两个 teacher 引用指向的是两个对象,但对 teacher 对象的修改只能影响 student1 对象,所以说是深拷贝。
浅拷贝:对基本数据类型进行值传递,对引用数据类型进行引用传递般的拷贝,此为浅拷贝。
深拷贝:对基本数据类型进行值传递,对引用数据类型,创建一个新的对象,并复制其内容,此为深拷贝。
🚁追问1:浅拷贝与深拷贝的特点是什么?
浅拷贝特点:
- 对于基本数据类型的成员对象,因为基础数据类型是值传递的,所以是直接将属性值赋值给新的对象。基础类型的拷贝,其中一个对象修改该值,不会影响另外一个。
- 对于引用类型,比如数组或者类对象,因为引用类型是引用传递,所以浅拷贝只是把内存地址赋值给了成员变量,它们指向了同一内存空间。改变其中一个,会对另外一个也产生影响。
深拷贝特点:
- 对于基本数据类型的成员对象,因为基础数据类型是值传递的,所以是直接将属性值赋值给新的对象。基础类型的拷贝,其中一个对象修改该值,不会影响另外一个(和浅拷贝一样)。
- 对于引用类型,比如数组或者类对象,深拷贝会新建一个对象空间,然后拷贝里面的内容,所以它们指向了不同的内存空间。改变其中一个,不会对另外一个也产生影响。
- 对于有多层对象的,每个对象都需要实现 Cloneable 并重写 clone() 方法,进而实现了对象的串行层层拷贝。
- 深拷贝相比于浅拷贝速度较慢并且花销较大。
🚁值传递和引用传递的区别?
- 值传递:是指在调用函数时将实际参数复制一份传递到函数中,这样在函数中如果对参数进行修改,将不会影响到实际参数。
- 引用传递:是指在调用函数时将实际参数的地址传递到函数中,那么在函数中对参数所进行的修改,将影响到实际参数。
Java 中传递引用数据类型的时候也是值传递,复制的是参数的引用(地址值),并不是引用指向的存在于堆内存中的实际对象。
🚀Java 中的基本数据类型有哪些?
Java 中的四类八种基本数据类型:
第一类:整数类型 byte(1 byte) short(2 byte) int(4 byte) long(8 byte)
第二类:浮点型 float(4 byte) double(8 byte)
第三类:布尔型 boolean(1 bit)
第四类:字符型 char(2 byte)
🚀谈谈全局变量和局部变量的区别?
Java 中没有全局变量的说法,只有成员变量和局部变量,这里的成员变量就相当于 C 语言中的全局变量。
成员变量和局部变量的区别:
- 成员变量是在类的范围里定义的变量,局部变量是在方法中定义的变量。
- 成员变量有默认初始值,局部变量没有默认初始值。
- 未被
static
修饰的成员变量叫实例变量,它存储于对象所在的堆内存中,生命周期与对象相同;被static
修饰的成员变量叫类变量,它存储于方法区中,生命周期与当前类相同。局部变量存储于栈内存中,作用的范围结束,变量空间会自动的释放
🚀Java 中抽象类和接口中方法的默认访问权限?
关于抽象类
JDK 1.8 以前,抽象类的方法默认访问权限为 protected.
JDK 1.8 及以后,抽象类的方法默认访问权限变为 default.
关于接口
JDK 1.8 以前,接口中的方法必须是 public 的。
JDK 1.8 时,接口中的方法可以是 public 的,也可以是 default 的。
JDK 1.9 时,接口中的方法可以是 private 的。
🚀Object 类中的方法有哪些
Object 类中方法:
- protected Object clone() 创建并返回此对象的一个副本。
- boolean equals(Object obj) 指示其他某个对象是否与此对象“相等”。
- protected void finalize() 当垃圾回收器确定不存在对该对象的更多引用时,由对象的垃圾回收器调用此方法。
- class getClass() 返回此 Object 的运行时类。
- int hashCode() 返回该对象的哈希码值。
- void notify() 唤醒在此对象监视器上等待的单个线程。
- void notifyAll() 唤醒在此对象监视器上等待的所有线程。
- String toString() 返回该对象的字符串表示。
- void wait() 在其他线程调用此对象的 notify() 方法或 notifyAll() 方法前,导致当前线程等待。
- void wait(long timeout) 在其他线程调用此对象的 notify() 方法或 notifyAll() 方法,或者超过指定的时间量前,导致当前线程等待。
- void wait(long timeout, int nanos) 在其他线程调用此对象的 notify() 方法或 notifyAll() 方法,或者其他某个线程中断当前线程,或者已超过某个实际时间量前,导致当前线程等待。
🚀IO
🚁字节流和字符流
Java 的流操作分为字节流和字符流两种:
字节流与字符流主要的区别是他们的处理方式:
- 字节流是最基本的,所有的
InputStream
和OutputStream
的子类都是,主要用在处理二进制数据,它是按字节来处理的。但实际中很多的数据是文本,又提出了字符流的概念,它是按虚拟机的编码来处理,也就是要进行字符集的转化。 - 这两个之间通过
InputStreamReader
,OutputStreamWriter
来关联,实际上是通过byte[]
和String
来关联。
在实际开发中出现的汉字问题实际上都是在字符流和字节流之间转化不统一而造成的:
- 字节流---->字符流实际上就是
byte[]
转化为String
时,public String(byte bytes[], String charsetName)
有一个关键的参数字符集编码,通常我们都省略了,那系统就用操作系统的lang
. - 字符流---->字节流实际上是
String
转化为byte[]
时,byte[] String.getBytes(String charsetName)
也是一样的道理。至于java.io
中还出现了许多其他的流,按主要是为了提高性能和使用方便,如BufferedInputStream
,PipedInputStream
等。
需要知道的知识点:
- 对于 GBK 编码标准,英文占用 1 个字节,中文占用 2 个字节。
- 对于 UTF-8 编码标准,英文占用 1 个字节,中文占用 3 个字节。
- 对于 Unicode 编码标准,英文中文都是 2 个字节。这也是为什么叫做统一编码(Unicode).
🚁怎么使用流打开一个大文件?
打开大文件,应避免直接将文件中的数据全部读取到内存中,可以采用分次读取的方式:
- 使用缓冲流。缓冲流内部维护了一个缓冲区,通过与缓冲区的交互,减少与设备的交互次数。使用缓冲输入流时,它每次会读取一批数据将缓冲区填满,每次调用读取方法并不是直接从设备取值,而是从缓冲区取值,当缓冲区为空时,它会再一次读取数据,将缓冲区填满。 使用缓冲输出流时,每次调用写入方法并不是直接写入到设备,而是写入缓冲区,当缓冲区填满时它会自动刷入设备。
- 使用 NIO. NIO 采用内存映射文件的方式来处理输入/输出,NIO 将文件或文件的一段区域映射到内存中,这样就可以像访问内存一样来访问文件了(这种方式模拟了操作系统上的虚拟内存的概念),通过这种方式来进行输入/输出比传统的输入/输出要快得多。
🚁BIO 和 NIO 的区别
- BIO 以流的方式处理数据,而 NIO 以块的方式处理数据,IO 块比 IO 流效率更高
- BIO 是阻塞的,NIO 是非阻塞的
- BIO 基于字节流和字符流进行操作,NIO 基于通道(Channel)和缓存区(Buffer)进行操作,数据总是从通道读取到缓存区中,或者从缓存区写入到通道中
🚁谈谈 NIO 的实现原理
Java 的 NIO 主要由三个核心部分组成:Channel、Buffer、Selector.
- 数据可以从 Channel 读到 Buffer 中,也可以从 Buffer 写到 Channel 中。
- Buffer 本质上是一块可以写入数据,然后可以从中读取数据的内存。这块内存被包装成 NIO Buffer 对象,并提供了一组方法,用来方便的访问该块内存。Buffer 对象包含三个重要的属性,分别是 capacity、position、limit,其中 position 和 limit 的含义取决于 Buffer 处在读模式还是写模式。但不管 Buffer 处在什么模式,capacity 的含义总是一样的。
- Selector 允许单线程处理多个 Channel,如果你的应用打开了多个连接(通道),但每个连接的流量都很低,使用 Selector 就会很方便。要使用 Selector,得向 Selector 注册 Channel,然后调用它的 select() 方法。这个方法会一直阻塞到某个注册的通道有事件就绪。一旦这个方法返回,线程就可以处理这些事件,事件例如有新连接进来,数据接收等。
JDK 在 Linux 已经默认使用 epoll 方式,但是 JDK 的 epoll 采用的是水平触发,所以 Netty 自4.0.16 起, Netty 为 Linux 通过 JNI 的方式提供了 native socket transport.
Netty 重新实现了 epoll 机制:
- 采用边缘触发方式
- netty epoll transport 暴露了更多的 nio 没有的配置参数,如
TCP_CORK, SO_REUSEADDR
等等 - C 代码,更少 GC,更少 synchronized.
🚁Java 反射机制
定义:所谓反射机制是指在程序运行的过程中,对任意一个类都能获取其属性和方法,并且对任意一个对象都能调用其任意的一个方法。
反射的 API:
Java 中有些反射的 API,比较常用的是获取属性和方法。主要是在程序运行过程中动态的获取类、接口或对象等信息。
- Class 类:用于获取类的属性、方法等信息。
- Field 类:表示类的成员变量,用于获取和设置类中的属性值。
- Method 类:表示类的方法,用于获取方法的描述信息或者执行某个方法。
- Constructor 类:表示类的构造方法。
反射的步骤:
-
获取类的 Class 对象,这是反射的核心!因为通过它可以获取类的属性和方法。
-
调用 Class 对象所对应的类中定义的方法,这是反射的使用阶段。
-
使用反射 API 来获取并调用类的属性和方法等信息。
普通的 Java 对象是通过 new 关键字把对应类的字节码文件加载到内存,然后创建该对象的。反射是通过一个名为 Class 的特殊类,用 Class.forName("className")
得到类的字节码对象,然后用 newInstance()
方法在虚拟机内部构造这个对象(针对无参构造函数)。也就是说反射机制让我们可以先拿到 Java 类对应的字节码对象,然后动态的进行任何可能的操作,包括:
- 在运行时判断任意一个对象所属的类
- 在运行时构造任意一个类的对象
- 在运行时判断任意一个类所具有的成员变量和方法
- 在运行时调用任意一个对象的方法
这些都是反射的功能。使用反射的主要作用是方便程序的扩展。
获取 Class 对象的方式:
- 知道具体类的情况下可以使用
A.class
. - 通过
Class.forName()
传入类的全路径获取,Class.forName("com.readthrough")
. - 通过类加载器
xxxClassLoader.loadClass()
传入类路径获取,ClassLoader.getSystemClassLoader().loadClass("com.readthrough")
. - 通过对象实例
A.getClass()
获取。
🚁Java 反射在实际项目中有哪些应用场景?
- 使用 JDBC 时,如果要创建数据库的连接,则需要先通过反射机制加载数据库的驱动程序。
- 多数框架都支持注解/XML 配置,从配置中解析出来的类是字符串,需要利用反射机制实例化。
- 面向切面编程 (AOP) 的实现方案,是在程序运行时创建目标对象的代理类,这必须由反射机制来实现。
🚀Java 的关键字、保留字
Java 中有 48 个关键字:
2 个保留字:goto、const
3 个直接量:false、true、null 都不是关键字,叫做直接量!
🚀阐述成员变量和局部变量的区别?
- 成员变量是在类的范围里定义的变量,局部变量是在方法里定义的变量
- 成员变量有默认初始值,局部变量没有默认的初始值
- 未被 static 修饰的成员变量也叫实例变量,它存储于对象所在的堆内存中,生命周期与对象相同,被 static 修饰的成员变量叫类变量,存储在方法区中,生命周期与当前类相同。局部变量存储于栈内存中,作用的范围结束,变量空间会自动的释放。
🚀Java 对象初始化顺序
Java 对象初始化顺序:
父类静态代码块,父类静态成员变量(同级,按代码顺序执行)
子类静态代码块,子类静态成员变量(同级,按代码顺序执行)
父类普通代码块,父类普通成员变量(同级,按代码顺序执行)
父类构造方法
子类普通代码块,子类普通成员变量(同级,按代码顺序执行)
子类构造方法
注意点:
- 静态内容只在类加载时执行一次,之后不再执行。
- 默认调用父类的无参构造方法,可以在子类构造方法中利用 super 指定调用父类的哪个构造方法。
🚀Java 内部类
🚁为什么使用内部类?
使用内部类最吸引人的原因是:每个内部类都能独立地继承一个接口的实现,所以无论外围类是否已经继承了某个接口的实现,对于内部类都没有影响。
使用内部类最大的优点就在于它能够非常好的解决多重继承的问题,使用内部类还能够为我们带来如下特性:
- 内部类可以用多个实例,每个实例都有自己的状态信息,并且与其他外围对象的信息相互独立。
- 在单个外围类中,可以让多个内部类以不同的方式实现同一个接口,或者继承同一个类。
- 创建内部类对象的时刻并不依赖于外围类对象的创建。
- 内部类并没有令人迷惑的 “is-a” 关系,它就是一个独立的实体。
- 内部类提供了更好的封装,除了该外围类,其他类都不能访问。
🚁内部类分类
🪂成员内部类
public class Outer{
private int age = 99;
String name = "Coco";
public class Inner{
String name = "Jayden";
public void show(){
System.out.println(Outer.this.name);
System.out.println(name);
System.out.println(age);
}
}
public Inner getInnerClass(){
return new Inner();
}
public static void main(String[] args){
Outer o = new Outer();
Inner in = o.new Inner();
in.show();
}
}
(1)Inner 类定义在 Outer 类的内部,相当于 Outer 类的一个成员变量的位置,Inner 类可以使用任意访问控制符,如 public 、 protected 、 private 等。
(2)Inner 类中定义的 show() 方法可以直接访问 Outer 类中的数据,而不受访问控制符的影响,如直接访问 Outer 类中的私有属性 age.
(3)定义了成员内部类后,必须使用外部类对象来创建内部类对象,而不能直接去 new 一个内部类对象, 即:内部类 对象名 = 外部类对象.new 内部类( );。
(4)编译上面的程序后,会发现产生了两个 .class 文件: Outer.class, Outer$Inner.class{}
(5)成员内部类中不能存在任何 static 的变量和方法,可以定义常量。
- 因为非静态内部类是要依赖于外部类的实例,而静态变量和方法是不依赖于对象的,仅与类相关。即在加载静态域时,根本没有外部类,所在在非静态内部类中不能定义静态域或方法,编译不通过,非静态内部类的作用域是实例级别。
- 常量是在编译器就确定的,放到所谓的常量池了。
注意:
- 外部类是不能直接使用内部类的成员和方法的,可先创建内部类的对象,然后通过内部类的对象来访问其成员变量和方法。
- 如果外部类和内部类具有相同的成员变量或方法,内部类默认访问自己的成员变量或方法,如果要访问外部类的成员变量,可以使用 this 关键字,如: Outer.this.name.
🪂静态内部类(static 修饰的内部类)
- 静态内部类不能直接访问外部类的非静态成员,但可以通过 new 外部类().成员的方式访问。
- 如果外部类的静态成员与内部类的成员名称相同,可通过“类名.静态成员”访问外部类的静态成员;如果外部类的静态成员与内部类的成员名称不相同,则可通过“成员名”直接调用外部类的静态成员。
- 创建静态内部类的对象时,不需要外部类的对象,可以直接创建 内部类 对象名 = new 内部类();
public class Outer{
private int age = 99;
static String name = "Coco";
public static class Inner {
String name = "Jayden";
public void show(){
System.out.println(Outer.name);
System.out.println(name);
}
}
public static void main(String[] args) {
Inner i = new Inner();
i.show();
}
}
🪂局部内部类(其作用域仅限于方法内,方法外部无法访问该内部类)
(1) 局部内部类就像是方法里面的一个局部变量一样,是不能有 public、protected、private 以及 static 修饰符的。
(2) 只能访问方法中定义的 final 类型的局部变量,因为当方法被调用运行完毕之后,局部变量就已消亡了。但内部类对象可能还存在,直到没有被引用时才会消亡。此时就会出现一种情况,就是内部类要访问一个不存在的局部变量。
==> 使用 final 修饰符不仅会保持对象的引用不会改变,而且编译器还会持续维护这个对象在回调方法中的生命周期。
局部内部类并不是直接调用方法传进来的参数,而是内部类将传进来的参数通过自己的构造器备份到了自己的内部,自己内部的方法调用的实际是自己的属性而不是外部类方法的参数。防止被篡改数据,而导致内部类得到的值不一致
/*
使用的形参为何要为 final
在内部类中的属性和外部方法的参数两者从外表上看是同一个东西,但实际上却不是,所以他们两者是可以任意变化的,
也就是说在内部类中我对属性的改变并不会影响到外部的形参,而然这从程序员的角度来看这是不可行的,
毕竟站在程序的角度来看这两个根本就是同一个,如果内部类该变了,而外部方法的形参却没有改变这是难以理解
和不可接受的,所以为了保持参数的一致性,就规定使用 final 来避免形参的不改变
*/
public class Outer{
public void Show(){
final int a = 25;
int b = 13;
class Inner{
int c = 2;
public void print(){
System.out.println("访问外部类:" + a);
System.out.println("访问内部类:" + c);
}
}
Inner i = new Inner();
i.print();
}
public static void main(String[] args){
Outer o = new Outer();
o.show();
}
}
(3) 在 JDK 1.8 版本之中,方法内部类中调用方法中的局部变量,可以不需要修饰为 final,匿名内部类也是一样的,主要是 JDK8 之后增加了 Effectively final 功能。
反编译 jdk 1.8 编译之后的 class 文件,发现内部类引用外部的局部变量都是 final 修饰的。
🪂匿名内部类
(1) 匿名内部类是直接使用 new 来生成一个对象的引用。
(2) 对于匿名内部类的使用它是存在一个缺陷的,就是它仅能被使用一次,创建匿名内部类时它会立即创建一个该类的实例,该类的定义会立即消失,所以匿名内部类是不能够被重复使用。
(3) 使用匿名内部类时,我们必须是继承一个类或者实现一个接口,但是两者不可兼得,同时也只能继承一个类或者实现一个接口。
(4) 匿名内部类中是不能定义构造函数的,匿名内部类中不能存在任何的静态成员变量和静态方法。
(5) 匿名内部类中不能存在任何的静态成员变量和静态方法,匿名内部类不能是抽象的,它必须要实现继承的类或者实现的接口的所有抽象方法。
(6) 匿名内部类初始化:使用构造代码块!利用构造代码块能够达到为匿名内部类创建一个构造器的效果。
public class OuterClass {
public InnerClass getInnerClass(final int num,String str2){
return new InnerClass(){
int number = num + 3;
public int getNumber(){
return number;
}
}; /* 注意:分号不能省 */
}
public static void main(String[] args) {
OuterClass out = new OuterClass();
InnerClass inner = out.getInnerClass(2, "chenssy");
System.out.println(inner.getNumber());
}
}
interface InnerClass {
int getNumber();
}
🚁思维导图总结
🚀关于接口中的属性和方法
接口中的属性在不提供修饰符修饰的情况下,会自动加上 public static final
注意(在 jdk 1.8 的编译器下可试):
- 属性不能用 private,protected,default 修饰,因为默认是 public
- 如果属性是基本数据类型,需要赋初始值,若是引用类型,也需要初始化,因为默认有 final 修饰,必须赋初始值
- 接口中常规的来说不能够定义方法体,所以无法通过 get 和 set 方法获取属性值,所以属性不属于对象,属于类(接口),因为默认使用 static 修饰。
🚀Java 的体系结构
Java 体系结构包括四个独立但相关的技术:
- Java 程序设计语言
- Java.class 文件格式
- Java 应用编程接口(API)
- Java 虚拟机
四者之间的关系:
当我们编写并运行一个 Java 程序时,就同时运用了这四种技术,用 Java 程序设计语言编写源代码,把它编译成 Java.class 文件格式,然后再在 Java 虚拟机中运行 class 文件。当程序运行的时候,它通过调用 class 文件实现了 Java API 的方法来满足程序的 Java API 调用。
🚀Java 中的访问修饰符
public:可以被所有其他类所访问。
private:只能被自己访问和修改。
protected:自身、子类及同一个包中类可以访问。
default(包访问权限):同一包中的类可以访问,声明时没有加修饰符,认为是 friendly.
访问权限控制从大到小依次为:public > protected > default(包访问权限)>private
🚁怎么获取 private 修饰的变量
通过调用对象的 get() 方法。
🚀谈谈对泛型的理解?
Java集合有个缺点把一个对象“丢进”集合里之后,集合就会“忘记”这个对象的数据类型,当再次取出该对象时,该对象的编译类型就变成了 Object 类型(其运行时类型没变)。Java 集合之所以被设计成这样,是因为集合的设计者不知道我们会用集合来保存什么类型的对象,所以他们把集合设计成能保存任何类型的对象,只要求具有很好的通用性,但这样做带来如下两个问题:
- 集合对元素类型没有任何限制,这样可能引发一些问题。例如,想创建一个只能保存 Dog 对象的集合,但程序也可以轻易地将 Cat 对象“丢”进去,所以可能引发异常。
- 由于把对象“丢进”集合时,集合丢失了对象的状态信息,只知道它盛装的是 Object,因此取出集合元素后通常还需要进行强制类型转换。这种强制类型转换既增加了编程的复杂度,也可能引发 ClassCastException 异常。
从 Java 5 开始,Java 引入了“参数化类型”的概念,允许程序在创建集合时指定集合元素的类型,Java 的参数化类型被称为泛型(Generic)。例如, List<String>
,表明该 List 只能保存字符串类型的对象。
有了泛型以后,程序再也不能“不小心”地把其他对象“丢进”集合中。而且程序更加简洁,集合自动记住所有集合元素的数据类型,从而无须对集合元素进行强制类型转换。
🚀介绍一下泛型擦除?
在严格的泛型代码里,带泛型声明的类总应该带着类型参数。但为了与老的 Java 代码保持一致,也允许在使用带泛型声明的类时不指定实际的类型。如果没有为这个泛型类指定实际的类型,此时被称作 raw type(原始类型),默认是声明该泛型形参时指定的第一个上限类型。
当把一个具有泛型信息的对象赋给另一个没有泛型信息的变量时,所有在尖括号之间的类型信息都将被扔掉。比如一个 List<String>
类型被转换为 List,则该 List 对集合元素的类型检查变成了泛型参数的上限(即 Object)。
上述规则即为泛型擦除,可以通过下面代码进一步理解泛型擦除:
List<String> list1 = ...;
List list2 = list1; // list2将元素当做Object处理
🚀Java 中序列化和反序列化是什么?
序列化:将数据结构或对象转换成二进制字节流的过程。
反序列化:将在序列化过程中所生成的二进制字节流转换成数据结构或者对象的过程。
序列化和反序列化的应用场景:
- 对象在进行网络传输(比如远程方法调用 RPC 的时候)之前需要先被序列化,接收到序列化的对象之后需要再进行反序列化。
- 将对象存储到文件之前需要进行序列化,将对象从文件中读取出来需要进行反序列化。
- 将对象存储到数据库(如 Redis)之前需要用到序列化,将对象从缓存数据库中读取出来需要反序列化。
- 将对象存储到内存之前需要进行序列化,从内存中读取出来之后需要进行反序列化。
序列化的主要目的是通过网络传输对象或者说是将对象存储到文件系统、数据库、内存中。
由于 JDK 自带的序列化效率低并且存在安全问题,因此不建议使用。比较常用的序列化协议有 Hessian、Kryo、Protobuf、ProtoStuff,这些都是基于二进制的序列化协议。