Java面试必会50题(1-25)

142 阅读46分钟

Java面试必会50题

1、Java堆空间内存溢出

Java的堆空间内存溢出是对象数量增长到对空间没有内存可用,即超出了堆的最大可用空间,导致无法分配新的对象而发生的错误。

堆空间内存溢出的常见原因包括:

  1. 内存泄漏:长时间持有对象的引用而不释放,导致无法被垃圾收集器回收的对象占满了堆空间。
  2. 对象生命周期不当:某些对象的生命周期过长,使得它们在堆中长时间存活,占用了大量的内存空间。
  3. 大对象:尝试分配一个比可用内存更大的对象,超出了堆的剩余空间。
  4. 并发内存溢出:在多线程应用程序中,如果每个线程分配的对象超出了堆的总可用空间,就可能导致内存溢出。

如何解决堆空间内存溢出的问题?

  1. 优化内存使用:检查代码,确保对象只在需要时创建,并在不再需要时及时释放。
  2. 增加堆空间:通过调整JVM参数(如-Xmx和-Xms),增加堆的最大和初始大小,以适应应用程序的需求。
  3. 分析内存泄漏:使用工具如Heap Dump分析工具(如Eclipse Memory Analyzer)来分析堆转储文件,找出内存泄漏的原因并进行修复。
  4. 减少对象的生命周期:确保对象的生命周期尽可能短,及时释放不再需要的对象的引用。
  5. 优化代码和数据结构:使用更高效的算法和数据结构,减少内存占用。
  6. 考虑使用更大的物理内存:如果可能的话,考虑在运行应用程序的机器上增加物理内存,以减少堆内存溢出的风险。

2、GC开销超过限制,引起OOM(outofmemory)内存溢出

  1. 频繁的Full GC
    • Full GC 是指对整个堆空间进行垃圾收集,它通常比部分收集(如新生代或老年代)更耗时。如果应用程序频繁执行Full GC,并且每次Full GC 的执行时间较长,会导致应用程序停顿时间过长,影响性能。
  2. 内存泄漏
    • 如果应用程序中存在内存泄漏,即无法访问的对象仍然占用内存,垃圾收集器将无法释放这些内存,最终导致堆内存耗尽。
  3. 对象存活时间过长
    • 有些对象可能会被错误地保持在内存中,其生命周期比预期更长。这可能是由于长时间持有对象引用或者缓存机制设计不当等原因。

解决方法:

  1. 调整GC参数
    • 调整垃圾收集器的参数和行为,例如调整新生代和老年代的比例、堆大小、GC算法选择等,以减少GC执行的频率和时间。可以通过 -Xmx-Xms-XX:NewRatio 等JVM参数进行调优。
  2. 分析GC日志
    • 使用JVM提供的GC日志功能(如 -verbose:gc-Xlog:gc 等),分析GC活动和应用程序行为。根据日志分析找出哪些GC导致了较高的开销,以及是否存在频繁的Full GC情况。
  3. 内存泄漏分析
    • 使用内存分析工具(如Eclipse Memory Analyzer、VisualVM、MAT等)来检测和解决内存泄漏问题。通过分析堆转储文件(heap dump),找出泄漏对象的引用链,识别出导致内存泄漏的代码位置。
  4. 代码优化
    • 优化应用程序代码,确保对象的生命周期尽可能短,避免不必要的对象持有。例如,及时释放不再需要的对象引用、避免过度使用静态变量等。
  5. 增加物理内存或者升级硬件
    • 如果应用程序的内存使用量已经接近硬件限制,可以考虑增加物理内存或者升级服务器硬件,以提供更大的堆空间和更好的性能。

3、请求的数组大小超过虚拟机限制,引起OOM?

请求的数组大小超过虚拟机的限制可能会导致OutOfMemoryError(OOM),具体来说,这种情况通常与Java虚拟机的堆内存大小有关。Java数组在内存中是以连续的存储空间存放的,因此数组的大小受到堆内存大小的限制。

可能的情况和原因:

  1. 堆内存大小限制
    • Java应用程序的堆内存大小由JVM参数 -Xmx-Xms 控制,分别指定了堆的最大和初始大小。如果请求的数组大小超过了可用的堆内存空间,JVM会抛出OutOfMemoryError。
  2. 单个数组大小限制
    • Java中,数组的大小由 int 类型的索引来表示,因此最大可索引的数组大小为 Integer.MAX_VALUE,即 2^31 - 1,约为2GB。如果尝试创建一个超过这个大小的数组,会导致 OutOfMemoryError
  3. 堆空间的其他使用
    • 堆内存不仅仅用于存放Java对象,还需要考虑其他因素如线程栈、类信息、常量池等的占用。因此,实际可用于数组分配的内存空间可能会比 -Xmx 指定的堆内存大小稍小。

解决方法:

  1. 增加堆内存大小
    • 如果应用程序需要处理大量数据或者大型数组,可以通过增加 -Xmx 参数来增加堆内存大小。这样可以提供更多的内存空间来存放大数组,从而避免OOM。
  2. 优化数据结构
    • 考虑是否可以通过其他数据结构或者算法来代替大数组,以减少内存使用。有时候可以使用流式处理或者分批次处理数据,而不是一次性加载所有数据到一个巨大的数组中。
  3. 检查内存使用情况
    • 使用JVM监控工具如VisualVM、JConsole等来监控应用程序的内存使用情况,查看堆内存的分配情况和趋势,及时调整 -Xmx 参数。
  4. 分析内存泄漏
    • 如果怀疑存在内存泄漏导致堆内存不断增长,应该使用堆转储文件和内存分析工具来分析内存泄漏问题,并进行修复。

4、永久代(Perm gen) 空间,引起OOM?

在旧版的Java虚拟机(JVM)中,比如Java 7及以前的版本,存在一个称为永久代(Permanent Generation,PermGen)的区域,用于存放类的元数据、常量池等信息。然而,PermGen空间的使用过度也可以导致OutOfMemoryError(OOM)异常,尽管它与普通的堆空间(Heap)不同。 引起PermGen OOM的情况:

1.类的元数据过多:

