碎碎念

本人根据B站视频总结的Java面试题,初步打算分为初级篇,中级篇和高级篇,因为篇幅比较长所以分开总结。顺便吐槽一下现在的面试面试造火箭,很多不会用到,但是你得会。看完总体下来还是收获很大的,在这里博主呼吁大家不要做API调用工程师,Ctrl C + Ctrl V 程序员!

面向对象

面向对象是一种编程风格,一切皆对象,把一切东西看成是一个个对象,比如人,耳机,鼠标,水杯等,他们各自都有属性,比如:耳机是白色的,鼠标是黑色的,水杯是圆柱形的等等,把这些对象拥有的属性变量和操作这些属性变量的函数打包成一个类来表示。面向对象有三大特征: 封装多态继承

  • 封装

将一类事物的属性和行为抽象成一个类,使其属性私有化,行为公开化,提高了数据的隐秘性的同时,使代码模块化。这样做使得代码的复用性更高。

  • 继承

在程序中,继承描述的是多个类之间的所属关系,如果一个类A里面的属性和方法可以复用,则可以通过继承的方式,传递到类B里,那么类A就是基类,也叫做父类;类B就是派生类,也叫做子类。继承进一步提高了代码的复用性。

  • 多态

基于独享所属类的不同,外部对同一个方法的调用,实际执行的逻辑不同。多态的前提条件是要有继承,方法重写,父类引用指向子类对象,并且父类无法调用子类特有功能。

JDK JRE JVM

  • JDK: Java Development Kit是Java开发工具
  • JRE: Java Runtime Environment是Java运行时环境
  • JVM: Java Virtual Machine是Java虚拟机

JDK包含JRE和Java工具(javac,jconsole),而JRE里面又包含bin(JVM)和lib(类库)。

==和equals

  • ==对比的是栈中的值,基本数据类型是变量值,引用数据类型是堆中内存对象的地址
  • Object中默认也是采用==比较,通常会重写。String类中被重写的equals()方法其实是比较两个字符串的内容

final

  • 修饰类表示类不可被继承
  • 修饰方法表示方法不可被重写,但是可以重载
  • 修饰变量表示变量一旦被复制就不可以更改它的值
  • 如果修饰的是类变量,只能在静态初始化块中指定初始值或者声明该变量时指定初始值
  • 如果final修饰的是成员变量,可以在非静态初始化块,声明该变量或者构造器中执行初始值
  • 系统不会为局部变量进行初始化,局部变量必须由程序员显示初始化,因此使用final修饰局部变量时,即可以在定义时指定默认值,也可以不指定默认值,而在后面的代码中对final变量赋初值
  • 如果是基本数据类型的变量,则其数值一旦初始化之后就不能更改
  • 如果是引用数据类型的变量,则其初始化之后便不能让它指向另一个对象,但是引用的值是可变的

String StringBuffer StringBuilder

  • String是final修饰的,不可变,每次操作都会产生新的String对象
  • StringBuffer和StringBuilder都是在原对象上操作的
  • StringBuffer是线程安全的,StringBuilder是线程不安全的
  • StringBuffer方法都是synchronized修饰的
  • 性能: StringBuilder > StringBuffer > String
  • 经常需要改变字符串内容时用后面两个,优先使用StringBuilder,多线程共享变量是使用StringBuffer

重载和重写

  • 重载发生在同一个类中,方法名必须相同,参数类型不同,个数不同,顺序不同,方法返回值和访问修饰符可以不同,发生在编译时
  • 重写发生在父子类中,方法名,参数列表必须相同,返回值范围小于等于父类,抛出的异常范围小于等于父类,访问修饰符范围大于等于父类,如果父类方法访问修饰符为private则子类不能重写该方法

接口和抽象类

  • 抽象类可以存在普通成员函数,而接口中只能存在public abstract方法
  • 抽象类中的成员变量可以是各种类型,而接口中的成员变量只能是public static final类型的
  • 抽象类只能继承一个,接口可以实现多个

List和Set

  • List: 有序,按对象进入的顺序保存对象,可重复,允许多个NULL元素对象,可以使用Iterator取出所有元素,在逐一遍历,还可以使用get(int index)获取指定下标的元素
  • Set: 无序,不可重复,最多允许有一个NULL元素对象,取元素时只能用Iterator接口取得所有元素,在逐一遍历元素

