Java后端开发岗高频面试题及答案(面试必看)

Java 面试随着时间的改变而改变。在过去的日子里,当你知道 String 和 StringBuilder 的区别就能让你直接进入第二轮面试,但是现在问题变得越来越高级,面试官问的问题也更深入。

在我初入职场的时候,类似于 Vector 与 Array 的区别、HashMap 与 Hashtable 的区别是最流行的问题,只需要记住它们,就能在面试中获得更好的机会,但这种情形已经不复存在。

如今,你将会被问到许多 Java 程序员都没有看过的领域,如 NIO,[设计模式]“设计模式:可复用面向对象软件的基础”),成熟的单元测试,或者那些很难掌握的知识,如并发、算法、数据结构及编码。


一、Java 基础​

1. 深入讲讲 Java 内存模型(JMM)以及它如何解决可见性、原子性和有序性问题?​

Java 内存模型(JMM)是一种抽象的规范,定义了线程和主内存之间的抽象关系。线程之间的共享变量存储在主内存中,每个线程都有自己独立的工作内存,线程对变量的操作都在工作内存中进行,不能直接读写主内存中的变量。​

  • 可见性:当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。JMM 通过volatile关键字来保证可见性。被volatile修饰的变量,在每次被线程访问时,都会强制从主内存中读取最新值;在每次被修改后,也会立即刷新到主内存中,从而让其他线程能够看到最新值。此外,synchronized块在进入时会从主内存刷新变量,退出时会将变量写回主内存,也能保证可见性。​
  • 原子性:一个操作是不可中断的,要么全部执行成功,要么全部不执行。对于基本数据类型的变量赋值操作(除了long和double),JMM 保证其原子性。对于long和double类型的变量赋值,在某些平台上可能不是原子操作。而对于复合操作(如i++),需要使用atomic包下的原子类(如AtomicInteger)或者synchronized关键字来保证原子性。AtomicInteger内部利用 CAS(Compare - And - Swap)算法,在硬件层面保证操作的原子性;synchronized关键字通过锁机制,同一时刻只允许一个线程进入同步块,从而保证了代码块中操作的原子性。​
  • 有序性:程序执行的顺序按照代码的先后顺序执行。但在实际执行中,为了提高性能,编译器和处理器可能会对指令进行重排序。JMM 通过happens - before原则来保证有序性。例如,volatile变量规则规定对一个volatile变量的写操作先行发生于后面对这个volatile变量的读操作;传递性规则指出如果 A 先行发生于 B,B 先行发生于 C,那么 A 先行发生于 C 等。synchronized块也通过锁的获取和释放建立了happens - before关系,保证了同步块内代码的有序性。​

2. 详述 Java 中方法的重载(Overloading)和重写(Overriding),并说明它们在多态性中的作用及区别?​

  • 重载(Overloading):指在同一个类中,方法名相同,但参数列表不同(参数个数、类型或顺序不同)的方法。

重载体现了编译时多态性,在编译阶段,编译器会根据调用方法时传入的参数类型和个数来决定调用哪个方法。它增加了方法的灵活性,方便在不同场景下对相似功能进行统一命名。​

  • 重写(Overriding):发生在子类与父类之间,子类重写父类中已有的方法,要求方法名、参数列表、返回类型(在 Java 5 及以上,返回类型可以是父类方法返回类型的子类型)都相同,并且访问权限不能比父类中被重写的方法更严格。

重写体现了运行时多态性,在运行时,根据对象的实际类型来决定调用哪个类的重写方法。通过重写,子类可以根据自身特点定制父类方法的行为,实现不同子类对相同方法的不同实现,增强了代码的扩展性和可维护性。​

  • 区别:重载是在同一类中方法名相同参数不同,编译时决定调用哪个方法;重写是子类重写父类方法,运行时根据对象实际类型决定调用子类还是父类的方法。重载不涉及访问权限和返回类型的特殊要求(除参数不同外),重写要求方法签名一致且访问权限不能更严格,返回类型有特定规则。​

3. 谈谈你对 Java 中泛型擦除的理解,它会带来哪些问题,如何解决?​

泛型是 Java 5 引入的特性,它允许在定义类、接口和方法时使用类型参数,提高代码的复用性和安全性。但 Java 中的泛型在编译后会进行擦除,即编译后的字节码文件中,泛型类型信息会被擦除,替换为其限定类型(如果有泛型限定,如<T extends Number>,则替换为Number;如果没有限定,替换为Object)。​

  • 带来的问题:​
  • 无法在运行时获取泛型的实际类型:例如List<String>和List<Integer>在运行时类型擦除后都是List,无法区分。​
  • 不能创建泛型数组:如T[] array = new T[10];是不允许的,因为类型擦除后无法确定数组的实际类型。​
  • 泛型方法重载问题:两个泛型方法如果仅仅是类型参数不同,在编译后会变成相同的方法签名,导致编译错误。
  • 解决方法:​
  • 利用反射获取泛型信息:在一些场景下,可以通过反射来获取泛型的实际类型。例如,通过获取方法的参数化类型,解析其中的泛型信息。​
  • 使用通配符和上限下限限定:在使用泛型时,合理使用通配符?以及上限限定(如<T extends Number>)和下限限定(如<T super Integer>)来解决部分类型不明确的问题,同时又能保证一定的类型安全。​
  • 使用类型令牌:可以创建一个代表泛型类型的类,并将其作为参数传递,在运行时通过该类获取泛型类型信息。​