**2.每个类在内存中都有一些元数据,包括类的结构、方法信息等。**如果应用程序动态加载了大量的类,或者使用了大量的第三方库,可能会导致PermGen空间的耗尽。例如,一些框架或者应用服务器在运行时动态生成或者加载类,会增加PermGen的使用量。

3.字符串常量池:

4.字符串常量池中的字符串对象也存储在PermGen空间中。如果应用程序大量使用字符串,并且在运行时动态生成了大量的字符串常量,也可能导致PermGen空间耗尽。

5.未正确配置PermGen大小:

**6.默认情况下,PermGen空间的大小是有限的,并且可能比较小。**如果应用程序加载了大量的类或者有大量的字符串常量,而PermGen空间配置不足以容纳这些数据,就会发生OOM。

解决PermGen OOM的方法:

1.增加PermGen空间的大小:

**2.可以通过JVM参数 -XX:MaxPermSize 来增加PermGen空间的大小。**例如,-XX:MaxPermSize=256m 表示将PermGen空间的最大大小设置为256MB。但需要注意,Java 8及更新版本已经移除了PermGen空间,改为使用元数据区(Metaspace)。

3.优化类加载和卸载:

4.确保应用程序只加载需要的类,并且能够及时卸载不再需要的类。一些应用服务器如Tomcat、WebLogic等提供了类加载器的监控和调优功能,可以帮助优化类加载行为。

5.升级到较新的Java版本:

**6.Java 8及更高版本使用元数据区(Metaspace)替代了PermGen空间。**Metaspace的大小由操作系统的内存来管理,默认情况下不再有固定的上限,可以更灵活地管理类的元数据。

7.检查应用程序的类加载和字符串使用:

8.分析应用程序的类加载情况和字符串使用情况,优化代码,尽量减少不必要的类加载和字符串常量的创建。

总之,虽然PermGen空间不再在Java 8及更新版本中存在,但在使用旧版Java时,理解和适当管理PermGen空间仍然是避免OOM异常的重要一步。

5、Metaspace元空间耗尽,引起OOM?

,Metaspace(元空间)的耗尽也可以导致OutOfMemoryError(OOM)异常。Metaspace是Java 8及更新版本中取代了永久代(PermGen)的区域,用于存储类的元数据。与PermGen不同,Metaspace的大小不再由 -XX:MaxPermSize 参数控制,而是由操作系统的虚拟内存限制(如物理内存和交换空间)来管理。

引起Metaspace OOM的情况:

  1. 动态生成和加载类过多
    • 如果应用程序动态生成了大量的类(比如通过反射或者动态代理),或者加载了大量的第三方库和依赖,可能会导致Metaspace空间的耗尽。
  2. 字符串常量池和静态变量过多
    • Metaspace还包括存储字符串常量池和静态变量的空间。如果应用程序大量使用字符串常量或者有大量的静态变量,也会增加Metaspace的使用量。
  3. 未正确配置Metaspace大小
    • 虽然Metaspace的大小由操作系统的虚拟内存限制来管理,但可以通过 -XX:MetaspaceSize-XX:MaxMetaspaceSize 参数来调整Metaspace的初始大小和最大大小。如果未能合理配置这些参数,可能会导致Metaspace OOM。

解决Metaspace OOM的方法:

  1. 增加Metaspace大小
    • 可以通过调整 -XX:MetaspaceSize-XX:MaxMetaspaceSize 参数来增加Metaspace的初始大小和最大大小。例如,-XX:MaxMetaspaceSize=256m 表示将Metaspace的最大大小设置为256MB。
  2. 优化类的加载和卸载
    • 确保应用程序只加载需要的类,并且能够及时卸载不再需要的类。合理使用类加载器、避免不必要的动态类生成等可以减少Metaspace的压力。
  3. 检查字符串和静态变量的使用
    • 分析应用程序中字符串常量池和静态变量的使用情况,优化代码,尽量减少不必要的字符串常量和静态变量的创建。

6、无法新建本机线程?

引起无法新建本机线程的原因:

  1. 操作系统资源限制
    • 操作系统对于每个进程的线程数量有限制。如果应用程序请求创建的线程数超过了操作系统允许的限制,就会导致无法新建本机线程的错误。不同的操作系统和配置可能有不同的限制。
  2. 内存资源不足
    • 创建一个新的线程需要分配内存资源,包括栈空间和其他数据结构所需的内存。如果系统内存资源已经耗尽或者达到了操作系统的限制,就无法再创建新的线程。
  3. 线程资源泄漏
    • 如果应用程序存在线程资源泄漏,即创建了大量的线程但未正确释放,会导致线程池或系统资源被耗尽,进而导致无法创建新的线程。

解决方法:

  1. 增加操作系统的线程限制
    • 可以通过调整操作系统的配置参数(如ulimit)来增加允许每个进程的线程数量。具体操作取决于使用的操作系统。
  2. 优化线程使用
    • 确保应用程序合理使用线程,避免创建过多的线程。可以使用线程池来管理和重用线程,以减少线程创建和销毁的开销。
  3. 检测和修复线程泄漏
    • 使用性能分析工具来检测是否存在线程泄漏问题,及时修复并释放不再需要的线程资源。
  4. 升级硬件和优化资源分配
    • 如果是因为系统资源不足导致的问题,考虑升级硬件(如增加内存)或者优化应用程序的资源使用方式。

7、为什么wait\notify\notifyall是在 Object 而不是 Thread 中?

wait(), notify(), 和 notifyAll() 方法是定义在 Java 中所有对象的基类 Object 中,而不是 Thread 类中的原因主要有两点:

  1. 等待/通知模型的本质
    • Java 中的等待/通知(wait/notify)机制是基于对象之间的协作而不是线程之间的协作。每个对象都有一个相关的锁(或监视器),线程可以通过获取该锁来进入对象的同步代码块或方法。wait(), notify(), 和 notifyAll() 方法实际上是用来管理线程在对象上的等待和通知关系,而不是直接控制线程的执行。
  2. 对象的监视器和等待集合
    • 每个对象在 Java 中都与一个监视器(monitor)相关联,该监视器包含一个等待集合(wait set)。当一个线程调用对象的 wait() 方法时,它将释放该对象的锁并进入等待集合,直到其他线程调用相同对象的 notify()notifyAll() 方法来唤醒等待线程。因此,这些方法是操作对象的等待集合和通知等待线程的机制。

