跳到主要内容

Java 基础面试题

Java 的集合类、实际使用

HashMap 为什么要用红黑树

  • 在 jdk1.8 版本后,java 对 HashMap 做了改进,在链表长度大于 8 的时候,将后面的数据存在红黑树中,以加快检索速度
  • 红黑树虽然本质上是一棵二叉查找树,但它在二叉查找树的基础上增加了着色和相关的性质使得红黑树相对平衡,从而保证了红黑树的查找、插入、删除的时间复杂度最坏为 O(log n)。加快检索速率。

集合类怎么解决高并发问题

  • 思路
    1. 哪些是非安全的:ArrayList、LinkedList、HashSet、TreeSet、HashMap、TreeMap
    2. 普通的线程安全集合类:Vector、HashTable,效率没有 JUC 包中的高性能集合高
    3. JUC 中高并发的集合类

Object 类中常用的方法

JDK1.8 新特性

Java 中重写和重载的区别?

  • 重写(Override):子类对父类允许访问的方法的实现过程的重新编写返回值和形参都不能改变,外壳不变,核心重写
  • 重载(Overload):在一个类中,方法名称相同,参数不同,返回类型可相同可不同,每个重载的方法(或者构造函数)都必须有一个独一无二的参数类型列表
  • 区别
    重写和重载的区别

怎样声明一个类不会被继承,什么场景下会用

  • 方法
    1. 把类声明为 final
    2. 对类中的构造器声明为 private,类中提供一个静态变量用于完成对类的初始化
      public class Base{
      private Base(){}
      public static Base ini(){
      Base a=new Base();
      return a;
      }
      public static void main(String[] args) {
      Base base=Base.ini();
      }
      }
  • String 为什么要用 final 修饰
    1. 不可变性:String 类被 final 修饰后,其中用来存储数据的 char[](JDK9 后改为 byte[])也就被 final 修饰了,当一个 String 类实例出现时,就不能被修改
    2. 缓存结果:可以缓存结果,传参时不需要考虑值是否会被修改,如果可变,可能需要考虑创建一个新的值来传参,造成性能损失
      在实际开发中,大部分 String 都用于存储一些常量,开发者不希望前端传过来的数据被后端修改,因为没有修改的必要(比如 username、password)
    3. 安全性:当调用一些系统操作指令时,可能会有一系列的校验,如果是可变类的话,校验过后,内部值被改变,有可能引起系统崩溃
    4. 实现常量池
      • 当使用 String str = "a"; 等语句时,JVM 会通过 hash 值映射找到常量池地址并赋值(不存在则创建并赋值),如果 String 是可变类,使用地址进行修改相当于修改了常量池内容,不符合常量池的理念
      • 或者当使用 String str = new String("a"); 时,会在堆中创建一个 String 对象,并同时在常量池中添加 "a",但返回的地址是堆中对象的地址,如果 String 可修改,修改后常量池中存储的 "a" 相当于一个垃圾值,会慢慢累积

Java 自增原子性问题

  • Java 中的自增操作 ++i 是一种紧凑的语法,看起来是一个操作,但是并非原子性操作,包含了三个独立的操作:读取 i 的值,值 +1,将计算结果写入 i,这是一个 读取-修改-写入 的操作序列,并且结果状态依赖于之前的状态,3 步之间都可能会有 CPU 调度产生,造成值被修改,导致脏读脏写
  • 如果 i 是方法内的局部变量,则一定线程安全,因为每个方法栈都是线程私有的;如果 i 是多个线程可见的变量则存在线程问题
  • 线程安全方案
    1. synchronized 加锁:性能损耗大
    2. AtomicInteger 自增:通过 volatile 和 CAS 实现,volatile 保证内存可见性,CAS 保证原子性
      • 构造函数:声明了一个 volatile 修饰的变量 value 用于保存实际值
      • 自增函数:调用 Unsafe 函数的 getAndAddInt 方法

JDK1.8 Stream、并行操作原理、并行的线程池

什么是 ForkJoin 框架,适用场景

Java 中的代理实现方式

equals() 和 == 的区别,为什么重写 equals() 要重写 hashcode