二、集合与数据结构​

4. 分析 ConcurrentHashMap 在高并发场景下的性能优势及原理,与 HashTable 对比有何不同?​

ConcurrentHashMap 在高并发场景下具有出色的性能优势,其原理和与 HashTable 的对比如下:​

  • 性能优势及原理:​
  • 分段锁机制:在 Java 7 及之前版本,ConcurrentHashMap 采用分段锁(Segment)机制,将整个哈希表分成多个段(默认 16 个),每个段独立加锁。当多个线程访问不同段的数据时,不会产生锁竞争,从而大大提高了并发性能。例如,线程 A 访问第一段数据,线程 B 访问第二段数据,它们可以同时进行,互不干扰。在 Java 8 中,虽然摒弃了 Segment 机制,但仍然采用了类似的思想,使用 CAS 操作和synchronized关键字结合来保证并发安全,并且在链表过长时会转换为红黑树,进一步提高查询效率。​
  • 读操作无锁:对于读操作,ConcurrentHashMap 大部分情况下不需要加锁。因为其内部的数据结构设计,允许多个线程同时读取,只有在部分更新操作时才会加锁,减少了锁的争用,提高了读性能。例如,多个线程可以同时读取不同位置的元素,而不会相互影响。​
  • 高效的扩容机制:在扩容时,ConcurrentHashMap 采用了更高效的方式。它不会一次性将所有元素迁移到新的哈希表中,而是采用分段迁移的方式,每次只迁移一部分数据,在迁移过程中,旧的哈希表仍然可以正常工作,保证了在扩容期间的并发访问。例如,它会逐步将每个段的数据迁移到新的哈希表中,而不是暂停所有读写操作来进行一次性的全量迁移。​
  • 与 HashTable 对比:​
  • 锁机制不同:HashTable 对整个哈希表使用一把锁,所有的读写操作都需要获取这把锁,在高并发场景下,锁竞争激烈,性能低下。而 ConcurrentHashMap 采用更细粒度的锁机制,大大减少了锁争用。​
  • 线程安全性不同:HashTable 是线程安全的,但其所有方法都使用synchronized修饰,保证了线程安全,但也导致了性能瓶颈。ConcurrentHashMap 同样是线程安全的,但其采用了更优化的并发控制策略,在保证线程安全的同时,提高了并发性能。​
  • 迭代器不同:HashTable 的迭代器是强一致性的,在迭代过程中,如果哈希表结构发生变化(如添加或删除元素),会抛出ConcurrentModificationException异常。而 ConcurrentHashMap 的迭代器是弱一致性的,在迭代过程中,即使哈希表结构发生变化,也不会抛出异常,迭代器可以继续工作,返回遍历过程中遇到的元素,可能会出现一些不一致的情况,但在高并发场景下,更能保证迭代的进行,不会因为结构变化频繁中断迭代。​

5. 详细说明 TreeSet 的实现原理,以及如何实现自定义排序?​

TreeSet 基于红黑树(一种自平衡的二叉搜索树)实现,它能够对存储的元素进行排序。其实现原理和自定义排序方式如下:​

  • 实现原理:​
  • 红黑树特性:红黑树具有以下特性:每个节点要么是红色,要么是黑色;根节点是黑色;每个叶子节点(空节点)是黑色;如果一个节点是红色,那么它的两个子节点都是黑色;从任意一个节点到其每个叶子节点的所有路径上包含相同数目的黑色节点。这些特性保证了红黑树在插入、删除等操作后能够自动调整平衡,使得树的高度始终保持在一个相对较低的水平,从而保证了插入、删除和查询操作的时间复杂度为 O (log n)。​
  • 元素存储与排序:当向 TreeSet 中添加元素时,会按照红黑树的插入规则将元素插入到合适的位置。在插入过程中,会根据元素的自然顺序(如果元素实现了Comparable接口)或者自定义的比较器(如果在创建 TreeSet 时传入了Comparator)来比较元素大小,确定插入位置。例如,对于一个存储整数的 TreeSet,插入元素时会按照整数的大小顺序将元素插入到红黑树中,使得树的中序遍历结果是有序的。​
  • 查询与删除操作:查询元素时,通过红黑树的搜索算法,从根节点开始,根据元素大小与节点元素比较,逐步向下查找,直到找到目标元素或者确定目标元素不存在,时间复杂度为 O (log n)。删除元素时,同样通过搜索找到目标元素,然后按照红黑树的删除规则删除元素,并在删除后调整树的结构以保持红黑树的特性。​
  • 自定义排序:有两种方式实现自定义排序:​
  • 实现 Comparable 接口:让存储在 TreeSet 中的元素所属的类实现Comparable接口,并重写compareTo方法。在compareTo方法中定义元素的比较规则。​