因此,将 wait(), notify(), 和 notifyAll() 方法定义在 Object 类中使得任何对象都可以作为同步的信号量或锁,并允许线程在等待和唤醒之间进行协作。这种设计的优势在于它的通用性和灵活性,使得 Java 中的线程同步和协作机制更加简洁和直观。

8、为什么 String 在 Java 中是不可变的?

在 Java 中,String 对象是不可变的,这意味着一旦创建了一个 String 对象,它的值就不能被修改。这种设计选择有以下几个重要的原因:

1、安全性:

字符串常常作为用于密码、数据库连接等敏感信息的容器。如果字符串是可变的,那么在程序的任何地方都有可能修改它们的内容,这可能导致安全漏洞。通过使字符串不可变,可以确保一旦创建,它们的值在整个程序执行期间保持不变,从而避免了意外的修改。

2、线程安全:

不可变的字符串是线程安全的,因为多个线程可以同时访问它们而无需担心数据竞争或需要同步措施。如果字符串是可变的,那么在并发环境中需要额外的同步操作来确保多线程访问时不会导致不一致的状态。

3、缓存优化:

不可变字符串可以被缓存,因为它们的值在内存中是唯一的,可以安全地共享。这种共享可以提高性能,因为不需要为相同的字符串分配额外的内存。

4、Hash值缓存:

因为字符串是不可变的,所以它们的 hash 值可以在第一次计算后进行缓存。这种优化提高了字符串作为 HashMap 的键时的性能,因为不需要每次访问都重新计算 hash 值。

5、简化并提高效率:

不可变对象的设计使得编码更加简单和可靠。它们也更容易进行优化,因为编译器可以在编译时对字符串的处理进行更多的静态优化,而不必担心在运行时可能发生的意外修改。

总之,Java 中字符串不可变的设计是出于安全性、线程安全、性能优化以及代码可靠性的考虑。这种设计选择是 Java 语言中的一个重要特性,被广泛应用于各种开发场景中。

9、什么是线程安全的单例,你怎么创建它?

线程安全的单例是指在多线程环境下,保证只有一个实例被创建,并且所有线程都能够安全地访问该实例。在 Java 中,创建线程安全的单例通常有以下几种方式:

  1. 饿汉式单例模式(Eager Initialization) 在类加载时就创建单例实例,保证线程安全。适合于单例对象较小且使用频繁的情况。
public class Singleton {
 // 在类加载时即创建单例实例
 private static final Singleton instance = new Singleton();

 // 私有构造方法,防止外部实例化
 private Singleton() {}

 // 提供全局访问点
 public static Singleton getInstance() {
     return instance;
 }
}
  1. 懒汉式单例模式(Lazy Initialization) 延迟实例化,在首次被调用时才创建单例实例。可以通过加锁保证线程安全,但性能稍差。 2.1. 简单的懒汉式单例(非线程安全)
public class Singleton {
 private static Singleton instance;

 private Singleton() {}

 public static Singleton getInstance() {
     if (instance == null) {
         instance = new Singleton();
     }
     return instance;
 }
}

这种简单的懒汉式单例在多线程环境下会有问题,可能会创建多个实例。 2.2. 加锁的懒汉式单例(线程安全) 使用双重检查锁定(double-checked locking)确保在多线程环境下也只创建一个实例。 public class Singleton { private static volatile Singleton instance;

private Singleton() {}

public static Singleton getInstance() {
    if (instance == null) {
        synchronized (Singleton.class) {
            if (instance == null) {
                instance = new Singleton();
            }
        }
    }
    return instance;
}

}

1.volatile 关键字确保在多线程情况下,instance 的修改能够立即被其他线程可见。 2.双重检查锁定可以减少锁的使用次数,提高性能。

  1. 静态内部类单例模式 利用 Java 类加载的特性,结合静态内部类实现懒加载,并且保证线程安全。
public class Singleton {
 private Singleton() {}

 private static class SingletonHolder {
     private static final Singleton INSTANCE = new Singleton();
 }

 public static Singleton getInstance() {
     return SingletonHolder.INSTANCE;
 }
}

静态内部类的方式利用了类加载时的线程安全性,同时实现了延迟加载,保证了高效和线程安全。 总结 选择合适的单例模式取决于应用的具体需求,饿汉式和静态内部类方式通常是首选,因为它们都能够保证线程安全且实现简单高效。在实现线程安全单例时,要注意考虑性能、延迟加载需求以及并发访问的情况,以选择最适合的实现方式。

10. Java中的CAS算法?

在Java中,CAS(Compare and Swap)是一种乐观锁机制,通常用于实现多线程环境下的并发控制。CAS算法的核心思想是,当多个线程尝试更新同一个变量时,只有一个线程能够成功,其他线程失败,失败的线程可以根据失败的情况进行重试或者其他逻辑处理。 CAS的基本原理 CAS操作包含三个操作数:内存位置(通常是一个变量的内存地址)、期望值(即当前变量的预期值)、新值(即希望更新后的新值)。操作的过程如下:

1.读取当前内存中的值(期望值)。 2.比较内存中的值与期望值是否相等。 3.如果相等,则更新内存中的值为新值。 4.如果不相等,则不做任何操作(或者根据业务逻辑进行重试等)。

Java中的CAS操作通常由 java.util.concurrent.atomic 包中的类提供,例如 AtomicInteger, AtomicLong, AtomicReference 等。 示例 以 AtomicInteger 为例,它使用CAS来实现线程安全的自增操作: import java.util.concurrent.atomic.AtomicInteger;

public class AtomicExample { private static AtomicInteger counter = new AtomicInteger(0);

public static void main(String[] args) {
    // 多线程同时自增操作
    Thread thread1 = new Thread(() -> {
        for (int i = 0; i < 1000; i++) {
            counter.incrementAndGet(); // CAS操作,自增并获取结果
        }
    });

    Thread thread2 = new Thread(() -> {
        for (int i = 0; i < 1000; i++) {
            counter.incrementAndGet(); // CAS操作,自增并获取结果
        }
    });

    thread1.start();
    thread2.start();

    // 等待两个线程执行完成
    try {
        thread1.join();
        thread2.join();
    } catch (InterruptedException e) {
        e.printStackTrace();
    }

    // 打印最终结果
    System.out.println("Final counter value: " + counter.get());
}

}