equals() 和 == 的区别

  • ==
    • 基本类型:比较值是否相等
    • 引用类型:比较引用是否相同
  • equals()
    • 是超类 Object 中的方法
    • 本质上就是 ==,只不过 String、Integer 等类重写了 equals() 方法,将它变成了值比较,在没有重写 equals() 的类中等价于 ==
    • 源码
      public boolean equals(Object obj) {
      return (this == obj);
      }

为什么重写 equals() 必须重写 hashCode

每个覆盖了 equals 方法的类中,必须覆盖 hashCode。如果不这么做,就违背了 hashCode 的通用约定,也就是上面注释中所说的。进而导致该类无法结合所以与散列的集合一起正常运作,这里指的是 HashMap、HashSet、HashTable、ConcurrentHashMap。

--《Effective Java 第三版》

  • 两个相等的对象必须具有相等的散列码(Java 关键约定)
  • equals 和 hashCode 都是用于判断对象是否相等
  • equals(保证可靠)
    • 比较对象是否绝对相等
    • 可靠性的由来,同一个对象地址肯定相同,不同对象地址必定不同
  • hashCode(保证性能)
    • Object 的 hashCode 默认将对象的内存地址转换成一个整数返回,由此可以判断两个对象是否相等,hashCode 也是唯一的
    • 最快时间内判断两个对象是否相等,可能存在误差
    • 同一个对象的 hashCode 一定相等,不同对象的 hashCode 也可能相等,因为 hashCode 是根据地址 hash 出来的一个 int 32 位的整形数字,可能存在相等
  • 只重写 equals() 的后果
    • Java 对于两个对象相等的约定
      • equals() 为 true 时,hashCode 必须相等
      • hashCode 相等时,equals() 结果可以不为 true(即 hash 碰撞发生时)
    • 例子
      如果一个只重写了 equals() (比较所有属性是否相等)的类 new 出了两个属性相同的对象,此时这两个属性相同的对象地址肯定不相同,但 equals() 结果为 true,hashCode 返回的是不相等(一般不出现 hash 碰撞)
      此时这个类对象违背了 Java 对于两个对象相等的约定,可靠的 equals() 判断两个对象相等,但他们的散列码不相等
  • 不重写 hashCode 对于散列集合(HashMap,HashSet,HashTable)的影响
    if (p.hash == hash &&
    ((k = p.key) == key || (key != null && key.equals(k))))
    两个所有属性相等的对象,地址不同,没有重写 hashCode 时,p.hash == hash 必定不相等,但在逻辑上这两个对象是相等的,并且 equals() 结果也为 true
    可能导致在散列集合中获取数据时取得的值为空(没有重写 hashCode,导致 new 出来的不同对象具有不同的 hashCode),put 的时候根据 hashCode 放到散列桶中,get 的时候根据 new 出来的对象的 HashCode 去对应的散列桶取值,因此取不到值,例子如下
    Student student1 = new Student("xiaoming",11);
    Student student2 = new Student("xiaoming",11);
    System.out.println("student1 hash code:"+student1.hashCode());
    System.out.println("student2 hash code:"+student2.hashCode());
    Map<Student,Integer> studentIntegerMap = new HashMap<Student,Integer>();
    studentIntegerMap.put(student1,11);
    Integer value = studentIntegerMap.get(new Student("xiaoming",11));
    System.out.println("student1 value:"+value);
    结果
    student1 hash code:2009832657
    student2 hash code:158460163
    student1 value:null