hashCode与equals

  • 如果两个对象相等,则hashCode也一定相等
  • 两个对象相等,对两个对象分别调用equals方法都返回true
  • 两个对象有相同的hashCode值,它们也不一定是相等的
  • equals方法被覆盖,则hashCode方法也必须被覆盖
  • hashCode的默认行为是对堆上的对象产生独特值。如果没有重写hashCode,则该class的两个对象无论如何都不会相等(即使这两个对象指向相同的数据)

ArrayList和LinkedList

  • ArrayList: 基于动态数组,连续内存存储,适合下标访问(随机访问),扩容机制: 因为数组长度固定,超出长度存数据时需要新建数组,然后将老数组的数据拷贝到新数组,如果不是尾部插入数据还会涉及到元素的移动(往后复制一份,插入数据),使用尾插法并指定初始容量可以极大提升性能,甚至超过LinkedList(需要创建大量的node对象)
  • LinkedList: 基于链表,可以存储在分散的内存中,适合做数据插入及删除操作,不适合查询: 需要逐一遍历。遍历LinkedList必须使用Iterator不能使用for循环,因为每次使用for循环体内通过get(i)取得某一元素时都需要对List重新遍历,性能消耗极大。
  • 另外不要试图使用indexOf等返回元素索引,并利用其进行遍历,使用indexOf对list进行遍历时,当结果为空时会遍历整个列表

HashMap和HashTable

  • HashMap方法没有Synchronized修饰,线程非安全,HashTable线程安全
  • HashMap允许key和value为NULL,而HashTable不允许
  • JDK8之前底层是数组 + 链表,JDK8之后是数组 + 链表 + 红黑树
  • JDK8开始链表高度到8,数组长度超过64,链表转变为红黑树,元素以内部类Node及诶单存在
    1. 计算key的hash值,二次hash然后对数组长度取模,对应到数组下标
    2. 如果没有产生hash冲突(下标位置没有元素),则直接创建Node存入数组
    3. 如果产生了hash冲突,先进行equals比较,相同则取代该元素,不同,则判断链表高度插入链表,链表高度达到8,并且数组长度到64则转变为红黑树,长度低于6则将红黑树转回链表
    4. key为NULL,存在下标0的位置

重要参数

transient int size; //当前存储的键值对总数
int threshold; //阈值,默认为16
final float loadFactor; //负载因子
transient int modCount; //HashMap被改变的次数,比如使用迭代器进行迭代时,如果其他线程对当HashMap,进行了修改,则会改变该字段值,即结构已经发生变化,那么迭代器就会抛出并发修改异常

扩容操作

  • 数组: 初始容量为16(2^4),初始负载因子为0.75,即当存储元素超过threshold(当前容量) * 0.75(负载因子)时会进行扩容,每次扩容后容量为之前的2倍
  • 链表: 使用哈希函数计算存储位置后,如果数组中该位置已经被使用,那么就会在该位置处添加链表,链表的最大容量为8,如果超过8,就会转换为红黑树来存储
  • 红黑树: 使用红黑树的查询效率高于普通链表,因为使用红黑树相当于二分查找,二分查找的效率要高于顺序查找,并且红黑树会自动保持平衡,从而进一步保证查询效率。当红黑树结点减少到6(不是8)时,会转换回链表
  • 注意: 当添加元素时会进行扩容,同理,减少元素时也会进行减少容量的操作。所以,Map集合会在增加,减少等修改Map集合时对整体结构进行维护(修改内部结构和容量)

为什么默认值都为2^n?

为了减少碰撞,源码中计算索引位置为了效率考虑使用的是按位与操作(&),并不是直接取余,所以2的幂的效率相比于其他更高
扩容时为2倍,因为当扩容完成后将键值对全部移入新的Map中(所以扩容是耗时操作),而直接扩容为原来的2倍能更有效的是之前的元素均匀的分布在新的Map中

为什么JDK8添加了红黑树提高性能?

红黑树查找事件复制这咋读为O(logn),链表为O(n)

为什链表会达到阈值将结构修改为红黑树,而不是直接为红黑树?