CAS的优缺点 优点:

5.比传统的锁机制(如synchronized)具有更好的性能,因为它是乐观锁,不会阻塞线程。 6.避免了死锁的发生。

缺点:

7.自旋重试会消耗CPU资源,特别是在高并发时。 8.ABA问题:CAS操作基于当前值,无法检测到值被多次修改后恢复原值的情况,可以通过版本号或标记来解决。

在Java并发编程中,CAS算法在实现非阻塞算法和高性能并发容器时被广泛应用,例如ConcurrentHashMap等。

11、Aio,Nio,Bio的作用和区别?

在Java中,AIO(Asynchronous I/O)、NIO(Non-blocking I/O)、BIO(Blocking I/O)是三种不同的I/O模型,各自具有不同的特点和适用场景。

  1. BIO(Blocking I/O) 作用:

1.BIO是最传统的I/O模型,也称为阻塞I/O。 2.每个I/O操作(如读或写)都会阻塞当前线程,直到数据准备好或者操作完成。 3.适合于低并发、简单易理解的场景,如单线程服务器或者客户端编程。

特点:

4.每个连接都需要独立的线程进行处理,如果连接数目较大,需要大量线程,会造成线程资源消耗。 5.阻塞I/O的方式简单直观,但在高并发环境下效率低下,因为大量线程会竞争系统资源而导致性能下降。

  1. NIO(Non-blocking I/O) 作用:

6.NIO引入了非阻塞概念,使得一个线程可以管理多个连接(Channel)。 7.NIO支持Selector选择器,通过一个线程可以监听多个Channel上的事件,实现了多路复用。 8.适合于高并发、连接数多的网络应用,如聊天服务器、游戏服务器等。

特点:

9.通过单一的线程管理多个连接,避免了大量线程的创建和维护,提高了系统的扩展性和性能。 10.编程模型较复杂,需要处理事件的分发和管理,但可以通过合理的事件驱动模型实现高效的网络通信。

  1. AIO(Asynchronous I/O) 作用:

11.AIO是在NIO的基础上进一步封装的一种异步非阻塞I/O模型。 12.AIO的关键在于操作系统提供的异步通知机制,使得I/O操作完全异步完成,不需要通过轮询方式等待数据就绪。 13.适合于处理大量并发连接,并且每个连接都有较少的数据交互但需要快速响应的场景,如文件操作、大文件传输等。

特点:

14.完全异步的处理方式,不会阻塞线程,可以大幅提升系统的吞吐量和并发能力。 15.相对于NIO,AIO对于大文件读写和网络编程的支持更为强大,但在某些情况下可能带来更高的系统开销。

区别总结

BIO:每个连接都需要独立的线程处理,阻塞I/O,适合低并发的场景。 NIO:通过单一线程管理多个连接,非阻塞I/O,使用选择器实现多路复用,适合高并发的网络应用。 AIO:完全异步非阻塞I/O,利用操作系统提供的异步通知机制,适合大量连接和大文件操作。

选择合适的I/O模型取决于具体的应用场景和性能要求。

12、ReetrantLock和synchronized的区别和原理?

ReentrantLocksynchronized 是 Java 中用于实现线程同步的两种机制,它们有一些区别和原理上的不同点。

区别和原理

  1. 原理
  • synchronized
    • synchronized 是 Java 中的关键字,用于实现原子性的同步操作。
    • synchronized 依赖于 JVM 内置的锁机制,即 monitor 锁。
    • 当一个线程获取了对象的锁(monitor),其他试图获取该对象锁的线程将被阻塞,直到持有锁的线程释放锁。
    • synchronized 保证了线程的可见性和原子性,简单易用,编写和维护代码相对容易。
  • ReentrantLock
    • ReentrantLockjava.util.concurrent.locks 包中提供的锁实现类。
    • ReentrantLock 提供了比 synchronized 更多的灵活性和额外的功能。
    • ReentrantLock 是基于 AQS(AbstractQueuedSynchronizer)实现的,使用了 CAS(Compare and Swap)操作来实现非阻塞的同步。
    • 它支持可重入性、公平锁和非公平锁、定时锁等高级特性,可以实现更复杂的同步需求。
  1. 区别
  • 可重入性
    • synchronized 是可重入的,同一个线程可以多次获取同一个锁,而不会出现死锁。
    • ReentrantLock 也是可重入的,同一个线程可以多次获取锁,但需要注意在使用完毕后正确释放锁,否则可能造成死锁。
  • 灵活性和功能
    • ReentrantLocksynchronized 提供了更多的功能,如定时锁、公平锁和非公平锁的选择、可中断的锁获取、多条件变量等。
    • synchronized 是内置的语言特性,功能相对简单,不能灵活控制锁的行为。
  • 性能
    • 在 JDK 6 之前,synchronized 的性能比 ReentrantLock 差一些,但是从 JDK 6 开始,JVM 对 synchronized 进行了很多优化,性能逐渐接近。
    • ReentrantLock 在高并发情况下可能比 synchronized 更高效,特别是对于公平锁和大量线程竞争的场景。
  1. 使用场景
  • synchronized
    • 对于简单的同步需求,如方法内部的临界区同步,通常优先考虑使用 synchronized,因为它简单、安全且性能不错。
    • JDK 中的大部分类库都是使用 synchronized 进行同步的,比如 ArrayListHashMap 等。
  • ReentrantLock
    • 需要更高级功能的同步,比如可定时的、可中断的锁、公平锁和非公平锁的选择等,可以考虑使用 ReentrantLock
    • 在需要细粒度控制锁的获取和释放时,或者需要使用 Condition 来进行线程间的协作时,适合使用 ReentrantLock

综上所述,synchronized 是 Java 中最基本的线程同步方法,而 ReentrantLock 则提供了更多的高级功能和灵活性,适合复杂的并发控制需求。在实际开发中,根据具体的场景和需求选择合适的同步机制非常重要。

*Synchronized原理:*