HashMap 在 JDK1.8 中的优化

  • 数据结构:JDK1.7 中 HashMap 的数据结构为 数组 + 单向链表,JDK1.8 中变成了 数组 + 单向链表 + 红黑树
  • 链表插入节点的方式
    • JDK1.7 中使用头插法,JDK1.8 中使用尾插法。
    • 目的是为了安全,防止环化
      HashMap 扩容时(多线程场景下),因为使用单链表的头插入方式,同一位置上新元素总会被放在链表的头部位置,在旧数组中同一条 Entry 链上的元素经过重新计算索引位置后,有可能被放到新数组的不同位置上
      使用头插会改变链表上元素的顺序,使用尾插在扩容时则会保持元素原本的顺序,避免链表成环问题的出现
      链表成环
      链表成环
      链表成环
  • hash 函数:JDK1.8 的 hash() 中,将 hash 值高位(前 16 位)参与到取模的运算中,使计算结果不确定性增加,降低哈希碰撞的概率
  • 扩容优化
    因为使用的是2次幂的扩展(长度扩大为原来的 2 倍),因此元素要么在原位置,要么在原位置再移动2次幂的位置,下图 a 为扩容前,b 为扩容后确定索引位置的示例
    扩容优化
    元素重新计算 hash 后因为 n 变为 2 倍,n-1 的 mask 范围在高位多 1bit(红色),此时新的 index 变化如下
    扩容优化
    因此在扩容时不需要再重新计算 hash,只需要判断原来的 hash 值新增的 bit 是 1 还是 0,0 的话索引不变,1 的话索引变成 原索引 + oldCap
    扩容优化

HashMap 线程安全的方式

  • JDK 原生提供的方法
    1. 通过 Collections.synchronizedMap() 返回一个新的 Map,这个新的 Map 就是线程安全的,这要求习惯基于接口编程,因为返回的并不是 HashMap,而是一个 Map 的实现
      特点:
    2. 重新改写 HashMap,具体查看 ConcurrentHashMap
  • 特点
    • 方法 1
      通过 Collections.synchronizedMap() 封装所有不安全的 HashMap 的方法,连 toString 和 hashCode 都进行封装
      • 优点:代码实现简单
      • 缺点:锁住了尽可能大的代码块,性能较差
      • 关键点
        1. 使用经典的 synchronized 来进行互斥
        2. 使用代理模式 new 了一个新的类,这个类同样实现了 Map 接口
    • 方法 2
      重写了 HashMap,使用了新的锁机制把 HashMap 拆分成多个独立的块,在高并发的情况下减少了锁冲突的可能,使用 NonFairSync 特性调用 CAS 指令确保原子性和互斥性
      • 优点:需要互斥的代码段较少,ConcurrentHashMap 把整个 Map 切分成了多个块,产生锁碰撞的几率大量降低,性能较好
      • 缺点:代码繁琐

为什么 HashMap 扩容的时候是两倍

  1. 哈希函数的问题:通过除留余数法的方式获取桶号,因为 hash 表的大小始终为 2 的 N 次方,因此可以将取模转换为位运算,提高效率,容量为 n 的幂次方,n-1 的二进制会全为 1,位运算时可以充分散列,避免不必要的哈希冲突
  2. 是否移位的问题:是否移位由扩容后表示的最高位是否为 1 决定,并且移动方向只有一个(向高位移动),因此可以检测最高位决定是否移位,从而优化性能

解决 hash 冲突的方法

  1. 开放定址法:一旦发生冲突,就去寻找下一个空的散列地址,只要散列表足够大,就能保证找到空的散列地址
  2. 再哈希法:又叫双哈希法,有多个不同的哈希函数,当发生冲突时,使用下一个哈希函数计算地址,直到不发生冲突,不易聚集,但增加了计算时间
  3. 链地址法:每个哈希表节点都有一个 next 指针,多个哈希表节点可以用 next 指针构成一个单向链表,被分配到同一个索引上的多个节点可以使用这个单向链表连接起来
  4. 建立公共溢出区:将哈希表分为基本表和溢出表两个部分,凡是和基本表发生冲突的元素一律填入溢出表

Tomcat 为什么重写类加载器

简述 Java 运行时数据区

简述反射,反射是否影响性能

  • Java 反射机制是在运行状态中,对于任意一个类,都能获取这个类的所有属性和方法,对于任意一个对象,都能调用这个对象的任意一个方法和属性,这种动态获取信息以及动态调用对象的方法的功能称为 Java 的反射机制,是一种运行时动态的功能
  • 反射方式实例化对象、属性赋值、调用方法比直接的慢,大量调用的情况下才会产生比较明显的影响

HashMap 为什么使用红黑树

