美文网首页知识点
Java 基础面试题总结 - 草稿

Java 基础面试题总结 - 草稿

作者: 彭旭锐 | 来源:发表于2021-04-14 22:41 被阅读0次

    点赞关注,不再迷路,你的支持对我意义重大!

    🔥 Hi,我是丑丑。本文 「Java 路线」导读 —— 他山之石,可以攻玉 已收录,这里有 Android 进阶成长路线笔记 & 博客,欢迎跟着彭丑丑一起成长。(联系方式在 GitHub)


    前言

    面试季又来了,Java 基础知识又可以拿出来复习了~ “基础不牢,地动山摇”,这些内容不难,但必须要会,一起加油吧。


    1. 误区

    Java 是解释型语言还是编译型语言?

    所谓 “某个语言是编译型 / 解释型” 是个伪命题,编译型和解释型并不是一门编程语言的特性,而是语言实现的特性。我们经常会在文章或教科书上看到 “C 是编译型语言,因为 C 是编译执行的;Java 是解释型语言,因为 Java 是 JVM 解释执行的”,这些都是不准确的。更准确的说法是:“某个语言的特定实现是编译型还是解释型”。

    举个例子,在早期的 Android Dalvik 虚拟机只有解释器,效率低。为了优化运行效率,Android 2.2 引入 JIT 即时编译器,可以在运行时探测热点代码进行编译执行。后续的 Android 5.0 ART 虚拟机又推出了 AOT 提前编译,Android 7/0 ART 又引入了解释、JIT 和 AOT 混合的执行方式。可以看到,同一段代码既可以解释执行,也可以编译执行,这取决于语言的实现而不是语言本身。

    相关深入文章:《Android 虚拟机 | 从类加载到程序执行》


    C 语言是面向过程语言,Java 是面向对象编程语言?

    所谓 “某种语言是面向过程 / 面向对象 / 函数式” 是一个伪命题。经常会在文章或教科书上看到 “C 是面向过程语言,Java 是面向对象语言,C++ 是面向对象语言”,这种分类方法是没有意义的。举个例子,使用 Java 可以使用写出函数式风格的代码,也可以写出面向对象风格的代码。因此,这种分类方式对于编程没有意义。

    更科学的思考方式是把编程语言理解为一个个特性的组合 语言 = {特性1, 特性2, ..., 特性 N},学习一门语言应该把语言打散为一个个特性,把每个特性都理解透彻。那么,当你遇到一门新的语言,虽然语法有所不同,但是很多特性是相似的,这样就只需要专注于两门语言差集的特性,学习效率就高了。


    Java 是静态类型检查还是动态类型检查?

    静态 / 动态类型语言的区分,关键在于类型检查是否 (倾向于) 编译时执行。例如, Java & C/C++ 是静态类型检查,而 JavaScript 是动态类型检查。静态类型检查的优点是可以在编译期提前检查出可能出现的 bug。需要注意的是,这个定义并不是绝对的,例如 Java 也存在运行时类型检查的方式,例如上面提到的 checkcast 指令本质上是在运行时检查变量的类型与对象的类型是否相同。


    2. 类 & 对象

    为什么 Java 匿名内部类调用的局部变量需要声明 final?

    局部变量的作用域是从变量定义到代码块结束,在匿名内部类访问局部变量,其实是超出了局部变量的作用域。为了扩大变量的作用域,编译后的字节码实现是将局部变量的值通过构造函数传递到内部类内部,存储在成员变量。这就意味着局部变量的值存在两份拷贝,又因为 Java 赋值是值传递,所以当任何一份拷贝修改时,另外一份拷贝是无感知的,这会引起语义上的混乱。因此,为了保证数据一致性,Java 规定匿名内部类访问的局部变量需要声明为 final。

    有没有办法不使用 final 呢?也是有的,那就是使用一层数组或者对象包装,这正是 Kotlin lambda 表达式可以直接访问局部变量的原理。


    == 和 equals() 有什么区别?为什么重写 equals() 必须重写 hashCode()?


    这三者都带有 相等 的含义,但它们在表示相等的层面又各不相同:

    1、== 表示值相等,对于基础数据类型是值相等,对于引用类型是对象地址相等,本质上也是值相等;

    2、equals() 表示内容相等,equals() 是 Object 的成员方法,所以基本数据类型没有 equals() 方法。Object#equals() 的默认实现时比较对象地址相等 (this == obj);

    3、hashCode() 是 Object 的 native 方法,底层实现是将对象的内存地址进行哈希运算计算出的整数值;

    4、 你说的是 Object.hashCode 通用约定(即:在实际中会有两个对象相同,那么对应的 hashCode() 一定相同,如果两个对象 hashCode() 相同,它们不一定相同(哈希冲突))。这个约定是为了确保该类作为散列集合的 Key 时能够正常运行(包括 HashMap、HashSet 和 Hashtable)。如果不按照约定,也就是重写 equals() 但未重写 hashCode(),就会出现两个 equals 的对象在哈希表中存储了两个独立的键值对,这与哈希集合的语义矛盾。


    3、字符 & 字符串

    在每种编程语言里,字符串都是一个躲不开的话题,也是面试常常出现的问题,关于「字符串」的面试题我统一整理在这篇文章:《Java | String 常见面试题》


    4、程序设计

    Java 如何实现单例模式?

    在 Java 中,有五种常规单例实现,每种单例实现各有特点,没有哪一种是最佳选择。这五种实现是进程级别的单例,除此之外还有线程级别单例,可以利用 ThreadLocal 实现。

    先说一下五种常规单例实现:

    1、饿汉式: 线程安全,调用效率最高,但不能懒加载

    2、懒汉式 + synchronized: 线程安全,调用效率不高,可以懒加载

    3、DCL + volatile: 线程安全,调用效率高,可以懒加载

    4、静态内部类: 线程安全,调用效率高,可以懒加载,但不能动态传递参数

    5、枚举: 线程安全,调用效率高,但不能延迟加载,天然地防止反射和反序列化破坏单例

    相关深入文章:《我向面试官讲解了单例模式,他对我竖起了大拇指》


    不使用 synchronized 关键字,如何实现线程安全的单例?

    常规的单例模式都显式或隐式使用了 synchronized 关键字,懒汉式和 DCL 直接使用了,而饿汉式和静态内部类使用了静态成员变量,其原理其实也是使用了 synchronized。具体要从 Javac 编译讲起,Javac 会将静态内部类和静态代码块会整合为 <clinit> 方法,这个方法就是类加载阶段的最后一个阶段 - 初始化阶段。JVM 内部会保证多线程环境下只有一个线程会执行 <clinit>,而其它线程需要等待。最后还有枚举,枚举底层是依赖 Enum 类,每个枚举对象其实都是类的静态成员,本质上也和饿汉式类似。

    那么,有没有办法不使用传统的互斥机制实现单例呢?有的,可以使用 CAS 或 ThreadLocal,这两种方法都没有使用互斥机制,但也可以保证线程安全,优点是可以避免线程阻塞和唤醒的上下文切换消耗,也各有缺点:CAS 在资源竞争紧张的情况下,会创建大量对象,长时间自旋 CAS 也会增大 CPU 负载。ThreadLocal 的原理是在每个线程都提供一个副本,其实是以空间换时间,对内存消耗大。

    相关深入文章:
    《面试官真是搞笑!让实现线程安全的单例,又不让使用 synchronized!》
    《Java 虚拟机 | CAS 比较并交换》


    如何破坏单例?

    破坏单例其实就是在单例对象之外再创建另一个对象,常规的对象创建是使用 new 关键字,此外还可以使用反射或反序列化创建对象,这些方法都可以破坏对象的单例性。需要注意的是,枚举天然地可以防止反射和反序列化:使用反射创建枚举对象时,JDK 会判断该类是否是一个枚举类,如果是会抛出异常;在序列化和反序列化枚举时,写入和读取的只是枚举类型和枚举对象的名字,反序列化时直接使用 枚举 valueOf(name) 直接查找枚举对象,不会创建新的对象。因此枚举天然地可以防止破坏单例。


    Kotlin 如何实现单例模式?

    相关深入文章:《Kotlin 下的 5 种单例模式》


    Java 创建对象有几种方式?

    创建对象 是否调用构造方法
    new 关键字 调用构造函数
    反射(Class#newInstance() & Constructor#newInstance()) 调用构造函数
    Object#clone() 没有调用构造函数
    反序列化 没有调用构造函数

    Java 创建对象可以使用 new、反射、clone() 和反序列化,需要注意,使用 clone() 和反序列化创建对象是不会调用构造函数的。

    new 关键字: 先在堆中创建对象,并把对象的引用入栈,随后 invokespecial 调用 <init> 方法

    Object obj = new Object();
    
    new           // class java/lang/Object
    dup
    invokespecial // Method java/lang/Object.<init> :()V
    astore_1
    return
    
    dup(完整单词:duplicate 复制)会复制栈上最后一个元素,然后再次入栈。因为 invokespecial 会消耗栈顶的对象引用,所以如果我们希望调用 invokespecial 后操作数栈顶还维持一个指向新建对象的引用,就必须先使用 dup 复制一份引用。
    astore_1 将操作数栈顶元素出栈并存储在第 1 位局部变量表。
    

    反射(Class#newInstance() & Constructor#newInstance())

    User user = User.class.newInstance();
    
    ldc            // class com/User
    invokevirtual  // Method java/lang/Class.newInstance: ()Ljava/lang/Object;
    checkcast      // class com/User
    astore_1
    return
    
    ldc:将常量池引用入栈
    checkcast:检查类型转换合法
    

    Object#clone(): 前提是类实现 Cloneable 接口,复制对象时,首先创建一个和源对象相同大小的空间,然后进行属性复制,整个过程没有经过构造方法。属性复制是浅拷贝,基本类型的成员变量拷贝的是值,而引用类型的成员变量拷贝的是对象的内存地址,不会创建新的对象。要实现深拷贝,需要每个成员变量的对象都实现 Cloneabe 并重写 clone() 方法,进而实现对象的层层拷贝。深拷贝比浅拷贝性能损耗更大。

    反序列化

    相关深入文章:《盘点 Java 创建对象的 x 操作》


    new 创建对象的过程

    Java new 一个对象的过程,基本上可以分为 5 个步骤:检查加载 -> 分配内存 -> 初始化零值 -> 设置对象头 -> 执行 <init> 构造函数:

    • 1、检查加载 & 类加载: 检查类是否被类加载器加载,如果没有需要先执行类加载过程(加载 & 解析 & 初始化);
    • 2、分配内存: Java 对象需要一块连续的堆内存空间,分配方式有 指针碰撞 & 空闲列表。指针碰撞法要求 Java 堆是绝对规整的,而空闲列表法不要求 Java 堆是绝对规整的。由于 Java 堆是线程共享的,所以需要考虑多线程并发分配内存的问题,解决方法有 CAS 操作和 TLAB 分配缓冲。
    • 3、初始化零值: 将实例数据的值初始化为零值;
    • 4、设置对象头: 设置对象头信息,包括 Mark Work & 类型指针 & 数组长度;
    • 5、执行 <init> 构造函数: 执行 <init> 构造函数,<init> 由编译器生成,包括成员变量初始值、实例代码块和对象构造函数。

    相关深入文章:Java 虚拟机 | 拿放大镜看对象


    枚举的实现原理?

    序列化 & 反序列化的原理?

    Kotlin lazy 的原理:


    final、finally 和 finalize() 有什么区别?

    final


    反射

    • 反射可以修改 final 变量吗?

    5、集合

    为什么 HashMap 是线程不安全的,体现在哪里?

    • 数据覆盖问题:如果两个线程并发执行 put 操作,并且两个数据的 hash 值冲突,就可能出现数据覆盖(线程 A 判断 hash 值位置为 null,还未写入数据时挂起,此时线程 B 正常插入数据。接着线程 A 获得时间片,由于线程 A 不会重新判断该位置是否为空,就会把刚才线程 B 写入的数据覆盖掉);
    • 环形链表问题: 如果两个线程并发执行 put 操作,并且触发扩容,就可能出现环形链表,此时获取数据会死循环。这是因为 JDK 1.7 版本采用头插法,在扩容时会翻转链表的顺序,而 JDK 1.8 采用尾插法,再扩容时会保持链表原本的顺序,就不会出现链表成环问题了。

    相关深入文章:都说 HashMap 是线程不安全的,到底体现在哪儿?

    相关文章

      网友评论

        本文标题:Java 基础面试题总结 - 草稿

        本文链接:https://www.haomeiwen.com/subject/ztoqlltx.html