这不只是一篇面试题的汇总,也有自己在学习 Java 过程总结的比较重要的或容易模糊的知识点,故整理如下
1. 为什么说内部类会隐式持有外部类的引用
编译器会在编译阶段做4件事:
- 给内部类添加一个类型为外部类的字段;
- 给内部类的构造函数增加一个类型为外部类的参数;
- 在内部类的所有构造函数中增加初始化外部类字段的代码;
- 使用内部类的任何构造函数实例化内部类的地方,编译器都会为构造函数传入外部类的引用
这样就实现了内部类隐式持有外部类,在代码层面看不到,但是通过 javap 命令反编译,从字节码层面就能清晰看到,例如如下代码:
public class Outer {
class Inner{
}
}
反编译 Outer$Inner.class 的结果如图(省略常量池部分):
2. 为什么在方法中定义的内部类可以引用方法的局部变量?并且该局部变量必须为 final 类型?
其原理和上个问题类似,也是编译器在编译阶段对内部类进行了一些改造:
- 为内部类增加一个类型和所使用的局部变量相同的字段
- 为内部类的所有构造函数增加一个局部变量类型的参数
- 在内部类的所有构造函数中给添加的字段赋值,这个值即是所引用的外部方法的局部变量的值
简单的说,就是在方法中定义内部类时,如果引用了方法中的局部变量,那么编译器就会把该局部变量拷贝一份保存在内部类中。
而被引用的局部变量必须为 final 类型的原因也很清楚了,因为内部类只是拷贝了一份局部变量的值,如果之后局部变量发生改变,内部类是无法获知的,这样就可能出现不符合预期的结果。所以强制局部变量为 final 类型主要是为了在编译阶段就发现这种可能的错误。比如下面的代码,假设编译器没有强制局部变量为 final :
public class Outer {
Runnable runnable;
public Outer(){
int i = 1;
runnable = new Runnable(){
@Override
public void run() {
System.out.println(i);
}
};
i = 2; // 错误代码,编译器会报错,仅为说明问题
runnable.run();
}
}
我们可能会预期打印出的值为 2,但实际上 runnable 对象中保存的仅是 i 的一份拷贝,在定义之后对 i 的改变无法反映到 runnable 中。所以强制 i 为 final 类型,就确保了内部类和局部变量之间的一致性。
最后,在 Java8 中有一点变化,编译器变得更加智能,对于逻辑上和 final 类型等价的局部变量 可以不用强制声明为 final。简单说就是如果这个局部变量初始化之后,再没有改变其值的操作,那么不用声明为 final 也不会报错
3. Java 中的数组是对象么?有哪些特点?
Java 中的数组类型也是一种对象,从其具有 length 字段和 toString(),clone() 方法就能看出。数组对象的父类是 Object,所以以下代码都正确:
int[] array = new int[10];
//可以向上转型成 Object
Object obj = array ;
//可以向下转型成 int[]
int[] b = (int[])obj;
//可以用instanceof关键字进行
if(obj instanceof int[]){
...
}
数组还有一些令人迷惑的特性,比如下面这段代码:
String[] s = new String[5];
Object[] obja = s;
这段代码是正确的!而前面我们已经知道 String[] 是 Object 的子类,不可能也同时是 Object[] 的子类,不然就违反了单继承原则。只能把这个当作数组对象的一种特殊性质来理解了(背后原理还有待研究)。概括一下就是:
** 如果B继承(extends)了A,那么A[]类型的引用就可以指向B[]类型的对象。
**
另外这种用法不包括基本类型,这也很好理解,因为基本类型并不继承于 Object:
int[] a = new int[4];
//Object[] obja = a; //错误,不能通过编译
再看下面这段代码:
List list = new LinkedList<String>();
list.add("a");
// String[] strs = (String[]) list.toArray(); // 错误,运行时异常,无法强转
Object[] objs = list.toArray(new String[1]);
String[] strs = (String[]) objs; // 正确,可以强转
System.out.println(strs[0] );
List.toArray() 方法返回 Object[],无法强转成 String[],尽管其数组成员实际上都是 String 类型。而 List.toArray(T[] a) 方法返回 T[],在本例中也就是返回 String[],可以用一个 Object[] 类型的变量指向 String[],然后还能强转。
所以进一步总结就是:
一个类型为 Object[] 的数组对象,尽管其数组元素类型为 A, 但是也不能强转成 A[]。但是一个类型为 A[] 的数组对象,可以用 Object 或者 A的父类类型的数组 类型的变量来指向,并且可以再强转成 A[]。
4. Java 中对象的初始化顺序遵循怎样的规则?
- 先基类,后父类
- 先成员变量,后构造函数
- 先静态成员,后非静态成员
- 静态变量只在初次使用时初始化一次,之后不再执行
- 触发静态变量(或静态块)初始化的动作有:
- 使用 new 关键字实例化对象;
- 读取或设置一个类的静态字段;
- 调用一个类的静态方法
- 对类进行反射调用
- 初始化子类时,如果父类还未初始化,会触发父类的初始化
- 虚拟机启动时用户需要指定一个要执行的主类(包含 main() 函数的那个类),虚拟机会先初始化这个类
5. Java 虚拟机是怎样实现方法的重载(Overload)和重写(Override)的?
概括的说,方法的重载是在编译期确定,根据变量的静态类型决定要调用的方法,方法的重写是在运行时确定,根据变量的实际类型决定要调用的方法。
- 重载举例(引用自《深入理解 Java 虚拟机》):
public class StaticDispatch {
static abstract class Human {}
static class Man extends Human {}
static class Woman extends Human {}
public void sayHello(Human guy) {
System.out.println("hello,guy!");
}
public void sayHello(Man guy) {
System.out.println("hello,gentleman!");
}
public void sayHello(Woman guy) {
System.out.println("hello,lady!");
}
public static void main(String[] args) {
Human man = new Man();
Human woman = new Woman();
StaticDispatch sr = new StaticDispatch();
sr.sayHello(man);
sr.sayHello(woman);
}
}
变量 man 和 woman 的静态类型都是 Human,所以 sr.sayHello(man) 和 sr.sayHello(woman) 这两条语句,编译器在编译时就确定了调用的版本为sayHello(Human guy) ,这点通过反编译后字节码也可以看出,在第26和第31行的字节码可以看到,调用的方法已经确定为 sayHello(Human guy) 。这也被叫做方法的 静态分派
另外对于基本类型的重载需要单独说明一下,以 char 为例,其匹配重载方法的优先级是
char->int->long->float->double->Character->Serializable/Comparable(这两个优先级一样不能同时出现)-> Object。注意 byte-char-short 三者之间不能转型,因为 char 是无符号数,short 是有符号数,所以数据范围不同8
- 重写举例(引用自《深入理解 Java 虚拟机》):
public class DynamicDispatch {
static abstract class Human {
protected abstract void sayHello();
}
static class Man extends Human {
@Override
protected void sayHello() {
System.out.println("man say hello");
}
}
static class Woman extends Human {
@Override
protected void sayHello() {
System.out.println("woman say hello");
}
}
public static void main(String[] args) {
Human man = new Man();
Human woman = new Woman();
man.sayHello();
woman.sayHello();
man = new Woman();
man.sayHello();
}
}
显然这里 man 和 woman 会调用到各自重写的方法,背后的原理还是从字节码角度说明比较清楚:
第16、17行的 aload_1 和 invokevirtual 指令。aload_1 把刚刚创建的 Man 对象压到操作数栈顶。Java 虚拟机规定执行 invokevirtual 指令时,会找到操作数栈顶的第一个元素所指向的对象的实际类型(本例中就是 Man),在其类型定义中查找对应的方法,如果找到那么就返回该方法的直接引用,否则在其父类中查找。
6. HashMap 的实现原理(基于Android SDK 里的实现,与 OpenSDK 略有不同)
一句话概括,横向是一个 HashMapEntry 数组,纵向是一个 HashMapEntry 链表。另外有一个单独的 HashMapEntry 保存 Key == null 的元素
- Map 接口有个内部接口 Entry,它定义了 Map 的基本元素,键值对。HashMap 中实现 Entry 接口的内部类时 HashMapEntry
- HashMap 维护一个 HashMapEntry 的数组 table,初始化大小总是为2的n次幂
- put():
- 根据 Key 的 hashCode() 做二次hash计算出 Key 的hash值
- hash值取模数组长度,得到应该插入数组的位置 index
- 如果 index 位置不空,遍历 table[index] 为头的链表,查找是否有 Key 的 hash值相等且 equal() 为 true 的元素,如果有则返回旧值,保存新值
- 如果 table[index] == null,或者链表中未找到 Key 值相等的 Entry,那么size++(size > threshold 需要扩容,新建一个大小*2的数组,然后把之前的元素全部取出重新找到各自的位置),然后插入新 Entry 到数组 index 位置,新 Entry.next 指向之前的 table[index] (其实就是链表在头部的插入操作)
- get() 和 remove() 很简单,前两步跟 put() 一样,之后就是遍历链表根据 Key 查找。
- 遍历实现都基于 HashIterator.nextEntry() 方法,会从数组的第一个元素开始,按照先纵向后横向的顺序遍历
Java8 里的优化:HashMap 的实现在 Java8 里做了进一步的优化,当一个 index 下面的链表长度超过8时,该链表就转变成一颗红黑树,这样的查找效率就更高,一图胜千言:
7. 使用 AtomicInteger 和 使用 synchronized 实现对变量的原子操作有什么不同?
- synchronized 是阻塞式的,会导致线程上下文的切换,对于简单的赋值操作来说,代价太高。AtomicInteger 通过 CPU 对 CAS(compare and swap) 操作的原子性 以及 volatile 关键字实现了非阻塞式的原子操作,是非阻塞的,没有线程切换的开销
- synchronized 是悲观的,它假设一定会有竞争,所以会先获取锁再执行操作;AtomicInteger 是乐观的,它先尝试更新操作,如果当前值与期望值不等,则代表出现竞争,返回false,然后不断尝试直到成功
以 AtomicInteger.getAndIncrement() 为例,它实现了 i++ 的原子操作:
public final int getAndIncrement() {
for (;;) {
int current = get();
int next = current + 1;
if (compareAndSet(current, next))
return current;
}
}
可以看到有一个死循环(这也是自旋锁说法的由来),只要 compareAndSet() 不成功,就不断尝试,直到成功再返回。