红黑树和AVL树对比

  • AVL 树
    一般使用平衡因子判断是否平衡并通过旋转来实现平衡,左右子树树高不超过 1,和红黑树相比,AVL 树是高度平衡的二叉树,平衡条件必须满足(所有节点的左右子树高度差不超过 1)。
    不管执行插入还是删除操作,只要不满足上述条件,就要通过一次或者多次旋转来保持平衡,由于旋转比较耗时,因此 AVL 树适用于插入和删除较少,查找较多的场景
    AVL 树
  • 红黑树
    一种平衡二叉树,每个节点都有一个存储位表示节点颜色(红或黑),通过对任何一条根到叶子的路径上各个节点着色的方式进行限制,红黑树确保了没有一条路径会比其他路径长出两倍,因此红黑树是一种弱平衡二叉树(由于是弱平衡,相同节点数的情况下,AVL 树的高度 <= 红黑树)
    红黑树

sleep 和 wait 的区别

  1. sleep 方法属于 Thread 类,wait 方法属于 Object 类
  2. sleep 方法使程序暂停执行指定的时间,让出 CPU,但是监控状态依然保持,指定的时间到了就会恢复运行状态,在调用 sleep 方法的过程中,线程不会释放对象锁
  3. 调用 wait 方法时,线程会放弃对象锁,进入等待此对象的等待锁定池,只有针对此对象调用 notify() 方法后,才进入对象锁定池准备获取对象锁进入运行状态
  4. sleep 用 Thread 调用,在非同步状态下就可以调用,wait 使用同步监视器调用,必须在同名代码中调用

synchronized 和 ReentrantLock(未看)

  • 两者的共同点
  1. 都是用来协调多线程对共享对象、变量的访问
  2. 都是可重入锁,同一线程可以多次获得同一个锁
  3. 都保证了可见性和互斥性
  • 两者的不同点
  1. ReentrantLock 显示的获得、释放锁,synchronized 隐式获得释放锁
  2. ReentrantLock 可响应中断、可轮回,synchronized 是不可以响应中断的,为处理锁的不可用性提供了更高的灵活性
  3. ReentrantLock 是 API 级别的,synchronized 是 JVM 级别的
  4. ReentrantLock 可以实现公平锁
  5. ReentrantLock 通过 Condition 可以绑定多个条件
  6. 底层实现不一样, synchronized 是同步阻塞,使用的是悲观并发策略,lock 是同步非阻塞,采用的是乐观并发策略
  7. Lock 是一个接口,而 synchronized 是 Java 中的关键字,synchronized 是内置的语言实现。
  8. synchronized 在发生异常时,会自动释放线程占有的锁,因此不会导致死锁现象发生;而 Lock 在发生异常时,如果没有主动通过 unLock()去释放锁,则很可能造成死锁现象,因此使用 Lock 时需要在 finally 块中释放锁。
  9. Lock 可以让等待锁的线程响应中断,而 synchronized 却不行,使用 synchronized 时,等待的线程会一直等待下去,不能够响应中断。
  10. 通过 Lock 可以知道有没有成功获取锁,而 synchronized 却无法办到。
  11. Lock 可以提高多个线程进行读操作的效率,既就是实现读写锁等。多个读取线程使用共享锁,写线程使用排它锁/独占

Condition 类和 Object 类锁方法区别(未看)

  1. tryLock 能获得锁就返回 true,不能就立即返回 false,tryLock(long timeout,TimeUnit unit),可以增加时间限制,如果超过该时间段还没获得锁,返 回 false
  2. lock 能获得锁就返回 true,不能的话一直等待获得锁
  3. lock 和 lockInterruptibly,如果两个线程分别执行这两个方法,但此时中断这两个线程, lock 不会抛出异常,而 lockInterruptibly 会抛出异常。

单例模式