红黑树结点基本上为链接结点大小的两倍,所以存储元素较少时,不宜使用较多的存储空间

为什么会在红黑树结点数量为6的时候转换回链表,而不是和链表一样转换为红黑树时一样为8?

避免在阈值附近增加删除元素而引起频繁的链表和红黑树的转换

JDK8之前存在的多线程情况下的死循环问题?

JDK7扩容采用的是头插法,会导致同一索引位置的节点在扩容后顺序反掉。而JDK8之后采用的尾插法,扩容后节点顺序不会反掉,不存在死循环问题

JDK8对Map的优化?

底层数据结构从数组 + 链表改变成数组 + 链表 + 红黑树
计算 table(底层数组)初始容量的方式发生了改变
优化了hash值的计算方法。新的计算方式是让高16位参与了计算
扩容时插入方式从头插法改变成尾插法,避免了并发下的死循环
扩容时计算节点在新表的索引位置方式h & (length - 1) 改变成 hash & oldCap

ConcurrentHashMap

  • JDK7

数据结构: ReentrantLock + Segment + HashEntry,一个Segment中包含一个HashEntry数组,每个HashEntry又是一个链表结构。元素查询: 二次hash,第一次Hash定位到Segment,第二次Hash定位到元素所在的链表的头部。锁: Segment分段锁 Segment继承了ReentrantLock,锁定操作的Segment,其他的SEGMENT不受影响,并发度为Segment个数,可以通过构造函数指定,数组扩容不会影响其他的Segment。get方法无需加锁,volatile保证

  • JDK8

数据结构: Synchronized + CAS + Node + 红黑树,Node的val和next都用volatile修饰,保证可见性。查找,替换,赋值操作都使用CAS。锁: 锁链表的head节点,不影响其他元素的读写,锁粒度更细,扩容时,阻塞所有的读写操作,并发扩容。读操作无锁: Node的val和next使用volatile修饰,读写线程对该变量互相可见,数组用volatile修饰,保证扩容时读线程感知

为什么有Synchronized的情况下还要使用CAS

因为CAS是乐观锁,在一些场景中(并发不激烈的情况下)它比Synchronized的ReentrantLock的效率要高,当CAS保障不了线程安全的情况下(扩容或者hash冲突的情况下)转成Synchronized来保证线程安全,大大提升了低并发下的性能

如何实现一个IOC容器

  • 配置文件中指定需要包扫描路径
  • 定义一些注解,分别表示访问控制层,业务服务层,数据持久层,依赖注入注解,获取配置文件注解
  • 从配置文件中获取需要扫描的包路径,获取当前路径下的文件信息及文件夹信息,我们将当前路径下所有以.class结尾的文件添加到一个Set集合中进行存储
  • 遍历这个Set集合,获取在类上有指定注解的类,并将其交给IOC容器,定义一个安全的Map用来存储这些对象
  • 遍历这个IOC容器,获取到每一个类的实例,判断里面是有有依赖其他的类的实例,然后进行递归注入

字节码

Java中引入了虚拟机的概念,即在机器和编译程序之间加入了一层抽象的虚拟的机器,这台虚拟的机器在任何平台都提供了给编译程序一个共同的接口。编译器只需要面向虚拟机,生成虚拟机能够理解的代码,然后由解释器来讲虚拟机代码转换为特定的机器码执行。在Java中,这种供虚拟机理解的代码叫做字节码(即扩展名为.class文件),它不面向任何特定的处理器,只面向虚拟机。每一种平台的解释器是不同的,但是实现的虚拟机是相同的。Java源程序经过编译器编译后变成字节码,字节码由虚拟机解释执行,虚拟机将每一台要执行的字节码送给解释器,解释器将其翻译成特定机器上的机器码,然后在特定的机器上运行,这也就是解释了Java的编译与解释并存的特定。 Java源代码 -> 编译器 -> JVM可执行的Java字节码(即虚拟指令) -> JVM -> JVM中解释器 -> 机器可执行的二进制机器码 -> 程序运行。Java语言通过字节码的方式,在一定程度上解决了传统解释型语言执行效率低的问题,同时又保留了解释型语言可移植的特点。所以Java程序运行时比较高效,而且,由于字节码并不专对一种特定的机器,因此,Java程序无须重新编译便可在多种不同的计算机上运行。