Synchronized进过编译,会在同步块的前后分别形成monitorenter和monitorexit这个两个字节码指令。在执行monitorenter指令时,首先要尝试获取对象锁。如果这个对象没被锁定,或者当前线程已经拥有了那个对象锁,把锁的计算器加1,相应的,在执行monitorexit指令时会将锁计算器就减1,当计算器为0时,锁就被释放了。如果获取对象锁失败,那当前线程就要阻塞,直到对象锁被另一个线程释放为止。

*ReenTrantLock实现的原理:*

CAS+CLH队列来实现。它支持公平锁和非公平锁,两者的实现类似。

CAS:Compare and Swap,比较并交换。CAS有3个操作数:内存值V、预期值A、要修改的新值B。当且仅当预期值A和内存值V相同时,将内存值V修改为B,否则什么都不做。该操作是一个原子操作,被广泛的应用在Java的底层实现中。在Java中,CAS主要是由sun.misc.Unsafe这个类通过JNI调用CPU底层指令实现。

CLH队列:带头结点的双向非循环链表

ReentrantLock实现的前提就是AbstractQueuedSynchronizer,简称AQS,是java.util.concurrent的核心,CountDownLatch、FutureTask、Semaphore、ReentrantLock等都有一个内部类是这个抽象类的子类。先用两张表格介绍一下AQS。第一个讲的是Node,由于AQS是基于FIFO队列的实现,因此必然存在一个个节点,Node就是一个节点

13、HashMap实现原理

1、JDK1.7的时候使用的是数组+ 单链表的数据结构。但是在JDK1.8及之后时,使用的是数组+链表+红黑树的数据结构(当链表的深度达到8的时候,也就是默认阈值,就会自动扩容把链表转成红黑树的数据结构来把时间复杂度从O(n)变成O(logN)提高了效率)

2、新增数据和扩容方式

(1)JDK1.7用的是头插法,而JDK1.8及之后使用的都是尾插法,那么他们为什么要这样做呢?因为JDK1.7是用单链表进行的纵向延伸,当采用头插法时会容易出现逆序且环形链表死循环问题。但是在JDK1.8之后是因为加入了红黑树使用尾插法,能够避免出现逆序且链表死循环的问题。

(2)扩容后数据存储位置的计算方式也不一样:1. 在JDK1.7的时候是直接用hash值和需要扩容的二进制数进行&(这里就是为什么扩容的时候为啥一定必须是2的多少次幂的原因所在,因为如果只有2的n次幂的情况时最后一位二进制数才一定是1,这样能最大程度减少hash碰撞)(hash值 & length-1)

3、而在JDK1.8的时候直接用了JDK1.7的时候计算的规律,也就是扩容前的原始位置+扩容的大小值=JDK1.8的计算方式,而不再是JDK1.7的那种异或的方法。但是这种方式就相当于只需要判断Hash值的新增参与运算的位是0还是1就直接迅速计算出了扩容后的储存方式。

image-20240617210854371

14、HashMap把链表转化为红黑树的阈值是8,而不是7也不是20?

理解HashMap在Java中如何工作以及为什么会选择在链表长度超过一定阈值时转换为红黑树,需要涉及以下几个关键点:

  1. HashMap 概述 HashMap是Java中常用的数据结构,用于存储键值对。它通过哈希表实现,可以提供快速的插入、删除和查找操作。
  2. 哈希表的桶(Buckets) HashMap内部由一个数组(称为桶)组成,每个桶用来存储具有相同哈希码的键值对。当我们将一个键值对插入HashMap时,首先计算键的哈希码,然后根据哈希码确定存放位置。
  3. 解决哈希冲突 由于不同的键可能具有相同的哈希码(哈希冲突),因此每个桶实际上是一个链表或红黑树。当新的键值对需要插入到一个已经存在键的桶中时,新的键值对会添加到该桶对应的链表或红黑树中。
  4. 链表转换为红黑树的条件 为了提高HashMap在大量数据情况下的性能,JDK 8 引入了将链表转换为红黑树的优化策略。具体来说,当一个桶中的链表长度超过一定阈值时,HashMap会将这个链表转换为一个更高效的红黑树。这个阈值是8。
  5. 为什么选择阈值为8?

1.性能考虑:当链表长度较长时,查找、插入和删除操作的时间复杂度可能会变得较高(O(n),n为链表长度),转换为红黑树后,这些操作的时间复杂度可以降低到O(log n)。 2.空间利用:红黑树相比于链表会占用更多的空间,但当链表长度较长时,时间性能的提升通常会超过额外的空间消耗。 3.平衡考量:红黑树在高负载因子(大量元素存储在HashMap中)下更能保持平衡,从而提供更稳定的性能。

  1. 红黑树转换过程 一旦链表长度超过阈值8,HashMap会将该链表转换为一个红黑树。这个转换过程涉及重新计算哈希码、重新插入所有元素等操作,但由于红黑树的性能优势,这种转换对整体性能有积极的影响。
  2. JDK 8 之后的优化 JDK 8中还对HashMap进行了其他优化,例如引入了树化与树退化的机制,以及对键值对的查找逻辑进行了优化,这些都是为了在各种负载因子下提供更好的性能和稳定性。 总之,HashMap在设计上综合考虑了时间复杂度、空间利用和性能稳定性,通过合理选择链表转换为红黑树的阈值,有效地提高了在大数据量情况下的操作效率。

15、哈希表如何解决Hash冲突, key若Object类型,怎么办?

image-20240617211404074

哈希表在解决哈希冲突时通常会使用以下几种方法,不论是什么类型的键(Object类型或其他): 解决哈希冲突的方法:

1.链地址法(Separate Chaining):

2.这是最常见的解决冲突的方法之一。 3.每个桶(存储位置)维护一个链表或者更优化的红黑树结构,用于存储哈希冲突的键值对。 4.当发生哈希冲突时,新的键值对被插入到对应桶的链表或红黑树中。 5.Java的HashMap在JDK 8之后,对链表长度超过一定阈值(默认为8)的桶会将链表转换为红黑树,以提高查找效率。

6.开放地址法(Open Addressing):

7.在这种方法中,所有的数据项都存放在哈希表的桶数组中,而不是单独的链表或树结构。 8.当发生哈希冲突时,根据特定的探测序列(如线性探测、二次探测等),在其他的空桶中寻找可以存放该数据项的位置。 9.这种方法要求在哈希表中有足够的空桶,以保证能够在冲突发生时找到合适的位置。