关于 intern

  • String.intern() 方法是一个 Native (本地)方法,作用是如果字符串常量池已经包含一个等于此 String 对象的字符串,则返回字符串常量池中这个字符串的引用,否则将当前 String 对象的引用地址(在堆中)添加到字符串常量池并返回

  • 场景

    • 常量池中存在字符串

      public class StringTest {
      public static void main(String[] args) {
      // 基本数据类型之间的 == 是比较值,引用数据类型 == 比较的是地址值
      // 1:在Java Heap中创建对象 2:在字符串常量池中添加 zhangsan
      String a = new String("zhangsan");
      // 调用 intern 方法,因上一步中已经将zhangsan存入常量池中,这里直接返回常量池 zhangsan 的引用地址
      String b = a.intern();
      // a 的地址在Java Heap中 , b的地址在 常量池中 ,所以结果是flase
      System.out.println(a == b);
      // 因为常量池中已经包含zhangsan,所以直接返回
      String c = "zhangsan";
      // b c 的地址一致,所以是true
      System.out.println(b == c);
      }
      }

      //结果
      false
      true
    • 常量池中不存在字符串

      public class StringTest {
      public static void main(String[] args) {
      //1: 首先会在Heap中创建对象,然后在常量池中放入zhagnsan 和 wangwu ,但是并不会放入zhagnsanwangwu
      String a = new String("zhangsan") + "wangwu";
      // 2:调用 intern ,因为字符串常量池中没有”zhangsanwangwu”这种拼接后的字符串,所以将堆中String对象的引用地址添加到字符串常量池中。jdk1.7后常量池引入到了Heap中,所以可以直接存储引用
      String b = a.intern();
      // 3:因为 a 的地址和 b的地址一致,锁以是true
      System.out.println(a == b);

      //4:因常量池中已经存在 zhangsanwangwu 了,所以直接返回引用就是 a 类型 a ==b 锁 a==b==c
      String c = "zhangsanwangwu";
      System.out.println(a == c); // true
      System.out.println(b == c); // true

      // 5:首先会在Heap中创建对象,然后会在常量池中存储 zhang 和 san
      String d = new String("zhang") + "san";
      // 6: 返回的是 常量池中的 地址,因在a变量时已经将 zhangsan 放入到了常量池中
      String f = d.inter();
      System.out.println(d = f); // false
      }
      }

BIO,NIO,AIO 有什么区别?

包装类缓存问题

  • int、char 类型对应的包装类在自动装箱的时候对于 -128 ~ 127 之间的值会进行缓存处理,目的是提高效率
  • 缓存处理
    如果数据在 -128 ~ 127 之间,那么在类加载时就已经为该区间的每个数值创建了对象,并把这个 256 个对象存放在一个名为 cache 的数组中
    自动装箱过程发生时(或者手动调用 valueOf() 时),会先判断数据是否在这个区间中,是则直接获取数组中对应的包装类对象的引用,否则通过 new 调用包装类的构造方法来创建对象
  • 有缓存的包装类:Byte、Short、Long、Integer、Character

简述线程生命周期(状态)

终结线程的方式

  1. 正常结束运行:程序运行结束,线程自动结束
  2. 使用退出标志退出线程 一般 run() 方法执行完,线程就会正常结束,但是通常有些线程是伺服线程,需要长时间的运行,只有外部条件满足的情况下才会关闭,需要一个变量来控制
    public class ThreadSafe extends Thread {
    public volatile boolean exit = false;
    public void run() { while (!exit){
    //do something
    }
    }
    }
    定义 exit 时使用了 volatile 关键字,目的是使 exit 同步,同一个时刻只能由一个线程来修改 exit 的值
  3. Interrupt() 方法结束线程
  • 线程处于阻塞状态 如果使用了 sleep、同步锁的 wait、socket 中的 receive、accept 等方法时,会使线程处于阻塞状态,调用线程的 interrupt() 时会抛出异常(InterruptException),阻塞中的方法抛出这个异常后,通过代码捕获这个异常,然后 break 跳出循环状态,从而找到结束线程的机会
    实际上调用 interrupt() 并不会导致线程结束,而是捕获 InterruptException 异常后通过 break 来跳出循环,从而结束 run 方法
  • 线程处于未阻塞状态
    使用 isInterrupted() 判断线程的中断标志来退出循环。当使用 interrupt() 方法时,中断标志就会置为 true,和使用自定义的标志来控制循环是同样的道理
    public class ThreadSafe extends Thread {
    public void run() {
    while (!isInterrupted()){ //非阻塞过程中通过判断中断标志来退出
    try{
    Thread.sleep(51000);//阻塞过程捕获中断异常来退出
    }catch(InterruptedException e){
    e.printStackTrace();
    break;//捕获到异常之后,执行 break 跳出循环
    }
    }
    }
    }
  1. stop() 方法终止线程
    在程序中可以直接使用 thread.stop() 强行终止线程,凡是 stop() 方法是很危险的,并不是按照正常程序终止线程,可能会产生不可预知的后果
    不安全的点:thread.stop() 调用之后,创建子线程的线程就会抛出 ThreadDeathError 的错误,并且会释放所有子线程持有的锁。一般任何加锁的代码块都是为了保护数据的一致性,在调用 thread.stop() 后导致线程持有的锁突然释放(不可控制),那么被保护的数据就有可能呈现不一致性,其他线程在使用这些被破坏的数据时,就有可能导致未知的 bug,因此不推荐使用 stop 方法终止线程