Java类加载器

  • JDK自带有三大类加载器: Bootstrap ClassLoader ExtClassLoader AppClassLoader
  • Bootstrap ClassLoader是ExtCLassLoader的父类加载器,默认负责加载JAVA_HOME lib下的jar包和class文件
  • ExtClassLoader是AppClassLoader的父类加载器,负责加载JAVA_HOME lib/ext文件夹下的jar包和class类
  • AppClassLoader是自定义类加载器的父类,负责加载classpath下的类文件。系统类加载器,线程上下文加载器
  • 继承ClassLoader实现自定义类加载器

双亲委派机制

  • 如果一个类加载器收到了类加载请求,它并不会自己先去加载,而是把这个请求委托给父类的加载器去执行
  • 如果父类加载器还存在其父类加载器,则进一步向上委托,依次递归,请求最终到达顶层的引导类加载器
  • 如果父类加载器可以完成类加载任务,就成功返回模,倘若父类加载器无法完成此加载任务,子加载器才会尝试自己去加载

双亲委派的好处主要是为了安全性,避免用户自己编写的类动态替换Java的一些核心类,比如String。同时也避免了类的重复加载,因为JVM中区分不同类,不仅仅是根据类名,相同的class文件被不同的ClassLoader加载就是不同的两个类

Java中的异常体系

Java中所有异常都来自顶级父类Throwable。Throwable下有两个子类Exception和Error。Error是程序无法处理的错误,一旦出现这个错误,则程序将被迫停止运行。Exception不会导致程序停止,又分为两个部分RunTimeException和CheckedException检查异常。RunTimeException常常发生在程序运行过程中,会导致程序当前线程执行失败。CheckedException程序编译过程中,会导致程序编译不通过。

线程的生命周期

线程通常有五种状态: 创建,就绪,运行,阻塞,死亡状态。阻塞又分为三种:

  • 等待阻塞: 运行的线程执行wait方法,该线程会释放占用的所有资源,JVM会把该线程方法等待池中。进入这个状态后,是不能自动唤醒的,必须依靠其他线程调用notify或notifyAll方法才能被唤醒,wait是Object类的方法
  • 同步阻塞: 运行的线程在获取对象的同步锁,若该同步锁被别的线程占用,则JVM会把该线程放入锁池中
  • 其他阻塞: 运行的线程执行sleep或join方法,或者发出了I/O请求时,JVM会把该线程置为阻塞状态。当sleep状态超时,join等待线程终止或者超时,或者I/O处理完毕时,线程重新转入就绪状态。sleep是Thread类的方法

sleep wait join yield

  • sleep是Thread类的静态本地方法,wait则是Object类的本地方法
  • sleep方法不会释放lock,但是wait会释放,而且会加入到等待队列中

sleep就是把CPUD额执行资格和执行权释放出去,不再执行此线程,当定时时间结束再取回CPU资源,参与CPU的调度,获取到CPU资源后就可以继续运行了。而如果sleep时该线程有锁,那么sleep不会释放这个锁,而是把锁带着进入了冻结状态,也就是说其他需要这个锁的线程根本不可能获取到这个锁,也就是说无法执行程序。如果在睡眠期间其他线程调用了这个线程的interrupt方法,那么这个先测很难过也会抛出interruptException异常返回,这点和wait是一样的。

  • sleep方法不依赖于同步器Synchronized,但是wait需要依赖Synchronized关键字
  • sleep不需要被唤醒(休眠之后退出阻塞),但是wait需要(不指定时间需要别别人中断)
  • sleep一般用于当前线程休眠,或者轮训暂停操作,wait多用于多线程之间的通信
  • sleep会让出CPU执行时间且强制上下文切换,而wait则不一定,wait后肯呢个还是有机会重新竞争到锁继续执行的

yield()执行后线程直接进入就绪状态,马上释放了CPU的执行权,但是保留了CPU的执行资格,所以有可能CPU下次进行调度还会让这个线程获取到执行权继续执行

join()执行后线程进入阻塞状态,例如在线程B中调用线程A的join(),那么线程B会进入到阻塞队列,知道线程A结束或中断线程

参考文献