java经典问题100问_Java 面试&基础100问(持续更新)

这不只是一篇面试题的汇总,也有自己在学习 Java 过程总结的比较重要的或容易模糊的知识点,故整理如下

1. 为什么说内部类会隐式持有外部类的引用

编译器会在编译阶段做4件事:

给内部类添加一个类型为外部类的字段;

给内部类的构造函数增加一个类型为外部类的参数;

在内部类的所有构造函数中增加初始化外部类字段的代码;

使用内部类的任何构造函数实例化内部类的地方,编译器都会为构造函数传入外部类的引用

这样就实现了内部类隐式持有外部类,在代码层面看不到,但是通过 javap 命令反编译,从字节码层面就能清晰看到,例如如下代码:

public class Outer {

class Inner{

}

}

反编译 Outer$Inner.class 的结果如图(省略常量池部分):

16f6d441b589

反编译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();

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) 。这也被叫做方法的 静态分派

16f6d441b589

反编译字节码

另外对于基本类型的重载需要单独说明一下,以 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 会调用到各自重写的方法,背后的原理还是从字节码角度说明比较清楚:

16f6d441b589

反编译字节码

第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时,该链表就转变成一颗红黑树,这样的查找效率就更高,一图胜千言:

16f6d441b589

http://coding-geek.com/how-does-a-hashmap-work-in-java/

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() 不成功,就不断尝试,直到成功再返回。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值