线程池使用过没,如果获得一个线程池,线程池各个参数的含义

为什么要区分栈和堆

  • 栈解决程序运行的问题,及程序如何执行,或者说如何处理数据。
    堆解决的是数据存储的问题,即数据如何存放,存放在哪里。
    在 Java 中每一个线程都会有一个对应的线程栈,因为不同的线程执行的逻辑有所不同,因此需要独立的线程栈,而堆是所有线程共享的。
    栈因为是运行单位,因此栈里存储的信息都是跟当前线程(或程序)相关的,包括局部变量、程序运行状态、方法返回值等,而栈只负责存储对象信息
  • 从软件设计的角度,栈代表了处理逻辑,堆代表了数据,栈和堆的分离使得处理逻辑更加清晰
  • 栈和堆分离,使得堆中的内容可以被多个线程共享(可理解为多个线程访问同一个对象),这种共享一方面提供了一种有效的数据交互方式(比如共享内存);另一方面堆中的共享常量和缓存可以被所有栈访问,节省了空间
  • 栈因为运行的需要,只能保存系统运行的上下文,需要进行地址段的划分,由于栈只能向上增长,因此会限制住栈存储内容的能力;而堆不同,堆中的对象可以根据需要动态增长。因此堆和栈的拆分使得动态增长成为可能,相应的栈中只需要记录堆中的一个地址就够了
  • 面向对象就是堆和栈的完美结合

为什么不把基本类型放在堆中

  • 基本类型占用空间一般为 1-8 个字节(需要的空间比较少),并且因为是基本类型,不会出现动态增长的情况(长度固定),因此存储在栈中就足够了,存储在堆中的意义不大
  • 基本类型和对象的引用都存放在栈中,大小都只有几个字节,因此在程序运行时,它们的处理方式是统一的。
    但基本类型、对象引用和对象本身就有所区别了,因为一个是栈中的数据,一个是堆中的数据

堆中和栈中分别存放的数据

  • 堆中存放的是对象,栈中存放的是基本数据类型和堆中对象的引用
  • 一个对象的大小是不可估计的,或者可以说是动态变化的,但是在栈中,一个对象只对应了一个 4byte 的引用

Java 的参数传递

  • 参数是基本数据类型时按值传递,是引用类型时按引用传递
  • 按引用传递在方法体重新修改形参时,可能会对实参产生影响

设计模式

代码块以及代码块和构造方法的执行顺序

  1. 普通代码块:类中方法的方法体
  2. 构造代码块:构造代码块会在创建对象的时候被调用,每次创建都会调用,优先于类构造函数执行
  3. 静态代码块:用 static{} 包裹的代码片段,只会执行一次,优先于构造代码块执行
  4. 同步代码块:用 synchronized(){} 包裹的代码块,在多线程环境下,对共享数据的读写操作需要互斥进行,否则会导致数据的不一致性,同步代码块需要写在方法中

JVM 如何确定垃圾

JVM 垃圾回收算法