Key为Object类型的情况: 如果键的类型是Object类型(比如自定义的类),Java中通常会依赖该对象的hashCode()方法和equals()方法来计算哈希值和比较键的相等性。具体步骤如下:

10.计算哈希值: 11.调用键对象的hashCode()方法来获取哈希码。 12.hashCode()方法的实现需要保证对于相等的对象返回相同的哈希码,但不同的对象也可以返回相同的哈希码(这就是哈希冲突)。 13.确定存储位置: 14.根据哈希码计算出存储位置(桶的索引)。 15.如果该位置已经有其他键值对,则需要使用上述的解决冲突方法来处理。 16.处理冲突: 17.如果发生了哈希冲突,HashMap会根据具体的实现选择合适的解决冲突方法。 18.对于链地址法,将键值对添加到对应桶的链表或红黑树中。 19.对于开放地址法,根据探测序列寻找下一个可用的空桶。

总结来说,无论键的类型是什么,在哈希表中都需要解决哈希冲突。Java中的HashMap通过链地址法来解决冲突,并且对于Object类型的键,依赖对象的hashCode()和equals()方法来计算哈希值和比较键的相等性。

16、ArrayList实现原理?

ArrayList 是 Java 中常用的动态数组实现,它的实现原理如下:

1.内部数组存储:

ArrayList 内部通过一个数组来存储元素,数组的默认初始化大小是 10。 如果元素数量超过了当前数组的容量,ArrayList 会进行扩容操作。扩容的策略是创建一个新的更大容量的数组,并将旧数组中的元素复制到新数组中。

4.动态扩容:

当添加新元素导致当前数组容量不足时,ArrayList 会执行扩容操作。 扩容的大小通常是当前容量的 1.5 倍(在一些实现中也有不同的倍数,但通常不会是线性增长),以减少频繁扩容的开销。 .扩容涉及数组的复制操作,时间复杂度为 O(n),其中 n 是当前 ArrayList 中的元素数量。

8.随机访问:

通过数组下标进行快速访问,时间复杂度为 O(1)。 因为内部是数组实现,所以支持通过索引快速访问和修改元素。

插入和删除:

在尾部插入元素的时间复杂度为 O(1),因为不涉及移动其他元素。 在中间或头部插入元素则涉及到将插入点后的元素向后移动,时间复杂度为 O(n)。 删除操作类似,尾部删除的时间复杂度为 O(1),而删除中间或头部的操作涉及到元素的移动,时间复杂度也是 O(n)。

15.迭代器:

ArrayList 提供了迭代器 Iterator 接口的实现,可以通过迭代器遍历 ArrayList 中的元素。 迭代器的操作是基于数组的索引,因此迭代访问的时间复杂度也是 O(n)。

18.线程不安全:

ArrayList 不是线程安全的,不适合在多线程环境下进行并发操作。如果需要在多线程环境中使用,可以考虑使用线程安全的 Vector 或者通过 Collections.synchronizedList() 方法包装实现线程安全的 List。

总结来说,ArrayList 的实现基于数组,通过动态扩容和数组复制来实现动态大小。它适合于需要频繁随机访问元素和尾部插入、删除元素的场景,但对于频繁插入、删除中间元素的操作效率较低。

17、ConcurrentHashMap实现原理?

ConcurrentHashMap 是 Java 中线程安全的哈希表实现,它的实现原理相比于普通的 HashMap 更为复杂和精细化,主要目的是支持高并发的读写操作而不需要显式的同步措施。以下是 ConcurrentHashMap 的主要实现原理和特点:

  1. 分段锁(Segmentation)
  • ConcurrentHashMap 内部将数据结构分成多个段(Segment),每个段类似于一个小的 HashMap,拥有自己的锁。
  • 初始时,ConcurrentHashMap 包含多个段,每个段中包含一部分桶(buckets)。
  • 目的是通过减小锁的粒度,使得多个线程可以并发地操作不同的段,从而提高并发性能。
  1. 桶(Buckets)
  • 每个段中包含多个桶(buckets),每个桶存储多个键值对。
  • 桶的数量是可以动态调整的,可以根据实际需求进行扩展。
  1. put 操作
  • 对于 put 操作,会先根据键的哈希值确定所属的段,然后对该段加锁。
  • 在加锁的段内,会根据键的哈希值确定存放位置,将键值对存放到对应的桶中。
  • 如果需要扩展桶的数量或者段的数量,会涉及到复制原有数据结构的操作。

4. get 操作

  • 对于 get 操作,同样先根据键的哈希值确定所属的段,然后对该段加锁。
  • 在加锁的段内,根据键的哈希值找到对应的桶,然后查找桶中的键值对。
  • ConcurrentHashMap 在读取时可以不加锁,但可能会读取到更新中的数据(弱一致性),因此可能需要通过 volatile 或者 CAS(Compare and Swap)操作来确保读取的正确性。

5. remove 操作

  • 对于 remove 操作,同样需要先定位所属的段并加锁。
  • 然后在锁定的段内进行删除操作,需要注意处理可能的冲突和扩容情况。

6. CAS 和 volatile

  • ConcurrentHashMap 内部使用了 CAS 和 volatile 等机制来保证在并发环境下的数据一致性和更新操作的原子性。

7. 扩容

  • ConcurrentHashMap 在扩容时不会复制整个数据结构,而是仅复制需要扩容的段,这样可以减小扩容的开销和影响。
  • 扩容时,每次只会处理一个段,而其他段仍然可以被访问,因此整体的并发性能得到了提高。

8. 性能和并发度

  • ConcurrentHashMap 的设计旨在提高并发度和性能,特别适用于读多写少的场景。
  • 由于分段锁的设计,多个线程可以同时访问不同段,因此在多线程并发读写时能够提供比普通 HashMap 更好的性能。

总结来说,ConcurrentHashMap 的实现利用了分段锁和精细化的操作来支持高并发环境下的安全操作,是Java中线程安全且高效的哈希表实现之一。

18、java8新特性?

Java 8 引入了许多新特性和改进,主要集中在语言、库和工具方面,其中一些最显著的新特性包括:

  1. Lambda 表达式 Lambda 表达式是 Java 8 中最重要的新特性之一,它提供了一种简洁而功能强大的方法来传递和使用匿名函数。