然后创建 TreeSet 时,直接添加Student对象,TreeSet 会根据Student类中定义的compareTo方法进行排序。​

  • 使用 Comparator 接口:在创建 TreeSet 时,传入一个实现了Comparator接口的比较器对象。在比较器的compare方法中定义元素的比较规则。

三、多线程与并发​

6. 描述线程池的工作原理,包括核心线程、最大线程、队列容量、拒绝策略之间的关系?​

线程池的工作原理涉及核心线程、最大线程、队列容量和拒绝策略之间的协同工作,具体如下:​

  • 核心线程:线程池会维护一定数量的核心线程,这些线程在创建后不会轻易被销毁,即使它们处于空闲状态。核心线程的数量是线程池的一个重要参数,例如在ThreadPoolExecutor中,可以通过构造函数的参数corePoolSize来设置核心线程数。当有任务提交到线程池时,线程池会优先创建核心线程来执行任务,直到核心线程数达到corePoolSize。​
  • 最大线程:线程池允许创建的最大线程数量,由maximumPoolSize参数指定。当任务数量超过核心线程数且任务队列已满时,线程池会尝试创建新的线程(非核心线程)来处理任务,直到线程总数达到maximumPoolSize。非核心线程在空闲一段时间(由keepAliveTime参数指定)后会被销毁,以释放资源。​
  • 队列容量:线程池使用一个任务队列来存储等待执行的任务。当提交的任务数量超过核心线程数时,任务会被放入任务队列中。任务队列的类型和容量会影响线程池的性能和行为。常见的任务队列有ArrayBlockingQueue(有界队列)、LinkedBlockingQueue(无界队列,默认容量为Integer.MAX_VALUE)等。例如,使用ArrayBlockingQueue时,需要指定队列的容量,如果任务数量超过队列容量,且线程数未达到maximumPoolSize,则会创建新线程;如果线程数已达到maximumPoolSize,则会触发拒绝策略。​
  • 拒绝策略:当线程池中的线程数达到maximumPoolSize,且任务队列也已满时,新提交的任务将被拒绝。线程池提供了几种内置的拒绝策略:​
  • AbortPolicy:默认策略,直接抛出RejectedExecutionException异常,拒绝处理任务。​
  • CallerRunsPolicy:将任务交给提交任务的线程来执行,这样可以降低新任务的提交速度,缓解线程池的压力。​
  • DiscardPolicy:直接丢弃新提交的任务,不做任何处理。​
  • DiscardOldestPolicy:丢弃任务队列中最老的任务(即最先进入队列的任务),然后尝试提交新任务。​

在实际应用中,可以根据业务需求选择合适的拒绝策略,也可以自定义拒绝策略,实现RejectedExecutionHandler接口即可。例如,在一个订单处理系统中,如果订单提交任务过多,超过线程池处理能力,可以根据业务重要性,选择DiscardPolicy丢弃一些不重要的订单任务,或者使用CallerRunsPolicy让提交订单的线程自己处理订单,避免订单堆积导致系统崩溃。​


7. 谈谈你对 AQS(AbstractQueuedSynchronizer)的理解,它在 Java 并发包中的应用场景有哪些?​

