中厂Java后端15连问!

2024年 4月 29日 70.2k 0

前言

大家好,我是田螺。最近一位星球粉丝去面试一个中厂,Java后端。他说,好几道题答不上来,于是我帮忙整理了一波答案

  • G1收集器
  • JVM内存划分
  • 对象进入老年代标志
  • 你在项目中用到的是哪种收集器,怎么调优的
  • new对象的内存分布
  • 局部变量的内存分布
  • Synchronized和Lock的区别
  • Synchronized原理
  • 可重入是如何知道当前锁的拥有着的
  • Spring用到的设计模式
  • 说说SPI
  • 排行榜怎么设计
  • SpringBoot 中注解实现缓存用过没?实现原理是什么。
  • 深分页优化
  • Redis分布式锁如何进一步提升性能
  • 1. 说说G1收集器

    G1(Garbage-First)收集器是 Java 虚拟机中的一种垃圾收集器。它于 JDK 7 中首次引入,并在后续版本中不断改进优化。

    • 可以使用 -XX:+UseG1GC 开启它。这个选项告诉 JVM 在运行时使用 G1 垃圾收集器来管理堆内存。
    • G1(Garbage-First)收集器的核心原则之一就是“首先收集尽可能多的垃圾”,即优先回收那些包含最多垃圾的区域。这个原则是与CMS收集器有所不同的,CMS主要关注于尽可能减少应用程序的停顿时间。
    • G1 收集器会将 Java 堆划分为多个大小相等的区域(Region),然后根据当前堆内存的使用情况,选择性地进行垃圾回收。
    • 当 G1 收集器决定进行Full GC时,它会执行一次 Full GC(Full GC),这时整个应用程序将会暂停(STW)。但在Mixed GC中,G1 收集器会在某些情况下选择部分年轻代分区和部分老年代分区进行回收,这样一来,即使不是整个堆内存的垃圾回收,仍然可能会导致一些暂停,但相对于 Full GC,这种暂停时间会更短。
    • 适用于大内存、对停顿时间敏感、需要高吞吐量的应用场景。

    2. JVM内存划分

    Java 虚拟机(JVM)的内存分为多个区域,每个区域都有不同的作用和管理方式。JVM 内存划分的主要区域:

    图片图片

    • 堆(Heap):堆是 JVM 中最大的一块内存区域,用于存储对象实例和数组。堆内存由所有线程共享,是垃圾收集器主要管理的区域。
    • 程序计数器(Program Counter):程序计数器是一块较小的内存空间,它是线程私有的,用于存储当前线程正在执行的字节码指令的地址。在多线程环境下,每个线程都有一个独立的程序计数器,以保证线程切换后能够恢复到正确的执行位置。
    • 虚拟机栈(Java Virtual Machine Stacks):虚拟机栈也是线程私有的,用于存储方法执行过程中的局部变量、方法参数、中间结果等数据。每个方法在执行时都会创建一个栈帧,栈帧包含了方法的局部变量表、操作数栈、动态链接、方法返回地址等信息。
    • 本地方法栈(Native Method Stacks):本地方法栈与虚拟机栈类似,用于执行本地方法(Native Method)时的数据存储。与虚拟机栈一样,本地方法栈也是线程私有的。
    • 方法区(Method Area):方法区用于存储类信息、常量、静态变量和即时编译器编译后的代码等数据。在jdk7及以前,习惯上把方法区,称为永久代。jdk8开始,使用元空间取代了永久代。本质上,方法区和永久代并不等价。通过-XX:Permsize来设置永久代初始分配空间。可以使用参数 -XX:MetaspaceSize 和 -XX:MaxMetaspaceSize指定默认值依赖于平台。
    • 运行时常量池(Runtime Constant Pool):运行时常量池是方法区的一部分,用于存储编译时生成的字面量常量和符号引用。与类文件中的常量池(Constant Pool)相对应,运行时常量池具有动态性,可以在运行时动态地添加、修改常量。
    • 直接内存(Direct Memory):直接内存不是 JVM 内存的一部分,但是在一些情况下会被 JVM 使用。直接内存是通过使用 java.nio 包中的类来申请和释放的,它允许 JVM 在堆外分配内存,可以提高 I/O 操作的效率。

    3. 对象进入老年代标志

    图片图片

    • 对象年龄达到阈值:对象在 Java 堆中的存活时间通常会通过对象年龄(Age)来衡量。在某些垃圾收集器中,当对象经过多次垃圾收集后仍然存活,并且达到了一定的年龄阈值,就会被晋升到老年代。年龄阈值可以通过 JVM 参数进行调整。
    • 大对象:大对象通常指的是占用大量内存空间的对象,例如数组或者很大的字符串。在一些垃圾收集器中,为了避免在新生代中频繁复制大对象,会直接将大对象分配在老年代中。
    • 长期存活的对象:一些长期存活的对象,例如长期存在的线程、静态变量等,有可能直接被分配到老年代中。
    • 晋升失败:当新生代中的对象经过多次垃圾回收仍然存活,并且新生代内存不足以容纳这些存活对象时,会发生一次晋升失败,这时一部分存活对象可能会被直接晋升到老年代。

    通常情况下,老年代用于存放长期存活的对象,以减少垃圾收集的频率和提高垃圾回收的效率。

    4. 你在项目中用到的是哪种收集器,怎么调优的

    我列举了常见的垃圾收集器以及调优策略:

    串行收集器(Serial Garbage Collector):

    • 适用于单核 CPU 或小型应用场景。
    • 调优参数:-XX:+UseSerialGC

    并行收集器(Parallel Garbage Collector):

    • 适用于多核 CPU,通过并行收集来提高垃圾回收效率。
    • 调优参数:-XX:+UseParallelGC

    并发标记-清除收集器(Concurrent Mark-Sweep Garbage Collector,CMS GC):

    • 适用于对系统停顿时间敏感的应用场景。
    • 调优参数:-XX:+UseConcMarkSweepGC

    G1 收集器(Garbage-First Garbage Collector):

    • 适用于大内存、对停顿时间敏感、需要高吞吐量的应用场景。
    • 调优参数:-XX:+UseG1GC

    ZGC 和 Shenandoah(JDK 11+):

    • 适用于超大内存、对停顿时间极为敏感的应用场景。
    • 调优参数:-XX:+UseZGC(ZGC)、-XX:+UseShenandoahGC(Shenandoah)

    当然,调优的具体策略会根据应用的特点和需求来定,可以通过以下一些常见的调优手段来改进垃圾收集器的性能:

    • 调整堆大小(-Xms、-Xmx)以及新生代与老年代的比例(-XX:NewRatio、-XX:SurvivorRatio)。
    • 设置垃圾收集器的参数,如并行收集器的线程数(-XX:ParallelGCThreads)、并发标记-清除收集器的初始标记阶段并发线程数(-XX:ConcGCThreads)等。
    • 设置垃圾收集器的触发条件,如新生代垃圾收集的触发条件(-XX:MaxNewSize、-XX:NewThreshold)等。
    • 监控和分析 GC 日志,根据应用的实际情况进行优化。选择合适的 GC 日志分析工具,如GCViewer、GCEasy等,进行进一步的性能分析和优化。

    5. new对象的内存分布

    在 Java 中,当使用 new 关键字创建一个对象时,对象在内存中的基本结构通常由对象头、实例数据和填充组成。

    • 对象头(Object Header):对象头存储了对象的元数据信息,如哈希码、对象锁状态、GC 相关信息等。对象头的结构和大小在不同的 JVM 实现中可能会有所不同,但通常包括一些固定的字段。
    • 实例数据(Instance Data):实例数据包含了对象的实际数据,即对象中的成员变量的值。这些数据根据对象的类定义来确定,包括各种类型的字段和对象引用等。
    • 填充(Padding):填充是为了满足内存对齐的需要而添加的额外字节。内存对齐可以提高内存访问的效率,一些 JVM 可能会在对象的实例数据后添加填充字节,使得对象的起始地址能够对齐到某个特定的边界。

    6. 局部变量的内存分布

    栈帧结构:

    • 栈帧由三部分组成:局部变量表(Local Variable Table)、操作数栈(Operand Stack)和帧数据(Frame Data)。
    • 局部变量表用于存储方法参数和方法内部定义的局部变量。
    • 操作数栈用于存储方法执行过程中的操作数。
    • 帧数据包含了方法的异常处理信息、方法返回地址等。

    局部变量表:

    • 局部变量表是一个数组,用于存储方法参数和方法内部定义的局部变量。
    • 局部变量表中的每个元素都可以存储一个数据值,数据类型可以是基本数据类型或者引用类型。
    • 局部变量表中的变量只在方法执行期间有效,当方法执行结束后,局部变量表所占用的内存空间会被释放。

    局部变量的存储位置:

    • 对于基本数据类型的局部变量,它们的值直接存储在局部变量表中。
    • 对于引用类型的局部变量,局部变量表中存储的是对象的引用,而对象的实际数据存储在堆内存中。

    局部变量的生命周期:

    • 局部变量的生命周期与方法的执行周期相同,当方法执行结束后,局部变量表所占用的内存空间会被释放。
    • 局部变量的生命周期也可能会被延长,比如局部变量被捕获到一个匿名内部类中,那么该局部变量的生命周期会与匿名内部类的生命周期保持一致。

    7.Synchronized和ReenTrantLock的区别

    • Synchronized是依赖于JVM实现的,而ReenTrantLock是API实现的。
    • 在Synchronized优化以前,synchronized的性能是比ReenTrantLock差很多的,但是自从Synchronized引入了偏向锁,轻量级锁(自旋锁)后,两者性能就差不多了。
    • Synchronized的使用比较方便简洁,它由编译器去保证锁的加锁和释放。而ReenTrantLock需要手工声明来加锁和释放锁,最好在finally中声明释放锁。
    • ReentrantLock可以指定是公平锁还是⾮公平锁。⽽synchronized只能是⾮公平锁。
    • ReentrantLock可响应中断、可轮回,而Synchronized是不可以响应中断的

    8.Synchronized原理

    synchronized是Java中的关键字,是一种同步锁。synchronized关键字可以作用于方法或者代码块。

    一般面试时。可以这么回答:

    • 反编译后,monitorenter、monitorexit、ACC_SYNCHRONIZED
    • monitor监视器
    • Java Monitor 的工作机理
    • 对象与monitor关联

    8.1 monitorenter、monitorexit、ACC_SYNCHRONIZED

    • 如果synchronized作用于代码块,反编译可以看到两个指令:monitorenter、monitorexit,JVM使用monitorenter和monitorexit两个指令实现同步;
    • 如果作用synchronized作用于方法,反编译可以看到ACCSYNCHRONIZED标记,JVM通过在方法访问标识符(flags)中加入ACCSYNCHRONIZED来实现同步功能。
    • 同步代码块是通过monitorenter和monitorexit来实现,当线程执行到monitorenter的时候要先获得monitor锁,才能执行后面的方法。当线程执行到monitorexit的时候则要释放锁。
    • 同步方法是通过中设置ACCSYNCHRONIZED标志来实现,当线程执行有ACCSYNCHRONIZED标志的方法,需要获得monitor锁。每个对象都与一个monitor相关联,线程可以占有或者释放monitor。

    8.2 monitor监视器

    monitor是什么呢?操作系统的管程(monitors)是概念原理,ObjectMonitor是它的原理实现。

    在Java虚拟机(HotSpot)中,Monitor(管程)是由ObjectMonitor实现的,其主要数据结构如下:

    ObjectMonitor() {
        _header       = NULL;
        _count        = 0; // 记录个数
        _waiters      = 0,
        _recursions   = 0;
        _object       = NULL;
        _owner        = NULL;
        _WaitSet      = NULL;  // 处于wait状态的线程,会被加入到_WaitSet
        _WaitSetLock  = 0 ;
        _Responsible  = NULL ;
        _succ         = NULL ;
        _cxq          = NULL ;
        FreeNext      = NULL ;
        _EntryList    = NULL ;  // 处于等待锁block状态的线程,会被加入到该列表
        _SpinFreq     = 0 ;
        _SpinClock    = 0 ;
        OwnerIsThread = 0 ;
      }

    ObjectMonitor中几个关键字段的含义如图所示:

    图片图片

    8.3 Java Monitor 的工作机理

    图片图片

    • 想要获取monitor的线程,首先会进入_EntryList队列。
    • 当某个线程获取到对象的monitor后,进入Owner区域,设置为当前线程,同时计数器count加1。
    • 如果线程调用了wait()方法,则会进入WaitSet队列。它会释放monitor锁,即将owner赋值为null,count自减1,进入WaitSet队列阻塞等待。
    • 如果其他线程调用 notify() / notifyAll() ,会唤醒WaitSet中的某个线程,该线程再次尝试获取monitor锁,成功即进入Owner区域。
    • 同步方法执行完毕了,线程退出临界区,会将monitor的owner设为null,并释放监视锁。

    8.4 对象与monitor关联

    图片图片

    • 在HotSpot虚拟机中,对象在内存中存储的布局可以分为3块区域:对象头(Header),实例数据(Instance Data)和对象填充(Padding)。
    • 对象头主要包括两部分数据:Mark Word(标记字段)、Class Pointer(类型指针)。
    • Mark Word 是用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等。
    • 重量级锁,指向互斥量的指针。其实synchronized是重量级锁,也就是说Synchronized的对象锁,Mark Word锁标识位为10,其中指针指向的是Monitor对象的起始地址。

    9.可重入是如何知道当前锁的拥有着的

    比如,ReentrantLock 是可重入的锁。ReentrantLock 类实现了可重入锁的概念,允许同一个线程在持有锁的情况下多次获取同一个锁,而不会被阻塞。

    在 ReentrantLock 中,每个锁都关联着一个持有计数器和一个拥有者线程。当一个线程首次获取锁时,持有计数器会增加;当该线程再次获取锁时,持有计数器会继续增加。每次释放锁时,计数器会相应减少。只有当持有计数器减为零时,锁才会完全释放,其他线程才有机会获取锁。

    这样一来,当线程尝试获取锁时,它会检查当前锁的持有计数器以及拥有者线程。如果锁未被任何线程持有,或者当前线程是锁的拥有者,那么锁将立即分配给当前线程。否则,当前线程会被阻塞,直到锁被释放。

    10.Spring用到的设计模式

    • 单例模式(Singleton Pattern):Spring 中的 Bean 默认是单例的,即每个 Bean 只有一个实例。这种方式可以提高性能并减少资源消耗。
    • 工厂模式(Factory Pattern):Spring 使用工厂模式来创建和管理 Bean。它提供了几种不同类型的工厂,比如 BeanFactory 和 ApplicationContext,用于创建和管理 Bean 对象。
    • 代理模式(Proxy Pattern):Spring AOP(面向切面编程)功能基于代理模式实现。它允许通过在运行时为目标对象创建代理对象来添加横切关注点(如日志记录、性能监控等)。
    • 装饰者模式(Decorator Pattern):Spring 中的 BeanPostProcessor 接口就是一个装饰器模式的例子。它允许在 Bean 初始化过程中动态地添加新的功能。
    • 观察者模式(Observer Pattern):Spring 的事件机制就是基于观察者模式实现的。它允许 Bean 发布事件,其他 Bean 可以注册监听器来响应这些事件。
    • 策略模式(Strategy Pattern):Spring 的 IOC(控制反转)和 DI(依赖注入)功能基于策略模式实现。它允许将不同的实现注入到一个接口或抽象类中,以便在运行时选择不同的行为。
    • 模板模式(Template Pattern):Spring 的 JdbcTemplate 和 HibernateTemplate 等模板类就是模板模式的应用。它们封装了一些常见的操作,使开发者可以通过简单的方法调用来执行数据库操作。
    • 适配器模式(Adapter Pattern):Spring 的 AOP 功能和 Spring MVC 框架都使用了适配器模式。它们允许将现有的类与新的接口进行适配,以便实现新的功能。
    • 建造者模式(Builder Pattern):Spring 中的 BeanDefinitionBuilder 和 BeanFactoryBuilder 等构建器模式的应用。它们用于构建复杂的对象,并且允许逐步设置对象的属性。

    11.聊聊SPI

    SPI,其实就是Service Provider Interface,是Java提供的一种机制,用于在运行时动态装载实现模块,使得应用程序能够扩展、替换特定的服务或实现。

    SPI的工作原理主要包括以下几个关键点:

    • 接口定义:首先,需要定义一个接口,该接口定义了一组操作或服务。这个接口通常由Java核心库或者第三方库提供。
    • 服务提供者:其次,可以有多个服务提供者来实现这个接口。这些服务提供者通常是独立的模块或者库,它们在自己的jar包中提供了实现。
    • 配置文件:在Java中,SPI通过在META-INF/services目录下的特定配置文件中指定实现类的方式来实现动态装载。具体来说,SPI机制要求服务提供者在这个目录下创建一个以接口全限定名为文件名的文件,文件中列出了具体的实现类名。
    • 动态加载:当应用程序需要某个服务时,Java运行时会动态加载配置文件中指定的实现类,并实例化它们。这种机制使得应用程序能够在不修改源代码的情况下灵活地替换或扩展特定的服务实现。

    在实际应用中,我们可以利用SPI来实现插件化架构、模块化开发等,从而提高代码的可维护性和可扩展性。

    12.排行榜怎么设计

    可以考虑:数据库的order by、或者Redis 的zset

    大家可以看下我的这篇文章哈:

    如何设计一个排行榜

    对于游戏排行榜,如果数据量达到亿万级别,需要考虑的确实是分桶策略,而桶排序则是分桶策略的一种可能实现方式之一。

    桶排序是一种基于这种分桶策略的排序算法,它将数据划分到若干个有序的桶中,然后分别对每个桶中的数据进行排序,最后将所有桶中的数据按照顺序合并起来,得到排好序的结果。

    在游戏排行榜系统中,可以根据玩家的分数范围、等级、地区等因素来设计分桶策略,将玩家数据划分到不同的桶中。这样可以使得每个桶内的数据量相对较小,提高了排序的效率。

    13.SpringBoot 中注解实现缓存用过没?实现原理是什么。

    常见的缓存注解包括 @Cacheable、@CachePut、@CacheEvict 等。这些注解的实现原理基于Spring提供的缓存抽象。

    是的,Spring Boot 中的缓存注解常用于提升系统性能,减少重复计算,常见的缓存注解包括 @Cacheable、@CachePut、@CacheEvict 等。这些注解的实现原理基于 Spring Framework 提供的缓存抽象。

    实现原理:

  • 代理机制:
    • Spring Boot 使用 AOP(面向切面编程)的方式,在运行时动态地为带有缓存注解的方法生成代理对象。
    • 当调用带有缓存注解的方法时,实际上是调用代理对象的方法。
  • 缓存管理器:
    • Spring Boot 提供了多种缓存管理器的实现,比如基于 ConcurrentHashMap 的 SimpleCacheManager、基于 Ehcache的缓存管理器。

    • 缓存管理器负责真正地操作缓存,将数据存储到缓存中或者从缓存中获取数据。

  • 缓存注解:

    • @Cacheable:标记在方法上,表示方法的返回值将会被缓存,当方法被调用时,首先从缓存中查找数据,如果缓存中存在数据,则直接返回,否则执行方法并将结果存储到缓存中。

    • @CachePut:标记在方法上,表示方法的返回值将会被存储到缓存中,即使缓存中已经存在相同 key 的数据,也会重新存储。

    • @CacheEvict:标记在方法上,表示清除缓存中的数据,可以根据条件来清除指定的缓存数据。

  • 缓存 key 的生成:

    • 默认情况下,缓存 key 是由方法的参数组成的,默认的 key 生成器是 SimpleKeyGenerator。

    • 可以通过自定义 key 生成器来生成复杂的缓存 key。

  • 缓存注解的执行流程:

    • 当方法被调用时,首先会根据方法参数生成缓存 key。

    • 然后从缓存中根据缓存 key 查找数据,如果找到则返回,否则执行方法并将结果存储到缓存中。

    • 在 @CachePut 和 @CacheEvict 注解中,会根据条件清除缓存中的数据或者将数据存储到缓存中。

    14.深分页优化

    我们可以通过减少回表次数来优化。一般有标签记录法和延迟关联法。

    14.1 标签记录法

    就是标记一下上次查询到哪一条了,下次再来查的时候,从该条开始往下扫描。就好像看书一样,上次看到哪里了,你就折叠一下或者夹个书签,下次来看的时候,直接就翻到啦。

    假设上一次记录到100000,则SQL可以修改为:

    select  id,name,balance FROM account where id > 100000 limit 10;

    这样的话,后面无论翻多少页,性能都会不错的,因为命中了id索引。但是这种方式有局限性:需要一种类似连续自增的字段。

    14.2 延迟关联法

    延迟关联法,就是把条件转移到主键索引树,然后减少回表。假设原生SQL是这样的的,其中id是主键,create_time是普通索引

    select id,name,balance from account where create_time> '2020-09-19' limit 100000,10;

    使用延迟关联法优化,如下:

    select  acct1.id,acct1.name,acct1.balance FROM account acct1 INNER JOIN 
    (SELECT a.id FROM account a WHERE a.create_time > '2020-09-19' limit 100000, 10) 
    AS acct2 on acct1.id= acct2.id;

    优化思路就是,先通过idx_create_time二级索引树查询到满足条件的主键ID,再与原表通过主键ID内连接,这样后面直接走了主键索引了,同时也减少了回表。

    15.分布式锁如何进一步提升性能,答了Redis的实现思路好像不是面试官想听的

    redis分布式锁,我觉得可以从一下这几个方向来回答:

    • Pipeline 批量操作:使用 Redis 的 Pipeline 功能可以将多个 Redis 命令打包发送给服务器,减少网络延迟,提高性能。在获取锁、释放锁等操作时,可以考虑使用 Pipeline 来批量执行多个命令。
    • Lua 脚本:Redis 支持 Lua 脚本,可以将多个操作封装在一个脚本中,在服务器端原子性地执行,减少了网络通信的开销。可以将获取锁和释放锁的逻辑封装在 Lua 脚本中,以提高性能。
    • 降级策略:在高并发情况下,可以考虑引入降级策略,当获取锁失败时,可以使用备用方案或者默认值来处理,而不是一直等待锁的释放。
    • 监控和优化:通过监控 Redis 的性能指标,如连接数、命令执行时间等,可以及时发现性能瓶颈,并进行优化。可以通过 Redis 的监控工具或者第三方监控工具来实现监控。
    • 合理设置锁的过期时间:根据业务场景的特点和需求,合理设置锁的过期时间,避免锁被长时间占用而影响系统性能。可以根据操作的耗时和锁的竞争情况来动态调整锁的过期时间。
    • 使用 Redlock 算法:Redlock 是一种基于多个 Redis 节点的分布式锁算法,可以提高分布式锁的可靠性和性能。可以考虑使用 Redlock 算法来实现分布式锁。

    相关文章

    JavaScript2024新功能:Object.groupBy、正则表达式v标志
    PHP trim 函数对多字节字符的使用和限制
    新函数 json_validate() 、randomizer 类扩展…20 个PHP 8.3 新特性全面解析
    使用HTMX为WordPress增效:如何在不使用复杂框架的情况下增强平台功能
    为React 19做准备:WordPress 6.6用户指南
    如何删除WordPress中的所有评论

    发布评论