List<String> names = Arrays.asList("Alice", "Bob", "Charlie");
names.forEach(name -> System.out.println(name));
  1. Stream API Stream API 提供了一种更简洁、更易读的方式来处理集合数据。它支持函数式编程的风格,可以进行过滤、映射、归约等操作。
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
int sum = numbers.stream()
              .filter(n -> n % 2 == 0)
              .mapToInt(n -> n * 2)
              .sum();
  1. 接口的默认方法和静态方法 接口现在可以包含默认方法(default methods),这些方法可以在接口中直接定义实现,而不需要所有实现类重新实现。还引入了静态方法。
public interface MyInterface {
    default void defaultMethod() {
        System.out.println("Default method implementation");
    }

    static void staticMethod() {
        System.out.println("Static method in interface");
    }
   }


4、方法引用 方法引用提供了一种更简洁地调用现有方法的语法,支持静态方法、实例方法和构造函数的引用。

Function<String, Integer> parseIntFunction = Integer::parseInt;

5、新的日期和时间 API java.time 包提供了全新的日期和时间 API,解决了旧 java.util.Date 和 java.util.Calendar 的不足,设计更为清晰和易用。

LocalDate today = LocalDate.now();
LocalDateTime dateTime = LocalDateTime.of(2024, Month.JUNE, 17, 10, 30);

6、CompletableFuture CompletableFuture 是一种新的异步编程机制,支持非阻塞的回调风格编程,能够更方便地处理异步任务和组合多个异步操作。

CompletableFuture.supplyAsync(() -> "Hello")
 .thenApply(s -> s + " World")
 .thenAccept(System.out::println);

7、函数式接口 新增了 @FunctionalInterface 注解,用于标记函数式接口,接口中只能有一个抽象方法。

@FunctionalInterface
interface MyFunctionalInterface {
 void myMethod();
}

8、默认方法允许在接口中提供方法实现。

19、TCP的三次握手和四次挥手?

image-20240617213017416

TCP 三次握手(Three-Way Handshake)

TCP 三次握手是建立一个 TCP 连接的过程,确保双方都能够同步初始化序列号(Sequence Number)和确认通信双方的能力。

  1. 第一步:客户端发送 SYN 报文
    • 客户端(Client)向服务器(Server)发送一个特殊的TCP报文段,其中SYN标志位被置为1,表明客户端希望建立连接。
    • 客户端选择一个初始序列号(Sequence Number),用来标识从客户端到服务器的数据。
  2. 第二步:服务器响应 SYN-ACK 报文
    • 如果服务器愿意建立连接,它会回复一个TCP报文段,其中SYN和ACK标志位都被置为1。
    • 服务器选择自己的初始序列号,同时确认客户端的SYN报文。
    • ACK标志表示确认号(Acknowledgement Number),确认服务器收到了客户端的SYN报文。
  3. 第三步:客户端发送 ACK 报文
    • 最后,客户端向服务器发送另一个TCP报文段,其中ACK标志位被置为1。
    • 客户端确认了服务器的SYN-ACK报文,同时服务器的序列号也被确认。
    • 至此,TCP连接建立完成,双方可以开始传输数据。

TCP 四次挥手(Four-Way Handshake)

TCP 四次挥手是安全地关闭一个TCP连接,确保数据能够完整传输和释放连接资源。

  1. 第一步:客户端发送 FIN 报文
    • 客户端决定关闭连接时,发送一个TCP报文段,FIN标志位被置为1。
    • 客户端不再发送数据,但仍可以接收数据。
  2. 第二步:服务器响应 ACK 报文
    • 服务器收到客户端的FIN报文后,发送一个TCP报文段作为确认,ACK标志位被置为1。
    • 服务器确认收到了客户端的关闭请求,但此时可能还有数据需要发送给客户端。
  3. 第三步:服务器发送 FIN 报文
    • 当服务器所有数据都发送完毕后,会发送另一个TCP报文段,FIN标志位被置为1。
    • 服务器希望关闭连接,不再发送数据。
  4. 第四步:客户端响应 ACK 报文
    • 最后,客户端收到服务器的FIN报文后,发送一个TCP报文段作为确认,ACK标志位被置为1。
    • 客户端确认了服务器的关闭请求,并进入TIME-WAIT状态。
    • TIME-WAIT状态是为了确保最后一个ACK报文能够被服务器收到,以及处理可能出现的延迟报文段。

通过TCP四次挥手,双方确认了数据传输完毕,并释放了连接资源,完成了TCP连接的安全关闭过程。

20、TCP相关面试题?

*连接是三次握手关闭却是四次握手?*

当Server端收到Client端的SYN连接请求报文后,可以直接发送SYN+ACK报文。其中ACK报文是用来应答的,SYN报文是用来同步的。但是关闭连接时,当Server端收到FIN报文时,很可能并不会立即关闭SOCKET,所以只能先回复一个ACK报文,告诉Client端,"你发的FIN报文我收到了"。只有等到我Server端所有的报文都发送完了,我才能发送FIN报文,因此不能一起发送。故需要四步握手。

*TIME_WAIT状态经过2MSL才能返回到CLOSE状态?*

四个报文都发送完毕,我们可以直接进入CLOSE状态了,但是我们必须假象网络是不可靠的,有可以最后一个ACK丢失。所以TIME_WAIT状态就是用来重发可能丢失的ACK报文

*不能用两次握手连接?*

3次握手完成两个重要的功能,既要双方做好发送数据的准备工作(双方都知道彼此已准备好),也要允许双方就初始序列号进行协商,这个序列号在握手过程中被发送和确认。

*如果建立了连接,但是客户端突然出现故障了怎么办?*

TCP还设有一个保活计时器,显然,客户端如果出现故障,服务器不能一直等下去,白白浪费资源。服务器每收到一次客户端的请求后都会重新复位这个计时器,时间通常是设置为2小时,若两小时还没有收到客户端的任何数据,服务器就会发送一个探测报文段,以后每隔75秒钟发送一次。若一连发送10个探测报文仍然没反应,服务器就认为客户端出了故障,接着就关闭连接。

21、生产环境排查定位思路