AQS(AbstractQueuedSynchronizer)是 Java 并发包中实现锁和同步器的基础框架,它通过一个 FIFO 队列来管理等待获取同步状态的线程。​

  • 工作原理:AQS 内部维护了一个 volatile 修饰的整数变量state来表示同步状态,通过getState、setState和compareAndSetState等方法来操作这个状态。当一个线程尝试获取同步状态时,如果state值符合获取条件(例如,对于独占锁,state为 0 表示锁未被占用),则获取成功,否则该线程会被包装成一个节点(Node)加入到 FIFO 队列中,进入等待状态。当持有同步状态的线程释放同步状态时,会唤醒队列中等待的线程,被唤醒的线程重新尝试获取同步状态。例如,在ReentrantLock中,state表示锁被获取的次数,当一个线程获取锁时,如果state为 0,获取成功并将state设为 1;如果state不为 0 且当前线程是持有锁的线程,则state加 1(可重入特性)

    8. 说一下 ArrayList 和 LinkedList 的区别和适用场景?​

    ​ArrayList 基于动态数组实现,它在内存中以连续的空间存储元素。正因如此,通过索引访问元素时速度极快,时间复杂度为 O (1),比如list.get(5)能迅速定位到第 6 个元素。但在进行插入和删除操作时,尤其是在列表中间位置,需要移动大量元素,性能较差,时间复杂度为 O (n) 。例如在 ArrayList 的头部插入一个元素,后续所有元素都要向后移动一位。它适用于需要频繁随机访问元素的场景,像数据统计分析中,经常需要快速获取特定位置的数据进行计算。​

    LinkedList 基于双向链表实现,每个节点都包含前驱节点和后继节点的引用。插入和删除操作只需要修改相关节点的引用即可,无需移动大量元素,在列表中间插入或删除元素的时间复杂度为 O (1) ,比如在链表中间某节点后插入新节点,只需调整几个引用关系。不过,由于它不是基于连续内存存储,无法通过索引直接访问元素,只能从头或尾开始遍历,访问元素的时间复杂度为 O (n) 。LinkedList 适用于需要频繁进行插入和删除操作的场景,例如在实现消息队列时,新消息不断插入队尾,处理后的消息从队头删除。​


    9. 如何实现数组和 List 之间的转换?​

    将数组转换为 List,可以使用 Arrays 类的asList方法。​

    需要注意的是,Arrays.asList返回的 List 是一个固定大小的 List,不支持添加或删除元素操作,如果尝试调用add或remove方法会抛出UnsupportedOperationException异常。如果需要一个可变的 List,可以再包装一层,使用 ArrayList 的构造函数:​

    String[] array = {"apple", "banana", "cherry"};​

    List<String> mutableList = new ArrayList<>(Arrays.asList(array));​

    mutableList.add("date"); ​

    将 List 转换为数组,可以使用 List 的toArray方法。如果 List 中元素类型为 Object,可以直接调用toArray,它会返回一个 Object 数组:​

    List<String> list = Arrays.asList("apple", "banana", "cherry");​

    Object[] array = list.toArray();​

    如果希望返回指定类型的数组,可以使用toArray(T[] a)方法,传入一个类型相同且长度合适的数组(若长度小于 List 大小,会创建一个新的合适长度的数组):​

    List<String> list = Arrays.asList("apple", "banana", "cherry");​

    String[] array = list.toArray(new String[0]); ​


    10. 说一下 Iterator 和 ListIterator 的区别?​

    Iterator 是 Java 集合框架中用于遍历集合元素的迭代器,它可以用于遍历 List、Set 等实现了 Collection 接口的集合。Iterator 提供了hasNext方法用于判断是否还有下一个元素,next方法用于获取下一个元素,以及remove方法用于删除当前迭代到的元素。它只能单向遍历集合,从集合的开头向末尾移动,且不能在遍历过程中修改集合结构(除了使用remove方法),否则会抛出ConcurrentModificationException异常。​

    ListIterator 是 Iterator 的子接口,专门用于遍历 List 集合。它除了拥有 Iterator 的所有方法外,还增加了一些额外功能。它可以双向遍历 List,既可以使用next方法向前移动,也可以使用previous方法向后移动。并且提供了add方法用于在当前位置插入元素,set方法用于修改当前位置的元素,这使得在遍历 List 时可以更灵活地修改集合内容。例如,在遍历 List 时,发现某个元素不符合条件,可以使用 ListIterator 将其修改或在其前插入新元素。​

    Java 多线程​

    1. 线程和进程的区别是什么?​

    进程是程序的一次执行过程,是系统进行资源分配和调度的一个独立单位。每个进程都有自己独立的内存空间、系统资源(如文件描述符、内存空间等),不同进程之间相互隔离,进程之间的通信需要借助特定的机制,如管道、消息队列、共享内存等。例如,当你同时打开浏览器和音乐播放器,它们就是两个不同的进程,各自独立运行,互不干扰,并且分别占用一定的系统资源。​

    线程是进程中的一个执行单元,是进程内的可调度实体。一个进程可以包含多个线程,同一进程内的线程共享进程的资源,如内存空间、文件描述符等,这使得线程间通信相对简单,例如通过共享变量就可以实现数据交换。但也正因如此,多线程编程需要注意线程安全问题,避免多个线程同时访问和修改共享资源导致数据不一致。线程的创建和销毁开销相对进程较小,在多任务处理场景下,使用多线程能更高效地利用 CPU 资源,提升程序的并发性能。例如,在一个网络服务器程序中,可以使用多线程来同时处理多个客户端的请求,每个线程负责与一个客户端进行通信和数据处理。​

    2. 线程的生命周期有哪些状态?​

    线程的生命周期有以下几种状态:​

  • 新建(New):当使用new关键字创建一个线程对象时,线程进入新建状态,此时线程尚未启动,还未分配系统资源。例如Thread thread = new Thread();,thread就处于新建状态。​
  • 就绪(Runnable):调用线程的start方法后,线程进入就绪状态。处于就绪状态的线程已经获得了除 CPU 之外的所有必要资源,等待 CPU 调度执行。在就绪队列中的线程都处于这种状态,一旦获得 CPU 时间片,就可以进入运行状态。​
  • 运行(Running):当线程获得 CPU 时间片开始执行时,进入运行状态。此时线程执行run方法中的代码逻辑。在运行过程中,线程可能因为时间片用完、主动调用yield方法等原因重新回到就绪状态,也可能因发生 I/O 阻塞、调用wait方法等进入阻塞状态。​
  • 阻塞(Blocked):线程因某些原因暂时无法继续执行时进入阻塞状态。常见的阻塞原因有:等待获取锁(进入synchronized同步代码块或方法时,如果锁被其他线程占用)、执行 I/O 操作(如读取文件、网络通信等)、调用wait方法等。处于阻塞状态的线程不会占用 CPU 资源,直到导致阻塞的原因消除,线程才会重新回到就绪状态,等待 CPU 调度。​
  • 死亡(Terminated):线程执行完run方法中的代码正常结束,或者因发生未捕获的异常而提前终止,线程就进入死亡状态。一旦进入死亡状态,线程就不能再被启动。例如,当run方法执行完毕,线程自然死亡;如果run方法中抛出了未处理的异常,线程也会异常终止进入死亡状态。​
  • 3. 如何创建一个线程?​

    在 Java 中有两种常见的创建线程的方式:​

    第一种是继承 Thread 类。定义一个类继承自 Thread 类,重写其run方法,在run方法中编写线程执行的逻辑,然后创建该类的实例并调用start方法启动线程。示例代码如下:​

    class MyThread extends Thread {​

    @Override​

    public void run() {​

    System.out.println("This is a thread created by extending Thread class.");​

    }​

    }​

    public class Main {​

    public static void main(String[] args) {​

    MyThread myThread = new MyThread();​

    myThread.start(); ​

    }​

    }​

    第二种是实现 Runnable 接口。定义一个类实现 Runnable 接口,实现其run方法,然后创建一个 Thread 类的实例,并将实现了 Runnable 接口的类的实例作为参数传递给 Thread 类的构造函数,最后调用start方法启动线程。示例代码如下:​

    class MyRunnable implements Runnable {​

    @Override​

    public void run() {​

    System.out.println("This is a thread created by implementing Runnable interface.");​

    }​

    }​

    public class Main {​

    public static void main(String[] args) {​

    MyRunnable myRunnable = new MyRunnable();​

    Thread thread = new Thread(myRunnable);​

    thread.start(); ​

    }​

    }​

    相比继承 Thread 类,实现 Runnable 接口的方式更灵活,因为 Java 不支持多继承,一个类继承了 Thread 类就无法再继承其他类,而实现 Runnable 接口的类还可以继承其他类,并且多个线程可以共享同一个 Runnable 实例,更便于资源共享。在 Java 5 之后,还引入了 Callable 接口和 Future 接口来创建有返回值的线程,通过ExecutorService框架来管理和执行线程,这在需要获取线程执行结果的场景中非常有用。​

    4. 说一下线程同步的方式有哪些?​

    线程同步的方式主要有以下几种:​

  • synchronized 关键字:可以用于修饰方法或代码块。当修饰实例方法时,锁住的是当前对象的实例,即同一时刻只有一个线程能够进入该实例的同步方法;当修饰静态方法时,锁住的是当前类的 Class 对象,因为静态方法属于类,所有实例共享同一个 Class 对象,所以同一时刻只有一个线程能够进入该类的静态同步方法;当修饰代码块时,可以指定锁住的对象,例如synchronized (obj) {... },只有获得obj对象锁的线程才能进入该代码块。例如:​
  • public class SynchronizedExample {​

    private int count = 0;​

    public synchronized void increment() {​

    count++;​

    }​

    public void incrementInBlock() {​

    synchronized (this) {​

    count++;​

    }​

    }​

    }​

  • Lock 接口:Java 5 引入的java.util.concurrent.locks.Lock接口提供了更灵活的锁机制。常见的实现类有 ReentrantLock(可重入锁)。与synchronized不同,Lock需要手动获取和释放锁,通过lock方法获取锁,unlock方法释放锁,通常将unlock方法放在finally块中以确保锁一定会被释放,避免死锁。例如:​
  • import java.util.concurrent.locks.Lock;​

    import java.util.concurrent.locks.ReentrantLock;​

    public class LockExample {​

    private int count = 0;​

    private Lock lock = new ReentrantLock();​

    public void increment() {​

    lock.lock();​

    try {​

    count++;​

    } finally {​

    lock.unlock();​

    }​

    }​

    }​

  • 使用并发集合类:Java 的java.util.concurrent包中提供了一些线程安全的集合类,如 ConcurrentHashMap、CopyOnWriteArrayList 等,这些集合类内部已经实现了线程同步机制,使用它们可以避免在多线程环境下对集合操作时出现的数据不一致问题,无需额外进行同步操作。例如,在多线程环境下使用 ConcurrentHashMap 进行元素的插入和查询操作,无需担心线程安全问题。​
  • 使用阻塞队列:java.util.concurrent包中的阻塞队列,如 ArrayBlockingQueue、LinkedBlockingQueue 等,本身具有线程安全特性。当一个线程尝试从队列中取出元素而队列为空时,线程会被阻塞,直到队列中有元素;当一个线程尝试向队列中添加元素而队列已满时,线程也会被阻塞,直到队列有空间。这在生产者 - 消费者模型中非常有用,生产者线程将数据放入阻塞队列,消费者线程从队列中取出数据,天然实现了线程同步。​
  • 使用 Semaphore(信号量):java.util.concurrent.Semaphore可以控制同时访问某个资源的线程数量。它内部维护了一个许可集,通过acquire方法获取许可,如果没有可用许可,线程会被阻塞,直到有许可可用;通过release方法释放许可。例如,假设有一个资源只能同时被 3 个线程访问,可以创建一个初始许可数量为 3 的 Semaphore,每个线程在访问资源前调用acquire方法获取许可,访问完后调用release方法释放许可。​
  • 5. 什么是死锁?如何避免死锁?​

    死锁是指两个或两个以上的线程在执行过程中,因争夺资源而造成的一种互相等待的现象,若无外力作用,它们都将无法推进下去,程序陷入僵持状态。例如,线程 A 持有资源 1 并等待获取资源 2,而线程 B 持有资源 2 并等待获取资源 1,此时两个线程互相等待对方释放资源,就形成了死锁。​

    死锁的产生需要同时满足四个必要条件:​

  • 互斥条件:资源在某一时刻只能被一个线程占有,其他线程若要使用该资源,必须等待资源被释放。例如打印机资源,在同一时间只能被一个程序使用。​
  • 占有并等待条件:一个线程已经占有了至少一个资源,但又提出了新的资源请求,而新资源被其他线程占有,此时该线程会等待新资源,并且不释放已占有的资源。​
  • 不可剥夺条件:线程已获得的资源,在未使用完之前,不能被其他线程强行剥夺,只能由该线程自己释放。​
  • 循环等待条件:存在一个线程 - 资源的循环链,即线程 A 等待线程 B 占有的资源,线程 B 等待线程 C 占有的资源,……,线程 N 等待线程 A 占有的资源。​
  • 为了避免死锁,可以采取以下措施:​

  • 破坏互斥条件:尽量使用可共享的资源替代独占资源,比如使用 ConcurrentHashMap 替代 HashMap 在多线程环境下进行数据存储,因为 ConcurrentHashMap 允许多个线程同时进行读操作,减少了资源独占的情况。但有些资源本身特性决定了其必须是互斥的,如打印机,所以这种方法适用性有限。​
  • 破坏占有并等待条件:可以让线程一次性申请所有需要的资源,而不是逐步申请。例如,一个线程需要资源 A、B、C 才能执行任务,在开始执行前就一次性申请这三个资源,若申请成功则继续执行,若有任何一个资源申请失败,则释放已申请到的所有资源,避免部分占有资源并等待其他资源的情况。​
  • 破坏不可剥夺条件:当一个线程获取到部分资源后,如果无法获取到剩余资源,可以允许操作系统剥夺该线程已占有的资源,分配给其他更需要的线程。但这种方式在实际应用中较难实现,因为需要操作系统提供相应的支持,并且可能会影响程序的正常逻辑。​
  • 破坏循环等待条件:对资源进行排序,规定线程必须按照资源编号从小到大的顺序申请资源。例如,有资源 A、B、C,编号分别为 1、2、3,线程在申请资源时,只能先申请 A,再申请 B,最后申请 C,这样就避免了循环等待的情况。在代码实现中,可以通过定义资源顺序和相应的申请逻辑来确保线程遵循该规则。​
  • 6. 说一下 ThreadLocal 的原理?​

    ThreadLocal 为每个使用该变量的线程提供独立的变量副本,每个线程都可以独立地修改自己的副本,而不会影响其他线程的副本。其原理如下:​

    每个 Thread 对象内部都有一个 ThreadLocalMap 类型的成员变量threadLocals,当线程调用 ThreadLocal 的set方法时,实际上是将当前线程作为键,要设置的值作为值,存入该线程的threadLocals中。例如:​

    ThreadLocal<String> threadLocal = new ThreadLocal<>();​

    threadLocal.set("value for this thread");​

    这里,threadLocal在当前线程的threadLocals中存入了一个键值对,键为threadLocal对象本身,值为"value for this thread"。​

    当线程调用get方法时,会先获取当前线程的threadLocals,然后根据当前 ThreadLocal 对象作为键去查找对应的值并返回。如果在threadLocals中没有找到对应的键值对(即该线程尚未对这个 ThreadLocal 进行set操作),则会调用initialValue方法来获取初始值(initialValue方法默认返回null,可以通过继承 ThreadLocal 并重写initialValue方法来自定义初始值)。​

    ThreadLocalMap 是 ThreadLocal 的一个内部类,它使用线性探测法来解决哈希冲突(不同于 HashMap 的链地址法)。在 ThreadLocalMap 中,Entry 类继承自 WeakReference<ThreadLocal<?>>,其键是一个弱引用指向 ThreadLocal 对象。这意味着当没有强引用指向 ThreadLocal 对象时,在垃圾回收时,ThreadLocal 对象会被回收,避免了因 ThreadLocal 对象无法被回收而导致的内存泄漏。不过,由于 Entry 的键是弱引用,若不及时清理,当 ThreadLocal 对象被回收后,对应的 Entry 中的值会一直存在,仍然可能导致内存泄漏。所以在使用完 ThreadLocal 后,应该及时调用remove方法,删除threadLocals中对应的键值对,防止内存泄漏。例如,在一个 Web 应用中,每个请求由一个线程处理,可以使用 ThreadLocal 来存储当前请求的用户信息,每个线程都有自己独立的用户信息副本,互不干扰,并且在请求处理完成后及时调用remove方法清理。​

    7. 线程池有什么作用?​

    线程池的主要作用如下:​

  • 提高性能:避免了频繁创建和销毁线程带来的开销。创建和销毁线程需要分配和释放系统资源,如内存空间、线程上下文等,这是比较耗时的操作。线程池中的线程可以被复用,当有新任务到来时,直接从线程池中获取一个空闲线程来执行任务,任务执行完毕后,线程又回到线程池中等待下一个任务,大大减少了线程创建和销毁的次数,提高了程序的响应速度和整体性能。

    四、数据库(JDBC、SQL 优化、数据库连接池等)​

    11. 详细介绍 JDBC(Java Database Connectivity)的工作原理,使用 JDBC 时如何提高性能?​

  • JDBC 工作原理:JDBC 是 Java 提供的一套用于与各种关系型数据库进行交互的 API。其工作原理如下:​
  • 加载驱动程序:首先需要加载数据库对应的驱动程序。例如,对于 MySQL 数据库,需要加载com.mysql.cj.jdbc.Driver类。通过Class.forName("com.mysql.cj.jdbc.Driver")语句,该语句会将 MySQL 驱动类加载到内存中,并执行其静态代码块,注册驱动到DriverManager中。​
  • 建立连接:使用DriverManager.getConnection(url, username, password)方法建立与数据库的连接。url指定了数据库的地址、端口以及数据库名称等信息,username和password用于身份验证。DriverManager会根据url信息找到对应的驱动程序,并通过驱动程序与数据库建立物理连接。例如:​
  • String url = "jdbc:mysql://localhost:3306/mydb";​

    String username = "root";​

    String password = "password";​

    Connection connection = DriverManager.getConnection(url, username, password);​

  • 创建 Statement 对象:通过Connection对象创建Statement(用于执行 SQL 语句的对象)。Statement有三种类型:Statement、PreparedStatement和CallableStatement。Statement用于执行普通的 SQL 语句;PreparedStatement是Statement的子接口,它可以预编译 SQL 语句,提高执行效率,并且能有效防止 SQL 注入攻击;CallableStatement用于调用数据库中的存储过程。例如创建PreparedStatement:​
  • String sql = "SELECT * FROM users WHERE age >?";​

    PreparedStatement preparedStatement = connection.prepareStatement(sql);​

    preparedStatement.setInt(1, 30);​

  • 执行 SQL 语句:使用Statement对象执行 SQL 语句。对于查询语句,使用executeQuery方法,它返回一个ResultSet对象,包含查询结果;对于更新、插入、删除等操作,使用executeUpdate方法,返回受影响的行数。例如:​
  • ResultSet resultSet = preparedStatement.executeQuery();​

    while (resultSet.next()) {​

    String name = resultSet.getString("name");​

    int age = resultSet.getInt("age");​

    // 处理查询结果​

    }​

  • 处理结果:根据 SQL 语句的执行结果进行相应处理。对于查询结果,通过ResultSet对象获取数据;对于更新、插入、删除操作,根据executeUpdate返回的受影响行数判断操作是否成功。​
  • 关闭资源:使用完ResultSet、Statement和Connection后,需要及时关闭它们,释放资源。一般在finally块中进行关闭操作,防止资源泄漏。例如:​
  • finally {​

    try {​

    if (resultSet!= null) resultSet.close();​

    if (preparedStatement!= null) preparedStatement.close();​

    if (connection!= null) connection.close();​

    } catch (SQLException e) {​

    e.printStackTrace();​

    }​

    }​

  • 提高性能的方法:​
  • 使用 PreparedStatement:PreparedStatement会预编译 SQL 语句,数据库可以缓存预编译的执行计划,后续相同 SQL 语句执行时无需重新编译,提高执行效率。并且PreparedStatement通过占位符设置参数,能有效防止 SQL 注入攻击。例如:​
  • String sql = "INSERT INTO users (name, age) VALUES (?,?)";​

    PreparedStatement preparedStatement = connection.prepareStatement(sql);​

    preparedStatement.setString(1, "John");​

    preparedStatement.setInt(2, 30);​

    preparedStatement.executeUpdate();​

  • 批量操作:对于插入、更新等操作,如果需要执行多条 SQL 语句,可以使用批量操作。PreparedStatement提供了addBatch和executeBatch方法。例如:​
  • String sql = "INSERT INTO products (name, price) VALUES (?,?)";​

    PreparedStatement preparedStatement = connection.prepareStatement(sql);​

    for (Product product : productList) {​

    preparedStatement.setString(1, product.getName());​

    preparedStatement.setDouble(2, product.getPrice());​

    preparedStatement.addBatch();​

    }​

    preparedStatement.executeBatch();​

  • 合理设置 FetchSize:对于查询操作,通过ResultSet的setFetchSize方法设置每次从数据库获取的数据行数。例如,如果设置fetchSize为 100,ResultSet每次会从数据库获取 100 行数据,而不是一次性获取所有数据,减少内存占用,提高查询性能,尤其适用于大数据量查询。​
  • PreparedStatement preparedStatement = connection.prepareStatement(sql);​

    ResultSet resultSet = preparedStatement.executeQuery();​

    resultSet.setFetchSize(100);​

  • 连接池的使用:使用数据库连接池(如 HikariCP、C3P0 等)来管理数据库连接。连接池可以预先创建一定数量的数据库连接,当应用程序需要连接时,直接从连接池获取,而不是每次都创建新的连接,减少连接创建的开销,提高性能。例如使用 HikariCP 连接池:​
  • HikariConfig config = new HikariConfig();​

    config.setJdbcUrl("jdbc:mysql://localhost:3306/mydb");​

    config.setUsername("root");​

    config.setPassword("password");​

    HikariDataSource dataSource = new HikariDataSource(config);​

    Connection connection = dataSource.getConnection();​

    12. 如何优化 SQL 查询语句以提高数据库性能?请从索引优化、查询语句结构优化等方面进行阐述。​

    优化 SQL 查询语句可从以下几个方面提高数据库性能:​

  • 索引优化:​
  • 创建合适的索引:分析查询语句中经常用于WHERE、JOIN等条件的列,为这些列创建索引。例如,在一个用户表users中,经常根据email字段查询用户信息,可以为email字段创建索引:​
  • CREATE INDEX idx_users_email ON users (email);​

    索引可以加快数据的查找速度,因为数据库可以通过索引快速定位到满足条件的数据行,而不需要全表扫描。​

  • 避免索引失效:​
  • 避免在索引列上使用函数:例如SELECT * FROM users WHERE YEAR(birth_date) = 1990;,这里对birth_date列使用了YEAR函数,会导致索引失效,数据库不得不进行全表扫描。应尽量改写为SELECT * FROM users WHERE birth_date BETWEEN '1990 - 01 - 01' AND '1990 - 12 - 31';。​
  • 避免使用LIKE '%value%':这种方式会导致索引失效,因为数据库无法通过索引快速定位数据。如果必须使用模糊查询,可以使用LIKE 'value%',这样在某些情况下数据库还能利用索引进行前缀匹配。​
  • 避免数据类型不匹配:如果索引列是VARCHAR类型,查询时传入的参数也应该是字符串类型,否则可能导致索引失效。例如,users表的user_id列是VARCHAR类型,SELECT * FROM users WHERE user_id = 123;这样的查询会使索引失效,应改为SELECT * FROM users WHERE user_id = '123';。​
  • 查询语句结构优化:​
  • 减少子查询:子查询可能会导致性能问题,尽量使用JOIN来替代子查询。例如,有一个订单表orders和用户表users,要查询每个用户的订单数量,使用子查询可能如下:​
  • SELECT user_id, (SELECT COUNT(*) FROM orders WHERE user_id = users.user_id) AS order_count​

    FROM users;​

    可以改写为JOIN方式:​

    SELECT users.user_id, COUNT(orders.order_id) AS order_count​

    FROM users​

    LEFT JOIN orders ON users.user_id = orders.user_id​

    GROUP BY users.user_id;​

    JOIN操作通常比子查询更高效,因为数据库可以更好地优化JOIN操作的执行计划。​

  • 优化JOIN操作:​
  • 选择合适的JOIN类型:根据业务需求选择INNER JOIN、LEFT JOIN、RIGHT JOIN或FULL OUTER JOIN。INNER JOIN返回两个表中满足连接条件的所有行,性能通常较好;LEFT JOIN返回左表中的所有行以及右表中满足连接条件的行,如果左表数据量较大且右表匹配数据较少,使用LEFT JOIN可能会导致性能问题,需要谨慎使用。例如,在一个商品表products和库存表stocks中,如果要查询所有有库存的商品,使用INNER JOIN:​
  • SELECT products.product_name, stocks.quantity​

    FROM products​

    INNER JOIN stocks ON products.product_id = stocks.product_id;​

    如果要查询所有商品及其库存情况(包括没有库存的商品),使用LEFT JOIN:​

    SELECT products.product_name, stocks.quantity​

    FROM products​

    LEFT JOIN stocks ON products.product_id = stocks.product_id;​

  • 减少JOIN的表数量:尽量避免复杂的多表JOIN,过多的表JOIN会增加查询的复杂度和执行时间。如果可以通过其他方式(如冗余部分数据)来减少表JOIN,可以考虑这样做。例如,在一个包含用户信息、用户地址信息、用户订单信息等多个表的系统中,如果经常需要查询用户的基本信息和订单数量,可以在用户表中冗余订单数量字段,减少与订单表的JOIN操作。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值