每日10道JAVA面试题-2
1、Java 中ThreadLocal的原理是什么在多线程环境下它是如何为每个线程提供独立的变量副本的使用ThreadLocal有哪些需要注意的地方可能会引发什么问题2、请详细阐述 Java 的动态代理机制包括 JDK 动态代理和 CGLIB 代理。它们分别是基于什么原理实现的在实际应用中如何根据具体场景选择使用哪种动态代理方式3、数据库中的索引优化是提高查询性能的关键。请列举至少 5 种优化索引的方法并结合具体的 SQL 语句说明每种方法在实际场景中的应用比如如何优化复杂的多表联合查询的索引。4、Kafka 作为一款高性能的消息队列它的消息存储机制是怎样的包括日志文件的组织结构、数据的读写流程以及如何保证数据的持久化和高效读写。Kafka 又是如何处理消息积压问题的5、Spring 框架中的依赖注入DI有哪些方式请分别介绍构造函数注入、Setter 方法注入和字段注入的优缺点。在实际项目中应该如何选择合适的注入方式6、什么是分布式系统中的一致性哈希算法它是如何解决分布式环境中节点的添加和删除问题的与传统的哈希算法相比一致性哈希算法有哪些优势和不足请举例说明在缓存集群中的应用。7、在 Java 多线程编程中CountDownLatch、CyclicBarrier和Semaphore这三个同步工具类的作用分别是什么它们之间有什么区别请通过具体的代码示例说明在不同场景下如何使用它们。8、Redis 是一种常用的缓存数据库它的缓存淘汰策略有哪些每种策略在什么情况下会被触发在实际应用中如何根据业务需求选择合适的缓存淘汰策略以确保系统性能和数据的可用性9、微服务架构下服务之间的通信方式有哪些请比较 RESTful API、RPC如 gRPC和消息队列这几种通信方式的优缺点并举例说明在不同业务场景下应该如何选择合适的通信方式。10、请描述 Java 垃圾回收机制中的分代收集算法。为什么要采用分代收集新生代和老年代分别适合采用哪些垃圾回收算法不同的垃圾回收算法在实现原理和应用场景上有什么区别答案Java中ThreadLocal的原理及注意事项原理ThreadLocal通过为每个线程维护一个ThreadLocalMap来实现为每个线程提供独立的变量副本。ThreadLocalMap以ThreadLocal实例为键存储对应线程的变量副本。当线程访问ThreadLocal变量时实际上是从自身的ThreadLocalMap中获取对应的值。实现过程调用ThreadLocal的get()方法时首先获取当前线程然后从当前线程对象中获取ThreadLocalMap再以当前ThreadLocal实例为键从ThreadLocalMap中获取值。如果ThreadLocalMap不存在则创建一个。调用set()方法时同样先获取当前线程的ThreadLocalMap然后以当前ThreadLocal实例为键设置值。注意事项及可能引发的问题内存泄漏问题由于ThreadLocalMap的键是ThreadLocal的弱引用如果ThreadLocal对象不再被外部强引用在垃圾回收时ThreadLocal键会被回收但值仍然存在于ThreadLocalMap中导致内存泄漏。因此在使用完ThreadLocal后应该及时调用remove()方法清除对应的值。父子线程传递问题ThreadLocal默认情况下子线程无法访问父线程的ThreadLocal变量。如果需要父子线程共享数据可以使用InheritableThreadLocal它通过在子线程创建时将父线程的InheritableThreadLocalMap复制一份到子线程来实现父子线程间数据的传递。Java的动态代理机制JDK动态代理和CGLIB代理JDK动态代理原理基于接口实现通过Proxy类的newProxyInstance()方法动态生成代理类。代理类实现了目标接口并且在代理类的方法中通过反射调用InvocationHandler的invoke()方法来处理实际的业务逻辑。适用场景适用于目标对象实现了接口的情况。因为JDK动态代理要求目标对象必须实现接口所以在这种场景下JDK动态代理具有简洁、高效的特点。CGLIB代理原理基于继承实现通过字节码增强技术在运行时动态生成目标类的子类作为代理类。代理类重写了目标类的方法在方法中通过回调MethodInterceptor的intercept()方法来实现代理逻辑。适用场景适用于目标对象没有实现接口的情况。CGLIB代理可以对任何类进行代理无需目标类实现接口但由于是通过继承实现的所以不能代理final类和final方法。选择依据如果目标对象实现了接口优先使用JDK动态代理因为其性能较高且实现简单。如果目标对象没有实现接口或者需要代理的方法是final方法但final方法无法被CGLIB代理则使用CGLIB代理。数据库索引优化方法及应用选择合适的索引列选择经常出现在WHERE子句、JOIN子句中的列作为索引列。例如SELECT * FROM users WHERE age 30;如果age列经常用于这样的查询那么在age列上创建索引可以提高查询性能。避免索引列上的计算和函数操作索引列参与计算或函数操作会导致索引失效。例如SELECT * FROM orders WHERE YEAR(order_date) 2023;应该改写为SELECT * FROM orders WHERE order_date 2023 - 01 - 01 AND order_date 2024 - 01 - 01;以利用order_date列上的索引。使用联合索引并遵循最左前缀原则对于多条件查询创建联合索引可以提高查询效率。例如SELECT * FROM products WHERE category electronics AND price 100;可以创建联合索引(category, price)查询时会从左到右匹配索引列。避免冗余和重复索引冗余索引是指多个索引包含相同的列组合只是顺序不同。重复索引是指对同一列创建多个相同类型的索引。例如已经有索引(a, b)再创建(b, a)就是冗余索引这会增加索引维护成本降低插入、更新和删除操作的性能。定期重建和优化索引随着数据的插入、更新和删除索引可能会变得碎片化影响查询性能。定期重建索引如使用ALTER TABLE table_name REBUILD INDEX index_name;或优化索引如使用ALTER INDEX index_name REORGANIZE;可以提高索引的效率。覆盖索引当查询的所有列都包含在索引中时数据库可以直接从索引中获取数据而无需回表操作从而提高查询性能。例如SELECT id, name FROM users WHERE age 30;如果创建索引(age, id, name)则可以利用覆盖索引优化查询。Kafka的消息存储机制及消息积压处理日志文件组织结构Kafka的每个主题Topic被划分为多个分区Partition每个分区是一个有序的、不可变的消息序列存储在磁盘上的一个日志文件中。日志文件由多个段Segment组成每个段包含一定数量的消息段文件以该段中最小的消息偏移量命名。数据读写流程写流程生产者将消息发送到Kafka集群Kafka根据分区策略将消息发送到对应的分区。每个分区的领导者副本Leader Replica负责接收消息并将其追加到本地日志文件中。追随者副本Follower Replica从领导者副本同步消息。读流程消费者从Kafka集群拉取消息Kafka根据消费者的偏移量Offset从对应的分区日志文件中读取消息。消费者可以控制自己的偏移量以决定从哪里开始消费消息。数据持久化和高效读写保证数据持久化Kafka通过将消息追加到磁盘日志文件来实现数据持久化。同时Kafka使用了页缓存Page Cache技术将磁盘数据缓存到内存中提高读写性能。此外通过副本机制每个分区的消息会在多个节点上保存副本保证数据的可靠性。高效读写顺序写磁盘Kafka的消息是追加写入日志文件的这利用了磁盘的顺序写特性大大提高了写性能。零拷贝技术在数据传输过程中Kafka使用零拷贝技术减少数据在用户空间和内核空间之间的拷贝次数提高数据传输效率。消息积压处理增加分区数通过增加主题的分区数可以提高Kafka的并行处理能力加快消息的消费速度。但增加分区数需要谨慎因为过多的分区会增加系统的管理开销。优化消费者提高消费者的消费能力例如增加消费者实例数提高每个消费者的处理速度。可以通过优化消费者的业务逻辑减少处理每条消息的时间。排查问题检查是否存在消费者端的性能瓶颈如网络问题、数据库写入性能等。同时检查生产者是否存在异常如发送消息频率过高导致消息积压。Spring框架中的依赖注入方式及选择构造函数注入优点可以确保依赖对象在创建时就已经存在避免了空指针异常。同时通过构造函数注入的依赖是不可变的提高了代码的安全性和稳定性。缺点如果依赖过多构造函数的参数列表会变得很长代码可读性变差。此外如果依赖之间存在循环依赖会导致程序启动失败。Setter方法注入优点灵活性高可以在对象创建后动态地设置依赖对象。对于可选的依赖使用Setter方法注入更为合适。缺点可能会出现空指针异常因为依赖对象可能在使用时还未设置。同时Setter方法注入可能会导致对象在部分状态下被使用增加了代码的复杂性。字段注入优点代码简洁通过注解直接在字段上进行注入无需编写构造函数或Setter方法。缺点依赖关系不明显不利于代码的维护和测试。此外字段注入无法实现不可变依赖也可能导致循环依赖问题。选择依据对于必须的、不可变的依赖优先使用构造函数注入。对于可选的依赖或者需要在对象创建后动态设置的依赖使用Setter方法注入。字段注入一般用于一些简单的场景或者在测试代码中使用但在实际项目中应尽量避免以提高代码的可读性和可维护性。分布式系统中的一致性哈希算法原理一致性哈希算法将整个哈希值空间组织成一个虚拟的圆环哈希环。节点和数据都通过哈希函数映射到这个环上。当有数据需要存储或读取时首先计算数据的哈希值然后在哈希环上顺时针查找找到的第一个节点就是该数据的存储节点。解决节点添加和删除问题节点添加当添加一个新节点时只需要将哈希环上该节点逆时针方向的部分数据迁移到新节点对其他节点的影响较小。节点删除当一个节点失效时该节点上的数据会被迁移到其顺时针方向的下一个节点同样对其他节点的影响较小。与传统哈希算法的比较优势传统哈希算法在节点数量变化时大量数据的存储位置会发生变化导致数据的迁移和重新分布成本较高。而一致性哈希算法能够保证在节点数量变化时只有少量数据需要迁移提高了系统的可扩展性和稳定性。不足一致性哈希算法可能会导致数据分布不均匀部分节点负载过高。可以通过引入虚拟节点的方式来解决这个问题将每个物理节点映射为多个虚拟节点均匀分布在哈希环上从而使数据分布更加均匀。在缓存集群中的应用在缓存集群中使用一致性哈希算法可以将数据均匀地分布在各个缓存节点上。当某个缓存节点失效或有新的缓存节点加入时只需要迁移少量的数据保证缓存集群的稳定性和数据的可用性。例如在一个分布式缓存系统中将用户ID通过一致性哈希算法映射到缓存节点上当某个缓存节点故障时用户ID对应的缓存数据会自动迁移到其他节点而不会影响整个系统的缓存功能。Java多线程编程中的同步工具类CountDownLatch、CyclicBarrier和SemaphoreCountDownLatch作用允许一个或多个线程等待其他一组线程完成操作后再继续执行。示例代码importjava.util.concurrent.CountDownLatch;publicclassCountDownLatchExample{publicstaticvoidmain(String[]args){intnumThreads5;CountDownLatchlatchnewCountDownLatch(numThreads);for(inti0;inumThreads;i){newThread(()-{try{// 模拟线程执行任务Thread.sleep(1000);System.out.println(Thread.currentThread().getName() 任务完成);}catch(InterruptedExceptione){e.printStackTrace();}finally{latch.countDown();}}).start();}try{latch.await();System.out.println(所有线程任务完成主线程继续执行);}catch(InterruptedExceptione){e.printStackTrace();}}}CyclicBarrier作用使一组线程达到一个屏障同步点时被阻塞直到所有线程都到达该屏障时所有线程才继续执行。与CountDownLatch不同的是CyclicBarrier可以重复使用。示例代码importjava.util.concurrent.CyclicBarrier;publicclassCyclicBarrierExample{publicstaticvoidmain(String[]args){intnumThreads3;CyclicBarrierbarriernewCyclicBarrier(numThreads,()-{System.out.println(所有线程到达屏障继续执行);});for(inti0;inumThreads;i){newThread(()-{try{// 模拟线程执行任务Thread.sleep((long)(Math.random()*2000));System.out.println(Thread.currentThread().getName() 到达屏障);barrier.await();System.out.println(Thread.currentThread().getName() 继续执行);}catch(Exceptione){e.printStackTrace();}}).start();}}}Semaphore作用用于控制同时访问某个资源的线程数量通过一个计数器来实现。当线程获取信号量时计数器减1当线程释放信号量时计数器加1。当计数器为0时其他线程无法获取信号量只能等待。示例代码importjava.util.concurrent.Semaphore;publicclassSemaphoreExample{publicstaticvoidmain(String[]args){intavailablePermits2;SemaphoresemaphorenewSemaphore(availablePermits);for(inti0;i5;i){newThread(()-{try{semaphore.acquire();System.out.println(Thread.currentThread().getName() 获取到信号量开始执行任务);// 模拟线程执行任务Thread.sleep(1000);System.out.println(Thread.currentThread().getName() 任务执行完毕释放信号量);}catch(InterruptedExceptione){e.printStackTrace();}finally{semaphore.release();}}).start();}}}区别CountDownLatch是一次性的计数器无法重置而CyclicBarrier可以重复使用适用于需要多次同步的场景。CountDownLatch主要用于一个或多个线程等待其他一组线程完成任务CyclicBarrier用于一组线程相互等待达到同步点后继续执行。Semaphore主要用于控制对共享资源的访问数量而CountDownLatch和CyclicBarrier主要用于线程同步。Redis的缓存淘汰策略及选择缓存淘汰策略noeviction默认策略当内存达到上限时新写入操作会报错不会淘汰任何数据。适用于不希望丢失数据的场景如缓存数据库配置信息等。allkeys - lru在所有键中淘汰最近最少使用Least Recently UsedLRU的键。适用于希望缓存的数据尽可能是热点数据的场景例如缓存用户会话信息LRU策略可以保证经常使用的会话信息不会被淘汰。allkeys - random从所有键中随机淘汰键。该策略比较简单但可能会淘汰掉热点数据一般较少使用。volatile - lru在设置了过期时间的键中淘汰最近最少使用的键。适用于希望在有限内存中尽可能保留热点且设置了过期时间的数据例如缓存限时优惠活动信息。volatile - random从设置了过期时间的键中随机淘汰键。同样可能会淘汰热点数据使用场景有限。volatile - ttl在设置了过期时间的键中淘汰剩余生存时间Time To LiveTTL最短的键。适用于希望优先淘汰即将过期的数据的场景例如缓存即将过期的验证码。allkeys - lfu在所有键中淘汰最不经常使用Least Frequently UsedLFU的键。与LRU不同LFU不仅考虑使用时间还考虑使用频率更能反映数据的热度。适用于需要更精确地保留热点数据的场景。volatile - lfu在设置了过期时间的键中淘汰最不经常使用的键。结合了LFU和过期时间的特性适用于需要保留热点且设置了过期时间的数据。选择依据如果对数据一致性要求较高不允许数据丢失选择noeviction策略。如果希望缓存热点数据并且大部分数据都设置了过期时间volatile - lru或volatile - lfu是不错的选择如果大部分数据没有设置过期时间则选择allkeys - lru或allkeys - lfu。如果对数据淘汰没有特别的要求只希望简单地随机淘汰数据可以选择allkeys - random或volatile - random。如果希望优先淘汰即将过期的数据选择volatile - ttl策略。微服务架构下的服务通信方式RESTful API优点基于HTTP协议通用性强易于理解和使用。支持多种数据格式如JSON、XML -缺点性能相对较低HTTP协议的头部信息较多增加了数据传输量。在高并发场景下频繁的HTTP请求可能会导致网络开销较大。适用场景适用于不同技术栈之间的通信对性能要求不是特别高但对通用性和可扩展性要求较高的场景。例如前端应用与后端微服务之间的通信或者与第三方系统进行集成时RESTful API是一种常见的选择。RPC如gRPC优点性能高基于二进制协议数据传输量小序列化和反序列化速度快。支持多种编程语言并且可以通过接口定义语言IDL清晰地定义服务接口生成客户端和服务器端代码提高开发效率和代码的一致性。缺点由于使用特定的协议和工具与其他非RPC系统的集成相对复杂灵活性不如RESTful API。适用场景适用于对性能要求较高且服务之间耦合度较高、技术栈相对统一的场景。例如在一个大型的微服务系统内部各个微服务之间的通信可以使用RPC以提高系统的整体性能。消息队列优点具有异步解耦的特性发送方和接收方不需要同时在线提高了系统的可用性和可靠性。可以有效地削峰填谷应对突发流量。适用于数据的异步处理和事件驱动的架构。缺点引入了额外的复杂性需要处理消息的可靠性、顺序性、重复消费等问题。消息的处理可能存在一定的延迟。适用场景适用于对实时性要求不高但对系统的扩展性、可靠性和异步处理能力要求较高的场景。例如在电商系统中订单创建后发送短信、邮件通知等非核心业务可以通过消息队列异步处理避免影响订单创建的主流程。Java垃圾回收机制中的分代收集算法分代收集的原因不同对象的生命周期不同有些对象创建后很快就不再使用如局部变量而有些对象则会长时间存活如缓存对象。分代收集算法根据对象的生命周期特点将堆内存划分为不同的代如新生代、老年代对不同代采用不同的垃圾回收算法以提高垃圾回收的效率。新生代特点对象创建和消亡频繁存活率低。适合的垃圾回收算法复制算法将新生代分为Eden区和两个Survivor区通常比例为8:1:1。新对象优先分配在Eden区当Eden区满时触发Minor GC将存活对象复制到其中一个Survivor区同时年龄计数器加1。当对象的年龄达到一定阈值默认15则晋升到老年代。复制算法的优点是实现简单不会产生内存碎片但需要额外的空间。老年代特点对象存活率高空间较大。适合的垃圾回收算法标记 - 清除算法首先标记出所有需要回收的对象然后统一回收所有被标记的对象。该算法的缺点是会产生大量不连续的内存碎片导致后续大对象分配内存时可能由于无法找到足够连续的空间而提前触发垃圾回收。标记 - 整理算法在标记 - 清除算法的基础上在回收对象后将存活对象向一端移动整理出连续的内存空间避免内存碎片问题但该算法的移动操作会增加一定的开销。CMSConcurrent Mark Sweep算法一种以获取最短回收停顿时间为目标的垃圾回收器。它采用标记 - 清除算法在老年代使用。CMS收集器的工作过程分为初始标记、并发标记、重新标记和并发清除四个阶段。其中初始标记和重新标记阶段会暂停用户线程STW但时间较短并发标记和并发清除阶段可以与用户线程并发执行减少了垃圾回收对应用程序的影响。不过CMS算法也存在一些问题比如会产生内存碎片并且在并发清除阶段可能会有新的垃圾产生浮动垃圾。G1Garbage - First算法G1收集器将堆内存划分为多个大小相等的独立区域Region同时兼顾新生代和老年代。它采用标记 - 整理算法能够更精确地控制停顿时间并且可以根据每个Region的垃圾占比优先回收垃圾最多的RegionGarbage - First的由来。G1收集器在垃圾回收过程中通过维护一个优先队列来管理各个Region根据停顿时间目标和每个Region的垃圾回收收益动态调整回收策略。区别复制算法适用于对象存活率低的场景如新生代通过牺牲空间来换取高效的回收速度。标记 - 清除算法简单但会产生内存碎片不适合对内存连续性要求高的场景。标记 - 整理算法解决了内存碎片问题但移动对象的开销较大。CMS算法追求最短停顿时间适合对响应时间要求高的应用但存在内存碎片和浮动垃圾问题。G1算法能更灵活地控制停顿时间适用于大内存、多CPU的场景并且在垃圾回收的同时兼顾了内存整理。