熟悉linux基本命令,排查日志你首先要知道关键字,比如产品ID等等ID,

命令|搜索关键字|定位到日志信息log|看堆栈信息|分析具体异常原因(结合你实际问题场景)

如果是分布式系统,有一个关键字叫traceId,为每次请求分配一个流水号traceId,在日志打印处加上这个traceId,模块调用时亦将traceId往下传,直至整条消息处理完成,返回时清除traceId

模块内部日志串联:在网关接到订单时生成唯一流水号traceId,并将其放入threadLocal线程上下文里面,修改日志包在输出时取出threadLocal的traceId,一同输出,这样模块内部的同个请求的日志便可以串联起来;(也可以直接使用org.slf4j.MDC进行设置)

模块之间调用传递traceId:由于项目使用的是阿里的dubbo框架,利用dubbo的RpcContext分別实现DubboConsumerFilter和DubboProviderFilter,前者负责将traceId放入RPC上下文,后者则取出traceId放入threadLocal;

22、Spring Bean的生命周期?

Spring Bean 的生命周期包括以下几个关键阶段:

  1. 实例化(Instantiation):
    • Spring 根据配置或注解创建 Bean 的实例。
  2. 依赖注入(Dependency Injection):
    • Spring 将依赖注入到 Bean 的相应属性中。
  3. 初始化方法调用(Initialization):
    • 如果 Bean 实现了 InitializingBean 接口,或者在方法上标注了 @PostConstruct 注解,Spring 将调用其初始化方法。
  4. Bean 可用(Ready for Use):
    • Bean 初始化完成,可以被应用程序使用。
  5. 销毁方法调用(Destruction):
    • 如果 Bean 实现了 DisposableBean 接口,或者在方法上标注了 @PreDestroy 注解,Spring 在销毁 Bean 之前会调用其销毁方法。

Spring 容器负责管理整个生命周期过程,确保 Bean 的依赖注入、初始化和销毁都按照配置和规范进行。

23、Spring的核心接口

*BeanFactory*

BeanFactory接口负责创建和分发各种类型的Bean。

在Spring中有几种BeanFactory的实现,其中最常用的是org.springframework.bean.factory.xml.XmlBeanFactory。它根据XML文件中的定义装载Bean。

要创建XmlBeanFactory,需要传递一个InputStream对象给构造函数,用来提供XML文件给工厂

*ApplicationContext*

ApplicationContext(应用上下文)继承自BeanFactory,表面上看两者功能差不多,都是载入Bean定义信息,装配Bean,根据需要分发Bean,但是ApplicationContext提供了更多功能:

1、应用上下文提供了文本信息解析工具,包括对国际化的支持

2、应用上下文提供了载入文本资源的通用方法,如载入图片

3、应用上下文可以向注册为监听器的Bean发送事件

在ApplicationContext的诸多实现中,有如下三个常用的实现。

ClassPathXmlApplicationContext:从类路径中的XML文件载入上下文定义信息,把上下文定义文件当成类路径资源。

FileSystemXmlApplicationContext:从文件系统中的XML文件载入上下文定义信息

XmlWebApplicationContext:从Web系统中的XML文件载入上下文定义信息

24、SpringMVC的运行流程?

image-20240603140644956

Spring MVC 的请求流程可以简要概括为以下几个步骤:

  1. 请求到达 DispatcherServlet:
    • 客户端的请求首先被 DispatcherServlet 接收。
  2. HandlerMapping 定位处理器(Controller):
    • DispatcherServlet 通过 HandlerMapping 确定请求对应的处理器(Controller)。
  3. 处理器执行(Controller):
    • 根据 HandlerMapping 的映射结果,DispatcherServlet 调用相应的 Controller 处理请求,并执行相关的业务逻辑。
  4. ModelAndView 的生成:
    • Controller 处理完成后,返回一个 ModelAndView 对象,其中包含处理结果数据以及视图名称。
  5. ViewResolver 解析视图:
    • DispatcherServlet 通过 ViewResolver 解析视图名称,确定最终的视图对象。
  6. 视图渲染:
    • 最终的视图对象负责渲染返回给客户端的内容,通常是 HTML 页面或者其他类型的响应数据。
  7. 响应返回给客户端:
    • 渲染后的视图内容或者响应数据返回给客户端,完成请求处理过程。

在这个过程中,DispatcherServlet 充当中央调度器的角色,负责协调整个请求的处理流程,从接收请求到最终的响应返回,整个过程利用了各种配置组件(如 HandlerMapping、Controller、ViewResolver 等)来实现请求的转发和处理。

25、ThreadLocal具体怎么使用?使用在什么场景?

ThreadLocal 是 Java 中的一个类,它提供了线程局部变量的功能。具体来说,ThreadLocal 实例通常被用来在每个线程中存储一些数据,使得这些数据对于线程是独立的,线程之间互不干扰。

如何使用 ThreadLocal?

  1. 创建 ThreadLocal 变量:

    javaprivate static ThreadLocal<MyObject> threadLocal = new ThreadLocal<>();
    
  2. 向 ThreadLocal 设置值:

    javathreadLocal.set(new MyObject());
    
  3. 从 ThreadLocal 获取值:

    javaMyObject obj = threadLocal.get();
    
  4. 清理 ThreadLocal 中的值(可选):

    threadLocal.remove();
    

ThreadLocal是线程本地存储,在每个线程中都创建了一个ThreadLocalMap对象,

每个线程可以访问自己内部ThreadLocalMap对象内的value

经典的使用场景是为每个线程分配一个JDBC连接Connection。

这样就可以保证每个线程的都在各自的Connection上进行数据库的操作,不会出现A线程关了B线程正在使用的Connection

还有Session管理等问题,在线程池中线程的存活时间太长,往往都是和程序同生共死的

这样Thread持有的ThreadLocalMap一直都不会被回收,再加上ThreadLocalMap中的Entry对ThreadLocal是弱引用(WeakReference)

所以只要ThreadLocal结束了自己的生命周期是可以被回收掉的。

Entry中的Value是被Entry强引用的,即便value的生命周期结束了,value也是无法被回收的,导致内存泄露

标准应用是在finally代码块中手动清理ThreadLocal中的value,调用ThreadLocal的remove()方法